- structured-logger.ts header bullet "External system integration via
hooks with circuit breaker" -> updated to describe current
fire-and-forget hook semantics + hookFailures metric counter.
- retry-utils.ts retryConfigs.rsocket comment referenced the
prometheus-metrics -> circuit-breaker.ts circular import chain.
circuit-breaker.ts is gone; trimmed the parenthetical to keep just
the core reasoning (avoid ESM init-order issues via prometheus-metrics).
No code change. Build clean, 35/35 suites, 809/809 tests pass.
PR #393 review (Copilot): the class-level JSDoc still listed "Circuit
breaker protection" as a feature, but the protection was removed in
commit b531b46999 in favour of best-effort single-attempt semantics
(telemetry is non-critical — failures are swallowed and counted in the
hookFailures metric).
Updated the bullet point to describe what actually happens now.
Note: not addressing the breaking-API change comments (removed
enableCircuitBreaker option from UnifiedSessionOptions, removed
circuitBreakers/getCircuitBreakerStats/resetCircuitBreakers from
makeSocket() return, removed circuit_breaker_* Prometheus metrics)
per maintainer policy — this fork has a single consumer (zpro.io),
downstream forks absorb on merge.
Two doc-only fixes from PR #393 review (Copilot):
1. **bounded-retry.ts BoundedRetryOptions.signal**
The previous one-line "if aborted, give up immediately" was technically
correct from the loop's perspective but misleading about what happens
to an in-flight operation. Expanded to describe the three observation
points: sleep wakes immediately, in-flight attempt is forwarded the
per-attempt signal (only truly immediate if the operation itself
observes it), and the next loop iteration throws BoundedRetryAbortedError.
2. **retry-utils.ts module-level JSDoc**
The header still listed "Circuit breaker integration" as a feature.
The circuitBreaker option was removed in commit b531b46999 and the
withRetry/retryable/RetryManager helpers in 0f2402da58. Updated the
header to describe the actual current surface (used for Bad MAC
recovery on decrypt path) and recommend withBoundedRetry for socket
operations.
Build clean, 35/35 suites, 809/809 tests pass.
Note: not addressing the suppressed comment about removed APIs being a
breaking change — per maintainer policy this fork has a single consumer,
downstream forks absorb on merge.
All 5 issues verified as REAL (not false positives) and fixed:
1. **CRITICAL: query() swallows timeouts → bounded-retry treats as success**
(CodeRabbit Critical, line 319 socket.ts)
waitForMessage() returns undefined on timeout instead of throwing.
queryInternal() returns that undefined unchanged. uploadToServer
was `await query(...)` then logging success regardless — a timed-out
upload would update lastUploadTime with no retry triggered.
Fix: validate response shape inside uploadToServer:
if (!result) throw new Boom('Pre-key upload query timed out (no response)', { statusCode: 408 })
Bounded-retry now sees the throw and properly retries.
2. **MAJOR: Outer Promise.race + bounded-retry can run in parallel**
(CodeRabbit Major + Copilot, line 674 socket.ts)
With safety margin (ttlMs=28s vs UPLOAD_TIMEOUT=30s) bounded-retry
should always reach give-up first, but if anything bypasses that
bound the outer race rejects while bounded-retry keeps running.
Fix: wire AbortController. Pass its signal to bounded-retry.
On outer race timeout, abort the controller → bounded-retry stops
cleanly with BoundedRetryAbortedError. uploadToServer also honours
the signal before logging success.
3. **MINOR: sleep-leak test does not observe the leak**
(CodeRabbit Minor)
Test only triggered 4 sleeps — below Node's 11-listener warning —
and never captured warning events.
Fix: spy on signal.addEventListener / removeEventListener and
assert add count == remove count.
4. **MINOR: structured-logger thenable .catch can throw**
(Copilot)
Code called (hookResult as Promise).catch(...) directly. Fails on
thenables that only implement .then.
Fix: use Promise.resolve(hookResult).catch(...) which adapts any
thenable safely.
5. **Internal: bounded-retry catch was double-removing outer-abort listener**
Found by the new spy-based test. Both catch and finally called
removeEventListener. Removed redundant catch-block remove; finally
is single source of truth.
Build clean, 35/35 suites, 809/809 tests pass.
Addresses 7 issues from Codex P2 + Copilot + CodeRabbit Critical/Major:
1. **TTL enforced BEFORE each attempt** (Codex P2 + Copilot + CodeRabbit Major)
Previously TTL was only checked AFTER a failure, so an attempt could
start with 0ms remaining and run for the full perAttemptTimeoutMs.
Now: check at top of loop. If already exceeded, throw immediately.
2. **Per-attempt timeout capped by remaining TTL** (Copilot)
`attemptTimeoutMs = min(perAttemptTimeoutMs, ttlMs - elapsed)` so a
single attempt cannot run past the wall-clock deadline.
3. **Operation receives AbortSignal for cancellation** (CodeRabbit Critical)
`withTimeout` now aborts an `AbortController` when the timeout fires.
The signal is forwarded to the operation, so callers can opt into
cancelling in-flight work (e.g. `(signal) => fetch({ signal })`).
Without this, a timed-out attempt's network call kept running while
bounded-retry started the next attempt — wasted work + duplicate
listeners.
4. **sleep() removes abort listener on normal completion** (Codex P2 +
Copilot + CodeRabbit)
Previously `signal.addEventListener('abort', onAbort, { once: true })`
was added on every retry but never removed when the timer resolved.
Long-lived signals reused across retries accumulated listeners. Now
explicit cleanup on both timer-resolve and abort paths.
5. **Outer abort listener properly cleaned up in main loop**
Adds + removes via try/finally to prevent leaks when the loop throws.
6. **Metrics use optional chaining** (Copilot)
`metrics.socketEvents?.inc(...)` everywhere, matching the rest of the
codebase pattern (handles partial mocks in tests).
7. **Structured logging** (user request)
New optional `logger?: ILogger` field. Emits:
- debug: each retry scheduling (`{op, attempt, delayMs, elapsedMs, error}`)
- info: recovery after retries (`{op, attempts, elapsedMs}`)
- warn: give-up via TTL or shouldRetry predicate
8. **socket.ts: align uploadPreKeys query timeout with bounded-retry**
`query(node, PER_ATTEMPT_TIMEOUT_MS)` so a stale attempt does not
keep an iq listener registered past bounded-retry's per-attempt
deadline (Copilot).
9. **Doc updated**: removed claim about `maxAttempts` (option doesn't
exist; bounded by ttlMs + delay-cap + per-attempt-timeout).
Tests: 18 pass (was 12; added 6 covering each fix).
Full suite: 35/35 suites, 809/809 tests pass.
NB: The "breaking API change" comments (Types/Socket.ts, Utils/index.ts,
structured-logger.ts) are intentional and not addressed — this fork has
a single consumer; downstream forks merging will absorb the change.
- bounded-retry.ts:103 — drop unused generic parameter <T> from
BoundedRetryOptions interface (and its single use at line 220).
The generic served no purpose: the caller's return type is inferred
from the operation function, not from the options.
- retry-utils.test.ts:5 — remove `beforeEach` from imports. It was
only used by the RetryManager describe block, removed in commit
0f2402da58.
Build clean, 58 tests pass (12 bounded-retry + 46 retry-utils).
Audit follow-up. After verifying the codebase, three exports of
retry-utils.ts have ZERO production callers:
- `withRetry` decorator (lines 419-440): unused
- `retryable` wrapper (lines 442-453): unused (1 self-test only)
- `RetryManager` class (lines 455-549): unused (1 self-test only)
Note: `MessageRetryManager` in messages-send.ts is a separate class,
not the same thing.
The kept exports are still used:
- `retry()` function: used by decode-wa-message.ts for Bad MAC recovery
- `RetryExhaustedError`, `RetryOptions`, `RetryContext`: used same place
- `RETRY_BACKOFF_DELAYS`, `RETRY_JITTER_FACTOR`: re-exported via Defaults
- `retryPredicates`, `retryConfigs`: helper presets (kept; small)
Removed `EventEmitter` import (was only used by the deleted RetryManager).
Removed `retryable` and `RetryManager` from test imports + their describe
blocks (5 tests removed, all targeting the deleted code).
Net: -148 lines from retry-utils.ts, -90 lines from its test file.
803 tests pass (was 809; 6 deleted tests covered the removed code).
Now the InfiniteAPI retry surface is:
- `retry-utils.ts:retry()` for Bad MAC recovery (decryption layer)
- `bounded-retry.ts:withBoundedRetry()` for socket operation retry
(uploadPreKeys), with WhatsApp Android empirical defaults
No redundancy: each helper handles a distinct concern.
The structured-logger had its OWN private CircuitBreaker class for
external-hook protection — gate hook calls when the user's hook keeps
throwing, to prevent log explosion.
Replaced by simple try/catch + hookFailures metric increment:
- If the user's hook throws synchronously → catch + increment metric.
- If the hook returns a rejected promise → .catch + increment metric.
- We do NOT gate further calls. If the hook is broken, the metric
goes up rapidly and operators see it. Consistent with the rest of
the codebase after the socket-layer circuit-breaker removal:
fast-fail, no state machines, no cross-call gating.
Removed:
- The internal CircuitBreaker class (lines 244-298, 55 lines)
- circuitBreakerThreshold + circuitBreakerResetMs config options
- circuitBreakerTrips metric field (LoggerMetrics)
- circuitBreakerState statistics field (LoggerStatistics)
- circuitBreaker init in constructor + null assignment in destroy()
Build passes; 19 structured-logger tests pass; 35/35 suites pass.
Sweep after the main substitution found three stale references that
predated this PR but became misleading once circuit breaker was removed:
- src/Socket/socket.ts: keep-alive ping comment claimed it bypassed the
circuit breaker. Rewritten to reflect the actual reason for using
sendNode() (avoid response correlator overhead) with a brief
historical note.
- src/Socket/socket.ts: comment in sendNode() init mentioned avoiding
duplicating circuit breakers; updated to "manager state".
- src/Socket/socket.ts: queryInternal comment described a self-tripping
loop in the circuit breaker; trimmed to keep only the relevant point
about the outer-timeout race.
- src/Utils/prometheus-metrics.ts: 6 circuit_breaker_* metrics removed
(state/trips/recoveries/rejections/successes/failures) — no longer
emitted by anything in the codebase. Also removed the
initialize-with-zero call in initializeMetricsWithLabels.
Expand the file-level JSDoc on bounded-retry.ts to document:
- Why circuit breaker was removed (cascading failures in production:
5 timeouts in 60s blocked all queries for 30s).
- Empirical justification: Frida hook captures of WhatsApp Android
showing per-operation exponential backoff (3s -> 10s -> 60s ->
~64s -> 120s cap), bounded memory, no retry storm on recovery,
no global state machine.
- Design properties (per-op isolation, bounded by attempts+TTL,
per-attempt timeout, abort support, memory bound).
- Guidance: when to use plain query() vs. withBoundedRetry().
- Three usage examples covering common patterns.
No code changes — comment-only.
Delete src/Utils/circuit-breaker.ts (767 lines) and the corresponding
test file src/__tests__/Utils/circuit-breaker.test.ts (371 lines).
Replaced by src/Utils/bounded-retry.ts (added in earlier commit). All
remaining circuit-breaker references in the codebase are explanatory
comments in code that previously used circuit breakers — kept for
git-history context.
Final state: 35/35 test suites pass, 809 tests pass.
- Utils/index.ts: re-export bounded-retry instead of circuit-breaker
- Utils/unified-session.ts: drop the optional circuit-breaker wrapping.
Telemetry is non-critical; a single send attempt with the underlying
sendNode timeout is sufficient. Removed enableCircuitBreaker option,
circuitBreaker field, and CircuitOpenError handling.
- Utils/retry-utils.ts: drop the optional circuitBreaker option from
withRetry. Retry mechanics are now self-contained; callers that need
bounded retries should prefer withBoundedRetry directly.
- Utils/health-status.ts: stop importing globalCircuitRegistry. Keep the
CircuitBreakerHealth shape for k8s probe schema stability — always
reports an empty list now.
Note: src/Utils/structured-logger.ts has its own *private* CircuitBreaker
class for log-throttling — independent of the socket-level circuit breaker
being removed. Untouched.
baileys-logger.ts logCircuitBreaker() is a generic logging helper that
takes state strings as input — no import dependency. Untouched (no-op
for callers that no longer have circuit breakers).
Per-operation exponential backoff with jitter, max attempts, and TTL.
Replaces the circuit breaker pattern, which was causing cascading
failures (5 timeouts in 60s blocked all queries for 30s).
Empirical defaults captured via Frida from WhatsApp Android:
- delays: [3000, 10000, 60000, 60000, 120000] ms (cap at 2 min)
- TTL: 600_000 ms (10 min — empirical 3-5 min stabilisation + buffer)
- per-attempt timeout: 30_000 ms
- jitter: ±15%
Properties (vs circuit breaker):
- Per-operation independent — failures in op A do NOT block op B
- No global state machine (no CLOSED/OPEN/HALF-OPEN cascade)
- Bounded by maxAttempts + ttlMs (no unbounded retry accumulation)
- AbortSignal cancellation support
- Aligned with WhatsApp Android empirical retry behavior
12 tests cover: default sequence, TTL exhaustion, per-attempt timeout,
shouldRetry predicate, AbortSignal, per-op isolation, delay cap, jitter,
budget cap. All pass in ~4s.
* 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.
* 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>
* fix: respect caller-provided waveform for PTT audio messages
When sending PTT audio with an explicit waveform, Baileys unconditionally
overwrites it with getAudioWaveform() which returns undefined when
audio-decode is not installed — resulting in a flat line on WhatsApp.
Now only invokes getAudioWaveform() when no waveform is already provided,
matching the existing pattern used for jpegThumbnail (line 241).
Validated via CDP + Frida interception: WhatsApp waveform format is
64 bytes Uint8Array, values 0-99, byte-for-byte identical between
Android (audio_data table) and WA Web (model-storage IDB).
Ref: WhiskeySockets/Baileys#2443
Co-Authored-By: Renato Alcara
- Add mentionAll?: boolean to Mentionable type (upstream 6afde71691)
- Set contextInfo.nonJidMentions=1 for @everyone group mentions
- Use viewOnceMessageV2 (field 55) wrapper instead of legacy viewOnceMessage (field 37)
- Set viewOnce=true on inner imageMessage/videoMessage/audioMessage
- Route view-once media uploads to /o1/mms/* CDN via mediaTypeOverride
Co-Authored-By: Renato Alcará
* fix: detect view-once media (image/video/audio) on stanza 1 for linked devices
When a view-once arrives at a linked device (InfiniteAPI), the server sends:
- Stanza 1: enc payload with full media metadata (mediaKey, directPath) wrapped in
viewOnceMessage > imageMessage/videoMessage/audioMessage with viewOnce: true
- Stanza 2: unavailable view_once fanout placeholder (already handled)
Previously only stanza 2 set key.isViewOnce = true. Stanza 1 was emitted as
plain media with no view-once indicator, making it indistinguishable from regular
media on the consumer side (messages.upsert).
This fix inspects the decrypted proto for viewOnceMessage (v1/v2/v2Ext) wrappers
containing a media message with viewOnce=true and propagates key.isViewOnce=true.
The viewOnceMessage wrapper is also used for interactive messages (carousel,
buttons, lists) but those carry interactiveMessage/listMessage inside -- never
imageMessage.viewOnce / videoMessage.viewOnce / audioMessage.viewOnce.
The distinction is unambiguous and does not affect interactive message handling.
Verified via WA Desktop
- history.ts: add Origin: DEFAULT_ORIGIN to CDN DELETE request headers —
the download path injects it internally but options does not carry it
- socket.ts: fix lowServerCount comparison — was preKeyCount <= topUpAmount
which triggered uploads even when above MIN_PREKEY_COUNT (e.g. 250 prekeys
→ topUp=550 → 250<=550=true → unnecessary upload). Now uses correct
threshold: preKeyCount < MIN_PREKEY_COUNT
- socket.ts: guard scheduleNextKeepAlive() with !closed check to prevent
orphan timers when end() was called concurrently on a different path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- history.ts: move CDN DELETE from downloadHistory() to
downloadAndProcessHistorySyncNotification(), after processHistoryMessage()
succeeds — prevents permanent history loss if processing throws post-download
- history.ts: fix fetch spread order: { ...options, method: 'DELETE' } so
options.method cannot shadow the intended DELETE verb
- socket.ts: add early return after void end() in onKeepAliveTick to prevent
scheduleNextKeepAlive() from leaking a timer while connection is closing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. USyncQuery: extract raw_id attr from node attrs into rawId field; implement
side_list parsing (was TODO/commented) reusing shared parseNodeList helper
2. getUSyncDevices: add 4th LID→PN source via raw_id pairing from device-list
WA Business sends zero phoneNumberToLidMappings in HistorySync — raw_id is
the only way to resolve LID↔PN for those accounts during message send
3. MIN_PREKEY_COUNT: 5 → 25 (WA Business maintains ~812; 5 was too low a buffer
before triggering replenishment upload)
4. startKeepAliveRequest: replace fixed setInterval with recursive setTimeout
+ ±15% jitter, matching WA Desktop's ~25-30s variable heartbeat pattern;
clearInterval → clearTimeout for the handle
5. downloadHistory: fire-and-forget DELETE to CDN after successful download,
mirroring WA Desktop behaviour (server-side one-time file cleanup)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: histsync LID improvements — raw_id mapping, prekeys, keepalive jitter, CDN DELETE
* fix: prekey pool strategy — 800 initial, top-up to 800 when below 200
- INITIAL_PREKEY_COUNT: 812 → 800 (rounded, matches WA Business ~812 from CDP capture)
- MIN_PREKEY_COUNT: 25 → 200 (replenishment trigger threshold)
- uploadPreKeysToServerIfRequired: top-up to INITIAL_PREKEY_COUNT instead of
uploading a flat MIN_PREKEY_COUNT — restores full 800-key pool on each replenish
- handleEncryptNotification: same top-up logic (INITIAL_PREKEY_COUNT - count)
so server notification path also restores to 800, not just adds 200
uploadPreKeys(5) in error recovery path intentionally left unchanged.
Two bugs fixed compared to prior implementation:
1. history.ts — normalize userJid LID→PN
pastParticipants[].userJid may arrive as a LID identifier (e.g. "46802258641027@lid").
The consumer expects a phone number. After building the lidPnMap from conversations
and phoneNumberToLidMappings, each userJid is resolved to its PN equivalent.
If the mapping is not available the original value is preserved (no data loss).
2. event-buffer.ts — deduplicate by groupJid + userJid across chunks
Multiple HistorySync chunks can contain the same group. The previous approach
concatenated blindly, producing duplicate entries. Now a keyed map
{ [groupJid]: IPastParticipant[] } is used so each group appears once and
each participant within a group appears at most once.
Types updated (Events.ts):
- messaging-history.set event includes pastParticipants?: IPastParticipants[]
- BufferedEventData.historySets.pastParticipants typed as keyed map
- Add interactive message detection helpers (getButtonType, isCarouselMessage, etc.)
- Inject biz node with interactive/native_flow for non-carousel interactive messages
- Inject biz + quality_control with decision_id for carousel messages
- Skip bot node for native_flow/carousel/catalog (breaks Web rendering)
- Force device-identity inclusion for carousel messages
- Append deferred biz/bot/quality_control nodes after device-identity and tctoken
- Store tctoken under both LID and PN for reliable lookup
Two fixes:
1. Stanza type="media" for carousel messages — WhatsApp Web needs this
to render carousel in real-time. Without it, only shows header until
page refresh (F5).
2. Fix jimp detection: typeof Jimp === 'function' (not just 'object')
so jpegThumbnail is generated for card images.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7 corrections based on reverse-engineering of WhatsApp Business Android:
1. resolveCanonicalJid: PN→LID resolution for transaction lock keys (prevent race conditions)
2. Retain PN session during LID migration (avoid No Session errors)
3. Delayed PreKey deletion with 5-min grace period (prevent Invalid PreKey ID races)
4. Surgical session cleanup: only delete the specific corrupted device, not all devices
5. Identity dual storage: save identity key in both LID and PN addresses
6. MAC error cooldown reduced 10s→1s (faster recovery, aligned with WABA)
7. Allow session recreation on first retry (retryCount >= 1 instead of > 1)
* fix: remove unused jidDecode import from decode-wa-message.ts
* fix: add try/catch to delayed PreKey deletion to prevent unhandled rejection
The setTimeout async callback in removePreKey could cause unhandled promise
rejection if the keystore was destroyed (connection closed) before the
5-min grace period expired.
The web protocol (WA\x06\x03) requires a web-compatible UserAgent.
Using SMB_ANDROID in the UserAgent caused pair code registration to
fail with "cannot connect device" even though the phone confirmation
appeared. The Android identity is now only set in DeviceProps.platformType
(ANDROID_PHONE) in the registration node, which correctly determines
the display name in "Linked Devices" as "Android (14)".
Changes:
- getUserAgent(): always returns MACOS platform and Desktop device
- getClientPayload(): always includes webInfo (no longer skipped for Android)
- Remove unused isAndroidBrowser import from validate-connection.ts
- Update pair code comments documenting tested platform IDs
- Add structured logging to pair code request
* feat: add Android browser preset for companion device registration
Add Browsers.android(apiLevel) preset that identifies as an Android
companion device (SMB_ANDROID platform, ANDROID_PHONE device type).
Validated against Frida captures of real WhatsApp Android protocol.
* feat: support BAILEYS_BROWSER env var for browser selection
Allows selecting Android companion mode via environment variable
without code changes. Set BAILEYS_BROWSER=android (or android:15
for a specific API level) to register as an Android device.
* fix: auto-detect Android browser in pair code and fall back to Chrome
Pair code companion registration fails with Android browser because the
WA server rejects the ANDROID_PHONE companion_platform_id over the web
Noise protocol. When Android is detected, requestPairingCode() now
transparently uses Chrome/macOS platform values for the pair code stanza
while QR code pairing continues to work as Android (SMB_ANDROID).
* docs: add android to isValidBrowserPreset JSDoc
* fix: break infinite Bad MAC + session recreation loop
Remove aggressive session cleanup from the decryption hot path that caused
cascading failures when multiple messages from the same contact arrived
simultaneously. The Signal Protocol naturally recovers via retry+pkmsg flow.
Changes:
- Remove cleanupCorruptedSession() from per-message error handler (hot path)
- Move session cleanup to retry-exhausted handler as safety net
- Add per-JID retry request deduplication (5s window)
- Add 10s cooldown on MAC error session recreation
- Export cleanupCorruptedSession/getDecryptionJid for use in messages-recv
Tested: corrupted session with fake keys → Bad MAC → retry receipt →
pkmsg recovery → all 7 messages delivered, zero infinite loop.
* fix: address PR review — lint, dedup scope, and cleanup targeting
- Remove unused `autoCleanCorrupted` param from decryptMessageNode (lint fix)
- Scope retry dedup by participant JID in group chats (not group JID)
- Move dedup registration after early-return checks to avoid blocking
subsequent messages when max retries reached
- Use participant JID for session cleanup in groups (Signal sessions
are per-participant, not per-group)
* feat: centralized LID→PN normalization for all emitted events
WhatsApp's server increasingly uses LID (Linked ID) as the primary
addressing format. Consumers expect phone numbers (PN),
not opaque LID identifiers. This adds comprehensive LID→PN resolution
across all ev.emit() paths so downstream consumers always receive PN.
Key changes:
- Add resolveLidToPn() and normalizeKeyLidToPn() centralized helpers
in process-message.ts for consistent LID→PN resolution
- normalizeMessageJids() fast path: use alt JID directly from stanza
attributes (zero I/O, eliminates race condition with LIDMappingStore)
- Await storeLIDPNMappings() before normalization in message receipt
flow (was fire-and-forget, could race with subsequent getPNForLID)
- Normalize LID→PN in all event emission points:
* messages.upsert (keys, nested reaction/poll keys, participantAlt)
* presence.update (jid + participant)
* message-receipt.update (key + userJid)
* contacts.update (picture notifications)
* blocklist.update (blocklist JIDs)
* call events (chatId, from)
* group metadata (participants, owner, subjectOwner)
* group notifications (acting participant, add/remove/promote/demote)
* newsletter notifications (author, user JIDs)
* sync actions (mutation index normalization)
- Make handlePresenceUpdate and handleGroupNotification async to
support await on LID resolution
- Add normalizeGroupMetadata() helper in groups.ts for all
extractGroupMetadata call sites
Performance: ~0.01ms per message (LRU cache hit). No impact on
message sending. First-contact resolution ~2-5ms (one-time per contact).
* fix: normalize LID→PN in handleBadAck, media retry, and PDO recovery
Additional leak points found during final audit:
- handleBadAck: key.remoteJid from ack stanza could be LID
- messages.media-update: media retry key JIDs not normalized
- CTWA PDO recovery: webMessageInfo.key from phone response not normalized
* fix: address PR review — parallelize LID resolution, use helper consistently
- normalizeGroupMetadata: resolve participant LIDs with Promise.all
instead of sequential loop (perf on large groups)
- handleCall: resolve participant JIDs with Promise.all
- handleBadAck: use normalizeKeyLidToPn() helper instead of manual
resolveLidToPn + assignment (consistency with other code paths)
- CB:relay: resolve callCreator LID→PN before emitting
* fix: add timeout to fetchLatestBaileysVersion to prevent hanging connections
AbortController with 5s default timeout prevents indefinite blocking
when GitHub is unreachable (DNS failure, network issues). Falls back
to bundled version via existing catch block. Ref: Baileys#2385.
* fix: move clearTimeout to finally block in fetchLatestBaileysVersion
Ensures timer cleanup on both success and fetch rejection paths,
preventing dangling timers when DNS/network fails before abort fires.
In DMs, when the connected user sends a reaction, the inner key's
remoteJid could remain unnormalized (e.g. LID vs PN), preventing
correct matching with the original message. Also normalises participant
in groups for own messages — an improvement over upstream Baileys#2386.