Skip to content

fix(send): align own devices to LID namespace for LID-addressed DMs - #636

Merged
jlucaso1 merged 3 commits into
oxidezap:mainfrom
oonid:main
May 27, 2026
Merged

jlucaso1 merged 3 commits into
oxidezap:mainfrom
oonid:main

Conversation

@oonid

@oonid oonid commented May 21, 2026 •

Copy link
Copy Markdown

Fixes ACK 400 on DMs to LID-addressed recipients.

Root cause: own devices are fetched via the bot's PN JID, so they come back PN addressed (628xxx@s.whatsapp.net). The server rejects a stanza that mixes PN and LID participants, so including a PN own device in a LID stanza gets the whole stanza rejected with ACK error 400.

Fix: when the recipient is LID, convert the remaining own PN devices to their LID equivalents before dedup, so the stanza is uniformly LID addressed. This matches whatsmeow, which switches ownID to LID before GetUserDevices so own devices come back in the right namespace.

@coderabbitai

coderabbitai Bot commented May 21, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds LID-aware device namespace normalization to the 1:1 DM fanout path. It detects whether the recipient is a LID from their bare encryption JID, then conditionally remaps the bot's own device entries in the fanout set from PN-form to LID-form device JIDs to keep device namespaces consistent and avoid server ACK failures.

Changes

DM Fanout LID Device Normalization

Layer / File(s) Summary
Detect LID recipient
src/send.rs
Compute recipient_is_lid boolean derived from the recipient's bare encryption JID to drive namespace-handling decisions downstream.
Remap own devices for LID recipients
src/send.rs
When recipient is detected as LID and the bot's own LID identity is known, convert any remaining own PN device JIDs in the fanout list to the corresponding LID device JIDs (same user as known own-LID, preserving device id).

Possibly related PRs

  • oxidezap/whatsapp-rust#451: Both PRs handle LID device recognition in 1:1 DM fanout; this PR remaps bot PN devices to LID when recipient is LID, while the retrieved PR updates device partitioning to recognize own LID as self.
  • oxidezap/whatsapp-rust#464: Both PRs modify 1:1 DM fanout handling with PN↔LID-aware device JID remapping driven by recipient LID detection in src/send.rs.
  • oxidezap/whatsapp-rust#634: Both PRs modify send_message_impl 1:1 DM fanout self-device namespace handling to fix duplicate device entries and maintain correct JID forms.

🎯 2 (Simple) | ⏱️ ~8 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: aligning own devices to LID namespace for LID-addressed DMs, which directly addresses the ACK 400 issue described in the changeset.
Description check ✅ Passed The description is directly related to the changeset, explaining the root cause (mixing PN and LID namespaces), the specific fix (converting own PN devices to LID equivalents), and the expected outcome (preventing ACK 400 errors).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@oonid

oonid commented May 21, 2026

Copy link
Copy Markdown
Author

Hey @jlucaso1 , would appreciate a review when you have a moment!

This fixes ACK 400 on DM sends to LID-addressed recipients.
The root cause: own_cached is fetched via the bot's PN JID so it always returns PN-format JIDs, but the server requires all participants in a stanza to share the same namespace.
Including a 628xxx@s.whatsapp.net participant in a LID-addressed stanza causes the entire stanza to be rejected.

Fix: after the sender-device retain filter, convert any remaining own PN devices to their LID equivalents when recipient_is_lid.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/device_registry.rs (1)

367-379: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stop cross-joining aliases with namespaces.

This now builds a cartesian product of {lid, pn} × {Lid, Pn}. For a known mapping that means you also delete sessions for impossible JIDs like pn@lid and lid@s.whatsapp.net, which can wipe unrelated sessions once the new ACK-400 path calls this helper.

