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
14const MAX_RECEIPT_IDS_PER_STANZA: usize = 256;
18
19fn 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
78fn 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
129fn 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
146fn delivery_receipt_builder(info: &MessageInfo, active: bool) -> NodeBuilder {
149 let is_status = info.source.chat.is_status_broadcast();
150 let sender_receipt = info.source.is_self_fanout() && info.category != MessageCategory::Peer;
154 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 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
190struct DeliveryReceiptGroup<'a> {
193 rep: &'a MessageInfo,
194 ids: Vec<&'a str>,
195}
196
197fn 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
253fn 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
345fn 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 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 let participant_pn = attrs.optional_jid("participant_pn");
429 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 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 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 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 let effective_type = match user.r#type.as_deref() {
479 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 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 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 #[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 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 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 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 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 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 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, ×tamp,
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 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 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 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 #[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 #[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 #[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 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 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 ×tamp,
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 #[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 for chunk in message_ids.chunks(MAX_RECEIPT_IDS_PER_STANZA) {
936 let node =
937 build_played_receipt_node(chat, sender, chunk, ×tamp, 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 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 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 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 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 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 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 #[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: "156535032389744@lid".parse().expect("chat"),
1181 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 #[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 #[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 #[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 #[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 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 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 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 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 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 #[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 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 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 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 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 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 client.send_delivery_receipt(&info).await;
1795
1796 }
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 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, is_group: false,
1867 ..Default::default()
1868 },
1869 ..Default::default()
1870 };
1871
1872 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(), 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 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"), 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 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 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 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 #[tokio::test]
2028 async fn test_enc_rekey_retry_receipt_dispatches_event() {
2029 let (client, collector) = setup_client_with_collector().await;
2030
2031 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 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 #[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 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 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 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 #[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 #[tokio::test]
2194 async fn test_receipt_threads_participant_pn_into_sender_alt() {
2195 let (client, collector) = setup_client_with_collector().await;
2196
2197 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 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 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 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 #[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 #[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 #[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 assert_eq!(r.message_ids, vec!["MSG-B", "MSG-C", "MSG-A"]);
2422 assert_eq!(r.r#type, ReceiptType::Read);
2423 }
2424
2425 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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}