Skip to main content

imessage_private_api/
actions.rs

1/// All outgoing action payloads for the Private API helper dylib.
2///
3/// Each action maps to a JSON object sent over TCP with an action string.
4use serde_json::{Value, json};
5
6use crate::transaction::TransactionType;
7
8/// An action to send to the helper dylib.
9pub struct Action {
10    pub name: &'static str,
11    pub data: Value,
12    pub transaction_type: Option<TransactionType>,
13}
14
15/// Common optional fields shared across send actions (text, multipart, attachment).
16#[derive(Debug, Default)]
17pub struct SendOptions<'a> {
18    pub subject: Option<&'a str>,
19    pub effect_id: Option<&'a str>,
20    pub selected_message_guid: Option<&'a str>,
21    pub part_index: Option<i64>,
22    pub attributed_body: Option<&'a Value>,
23}
24
25/// Apply common SendOptions fields to a JSON data object.
26fn apply_send_options(data: &mut Value, opts: &SendOptions) {
27    if let Some(s) = opts.subject {
28        data["subject"] = json!(s);
29    }
30    if let Some(e) = opts.effect_id {
31        data["effectId"] = json!(e);
32    }
33    if let Some(g) = opts.selected_message_guid {
34        data["selectedMessageGuid"] = json!(g);
35    }
36    if let Some(ab) = opts.attributed_body {
37        data["attributedBody"] = ab.clone();
38    }
39}
40
41// ---------------------------------------------------------------------------
42// Message actions (PrivateApiMessage)
43// ---------------------------------------------------------------------------
44
45pub fn send_message(
46    chat_guid: &str,
47    message: &str,
48    opts: &SendOptions,
49    text_formatting: Option<&Value>,
50    dd_scan: Option<bool>,
51) -> Action {
52    // Always send all fields (null for unset optionals, 0 for partIndex)
53    let mut data = json!({
54        "chatGuid": chat_guid,
55        "message": message,
56        "subject": Value::Null,
57        "attributedBody": Value::Null,
58        "effectId": Value::Null,
59        "selectedMessageGuid": Value::Null,
60        "partIndex": opts.part_index.unwrap_or(0),
61        "textFormatting": Value::Null,
62    });
63    apply_send_options(&mut data, opts);
64    if let Some(tf) = text_formatting {
65        data["textFormatting"] = tf.clone();
66    }
67    if let Some(dd) = dd_scan {
68        data["ddScan"] = json!(if dd { 1 } else { 0 });
69    }
70
71    Action {
72        name: "send-message",
73        data,
74        transaction_type: Some(TransactionType::Message),
75    }
76}
77
78pub fn send_multipart(
79    chat_guid: &str,
80    parts: &Value,
81    opts: &SendOptions,
82    dd_scan: Option<bool>,
83) -> Action {
84    // Always send all fields (null for unset optionals, 0 for partIndex)
85    let mut data = json!({
86        "chatGuid": chat_guid,
87        "parts": parts,
88        "subject": Value::Null,
89        "effectId": Value::Null,
90        "selectedMessageGuid": Value::Null,
91        "partIndex": opts.part_index.unwrap_or(0),
92        "attributedBody": Value::Null,
93    });
94    apply_send_options(&mut data, opts);
95    if let Some(dd) = dd_scan {
96        data["ddScan"] = json!(if dd { 1 } else { 0 });
97    }
98
99    Action {
100        name: "send-multipart",
101        data,
102        transaction_type: Some(TransactionType::Message),
103    }
104}
105
106pub fn send_reaction(
107    chat_guid: &str,
108    selected_message_guid: &str,
109    reaction_type: &str,
110    part_index: Option<i64>,
111    emoji: Option<&str>,
112    sticker_path: Option<&str>,
113) -> Action {
114    // Always send partIndex (default 0)
115    let mut data = json!({
116        "chatGuid": chat_guid,
117        "selectedMessageGuid": selected_message_guid,
118        "reactionType": reaction_type,
119        "partIndex": part_index.unwrap_or(0),
120    });
121    if let Some(e) = emoji {
122        data["emoji"] = json!(e);
123    }
124    if let Some(sp) = sticker_path {
125        data["stickerPath"] = json!(sp);
126    }
127
128    Action {
129        name: "send-reaction",
130        data,
131        transaction_type: Some(TransactionType::Message),
132    }
133}
134
135pub fn edit_message(
136    chat_guid: &str,
137    message_guid: &str,
138    edited_message: &str,
139    backwards_compat_message: &str,
140    part_index: Option<i64>,
141) -> Action {
142    // Always send partIndex (default 0)
143    let data = json!({
144        "chatGuid": chat_guid,
145        "messageGuid": message_guid,
146        "editedMessage": edited_message,
147        "backwardsCompatibilityMessage": backwards_compat_message,
148        "partIndex": part_index.unwrap_or(0),
149    });
150
151    Action {
152        name: "edit-message",
153        data,
154        transaction_type: Some(TransactionType::Message),
155    }
156}
157
158pub fn unsend_message(chat_guid: &str, message_guid: &str, part_index: Option<i64>) -> Action {
159    // Always send partIndex (default 0)
160    let data = json!({
161        "chatGuid": chat_guid,
162        "messageGuid": message_guid,
163        "partIndex": part_index.unwrap_or(0),
164    });
165
166    Action {
167        name: "unsend-message",
168        data,
169        transaction_type: Some(TransactionType::Message),
170    }
171}
172
173pub fn get_embedded_media(chat_guid: &str, message_guid: &str) -> Action {
174    Action {
175        name: "balloon-bundle-media-path",
176        data: json!({
177            "chatGuid": chat_guid,
178            "messageGuid": message_guid,
179        }),
180        transaction_type: Some(TransactionType::Message),
181    }
182}
183
184pub fn notify_silenced(chat_guid: &str, message_guid: &str) -> Action {
185    Action {
186        name: "notify-anyways",
187        data: json!({
188            "chatGuid": chat_guid,
189            "messageGuid": message_guid,
190        }),
191        transaction_type: Some(TransactionType::Message),
192    }
193}
194
195pub fn search_messages(query: &str, match_type: &str) -> Action {
196    Action {
197        name: "search-messages",
198        data: json!({
199            "query": query,
200            "matchType": match_type,
201        }),
202        transaction_type: Some(TransactionType::Message),
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Chat actions (PrivateApiChat)
208// ---------------------------------------------------------------------------
209
210pub fn create_chat(
211    addresses: &[String],
212    message: &str,
213    service: &str,
214    attributed_body: Option<&Value>,
215    effect_id: Option<&str>,
216    subject: Option<&str>,
217) -> Action {
218    let mut data = json!({
219        "addresses": addresses,
220        "message": message,
221        "service": service,
222    });
223    if let Some(ab) = attributed_body {
224        data["attributedBody"] = ab.clone();
225    }
226    if let Some(e) = effect_id {
227        data["effectId"] = json!(e);
228    }
229    if let Some(s) = subject {
230        data["subject"] = json!(s);
231    }
232
233    Action {
234        name: "create-chat",
235        data,
236        transaction_type: Some(TransactionType::Message), // returns message GUID
237    }
238}
239
240pub fn delete_message(chat_guid: &str, message_guid: &str) -> Action {
241    Action {
242        name: "delete-message",
243        data: json!({
244            "chatGuid": chat_guid,
245            "messageGuid": message_guid,
246        }),
247        transaction_type: Some(TransactionType::Chat),
248    }
249}
250
251pub fn start_typing(chat_guid: &str) -> Action {
252    Action {
253        name: "start-typing",
254        data: json!({ "chatGuid": chat_guid }),
255        transaction_type: None, // fire-and-forget
256    }
257}
258
259pub fn stop_typing(chat_guid: &str) -> Action {
260    Action {
261        name: "stop-typing",
262        data: json!({ "chatGuid": chat_guid }),
263        transaction_type: None,
264    }
265}
266
267pub fn mark_chat_read(chat_guid: &str) -> Action {
268    Action {
269        name: "mark-chat-read",
270        data: json!({ "chatGuid": chat_guid }),
271        transaction_type: None,
272    }
273}
274
275pub fn mark_chat_unread(chat_guid: &str) -> Action {
276    Action {
277        name: "mark-chat-unread",
278        data: json!({ "chatGuid": chat_guid }),
279        transaction_type: None,
280    }
281}
282
283pub fn add_participant(chat_guid: &str, address: &str) -> Action {
284    Action {
285        name: "add-participant",
286        data: json!({
287            "chatGuid": chat_guid,
288            "address": address,
289        }),
290        transaction_type: Some(TransactionType::Chat),
291    }
292}
293
294pub fn remove_participant(chat_guid: &str, address: &str) -> Action {
295    Action {
296        name: "remove-participant",
297        data: json!({
298            "chatGuid": chat_guid,
299            "address": address,
300        }),
301        transaction_type: Some(TransactionType::Chat),
302    }
303}
304
305pub fn set_display_name(chat_guid: &str, new_name: &str) -> Action {
306    Action {
307        name: "set-display-name",
308        data: json!({
309            "chatGuid": chat_guid,
310            "newName": new_name,
311        }),
312        transaction_type: Some(TransactionType::Chat),
313    }
314}
315
316pub fn set_group_chat_icon(chat_guid: &str, file_path: Option<&str>) -> Action {
317    Action {
318        name: "update-group-photo",
319        data: json!({
320            "chatGuid": chat_guid,
321            "filePath": file_path,
322        }),
323        transaction_type: Some(TransactionType::Chat),
324    }
325}
326
327pub fn should_offer_contact_sharing(chat_guid: &str) -> Action {
328    Action {
329        name: "should-offer-nickname-sharing",
330        data: json!({ "chatGuid": chat_guid }),
331        transaction_type: Some(TransactionType::Other),
332    }
333}
334
335pub fn share_contact_card(chat_guid: &str) -> Action {
336    Action {
337        name: "share-nickname",
338        data: json!({ "chatGuid": chat_guid }),
339        transaction_type: None,
340    }
341}
342
343pub fn leave_chat(chat_guid: &str) -> Action {
344    Action {
345        name: "leave-chat",
346        data: json!({ "chatGuid": chat_guid }),
347        transaction_type: Some(TransactionType::Chat),
348    }
349}
350
351pub fn delete_chat(chat_guid: &str) -> Action {
352    Action {
353        name: "delete-chat",
354        data: json!({ "chatGuid": chat_guid }),
355        transaction_type: Some(TransactionType::Chat),
356    }
357}
358
359// ---------------------------------------------------------------------------
360// Handle actions (PrivateApiHandle)
361// ---------------------------------------------------------------------------
362
363pub fn get_focus_status(address: &str) -> Action {
364    Action {
365        name: "check-focus-status",
366        data: json!({ "address": address }),
367        transaction_type: Some(TransactionType::Handle),
368    }
369}
370
371pub fn get_imessage_availability(address: &str) -> Action {
372    let alias_type = if address.contains('@') {
373        "email"
374    } else {
375        "phone"
376    };
377    Action {
378        name: "check-imessage-availability",
379        data: json!({
380            "aliasType": alias_type,
381            "address": address,
382        }),
383        transaction_type: Some(TransactionType::Handle),
384    }
385}
386
387pub fn get_facetime_availability(address: &str) -> Action {
388    let alias_type = if address.contains('@') {
389        "email"
390    } else {
391        "phone"
392    };
393    Action {
394        name: "check-facetime-availability",
395        data: json!({
396            "aliasType": alias_type,
397            "address": address,
398        }),
399        transaction_type: Some(TransactionType::Handle),
400    }
401}
402
403// ---------------------------------------------------------------------------
404// Attachment actions (PrivateApiAttachment)
405// ---------------------------------------------------------------------------
406
407pub fn send_attachment(
408    chat_guid: &str,
409    file_path: &str,
410    is_audio_message: bool,
411    opts: &SendOptions,
412) -> Action {
413    let mut data = json!({
414        "chatGuid": chat_guid,
415        "filePath": file_path,
416        "isAudioMessage": if is_audio_message { 1 } else { 0 },
417        // Always send partIndex (default 0), attributedBody,
418        // subject, effectId, selectedMessageGuid
419        "partIndex": opts.part_index.unwrap_or(0),
420        "attributedBody": Value::Null,
421        "subject": Value::Null,
422        "effectId": Value::Null,
423        "selectedMessageGuid": Value::Null,
424    });
425    apply_send_options(&mut data, opts);
426
427    Action {
428        name: "send-attachment",
429        data,
430        transaction_type: Some(TransactionType::Attachment),
431    }
432}
433
434pub fn download_purged_attachment(attachment_guid: &str) -> Action {
435    Action {
436        name: "download-purged-attachment",
437        data: json!({ "attachmentGuid": attachment_guid }),
438        transaction_type: None,
439    }
440}
441
442// ---------------------------------------------------------------------------
443// FindMy actions
444// ---------------------------------------------------------------------------
445
446pub fn refresh_findmy_friends() -> Action {
447    Action {
448        name: "refresh-findmy-friends",
449        data: Value::Null,
450        transaction_type: Some(TransactionType::FindMy),
451    }
452}
453
454pub fn get_findmy_key() -> Action {
455    Action {
456        name: "get-findmy-key",
457        data: Value::Null,
458        transaction_type: Some(TransactionType::FindMy),
459    }
460}
461
462// ---------------------------------------------------------------------------
463// Cloud / iCloud actions
464// ---------------------------------------------------------------------------
465
466pub fn get_account_info() -> Action {
467    Action {
468        name: "get-account-info",
469        data: Value::Null,
470        transaction_type: Some(TransactionType::Other),
471    }
472}
473
474pub fn get_contact_card(address: &str) -> Action {
475    Action {
476        name: "get-nickname-info",
477        data: json!({ "address": address }),
478        transaction_type: Some(TransactionType::Other),
479    }
480}
481
482pub fn modify_active_alias(alias: &str) -> Action {
483    Action {
484        name: "modify-active-alias",
485        data: json!({ "alias": alias }),
486        transaction_type: Some(TransactionType::Other),
487    }
488}
489
490// ---------------------------------------------------------------------------
491// FaceTime actions
492// ---------------------------------------------------------------------------
493
494pub fn answer_call(call_uuid: &str) -> Action {
495    Action {
496        name: "answer-call",
497        data: json!({ "callUUID": call_uuid }),
498        transaction_type: Some(TransactionType::Other),
499    }
500}
501
502pub fn leave_call(call_uuid: &str) -> Action {
503    Action {
504        name: "leave-call",
505        data: json!({ "callUUID": call_uuid }),
506        transaction_type: None,
507    }
508}
509
510/// Generate a FaceTime link.
511/// Pass `None` for a new link (no existing call), or `Some(uuid)` for an existing call.
512/// The dylib checks `callUUID != [NSNull null]` — sending a non-null string that doesn't
513/// match any active call causes a nil-dereference crash. Always send null for new sessions.
514pub fn generate_facetime_link(call_uuid: Option<&str>) -> Action {
515    Action {
516        name: "generate-link",
517        data: json!({ "callUUID": call_uuid }),
518        transaction_type: Some(TransactionType::Other),
519    }
520}
521
522pub fn check_typing_status(chat_guid: &str) -> Action {
523    Action {
524        name: "check-typing-status",
525        data: json!({ "chatGuid": chat_guid }),
526        transaction_type: Some(TransactionType::Chat),
527    }
528}
529
530pub fn admit_participant(conversation_uuid: &str, handle_uuid: &str) -> Action {
531    Action {
532        name: "admit-pending-member",
533        data: json!({
534            "conversationUUID": conversation_uuid,
535            "handleUUID": handle_uuid,
536        }),
537        transaction_type: Some(TransactionType::Other),
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn send_message_action_shape() {
547        let action = send_message(
548            "iMessage;-;+1555",
549            "hello",
550            &SendOptions::default(),
551            None,
552            None,
553        );
554        assert_eq!(action.name, "send-message");
555        assert_eq!(action.data["chatGuid"], "iMessage;-;+1555");
556        assert_eq!(action.data["message"], "hello");
557        assert_eq!(action.transaction_type, Some(TransactionType::Message));
558    }
559
560    #[test]
561    fn create_chat_uses_message_transaction() {
562        let action = create_chat(&["addr".to_string()], "hi", "iMessage", None, None, None);
563        assert_eq!(action.name, "create-chat");
564        assert_eq!(action.transaction_type, Some(TransactionType::Message));
565    }
566
567    #[test]
568    fn fire_and_forget_actions_have_no_transaction() {
569        let a = start_typing("guid");
570        assert!(a.transaction_type.is_none());
571        let b = stop_typing("guid");
572        assert!(b.transaction_type.is_none());
573        let c = mark_chat_read("guid");
574        assert!(c.transaction_type.is_none());
575    }
576
577    #[test]
578    fn imessage_availability_detects_email() {
579        let a = get_imessage_availability("user@icloud.com");
580        assert_eq!(a.data["aliasType"], "email");
581        let b = get_imessage_availability("+15551234567");
582        assert_eq!(b.data["aliasType"], "phone");
583    }
584
585    #[test]
586    fn send_attachment_encodes_audio_as_int() {
587        let a = send_attachment("guid", "/path", true, &SendOptions::default());
588        assert_eq!(a.data["isAudioMessage"], 1);
589        assert_eq!(a.data["partIndex"], 0); // default partIndex
590        let b = send_attachment("guid", "/path", false, &SendOptions::default());
591        assert_eq!(b.data["isAudioMessage"], 0);
592    }
593
594    #[test]
595    fn dd_scan_encodes_as_int() {
596        let a = send_message("g", "m", &SendOptions::default(), None, Some(true));
597        assert_eq!(a.data["ddScan"], 1);
598    }
599}