Skip to content
113 changes: 98 additions & 15 deletions src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7278,6 +7278,30 @@ mod tests {
None
}

/// First `<receipt>` on the wire for `id` as `(to, type, recipient)`.
fn find_receipt(
frames: &[bytes::Bytes],
id: &str,
) -> Option<(String, Option<String>, Option<String>)> {
for (i, frame) in frames.iter().enumerate() {
let Some(buf) = decode_frame(i, frame) else {
continue;
};
let Ok(node) = wacore_binary::marshal::unmarshal_ref(&buf[1..]) else {
continue;
};
if node.tag.as_ref() == "receipt"
&& node.get_attr("id").is_some_and(|v| v.as_str() == id)
&& let Some(to) = node.get_attr("to")
{
let typ = node.get_attr("type").map(|v| v.as_str().to_string());
let recipient = node.get_attr("recipient").map(|v| v.as_str().to_string());
return Some((to.as_str().to_string(), typ, recipient));
}
}
None
}

/// Count delivery `<receipt>` (anything but type="retry") on the wire for `id`.
fn delivery_receipts_for(frames: &[bytes::Bytes], id: &str) -> usize {
let mut count = 0;
Expand Down Expand Up @@ -7578,18 +7602,21 @@ mod tests {
);
}

/// Own-account fan-out (is_from_me, non-peer) has its delivery receipt
/// suppressed by `should_send_delivery_receipt`, so it must instead get a
/// transport ack (to = own LID) or the server replays it forever.
/// Own-account self-fanout (is_from_me, non-peer, carries a `recipient`):
/// our own outgoing message echoed back to this device. WA Web
/// (`isMeAccount(author) => SENDER`) and whatsmeow (`IsFromMe => "sender"`)
/// clear it with a `<receipt type="sender" recipient=...>`, NOT a bare
/// transport `<ack>`. The server's offline queue ignores the bare ack and
/// replays the stanza forever (the ~50min disconnect loop).
#[tokio::test]
async fn own_account_message_acked_via_transport_ack() {
async fn own_self_fanout_acked_via_sender_receipt() {
let (client, transport) = capturing_client("own_ack").await;
let own = Arc::new(MessageInfo {
id: "OWN1".to_string(),
source: crate::types::message::MessageSource {
sender: "236395184570386@lid".parse().expect("sender"),
chat: "156535032389744@lid".parse().expect("chat"),
recipient: Some("156535032389744@lid".parse().expect("recipient")),
sender: "100000000000001@lid".parse().expect("sender"),
chat: "300000000000003@lid".parse().expect("chat"),
recipient: Some("300000000000003@lid".parse().expect("recipient")),
is_from_me: true,
..Default::default()
},
Expand All @@ -7599,21 +7626,77 @@ mod tests {

let mut found = None;
for _ in 0..80 {
if let Some(a) = find_message_ack(&transport.sent()) {
found = Some(a);
if let Some(r) = find_receipt(&transport.sent(), "OWN1") {
found = Some(r);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
let (to, _) = found.expect("own-account message must get a transport ack");
let (to, typ, recipient) = found.expect("own self-fanout must get a sender <receipt>");
assert_eq!(
to, "236395184570386@lid",
"ack must be addressed to the own LID"
to, "100000000000001@lid",
"receipt `to` must echo the own LID with its device"
);
assert_eq!(
delivery_receipts_for(&transport.sent(), "OWN1"),
0,
"own non-peer must NOT get a delivery receipt (it's suppressed)"
typ.as_deref(),
Some("sender"),
"own self-fanout receipt must be type=sender"
);
assert_eq!(
recipient.as_deref(),
Some("300000000000003@lid"),
"receipt must echo the fanout recipient"
);
assert!(
find_message_ack(&transport.sent()).is_none(),
"self-fanout must NOT also emit a bare transport <ack> (the server rejects it)"
);
}

/// Regression for the bot self-fanout disconnect loop: our own message to a
/// `@bot` recipient, echoed back as a duplicate/undecryptable stanza, must
/// be cleared with a `<receipt type="sender" recipient=@bot>`. Pre-fix it
/// got a bare `<ack class="message">` which the server ignored, replaying
/// the stanza every reconnect until a ~50min `<stream:error><ack/>` GC
/// force-closed the connection (the exact production symptom).
#[tokio::test]
async fn bot_self_fanout_acked_via_sender_receipt() {
let (client, transport) = capturing_client("bot_self_fanout").await;
let own = Arc::new(MessageInfo {
id: "AC00000000000000000000000000BEEF".to_string(),
source: crate::types::message::MessageSource {
// from = our own LID (the server fans our outgoing bot prompt
// back to this device); chat = the bot (recipient.to_non_ad).
sender: "100000000000001@lid".parse().expect("sender"),
chat: "200000000000002@bot".parse().expect("chat"),
recipient: Some("200000000000002@bot".parse().expect("recipient")),
is_from_me: true,
..Default::default()
},
..Default::default()
});
client.ack_received_message(&own);

let mut found = None;
for _ in 0..80 {
if let Some(r) = find_receipt(&transport.sent(), "AC00000000000000000000000000BEEF") {
found = Some(r);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
let (to, typ, recipient) =
found.expect("bot self-fanout must get a sender <receipt> to drain the offline queue");
assert_eq!(to, "100000000000001@lid", "receipt `to` is the own LID");
assert_eq!(typ.as_deref(), Some("sender"));
assert_eq!(
recipient.as_deref(),
Some("200000000000002@bot"),
"receipt must route to the bot recipient"
);
assert!(
find_message_ack(&transport.sent()).is_none(),
"the bare <ack> that triggered <stream:error><ack/> must no longer be emitted"
);
}

Expand Down
131 changes: 128 additions & 3 deletions src/receipt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,22 @@ use wacore_binary::OwnedNodeRef;
/// JID. Without it the server can't map the ack back to the status owner.
/// `active=false` sends `type="inactive"` (not rendered as ticks), matching
/// whatsmeow's background companion. Peer/status keep their own type/context.
///
/// Self-fanout (`is_from_me` + a `recipient`) gets `type="sender"` + the
/// `recipient`, matching WA Web (`isMeAccount` author => SENDER) and whatsmeow.
/// The server's offline queue only drops a self-fanout on this sender receipt;
/// a bare transport `<ack>` is ignored and the stanza is replayed until a
/// ~50min GC closes the stream.
fn build_delivery_receipt_node(
info: &crate::types::message::MessageInfo,
active: bool,
) -> wacore_binary::Node {
let is_status = info.source.chat.is_status_broadcast();
let is_self_fanout = info.source.is_from_me
&& info.source.recipient.is_some()
&& !info.source.is_group
&& !is_status
&& !info.source.chat.is_newsletter();
// Mirror whatsmeow `buildBaseReceipt` / WA Web `JID(extractJidFromJidWithType)`:
// echo `from` verbatim so the device survives. `chat` strips it via to_non_ad,
// which the LID server rejects for multi-device DMs.
Expand All @@ -38,10 +49,17 @@ fn build_delivery_receipt_node(

if info.category == MessageCategory::Peer {
builder = builder.attr("type", "peer_msg");
} else if is_self_fanout {
builder = builder.attr("type", "sender");
} else if !active && !is_status {
builder = builder.attr("type", "inactive");
}

// Device-stripped recipient (WA Web `USER_JID`) so the server can route it.
if is_self_fanout && let Some(recipient) = &info.source.recipient {
builder = builder.attr("recipient", recipient.to_non_ad());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if info.source.is_group || is_status {
builder = builder.attr("participant", &info.source.sender);
}
Expand Down Expand Up @@ -105,7 +123,16 @@ impl Client {
// (`Send/DeliveryReceiptJob.js` + `Handle/MsgSendReceipt.js` —
// `C = y && isStatusStanzaReceiveEnabled() ? "status" : void 0`).
// The context attribute is added in send_delivery_receipt below.
info.category == MessageCategory::Peer || !info.source.is_from_me
//
// Self-fanout (own message echoed back, carrying a `recipient`) needs a
// sender receipt to drain the offline queue; without it the server
// replays it until a ~50min GC closes the stream. A recipient-less own
// message (self-note) stays skipped. See `build_delivery_receipt_node`.
let is_self_fanout = info.source.is_from_me
&& info.source.recipient.is_some()
&& !info.source.is_group
&& !info.source.chat.is_status_broadcast();
info.category == MessageCategory::Peer || !info.source.is_from_me || is_self_fanout
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
}

pub(crate) async fn handle_receipt(self: &Arc<Self>, node: Arc<OwnedNodeRef>) {
Expand Down Expand Up @@ -433,6 +460,66 @@ mod tests {
assert!(node.attrs.get("type").is_none());
}

#[test]
fn delivery_receipt_for_self_fanout_to_bot_is_sender_with_recipient() {
// Own prompt to a @bot, echoed back: <receipt type="sender" to=ourLID
// recipient=@bot>, `to` preserving the sender's device. Mirrors WA Web
// DeliveryReceiptJob (SENDER + USER_JID(recipient)) and whatsmeow.
let info = MessageInfo {
id: "FANOUT_BOT".to_string(),
source: MessageSource {
sender: "100000000000001:11@lid".parse().expect("sender"),
chat: "200000000000002@bot".parse().expect("chat"),
recipient: Some("200000000000002@bot".parse().expect("recipient")),
is_from_me: true,
is_group: false,
..Default::default()
},
..Default::default()
};
let node = build_delivery_receipt_node(&info, true);
assert_eq!(node.tag, "receipt");
assert_eq!(
node.attrs.get("type").map(|v| v.as_str()).as_deref(),
Some("sender")
);
assert_eq!(
node.attrs.get("to").map(|v| v.as_str()).as_deref(),
Some("100000000000001:11@lid"),
"`to` must preserve the own device or the LID server rejects it"
);
assert_eq!(
node.attrs.get("recipient").map(|v| v.as_str()).as_deref(),
Some("200000000000002@bot")
);
assert!(node.attrs.get("participant").is_none());
assert!(node.attrs.get("context").is_none());
}

#[test]
fn delivery_receipt_for_self_fanout_strips_recipient_device() {
// WA Web's `USER_JID` strips the device from `recipient`; a fanout to a
// multi-device user echoes the non-AD recipient.
let info = MessageInfo {
id: "FANOUT_DEV".to_string(),
source: MessageSource {
sender: "100000000000001:5@lid".parse().expect("sender"),
chat: "300000000000003@lid".parse().expect("chat"),
recipient: Some("300000000000003:7@lid".parse().expect("recipient")),
is_from_me: true,
is_group: false,
..Default::default()
},
..Default::default()
};
let node = build_delivery_receipt_node(&info, true);
assert_eq!(
node.attrs.get("recipient").map(|v| v.as_str()).as_deref(),
Some("300000000000003@lid"),
"recipient device must be stripped (USER_JID semantics)"
);
}

#[test]
fn delivery_receipt_is_inactive_when_not_active() {
let info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
Expand Down Expand Up @@ -785,13 +872,51 @@ mod tests {

#[test]
fn should_send_delivery_receipt_skips_own_dm() {
// Self-sent DM with category=Regular: no receipt (we don't ack our own
// messages). Peer-category self-sync messages are handled below.
// Self-sent message with NO `recipient` (a self-note where from==to):
// not a fanout, so no receipt. Peer-category self-sync and self-fanouts
// (which carry a `recipient`) are handled by the cases below.
let mut info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
info.source.is_from_me = true;
assert!(info.source.recipient.is_none());
assert!(!Client::should_send_delivery_receipt(&info));
}

#[test]
fn should_send_delivery_receipt_allows_self_fanout_to_user() {
// Own outgoing DM to another user, echoed back to this device
// (is_from_me + recipient). WA Web emits a `<receipt type="sender">`.
let mut info = info_with("300000000000003@lid", "100000000000001@lid", false);
info.source.is_from_me = true;
info.source.recipient = Some("300000000000003@lid".parse().expect("recipient"));
assert!(Client::should_send_delivery_receipt(&info));
}

#[test]
fn should_send_delivery_receipt_allows_self_fanout_to_bot() {
// The reported disconnect-loop case: our own prompt to a @bot, echoed
// back. Must get a sender receipt or the server replays it forever.
let mut info = info_with("200000000000002@bot", "100000000000001@lid", false);
info.source.is_from_me = true;
info.source.recipient = Some("200000000000002@bot".parse().expect("recipient"));
assert!(Client::should_send_delivery_receipt(&info));
}

#[test]
fn should_send_delivery_receipt_skips_own_status_and_group_fanout() {
// Regression guard: the self-fanout allowance must NOT leak into our own
// status broadcasts or group messages (WA Web does not send a DM-style
// sender receipt there).
let mut own_status = info_with("status@broadcast", "100000000000001@lid", false);
own_status.source.is_from_me = true;
own_status.source.recipient = Some("100000000000001@lid".parse().expect("recipient"));
assert!(!Client::should_send_delivery_receipt(&own_status));

let mut own_group = info_with("120363021033254949@g.us", "100000000000001@lid", true);
own_group.source.is_from_me = true;
own_group.source.recipient = Some("100000000000001@lid".parse().expect("recipient"));
assert!(!Client::should_send_delivery_receipt(&own_group));
}

#[test]
fn should_send_delivery_receipt_allows_own_peer_msg() {
// Self-synced messages from the primary phone (category=Peer) DO need
Expand Down
45 changes: 37 additions & 8 deletions wacore/src/stanza/receipt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,17 @@ pub fn collect_simple_message_ids(
/// - Messages with an empty ID
/// - Status broadcasts (`status@broadcast`)
/// - Newsletter messages
/// - Own outgoing messages (unless category is `"peer"`, i.e., self-synced)
/// - Own outgoing messages, EXCEPT category `"peer"` (self-synced) and
/// self-fanouts (`is_from_me` with a `recipient`), which need a
/// `<receipt type="sender">`.
///
/// WA Web sends `type="peer_msg"` delivery receipts for self-synced messages
/// (category="peer"). For all other messages, receipts are skipped for our own.
/// WA Web sends `type="peer_msg"` for self-synced and `type="sender"` for
/// own-account fanouts (`isMeAccount(author)`). For all other own messages,
/// receipts are skipped.
///
/// NOTE: the authoritative copy used by the message-dispatch hot path is
/// `crate::client::Client::should_send_delivery_receipt` (in the
/// `whatsapp-rust` crate). Keep the two in sync.
pub fn should_send_delivery_receipt(info: &MessageInfo) -> bool {
if info.id.is_empty()
|| info.source.chat.user == STATUS_BROADCAST_USER
Expand All @@ -113,11 +120,14 @@ pub fn should_send_delivery_receipt(info: &MessageInfo) -> bool {
return false;
}

// WA Web sends type="peer_msg" delivery receipts for self-synced
// messages (category="peer"). These tell the primary phone that
// this companion device received the message.
// For all other messages, skip receipts for our own messages.
info.category == MessageCategory::Peer || !info.source.is_from_me
// status & newsletter already returned false above, so a self-fanout here
// is necessarily a non-group DM to a user/bot.
let is_self_fanout =
info.source.is_from_me && info.source.recipient.is_some() && !info.source.is_group;

// WA Web sends type="peer_msg" for self-synced (category="peer") and
// type="sender" for own-account fanouts. Other own messages are skipped.
info.category == MessageCategory::Peer || !info.source.is_from_me || is_self_fanout
}

#[cfg(test)]
Expand Down Expand Up @@ -202,6 +212,25 @@ mod tests {
assert!(should_send_delivery_receipt(&info));
}

#[test]
fn allow_self_fanout_with_recipient() {
// Own outgoing message echoed back (is_from_me + recipient): needs a
// sender receipt. A recipient-less own message (skip_own_non_peer_*)
// stays skipped. Mirrors the hot-path copy in the whatsapp-rust crate.
let info = MessageInfo {
id: "FANOUT1".to_string(),
source: MessageSource {
chat: "200000000000002@bot".parse().unwrap(),
sender: "100000000000001@lid".parse().unwrap(),
recipient: Some("200000000000002@bot".parse().unwrap()),
is_from_me: true,
..Default::default()
},
..Default::default()
};
assert!(should_send_delivery_receipt(&info));
}

#[test]
fn allow_incoming_dm() {
let info = MessageInfo {
Expand Down
Loading