Addresses bot review on commit 4bdcd5769d:
1) [Major — CodeRabbit + Copilot] `injectE2ESession` was the last
session-mutating path still locking on raw `jid` while writing through
`storeSession()` (which canonicalizes via `resolveLIDSignalAddress`). Same
PN/LID lock-vs-storage drift as the original bug — fixed by computing
`wireAddr` once and locking on it (same pattern as encrypt/decrypt).
2) [Minor — Copilot] `resolveLIDSignalAddress` docstring described the input
as `userDomain.device` but the actual format from
`jidToSignalProtocolAddress` is `signalUser.device`, where `signalUser` is
either the bare user (WhatsApp domain) or `user_domainType` for LID/hosted.
Updated to match.
3) [Minor — Copilot] `Missing device ID` error fired when the user portion
(before the `.`) was empty, which was misleading. Split into two specific
errors and added validation for the empty-device-portion case (e.g.
trailing `.`). Each now points at the actual missing part.
4) [Doc — CodeRabbit residual race concern] Added a paragraph explaining the
theoretical race between our `wireAddr` resolution and storage's internal
re-resolution if `lidMapping` mutates between the two awaits. In production
this is mitigated by the `migrateSession()` call upstream in
`messages-recv.ts` running synchronously BEFORE `decryptMessage`, so the
mapping is populated by the time the lock is acquired. Eliminating the
residual entirely requires storage to skip re-resolution — bigger refactor
reserved for a follow-up if the residual ever bites in prod.
5) [Out of scope — Copilot PR-metadata staleness] PR title still says "raw
JID" from the original commit. Will be retitled on the GitHub side after
this lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bots (Codex P1, Copilot, CodeRabbit) flagged a real defect in the previous
commit (79844a9d29): locking on the raw `jid` doesn't actually serialize
concurrent ops on the same session, because `signalStorage.loadSession`/
`storeSession` internally call `resolveLIDSignalAddress(id)` which canonicalizes
PN→LID before hitting `keys.get/set('session', ...)`. So:
- Op A arrives as PN → lock(PN_addr) → storage rewrites to LID_addr
- Op B arrives as LID → lock(LID_addr) → storage stays at LID_addr
- Two locks, SAME session record → ratchet interleaving → Bad MAC.
This was the same drift the original `canonicalJid` was trying to prevent —
just expressed at a different layer. Pedro's snapshot doesn't trip it because
in Feb/6 there were no PN→LID mappings populated yet (WhatsApp hadn't fully
rolled out LID-addressed DSM), so storage canonicalization was a no-op and lock
key == storage key by accident.
Fix: extract `resolveLIDSignalAddress` to a top-level helper (parameterized by
`lidMapping`) and use it in BOTH places —
1. `signalStorage.loadSession`/`storeSession` (unchanged behavior, just no
longer a closure).
2. `decryptMessage`/`encryptMessage` to compute the *wire address* and lock
on it. Lock key now matches the storage key exactly.
This eliminates both directions of the PN/LID drift. PN/LID mapping latency on
the hot path is unchanged from the previous commit (one `getLIDForPN` lookup
per decrypt — same as before — but now correctly placed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PRODUCTION BUG: After WhatsApp rolled out LID-addressed DSM (deviceSentMessage),
own messages flooded the inbound pipeline with perpetual `Bad MAC` errors,
delaying delivery to the consumer by ~60s. Pedro's fork (Feb/6 snapshot, 484
commits behind) does NOT have this bug — diff isolated the regression to this
file.
ROOT CAUSE: `decryptMessage` was locking on `canonicalJid` (resolved via
`resolveCanonicalJid`, which awaits `lidMapping.getLIDForPN`) but the
`SessionCipher` is constructed from `jidToSignalProtocolAddress(jid)` — the
RAW jid. So:
1. The mutex lock key (canonical LID) and the session-storage key (raw
JID/PN-derived) drift apart. Two concurrent decrypts for the same
logical session — one arriving as PN, one as LID — acquire DIFFERENT
locks and interleave reads/writes on the same Signal session record,
corrupting the ratchet. Symptom: perpetual `Bad MAC Error`.
2. Each decrypt also paid the cost of an extra `await getLIDForPN()` round
trip to the LID store on the hot path — a needless await per message.
`encryptMessage` had the same bug.
FIX: Lock on the raw `jid` (matches what `SessionCipher` actually uses for
storage). PN/LID race protection still happens upstream in `migrateSession`
+ `storeLIDPNMappings`, which run BEFORE decrypt and ensure the session is
already stored under the correct format by the time we acquire the lock.
`resolveCanonicalJid` is now unused — removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(inbound-latency): restore async LID mapping + fire-and-forget tctoken history sync
PRODUCTION ISSUE: Inbound messages from smartphone to ZPRO frontend were
arriving with seconds of delay. Outbound (ZPRO → smartphone) was instant.
Started after PR #386 (tctoken lifecycle) deploy.
ROOT CAUSE: Three compounding factors:
1. The historical fix d73cd28d39 (2026-02-03, "fix inbound latency by making
LID mapping async") was partially reverted the same day by c3fc792351
("hybrid approach") due to a valid race-condition concern with decrypt().
The reversion was over-protective: storeLIDPNMappings does NOT need to be
sync — only migrateSession does. The hybrid kept all 3 awaits sync.
2. PR #386 added `await storeTcTokensFromHistorySync(...)` BEFORE the
`messaging-history.set` emit. Per chunk this drains the event buffer with
2-4 store ops, which compounds when many chunks arrive at once (restart,
QR scan, multi-device login).
3. Each pre-check `await getPNForLID(alt)` / `getLIDForPN(alt)` before
storeLIDPNMappings was redundant — the store has its own LRU cache + dedup.
Combined under production load (multi-instance store contention, post-PR #386
extra ops per send) the per-message hot-path penalty became user-visible delay.
THIS FIX:
#1+#3: messages-recv.ts ~line 2332 — `storeLIDPNMappings` becomes
fire-and-forget, pre-check `getPNForLID/getLIDForPN` removed. `migrateSession`
stays SYNC (REQUIRED for decrypt — see Codex/Copilot review on PR #72 / commit
c3fc792351). normalizeMessageJids has a fast-path that uses key.*Alt directly
without hitting the store, so the just-arrived message normalizes correctly
even before the background store completes.
#2: process-message.ts ~line 451 — `storeTcTokensFromHistorySync` becomes
fire-and-forget. Trade-off: a listener firing an outbound send IMMEDIATELY
after the emit may race the background persistence and hit error 463 on that
specific send. Existing 463 handler in messages-recv.ts triggers
getPrivacyTokens() refetch that auto-recovers in seconds. Net UX is much
better than per-chunk stalls.
INVARIANTS PRESERVED:
- migrateSession remains SYNC — decrypt() depends on it (race condition guard)
- normalizeMessageJids remains SYNC — events need correct JIDs before emit
- messageMutex remains SYNC — per-chat ordering preserved
- All 824 tests still pass
* perf: optimize history sync memory and CPU usage
Aligns with Baileys upstream PR #2333 . Surgical port of
4 micro-optimizations + 2 test files. No collision with InfiniteAPI's
LID/PN, carousel, buttons, Bad MAC or app state sync resilience code.
Changes:
1. WAProto longToString/longToNumber: use native BigInt instead of
Long library division loops. ~4.6x faster per call (237ns -> 52ns).
BigInt.toString() delegates to V8 native C++. Same change applied
to fix-imports.js (template) and index.js (generated artifact) so
future regenerations stay in sync.
2. downloadHistory: stream-pipe createInflate instead of buffering
the full compressed payload then inflating. Cuts RSS peak ~50%
on 50MB history payloads.
3. processHistoryMessage: drop the { ...chat } shallow copy before
pushing to chats[]. The decoded protobuf object is not referenced
after the push.
4. downloadEncryptedContent transform: skip Buffer.concat when
remainingBytes is empty (the common case — chunks usually arrive
AES-aligned). ~19x faster per chunk (78ms -> 4ms over 25k chunks).
Tests:
- bigint-validation.test.ts (NEW): 32 parameterized cases covering
zero, MAX_SAFE_INTEGER boundary, max int64/uint64, negative values,
tricky bit patterns and powers of 2; plus 200-iter fuzz comparing
old Long vs new BigInt implementations for equivalence.
- proto-tojson-long.test.ts: kept the existing fork-only test for
string-in-Long-field graceful handling, appended 4 upstream tests
covering Long->string fast path, large unsigned > MAX_SAFE_INTEGER,
zero/small values and encode/decode roundtrip.
* test(binary): cover reconnection sync skip for both signals
Ports the upstream reconnection-sync-skip test from Baileys PR #2350
and adapts it to cover InfiniteAPI's dual-signal reconnect detection
in chats.ts:
1. accountSyncCounter > 0 (a previous full sync completed)
2. socketSkippedOfflineBuffer (forwarded from socket.ts when
hadStaleRoutingInfo is true, e.g. routingInfo discarded on start)
Both are required because they cover different reconnect scenarios
and the absence of the second branch causes a buffer mismatch where
socket.ts skips the offline buffer while chats.ts still waits in
AwaitingInitialSync — stalling live messages for up to 4 seconds.
Co-Authored-By: Renato Alcara
* perf(messages-recv): early-ignore JIDs before buffer/queue
Aligns with Baileys upstream PR #2352. Moves the shouldIgnoreJid check
out of handleMessage/handleReceipt/handleNotification and into
processNode so ignored stanzas are acked and dropped before entering
ev.buffer(), the offline queue, or the keyed mutexes. Skips the LID->PN
resolution work in handleReceipt for ignored receipts as a side effect.
Diverges from upstream by excluding type='call' from the early-ignore
check — InfiniteAPI's handleCall has never used shouldIgnoreJid, so
keeping calls outside this filter preserves existing behavior for
integrators that filter messages but still want call events.
Carousel, buttons, LID/PN normalization, Bad MAC fix and retry manager
untouched.
Co-Authored-By: Renato Alcara
* feat: add inbound username support and USync username protocol
Aligns with Baileys upstream PR #2480. WhatsApp is rolling out an
inbound username field that maps to the user's LID. This change is
purely additive — it captures and propagates the new optional field
through types, decoders, USync queries, and group/contact events.
Skipped intentionally to preserve InfiniteAPI's LID/PN customization:
- handleGroupNotification in messages-recv.ts (custom LID->PN flow on
groups.upsert / participants.map). Username for these specific
events still flows via process-message's emitParticipantsUpdate
(reads message.key.participantUsername added in decode-wa-message).
Carousel/buttons code (messages.ts, messages-send.ts) untouched.
Co-Authored-By: Renato Alcara
Adds experimental native protocol mode (WAM\x05) as alternative to
web protocol (WA\x06\x03). Configurable via BAILEYS_PROTOCOL env var:
- web (default): standard WhatsApp Web protocol
- native: Android native protocol (may enable view-once media on companions)
When native mode is enabled:
- NOISE_WA_HEADER changes from WA\x06\x03 to WAM\x05
- DICT_VERSION changes from 3 to 5
- WebInfo is omitted from ClientPayload
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add webdPayload to WebInfo during connection with:
- supportsE2EImage/Video/Audio/Document = true
- supportsMediaRetry = true
- features bitmask with view-once capability flag
This may enable the WA server to deliver view-once media content
to Baileys companions instead of just <unavailable> placeholders.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When receiving <unavailable type='view_once'/>, attempt to request
the actual media content from the primary phone via PDO
(Peer Data Operation / PLACEHOLDER_MESSAGE_RESEND).
If the phone responds, the full media (url, mediaKey, directPath)
arrives via messages.upsert, allowing consumers to display the image.
Falls back to placeholder if PDO fails or phone doesn't respond.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The early unavailable handler at line ~2261 was intercepting
view_once stanzas and silently discarding them (sendMessageAck + return)
before the view_once_unavailable_fanout handler could emit them.
Now emits a viewOnceMessage placeholder with isViewOnce=true so
consumers receive the event in messages.upsert.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two issues found during view-once testing on linked devices:
1. msmsg block removal:
The "temporary fix" that discarded all msmsg-type encrypted messages
was also blocking legitimate view-once messages. On linked devices,
view-once arrives as msmsg encryption type. The block was originally
added to prevent crashes from missing messageSecret, but the existing
try/catch in the decrypt path already handles those errors gracefully
by marking the message as a CIPHERTEXT stub.
2. view_once_unavailable_fanout emission:
When a linked device receives a view-once, the WA server sends only
<unavailable type="view_once"/> (no <enc> with actual media content).
Previously this was silently acknowledged without emitting any event,
so consumers (zpro, astra-api, etc.) never knew a view-once arrived.
Now it emits the message as a CIPHERTEXT stub with key.isViewOnce=true
so consumers can display "view-once message received" in their UI.
Co-Authored-By: Renato Alcara