Skip to main content

whatsapp_rust/send/
mod.rs

1//! Outgoing message pipeline.
2//!
3//! Cost model, clock reads: one per send operation, sampled as `SendInstant`
4//! and carried to every stamp. A new timestamp here should take that instant
5//! rather than read again, or the path silently accumulates reads the way it
6//! had accumulated four.
7
8use crate::client::Client;
9use crate::types::message::EditAttribute;
10use anyhow::anyhow;
11use log::debug;
12use wacore::libsignal::protocol::SignalProtocolError;
13use wacore::send::StanzaType;
14use wacore::types::jid::JidExt;
15use wacore::types::message::AddressingMode;
16#[cfg(test)]
17use wacore_binary::DeviceKey;
18use wacore_binary::Node;
19use wacore_binary::builder::NodeBuilder;
20use wacore_binary::{Jid, JidExt as _, Server};
21use waproto::whatsapp as wa;
22
23use crate::client::ClientError;
24use crate::features::GroupError;
25use crate::request::IqError;
26use thiserror::Error;
27
28mod actions;
29mod tctoken_lifecycle;
30
31/// Error returned by the message send path ([`Client::send_message`],
32/// [`Client::send_text`], [`Client::forward_message`], reactions, edits,
33/// revokes, pins, polls, events, comments, status) and the bot
34/// [`crate::bot::MessageContext`] helpers.
35///
36/// Wraps the shared [`ClientError`] (transport/connection/IQ) and surfaces the
37/// actionable send-time failure modes explicitly. `Internal` is the last-resort
38/// catch-all for crypto/encoding paths that still thread `anyhow` internally.
39#[derive(Debug, Error)]
40#[non_exhaustive]
41pub enum SendError {
42    /// Connection/transport/IQ failure (embeds the shared base error).
43    // No `#[from]`: the manual `From<ClientError>` impl flattens a bare `?` so
44    // `NotLoggedIn`/`Iq` stay matchable instead of nesting under `Client(..)`.
45    #[error("{0}")]
46    Client(#[source] ClientError),
47    /// The client has no PN/LID identity yet (not paired / mid LID migration).
48    #[error("client is not logged in")]
49    NotLoggedIn,
50    /// An IQ issued as part of the send (e.g. a group-info query) failed.
51    #[error("IQ request failed: {0}")]
52    Iq(#[from] IqError),
53    /// The recipient JID or send arguments are invalid for this operation
54    /// (e.g. a newsletter JID on the E2E path, an empty status recipient list).
55    #[error("invalid send request: {0}")]
56    InvalidRequest(String),
57    /// Catch-all for internal send failures (Signal encrypt, protobuf, group
58    /// resolution) that have no dedicated variant yet. `Display` forwards to
59    /// the inner error while `source()` still exposes it for downcast.
60    #[error("{0}")]
61    Internal(#[from] anyhow::Error),
62}
63
64impl SendError {
65    /// Map an `anyhow::Error` bubbled up from a helper that still threads
66    /// `anyhow` (e.g. `send_message_impl`, `require_pn`) into a typed
67    /// `SendError`, recovering the concrete [`ClientError`]. Without this the
68    /// blanket `#[from] anyhow::Error` would funnel a logged-out
69    /// `ClientError::NotLoggedIn` into the un-matchable `Internal` catch-all.
70    pub(crate) fn from_anyhow(err: anyhow::Error) -> Self {
71        // A validation deeper in the pipeline may already be a typed `SendError`
72        // (e.g. send_message_impl's newsletter/status guards); recover it so it
73        // stays matchable instead of collapsing into `Internal`.
74        let err = match err.downcast::<SendError>() {
75            Ok(send) => return send,
76            Err(other) => other,
77        };
78        // A group-metadata IQ in the send path (e.g. query_info) bubbles up as
79        // `GroupError`; flatten it before the `ClientError` check so an IQ
80        // failure surfaces as `SendError::Iq`, not the `Internal` catch-all.
81        let err = match err.downcast::<GroupError>() {
82            Ok(group) => return group.into(),
83            Err(other) => other,
84        };
85        match err.downcast::<ClientError>() {
86            Ok(client) => client.into(),
87            Err(other) => match other.downcast::<IqError>() {
88                Ok(iq) => SendError::Iq(iq),
89                Err(other) => SendError::Internal(other),
90            },
91        }
92    }
93}
94
95impl From<ClientError> for SendError {
96    fn from(err: ClientError) -> Self {
97        match err {
98            ClientError::NotLoggedIn => SendError::NotLoggedIn,
99            ClientError::Iq(iq) => SendError::Iq(iq),
100            client => SendError::Client(client),
101        }
102    }
103}
104
105impl From<GroupError> for SendError {
106    fn from(err: GroupError) -> Self {
107        match err {
108            GroupError::Iq(iq) => SendError::Iq(iq),
109            GroupError::InvalidRequest(msg) => SendError::InvalidRequest(msg),
110            GroupError::Internal(e) => SendError::from_anyhow(e),
111            // No dedicated variant for MEX mutations or description conflicts;
112            // preserve the full typed error as the `Internal` source so its
113            // Display/source chain survives.
114            group @ (GroupError::Mex(_) | GroupError::DescriptionConflict) => {
115                SendError::Internal(group.into())
116            }
117        }
118    }
119}
120
121/// Returns a `GroupInfo` whose participant list is guaranteed to contain our own
122/// sending JID, without deep-cloning the shared (cached) metadata in the common
123/// case where the server's participant list already includes us.
124fn ensure_self_in_group(
125    info: std::sync::Arc<wacore::client::context::GroupInfo>,
126    own_sending_jid: &Jid,
127) -> std::sync::Arc<wacore::client::context::GroupInfo> {
128    if info
129        .participants
130        .iter()
131        .any(|participant| participant.is_same_user_as(own_sending_jid))
132    {
133        info
134    } else {
135        let mut owned = (*info).clone();
136        owned.participants.push(own_sending_jid.to_non_ad());
137        std::sync::Arc::new(owned)
138    }
139}
140
141/// SKDM update data — only populated for group sends, deferred until after
142/// send_node(). This matches WhatsApp Web which only calls markHasSenderKey()
143/// after server ACK.
144struct SkdmUpdate {
145    to_str: String,
146    devices: Vec<Jid>,
147    stale_users: Vec<String>,
148}
149
150/// One send branch's result: the wire stanza plus the state the shared
151/// epilogue of `send_message_impl` consumes. Each branch runs as its own
152/// boxed future, so a DM send never pays for the group branch's frame.
153struct SendBranchOutput {
154    node: Node,
155    /// Generated `MessageContextInfo.message_secret`, persisted after send_node.
156    msg_secret: Option<[u8; 32]>,
157    /// Group sends: the identity (LID or PN) the secret must be keyed under,
158    /// matching what `<meta target_sender_jid>` echoes back.
159    group_sender_identity: Option<Jid>,
160    skdm_update: Option<SkdmUpdate>,
161    /// Single-flight for cold group sends: held from SKDM target resolution
162    /// through `update_sender_key_devices` in the epilogue so a concurrent
163    /// cold send re-resolves against the winner's warm marking.
164    distribution_guard: Option<async_lock::MutexGuardArc<()>>,
165    issue_tc_token_after_send: bool,
166    dm_phash: Option<wacore_binary::CompactString>,
167}
168
169struct GroupBranchRequest<'a> {
170    to: Jid,
171    message: &'a wa::Message,
172    request_id: &'a str,
173    force_key_distribution: bool,
174    edit: Option<EditAttribute>,
175    extra_stanza_nodes: &'a [Node],
176    group_metadata_freshness: crate::cache::Freshness,
177    device_freshness: crate::cache::Freshness,
178    borrowed_message_id: bool,
179}
180
181struct DmBranchRequest<'a> {
182    to: Jid,
183    message: &'a wa::Message,
184    request_id: &'a str,
185    sent_at: SendInstant,
186    edit: Option<EditAttribute>,
187    extra_stanza_nodes: Vec<Node>,
188    is_status_addon: bool,
189    device_freshness: crate::cache::Freshness,
190    borrowed_message_id: bool,
191}
192
193enum GroupDeviceSnapshot {
194    Owned(wacore::send::ResolvedGroupDevices),
195    Shared(std::sync::Arc<wacore::send::ResolvedGroupDevices>),
196}
197
198impl AsRef<wacore::send::ResolvedGroupDevices> for GroupDeviceSnapshot {
199    fn as_ref(&self) -> &wacore::send::ResolvedGroupDevices {
200        match self {
201            Self::Owned(devices) => devices,
202            Self::Shared(devices) => devices,
203        }
204    }
205}
206
207/// Keep each branch future out of the shared send frame. In tracing builds the
208/// dedicated span lets allocation profilers distinguish this deliberate box
209/// from work performed while polling the selected branch.
210#[inline]
211#[cfg_attr(
212    feature = "tracing",
213    tracing::instrument(
214        name = "wa.send.branch_box",
215        level = "debug",
216        skip_all,
217        fields(future_bytes = size_of::<F>())
218    )
219)]
220fn box_send_branch<F>(future: F) -> std::pin::Pin<Box<F>>
221where
222    F: Future,
223{
224    Box::pin(future)
225}
226
227/// True when every SKDM target belongs to our own account (PN or LID user).
228/// Own devices are never memoized warm (WA Web's `!isMeDevice` guard on
229/// `markHasSenderKey`), so an own-only `needs` set is the permanent
230/// warm-send steady state — not a cold-group signal.
231fn skdm_needs_only_own_devices(needs: &[Jid], own_pn: Option<&Jid>, own_lid: Option<&Jid>) -> bool {
232    !needs.is_empty()
233        && needs.iter().all(|j| {
234            own_pn.is_some_and(|p| j.is_same_user_as(p))
235                || own_lid.is_some_and(|l| j.is_same_user_as(l))
236        })
237}
238
239const RESERVED_EXTRA_STANZA_CHILDREN: &[&str] =
240    &["enc", "participants", "device-identity", "plaintext"];
241
242fn validate_extra_stanza_nodes(nodes: &[Node]) -> Result<(), SendError> {
243    if let Some(node) = nodes.iter().find(|node| {
244        RESERVED_EXTRA_STANZA_CHILDREN
245            .iter()
246            .any(|reserved| node.tag == *reserved)
247    }) {
248        return Err(SendError::InvalidRequest(format!(
249            "extra stanza child <{}> is reserved by the send pipeline",
250            node.tag
251        )));
252    }
253    Ok(())
254}
255
256impl SendBranchOutput {
257    fn stanza_only(node: Node) -> Self {
258        Self {
259            node,
260            msg_secret: None,
261            group_sender_identity: None,
262            skdm_update: None,
263            distribution_guard: None,
264            issue_tc_token_after_send: false,
265            dm_phash: None,
266        }
267    }
268}
269
270/// Options for [`Client::send_message_with_options`].
271///
272/// Start from [`SendOptions::default`] and chain the `with_*` setters; the
273/// struct is `#[non_exhaustive]` so new knobs can be added without breaking
274/// consumers.
275///
276/// ```
277/// # use whatsapp_rust::send::SendOptions;
278/// let options = SendOptions::default().with_message_id("3EB0ABCDEF");
279/// ```
280#[derive(Debug, Clone, Default)]
281#[non_exhaustive]
282pub struct SendOptions {
283    /// Override the auto-generated message ID.
284    /// Useful for resending a failed message with the same ID or idempotency.
285    pub message_id: Option<String>,
286    /// Extra XML child nodes on the message stanza.
287    pub extra_stanza_nodes: Vec<Node>,
288    /// Ephemeral duration in seconds. Sets `contextInfo.expiration` on the
289    /// message (WA Web `EProtoGenerator.js:183` parity).
290    /// Common values: 86400 (24h), 604800 (7d), 7776000 (90d).
291    pub ephemeral_expiration: Option<u32>,
292    /// Force the `<message type="...">` attribute instead of deriving it from
293    /// content. Escape hatch for a type the classifier can't infer.
294    pub stanza_type_override: Option<StanzaType>,
295    /// Freshness policy for group metadata used by this send.
296    pub group_metadata_freshness: crate::cache::Freshness,
297    /// Freshness policy for recipient device lists used by this send.
298    pub device_freshness: crate::cache::Freshness,
299}
300
301impl SendOptions {
302    /// See [`SendOptions::message_id`].
303    #[must_use]
304    pub fn with_message_id(mut self, message_id: impl Into<String>) -> Self {
305        self.message_id = Some(message_id.into());
306        self
307    }
308
309    /// See [`SendOptions::extra_stanza_nodes`].
310    #[must_use]
311    pub fn with_extra_stanza_nodes(mut self, nodes: Vec<Node>) -> Self {
312        self.extra_stanza_nodes = nodes;
313        self
314    }
315
316    /// See [`SendOptions::ephemeral_expiration`].
317    #[must_use]
318    pub fn with_ephemeral_expiration(mut self, seconds: u32) -> Self {
319        self.ephemeral_expiration = Some(seconds);
320        self
321    }
322
323    /// See [`SendOptions::stanza_type_override`].
324    #[must_use]
325    pub fn with_stanza_type_override(mut self, stanza_type: StanzaType) -> Self {
326        self.stanza_type_override = Some(stanza_type);
327        self
328    }
329
330    /// See [`SendOptions::group_metadata_freshness`].
331    #[must_use]
332    pub fn with_group_metadata_freshness(mut self, freshness: crate::cache::Freshness) -> Self {
333        self.group_metadata_freshness = freshness;
334        self
335    }
336
337    /// See [`SendOptions::device_freshness`].
338    #[must_use]
339    pub fn with_device_freshness(mut self, freshness: crate::cache::Freshness) -> Self {
340        self.device_freshness = freshness;
341        self
342    }
343}
344
345/// Options for [`Client::edit_message_with_options`].
346///
347/// Start from [`EditOptions::default`] and chain the `with_*` setters; the
348/// struct is `#[non_exhaustive]` so new knobs can be added without breaking
349/// consumers.
350#[derive(Debug, Clone, Default)]
351#[non_exhaustive]
352pub struct EditOptions {
353    /// Override the outer stanza id (default: a fresh id, like
354    /// [`Client::edit_message`]). Pinning it to an **existing** message's id is
355    /// a best-effort, side-effect-aware operation:
356    /// - No id-keyed local state is bound to the borrowed id — the edit does not
357    ///   persist an outbound message secret or a retry-cache entry under it, so
358    ///   the original message's secret and retry content are left intact.
359    /// - Whether the wire-level collision is honored is server- and
360    ///   client-dependent (the server may dedupe against the outer id), so treat
361    ///   the visible outcome as non-guaranteed.
362    pub stanza_id: Option<String>,
363}
364
365impl EditOptions {
366    /// See [`EditOptions::stanza_id`].
367    #[must_use]
368    pub fn with_stanza_id(mut self, stanza_id: impl Into<String>) -> Self {
369        self.stanza_id = Some(stanza_id.into());
370        self
371    }
372}
373
374/// The wall-clock second one send operation is stamped with.
375///
376/// Sampled once where the operation starts and carried down, so the message id,
377/// the biz node, the privacy-token decision and the outbound message secret
378/// describe one instant instead of four reads that can straddle a second
379/// boundary, on a path where a clock read is not always cheap.
380#[derive(Debug, Clone, Copy)]
381pub(crate) struct SendInstant(i64);
382
383impl SendInstant {
384    pub(crate) fn now() -> Self {
385        Self(wacore::time::now_secs())
386    }
387
388    pub(crate) fn unix_secs(self) -> i64 {
389        self.0
390    }
391
392    /// Saturated at 0 for the encodings that carry unsigned seconds.
393    pub(crate) fn unix_secs_u64(self) -> u64 {
394        self.0.max(0) as u64
395    }
396}
397
398#[derive(Default)]
399pub(crate) struct SendPipelineOptions<'a> {
400    /// Instant this operation is stamped with, when the caller already sampled
401    /// one. `None` makes [`Client::send_message_impl`] sample its own.
402    pub(crate) sent_at: Option<SendInstant>,
403    /// Borrowed on purpose: the caller that already owns an id (because it
404    /// returns it, or stamped state with it) lends it for the whole send
405    /// instead of handing over a copy.
406    pub(crate) request_id: Option<&'a str>,
407    pub(crate) peer: bool,
408    pub(crate) force_key_distribution: bool,
409    pub(crate) edit: Option<EditAttribute>,
410    pub(crate) extra_stanza_nodes: Vec<Node>,
411    pub(crate) stanza_type: Option<StanzaType>,
412    pub(crate) group_metadata_freshness: crate::cache::Freshness,
413    pub(crate) device_freshness: crate::cache::Freshness,
414    /// The outer stanza id is borrowed from another message (caller-forced
415    /// `request_id`), so id-keyed state must NOT be bound to it: skip
416    /// `add_recent_message` (retry cache) and `persist_outbound_msg_secret`.
417    /// Without this, the borrowed id clobbers the original message's retry
418    /// content and outbound secret.
419    pub(crate) borrowed_message_id: bool,
420}
421
422/// Result of a successfully sent message.
423#[derive(Debug, Clone, PartialEq, Eq)]
424#[non_exhaustive]
425pub struct SendResult {
426    pub message_id: String,
427    pub to: Jid,
428}
429
430impl SendResult {
431    /// `participant` is `None` -- only valid for the sender's own messages.
432    pub fn message_key(&self) -> wa::MessageKey {
433        wa::MessageKey {
434            remote_jid: Some(self.to.to_string()),
435            from_me: Some(true),
436            id: Some(self.message_id.clone()),
437            participant: None,
438        }
439    }
440}
441
442/// Duration for pinned messages. Default is 7 days (matches WA Web).
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
444#[non_exhaustive]
445pub enum PinDuration {
446    Hours24,
447    #[default]
448    Days7,
449    Days30,
450}
451
452impl PinDuration {
453    fn as_secs(self) -> u32 {
454        match self {
455            Self::Hours24 => 86_400,
456            Self::Days7 => 604_800,
457            Self::Days30 => 2_592_000,
458        }
459    }
460}
461
462/// Specifies who is revoking (deleting) the message.
463#[derive(Debug, Clone, PartialEq, Eq, Default)]
464#[non_exhaustive]
465pub enum RevokeType {
466    /// The message sender deleting their own message.
467    #[default]
468    Sender,
469    /// A group admin deleting another user's message.
470    /// `original_sender` is the JID of the user who sent the message being deleted.
471    Admin { original_sender: Jid },
472}
473
474/// Derive stanza-level edit attribute and meta node from message content.
475///
476/// The `edit` attribute and the `<meta>` child are independent in WA Web: the
477/// edit attribute comes from `editAttribute(msg, subtype)` and the meta node
478/// from `genMetaNode(...)`. A message can carry both (e.g. a poll vote sets
479/// `polltype=vote` meta; an event edit sets both `event_type=edit` meta and
480/// `edit="1"` attribute).
481pub(crate) fn infer_stanza_metadata(msg: &wa::Message) -> (Option<EditAttribute>, Option<Node>) {
482    use wacore::proto_helpers::MessageExt;
483    let edit = EditAttribute::infer_from_message(msg);
484
485    // genMetaNode builds a single <meta> carrying every applicable attr together,
486    // so accumulate onto one node instead of emitting at most one attr.
487    let mut meta = NodeBuilder::new("meta");
488    let mut has_attr = false;
489
490    if msg.poll_creation_message.is_set()
491        || msg.poll_creation_message_v2.is_set()
492        || msg.poll_creation_message_v3.is_set()
493    {
494        meta = meta.attr("polltype", "creation");
495        has_attr = true;
496    } else if let Some(poll_update) = msg.poll_update_message.as_option()
497        && poll_update.vote.is_set()
498    {
499        meta = meta.attr("polltype", "vote");
500        has_attr = true;
501        // TODO: polltype="result_snapshot" for poll_result_snapshot_message (gated behind AB flag)
502    } else if msg.event_message.is_set() {
503        meta = meta.attr("event_type", "creation");
504        has_attr = true;
505    } else if msg.enc_event_response_message.is_set() {
506        meta = meta.attr("event_type", "response");
507        has_attr = true;
508    } else if let Some(sec) = msg.secret_encrypted_message.as_option()
509        && sec.secret_enc_type
510            == Some(wa::message::secret_encrypted_message::SecretEncType::EventEdit)
511    {
512        meta = meta.attr("event_type", "edit");
513        has_attr = true;
514    } else if let Some(ml) = msg
515        .protocol_message
516        .as_option()
517        .and_then(|pm| pm.member_label.as_option())
518    {
519        // genMetaNode (MsgMetaNode `d`/`p`): a member_label protocol message carries
520        // appdata="member_tag" and tag_reason="user_delete" when the label is cleared
521        // (empty/absent), "user_update" otherwise.
522        let tag_reason = if ml.label.as_deref().unwrap_or("").is_empty() {
523            "user_delete"
524        } else {
525            "user_update"
526        };
527        meta = meta
528            .attr("appdata", "member_tag")
529            .attr("tag_reason", tag_reason);
530        has_attr = true;
531    }
532
533    // genMetaNode: `view_once="true"` whenever the media is view-once (wrapper or
534    // inline flag). Detection covers both via MessageExt::is_view_once.
535    if msg.is_view_once() {
536        meta = meta.attr("view_once", "true");
537        has_attr = true;
538    }
539
540    (edit, has_attr.then(|| meta.build()))
541}
542
543fn validate_status_message_id(
544    message: &wa::Message,
545    outer_id: Option<&str>,
546) -> Result<(), SendError> {
547    let Some(outer_id) = outer_id else {
548        return Ok(());
549    };
550    if outer_id.is_empty() {
551        return Err(SendError::InvalidRequest(
552            "status message ID must not be empty".into(),
553        ));
554    }
555    if wacore::send::status_revoke_target_id(message) == Some(outer_id) {
556        return Err(SendError::InvalidRequest(
557            "status revoke stanza ID must differ from the revoked message ID".into(),
558        ));
559    }
560    Ok(())
561}
562
563/// Offset subtracted from the current unix timestamp to produce the
564/// `privacy_mode_ts` attr value on a `<biz>` stanza. Empirically confirmed
565/// against live WhatsApp servers.
566const BIZ_PRIVACY_MODE_TS_OFFSET: u64 = 77_980_457;
567
568enum BizCategory<'a> {
569    /// `<biz actual_actors host_storage privacy_mode_ts native_flow_name=X/>` — no children.
570    /// The one shape WA Web also emits attrs-only: `createFanoutMsgStanza`
571    /// builds exactly these four attrs (and never a child) when the peer
572    /// contact carries a `privacyMode`.
573    PaymentSimple(&'a str),
574    /// Nested form with `name="mixed"`.
575    Mixed,
576}
577
578/// Pick the `<biz>` shape for a native-flow message from its first button.
579///
580/// Only the payment family keeps a literal flow name. Every other name routes
581/// through `mixed`, because live probes (issue #1132: fifteen sends from a
582/// Business account to a consumer handset) found that every one of the named
583/// nested shapes is refused — `cta_url`, `call_permission_request` and
584/// `payment_info` with a 473, `open_webview` and `galaxy_message` with a 405 —
585/// while `mixed` delivered on all ten of its attempts. Leading an otherwise
586/// byte-identical message with a `quick_reply` re-classified it to `mixed` and
587/// it went through, so the name is the only variable.
588///
589/// The name is very likely not the whole story, and the rest is worth a live
590/// probe before anyone reinstates the named form. The WA Web bundle
591/// (`WAWebSendMsgFanout.createFanoutMsgStanza`) builds a `<biz>` in exactly one
592/// of three mutually exclusive shapes, and our nested form matches none of
593/// them: it merges the privacy attrs with the nested child, stamps a `v="9"` on
594/// `<native_flow>` that WA Web never emits (all three of its builders pass
595/// `name` alone), and adds a `<quality_control>` child that appears in the
596/// bundle only in the INCOMING parser. `mixed` may simply be the one name the
597/// server does not validate strictly enough to notice.
598fn classify_button(button_name: &str) -> BizCategory<'_> {
599    match button_name {
600        // Untouched by the collapse: #1132 probed only `payment_info` of the
601        // six, and a merchant-provisioned account on real payment rails may
602        // legitimately answer differently than the test account did.
603        "payment_info" => BizCategory::PaymentSimple("payment_info"),
604        "review_and_pay" => BizCategory::PaymentSimple("order_details"),
605        "review_order" | "order_status" => BizCategory::PaymentSimple("order_status"),
606        "payment_status" => BizCategory::PaymentSimple("payment_status"),
607        "payment_method" => BizCategory::PaymentSimple("payment_method"),
608        "payment_reminder" => BizCategory::PaymentSimple("payment_reminder"),
609
610        _ => BizCategory::Mixed,
611    }
612}
613
614/// Does this interactive message carry something a client can render on its
615/// own, independent of native-flow buttons?
616///
617/// WA Web's rule verbatim (the `f` term of `getNativeFlowNameFromMsg`): a body,
618/// a header title, a footer, or a header image. `hasMediaAttachment` and the
619/// non-image header media are deliberately not part of it.
620fn has_renderable_envelope(im: &wa::message::InteractiveMessage) -> bool {
621    let non_empty = |text: Option<&str>| text.is_some_and(|t| !t.is_empty());
622
623    if non_empty(im.body.as_option().and_then(|b| b.text.as_deref()))
624        || non_empty(im.footer.as_option().and_then(|f| f.text.as_deref()))
625    {
626        return true;
627    }
628    let Some(header) = im.header.as_option() else {
629        return false;
630    };
631    non_empty(header.title.as_deref())
632        || matches!(
633            header.media,
634            Some(wa::message::interactive_message::header::Media::ImageMessage(_))
635        )
636}
637
638/// Classify an interactive payload into the `<biz>` shape it should carry,
639/// mirroring WA Web's `getNativeFlowNameFromMsg`: the first native-flow
640/// button's name decides when there is one, otherwise a payload that renders
641/// on its own is announced as `mixed`.
642///
643/// That second arm is what makes a carousel work (issue #1133): its buttons
644/// live on the cards, not at the top level, so the button rule never fires and
645/// the message used to leave without a `<biz>` at all — accepted, acked, and
646/// then invisible on the handset. A `shopStorefrontMessage` is excluded, as it
647/// is in WA Web.
648fn classify_interactive(im: &wa::message::InteractiveMessage) -> Option<BizCategory<'_>> {
649    use wa::message::interactive_message::InteractiveMessage as Payload;
650    match im.interactive_message.as_ref()? {
651        Payload::NativeFlowMessage(nf) if !nf.buttons.is_empty() => {
652            nf.buttons.first()?.name.as_deref().map(classify_button)
653        }
654        Payload::ShopStorefrontMessage(_) => None,
655        _ => has_renderable_envelope(im).then_some(BizCategory::Mixed),
656    }
657}
658
659/// Derive the `<biz>` stanza child for interactive messages.
660///
661/// Returns `None` when the message has no interactive payload, or one that
662/// announces nothing (a storefront, or a payload with neither buttons nor any
663/// renderable envelope). Otherwise returns the assembled `<biz>` node. The
664/// caller is responsible for prepending `<bot biz_bot="1"/>` for DM-bound
665/// sends (see `build_extra_stanza_nodes`).
666///
667/// `now_unix_secs` is the current wall-clock time in unix seconds. Taking it
668/// as a parameter keeps the function pure and lets tests pin the resulting
669/// `privacy_mode_ts` deterministically without touching the global time
670/// provider.
671fn infer_biz_node(msg: &wa::Message, now_unix_secs: u64) -> Option<Node> {
672    let category = classify_interactive(extract_interactive_message(msg)?)?;
673    let privacy_mode_ts = now_unix_secs
674        .saturating_sub(BIZ_PRIVACY_MODE_TS_OFFSET)
675        .to_string();
676
677    Some(match category {
678        BizCategory::PaymentSimple(flow_name) => NodeBuilder::new("biz")
679            .attr("actual_actors", "2")
680            .attr("host_storage", "2")
681            .attr("privacy_mode_ts", &privacy_mode_ts)
682            .attr("native_flow_name", flow_name)
683            .build(),
684        BizCategory::Mixed => build_nested_biz(&privacy_mode_ts, "mixed"),
685    })
686}
687
688fn build_nested_biz(privacy_mode_ts: &str, flow_name: &str) -> Node {
689    NodeBuilder::new("biz")
690        .attr("actual_actors", "2")
691        .attr("host_storage", "2")
692        .attr("privacy_mode_ts", privacy_mode_ts)
693        .children([
694            NodeBuilder::new("interactive")
695                .attr("type", "native_flow")
696                .attr("v", "1")
697                .children([NodeBuilder::new("native_flow")
698                    .attr("v", "9")
699                    .attr("name", flow_name)
700                    .build()])
701                .build(),
702            NodeBuilder::new("quality_control")
703                .attr("source_type", "third_party")
704                .build(),
705        ])
706        .build()
707}
708
709fn extract_interactive_message(msg: &wa::Message) -> Option<&wa::message::InteractiveMessage> {
710    // Only checks documentWithCaptionMessage wrapper (for media headers) and direct field.
711    // Does not use unwrap_message() since we need the InteractiveMessage specifically.
712    if let Some(doc) = msg.document_with_caption_message.as_option()
713        && let Some(inner) = doc.message.as_option()
714        && let Some(im) = inner.interactive_message.as_option()
715    {
716        return Some(im);
717    }
718    msg.interactive_message.as_option()
719}
720
721/// Assemble the `extra_stanza_nodes` vector for a non-newsletter send.
722///
723/// Order: `inferred_meta`, optional `<bot biz_bot="1"/>` (DM only), `<biz>`,
724/// then any user-provided extra nodes. Pure so the caller stays trivial and
725/// the assembly logic is unit-testable.
726fn build_extra_stanza_nodes(
727    to: &Jid,
728    inferred_meta: Option<Node>,
729    biz: Option<Node>,
730    user_nodes: Vec<Node>,
731) -> Vec<Node> {
732    if inferred_meta.is_none() && biz.is_none() {
733        return user_nodes;
734    }
735    let bot_emitted = biz.is_some() && !to.is_group();
736    let extra = inferred_meta.is_some() as usize + biz.is_some() as usize + bot_emitted as usize;
737    let mut nodes = Vec::with_capacity(user_nodes.len() + extra);
738    nodes.extend(inferred_meta);
739    if let Some(node) = biz {
740        if bot_emitted {
741            nodes.push(NodeBuilder::new("bot").attr("biz_bot", "1").build());
742        }
743        nodes.push(node);
744    }
745    nodes.extend(user_nodes);
746    nodes
747}
748
749fn build_revoke_message(
750    remote_jid: &Jid,
751    from_me: bool,
752    message_id: String,
753    participant: Option<String>,
754) -> wa::Message {
755    wa::Message {
756        protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage {
757            key: buffa::MessageField::some(wa::MessageKey {
758                remote_jid: Some(remote_jid.to_string()),
759                from_me: Some(from_me),
760                id: Some(message_id),
761                participant,
762            }),
763            r#type: Some(wa::message::protocol_message::Type::Revoke),
764            ..Default::default()
765        }),
766        ..Default::default()
767    }
768}
769
770/// A newsletter (channel) admin op on an existing message: edit (with the
771/// replacement body) or revoke. Keeping content tied to the variant makes the
772/// invalid edit-without-body / revoke-with-body states unrepresentable.
773pub(crate) enum NewsletterEdit<'a> {
774    Edit(&'a wa::Message),
775    Revoke,
776}
777
778/// Build a newsletter (channel) plaintext edit/revoke stanza. The target is keyed
779/// by `message_id` (the original message's stanza id string, the wire `id`), NOT
780/// by `server_id`: WA Web (mergeNewsletterClientIDMixin -> `id`) and whatsmeow
781/// (sendNewsletter, req.ID = protocolMessage.key.id) both reference edit/revoke by
782/// the message id and emit no `server_id` (that attr is reaction-only).
783pub(crate) fn build_newsletter_edit_node(
784    to: &Jid,
785    message_id: &str,
786    op: NewsletterEdit<'_>,
787) -> Node {
788    use crate::types::message::EditAttribute;
789    let mut plaintext = NodeBuilder::new("plaintext");
790    let (edit, stanza_type, body) = match op {
791        NewsletterEdit::Edit(m) => {
792            if let Some(mt) = wacore::send::media_type_from_message(m) {
793                plaintext = plaintext.attr("mediatype", mt);
794            }
795            (
796                EditAttribute::AdminEdit,
797                wacore::send::stanza_type_from_message(m),
798                waproto::codec::message_to_vec(m),
799            )
800        }
801        NewsletterEdit::Revoke => (EditAttribute::AdminRevoke, "text", Vec::new()),
802    };
803    NodeBuilder::new("message")
804        .attr("to", to)
805        .attr("id", message_id)
806        .attr("type", stanza_type)
807        .attr("edit", edit.to_string_val())
808        .children([plaintext.bytes(body).build()])
809        .build()
810}
811
812/// Build a message edit in WA Web's wire shape: a top-level
813/// protocolMessage(type=MESSAGE_EDIT) carrying the new content under
814/// editedMessage, same as build_revoke_message and our own receive path. The
815/// top-level Message.editedMessage FutureProofMessage is the history/storage
816/// form, not what WA Web sends on the wire.
817pub(crate) fn build_edit_message(
818    remote_jid: &Jid,
819    message_id: String,
820    participant: Option<String>,
821    new_content: wa::Message,
822    timestamp_ms: i64,
823) -> wa::Message {
824    wa::Message {
825        protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage {
826            key: buffa::MessageField::some(wa::MessageKey {
827                remote_jid: Some(remote_jid.to_string()),
828                from_me: Some(true),
829                id: Some(message_id),
830                participant,
831            }),
832            r#type: Some(wa::message::protocol_message::Type::MessageEdit),
833            edited_message: buffa::MessageField::some(new_content),
834            timestamp_ms: Some(timestamp_ms),
835            ..Default::default()
836        }),
837        ..Default::default()
838    }
839}
840
841impl Client {
842    /// Send a message to a user, group, or newsletter.
843    ///
844    /// Newsletter messages are sent as plaintext (no E2E encryption).
845    /// For status/story updates use [`Client::status()`] instead.
846    pub fn send_message(
847        &self,
848        to: impl Into<Jid>,
849        message: wa::Message,
850    ) -> impl Future<Output = Result<SendResult, SendError>> + '_ {
851        // Sync-prologue box: a plain async fn would hold the ~1 KB message
852        // by value in every embedder's frame.
853        let to = to.into();
854        let message = Box::new(message);
855        async move {
856            // Box::pin: the inner future carries ~1 KB of pre-encrypt locals.
857            Box::pin(self.send_message_with_options_inner(to, message, SendOptions::default()))
858                .await
859        }
860    }
861
862    /// Plain-text convenience over [`Client::send_message`].
863    pub fn send_text(
864        &self,
865        to: impl Into<Jid>,
866        text: impl Into<String>,
867    ) -> impl Future<Output = Result<SendResult, SendError>> + '_ {
868        use wacore::proto_helpers::MessageBuilderExt;
869        let to = to.into();
870        let message = Box::new(wa::Message::text(text));
871        async move {
872            Box::pin(self.send_message_with_options_inner(to, message, SendOptions::default()))
873                .await
874        }
875    }
876
877    /// Forward an existing message to a chat.
878    ///
879    /// Builds a forward-ready copy of `message` (sets `is_forwarded`, bumps the
880    /// forwarding score, strips the reply/quote chain, and drops the source
881    /// `message_secret`) via
882    /// [`MessageExt::prepare_for_forward`](wacore::proto_helpers::MessageExt::prepare_for_forward),
883    /// then sends it.
884    /// `message` may be a received body or a wrapper (ephemeral/view-once); the
885    /// inner content is unwrapped before forwarding. Existing media is relayed
886    /// from the same CDN blob rather than re-uploaded.
887    pub fn forward_message(
888        &self,
889        to: impl Into<Jid>,
890        message: &wa::Message,
891    ) -> impl Future<Output = Result<SendResult, SendError>> + '_ {
892        use wacore::proto_helpers::MessageExt;
893        let to = to.into();
894        let body = message.get_base_message().prepare_for_forward();
895        async move {
896            Box::pin(self.send_message_with_options_inner(to, body, SendOptions::default())).await
897        }
898    }
899
900    /// Send a message with additional options.
901    pub fn send_message_with_options(
902        &self,
903        to: impl Into<Jid>,
904        message: wa::Message,
905        options: SendOptions,
906    ) -> impl Future<Output = Result<SendResult, SendError>> + '_ {
907        // Thin generic shim: the large async body below stays monomorphic so
908        // each `Into<Jid>` instantiation does not duplicate the state machine.
909        // Sync-prologue box + Box::pin as in send_message.
910        let to = to.into();
911        let message = Box::new(message);
912        async move { Box::pin(self.send_message_with_options_inner(to, message, options)).await }
913    }
914
915    #[cfg_attr(
916        feature = "tracing",
917        tracing::instrument(
918            name = "wa.send.message",
919            level = "debug",
920            skip_all,
921            fields(
922                to = %to.observe(),
923                lid = tracing::field::Empty,
924                pn = tracing::field::Empty
925            ),
926            err(Debug)
927        )
928    )]
929    async fn send_message_with_options_inner(
930        &self,
931        to: Jid,
932        mut message: Box<wa::Message>,
933        options: SendOptions,
934    ) -> Result<SendResult, SendError> {
935        #[cfg(feature = "tracing")]
936        self.record_identity_on_span(&tracing::Span::current());
937
938        validate_extra_stanza_nodes(&options.extra_stanza_nodes)?;
939        if options.message_id.as_ref().is_some_and(String::is_empty) {
940            return Err(SendError::InvalidRequest(
941                "message ID must not be empty".into(),
942            ));
943        }
944
945        let _t = wacore::telemetry::timer(wacore::telemetry::SEND_DURATION);
946        self.stats.record_message_sent();
947        wacore::telemetry::send(match to.server {
948            Server::Group => "group",
949            Server::Broadcast => "status",
950            Server::Newsletter => "newsletter",
951            _ => "dm",
952        });
953        if let Some(exp) = options.ephemeral_expiration
954            && exp > 0
955        {
956            use wacore::proto_helpers::MessageExt;
957            if !message.set_ephemeral_expiration(exp) {
958                // Bare `conversation` messages have no contextInfo field.
959                log::warn!("Could not set contextInfo.expiration on this message type");
960            }
961        }
962
963        let stanza_type_override = options.stanza_type_override;
964        let group_metadata_freshness = options.group_metadata_freshness;
965        let device_freshness = options.device_freshness;
966        let sent_at = SendInstant::now();
967        let request_id = match options.message_id {
968            Some(id) => id,
969            None => self.generate_message_id_at(sent_at.unix_secs_u64()),
970        };
971        // Both paths below consume `to`, so save a copy for the result. The id
972        // is not copied: it is lent to the pipeline as `&str` and moved into
973        // the result once the send returns.
974        let result_to = to.clone();
975
976        // Newsletters are not E2E encrypted — send as plaintext via SMAX stanza.
977        // Matches WA Web's OutMessagePublishNewsletterRequest + ContentType mixins.
978        if to.is_newsletter() {
979            let stanza_type = stanza_type_override
980                .map(StanzaType::as_wire)
981                .unwrap_or_else(|| wacore::send::stanza_type_from_message(&message));
982            let (_, meta_node) = infer_stanza_metadata(&message);
983            let mut plaintext_builder = NodeBuilder::new("plaintext");
984            if let Some(mt) = wacore::send::media_type_from_message(&message) {
985                plaintext_builder = plaintext_builder.attr("mediatype", mt);
986            }
987            let mut children = vec![
988                plaintext_builder
989                    .bytes(waproto::codec::message_to_vec(&message))
990                    .build(),
991            ];
992            children.extend(meta_node);
993            children.extend(options.extra_stanza_nodes);
994            let stanza = NodeBuilder::new("message")
995                .attr("to", to)
996                .attr("type", stanza_type)
997                .attr("id", &request_id)
998                .children(children)
999                .build();
1000            self.send_node(stanza).await?;
1001            return Ok(SendResult {
1002                message_id: request_id,
1003                to: result_to,
1004            });
1005        }
1006
1007        let (edit, inferred_meta) = infer_stanza_metadata(&message);
1008        let biz = infer_biz_node(&message, sent_at.unix_secs_u64());
1009
1010        let extra_nodes =
1011            build_extra_stanza_nodes(&to, inferred_meta, biz, options.extra_stanza_nodes);
1012        // send_message_impl now boxes each branch future itself, so its own
1013        // frame (prologue + epilogue) embeds here without a second box; the
1014        // shim's Box::pin above still keeps `send_message`'s future
1015        // pointer-sized for callers embedding it in their own futures.
1016        self.send_message_impl(
1017            to,
1018            &message,
1019            SendPipelineOptions {
1020                sent_at: Some(sent_at),
1021                request_id: Some(&request_id),
1022                edit,
1023                extra_stanza_nodes: extra_nodes,
1024                stanza_type: stanza_type_override,
1025                group_metadata_freshness,
1026                device_freshness,
1027                ..Default::default()
1028            },
1029        )
1030        .await
1031        .map_err(SendError::from_anyhow)?;
1032        Ok(SendResult {
1033            message_id: request_id,
1034            to: result_to,
1035        })
1036    }
1037
1038    /// Send a status/story update using sender-key encryption.
1039    ///
1040    /// Status uses LID addressing (matches `WAWebEncryptAndSendStatusMsg`):
1041    /// LID recipients pass through, PN recipients are resolved to LID via
1042    /// `Client::get_lid_pn_entry` (cache-aside), and unresolvable recipients
1043    /// are skipped silently. The resulting `GroupInfo` carries
1044    /// `AddressingMode::Lid`; `prepare_group_stanza` signs with `own_lid`
1045    /// and emits `addressing_mode="lid"` on the stanza. Errors only if no
1046    /// recipient could be resolved.
1047    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.status", level = "debug", skip_all, fields(count = recipients.len()), err(Debug)))]
1048    pub(crate) async fn send_status_message(
1049        &self,
1050        message: wa::Message,
1051        recipients: &[Jid],
1052        mut options: crate::features::status::StatusSendOptions,
1053    ) -> Result<SendResult, SendError> {
1054        use wacore::client::context::GroupInfo;
1055        use wacore_binary::builder::NodeBuilder;
1056
1057        if recipients.is_empty() {
1058            return Err(SendError::InvalidRequest(
1059                "cannot send status with no recipients".into(),
1060            ));
1061        }
1062        validate_extra_stanza_nodes(&options.extra_stanza_nodes)?;
1063        validate_status_message_id(&message, options.message_id.as_deref())?;
1064
1065        // Status posts don't go through send_message_with_options, so count them here.
1066        let _t = wacore::telemetry::timer(wacore::telemetry::SEND_DURATION);
1067        self.stats.record_message_sent();
1068        wacore::telemetry::send("status");
1069
1070        let to = Jid::status_broadcast();
1071        let request_id = options
1072            .message_id
1073            .take()
1074            .unwrap_or_else(|| self.generate_message_id());
1075
1076        // Borrow from the held snapshot: no field clones, the Arc keeps it alive.
1077        let device_snapshot = self.persistence_manager.get_device_snapshot();
1078        let account_info = &device_snapshot.account;
1079        let own_jid = device_snapshot.pn.as_ref().ok_or(SendError::NotLoggedIn)?;
1080        // Status is LID-addressed (matches WA Web post-LID-migration). Without
1081        // a real device LID we can't sign or fan out correctly; refuse rather
1082        // than silently emit `addressing_mode="lid"` with a PN sender.
1083        let own_lid = device_snapshot.lid.as_ref().ok_or_else(|| {
1084            SendError::InvalidRequest(
1085                "cannot send status: device has no LID yet. Finish pairing / LID \
1086                 migration before posting status."
1087                    .into(),
1088            )
1089        })?;
1090
1091        // Fail fast for any JID that isn't a user (PN or LID). Mirrors WA
1092        // Web's `asUserWidOrThrow` inside `toUserLid`: non-user inputs are a
1093        // programming bug, not something to silently drop during resolution.
1094        for jid in recipients {
1095            if !(jid.is_pn() || jid.is_lid()) {
1096                return Err(SendError::InvalidRequest(format!(
1097                    "invalid status recipient {jid}: must be a user JID (PN or LID), \
1098                     not a group/broadcast/newsletter/hosted/etc."
1099                )));
1100            }
1101        }
1102
1103        use futures::StreamExt;
1104        use std::collections::HashMap;
1105        // Resolve recipient LIDs concurrently (a status audience can be hundreds of
1106        // contacts, each a cold-cache DB read). Stream over indices and rebuild
1107        // `resolved` in order — assemble_status_participants is position-sensitive.
1108        const STATUS_LID_RESOLVE_CONCURRENCY: usize = 16;
1109        let resolved_indexed: Vec<(usize, Option<Jid>)> =
1110            futures::stream::iter(0..recipients.len())
1111                .map(|i| async move { (i, self.resolve_recipient_to_lid(&recipients[i]).await) })
1112                .buffer_unordered(STATUS_LID_RESOLVE_CONCURRENCY)
1113                .collect()
1114                .await;
1115        let mut resolved: Vec<Option<Jid>> = vec![None; recipients.len()];
1116        let mut lid_to_pn_map: HashMap<wacore_binary::CompactString, Jid> =
1117            HashMap::with_capacity(recipients.len() + 1);
1118        for (i, lid) in resolved_indexed {
1119            if let Some(lid_jid) = lid {
1120                if recipients[i].is_pn() {
1121                    lid_to_pn_map.insert(lid_jid.user.clone(), recipients[i].to_non_ad());
1122                }
1123                resolved[i] = Some(lid_jid);
1124            }
1125        }
1126        lid_to_pn_map.insert(own_lid.user.clone(), own_jid.to_non_ad());
1127
1128        let participants = wacore::send::assemble_status_participants(resolved, own_lid)?;
1129        let mut group_info =
1130            GroupInfo::with_lid_to_pn_map(participants, AddressingMode::Lid, lid_to_pn_map);
1131
1132        // One encode feeds retry cache and wire; mci-hoist re-encodes (folded context).
1133        let shared_content = message
1134            .message_context_info
1135            .is_unset()
1136            .then(|| std::sync::Arc::new(waproto::codec::message_to_vec(&message)));
1137        self.add_recent_message(&to, &request_id, &message, shared_content.clone())
1138            .await;
1139
1140        let device_store_arc = self.persistence_manager.get_device_arc().await;
1141        let to_str = to.to_string();
1142        let distribution_guard = self.group_distribution_lock(&to).await;
1143
1144        let force_skdm = {
1145            use wacore::libsignal::store::sender_key_name::SenderKeyName;
1146            // Sender key name tracks the addressing mode of the group stanza.
1147            // Since status now uses LID addressing (see send_status_message
1148            // header), the key is stored under own_lid, matching the address
1149            // prepare_group_stanza derives internally.
1150            let sender_address = own_lid.to_protocol_address();
1151            let sender_key_name = SenderKeyName::from_parts(&to_str, sender_address.as_str());
1152
1153            let key_exists = self
1154                .signal_cache
1155                .get_sender_key(&sender_key_name, &*device_snapshot.backend)
1156                .await?
1157                .is_some();
1158
1159            if !key_exists {
1160                self.reset_sender_key_device_tracking(&to_str).await?;
1161            }
1162
1163            !key_exists
1164        };
1165
1166        let mut store_adapter = self.signal_adapter_from(device_store_arc.clone());
1167        let mut stores = store_adapter.as_signal_stores();
1168
1169        // Determine which devices need SKDM using the unified per-device map.
1170        // Status keeps the prior phash behavior, so we drop the full device set
1171        // and only use the SKDM-target subset.
1172        let skdm_target_devices =
1173            if !force_skdm || options.device_freshness == crate::cache::Freshness::Refresh {
1174                self.resolve_status_skdm_targets(
1175                    &to_str,
1176                    &group_info,
1177                    own_lid,
1178                    options.device_freshness,
1179                    force_skdm,
1180                )
1181                .await?
1182            } else {
1183                None
1184            };
1185
1186        // prepare_group_stanza and ensure_status_participants both read the
1187        // participant list and expect self present. Done after SKDM resolution
1188        // to preserve the prior ordering (resolve ran without self appended).
1189        let own_status_base = own_lid.to_non_ad();
1190        if !group_info
1191            .participants
1192            .iter()
1193            .any(|participant| participant.is_same_user_as(&own_status_base))
1194        {
1195            group_info.participants.push(own_status_base);
1196        }
1197
1198        // `<meta status_setting>` describes the POSTER's privacy on their own
1199        // status. Reactions go through WA Web's addon path and never visit
1200        // `WAWebEncryptAndSendStatusMsg`; attaching the meta on a reaction
1201        // gets the stanza NACK'd with 479 (SmaxInvalid). Revokes also skip it.
1202        let mut extra_stanza_nodes = options.extra_stanza_nodes;
1203        if wacore::send::status_carries_privacy_meta(&message) {
1204            extra_stanza_nodes.push(
1205                NodeBuilder::new("meta")
1206                    .attr("status_setting", options.privacy.as_str())
1207                    .build(),
1208            );
1209        }
1210
1211        let prepared = match wacore::send::prepare_group_stanza(
1212            &*self.runtime,
1213            &mut stores,
1214            self,
1215            wacore::send::GroupStanzaRequest {
1216                group: &group_info,
1217                own_jid,
1218                own_lid,
1219                account: account_info.as_deref(),
1220                to: &to,
1221                message: &message,
1222                message_id: &request_id,
1223                force_distribution: force_skdm,
1224                distribution_targets: skdm_target_devices,
1225                distribution_policy: wacore::send::SenderKeyDistributionPolicy::BestEffort,
1226                phash_devices: None,
1227                edit: None,
1228                extra_nodes: &extra_stanza_nodes,
1229                pre_encoded: shared_content.as_deref().map(Vec::as_slice),
1230            },
1231        )
1232        .await
1233        {
1234            Ok(prepared) => prepared,
1235            Err(e) => {
1236                if let Some(SignalProtocolError::NoSenderKeyState(_)) =
1237                    e.downcast_ref::<SignalProtocolError>()
1238                {
1239                    log::warn!("No sender key for status broadcast, forcing distribution.");
1240
1241                    self.reset_sender_key_device_tracking(&to_str).await?;
1242
1243                    let mut store_adapter_retry =
1244                        self.signal_adapter_from(device_store_arc.clone());
1245                    let mut stores_retry = store_adapter_retry.as_signal_stores();
1246
1247                    wacore::send::prepare_group_stanza(
1248                        &*self.runtime,
1249                        &mut stores_retry,
1250                        self,
1251                        wacore::send::GroupStanzaRequest {
1252                            group: &group_info,
1253                            own_jid,
1254                            own_lid,
1255                            account: account_info.as_deref(),
1256                            to: &to,
1257                            message: &message,
1258                            message_id: &request_id,
1259                            force_distribution: true,
1260                            distribution_targets: None,
1261                            distribution_policy:
1262                                wacore::send::SenderKeyDistributionPolicy::BestEffort,
1263                            phash_devices: None,
1264                            edit: None,
1265                            extra_nodes: &extra_stanza_nodes,
1266                            pre_encoded: shared_content.as_deref().map(Vec::as_slice),
1267                        },
1268                    )
1269                    .await?
1270                } else {
1271                    return Err(e.into());
1272                }
1273            }
1274        };
1275
1276        let stanza = self
1277            .ensure_status_participants(prepared.node, &group_info)
1278            .await?;
1279
1280        // Gate the stanza on the sender-key ratchet advance being durable
1281        // (same rule as the DM/group send path); a failure aborts the send.
1282        self.persist_signal_state_pre_wire().await?;
1283
1284        let ack = stanza
1285            .attrs()
1286            .optional_string("phash")
1287            .map(|s| wacore_binary::CompactString::from(s.as_ref()));
1288        if let Some(phash) = ack.clone() {
1289            self.register_phash_waiter(&request_id, phash, to.clone(), true);
1290        }
1291
1292        if let Err(e) = self.send_node(stanza).await {
1293            if ack.is_some() {
1294                self.response_waiters_guard().remove(&request_id);
1295            }
1296            return Err(e.into());
1297        }
1298
1299        self.update_sender_key_devices(&to_str, &prepared.skdm_devices)
1300            .await;
1301        drop(distribution_guard);
1302
1303        for user in &prepared.stale_device_users {
1304            self.invalidate_device_cache(user).await;
1305        }
1306
1307        Ok(SendResult {
1308            message_id: request_id,
1309            to,
1310        })
1311    }
1312
1313    /// Resolve the group's device set for a warm/partial send. Returns
1314    /// `None` when device resolution fails (caller falls back to the full
1315    /// `force_skdm` path), otherwise `Some((all_devices, needs_skdm))` where
1316    /// `all_devices` is the complete resolved set (feeds the phash) and
1317    /// `needs_skdm` is the subset still missing the sender key (feeds SKDM
1318    /// distribution). `needs_skdm` may be empty (fully warm send).
1319    ///
1320    /// For LID mode, uses `group_info.phone_jid_for_lid_user` to query devices
1321    /// via PN when available (LID usync is unreliable for own JID), then
1322    /// converts the result back to LID. Same fallback as `prepare_group_stanza`.
1323    /// Load (or lazily build) the per-group sender-key device map.
1324    ///
1325    /// Atomic get-or-init: if another task invalidated the cache during our
1326    /// DB read, get_or_init's single-flight guarantee means the stale data
1327    /// won't be inserted — the invalidation wins and the next caller re-inits.
1328    async fn skdm_device_map(
1329        &self,
1330        group_jid: &str,
1331    ) -> std::sync::Arc<crate::sender_key_device_cache::SenderKeyDeviceMap> {
1332        use crate::sender_key_device_cache::SenderKeyDeviceMap;
1333        let pm = self.persistence_manager.clone();
1334        self.sender_key_device_cache
1335            .get_or_init(group_jid, async {
1336                let db_rows = pm
1337                    .get_sender_key_devices(group_jid)
1338                    .await
1339                    .unwrap_or_else(|e| {
1340                        log::warn!(
1341                            "Failed to read sender key devices for {}: {:?}",
1342                            group_jid,
1343                            e
1344                        );
1345                        vec![]
1346                    });
1347                std::sync::Arc::new(SenderKeyDeviceMap::from_db_rows(&db_rows))
1348            })
1349            .await
1350    }
1351
1352    /// Filter the resolved device set down to the subset still needing SKDM.
1353    ///
1354    /// No empty-cache early-exit: WA Web iterates an empty `senderKey` Map
1355    /// as `false` per participant, so the filter must run unconditionally.
1356    fn filter_skdm_targets(
1357        &self,
1358        group_jid: &str,
1359        all_devices: &[Jid],
1360        cached_map: &crate::sender_key_device_cache::SenderKeyDeviceMap,
1361        own_sending_jid: &Jid,
1362    ) -> Vec<Jid> {
1363        let needs_skdm: Vec<Jid> = all_devices
1364            .iter()
1365            .filter(|device| {
1366                if device.is_hosted() {
1367                    return false;
1368                }
1369                if device.user == own_sending_jid.user && device.device == own_sending_jid.device {
1370                    return false;
1371                }
1372                // WA Web parity (ParticipantStore.js skDistribList): a device is
1373                // warm only when it AND its primary (device 0) hold the key, so a
1374                // forgotten primary redistributes the whole user while a forgotten
1375                // companion redistributes only itself. One inner-map resolution
1376                // per device (single user-string hash) instead of two.
1377                !cached_map.device_and_primary_warm(&device.user, device.device)
1378            })
1379            .cloned()
1380            .collect();
1381
1382        log::debug!(
1383            "Resolved {} devices ({} need SKDM) for {}",
1384            all_devices.len(),
1385            needs_skdm.len(),
1386            group_jid
1387        );
1388        needs_skdm
1389    }
1390
1391    /// SKDM target resolution for the status path, whose `GroupInfo` is built
1392    /// fresh per send (no stable identity to memoize against).
1393    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.resolve_skdm_targets", level = "debug", skip_all, fields(group = %wacore_binary::jid::observe_str(group_jid))))]
1394    async fn resolve_status_skdm_targets(
1395        &self,
1396        group_jid: &str,
1397        group_info: &wacore::client::context::GroupInfo,
1398        own_sending_jid: &Jid,
1399        freshness: crate::cache::Freshness,
1400        force_distribution: bool,
1401    ) -> Result<Option<Vec<Jid>>, anyhow::Error> {
1402        let cached_map = if force_distribution {
1403            None
1404        } else {
1405            Some(self.skdm_device_map(group_jid).await)
1406        };
1407
1408        let is_lid_mode = group_info.addressing_mode == AddressingMode::Lid;
1409        let jids_to_resolve: Vec<Jid> = group_info
1410            .participants
1411            .iter()
1412            .map(|jid| {
1413                if is_lid_mode
1414                    && jid.is_lid()
1415                    && let Some(pn) = group_info.phone_jid_for_lid_user(&jid.user)
1416                {
1417                    return pn.to_non_ad();
1418                }
1419                jid.to_non_ad()
1420            })
1421            .collect();
1422
1423        let resolved = match freshness {
1424            crate::cache::Freshness::CachePreferred => {
1425                self.get_user_devices_owned(jids_to_resolve).await
1426            }
1427            crate::cache::Freshness::Refresh => self.refresh_user_devices(jids_to_resolve).await,
1428        };
1429        match resolved {
1430            Ok(mut devices) => {
1431                if is_lid_mode {
1432                    for device in &mut devices {
1433                        *device = group_info.phone_device_jid_into_lid(std::mem::take(device));
1434                    }
1435                }
1436                if force_distribution {
1437                    wacore::send::retain_skdm_distribution_targets(&mut devices, own_sending_jid);
1438                } else if let Some(cached_map) = cached_map {
1439                    devices.retain(|device| {
1440                        !device.is_hosted()
1441                            && !(device.user == own_sending_jid.user
1442                                && device.device == own_sending_jid.device)
1443                            && !cached_map.device_and_primary_warm(&device.user, device.device)
1444                    });
1445                }
1446                log::debug!(
1447                    "Resolved {} status devices needing SKDM for {}",
1448                    devices.len(),
1449                    group_jid
1450                );
1451                Ok(Some(devices))
1452            }
1453            Err(error) if freshness == crate::cache::Freshness::CachePreferred => {
1454                log::warn!(
1455                    "Failed to resolve devices for SKDM check in {}: {:?}",
1456                    group_jid,
1457                    error
1458                );
1459                Ok(None)
1460            }
1461            Err(error) => Err(error),
1462        }
1463    }
1464
1465    /// SKDM target resolution for cached-group sends: the full device set
1466    /// comes from the per-group memo (`resolve_group_devices_memoized`), so a
1467    /// warm repeat send skips the per-member registry fan-out entirely.
1468    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.resolve_skdm_targets_memoized", level = "debug", skip_all, fields(group = %group_jid)))]
1469    async fn resolve_skdm_targets_memoized(
1470        &self,
1471        group: &Jid,
1472        group_jid: &str,
1473        group_info: &std::sync::Arc<wacore::client::context::GroupInfo>,
1474        own_sending_jid: &Jid,
1475    ) -> Option<(std::sync::Arc<wacore::send::ResolvedGroupDevices>, Vec<Jid>)> {
1476        let cached_map = self.skdm_device_map(group_jid).await;
1477        match self
1478            .resolve_group_devices_memoized(group, group_info, own_sending_jid)
1479            .await
1480        {
1481            Ok(all_devices) => {
1482                // Load after the resolve await (so a cold flip during it is
1483                // visible to the hit check below) but BEFORE the filter: a
1484                // flip racing the filter stamps the inserted memo as already
1485                // stale (its stored generation lags the map's), so the next
1486                // read re-runs it. A flip after this load and before the send
1487                // is the same bounded one-send window the unmemoized filter
1488                // has, recovered by the retry-receipt resend.
1489                let cached_map_gen = cached_map.generation();
1490                // Skip the O(devices) filter_skdm_targets scan when the same
1491                // (devices, sender-key-map) Arc pair, generation AND sending
1492                // identity were already warm, reusing the memoized targets
1493                // (empty, or the own devices that re-receive their SKDM every
1494                // send). The devices Arc swaps on membership change; the
1495                // cached-map Arc swaps on a warm-mark invalidation; the
1496                // generation catches an in-place cold flip that keeps the
1497                // same Arc; the memoized needs are a pure function of that
1498                // identity.
1499                if self.device_memos_enabled
1500                    && let Some((dw, cw, memo_gen, memo_sender, memo_needs)) =
1501                        self.skdm_warm_memo.get(group).await
1502                    && std::ptr::eq(dw.as_ptr(), std::sync::Arc::as_ptr(&all_devices))
1503                    && std::ptr::eq(cw.as_ptr(), std::sync::Arc::as_ptr(&cached_map))
1504                    && memo_gen == cached_map_gen
1505                    && &memo_sender == own_sending_jid
1506                {
1507                    return Some((all_devices, memo_needs));
1508                }
1509                let needs_skdm = self.filter_skdm_targets(
1510                    group_jid,
1511                    all_devices.devices(),
1512                    &cached_map,
1513                    own_sending_jid,
1514                );
1515                if self.device_memos_enabled
1516                    && (needs_skdm.is_empty() || {
1517                        let snapshot = self.persistence_manager.get_device_snapshot();
1518                        skdm_needs_only_own_devices(
1519                            &needs_skdm,
1520                            snapshot.pn.as_ref(),
1521                            snapshot.lid.as_ref(),
1522                        )
1523                    })
1524                {
1525                    self.skdm_warm_memo
1526                        .insert(
1527                            group.clone(),
1528                            (
1529                                std::sync::Arc::downgrade(&all_devices),
1530                                std::sync::Arc::downgrade(&cached_map),
1531                                cached_map_gen,
1532                                own_sending_jid.clone(),
1533                                needs_skdm.clone(),
1534                            ),
1535                        )
1536                        .await;
1537                }
1538                Some((all_devices, needs_skdm))
1539            }
1540            Err(e) => {
1541                log::warn!(
1542                    "Failed to resolve devices for SKDM check in {}: {:?}",
1543                    group_jid,
1544                    e
1545                );
1546                None
1547            }
1548        }
1549    }
1550
1551    /// Update sender key device tracking after a successful group/status send.
1552    ///
1553    /// Called AFTER `send_node()` succeeds (WA Web: `markHasSenderKey` after server ACK).
1554    /// On full distribution, clears old state and marks the provided device list.
1555    /// On partial, marks only the specific SKDM recipients.
1556    ///
1557    /// The `all_resolved_devices` parameter carries the exact device list resolved
1558    /// for the stanza, avoiding a redundant `resolve_devices` call and preventing
1559    /// the clear-then-fail race where a transient resolver failure leaves the map empty.
1560    /// Mark devices as `has_key=true` after successful SKDM distribution.
1561    ///
1562    /// Excludes our own devices (`exclude_own_devices=true`), mirroring WA Web's
1563    /// `ParticipantStore` helper, which guards every `markHasSenderKey` mutation
1564    /// with `!isMeDevice`. Own companions are therefore never memoized as warm, so
1565    /// `filter_skdm_targets` re-distributes their SKDM on every send — the same
1566    /// reason WA Web can't orphan its own companions. Marking them here instead
1567    /// would be one-directional: the retry-receipt forget path also excludes own
1568    /// devices (to stop an inbound retry tearing down our own session), so an own
1569    /// companion whose one SKDM encryption failed could never be re-sent one.
1570    pub(crate) async fn update_sender_key_devices(&self, group_jid: &str, devices: &[Jid]) {
1571        if devices.is_empty() {
1572            return;
1573        }
1574
1575        // No invalidation on success: set_sender_key_status_for_devices
1576        // already drops the cached map when it actually writes new warm marks,
1577        // and an own-devices-only set (never memoized) writes nothing —
1578        // invalidating for it would force a DB re-read on every warm send.
1579        if let Err(e) = self
1580            .set_sender_key_status_for_devices(group_jid, devices, true, true)
1581            .await
1582        {
1583            log::warn!(
1584                "Failed to update sender key devices for {}: {:?}",
1585                group_jid,
1586                e
1587            );
1588            // A failed write may still have partially landed (backend
1589            // implementations are not required to be atomic), so drop the
1590            // cached map rather than risk serving pre-write state.
1591            self.sender_key_device_cache.invalidate(group_jid).await;
1592        }
1593    }
1594
1595    /// Cold path of the phash check: the server's phash disagreed with ours, so
1596    /// invalidate the relevant device/group caches and (for groups) force
1597    /// sender-key redistribution. Spawned only on a mismatch, which is why the
1598    /// common path costs a string comparison on the read loop.
1599    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.phash_mismatch", level = "debug", skip_all, fields(jid = %jid.observe())))]
1600    pub(crate) async fn handle_phash_mismatch(
1601        &self,
1602        jid: &Jid,
1603        our_phash: &str,
1604        server_phash: &str,
1605        invalidate_group_cache: bool,
1606    ) {
1607        log::warn!(
1608            "Phash mismatch for {}: ours={our_phash}, server={server_phash}. Invalidating caches.",
1609            jid.observe()
1610        );
1611        // DM phash covers both recipient + own devices
1612        // (WA Web: syncDeviceListJob([recipient, me]))
1613        if !jid.is_group() && !jid.is_status_broadcast() {
1614            self.invalidate_device_cache(&jid.user).await;
1615            if let Some(own_pn) = &self.persistence_manager.get_device_snapshot().pn {
1616                self.invalidate_device_cache(&own_pn.user).await;
1617            }
1618        }
1619        let jid_str = jid.to_string();
1620        // Cache-only invalidation re-reads the same stale rows on the next send.
1621        // Drop the persisted state too so the next send takes the full-
1622        // distribution path. If the clear fails, fall back to deleting the bot's
1623        // own sender key for the chat — the next send will see `!key_exists` and
1624        // force_skdm without depending on the tracker.
1625        let mut flush_fallback = false;
1626        if jid.is_group() || jid.is_status_broadcast() {
1627            let distribution_guard = self.group_distribution_lock(jid).await;
1628            if let Err(e) = self.reset_sender_key_device_tracking(&jid_str).await {
1629                log::warn!(
1630                    "phash mismatch: clear_sender_key_devices failed: {e} — \
1631                     deleting own sender key as fallback to force redistribution"
1632                );
1633                use wacore::libsignal::store::sender_key_name::SenderKeyName;
1634                use wacore::types::jid::JidExt;
1635                let snapshot = self.persistence_manager.get_device_snapshot();
1636                for own in snapshot.lid.iter().chain(snapshot.pn.iter()) {
1637                    let sk =
1638                        SenderKeyName::from_parts(&jid_str, own.to_protocol_address().as_str());
1639                    self.signal_cache.delete_sender_key(sk.cache_key()).await;
1640                }
1641                flush_fallback = true;
1642            }
1643            drop(distribution_guard);
1644        } else {
1645            self.sender_key_device_cache.invalidate(&jid_str).await;
1646        }
1647        if flush_fallback {
1648            let _ = self
1649                .flush_signal_cache_batch_safe_logged("phash-mismatch-fallback", None)
1650                .await;
1651        }
1652        if invalidate_group_cache {
1653            self.lock_group_metadata(jid).await.invalidate().await;
1654        }
1655    }
1656
1657    /// Ensure the status stanza has a <participants> node listing all recipient
1658    /// user JIDs. WhatsApp Web's `participantList` uses bare USER JIDs (not
1659    /// device JIDs) — `<to jid="user@s.whatsapp.net"/>` — to tell the server
1660    /// which users should receive the skmsg. The SKDM distribution list
1661    /// (already in <participants>) uses device JIDs with <enc> children.
1662    async fn ensure_status_participants(
1663        &self,
1664        stanza: Node,
1665        group_info: &wacore::client::context::GroupInfo,
1666    ) -> Result<Node, anyhow::Error> {
1667        Ok(wacore::send::ensure_status_participants(stanza, group_info))
1668    }
1669
1670    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.impl", level = "debug", skip_all, fields(to = %to.observe()), err(Debug)))]
1671    pub(crate) async fn send_message_impl(
1672        &self,
1673        to: Jid,
1674        message: &wa::Message,
1675        options: SendPipelineOptions<'_>,
1676    ) -> Result<(), anyhow::Error> {
1677        let SendPipelineOptions {
1678            sent_at,
1679            request_id: request_id_override,
1680            peer,
1681            force_key_distribution,
1682            edit,
1683            extra_stanza_nodes,
1684            stanza_type: stanza_type_override,
1685            group_metadata_freshness,
1686            device_freshness,
1687            borrowed_message_id,
1688        } = options;
1689        // Callers that already stamped their message hand the instant down; the
1690        // rest sample here so the pipeline below still has exactly one.
1691        let sent_at = sent_at.unwrap_or_else(SendInstant::now);
1692        validate_extra_stanza_nodes(&extra_stanza_nodes)?;
1693        if request_id_override.is_some_and(str::is_empty) {
1694            return Err(SendError::InvalidRequest("message ID must not be empty".into()).into());
1695        }
1696        // Newsletters are plaintext channels and never use the E2E path. Text
1697        // sends go through the <plaintext> branch in send_message_with_options;
1698        // edit/revoke have dedicated plaintext methods (newsletter().edit_message
1699        // / revoke_message). A newsletter JID here is a mis-routed pin/edit/revoke
1700        // (pin is not a channel op), so reject it.
1701        if to.is_newsletter() {
1702            return Err(SendError::InvalidRequest(
1703                "newsletter JIDs are not valid on the E2E send path; use \
1704                 newsletter().edit_message/revoke_message (pin is unsupported on channels)"
1705                    .into(),
1706            )
1707            .into());
1708        }
1709
1710        // status@broadcast reactions fan out pairwise to the author's devices;
1711        // status posts keep going through send_status_message (owns recipients).
1712        let (to, is_status_addon) = if to.is_status_broadcast() {
1713            let author = message
1714                .reaction_message
1715                .as_option()
1716                .and_then(|rm| rm.key.as_option())
1717                .and_then(|k| k.participant.as_ref())
1718                .and_then(|p| p.parse::<Jid>().ok())
1719                .filter(|jid| jid.is_pn() || jid.is_lid())
1720                .ok_or_else(|| {
1721                    SendError::InvalidRequest(
1722                        "send_message to status@broadcast requires \
1723                         reaction_message.key.participant = status author (user JID). \
1724                         Use client.status() for posting new statuses."
1725                            .into(),
1726                    )
1727                })?;
1728            (author, true)
1729        } else {
1730            (to, false)
1731        };
1732
1733        // Generate request ID early (doesn't need lock). This frame owns the
1734        // only copy for the whole send: the branch builders, the phash waiter
1735        // and the messageSecret persistence all borrow it, so a send names its
1736        // message exactly once no matter how many stages read that name.
1737        let generated_request_id;
1738        let request_id: &str = match request_id_override {
1739            Some(id) => id,
1740            None => {
1741                generated_request_id = self.generate_message_id_at(sent_at.unix_secs_u64());
1742                &generated_request_id
1743            }
1744        };
1745        let tc_issue_target = to.clone();
1746
1747        // Dispatch to a concrete boxed future per branch: this function's own
1748        // frame stays small (prologue + epilogue), and a DM send never
1749        // allocates the group branch's state machine, which dominated the old
1750        // single-future layout.
1751        let SendBranchOutput {
1752            node: stanza_to_send,
1753            msg_secret: outbound_msg_secret,
1754            group_sender_identity: outbound_group_sender_identity,
1755            skdm_update,
1756            distribution_guard,
1757            issue_tc_token_after_send: should_issue_tc_token_after_send,
1758            dm_phash,
1759        } = if peer && !to.is_group() {
1760            box_send_branch(self.send_peer_branch(to, message, request_id)).await?
1761        } else if to.is_group() {
1762            box_send_branch(self.send_group_branch(GroupBranchRequest {
1763                to,
1764                message,
1765                request_id,
1766                force_key_distribution,
1767                edit,
1768                extra_stanza_nodes: &extra_stanza_nodes,
1769                group_metadata_freshness,
1770                device_freshness,
1771                borrowed_message_id,
1772            }))
1773            .await?
1774        } else {
1775            box_send_branch(self.send_dm_branch(DmBranchRequest {
1776                to,
1777                message,
1778                request_id,
1779                sent_at,
1780                edit,
1781                extra_stanza_nodes,
1782                is_status_addon,
1783                device_freshness,
1784                borrowed_message_id,
1785            }))
1786            .await?
1787        };
1788
1789        // The outbound advance must be durable BEFORE the stanza hits the wire:
1790        // reusing an outbound counter reuses its message key + IV. Counters are
1791        // leased in batches (see `SessionRecord::reserve_sender_chain_counters`),
1792        // so most sends are already covered by a durable lease and only
1793        // schedule the coalesced write-behind; a send that raised either lease
1794        // flushes synchronously, and a persistence failure must abort the send
1795        // rather than transmit an advance we couldn't save.
1796        self.persist_signal_state_pre_wire().await?;
1797
1798        // A borrowed id must not register a phash ack-waiter: the waiter map is
1799        // keyed by outer stanza id, so it would overwrite the original send's
1800        // waiter (either ack could resolve the wrong send, and the older timeout
1801        // could remove the replacement). The edit's own ack is best-effort.
1802        // Registered before the stanza goes out: the ack can arrive while
1803        // send_node is still returning, and a waiter installed afterwards would
1804        // miss it.
1805        // Keying the waiter off `request_id` rather than re-reading the stanza
1806        // is only sound while every branch stamps the id it was handed; assert
1807        // that instead of paying an owned copy of an attribute we already have.
1808        debug_assert_eq!(
1809            stanza_to_send.attrs().optional_string("id").as_deref(),
1810            Some(request_id),
1811            "branch stanza must carry the id this send was named with"
1812        );
1813        let ack_message_id = if !borrowed_message_id && let Some(phash) = dm_phash {
1814            // Group sends also invalidate group cache on mismatch: the server's
1815            // participant set diverged, so the next send needs a fresh query.
1816            let invalidate_group = tc_issue_target.is_group();
1817            self.register_phash_waiter(
1818                request_id,
1819                phash,
1820                tc_issue_target.clone(),
1821                invalidate_group,
1822            );
1823            Some(request_id)
1824        } else {
1825            None
1826        };
1827
1828        // Server expects the outer `to` as the broadcast chat even though
1829        // encryption targeted the author's devices (mirrors incoming `from`).
1830        let mut stanza_to_send = stanza_to_send;
1831        if is_status_addon {
1832            stanza_to_send.attrs.insert("to", Jid::status_broadcast());
1833        }
1834        if let Some(t) = stanza_type_override {
1835            stanza_to_send.attrs.insert("type", t.as_wire());
1836        }
1837
1838        if let Err(e) = self.send_node(stanza_to_send).await {
1839            if let Some(msg_id) = ack_message_id {
1840                self.response_waiters_guard().remove(msg_id);
1841            }
1842            return Err(e.into());
1843        }
1844        // Skip when the stanza id is borrowed from another message: binding the
1845        // outbound secret under the borrowed id would overwrite the original
1846        // message's secret (breaking later reactions/poll votes on it).
1847        if !borrowed_message_id && let Some(secret) = outbound_msg_secret.as_ref() {
1848            let sender = match outbound_group_sender_identity {
1849                Some(s) => Some(s),
1850                None => self.dm_sender_identity_for(&tc_issue_target).await,
1851            };
1852            if let Some(sender) = sender {
1853                let is_bot_chat = tc_issue_target.is_bot();
1854                let class = wacore::msg_secret::classify(message, is_bot_chat);
1855                self.persist_outbound_msg_secret(
1856                    &tc_issue_target,
1857                    &sender,
1858                    request_id,
1859                    secret,
1860                    class,
1861                    sent_at,
1862                )
1863                .await;
1864            }
1865        }
1866
1867        if let Some(update) = skdm_update {
1868            self.update_sender_key_devices(&update.to_str, &update.devices)
1869                .await;
1870            for user in &update.stale_users {
1871                self.invalidate_device_cache(user).await;
1872            }
1873        }
1874        // Warm marking is visible; a waiting cold send may now re-resolve.
1875        drop(distribution_guard);
1876
1877        // Issue new tc token after send if a bucket boundary was crossed.
1878        // Fire-and-forget so send_message returns without waiting for the IQ
1879        if should_issue_tc_token_after_send {
1880            if let Some(client) = self.self_weak.get().and_then(|w| w.upgrade()) {
1881                let target = tc_issue_target;
1882                self.runtime
1883                    .spawn(Box::pin(async move {
1884                        client.issue_tc_token_after_send(&target).await;
1885                    }))
1886                    .detach();
1887            } else {
1888                log::debug!(target: "Client/TcToken", "Skipping fire-and-forget issuance: client dropped");
1889            }
1890        }
1891
1892        Ok(())
1893    }
1894
1895    /// Peer branch of [`Self::send_message_impl`]: own-device sync messages,
1896    /// never groups.
1897    async fn send_peer_branch(
1898        &self,
1899        to: Jid,
1900        message: &wa::Message,
1901        request_id: &str,
1902    ) -> Result<SendBranchOutput, anyhow::Error> {
1903        let node = {
1904            // Peer messages are only valid for individual users, not groups
1905            // Resolve encryption JID and acquire lock ONLY for encryption
1906            let encryption_jid = self.resolve_encryption_jid(&to).await;
1907            let signal_addr = encryption_jid.to_protocol_address();
1908
1909            let session_mutex = self.session_lock_for(signal_addr.as_str()).await;
1910            let _session_guard = session_mutex.lock().await;
1911
1912            let mut store_adapter = self.signal_adapter().await;
1913
1914            let device_snapshot = self.persistence_manager.get_device_snapshot();
1915            wacore::send::prepare_peer_stanza(
1916                &mut store_adapter.session_store,
1917                &mut store_adapter.identity_store,
1918                to,
1919                &signal_addr,
1920                message,
1921                request_id,
1922                device_snapshot.account.as_deref(),
1923            )
1924            .await?
1925        };
1926        Ok(SendBranchOutput::stanza_only(node))
1927    }
1928
1929    /// Group branch of [`Self::send_message_impl`]: sender-key encryption,
1930    /// SKDM distribution and the cold/rotation single-flight.
1931    async fn send_group_branch(
1932        &self,
1933        request: GroupBranchRequest<'_>,
1934    ) -> Result<SendBranchOutput, anyhow::Error> {
1935        let GroupBranchRequest {
1936            to,
1937            message,
1938            request_id,
1939            force_key_distribution,
1940            edit,
1941            extra_stanza_nodes,
1942            group_metadata_freshness,
1943            device_freshness,
1944            borrowed_message_id,
1945        } = request;
1946        // Every arm of the prepare match below assigns these three.
1947        let outbound_msg_secret: Option<[u8; 32]>;
1948        let outbound_group_sender_identity: Option<Jid>;
1949        let skdm_update: Option<SkdmUpdate>;
1950        let mut distribution_guard: Option<async_lock::MutexGuardArc<()>> = None;
1951        let node = {
1952            // No send-level lock: encrypt_group_message serializes the
1953            // sender-key chain advance per (group, sender) at the cipher.
1954            let group_info = self
1955                .groups()
1956                .query_info_with_freshness(&to, group_metadata_freshness)
1957                .await?;
1958
1959            // Borrow from the held snapshot: no field clones, the Arc keeps it alive.
1960            let device_snapshot = self.persistence_manager.get_device_snapshot();
1961            let account_info = &device_snapshot.account;
1962            let own_jid = device_snapshot
1963                .pn
1964                .as_ref()
1965                .ok_or(ClientError::NotLoggedIn)?;
1966            let own_lid = device_snapshot
1967                .lid
1968                .as_ref()
1969                .ok_or_else(|| anyhow!("LID not set, cannot send to group"))?;
1970
1971            // One encode feeds retry cache and wire; mci-hoist re-encodes (folded context).
1972            let shared_content = message
1973                .message_context_info
1974                .is_unset()
1975                .then(|| std::sync::Arc::new(waproto::codec::message_to_vec(message)));
1976            // Store serialized message bytes for retry (lightweight). Skip when
1977            // the id is borrowed: it would replace the original message's
1978            // retry-cache entry, so a retry receipt for it returns this edit.
1979            if !borrowed_message_id {
1980                self.add_recent_message(&to, request_id, message, shared_content.clone())
1981                    .await;
1982            }
1983
1984            let device_store_arc = self.persistence_manager.get_device_arc().await;
1985            let to_str = to.to_string();
1986
1987            let (own_sending_jid, _) = match group_info.addressing_mode {
1988                AddressingMode::Lid => (own_lid.clone(), "lid"),
1989                AddressingMode::Pn => (own_jid.clone(), "pn"),
1990            };
1991
1992            // Memo identity must be the CACHED Arc: ensure_self_in_group clones
1993            // a fresh GroupInfo whenever self is absent from the snapshot, which
1994            // would make the memo miss on every send to such groups. The memoized
1995            // resolver applies the same self-append internally.
1996            let group_info_for_memo = std::sync::Arc::clone(&group_info);
1997            let refreshed_devices = if device_freshness == crate::cache::Freshness::Refresh {
1998                Some(
1999                    self.resolve_group_devices_uncached(
2000                        &group_info_for_memo,
2001                        &own_sending_jid,
2002                        crate::cache::Freshness::Refresh,
2003                    )
2004                    .await?,
2005                )
2006            } else {
2007                None
2008            };
2009            // resolve_skdm_targets and prepare_group_stanza both read the
2010            // participant list and expect self to be present.
2011            let group_info = ensure_self_in_group(group_info, &own_sending_jid);
2012
2013            // Side-effect-free cold check: does the sender key record exist,
2014            // and has its chain advanced past the rotation threshold? Reads
2015            // the record without deleting anything, so a false positive (a
2016            // concurrent send already rotating/recreating) costs only the
2017            // re-check under the lock below.
2018            use wacore::libsignal::store::sender_key_name::SenderKeyName;
2019            let sender_address = own_sending_jid.to_protocol_address();
2020            let sender_key_name = SenderKeyName::from_parts(&to_str, sender_address.as_str());
2021            // WA Web posts SenderKeyExpired with `PERIODIC_ROTATION` after
2022            // a chain advances past a threshold. Captured-js doesn't show
2023            // the value; 1000 mirrors common Signal hygiene defaults.
2024            const SENDER_KEY_ROTATION_THRESHOLD: u32 = 1000;
2025            let read_sender_key_state = || async {
2026                let record = self
2027                    .signal_cache
2028                    .get_sender_key(&sender_key_name, &*device_snapshot.backend)
2029                    .await?;
2030                let key_exists = record.is_some();
2031                // Read the chain iteration through the shared `Arc` without cloning
2032                // the record: borrow the current state instead of `*_mut().cloned()`.
2033                let needs_rotation = record
2034                    .as_ref()
2035                    .and_then(|r| r.sender_key_state().ok())
2036                    .and_then(|state| state.sender_chain_key())
2037                    .map(|ck| ck.iteration())
2038                    .is_some_and(|iter| iter >= SENDER_KEY_ROTATION_THRESHOLD);
2039                Ok::<(bool, bool), anyhow::Error>((key_exists, needs_rotation))
2040            };
2041
2042            let (key_exists, needs_rotation) = read_sender_key_state().await?;
2043            let mut force_skdm = force_key_distribution || !key_exists || needs_rotation;
2044            if force_skdm {
2045                // Serialize the whole rotation/redistribution under the
2046                // per-group guard and RE-CHECK once inside it: a send that
2047                // merely raced the winner's delete->recreate window sees the
2048                // fresh record here and downgrades to a warm send instead of
2049                // redistributing to every member again.
2050                distribution_guard = Some(self.group_distribution_lock(&to).await);
2051                let (key_exists, needs_rotation) = read_sender_key_state().await?;
2052                force_skdm = force_key_distribution || !key_exists || needs_rotation;
2053                if !key_exists || needs_rotation {
2054                    self.reset_sender_key_device_tracking(&to_str).await?;
2055                }
2056                if needs_rotation {
2057                    log::info!(
2058                        "Periodic sender-key rotation for {} (chain iteration >= {SENDER_KEY_ROTATION_THRESHOLD})",
2059                        to.observe()
2060                    );
2061                    self.signal_cache
2062                        .delete_sender_key(sender_key_name.cache_key())
2063                        .await;
2064                }
2065                if !force_skdm {
2066                    distribution_guard = None;
2067                }
2068            }
2069
2070            let mut store_adapter = self.signal_adapter_from(device_store_arc.clone());
2071
2072            let mut stores = store_adapter.as_signal_stores();
2073
2074            // Determine which devices need SKDM distribution using the unified
2075            // per-device sender key map (matches WA Web's participant.senderKey Map).
2076            // `all_devices_for_phash` carries the FULL resolved set so the phash
2077            // covers every device + self even on a warm send (WA Web sends a
2078            // phash on every group send); `skdm_target_devices` is the subset
2079            // still missing the key. On the cold/`force_skdm` path both are
2080            // `None` and `prepare_group_stanza` resolves the set itself.
2081            let (all_devices_for_phash, skdm_target_devices): (
2082                Option<GroupDeviceSnapshot>,
2083                Option<Vec<Jid>>,
2084            ) = if force_skdm {
2085                match refreshed_devices {
2086                    Some(mut targets) => {
2087                        wacore::send::retain_skdm_distribution_targets(
2088                            &mut targets,
2089                            &own_sending_jid,
2090                        );
2091                        (None, Some(targets))
2092                    }
2093                    None => (None, None),
2094                }
2095            } else {
2096                let initial_targets = match refreshed_devices {
2097                    Some(all) => {
2098                        let all = GroupDeviceSnapshot::Owned(
2099                            wacore::send::ResolvedGroupDevices::new(all),
2100                        );
2101                        let cached_map = self.skdm_device_map(&to_str).await;
2102                        let needs = self.filter_skdm_targets(
2103                            &to_str,
2104                            all.as_ref().devices(),
2105                            &cached_map,
2106                            &own_sending_jid,
2107                        );
2108                        Some((all, needs))
2109                    }
2110                    None => self
2111                        .resolve_skdm_targets_memoized(
2112                            &to,
2113                            &to_str,
2114                            &group_info_for_memo,
2115                            &own_sending_jid,
2116                        )
2117                        .await
2118                        .map(|(all, needs)| (GroupDeviceSnapshot::Shared(all), needs)),
2119                };
2120                match initial_targets {
2121                    Some((all, needs)) if needs.is_empty() => (Some(all), Some(needs)),
2122                    // Own devices are never memoized warm, so they re-receive
2123                    // their SKDM on every send by design — own-only needs IS
2124                    // the warm steady state, not a cold group: no distribution
2125                    // guard, no cache invalidation, no re-resolve.
2126                    Some((all, needs))
2127                        if skdm_needs_only_own_devices(&needs, Some(own_jid), Some(own_lid)) =>
2128                    {
2129                        (Some(all), Some(needs))
2130                    }
2131                    Some((first_all, first_needs)) => {
2132                        // Cold: wait for any in-flight distribution, then
2133                        // re-resolve. The loser usually finds every device
2134                        // already marked warm by the winner and downgrades to a
2135                        // plain skmsg send; if the winner failed, the targets
2136                        // are still cold and this send distributes normally.
2137                        distribution_guard = Some(self.group_distribution_lock(&to).await);
2138                        // Force a DB re-read: a concurrent warm send may have
2139                        // started the cache init before the winner's marking
2140                        // landed and then published that stale (empty) map,
2141                        // which would otherwise turn this into a full
2142                        // re-distribution to every member.
2143                        self.sender_key_device_cache.invalidate(&to_str).await;
2144                        match self
2145                            .resolve_skdm_targets_memoized(
2146                                &to,
2147                                &to_str,
2148                                &group_info_for_memo,
2149                                &own_sending_jid,
2150                            )
2151                            .await
2152                        {
2153                            Some((all, needs)) => {
2154                                // Fully warm OR down to the own-only steady
2155                                // state: nothing left that needs the
2156                                // single-flight, release it before the send.
2157                                if needs.is_empty()
2158                                    || skdm_needs_only_own_devices(
2159                                        &needs,
2160                                        Some(own_jid),
2161                                        Some(own_lid),
2162                                    )
2163                                {
2164                                    distribution_guard = None;
2165                                }
2166                                (Some(GroupDeviceSnapshot::Shared(all)), Some(needs))
2167                            }
2168                            // Transient re-resolve failure: keep the first
2169                            // resolve's targets rather than silently sending
2170                            // without the distribution it already knew was
2171                            // needed.
2172                            None => (Some(first_all), Some(first_needs)),
2173                        }
2174                    }
2175                    None => (None, None),
2176                }
2177            };
2178
2179            match wacore::send::prepare_group_stanza(
2180                &*self.runtime,
2181                &mut stores,
2182                self,
2183                wacore::send::GroupStanzaRequest {
2184                    group: &group_info,
2185                    own_jid,
2186                    own_lid,
2187                    account: account_info.as_deref(),
2188                    to: &to,
2189                    message,
2190                    message_id: request_id,
2191                    force_distribution: force_skdm,
2192                    distribution_targets: skdm_target_devices,
2193                    distribution_policy: wacore::send::SenderKeyDistributionPolicy::BestEffort,
2194                    phash_devices: all_devices_for_phash.as_ref().map(AsRef::as_ref),
2195                    edit: edit.as_ref(),
2196                    extra_nodes: extra_stanza_nodes,
2197                    pre_encoded: shared_content.as_deref().map(Vec::as_slice),
2198                },
2199            )
2200            .await
2201            {
2202                Ok(prepared) => {
2203                    skdm_update = Some(SkdmUpdate {
2204                        to_str: to_str.clone(),
2205                        devices: prepared.skdm_devices,
2206                        stale_users: prepared.stale_device_users,
2207                    });
2208                    outbound_msg_secret = prepared.message_secret;
2209                    outbound_group_sender_identity = Some(prepared.sender_identity);
2210                    prepared.node
2211                }
2212                Err(e) => {
2213                    if let Some(SignalProtocolError::NoSenderKeyState(_)) =
2214                        e.downcast_ref::<SignalProtocolError>()
2215                    {
2216                        log::warn!(
2217                            "No sender key for group {}, forcing distribution.",
2218                            to.observe()
2219                        );
2220
2221                        // This retry redistributes, so it needs the same
2222                        // single-flight guard as a cold send (a warm send that
2223                        // lost its sender key arrives here without one).
2224                        if distribution_guard.is_none() {
2225                            distribution_guard = Some(self.group_distribution_lock(&to).await);
2226                        }
2227
2228                        // Re-check under the guard: a concurrent retry may have
2229                        // already recreated the key and marked the devices, in
2230                        // which case this send retries warm instead of clearing
2231                        // the tracking and redistributing to every member again.
2232                        let (key_recreated, _) = read_sender_key_state().await?;
2233                        let warm_targets = if key_recreated {
2234                            self.sender_key_device_cache.invalidate(&to_str).await;
2235                            self.resolve_skdm_targets_memoized(
2236                                &to,
2237                                &to_str,
2238                                &group_info_for_memo,
2239                                &own_sending_jid,
2240                            )
2241                            .await
2242                        } else {
2243                            None
2244                        };
2245                        let (retry_force, retry_targets, retry_all) = match warm_targets {
2246                            Some((all, needs)) => {
2247                                (false, Some(needs), Some(GroupDeviceSnapshot::Shared(all)))
2248                            }
2249                            None => {
2250                                self.reset_sender_key_device_tracking(&to_str).await?;
2251                                (true, None, None)
2252                            }
2253                        };
2254
2255                        let mut store_adapter_retry =
2256                            self.signal_adapter_from(device_store_arc.clone());
2257                        let mut stores_retry = store_adapter_retry.as_signal_stores();
2258
2259                        let retry_prepared = wacore::send::prepare_group_stanza(
2260                            &*self.runtime,
2261                            &mut stores_retry,
2262                            self,
2263                            wacore::send::GroupStanzaRequest {
2264                                group: &group_info,
2265                                own_jid,
2266                                own_lid,
2267                                account: account_info.as_deref(),
2268                                to: &to,
2269                                message,
2270                                message_id: request_id,
2271                                force_distribution: retry_force,
2272                                distribution_targets: retry_targets,
2273                                distribution_policy:
2274                                    wacore::send::SenderKeyDistributionPolicy::BestEffort,
2275                                phash_devices: retry_all.as_ref().map(AsRef::as_ref),
2276                                edit: edit.as_ref(),
2277                                extra_nodes: extra_stanza_nodes,
2278                                pre_encoded: shared_content.as_deref().map(Vec::as_slice),
2279                            },
2280                        )
2281                        .await?;
2282
2283                        skdm_update = Some(SkdmUpdate {
2284                            to_str,
2285                            devices: retry_prepared.skdm_devices,
2286                            stale_users: retry_prepared.stale_device_users,
2287                        });
2288                        outbound_msg_secret = retry_prepared.message_secret;
2289                        outbound_group_sender_identity = Some(retry_prepared.sender_identity);
2290                        retry_prepared.node
2291                    } else {
2292                        return Err(e);
2293                    }
2294                }
2295            }
2296        };
2297        Ok(SendBranchOutput {
2298            node,
2299            msg_secret: outbound_msg_secret,
2300            group_sender_identity: outbound_group_sender_identity,
2301            skdm_update,
2302            distribution_guard,
2303            issue_tc_token_after_send: false,
2304            dm_phash: None,
2305        })
2306    }
2307
2308    /// DM branch of [`Self::send_message_impl`]: pairwise Signal encryption
2309    /// with device fan-out (also used by status-reaction add-ons).
2310    async fn send_dm_branch(
2311        &self,
2312        request: DmBranchRequest<'_>,
2313    ) -> Result<SendBranchOutput, anyhow::Error> {
2314        let DmBranchRequest {
2315            to,
2316            message,
2317            request_id,
2318            sent_at,
2319            edit,
2320            extra_stanza_nodes,
2321            is_status_addon,
2322            device_freshness,
2323            borrowed_message_id,
2324        } = request;
2325        let mut should_issue_tc_token_after_send = false;
2326        let prepared = {
2327            // Per-device locking to match decrypt path (message.rs:684),
2328            // preventing ratchet desync on concurrent send/receive.
2329
2330            // One encode feeds retry cache and wire; mci-hoist re-encodes (folded context).
2331            let shared_content = message
2332                .message_context_info
2333                .is_unset()
2334                .then(|| std::sync::Arc::new(waproto::codec::message_to_vec(message)));
2335            // Status reaction retries arrive with `from=status@broadcast`;
2336            // cache under the broadcast chat so take_recent_message hits. Skip
2337            // for a borrowed id: it would replace the original message's
2338            // retry-cache entry (a retry receipt for it would return this edit).
2339            if !borrowed_message_id {
2340                if is_status_addon {
2341                    self.add_recent_message(
2342                        &Jid::status_broadcast(),
2343                        request_id,
2344                        message,
2345                        shared_content.clone(),
2346                    )
2347                    .await;
2348                } else {
2349                    self.add_recent_message(&to, request_id, message, shared_content.clone())
2350                        .await;
2351                }
2352            }
2353
2354            let device_snapshot = self.persistence_manager.get_device_snapshot();
2355            let own_jid = device_snapshot
2356                .pn
2357                .as_ref()
2358                .ok_or(ClientError::NotLoggedIn)?;
2359
2360            // PN→LID mapping (WA Web: ManagePhoneNumberMappingJob)
2361            if to.is_pn() && self.lid_pn_cache.get_current_lid(&to.user).await.is_none() {
2362                let sid = self.generate_request_id();
2363                let spec = wacore::iq::usync::LidQuerySpec::new(vec![to.to_non_ad()], sid);
2364                // Best-effort: WA Web also catches and warns on failure
2365                match self.execute(spec).await {
2366                    Ok(resp) => {
2367                        for mapping in &resp.lid_mappings {
2368                            if let Err(e) = self
2369                                .add_lid_pn_mapping(
2370                                    &mapping.lid,
2371                                    &mapping.phone_number,
2372                                    crate::lid_pn_cache::LearningSource::Usync,
2373                                )
2374                                .await
2375                            {
2376                                log::warn!(
2377                                    "Failed to persist LID mapping {} -> {}: {e:?}",
2378                                    mapping.phone_number,
2379                                    mapping.lid
2380                                );
2381                            }
2382                        }
2383                    }
2384                    Err(e) => {
2385                        log::warn!(
2386                            "LID query failed for {}, falling back to PN: {e:?}",
2387                            to.observe()
2388                        );
2389                    }
2390                }
2391            }
2392
2393            // The LID-vs-PN wire namespace is an account-level decision: the
2394            // server 400-nacks LID-addressed DMs from accounts that are not
2395            // 1:1-LID-migrated (issue #941).
2396            let recipient_bare = self.resolve_dm_wire_jid(&to).await;
2397
2398            let stanza_to = dm_stanza_to(&recipient_bare, &to);
2399
2400            // DM fanout, memoized per recipient: a warm repeat DM skips both
2401            // registry lookups, the list rebuild and the phash. See
2402            // `resolve_dm_devices_memoized` for its freshness contract.
2403            let dm_devices = self
2404                .resolve_dm_devices_memoized(
2405                    &to,
2406                    &recipient_bare,
2407                    own_jid,
2408                    device_snapshot.lid.as_ref(),
2409                    device_freshness,
2410                )
2411                .await?;
2412
2413            self.ensure_e2e_sessions(dm_devices.devices()).await?;
2414
2415            let mut extra_stanza_nodes = extra_stanza_nodes;
2416            // tctoken applies to 1:1 chats; status reactions share the fanout
2417            // path but WA Web does not attach tctokens to them.
2418            if !to.is_group() && !to.is_newsletter() && !is_status_addon {
2419                should_issue_tc_token_after_send = self
2420                    .maybe_include_tc_token(&to, &mut extra_stanza_nodes, sent_at)
2421                    .await;
2422            }
2423            if should_issue_tc_token_after_send {
2424                debug!(target: "Client/TcToken", "Scheduled tc token issuance after send for {}", to.observe());
2425            }
2426
2427            let lock_jids = self.build_session_lock_keys(dm_devices.devices()).await;
2428            let _session_guards = self.session_guards_for(&lock_jids).await;
2429
2430            let mut store_adapter = self.signal_adapter().await;
2431
2432            let mut stores = store_adapter.as_signal_stores();
2433
2434            wacore::send::prepare_dm_stanza(
2435                &*self.runtime,
2436                &mut stores,
2437                self,
2438                wacore::send::DmStanzaRequest {
2439                    own_jid,
2440                    account: device_snapshot.account.as_deref(),
2441                    to: &stanza_to,
2442                    message,
2443                    message_id: request_id,
2444                    edit: edit.as_ref(),
2445                    extra_nodes: &extra_stanza_nodes,
2446                    devices: &dm_devices,
2447                    pre_encoded: shared_content.as_deref().map(Vec::as_slice),
2448                },
2449            )
2450            .await?
2451        };
2452        Ok(SendBranchOutput {
2453            node: prepared.node,
2454            msg_secret: prepared.message_secret,
2455            group_sender_identity: None,
2456            skdm_update: None,
2457            distribution_guard: None,
2458            issue_tc_token_after_send: should_issue_tc_token_after_send,
2459            dm_phash: prepared.phash,
2460        })
2461    }
2462
2463    /// Persist a generated `MessageContextInfo.message_secret` keyed by
2464    /// `(chat_non_ad, sender_non_ad, msg_id)`. The sender identity must
2465    /// match what `<meta target_sender_jid>` echoes back at GET time —
2466    /// LID for bot chats and LID-mode groups, PN otherwise.
2467    pub(crate) async fn persist_outbound_msg_secret(
2468        &self,
2469        chat: &Jid,
2470        sender: &Jid,
2471        msg_id: &str,
2472        secret: &[u8; wacore::reporting_token::MESSAGE_SECRET_SIZE],
2473        class: wacore::msg_secret::RetentionClass,
2474        sent_at: SendInstant,
2475    ) {
2476        let policy = self.cache_config.msg_secret_policy;
2477        if !policy.persists() {
2478            return;
2479        }
2480        // BotOnly keeps only bot-context secrets; a group message that invokes a
2481        // bot classifies as Bot, so its reply can still be decrypted.
2482        if policy.bot_only() && class != wacore::msg_secret::RetentionClass::Bot {
2483            return;
2484        }
2485        // Outbound secrets are minted with the parent event, so the send's own
2486        // instant IS the parent event time.
2487        let now = sent_at.unix_secs();
2488        let expires_at = wacore::msg_secret::expires_at(
2489            policy,
2490            &self.cache_config.msg_secret_retention,
2491            class,
2492            u64::try_from(now).ok(),
2493            now,
2494        );
2495        let entry = wacore::store::traits::MsgSecretEntry::new(
2496            chat, sender, msg_id, *secret, expires_at, now,
2497        );
2498        // Same write-behind buffer as inbound captures: visible immediately,
2499        // flushed off the send path (msmsg replies read buffer-first).
2500        self.msg_secret_buffer.queue_one(entry).await;
2501    }
2502
2503    /// Decide the identity (LID vs PN) under which an outbound DM's
2504    /// `messageSecret` should be persisted. Group sends should use
2505    /// `PreparedGroupStanza.sender_identity` directly instead of this.
2506    pub(crate) async fn dm_sender_identity_for(&self, to: &Jid) -> Option<Jid> {
2507        if to.server == Server::Bot {
2508            self.lid()
2509        } else {
2510            self.pn()
2511        }
2512    }
2513
2514    /// Build sorted, deduplicated per-device session lock keys.
2515    /// INVARIANT: Keys are sorted to prevent deadlocks when acquiring multiple
2516    /// session locks (e.g. DM sends that encrypt for recipient + own devices).
2517    /// Resolve encryption JIDs and sort for deadlock-free lock acquisition.
2518    pub(crate) async fn build_session_lock_keys(&self, device_jids: &[Jid]) -> Vec<Jid> {
2519        let mut keys: Vec<Jid> = Vec::with_capacity(device_jids.len());
2520        for jid in device_jids {
2521            keys.push(self.resolve_encryption_jid(jid).await);
2522        }
2523        keys.sort_unstable_by(wacore::types::jid::cmp_for_lock_order);
2524        keys.dedup_by(|a, b| wacore::types::jid::cmp_for_lock_order(a, b).is_eq());
2525        keys
2526    }
2527
2528    /// Take every per-device session lock, in `jids` order.
2529    ///
2530    /// INVARIANT: acquisition order IS `jids` order, and callers pass keys from
2531    /// [`Self::build_session_lock_keys`], which sorts them. That single order is
2532    /// what keeps two sends overlapping on a device from deadlocking, so a
2533    /// change here has to preserve it.
2534    ///
2535    /// Each mutex is locked as it is resolved rather than resolving the whole
2536    /// set first: the handles exist only to be locked, so the vector holding
2537    /// them was pure staging. The guards themselves must still be collected —
2538    /// they are what keeps the locks held for the caller's scope.
2539    pub(crate) async fn session_guards_for(
2540        &self,
2541        jids: &[Jid],
2542    ) -> Vec<async_lock::MutexGuardArc<()>> {
2543        // A duplicate key would have this loop await a lock it already holds,
2544        // which is a silent self-deadlock rather than a panic: the send just
2545        // never returns. Every caller goes through `build_session_lock_keys`,
2546        // which sorts and dedups, so this only fires if a future path forgets
2547        // to.
2548        debug_assert!(
2549            jids.windows(2).all(|pair| pair[0] != pair[1]),
2550            "session lock keys must be deduped before acquisition, or the loop deadlocks on itself"
2551        );
2552
2553        let mut guards = Vec::with_capacity(jids.len());
2554        // A `ProtocolAddress` IS the "{name}.0" string the lock map is keyed by,
2555        // and it holds it inline, so the whole loop names its keys without
2556        // allocating a formatting buffer.
2557        let mut addr = wacore::types::jid::make_reusable_protocol_address();
2558        for jid in jids {
2559            jid.reset_protocol_address(&mut addr);
2560            let mutex = self.session_lock_for(addr.as_str()).await;
2561            guards.push(mutex.lock_arc().await);
2562        }
2563        guards
2564    }
2565
2566    /// The mutexes [`Self::session_guards_for`] would take, without taking
2567    /// them. Only tests need this: production code always wants the guards, and
2568    /// resolving handles it does not lock is what this commit removed.
2569    #[cfg(test)]
2570    pub(crate) async fn session_mutexes_for(
2571        &self,
2572        jids: &[Jid],
2573    ) -> Vec<std::sync::Arc<async_lock::Mutex<()>>> {
2574        let mut mutexes = Vec::with_capacity(jids.len());
2575        let mut addr = wacore::types::jid::make_reusable_protocol_address();
2576        for jid in jids {
2577            jid.reset_protocol_address(&mut addr);
2578            mutexes.push(self.session_lock_for(addr.as_str()).await);
2579        }
2580        mutexes
2581    }
2582}
2583
2584/// Self-DM detection: appending an own-device lookup on top of the
2585/// recipient's list would address each physical device twice (LID + PN),
2586/// which the server rejects with `ack error="400"`.
2587/// WAWebDBDeviceListFanout never re-fetches the own list for the same account.
2588pub(crate) fn is_self_dm_recipient(
2589    recipient_bare: &Jid,
2590    own_pn: &Jid,
2591    own_lid: Option<&Jid>,
2592) -> bool {
2593    match recipient_bare.server {
2594        Server::Lid => own_lid.is_some_and(|lid| recipient_bare.user == lid.user),
2595        Server::Pn => recipient_bare.user == own_pn.user,
2596        _ => false,
2597    }
2598}
2599
2600/// The outer `<message to>`, the DeviceSentMessage destinationJid, and the
2601/// reporting-token remote jid must share the participants' namespace.
2602/// WAWebSendMsgCreateFanoutStanza builds the whole stanza from one CHAT_JID
2603/// (always a bare user wid), so the `to` is the resolved wire jid whenever
2604/// the caller's namespace differs from it (LID upgrade, or PN downgrade on
2605/// an unmigrated account), and a device-qualified caller jid is normalized
2606/// to the bare chat jid. A `to` mixing namespaces with the participants is
2607/// rejected wholesale by the server with `ack error="400"`.
2608pub(crate) fn dm_stanza_to(recipient_bare: &Jid, to: &Jid) -> Jid {
2609    if recipient_bare.is_lid() || to.is_lid() {
2610        recipient_bare.clone()
2611    } else {
2612        to.to_non_ad()
2613    }
2614}
2615
2616#[cfg(test)]
2617#[allow(clippy::disallowed_methods)]
2618mod tests {
2619    use super::*;
2620    use crate::test_utils::wait_for_lock_waiter;
2621    use std::str::FromStr;
2622
2623    #[test]
2624    fn status_revoke_requires_a_distinct_outer_stanza_id() {
2625        let target_id = "3EB0REVOKETARGET";
2626        let revoke = wa::Message {
2627            protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage {
2628                r#type: Some(wa::message::protocol_message::Type::Revoke),
2629                key: buffa::MessageField::some(wa::MessageKey {
2630                    id: Some(target_id.into()),
2631                    ..Default::default()
2632                }),
2633                ..Default::default()
2634            }),
2635            ..Default::default()
2636        };
2637
2638        assert!(matches!(
2639            validate_status_message_id(&revoke, Some(target_id)),
2640            Err(SendError::InvalidRequest(_))
2641        ));
2642        assert!(validate_status_message_id(&revoke, Some("3EB0NEWSTANZAID")).is_ok());
2643        assert!(validate_status_message_id(&revoke, None).is_ok());
2644    }
2645
2646    #[test]
2647    fn dm_stanza_to_follows_resolved_wire_namespace() {
2648        let pn: Jid = "5511987650001@s.whatsapp.net".parse().unwrap();
2649        let lid: Jid = "111000011112222@lid".parse().unwrap();
2650
2651        // PN caller, PN wire (unmigrated or unmapped): caller jid preserved.
2652        assert_eq!(dm_stanza_to(&pn, &pn), pn);
2653        // PN caller upgraded to LID wire: `to` must be the LID.
2654        assert_eq!(dm_stanza_to(&lid, &pn), lid);
2655        // LID caller kept on LID wire: unchanged.
2656        assert_eq!(dm_stanza_to(&lid, &lid), lid);
2657        // LID caller downgraded to PN wire (unmigrated account): `to` must be
2658        // the PN — reusing the caller's LID would mix namespaces.
2659        assert_eq!(dm_stanza_to(&pn, &lid), pn);
2660        // Device-qualified caller jid is normalized to the bare chat jid.
2661        let pn_device: Jid = "5511987650001:5@s.whatsapp.net".parse().unwrap();
2662        assert_eq!(dm_stanza_to(&pn, &pn_device), pn);
2663    }
2664
2665    #[test]
2666    fn ensure_self_in_group_shares_when_present_and_appends_when_absent() {
2667        use wacore::client::context::GroupInfo;
2668        use wacore::types::message::AddressingMode;
2669
2670        let own: Jid = "999999999999@s.whatsapp.net".parse().unwrap();
2671        let other: Jid = "111111111111@s.whatsapp.net".parse().unwrap();
2672
2673        // Self already a member (the common case): the shared Arc passes through
2674        // untouched, with no deep clone of the participant list.
2675        let with_self = Arc::new(GroupInfo::new(
2676            vec![other.to_non_ad(), own.to_non_ad()],
2677            AddressingMode::Pn,
2678        ));
2679        let out = ensure_self_in_group(with_self.clone(), &own);
2680        assert!(Arc::ptr_eq(&with_self, &out));
2681
2682        // Self missing: a fresh GroupInfo is built with self appended.
2683        let without_self = Arc::new(GroupInfo::new(vec![other.to_non_ad()], AddressingMode::Pn));
2684        let out = ensure_self_in_group(without_self.clone(), &own);
2685        assert!(!Arc::ptr_eq(&without_self, &out));
2686        assert_eq!(out.participants.len(), 2);
2687        assert!(out.participants.iter().any(|p| p.is_same_user_as(&own)));
2688    }
2689
2690    // The group SKDM pairwise fan-out must hold the SAME per-device session mutex
2691    // the DM path locks, so the two can't advance a shared device's ratchet at
2692    // once. Acquiring the group lock must block the DM per-device lock.
2693    #[tokio::test]
2694    async fn group_skdm_lock_shares_dm_per_device_session_mutex() {
2695        use wacore::client::context::SendContextResolver;
2696
2697        let client = crate::test_utils::create_test_client().await;
2698        let device: Jid = "15551234567:3@s.whatsapp.net".parse().unwrap();
2699
2700        // The exact mutex the DM send path would lock for this device.
2701        let keys = client
2702            .build_session_lock_keys(std::slice::from_ref(&device))
2703            .await;
2704        let dm_mutexes = client.session_mutexes_for(&keys).await;
2705        assert_eq!(dm_mutexes.len(), 1);
2706        assert!(
2707            dm_mutexes[0].try_lock().is_some(),
2708            "uncontended before the group lock"
2709        );
2710
2711        // Hold the group SKDM lock for the same device.
2712        let guard = client
2713            .lock_device_sessions(std::slice::from_ref(&device))
2714            .await;
2715        assert!(
2716            dm_mutexes[0].try_lock().is_none(),
2717            "group SKDM fan-out must block the DM per-device session lock"
2718        );
2719
2720        drop(guard);
2721        assert!(
2722            dm_mutexes[0].try_lock().is_some(),
2723            "the per-device session lock releases when the group guard drops"
2724        );
2725    }
2726
2727    #[tokio::test]
2728    async fn send_message_to_status_without_reaction_errors() {
2729        let client = crate::test_utils::create_test_client().await;
2730        let to = Jid::status_broadcast();
2731        let err = client
2732            .send_message(
2733                to,
2734                wa::Message {
2735                    conversation: Some("hi".into()),
2736                    ..Default::default()
2737                },
2738            )
2739            .await
2740            .expect_err("status@broadcast without reaction must error");
2741        let msg = format!("{err}");
2742        assert!(
2743            msg.contains("reaction_message") || msg.contains("status"),
2744            "unexpected error: {msg}"
2745        );
2746    }
2747
2748    #[tokio::test]
2749    async fn status_send_waits_for_distribution_guard() {
2750        let client = crate::test_utils::create_test_client().await;
2751        let own_pn: Jid = "15551234001@s.whatsapp.net".parse().unwrap();
2752        let own_lid: Jid = "100000000000001@lid".parse().unwrap();
2753        client
2754            .persistence_manager
2755            .process_command(DeviceCommand::SetId(Some(own_pn)))
2756            .await;
2757        client
2758            .persistence_manager
2759            .process_command(DeviceCommand::SetLid(Some(own_lid)))
2760            .await;
2761
2762        let status = Jid::status_broadcast();
2763        let held = client.group_distribution_lock(&status).await;
2764        let lock = client
2765            .group_distribution_locks
2766            .get(&status)
2767            .await
2768            .expect("cached distribution lock");
2769        let lock_refs = Arc::strong_count(&lock);
2770        let mut task = tokio::spawn({
2771            let client = client.clone();
2772            async move {
2773                let recipient: Jid = "100000000000002@lid".parse().unwrap();
2774                client
2775                    .send_status_message(
2776                        wa::Message {
2777                            conversation: Some("serialized status".into()),
2778                            ..Default::default()
2779                        },
2780                        std::slice::from_ref(&recipient),
2781                        crate::features::status::StatusSendOptions::default(),
2782                    )
2783                    .await
2784            }
2785        });
2786
2787        wait_for_lock_waiter(&lock, lock_refs).await;
2788        assert!(!task.is_finished(), "status send must wait for the lane");
2789        drop(held);
2790
2791        let _ = tokio::time::timeout(std::time::Duration::from_secs(5), &mut task)
2792            .await
2793            .expect("status send must resume")
2794            .expect("status task");
2795    }
2796
2797    // A logged-out send goes through send_message_impl, whose internal
2798    // `ClientError::NotLoggedIn` is threaded as `anyhow`. The wrapper must
2799    // surface the typed `SendError::NotLoggedIn`, not the `Internal` catch-all,
2800    // so callers can match it (regression test for r3432644890).
2801    #[tokio::test]
2802    async fn send_message_logged_out_dm_returns_not_logged_in() {
2803        let client = crate::test_utils::create_test_client().await;
2804        let to: Jid = "111111111111@s.whatsapp.net".parse().unwrap();
2805        let err = client
2806            .send_message(
2807                to,
2808                wa::Message {
2809                    conversation: Some("hi".into()),
2810                    ..Default::default()
2811                },
2812            )
2813            .await
2814            .expect_err("logged-out DM send must error");
2815        assert!(
2816            matches!(err, SendError::NotLoggedIn),
2817            "expected SendError::NotLoggedIn, got: {err:?}"
2818        );
2819    }
2820
2821    // Edit path resolves the sender before the wire, so a logged-out DM edit
2822    // must surface the typed NotLoggedIn (not the Internal catch-all).
2823    #[tokio::test]
2824    async fn edit_message_logged_out_dm_returns_not_logged_in() {
2825        let client = crate::test_utils::create_test_client().await;
2826        let to: Jid = "111111111111@s.whatsapp.net".parse().unwrap();
2827        let err = client
2828            .edit_message(
2829                to,
2830                "ORIG_ID",
2831                wa::Message {
2832                    conversation: Some("x".into()),
2833                    ..Default::default()
2834                },
2835            )
2836            .await
2837            .expect_err("logged-out DM edit must error");
2838        assert!(
2839            matches!(err, SendError::NotLoggedIn),
2840            "expected SendError::NotLoggedIn, got: {err:?}"
2841        );
2842    }
2843
2844    // An empty EditOptions::stanza_id must land in request_id and be rejected as
2845    // InvalidRequest — doubles as a guard that stanza_id actually reaches the id.
2846    #[tokio::test]
2847    async fn edit_message_with_empty_stanza_id_returns_invalid_request() {
2848        let client = crate::test_utils::create_test_client().await;
2849        seed_pn(&client, "222222222222@s.whatsapp.net").await;
2850        let to: Jid = "111111111111@s.whatsapp.net".parse().unwrap();
2851        let err = client
2852            .edit_message_with_options(
2853                to,
2854                "ORIG_ID",
2855                wa::Message {
2856                    conversation: Some("x".into()),
2857                    ..Default::default()
2858                },
2859                EditOptions {
2860                    stanza_id: Some(String::new()),
2861                },
2862            )
2863            .await
2864            .expect_err("empty stanza_id must error");
2865        assert!(
2866            matches!(err, SendError::InvalidRequest(_)),
2867            "expected SendError::InvalidRequest, got: {err:?}"
2868        );
2869    }
2870
2871    #[tokio::test]
2872    async fn send_message_to_status_reaction_rejects_non_user_participant() {
2873        let client = crate::test_utils::create_test_client().await;
2874        let to = Jid::status_broadcast();
2875        let err = client
2876            .send_message(
2877                to,
2878                wa::Message {
2879                    reaction_message: buffa::MessageField::some(wa::message::ReactionMessage {
2880                        key: buffa::MessageField::some(wa::MessageKey {
2881                            remote_jid: Some("status@broadcast".into()),
2882                            from_me: Some(false),
2883                            id: Some("ORIGID".into()),
2884                            participant: Some("120363040237990503@g.us".into()),
2885                        }),
2886                        text: Some("❤️".into()),
2887                        sender_timestamp_ms: Some(1),
2888                        ..Default::default()
2889                    }),
2890                    ..Default::default()
2891                },
2892            )
2893            .await
2894            .expect_err("group JID as participant must error");
2895        assert!(
2896            format!("{err}").contains("user JID"),
2897            "expected user-JID error, got: {err}"
2898        );
2899    }
2900
2901    #[tokio::test]
2902    async fn send_message_to_status_reaction_without_participant_errors() {
2903        let client = crate::test_utils::create_test_client().await;
2904        let to = Jid::status_broadcast();
2905        let err = client
2906            .send_message(
2907                to,
2908                wa::Message {
2909                    reaction_message: buffa::MessageField::some(wa::message::ReactionMessage {
2910                        key: buffa::MessageField::some(wa::MessageKey {
2911                            remote_jid: Some("status@broadcast".into()),
2912                            from_me: Some(false),
2913                            id: Some("ORIGID".into()),
2914                            participant: None,
2915                        }),
2916                        text: Some("❤️".into()),
2917                        sender_timestamp_ms: Some(1),
2918                        ..Default::default()
2919                    }),
2920                    ..Default::default()
2921                },
2922            )
2923            .await
2924            .expect_err("reaction without key.participant must error");
2925        assert!(
2926            format!("{err}").contains("participant"),
2927            "expected participant error, got: {err}"
2928        );
2929    }
2930
2931    #[test]
2932    fn test_revoke_type_default_is_sender() {
2933        // RevokeType::Sender is the default (for deleting own messages)
2934        let revoke_type = RevokeType::default();
2935        assert_eq!(revoke_type, RevokeType::Sender);
2936    }
2937
2938    #[test]
2939    fn test_force_skdm_only_for_admin_revoke() {
2940        // Admin revokes require force_skdm=true to get proper message structure
2941        // with phash, <participants>, and <device-identity> that WhatsApp Web uses.
2942        // Without this, the server returns error 479.
2943        let sender_jid = Jid::from_str("123456@s.whatsapp.net").unwrap();
2944
2945        let sender_revoke = RevokeType::Sender;
2946        let admin_revoke = RevokeType::Admin {
2947            original_sender: sender_jid,
2948        };
2949
2950        // This matches the logic in revoke_message()
2951        let force_skdm_sender = matches!(sender_revoke, RevokeType::Admin { .. });
2952        let force_skdm_admin = matches!(admin_revoke, RevokeType::Admin { .. });
2953
2954        assert!(!force_skdm_sender, "Sender revoke should NOT force SKDM");
2955        assert!(force_skdm_admin, "Admin revoke MUST force SKDM");
2956    }
2957
2958    #[test]
2959    fn test_sender_revoke_message_key_structure() {
2960        // Sender revoke (edit="7"): from_me=true, participant=None
2961        // The sender is identified by from_me=true, no participant field needed
2962        let to = Jid::from_str("120363040237990503@g.us").unwrap();
2963        let message_id = "3EB0ABC123".to_string();
2964
2965        let (from_me, participant, edit_attr) = match RevokeType::Sender {
2966            RevokeType::Sender => (true, None, EditAttribute::SenderRevoke),
2967            RevokeType::Admin { original_sender } => (
2968                false,
2969                Some(original_sender.to_non_ad_string()),
2970                EditAttribute::AdminRevoke,
2971            ),
2972        };
2973
2974        assert!(from_me, "Sender revoke must have from_me=true");
2975        assert!(
2976            participant.is_none(),
2977            "Sender revoke must NOT set participant"
2978        );
2979        assert_eq!(edit_attr.to_string_val(), "7");
2980
2981        let revoke_message = build_revoke_message(&to, from_me, message_id.clone(), participant);
2982
2983        let proto_msg = revoke_message.protocol_message.into_option().unwrap();
2984        let key = proto_msg.key.into_option().unwrap();
2985        assert_eq!(key.from_me, Some(true));
2986        assert_eq!(key.participant, None);
2987        assert_eq!(key.id, Some(message_id));
2988    }
2989
2990    #[test]
2991    fn test_admin_revoke_message_key_structure() {
2992        // Admin revoke (edit="8"): from_me=false, participant=original_sender
2993        // The participant field identifies whose message is being deleted
2994        let to = Jid::from_str("120363040237990503@g.us").unwrap();
2995        let message_id = "3EB0ABC123".to_string();
2996        let original_sender = Jid::from_str("236395184570386:22@lid").unwrap();
2997
2998        let revoke_type = RevokeType::Admin {
2999            original_sender: original_sender.clone(),
3000        };
3001        let (from_me, participant, edit_attr) = match revoke_type {
3002            RevokeType::Sender => (true, None, EditAttribute::SenderRevoke),
3003            RevokeType::Admin { original_sender } => (
3004                false,
3005                Some(original_sender.to_non_ad_string()),
3006                EditAttribute::AdminRevoke,
3007            ),
3008        };
3009
3010        assert!(!from_me, "Admin revoke must have from_me=false");
3011        assert!(
3012            participant.is_some(),
3013            "Admin revoke MUST set participant to original sender"
3014        );
3015        assert_eq!(edit_attr.to_string_val(), "8");
3016
3017        let revoke_message =
3018            build_revoke_message(&to, from_me, message_id.clone(), participant.clone());
3019
3020        let proto_msg = revoke_message.protocol_message.into_option().unwrap();
3021        let key = proto_msg.key.into_option().unwrap();
3022        assert_eq!(key.from_me, Some(false));
3023        // Participant should be the original sender with device number stripped
3024        assert_eq!(key.participant, Some("236395184570386@lid".to_string()));
3025        assert_eq!(key.id, Some(message_id));
3026    }
3027
3028    // Fictitious JIDs (not real PII):
3029    //   own PN user = "5500000000000"
3030    //   own LID user = "111111111111111"
3031    //   other LID user = "222222222222222"
3032    const SELF_PN: &str = "5500000000000";
3033    const SELF_LID: &str = "111111111111111";
3034    const SELF_DEVICE: u16 = 7;
3035    const OTHER_LID: &str = "222222222222222";
3036
3037    #[test]
3038    fn self_dm_lid_recipient_matches_own_lid() {
3039        let own_pn = Jid::pn_device(SELF_PN, SELF_DEVICE);
3040        let own_lid = Jid::lid_device(SELF_LID, SELF_DEVICE);
3041        let recipient = Jid::lid(SELF_LID);
3042
3043        assert!(is_self_dm_recipient(&recipient, &own_pn, Some(&own_lid)));
3044    }
3045
3046    #[test]
3047    fn self_dm_pn_recipient_matches_own_pn() {
3048        // Self-DM addressed in PN namespace (no LID mapping resolved yet).
3049        let own_pn = Jid::pn_device(SELF_PN, SELF_DEVICE);
3050        let own_lid = Jid::lid_device(SELF_LID, SELF_DEVICE);
3051        let recipient = Jid::pn(SELF_PN);
3052
3053        assert!(is_self_dm_recipient(&recipient, &own_pn, Some(&own_lid)));
3054    }
3055
3056    #[test]
3057    fn self_dm_pn_recipient_self_dm_even_without_own_lid() {
3058        // PN-keyed self-detection does not require an own_lid to be known.
3059        let own_pn = Jid::pn_device(SELF_PN, SELF_DEVICE);
3060        let recipient = Jid::pn(SELF_PN);
3061
3062        assert!(is_self_dm_recipient(&recipient, &own_pn, None));
3063    }
3064
3065    #[test]
3066    fn non_self_lid_recipient_is_not_self_dm() {
3067        let own_pn = Jid::pn_device(SELF_PN, SELF_DEVICE);
3068        let own_lid = Jid::lid_device(SELF_LID, SELF_DEVICE);
3069        let recipient = Jid::lid(OTHER_LID);
3070
3071        assert!(!is_self_dm_recipient(&recipient, &own_pn, Some(&own_lid)));
3072    }
3073
3074    #[test]
3075    fn lid_recipient_without_own_lid_is_not_self_dm() {
3076        // WAWebUserPrefsMeUser.isMeAccount keys on isSameAccountAndAddressingMode;
3077        // PN-string equality across namespaces must NOT trigger.
3078        let own_pn = Jid::pn_device(SELF_PN, SELF_DEVICE);
3079        let recipient = Jid::lid(SELF_PN);
3080
3081        assert!(!is_self_dm_recipient(&recipient, &own_pn, None));
3082    }
3083
3084    #[test]
3085    fn group_or_broadcast_recipient_is_not_self_dm() {
3086        // Defensive: only PN/LID DMs ever take the self-DM short-circuit.
3087        let own_pn = Jid::pn_device(SELF_PN, SELF_DEVICE);
3088        let own_lid = Jid::lid_device(SELF_LID, SELF_DEVICE);
3089
3090        assert!(!is_self_dm_recipient(
3091            &Jid::group("120363000000000000"),
3092            &own_pn,
3093            Some(&own_lid),
3094        ));
3095        assert!(!is_self_dm_recipient(
3096            &Jid::status_broadcast(),
3097            &own_pn,
3098            Some(&own_lid),
3099        ));
3100    }
3101
3102    #[test]
3103    fn self_dm_with_no_recipient_cache_still_appends_own_devices() {
3104        // Edge case raised in PR review: if `recipient_cached` ends up `None`
3105        // (cache eviction + warmup failed), the self-DM short-circuit must
3106        // still let `own_cached` populate the fanout. Otherwise the bare-JID
3107        // fallback drops every companion device.
3108        let own_pn = Jid::pn_device(SELF_PN, SELF_DEVICE);
3109        let own_lid = Jid::lid_device(SELF_LID, SELF_DEVICE);
3110        let recipient_bare = Jid::lid(SELF_LID);
3111        assert!(is_self_dm_recipient(
3112            &recipient_bare,
3113            &own_pn,
3114            Some(&own_lid)
3115        ));
3116
3117        let recipient_cached: Option<Vec<Jid>> = None;
3118        let own_cached_pn: Vec<Jid> = [0u16, 3, SELF_DEVICE]
3119            .into_iter()
3120            .map(|d| Jid::pn_device(SELF_PN, d))
3121            .collect();
3122
3123        // Mirrors the call-site logic: we keep own_cached when recipient_cached is None
3124        // even in a self-DM.
3125        let keep_own = recipient_cached.is_none();
3126        assert!(keep_own);
3127
3128        let mut all_dm_jids = match recipient_cached {
3129            Some(devices) => devices,
3130            None => vec![recipient_bare],
3131        };
3132        if keep_own {
3133            all_dm_jids.extend(own_cached_pn.iter().cloned());
3134        }
3135        all_dm_jids.retain(|j| {
3136            let is_sender = (j.is_same_user_as(&own_pn) && j.device == own_pn.device)
3137                || (j.is_same_user_as(&own_lid) && j.device == own_lid.device);
3138            !is_sender
3139        });
3140        wacore::types::jid::sort_dedup_by_device(&mut all_dm_jids);
3141
3142        // Must contain the bare LID plus the two non-sender PN companion devices.
3143        assert!(
3144            all_dm_jids.iter().any(|j| j.is_lid()),
3145            "bare recipient LID must remain"
3146        );
3147        assert_eq!(
3148            all_dm_jids.iter().filter(|j| j.is_pn()).count(),
3149            2,
3150            "companion PN devices must survive when recipient_cached is None"
3151        );
3152    }
3153
3154    #[test]
3155    fn old_merge_produced_lid_pn_duplicates_for_self_dm() {
3156        // Pinning regression: the OLD merge path (recipient_cached LID ++
3157        // own_cached PN, then sort_dedup_by_device) left every device listed
3158        // twice for a self-DM, which the server rejects with ack error="400".
3159        let own_pn = Jid::pn_device(SELF_PN, SELF_DEVICE);
3160        let own_lid = Jid::lid_device(SELF_LID, SELF_DEVICE);
3161        let recipient_bare = Jid::lid(SELF_LID);
3162
3163        let devices = [0u16, 3, 5, SELF_DEVICE];
3164        let recipient_cached: Vec<Jid> = devices
3165            .iter()
3166            .map(|&d| Jid::lid_device(SELF_LID, d))
3167            .collect();
3168        let own_cached: Vec<Jid> = devices
3169            .iter()
3170            .map(|&d| Jid::pn_device(SELF_PN, d))
3171            .collect();
3172
3173        let retain_non_sender = |j: &Jid| {
3174            let is_sender = (j.is_same_user_as(&own_pn) && j.device == own_pn.device)
3175                || (j.is_same_user_as(&own_lid) && j.device == own_lid.device);
3176            !is_sender
3177        };
3178
3179        let mut buggy = recipient_cached.clone();
3180        buggy.extend(own_cached.clone());
3181        buggy.retain(retain_non_sender);
3182        wacore::types::jid::sort_dedup_by_device(&mut buggy);
3183        assert_eq!(buggy.len(), (devices.len() - 1) * 2);
3184
3185        assert!(is_self_dm_recipient(
3186            &recipient_bare,
3187            &own_pn,
3188            Some(&own_lid)
3189        ));
3190
3191        let mut fixed = recipient_cached;
3192        fixed.retain(retain_non_sender);
3193        wacore::types::jid::sort_dedup_by_device(&mut fixed);
3194        assert_eq!(fixed.len(), devices.len() - 1);
3195        for j in &fixed {
3196            assert!(j.is_lid());
3197        }
3198    }
3199
3200    #[test]
3201    fn test_admin_revoke_preserves_lid_format() {
3202        // LID JIDs must NOT be converted to PN (phone number) format.
3203        // This was a bug that caused error 479 - the participant field must
3204        // preserve the original JID format exactly (with device stripped).
3205        let lid_sender = Jid::from_str("236395184570386:22@lid").unwrap();
3206        let participant_str = lid_sender.to_non_ad_string();
3207
3208        // Must preserve @lid suffix, device number stripped
3209        assert_eq!(participant_str, "236395184570386@lid");
3210        assert!(
3211            participant_str.ends_with("@lid"),
3212            "LID participant must preserve @lid suffix"
3213        );
3214    }
3215
3216    // SKDM Recipient Filtering Tests - validates DeviceKey-based filtering
3217
3218    #[test]
3219    fn test_skdm_recipient_filtering_basic() {
3220        use std::collections::HashSet;
3221
3222        let known_recipients: Vec<Jid> = [
3223            "1234567890:0@s.whatsapp.net",
3224            "1234567890:5@s.whatsapp.net",
3225            "9876543210:0@s.whatsapp.net",
3226        ]
3227        .into_iter()
3228        .map(|s| Jid::from_str(s).unwrap())
3229        .collect();
3230
3231        let all_devices: Vec<Jid> = [
3232            "1234567890:0@s.whatsapp.net",
3233            "1234567890:5@s.whatsapp.net",
3234            "9876543210:0@s.whatsapp.net",
3235            "5555555555:0@s.whatsapp.net", // new
3236        ]
3237        .into_iter()
3238        .map(|s| Jid::from_str(s).unwrap())
3239        .collect();
3240
3241        let known_set: HashSet<DeviceKey<'_>> =
3242            known_recipients.iter().map(|j| j.device_key()).collect();
3243
3244        let new_devices: Vec<Jid> = all_devices
3245            .into_iter()
3246            .filter(|device| !known_set.contains(&device.device_key()))
3247            .collect();
3248
3249        assert_eq!(new_devices.len(), 1);
3250        assert_eq!(new_devices[0].user, "5555555555");
3251    }
3252
3253    #[test]
3254    fn test_skdm_recipient_filtering_lid_jids() {
3255        use std::collections::HashSet;
3256
3257        let known_recipients: Vec<Jid> = [
3258            "236395184570386:91@lid",
3259            "129171292463295:0@lid",
3260            "45857667830004:14@lid",
3261        ]
3262        .into_iter()
3263        .map(|s| Jid::from_str(s).unwrap())
3264        .collect();
3265
3266        let all_devices: Vec<Jid> = [
3267            "236395184570386:91@lid",
3268            "129171292463295:0@lid",
3269            "45857667830004:14@lid",
3270            "45857667830004:15@lid", // new
3271        ]
3272        .into_iter()
3273        .map(|s| Jid::from_str(s).unwrap())
3274        .collect();
3275
3276        let known_set: HashSet<DeviceKey<'_>> =
3277            known_recipients.iter().map(|j| j.device_key()).collect();
3278
3279        let new_devices: Vec<Jid> = all_devices
3280            .into_iter()
3281            .filter(|device| !known_set.contains(&device.device_key()))
3282            .collect();
3283
3284        assert_eq!(new_devices.len(), 1);
3285        assert_eq!(new_devices[0].user, "45857667830004");
3286        assert_eq!(new_devices[0].device, 15);
3287    }
3288
3289    #[test]
3290    fn test_skdm_recipient_filtering_all_known() {
3291        use std::collections::HashSet;
3292
3293        let known_recipients: Vec<Jid> =
3294            ["1234567890:0@s.whatsapp.net", "1234567890:5@s.whatsapp.net"]
3295                .into_iter()
3296                .map(|s| Jid::from_str(s).unwrap())
3297                .collect();
3298
3299        let all_devices: Vec<Jid> = ["1234567890:0@s.whatsapp.net", "1234567890:5@s.whatsapp.net"]
3300            .into_iter()
3301            .map(|s| Jid::from_str(s).unwrap())
3302            .collect();
3303
3304        let known_set: HashSet<DeviceKey<'_>> =
3305            known_recipients.iter().map(|j| j.device_key()).collect();
3306
3307        let new_devices: Vec<Jid> = all_devices
3308            .into_iter()
3309            .filter(|device| !known_set.contains(&device.device_key()))
3310            .collect();
3311
3312        assert!(new_devices.is_empty());
3313    }
3314
3315    #[test]
3316    fn test_skdm_recipient_filtering_all_new() {
3317        use std::collections::HashSet;
3318
3319        let known_recipients: Vec<Jid> = vec![];
3320
3321        let all_devices: Vec<Jid> = ["1234567890:0@s.whatsapp.net", "9876543210:0@s.whatsapp.net"]
3322            .into_iter()
3323            .map(|s| Jid::from_str(s).unwrap())
3324            .collect();
3325
3326        let known_set: HashSet<DeviceKey<'_>> =
3327            known_recipients.iter().map(|j| j.device_key()).collect();
3328
3329        let new_devices: Vec<Jid> = all_devices
3330            .clone()
3331            .into_iter()
3332            .filter(|device| !known_set.contains(&device.device_key()))
3333            .collect();
3334
3335        assert_eq!(new_devices.len(), all_devices.len());
3336    }
3337
3338    #[test]
3339    fn test_device_key_comparison() {
3340        // Jid parse/display normalizes :0 (omitted in Display, missing ':N' parses as device 0).
3341        // This test ensures DeviceKey comparisons work correctly under that normalization.
3342        let test_cases = [
3343            (
3344                "1234567890:0@s.whatsapp.net",
3345                "1234567890@s.whatsapp.net",
3346                true,
3347            ),
3348            (
3349                "1234567890:5@s.whatsapp.net",
3350                "1234567890:5@s.whatsapp.net",
3351                true,
3352            ),
3353            (
3354                "1234567890:5@s.whatsapp.net",
3355                "1234567890:6@s.whatsapp.net",
3356                false,
3357            ),
3358            ("236395184570386:91@lid", "236395184570386:91@lid", true),
3359            ("236395184570386:0@lid", "236395184570386@lid", true),
3360            ("user1@s.whatsapp.net", "user2@s.whatsapp.net", false),
3361        ];
3362
3363        for (jid1_str, jid2_str, should_match) in test_cases {
3364            let jid1: Jid = jid1_str.parse().expect("should parse jid1");
3365            let jid2: Jid = jid2_str.parse().expect("should parse jid2");
3366
3367            let key1 = jid1.device_key();
3368            let key2 = jid2.device_key();
3369
3370            assert_eq!(
3371                key1 == key2,
3372                should_match,
3373                "DeviceKey comparison failed for '{}' vs '{}': expected match={}, got match={}",
3374                jid1_str,
3375                jid2_str,
3376                should_match,
3377                key1 == key2
3378            );
3379
3380            assert_eq!(
3381                jid1.device_eq(&jid2),
3382                should_match,
3383                "device_eq failed for '{}' vs '{}'",
3384                jid1_str,
3385                jid2_str
3386            );
3387        }
3388    }
3389
3390    #[test]
3391    fn empty_sender_key_device_map_marks_all_devices_for_skdm() {
3392        use crate::sender_key_device_cache::SenderKeyDeviceMap;
3393
3394        let map = SenderKeyDeviceMap::from_db_rows(&[]);
3395        assert_eq!(map.device_has_key("271060335329480", 0), None);
3396
3397        let all_resolved_devices: Vec<Jid> = [
3398            "271060335329480@lid",
3399            "77610646245392@lid",
3400            "276661023027320:5@lid",
3401        ]
3402        .into_iter()
3403        .map(|s| Jid::from_str(s).unwrap())
3404        .collect();
3405
3406        let needs_skdm: Vec<&Jid> = all_resolved_devices
3407            .iter()
3408            .filter(|device| {
3409                !map.device_has_key(&device.user, device.device)
3410                    .unwrap_or(false)
3411                    || !map.device_has_key(&device.user, 0).unwrap_or(false)
3412            })
3413            .collect();
3414
3415        assert_eq!(needs_skdm.len(), all_resolved_devices.len());
3416    }
3417
3418    /// Fails if the empty-cache early-exit is reintroduced.
3419    #[tokio::test]
3420    async fn resolve_skdm_targets_distributes_when_cache_empty_but_devices_known() {
3421        use wacore::client::context::GroupInfo;
3422        use wacore::store::traits::{DeviceInfo, DeviceListRecord};
3423        use wacore::types::message::AddressingMode;
3424
3425        let client = crate::test_utils::create_test_client().await;
3426        let group_jid = "120363161500776365@g.us";
3427        let own_lid = Jid::from_str("193832511623409:13@lid").unwrap();
3428
3429        let participant_users = ["271060335329480", "77610646245392", "276661023027320"];
3430
3431        // Pre-populate so `resolve_devices` succeeds without a transport.
3432        for user in &participant_users {
3433            let record = DeviceListRecord {
3434                user: (*user).into(),
3435                devices: vec![DeviceInfo::new(0, None)],
3436                timestamp: wacore::time::now_secs(),
3437                phash: None,
3438                raw_id: None,
3439            };
3440            client
3441                .device_registry_cache
3442                .raw_insert_for_tests((*user).into(), Arc::new(record))
3443                .await;
3444        }
3445
3446        let participants: Vec<Jid> = participant_users
3447            .iter()
3448            .map(|u| Jid::from_str(&format!("{u}@lid")).unwrap())
3449            .collect();
3450
3451        let group_info = GroupInfo::new(participants.clone(), AddressingMode::Lid);
3452
3453        let needs_skdm = client
3454            .resolve_status_skdm_targets(
3455                group_jid,
3456                &group_info,
3457                &own_lid,
3458                crate::cache::Freshness::CachePreferred,
3459                false,
3460            )
3461            .await
3462            .expect("device resolution must succeed")
3463            .expect("missing targets means device resolution failed");
3464
3465        // Empty cache → every participant needs SKDM, and the full set equals
3466        // the target set on this cold path.
3467        assert_eq!(needs_skdm.len(), participants.len());
3468        for user in &participant_users {
3469            assert!(needs_skdm.iter().any(|j| j.user == *user));
3470        }
3471    }
3472
3473    #[test]
3474    fn single_forgotten_row_keeps_full_distribution() {
3475        use crate::sender_key_device_cache::SenderKeyDeviceMap;
3476
3477        let map = SenderKeyDeviceMap::from_db_rows(&[("271060335329480@lid".to_string(), false)]);
3478        assert_eq!(map.device_has_key("271060335329480", 0), Some(false));
3479
3480        let all_resolved_devices: Vec<Jid> = [
3481            "271060335329480@lid",
3482            "77610646245392@lid",
3483            "276661023027320:5@lid",
3484        ]
3485        .into_iter()
3486        .map(|s| Jid::from_str(s).unwrap())
3487        .collect();
3488
3489        let needs_skdm: Vec<&Jid> = all_resolved_devices
3490            .iter()
3491            .filter(|device| {
3492                !map.device_has_key(&device.user, device.device)
3493                    .unwrap_or(false)
3494                    || !map.device_has_key(&device.user, 0).unwrap_or(false)
3495            })
3496            .collect();
3497
3498        assert_eq!(
3499            needs_skdm.len(),
3500            3,
3501            "after retry inserts one row, ALL devices correctly flagged for SKDM \
3502             (this is what unblocks redistribution on the SECOND message)"
3503        );
3504    }
3505
3506    /// WA Web primary-device gate (ParticipantStore.js): a companion is warm only
3507    /// when it AND its primary (device 0) hold the key. A forgotten companion
3508    /// redistributes only itself (no per-user amplification); a forgotten primary
3509    /// redistributes the whole user. Drives the real `filter_skdm_targets`.
3510    #[tokio::test]
3511    async fn filter_skdm_targets_uses_primary_device_gate() {
3512        use crate::sender_key_device_cache::SenderKeyDeviceMap;
3513
3514        let client = crate::test_utils::create_test_client().await;
3515        let group = "120363161500776365@g.us";
3516        let own = Jid::from_str("999999999999999:1@lid").unwrap();
3517
3518        // Companion forgotten, primary warm: only the companion redistributes.
3519        let map = SenderKeyDeviceMap::from_db_rows(&[
3520            ("100100100100100@lid".to_string(), true),
3521            ("100100100100100:5@lid".to_string(), false),
3522        ]);
3523        let devices = [
3524            Jid::from_str("100100100100100@lid").unwrap(),
3525            Jid::from_str("100100100100100:5@lid").unwrap(),
3526        ];
3527        let needs = client.filter_skdm_targets(group, &devices, &map, &own);
3528        assert_eq!(needs.len(), 1, "warm primary keeps the keyed companion out");
3529        assert_eq!(needs[0].device, 5);
3530
3531        // Primary forgotten, companion warm: the whole user redistributes (WA Web
3532        // marks a companion cold when its primary is cold).
3533        let map = SenderKeyDeviceMap::from_db_rows(&[
3534            ("200200200200200@lid".to_string(), false),
3535            ("200200200200200:5@lid".to_string(), true),
3536        ]);
3537        let devices = [
3538            Jid::from_str("200200200200200@lid").unwrap(),
3539            Jid::from_str("200200200200200:5@lid").unwrap(),
3540        ];
3541        let needs = client.filter_skdm_targets(group, &devices, &map, &own);
3542        assert_eq!(needs.len(), 2, "cold primary redistributes the whole user");
3543
3544        // Companion warm but the primary row is absent (None): WA Web's `?? false`
3545        // treats a missing primary as cold, so the companion still redistributes.
3546        let map = SenderKeyDeviceMap::from_db_rows(&[("300300300300300:5@lid".to_string(), true)]);
3547        let devices = [Jid::from_str("300300300300300:5@lid").unwrap()];
3548        let needs = client.filter_skdm_targets(group, &devices, &map, &own);
3549        assert_eq!(
3550            needs.len(),
3551            1,
3552            "absent primary is cold, companion redistributes"
3553        );
3554    }
3555
3556    /// End-to-end: after a send marks its SKDM targets, our own companion is NOT
3557    /// memoized (WA Web `!isMeDevice` guard on `markHasSenderKey`), so the next send
3558    /// re-distributes its SKDM — it can't be orphaned by a one-off encryption
3559    /// failure (the retry/forget path also excludes own devices). An external member
3560    /// stays warm and is not re-targeted.
3561    #[tokio::test]
3562    async fn own_companion_is_never_memoized_so_it_redistributes_every_send() {
3563        use crate::sender_key_device_cache::SenderKeyDeviceMap;
3564
3565        let client = crate::test_utils::create_test_client().await;
3566        let own_lid = Jid::from_str("888000888000888:1@lid").unwrap();
3567        client
3568            .persistence_manager
3569            .process_command(DeviceCommand::SetLid(Some(own_lid.clone())))
3570            .await;
3571
3572        let group = "120363000000000009@g.us";
3573        let own_companion = Jid::from_str("888000888000888:5@lid").unwrap();
3574        let member = Jid::from_str("111000111000111@lid").unwrap();
3575
3576        // A send marks its full target set warm (own companion + external member).
3577        client
3578            .update_sender_key_devices(group, &[own_companion.clone(), member.clone()])
3579            .await;
3580
3581        // Persisted: the external member is warm; our own companion was skipped.
3582        let rows = client
3583            .persistence_manager
3584            .get_sender_key_devices(group)
3585            .await
3586            .unwrap();
3587        let map = SenderKeyDeviceMap::from_db_rows(&rows);
3588        assert_eq!(
3589            map.device_has_key("111000111000111", 0),
3590            Some(true),
3591            "external member is memoized warm"
3592        );
3593        assert_eq!(
3594            map.device_has_key("888000888000888", 5),
3595            None,
3596            "own companion is never memoized (re-distributed every send)"
3597        );
3598
3599        // Next send: only the own companion is re-targeted; the member stays warm.
3600        let devices = [own_companion.clone(), member];
3601        let needs = client.filter_skdm_targets(group, &devices, &map, &own_lid);
3602        assert_eq!(
3603            needs,
3604            vec![own_companion],
3605            "own companion redistributes; external member stays warm"
3606        );
3607    }
3608
3609    /// The own-only re-distribution set is the warm steady state (WA Web:
3610    /// `getGroupSenderKeyList` reads the in-memory map with no storage
3611    /// re-read), so marking it after a send must NOT drop the cached device
3612    /// map. A set with an external member writes a new warm mark and must.
3613    #[tokio::test]
3614    async fn own_only_skdm_mark_keeps_device_map_cached() {
3615        use crate::sender_key_device_cache::SenderKeyDeviceMap;
3616        use std::sync::Arc;
3617
3618        let client = crate::test_utils::create_test_client().await;
3619        let own_lid = Jid::from_str("888000888000888:1@lid").unwrap();
3620        client
3621            .persistence_manager
3622            .process_command(DeviceCommand::SetLid(Some(own_lid.clone())))
3623            .await;
3624
3625        let group = "120363000000000010@g.us";
3626        let own_primary = Jid::from_str("888000888000888:0@lid").unwrap();
3627        let member = Jid::from_str("111000111000111:0@lid").unwrap();
3628
3629        client
3630            .sender_key_device_cache
3631            .get_or_init(group, async {
3632                Arc::new(SenderKeyDeviceMap::from_db_rows(&[]))
3633            })
3634            .await;
3635
3636        // Own-only mark (the every-send steady state): nothing written, cache kept.
3637        client
3638            .update_sender_key_devices(group, std::slice::from_ref(&own_primary))
3639            .await;
3640        client
3641            .sender_key_device_cache
3642            .get_or_init(group, async {
3643                panic!("own-only SKDM mark must not invalidate the device map")
3644            })
3645            .await;
3646
3647        // An external member writes a warm mark: the cached map must drop so
3648        // the next send re-reads the new state.
3649        client
3650            .update_sender_key_devices(group, &[own_primary, member])
3651            .await;
3652        let rebuilt = std::sync::atomic::AtomicBool::new(false);
3653        client
3654            .sender_key_device_cache
3655            .get_or_init(group, async {
3656                rebuilt.store(true, std::sync::atomic::Ordering::Relaxed);
3657                Arc::new(SenderKeyDeviceMap::from_db_rows(&[]))
3658            })
3659            .await;
3660        assert!(
3661            rebuilt.load(std::sync::atomic::Ordering::Relaxed),
3662            "a new external warm mark must invalidate the device map"
3663        );
3664    }
3665
3666    /// `skdm_needs_only_own_devices` gates the warm fast path in
3667    /// `send_group_branch`: own-only sets qualify, anything external (or an
3668    /// empty set, which has its own arm) does not.
3669    #[test]
3670    fn skdm_needs_only_own_devices_classification() {
3671        let own_pn = Jid::from_str("5511999990000:2@s.whatsapp.net").unwrap();
3672        let own_lid = Jid::from_str("888000888000888:2@lid").unwrap();
3673        let own_primary_lid = Jid::from_str("888000888000888:0@lid").unwrap();
3674        let own_primary_pn = Jid::from_str("5511999990000:0@s.whatsapp.net").unwrap();
3675        let member = Jid::from_str("111000111000111:0@lid").unwrap();
3676
3677        assert!(skdm_needs_only_own_devices(
3678            &[own_primary_lid.clone(), own_primary_pn],
3679            Some(&own_pn),
3680            Some(&own_lid)
3681        ));
3682        assert!(
3683            !skdm_needs_only_own_devices(
3684                &[own_primary_lid, member.clone()],
3685                Some(&own_pn),
3686                Some(&own_lid)
3687            ),
3688            "an external member must take the cold path"
3689        );
3690        assert!(
3691            !skdm_needs_only_own_devices(&[], Some(&own_pn), Some(&own_lid)),
3692            "the empty set is handled by its own (fully warm) arm"
3693        );
3694        assert!(
3695            !skdm_needs_only_own_devices(&[member], Some(&own_pn), Some(&own_lid)),
3696            "external-only must take the cold path"
3697        );
3698    }
3699
3700    #[test]
3701    fn test_skdm_filtering_large_group() {
3702        use std::collections::HashSet;
3703
3704        let mut known_recipients: Vec<Jid> = Vec::with_capacity(1000);
3705        let mut all_devices: Vec<Jid> = Vec::with_capacity(1010);
3706
3707        for i in 0..1000i64 {
3708            let jid_str = format!("{}:1@lid", 100000000000000i64 + i);
3709            let jid = Jid::from_str(&jid_str).unwrap();
3710            known_recipients.push(jid.clone());
3711            all_devices.push(jid);
3712        }
3713
3714        for i in 1000i64..1010i64 {
3715            let jid_str = format!("{}:1@lid", 100000000000000i64 + i);
3716            all_devices.push(Jid::from_str(&jid_str).unwrap());
3717        }
3718
3719        let known_set: HashSet<DeviceKey<'_>> =
3720            known_recipients.iter().map(|j| j.device_key()).collect();
3721
3722        let new_devices: Vec<Jid> = all_devices
3723            .into_iter()
3724            .filter(|device| !known_set.contains(&device.device_key()))
3725            .collect();
3726
3727        assert_eq!(new_devices.len(), 10);
3728    }
3729
3730    mod infer_stanza {
3731        use super::*;
3732
3733        #[test]
3734        fn regular_message_returns_none() {
3735            let msg = wa::Message {
3736                conversation: Some("hello".into()),
3737                ..Default::default()
3738            };
3739            let (edit, node) = infer_stanza_metadata(&msg);
3740            assert!(edit.is_none());
3741            assert!(node.is_none());
3742        }
3743
3744        #[test]
3745        fn pin_returns_edit_attribute() {
3746            let msg = wa::Message {
3747                pin_in_chat_message: buffa::MessageField::some(Default::default()),
3748                ..Default::default()
3749            };
3750            let (edit, node) = infer_stanza_metadata(&msg);
3751            assert_eq!(edit, Some(EditAttribute::PinInChat));
3752            assert!(node.is_none());
3753        }
3754
3755        #[test]
3756        fn poll_creation_v3_returns_meta_node() {
3757            let msg = wa::Message {
3758                poll_creation_message_v3: buffa::MessageField::some(Default::default()),
3759                ..Default::default()
3760            };
3761            let (edit, node) = infer_stanza_metadata(&msg);
3762            assert!(edit.is_none());
3763            let node = node.expect("should have meta node");
3764            assert_eq!(node.tag, "meta");
3765            let mut attrs = node.attrs();
3766            assert_eq!(
3767                attrs.optional_string("polltype").unwrap().as_ref(),
3768                "creation"
3769            );
3770        }
3771
3772        #[test]
3773        fn event_returns_meta_node() {
3774            let msg = wa::Message {
3775                event_message: buffa::MessageField::some(Default::default()),
3776                ..Default::default()
3777            };
3778            let (edit, node) = infer_stanza_metadata(&msg);
3779            assert!(edit.is_none());
3780            let node = node.expect("should have meta node");
3781            assert_eq!(node.tag, "meta");
3782            let mut attrs = node.attrs();
3783            assert_eq!(
3784                attrs.optional_string("event_type").unwrap().as_ref(),
3785                "creation"
3786            );
3787        }
3788
3789        #[test]
3790        fn empty_message_returns_none() {
3791            let (edit, node) = infer_stanza_metadata(&wa::Message::default());
3792            assert!(edit.is_none());
3793            assert!(node.is_none());
3794        }
3795
3796        #[test]
3797        fn member_label_set_returns_member_tag_user_update() {
3798            let msg = wacore::send::build_member_label_message("VIP".to_string(), 1_700_000_000);
3799            let (_, node) = infer_stanza_metadata(&msg);
3800            let node = node.expect("member_label should have meta node");
3801            let mut attrs = node.attrs();
3802            assert_eq!(
3803                attrs.optional_string("appdata").unwrap().as_ref(),
3804                "member_tag"
3805            );
3806            assert_eq!(
3807                attrs.optional_string("tag_reason").unwrap().as_ref(),
3808                "user_update"
3809            );
3810        }
3811
3812        #[test]
3813        fn member_label_clear_returns_user_delete() {
3814            // Empty label = clearing the tag → tag_reason "user_delete".
3815            let msg = wacore::send::build_member_label_message(String::new(), 1_700_000_000);
3816            let (_, node) = infer_stanza_metadata(&msg);
3817            let node = node.expect("member_label should have meta node");
3818            let mut attrs = node.attrs();
3819            assert_eq!(
3820                attrs.optional_string("appdata").unwrap().as_ref(),
3821                "member_tag"
3822            );
3823            assert_eq!(
3824                attrs.optional_string("tag_reason").unwrap().as_ref(),
3825                "user_delete"
3826            );
3827        }
3828
3829        #[test]
3830        fn poll_creation_v1_returns_meta_node() {
3831            let msg = wa::Message {
3832                poll_creation_message: buffa::MessageField::some(Default::default()),
3833                ..Default::default()
3834            };
3835            let (edit, node) = infer_stanza_metadata(&msg);
3836            assert!(edit.is_none());
3837            let node = node.expect("should have meta node");
3838            assert_eq!(node.tag, "meta");
3839            let mut attrs = node.attrs();
3840            assert_eq!(
3841                attrs.optional_string("polltype").unwrap().as_ref(),
3842                "creation"
3843            );
3844        }
3845
3846        #[test]
3847        fn poll_creation_v2_returns_meta_node() {
3848            let msg = wa::Message {
3849                poll_creation_message_v2: buffa::MessageField::some(Default::default()),
3850                ..Default::default()
3851            };
3852            let (edit, node) = infer_stanza_metadata(&msg);
3853            assert!(edit.is_none());
3854            let node = node.expect("should have meta node");
3855            assert_eq!(node.tag, "meta");
3856            let mut attrs = node.attrs();
3857            assert_eq!(
3858                attrs.optional_string("polltype").unwrap().as_ref(),
3859                "creation"
3860            );
3861        }
3862
3863        #[test]
3864        fn poll_vote_returns_meta_node() {
3865            let msg = wa::Message {
3866                poll_update_message: buffa::MessageField::some(wa::message::PollUpdateMessage {
3867                    vote: buffa::MessageField::some(wa::message::PollEncValue::default()),
3868                    ..Default::default()
3869                }),
3870                ..Default::default()
3871            };
3872            let (edit, node) = infer_stanza_metadata(&msg);
3873            assert!(edit.is_none());
3874            let node = node.expect("should have meta node");
3875            assert_eq!(node.tag, "meta");
3876            let mut attrs = node.attrs();
3877            assert_eq!(attrs.optional_string("polltype").unwrap().as_ref(), "vote");
3878        }
3879
3880        #[test]
3881        fn view_once_image_emits_view_once_meta() {
3882            let msg = wa::Message {
3883                image_message: buffa::MessageField::some(wa::message::ImageMessage {
3884                    view_once: Some(true),
3885                    ..Default::default()
3886                }),
3887                ..Default::default()
3888            };
3889            let (_, node) = infer_stanza_metadata(&msg);
3890            let node = node.expect("view-once image should emit meta");
3891            assert_eq!(node.tag, "meta");
3892            assert_eq!(
3893                node.attrs().optional_string("view_once").unwrap().as_ref(),
3894                "true"
3895            );
3896        }
3897
3898        #[test]
3899        fn plain_image_emits_no_meta() {
3900            let msg = wa::Message {
3901                image_message: buffa::MessageField::some(wa::message::ImageMessage::default()),
3902                ..Default::default()
3903            };
3904            assert!(infer_stanza_metadata(&msg).1.is_none());
3905        }
3906
3907        #[test]
3908        fn event_response_returns_meta_node() {
3909            let msg = wa::Message {
3910                enc_event_response_message: buffa::MessageField::some(Default::default()),
3911                ..Default::default()
3912            };
3913            let (edit, node) = infer_stanza_metadata(&msg);
3914            assert!(edit.is_none());
3915            let node = node.expect("should have meta node");
3916            assert_eq!(node.tag, "meta");
3917            let mut attrs = node.attrs();
3918            assert_eq!(
3919                attrs.optional_string("event_type").unwrap().as_ref(),
3920                "response"
3921            );
3922        }
3923
3924        #[test]
3925        fn poll_update_without_vote_returns_none() {
3926            let msg = wa::Message {
3927                poll_update_message: buffa::MessageField::some(wa::message::PollUpdateMessage {
3928                    ..Default::default()
3929                }),
3930                ..Default::default()
3931            };
3932            let (edit, node) = infer_stanza_metadata(&msg);
3933            assert!(edit.is_none());
3934            assert!(node.is_none());
3935        }
3936
3937        #[test]
3938        fn revoked_reaction_returns_sender_revoke() {
3939            let msg = wa::Message {
3940                reaction_message: buffa::MessageField::some(wa::message::ReactionMessage {
3941                    text: Some(String::new()),
3942                    ..Default::default()
3943                }),
3944                ..Default::default()
3945            };
3946            let (edit, _) = infer_stanza_metadata(&msg);
3947            assert_eq!(edit, Some(EditAttribute::SenderRevoke));
3948        }
3949
3950        #[test]
3951        fn keep_in_chat_undo_returns_sender_revoke() {
3952            let msg = wa::Message {
3953                keep_in_chat_message: buffa::MessageField::some(wa::message::KeepInChatMessage {
3954                    key: buffa::MessageField::some(wa::MessageKey {
3955                        from_me: Some(true),
3956                        ..Default::default()
3957                    }),
3958                    keep_type: Some(wa::KeepType::UndoKeepForAll),
3959                    ..Default::default()
3960                }),
3961                ..Default::default()
3962            };
3963            let (edit, _) = infer_stanza_metadata(&msg);
3964            assert_eq!(edit, Some(EditAttribute::SenderRevoke));
3965        }
3966
3967        #[test]
3968        fn secret_encrypted_message_edit_returns_message_edit() {
3969            let msg = wa::Message {
3970                secret_encrypted_message: buffa::MessageField::some(
3971                    wa::message::SecretEncryptedMessage {
3972                        secret_enc_type: Some(
3973                            wa::message::secret_encrypted_message::SecretEncType::MessageEdit,
3974                        ),
3975                        ..Default::default()
3976                    },
3977                ),
3978                ..Default::default()
3979            };
3980            let (edit, _) = infer_stanza_metadata(&msg);
3981            assert_eq!(edit, Some(EditAttribute::MessageEdit));
3982        }
3983
3984        #[test]
3985        fn secret_encrypted_event_edit_emits_both_edit_attr_and_meta_node() {
3986            // EVENT_EDIT is the one case where the edit attribute AND the
3987            // meta node both fire: `event_type=edit` meta + `edit="1"` attr.
3988            let msg = wa::Message {
3989                secret_encrypted_message: buffa::MessageField::some(
3990                    wa::message::SecretEncryptedMessage {
3991                        secret_enc_type: Some(
3992                            wa::message::secret_encrypted_message::SecretEncType::EventEdit,
3993                        ),
3994                        ..Default::default()
3995                    },
3996                ),
3997                ..Default::default()
3998            };
3999            let (edit, node) = infer_stanza_metadata(&msg);
4000            assert_eq!(edit, Some(EditAttribute::MessageEdit));
4001            let node = node.expect("should have meta node");
4002            assert_eq!(
4003                node.attrs().optional_string("event_type").unwrap().as_ref(),
4004                "edit"
4005            );
4006        }
4007
4008        #[test]
4009        fn top_level_edited_message_returns_message_edit() {
4010            let msg = wa::Message {
4011                edited_message: buffa::MessageField::some(wa::message::FutureProofMessage {
4012                    message: buffa::MessageField::some(wa::Message::default()),
4013                }),
4014                ..Default::default()
4015            };
4016            let (edit, _) = infer_stanza_metadata(&msg);
4017            assert_eq!(edit, Some(EditAttribute::MessageEdit));
4018        }
4019
4020        #[test]
4021        fn build_edit_message_uses_top_level_protocol_message() {
4022            use std::str::FromStr;
4023            let to = Jid::from_str("5511999999999@s.whatsapp.net").unwrap();
4024            let new_content = wa::Message {
4025                conversation: Some("edited".to_string()),
4026                ..Default::default()
4027            };
4028            let msg = build_edit_message(
4029                &to,
4030                "ORIG_ID".to_string(),
4031                None,
4032                new_content,
4033                1_700_000_000_000,
4034            );
4035
4036            // Canonical WA Web shape: top-level protocolMessage(type=MESSAGE_EDIT),
4037            // not the Message.editedMessage FutureProofMessage history wrapper.
4038            assert!(
4039                msg.edited_message.is_unset(),
4040                "edit must not use the FutureProofMessage wrapper"
4041            );
4042            let pm = msg
4043                .protocol_message
4044                .as_option()
4045                .expect("top-level protocol_message");
4046            assert_eq!(
4047                pm.r#type,
4048                Some(wa::message::protocol_message::Type::MessageEdit)
4049            );
4050            assert_eq!(
4051                pm.key.as_option().and_then(|k| k.id.as_deref()),
4052                Some("ORIG_ID")
4053            );
4054            assert_eq!(pm.key.as_option().and_then(|k| k.from_me), Some(true));
4055            assert_eq!(
4056                pm.edited_message
4057                    .as_option()
4058                    .and_then(|m| m.conversation.as_deref()),
4059                Some("edited")
4060            );
4061            // The send path still derives the edit attribute from this shape.
4062            assert_eq!(
4063                infer_stanza_metadata(&msg).0,
4064                Some(EditAttribute::MessageEdit)
4065            );
4066        }
4067    }
4068
4069    mod biz_node_tests {
4070        use super::*;
4071        use std::str::FromStr;
4072        use wa::message::interactive_message::{
4073            self, NativeFlowMessage, native_flow_message::NativeFlowButton,
4074        };
4075
4076        // Fixed unix seconds for deterministic privacy_mode_ts assertions.
4077        const FIXED_NOW: u64 = 1_700_000_000;
4078        // FIXED_NOW - BIZ_PRIVACY_MODE_TS_OFFSET = 1_700_000_000 - 77_980_457
4079        const EXPECTED_PRIVACY_TS: &str = "1622019543";
4080
4081        fn msg_with_native_flow_button(button_name: &str) -> wa::Message {
4082            wa::Message {
4083                interactive_message: buffa::MessageField::some(wa::message::InteractiveMessage {
4084                    interactive_message: Some(
4085                        interactive_message::InteractiveMessage::NativeFlowMessage(Box::new(
4086                            NativeFlowMessage {
4087                                buttons: vec![NativeFlowButton {
4088                                    name: Some(button_name.to_string()),
4089                                    button_params_json: None,
4090                                }],
4091                                message_version: Some(1),
4092                                message_params_json: None,
4093                            },
4094                        )),
4095                    ),
4096                    ..Default::default()
4097                }),
4098                ..Default::default()
4099            }
4100        }
4101
4102        fn assert_biz_common_attrs(node: &Node, ctx: &str) {
4103            assert_eq!(node.tag, "biz", "{ctx}");
4104            let mut a = node.attrs();
4105            assert_eq!(
4106                a.optional_string("actual_actors").unwrap().as_ref(),
4107                "2",
4108                "{ctx}"
4109            );
4110            assert_eq!(
4111                a.optional_string("host_storage").unwrap().as_ref(),
4112                "2",
4113                "{ctx}"
4114            );
4115            assert_eq!(
4116                a.optional_string("privacy_mode_ts").unwrap().as_ref(),
4117                EXPECTED_PRIVACY_TS,
4118                "{ctx}"
4119            );
4120        }
4121
4122        fn assert_nested_biz(node: &Node, expected_flow_name: &str, ctx: &str) {
4123            assert_biz_common_attrs(node, ctx);
4124            assert!(
4125                node.attrs().optional_string("native_flow_name").is_none(),
4126                "{ctx}: nested form has no native_flow_name attr"
4127            );
4128            let interactive = node
4129                .get_optional_child("interactive")
4130                .unwrap_or_else(|| panic!("{ctx}: missing <interactive>"));
4131            let mut ia = interactive.attrs();
4132            assert_eq!(
4133                ia.optional_string("type").unwrap().as_ref(),
4134                "native_flow",
4135                "{ctx}"
4136            );
4137            assert_eq!(ia.optional_string("v").unwrap().as_ref(), "1", "{ctx}");
4138
4139            let nf = interactive
4140                .get_optional_child("native_flow")
4141                .unwrap_or_else(|| panic!("{ctx}: missing <native_flow>"));
4142            let mut nfa = nf.attrs();
4143            assert_eq!(nfa.optional_string("v").unwrap().as_ref(), "9", "{ctx}");
4144            assert_eq!(
4145                nfa.optional_string("name").unwrap().as_ref(),
4146                expected_flow_name,
4147                "{ctx}"
4148            );
4149
4150            let qc = node
4151                .get_optional_child("quality_control")
4152                .unwrap_or_else(|| panic!("{ctx}: missing <quality_control>"));
4153            assert_eq!(
4154                qc.attrs().optional_string("source_type").unwrap().as_ref(),
4155                "third_party",
4156                "{ctx}"
4157            );
4158        }
4159
4160        /// Payment-family buttons emit the flat `<biz>` form with
4161        /// `native_flow_name` as an attr and NO children.
4162        #[test]
4163        fn payment_simple_form() {
4164            let cases: &[(&str, &str)] = &[
4165                ("payment_info", "payment_info"),
4166                ("review_and_pay", "order_details"),
4167                ("review_order", "order_status"),
4168                ("order_status", "order_status"),
4169                ("payment_status", "payment_status"),
4170                ("payment_method", "payment_method"),
4171                ("payment_reminder", "payment_reminder"),
4172            ];
4173            for (button, expected_flow) in cases {
4174                let biz = infer_biz_node(&msg_with_native_flow_button(button), FIXED_NOW)
4175                    .unwrap_or_else(|| panic!("{button}: should produce biz"));
4176                assert_biz_common_attrs(&biz, button);
4177                assert_eq!(
4178                    biz.attrs()
4179                        .optional_string("native_flow_name")
4180                        .unwrap()
4181                        .as_ref(),
4182                    *expected_flow,
4183                    "{button}: native_flow_name attr"
4184                );
4185                assert!(
4186                    biz.children().unwrap_or(&[]).is_empty(),
4187                    "{button}: PaymentSimple has no children"
4188                );
4189            }
4190        }
4191
4192        /// Every non-payment button name announces itself as `mixed`. The
4193        /// eight names that used to keep their own flow name are the ones
4194        /// #1132 measured as universally refused (473/405), so they must not
4195        /// regain a bespoke shape without fresh live evidence.
4196        #[test]
4197        fn formerly_named_buttons_now_route_through_mixed() {
4198            let cases: &[&str] = &[
4199                "cta_url",
4200                "cta_catalog",
4201                "catalog_message",
4202                "galaxy_message",
4203                "booking_confirmation",
4204                "call_permission_request",
4205                "open_webview",
4206                "message_with_link_status",
4207            ];
4208            for button in cases {
4209                let biz = infer_biz_node(&msg_with_native_flow_button(button), FIXED_NOW)
4210                    .unwrap_or_else(|| panic!("{button}: should produce biz"));
4211                assert_nested_biz(&biz, "mixed", button);
4212            }
4213        }
4214
4215        /// quick_reply / cta_copy / cta_call / single_select / send_location
4216        /// and unknown future button names route through `name="mixed"`.
4217        #[test]
4218        fn mixed_form_for_dropped_buttons() {
4219            let cases: &[&str] = &[
4220                "quick_reply",
4221                "cta_copy",
4222                "cta_call",
4223                "single_select",
4224                "send_location",
4225                "future_button_xyz",
4226            ];
4227            for button in cases {
4228                let biz = infer_biz_node(&msg_with_native_flow_button(button), FIXED_NOW)
4229                    .unwrap_or_else(|| panic!("{button}: should produce biz"));
4230                assert_nested_biz(&biz, "mixed", button);
4231            }
4232        }
4233
4234        /// Non-interactive messages produce no `<biz>` (no fan-out into the
4235        /// extra_stanza_nodes path).
4236        #[test]
4237        fn no_interactive_returns_none() {
4238            let msg = wa::Message {
4239                conversation: Some("hello".into()),
4240                ..Default::default()
4241            };
4242            assert!(infer_biz_node(&msg, FIXED_NOW).is_none());
4243        }
4244
4245        fn carousel_msg(im: wa::message::InteractiveMessage) -> wa::Message {
4246            wa::Message {
4247                interactive_message: buffa::MessageField::some(wa::message::InteractiveMessage {
4248                    interactive_message: Some(
4249                        interactive_message::InteractiveMessage::CarouselMessage(Default::default()),
4250                    ),
4251                    ..im
4252                }),
4253                ..Default::default()
4254            }
4255        }
4256
4257        fn body(text: &str) -> buffa::MessageField<interactive_message::Body> {
4258            buffa::MessageField::some(interactive_message::Body {
4259                text: Some(text.to_string()),
4260            })
4261        }
4262
4263        /// #1133: a carousel's buttons live on its cards, so the button rule
4264        /// never fires. Without this the message left with no `<biz>` at all —
4265        /// accepted, acked, and then invisible on the recipient's handset.
4266        #[test]
4267        fn carousel_with_body_emits_mixed_biz() {
4268            let msg = carousel_msg(wa::message::InteractiveMessage {
4269                body: body("pick a plan"),
4270                ..Default::default()
4271            });
4272            let biz = infer_biz_node(&msg, FIXED_NOW).expect("carousel should produce biz");
4273            assert_nested_biz(&biz, "mixed", "carousel");
4274        }
4275
4276        /// A header title alone is enough, and so is a footer — WA Web's rule
4277        /// is a disjunction over body / title / footer / header image.
4278        #[test]
4279        fn carousel_envelope_variants_emit_mixed_biz() {
4280            let title = carousel_msg(wa::message::InteractiveMessage {
4281                header: buffa::MessageField::some(interactive_message::Header {
4282                    title: Some("Our menu".into()),
4283                    ..Default::default()
4284                }),
4285                ..Default::default()
4286            });
4287            assert_nested_biz(
4288                &infer_biz_node(&title, FIXED_NOW).expect("title should produce biz"),
4289                "mixed",
4290                "header title",
4291            );
4292
4293            let footer = carousel_msg(wa::message::InteractiveMessage {
4294                footer: buffa::MessageField::some(interactive_message::Footer {
4295                    text: Some("tap to order".into()),
4296                    ..Default::default()
4297                }),
4298                ..Default::default()
4299            });
4300            assert_nested_biz(
4301                &infer_biz_node(&footer, FIXED_NOW).expect("footer should produce biz"),
4302                "mixed",
4303                "footer",
4304            );
4305
4306            let image = carousel_msg(wa::message::InteractiveMessage {
4307                header: buffa::MessageField::some(interactive_message::Header {
4308                    media: Some(interactive_message::header::Media::ImageMessage(
4309                        Default::default(),
4310                    )),
4311                    ..Default::default()
4312                }),
4313                ..Default::default()
4314            });
4315            assert_nested_biz(
4316                &infer_biz_node(&image, FIXED_NOW).expect("header image should produce biz"),
4317                "mixed",
4318                "header image",
4319            );
4320        }
4321
4322        /// An empty envelope announces nothing, so there is nothing to mark.
4323        #[test]
4324        fn carousel_without_envelope_returns_none() {
4325            assert!(infer_biz_node(&carousel_msg(Default::default()), FIXED_NOW).is_none());
4326        }
4327
4328        /// An empty-string body is not an envelope: WA Web tests `length > 0`,
4329        /// not presence.
4330        #[test]
4331        fn empty_body_text_is_not_an_envelope() {
4332            let msg = carousel_msg(wa::message::InteractiveMessage {
4333                body: body(""),
4334                ..Default::default()
4335            });
4336            assert!(infer_biz_node(&msg, FIXED_NOW).is_none());
4337        }
4338
4339        /// Storefronts are excluded from the envelope rule, as in WA Web.
4340        #[test]
4341        fn shop_storefront_returns_none() {
4342            let msg = wa::Message {
4343                interactive_message: buffa::MessageField::some(wa::message::InteractiveMessage {
4344                    body: body("visit our shop"),
4345                    interactive_message: Some(
4346                        interactive_message::InteractiveMessage::ShopStorefrontMessage(
4347                            Default::default(),
4348                        ),
4349                    ),
4350                    ..Default::default()
4351                }),
4352                ..Default::default()
4353            };
4354            assert!(infer_biz_node(&msg, FIXED_NOW).is_none());
4355        }
4356
4357        /// Interactive but not native-flow (e.g. CollectionMessage) yields None.
4358        #[test]
4359        fn interactive_without_native_flow_returns_none() {
4360            let msg = wa::Message {
4361                interactive_message: buffa::MessageField::some(wa::message::InteractiveMessage {
4362                    interactive_message: Some(
4363                        interactive_message::InteractiveMessage::CollectionMessage(
4364                            Default::default(),
4365                        ),
4366                    ),
4367                    ..Default::default()
4368                }),
4369                ..Default::default()
4370            };
4371            assert!(infer_biz_node(&msg, FIXED_NOW).is_none());
4372        }
4373
4374        /// NativeFlow with empty button list yields None — no signal to classify.
4375        #[test]
4376        fn native_flow_without_buttons_returns_none() {
4377            let msg = wa::Message {
4378                interactive_message: buffa::MessageField::some(wa::message::InteractiveMessage {
4379                    interactive_message: Some(
4380                        interactive_message::InteractiveMessage::NativeFlowMessage(Box::new(
4381                            NativeFlowMessage {
4382                                buttons: vec![],
4383                                message_version: Some(1),
4384                                message_params_json: None,
4385                            },
4386                        )),
4387                    ),
4388                    ..Default::default()
4389                }),
4390                ..Default::default()
4391            };
4392            assert!(infer_biz_node(&msg, FIXED_NOW).is_none());
4393        }
4394
4395        /// Button with `name = None` is treated as missing classifier → None.
4396        #[test]
4397        fn button_without_name_returns_none() {
4398            let msg = wa::Message {
4399                interactive_message: buffa::MessageField::some(wa::message::InteractiveMessage {
4400                    interactive_message: Some(
4401                        interactive_message::InteractiveMessage::NativeFlowMessage(Box::new(
4402                            NativeFlowMessage {
4403                                buttons: vec![NativeFlowButton {
4404                                    name: None,
4405                                    button_params_json: None,
4406                                }],
4407                                message_version: Some(1),
4408                                message_params_json: None,
4409                            },
4410                        )),
4411                    ),
4412                    ..Default::default()
4413                }),
4414                ..Default::default()
4415            };
4416            assert!(infer_biz_node(&msg, FIXED_NOW).is_none());
4417        }
4418
4419        /// Messages wrapped in `documentWithCaptionMessage` still pick up the
4420        /// native_flow payload from the inner message.
4421        #[test]
4422        fn document_with_caption_wrapper() {
4423            let inner = wa::Message {
4424                interactive_message: buffa::MessageField::some(wa::message::InteractiveMessage {
4425                    interactive_message: Some(
4426                        interactive_message::InteractiveMessage::NativeFlowMessage(Box::new(
4427                            NativeFlowMessage {
4428                                buttons: vec![NativeFlowButton {
4429                                    name: Some("quick_reply".into()),
4430                                    button_params_json: None,
4431                                }],
4432                                message_version: Some(1),
4433                                message_params_json: None,
4434                            },
4435                        )),
4436                    ),
4437                    ..Default::default()
4438                }),
4439                ..Default::default()
4440            };
4441            let msg = wa::Message {
4442                document_with_caption_message: buffa::MessageField::some(
4443                    wa::message::FutureProofMessage {
4444                        message: buffa::MessageField::some(inner),
4445                    },
4446                ),
4447                ..Default::default()
4448            };
4449            let biz = infer_biz_node(&msg, FIXED_NOW)
4450                .expect("doc-with-caption wrapper should propagate the inner native_flow");
4451            assert_nested_biz(&biz, "mixed", "doc-with-caption/quick_reply");
4452        }
4453
4454        // -- build_extra_stanza_nodes assembly tests --
4455
4456        fn quick_reply_biz() -> Node {
4457            infer_biz_node(&msg_with_native_flow_button("quick_reply"), FIXED_NOW)
4458                .expect("quick_reply produces biz")
4459        }
4460
4461        fn payment_biz() -> Node {
4462            infer_biz_node(&msg_with_native_flow_button("payment_info"), FIXED_NOW)
4463                .expect("payment_info produces biz")
4464        }
4465
4466        fn jid(s: &str) -> Jid {
4467            Jid::from_str(s).expect("valid jid in test")
4468        }
4469
4470        /// DM: `<bot biz_bot="1"/>` is prepended before the `<biz>`. The
4471        /// order matters because it is part of the wire shape.
4472        #[test]
4473        fn dm_emits_bot_before_biz() {
4474            let nodes = build_extra_stanza_nodes(
4475                &jid("5511999999999@s.whatsapp.net"),
4476                None,
4477                Some(quick_reply_biz()),
4478                vec![],
4479            );
4480            assert_eq!(nodes.len(), 2, "expected [<bot>, <biz>]");
4481            assert_eq!(nodes[0].tag, "bot");
4482            assert_eq!(
4483                nodes[0]
4484                    .attrs()
4485                    .optional_string("biz_bot")
4486                    .unwrap()
4487                    .as_ref(),
4488                "1"
4489            );
4490            assert_eq!(nodes[1].tag, "biz");
4491        }
4492
4493        /// Group: `<bot>` is NOT emitted; only `<biz>`.
4494        #[test]
4495        fn group_omits_bot() {
4496            let nodes = build_extra_stanza_nodes(
4497                &jid("120363000000000001@g.us"),
4498                None,
4499                Some(quick_reply_biz()),
4500                vec![],
4501            );
4502            assert_eq!(nodes.len(), 1);
4503            assert_eq!(nodes[0].tag, "biz");
4504        }
4505
4506        /// LID DM (non-group): `<bot>` is still emitted.
4507        #[test]
4508        fn lid_dm_emits_bot() {
4509            let nodes = build_extra_stanza_nodes(
4510                &jid("100000000000001@lid"),
4511                None,
4512                Some(payment_biz()),
4513                vec![],
4514            );
4515            assert_eq!(nodes.len(), 2);
4516            assert_eq!(nodes[0].tag, "bot");
4517        }
4518
4519        /// No biz + no meta → user nodes pass through untouched.
4520        #[test]
4521        fn no_biz_no_meta_passthrough() {
4522            let user_nodes = vec![NodeBuilder::new("custom").build()];
4523            let nodes =
4524                build_extra_stanza_nodes(&jid("X@s.whatsapp.net"), None, None, user_nodes.clone());
4525            assert_eq!(nodes.len(), 1);
4526            assert_eq!(nodes[0].tag, "custom");
4527        }
4528
4529        /// Full ordering: [meta, bot, biz, user_nodes...].
4530        #[test]
4531        fn full_ordering_meta_bot_biz_user() {
4532            let meta = NodeBuilder::new("meta").attr("appdata", "default").build();
4533            let user_a = NodeBuilder::new("user_a").build();
4534            let user_b = NodeBuilder::new("user_b").build();
4535            let nodes = build_extra_stanza_nodes(
4536                &jid("X@s.whatsapp.net"),
4537                Some(meta),
4538                Some(quick_reply_biz()),
4539                vec![user_a, user_b],
4540            );
4541            assert_eq!(nodes.len(), 5);
4542            assert_eq!(nodes[0].tag, "meta");
4543            assert_eq!(nodes[1].tag, "bot");
4544            assert_eq!(nodes[2].tag, "biz");
4545            assert_eq!(nodes[3].tag, "user_a");
4546            assert_eq!(nodes[4].tag, "user_b");
4547        }
4548
4549        /// Meta-only (no biz) preserves order: meta then user nodes; no bot.
4550        #[test]
4551        fn meta_only_preserves_order() {
4552            let meta = NodeBuilder::new("meta").build();
4553            let user = NodeBuilder::new("u").build();
4554            let nodes =
4555                build_extra_stanza_nodes(&jid("X@s.whatsapp.net"), Some(meta), None, vec![user]);
4556            assert_eq!(nodes.len(), 2);
4557            assert_eq!(nodes[0].tag, "meta");
4558            assert_eq!(nodes[1].tag, "u");
4559        }
4560    }
4561
4562    #[test]
4563    fn structural_extra_children_are_rejected_before_send_work() {
4564        for tag in RESERVED_EXTRA_STANZA_CHILDREN {
4565            let error = validate_extra_stanza_nodes(&[NodeBuilder::new(tag).build()])
4566                .expect_err("send-owned child must be rejected");
4567            assert!(error.to_string().contains(tag));
4568        }
4569
4570        validate_extra_stanza_nodes(&[
4571            NodeBuilder::new("meta").build(),
4572            NodeBuilder::new("biz").build(),
4573            NodeBuilder::new("custom-extension").build(),
4574        ])
4575        .expect("non-structural protocol extensions remain available");
4576    }
4577
4578    /// Regression tests for #462: send path session lock keys must match decrypt path.
4579    mod session_lock_regression {
4580        use super::*;
4581
4582        #[tokio::test]
4583        async fn per_device_lock_keys_cover_all_devices() {
4584            let client = crate::test_utils::create_test_client().await;
4585
4586            let devices: Vec<Jid> = [
4587                "100000012345678@lid",
4588                "100000012345678:5@lid",
4589                "100000012345678:33@lid",
4590            ]
4591            .iter()
4592            .map(|s| Jid::from_str(s).unwrap())
4593            .collect();
4594
4595            // Uses the production helper (resolve_encryption_jid + sort + dedup)
4596            let send_lock_keys = client.build_session_lock_keys(&devices).await;
4597
4598            assert_eq!(send_lock_keys.len(), 3);
4599            // Sorted by (server, user, device_numeric): 0, 5, 33
4600            assert_eq!(send_lock_keys[0].device, 0);
4601            assert_eq!(send_lock_keys[1].device, 5);
4602            assert_eq!(send_lock_keys[2].device, 33);
4603
4604            // Send keys must cover every device
4605            for device_jid in &devices {
4606                assert!(
4607                    send_lock_keys.contains(device_jid),
4608                    "device {device_jid} not in send keys: {send_lock_keys:?}"
4609                );
4610            }
4611
4612            // Bare JID key alone wouldn't protect linked devices
4613            let bare_key = devices[0].to_protocol_address_string();
4614            let device5_key = devices[1].to_protocol_address_string();
4615            assert_ne!(bare_key, device5_key);
4616        }
4617
4618        #[tokio::test]
4619        async fn per_device_lock_serializes_concurrent_session_access() {
4620            use std::sync::Arc;
4621            use std::sync::atomic::{AtomicU32, Ordering};
4622
4623            let session_locks: crate::cache::Cache<String, Arc<async_lock::Mutex<()>>> =
4624                crate::cache::Cache::builder().max_capacity(100).build();
4625
4626            let lock_key = "100000012345678:5@lid.0".to_string();
4627            let access_counter = Arc::new(AtomicU32::new(0));
4628            let max_concurrent = Arc::new(AtomicU32::new(0));
4629
4630            let mut handles = Vec::new();
4631            for _ in 0..10 {
4632                let locks = session_locks.clone();
4633                let key = lock_key.clone();
4634                let counter = access_counter.clone();
4635                let max = max_concurrent.clone();
4636
4637                handles.push(tokio::spawn(async move {
4638                    let mutex: Arc<async_lock::Mutex<()>> = locks
4639                        .get_with_by_ref(&key, async { Arc::new(async_lock::Mutex::new(())) })
4640                        .await;
4641                    // lock_arc() needed: guard must own the Arc since mutex is a local
4642                    // (production uses lock() with a separate Vec keeping Arcs alive)
4643                    let _guard = mutex.lock_arc().await;
4644
4645                    let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
4646                    max.fetch_max(active, Ordering::SeqCst);
4647                    tokio::task::yield_now().await;
4648                    counter.fetch_sub(1, Ordering::SeqCst);
4649                }));
4650            }
4651
4652            for handle in handles {
4653                handle.await.unwrap();
4654            }
4655
4656            assert_eq!(max_concurrent.load(Ordering::SeqCst), 1);
4657        }
4658
4659        #[tokio::test]
4660        async fn different_device_locks_are_independent() {
4661            use std::sync::Arc;
4662            use std::sync::atomic::{AtomicU32, Ordering};
4663
4664            let session_locks: crate::cache::Cache<String, Arc<async_lock::Mutex<()>>> =
4665                crate::cache::Cache::builder().max_capacity(100).build();
4666
4667            let max_concurrent = Arc::new(AtomicU32::new(0));
4668            let counter = Arc::new(AtomicU32::new(0));
4669            let barrier = Arc::new(tokio::sync::Barrier::new(2));
4670
4671            let keys = ["100000012345678@lid.0", "100000012345678:5@lid.0"];
4672
4673            let mut handles = Vec::new();
4674            for key in keys {
4675                let locks = session_locks.clone();
4676                let key = key.to_string();
4677                let c = counter.clone();
4678                let m = max_concurrent.clone();
4679                let b = barrier.clone();
4680
4681                handles.push(tokio::spawn(async move {
4682                    let mutex: Arc<async_lock::Mutex<()>> = locks
4683                        .get_with_by_ref(&key, async { Arc::new(async_lock::Mutex::new(())) })
4684                        .await;
4685                    // lock_arc(): same reason as above
4686                    let _guard = mutex.lock_arc().await;
4687
4688                    let active = c.fetch_add(1, Ordering::SeqCst) + 1;
4689                    m.fetch_max(active, Ordering::SeqCst);
4690                    b.wait().await;
4691                    c.fetch_sub(1, Ordering::SeqCst);
4692                }));
4693            }
4694
4695            for handle in handles {
4696                handle.await.unwrap();
4697            }
4698
4699            assert_eq!(max_concurrent.load(Ordering::SeqCst), 2);
4700        }
4701
4702        /// Regression: 1:1 DM recipient must use bare Signal address matching
4703        /// the receive path. Starts from device-specific JID and verifies
4704        /// to_non_ad() normalization produces the correct bare key.
4705        #[tokio::test]
4706        async fn dm_recipient_uses_bare_address() {
4707            let client = crate::test_utils::create_test_client().await;
4708
4709            // Start from device-specific JID, exercise the production path
4710            let recipient_device33 = Jid::from_str("100000012345678:33@lid").unwrap();
4711            let own_device_5 = Jid::from_str("999999999999:5@s.whatsapp.net").unwrap();
4712
4713            // Same normalization as send_message_impl
4714            let recipient_bare = client
4715                .resolve_encryption_jid(&recipient_device33)
4716                .await
4717                .to_non_ad();
4718
4719            let all_dm_jids = vec![recipient_bare.clone(), own_device_5.clone()];
4720            let lock_jids = client.build_session_lock_keys(&all_dm_jids).await;
4721
4722            // Recipient lock key must be BARE (device 0), matching decrypt path
4723            assert_eq!(
4724                recipient_bare.to_protocol_address_string(),
4725                "100000012345678@lid.0"
4726            );
4727            assert!(lock_jids.contains(&recipient_bare));
4728
4729            // Own device lock key must be device-specific
4730            assert!(lock_jids.contains(&own_device_5));
4731
4732            // Device-specific recipient key must NOT be present
4733            assert!(
4734                !lock_jids.contains(&recipient_device33),
4735                "recipient must NOT use device-specific address"
4736            );
4737        }
4738
4739        /// Verify bare normalization deduplicates multiple recipient devices.
4740        #[test]
4741        fn bare_normalization_deduplicates_recipient_devices() {
4742            let devices: Vec<Jid> = [
4743                "100000012345678@lid",
4744                "100000012345678:5@lid",
4745                "100000012345678:33@lid",
4746            ]
4747            .iter()
4748            .map(|s| Jid::from_str(s).unwrap())
4749            .collect();
4750
4751            // All collapse to the same bare JID
4752            let bare: Vec<Jid> = devices.iter().map(|j| j.to_non_ad()).collect();
4753            assert!(bare.windows(2).all(|w| w[0] == w[1]));
4754            assert_eq!(
4755                bare[0].to_protocol_address_string(),
4756                "100000012345678@lid.0"
4757            );
4758        }
4759
4760        /// Every key handed in ends up locked, and every one is released when
4761        /// the guards are dropped. The device counts are the three the DM path
4762        /// actually produces: none (a fan-out that resolved to nothing), one
4763        /// (a steady 1:1) and several (companion devices in play).
4764        #[tokio::test]
4765        async fn taking_guards_locks_every_key_and_releasing_them_frees_every_key() {
4766            let client = crate::test_utils::create_test_client_with_name("guards_cover").await;
4767
4768            for count in [0usize, 1, 3] {
4769                let devices: Vec<Jid> = (0..count)
4770                    .map(|i| Jid::from_str(&format!("10000001234567{i}:5@lid")).unwrap())
4771                    .collect();
4772                let keys = client.build_session_lock_keys(&devices).await;
4773                assert_eq!(keys.len(), count, "one key per device at count {count}");
4774                let mutexes = client.session_mutexes_for(&keys).await;
4775
4776                let guards = client.session_guards_for(&keys).await;
4777                assert_eq!(guards.len(), count, "one guard per key at count {count}");
4778                for (i, mutex) in mutexes.iter().enumerate() {
4779                    assert!(
4780                        mutex.try_lock().is_none(),
4781                        "key {i} of {count} must be held while the guards live"
4782                    );
4783                }
4784
4785                drop(guards);
4786                for (i, mutex) in mutexes.iter().enumerate() {
4787                    assert!(
4788                        mutex.try_lock().is_some(),
4789                        "key {i} of {count} must be free once the guards are dropped"
4790                    );
4791                }
4792            }
4793        }
4794
4795        /// The keys are locked in the order given, which is the sorted order
4796        /// `build_session_lock_keys` produces. That single global order is the
4797        /// only thing keeping two sends that overlap on a device from
4798        /// deadlocking, so acquiring out of order must be observable.
4799        ///
4800        /// Blocking the SECOND key and then waiting for the FIRST to become
4801        /// contended is what pins the order down: a taker that went second-first
4802        /// would park on the blocked key and never touch the first one.
4803        #[tokio::test]
4804        async fn keys_are_locked_in_the_order_they_are_given() {
4805            let client = crate::test_utils::create_test_client_with_name("guards_order").await;
4806
4807            let devices: Vec<Jid> = ["100000012345670:5@lid", "100000012345671:5@lid"]
4808                .iter()
4809                .map(|s| Jid::from_str(s).unwrap())
4810                .collect();
4811            let keys = client.build_session_lock_keys(&devices).await;
4812            assert_eq!(keys.len(), 2);
4813            let mutexes = client.session_mutexes_for(&keys).await;
4814
4815            let blocker = mutexes[1].lock_arc().await;
4816
4817            let mut taker = tokio::spawn({
4818                let client = client.clone();
4819                let keys = keys.clone();
4820                async move { client.session_guards_for(&keys).await.len() }
4821            });
4822
4823            // Bounded work, not a deadline: yield until the first key is taken.
4824            let mut polls = 0;
4825            while mutexes[0].try_lock().is_some() {
4826                polls += 1;
4827                assert!(
4828                    polls < 10_000,
4829                    "the first key was never taken, so acquisition did not start there"
4830                );
4831                tokio::task::yield_now().await;
4832            }
4833            assert!(
4834                futures::poll!(&mut taker).is_pending(),
4835                "the taker must still be parked on the second key"
4836            );
4837
4838            drop(blocker);
4839            assert_eq!(taker.await.expect("taker finishes"), 2);
4840        }
4841    }
4842
4843    // ---- outbound messageSecret capture ---------------------------------
4844
4845    use crate::store::commands::DeviceCommand;
4846    use std::sync::Arc;
4847
4848    async fn seed_pn(client: &Arc<Client>, pn: &str) {
4849        client
4850            .persistence_manager
4851            .process_command(DeviceCommand::SetId(Some(pn.parse().expect("pn"))))
4852            .await;
4853    }
4854
4855    async fn seed_pn_and_lid(client: &Arc<Client>, pn: &str, lid: &str) {
4856        client
4857            .persistence_manager
4858            .process_command(DeviceCommand::SetId(Some(pn.parse().expect("pn"))))
4859            .await;
4860        client
4861            .persistence_manager
4862            .process_command(DeviceCommand::SetLid(Some(lid.parse().expect("lid"))))
4863            .await;
4864    }
4865
4866    fn peer_test_account_proto() -> wa::ADVSignedDeviceIdentity {
4867        wa::ADVSignedDeviceIdentity {
4868            details: Some(vec![0u8; 32]),
4869            account_signature_key: Some(vec![0u8; 32]),
4870            account_signature: Some(vec![0u8; 64]),
4871            device_signature: Some(vec![0u8; 64]),
4872        }
4873    }
4874
4875    async fn seed_peer_send_state(client: &Arc<Client>, peer: &Jid) {
4876        use wacore::libsignal::protocol::{
4877            IdentityKeyPair, KeyPair, PreKeyBundle, SignalProtocolError, UsePQRatchet,
4878            process_prekey_bundle,
4879        };
4880
4881        client
4882            .persistence_manager
4883            .process_command(DeviceCommand::SetAccount(Some(peer_test_account_proto())))
4884            .await;
4885
4886        let bundle =
4887            tokio::task::spawn_blocking(|| -> Result<PreKeyBundle, SignalProtocolError> {
4888                let mut rng = rand::make_rng::<rand::rngs::StdRng>();
4889                let receiver = IdentityKeyPair::generate(&mut rng);
4890                let spk = KeyPair::generate(&mut rng);
4891                let opk = KeyPair::generate(&mut rng);
4892                let sig = receiver
4893                    .private_key()
4894                    .calculate_signature(&spk.public_key.serialize(), &mut rng)?;
4895
4896                PreKeyBundle::new(
4897                    1,
4898                    1u32.into(),
4899                    Some((1u32.into(), opk.public_key)),
4900                    1u32.into(),
4901                    spk.public_key,
4902                    sig.to_vec(),
4903                    *receiver.identity_key(),
4904                )
4905            })
4906            .await
4907            .expect("prekey bundle task")
4908            .expect("prekey bundle");
4909
4910        let mut adapter = client.signal_adapter().await;
4911        let mut rng = rand::make_rng::<rand::rngs::StdRng>();
4912        process_prekey_bundle(
4913            &peer.to_protocol_address(),
4914            &mut adapter.session_store,
4915            &mut adapter.identity_store,
4916            &bundle,
4917            &mut rng,
4918            UsePQRatchet::No,
4919        )
4920        .await
4921        .expect("peer session");
4922    }
4923
4924    fn pdo_request_message(request_type: wa::message::PeerDataOperationRequestType) -> wa::Message {
4925        wa::Message {
4926            protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage {
4927                r#type: Some(wa::message::protocol_message::Type::PeerDataOperationRequestMessage),
4928                peer_data_operation_request_message: buffa::MessageField::some(
4929                    wa::message::PeerDataOperationRequestMessage {
4930                        peer_data_operation_request_type: Some(request_type),
4931                        ..Default::default()
4932                    },
4933                ),
4934                ..Default::default()
4935            }),
4936            ..Default::default()
4937        }
4938    }
4939
4940    #[tokio::test]
4941    async fn peer_pdo_send_path_stamps_history_sync_options() {
4942        let client = crate::test_utils::create_test_client_with_name("peer_pdo_attrs").await;
4943        let peer: Jid = "100000000000001@s.whatsapp.net".parse().unwrap();
4944        seed_peer_send_state(&client, &peer).await;
4945
4946        let request_id = "PDO_PEER_ATTRS_1";
4947        let waiter = client
4948            .wait_for_sent_node(crate::client::NodeFilter::tag("message").attr("id", request_id));
4949        let msg =
4950            pdo_request_message(wa::message::PeerDataOperationRequestType::HistorySyncOnDemand);
4951
4952        let result = client
4953            .send_message_impl(
4954                peer,
4955                &msg,
4956                SendPipelineOptions {
4957                    request_id: Some(request_id),
4958                    peer: true,
4959                    ..Default::default()
4960                },
4961            )
4962            .await;
4963        assert!(
4964            result.is_err(),
4965            "test client has no socket; send should fail after stanza capture"
4966        );
4967
4968        let node = tokio::time::timeout(std::time::Duration::from_secs(1), waiter)
4969            .await
4970            .expect("sent node should be captured")
4971            .expect("sent node waiter should resolve");
4972        assert_eq!(
4973            node.attrs().optional_string("category").unwrap().as_ref(),
4974            "peer"
4975        );
4976        assert_eq!(
4977            node.attrs()
4978                .optional_string("push_priority")
4979                .unwrap()
4980                .as_ref(),
4981            "high_force"
4982        );
4983        assert_eq!(
4984            node.attrs()
4985                .optional_string("privacy_sensitive")
4986                .unwrap()
4987                .as_ref(),
4988            "1"
4989        );
4990    }
4991
4992    #[tokio::test]
4993    async fn stanza_type_override_sets_wire_type_attr() {
4994        let client = crate::test_utils::create_test_client_with_name("stanza_type_override").await;
4995        let peer: Jid = "100000000000003@s.whatsapp.net".parse().unwrap();
4996        seed_peer_send_state(&client, &peer).await;
4997
4998        let request_id = "STANZA_TYPE_OVERRIDE_1";
4999        let waiter = client
5000            .wait_for_sent_node(crate::client::NodeFilter::tag("message").attr("id", request_id));
5001        let msg =
5002            pdo_request_message(wa::message::PeerDataOperationRequestType::HistorySyncOnDemand);
5003
5004        // Poll is never the type for this message; it can only come from the override.
5005        let result = client
5006            .send_message_impl(
5007                peer,
5008                &msg,
5009                SendPipelineOptions {
5010                    request_id: Some(request_id),
5011                    peer: true,
5012                    stanza_type: Some(StanzaType::Poll),
5013                    ..Default::default()
5014                },
5015            )
5016            .await;
5017        assert!(
5018            result.is_err(),
5019            "test client has no socket; send should fail after stanza capture"
5020        );
5021
5022        let node = tokio::time::timeout(std::time::Duration::from_secs(1), waiter)
5023            .await
5024            .expect("sent node should be captured")
5025            .expect("sent node waiter should resolve");
5026        assert_eq!(
5027            node.attrs().optional_string("type").unwrap().as_ref(),
5028            StanzaType::Poll.as_wire()
5029        );
5030    }
5031
5032    /// Shared setup for the DM wire-namespace regression tests: own PN/LID +
5033    /// account, the peer's LID mapping, device-registry entries for both peer
5034    /// namespaces and self, offline-sync completion, and a seeded Signal
5035    /// session for the peer's LID device so the offline fanout can encrypt
5036    /// without a socket. Returns `(peer_pn, peer_lid)`.
5037    async fn seed_dm_wire_namespace_state(client: &Arc<Client>) -> (Jid, Jid) {
5038        use wacore::libsignal::protocol::{
5039            IdentityKeyPair, KeyPair, PreKeyBundle, SignalProtocolError, UsePQRatchet,
5040            process_prekey_bundle,
5041        };
5042
5043        // A LID-addressed DM requires the device's own PN and LID to be known.
5044        let own_pn: Jid = "111111111111@s.whatsapp.net".parse().unwrap();
5045        let own_lid: Jid = "222222222222@lid".parse().unwrap();
5046        client
5047            .persistence_manager
5048            .process_command(DeviceCommand::SetId(Some(own_pn.clone())))
5049            .await;
5050        client
5051            .persistence_manager
5052            .process_command(DeviceCommand::SetLid(Some(own_lid)))
5053            .await;
5054        client
5055            .persistence_manager
5056            .process_command(DeviceCommand::SetAccount(Some(peer_test_account_proto())))
5057            .await;
5058
5059        // The peer is LID-mapped: the wire namespace is then decided solely by
5060        // the account's migration state.
5061        let peer_pn: Jid = "100000000000777@s.whatsapp.net".parse().unwrap();
5062        let peer_lid: Jid = "555000000000777@lid".parse().unwrap();
5063        client
5064            .add_lid_pn_mapping(
5065                peer_lid.user.as_str(),
5066                peer_pn.user.as_str(),
5067                crate::lid_pn_cache::LearningSource::Usync,
5068            )
5069            .await
5070            .expect("seed lid mapping");
5071
5072        // Pre-seed the device registry for the peer (both namespaces) and self
5073        // so the offline send resolves the fanout from cache instead of
5074        // blocking on a network device-list fetch (which would time out with
5075        // no socket).
5076        for user in [
5077            peer_lid.user.to_string(),
5078            peer_pn.user.to_string(),
5079            own_pn.user.to_string(),
5080        ] {
5081            client
5082                .update_device_list(wacore::store::traits::DeviceListRecord {
5083                    user,
5084                    devices: vec![wacore::store::traits::DeviceInfo::new(0, None)],
5085                    timestamp: wacore::time::now_secs(),
5086                    phash: None,
5087                    raw_id: None,
5088                })
5089                .await
5090                .expect("seed device registry");
5091        }
5092
5093        // The test client never connects, so the send's `ensure_e2e_sessions`
5094        // would otherwise block on `wait_for_offline_delivery_end` until
5095        // timeout. Enter live state synchronously (the real finisher now runs
5096        // as a spawned task).
5097        client.enter_live_mode_for_tests();
5098
5099        // Seed a Signal session for the peer's LID device so the offline fanout
5100        // can encrypt without fetching prekeys over the (absent) socket. The
5101        // session lives under the LID address in both tests: Signal addressing
5102        // is LID-first regardless of the wire namespace (WAWebSignalAddress).
5103        let lid_addr = peer_lid.to_non_ad();
5104        let bundle =
5105            tokio::task::spawn_blocking(|| -> Result<PreKeyBundle, SignalProtocolError> {
5106                let mut rng = rand::make_rng::<rand::rngs::StdRng>();
5107                let receiver = IdentityKeyPair::generate(&mut rng);
5108                let spk = KeyPair::generate(&mut rng);
5109                let opk = KeyPair::generate(&mut rng);
5110                let sig = receiver
5111                    .private_key()
5112                    .calculate_signature(&spk.public_key.serialize(), &mut rng)?;
5113                PreKeyBundle::new(
5114                    1,
5115                    1u32.into(),
5116                    Some((1u32.into(), opk.public_key)),
5117                    1u32.into(),
5118                    spk.public_key,
5119                    sig.to_vec(),
5120                    *receiver.identity_key(),
5121                )
5122            })
5123            .await
5124            .expect("prekey bundle task")
5125            .expect("prekey bundle");
5126        {
5127            let mut adapter = client.signal_adapter().await;
5128            let mut rng = rand::make_rng::<rand::rngs::StdRng>();
5129            process_prekey_bundle(
5130                &lid_addr.to_protocol_address(),
5131                &mut adapter.session_store,
5132                &mut adapter.identity_store,
5133                &bundle,
5134                &mut rng,
5135                UsePQRatchet::No,
5136            )
5137            .await
5138            .expect("peer lid session");
5139        }
5140
5141        (peer_pn, peer_lid)
5142    }
5143
5144    /// Regression for #730: on a 1:1-LID-migrated account, a DM to a
5145    /// LID-mapped peer must address the outer `<message to>` by LID, matching
5146    /// the LID `<participants>`. Pre-fix the outer `to` kept the caller's PN,
5147    /// so a PN-to over LID participants was rejected wholesale by the server
5148    /// with `ack error="400"` and never delivered (while the send still
5149    /// returned Ok). WAWebSendMsgCreateFanoutStanza builds the whole stanza
5150    /// from one CHAT_JID (the LID after migration).
5151    #[tokio::test]
5152    async fn dm_to_lid_mapped_peer_addresses_outer_to_by_lid() {
5153        let client = crate::test_utils::create_test_client_with_name("lid_dm_to").await;
5154        let (peer_pn, peer_lid) = seed_dm_wire_namespace_state(&client).await;
5155
5156        // LID wire addressing is gated on the account being 1:1-LID-migrated.
5157        client
5158            .persistence_manager
5159            .process_command(DeviceCommand::SetLidMigrated(true))
5160            .await;
5161
5162        let request_id = "LID_DM_TO_1";
5163        let waiter = client
5164            .wait_for_sent_node(crate::client::NodeFilter::tag("message").attr("id", request_id));
5165        let msg = wa::Message {
5166            conversation: Some("hi".into()),
5167            ..Default::default()
5168        };
5169        // Caller passes the PN form; the resolved namespace must win on the wire.
5170        let result = client
5171            .send_message_impl(
5172                peer_pn,
5173                &msg,
5174                SendPipelineOptions {
5175                    request_id: Some(request_id),
5176                    ..Default::default()
5177                },
5178            )
5179            .await;
5180        assert!(
5181            result.is_err(),
5182            "test client has no socket; send captures the stanza then errors"
5183        );
5184
5185        let node = tokio::time::timeout(std::time::Duration::from_secs(1), waiter)
5186            .await
5187            .expect("sent node should be captured")
5188            .expect("sent node waiter should resolve");
5189
5190        // The fix: outer `<message to>` is the LID, not the caller's PN.
5191        let to_str = node
5192            .attrs()
5193            .optional_string("to")
5194            .expect("message has a to")
5195            .into_owned();
5196        let to_jid: Jid = to_str.parse().expect("to parses");
5197        assert!(
5198            to_jid.is_lid(),
5199            "outer <message to> must be LID to match the LID participants, got {to_str}"
5200        );
5201        assert_eq!(
5202            to_jid.user.as_str(),
5203            peer_lid.user.as_str(),
5204            "outer to user must be the peer LID"
5205        );
5206
5207        // Uniformity guard: every <participants>/<to> is LID too (no mix).
5208        let participants = node
5209            .get_optional_child("participants")
5210            .expect("stanza has participants");
5211        let entries = participants.children().expect("participants has children");
5212        assert!(
5213            !entries.is_empty(),
5214            "fanout must target at least the recipient"
5215        );
5216        for entry in entries {
5217            let pj: Jid = entry
5218                .attrs()
5219                .optional_string("jid")
5220                .expect("participant jid")
5221                .parse()
5222                .expect("participant jid parses");
5223            assert!(
5224                pj.is_lid(),
5225                "participant {pj} must be LID (uniform namespace)"
5226            );
5227        }
5228    }
5229
5230    /// Regression for #941: an account that is NOT 1:1-LID-migrated must keep
5231    /// DM wire addressing on PN even with a cached LID mapping — the server
5232    /// 400-nacks LID-addressed DMs from unmigrated accounts. WA Web only
5233    /// addresses 1:1 chats by LID once `Lid1X1MigrationUtils.isLidMigrated()`.
5234    #[tokio::test]
5235    async fn dm_from_unmigrated_account_addresses_outer_to_by_pn() {
5236        let client = crate::test_utils::create_test_client_with_name("pn_dm_to").await;
5237        let (peer_pn, _peer_lid) = seed_dm_wire_namespace_state(&client).await;
5238
5239        let request_id = "PN_DM_TO_1";
5240        let waiter = client
5241            .wait_for_sent_node(crate::client::NodeFilter::tag("message").attr("id", request_id));
5242        let msg = wa::Message {
5243            conversation: Some("hi".into()),
5244            ..Default::default()
5245        };
5246        let result = client
5247            .send_message_impl(
5248                peer_pn.clone(),
5249                &msg,
5250                SendPipelineOptions {
5251                    request_id: Some(request_id),
5252                    ..Default::default()
5253                },
5254            )
5255            .await;
5256        assert!(
5257            result.is_err(),
5258            "test client has no socket; send captures the stanza then errors"
5259        );
5260
5261        let node = tokio::time::timeout(std::time::Duration::from_secs(1), waiter)
5262            .await
5263            .expect("sent node should be captured")
5264            .expect("sent node waiter should resolve");
5265
5266        let to_str = node
5267            .attrs()
5268            .optional_string("to")
5269            .expect("message has a to")
5270            .into_owned();
5271        let to_jid: Jid = to_str.parse().expect("to parses");
5272        assert!(
5273            to_jid.is_pn(),
5274            "outer <message to> must stay PN on an unmigrated account, got {to_str}"
5275        );
5276        assert_eq!(
5277            to_jid.user.as_str(),
5278            peer_pn.user.as_str(),
5279            "outer to user must be the peer PN"
5280        );
5281
5282        // Uniformity guard: every <participants>/<to> is PN too (no mix).
5283        let participants = node
5284            .get_optional_child("participants")
5285            .expect("stanza has participants");
5286        let entries = participants.children().expect("participants has children");
5287        assert!(
5288            !entries.is_empty(),
5289            "fanout must target at least the recipient"
5290        );
5291        for entry in entries {
5292            let pj: Jid = entry
5293                .attrs()
5294                .optional_string("jid")
5295                .expect("participant jid")
5296                .parse()
5297                .expect("participant jid parses");
5298            assert!(
5299                pj.is_pn(),
5300                "participant {pj} must be PN (uniform namespace)"
5301            );
5302        }
5303    }
5304
5305    /// Newsletter JIDs must be rejected at the E2E send path root (covers the
5306    /// mis-routed pin/edit/revoke producers that call send_message_impl directly).
5307    #[tokio::test]
5308    async fn newsletter_jid_rejected_on_e2e_send_path() {
5309        let client = crate::test_utils::create_test_client_with_name("newsletter_e2e_guard").await;
5310        let channel: Jid = "120363000000000001@newsletter".parse().unwrap();
5311        let msg = wa::Message {
5312            conversation: Some("x".to_string()),
5313            ..Default::default()
5314        };
5315        let err = client
5316            .send_message_impl(channel, &msg, SendPipelineOptions::default())
5317            .await
5318            .expect_err("newsletter JID must be rejected on the E2E send path");
5319        assert!(
5320            err.to_string().to_lowercase().contains("newsletter"),
5321            "error should name the newsletter mis-route, got: {err}"
5322        );
5323    }
5324
5325    /// The pin producer routes through send_message_impl, so a newsletter pin is
5326    /// rejected rather than building an encrypted fanout against a channel.
5327    #[tokio::test]
5328    async fn pin_message_rejects_newsletter() {
5329        let client = crate::test_utils::create_test_client_with_name("newsletter_pin_guard").await;
5330        let channel: Jid = "120363000000000002@newsletter".parse().unwrap();
5331        let key = wa::MessageKey {
5332            remote_jid: Some(channel.to_string()),
5333            from_me: Some(true),
5334            id: Some("MID".to_string()),
5335            participant: None,
5336        };
5337        let err = client
5338            .pin_message(channel, key, PinDuration::Days7)
5339            .await
5340            .expect_err("pinning a newsletter message must be rejected");
5341        assert!(
5342            err.to_string().to_lowercase().contains("newsletter"),
5343            "error should name the newsletter mis-route, got: {err}"
5344        );
5345    }
5346
5347    /// Newsletter edit: plaintext `<message edit="3">` keyed by server_id, with the
5348    /// new content in `<plaintext>`. Keyed by the message id STRING (not server_id),
5349    /// and a text edit carries no mediatype.
5350    #[test]
5351    fn build_newsletter_edit_node_emits_plaintext_edit() {
5352        use buffa::Message as _;
5353        let to: Jid = "120363000000000001@newsletter".parse().unwrap();
5354        let content = wa::Message {
5355            conversation: Some("edited text".to_string()),
5356            ..Default::default()
5357        };
5358        let node =
5359            build_newsletter_edit_node(&to, "3EB0EDITTARGET", NewsletterEdit::Edit(&content));
5360
5361        let mut a = node.attrs();
5362        assert_eq!(a.optional_string("id").unwrap().as_ref(), "3EB0EDITTARGET");
5363        assert_eq!(a.optional_string("type").unwrap().as_ref(), "text");
5364        assert_eq!(a.optional_string("edit").unwrap().as_ref(), "3");
5365
5366        let pt = node
5367            .get_optional_child("plaintext")
5368            .expect("plaintext child");
5369        assert!(
5370            pt.attrs().optional_string("mediatype").is_none(),
5371            "a text edit must not carry a mediatype attr"
5372        );
5373        let bytes = match pt.content.as_ref() {
5374            Some(wacore_binary::NodeContent::Bytes(b)) => b.clone(),
5375            other => panic!("expected plaintext bytes, got {other:?}"),
5376        };
5377        let decoded = wa::Message::decode_from_slice(bytes.as_slice()).expect("decode plaintext");
5378        assert_eq!(decoded.conversation.as_deref(), Some("edited text"));
5379    }
5380
5381    /// Media newsletter edit: type="media" + `<plaintext mediatype="image">`.
5382    #[test]
5383    fn build_newsletter_edit_node_media_edit() {
5384        let to: Jid = "120363000000000001@newsletter".parse().unwrap();
5385        let content = wa::Message {
5386            image_message: buffa::MessageField::some(wa::message::ImageMessage {
5387                caption: Some("new caption".to_string()),
5388                ..Default::default()
5389            }),
5390            ..Default::default()
5391        };
5392        let node = build_newsletter_edit_node(&to, "3EB0MEDIA", NewsletterEdit::Edit(&content));
5393
5394        let mut a = node.attrs();
5395        assert_eq!(a.optional_string("id").unwrap().as_ref(), "3EB0MEDIA");
5396        assert_eq!(a.optional_string("type").unwrap().as_ref(), "media");
5397        assert_eq!(a.optional_string("edit").unwrap().as_ref(), "3");
5398        let pt = node
5399            .get_optional_child("plaintext")
5400            .expect("plaintext child");
5401        assert_eq!(
5402            pt.attrs().optional_string("mediatype").unwrap().as_ref(),
5403            "image"
5404        );
5405    }
5406
5407    /// Newsletter revoke: plaintext `<message type="text" edit="8">` keyed by the
5408    /// message id STRING, with an empty `<plaintext>`.
5409    #[test]
5410    fn build_newsletter_edit_node_revoke_is_empty_plaintext() {
5411        let to: Jid = "120363000000000002@newsletter".parse().unwrap();
5412        let node = build_newsletter_edit_node(&to, "3EB0REVOKETARGET", NewsletterEdit::Revoke);
5413
5414        let mut a = node.attrs();
5415        assert_eq!(
5416            a.optional_string("id").unwrap().as_ref(),
5417            "3EB0REVOKETARGET"
5418        );
5419        assert_eq!(a.optional_string("type").unwrap().as_ref(), "text");
5420        assert_eq!(a.optional_string("edit").unwrap().as_ref(), "8");
5421
5422        let pt = node
5423            .get_optional_child("plaintext")
5424            .expect("plaintext child");
5425        let empty = match pt.content.as_ref() {
5426            None => true,
5427            Some(wacore_binary::NodeContent::Bytes(b)) => b.is_empty(),
5428            _ => false,
5429        };
5430        assert!(empty, "revoke must carry an empty plaintext");
5431    }
5432
5433    /// The public newsletter().edit_message wrapper emits the plaintext edit stanza
5434    /// keyed by the message id it was given.
5435    #[tokio::test]
5436    async fn newsletter_edit_message_wrapper_sends_plaintext_edit() {
5437        let client = crate::test_utils::create_test_client_with_name("nl_edit_wrap").await;
5438        let channel: Jid = "120363000000000001@newsletter".parse().unwrap();
5439        let waiter =
5440            client.wait_for_sent_node(crate::client::NodeFilter::tag("message").attr("edit", "3"));
5441        let content = wa::Message {
5442            conversation: Some("edited".to_string()),
5443            ..Default::default()
5444        };
5445        // No socket on the test client: send_node captures the node, then errors.
5446        let _ = client
5447            .newsletter()
5448            .edit_message(&channel, "TARGETMID", content)
5449            .await;
5450
5451        let node = tokio::time::timeout(std::time::Duration::from_secs(1), waiter)
5452            .await
5453            .expect("sent node captured")
5454            .expect("waiter resolves");
5455        let mut a = node.attrs();
5456        assert_eq!(a.optional_string("id").unwrap().as_ref(), "TARGETMID");
5457        assert_eq!(a.optional_string("edit").unwrap().as_ref(), "3");
5458    }
5459
5460    /// The newsletter edit/revoke methods reject non-newsletter JIDs, so a misuse
5461    /// cannot send plaintext content to a DM/group (it would not be E2E-encrypted).
5462    #[tokio::test]
5463    async fn newsletter_edit_revoke_reject_non_newsletter_jid() {
5464        let client = crate::test_utils::create_test_client_with_name("nl_reject_nonchannel").await;
5465        let dm: Jid = "5511999999999@s.whatsapp.net".parse().unwrap();
5466        let group: Jid = "120363000000000009@g.us".parse().unwrap();
5467
5468        let e1 = client
5469            .newsletter()
5470            .edit_message(
5471                &dm,
5472                "MID",
5473                wa::Message {
5474                    conversation: Some("x".to_string()),
5475                    ..Default::default()
5476                },
5477            )
5478            .await
5479            .expect_err("edit_message must reject a DM JID");
5480        assert!(e1.to_string().to_lowercase().contains("newsletter"));
5481
5482        let e2 = client
5483            .newsletter()
5484            .revoke_message(&group, "MID")
5485            .await
5486            .expect_err("revoke_message must reject a group JID");
5487        assert!(e2.to_string().to_lowercase().contains("newsletter"));
5488    }
5489
5490    /// An empty message_id (NewsletterMessage.message_id may be empty if the server
5491    /// omitted the id) is rejected rather than sending a target-less id="" stanza.
5492    #[tokio::test]
5493    async fn newsletter_edit_revoke_reject_empty_message_id() {
5494        let client = crate::test_utils::create_test_client_with_name("nl_reject_empty_id").await;
5495        let channel: Jid = "120363000000000001@newsletter".parse().unwrap();
5496
5497        let e1 = client
5498            .newsletter()
5499            .edit_message(
5500                &channel,
5501                "",
5502                wa::Message {
5503                    conversation: Some("x".to_string()),
5504                    ..Default::default()
5505                },
5506            )
5507            .await
5508            .expect_err("edit_message must reject an empty message_id");
5509        assert!(e1.to_string().to_lowercase().contains("message_id"));
5510
5511        let e2 = client
5512            .newsletter()
5513            .revoke_message(&channel, "")
5514            .await
5515            .expect_err("revoke_message must reject an empty message_id");
5516        assert!(e2.to_string().to_lowercase().contains("message_id"));
5517    }
5518
5519    #[tokio::test]
5520    async fn persist_outbound_msg_secret_writes_under_chat_sender_id() {
5521        let client = crate::test_utils::create_test_client_with_name("secret_chat_id").await;
5522        seed_pn(&client, "5511000000001:0@s.whatsapp.net").await;
5523        let chat: Jid = "5511777776666@s.whatsapp.net".parse().unwrap();
5524        let sender: Jid = "5511000000001:0@s.whatsapp.net".parse().unwrap();
5525        let secret = [0x55u8; 32];
5526        client
5527            .persist_outbound_msg_secret(
5528                &chat,
5529                &sender,
5530                "MID_1",
5531                &secret,
5532                wacore::msg_secret::RetentionClass::Text,
5533                SendInstant::now(),
5534            )
5535            .await;
5536        client.msg_secret_buffer.wait_flushed().await;
5537        let got = client
5538            .persistence_manager
5539            .backend()
5540            .get_msg_secret(
5541                "5511777776666@s.whatsapp.net",
5542                "5511000000001@s.whatsapp.net",
5543                "MID_1",
5544            )
5545            .await
5546            .expect("get");
5547        assert_eq!(got.as_deref(), Some(&secret[..]));
5548    }
5549
5550    #[tokio::test]
5551    async fn persist_outbound_msg_secret_strips_devices_in_key() {
5552        let client = crate::test_utils::create_test_client_with_name("secret_strip").await;
5553        let chat_with_dev: Jid = "5511777776666:7@s.whatsapp.net".parse().unwrap();
5554        let sender_with_dev: Jid = "5511000000001:3@s.whatsapp.net".parse().unwrap();
5555        client
5556            .persist_outbound_msg_secret(
5557                &chat_with_dev,
5558                &sender_with_dev,
5559                "MID_4",
5560                &[2u8; 32],
5561                wacore::msg_secret::RetentionClass::Text,
5562                SendInstant::now(),
5563            )
5564            .await;
5565        client.msg_secret_buffer.wait_flushed().await;
5566        let got = client
5567            .persistence_manager
5568            .backend()
5569            .get_msg_secret(
5570                "5511777776666@s.whatsapp.net",
5571                "5511000000001@s.whatsapp.net",
5572                "MID_4",
5573            )
5574            .await
5575            .unwrap();
5576        assert_eq!(
5577            got.as_deref(),
5578            Some(&[2u8; 32][..]),
5579            "chat and sender must be stored non-AD"
5580        );
5581    }
5582
5583    #[tokio::test]
5584    async fn dm_sender_identity_picks_lid_for_bot_else_pn() {
5585        let client = crate::test_utils::create_test_client_with_name("dm_id_pick").await;
5586        seed_pn_and_lid(
5587            &client,
5588            "5511000000001:0@s.whatsapp.net",
5589            "999888777666555:0@lid",
5590        )
5591        .await;
5592        let bot_chat: Jid = "867051314767696@bot".parse().unwrap();
5593        let pn_chat: Jid = "5511777776666@s.whatsapp.net".parse().unwrap();
5594        let lid_chat: Jid = "111222333444555@lid".parse().unwrap();
5595        assert_eq!(
5596            client
5597                .dm_sender_identity_for(&bot_chat)
5598                .await
5599                .map(|j| j.to_non_ad_string()),
5600            Some("999888777666555@lid".to_string()),
5601            "bot chats must resolve to our LID"
5602        );
5603        assert_eq!(
5604            client
5605                .dm_sender_identity_for(&pn_chat)
5606                .await
5607                .map(|j| j.to_non_ad_string()),
5608            Some("5511000000001@s.whatsapp.net".to_string()),
5609            "PN chats must resolve to our PN"
5610        );
5611        // LID-DM is presently routed under PN; flagged as a follow-up only
5612        // because production hasn't surfaced it. Documented behaviour.
5613        assert_eq!(
5614            client
5615                .dm_sender_identity_for(&lid_chat)
5616                .await
5617                .map(|j| j.to_non_ad_string()),
5618            Some("5511000000001@s.whatsapp.net".to_string()),
5619        );
5620    }
5621
5622    /// Regression for Codex P2 (LID-mode group bot replies): the persisted
5623    /// sender must match whatever `prepare_group_stanza` picked for the
5624    /// group's addressing_mode, surfaced via `PreparedGroupStanza.sender_identity`.
5625    #[tokio::test]
5626    async fn persist_uses_group_sender_identity_for_lid_mode_groups() {
5627        let client = crate::test_utils::create_test_client_with_name("secret_lid_group").await;
5628        seed_pn_and_lid(
5629            &client,
5630            "5511000000001:0@s.whatsapp.net",
5631            "999888777666555:0@lid",
5632        )
5633        .await;
5634        // Simulate a LID-mode group: addressing identity is our LID, not PN.
5635        let group_chat: Jid = "120363021033254949@g.us".parse().unwrap();
5636        let lid_sender: Jid = "999888777666555:0@lid".parse().unwrap();
5637        let secret = [0x4Du8; 32];
5638        client
5639            .persist_outbound_msg_secret(
5640                &group_chat,
5641                &lid_sender,
5642                "GROUP_MID",
5643                &secret,
5644                wacore::msg_secret::RetentionClass::Text,
5645                SendInstant::now(),
5646            )
5647            .await;
5648        client.msg_secret_buffer.wait_flushed().await;
5649        let got = client
5650            .persistence_manager
5651            .backend()
5652            .get_msg_secret(
5653                "120363021033254949@g.us",
5654                "999888777666555@lid",
5655                "GROUP_MID",
5656            )
5657            .await
5658            .unwrap();
5659        assert_eq!(
5660            got.as_deref(),
5661            Some(&secret[..]),
5662            "LID-mode group secrets must key under our LID, not PN"
5663        );
5664        let under_pn = client
5665            .persistence_manager
5666            .backend()
5667            .get_msg_secret(
5668                "120363021033254949@g.us",
5669                "5511000000001@s.whatsapp.net",
5670                "GROUP_MID",
5671            )
5672            .await
5673            .unwrap();
5674        assert!(
5675            under_pn.is_none(),
5676            "LID-mode group must NOT key under our PN"
5677        );
5678    }
5679
5680    /// Regression: `wacore::send::prepare_dm_stanza` mints the
5681    /// `message_secret` on a CLONE of the caller's message. Verify the secret
5682    /// is surfaced via `PreparedDmStanza.message_secret` so the post-send hook
5683    /// can persist it -- without this an original-message-based check would
5684    /// miss every ordinary outbound bot prompt.
5685    #[test]
5686    fn prepared_dm_stanza_exposes_generated_message_secret() {
5687        use wacore::reporting_token::generate_reporting_token;
5688
5689        let msg = wa::Message {
5690            conversation: Some("hi bot".into()),
5691            ..Default::default()
5692        };
5693        let to: Jid = "867051314767696@bot".parse().unwrap();
5694        let result = generate_reporting_token(&msg, "MID_X", &to, &to, None);
5695        assert!(
5696            result.is_some(),
5697            "ordinary text messages must produce a reporting token + secret"
5698        );
5699        let result = result.unwrap();
5700        assert_eq!(result.message_secret.len(), 32);
5701        // PreparedDmStanza/PreparedGroupStanza now carry this exact array
5702        // through to send_message_impl which calls persist_outbound_msg_secret.
5703        let prepared = wacore::send::PreparedDmStanza {
5704            node: NodeBuilder::new("message").build(),
5705            phash: None,
5706            message_secret: Some(result.message_secret),
5707        };
5708        assert_eq!(prepared.message_secret.as_ref().unwrap().len(), 32);
5709    }
5710
5711    /// A send names its message once and every downstream stage reads that same
5712    /// name: the wire stanza, the phash ack-waiter, the outbound messageSecret
5713    /// and the returned `SendResult`. A non-ASCII id is used on purpose — a
5714    /// truncating or byte-indexing copy anywhere in that chain would show up
5715    /// here and nowhere else.
5716    #[tokio::test]
5717    async fn one_id_names_the_stanza_the_waiter_the_secret_and_the_result() {
5718        let (client, _transport) = crate::test_utils::create_iq_test_client().await;
5719        let (peer_pn, _peer_lid) = seed_dm_wire_namespace_state(&client).await;
5720
5721        let message_id = "ID_ünïcødé_✅_ONE";
5722        let result = client
5723            .send_message_with_options(
5724                peer_pn.clone(),
5725                wa::Message {
5726                    conversation: Some("hi".into()),
5727                    ..Default::default()
5728                },
5729                SendOptions::default().with_message_id(message_id),
5730            )
5731            .await
5732            .expect("connected test client should complete the send");
5733
5734        assert_eq!(
5735            result.message_id, message_id,
5736            "result carries the caller id"
5737        );
5738        assert_eq!(result.to, peer_pn, "result carries the caller target");
5739
5740        let waiters = client.response_waiters_guard();
5741        assert!(
5742            waiters.contains_key(message_id),
5743            "the phash ack-waiter must be keyed by the send's own id"
5744        );
5745        assert_eq!(waiters.len(), 1, "no second entry under another spelling");
5746        drop(waiters);
5747
5748        let secret = client.msg_secret_buffer.lookup(
5749            &peer_pn.to_non_ad_string(),
5750            &client.pn().expect("own pn").to_non_ad_string(),
5751            message_id,
5752        );
5753        assert!(
5754            secret.is_some(),
5755            "the outbound messageSecret must be bound to the same id"
5756        );
5757    }
5758
5759    /// The waiter is installed before the stanza reaches the socket (a fast ack
5760    /// can land while `send_node` is still returning), so a send that fails on
5761    /// the wire has to take it back out — under the id it registered. Removing
5762    /// under anything else leaks an entry that a later ack could resolve.
5763    #[tokio::test]
5764    async fn a_failed_send_takes_its_phash_waiter_back_out() {
5765        let client = crate::test_utils::create_test_client_with_name("phash_waiter_rollback").await;
5766        let (peer_pn, _peer_lid) = seed_dm_wire_namespace_state(&client).await;
5767
5768        let message_id = "ID_ünïcødé_✅_ROLLBACK";
5769        let result = client
5770            .send_message_impl(
5771                peer_pn,
5772                &wa::Message {
5773                    conversation: Some("hi".into()),
5774                    ..Default::default()
5775                },
5776                SendPipelineOptions {
5777                    request_id: Some(message_id),
5778                    ..Default::default()
5779                },
5780            )
5781            .await;
5782        assert!(result.is_err(), "no socket: the send must fail on the wire");
5783        assert_eq!(
5784            client.response_waiters_guard().len(),
5785            0,
5786            "a failed send must leave no waiter behind, under any key"
5787        );
5788    }
5789
5790    /// A borrowed id belongs to another message: registering a waiter under it
5791    /// would overwrite the original send's waiter, and binding a secret under it
5792    /// would overwrite the original's secret.
5793    #[tokio::test]
5794    async fn a_borrowed_id_registers_no_waiter_and_binds_no_secret() {
5795        let (client, _transport) = crate::test_utils::create_iq_test_client().await;
5796        let (peer_pn, _peer_lid) = seed_dm_wire_namespace_state(&client).await;
5797
5798        let message_id = "ID_BORROWED_1";
5799        client
5800            .send_message_impl(
5801                peer_pn.clone(),
5802                &wa::Message {
5803                    conversation: Some("hi".into()),
5804                    ..Default::default()
5805                },
5806                SendPipelineOptions {
5807                    request_id: Some(message_id),
5808                    borrowed_message_id: true,
5809                    ..Default::default()
5810                },
5811            )
5812            .await
5813            .expect("connected test client should complete the send");
5814
5815        assert_eq!(
5816            client.response_waiters_guard().len(),
5817            0,
5818            "a borrowed id must not claim the waiter slot"
5819        );
5820        assert!(
5821            client
5822                .msg_secret_buffer
5823                .lookup(
5824                    &peer_pn.to_non_ad_string(),
5825                    &client.pn().expect("own pn").to_non_ad_string(),
5826                    message_id,
5827                )
5828                .is_none(),
5829            "a borrowed id must not claim the secret slot"
5830        );
5831    }
5832
5833    /// An empty id would name nothing: it must be refused at both entry points
5834    /// before any state is stamped with it.
5835    #[tokio::test]
5836    async fn an_empty_id_is_refused_at_both_entry_points() {
5837        let client = crate::test_utils::create_test_client_with_name("empty_send_id").await;
5838        let peer: Jid = "100000000000777@s.whatsapp.net".parse().unwrap();
5839        let msg = wa::Message {
5840            conversation: Some("hi".into()),
5841            ..Default::default()
5842        };
5843
5844        let public = client
5845            .send_message_with_options(
5846                peer.clone(),
5847                msg.clone(),
5848                SendOptions::default().with_message_id(""),
5849            )
5850            .await;
5851        assert!(
5852            matches!(public, Err(SendError::InvalidRequest(_))),
5853            "public send must reject an empty id, got {public:?}"
5854        );
5855
5856        let internal = client
5857            .send_message_impl(
5858                peer,
5859                &msg,
5860                SendPipelineOptions {
5861                    request_id: Some(""),
5862                    ..Default::default()
5863                },
5864            )
5865            .await;
5866        let internal = internal.expect_err("internal send must reject an empty id");
5867        assert!(
5868            internal
5869                .to_string()
5870                .contains("message ID must not be empty"),
5871            "unexpected error: {internal}"
5872        );
5873    }
5874
5875    /// The plaintext newsletter branch returns before the E2E pipeline, so it
5876    /// builds its own result; it must still hand back the id it stamped and the
5877    /// channel it addressed.
5878    #[tokio::test]
5879    async fn the_newsletter_branch_returns_the_id_and_target_it_stamped() {
5880        let (client, _transport) = crate::test_utils::create_iq_test_client().await;
5881        let channel: Jid = "123456789@newsletter".parse().unwrap();
5882
5883        let message_id = "ID_ünïcødé_✅_NEWS";
5884        let waiter = client
5885            .wait_for_sent_node(crate::client::NodeFilter::tag("message").attr("id", message_id));
5886        let result = client
5887            .send_message_with_options(
5888                channel.clone(),
5889                wa::Message {
5890                    conversation: Some("hi".into()),
5891                    ..Default::default()
5892                },
5893                SendOptions::default().with_message_id(message_id),
5894            )
5895            .await
5896            .expect("newsletter send is plaintext and needs no session");
5897
5898        assert_eq!(result.message_id, message_id);
5899        assert_eq!(result.to, channel);
5900
5901        let node = waiter.await.expect("the stanza should be captured");
5902        assert_eq!(
5903            node.attrs().optional_string("id").as_deref(),
5904            Some(message_id),
5905            "the wire id must be the same one the result reports"
5906        );
5907    }
5908}
5909
5910#[cfg(test)]
5911mod jid_into_convention {
5912    use super::*;
5913
5914    /// Compile-time guard for the `impl Into<Jid>` convention: every core
5915    /// method must accept BOTH an owned `Jid` (move, zero copy) and a `&Jid`
5916    /// (one clone via `From<&Jid>`). Never executed; compilation is the test.
5917    #[allow(dead_code)]
5918    async fn both_call_styles_compile(client: &Client, jid: Jid) {
5919        let msg = wa::Message::default();
5920        let _ = client.send_message(&jid, msg.clone()).await;
5921        let _ = client
5922            .send_message_with_options(&jid, msg.clone(), SendOptions::default())
5923            .await;
5924        let _ = client.forward_message(&jid, &msg).await;
5925        let _ = client
5926            .edit_message(&jid, "ID", wa::Message::default())
5927            .await;
5928        let _ = client.revoke_message(&jid, "ID", RevokeType::Sender).await;
5929        let _ = client
5930            .pin_message(&jid, wa::MessageKey::default(), PinDuration::default())
5931            .await;
5932        let _ = client.unpin_message(&jid, wa::MessageKey::default()).await;
5933        let _ = client
5934            .send_reaction(&jid, wa::MessageKey::default(), "x")
5935            .await;
5936        let _ = client
5937            .keep_message(&jid, wa::MessageKey::default(), true)
5938            .await;
5939        // Owned style: moves, no clone. Each method consumes its own copy so
5940        // the whole core surface is pinned, not just send_message.
5941        let _ = client.send_message(jid.clone(), msg.clone()).await;
5942        let _ = client
5943            .send_message_with_options(jid.clone(), msg.clone(), SendOptions::default())
5944            .await;
5945        let _ = client.forward_message(jid.clone(), &msg).await;
5946        let _ = client
5947            .edit_message(jid.clone(), "ID", wa::Message::default())
5948            .await;
5949        let _ = client
5950            .revoke_message(jid.clone(), "ID", RevokeType::Sender)
5951            .await;
5952        let _ = client
5953            .pin_message(
5954                jid.clone(),
5955                wa::MessageKey::default(),
5956                PinDuration::default(),
5957            )
5958            .await;
5959        let _ = client
5960            .unpin_message(jid.clone(), wa::MessageKey::default())
5961            .await;
5962        let _ = client
5963            .send_reaction(jid.clone(), wa::MessageKey::default(), "x")
5964            .await;
5965        let _ = client
5966            .keep_message(jid, wa::MessageKey::default(), true)
5967            .await;
5968    }
5969}
5970
5971#[cfg(test)]
5972mod future_size_tests {
5973    /// The public send futures embed in every event-handler and spawned-task
5974    /// frame, so their size is a per-event heap cost. Keep them pointer-scale
5975    /// (measured 64-128 B; the bound leaves slack only for layout drift).
5976    #[tokio::test]
5977    async fn send_futures_stay_small() {
5978        let client = crate::test_utils::create_test_client().await;
5979        let jid: wacore_binary::jid::Jid = "5511999990000@s.whatsapp.net".parse().unwrap();
5980        let msg = waproto::whatsapp::Message::default();
5981
5982        let f = client.send_message(jid.clone(), msg.clone());
5983        assert!(size_of_val(&f) <= 192, "send_message future grew");
5984        drop(f);
5985        let f = client.send_text(jid.clone(), "x");
5986        assert!(size_of_val(&f) <= 192, "send_text future grew");
5987        drop(f);
5988        let f = client.forward_message(jid.clone(), &msg);
5989        assert!(size_of_val(&f) <= 192, "forward_message future grew");
5990        drop(f);
5991        let f = client.send_message_with_options(jid, msg, Default::default());
5992        assert!(
5993            size_of_val(&f) <= 192,
5994            "send_message_with_options future grew"
5995        );
5996        drop(f);
5997    }
5998}
5999
6000#[cfg(test)]
6001mod clock_budget_tests {
6002    use super::*;
6003    use crate::store::commands::DeviceCommand;
6004    use std::sync::Arc;
6005    use wacore::time::clock_reads;
6006
6007    const OWN_PN: &str = "15551234001";
6008    const PEER_PN: &str = "5511900000001";
6009    const PEER_LID: &str = "100000000000079";
6010
6011    /// Budget for one steady-state DM send, in clock reads. On wasm32 and
6012    /// embedded targets every read leaves the module, so this is a real cost of
6013    /// the send path and not just an instruction count.
6014    const SEND_WALL_READS: u64 = 1;
6015    const SEND_MONOTONIC_READS: u64 = 2;
6016
6017    async fn seed_devices(client: &Arc<Client>, user: &str) {
6018        client
6019            .update_device_list(wacore::store::traits::DeviceListRecord {
6020                user: user.into(),
6021                devices: vec![wacore::store::traits::DeviceInfo::new(0, None)],
6022                timestamp: wacore::time::now_secs(),
6023                phash: None,
6024                raw_id: None,
6025            })
6026            .await
6027            .expect("seed device list");
6028    }
6029
6030    /// Registry, LID mapping and Signal sessions already seeded, so a send
6031    /// queries nothing over the wire.
6032    async fn cold_send_client() -> (
6033        Arc<Client>,
6034        Arc<crate::transport::mock::CapturingMockTransport>,
6035        Jid,
6036    ) {
6037        let (client, transport) = crate::test_utils::create_iq_test_client().await;
6038        client
6039            .persistence_manager
6040            .process_command(DeviceCommand::SetId(Some(
6041                format!("{OWN_PN}@s.whatsapp.net").parse().expect("own pn"),
6042            )))
6043            .await;
6044        client
6045            .persistence_manager
6046            .process_command(DeviceCommand::SetLid(Some(
6047                "100000000000001@lid".parse().expect("own lid"),
6048            )))
6049            .await;
6050
6051        let peer = Jid::pn(PEER_PN);
6052        seed_devices(&client, PEER_PN).await;
6053        seed_devices(&client, OWN_PN).await;
6054        seed_devices(&client, PEER_LID).await;
6055        client
6056            .add_lid_pn_mapping(
6057                PEER_LID,
6058                PEER_PN,
6059                crate::lid_pn_cache::LearningSource::Usync,
6060            )
6061            .await
6062            .expect("lid mapping");
6063        crate::test_utils::seed_peer_session(&client, &peer).await;
6064        crate::test_utils::seed_peer_session(
6065            &client,
6066            &format!("{PEER_LID}@lid").parse().expect("lid jid"),
6067        )
6068        .await;
6069
6070        (client, transport, peer)
6071    }
6072
6073    /// [`cold_send_client`] plus a first send, which drains the once-per-peer
6074    /// privacy-token issuance so the next send is steady state.
6075    async fn warm_send_client() -> (
6076        Arc<Client>,
6077        Arc<crate::transport::mock::CapturingMockTransport>,
6078        Jid,
6079    ) {
6080        let (client, transport, peer) = cold_send_client().await;
6081        client.send_text(peer.clone(), "warm").await.expect("warm");
6082        // The privacy token is issued off the send path, so wait for its frame
6083        // rather than let it land inside the measured window.
6084        crate::test_utils::poll_until("the privacy token to be issued", || {
6085            transport.sent_count() >= 2
6086        })
6087        .await;
6088        crate::test_utils::wait_for_outbound_tasks(&client).await;
6089        (client, transport, peer)
6090    }
6091
6092    /// Paused time so the unanswered IQs this harness leaves behind do not cost
6093    /// their real timeouts.
6094    #[tokio::test(start_paused = true)]
6095    async fn dm_send_stays_within_its_clock_budget() {
6096        let (client, transport, peer) = warm_send_client().await;
6097        let frames_before = transport.sent_count();
6098
6099        let base = clock_reads::snapshot();
6100        client.send_text(peer, "hello").await.expect("send");
6101        let reads = clock_reads::since(base);
6102
6103        assert_eq!(
6104            transport.sent_count() - frames_before,
6105            1,
6106            "the budget only describes a send that writes exactly one frame"
6107        );
6108        assert!(
6109            reads.total() > 0,
6110            "a zero count means the flow did not run, not that it got free"
6111        );
6112        assert!(
6113            reads.wall <= SEND_WALL_READS,
6114            "wall-clock reads per DM send rose to {} (budget {SEND_WALL_READS})",
6115            reads.wall
6116        );
6117        assert!(
6118            reads.monotonic <= SEND_MONOTONIC_READS,
6119            "monotonic reads per DM send rose to {} (budget {SEND_MONOTONIC_READS})",
6120            reads.monotonic
6121        );
6122
6123        client.disconnect().await;
6124    }
6125
6126    /// One send, one instant: the message id, the biz node, the privacy-token
6127    /// decision and the outbound secret are stamped from a single read, so they
6128    /// cannot end up describing different seconds for the same message.
6129    #[tokio::test(start_paused = true)]
6130    async fn a_send_reads_the_clock_once() {
6131        let (client, _transport, peer) = warm_send_client().await;
6132
6133        let base = clock_reads::snapshot();
6134        let sent = client.send_text(peer.clone(), "hello").await.expect("send");
6135        assert_eq!(
6136            clock_reads::since(base).wall,
6137            1,
6138            "every stamp on the send path must come from the same read"
6139        );
6140
6141        // The outbound secret is one of the stamps, so its presence proves the
6142        // measured window really covered that write.
6143        client.msg_secret_buffer.wait_flushed().await;
6144        let stored = client
6145            .persistence_manager
6146            .backend()
6147            .get_msg_secret(
6148                &peer.to_non_ad_string(),
6149                &format!("{OWN_PN}@s.whatsapp.net"),
6150                &sent.message_id,
6151            )
6152            .await
6153            .expect("msg secret lookup");
6154        assert!(
6155            stored.is_some(),
6156            "the send persisted an outbound secret under the measured instant"
6157        );
6158
6159        client.disconnect().await;
6160    }
6161
6162    /// The wire timestamp is the one thing the budget must never buy: the
6163    /// privacy-token IQ a first send emits still carries the real second.
6164    #[tokio::test(start_paused = true)]
6165    async fn wire_timestamp_keeps_real_time() {
6166        let (client, transport, peer) = cold_send_client().await;
6167
6168        let before = wacore::time::now_secs();
6169        client.send_text(peer, "hello").await.expect("send");
6170        crate::test_utils::poll_until("the privacy token to be issued", || {
6171            transport.sent_count() >= 2
6172        })
6173        .await;
6174        let after = wacore::time::now_secs();
6175
6176        let mut seen = None;
6177        for index in 0..transport.sent_count() {
6178            let node = crate::test_utils::decode_sent_iq(&transport, index).await;
6179            let node = node.get();
6180            if node.attrs().optional_string("xmlns").as_deref() != Some("privacy") {
6181                continue;
6182            }
6183            let token = node
6184                .get_optional_child("tokens")
6185                .and_then(|t| t.get_optional_child("token"))
6186                .expect("the privacy IQ carries a <token>");
6187            seen = token
6188                .attrs()
6189                .optional_string("t")
6190                .and_then(|t| t.parse::<i64>().ok());
6191            break;
6192        }
6193
6194        let stamped = seen.expect("a first send issues a privacy token");
6195        assert!(
6196            (before..=after).contains(&stamped),
6197            "wire timestamp {stamped} outside [{before}, {after}]"
6198        );
6199
6200        client.disconnect().await;
6201    }
6202}