imessage_apple/
scripts.rs1use imessage_core::macos::MacOsVersion;
6use imessage_core::utils::{escape_osa_exp, is_not_empty};
7
8fn build_service_script(input_service: &str) -> String {
15 format!("set targetService to 1st account whose service type = {input_service}")
16}
17
18fn build_message_script(message: &str, target: &str) -> String {
20 if is_not_empty(message) {
21 format!("send \"{}\" to {target}", escape_osa_exp(message))
22 } else {
23 String::new()
24 }
25}
26
27fn build_attachment_script(attachment: &str, variable: &str, target: &str) -> String {
29 if is_not_empty(attachment) {
30 format!(
31 "set {variable} to \"{}\" as POSIX file\n send theAttachment to {target}\n delay 1",
32 escape_osa_exp(attachment)
33 )
34 } else {
35 String::new()
36 }
37}
38
39pub fn get_address_from_input(value: &str) -> &str {
41 value.rsplit(';').next().unwrap_or(value)
42}
43
44pub fn get_service_from_input(value: &str) -> &str {
47 if !value.contains(';') {
48 return "iMessage";
49 }
50 let service = value.split(';').next().unwrap_or("iMessage");
51 if service == "any" {
52 "iMessage"
53 } else {
54 service
55 }
56}
57
58fn start_app(app_name: &str) -> String {
63 format!(
64 "set appName to \"{app_name}\"\n\
65 if application appName is running then\n\
66 return 0\n\
67 else\n\
68 tell application appName to reopen\n\
69 end if"
70 )
71}
72
73pub fn start_messages() -> String {
74 start_app("Messages")
75}
76
77pub fn send_message(
85 chat_guid: &str,
86 message: &str,
87 attachment: &str,
88 v: MacOsVersion,
89 format_address: &(dyn Fn(&str) -> String + Send + Sync),
90) -> Option<String> {
91 if chat_guid.is_empty() || (message.is_empty() && attachment.is_empty()) {
92 return None;
93 }
94
95 let attachment_scpt = build_attachment_script(attachment, "theAttachment", "targetChat");
96 let message_scpt = build_message_script(message, "targetChat");
97
98 if !chat_guid.contains(';') {
99 return None; }
101
102 let mut guid = chat_guid.to_string();
103
104 if guid.contains(";-;") {
106 let parts: Vec<&str> = guid.splitn(2, ";-;").collect();
107 if parts.len() == 2 {
108 let service = parts[0];
109 let addr = parts[1];
110 let formatted = format_address(addr);
111 guid = format!("{service};-;{formatted}");
112 }
113 }
114
115 if v.is_min_tahoe() {
117 if guid.starts_with("iMessage;") {
118 guid = format!("any;{}", &guid["iMessage;".len()..]);
119 } else if guid.starts_with("SMS;") {
120 guid = format!("any;{}", &guid["SMS;".len()..]);
121 }
122 }
123
124 Some(format!(
125 "tell application \"Messages\"\n\
126 set targetChat to a reference to chat id \"{guid}\"\n\
127 \n\
128 {attachment_scpt}\n\
129 {message_scpt}\n\
130 end tell"
131 ))
132}
133
134pub fn send_message_fallback(
136 chat_guid: &str,
137 message: &str,
138 attachment: &str,
139) -> Result<Option<String>, String> {
140 if chat_guid.is_empty() || (message.is_empty() && attachment.is_empty()) {
141 return Ok(None);
142 }
143
144 let attachment_scpt = build_attachment_script(attachment, "theAttachment", "targetBuddy");
145 let message_scpt = build_message_script(message, "targetBuddy");
146
147 let address = get_address_from_input(chat_guid);
148 let service = get_service_from_input(chat_guid);
149
150 if address.starts_with("chat") {
151 return Err(
152 "Can't use the send message (fallback) script to text a group chat!".to_string(),
153 );
154 }
155
156 let service_script = build_service_script(service);
157
158 Ok(Some(format!(
159 "tell application \"Messages\"\n\
160 {service_script}\n\
161 set targetBuddy to participant \"{address}\" of targetService\n\
162 \n\
163 {attachment_scpt}\n\
164 {message_scpt}\n\
165 end tell"
166 )))
167}
168
169pub fn restart_messages(delay_seconds: u32) -> String {
171 format!(
172 "tell application \"Messages\"\n\
173 quit\n\
174 delay {delay_seconds}\n\
175 reopen\n\
176 end tell"
177 )
178}
179
180pub fn start_chat(participants: &[String], service: &str, message: Option<&str>) -> String {
182 let service_script = build_service_script(service);
183 let buddies = participants
184 .iter()
185 .map(|b| format!("buddy \"{b}\" of targetService"))
186 .collect::<Vec<_>>()
187 .join(", ");
188
189 let message_scpt = match message {
190 Some(msg) if is_not_empty(msg) => build_message_script(msg, "thisChat"),
191 _ => String::new(),
192 };
193
194 format!(
195 "tell application \"Messages\"\n\
196 {service_script}\n\
197 \n\
198 (* Start the new chat with all the recipients *)\n\
199 set thisChat to make new chat with properties {{participants: {{{buddies}}}}}\n\
200 log thisChat\n\
201 {message_scpt}\n\
202 end tell\n\
203 \n\
204 try\n\
205 tell application \"System Events\" to tell process \"Messages\" to set visible to false\n\
206 end try"
207 )
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 fn tahoe() -> MacOsVersion {
215 MacOsVersion::new(26, 3, 0)
216 }
217 fn sequoia() -> MacOsVersion {
218 MacOsVersion::new(15, 0, 0)
219 }
220
221 fn identity(s: &str) -> String {
222 s.to_string()
223 }
224
225 #[test]
226 fn get_address_extracts_last_segment() {
227 assert_eq!(
228 get_address_from_input("iMessage;-;+15551234567"),
229 "+15551234567"
230 );
231 assert_eq!(get_address_from_input("SMS;-;+15551234567"), "+15551234567");
232 assert_eq!(get_address_from_input("no-semicolons"), "no-semicolons");
233 }
234
235 #[test]
236 fn get_service_extracts_first_segment() {
237 assert_eq!(
238 get_service_from_input("iMessage;-;+15551234567"),
239 "iMessage"
240 );
241 assert_eq!(get_service_from_input("SMS;-;+15551234567"), "SMS");
242 assert_eq!(get_service_from_input("any;-;+15551234567"), "iMessage"); assert_eq!(get_service_from_input("no-semicolons"), "iMessage"); }
245
246 #[test]
247 fn send_message_returns_none_for_empty() {
248 assert!(send_message("", "hello", "", tahoe(), &identity).is_none());
249 assert!(send_message("iMessage;-;+15551234567", "", "", tahoe(), &identity).is_none());
250 }
251
252 #[test]
253 fn send_message_tahoe_uses_any_prefix() {
254 let script =
255 send_message("iMessage;-;+15551234567", "hello", "", tahoe(), &identity).unwrap();
256 assert!(script.contains("any;-;+15551234567"));
257 assert!(!script.contains("iMessage;-;"));
258 }
259
260 #[test]
261 fn send_message_sequoia_keeps_imessage_prefix() {
262 let script =
263 send_message("iMessage;-;+15551234567", "hello", "", sequoia(), &identity).unwrap();
264 assert!(script.contains("iMessage;-;+15551234567"));
265 }
266
267 #[test]
268 fn build_service_uses_account() {
269 let s = build_service_script("iMessage");
270 assert!(s.contains("1st account whose service type"));
271 }
272
273 #[test]
274 fn restart_messages_includes_delay() {
275 let s = restart_messages(5);
276 assert!(s.contains("delay 5"));
277 }
278
279 #[test]
280 fn start_chat_uses_make_new_chat() {
281 let s = start_chat(&["buddy1".to_string()], "iMessage", None);
282 assert!(s.contains("make new chat"));
283 assert!(!s.contains("text chat"));
284 }
285
286 #[test]
287 fn fallback_rejects_group_chats() {
288 let result = send_message_fallback("iMessage;+;chat123456", "hello", "");
289 assert!(result.is_err());
290 }
291
292 #[test]
293 fn fallback_uses_participant() {
294 let script = send_message_fallback("iMessage;-;+15551234567", "hello", "")
295 .unwrap()
296 .unwrap();
297 assert!(script.contains("participant"));
298 }
299}