From a635eb1a892dbbe6d920b57773532aa1db9819d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:09:31 +0000 Subject: [PATCH 1/6] feat(recv): parse the envelope type, enc mediatype and report-to-admin group IQs Four attributes the official client reads or writes on the receive path had no counterpart here, and three of them were fields this crate already declared and never assigned, so a consumer got an empty string with no way to tell a missing parse from a missing attribute. - `` now reaches `MessageInfo::type` as a typed wire enum with the seven values the official parser accepts. That parser rejects the stanza when the attribute is absent or unrecognized; rejecting here would drop a message this client delivers today, so absence is `None` and an unrecognized value keeps its bytes. `` follows, scoped to poll envelopes the way the official parser scopes it. - `envelope_is_coherent` states the rule the official client uses to decide whether a `decrypt-fail="hide"` stanza may be nacked. It only answers; no control flow in this crate consults it. - A retry receipt for a stanza whose `` asked for hidden failures now reports the HID_FAILED_DECRYPT bit in ``, built only when the bitmask is non-zero and only while `receipt_mode_bitmask_enabled` is on. - `` fills `MessageInfo::media_type`, aggregated to the first node that declares one; `` and `` reach `Event::DecryptedPayload` per node. Both are read in the loop the receive path already runs over every ``. - The `w:g2` report-to-admin pair (`` set and get) lands as two IQ specs with their own response types, distinct from the `spam` IQ that reports to WhatsApp rather than to the group's admins. Breaking: `MessageInfo::type` is `Option` and `MessageInfo::media_type` is `Option`; both were `String`. `MsgMetaInfo::deprecated_lid_session` is gone -- never assigned and absent from the protocol's ``. `size_of::()` is unchanged at 952 bytes and a known type costs no allocation. --- src/features/groups.rs | 53 +++++-- src/features/mod.rs | 11 +- src/features/stanza.rs | 20 +++ src/lib.rs | 29 ++-- src/message.rs | 13 ++ src/message/msg_secret.rs | 4 + src/message/receive.rs | 62 +++++++- src/message/retry.rs | 21 ++- src/message/tests.rs | 238 +++++++++++++++++++++++++++-- src/pdo.rs | 4 +- src/receipt.rs | 10 +- src/retry.rs | 38 ++++- wacore/src/iq/groups.rs | 282 +++++++++++++++++++++++++++++++++++ wacore/src/messages.rs | 156 ++++++++++++++++++- wacore/src/protocol/retry.rs | 24 +++ wacore/src/types/events.rs | 13 ++ wacore/src/types/message.rs | 245 +++++++++++++++++++++++++++++- 17 files changed, 1157 insertions(+), 66 deletions(-) diff --git a/src/features/groups.rs b/src/features/groups.rs index c365c9647..9c7fc782f 100644 --- a/src/features/groups.rs +++ b/src/features/groups.rs @@ -13,13 +13,14 @@ pub use wacore::iq::contacts::SetProfilePictureResponse; use wacore::iq::groups::{ AcceptGroupInviteIq, AcceptGroupInviteV4Iq, AcknowledgeGroupIq, AddParticipantsIq, BatchGetGroupInfoIq, CancelMembershipRequestsIq, DemoteParticipantsIq, GetGroupInviteInfoIq, - GetGroupInviteLinkIq, GetGroupProfilePicturesIq, GetMembershipRequestsIq, GroupCreateIq, - GroupInfoOutcome, GroupInfoResponse, GroupParticipantResponse, GroupParticipatingIq, - GroupQueryIq, LeaveGroupIq, MembershipRequestActionIq, PromoteParticipantsIq, - RemoveParticipantsIncludingLinkedGroupsIq, RemoveParticipantsIq, RevokeRequestCodeIq, - SetAllowAdminReportsIq, SetGroupAnnouncementIq, SetGroupDescriptionIq, SetGroupEphemeralIq, - SetGroupHistoryIq, SetGroupLockedIq, SetGroupMembershipApprovalIq, SetGroupSubjectIq, - SetMemberAddModeIq, SetNoFrequentlyForwardedIq, normalize_participants, + GetGroupInviteLinkIq, GetGroupProfilePicturesIq, GetMembershipRequestsIq, + GetReportedGroupMessagesIq, GroupCreateIq, GroupInfoOutcome, GroupInfoResponse, + GroupParticipantResponse, GroupParticipatingIq, GroupQueryIq, LeaveGroupIq, + MembershipRequestActionIq, PromoteParticipantsIq, RemoveParticipantsIncludingLinkedGroupsIq, + RemoveParticipantsIq, ReportGroupMessagesIq, RevokeRequestCodeIq, SetAllowAdminReportsIq, + SetGroupAnnouncementIq, SetGroupDescriptionIq, SetGroupEphemeralIq, SetGroupHistoryIq, + SetGroupLockedIq, SetGroupMembershipApprovalIq, SetGroupSubjectIq, SetMemberAddModeIq, + SetNoFrequentlyForwardedIq, normalize_participants, }; use wacore::iq::mex_operations::update_group_property; use wacore::types::message::AddressingMode; @@ -28,10 +29,11 @@ use wacore_binary::{Jid, JidExt as _}; use wacore::iq::groups::BatchGroupInfoResult as RawBatchResult; pub use wacore::iq::groups::{ GroupAppealStatus, GroupCreateOptions, GroupDescription, GroupEphemeralSettings, - GroupJoinError, GroupParticipantDetails, GroupParticipantOptions, GroupProfilePicture, - GroupSubject, GrowthLockInfo, InviteInfoError, JoinGroupResult, MemberAddMode, MemberLinkMode, - MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, ParticipantChangeResponse, - ParticipantType, PictureType, + GroupJoinError, GroupMessageReporter, GroupParticipantDetails, GroupParticipantOptions, + GroupProfilePicture, GroupSubject, GrowthLockInfo, InviteInfoError, JoinGroupResult, + MemberAddMode, MemberLinkMode, MemberShareHistoryMode, MembershipApprovalMode, + MembershipRequest, ParticipantChangeResponse, ParticipantType, PictureType, + ReportedGroupMessage, ReportedGroupMessages, }; /// Error returned by group operations (metadata queries, participant and @@ -1086,6 +1088,35 @@ impl<'a> Groups<'a> { .await?) } + /// Report messages to the group's admins. + /// + /// Reports to the group's own admins, not to WhatsApp; the group has to + /// allow admin reports for the server to accept it. The result is empty on + /// success and says nothing about which ids were accepted. + pub async fn report_messages_to_admins( + &self, + jid: impl Into, + message_ids: &[String], + ) -> Result<(), GroupError> { + let jid = &jid.into(); + Ok(self + .client + .execute(ReportGroupMessagesIq::new(jid, message_ids)) + .await?) + } + + /// Fetch the messages reported to this group's admins. + pub async fn get_reported_messages( + &self, + jid: impl Into, + ) -> Result { + let jid = &jid.into(); + Ok(self + .client + .execute(GetReportedGroupMessagesIq::new(jid)) + .await?) + } + /// Approve pending membership requests. pub async fn approve_membership_requests( &self, diff --git a/src/features/mod.rs b/src/features/mod.rs index 8ff9b796a..3d0394761 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -69,11 +69,12 @@ pub use events::{EventCreationParams, EventResponseType, Events}; pub use groups::{ BatchGroupResult, CreateGroupResult, GroupAppealStatus, GroupCreateOptions, GroupDescription, - GroupEphemeralSettings, GroupError, GroupJoinError, GroupMetadata, GroupParticipant, - GroupParticipantDetails, GroupParticipantOptions, GroupProfilePicture, GroupSubject, Groups, - GrowthLockInfo, InviteInfoError, JoinGroupResult, MemberAddMode, MemberLinkMode, - MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, ParticipantChangeResponse, - ParticipantType, PictureType, PreviousDescription, + GroupEphemeralSettings, GroupError, GroupJoinError, GroupMessageReporter, GroupMetadata, + GroupParticipant, GroupParticipantDetails, GroupParticipantOptions, GroupProfilePicture, + GroupSubject, Groups, GrowthLockInfo, InviteInfoError, JoinGroupResult, MemberAddMode, + MemberLinkMode, MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, + ParticipantChangeResponse, ParticipantType, PictureType, PreviousDescription, + ReportedGroupMessage, ReportedGroupMessages, }; pub use labels::Labels; diff --git a/src/features/stanza.rs b/src/features/stanza.rs index 0b4d1507d..42f657512 100644 --- a/src/features/stanza.rs +++ b/src/features/stanza.rs @@ -83,6 +83,7 @@ pub enum StanzaResponseError { pub struct RetryRequestOptions { reason: RetryReason, force_include_keys: bool, + decrypt_fail_mode: wacore::types::events::DecryptFailMode, } impl RetryRequestOptions { @@ -91,6 +92,7 @@ impl RetryRequestOptions { Self { reason: RetryReason::UnknownError, force_include_keys: false, + decrypt_fail_mode: wacore::types::events::DecryptFailMode::Show, } } @@ -115,6 +117,24 @@ impl RetryRequestOptions { pub const fn force_include_keys(self) -> bool { self.force_include_keys } + + /// Record that the stanza being retried asked for its decryption failures + /// to be hidden, which the receipt reports back in its ``. + /// + /// Defaults to [`DecryptFailMode::Show`](wacore::types::events::DecryptFailMode::Show), + /// so a caller that does not set it sends no `` at all. + pub const fn with_decrypt_fail_mode( + mut self, + decrypt_fail_mode: wacore::types::events::DecryptFailMode, + ) -> Self { + self.decrypt_fail_mode = decrypt_fail_mode; + self + } + + /// How the stanza being retried asked its failures to be surfaced. + pub const fn decrypt_fail_mode(self) -> wacore::types::events::DecryptFailMode { + self.decrypt_fail_mode + } } impl Default for RetryRequestOptions { diff --git a/src/lib.rs b/src/lib.rs index 0c970700b..9a625ac66 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -219,26 +219,27 @@ pub use features::{ CommunitySubgroup, ContactError, Contacts, CoverPhotoUpload, CreateCommunityOptions, CreateCommunityResult, CreateGroupResult, DayOfWeek, EncType, EncryptedEdit, EventCreationParams, EventResponseType, Events, GroupAppealStatus, GroupCreateOptions, - GroupDescription, GroupEphemeralSettings, GroupError, GroupJoinError, GroupMetadata, - GroupParticipant, GroupParticipantDetails, GroupParticipantOptions, GroupProfilePicture, - GroupSubject, GroupType, Groups, GrowthLockInfo, ImporterAddress, InviteInfoError, - IsOnWhatsAppResult, JoinGroupResult, Labels, LinkSubgroupsResult, MediaRetryResult, - MediaReupload, MediaReuploadError, MediaReuploadRequest, MemberAddMode, MemberLinkMode, - MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, MessageEditError, - MessageRetransmission, Mex, MexError, MexErrorExtensions, MexGraphQLError, MexRequest, - MexResponse, NackReason, NewChatMessageCapping, Newsletter, NewsletterAdminInfo, + GroupDescription, GroupEphemeralSettings, GroupError, GroupJoinError, GroupMessageReporter, + GroupMetadata, GroupParticipant, GroupParticipantDetails, GroupParticipantOptions, + GroupProfilePicture, GroupSubject, GroupType, Groups, GrowthLockInfo, ImporterAddress, + InviteInfoError, IsOnWhatsAppResult, JoinGroupResult, Labels, LinkSubgroupsResult, + MediaRetryResult, MediaReupload, MediaReuploadError, MediaReuploadRequest, MemberAddMode, + MemberLinkMode, MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, + MessageEditError, MessageRetransmission, Mex, MexError, MexErrorExtensions, MexGraphQLError, + MexRequest, MexResponse, NackReason, NewChatMessageCapping, Newsletter, NewsletterAdminInfo, NewsletterAdminProfile, NewsletterError, NewsletterFollower, NewsletterMessage, NewsletterMessageType, NewsletterMetadata, NewsletterReactionCount, NewsletterRole, NewsletterState, NewsletterVerification, Order, OrderPriceDetails, OrderProduct, ParticipantChangeResponse, ParticipantType, PictureType, PollError, PollOptionResult, PollVoteCiphertext, Polls, Presence, PresenceError, PresenceStatus, PreviousDescription, Price, Product, ProductAvailability, ProductImage, ProductVideo, Profile, ProfileError, - ProfilePicture, QuickReplies, ReachoutTimelock, RetryReason, RetryRequestError, - RetryRequestOptions, RetryRequestOutcome, SalePrice, SecretEncKind, SecretEncrypted, - SetProfilePictureResponse, Signal, SignalError, SignalSessionInfo, SignalSessionMigration, - StanzaRejection, StanzaResponseError, Status, StatusPrivacySetting, StatusSendOptions, - SyncActionMessageRange, TcToken, TcTokenError, UnlinkSubgroupsResult, UserInfo, - UsyncSubprotocolError, VariantProperty, VerifiedName, group_type, message_key, message_range, + ProfilePicture, QuickReplies, ReachoutTimelock, ReportedGroupMessage, ReportedGroupMessages, + RetryReason, RetryRequestError, RetryRequestOptions, RetryRequestOutcome, SalePrice, + SecretEncKind, SecretEncrypted, SetProfilePictureResponse, Signal, SignalError, + SignalSessionInfo, SignalSessionMigration, StanzaRejection, StanzaResponseError, Status, + StatusPrivacySetting, StatusSendOptions, SyncActionMessageRange, TcToken, TcTokenError, + UnlinkSubgroupsResult, UserInfo, UsyncSubprotocolError, VariantProperty, VerifiedName, + group_type, message_key, message_range, }; pub mod bot; diff --git a/src/message.rs b/src/message.rs index d499e0662..3d2122098 100644 --- a/src/message.rs +++ b/src/message.rs @@ -81,6 +81,12 @@ pub(crate) struct EncPayload { /// payload are skipped, so a position within a bucket is not a position in /// the stanza. pub enc_index: usize, + /// The node's `state` attribute, verbatim. Absent on ordinary traffic, so + /// the common case allocates nothing. + pub state: Option, + /// The node's `session_type` attribute, verbatim. Absent on ordinary + /// traffic, so the common case allocates nothing. + pub session_type: Option, } impl EncPayload { @@ -91,11 +97,16 @@ impl EncPayload { ) -> Option { let enc_type = EncType::from_wire(enc_node.attrs().optional_string("type")?.as_ref())?; let padding_version = enc_node.attrs().optional_u64("v").unwrap_or(2) as u8; + let mut attrs = enc_node.attrs(); Some(Self { ciphertext, enc_type, padding_version, enc_index, + state: attrs.optional_string("state").map(|s| s.into_owned()), + session_type: attrs + .optional_string("session_type") + .map(|s| s.into_owned()), }) } @@ -233,6 +244,8 @@ struct DeferredPlaintext { /// Which `` in the stanza produced this — [`EncPayload::enc_index`], /// carried through because the buffer drains after the decrypt loop. enc_index: usize, + state: Option, + session_type: Option, } fn should_process_skmsg_after_session( diff --git a/src/message/msg_secret.rs b/src/message/msg_secret.rs index 0a874e25c..048bbfd9a 100644 --- a/src/message/msg_secret.rs +++ b/src/message/msg_secret.rs @@ -500,6 +500,8 @@ impl Client { // Read off before the payload is consumed below. let enc_index = payload.enc_index; let enc_type = payload.enc_type.as_wire_str(); + let enc_state = payload.state.clone(); + let enc_session_type = payload.session_type.clone(); let ms_msg = match waproto::codec::message_secret_message_decode(&payload.ciphertext) { Ok(m) => m, @@ -801,6 +803,8 @@ impl Client { .info(Arc::clone(info)) .enc_index(enc_index) .enc_type(enc_type) + .maybe_state(enc_state.clone()) + .maybe_session_type(enc_session_type.clone()) .payload(plaintext.clone()) .build(), )); diff --git a/src/message/receive.rs b/src/message/receive.rs index df33c1bf4..3192b88f7 100644 --- a/src/message/receive.rs +++ b/src/message/receive.rs @@ -128,7 +128,7 @@ impl Client { node: &OwnedNodeRef, ) -> Option { let nr = node.get(); - let info = match self.parse_message_info(nr).await { + let mut info = match self.parse_message_info(nr).await { Ok(info) => Arc::new(info), Err(e) => { let id = nr.get_attr("id").map(|v| v.as_str()); @@ -244,6 +244,7 @@ impl Client { let mut has_hide_fail = false; let mut had_unknown_enc = false; let mut had_custom_handler = false; + let mut media_type: Option = None; // Custom enc handlers are set once at Bot::build and immutable after, so // read the map lock-free once instead of acquiring an async RwLock guard @@ -253,6 +254,18 @@ impl Client { for (enc_index, enc_node) in all_enc_nodes.iter().enumerate() { max_sender_retry_count = max_sender_retry_count.max(sender_retry_count(enc_node)); + // The declared media type belongs to the message, not to a device + // copy, so the first `` carrying one settles it for the whole + // stanza and a divergent later value is dropped. Read here rather + // than in the parser so the fan-out nodes under + // count too, and so the stanza's children are + // walked once. + if media_type.is_none() + && let Some(value) = enc_node.attrs().optional_string("mediatype") + { + media_type = Some(crate::types::message::EncMediaType::from(value.as_ref())); + } + // Parse decrypt-fail attribute (WA Web: e.maybeAttrString("decrypt-fail") === "hide") if enc_node .get_attr("decrypt-fail") @@ -344,6 +357,15 @@ impl Client { push_enc_payload(bucket, all_enc_nodes.len(), payload); } + // The media type is only known once the `` nodes have been walked, + // and the parser that built `info` never saw them. Nothing has cloned + // this Arc on the path every message takes, so finishing the struct + // here costs a refcount check; the failure paths that did clone it pay + // one copy rather than leaving the field unset. + if let Some(media_type) = media_type { + Arc::make_mut(&mut info).media_type = Some(media_type); + } + // WA Web diagnostic: validate skmsg is not first in multi-enc messages. if !session_payloads.is_empty() && !group_payloads.is_empty() @@ -721,6 +743,8 @@ impl Client { enc_type, padding_version, enc_index, + state, + session_type, } = payload; let enc_type_str = enc_type.as_wire_str(); #[cfg(feature = "tracing")] @@ -851,6 +875,8 @@ impl Client { plaintext: decrypted.plaintext, padding_version, enc_index, + state: state.clone(), + session_type: session_type.clone(), }); } Err(e) => { @@ -974,6 +1000,8 @@ impl Client { plaintext: decrypted.plaintext, padding_version, enc_index, + state: state.clone(), + session_type: session_type.clone(), }); } Err(retry_err) => { @@ -1005,6 +1033,8 @@ impl Client { enc_type, padding_version, enc_index, + state.as_deref(), + session_type.as_deref(), info, &session_mutex, &mut session_guard, @@ -1122,6 +1152,8 @@ impl Client { enc_type, padding_version, enc_index, + state.as_deref(), + session_type.as_deref(), info, &session_mutex, &mut session_guard, @@ -1176,6 +1208,8 @@ impl Client { enc_type, padding_version, enc_index, + state.as_deref(), + session_type.as_deref(), info, &session_mutex, &mut session_guard, @@ -1248,6 +1282,8 @@ impl Client { enc_type, padding_version, enc_index, + state.as_deref(), + session_type.as_deref(), info, &session_mutex, &mut session_guard, @@ -1362,10 +1398,20 @@ impl Client { plaintext, padding_version, enc_index, + state, + session_type, } in deferred { match self - .handle_decrypted_plaintext(enc_type, plaintext, padding_version, enc_index, info) + .handle_decrypted_plaintext( + enc_type, + plaintext, + padding_version, + enc_index, + state.as_deref(), + session_type.as_deref(), + info, + ) .await { Ok(plaintext_outcome) => { @@ -1468,6 +1514,8 @@ impl Client { padded_plaintext, padding_version, enc_index, + payload.state.as_deref(), + payload.session_type.as_deref(), info, ) .await @@ -1658,6 +1706,8 @@ impl Client { padded_plaintext: Vec, padding_version: u8, enc_index: usize, + enc_state: Option<&str>, + enc_session_type: Option<&str>, info: &Arc, ) -> Result { let source = wacore::messages::unpad_plaintext(padded_plaintext, padding_version)?; @@ -1671,6 +1721,8 @@ impl Client { .info(Arc::clone(info)) .enc_index(enc_index) .enc_type(enc_type) + .maybe_state(enc_state.map(str::to_owned)) + .maybe_session_type(enc_session_type.map(str::to_owned)) .payload(source.clone()) .build(), )); @@ -1867,6 +1919,8 @@ impl Client { enc_type: &'static str, padding_version: u8, enc_index: usize, + enc_state: Option<&str>, + enc_session_type: Option<&str>, info: &Arc, session_mutex: &Arc>, session_guard: &mut Option>, @@ -1927,6 +1981,8 @@ impl Client { plaintext: decrypted.plaintext, padding_version, enc_index, + state: enc_state.map(str::to_owned), + session_type: enc_session_type.map(str::to_owned), }); MigrationDecryptResult::Decrypted } @@ -2011,6 +2067,8 @@ mod enc_bucket_tests { ciphertext: bytes::Bytes::from_static(b"ct"), enc_type, padding_version: 2, + state: None, + session_type: None, } } diff --git a/src/message/retry.rs b/src/message/retry.rs index 2c881e8ec..ea6a2d11b 100644 --- a/src/message/retry.rs +++ b/src/message/retry.rs @@ -223,7 +223,9 @@ impl Client { } // Only ack once the resend request is actually out; otherwise leave // the stanza queued so the server redelivers and we retry. - let resend_sent = client.run_retry_receipt(&info, reason).await; + let resend_sent = client + .run_retry_receipt(&info, reason, decrypt_fail_mode) + .await; if resend_sent { client.send_transport_ack(&info).await; } @@ -337,7 +339,9 @@ impl Client { let client = Arc::clone(self); let info = Arc::clone(info); self.outbound_flush.spawn(&*self.runtime, async move { - client.run_retry_receipt(&info, reason).await; + client + .run_retry_receipt(&info, reason, crate::types::events::DecryptFailMode::Show) + .await; }); } @@ -384,7 +388,13 @@ impl Client { } let send_result = self - .send_retry_receipt(info, retry_count, reason, options.force_include_keys()) + .send_retry_receipt( + info, + retry_count, + reason, + options.force_include_keys(), + options.decrypt_fail_mode(), + ) .await; // PDO is an independent first-attempt recovery path. Preserve it even @@ -434,11 +444,14 @@ impl Client { self: &Arc, info: &Arc, reason: RetryReason, + decrypt_fail_mode: crate::types::events::DecryptFailMode, ) -> bool { match self .request_retry_for_info( info, - crate::features::RetryRequestOptions::new().with_reason(reason), + crate::features::RetryRequestOptions::new() + .with_reason(reason) + .with_decrypt_fail_mode(decrypt_fail_mode), None, ) .await diff --git a/src/message/tests.rs b/src/message/tests.rs index 4638a0975..de47bb1d9 100644 --- a/src/message/tests.rs +++ b/src/message/tests.rs @@ -3334,7 +3334,7 @@ fn create_test_message_info(chat: &str, msg_id: &str, sender: &str) -> MessageIn MessageInfo { id: msg_id.to_string(), server_id: 0, - r#type: "text".to_string(), + r#type: Some(wacore::types::message::StanzaMessageType::Text), source: MessageSource { chat: chat_jid.clone(), sender: sender_jid, @@ -3350,7 +3350,7 @@ fn create_test_message_info(chat: &str, msg_id: &str, sender: &str) -> MessageIn push_name: "Test User".to_string(), category: MessageCategory::default(), multicast: false, - media_type: "".to_string(), + media_type: None, edit: EditAttribute::default(), bot_info: None, meta_info: MsgMetaInfo::default(), @@ -6784,6 +6784,8 @@ async fn pkmsg_parse_error_dispatches_parsing_error_nack() { ciphertext: bytes::Bytes::from_static(&[0xFF]), enc_type: EncType::PreKeyMessage, padding_version: 2, + state: None, + session_type: None, }; let outcome = client @@ -6828,6 +6830,8 @@ async fn signal_message_parse_error_dispatches_parsing_error_nack() { ciphertext: bytes::Bytes::from_static(&[0xFF]), enc_type: EncType::Message, padding_version: 2, + state: None, + session_type: None, }; let outcome = client @@ -8856,6 +8860,204 @@ async fn enc_index_is_the_position_in_the_stanza_not_in_its_bucket() { assert_eq!(group, [2], "the skmsg is its third, not its first"); } +/// The `mediatype` is a property of the message, and a fan-out repeats the +/// message once per device. The first `` that declares one settles it; +/// a divergent later value is dropped rather than overwriting it. +#[tokio::test] +async fn fan_out_media_type_takes_the_first_enc_that_declares_one() { + use wacore::types::message::EncMediaType; + + let (client, _transport) = capturing_client("fanout_mediatype").await; + let node = NodeBuilder::new("message") + .attr("from", "5511777776666@s.whatsapp.net") + .attr("id", "FANOUT_MEDIATYPE") + .attr("type", "media") + .children([ + // No mediatype at all: the aggregation must not stop here. + NodeBuilder::new("enc") + .attr("type", "pkmsg") + .bytes(vec![0u8; 8]) + .build(), + NodeBuilder::new("enc") + .attr("type", "msg") + .attr("mediatype", "image") + .bytes(vec![0u8; 8]) + .build(), + NodeBuilder::new("enc") + .attr("type", "msg") + .attr("mediatype", "document") + .bytes(vec![0u8; 8]) + .build(), + ]) + .build(); + + let classified = client + .classify_incoming_message(&node_to_arc(node)) + .await + .expect("a stanza with decryptable encs must classify"); + + assert_eq!( + classified.info.media_type, + Some(EncMediaType::Image), + "the first declared mediatype wins over a divergent sibling" + ); +} + +/// A `state` attribute belongs to the one `` that carried it, so it must +/// not be smeared across the stanza's other nodes. +#[tokio::test] +async fn enc_state_and_session_type_stay_on_their_own_node() { + let (client, _transport) = capturing_client("enc_state").await; + let node = NodeBuilder::new("message") + .attr("from", "5511777776666@s.whatsapp.net") + .attr("id", "ENC_STATE") + .attr("type", "text") + .children([ + NodeBuilder::new("enc") + .attr("type", "pkmsg") + .attr("state", "resumed") + .attr("session_type", "lid") + .bytes(vec![0u8; 8]) + .build(), + NodeBuilder::new("enc") + .attr("type", "msg") + .bytes(vec![0u8; 8]) + .build(), + ]) + .build(); + + let classified = client + .classify_incoming_message(&node_to_arc(node)) + .await + .expect("a stanza with decryptable encs must classify"); + + let states: Vec<_> = classified + .session_payloads + .iter() + .map(|payload| (payload.state.as_deref(), payload.session_type.as_deref())) + .collect(); + assert_eq!(states, [(Some("resumed"), Some("lid")), (None, None)]); +} + +/// Finds the `` of the first `` on the wire. +/// `Ok(None)` means a retry receipt went out carrying no `` at all. +fn retry_receipt_meta_mode(frames: &[bytes::Bytes]) -> Result, &'static str> { + for (i, frame) in frames.iter().enumerate() { + let Some(buf) = decode_frame(i, frame) else { + continue; + }; + let Ok(node) = wacore_binary::marshal::unmarshal_packed_ref(&buf) else { + continue; + }; + if node.tag.as_ref() != "receipt" + || node.get_attr("type").map(|v| v.as_str()).as_deref() != Some("retry") + { + continue; + } + return Ok(node + .get_optional_child("meta") + .map(|meta| meta.attrs().optional_u64("mode").unwrap_or_default())); + } + Err("no retry receipt reached the wire") +} + +async fn enable_receipt_mode_bitmask(client: &Arc) { + let prop = wacore::iq::abprops::web::RECEIPT_MODE_BITMASK_ENABLED; + client.ab_props().watch(prop).await; + client + .ab_props() + .apply_props(false, std::iter::once((prop.code, "1".into()))) + .await; +} + +/// A retry for a stanza whose `` asked for its failure to be hidden +/// reports the HID_FAILED_DECRYPT bit. Read as an integer off the wire, not +/// compared as a string, so the bit position stays the thing under test. +#[tokio::test] +async fn retry_receipt_reports_the_hidden_decrypt_fail_bit() { + use crate::types::events::DecryptFailMode; + use wacore::protocol::retry::RECEIPT_MODE_HID_FAILED_DECRYPT; + + let (client, transport) = capturing_client("retry_meta_hide").await; + enable_receipt_mode_bitmask(&client).await; + let info = create_test_message_info( + "5511999998888@s.whatsapp.net", + "RETRY_META_HIDE", + "5511777776666@s.whatsapp.net", + ); + + client + .send_retry_receipt( + &info, + 1, + RetryReason::UnknownError, + false, + DecryptFailMode::Hide, + ) + .await + .expect("the retry receipt should be sent"); + + assert_eq!( + retry_receipt_meta_mode(&transport.sent()), + Ok(Some(u64::from(RECEIPT_MODE_HID_FAILED_DECRYPT))), + ); +} + +/// The case that matters: an ordinary failure sets no bit, and an all-zero +/// bitmask means the node is not built at all. +#[tokio::test] +async fn retry_receipt_without_hidden_failures_carries_no_meta() { + use crate::types::events::DecryptFailMode; + + let (client, transport) = capturing_client("retry_meta_show").await; + enable_receipt_mode_bitmask(&client).await; + let info = create_test_message_info( + "5511999998888@s.whatsapp.net", + "RETRY_META_SHOW", + "5511777776666@s.whatsapp.net", + ); + + client + .send_retry_receipt( + &info, + 1, + RetryReason::UnknownError, + false, + DecryptFailMode::Show, + ) + .await + .expect("the retry receipt should be sent"); + + assert_eq!(retry_receipt_meta_mode(&transport.sent()), Ok(None)); +} + +/// With the prop off -- which is also what a cold props cache reads -- the +/// receipt keeps its pre-bitmask shape even for a hidden failure. +#[tokio::test] +async fn retry_receipt_omits_meta_while_the_prop_is_off() { + use crate::types::events::DecryptFailMode; + + let (client, transport) = capturing_client("retry_meta_gated").await; + let info = create_test_message_info( + "5511999998888@s.whatsapp.net", + "RETRY_META_GATED", + "5511777776666@s.whatsapp.net", + ); + + client + .send_retry_receipt( + &info, + 1, + RetryReason::UnknownError, + false, + DecryptFailMode::Hide, + ) + .await + .expect("the retry receipt should be sent"); + + assert_eq!(retry_receipt_meta_mode(&transport.sent()), Ok(None)); +} + /// Unknown-only stanzas (e.g. msmsg) must be acked or they loop the queue. #[tokio::test] async fn unknown_only_enc_is_transport_acked() { @@ -9126,7 +9328,7 @@ async fn app_state_sync_key_share_honored_only_from_self() { create_test_message_info("5510000@s.whatsapp.net", "AKS1", "5510000@s.whatsapp.net"); info.source.is_from_me = false; client - .handle_decrypted_plaintext("msg", padded.clone(), 2, 0, &Arc::new(info)) + .handle_decrypted_plaintext("msg", padded.clone(), 2, 0, None, None, &Arc::new(info)) .await .unwrap(); assert!( @@ -9150,7 +9352,7 @@ async fn app_state_sync_key_share_honored_only_from_self() { ); info.source.is_from_me = true; client - .handle_decrypted_plaintext("msg", padded, 2, 0, &Arc::new(info)) + .handle_decrypted_plaintext("msg", padded, 2, 0, None, None, &Arc::new(info)) .await .unwrap(); assert!( @@ -9306,6 +9508,8 @@ async fn app_state_key_share_waits_outside_the_offline_message_lane() { MessageUtils::encode_and_pad(&request), 2, 0, + None, + None, &info, ), ) @@ -9330,6 +9534,8 @@ async fn app_state_key_share_waits_outside_the_offline_message_lane() { MessageUtils::encode_and_pad(&request), 2, 0, + None, + None, &info, ), ) @@ -9637,7 +9843,7 @@ async fn lid_migration_mapping_sync_honored_only_from_self() { create_test_message_info("5510000@s.whatsapp.net", "LMS1", "5510000@s.whatsapp.net"); info.source.is_from_me = false; client - .handle_decrypted_plaintext("msg", padded.clone(), 2, 0, &Arc::new(info)) + .handle_decrypted_plaintext("msg", padded.clone(), 2, 0, None, None, &Arc::new(info)) .await .unwrap(); assert!( @@ -9653,7 +9859,7 @@ async fn lid_migration_mapping_sync_honored_only_from_self() { ); info.source.is_from_me = true; client - .handle_decrypted_plaintext("msg", padded, 2, 0, &Arc::new(info)) + .handle_decrypted_plaintext("msg", padded, 2, 0, None, None, &Arc::new(info)) .await .unwrap(); assert_eq!( @@ -13072,7 +13278,7 @@ async fn decrypted_payloads_are_not_forwarded_without_a_lease() { ..Default::default() }); client - .handle_decrypted_plaintext("msg", padded, 2, 0, &info) + .handle_decrypted_plaintext("msg", padded, 2, 0, None, None, &info) .await .expect("decodes"); @@ -13104,7 +13310,15 @@ async fn a_lease_forwards_the_payload_before_it_is_decoded() { )); client - .handle_decrypted_plaintext("msg", MessageUtils::encode_and_pad(&message), 2, 3, &info) + .handle_decrypted_plaintext( + "msg", + MessageUtils::encode_and_pad(&message), + 2, + 3, + None, + None, + &info, + ) .await .expect("decodes"); @@ -13141,7 +13355,7 @@ async fn a_payload_that_fails_to_decode_is_still_forwarded() { "5510000@s.whatsapp.net", )); let outcome = client - .handle_decrypted_plaintext("msg", undecodable_payload(), 2, 0, &info) + .handle_decrypted_plaintext("msg", undecodable_payload(), 2, 0, None, None, &info) .await; assert!(outcome.is_err(), "the fixture must actually fail to decode"); @@ -13176,7 +13390,7 @@ async fn forwarding_stops_when_the_last_lease_drops() { let first = client.acquire_decrypted_payload_forwarding(); let second = client.acquire_decrypted_payload_forwarding(); client - .handle_decrypted_plaintext("msg", payload(), 2, 0, &info) + .handle_decrypted_plaintext("msg", payload(), 2, 0, None, None, &info) .await .expect("decodes"); assert!(events.try_recv().is_ok()); @@ -13184,14 +13398,14 @@ async fn forwarding_stops_when_the_last_lease_drops() { // One lease left: still on. drop(first); client - .handle_decrypted_plaintext("msg", payload(), 2, 0, &info) + .handle_decrypted_plaintext("msg", payload(), 2, 0, None, None, &info) .await .expect("decodes"); assert!(events.try_recv().is_ok(), "one lease still holds it open"); drop(second); client - .handle_decrypted_plaintext("msg", payload(), 2, 0, &info) + .handle_decrypted_plaintext("msg", payload(), 2, 0, None, None, &info) .await .expect("decodes"); assert!( diff --git a/src/pdo.rs b/src/pdo.rs index be0798681..90907ea5c 100644 --- a/src/pdo.rs +++ b/src/pdo.rs @@ -494,7 +494,7 @@ impl Client { Ok(MessageInfo { id: id.unwrap_or_default().to_owned(), server_id: 0, - r#type: String::new(), + r#type: None, source: MessageSource { chat: remote_jid, sender, @@ -510,7 +510,7 @@ impl Client { push_name: push_name.unwrap_or_default().to_owned(), category: MessageCategory::default(), multicast: false, - media_type: String::new(), + media_type: None, edit: EditAttribute::default(), bot_info: None, meta_info: MsgMetaInfo::default(), diff --git a/src/receipt.rs b/src/receipt.rs index c9fc9a300..5aaff16db 100644 --- a/src/receipt.rs +++ b/src/receipt.rs @@ -470,7 +470,9 @@ impl NackSource for MessageInfo { } fn stanza_type(&self) -> Option { - (!self.r#type.is_empty()).then(|| NodeValue::from(&self.r#type)) + self.r#type + .as_ref() + .map(|stanza_type| NodeValue::from(stanza_type.as_str())) } } @@ -1815,7 +1817,7 @@ mod tests { #[test] fn nack_includes_type_when_present() { let mut info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false); - info.r#type = "text".to_string(); + info.r#type = Some(wacore::types::message::StanzaMessageType::Text); let node = build_nack_node(&info, &own_pn(), NackReason::ParsingError, None) .expect("valid message should produce a nack"); assert_eq!( @@ -1825,9 +1827,9 @@ mod tests { } #[test] - fn nack_omits_type_when_empty() { + fn nack_omits_type_when_absent() { let mut info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false); - info.r#type = String::new(); + info.r#type = None; let node = build_nack_node(&info, &own_pn(), NackReason::ParsingError, None) .expect("valid message should produce a nack"); assert!(node.attrs.get("type").is_none()); diff --git a/src/retry.rs b/src/retry.rs index 17dde5e0e..935d630b4 100644 --- a/src/retry.rs +++ b/src/retry.rs @@ -1513,6 +1513,8 @@ impl Client { /// know which attempt this is. The sender may use this to decide whether to resend. /// * `reason` - The retry reason code (matches WhatsApp Web's RetryReason enum). This helps /// the sender understand why the message couldn't be decrypted. + /// * `decrypt_fail_mode` - How the failing stanza asked its failures to be surfaced, + /// reported back in the receipt's `` bitmask. #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.retry.send_receipt", level = "debug", skip_all, fields(chat = %info.source.chat.observe(), sender = %info.source.sender.observe(), retry = retry_count), err(Debug)))] pub(crate) async fn send_retry_receipt( &self, @@ -1520,6 +1522,7 @@ impl Client { retry_count: u8, reason: RetryReason, force_include_keys: bool, + decrypt_fail_mode: crate::types::events::DecryptFailMode, ) -> Result { let device_snapshot = self.persistence_manager.get_device_snapshot(); @@ -1649,15 +1652,36 @@ impl Client { } } - // Build the final child list after the policy has decided whether this - // request carries key material. - let receipt_node = if let Some(keys) = keys_node { - builder - .children([retry_node, registration_node, keys]) - .build() + // Only the bit this client can observe: the stanza carried an + // ``, so its failure was never shown. The node + // is built only when the bitmask is non-zero, and only while the prop + // that introduced it is on -- with a cold props cache the prop reads + // false and the receipt goes out in its pre-bitmask shape, which is + // what a server that never enabled the flag expects anyway. + let mode = if decrypt_fail_mode == crate::types::events::DecryptFailMode::Hide + && self + .ab_props() + .is_enabled(wacore::iq::abprops::web::RECEIPT_MODE_BITMASK_ENABLED) + .await + { + wacore::protocol::retry::RECEIPT_MODE_HID_FAILED_DECRYPT } else { - builder.children([retry_node, registration_node]).build() + 0 }; + let meta_node = wacore::protocol::retry::build_receipt_meta_node(mode); + + // Build the final child list after the policy has decided whether this + // request carries key material. + let mut children = Vec::with_capacity(4); + children.push(retry_node); + children.push(registration_node); + if let Some(keys) = keys_node { + children.push(keys); + } + if let Some(meta) = meta_node { + children.push(meta); + } + let receipt_node = builder.children(children).build(); drop(device_snapshot); self.send_node(receipt_node).await?; diff --git a/wacore/src/iq/groups.rs b/wacore/src/iq/groups.rs index 586eef313..43b67ef9c 100644 --- a/wacore/src/iq/groups.rs +++ b/wacore/src/iq/groups.rs @@ -3518,11 +3518,293 @@ impl IqSpec for GetGroupProfilePicturesIq { } } +// --------------------------------------------------------------------------- +// Report-to-admin IQ Specs +// --------------------------------------------------------------------------- + +/// One account that reported a message to a group's admins. +/// +/// The identity attributes are a mixin the server may attach to the same node, +/// so a `` addressed by LID can also carry the reporter's phone +/// number and username. Both are absent as often as not, and neither is +/// verified here beyond parsing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupMessageReporter { + /// The reporting account, in whatever addressing the response declares. + pub jid: Jid, + /// When the report was filed, in seconds since the Unix epoch. + pub timestamp: u64, + /// The reporter's phone-number JID, when the identity mixin carries one. + pub phone_number: Option, + /// The reporter's username, when the identity mixin carries one. + pub username: Option, +} + +/// One reported message and everyone who reported it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReportedGroupMessage { + /// The reported message's stanza id. Nothing guarantees this device holds + /// the message it names. + pub message_id: String, + pub reporters: Vec, +} + +/// The outstanding reports an admin can see for a group. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReportedGroupMessages { + /// The addressing the server used for the JIDs in this response, when it + /// declared one. + pub addressing_mode: Option, + pub reports: Vec, +} + +/// Report messages to a group's admins. +/// +/// Distinct from the `spam` IQ in [`crate::iq::spam_report`], which reports to +/// WhatsApp: this one stays inside the group and is what the group's own +/// `allow_admin_reports` property gates (see [`SetAllowAdminReportsIq`]). +/// +/// ```xml +/// +/// +/// +/// ``` +/// +/// The server answers an empty result, so nothing here reports which of the +/// listed ids it accepted. A refusal arrives as an IQ error instead: 403 +/// (`forbidden`) for a group that does not allow admin reports or a sender who +/// may not report in it, 429 (`rate-overlimit`) for too many reports in +/// sequence, 404 (`item-not-found`) for an unknown group or message, 423 +/// (`locked`) for a locked group, 400 (`bad-request`) for a malformed list. +#[derive(Debug, Clone)] +pub struct ReportGroupMessagesIq { + pub group_jid: Jid, + pub message_ids: Vec, +} + +impl ReportGroupMessagesIq { + pub fn new(group_jid: &Jid, message_ids: &[String]) -> Self { + Self { + group_jid: group_jid.clone(), + message_ids: message_ids.to_vec(), + } + } +} + +impl IqSpec for ReportGroupMessagesIq { + type Response = (); + + fn build_iq(&self) -> InfoQuery<'static> { + let reports = NodeBuilder::new("reports") + .children(self.message_ids.iter().map(|id| { + NodeBuilder::new("report") + .attr("message_id", id.as_str()) + .build() + })) + .build(); + + InfoQuery::set_ref( + GROUP_IQ_NAMESPACE, + &self.group_jid, + Some(NodeContent::Nodes(vec![reports])), + ) + } + + fn parse_response(&self, _response: &NodeRef<'_>) -> Result { + Ok(()) + } +} + +/// Fetch the messages already reported to a group's admins. +/// +/// ```xml +/// +/// ``` +#[derive(Debug, Clone)] +pub struct GetReportedGroupMessagesIq { + pub group_jid: Jid, +} + +impl GetReportedGroupMessagesIq { + pub fn new(group_jid: &Jid) -> Self { + Self { + group_jid: group_jid.clone(), + } + } +} + +impl IqSpec for GetReportedGroupMessagesIq { + type Response = ReportedGroupMessages; + + fn build_iq(&self) -> InfoQuery<'static> { + InfoQuery::get_ref( + GROUP_IQ_NAMESPACE, + &self.group_jid, + Some(NodeContent::Nodes(vec![ + NodeBuilder::new("reports").build(), + ])), + ) + } + + fn parse_response(&self, response: &NodeRef<'_>) -> Result { + let addressing_mode = response + .attrs() + .optional_string("addressing_mode") + .and_then(|s| AddressingMode::try_from(s.as_ref()).ok()); + + let reports_node = required_child(response, "reports")?; + let mut reports = Vec::new(); + for report in reports_node.get_children_by_tag("report") { + let message_id = required_attr(report, "message_id")?; + let mut reporters = Vec::new(); + for reporter in report.get_children_by_tag("reporter") { + let mut attrs = reporter.attrs(); + let jid = attrs + .optional_jid("jid") + .ok_or_else(|| anyhow!("reporter of {message_id} has no jid"))?; + let timestamp = attrs + .optional_u64("timestamp") + .ok_or_else(|| anyhow!("reporter of {message_id} has no timestamp"))?; + reporters.push(GroupMessageReporter { + jid, + timestamp, + phone_number: attrs.optional_jid("phone_number"), + username: attrs.optional_string("username").map(|s| s.into_owned()), + }); + } + reports.push(ReportedGroupMessage { + message_id, + reporters, + }); + } + + Ok(ReportedGroupMessages { + addressing_mode, + reports, + }) + } +} + #[cfg(test)] mod tests { use super::*; use crate::request::InfoQueryType; + #[test] + fn report_messages_iq_matches_the_group_report_shape() { + let jid: Jid = "120363000000000001@g.us".parse().unwrap(); + let iq = ReportGroupMessagesIq::new(&jid, &["MSG-AAA".to_string(), "MSG-BBB".to_string()]) + .build_iq(); + + assert_eq!(iq.namespace, GROUP_IQ_NAMESPACE); + assert_eq!(iq.query_type, InfoQueryType::Set); + assert_eq!(iq.to, jid, "a group report is addressed to the group"); + + let Some(NodeContent::Nodes(nodes)) = &iq.content else { + panic!("expected NodeContent::Nodes"); + }; + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "reports"); + let Some(NodeContent::Nodes(reports)) = &nodes[0].content else { + panic!("expected children"); + }; + let ids: Vec<_> = reports + .iter() + .map(|report| { + assert_eq!(report.tag, "report"); + report.attrs.get("message_id").expect("message_id").as_str() + }) + .collect(); + assert_eq!(ids, ["MSG-AAA", "MSG-BBB"]); + } + + #[test] + fn get_reported_messages_iq_is_an_empty_reports_get() { + let jid: Jid = "120363000000000001@g.us".parse().unwrap(); + let iq = GetReportedGroupMessagesIq::new(&jid).build_iq(); + + assert_eq!(iq.namespace, GROUP_IQ_NAMESPACE); + assert_eq!(iq.query_type, InfoQueryType::Get); + assert_eq!(iq.to, jid); + let Some(NodeContent::Nodes(nodes)) = &iq.content else { + panic!("expected NodeContent::Nodes"); + }; + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "reports"); + assert!(nodes[0].content.is_none(), "the get carries no children"); + } + + #[test] + fn reported_messages_response_parses_repeats_and_the_identity_mixin() { + let jid: Jid = "120363000000000001@g.us".parse().unwrap(); + let spec = GetReportedGroupMessagesIq::new(&jid); + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .attr("addressing_mode", "lid") + .children([NodeBuilder::new("reports") + .children([ + NodeBuilder::new("report") + .attr("message_id", "MSG-AAA") + .children([ + NodeBuilder::new("reporter") + .attr("jid", "100000000000001@lid") + .attr("timestamp", "1777415965") + .attr("phone_number", "559980000001@s.whatsapp.net") + .attr("username", "reporter.one") + .build(), + NodeBuilder::new("reporter") + .attr("jid", "100000000000002@lid") + .attr("timestamp", "1777415999") + .build(), + ]) + .build(), + NodeBuilder::new("report") + .attr("message_id", "MSG-BBB") + .children([NodeBuilder::new("reporter") + .attr("jid", "100000000000003@lid") + .attr("timestamp", "1777416100") + .build()]) + .build(), + ]) + .build()]) + .build(); + + let parsed = spec + .parse_response(&response.as_node_ref()) + .expect("a well-formed reports response should parse"); + + assert_eq!(parsed.addressing_mode, Some(AddressingMode::Lid)); + assert_eq!(parsed.reports.len(), 2); + assert_eq!(parsed.reports[0].message_id, "MSG-AAA"); + assert_eq!(parsed.reports[0].reporters.len(), 2); + + let first = &parsed.reports[0].reporters[0]; + assert_eq!(first.jid.user, "100000000000001"); + assert_eq!(first.timestamp, 1_777_415_965); + assert_eq!( + first.phone_number.as_ref().map(|pn| pn.user.as_str()), + Some("559980000001"), + "the identity mixin is the LID to PN mapping this response carries" + ); + assert_eq!(first.username.as_deref(), Some("reporter.one")); + + let second = &parsed.reports[0].reporters[1]; + assert_eq!(second.phone_number, None); + assert_eq!(second.username, None); + + assert_eq!(parsed.reports[1].message_id, "MSG-BBB"); + assert_eq!(parsed.reports[1].reporters.len(), 1); + } + + #[test] + fn reported_messages_response_without_reports_is_rejected() { + let jid: Jid = "120363000000000001@g.us".parse().unwrap(); + let spec = GetReportedGroupMessagesIq::new(&jid); + let response = NodeBuilder::new("iq").attr("type", "result").build(); + assert!(spec.parse_response(&response.as_node_ref()).is_err()); + } + #[test] fn group_query_iq_with_phash_emits_attr() { let jid: Jid = "120363000000000001@g.us".parse().unwrap(); diff --git a/wacore/src/messages.rs b/wacore/src/messages.rs index 0e096d058..019475a43 100644 --- a/wacore/src/messages.rs +++ b/wacore/src/messages.rs @@ -1085,7 +1085,8 @@ pub fn parse_message_info( own_lid: Option<&wacore_binary::Jid>, ) -> Result { use crate::types::message::{ - AddressingMode, EditAttribute, MessageCategory, MessageInfo, MessageSource, + AddressingMode, EditAttribute, MessageCategory, MessageInfo, MessageSource, PollType, + StanzaMessageType, }; use wacore_binary::{JidExt as _, STATUS_BROADCAST_USER, Server}; @@ -1206,6 +1207,14 @@ pub fn parse_message_info( .map(|s| MessageCategory::from(s.as_ref())) .unwrap_or_default(); + // WA Web's parser requires this attribute and rejects the stanza without + // it. Rejecting here would drop a message this client currently delivers, + // for an attribute nothing downstream needs, so absence is recorded as + // `None` and an unrecognized value keeps its wire bytes. + let stanza_type = attrs + .optional_string("type") + .map(|s| StanzaMessageType::from(s.as_ref())); + let server_id = attrs .optional_u64("server_id") .filter(|&v| (99..=2_147_476_647).contains(&v)) @@ -1247,6 +1256,16 @@ pub fn parse_message_info( meta_info.target_id = ma.optional_string("target_id").map(|s| s.into_owned()); meta_info.target_sender = ma.optional_jid("target_sender_jid"); meta_info.target_chat = ma.optional_jid("target_chat_jid"); + meta_info.thread_message_id = ma.optional_string("thread_msg_id").map(|s| s.into_owned()); + meta_info.thread_message_sender_jid = ma.optional_jid("thread_msg_sender_jid"); + // WA Web scopes `polltype` to poll envelopes, so a value on any other + // type is not the poll stage and is not recorded as one. Unknown + // values parse to None (the attribute is enum-or-null upstream). + if stanza_type == Some(StanzaMessageType::Poll) { + meta_info.poll_type = ma + .optional_string("polltype") + .and_then(|s| PollType::try_from(s.as_ref()).ok()); + } } if let Some(reporting) = node.get_optional_child("reporting") && let Some(tag) = reporting.get_optional_child("reporting_tag") @@ -1290,6 +1309,7 @@ pub fn parse_message_info( source, id, server_id, + r#type: stanza_type, push_name: attrs .optional_string("notify") .map(|s| s.to_string()) @@ -1997,6 +2017,140 @@ mod parse_message_info_tests { "group fanout participants are not a bcl" ); } + + fn envelope(stanza_type: Option<&str>) -> wacore_binary::Node { + let mut builder = NodeBuilder::new("message") + .attr("from", "559980000001@s.whatsapp.net") + .attr("id", "MSG-TYPE-1") + .attr("t", "1777415965"); + if let Some(stanza_type) = stanza_type { + builder = builder.attr("type", stanza_type); + } + builder.build() + } + + fn parse(node: &wacore_binary::Node) -> crate::types::message::MessageInfo { + let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap(); + parse_message_info(&node.as_node_ref(), &own_pn, None).expect("envelope should parse") + } + + /// Every variant of the envelope type has to survive a wire round trip. + /// Written as an exhaustive match so a variant added without a `#[wire]` + /// mapping fails to compile rather than silently parsing as `Unknown`. + #[test] + fn every_envelope_type_round_trips_through_the_wire() { + use crate::types::message::StanzaMessageType as T; + let all = [ + T::Text, + T::Media, + T::MediaNotify, + T::Pay, + T::Poll, + T::Reaction, + T::Event, + T::Unknown("sticker_pack_share".to_owned()), + ]; + for variant in &all { + // Exhaustive on purpose: a new variant lands here first. + let expected_wire = match variant { + T::Text => "text", + T::Media => "media", + T::MediaNotify => "medianotify", + T::Pay => "pay", + T::Poll => "poll", + T::Reaction => "reaction", + T::Event => "event", + T::Unknown(raw) => raw.as_str(), + }; + assert_eq!(variant.as_str(), expected_wire); + assert_eq!( + parse(&envelope(Some(expected_wire))).r#type.as_ref(), + Some(variant), + "envelope type {expected_wire} did not round trip" + ); + } + } + + /// The official parser rejects both of these; this one keeps the stanza and + /// distinguishes them, so neither collapses into the other or into `text`. + #[test] + fn absent_and_unknown_envelope_types_stay_distinguishable() { + use crate::types::message::StanzaMessageType as T; + assert_eq!(parse(&envelope(None)).r#type, None); + assert_eq!( + parse(&envelope(Some("newsletter_admin_invite"))).r#type, + Some(T::Unknown("newsletter_admin_invite".to_owned())) + ); + } + + #[test] + fn polltype_is_read_only_on_a_poll_envelope() { + use crate::types::message::{PollType, StanzaMessageType as T}; + let with_meta = |stanza_type: &str| { + let node = NodeBuilder::new("message") + .attr("from", "559980000001@s.whatsapp.net") + .attr("id", "MSG-POLL-1") + .attr("t", "1777415965") + .attr("type", stanza_type) + .children([NodeBuilder::new("meta").attr("polltype", "vote").build()]) + .build(); + parse(&node) + }; + + let poll = with_meta("poll"); + assert_eq!(poll.r#type, Some(T::Poll)); + assert_eq!(poll.meta_info.poll_type, Some(PollType::Vote)); + + let text = with_meta("text"); + assert_eq!(text.r#type, Some(T::Text)); + assert_eq!( + text.meta_info.poll_type, None, + "polltype belongs to poll envelopes only" + ); + } + + /// `attrEnumOrNullIfUnknown` upstream: a poll stage this build does not + /// model is dropped, not preserved as raw text. + #[test] + fn unknown_polltype_parses_as_absent() { + let node = NodeBuilder::new("message") + .attr("from", "559980000001@s.whatsapp.net") + .attr("id", "MSG-POLL-2") + .attr("t", "1777415965") + .attr("type", "poll") + .children([NodeBuilder::new("meta") + .attr("polltype", "retraction") + .build()]) + .build(); + assert_eq!(parse(&node).meta_info.poll_type, None); + } + + #[test] + fn meta_thread_attributes_reach_message_info() { + let node = NodeBuilder::new("message") + .attr("from", "120363000000000001@g.us") + .attr("participant", "559980000001@s.whatsapp.net") + .attr("id", "MSG-THREAD-1") + .attr("t", "1777415965") + .attr("type", "text") + .children([NodeBuilder::new("meta") + .attr("thread_msg_id", "PARENT-1") + .attr("thread_msg_sender_jid", "559980000002@s.whatsapp.net") + .build()]) + .build(); + let info = parse(&node); + assert_eq!( + info.meta_info.thread_message_id.as_deref(), + Some("PARENT-1") + ); + assert_eq!( + info.meta_info + .thread_message_sender_jid + .as_ref() + .map(|jid| jid.user.as_str()), + Some("559980000002") + ); + } } #[cfg(test)] diff --git a/wacore/src/protocol/retry.rs b/wacore/src/protocol/retry.rs index e95132081..eda4ccf89 100644 --- a/wacore/src/protocol/retry.rs +++ b/wacore/src/protocol/retry.rs @@ -161,6 +161,30 @@ pub fn should_drop_unknown_device_retry(keys_present: bool, device_known: bool) !keys_present && !device_known } +/// The `HID_FAILED_DECRYPT` bit of a receipt's `` bitmask. +/// +/// WA Web's receipt mode is a set of bit *positions*, not values; this is +/// position 2 already shifted. It says the failure that prompted the receipt +/// came from an ``, so the sender knows the receiver +/// showed the user nothing for it. +/// +/// The other two positions WA Web defines (`ORPHAN`, `NO_CHECKMARK_UX`) name +/// states this client does not model, so it never sets them. +pub const RECEIPT_MODE_HID_FAILED_DECRYPT: u32 = 1 << 2; + +/// The `` child of a receipt, or `None` when no bit is set. +/// +/// An all-zero bitmask carries nothing the server does not already assume, and +/// WA Web omits the node rather than sending a zero, so the empty case is +/// `None` instead of ``. +pub fn build_receipt_meta_node(mode: u32) -> Option { + (mode != 0).then(|| { + NodeBuilder::new("meta") + .attr("mode", mode.to_string()) + .build() + }) +} + /// Builds the `` bundle embedded in a retry receipt (type, identity, one-time prekey, /// signed prekey, device identity) so a peer can re-establish the Signal session. /// diff --git a/wacore/src/types/events.rs b/wacore/src/types/events.rs index f5f2c55a9..7f481f306 100755 --- a/wacore/src/types/events.rs +++ b/wacore/src/types/events.rs @@ -1750,6 +1750,19 @@ pub struct DecryptedPayload { pub enc_index: usize, /// The `type` attribute the `` carried: `msg`, `pkmsg`, `skmsg`, … pub enc_type: &'static str, + /// The `state` attribute the `` carried, verbatim, or `None` when it + /// carried none. + /// + /// The server's own annotation of the session this copy was encrypted + /// under. This build does not model the values and does not act on them; + /// they are handed over as text so a consumer can. + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + /// The `session_type` attribute the `` carried, verbatim, or `None` + /// when it carried none. Unmodelled and unacted-on, like + /// [`state`](Self::state). + #[serde(skip_serializing_if = "Option::is_none")] + pub session_type: Option, /// The plaintext, unpadded, exactly as decoding will receive it. /// /// A `Bytes`, so forwarding it costs a refcount bump rather than a copy. diff --git a/wacore/src/types/message.rs b/wacore/src/types/message.rs index cc4c6437e..654a944e4 100644 --- a/wacore/src/types/message.rs +++ b/wacore/src/types/message.rs @@ -47,6 +47,170 @@ pub enum PushPriority { HighForce, } +/// The `type` attribute of an incoming `` envelope. +/// +/// The server declares which class of payload the stanza carries before any +/// `` is decrypted. WhatsApp Web treats the attribute as required and +/// fails the parse when it is absent or carries a value outside its list; this +/// client keeps the stanza instead, so absence surfaces as `None` on +/// [`MessageInfo::type`](MessageInfo) and an unrecognized value as +/// [`Unknown`](Self::Unknown) holding the exact wire bytes. +/// +/// Says nothing about the decrypted content: it is the envelope's own claim, +/// which nothing verifies against the `Message` that comes out of the +/// ciphertext. +#[derive(Debug, Clone, PartialEq, Eq, WireEnum)] +pub enum StanzaMessageType { + #[wire = "text"] + Text, + #[wire = "media"] + Media, + #[wire = "medianotify"] + MediaNotify, + #[wire = "pay"] + Pay, + #[wire = "poll"] + Poll, + #[wire = "reaction"] + Reaction, + #[wire = "event"] + Event, + /// A value this build does not model, kept verbatim. + #[wire_fallback] + Unknown(String), +} + +/// The `polltype` attribute of an incoming `` node. +/// +/// Read only when the envelope declares [`StanzaMessageType::Poll`], mirroring +/// the official parser, which scopes the attribute to poll envelopes. Closed +/// on purpose: the attribute is `attrEnumOrNullIfUnknown` upstream, so a value +/// outside this list parses as `None` rather than being preserved. +#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] +pub enum PollType { + #[wire = "creation"] + Creation, + #[wire = "quiz_creation"] + QuizCreation, + #[wire = "vote"] + Vote, + #[wire = "result_snapshot"] + ResultSnapshot, + #[wire = "edit"] + Edit, +} + +/// The `mediatype` attribute of an `` node. +/// +/// A hint about the payload the ciphertext carries, available before the +/// decryption that would reveal it. It is the sender's claim and nothing +/// checks it against the decrypted `Message`, so it is useful for routing and +/// telemetry and not for deciding what a message is. +#[derive(Debug, Clone, PartialEq, Eq, WireEnum)] +pub enum EncMediaType { + #[wire = "image"] + Image, + #[wire = "video"] + Video, + #[wire = "ptv"] + Ptv, + #[wire = "audio"] + Audio, + #[wire = "ptt"] + Ptt, + #[wire = "location"] + Location, + #[wire = "vcard"] + Vcard, + #[wire = "document"] + Document, + #[wire = "url"] + Url, + #[wire = "call"] + Call, + #[wire = "gif"] + Gif, + #[wire = "future"] + Future, + #[wire = "contact_array"] + ContactArray, + #[wire = "livelocation"] + LiveLocation, + #[wire = "profile_pic"] + ProfilePic, + #[wire = "sticker"] + Sticker, + #[wire = "sticker_pack"] + StickerPack, + #[wire = "hsm"] + Hsm, + #[wire = "product_image"] + ProductImage, + #[wire = "template"] + Template, + #[wire = "md_app_state"] + MdAppState, + #[wire = "md_history_sync"] + MdHistorySync, + #[wire = "list"] + List, + #[wire = "list_response"] + ListResponse, + #[wire = "button"] + Button, + #[wire = "button_response"] + ButtonResponse, + #[wire = "order"] + Order, + #[wire = "product"] + Product, + #[wire = "native_flow_response"] + NativeFlowResponse, + #[wire = "group_history"] + GroupHistory, + /// A value this build does not model, kept verbatim. + #[wire_fallback] + Unknown(String), +} + +/// Whether an envelope's declared type agrees with the server's request to +/// hide decryption failures for it. +/// +/// WhatsApp Web crosses `decrypt-fail="hide"` on any `` with the +/// envelope's `type` and refuses to nack a stanza whose combination it calls +/// incoherent. The two legs are different lists: with hiding requested only a +/// reaction or a poll vote qualifies, without it the four content types do. +/// `pay` and `event` fall outside both. +/// +/// This answers the question and nothing more. It drives no decision in this +/// client: what gets acknowledged, retried or nacked is unchanged by it, and a +/// caller that wants the official gate has to apply it itself. +/// +/// An absent or [`Unknown`](StanzaMessageType::Unknown) type is never coherent, +/// because neither leg's list can contain it. +pub fn envelope_is_coherent( + stanza_type: Option<&StanzaMessageType>, + poll_type: Option, + decrypt_fail_mode: crate::types::events::DecryptFailMode, +) -> bool { + let Some(stanza_type) = stanza_type else { + return false; + }; + match decrypt_fail_mode { + crate::types::events::DecryptFailMode::Hide => matches!( + (stanza_type, poll_type), + (StanzaMessageType::Reaction, _) | (StanzaMessageType::Poll, Some(PollType::Vote)) + ), + crate::types::events::DecryptFailMode::Show => matches!( + stanza_type, + StanzaMessageType::Text + | StanzaMessageType::Media + | StanzaMessageType::MediaNotify + | StanzaMessageType::Poll + ), + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] pub enum PrivacySensitiveType { #[wire = "1"] @@ -279,12 +443,23 @@ pub struct MsgMetaInfo { /// lookup; see WA Web `decryptMsmsgBotMessage`). #[serde(skip_serializing_if = "Option::is_none")] pub target_chat: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub deprecated_lid_session: Option, + /// `` — the message this one threads under, for a + /// stanza the server routes into an existing thread. #[serde(skip_serializing_if = "Option::is_none")] pub thread_message_id: Option, + /// `` — who authored + /// [`thread_message_id`](Self::thread_message_id). Absent whenever that is. #[serde(skip_serializing_if = "Option::is_none")] pub thread_message_sender_jid: Option, + /// `` — which stage of a poll's lifecycle the envelope + /// carries. + /// + /// Read only when the envelope declares [`StanzaMessageType::Poll`], so a + /// `` on any other type is ignored rather than recorded. An + /// unrecognized value is `None`, indistinguishable from the attribute being + /// absent. + #[serde(skip_serializing_if = "Option::is_none")] + pub poll_type: Option, /// `` attr. Server marks reactions/edits as /// `"add_on"`; mirrors `WAWebHandleMsgParser` b()'s metadata read. #[serde(skip_serializing_if = "Option::is_none")] @@ -310,13 +485,28 @@ pub struct MessageInfo { pub source: MessageSource, pub id: MessageId, pub server_id: MessageServerId, - pub r#type: String, + /// The envelope's `type` attribute. `None` when the stanza carried none. + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, pub push_name: String, #[serde(serialize_with = "chrono::serde::ts_seconds::serialize")] pub timestamp: DateTime, pub category: MessageCategory, pub multicast: bool, - pub media_type: String, + /// The `mediatype` the stanza's `` nodes declared, aggregated to one + /// value per message. + /// + /// A fan-out stanza carries one `` per device and the attribute is a + /// property of the message, not of a device copy, so the first `` that + /// carries one wins in the order the client enumerates them: the direct + /// `` children first, then this device's under ``. + /// Divergent values across a fan-out are not reconciled and the later ones + /// are dropped; a consumer that needs per-node values reads them from + /// [`DecryptedPayload`](crate::types::events::DecryptedPayload). + /// + /// `None` when no `` carried the attribute. + #[serde(skip_serializing_if = "Option::is_none")] + pub media_type: Option, pub edit: EditAttribute, #[serde(skip_serializing_if = "Option::is_none")] pub bot_info: Option, @@ -748,4 +938,51 @@ mod tests { Some(EditAttribute::PinInChat) ); } + + /// The full cross product of envelope type against the hide flag, so a + /// change to either leg's list shows up as a diff here rather than as a + /// quiet behaviour change. `pay` and `event` are listed explicitly: they + /// are the two types that fall outside both legs. + #[test] + fn coherence_covers_both_legs_of_the_rule() { + use crate::types::events::DecryptFailMode::{Hide, Show}; + use StanzaMessageType as T; + + let cases: &[(T, Option, bool, bool)] = &[ + // (type, polltype, coherent when hidden, coherent when shown) + (T::Text, None, false, true), + (T::Media, None, false, true), + (T::MediaNotify, None, false, true), + (T::Pay, None, false, false), + (T::Poll, None, false, true), + (T::Poll, Some(PollType::Vote), true, true), + (T::Poll, Some(PollType::Creation), false, true), + (T::Reaction, None, true, false), + (T::Reaction, Some(PollType::Vote), true, false), + (T::Event, None, false, false), + (T::Unknown("archive".to_owned()), None, false, false), + ]; + + for (stanza_type, poll_type, when_hidden, when_shown) in cases { + assert_eq!( + envelope_is_coherent(Some(stanza_type), *poll_type, Hide), + *when_hidden, + "hide leg disagrees for {stanza_type:?} / {poll_type:?}" + ); + assert_eq!( + envelope_is_coherent(Some(stanza_type), *poll_type, Show), + *when_shown, + "show leg disagrees for {stanza_type:?} / {poll_type:?}" + ); + } + } + + /// Neither leg's list can hold a type that was never on the wire. + #[test] + fn an_absent_envelope_type_is_never_coherent() { + use crate::types::events::DecryptFailMode::{Hide, Show}; + assert!(!envelope_is_coherent(None, None, Hide)); + assert!(!envelope_is_coherent(None, Some(PollType::Vote), Hide)); + assert!(!envelope_is_coherent(None, None, Show)); + } } From b8861644b37f950d590b85408c0b642233436414 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:19:55 +0000 Subject: [PATCH 2/6] refactor(recv): bundle the passthrough attributes into one argument Two Option<&str> parameters pushed handle_decrypted_plaintext past the argument limit clippy enforces, and they always travel together. --- src/message.rs | 18 ++++++++++++++++ src/message/receive.rs | 39 +++++++++++++++++----------------- src/message/tests.rs | 48 +++++++++++++++++++++++++++++------------- 3 files changed, 70 insertions(+), 35 deletions(-) diff --git a/src/message.rs b/src/message.rs index 3d2122098..e5c6158fa 100644 --- a/src/message.rs +++ b/src/message.rs @@ -134,6 +134,24 @@ impl EncPayload { } } +/// The `` attributes this build carries to the consumer without acting on +/// them. Borrowed from the payload they came from, so passing them costs +/// nothing on a node that declared neither. +#[derive(Clone, Copy, Default)] +pub(crate) struct EncNodeAnnotations<'a> { + pub state: Option<&'a str>, + pub session_type: Option<&'a str>, +} + +impl EncPayload { + pub(crate) fn annotations(&self) -> EncNodeAnnotations<'_> { + EncNodeAnnotations { + state: self.state.as_deref(), + session_type: self.session_type.as_deref(), + } + } +} + /// Parsed and classified message ready for decryption. All data is owned -- /// the original node tree is no longer borrowed. pub(crate) struct ClassifiedMessage { diff --git a/src/message/receive.rs b/src/message/receive.rs index 3192b88f7..3656fd4a9 100644 --- a/src/message/receive.rs +++ b/src/message/receive.rs @@ -746,6 +746,10 @@ impl Client { state, session_type, } = payload; + let annotations = EncNodeAnnotations { + state: state.as_deref(), + session_type: session_type.as_deref(), + }; let enc_type_str = enc_type.as_wire_str(); #[cfg(feature = "tracing")] let ciphertext_len = ciphertext.len(); @@ -1033,8 +1037,7 @@ impl Client { enc_type, padding_version, enc_index, - state.as_deref(), - session_type.as_deref(), + annotations, info, &session_mutex, &mut session_guard, @@ -1152,8 +1155,7 @@ impl Client { enc_type, padding_version, enc_index, - state.as_deref(), - session_type.as_deref(), + annotations, info, &session_mutex, &mut session_guard, @@ -1208,8 +1210,7 @@ impl Client { enc_type, padding_version, enc_index, - state.as_deref(), - session_type.as_deref(), + annotations, info, &session_mutex, &mut session_guard, @@ -1282,8 +1283,7 @@ impl Client { enc_type, padding_version, enc_index, - state.as_deref(), - session_type.as_deref(), + annotations, info, &session_mutex, &mut session_guard, @@ -1408,8 +1408,10 @@ impl Client { plaintext, padding_version, enc_index, - state.as_deref(), - session_type.as_deref(), + EncNodeAnnotations { + state: state.as_deref(), + session_type: session_type.as_deref(), + }, info, ) .await @@ -1514,8 +1516,7 @@ impl Client { padded_plaintext, padding_version, enc_index, - payload.state.as_deref(), - payload.session_type.as_deref(), + payload.annotations(), info, ) .await @@ -1706,8 +1707,7 @@ impl Client { padded_plaintext: Vec, padding_version: u8, enc_index: usize, - enc_state: Option<&str>, - enc_session_type: Option<&str>, + annotations: EncNodeAnnotations<'_>, info: &Arc, ) -> Result { let source = wacore::messages::unpad_plaintext(padded_plaintext, padding_version)?; @@ -1721,8 +1721,8 @@ impl Client { .info(Arc::clone(info)) .enc_index(enc_index) .enc_type(enc_type) - .maybe_state(enc_state.map(str::to_owned)) - .maybe_session_type(enc_session_type.map(str::to_owned)) + .maybe_state(annotations.state.map(str::to_owned)) + .maybe_session_type(annotations.session_type.map(str::to_owned)) .payload(source.clone()) .build(), )); @@ -1919,8 +1919,7 @@ impl Client { enc_type: &'static str, padding_version: u8, enc_index: usize, - enc_state: Option<&str>, - enc_session_type: Option<&str>, + annotations: EncNodeAnnotations<'_>, info: &Arc, session_mutex: &Arc>, session_guard: &mut Option>, @@ -1981,8 +1980,8 @@ impl Client { plaintext: decrypted.plaintext, padding_version, enc_index, - state: enc_state.map(str::to_owned), - session_type: enc_session_type.map(str::to_owned), + state: annotations.state.map(str::to_owned), + session_type: annotations.session_type.map(str::to_owned), }); MigrationDecryptResult::Decrypted } diff --git a/src/message/tests.rs b/src/message/tests.rs index de47bb1d9..b53f7a25f 100644 --- a/src/message/tests.rs +++ b/src/message/tests.rs @@ -9328,7 +9328,14 @@ async fn app_state_sync_key_share_honored_only_from_self() { create_test_message_info("5510000@s.whatsapp.net", "AKS1", "5510000@s.whatsapp.net"); info.source.is_from_me = false; client - .handle_decrypted_plaintext("msg", padded.clone(), 2, 0, None, None, &Arc::new(info)) + .handle_decrypted_plaintext( + "msg", + padded.clone(), + 2, + 0, + Default::default(), + &Arc::new(info), + ) .await .unwrap(); assert!( @@ -9352,7 +9359,7 @@ async fn app_state_sync_key_share_honored_only_from_self() { ); info.source.is_from_me = true; client - .handle_decrypted_plaintext("msg", padded, 2, 0, None, None, &Arc::new(info)) + .handle_decrypted_plaintext("msg", padded, 2, 0, Default::default(), &Arc::new(info)) .await .unwrap(); assert!( @@ -9508,8 +9515,7 @@ async fn app_state_key_share_waits_outside_the_offline_message_lane() { MessageUtils::encode_and_pad(&request), 2, 0, - None, - None, + Default::default(), &info, ), ) @@ -9534,8 +9540,7 @@ async fn app_state_key_share_waits_outside_the_offline_message_lane() { MessageUtils::encode_and_pad(&request), 2, 0, - None, - None, + Default::default(), &info, ), ) @@ -9843,7 +9848,14 @@ async fn lid_migration_mapping_sync_honored_only_from_self() { create_test_message_info("5510000@s.whatsapp.net", "LMS1", "5510000@s.whatsapp.net"); info.source.is_from_me = false; client - .handle_decrypted_plaintext("msg", padded.clone(), 2, 0, None, None, &Arc::new(info)) + .handle_decrypted_plaintext( + "msg", + padded.clone(), + 2, + 0, + Default::default(), + &Arc::new(info), + ) .await .unwrap(); assert!( @@ -9859,7 +9871,7 @@ async fn lid_migration_mapping_sync_honored_only_from_self() { ); info.source.is_from_me = true; client - .handle_decrypted_plaintext("msg", padded, 2, 0, None, None, &Arc::new(info)) + .handle_decrypted_plaintext("msg", padded, 2, 0, Default::default(), &Arc::new(info)) .await .unwrap(); assert_eq!( @@ -13278,7 +13290,7 @@ async fn decrypted_payloads_are_not_forwarded_without_a_lease() { ..Default::default() }); client - .handle_decrypted_plaintext("msg", padded, 2, 0, None, None, &info) + .handle_decrypted_plaintext("msg", padded, 2, 0, Default::default(), &info) .await .expect("decodes"); @@ -13315,8 +13327,7 @@ async fn a_lease_forwards_the_payload_before_it_is_decoded() { MessageUtils::encode_and_pad(&message), 2, 3, - None, - None, + Default::default(), &info, ) .await @@ -13355,7 +13366,14 @@ async fn a_payload_that_fails_to_decode_is_still_forwarded() { "5510000@s.whatsapp.net", )); let outcome = client - .handle_decrypted_plaintext("msg", undecodable_payload(), 2, 0, None, None, &info) + .handle_decrypted_plaintext( + "msg", + undecodable_payload(), + 2, + 0, + Default::default(), + &info, + ) .await; assert!(outcome.is_err(), "the fixture must actually fail to decode"); @@ -13390,7 +13408,7 @@ async fn forwarding_stops_when_the_last_lease_drops() { let first = client.acquire_decrypted_payload_forwarding(); let second = client.acquire_decrypted_payload_forwarding(); client - .handle_decrypted_plaintext("msg", payload(), 2, 0, None, None, &info) + .handle_decrypted_plaintext("msg", payload(), 2, 0, Default::default(), &info) .await .expect("decodes"); assert!(events.try_recv().is_ok()); @@ -13398,14 +13416,14 @@ async fn forwarding_stops_when_the_last_lease_drops() { // One lease left: still on. drop(first); client - .handle_decrypted_plaintext("msg", payload(), 2, 0, None, None, &info) + .handle_decrypted_plaintext("msg", payload(), 2, 0, Default::default(), &info) .await .expect("decodes"); assert!(events.try_recv().is_ok(), "one lease still holds it open"); drop(second); client - .handle_decrypted_plaintext("msg", payload(), 2, 0, None, None, &info) + .handle_decrypted_plaintext("msg", payload(), 2, 0, Default::default(), &info) .await .expect("decodes"); assert!( From af1229a30ccf6fb4cafdaefce180072eca5edef8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:22:11 +0000 Subject: [PATCH 3/6] docs(types): drop em-dashes from the new meta field comments --- wacore/src/types/message.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wacore/src/types/message.rs b/wacore/src/types/message.rs index 654a944e4..e245a2cbf 100644 --- a/wacore/src/types/message.rs +++ b/wacore/src/types/message.rs @@ -443,15 +443,15 @@ pub struct MsgMetaInfo { /// lookup; see WA Web `decryptMsmsgBotMessage`). #[serde(skip_serializing_if = "Option::is_none")] pub target_chat: Option, - /// `` — the message this one threads under, for a + /// ``: the message this one threads under, for a /// stanza the server routes into an existing thread. #[serde(skip_serializing_if = "Option::is_none")] pub thread_message_id: Option, - /// `` — who authored + /// ``: who authored /// [`thread_message_id`](Self::thread_message_id). Absent whenever that is. #[serde(skip_serializing_if = "Option::is_none")] pub thread_message_sender_jid: Option, - /// `` — which stage of a poll's lifecycle the envelope + /// ``: which stage of a poll's lifecycle the envelope /// carries. /// /// Read only when the envelope declares [`StanzaMessageType::Poll`], so a From 8d6e1e28fe83d44403eba6c1d27e680848692e06 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:27:09 +0000 Subject: [PATCH 4/6] fix(props): watch receipt_mode_bitmask_enabled so the gate can open apply_props keeps only codes in the cache's interest set, seeded from WATCHED. The flag was read without being listed, so the server's value was discarded on arrival and every read fell through to the registry default of false, leaving the retry receipt's unreachable outside tests. --- wacore/src/iq/props.rs | 1 + wacore/src/store/ab_props.rs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/wacore/src/iq/props.rs b/wacore/src/iq/props.rs index 493e90265..50db874bd 100644 --- a/wacore/src/iq/props.rs +++ b/wacore/src/iq/props.rs @@ -76,6 +76,7 @@ pub const WATCHED: &[abprops::AbProp] = &[ abprops::web::TCTOKEN_NUM_BUCKETS, abprops::web::TCTOKEN_NUM_BUCKETS_SENDER, abprops::web::WA_NCT_TOKEN_SEND_ENABLED, + abprops::web::RECEIPT_MODE_BITMASK_ENABLED, stale::PRIVACY_TOKEN_ONLY_CHECK_LID, stale::PROFILE_PIC_PRIVACY_TOKEN, ]; diff --git a/wacore/src/store/ab_props.rs b/wacore/src/store/ab_props.rs index b1d75ac11..c54ac5a6b 100644 --- a/wacore/src/store/ab_props.rs +++ b/wacore/src/store/ab_props.rs @@ -224,6 +224,10 @@ mod tests { ), (web::TCTOKEN_DURATION.code, CompactString::from("604800")), (web::TCTOKEN_NUM_BUCKETS.code, CompactString::from("4")), + ( + web::RECEIPT_MODE_BITMASK_ENABLED.code, + CompactString::from("1"), + ), (99999u32, CompactString::from("unwatched")), ]; cache.apply_props(false, props.into_iter()).await; @@ -237,6 +241,10 @@ mod tests { assert!(cache.is_enabled(web::WA_NCT_TOKEN_SEND_ENABLED).await); assert_eq!(cache.get_int(web::TCTOKEN_DURATION).await, 604800); assert_eq!(cache.get_int(web::TCTOKEN_NUM_BUCKETS).await, 4); + // A flag whose registry default is false is the case that proves the + // interest set matters: without it the server's "on" is dropped and + // the read falls through to false forever. + assert!(cache.is_enabled(web::RECEIPT_MODE_BITMASK_ENABLED).await); // Unwatched code should NOT be retained assert_eq!(cache.get(flag(99999)).await, None); } From 524cdc4b16ac727c3ce562a3127a450b601dfd76 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:36:26 +0000 Subject: [PATCH 5/6] fix(recv): gate the HID bit on its own prop and finish MessageInfo before sharing it Two independent props guard the receipt bit: receipt_mode_bitmask_enabled introduces the node, and web_send_hid_failed_decrypt_in_receipts_ enabled is a separate experiment covering this one bit. An account in the first and not the second was being sent a shape the official client leaves off, so both are now required and both are watched. The media type was also being written after the decrypt loop, by which point a custom enc handler or a per-node failure event could already hold a clone of the Arc and observe the field unset. Enumerating the nodes before the parse lets the value land while MessageInfo is still owned, in the same single pass that fills the node vector. --- src/message/receive.rs | 58 ++++++++++++++++++------------------- src/message/tests.rs | 65 ++++++++++++++++++++++++++++++++++++------ src/retry.rs | 19 ++++++++---- wacore/src/iq/props.rs | 1 + 4 files changed, 100 insertions(+), 43 deletions(-) diff --git a/src/message/receive.rs b/src/message/receive.rs index 3656fd4a9..3a39cfad9 100644 --- a/src/message/receive.rs +++ b/src/message/receive.rs @@ -128,8 +128,34 @@ impl Client { node: &OwnedNodeRef, ) -> Option { let nr = node.get(); - let mut info = match self.parse_message_info(nr).await { - Ok(info) => Arc::new(info), + + // The `` nodes are enumerated before the parse so the media type + // they declare lands on `MessageInfo` while it is still owned. Every + // holder of the Arc -- a custom enc handler, a per-node failure event, + // the classified message -- then sees the same finished struct, which + // finishing the field after the decrypt loop could not promise. + let own_jid = nr + .get_optional_child("participants") + .and_then(|_| self.pn()); + let mut all_enc_nodes: Vec<&NodeRef<'_>> = Vec::with_capacity(4); + let mut media_type: Option = None; + for enc_node in message_enc_nodes_for_device(nr, own_jid.as_ref()) { + // The declared media type belongs to the message, not to a device + // copy, so the first `` carrying one settles it for the whole + // stanza and a divergent later value is dropped. + if media_type.is_none() + && let Some(value) = enc_node.attrs().optional_string("mediatype") + { + media_type = Some(crate::types::message::EncMediaType::from(value.as_ref())); + } + all_enc_nodes.push(enc_node); + } + + let info = match self.parse_message_info(nr).await { + Ok(mut info) => { + info.media_type = media_type; + Arc::new(info) + } Err(e) => { let id = nr.get_attr("id").map(|v| v.as_str()); let from = nr.get_attr("from").map(|v| v.as_str()); @@ -155,12 +181,6 @@ impl Client { let unavailable_node = nr.get_optional_child("unavailable"); - let own_jid = nr - .get_optional_child("participants") - .and_then(|_| self.pn()); - let mut all_enc_nodes: Vec<&NodeRef<'_>> = Vec::with_capacity(4); - all_enc_nodes.extend(message_enc_nodes_for_device(nr, own_jid.as_ref())); - if all_enc_nodes.is_empty() && unavailable_node.is_none() { log::warn!( "[msg:{}] Received non-newsletter message without child: {}", @@ -244,7 +264,6 @@ impl Client { let mut has_hide_fail = false; let mut had_unknown_enc = false; let mut had_custom_handler = false; - let mut media_type: Option = None; // Custom enc handlers are set once at Bot::build and immutable after, so // read the map lock-free once instead of acquiring an async RwLock guard @@ -254,18 +273,6 @@ impl Client { for (enc_index, enc_node) in all_enc_nodes.iter().enumerate() { max_sender_retry_count = max_sender_retry_count.max(sender_retry_count(enc_node)); - // The declared media type belongs to the message, not to a device - // copy, so the first `` carrying one settles it for the whole - // stanza and a divergent later value is dropped. Read here rather - // than in the parser so the fan-out nodes under - // count too, and so the stanza's children are - // walked once. - if media_type.is_none() - && let Some(value) = enc_node.attrs().optional_string("mediatype") - { - media_type = Some(crate::types::message::EncMediaType::from(value.as_ref())); - } - // Parse decrypt-fail attribute (WA Web: e.maybeAttrString("decrypt-fail") === "hide") if enc_node .get_attr("decrypt-fail") @@ -357,15 +364,6 @@ impl Client { push_enc_payload(bucket, all_enc_nodes.len(), payload); } - // The media type is only known once the `` nodes have been walked, - // and the parser that built `info` never saw them. Nothing has cloned - // this Arc on the path every message takes, so finishing the struct - // here costs a refcount check; the failure paths that did clone it pay - // one copy rather than leaving the field unset. - if let Some(media_type) = media_type { - Arc::make_mut(&mut info).media_type = Some(media_type); - } - // WA Web diagnostic: validate skmsg is not first in multi-enc messages. if !session_payloads.is_empty() && !group_payloads.is_empty() diff --git a/src/message/tests.rs b/src/message/tests.rs index b53f7a25f..d80bd76f4 100644 --- a/src/message/tests.rs +++ b/src/message/tests.rs @@ -8901,6 +8901,12 @@ async fn fan_out_media_type_takes_the_first_enc_that_declares_one() { Some(EncMediaType::Image), "the first declared mediatype wins over a divergent sibling" ); + assert_eq!( + Arc::strong_count(&classified.info), + 1, + "the media type must be set before anything can clone the Arc, so no \ + holder can observe a half-finished MessageInfo" + ); } /// A `state` attribute belongs to the one `` that carried it, so it must @@ -8961,12 +8967,23 @@ fn retry_receipt_meta_mode(frames: &[bytes::Bytes]) -> Result, &'sta Err("no retry receipt reached the wire") } -async fn enable_receipt_mode_bitmask(client: &Arc) { - let prop = wacore::iq::abprops::web::RECEIPT_MODE_BITMASK_ENABLED; - client.ab_props().watch(prop).await; +/// Both props the `` bit is gated on. `only` restricts the seed to +/// one of them, so a test can prove the other gate independently. +async fn enable_receipt_mode_props(client: &Arc, only: Option) { + let props = [ + wacore::iq::abprops::web::RECEIPT_MODE_BITMASK_ENABLED, + wacore::iq::abprops::web::WEB_SEND_HID_FAILED_DECRYPT_IN_RECEIPTS_ENABLED, + ]; + client.ab_props().watch_many(&props).await; client .ab_props() - .apply_props(false, std::iter::once((prop.code, "1".into()))) + .apply_props( + false, + props + .iter() + .filter(|prop| only.is_none_or(|code| code == prop.code)) + .map(|prop| (prop.code, "1".into())), + ) .await; } @@ -8979,7 +8996,7 @@ async fn retry_receipt_reports_the_hidden_decrypt_fail_bit() { use wacore::protocol::retry::RECEIPT_MODE_HID_FAILED_DECRYPT; let (client, transport) = capturing_client("retry_meta_hide").await; - enable_receipt_mode_bitmask(&client).await; + enable_receipt_mode_props(&client, None).await; let info = create_test_message_info( "5511999998888@s.whatsapp.net", "RETRY_META_HIDE", @@ -9010,7 +9027,7 @@ async fn retry_receipt_without_hidden_failures_carries_no_meta() { use crate::types::events::DecryptFailMode; let (client, transport) = capturing_client("retry_meta_show").await; - enable_receipt_mode_bitmask(&client).await; + enable_receipt_mode_props(&client, None).await; let info = create_test_message_info( "5511999998888@s.whatsapp.net", "RETRY_META_SHOW", @@ -9031,10 +9048,42 @@ async fn retry_receipt_without_hidden_failures_carries_no_meta() { assert_eq!(retry_receipt_meta_mode(&transport.sent()), Ok(None)); } -/// With the prop off -- which is also what a cold props cache reads -- the +/// The bit has its own experiment on top of the one that introduces the node, +/// so an account in the bitmask prop alone must not send it. +#[tokio::test] +async fn retry_receipt_omits_meta_without_the_hid_specific_prop() { + use crate::types::events::DecryptFailMode; + + let (client, transport) = capturing_client("retry_meta_hid_off").await; + enable_receipt_mode_props( + &client, + Some(wacore::iq::abprops::web::RECEIPT_MODE_BITMASK_ENABLED.code), + ) + .await; + let info = create_test_message_info( + "5511999998888@s.whatsapp.net", + "RETRY_META_HID_OFF", + "5511777776666@s.whatsapp.net", + ); + + client + .send_retry_receipt( + &info, + 1, + RetryReason::UnknownError, + false, + DecryptFailMode::Hide, + ) + .await + .expect("the retry receipt should be sent"); + + assert_eq!(retry_receipt_meta_mode(&transport.sent()), Ok(None)); +} + +/// With both props off -- which is also what a cold props cache reads -- the /// receipt keeps its pre-bitmask shape even for a hidden failure. #[tokio::test] -async fn retry_receipt_omits_meta_while_the_prop_is_off() { +async fn retry_receipt_omits_meta_while_the_props_are_off() { use crate::types::events::DecryptFailMode; let (client, transport) = capturing_client("retry_meta_gated").await; diff --git a/src/retry.rs b/src/retry.rs index 935d630b4..a8a69b585 100644 --- a/src/retry.rs +++ b/src/retry.rs @@ -1653,16 +1653,25 @@ impl Client { } // Only the bit this client can observe: the stanza carried an - // ``, so its failure was never shown. The node - // is built only when the bitmask is non-zero, and only while the prop - // that introduced it is on -- with a cold props cache the prop reads - // false and the receipt goes out in its pre-bitmask shape, which is - // what a server that never enabled the flag expects anyway. + // ``, so its failure was never shown. + // + // Two independent props, both required. `receipt_mode_bitmask_enabled` + // introduces the `` node at all; `web_send_hid_failed_decrypt_ + // in_receipts_enabled` is a separate experiment covering this one bit, + // so an account in the first and not the second must not send it. Both + // default to false, which is also what a cold props cache reads, so + // early receipts go out in the shape this client has always sent. let mode = if decrypt_fail_mode == crate::types::events::DecryptFailMode::Hide && self .ab_props() .is_enabled(wacore::iq::abprops::web::RECEIPT_MODE_BITMASK_ENABLED) .await + && self + .ab_props() + .is_enabled( + wacore::iq::abprops::web::WEB_SEND_HID_FAILED_DECRYPT_IN_RECEIPTS_ENABLED, + ) + .await { wacore::protocol::retry::RECEIPT_MODE_HID_FAILED_DECRYPT } else { diff --git a/wacore/src/iq/props.rs b/wacore/src/iq/props.rs index 50db874bd..4efd5ec28 100644 --- a/wacore/src/iq/props.rs +++ b/wacore/src/iq/props.rs @@ -77,6 +77,7 @@ pub const WATCHED: &[abprops::AbProp] = &[ abprops::web::TCTOKEN_NUM_BUCKETS_SENDER, abprops::web::WA_NCT_TOKEN_SEND_ENABLED, abprops::web::RECEIPT_MODE_BITMASK_ENABLED, + abprops::web::WEB_SEND_HID_FAILED_DECRYPT_IN_RECEIPTS_ENABLED, stale::PRIVACY_TOKEN_ONLY_CHECK_LID, stale::PROFILE_PIC_PRIVACY_TOKEN, ]; From 6dcd1c710930e49dcdd3a147da7ab47fe9b5bf1a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:58:01 +0000 Subject: [PATCH 6/6] perf(retry): let NodeBuilder format the receipt mode attribute NodeValue's integer conversion writes through itoa into a CompactString, which inlines a value this short, so to_string() was buying a heap allocation the builder does not need. Also names the one place the media type aggregation reads wider than WA Web's parser. --- wacore/src/protocol/retry.rs | 6 +----- wacore/src/types/message.rs | 6 ++++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/wacore/src/protocol/retry.rs b/wacore/src/protocol/retry.rs index eda4ccf89..a09de491a 100644 --- a/wacore/src/protocol/retry.rs +++ b/wacore/src/protocol/retry.rs @@ -178,11 +178,7 @@ pub const RECEIPT_MODE_HID_FAILED_DECRYPT: u32 = 1 << 2; /// WA Web omits the node rather than sending a zero, so the empty case is /// `None` instead of ``. pub fn build_receipt_meta_node(mode: u32) -> Option { - (mode != 0).then(|| { - NodeBuilder::new("meta") - .attr("mode", mode.to_string()) - .build() - }) + (mode != 0).then(|| NodeBuilder::new("meta").attr("mode", mode).build()) } /// Builds the `` bundle embedded in a retry receipt (type, identity, one-time prekey, diff --git a/wacore/src/types/message.rs b/wacore/src/types/message.rs index e245a2cbf..efff0a43a 100644 --- a/wacore/src/types/message.rs +++ b/wacore/src/types/message.rs @@ -504,6 +504,12 @@ pub struct MessageInfo { /// are dropped; a consumer that needs per-node values reads them from /// [`DecryptedPayload`](crate::types::events::DecryptedPayload). /// + /// Those fan-out nodes are a wider source than WA Web's parser, which maps + /// only the direct `` children. The two agree on every stanza seen so + /// far, since the attribute describes the message and every device copy + /// repeats it, so the wider read only fills the field on a stanza whose + /// direct children carry nothing. + /// /// `None` when no `` carried the attribute. #[serde(skip_serializing_if = "Option::is_none")] pub media_type: Option,