From c38b25a533121c1b842b00d51db2e07e83028201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Mon, 8 Jun 2026 14:27:04 -0300 Subject: [PATCH] feat(receipt): expose the 'offline' attr on the Receipt event WA Web parses 'offline' on incoming receipts (incomingMsgReceiptParser: offline = maybeAttrString) and retries; receipts drained from the server's offline queue on reconnect carry it. We never read it, so a consumer couldn't tell a live receipt from one replayed out of the offline queue (relevant for resume metrics and for not re-firing receipt-driven logic on stale acks). Parse the attr in handle_receipt and add Receipt.offline. --- src/receipt.rs | 59 ++++++++++++++++++++++++++++++++++++++ src/retry.rs | 2 ++ wacore/src/types/events.rs | 4 +++ 3 files changed, 65 insertions(+) diff --git a/src/receipt.rs b/src/receipt.rs index 5cb7e7d8f..7902fbca4 100644 --- a/src/receipt.rs +++ b/src/receipt.rs @@ -242,6 +242,8 @@ impl Client { let participant = attrs.optional_jid("participant"); // participant_pn -> sender_alt so the LID-PN cache warms from receipts too. let participant_pn = attrs.optional_jid("participant_pn"); + // Present when this receipt was drained from the offline queue on reconnect. + let offline = attrs.optional_string("offline").is_some(); let stanza_ts = attrs .optional_u64("t") .and_then(|t| i64::try_from(t).ok()) @@ -309,6 +311,7 @@ impl Client { }, timestamp: user_ts, r#type: effective_type, + offline, }; self.core.event_bus.dispatch(Event::Receipt(r)); } @@ -336,6 +339,7 @@ impl Client { }, timestamp: stanza_ts, r#type: receipt_type, + offline, }; if receipt.r#type == ReceiptType::Retry { @@ -1648,6 +1652,61 @@ mod tests { ); } + #[tokio::test] + async fn test_receipt_offline_attr_propagated() { + let (client, collector) = setup_client_with_collector().await; + + // Drained from the offline queue: carries the `offline` attr. + client + .handle_receipt(node_to_arc( + NodeBuilder::new("receipt") + .attr("from", "15551234567@s.whatsapp.net") + .attr("id", "OFFLINE-RCPT") + .attr("offline", "1") + .attr("t", "1700000000") + .build(), + )) + .await; + + // Live delivery: no `offline` attr. + client + .handle_receipt(node_to_arc( + NodeBuilder::new("receipt") + .attr("from", "15551234567@s.whatsapp.net") + .attr("id", "LIVE-RCPT") + .attr("t", "1700000000") + .build(), + )) + .await; + + let events = collector.events(); + let receipts: Vec<_> = events + .iter() + .filter_map(|e| match &**e { + Event::Receipt(r) => Some(r), + _ => None, + }) + .collect(); + + let offline = receipts + .iter() + .find(|r| r.message_ids.iter().any(|id| id == "OFFLINE-RCPT")) + .expect("offline receipt dispatched"); + assert!( + offline.offline, + "receipt with the offline attr sets offline=true" + ); + + let live = receipts + .iter() + .find(|r| r.message_ids.iter().any(|id| id == "LIVE-RCPT")) + .expect("live receipt dispatched"); + assert!( + !live.offline, + "receipt without the offline attr sets offline=false" + ); + } + /// Missing per-user `t`: the fan-out event's timestamp falls back to /// the stanza-level `t` rather than collapsing to epoch zero (which /// was the previous behavior). diff --git a/src/retry.rs b/src/retry.rs index 65a85a851..ec5ff4db2 100644 --- a/src/retry.rs +++ b/src/retry.rs @@ -2511,6 +2511,7 @@ mod tests { message_ids: vec!["MSG001".to_string()], timestamp: wacore::time::now_utc(), r#type: crate::types::presence::ReceiptType::Retry, + offline: false, } } @@ -2626,6 +2627,7 @@ mod tests { message_ids: vec!["MSG001".to_string()], timestamp: wacore::time::now_utc(), r#type: crate::types::presence::ReceiptType::Retry, + offline: false, }; let info = resolve_retry_chat_info(&receipt, &node.as_node_ref(), None, None); diff --git a/wacore/src/types/events.rs b/wacore/src/types/events.rs index 6f6b50912..1a685ebe5 100755 --- a/wacore/src/types/events.rs +++ b/wacore/src/types/events.rs @@ -970,6 +970,10 @@ pub struct Receipt { pub message_ids: Vec, pub timestamp: DateTime, pub r#type: ReceiptType, + /// True when the receipt carried the `offline` attribute, i.e. it was drained + /// from the server's offline queue on reconnect rather than delivered live. + /// Mirrors WA Web `incomingMsgReceiptParser` (`offline: maybeAttrString`). + pub offline: bool, } #[derive(Debug, Clone, Serialize)]