Skip to main content

whatsapp_rust/
receipt.rs

1use crate::client::Client;
2use crate::types::events::{Event, Receipt};
3use crate::types::message::MessageInfo;
4use crate::types::presence::ReceiptType;
5use log::debug;
6use std::sync::Arc;
7use wacore::protocol::nack::NackReason;
8use wacore::types::message::MessageCategory;
9use wacore_binary::builder::NodeBuilder;
10use wacore_binary::{Jid, JidExt as _, NodeRef, NodeValue};
11
12use wacore_binary::OwnedNodeRef;
13
14/// Max message ids per read/played `<receipt>` stanza. WA Web's
15/// `sendAggregateReceipts` splices ids into chunks of 256 and emits one receipt
16/// per chunk, so a large catch-up doesn't produce one oversized stanza.
17const MAX_RECEIPT_IDS_PER_STANZA: usize = 256;
18
19/// Pure builder for the delivery `<receipt>` node. Extracted so unit tests
20/// can assert wire shape without spinning a transport. Mirrors WA Web's
21/// `Send/DeliveryReceiptJob.js` — the participant gate there is
22/// `(t.isGroup() || t.isBroadcast()) && r ? DEVICE_JID(r) : DROP_ATTR`, so
23/// status broadcasts (isBroadcast = true) also carry the original poster's
24/// JID. Without it the server can't map the ack back to the status owner.
25/// `active=false` sends `type="inactive"` (not rendered as ticks), matching
26/// whatsmeow's background companion. Peer/status keep their own type/context.
27///
28/// Self-fanout (`is_from_me` + a `recipient`) gets `type="sender"` + the
29/// `recipient`, matching WA Web (`isMeAccount` author => SENDER) and whatsmeow.
30/// The server's offline queue only drops a self-fanout on this sender receipt;
31/// a bare transport `<ack>` is ignored and the stanza is replayed until a
32/// ~50min GC closes the stream.
33/// Builds a `<receipt type="played"|"played-self">` for voice/video notes,
34/// mirroring WA Web `WAWebSendPlayedReceiptJob`: newsletters use `played-self`,
35/// everything else `played`. The `participant` attr is set only for
36/// group/broadcast chats; in DMs WA Web drops it (`r.isUser() ? null : author`).
37/// `read_receipts_disabled` is the `readreceipts==none` privacy gate: only a DM
38/// (not group, status, or broadcast list) then uses `played-self` (does not
39/// notify the sender), matching WA Web `PlayedReceiptJob.js`.
40fn build_played_receipt_node(
41    chat: &Jid,
42    sender: Option<&Jid>,
43    message_ids: &[&str],
44    timestamp: &str,
45    read_receipts_disabled: bool,
46) -> wacore_binary::Node {
47    let is_private_dm =
48        !chat.is_group() && !chat.is_status_broadcast() && !chat.is_broadcast_list();
49    let receipt_type = if chat.is_newsletter() || (read_receipts_disabled && is_private_dm) {
50        ReceiptType::PlayedSelf
51    } else {
52        ReceiptType::Played
53    };
54
55    let mut builder = NodeBuilder::new("receipt")
56        .attr("to", chat)
57        .attr("type", receipt_type.as_wire_str())
58        .attr("id", message_ids[0])
59        .attr("t", timestamp);
60
61    if (chat.is_group() || chat.is_status_broadcast() || chat.is_broadcast_list())
62        && let Some(sender) = sender
63    {
64        builder = builder.attr("participant", sender);
65    }
66
67    if message_ids.len() > 1 {
68        let items: Vec<wacore_binary::Node> = message_ids[1..]
69            .iter()
70            .map(|id| NodeBuilder::new("item").attr("id", *id).build())
71            .collect();
72        builder = builder.children(vec![NodeBuilder::new("list").children(items).build()]);
73    }
74
75    builder.build()
76}
77
78/// Pure builder for the read `<receipt>` node. Mirrors WA Web
79/// `WAWebSendReadReceiptJob` + `sendAggregateReceipts`: newsletters use
80/// `read-self`, everything else `read`; status reads carry `context="status"`
81/// and, for a LID author, `peer_participant_pn` (the resolved LID->PN).
82/// `read_receipts_disabled` is the `readreceipts==none` privacy gate: only a DM
83/// (not group, status, or broadcast list) then uses `read-self` (does not notify
84/// the sender), matching WA Web `ReadReceiptJob.js`.
85fn build_read_receipt_node(
86    chat: &Jid,
87    sender: Option<&Jid>,
88    message_ids: &[&str],
89    timestamp: &str,
90    peer_participant_pn: Option<&Jid>,
91    read_receipts_disabled: bool,
92) -> wacore_binary::Node {
93    let is_private_dm =
94        !chat.is_group() && !chat.is_status_broadcast() && !chat.is_broadcast_list();
95    let receipt_type = if chat.is_newsletter() || (read_receipts_disabled && is_private_dm) {
96        ReceiptType::ReadSelf
97    } else {
98        ReceiptType::Read
99    };
100
101    let mut builder = NodeBuilder::new("receipt")
102        .attr("to", chat)
103        .attr("type", receipt_type.as_wire_str())
104        .attr("id", message_ids[0])
105        .attr("t", timestamp);
106
107    if let Some(sender) = sender {
108        builder = builder.attr("participant", sender);
109    }
110
111    if chat.is_status_broadcast() {
112        builder = builder.attr("context", "status");
113        if let Some(pn) = peer_participant_pn {
114            builder = builder.attr("peer_participant_pn", pn);
115        }
116    }
117
118    if message_ids.len() > 1 {
119        let items: Vec<wacore_binary::Node> = message_ids[1..]
120            .iter()
121            .map(|id| NodeBuilder::new("item").attr("id", *id).build())
122            .collect();
123        builder = builder.children(vec![NodeBuilder::new("list").children(items).build()]);
124    }
125
126    builder.build()
127}
128
129/// The `type` attr a delivery receipt carries for `info`, as a static wire
130/// string (`None` = plain delivered, which omits the attr). Single source of
131/// truth for both the receipt builder and the aggregation grouping key, so the
132/// two can't drift apart.
133fn delivery_receipt_type(info: &MessageInfo, active: bool) -> Option<&'static str> {
134    let is_status = info.source.chat.is_status_broadcast();
135    if info.category == MessageCategory::Peer {
136        Some("peer_msg")
137    } else if info.source.is_self_fanout() {
138        Some("sender")
139    } else if !active && !is_status {
140        Some("inactive")
141    } else {
142        None
143    }
144}
145
146/// Receipt-level attrs shared by the single and aggregate delivery builders
147/// (everything except `id`/`t`/the `<list>` child).
148fn delivery_receipt_builder(info: &MessageInfo, active: bool) -> NodeBuilder {
149    let is_status = info.source.chat.is_status_broadcast();
150    // A peer-synced message takes `type="peer_msg"` and carries NO recipient
151    // (WA Web `!l` guard), so the sender-receipt shape applies only off the
152    // peer path.
153    let sender_receipt = info.source.is_self_fanout() && info.category != MessageCategory::Peer;
154    // Mirror whatsmeow `buildBaseReceipt` / WA Web `JID(extractJidFromJidWithType)`:
155    // echo `from` verbatim so the device survives. `chat` strips it via to_non_ad,
156    // which the LID server rejects for multi-device DMs.
157    let to = if info.source.is_group || is_status {
158        &info.source.chat
159    } else {
160        &info.source.sender
161    };
162    let mut builder = NodeBuilder::new("receipt").attr("to", to);
163
164    if let Some(receipt_type) = delivery_receipt_type(info, active) {
165        builder = builder.attr("type", receipt_type);
166    }
167
168    // Device-stripped recipient (WA Web `USER_JID`) so the server can route it.
169    if sender_receipt && let Some(recipient) = &info.source.recipient {
170        builder = builder.attr("recipient", recipient.to_non_ad());
171    }
172
173    if info.source.is_group || is_status {
174        builder = builder.attr("participant", &info.source.sender);
175    }
176
177    if is_status {
178        builder = builder.attr("context", "status");
179    }
180
181    builder
182}
183
184fn build_delivery_receipt_node(info: &MessageInfo, active: bool) -> wacore_binary::Node {
185    delivery_receipt_builder(info, active)
186        .attr("id", &info.id)
187        .build()
188}
189
190/// One buffered-offline delivery group: every entry shares identical
191/// receipt-level attrs, derived from the representative `rep`.
192struct DeliveryReceiptGroup<'a> {
193    rep: &'a MessageInfo,
194    ids: Vec<&'a str>,
195}
196
197/// Group buffered offline messages so each group maps to ONE aggregate
198/// `<receipt>` (WA Web `sendAggregateOfflineReceipts` groups by chat and
199/// author). The key covers every input that varies the receipt-level attrs
200/// for a fixed `active`: the `to` JID, the participant (group/status author),
201/// the type attr, and the self-fanout recipient. Splitting more finely than
202/// WA Web (e.g. on recipient device) is always wire-safe; merging across any
203/// of these would corrupt the receipt. Keys are borrowed, no per-entry
204/// allocation beyond the ids vec.
205fn group_delivery_receipts<'a>(
206    infos: &'a [Arc<MessageInfo>],
207    active: bool,
208) -> Vec<DeliveryReceiptGroup<'a>> {
209    #[derive(PartialEq, Eq, Hash)]
210    struct Key<'a> {
211        to: &'a Jid,
212        participant: Option<&'a Jid>,
213        receipt_type: Option<&'static str>,
214        recipient: Option<&'a Jid>,
215    }
216
217    let mut index: std::collections::HashMap<Key, usize> = std::collections::HashMap::new();
218    let mut groups: Vec<DeliveryReceiptGroup> = Vec::new();
219    for info in infos {
220        let is_status = info.source.chat.is_status_broadcast();
221        let is_group_like = info.source.is_group || is_status;
222        let sender_receipt = info.source.is_self_fanout() && info.category != MessageCategory::Peer;
223        let key = Key {
224            to: if is_group_like {
225                &info.source.chat
226            } else {
227                &info.source.sender
228            },
229            participant: is_group_like.then_some(&info.source.sender),
230            receipt_type: delivery_receipt_type(info, active),
231            recipient: if sender_receipt {
232                info.source.recipient.as_ref()
233            } else {
234                None
235            },
236        };
237        match index.entry(key) {
238            std::collections::hash_map::Entry::Occupied(e) => {
239                groups[*e.get()].ids.push(&info.id);
240            }
241            std::collections::hash_map::Entry::Vacant(e) => {
242                e.insert(groups.len());
243                groups.push(DeliveryReceiptGroup {
244                    rep: info,
245                    ids: vec![&info.id],
246                });
247            }
248        }
249    }
250    groups
251}
252
253/// Aggregate delivery `<receipt>` nodes for one group, chunked at
254/// [`MAX_RECEIPT_IDS_PER_STANZA`]: per chunk the first id is the `id` attr and
255/// the rest become `<list><item id=.../></list>`, the same shape WA Web's
256/// `sendAggregateReceipts` emits and `collect_simple_message_ids` parses on
257/// ingest. `t` mirrors the offline aggregate path passing `unixTime()`.
258fn build_aggregate_delivery_receipt_nodes(
259    rep: &MessageInfo,
260    ids: &[&str],
261    active: bool,
262    timestamp: &str,
263) -> Vec<wacore_binary::Node> {
264    ids.chunks(MAX_RECEIPT_IDS_PER_STANZA)
265        .map(|chunk| {
266            let mut builder = delivery_receipt_builder(rep, active)
267                .attr("id", chunk[0])
268                .attr("t", timestamp);
269            if chunk.len() > 1 {
270                let items: Vec<wacore_binary::Node> = chunk[1..]
271                    .iter()
272                    .map(|id| NodeBuilder::new("item").attr("id", *id).build())
273                    .collect();
274                builder = builder.children(vec![NodeBuilder::new("list").children(items).build()]);
275            }
276            builder.build()
277        })
278        .collect()
279}
280
281trait NackSource {
282    fn class(&self, reason: NackReason) -> Result<&str, crate::features::StanzaResponseError>;
283    fn id(&self) -> Result<NodeValue, crate::features::StanzaResponseError>;
284    fn to(&self) -> Result<NodeValue, crate::features::StanzaResponseError>;
285    fn participant(&self) -> Option<NodeValue>;
286    fn stanza_type(&self) -> Option<NodeValue>;
287}
288
289impl NackSource for NodeRef<'_> {
290    fn class(&self, reason: NackReason) -> Result<&str, crate::features::StanzaResponseError> {
291        if reason == NackReason::UnrecognizedStanza
292            || matches!(self.tag.as_ref(), "message" | "notification" | "receipt")
293        {
294            Ok(self.tag.as_ref())
295        } else {
296            Err(crate::features::StanzaResponseError::UnsupportedStanzaClass)
297        }
298    }
299
300    fn id(&self) -> Result<NodeValue, crate::features::StanzaResponseError> {
301        crate::features::required_stanza_attr(self, "id").map(|value| value.to_node_value())
302    }
303
304    fn to(&self) -> Result<NodeValue, crate::features::StanzaResponseError> {
305        crate::features::required_stanza_attr(self, "from").map(|value| value.to_node_value())
306    }
307
308    fn participant(&self) -> Option<NodeValue> {
309        self.get_attr("participant")
310            .map(|value| value.to_node_value())
311    }
312
313    fn stanza_type(&self) -> Option<NodeValue> {
314        self.get_attr("type").map(|value| value.to_node_value())
315    }
316}
317
318impl NackSource for MessageInfo {
319    fn class(&self, _reason: NackReason) -> Result<&str, crate::features::StanzaResponseError> {
320        Ok("message")
321    }
322
323    fn id(&self) -> Result<NodeValue, crate::features::StanzaResponseError> {
324        if self.id.is_empty() {
325            Err(crate::features::StanzaResponseError::MissingAttribute("id"))
326        } else {
327            Ok(NodeValue::from(&self.id))
328        }
329    }
330
331    fn to(&self) -> Result<NodeValue, crate::features::StanzaResponseError> {
332        Ok(NodeValue::from(&self.source.chat))
333    }
334
335    fn participant(&self) -> Option<NodeValue> {
336        (self.source.is_group || self.source.chat.is_status_broadcast())
337            .then(|| NodeValue::from(&self.source.sender))
338    }
339
340    fn stanza_type(&self) -> Option<NodeValue> {
341        (!self.r#type.is_empty()).then(|| NodeValue::from(&self.r#type))
342    }
343}
344
345/// Build the canonical rejection for either an original stanza or parsed
346/// message metadata. `failure_reason` is valid only for `InvalidProtobuf`.
347fn build_nack_node<S: NackSource + ?Sized>(
348    source: &S,
349    own_pn: &Jid,
350    reason: NackReason,
351    failure_reason: Option<i32>,
352) -> Result<wacore_binary::Node, crate::features::StanzaResponseError> {
353    let mut builder = NodeBuilder::new("ack")
354        .attr("class", source.class(reason)?)
355        .attr("id", source.id()?)
356        .attr("from", own_pn)
357        .attr("to", source.to()?)
358        .attr("error", reason.code());
359
360    if let Some(participant) = source.participant() {
361        builder = builder.attr("participant", participant);
362    }
363
364    if let Some(stanza_type) = source.stanza_type() {
365        builder = builder.attr("type", stanza_type);
366    }
367
368    if reason == NackReason::InvalidProtobuf
369        && let Some(code) = failure_reason
370    {
371        let meta = NodeBuilder::new("meta")
372            .attr("failure_reason", code)
373            .build();
374        builder = builder.children(vec![meta]);
375    }
376
377    Ok(builder.build())
378}
379
380impl Client {
381    pub(crate) fn should_send_delivery_receipt(info: &MessageInfo) -> bool {
382        if info.id.is_empty() || info.source.chat.is_newsletter() {
383            return false;
384        }
385
386        // WA Web sends type="peer_msg" delivery receipts for self-synced
387        // messages (category="peer").  These tell the primary phone that
388        // this companion device received the message.
389        // For all other messages, skip receipts for our own messages.
390        //
391        // status@broadcast: WA Web sends `<receipt context="status">`
392        // (`Send/DeliveryReceiptJob.js` + `Handle/MsgSendReceipt.js` —
393        // `C = y && isStatusStanzaReceiveEnabled() ? "status" : void 0`).
394        // The context attribute is added in send_delivery_receipt below.
395        //
396        // Self-fanout (own message echoed back, carrying a `recipient`) needs a
397        // sender receipt to drain the offline queue; without it the server
398        // replays it until a ~50min GC closes the stream. A recipient-less own
399        // message (self-note) stays skipped. See `build_delivery_receipt_node`.
400        info.category == MessageCategory::Peer
401            || !info.source.is_from_me
402            || info.source.is_self_fanout()
403    }
404
405    pub(crate) async fn handle_receipt(self: &Arc<Self>, node: Arc<OwnedNodeRef>) {
406        self.handle_receipt_inline(node);
407    }
408
409    #[cfg_attr(
410        feature = "tracing",
411        tracing::instrument(name = "wa.receipt.handle", level = "debug", skip_all)
412    )]
413    pub(crate) fn handle_receipt_inline(self: &Arc<Self>, node: Arc<OwnedNodeRef>) {
414        let nr = node.get();
415        let mut attrs = nr.attrs();
416        let from = attrs.jid("from");
417        let stanza_id = match attrs.optional_string("id") {
418            Some(id) => id.to_string(),
419            None => {
420                log::warn!("Receipt stanza missing required 'id' attribute");
421                return;
422            }
423        };
424        let receipt_type_cow = attrs.optional_string("type");
425        let receipt_type_str = receipt_type_cow.as_deref().unwrap_or("delivery");
426        let participant = attrs.optional_jid("participant");
427        // participant_pn -> sender_alt so the LID-PN cache warms from receipts too.
428        let participant_pn = attrs.optional_jid("participant_pn");
429        // Present when this receipt was drained from the offline queue on reconnect.
430        let offline = attrs.optional_string("offline").is_some();
431        let stanza_ts = attrs
432            .optional_u64("t")
433            .and_then(|t| i64::try_from(t).ok())
434            .and_then(wacore::time::from_secs)
435            .unwrap_or_else(wacore::time::now_utc);
436
437        let receipt_type = ReceiptType::parse(receipt_type_str);
438        // WA Web downgrades a delivery ack to "sent" (not delivered) when the receipt carries
439        // <error reason="lid" type="feature-incapable"> (the LID peer can't receive it).
440        let receipt_type =
441            wacore::stanza::receipt::downgrade_for_feature_incapable(nr, receipt_type);
442        let is_view = receipt_type_str == "view";
443        let is_group = from.is_group();
444        let default_sender = if is_group {
445            participant.unwrap_or_else(|| from.clone())
446        } else {
447            from.clone()
448        };
449
450        // Aggregated shape (`<participants>` child): WAWebHandleMsgReceiptParser
451        // produces one entry per `<user>`. Fan out into one Receipt event per
452        // user so per-user type/timestamp/sender are not lost. Retries and
453        // enc_rekey_retry never use the aggregated shape, so this short-circuits
454        // before the retry pipeline below.
455        if let Some(part_node) = nr.get_optional_child("participants") {
456            let (agg_msg_id, agg_key, users) =
457                wacore::stanza::receipt::parse_participants(part_node);
458            let fan_out_id = agg_msg_id
459                .clone()
460                .or_else(|| agg_key.clone())
461                .unwrap_or_else(|| stanza_id.clone());
462            debug!(
463                "Aggregated receipt from {}: stanza={stanza_id} \
464                 message_id={agg_msg_id:?} key={agg_key:?} users={}",
465                from.observe(),
466                users.len()
467            );
468            for user in users {
469                // Missing `<user t>` means the server didn't disambiguate the
470                // per-user time; fall back to the stanza-level `t`.
471                let user_ts = user
472                    .timestamp
473                    .and_then(|t| i64::try_from(t).ok())
474                    .and_then(wacore::time::from_secs)
475                    .unwrap_or(stanza_ts);
476                // aggregated_by_message: each <user> carries its own type;
477                // aggregated_by_type: all users share the receipt-level type.
478                let effective_type = match user.r#type.as_deref() {
479                    // Apply the receipt-level feature-incapable downgrade to the per-user type
480                    // too, so an aggregated delivery receipt with a feature-incapable LID
481                    // participant doesn't re-emit a delivered tick for it.
482                    Some(t) => wacore::stanza::receipt::downgrade_for_feature_incapable(
483                        nr,
484                        ReceiptType::parse(t),
485                    ),
486                    None => receipt_type.clone(),
487                };
488                let r = Receipt::builder()
489                    .message_ids(vec![fan_out_id.clone()])
490                    .source(crate::types::message::MessageSource {
491                        chat: from.clone(),
492                        sender: user.jid,
493                        sender_alt: user.participant_pn,
494                        ..Default::default()
495                    })
496                    .timestamp(user_ts)
497                    .r#type(effective_type)
498                    .offline(offline)
499                    .build();
500                self.core.event_bus.dispatch(Event::Receipt(r));
501            }
502            return;
503        }
504
505        // Simple receipt: collect `<list><item id=.../>` items plus the stanza
506        // id (for non-view receipts), matching the JS p() branch.
507        let message_ids =
508            wacore::stanza::receipt::collect_simple_message_ids(nr, &stanza_id, is_view);
509
510        debug!(
511            "Received receipt type '{receipt_type:?}' for {} message(s) from {}",
512            message_ids.len(),
513            from.observe()
514        );
515
516        let receipt = Receipt::builder()
517            .message_ids(message_ids)
518            .source(crate::types::message::MessageSource {
519                chat: from,
520                sender: default_sender,
521                sender_alt: participant_pn,
522                ..Default::default()
523            })
524            .timestamp(stanza_ts)
525            .r#type(receipt_type)
526            .offline(offline)
527            .build();
528
529        if receipt.r#type == ReceiptType::Retry {
530            let client_clone = Arc::clone(self);
531            let node_clone = Arc::clone(&node);
532            self.runtime
533                .spawn(Box::pin(async move {
534                    if let Err(e) = client_clone
535                        .handle_retry_receipt(&receipt, &node_clone)
536                        .await
537                    {
538                        log::warn!(
539                            "Failed to handle retry receipt for {}: {:?}",
540                            receipt.message_ids[0],
541                            e
542                        );
543                    }
544                }))
545                .detach();
546        } else if receipt.r#type == ReceiptType::EncRekeyRetry {
547            // WA Web: both "retry" and "enc_rekey_retry" route through
548            // handleMessageRetryRequest, but enc_rekey_retry branches to the
549            // VoIP stack's resendEncRekeyRetry(peerJid, retryCount).
550            // Since we don't have a VoIP stack yet, log and dispatch as a
551            // Receipt event so consumers can observe it. When VoIP is
552            // implemented (#345), this will route to the VoIP re-key handler.
553            if let Some(child) = nr.get_optional_child("enc_rekey") {
554                let mut child_attrs = child.attrs();
555                log::debug!(
556                    "Received enc_rekey_retry receipt for call-id={} from {} \
557                     (call-creator={}, count={}). VoIP not implemented, forwarding as event.",
558                    child_attrs
559                        .optional_string("call-id")
560                        .as_deref()
561                        .unwrap_or_default(),
562                    receipt.source.chat.observe(),
563                    child_attrs
564                        .optional_string("call-creator")
565                        .as_deref()
566                        .unwrap_or_default(),
567                    child_attrs
568                        .optional_string("count")
569                        .and_then(|s| s.parse::<u8>().ok())
570                        .unwrap_or(1),
571                );
572            }
573            self.core.event_bus.dispatch(Event::Receipt(receipt));
574        } else {
575            self.core.event_bus.dispatch(Event::Receipt(receipt));
576        }
577    }
578
579    /// Sends a delivery receipt to the sender of a message.
580    ///
581    /// Eligibility lives in [`Self::should_send_delivery_receipt`]; the wire
582    /// shape is assembled by [`build_delivery_receipt_node`]. Coverage:
583    ///
584    /// - Direct messages (DMs) — `<receipt>` to the sender's JID.
585    /// - Group messages — `<receipt participant=...>` to the group JID.
586    /// - Peer device messages (`category="peer"`) — `<receipt type="peer_msg">`
587    ///   to acknowledge self-synced messages from the primary phone.
588    /// - Status broadcasts — `<receipt context="status">` (WA Web's
589    ///   `Send/DeliveryReceiptJob.js`); these are NOT skipped anymore.
590    /// - Newsletters and messages without an ID are skipped (newsletters are
591    ///   handled by the ack gate, not here).
592    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.receipt.send_delivery", level = "debug", skip_all, fields(chat = %info.source.chat.observe(), sender = %info.source.sender.observe(), msg_id = %info.id)))]
593    pub(crate) async fn send_delivery_receipt(&self, info: &MessageInfo) {
594        let Some(frame) = self.prepare_delivery_receipt(info) else {
595            return;
596        };
597        if let Err(e) = self.send_raw_bytes(frame).await
598            && !matches!(e, crate::client::ClientError::NotConnected)
599        {
600            log::warn!(target: "Client/Receipt", "Failed to send delivery receipt for message {}: {:?}", info.id, e);
601        }
602    }
603
604    /// Everything [`Self::send_delivery_receipt`] does short of the send: the
605    /// eligibility gate, node construction, logging and marshalling. Returns
606    /// `None` when no receipt is owed. Split out so the receipt worker can
607    /// prepare a whole burst before touching the socket.
608    pub(crate) fn prepare_delivery_receipt(&self, info: &MessageInfo) -> Option<Vec<u8>> {
609        if !Self::should_send_delivery_receipt(info) {
610            return None;
611        }
612
613        let receipt_node = build_delivery_receipt_node(info, self.receipts_are_active());
614
615        // Mirror build_delivery_receipt_node's type selection so the log is
616        // accurate (a passive companion emits `inactive`, not `delivery`).
617        let receipt_kind = if info.category == MessageCategory::Peer {
618            ReceiptType::PeerMsg
619        } else if info.source.is_self_fanout() {
620            ReceiptType::Sender
621        } else if !self.receipts_are_active() && !info.source.chat.is_status_broadcast() {
622            ReceiptType::Inactive
623        } else {
624            ReceiptType::Delivered
625        };
626        debug!(target: "Client/Receipt", "Sending {} receipt for message {} to {}",
627            receipt_kind.as_wire_str(), info.id, info.source.sender.observe());
628
629        self.marshal_node_for_send(receipt_node)
630            .inspect_err(|e| {
631                log::warn!(target: "Client/Receipt", "Failed to marshal delivery receipt for message {}: {:?}", info.id, e);
632            })
633            .ok()
634    }
635
636    /// Buffer an offline-drained message's delivery receipt for the aggregate
637    /// flush at offline-sync completion (WA Web `sendAggregateOfflineReceipts`).
638    /// Returns `false` when the sync already completed, so the caller falls
639    /// back to the live 1:1 receipt. The completed flag is re-checked under
640    /// the buffer lock: the drain finisher (`finish_offline_sync`) flips the
641    /// flag before draining, so a push that wins the lock either lands before
642    /// the drain (and is included) or observes the flag and goes 1:1 — a
643    /// receipt can never strand in the buffer.
644    pub(crate) fn try_buffer_offline_receipt(&self, info: &Arc<MessageInfo>) -> bool {
645        let mut buffer = self
646            .offline_receipt_buffer
647            .lock()
648            .unwrap_or_else(|poisoned| poisoned.into_inner());
649        // Batcher still active covers the deferred drain→live window: the
650        // completion flag is already set there, but SKDM-only stanzas keep
651        // mutating cache-only sender-key state, and a 1:1 receipt for one of
652        // them before the deferred retry flushes would trade a redeliverable
653        // failure for a crash-permanent one. Completion of the deferred
654        // transition flushes this buffer (after its durable flush).
655        if self
656            .offline_sync_completed
657            .load(std::sync::atomic::Ordering::Acquire)
658            && !self.inbound_commit_batch.is_active()
659        {
660            return false;
661        }
662        buffer.push(Arc::clone(info));
663        true
664    }
665
666    /// Drain the offline receipt buffer and send one aggregate `<receipt>`
667    /// per (chat, author, type, recipient) group, chunked at 256 ids. The
668    /// drain `mem::take`s the buffer so no capacity is retained between
669    /// offline windows, and the send runs as an `outbound_flush` task so
670    /// `disconnect()` flushes it like any other receipt (issue #571).
671    pub(crate) fn flush_offline_receipts(&self) {
672        let infos = std::mem::take(
673            &mut *self
674                .offline_receipt_buffer
675                .lock()
676                .unwrap_or_else(|poisoned| poisoned.into_inner()),
677        );
678        if infos.is_empty() {
679            return;
680        }
681        let Some(client) = self.self_weak.get().and_then(std::sync::Weak::upgrade) else {
682            // Shutdown teardown: receipts stay unsent and the server
683            // redelivers the messages on the next connect, same as a failed
684            // send today.
685            return;
686        };
687        self.outbound_flush.spawn(&*self.runtime, async move {
688            let active = client.receipts_are_active();
689            let timestamp = wacore::time::now_utc().timestamp().to_string();
690            let groups = group_delivery_receipts(&infos, active);
691            debug!(
692                target: "Client/Receipt",
693                "Flushing {} offline delivery receipts as {} aggregate stanza group(s)",
694                infos.len(),
695                groups.len()
696            );
697            for group in &groups {
698                for node in build_aggregate_delivery_receipt_nodes(
699                    group.rep, &group.ids, active, &timestamp,
700                ) {
701                    if let Err(e) = client.send_node(node).await
702                        && !matches!(e, crate::client::ClientError::NotConnected)
703                    {
704                        log::warn!(
705                            target: "Client/Receipt",
706                            "Failed to send aggregate delivery receipt for chat {}: {:?}",
707                            group.rep.source.chat.observe(),
708                            e
709                        );
710                    }
711                }
712            }
713        });
714    }
715
716    /// Drop receipts a teardown drain missed. Called from the connection-state
717    /// resets: a receipt buffered after `disconnect()`'s drain belongs to a
718    /// message that was never acked, so the server redelivers it on the next
719    /// connect and it gets re-acked fresh there. Carrying the stale entry over
720    /// would mix a dead connection's receipts into the next connection's
721    /// aggregate flush.
722    pub(crate) fn clear_offline_receipt_buffer(&self) {
723        *self
724            .offline_receipt_buffer
725            .lock()
726            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Vec::new();
727    }
728
729    /// Spawn an async nack so the caller doesn't await network I/O while
730    /// holding a session lock. Mirrors `spawn_retry_receipt`.
731    pub(crate) fn spawn_nack(
732        self: &Arc<Self>,
733        info: &Arc<MessageInfo>,
734        reason: NackReason,
735        failure_reason: Option<i32>,
736    ) {
737        let client = Arc::clone(self);
738        let info = Arc::clone(info);
739        self.runtime
740            .spawn(Box::pin(async move {
741                client.send_nack(&info, reason, failure_reason).await;
742            }))
743            .detach();
744    }
745
746    fn build_nack_from_snapshot<S: NackSource + ?Sized>(
747        &self,
748        source: &S,
749        reason: NackReason,
750        failure_reason: Option<i32>,
751    ) -> Result<wacore_binary::Node, crate::features::StanzaResponseError> {
752        let device = self.persistence_manager.get_device_snapshot();
753        let own_pn = device
754            .pn
755            .as_ref()
756            .ok_or(crate::features::StanzaResponseError::MissingLocalIdentity)?;
757        let nack = build_nack_node(source, own_pn, reason, failure_reason);
758        drop(device);
759        nack
760    }
761
762    /// Reject a malformed stanza without retaining or cloning its decoded tree.
763    pub(crate) fn spawn_stanza_nack(
764        self: &Arc<Self>,
765        stanza: &NodeRef<'_>,
766        reason: NackReason,
767        failure_reason: Option<i32>,
768    ) {
769        let nack = match self.build_nack_from_snapshot(stanza, reason, failure_reason) {
770            Ok(nack) => nack,
771            Err(error) => {
772                log::warn!(target: "Client/Receipt", "Failed to build stanza nack: {error}");
773                return;
774            }
775        };
776        let client = Arc::clone(self);
777        self.runtime
778            .spawn(Box::pin(async move {
779                if let Err(error) = client.send_node(nack).await
780                    && !matches!(error, crate::client::ClientError::NotConnected)
781                {
782                    log::warn!(target: "Client/Receipt", "Failed to send stanza nack: {error:?}");
783                }
784            }))
785            .detach();
786    }
787
788    /// Emits a nack so the server stops retransmitting an unrecoverable
789    /// failure. Prefer [`Client::send_retry_receipt`] for recoverable
790    /// errors (BadMac, NoSession, etc).
791    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.receipt.send_nack", level = "debug", skip_all, fields(chat = %info.source.chat.observe(), sender = %info.source.sender.observe(), msg_id = %info.id, reason = ?reason)))]
792    pub(crate) async fn send_nack(
793        &self,
794        info: &MessageInfo,
795        reason: NackReason,
796        failure_reason: Option<i32>,
797    ) {
798        if info.id.is_empty() {
799            return;
800        }
801        let nack = match self.build_nack_from_snapshot(info, reason, failure_reason) {
802            Ok(nack) => nack,
803            Err(crate::features::StanzaResponseError::MissingLocalIdentity) => {
804                log::debug!(
805                    "[msg:{}] Skipping nack ({:?}): own PN not yet set",
806                    info.id,
807                    reason
808                );
809                return;
810            }
811            Err(error) => {
812                log::warn!(target: "Client/Receipt",
813                    "Failed to build nack for message {}: {error}", info.id);
814                return;
815            }
816        };
817        debug!(target: "Client/Receipt",
818            "Sending nack (reason={:?}, code={}) for message {} from {}",
819            reason, reason.code(), info.id, info.source.sender.observe());
820
821        if let Err(e) = self.send_node(nack).await
822            && !matches!(e, crate::client::ClientError::NotConnected)
823        {
824            log::warn!(target: "Client/Receipt",
825                "Failed to send nack for message {}: {:?}", info.id, e);
826        }
827    }
828
829    /// Reject a received stanza using its original borrowed representation.
830    #[cfg_attr(
831        feature = "tracing",
832        tracing::instrument(
833            name = "wa.receipt.reject_stanza",
834            level = "debug",
835            skip_all,
836            err(Debug)
837        )
838    )]
839    pub async fn reject_stanza(
840        &self,
841        stanza: &NodeRef<'_>,
842        rejection: crate::features::StanzaRejection,
843    ) -> Result<(), crate::features::StanzaResponseError> {
844        let nack =
845            self.build_nack_from_snapshot(stanza, rejection.reason(), rejection.failure_reason())?;
846        self.send_node(nack).await?;
847        Ok(())
848    }
849
850    /// Sends read receipts for one or more messages.
851    ///
852    /// For group messages, pass the message sender as `sender`.
853    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.receipt.mark_as_read", level = "debug", skip_all, fields(chat = %chat.observe()), err(Debug)))]
854    pub async fn mark_as_read(
855        &self,
856        chat: &Jid,
857        sender: Option<&Jid>,
858        message_ids: &[&str],
859    ) -> Result<(), anyhow::Error> {
860        if message_ids.is_empty() {
861            return Ok(());
862        }
863
864        let timestamp = wacore::time::now_secs_u64().to_string();
865
866        // Status reads from a LID author carry peer_participant_pn (the resolved
867        // LID->PN), matching WA Web's LidMigrationUtils.toPn.
868        let peer_participant_pn = if chat.is_status_broadcast()
869            && let Some(sender) = sender
870            && sender.is_lid()
871        {
872            self.get_lid_pn_entry(sender)
873                .await
874                .ok()
875                .flatten()
876                .map(|e| Jid::new(&*e.phone_number, wacore_binary::Server::Pn))
877        } else {
878            None
879        };
880
881        debug!(target: "Client/Receipt", "Sending read receipt for {} message(s) to {}", message_ids.len(), chat.observe());
882
883        let read_receipts_disabled = self
884            .persistence_manager
885            .get_device_snapshot()
886            .read_receipts_disabled;
887
888        // WA Web's sendAggregateReceipts caps each <receipt> at 256 ids (one <list>
889        // per chunk), so a large catch-up read (post-reconnect / history scroll)
890        // doesn't emit one oversized stanza the server may reject.
891        for chunk in message_ids.chunks(MAX_RECEIPT_IDS_PER_STANZA) {
892            let node = build_read_receipt_node(
893                chat,
894                sender,
895                chunk,
896                &timestamp,
897                peer_participant_pn.as_ref(),
898                read_receipts_disabled,
899            );
900            self.send_node(node)
901                .await
902                .map_err(|e| anyhow::anyhow!("Failed to send read receipt: {}", e))?;
903        }
904        Ok(())
905    }
906
907    /// Marks one or more voice/video notes as played (`<receipt type="played">`).
908    ///
909    /// Mirrors WA Web `WAWebSendPlayedReceiptJob`. For group/broadcast chats pass
910    /// the message sender as `sender` so the receipt carries `participant`; in DMs
911    /// pass `None`. Newsletters emit `played-self`. When `readreceipts` privacy is
912    /// `none`, a DM emits `played-self` too (the sender is not notified), matching
913    /// [`mark_as_read`](Self::mark_as_read).
914    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.receipt.mark_as_played", level = "debug", skip_all, fields(chat = %chat.observe()), err(Debug)))]
915    pub async fn mark_as_played(
916        &self,
917        chat: &Jid,
918        sender: Option<&Jid>,
919        message_ids: &[&str],
920    ) -> Result<(), anyhow::Error> {
921        if message_ids.is_empty() {
922            return Ok(());
923        }
924
925        let timestamp = wacore::time::now_secs_u64().to_string();
926
927        debug!(target: "Client/Receipt", "Sending played receipt for {} message(s) to {}", message_ids.len(), chat.observe());
928
929        let read_receipts_disabled = self
930            .persistence_manager
931            .get_device_snapshot()
932            .read_receipts_disabled;
933
934        // Same 256-id cap per stanza as read receipts (WA Web sendAggregateReceipts).
935        for chunk in message_ids.chunks(MAX_RECEIPT_IDS_PER_STANZA) {
936            let node =
937                build_played_receipt_node(chat, sender, chunk, &timestamp, read_receipts_disabled);
938            self.send_node(node)
939                .await
940                .map_err(|e| anyhow::anyhow!("Failed to send played receipt: {}", e))?;
941        }
942        Ok(())
943    }
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949    use crate::store::persistence_manager::PersistenceManager;
950    use crate::test_utils::{MockHttpClient, TestEventCollector};
951    use crate::types::message::{MessageInfo, MessageSource};
952
953    fn node_to_arc(node: wacore_binary::Node) -> Arc<OwnedNodeRef> {
954        crate::test_utils::node_to_owned_ref(&node)
955    }
956
957    fn info_with(chat: &str, sender: &str, is_group: bool) -> MessageInfo {
958        MessageInfo {
959            id: "MID".to_string(),
960            source: MessageSource {
961                chat: chat.parse().expect("test chat JID"),
962                sender: sender.parse().expect("test sender JID"),
963                is_from_me: false,
964                is_group,
965                ..Default::default()
966            },
967            ..Default::default()
968        }
969    }
970
971    #[test]
972    fn delivery_receipt_for_status_broadcast_carries_context_status_and_participant() {
973        // WA Web's gate is `(isGroup || isBroadcast) && participant` for the
974        // participant attr, and `isStatus && gating` for context — see
975        // `Send/DeliveryReceiptJob.js`. Status broadcasts must carry BOTH so
976        // the server can map the ack back to the status owner.
977        let info = info_with("status@broadcast", "12345@s.whatsapp.net", false);
978        let node = build_delivery_receipt_node(&info, true);
979        assert_eq!(node.tag, "receipt");
980        assert_eq!(
981            node.attrs.get("context").map(|v| v.as_str()).as_deref(),
982            Some("status")
983        );
984        assert_eq!(
985            node.attrs.get("participant").map(|v| v.as_str()).as_deref(),
986            Some("12345@s.whatsapp.net")
987        );
988    }
989
990    #[test]
991    fn delivery_receipt_for_dm_has_no_context_no_participant() {
992        let info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
993        let node = build_delivery_receipt_node(&info, true);
994        assert!(node.attrs.get("context").is_none());
995        assert!(node.attrs.get("participant").is_none());
996        assert!(node.attrs.get("type").is_none());
997    }
998
999    #[test]
1000    fn delivery_receipt_for_self_fanout_to_bot_is_sender_with_recipient() {
1001        // Own prompt to a @bot, echoed back: <receipt type="sender" to=ourLID
1002        // recipient=@bot>, `to` preserving the sender's device. Mirrors WA Web
1003        // DeliveryReceiptJob (SENDER + USER_JID(recipient)) and whatsmeow.
1004        let info = MessageInfo {
1005            id: "FANOUT_BOT".to_string(),
1006            source: MessageSource {
1007                sender: "100000000000001:11@lid".parse().expect("sender"),
1008                chat: "200000000000002@bot".parse().expect("chat"),
1009                recipient: Some("200000000000002@bot".parse().expect("recipient")),
1010                is_from_me: true,
1011                is_group: false,
1012                ..Default::default()
1013            },
1014            ..Default::default()
1015        };
1016        let node = build_delivery_receipt_node(&info, true);
1017        assert_eq!(node.tag, "receipt");
1018        assert_eq!(
1019            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
1020            Some("sender")
1021        );
1022        assert_eq!(
1023            node.attrs.get("to").map(|v| v.as_str()).as_deref(),
1024            Some("100000000000001:11@lid"),
1025            "`to` must preserve the own device or the LID server rejects it"
1026        );
1027        assert_eq!(
1028            node.attrs.get("recipient").map(|v| v.as_str()).as_deref(),
1029            Some("200000000000002@bot")
1030        );
1031        assert!(node.attrs.get("participant").is_none());
1032        assert!(node.attrs.get("context").is_none());
1033    }
1034
1035    #[test]
1036    fn delivery_receipt_for_self_fanout_strips_recipient_device() {
1037        // WA Web's `USER_JID` strips the device from `recipient`; a fanout to a
1038        // multi-device user echoes the non-AD recipient.
1039        let info = MessageInfo {
1040            id: "FANOUT_DEV".to_string(),
1041            source: MessageSource {
1042                sender: "100000000000001:5@lid".parse().expect("sender"),
1043                chat: "300000000000003@lid".parse().expect("chat"),
1044                recipient: Some("300000000000003:7@lid".parse().expect("recipient")),
1045                is_from_me: true,
1046                is_group: false,
1047                ..Default::default()
1048            },
1049            ..Default::default()
1050        };
1051        let node = build_delivery_receipt_node(&info, true);
1052        assert_eq!(
1053            node.attrs.get("recipient").map(|v| v.as_str()).as_deref(),
1054            Some("300000000000003@lid"),
1055            "recipient device must be stripped (USER_JID semantics)"
1056        );
1057    }
1058
1059    #[test]
1060    fn peer_self_fanout_is_peer_msg_without_recipient() {
1061        // A peer-synced message that also looks like a self-fanout (is_from_me +
1062        // recipient) must keep `type="peer_msg"` and carry NO recipient (WA Web
1063        // `!l` guard), never `type="sender"`.
1064        let info = MessageInfo {
1065            id: "PEER_FANOUT".to_string(),
1066            source: MessageSource {
1067                sender: "100000000000001@lid".parse().expect("sender"),
1068                chat: "300000000000003@lid".parse().expect("chat"),
1069                recipient: Some("300000000000003@lid".parse().expect("recipient")),
1070                is_from_me: true,
1071                is_group: false,
1072                ..Default::default()
1073            },
1074            category: MessageCategory::Peer,
1075            ..Default::default()
1076        };
1077        let node = build_delivery_receipt_node(&info, true);
1078        assert_eq!(
1079            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
1080            Some("peer_msg")
1081        );
1082        assert!(
1083            node.attrs.get("recipient").is_none(),
1084            "a peer_msg receipt must not carry a recipient"
1085        );
1086    }
1087
1088    #[test]
1089    fn self_fanout_is_sender_even_when_inactive() {
1090        // type=sender takes precedence over the inactive (passive companion)
1091        // branch: a self-fanout is always acknowledged as sender.
1092        let info = MessageInfo {
1093            id: "FANOUT_INACTIVE".to_string(),
1094            source: MessageSource {
1095                sender: "100000000000001@lid".parse().expect("sender"),
1096                chat: "200000000000002@bot".parse().expect("chat"),
1097                recipient: Some("200000000000002@bot".parse().expect("recipient")),
1098                is_from_me: true,
1099                is_group: false,
1100                ..Default::default()
1101            },
1102            ..Default::default()
1103        };
1104        let node = build_delivery_receipt_node(&info, false);
1105        assert_eq!(
1106            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
1107            Some("sender"),
1108            "self-fanout must stay type=sender, not become inactive"
1109        );
1110        assert_eq!(
1111            node.attrs.get("recipient").map(|v| v.as_str()).as_deref(),
1112            Some("200000000000002@bot")
1113        );
1114    }
1115
1116    #[test]
1117    fn delivery_receipt_is_inactive_when_not_active() {
1118        let info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
1119        let inactive = build_delivery_receipt_node(&info, false);
1120        assert_eq!(
1121            inactive.attrs.get("type").map(|v| v.as_str()).as_deref(),
1122            Some("inactive"),
1123            "a passive companion sends inactive delivery receipts"
1124        );
1125        let active = build_delivery_receipt_node(&info, true);
1126        assert!(active.attrs.get("type").is_none());
1127    }
1128
1129    #[test]
1130    fn status_and_peer_receipts_ignore_inactive() {
1131        let status = info_with("status@broadcast", "12345@s.whatsapp.net", false);
1132        let node = build_delivery_receipt_node(&status, false);
1133        // status keeps context, never type=inactive
1134        assert!(node.attrs.get("type").is_none());
1135        assert_eq!(
1136            node.attrs.get("context").map(|v| v.as_str()).as_deref(),
1137            Some("status")
1138        );
1139
1140        let mut peer = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
1141        peer.category = MessageCategory::Peer;
1142        let node = build_delivery_receipt_node(&peer, false);
1143        assert_eq!(
1144            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
1145            Some("peer_msg")
1146        );
1147    }
1148
1149    #[test]
1150    fn delivery_receipt_for_group_carries_participant() {
1151        let info = info_with(
1152            "120363021033254949@g.us",
1153            "15551234567@s.whatsapp.net",
1154            true,
1155        );
1156        let node = build_delivery_receipt_node(&info, true);
1157        assert_eq!(
1158            node.attrs.get("participant").map(|v| v.as_str()).as_deref(),
1159            Some("15551234567@s.whatsapp.net")
1160        );
1161        assert!(node.attrs.get("context").is_none());
1162    }
1163
1164    #[test]
1165    fn should_send_delivery_receipt_allows_status_broadcast() {
1166        let info = info_with("status@broadcast", "12345@s.whatsapp.net", false);
1167        assert!(Client::should_send_delivery_receipt(&info));
1168    }
1169
1170    /// Regression: LID DM with explicit device must echo the device in `to`
1171    /// (matches whatsmeow buildBaseReceipt + WA Web JID encoding). Stripping
1172    /// the device caused <stream:error><ack/> for multi-device LID senders.
1173    #[test]
1174    fn delivery_receipt_for_lid_dm_preserves_device_in_to() {
1175        let info = MessageInfo {
1176            id: "LID_DEV_RECEIPT".to_string(),
1177            source: MessageSource {
1178                // chat is the non-AD form (matches parse_message_info's
1179                // chat = from.to_non_ad()).
1180                chat: "156535032389744@lid".parse().expect("chat"),
1181                // sender preserves device (matches parse_message_info's
1182                // sender = from.clone()).
1183                sender: "156535032389744:7@lid".parse().expect("sender"),
1184                is_from_me: false,
1185                is_group: false,
1186                ..Default::default()
1187            },
1188            ..Default::default()
1189        };
1190        let node = build_delivery_receipt_node(&info, true);
1191        assert_eq!(
1192            node.attrs.get("to").map(|v| v.as_str()).as_deref(),
1193            Some("156535032389744:7@lid"),
1194            "LID DM receipt must preserve the device or the server rejects the ack"
1195        );
1196        assert!(node.attrs.get("participant").is_none());
1197    }
1198
1199    /// LID DM without device stays as-is (no-op for the common case).
1200    #[test]
1201    fn delivery_receipt_for_lid_dm_no_device_unchanged() {
1202        let info = MessageInfo {
1203            id: "LID_NO_DEV".to_string(),
1204            source: MessageSource {
1205                chat: "185323896221943@lid".parse().expect("chat"),
1206                sender: "185323896221943@lid".parse().expect("sender"),
1207                is_from_me: false,
1208                is_group: false,
1209                ..Default::default()
1210            },
1211            ..Default::default()
1212        };
1213        let node = build_delivery_receipt_node(&info, true);
1214        assert_eq!(
1215            node.attrs.get("to").map(|v| v.as_str()).as_deref(),
1216            Some("185323896221943@lid")
1217        );
1218    }
1219
1220    /// Group: `to` must remain the group JID, participant carries the device.
1221    #[test]
1222    fn delivery_receipt_for_group_to_is_group_not_sender() {
1223        let info = MessageInfo {
1224            id: "GRP_RECEIPT".to_string(),
1225            source: MessageSource {
1226                chat: "120363021033254949@g.us".parse().expect("group"),
1227                sender: "156535032389744:7@lid".parse().expect("sender"),
1228                is_from_me: false,
1229                is_group: true,
1230                ..Default::default()
1231            },
1232            ..Default::default()
1233        };
1234        let node = build_delivery_receipt_node(&info, true);
1235        assert_eq!(
1236            node.attrs.get("to").map(|v| v.as_str()).as_deref(),
1237            Some("120363021033254949@g.us")
1238        );
1239        assert_eq!(
1240            node.attrs.get("participant").map(|v| v.as_str()).as_deref(),
1241            Some("156535032389744:7@lid")
1242        );
1243    }
1244
1245    /// peer_msg: `to` echoes from (us with device), no participant.
1246    #[test]
1247    fn delivery_receipt_for_peer_dm_to_preserves_device() {
1248        let mut info = MessageInfo {
1249            id: "PEER_DEV".to_string(),
1250            source: MessageSource {
1251                chat: "9999999999@lid".parse().expect("chat"),
1252                sender: "9999999999:3@lid".parse().expect("sender"),
1253                is_from_me: true,
1254                is_group: false,
1255                ..Default::default()
1256            },
1257            ..Default::default()
1258        };
1259        info.category = MessageCategory::Peer;
1260        let node = build_delivery_receipt_node(&info, true);
1261        assert_eq!(
1262            node.attrs.get("to").map(|v| v.as_str()).as_deref(),
1263            Some("9999999999:3@lid")
1264        );
1265        assert_eq!(
1266            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
1267            Some("peer_msg")
1268        );
1269        assert!(node.attrs.get("participant").is_none());
1270    }
1271
1272    /// status@broadcast: `to` must stay status@broadcast (chat), participant
1273    /// carries the original sender device.
1274    #[test]
1275    fn delivery_receipt_for_status_to_is_status_not_sender() {
1276        let info = MessageInfo {
1277            id: "STATUS_RECEIPT".to_string(),
1278            source: MessageSource {
1279                chat: "status@broadcast".parse().expect("status"),
1280                sender: "156535032389744:7@lid".parse().expect("sender"),
1281                is_from_me: false,
1282                is_group: false,
1283                ..Default::default()
1284            },
1285            ..Default::default()
1286        };
1287        let node = build_delivery_receipt_node(&info, true);
1288        assert_eq!(
1289            node.attrs.get("to").map(|v| v.as_str()).as_deref(),
1290            Some("status@broadcast")
1291        );
1292        assert_eq!(
1293            node.attrs.get("participant").map(|v| v.as_str()).as_deref(),
1294            Some("156535032389744:7@lid")
1295        );
1296        assert_eq!(
1297            node.attrs.get("context").map(|v| v.as_str()).as_deref(),
1298            Some("status")
1299        );
1300    }
1301
1302    #[test]
1303    fn delivery_receipt_for_peer_dm_carries_type_peer_msg() {
1304        // category=Peer + DM (self device sync) → type="peer_msg", no
1305        // participant, no context. Matches WA Web's DROP_ATTR gating.
1306        let mut info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
1307        info.category = MessageCategory::Peer;
1308        let node = build_delivery_receipt_node(&info, true);
1309        assert_eq!(
1310            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
1311            Some("peer_msg")
1312        );
1313        assert!(node.attrs.get("participant").is_none());
1314        assert!(node.attrs.get("context").is_none());
1315    }
1316
1317    #[test]
1318    fn delivery_receipt_for_status_broadcast_keeps_participant_even_with_peer_type() {
1319        // Defensive: if a status broadcast ever surfaces with category=Peer,
1320        // the participant attr must still be there — server identifies the
1321        // status owner from it regardless of the peer_msg type.
1322        let mut info = info_with("status@broadcast", "12345@s.whatsapp.net", false);
1323        info.category = MessageCategory::Peer;
1324        let node = build_delivery_receipt_node(&info, true);
1325        assert_eq!(
1326            node.attrs.get("participant").map(|v| v.as_str()).as_deref(),
1327            Some("12345@s.whatsapp.net")
1328        );
1329        assert_eq!(
1330            node.attrs.get("context").map(|v| v.as_str()).as_deref(),
1331            Some("status")
1332        );
1333    }
1334
1335    // --- read/played receipt privacy gating (readreceipts==none) ---
1336
1337    fn type_of(node: &wacore_binary::Node) -> Option<String> {
1338        node.attrs.get("type").map(|v| v.as_str().to_string())
1339    }
1340
1341    #[test]
1342    fn dm_read_receipt_gates_to_read_self_when_disabled() {
1343        let chat: Jid = "12025550143@s.whatsapp.net".parse().expect("dm jid");
1344        let read = build_read_receipt_node(&chat, None, &["MID"], "1", None, true);
1345        assert_eq!(type_of(&read).as_deref(), Some("read-self"));
1346        let played = build_played_receipt_node(&chat, None, &["MID"], "1", true);
1347        assert_eq!(type_of(&played).as_deref(), Some("played-self"));
1348    }
1349
1350    #[test]
1351    fn dm_receipts_stay_plain_when_privacy_enabled() {
1352        let chat: Jid = "12025550143@s.whatsapp.net".parse().expect("dm jid");
1353        let read = build_read_receipt_node(&chat, None, &["MID"], "1", None, false);
1354        assert_eq!(type_of(&read).as_deref(), Some("read"));
1355        let played = build_played_receipt_node(&chat, None, &["MID"], "1", false);
1356        assert_eq!(type_of(&played).as_deref(), Some("played"));
1357    }
1358
1359    #[test]
1360    fn group_receipts_ignore_privacy_gate() {
1361        // Privacy does not apply to groups: still `read`/`played` even when disabled.
1362        let chat: Jid = "120363021033254949@g.us".parse().expect("group jid");
1363        let sender: Jid = "12025550143@s.whatsapp.net".parse().expect("sender jid");
1364        let read = build_read_receipt_node(&chat, Some(&sender), &["MID"], "1", None, true);
1365        assert_eq!(type_of(&read).as_deref(), Some("read"));
1366        let played = build_played_receipt_node(&chat, Some(&sender), &["MID"], "1", true);
1367        assert_eq!(type_of(&played).as_deref(), Some("played"));
1368    }
1369
1370    #[test]
1371    fn broadcast_list_receipts_ignore_privacy_gate() {
1372        // Broadcast lists are group-adjacent (they carry `participant`), so the
1373        // privacy gate must not downgrade them to `*-self` — matching WA Web.
1374        let chat: Jid = "120363000000000001@broadcast"
1375            .parse()
1376            .expect("broadcast list jid");
1377        let sender: Jid = "12025550143@s.whatsapp.net".parse().expect("sender jid");
1378        let read = build_read_receipt_node(&chat, Some(&sender), &["MID"], "1", None, true);
1379        assert_eq!(type_of(&read).as_deref(), Some("read"));
1380        let played = build_played_receipt_node(&chat, Some(&sender), &["MID"], "1", true);
1381        assert_eq!(type_of(&played).as_deref(), Some("played"));
1382    }
1383
1384    #[test]
1385    fn newsletter_receipts_are_self_regardless_of_flag() {
1386        let chat: Jid = "120363298765432100@newsletter"
1387            .parse()
1388            .expect("newsletter jid");
1389        for disabled in [false, true] {
1390            let read = build_read_receipt_node(&chat, None, &["MID"], "1", None, disabled);
1391            assert_eq!(type_of(&read).as_deref(), Some("read-self"));
1392            let played = build_played_receipt_node(&chat, None, &["MID"], "1", disabled);
1393            assert_eq!(type_of(&played).as_deref(), Some("played-self"));
1394        }
1395    }
1396
1397    fn own_pn() -> Jid {
1398        "5511000000001:0@s.whatsapp.net"
1399            .parse()
1400            .expect("own PN should parse")
1401    }
1402
1403    #[test]
1404    fn nack_from_original_stanza_preserves_each_supported_class() {
1405        for tag in ["message", "receipt", "notification"] {
1406            let stanza = NodeBuilder::new(tag)
1407                .attr("id", "STANZA-ID")
1408                .attr("from", "120363021033254949@g.us")
1409                .attr("participant", "12025550111:4@s.whatsapp.net")
1410                .attr("type", "test-type")
1411                .build();
1412            let nack = build_nack_node(
1413                &stanza.as_node_ref(),
1414                &own_pn(),
1415                NackReason::ParsingError,
1416                None,
1417            )
1418            .expect("supported stanza should produce a nack");
1419
1420            assert_eq!(
1421                nack.attrs
1422                    .get("class")
1423                    .map(|value| value.as_str())
1424                    .as_deref(),
1425                Some(tag)
1426            );
1427            assert_eq!(
1428                nack.attrs.get("id").map(|value| value.as_str()).as_deref(),
1429                Some("STANZA-ID")
1430            );
1431            assert_eq!(
1432                nack.attrs.get("to").map(|value| value.as_str()).as_deref(),
1433                Some("120363021033254949@g.us")
1434            );
1435            assert_eq!(
1436                nack.attrs
1437                    .get("participant")
1438                    .map(|value| value.as_str())
1439                    .as_deref(),
1440                Some("12025550111:4@s.whatsapp.net")
1441            );
1442            assert_eq!(
1443                nack.attrs
1444                    .get("type")
1445                    .map(|value| value.as_str())
1446                    .as_deref(),
1447                Some("test-type")
1448            );
1449            assert_eq!(
1450                nack.attrs
1451                    .get("from")
1452                    .map(|value| value.as_str())
1453                    .as_deref(),
1454                Some("5511000000001@s.whatsapp.net")
1455            );
1456        }
1457    }
1458
1459    #[test]
1460    fn unrecognized_stanza_rejection_preserves_custom_class() {
1461        let stanza = NodeBuilder::new("future-stanza")
1462            .attr("id", "FUTURE-ID")
1463            .attr("from", "12025550111@s.whatsapp.net")
1464            .build();
1465        let nack = build_nack_node(
1466            &stanza.as_node_ref(),
1467            &own_pn(),
1468            NackReason::UnrecognizedStanza,
1469            None,
1470        )
1471        .expect("unrecognized stanza reason supports arbitrary classes");
1472
1473        assert_eq!(
1474            nack.attrs
1475                .get("class")
1476                .map(|value| value.as_str())
1477                .as_deref(),
1478            Some("future-stanza")
1479        );
1480        assert!(matches!(
1481            build_nack_node(
1482                &stanza.as_node_ref(),
1483                &own_pn(),
1484                NackReason::ParsingError,
1485                None
1486            ),
1487            Err(crate::features::StanzaResponseError::UnsupportedStanzaClass)
1488        ));
1489    }
1490
1491    #[test]
1492    fn nack_does_not_apply_the_receipt_ack_participant_rule() {
1493        let stanza = NodeBuilder::new("receipt")
1494            .attr("id", "NACK-DUPLICATE-PARTICIPANT")
1495            .attr("from", "12025550111@s.whatsapp.net")
1496            .attr("participant", "12025550111@s.whatsapp.net")
1497            .build();
1498        let nack = build_nack_node(
1499            &stanza.as_node_ref(),
1500            &own_pn(),
1501            NackReason::ParsingError,
1502            None,
1503        )
1504        .expect("supported stanza should produce a nack");
1505
1506        assert!(
1507            nack.attrs
1508                .get("participant")
1509                .is_some_and(|value| value == "12025550111@s.whatsapp.net"),
1510            "nack must preserve participant even when a receipt ack would omit it"
1511        );
1512    }
1513
1514    #[test]
1515    fn nack_from_original_stanza_requires_id_and_from() {
1516        let without_id = NodeBuilder::new("message")
1517            .attr("from", "12025550111@s.whatsapp.net")
1518            .build();
1519        assert!(matches!(
1520            build_nack_node(
1521                &without_id.as_node_ref(),
1522                &own_pn(),
1523                NackReason::ParsingError,
1524                None
1525            ),
1526            Err(crate::features::StanzaResponseError::MissingAttribute("id"))
1527        ));
1528
1529        let without_from = NodeBuilder::new("message")
1530            .attr("id", "MISSING-FROM")
1531            .build();
1532        assert!(matches!(
1533            build_nack_node(
1534                &without_from.as_node_ref(),
1535                &own_pn(),
1536                NackReason::ParsingError,
1537                None
1538            ),
1539            Err(crate::features::StanzaResponseError::MissingAttribute(
1540                "from"
1541            ))
1542        ));
1543    }
1544
1545    #[test]
1546    fn nack_preserves_unknown_numeric_reason() {
1547        let stanza = NodeBuilder::new("message")
1548            .attr("id", "UNKNOWN-REASON")
1549            .attr("from", "12025550111@s.whatsapp.net")
1550            .build();
1551        let nack = build_nack_node(
1552            &stanza.as_node_ref(),
1553            &own_pn(),
1554            NackReason::Unknown(599),
1555            None,
1556        )
1557        .expect("known stanza supports unknown future error codes");
1558
1559        assert_eq!(
1560            nack.attrs
1561                .get("error")
1562                .map(|value| value.as_str())
1563                .as_deref(),
1564            Some("599")
1565        );
1566    }
1567
1568    #[test]
1569    fn nack_for_dm_carries_class_message_and_error_code() {
1570        let info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
1571        let node = build_nack_node(&info, &own_pn(), NackReason::ParsingError, None)
1572            .expect("valid DM should produce a nack");
1573
1574        assert_eq!(node.tag, "ack");
1575        assert_eq!(
1576            node.attrs.get("class").map(|v| v.as_str()).as_deref(),
1577            Some("message")
1578        );
1579        assert_eq!(
1580            node.attrs.get("error").map(|v| v.as_str()).as_deref(),
1581            Some("487")
1582        );
1583        assert_eq!(
1584            node.attrs.get("id").map(|v| v.as_str()).as_deref(),
1585            Some("MID")
1586        );
1587        assert!(node.attrs.get("from").is_some());
1588        assert!(node.attrs.get("to").is_some());
1589        assert!(node.attrs.get("participant").is_none());
1590    }
1591
1592    #[test]
1593    fn nack_for_group_carries_participant() {
1594        let info = info_with(
1595            "120363021033254949@g.us",
1596            "15551234567@s.whatsapp.net",
1597            true,
1598        );
1599        let node = build_nack_node(&info, &own_pn(), NackReason::UnhandledError, None)
1600            .expect("valid group message should produce a nack");
1601
1602        assert_eq!(
1603            node.attrs.get("participant").map(|v| v.as_str()).as_deref(),
1604            Some("15551234567@s.whatsapp.net")
1605        );
1606        assert_eq!(
1607            node.attrs.get("error").map(|v| v.as_str()).as_deref(),
1608            Some("500")
1609        );
1610    }
1611
1612    #[test]
1613    fn nack_for_status_broadcast_carries_participant() {
1614        let info = info_with("status@broadcast", "12345@s.whatsapp.net", false);
1615        let node = build_nack_node(&info, &own_pn(), NackReason::ParsingError, None)
1616            .expect("valid status message should produce a nack");
1617
1618        assert_eq!(
1619            node.attrs.get("participant").map(|v| v.as_str()).as_deref(),
1620            Some("12345@s.whatsapp.net")
1621        );
1622    }
1623
1624    #[test]
1625    fn nack_invalid_protobuf_includes_meta_failure_reason() {
1626        let info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
1627        let node = build_nack_node(&info, &own_pn(), NackReason::InvalidProtobuf, Some(42))
1628            .expect("valid message should produce a nack");
1629
1630        assert_eq!(
1631            node.attrs.get("error").map(|v| v.as_str()).as_deref(),
1632            Some("491")
1633        );
1634        let meta = node
1635            .get_optional_child("meta")
1636            .expect("InvalidProtobuf nack must have <meta> child");
1637        assert_eq!(
1638            meta.attrs
1639                .get("failure_reason")
1640                .map(|v| v.as_str())
1641                .as_deref(),
1642            Some("42")
1643        );
1644    }
1645
1646    #[test]
1647    fn nack_invalid_protobuf_without_failure_reason_omits_meta() {
1648        let info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
1649        let node = build_nack_node(&info, &own_pn(), NackReason::InvalidProtobuf, None)
1650            .expect("valid message should produce a nack");
1651        assert!(node.get_optional_child("meta").is_none());
1652    }
1653
1654    /// failure_reason only applies to InvalidProtobuf.
1655    #[test]
1656    fn nack_omits_meta_for_non_invalid_protobuf_even_with_failure_reason() {
1657        let info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
1658        let node = build_nack_node(&info, &own_pn(), NackReason::ParsingError, Some(99))
1659            .expect("valid message should produce a nack");
1660        assert!(node.get_optional_child("meta").is_none());
1661    }
1662
1663    #[test]
1664    fn nack_includes_type_when_present() {
1665        let mut info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
1666        info.r#type = "text".to_string();
1667        let node = build_nack_node(&info, &own_pn(), NackReason::ParsingError, None)
1668            .expect("valid message should produce a nack");
1669        assert_eq!(
1670            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
1671            Some("text")
1672        );
1673    }
1674
1675    #[test]
1676    fn nack_omits_type_when_empty() {
1677        let mut info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
1678        info.r#type = String::new();
1679        let node = build_nack_node(&info, &own_pn(), NackReason::ParsingError, None)
1680            .expect("valid message should produce a nack");
1681        assert!(node.attrs.get("type").is_none());
1682    }
1683
1684    #[test]
1685    fn should_send_delivery_receipt_skips_newsletter() {
1686        let info = info_with(
1687            "120363298765432100@newsletter",
1688            "120363298765432100@newsletter",
1689            false,
1690        );
1691        assert!(!Client::should_send_delivery_receipt(&info));
1692    }
1693
1694    #[test]
1695    fn should_send_delivery_receipt_skips_empty_id() {
1696        let mut info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
1697        info.id = String::new();
1698        assert!(!Client::should_send_delivery_receipt(&info));
1699    }
1700
1701    #[test]
1702    fn should_send_delivery_receipt_skips_own_dm() {
1703        // Self-sent message with NO `recipient` (a self-note where from==to):
1704        // not a fanout, so no receipt. Peer-category self-sync and self-fanouts
1705        // (which carry a `recipient`) are handled by the cases below.
1706        let mut info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
1707        info.source.is_from_me = true;
1708        assert!(info.source.recipient.is_none());
1709        assert!(!Client::should_send_delivery_receipt(&info));
1710    }
1711
1712    #[test]
1713    fn should_send_delivery_receipt_allows_self_fanout_to_user() {
1714        // Own outgoing DM to another user, echoed back to this device
1715        // (is_from_me + recipient). WA Web emits a `<receipt type="sender">`.
1716        let mut info = info_with("300000000000003@lid", "100000000000001@lid", false);
1717        info.source.is_from_me = true;
1718        info.source.recipient = Some("300000000000003@lid".parse().expect("recipient"));
1719        assert!(Client::should_send_delivery_receipt(&info));
1720    }
1721
1722    #[test]
1723    fn should_send_delivery_receipt_allows_self_fanout_to_bot() {
1724        // The reported disconnect-loop case: our own prompt to a @bot, echoed
1725        // back. Must get a sender receipt or the server replays it forever.
1726        let mut info = info_with("200000000000002@bot", "100000000000001@lid", false);
1727        info.source.is_from_me = true;
1728        info.source.recipient = Some("200000000000002@bot".parse().expect("recipient"));
1729        assert!(Client::should_send_delivery_receipt(&info));
1730    }
1731
1732    #[test]
1733    fn should_send_delivery_receipt_skips_own_status_and_group_fanout() {
1734        // Regression guard: the self-fanout allowance must NOT leak into our own
1735        // status broadcasts or group messages (WA Web does not send a DM-style
1736        // sender receipt there).
1737        let mut own_status = info_with("status@broadcast", "100000000000001@lid", false);
1738        own_status.source.is_from_me = true;
1739        own_status.source.recipient = Some("100000000000001@lid".parse().expect("recipient"));
1740        assert!(!Client::should_send_delivery_receipt(&own_status));
1741
1742        let mut own_group = info_with("120363021033254949@g.us", "100000000000001@lid", true);
1743        own_group.source.is_from_me = true;
1744        own_group.source.recipient = Some("100000000000001@lid".parse().expect("recipient"));
1745        assert!(!Client::should_send_delivery_receipt(&own_group));
1746    }
1747
1748    #[test]
1749    fn should_send_delivery_receipt_allows_own_peer_msg() {
1750        // Self-synced messages from the primary phone (category=Peer) DO need
1751        // a receipt with type="peer_msg", per the WA Web `OUR_OWN_DEVICE` ack.
1752        let mut info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
1753        info.source.is_from_me = true;
1754        info.category = MessageCategory::Peer;
1755        assert!(Client::should_send_delivery_receipt(&info));
1756    }
1757
1758    #[tokio::test]
1759    async fn test_send_delivery_receipt_dm() {
1760        let backend = crate::test_utils::create_test_backend().await;
1761        let pm = Arc::new(
1762            PersistenceManager::new(backend)
1763                .await
1764                .expect("persistence manager should initialize"),
1765        );
1766        let (client, _rx) = Client::new(
1767            Arc::new(crate::runtime_impl::TokioRuntime),
1768            pm,
1769            Arc::new(crate::transport::mock::MockTransportFactory::new()),
1770            Arc::new(MockHttpClient),
1771            None,
1772        )
1773        .await;
1774
1775        let info = MessageInfo {
1776            id: "TEST-ID-123".to_string(),
1777            source: MessageSource {
1778                chat: "12345@s.whatsapp.net"
1779                    .parse()
1780                    .expect("test JID should be valid"),
1781                sender: "12345@s.whatsapp.net"
1782                    .parse()
1783                    .expect("test JID should be valid"),
1784                is_from_me: false,
1785                is_group: false,
1786                ..Default::default()
1787            },
1788            ..Default::default()
1789        };
1790
1791        // This should complete without panicking. The actual node sending
1792        // would fail since we're not connected, but the function should
1793        // handle that gracefully and log a warning.
1794        client.send_delivery_receipt(&info).await;
1795
1796        // If we got here, the function executed successfully.
1797        // In a real scenario, we'd need to mock the transport to verify
1798        // the exact node sent, but basic functionality testing confirms
1799        // the method doesn't panic and logs appropriately.
1800    }
1801
1802    #[tokio::test]
1803    async fn test_send_delivery_receipt_group() {
1804        let backend = crate::test_utils::create_test_backend().await;
1805        let pm = Arc::new(
1806            PersistenceManager::new(backend)
1807                .await
1808                .expect("persistence manager should initialize"),
1809        );
1810        let (client, _rx) = Client::new(
1811            Arc::new(crate::runtime_impl::TokioRuntime),
1812            pm,
1813            Arc::new(crate::transport::mock::MockTransportFactory::new()),
1814            Arc::new(MockHttpClient),
1815            None,
1816        )
1817        .await;
1818
1819        let info = MessageInfo {
1820            id: "GROUP-MSG-ID".to_string(),
1821            source: MessageSource {
1822                chat: "120363021033254949@g.us"
1823                    .parse()
1824                    .expect("test JID should be valid"),
1825                sender: "15551234567@s.whatsapp.net"
1826                    .parse()
1827                    .expect("test JID should be valid"),
1828                is_from_me: false,
1829                is_group: true,
1830                ..Default::default()
1831            },
1832            ..Default::default()
1833        };
1834
1835        // Should complete without panicking for group messages too.
1836        client.send_delivery_receipt(&info).await;
1837    }
1838
1839    #[tokio::test]
1840    async fn test_skip_delivery_receipt_for_own_messages() {
1841        let backend = crate::test_utils::create_test_backend().await;
1842        let pm = Arc::new(
1843            PersistenceManager::new(backend)
1844                .await
1845                .expect("persistence manager should initialize"),
1846        );
1847        let (client, _rx) = Client::new(
1848            Arc::new(crate::runtime_impl::TokioRuntime),
1849            pm,
1850            Arc::new(crate::transport::mock::MockTransportFactory::new()),
1851            Arc::new(MockHttpClient),
1852            None,
1853        )
1854        .await;
1855
1856        let info = MessageInfo {
1857            id: "OWN-MSG-ID".to_string(),
1858            source: MessageSource {
1859                chat: "12345@s.whatsapp.net"
1860                    .parse()
1861                    .expect("test JID should be valid"),
1862                sender: "12345@s.whatsapp.net"
1863                    .parse()
1864                    .expect("test JID should be valid"),
1865                is_from_me: true, // Own message
1866                is_group: false,
1867                ..Default::default()
1868            },
1869            ..Default::default()
1870        };
1871
1872        // Should return early without attempting to send.
1873        // We can't easily assert that send_node was not called without
1874        // refactoring, but at least verify the function completes.
1875        client.send_delivery_receipt(&info).await;
1876    }
1877
1878    #[tokio::test]
1879    async fn test_skip_delivery_receipt_for_empty_id() {
1880        let backend = crate::test_utils::create_test_backend().await;
1881        let pm = Arc::new(
1882            PersistenceManager::new(backend)
1883                .await
1884                .expect("persistence manager should initialize"),
1885        );
1886        let (client, _rx) = Client::new(
1887            Arc::new(crate::runtime_impl::TokioRuntime),
1888            pm,
1889            Arc::new(crate::transport::mock::MockTransportFactory::new()),
1890            Arc::new(MockHttpClient),
1891            None,
1892        )
1893        .await;
1894
1895        let info = MessageInfo {
1896            id: "".to_string(), // Empty ID
1897            source: MessageSource {
1898                chat: "12345@s.whatsapp.net"
1899                    .parse()
1900                    .expect("test JID should be valid"),
1901                sender: "12345@s.whatsapp.net"
1902                    .parse()
1903                    .expect("test JID should be valid"),
1904                is_from_me: false,
1905                is_group: false,
1906                ..Default::default()
1907            },
1908            ..Default::default()
1909        };
1910
1911        // Should return early without attempting to send.
1912        client.send_delivery_receipt(&info).await;
1913    }
1914
1915    #[tokio::test]
1916    async fn test_skip_delivery_receipt_for_status_broadcast() {
1917        let backend = crate::test_utils::create_test_backend().await;
1918        let pm = Arc::new(
1919            PersistenceManager::new(backend)
1920                .await
1921                .expect("persistence manager should initialize"),
1922        );
1923        let (client, _rx) = Client::new(
1924            Arc::new(crate::runtime_impl::TokioRuntime),
1925            pm,
1926            Arc::new(crate::transport::mock::MockTransportFactory::new()),
1927            Arc::new(MockHttpClient),
1928            None,
1929        )
1930        .await;
1931
1932        let info = MessageInfo {
1933            id: "STATUS-MSG-ID".to_string(),
1934            source: MessageSource {
1935                chat: "status@broadcast"
1936                    .parse()
1937                    .expect("test JID should be valid"), // Status broadcast
1938                sender: "12345@s.whatsapp.net"
1939                    .parse()
1940                    .expect("test JID should be valid"),
1941                is_from_me: false,
1942                is_group: true,
1943                ..Default::default()
1944            },
1945            ..Default::default()
1946        };
1947
1948        // Should return early without attempting to send for status broadcasts.
1949        client.send_delivery_receipt(&info).await;
1950    }
1951
1952    #[test]
1953    fn test_should_skip_delivery_receipt_for_newsletter() {
1954        let info = MessageInfo {
1955            id: "NEWSLETTER-MSG-ID".to_string(),
1956            source: MessageSource {
1957                chat: "120363173003902460@newsletter"
1958                    .parse()
1959                    .expect("newsletter JID should be valid"),
1960                sender: "120363173003902460@newsletter"
1961                    .parse()
1962                    .expect("newsletter JID should be valid"),
1963                is_from_me: false,
1964                is_group: false,
1965                ..Default::default()
1966            },
1967            ..Default::default()
1968        };
1969
1970        assert!(
1971            !Client::should_send_delivery_receipt(&info),
1972            "generic delivery receipts must be skipped for newsletters"
1973        );
1974    }
1975
1976    #[test]
1977    fn test_should_send_peer_msg_receipt_for_self_synced_messages() {
1978        // Self-synced messages (category="peer") should get delivery receipts
1979        // even though is_from_me is true.  WA Web sends type="peer_msg" for these.
1980        let info = MessageInfo {
1981            id: "PEER-MSG-ID".to_string(),
1982            source: MessageSource {
1983                chat: "155500012345@s.whatsapp.net"
1984                    .parse()
1985                    .expect("own PN JID should be valid"),
1986                sender: "155500012345@s.whatsapp.net"
1987                    .parse()
1988                    .expect("own PN JID should be valid"),
1989                is_from_me: true,
1990                is_group: false,
1991                ..Default::default()
1992            },
1993            category: MessageCategory::Peer,
1994            ..Default::default()
1995        };
1996
1997        assert!(
1998            Client::should_send_delivery_receipt(&info),
1999            "peer device messages must get delivery receipts even when is_from_me"
2000        );
2001    }
2002
2003    /// Create a test client with an event collector registered.
2004    async fn setup_client_with_collector() -> (Arc<Client>, Arc<TestEventCollector>) {
2005        let backend = crate::test_utils::create_test_backend().await;
2006        let pm = Arc::new(
2007            PersistenceManager::new(backend)
2008                .await
2009                .expect("persistence manager should initialize"),
2010        );
2011        let (client, _rx) = Client::new(
2012            Arc::new(crate::runtime_impl::TokioRuntime),
2013            pm,
2014            Arc::new(crate::transport::mock::MockTransportFactory::new()),
2015            Arc::new(MockHttpClient),
2016            None,
2017        )
2018        .await;
2019
2020        let collector = Arc::new(TestEventCollector::default());
2021        client.subscribe_handler(collector.clone()).detach();
2022        (client, collector)
2023    }
2024
2025    /// Verify that enc_rekey_retry receipt is dispatched as a Receipt event
2026    /// with EncRekeyRetry type so consumers can observe it.
2027    #[tokio::test]
2028    async fn test_enc_rekey_retry_receipt_dispatches_event() {
2029        let (client, collector) = setup_client_with_collector().await;
2030
2031        // Build an enc_rekey_retry receipt node matching WA Web structure
2032        let node = node_to_arc(
2033            NodeBuilder::new("receipt")
2034                .attr("from", "5511999999999@s.whatsapp.net")
2035                .attr("id", "3EB0AABBCCDD")
2036                .attr("type", "enc_rekey_retry")
2037                .children([
2038                    NodeBuilder::new("enc_rekey")
2039                        .attr("call-creator", "5511888888888@s.whatsapp.net")
2040                        .attr("call-id", "CALL-123")
2041                        .attr("count", "1")
2042                        .build(),
2043                    NodeBuilder::new("registration")
2044                        .bytes(12345u32.to_be_bytes().to_vec())
2045                        .build(),
2046                ])
2047                .build(),
2048        );
2049
2050        client.handle_receipt(node).await;
2051
2052        // Must dispatch exactly one Receipt event with EncRekeyRetry type
2053        let events = collector.events();
2054        let receipt_events: Vec<_> = events
2055            .iter()
2056            .filter_map(|e| match &**e {
2057                Event::Receipt(r) => Some(r),
2058                _ => None,
2059            })
2060            .collect();
2061        assert_eq!(
2062            receipt_events.len(),
2063            1,
2064            "enc_rekey_retry must dispatch exactly one Receipt event"
2065        );
2066        assert_eq!(
2067            receipt_events[0].r#type,
2068            ReceiptType::EncRekeyRetry,
2069            "dispatched receipt must have EncRekeyRetry type"
2070        );
2071        assert_eq!(receipt_events[0].message_ids, vec!["3EB0AABBCCDD"]);
2072    }
2073
2074    /// Verify that enc_rekey_retry without <enc_rekey> child still dispatches
2075    /// the Receipt event (graceful degradation, no crash).
2076    #[tokio::test]
2077    async fn test_enc_rekey_retry_receipt_without_child_still_dispatches() {
2078        let (client, collector) = setup_client_with_collector().await;
2079
2080        // Malformed: no <enc_rekey> child
2081        let node = node_to_arc(
2082            NodeBuilder::new("receipt")
2083                .attr("from", "5511999999999@s.whatsapp.net")
2084                .attr("id", "3EB0AABBCCDD")
2085                .attr("type", "enc_rekey_retry")
2086                .build(),
2087        );
2088
2089        client.handle_receipt(node).await;
2090
2091        // Should still dispatch the Receipt event even without <enc_rekey> child
2092        let events = collector.events();
2093        let receipt_events: Vec<_> = events
2094            .iter()
2095            .filter_map(|e| match &**e {
2096                Event::Receipt(r) => Some(r),
2097                _ => None,
2098            })
2099            .collect();
2100        assert_eq!(
2101            receipt_events.len(),
2102            1,
2103            "malformed enc_rekey_retry must still dispatch Receipt event"
2104        );
2105        assert_eq!(receipt_events[0].r#type, ReceiptType::EncRekeyRetry);
2106    }
2107
2108    #[test]
2109    fn test_should_skip_non_peer_self_messages() {
2110        // Normal self messages (no category) should still be skipped.
2111        let info = MessageInfo {
2112            id: "SELF-MSG-ID".to_string(),
2113            source: MessageSource {
2114                chat: "155500012345@s.whatsapp.net"
2115                    .parse()
2116                    .expect("own PN JID should be valid"),
2117                sender: "155500012345@s.whatsapp.net"
2118                    .parse()
2119                    .expect("own PN JID should be valid"),
2120                is_from_me: true,
2121                is_group: false,
2122                ..Default::default()
2123            },
2124            ..Default::default()
2125        };
2126
2127        assert!(
2128            !Client::should_send_delivery_receipt(&info),
2129            "non-peer self messages must not get delivery receipts"
2130        );
2131    }
2132
2133    /// Aggregated-by-message receipt: fan out one Receipt event per `<user>`
2134    /// with that user's type, and use the `message_id` attr (not the stanza
2135    /// id) as the message id. Matches `WAWebHandleMsgReceiptParser` m() branch.
2136    #[tokio::test]
2137    async fn test_aggregated_by_message_receipt_fans_out_per_user() {
2138        let (client, collector) = setup_client_with_collector().await;
2139
2140        let node = node_to_arc(
2141            NodeBuilder::new("receipt")
2142                .attr("from", "120363000000000001@g.us")
2143                .attr("id", "STANZA-AGG-XYZ")
2144                .attr("t", "1700000000")
2145                .children([NodeBuilder::new("participants")
2146                    .attr("message_id", "REAL-MSG-ID")
2147                    .children([
2148                        NodeBuilder::new("user")
2149                            .attr("jid", "99000000000001@lid")
2150                            .attr("t", "1700000001")
2151                            .attr("type", "delivery")
2152                            .build(),
2153                        NodeBuilder::new("user")
2154                            .attr("jid", "99000000000002@lid")
2155                            .attr("t", "1700000002")
2156                            .attr("type", "read")
2157                            .build(),
2158                        NodeBuilder::new("user")
2159                            .attr("jid", "99000000000003@lid")
2160                            .attr("t", "1700000003")
2161                            .attr("type", "inactive")
2162                            .build(),
2163                    ])
2164                    .build()])
2165                .build(),
2166        );
2167        client.handle_receipt(node).await;
2168
2169        let events = collector.events();
2170        let receipts: Vec<_> = events
2171            .iter()
2172            .filter_map(|e| match &**e {
2173                Event::Receipt(r) => Some(r),
2174                _ => None,
2175            })
2176            .collect();
2177        assert_eq!(receipts.len(), 3, "must dispatch one event per <user>");
2178        for r in &receipts {
2179            assert_eq!(
2180                r.message_ids,
2181                vec!["REAL-MSG-ID"],
2182                "fan-out events must use participants.message_id, not stanza id"
2183            );
2184            assert_eq!(r.source.chat.user, "120363000000000001");
2185        }
2186        assert_eq!(receipts[0].r#type, ReceiptType::Delivered);
2187        assert_eq!(receipts[0].source.sender.user, "99000000000001");
2188        assert_eq!(receipts[1].r#type, ReceiptType::Read);
2189        assert_eq!(receipts[2].r#type, ReceiptType::Inactive);
2190    }
2191
2192    /// participant_pn must land in the Receipt event's sender_alt on both shapes.
2193    #[tokio::test]
2194    async fn test_receipt_threads_participant_pn_into_sender_alt() {
2195        let (client, collector) = setup_client_with_collector().await;
2196
2197        // Aggregated shape: per-user participant_pn.
2198        client
2199            .handle_receipt(node_to_arc(
2200                NodeBuilder::new("receipt")
2201                    .attr("from", "120363000000000001@g.us")
2202                    .attr("id", "STANZA-PPN")
2203                    .attr("t", "1700000000")
2204                    .children([NodeBuilder::new("participants")
2205                        .attr("message_id", "MSG-PPN")
2206                        .children([NodeBuilder::new("user")
2207                            .attr("jid", "99000000000001@lid")
2208                            .attr("participant_pn", "15551234567@s.whatsapp.net")
2209                            .attr("type", "read")
2210                            .build()])
2211                        .build()])
2212                    .build(),
2213            ))
2214            .await;
2215
2216        // Simple shape: receipt-level participant_pn.
2217        client
2218            .handle_receipt(node_to_arc(
2219                NodeBuilder::new("receipt")
2220                    .attr("from", "99000000000002@lid")
2221                    .attr("id", "STANZA-PPN-SIMPLE")
2222                    .attr("participant_pn", "15557654321@s.whatsapp.net")
2223                    .attr("t", "1700000000")
2224                    .build(),
2225            ))
2226            .await;
2227
2228        let events = collector.events();
2229        let receipts: Vec<_> = events
2230            .iter()
2231            .filter_map(|e| match &**e {
2232                Event::Receipt(r) => Some(r),
2233                _ => None,
2234            })
2235            .collect();
2236
2237        let agg = receipts
2238            .iter()
2239            .find(|r| r.message_ids.iter().any(|id| id == "MSG-PPN"))
2240            .expect("aggregated receipt dispatched");
2241        assert_eq!(
2242            agg.source.sender_alt.as_ref().expect("sender_alt set").user,
2243            "15551234567",
2244            "aggregated receipt must thread per-user participant_pn into sender_alt"
2245        );
2246
2247        let simple = receipts
2248            .iter()
2249            .find(|r| r.message_ids.iter().any(|id| id == "STANZA-PPN-SIMPLE"))
2250            .expect("simple receipt dispatched");
2251        assert_eq!(
2252            simple
2253                .source
2254                .sender_alt
2255                .as_ref()
2256                .expect("sender_alt set")
2257                .user,
2258            "15557654321",
2259            "simple receipt must thread receipt-level participant_pn into sender_alt"
2260        );
2261    }
2262
2263    #[tokio::test]
2264    async fn test_receipt_offline_attr_propagated() {
2265        let (client, collector) = setup_client_with_collector().await;
2266
2267        // Drained from the offline queue: carries the `offline` attr.
2268        client
2269            .handle_receipt(node_to_arc(
2270                NodeBuilder::new("receipt")
2271                    .attr("from", "15551234567@s.whatsapp.net")
2272                    .attr("id", "OFFLINE-RCPT")
2273                    .attr("offline", "1")
2274                    .attr("t", "1700000000")
2275                    .build(),
2276            ))
2277            .await;
2278
2279        // Live delivery: no `offline` attr.
2280        client
2281            .handle_receipt(node_to_arc(
2282                NodeBuilder::new("receipt")
2283                    .attr("from", "15551234567@s.whatsapp.net")
2284                    .attr("id", "LIVE-RCPT")
2285                    .attr("t", "1700000000")
2286                    .build(),
2287            ))
2288            .await;
2289
2290        let events = collector.events();
2291        let receipts: Vec<_> = events
2292            .iter()
2293            .filter_map(|e| match &**e {
2294                Event::Receipt(r) => Some(r),
2295                _ => None,
2296            })
2297            .collect();
2298
2299        let offline = receipts
2300            .iter()
2301            .find(|r| r.message_ids.iter().any(|id| id == "OFFLINE-RCPT"))
2302            .expect("offline receipt dispatched");
2303        assert!(
2304            offline.offline,
2305            "receipt with the offline attr sets offline=true"
2306        );
2307
2308        let live = receipts
2309            .iter()
2310            .find(|r| r.message_ids.iter().any(|id| id == "LIVE-RCPT"))
2311            .expect("live receipt dispatched");
2312        assert!(
2313            !live.offline,
2314            "receipt without the offline attr sets offline=false"
2315        );
2316    }
2317
2318    /// Missing per-user `t`: the fan-out event's timestamp falls back to
2319    /// the stanza-level `t` rather than collapsing to epoch zero (which
2320    /// was the previous behavior).
2321    #[tokio::test]
2322    async fn test_aggregated_user_missing_t_uses_stanza_timestamp() {
2323        let (client, collector) = setup_client_with_collector().await;
2324
2325        let node = node_to_arc(
2326            NodeBuilder::new("receipt")
2327                .attr("from", "120363000000000001@g.us")
2328                .attr("id", "STANZA-AGG-NOT")
2329                .attr("t", "1700000000")
2330                .children([NodeBuilder::new("participants")
2331                    .attr("message_id", "REAL-MSG-NOT")
2332                    .children([NodeBuilder::new("user")
2333                        .attr("jid", "99000000000001@lid")
2334                        .attr("type", "delivery")
2335                        .build()])
2336                    .build()])
2337                .build(),
2338        );
2339        client.handle_receipt(node).await;
2340
2341        let events = collector.events();
2342        let r = events
2343            .iter()
2344            .find_map(|e| match &**e {
2345                Event::Receipt(r) => Some(r),
2346                _ => None,
2347            })
2348            .expect("expected Receipt");
2349        let expected = wacore::time::from_secs(1700000000).expect("valid ts");
2350        assert_eq!(r.timestamp, expected);
2351    }
2352
2353    /// Aggregated-by-type receipt: `<participants key="...">` without
2354    /// `message_id`. All users inherit the receipt-level type. Mirrors d() branch.
2355    #[tokio::test]
2356    async fn test_aggregated_by_type_receipt_uses_receipt_level_type() {
2357        let (client, collector) = setup_client_with_collector().await;
2358
2359        let node = node_to_arc(
2360            NodeBuilder::new("receipt")
2361                .attr("from", "120363000000000001@g.us")
2362                .attr("id", "STANZA-KEY")
2363                .attr("type", "read")
2364                .attr("t", "1700000000")
2365                .children([NodeBuilder::new("participants")
2366                    .attr("key", "AGG-KEY")
2367                    .children([NodeBuilder::new("user")
2368                        .attr("jid", "99000000000001@lid")
2369                        .attr("t", "1700000001")
2370                        .build()])
2371                    .build()])
2372                .build(),
2373        );
2374        client.handle_receipt(node).await;
2375
2376        let events = collector.events();
2377        let receipts: Vec<_> = events
2378            .iter()
2379            .filter_map(|e| match &**e {
2380                Event::Receipt(r) => Some(r),
2381                _ => None,
2382            })
2383            .collect();
2384        assert_eq!(receipts.len(), 1);
2385        assert_eq!(receipts[0].r#type, ReceiptType::Read);
2386        assert_eq!(receipts[0].message_ids, vec!["AGG-KEY"]);
2387    }
2388
2389    /// `<list><item id=.../>` batched read receipt: all items plus the stanza
2390    /// id (appended last) must end up in `message_ids`. Pre-fix only the
2391    /// stanza id was kept.
2392    #[tokio::test]
2393    async fn test_simple_receipt_with_list_collects_all_ids() {
2394        let (client, collector) = setup_client_with_collector().await;
2395
2396        let node = node_to_arc(
2397            NodeBuilder::new("receipt")
2398                .attr("from", "99000000000001@s.whatsapp.net")
2399                .attr("id", "MSG-A")
2400                .attr("type", "read")
2401                .attr("t", "1700000000")
2402                .children([NodeBuilder::new("list")
2403                    .children([
2404                        NodeBuilder::new("item").attr("id", "MSG-B").build(),
2405                        NodeBuilder::new("item").attr("id", "MSG-C").build(),
2406                    ])
2407                    .build()])
2408                .build(),
2409        );
2410        client.handle_receipt(node).await;
2411
2412        let events = collector.events();
2413        let r = events
2414            .iter()
2415            .find_map(|e| match &**e {
2416                Event::Receipt(r) => Some(r),
2417                _ => None,
2418            })
2419            .expect("expected Receipt");
2420        // Stanza id is appended LAST per WAWebHandleMsgReceiptParser.
2421        assert_eq!(r.message_ids, vec!["MSG-B", "MSG-C", "MSG-A"]);
2422        assert_eq!(r.r#type, ReceiptType::Read);
2423    }
2424
2425    /// Simple receipt without `<list>`: only the stanza id is in message_ids.
2426    #[tokio::test]
2427    async fn test_simple_receipt_without_list_uses_stanza_id() {
2428        let (client, collector) = setup_client_with_collector().await;
2429
2430        let node = node_to_arc(
2431            NodeBuilder::new("receipt")
2432                .attr("from", "99000000000001@s.whatsapp.net")
2433                .attr("id", "SOLO-MSG")
2434                .attr("t", "1700000000")
2435                .build(),
2436        );
2437        client.handle_receipt(node).await;
2438
2439        let events = collector.events();
2440        let r = events
2441            .iter()
2442            .find_map(|e| match &**e {
2443                Event::Receipt(r) => Some(r),
2444                _ => None,
2445            })
2446            .expect("expected Receipt");
2447        assert_eq!(r.message_ids, vec!["SOLO-MSG"]);
2448        assert_eq!(r.r#type, ReceiptType::Delivered);
2449    }
2450
2451    /// Verify that receipt nodes use JID-typed attrs for `to` and `participant`,
2452    /// ensuring the NodeValue::Jid optimization is not accidentally regressed to to_string.
2453    #[test]
2454    fn test_receipt_node_uses_jid_attrs() {
2455        use wacore_binary::NodeValue;
2456
2457        let chat_jid: Jid = "120363021033254949@g.us"
2458            .parse()
2459            .expect("test JID should be valid");
2460        let sender_jid: Jid = "15551234567@s.whatsapp.net"
2461            .parse()
2462            .expect("test JID should be valid");
2463
2464        // Build a group receipt node using the same pattern as send_delivery_receipt
2465        let node = NodeBuilder::new("receipt")
2466            .attr("id", "MSG-123")
2467            .attr("to", chat_jid.clone())
2468            .attr("participant", sender_jid.clone())
2469            .build();
2470
2471        // "to" must be stored as NodeValue::Jid, not NodeValue::String
2472        let to_attr = node.attrs.get("to").expect("receipt must have 'to' attr");
2473        assert!(
2474            matches!(to_attr, NodeValue::Jid(_)),
2475            "'to' attr should be JID-typed, got: {:?}",
2476            to_attr
2477        );
2478        assert_eq!(to_attr.to_jid().unwrap(), chat_jid);
2479
2480        // "participant" must also be JID-typed
2481        let participant_attr = node
2482            .attrs
2483            .get("participant")
2484            .expect("group receipt must have 'participant' attr");
2485        assert!(
2486            matches!(participant_attr, NodeValue::Jid(_)),
2487            "'participant' attr should be JID-typed, got: {:?}",
2488            participant_attr
2489        );
2490        assert_eq!(participant_attr.to_jid().unwrap(), sender_jid);
2491    }
2492
2493    fn jid(s: &str) -> Jid {
2494        s.parse().expect("test JID")
2495    }
2496
2497    #[test]
2498    fn played_receipt_group_is_played_with_participant() {
2499        let node = build_played_receipt_node(
2500            &jid("123@g.us"),
2501            Some(&jid("456@s.whatsapp.net")),
2502            &["M1"],
2503            "100",
2504            false,
2505        );
2506        assert_eq!(node.tag, "receipt");
2507        assert_eq!(
2508            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
2509            Some("played")
2510        );
2511        assert_eq!(
2512            node.attrs
2513                .get("participant")
2514                .and_then(|v| v.to_jid().map(|j| j.to_string()))
2515                .as_deref(),
2516            Some("456@s.whatsapp.net")
2517        );
2518    }
2519
2520    #[test]
2521    fn played_receipt_dm_is_played_without_participant() {
2522        // WA Web drops `participant` in DMs (PlayedReceiptJob `r.isUser() ? null`).
2523        let node =
2524            build_played_receipt_node(&jid("456@s.whatsapp.net"), None, &["M1"], "100", false);
2525        assert_eq!(
2526            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
2527            Some("played")
2528        );
2529        assert!(node.attrs.get("participant").is_none());
2530    }
2531
2532    #[test]
2533    fn played_receipt_newsletter_is_played_self() {
2534        let node = build_played_receipt_node(&jid("123@newsletter"), None, &["M1"], "100", false);
2535        assert_eq!(
2536            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
2537            Some("played-self")
2538        );
2539        assert!(node.attrs.get("participant").is_none());
2540    }
2541
2542    #[test]
2543    fn played_receipt_extra_ids_go_into_list() {
2544        let node = build_played_receipt_node(
2545            &jid("456@s.whatsapp.net"),
2546            None,
2547            &["M1", "M2", "M3"],
2548            "100",
2549            false,
2550        );
2551        assert_eq!(
2552            node.attrs.get("id").map(|v| v.as_str()).as_deref(),
2553            Some("M1")
2554        );
2555        let list = node
2556            .get_optional_child("list")
2557            .expect("extra ids must produce a <list>");
2558        assert_eq!(list.children().map(|c| c.len()).unwrap_or(0), 2);
2559    }
2560
2561    #[test]
2562    fn played_receipt_status_broadcast_carries_participant() {
2563        let node = build_played_receipt_node(
2564            &jid("status@broadcast"),
2565            Some(&jid("456@s.whatsapp.net")),
2566            &["M1"],
2567            "100",
2568            false,
2569        );
2570        assert_eq!(
2571            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
2572            Some("played")
2573        );
2574        assert_eq!(
2575            node.attrs
2576                .get("participant")
2577                .and_then(|v| v.to_jid().map(|j| j.to_string()))
2578                .as_deref(),
2579            Some("456@s.whatsapp.net")
2580        );
2581    }
2582
2583    #[test]
2584    fn played_receipt_broadcast_list_carries_participant() {
2585        let node = build_played_receipt_node(
2586            &jid("120363000000000001@broadcast"),
2587            Some(&jid("456@s.whatsapp.net")),
2588            &["M1"],
2589            "100",
2590            false,
2591        );
2592        assert_eq!(
2593            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
2594            Some("played")
2595        );
2596        assert_eq!(
2597            node.attrs
2598                .get("participant")
2599                .and_then(|v| v.to_jid().map(|j| j.to_string()))
2600                .as_deref(),
2601            Some("456@s.whatsapp.net")
2602        );
2603    }
2604
2605    #[test]
2606    fn read_receipt_dm_is_read_without_context() {
2607        let node = build_read_receipt_node(
2608            &jid("456@s.whatsapp.net"),
2609            None,
2610            &["M1"],
2611            "100",
2612            None,
2613            false,
2614        );
2615        assert_eq!(
2616            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
2617            Some("read")
2618        );
2619        assert!(node.attrs.get("context").is_none());
2620        assert!(node.attrs.get("peer_participant_pn").is_none());
2621    }
2622
2623    #[test]
2624    fn read_receipt_newsletter_is_read_self() {
2625        let node =
2626            build_read_receipt_node(&jid("123@newsletter"), None, &["M1"], "100", None, false);
2627        assert_eq!(
2628            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
2629            Some("read-self")
2630        );
2631    }
2632
2633    #[test]
2634    fn read_receipt_status_carries_context_and_peer_pn() {
2635        let pn = jid("559980000001@s.whatsapp.net");
2636        let node = build_read_receipt_node(
2637            &jid("status@broadcast"),
2638            Some(&jid("100000012345678@lid")),
2639            &["M1"],
2640            "100",
2641            Some(&pn),
2642            false,
2643        );
2644        assert_eq!(
2645            node.attrs.get("type").map(|v| v.as_str()).as_deref(),
2646            Some("read")
2647        );
2648        assert_eq!(
2649            node.attrs.get("context").map(|v| v.as_str()).as_deref(),
2650            Some("status")
2651        );
2652        assert_eq!(
2653            node.attrs
2654                .get("peer_participant_pn")
2655                .and_then(|v| v.to_jid().map(|j| j.to_string()))
2656                .as_deref(),
2657            Some("559980000001@s.whatsapp.net")
2658        );
2659    }
2660
2661    fn offline_info(id: &str, chat: &str, sender: &str, is_group: bool) -> Arc<MessageInfo> {
2662        let mut info = info_with(chat, sender, is_group);
2663        info.id = id.to_string();
2664        info.is_offline = true;
2665        Arc::new(info)
2666    }
2667
2668    #[test]
2669    fn aggregate_delivery_receipts_group_by_chat_author_and_type() {
2670        let group_chat = "120363000000000001@g.us";
2671        let mut peer = info_with(
2672            "5511999990000@s.whatsapp.net",
2673            "5511999990000@s.whatsapp.net",
2674            false,
2675        );
2676        peer.id = "M6".to_string();
2677        peer.source.is_from_me = true;
2678        peer.category = MessageCategory::Peer;
2679
2680        let infos = vec![
2681            offline_info(
2682                "M1",
2683                "5511999990000@s.whatsapp.net",
2684                "5511999990000@s.whatsapp.net",
2685                false,
2686            ),
2687            offline_info(
2688                "M2",
2689                "5511999990000@s.whatsapp.net",
2690                "5511999990000@s.whatsapp.net",
2691                false,
2692            ),
2693            offline_info("M3", group_chat, "5511888880000@s.whatsapp.net", true),
2694            offline_info("M4", group_chat, "5511888880000@s.whatsapp.net", true),
2695            offline_info("M5", group_chat, "5511777770000@s.whatsapp.net", true),
2696            Arc::new(peer),
2697        ];
2698
2699        let groups = group_delivery_receipts(&infos, true);
2700
2701        // DM sender, group author A, group author B, and the peer-typed DM
2702        // must each get their own stanza; same (chat, author, type) coalesce.
2703        assert_eq!(groups.len(), 4);
2704        assert_eq!(groups[0].ids, vec!["M1", "M2"]);
2705        assert_eq!(groups[1].ids, vec!["M3", "M4"]);
2706        assert_eq!(groups[2].ids, vec!["M5"]);
2707        assert_eq!(groups[3].ids, vec!["M6"]);
2708        assert_eq!(
2709            delivery_receipt_type(groups[3].rep, true),
2710            Some("peer_msg"),
2711            "peer messages must not coalesce into the plain delivered group"
2712        );
2713    }
2714
2715    #[test]
2716    fn aggregate_delivery_receipt_node_shape_and_ingest_roundtrip() {
2717        let infos = vec![
2718            offline_info(
2719                "M1",
2720                "120363000000000001@g.us",
2721                "5511888880000@s.whatsapp.net",
2722                true,
2723            ),
2724            offline_info(
2725                "M2",
2726                "120363000000000001@g.us",
2727                "5511888880000@s.whatsapp.net",
2728                true,
2729            ),
2730            offline_info(
2731                "M3",
2732                "120363000000000001@g.us",
2733                "5511888880000@s.whatsapp.net",
2734                true,
2735            ),
2736        ];
2737        let groups = group_delivery_receipts(&infos, true);
2738        assert_eq!(groups.len(), 1);
2739
2740        let nodes = build_aggregate_delivery_receipt_nodes(
2741            groups[0].rep,
2742            &groups[0].ids,
2743            true,
2744            "1760000000",
2745        );
2746        assert_eq!(nodes.len(), 1);
2747        let node = &nodes[0];
2748
2749        // WA Web sendAggregateReceipts: id = first, rest in <list><item>,
2750        // DELIVERY drops the type attr, t carries the flush timestamp.
2751        assert_eq!(node.tag, "receipt");
2752        assert_eq!(
2753            node.attrs.get("id").map(|v| v.as_str()).as_deref(),
2754            Some("M1")
2755        );
2756        assert_eq!(
2757            node.attrs.get("t").map(|v| v.as_str()).as_deref(),
2758            Some("1760000000")
2759        );
2760        assert!(node.attrs.get("type").is_none());
2761        assert_eq!(
2762            node.attrs.get("to").map(|v| v.as_str()).as_deref(),
2763            Some("120363000000000001@g.us")
2764        );
2765        assert_eq!(
2766            node.attrs.get("participant").map(|v| v.as_str()).as_deref(),
2767            Some("5511888880000@s.whatsapp.net")
2768        );
2769
2770        // The shape must round-trip through our own ingest parser (the same
2771        // form WA Web sends us): list items first, stanza id appended last.
2772        let owned = node_to_arc(node.clone());
2773        let parsed = wacore::stanza::receipt::collect_simple_message_ids(owned.get(), "M1", false);
2774        assert_eq!(
2775            parsed,
2776            vec!["M2".to_string(), "M3".to_string(), "M1".to_string()]
2777        );
2778    }
2779
2780    #[test]
2781    fn aggregate_delivery_receipt_chunks_at_256_ids() {
2782        let chat = "5511999990000@s.whatsapp.net";
2783        let infos: Vec<Arc<MessageInfo>> = (0..257)
2784            .map(|i| offline_info(&format!("M{i:03}"), chat, chat, false))
2785            .collect();
2786        let groups = group_delivery_receipts(&infos, true);
2787        assert_eq!(groups.len(), 1);
2788
2789        let nodes = build_aggregate_delivery_receipt_nodes(
2790            groups[0].rep,
2791            &groups[0].ids,
2792            true,
2793            "1760000000",
2794        );
2795        assert_eq!(nodes.len(), 2, "257 ids must split into 256 + 1 stanzas");
2796
2797        let first_list_len = nodes[0]
2798            .children()
2799            .and_then(|c| c.iter().find(|n| n.tag == "list"))
2800            .and_then(|l| l.children())
2801            .map(|items| items.len());
2802        assert_eq!(first_list_len, Some(255), "id attr + 255 list items = 256");
2803        assert_eq!(
2804            nodes[1].attrs.get("id").map(|v| v.as_str()).as_deref(),
2805            Some("M256")
2806        );
2807        assert!(
2808            nodes[1].children().is_none(),
2809            "a single-id chunk must not carry an empty <list>"
2810        );
2811    }
2812
2813    #[tokio::test]
2814    async fn offline_receipt_buffer_protocol() {
2815        let backend = crate::test_utils::create_test_backend().await;
2816        let pm = Arc::new(
2817            PersistenceManager::new(backend)
2818                .await
2819                .expect("persistence manager should initialize"),
2820        );
2821        let (client, _rx) = Client::new(
2822            Arc::new(crate::runtime_impl::TokioRuntime),
2823            pm,
2824            Arc::new(crate::transport::mock::MockTransportFactory::new()),
2825            Arc::new(MockHttpClient),
2826            None,
2827        )
2828        .await;
2829
2830        // Offline messages buffer instead of sending 1:1.
2831        let info = offline_info(
2832            "OFF1",
2833            "5511999990000@s.whatsapp.net",
2834            "5511999990000@s.whatsapp.net",
2835            false,
2836        );
2837        client.ack_received_message(&info);
2838        let info2 = offline_info(
2839            "OFF2",
2840            "5511999990000@s.whatsapp.net",
2841            "5511999990000@s.whatsapp.net",
2842            false,
2843        );
2844        client.ack_received_message(&info2);
2845        assert_eq!(
2846            client.offline_receipt_buffer.lock().expect("buffer").len(),
2847            2
2848        );
2849
2850        // A live (non-offline) message never touches the buffer.
2851        let mut live = info_with(
2852            "5511999990000@s.whatsapp.net",
2853            "5511999990000@s.whatsapp.net",
2854            false,
2855        );
2856        live.id = "LIVE1".to_string();
2857        client.ack_received_message(&Arc::new(live));
2858        assert_eq!(
2859            client.offline_receipt_buffer.lock().expect("buffer").len(),
2860            2
2861        );
2862
2863        // The completion flag alone is NOT enough to go 1:1: during a
2864        // deferred drain-to-live transition the flag is set while the batcher
2865        // stays active, and receipts must keep buffering (their SKDM state
2866        // may still be cache-only until the deferred flush).
2867        client
2868            .offline_sync_completed
2869            .store(true, std::sync::atomic::Ordering::Release);
2870        let deferred = offline_info(
2871            "OFF2B",
2872            "5511999990000@s.whatsapp.net",
2873            "5511999990000@s.whatsapp.net",
2874            false,
2875        );
2876        assert!(client.try_buffer_offline_receipt(&deferred));
2877        assert_eq!(
2878            client.offline_receipt_buffer.lock().expect("buffer").len(),
2879            3
2880        );
2881
2882        // Once the batcher goes live too, late offline receipts fall back to
2883        // 1:1 instead of stranding in the buffer (the exact race this guards).
2884        client.enter_live_mode_for_tests();
2885        let late = offline_info(
2886            "OFF3",
2887            "5511999990000@s.whatsapp.net",
2888            "5511999990000@s.whatsapp.net",
2889            false,
2890        );
2891        assert!(!client.try_buffer_offline_receipt(&late));
2892        assert_eq!(
2893            client.offline_receipt_buffer.lock().expect("buffer").len(),
2894            3
2895        );
2896
2897        // Flush drains everything and releases the backing capacity, so no
2898        // memory is held between offline windows.
2899        client.flush_offline_receipts();
2900        {
2901            let buffer = client.offline_receipt_buffer.lock().expect("buffer");
2902            assert!(buffer.is_empty());
2903            assert_eq!(
2904                buffer.capacity(),
2905                0,
2906                "drained buffer must not retain capacity"
2907            );
2908        }
2909
2910        // Teardown straggler: a receipt buffered after disconnect()'s drain
2911        // (flag still false on the next connection) must be dropped by the
2912        // connection-state reset instead of leaking into the next
2913        // connection's aggregate flush; the server redelivers its message.
2914        client
2915            .offline_sync_completed
2916            .store(false, std::sync::atomic::Ordering::Release);
2917        let straggler = offline_info(
2918            "OFF4",
2919            "5511999990000@s.whatsapp.net",
2920            "5511999990000@s.whatsapp.net",
2921            false,
2922        );
2923        assert!(client.try_buffer_offline_receipt(&straggler));
2924        client.clear_offline_receipt_buffer();
2925        assert!(
2926            client
2927                .offline_receipt_buffer
2928                .lock()
2929                .expect("buffer")
2930                .is_empty(),
2931            "connection reset must drop stale buffered receipts"
2932        );
2933    }
2934}