Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
cda07ef
fix(send): align DM retry stanza with WA Web (479 SmaxInvalid)
jlucaso1 May 19, 2026
e02fd2a
fix: CI feedback — drop duplicated #[allow] and trim retry shape comm…
jlucaso1 May 19, 2026
56ddef2
test(e2e): ignore retry_dm_multidevice — mock server doesn't route the
jlucaso1 May 19, 2026
62dd6f7
fix(retry): forward receipt's recipient verbatim (WA Web parity)
jlucaso1 May 19, 2026
c31f06d
fix(retry): force session recreate on no-keys retry receipts (whatsme…
jlucaso1 May 20, 2026
d56323a
review: harden retry tests + recreate-on-error semantics
jlucaso1 May 20, 2026
35f94cd
test(retry): pin recipient verbatim forwarding from receipt attr
jlucaso1 May 20, 2026
d6b4612
fix(pdo): address peer messages to LID when LID-migrated (WA Web parity)
jlucaso1 May 20, 2026
47db358
Revert "fix(pdo): address peer messages to LID when LID-migrated (WA …
jlucaso1 May 20, 2026
712f5e4
fix(lid-migration): PN session wins on conflict (whatsmeow parity)
jlucaso1 May 20, 2026
bf691f5
fix(libsignal): transactional decrypt — failed MAC must not advance c…
jlucaso1 May 20, 2026
53c98e0
fix(message): migrate PN session on inbound BadMac/InvalidMessage
jlucaso1 May 20, 2026
234b922
fix(pdo): address peer messages to LID when LID-migrated (whatsmeow p…
jlucaso1 May 20, 2026
8a54365
fix(send): include <meta>+<device-identity> on peer pkmsg (whatsmeow …
jlucaso1 May 20, 2026
ac0d1c1
test(send): regression tests for peer pkmsg stanza layout
jlucaso1 May 20, 2026
27be97b
review: address PR #635 comments + fix CI
jlucaso1 May 20, 2026
5a70089
fix(send): refuse to ship peer pkmsg without <device-identity>
jlucaso1 May 20, 2026
0a59219
perf(libsignal): snapshot only mutable decrypt fields + scrub PII
jlucaso1 May 20, 2026
676e07c
review: address Codex+CodeRabbit findings
jlucaso1 May 20, 2026
7ad35e9
review: serialize migration with session_locks + fix throttle expiry
jlucaso1 May 20, 2026
c643592
test(retry): exercise throttle-expiry branch via injectable clock
jlucaso1 May 20, 2026
39f299f
fix(lid-migration): skip re-acquiring LID lock that decrypt caller holds
jlucaso1 May 20, 2026
668c385
refactor(lid-migration): lock dance in the function, not its API
jlucaso1 May 20, 2026
c5f9683
review: address all Codex devil's-advocate findings
jlucaso1 May 20, 2026
24c6057
fix(send): restore checked-out session in pkmsg pre-flight (CodeRabbi…
jlucaso1 May 20, 2026
c3edc17
review: conservative pre-flight + dedup duplicate phone batches (Code…
jlucaso1 May 20, 2026
42a4ec2
fix(send): close pkmsg pre-flight gap in prepare_group_retry_stanza
jlucaso1 May 21, 2026
83708e7
nit: fix step numbering + doc-comment on inner test helper
jlucaso1 May 21, 2026
b12c144
feat(example): auto-pair benchmark example with mock server
jlucaso1 May 21, 2026
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
7 changes: 6 additions & 1 deletion src/retry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,11 +469,16 @@ impl Client {

let edit_attr =
wacore::types::message::EditAttribute::infer_from_message(&original_msg);
// For DM retries WA Web sets `recipient` to the original message's
// recipient (= `to` for the resend), matching
// WAWebSendMsgCreateDeviceStanza's `recipient: USER_JID(g)`.
// `info.chat` is the resolved original chat target (PN/LID-normalized).
let recipient_jid = info.chat.clone();
let stanza = wacore::send::prepare_dm_retry_stanza(
&mut store_adapter.session_store,
&mut store_adapter.identity_store,
info.original_from,
info.requester,
recipient_jid,
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
resolved_jid.clone(),
&original_msg,
message_id,
Expand Down
115 changes: 79 additions & 36 deletions wacore/src/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -997,11 +997,19 @@ where
/// Pairwise-encrypted retry stanza for a single DM recipient device.
/// WA Web retries target only the failing device, not a full DM fanout.
#[allow(clippy::too_many_arguments)]
/// Single-device retry resend for a DM, mirroring
/// `WAWebSendMsgCreateDeviceStanza.createUserDeviceMsgStanza`. The `<enc>`
/// is a direct child of `<message>` (no `<participants>` fanout wrapper),
/// `recipient` carries the original message's destination, and
/// `decrypt-fail="hide"` is set for the same message kinds that hide it on

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep new comments concise and why-focused

This newly added block violates the repository’s documented comment convention in AGENTS.md (“dont be so verbose, also only explain why, not what”), which is treated as a required review guideline in this project. Keeping this level of narrative detail in code comments makes maintenance harder and sets a conflicting precedent for future changes; please trim it to a short rationale-only note.

Useful? React with 👍 / 👎.

/// the original send. The previous fanout-shaped output triggered server
/// 479 (SmaxInvalid) on every self-DM retry in prod (2026-05-19 log).
#[allow(clippy::too_many_arguments)]
pub async fn prepare_dm_retry_stanza<S, I>(
session_store: &mut S,
identity_store: &mut I,
to_jid: Jid,
requester_jid: Jid,
recipient_jid: Jid,
encryption_jid: Jid,
message: &wa::Message,
message_id: String,
Expand All @@ -1022,26 +1030,20 @@ where
let (enc_type, is_prekey, serialized) = extract_ciphertext(encrypted)
.ok_or_else(|| anyhow!("Unexpected encryption message type for DM retry"))?;

let hide_decrypt_fail = should_hide_decrypt_fail_for_send(edit.as_ref(), message);
let mut enc_builder = NodeBuilder::new("enc")
.attr("v", stanza::ENC_VERSION)
.attr("type", enc_type)
.attr("count", retry_count);
if let Some(mt) = media_type_from_message(message) {
enc_builder = enc_builder.attr("mediatype", mt);
}
if hide_decrypt_fail {
enc_builder = enc_builder.attr("decrypt-fail", "hide");
}
let enc_node = enc_builder.bytes(serialized).build();

let participant_node = NodeBuilder::new("to")
.attr("jid", requester_jid)
.children([enc_node])
.build();

let mut children = vec![
NodeBuilder::new("participants")
.children([participant_node])
.build(),
];

let mut children = vec![enc_node];
if is_prekey && let Some(acc) = account {
children.push(
NodeBuilder::new("device-identity")
Expand All @@ -1052,6 +1054,7 @@ where

let mut stanza_builder = NodeBuilder::new("message")
.attr("to", to_jid)
.attr("recipient", recipient_jid)
.attr("id", message_id)
.attr("type", stanza_type_from_message(message));

Expand Down Expand Up @@ -2907,18 +2910,65 @@ mod tests {
assert!(n.get_optional_child("device-identity").is_none());
}

/// Regression for the prod 479 (SmaxInvalid) reported on the
/// 2026-05-19 self-DM run: the retry resend wrapped the `<enc>`
/// inside `<participants><to>` (the fanout shape) and the server
/// rejected every retry. WAWebSendMsgCreateDeviceStanza's
/// `createUserDeviceMsgStanza` puts the `<enc>` directly under
/// `<message>` and always sets the `recipient` attribute. This
/// test pins both invariants — it fails before the format fix
/// because today's `prepare_dm_retry_stanza` emits the fanout
/// shape.
#[tokio::test]
async fn dm_retry_emits_enc_directly_under_message_with_recipient() {
let (mut ss, mut is, jid) = setup_session().await;
let to: Jid = "236395184570386@lid".parse().unwrap();
let requester: Jid = jid.to_string().parse().unwrap();
let n = prepare_dm_retry_stanza(
&mut ss,
&mut is,
to.clone(),
requester.clone(),
requester,
&wa::Message::default(),
"dm-retry-format-1".into(),
1,
None,
None,
)
.await
.unwrap();

assert_eq!(n.tag, "message");
// <enc> is a direct child — no <participants> wrapper.
assert!(
n.get_optional_child("participants").is_none(),
"DM retry must not wrap <enc> in <participants> \
(matches WAWebSendMsgCreateDeviceStanza)"
);
assert!(
n.get_optional_child("enc").is_some(),
"<enc> must be a direct child of <message>"
);
// `recipient` attribute must be present so the server routes
// correctly (mirrors WA Web's `recipient: USER_JID(g)`).
assert!(
n.attrs().optional_string("recipient").is_some(),
"DM retry must carry the `recipient` attribute"
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[tokio::test]
async fn dm_retry_pkmsg_targets_single_device() {
let (mut ss, mut is, jid) = setup_session().await;
let to: Jid = "559922223333@s.whatsapp.net".parse().unwrap();
let requester: Jid = jid.to_string().parse().unwrap();
let encryption = requester.clone();
let encryption = jid.clone();

let n = prepare_dm_retry_stanza(
&mut ss,
&mut is,
to.clone(),
requester.clone(),
to.clone(),
encryption,
&wa::Message::default(),
"dm-retry-1".into(),
Expand All @@ -2935,6 +2985,10 @@ mod tests {
attrs.optional_string("to").unwrap().as_ref(),
to.to_string()
);
assert_eq!(
attrs.optional_string("recipient").unwrap().as_ref(),
to.to_string()
);
assert_eq!(attrs.optional_string("id").unwrap().as_ref(), "dm-retry-1");
assert_eq!(
attrs.optional_string("type").unwrap().as_ref(),
Expand All @@ -2943,16 +2997,9 @@ mod tests {
assert!(attrs.optional_string("participant").is_none());
assert!(attrs.optional_string("addressing_mode").is_none());

let participants = n.get_optional_child("participants").unwrap();
let targets = participants.children().unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].tag, "to");
assert_eq!(
targets[0].attrs().optional_string("jid").unwrap().as_ref(),
requester.to_string()
);

let enc = targets[0].get_optional_child("enc").unwrap();
// `<enc>` is a direct child of `<message>` (no `<participants>` wrapper).
assert!(n.get_optional_child("participants").is_none());
let enc = n.get_optional_child("enc").unwrap();
let mut enc_attrs = enc.attrs();
assert_eq!(
enc_attrs.optional_string("type").unwrap().as_ref(),
Expand All @@ -2965,7 +3012,7 @@ mod tests {
#[tokio::test]
async fn dm_retry_pkmsg_with_account_has_device_identity() {
let (mut ss, mut is, jid) = setup_session().await;
let requester: Jid = jid.to_string().parse().unwrap();
let to: Jid = "559922223333@s.whatsapp.net".parse().unwrap();
let acc = wa::AdvSignedDeviceIdentity {
details: Some(b"t".to_vec()),
..Default::default()
Expand All @@ -2974,9 +3021,9 @@ mod tests {
let n = prepare_dm_retry_stanza(
&mut ss,
&mut is,
"559922223333@s.whatsapp.net".parse().unwrap(),
requester.clone(),
requester,
to.clone(),
to,
jid,
&wa::Message::default(),
"dm-retry-2".into(),
2,
Expand All @@ -2986,10 +3033,7 @@ mod tests {
.await
.unwrap();

let participants = n.get_optional_child("participants").unwrap();
let enc = participants.children().unwrap()[0]
.get_optional_child("enc")
.unwrap();
let enc = n.get_optional_child("enc").unwrap();
assert_eq!(
enc.attrs().optional_string("type").unwrap().as_ref(),
stanza::ENC_TYPE_PKMSG
Expand Down Expand Up @@ -3100,13 +3144,12 @@ mod tests {
async fn dm_retry_preserves_edit_attribute() {
let (mut ss, mut is, jid) = setup_session().await;
let to: Jid = "559922223333@s.whatsapp.net".parse().unwrap();
let requester: Jid = jid.to_string().parse().unwrap();
let n = prepare_dm_retry_stanza(
&mut ss,
&mut is,
to.clone(),
to,
requester.clone(),
requester,
jid,
&wa::Message::default(),
"edit-1".into(),
1,
Expand Down
Loading