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..e5c6158fa 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()), }) } @@ -123,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 { @@ -233,6 +262,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..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(); + + // 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(info) => Arc::new(info), + 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: {}", @@ -721,7 +741,13 @@ impl Client { enc_type, padding_version, enc_index, + 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(); @@ -851,6 +877,8 @@ impl Client { plaintext: decrypted.plaintext, padding_version, enc_index, + state: state.clone(), + session_type: session_type.clone(), }); } Err(e) => { @@ -974,6 +1002,8 @@ impl Client { plaintext: decrypted.plaintext, padding_version, enc_index, + state: state.clone(), + session_type: session_type.clone(), }); } Err(retry_err) => { @@ -1005,6 +1035,7 @@ impl Client { enc_type, padding_version, enc_index, + annotations, info, &session_mutex, &mut session_guard, @@ -1122,6 +1153,7 @@ impl Client { enc_type, padding_version, enc_index, + annotations, info, &session_mutex, &mut session_guard, @@ -1176,6 +1208,7 @@ impl Client { enc_type, padding_version, enc_index, + annotations, info, &session_mutex, &mut session_guard, @@ -1248,6 +1281,7 @@ impl Client { enc_type, padding_version, enc_index, + annotations, info, &session_mutex, &mut session_guard, @@ -1362,10 +1396,22 @@ 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, + EncNodeAnnotations { + state: state.as_deref(), + session_type: session_type.as_deref(), + }, + info, + ) .await { Ok(plaintext_outcome) => { @@ -1468,6 +1514,7 @@ impl Client { padded_plaintext, padding_version, enc_index, + payload.annotations(), info, ) .await @@ -1658,6 +1705,7 @@ impl Client { padded_plaintext: Vec, padding_version: u8, enc_index: usize, + annotations: EncNodeAnnotations<'_>, info: &Arc, ) -> Result { let source = wacore::messages::unpad_plaintext(padded_plaintext, padding_version)?; @@ -1671,6 +1719,8 @@ impl Client { .info(Arc::clone(info)) .enc_index(enc_index) .enc_type(enc_type) + .maybe_state(annotations.state.map(str::to_owned)) + .maybe_session_type(annotations.session_type.map(str::to_owned)) .payload(source.clone()) .build(), )); @@ -1867,6 +1917,7 @@ impl Client { enc_type: &'static str, padding_version: u8, enc_index: usize, + annotations: EncNodeAnnotations<'_>, info: &Arc, session_mutex: &Arc>, session_guard: &mut Option>, @@ -1927,6 +1978,8 @@ impl Client { plaintext: decrypted.plaintext, padding_version, enc_index, + state: annotations.state.map(str::to_owned), + session_type: annotations.session_type.map(str::to_owned), }); MigrationDecryptResult::Decrypted } @@ -2011,6 +2064,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..d80bd76f4 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,253 @@ 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" + ); + 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 +/// 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") +} + +/// 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, + props + .iter() + .filter(|prop| only.is_none_or(|code| code == prop.code)) + .map(|prop| (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_props(&client, None).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_props(&client, None).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)); +} + +/// 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_props_are_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 +9377,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, &Arc::new(info)) + .handle_decrypted_plaintext( + "msg", + padded.clone(), + 2, + 0, + Default::default(), + &Arc::new(info), + ) .await .unwrap(); assert!( @@ -9150,7 +9408,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, Default::default(), &Arc::new(info)) .await .unwrap(); assert!( @@ -9306,6 +9564,7 @@ async fn app_state_key_share_waits_outside_the_offline_message_lane() { MessageUtils::encode_and_pad(&request), 2, 0, + Default::default(), &info, ), ) @@ -9330,6 +9589,7 @@ async fn app_state_key_share_waits_outside_the_offline_message_lane() { MessageUtils::encode_and_pad(&request), 2, 0, + Default::default(), &info, ), ) @@ -9637,7 +9897,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, &Arc::new(info)) + .handle_decrypted_plaintext( + "msg", + padded.clone(), + 2, + 0, + Default::default(), + &Arc::new(info), + ) .await .unwrap(); assert!( @@ -9653,7 +9920,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, Default::default(), &Arc::new(info)) .await .unwrap(); assert_eq!( @@ -13072,7 +13339,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, Default::default(), &info) .await .expect("decodes"); @@ -13104,7 +13371,14 @@ 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, + Default::default(), + &info, + ) .await .expect("decodes"); @@ -13141,7 +13415,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, &info) + .handle_decrypted_plaintext( + "msg", + undecodable_payload(), + 2, + 0, + Default::default(), + &info, + ) .await; assert!(outcome.is_err(), "the fixture must actually fail to decode"); @@ -13176,7 +13457,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, Default::default(), &info) .await .expect("decodes"); assert!(events.try_recv().is_ok()); @@ -13184,14 +13465,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, 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, &info) + .handle_decrypted_plaintext("msg", payload(), 2, 0, Default::default(), &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..a8a69b585 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,45 @@ 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. + // + // 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 { - 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/iq/props.rs b/wacore/src/iq/props.rs index 493e90265..4efd5ec28 100644 --- a/wacore/src/iq/props.rs +++ b/wacore/src/iq/props.rs @@ -76,6 +76,8 @@ 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, + abprops::web::WEB_SEND_HID_FAILED_DECRYPT_IN_RECEIPTS_ENABLED, stale::PRIVACY_TOKEN_ONLY_CHECK_LID, stale::PROFILE_PIC_PRIVACY_TOKEN, ]; 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..a09de491a 100644 --- a/wacore/src/protocol/retry.rs +++ b/wacore/src/protocol/retry.rs @@ -161,6 +161,26 @@ 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).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/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); } 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..efff0a43a 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,34 @@ 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). + /// + /// 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, pub edit: EditAttribute, #[serde(skip_serializing_if = "Option::is_none")] pub bot_info: Option, @@ -748,4 +944,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)); + } }