From 57fec82c54fcdfc6ee7af906527cd40a1a388b09 Mon Sep 17 00:00:00 2001 From: stealth658 <9551905+stealth658@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:08:45 -0400 Subject: [PATCH] Thread RCS replies (thread-identifier, replyToGUID) Received RCS replies reference the replied-to message, but RawSmsIncomingMessage did not model those keys and the parse hardcoded reply_guid/reply_part to None, so replies arrived unthreaded: - thread-identifier: "r::", same shape as the iMessage reply ref - thread-originator-guid: the bare guid (fallback, part 0) Replies sent from another of the account's devices are reflected as RawSmsOutgoingMessage with the guid in mD replyToGUID (capital GUID). The field was renamed "replyToGuid", so it never matched; accept both spellings and also map it to reply_guid (part 0). after_guid is unchanged. Relayed replies sent from rustpush now carry thread-identifier and thread-originator-guid built from reply_guid/reply_part. split_reply_thread parses the "r::" shape and returns None on malformed input instead of panicking. --- src/imessage/messages.rs | 39 +++++++++++++++++++++++++++++++++---- src/imessage/rawmessages.rs | 14 +++++++++++-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/imessage/messages.rs b/src/imessage/messages.rs index cf9420b..b5dac7b 100644 --- a/src/imessage/messages.rs +++ b/src/imessage/messages.rs @@ -1876,6 +1876,21 @@ fn remove_prefix(participants: &[String]) -> Vec { p.replace("mailto:", "").replace("tel:", "")).collect() } +// Parse a reply/thread identifier of the form "r::" into +// (guid, part). iMessage and the RCS relay both use this shape (e.g. +// "r:0:0:14:"); the guid is the segment containing a '-'. Returns +// None if there is no guid-looking segment. Mirrors the inline iMessage parse +// but never panics on malformed input. +fn split_reply_thread(to: &str) -> Option<(String, String)> { + let mut parts: Vec<&str> = to.split(':').collect(); + if parts.is_empty() { return None; } + parts.remove(0); // remove the leading "r" + let guididx = parts.iter().position(|p| p.contains('-'))?; + let guid = parts[guididx].to_string(); + parts.remove(guididx); + Some((guid, parts.join(":"))) +} + pub fn add_prefix(participants: &[String]) -> Vec { participants.iter().map(|p| if p.contains("@") { format!("mailto:{}", p) @@ -2353,6 +2368,9 @@ impl MessageInst { ic: 1, n: Some("310".to_string()), guid: self.id.clone(), + thread_identifier: normal.reply_guid.as_ref().map(|g| + format!("r:{}:{}", normal.reply_part.as_deref().unwrap_or("0"), g)), + thread_originator_guid: normal.reply_guid.clone(), }; let payload = plist_to_bin(&raw).unwrap(); @@ -2785,6 +2803,16 @@ impl MessageInst { let system_recv: SystemTime = loaded.recieved_date.clone().into(); let parts = MessageParts::parse_sms(&loaded); let normalized_sender = normalize_sms_handle(&loaded.sender); + // RCS reply: prefer thread-identifier (carries the part), fall back to + // the bare thread-originator-guid with part 0. + let (reply_guid, reply_part) = if let Some((g, p)) = + loaded.thread_identifier.as_deref().and_then(split_reply_thread) { + (Some(g), Some(p)) + } else if let Some(g) = loaded.thread_originator_guid.clone() { + (Some(g), Some("0".to_string())) + } else { + (None, None) + }; let mut msg = wrapper.to_message( Some(ConversationData { participants: if loaded.participants.len() > 0 { @@ -2804,8 +2832,8 @@ impl MessageInst { Message::Message(NormalMessage { parts, effect: None, // losers - reply_guid: None, // losers - reply_part: None, // losers + reply_guid, + reply_part, service: MessageType::SMS { // shame is_phone: false, // if we are recieving a incoming message (over apns), we must not be the phone using_number: normalize_sms_handle(&loaded.recieved_number), @@ -2828,6 +2856,9 @@ impl MessageInst { }, |xml| { MessageParts::parse_parts(xml, None) }); + // Reflected RCS reply sent from another of the account's devices: thread + // it off the replied-to message, part 0 (the payload carries only the guid). + let reply_to_guid = loaded.message.reply_to_guid.clone(); return wrapper.to_message(Some(ConversationData { participants: loaded.participants.iter().map(|p| format!("tel:{}", p.phone_number)).chain(std::iter::once(wrapper.sender.clone().unwrap())).collect(), cv_name: None, // ha sms sux, can't believe these losers don't have an iPhone @@ -2836,8 +2867,8 @@ impl MessageInst { }), Message::Message(NormalMessage { parts, effect: None, // losers - reply_guid: None, // losers - reply_part: None, // losers + reply_guid: reply_to_guid.clone(), + reply_part: reply_to_guid.map(|_| "0".to_string()), service: MessageType::SMS { // shame is_phone: false, // if we are recieving a outgoing message, we must not be the phone using_number: wrapper.sender.clone().unwrap(), diff --git a/src/imessage/rawmessages.rs b/src/imessage/rawmessages.rs index 5849ed2..2c57edb 100644 --- a/src/imessage/rawmessages.rs +++ b/src/imessage/rawmessages.rs @@ -277,7 +277,10 @@ struct RawSmsOutgoingInnerMessage { #[serde(rename = "sV")] version: String, // always 1 guid: String, // same as outside encryption - #[serde(rename = "replyToGuid")] + // Reflected RCS/SMS replies carry the replied-to message guid here. The wire + // name is "replyToGUID" (capital GUID) on RCS; the older "replyToGuid" is kept + // as an alias so both parse. + #[serde(rename = "replyToGUID", alias = "replyToGuid")] reply_to_guid: Option, #[serde(rename = "plain-body")] plain_body: String, @@ -342,7 +345,14 @@ struct RawSmsIncomingMessage { ic: u32, // always 1 n: Option, // always 310 (missing) #[serde(rename = "g")] - guid: String + guid: String, + // RCS replies: the replied-to message is referenced here. "thread-identifier" + // has the "r::" shape (same as iMessage); "thread-originator-guid" + // is the bare guid. Absent on non-reply texts. + #[serde(rename = "thread-identifier")] + thread_identifier: Option, + #[serde(rename = "thread-originator-guid")] + thread_originator_guid: Option, } #[derive(Serialize, Deserialize)]