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)]