diff --git a/wacore/src/messages.rs b/wacore/src/messages.rs index 5e9d376ad..2fea31719 100644 --- a/wacore/src/messages.rs +++ b/wacore/src/messages.rs @@ -97,6 +97,22 @@ pub fn decode_plaintext(padded_plaintext: &[u8], padding_version: u8) -> Result< .map_err(|e| anyhow::anyhow!("Failed to decode decrypted plaintext: {e}")) } +/// Wrap a message into a DeviceSentMessage for own-device sync, hoisting +/// `message_context_info` onto the outer message (matching WA Web). Inverse of +/// [`unwrap_device_sent`]. +pub fn wrap_device_sent(mut message: wa::Message, destination_jid: String) -> wa::Message { + let context = message.message_context_info.take(); + wa::Message { + message_context_info: context, + device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + destination_jid: Some(destination_jid), + message: Some(Box::new(message)), + phash: None, + })), + ..Default::default() + } +} + /// Unwrap a DeviceSentMessage wrapper, returning the inner message. /// /// When a message is sent from our own device, the actual content is nested @@ -741,3 +757,87 @@ mod parse_message_info_tests { ); } } + +#[cfg(test)] +mod device_sent_tests { + use super::*; + + fn msg_with_secret(secret: &[u8]) -> wa::Message { + wa::Message { + conversation: Some("hi".into()), + message_context_info: Some(wa::MessageContextInfo { + message_secret: Some(secret.to_vec()), + ..Default::default() + }), + ..Default::default() + } + } + + #[test] + fn wrap_hoists_context_to_outer_on_wire() { + let secret = [7u8; 32]; + let wrapped = wrap_device_sent(msg_with_secret(&secret), "1@s.whatsapp.net".into()); + + let bytes = wrapped.encode_to_vec(); + let decoded = wa::Message::decode(bytes.as_slice()).unwrap(); + + assert_eq!( + decoded + .message_context_info + .and_then(|c| c.message_secret) + .as_deref(), + Some(secret.as_slice()) + ); + let inner = decoded.device_sent_message.unwrap().message.unwrap(); + assert!(inner.message_context_info.is_none()); + assert_eq!(inner.conversation.as_deref(), Some("hi")); + } + + #[test] + fn wrap_without_context_leaves_outer_empty() { + let inner = wa::Message { + conversation: Some("hi".into()), + ..Default::default() + }; + let wrapped = wrap_device_sent(inner, "1@s.whatsapp.net".into()); + + assert!(wrapped.message_context_info.is_none()); + let dsm = wrapped.device_sent_message.unwrap(); + assert_eq!(dsm.destination_jid.as_deref(), Some("1@s.whatsapp.net")); + assert!(dsm.message.unwrap().message_context_info.is_none()); + } + + #[test] + fn wrap_then_unwrap_preserves_non_secret_context_fields() { + let inner = wa::Message { + message_context_info: Some(wa::MessageContextInfo { + message_add_on_duration_in_secs: Some(604800), + ..Default::default() + }), + ..Default::default() + }; + let unwrapped = unwrap_device_sent(wrap_device_sent(inner, "1@s.whatsapp.net".into())); + assert_eq!( + unwrapped + .message_context_info + .and_then(|c| c.message_add_on_duration_in_secs), + Some(604800) + ); + } + + #[test] + fn wrap_then_unwrap_round_trips_secret() { + let secret = [9u8; 32]; + let wrapped = wrap_device_sent(msg_with_secret(&secret), "1@s.whatsapp.net".into()); + let unwrapped = unwrap_device_sent(wrapped); + + assert_eq!(unwrapped.conversation.as_deref(), Some("hi")); + assert_eq!( + unwrapped + .message_context_info + .and_then(|c| c.message_secret) + .as_deref(), + Some(secret.as_slice()) + ); + } +} diff --git a/wacore/src/proto_helpers.rs b/wacore/src/proto_helpers.rs index 6028bdcca..66a15ef15 100644 --- a/wacore/src/proto_helpers.rs +++ b/wacore/src/proto_helpers.rs @@ -511,14 +511,9 @@ pub fn merge_dsm_context( inner.limit_sharing_v2 = None; Some(inner) } - (None, Some(outer)) => Some(wa::MessageContextInfo { - message_secret: outer.message_secret.clone(), - message_association: outer.message_association.clone(), - limit_sharing_v2: outer.limit_sharing_v2, - thread_id: outer.thread_id.clone(), - bot_metadata: outer.bot_metadata.clone(), - ..Default::default() - }), + // Inner was cleared by a WA-Web-style hoist; restore the full context the + // sender moved to the outer message, not just the merge subset. + (None, Some(outer)) => Some(outer.clone()), (Some(mut inner), Some(outer)) => { if inner.message_secret.is_none() { inner.message_secret = outer.message_secret.clone(); @@ -1550,6 +1545,20 @@ mod tests { ); } + #[test] + fn test_merge_dsm_context_outer_only_preserves_non_subset_fields() { + let outer = wa::MessageContextInfo { + message_add_on_duration_in_secs: Some(86400), + ..Default::default() + }; + let result = merge_dsm_context(None, Some(&outer)).unwrap(); + assert_eq!( + result.message_add_on_duration_in_secs, + Some(86400), + "hoisted fields outside the merge subset must survive unwrap" + ); + } + #[test] fn test_merge_dsm_context_inner_preferred_for_secret() { let inner = wa::MessageContextInfo { diff --git a/wacore/src/send.rs b/wacore/src/send.rs index 40c0f256e..41aebe3c5 100644 --- a/wacore/src/send.rs +++ b/wacore/src/send.rs @@ -23,7 +23,6 @@ use wacore_binary::builder::NodeBuilder; use wacore_binary::{Jid, JidExt as _}; use wacore_libsignal::crypto::aes_256_cbc_encrypt_into; use waproto::whatsapp as wa; -use waproto::whatsapp::message::DeviceSentMessage; /// Wire-format constants (MsgCreateDeviceStanza.js). pub(crate) mod stanza { @@ -1004,14 +1003,7 @@ pub async fn prepare_dm_stanza< MessageUtils::participant_list_hash(&sent).ok() }; - let dsm = wa::Message { - device_sent_message: Some(Box::new(DeviceSentMessage { - destination_jid: Some(to_jid.to_string()), - message: Some(Box::new(message_for_encryption)), - phash: None, // WA Web only sets DSM phash for groups - })), - ..Default::default() - }; + let dsm = crate::messages::wrap_device_sent(message_for_encryption, to_jid.to_string()); let own_devices_plaintext = MessageUtils::encode_and_pad(&dsm);