Suggested fix
     pub(crate) async fn delete_sessions_for_devices(&self, user: &str, device_ids: &[u16]) {
         let lookup = self.resolve_lookup_keys(user).await;
-        let servers = [wacore_binary::Server::Lid, wacore_binary::Server::Pn];
-        for server in servers {
-            for key in lookup.all_keys() {
-                for &device_id in device_ids {
-                    let mut jid = Jid::new(key, server);
-                    jid.device = device_id;
-                    let addr = wacore::types::jid::JidExt::to_protocol_address(&jid);
-                    self.signal_cache.delete_session(&addr).await;
-                }
-            }
-        }
+        let aliases = match &lookup {
+            UserLookupKeys::LidWithPn { lid, pn } | UserLookupKeys::PnWithLid { lid, pn } => {
+                vec![
+                    Jid::new(lid.as_str(), wacore_binary::Server::Lid),
+                    Jid::new(pn.as_str(), wacore_binary::Server::Pn),
+                ]
+            }
+            UserLookupKeys::Unknown { user } => vec![
+                Jid::new(user.as_str(), wacore_binary::Server::Lid),
+                Jid::new(user.as_str(), wacore_binary::Server::Pn),
+            ],
+        };
+        for mut jid in aliases {
+            for &device_id in device_ids {
+                jid.device = device_id;
+                let addr = wacore::types::jid::JidExt::to_protocol_address(&jid);
+                self.signal_cache.delete_session(&addr).await;
+            }
+        }
         self.flush_signal_cache_logged("delete_sessions_for_devices", None)
             .await;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/device_registry.rs` around lines 367 - 379, The current loop in
resolve_lookup_keys -> delete_sessions_for_devices builds a cartesian product by
combining servers = [wacore_binary::Server::Lid, wacore_binary::Server::Pn] with
lookup.all_keys(), producing impossible JIDs (e.g., pn@lid); fix by only pairing
each lookup key with its matching namespace/server instead of iterating all
servers: use the namespace/server information from the lookup key (or a function
on the key) when constructing the Jid (where Jid::new is called) and remove the
outer servers array loop so delete_session is only called for valid JIDs
generated for each key and device_id; update calls around resolve_lookup_keys,
Jid::new, and signal_cache.delete_session accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/send.rs`:
- Around line 1328-1340: The code may still produce a mixed PN/LID participant
list because when recipient_is_lid is true but own_lid (device_snapshot.lid) is
None, the loop that converts PN own devices in all_dm_jids is skipped; update
the logic so we never send mixed namespaces: either (a) resolve/obtain the
sender LID before building all_dm_jids (move or call the resolution that sets
device_snapshot.lid / own_lid earlier), or (b) fail fast when recipient_is_lid
is true and own_lid is None by returning an error (or propagating a clear
failure) instead of continuing; reference the recipient_is_lid check,
own_lid/device_snapshot.lid, own_cached/own_jid, and all_dm_jids so the fix
consistently converts PN own devices to Jid::lid_device or aborts before
sending.

---

Outside diff comments:
In `@src/client/device_registry.rs`:
- Around line 367-379: The current loop in resolve_lookup_keys ->
delete_sessions_for_devices builds a cartesian product by combining servers =
[wacore_binary::Server::Lid, wacore_binary::Server::Pn] with lookup.all_keys(),
producing impossible JIDs (e.g., pn@lid); fix by only pairing each lookup key
with its matching namespace/server instead of iterating all servers: use the
namespace/server information from the lookup key (or a function on the key) when
constructing the Jid (where Jid::new is called) and remove the outer servers
array loop so delete_session is only called for valid JIDs generated for each
key and device_id; update calls around resolve_lookup_keys, Jid::new, and
signal_cache.delete_session accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 71d3519d-f0a8-4c54-b0ec-32c6a716d7f6

📥 Commits

Reviewing files that changed from the base of the PR and between bfc67fb and 7e4087b.

📒 Files selected for processing (2)
  • src/client/device_registry.rs
  • src/send.rs

Comment thread src/send.rs Outdated
@jlucaso1

Copy link
Copy Markdown
Collaborator

Thanks for the contribution.

ACK 400 handler (spawn_phash_validation) When a DM ACK arrives with error="400", clears Signal sessions for own device 0 under both LID and PN namespaces and invalidates the device cache. Does not retry — a blind retry with the same fanout produces an infinite loop. The namespace conversion below is the primary prevention; this handler cleans up residual stale state.

This appears to be hacky, do you have any evidence/log that this is the correct approch? Some whatsmeow or whatsapp web source code?

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/device_registry.rs (1)

252-264: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This public API is more destructive than its name says.

invalidate_device_cache() now sits on the public Client surface, but it also deletes persisted device rows. That’s not “cache invalidation” anymore; downstream callers can trigger a full registry reset by accident. Keep this internal, or rename/re-doc it so the destructive behavior is explicit before you publish it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/device_registry.rs` around lines 252 - 264, The public method
invalidate_device_cache is misleading because it also deletes persisted device
rows (calls persistence_manager.backend().delete_devices) — either make it
non-public/internal or rename and document the destructive behavior;
specifically, change the visibility of invalidate_device_cache (and any public
Client surface exposing it) to private/internal OR rename it to something
explicit like clear_device_registry_persisted (and update its docstring) so
callers understand it not only invalidates device_registry_cache.invalidate but
also removes DB rows via persistence_manager.backend().delete_devices, and
update any call sites or API docs that expose resolve_lookup_keys behavior
accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/client/device_registry.rs`:
- Around line 252-264: The public method invalidate_device_cache is misleading
because it also deletes persisted device rows (calls
persistence_manager.backend().delete_devices) — either make it
non-public/internal or rename and document the destructive behavior;
specifically, change the visibility of invalidate_device_cache (and any public
Client surface exposing it) to private/internal OR rename it to something
explicit like clear_device_registry_persisted (and update its docstring) so
callers understand it not only invalidates device_registry_cache.invalidate but
also removes DB rows via persistence_manager.backend().delete_devices, and
update any call sites or API docs that expose resolve_lookup_keys behavior
accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b16ba9b1-3391-4d25-93ab-15dabe56c1cf

📥 Commits

Reviewing files that changed from the base of the PR and between 7e4087b and 5f75fad.

📒 Files selected for processing (2)
  • src/client/device_registry.rs
  • src/send.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/send.rs`:
- Around line 765-780: The 400-path currently infers a DM from jid causing
status-reaction sends (rewritten into the author JID in send_message_impl) to
trigger DM cleanup; change spawn_phash_validation() to accept an explicit
"real_dm: bool" parameter, update all call sites so only genuine 1:1 DM sends
pass true (status sends/reactions must pass false), and replace the jid-based DM
check in the ACK handling (the block referencing ack_node, jid, message_id, and
client.* calls) to use the new real_dm flag instead of inferring from jid.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 54310530-d4ae-4ee9-aa2d-4c9145de6af8

📥 Commits

Reviewing files that changed from the base of the PR and between 5f75fad and efbd4d0.

📒 Files selected for processing (2)
  • src/client/device_registry.rs
  • src/send.rs

Comment thread src/send.rs Outdated
@oonid

oonid commented May 22, 2026 •

Copy link
Copy Markdown
Author

This appears to be hacky, do you have any evidence/log that this is the correct approch? Some whatsmeow or whatsapp web source code?

Thank you for your time @jlucaso1 .

Fair point, the spawn_phash_validation ACK 400 branch (session clear + cache invalidate) is not backed by whatsmeow or WA Web source code.
I added it as defensive cleanup reasoning that a 400 on a DM likely means the server rejected the participant list, and stale sessions for own device 0 might be contributing. That reasoning is speculative.

Whatsmeow (send.go:445-446) simply wraps the error and returns it to the caller, no session cleanup at all on ACK 400. There is no captured WA Web JS available to cross-reference either.
The part that is consistent with whatsmeow is the primary prevention: ensuring namespace uniformity before building the stanza. Whatsmeow does this at send.go:346 by switching ownID = cli.getOwnLID() so GetUserDevices returns LID-format JIDs for own devices. The Rust equivalent is the ok_or_else fail-fast when own_lid is None, same intent, different mechanism.

I'm happy to drop the ACK 400 session-clearing block if you'd prefer to keep the diff minimal and evidence-based. The phash mismatch handler below it (cache invalidation) is closer to what whatsmeow does (send.go:460-462) and could stand on its own.

oonid added 2 commits May 25, 2026 11:34
…nout exclusion

Three related fixes for ACK 400 on DM sends:

src/client/device_registry.rs — visibility widening (no logic change):
- invalidate_device_cache: pub(crate) → pub
  Required by downstream crates that need to call this directly.
- delete_sessions_for_devices: private → pub(crate)
  Required by the ACK 400 handler below.

src/send.rs — three additions in send_message_impl:

1. Exclude own device 0 from all DM fanouts.
   Device 0 (primary phone) receives sent messages via the sync
   protocol, not direct E2E. Including own device 0 in the fanout
   causes ACK 400 from the server regardless of whether the recipient
   is self or another user. The previous filter only excluded it for
   self-DMs; this filter checks user identity so it applies to every
   outbound DM.

2. LID namespace conversion for own PN devices.
   own_cached is fetched via the bot's PN JID and always returns
   PN-format JIDs. When the recipient is LID-addressed, including a
   PN participant (e.g. 628xxx@s.whatsapp.net) in the stanza causes
   the server to reject the entire stanza with ACK error=400 — the
   server requires all participants to share the same JID namespace.
   Fix: after the sender-device and device-0 retain filters, convert
   any remaining own PN devices to their LID equivalents via
   Jid::lid_device so the stanza is uniformly LID-addressed.

3. ACK 400 defensive handler.
   When a DM ACK arrives with error="400", clears Signal sessions for
   own device 0 and invalidates the device cache. Does not retry — a
   blind retry with the same fanout produces an infinite loop. Fixes
   1 and 2 are the primary prevention; this handler cleans up any
   residual stale state.
Two independent fixes in maybe_include_tc_token:

1. Remove the is_self guard.
   The guard used user-equality to detect own-account DMs and skipped
   token issuance entirely. This was too broad: when the bot's LID
   matched the admin's chat LID (e.g. after a companion re-pair), the
   guard fired for legitimate outbound messages and no TC token was
   attached, causing the server to return 463 indefinitely.
   Fix: remove the guard. Status broadcast and bot JIDs are already
   excluded by the check immediately below; own-account sends do not
   need a separate early return.

2. Default the AB prop to true.
   ab_props is an in-memory-only cache populated by the server's delta
   fetch on connection. On a fresh start the cache is empty, so
   is_enabled_or(PRIVACY_TOKEN_ON_ALL_1_ON_1_MESSAGES, false) returned
   false and no tokens were ever attached until the server pushed the
   prop — which may not happen on every connect cycle.
   Fix: default to true so tokens are attached immediately on startup.
   The AB prop can still disable sending if the server explicitly sets
   the flag to false.
@tmigone

tmigone commented May 26, 2026

Copy link
Copy Markdown

I'm hitting LID <> PN problems with latest from main, this PR fixed it for me.

The LID namespace conversion alone resolves ACK 400 on LID-addressed DMs:
own devices arrive PN-addressed (registry keyed by the bot's PN) and the
server rejects stanzas mixing PN and LID participants. This mirrors
whatsmeow switching ownID to LID before fanout.

Drop the rest as non-WA-Web-faithful:
- own device-0 fanout exclusion: getFanOutList only excludes the current
  sender device, never the primary; excluding it breaks DeviceSentMessage
  sync to the primary phone.
- ACK error=400 session-clear handler: no basis in whatsmeow/WA Web; the
  namespace fix prevents the 400 in the first place.
- tc-token changes: the AB-prop default true diverges from WA Web (default
  is false); the perpetual 463 is a separate ab_props-population issue.
- device_registry visibility widening: only the removed handler needed it.
@jlucaso1

Copy link
Copy Markdown
Collaborator

Thanks for digging into this, your root cause is right. Own devices come back PN addressed and the server rejects a stanza that mixes PN and LID participants, so the LID conversion is the correct fix and it matches whatsmeow switching ownID to LID before fanout.

I pushed a commit trimming the PR down to just that conversion. I dropped the rest after checking against WA Web and whatsmeow:

  • device 0 exclusion: getFanOutList only excludes the current sender device, never the primary. Dropping device 0 stops the DeviceSentMessage from reaching the primary phone, so messages sent from the companion stop syncing there. The LID conversion already makes device 0 a valid participant, so the exclusion is not needed.
  • ACK 400 session clearing: no equivalent in whatsmeow or WA Web, and with the namespace fixed the 400 should not happen in the first place.
  • tc token changes: the AB prop default is false in WA Web, so defaulting to true diverges. The perpetual 463 looks like a separate problem with ab_props starting empty, better handled on its own.

Build, clippy and the unit tests are green. Let me know if you disagree on any of these.

@jlucaso1 jlucaso1 changed the title fix(send): LID namespace conversion and ACK 400 defensive handler fix(send): align own devices to LID namespace for LID-addressed DMs May 27, 2026
@jlucaso1
jlucaso1 merged commit cbcdd2a into oxidezap:main May 27, 2026
10 of 11 checks passed
soyokaze83 pushed a commit to soyokaze83/whatsapp-rust that referenced this pull request Aug 9, 2026
Backport the focused namespace fixes from upstream oxidezap#636 and oxidezap#731. Resolve a mapped peer to LID for the entire DM stanza, align own companion devices to that namespace, correct reporting-token sender identity, and surface otherwise-hidden server nacks.

Add regressions for PN behavior, missing own-LID state, companion alignment, and the complete outgoing LID stanza.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants