Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 36 additions & 8 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1853,12 +1853,18 @@ impl Client {
}
}

/// Determine if a node should be acknowledged with <ack/>.
/// Per WA Web (`Handle/MsgSendReceipt.js`), only newsletter `<message>`
/// gets `<ack class="message">` on the success path; DM/group use
/// `<receipt>`. Failure paths (retry/backfill/nack) emit `<ack>` from
/// their dedicated handlers, not via this gate.
///
/// Newsletter messages need `<ack class="message">` (per
/// `OutMessageDeliverCommonAckMixin`); regular DM/group send a
/// `<receipt>` instead. Status broadcasts also need the ack until
/// `send_delivery_receipt` stops skipping them.
/// status@broadcast is included as a fallback: drop paths in
/// `process_group_enc_batch` (expired status, missing sender key, generic
/// decrypt error) intentionally skip the delivery receipt to avoid
/// inflating the server-side offline counter for messages we'll never
/// process. Without the transport `<ack>` from this gate, the server
/// would redeliver indefinitely. WA Web emits `<receipt context="status">`
/// in the success path on top of this; the duplicate is tolerated.
fn should_ack(&self, node: &wacore_binary::NodeRef<'_>) -> bool {
let tag = node.tag.as_ref();
if node.get_attr("id").is_none() {
Expand Down Expand Up @@ -2144,6 +2150,25 @@ impl Client {
.await;
}
}

// WA Web bumps `lc` after each successful auth (Start/Backend.js
// listener on `onOpenSocketStream`). The Comms `onConnect` handler
// gates the trigger on `isRegistered()`, so the bump only happens
// for already-paired logins — never during the pairing XX
// handshake. We mirror that by skipping when `device.pn` is None.
let already_paired = client_clone
.persistence_manager
.get_device_snapshot()
.await
.pn
.is_some();
if already_paired {
client_clone
.persistence_manager
.process_command(DeviceCommand::IncrementLoginCounter)
.await;
}

// Macro to check if this task is still valid (connection hasn't been replaced)
macro_rules! check_generation {
() => {
Expand Down Expand Up @@ -4133,15 +4158,18 @@ mod tests {
"should_ack must return TRUE for newsletter <message>."
);

// send_delivery_receipt skips status@broadcast, so the ack stays
// as the server-level acknowledgement until receipts cover it.
// status@broadcast gets the transport <ack> as a fallback so that
// drop paths in process_group_enc_batch (expired status, missing
// sender key, decrypt error) don't leave the server retransmitting.
// The success path also emits <receipt context="status">; the
// duplicate is tolerated.
let mut status_attrs = Attrs::new();
status_attrs.insert("from".to_string(), "status@broadcast".to_string());
status_attrs.insert("id".to_string(), "MSG-STATUS-1".to_string());
let status_message = Node::new("message", status_attrs, None);
assert!(
client.should_ack(&status_message.as_node_ref()),
"should_ack must return TRUE for status@broadcast <message> until receipts cover it."
"should_ack must return TRUE for status@broadcast <message> (fallback for drop paths)."
);

info!(
Expand Down
11 changes: 5 additions & 6 deletions src/flush_scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,12 +227,11 @@ mod tests {
);
}

/// Regression for the field report from baileyrs (PR #576): if the outer
/// wrapping future is dropped *before its first poll* (e.g. the executor
/// is shutting down), the guard must still be dropped so the counter
/// decrements. Before the fix (guard constructed INSIDE the async body),
/// this would leak the counter and cause `flush()` to wait its full
/// timeout on every disconnect.
/// If the outer wrapping future is dropped *before its first poll* (e.g.
/// the executor is shutting down), the guard must still be dropped so the
/// counter decrements. Before the fix (guard constructed INSIDE the async
/// body), this would leak the counter and cause `flush()` to wait its
/// full timeout on every disconnect.
#[tokio::test]
async fn decrement_runs_when_future_is_dropped_before_first_poll() {
let scope = Arc::new(FlushScope::new());
Expand Down
206 changes: 177 additions & 29 deletions src/receipt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,48 @@ use wacore_binary::{Jid, JidExt as _};

use wacore_binary::OwnedNodeRef;

/// Pure builder for the delivery `<receipt>` node. Extracted so unit tests
/// can assert wire shape without spinning a transport. Mirrors WA Web's
/// `Send/DeliveryReceiptJob.js` — the participant gate there is
/// `(t.isGroup() || t.isBroadcast()) && r ? DEVICE_JID(r) : DROP_ATTR`, so
/// status broadcasts (isBroadcast = true) also carry the original poster's
/// JID. Without it the server can't map the ack back to the status owner.
fn build_delivery_receipt_node(info: &crate::types::message::MessageInfo) -> wacore_binary::Node {
let mut builder = NodeBuilder::new("receipt")
.attr("id", &info.id)
.attr("to", &info.source.chat);

if info.category == MessageCategory::Peer {
builder = builder.attr("type", "peer_msg");
}

let is_status = info.source.chat.is_status_broadcast();
if info.source.is_group || is_status {
builder = builder.attr("participant", &info.source.sender);
}

if is_status {
builder = builder.attr("context", "status");
}

builder.build()
}

impl Client {
fn should_send_delivery_receipt(info: &crate::types::message::MessageInfo) -> bool {
use wacore_binary::STATUS_BROADCAST_USER;

if info.id.is_empty()
|| info.source.chat.user == STATUS_BROADCAST_USER
|| info.source.chat.is_newsletter()
{
if info.id.is_empty() || info.source.chat.is_newsletter() {
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.
//
// status@broadcast: WA Web sends `<receipt context="status">`
// (`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
}

Expand Down Expand Up @@ -175,34 +202,23 @@ impl Client {

/// Sends a delivery receipt to the sender of a message.
///
/// This function handles:
/// - Direct messages (DMs) - sends receipt to the sender's JID.
/// - Group messages - sends receipt to the group JID with the sender as a participant.
/// - Peer device messages (category="peer") - sends `type="peer_msg"` receipt to
/// acknowledge self-synced messages from the primary phone.
/// - It correctly skips sending receipts for status broadcasts, newsletters,
/// or messages without an ID.
/// Eligibility lives in [`Self::should_send_delivery_receipt`]; the wire
/// shape is assembled by [`build_delivery_receipt_node`]. Coverage:
///
/// - Direct messages (DMs) — `<receipt>` to the sender's JID.
/// - Group messages — `<receipt participant=...>` to the group JID.
/// - Peer device messages (`category="peer"`) — `<receipt type="peer_msg">`
/// to acknowledge self-synced messages from the primary phone.
/// - Status broadcasts — `<receipt context="status">` (WA Web's
/// `Send/DeliveryReceiptJob.js`); these are NOT skipped anymore.
/// - Newsletters and messages without an ID are skipped (newsletters are
/// handled by the ack gate, not here).
pub(crate) async fn send_delivery_receipt(&self, info: &crate::types::message::MessageInfo) {
if !Self::should_send_delivery_receipt(info) {
return;
}

let mut builder = NodeBuilder::new("receipt")
.attr("id", &info.id)
.attr("to", &info.source.chat);

// WA Web: peer device messages (category="peer") use type="peer_msg".
// Normal delivery receipts omit the type attribute (DROP_ATTR).
if info.category == MessageCategory::Peer {
builder = builder.attr("type", "peer_msg");
}

// For group messages, the 'participant' attribute is required to identify the sender.
if info.source.is_group {
builder = builder.attr("participant", &info.source.sender);
}

let receipt_node = builder.build();
let receipt_node = build_delivery_receipt_node(info);

debug!(target: "Client/Receipt", "Sending {} receipt for message {} to {}",
if info.category == MessageCategory::Peer { "peer_msg" } else { "delivery" },
Expand Down Expand Up @@ -270,6 +286,138 @@ mod tests {
crate::test_utils::node_to_owned_ref(&node)
}

fn info_with(chat: &str, sender: &str, is_group: bool) -> MessageInfo {
MessageInfo {
id: "MID".to_string(),
source: MessageSource {
chat: chat.parse().expect("test chat JID"),
sender: sender.parse().expect("test sender JID"),
is_from_me: false,
is_group,
..Default::default()
},
..Default::default()
}
}

#[test]
fn delivery_receipt_for_status_broadcast_carries_context_status_and_participant() {
// WA Web's gate is `(isGroup || isBroadcast) && participant` for the
// participant attr, and `isStatus && gating` for context — see
// `Send/DeliveryReceiptJob.js`. Status broadcasts must carry BOTH so
// the server can map the ack back to the status owner.
let info = info_with("status@broadcast", "12345@s.whatsapp.net", false);
let node = build_delivery_receipt_node(&info);
assert_eq!(node.tag, "receipt");
assert_eq!(
node.attrs.get("context").map(|v| v.as_str()).as_deref(),
Some("status")
);
assert_eq!(
node.attrs.get("participant").map(|v| v.as_str()).as_deref(),
Some("12345@s.whatsapp.net")
);
}

#[test]
fn delivery_receipt_for_dm_has_no_context_no_participant() {
let info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
let node = build_delivery_receipt_node(&info);
assert!(node.attrs.get("context").is_none());
assert!(node.attrs.get("participant").is_none());
assert!(node.attrs.get("type").is_none());
}

#[test]
fn delivery_receipt_for_group_carries_participant() {
let info = info_with(
"120363021033254949@g.us",
"15551234567@s.whatsapp.net",
true,
);
let node = build_delivery_receipt_node(&info);
assert_eq!(
node.attrs.get("participant").map(|v| v.as_str()).as_deref(),
Some("15551234567@s.whatsapp.net")
);
assert!(node.attrs.get("context").is_none());
}

#[test]
fn should_send_delivery_receipt_allows_status_broadcast() {
let info = info_with("status@broadcast", "12345@s.whatsapp.net", false);
assert!(Client::should_send_delivery_receipt(&info));
}

#[test]
fn delivery_receipt_for_peer_dm_carries_type_peer_msg() {
// category=Peer + DM (self device sync) → type="peer_msg", no
// participant, no context. Matches WA Web's DROP_ATTR gating.
let mut info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
info.category = MessageCategory::Peer;
let node = build_delivery_receipt_node(&info);
assert_eq!(
node.attrs.get("type").map(|v| v.as_str()).as_deref(),
Some("peer_msg")
);
assert!(node.attrs.get("participant").is_none());
assert!(node.attrs.get("context").is_none());
}

#[test]
fn delivery_receipt_for_status_broadcast_keeps_participant_even_with_peer_type() {
// Defensive: if a status broadcast ever surfaces with category=Peer,
// the participant attr must still be there — server identifies the
// status owner from it regardless of the peer_msg type.
let mut info = info_with("status@broadcast", "12345@s.whatsapp.net", false);
info.category = MessageCategory::Peer;
let node = build_delivery_receipt_node(&info);
assert_eq!(
node.attrs.get("participant").map(|v| v.as_str()).as_deref(),
Some("12345@s.whatsapp.net")
);
assert_eq!(
node.attrs.get("context").map(|v| v.as_str()).as_deref(),
Some("status")
);
}

#[test]
fn should_send_delivery_receipt_skips_newsletter() {
let info = info_with(
"120363298765432100@newsletter",
"120363298765432100@newsletter",
false,
);
assert!(!Client::should_send_delivery_receipt(&info));
}

#[test]
fn should_send_delivery_receipt_skips_empty_id() {
let mut info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
info.id = String::new();
assert!(!Client::should_send_delivery_receipt(&info));
}

#[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.
let mut info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
info.source.is_from_me = true;
assert!(!Client::should_send_delivery_receipt(&info));
}

#[test]
fn should_send_delivery_receipt_allows_own_peer_msg() {
// Self-synced messages from the primary phone (category=Peer) DO need
// a receipt with type="peer_msg", per the WA Web `OUR_OWN_DEVICE` ack.
let mut info = info_with("12345@s.whatsapp.net", "12345@s.whatsapp.net", false);
info.source.is_from_me = true;
info.category = MessageCategory::Peer;
assert!(Client::should_send_delivery_receipt(&info));
}

#[tokio::test]
async fn test_send_delivery_receipt_dm() {
let backend = crate::test_utils::create_test_backend().await;
Expand Down
10 changes: 4 additions & 6 deletions src/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,8 @@ fn meta_node(key: &'static str, value: &'static str) -> Node {
}

/// Offset subtracted from the current unix timestamp to produce the
/// `privacy_mode_ts` attr value on a `<biz>` stanza. The value is the one
/// the upstream Baileys reproducer emits (Issue oxidezap/baileyrs#7) and is
/// confirmed to work against the live WhatsApp servers.
/// `privacy_mode_ts` attr value on a `<biz>` stanza. Empirically confirmed
/// against live WhatsApp servers.
const BIZ_PRIVACY_MODE_TS_OFFSET: u64 = 77_980_457;

enum BizCategory<'a> {
Expand Down Expand Up @@ -233,9 +232,8 @@ fn extract_interactive_message(msg: &wa::Message) -> Option<&wa::message::Intera
/// Assemble the `extra_stanza_nodes` vector for a non-newsletter send.
///
/// Order: `inferred_meta`, optional `<bot biz_bot="1"/>` (DM only), `<biz>`,
/// then any user-provided extra nodes. Matches the upstream Baileys
/// reproducer (Issue oxidezap/baileyrs#7). Pure so the caller stays trivial
/// and the assembly logic is unit-testable.
/// then any user-provided extra nodes. Pure so the caller stays trivial and
/// the assembly logic is unit-testable.
fn build_extra_stanza_nodes(
to: &Jid,
inferred_meta: Option<Node>,
Expand Down
12 changes: 7 additions & 5 deletions tests/discrepancy_pocs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,19 @@ fn regression_a4_login_payload_passive_is_configurable() {
assert_eq!(device.get_client_payload().passive, Some(true));
}

// A5. UserAgent: phone_id is UUID v4, locale country is ISO-3166-1 alpha-2.
// A5. UserAgent: phone_id is omitted by default (WA Web parity, see
// Client/Payload.js), locale country is ISO-3166-1 alpha-2.

#[test]
fn regression_a5_useragent_phone_id_is_uuid_v4_by_default() {
fn regression_a5_useragent_phone_id_is_omitted_by_default() {
let mut device = Device::new();
device.pn = Some("5511999999999@s.whatsapp.net".parse().unwrap());

let user_agent = device.get_client_payload().user_agent.unwrap();
let phone_id = user_agent.phone_id.expect("phone_id must be populated");
let parsed = uuid::Uuid::parse_str(&phone_id).expect("phone_id must be a valid UUID");
assert_eq!(parsed.get_version(), Some(uuid::Version::Random));
assert!(
user_agent.phone_id.is_none(),
"phone_id must stay unset on the wire (WA Web never assigns UserAgent.phoneId)"
);
}

#[test]
Expand Down
Loading
Loading