Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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
14 changes: 9 additions & 5 deletions tests/e2e/tests/retry_dm_multidevice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ use wacore_binary::JidExt as _;
use wacore_binary::node::Node;
use whatsapp_rust::{NodeFilter, SendOptions};

/// A non-empty `<participants>` on a DM retry would mean we regressed to
/// the fanout shape (server rejects with 479 SmaxInvalid).
fn participant_target_count(message_node: &Node) -> usize {
message_node
.get_optional_child("participants")
Expand All @@ -16,9 +18,7 @@ fn participant_target_count(message_node: &Node) -> usize {
}

fn retry_enc_count(message_node: &Node) -> Option<String> {
let participants = message_node.get_optional_child("participants")?;
let target = participants.children()?.first()?;
let enc = target.get_optional_child("enc")?;
let enc = message_node.get_optional_child("enc")?;
enc.attrs().optional_string("count").map(|s| s.into_owned())
}

Expand Down Expand Up @@ -100,8 +100,12 @@ async fn test_dm_retry_recovers_after_session_deletion() -> anyhow::Result<()> {
.map_err(|_| anyhow::anyhow!("retry DM send waiter was canceled"))?;
assert_eq!(
participant_target_count(&retry_node),
1,
"Retry resend should target exactly one device"
0,
"DM retry resend must not use the <participants><to> fanout shape"
);
assert!(
retry_node.get_optional_child("enc").is_some(),
"Retry resend should carry an <enc> directly under <message>"
);
assert_eq!(
retry_enc_count(&retry_node).as_deref(),
Expand Down
108 changes: 70 additions & 38 deletions wacore/src/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -994,14 +994,16 @@ where
Ok(stanza)
}

/// Pairwise-encrypted retry stanza for a single DM recipient device.
/// WA Web retries target only the failing device, not a full DM fanout.
/// Mirrors `WAWebSendMsgCreateDeviceStanza.createUserDeviceMsgStanza`:
/// `<enc>` directly under `<message>`, `recipient` carries the original
/// destination. The fanout shape (`<participants><to>`) is server-rejected
/// with 479 (SmaxInvalid) on retries.
#[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 +1024,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 +1048,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 +2904,60 @@ mod tests {
assert!(n.get_optional_child("device-identity").is_none());
}

/// Pins the WAWebSendMsgCreateDeviceStanza retry shape: `<enc>`
/// directly under `<message>` plus a `recipient` attribute.
/// Pre-fix this regressed to the fanout shape and the server
/// rejected every retry with 479.
#[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 +2974,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 +2986,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 +3001,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 +3010,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 +3022,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 +3133,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