Compare commits

...

109 Commits

Author SHA1 Message Date
Renato Alcara 9e3af35b54 fix(history-sync): serialise tcToken persistence + drop external doc reference
Addresses 4 PR #390 review findings (Codex P1 + Copilot ×3):

#1 Codex P1 (real, MAJOR) — `storeTcTokensFromHistorySync` fire-and-forget
   created a write race when multiple history-sync chunks arrived in parallel
   (common during reconnect/QR scan). The function does a non-atomic
   read-then-merge-then-write: chunk B could read `existing` before chunk A
   wrote, then commit later and overwrite chunk A's newer timestamp. Even
   worse, the merged `__index` write could drop JIDs added by the other
   chunk, leaving stale tcTokens and persistent 463 send errors.

   FIX: introduced `scheduleHistoryTcTokenSync()` — a module-scoped
   single-concurrency promise chain that sequentialises all calls in order.
   The chain swallows prior errors so a single failure can't stall future
   runs. Call site stays fire-and-forget so the `messaging-history.set`
   emit is still instant (which is the whole point of PR #389).

#2 Copilot (same root as #1) — already covered by the chain.

#3+#4 Copilot — code comments referenced
   `Downloads/InfiniteAPI-Inbound-Latency-Fix-Documentation.md` which is
   intentionally out-of-tree (user keeps it locally). Replaced the broken
   refs with self-contained explanations covering the same rationale —
   future maintainers no longer need an external file to understand the
   trade-offs.

Skipped: CodeRabbit nitpick to extract a `fireAndForgetMappingStore` helper
(stylistic, current explicit form makes the lid/pn argument order obvious to
reviewers — keeping it).

Tests: 35/35 suites, 824/824 still passing. Customizations untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:25:27 -03:00
Renato Alcara a631c8c3c3 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

DOCUMENTATION:
Full rationale, before/after code, test scenarios, rollback procedure and
guidance for future maintainers in:
Downloads/InfiniteAPI-Inbound-Latency-Fix-Documentation.md

DO NOT REVERT WITHOUT READING THE DOC.

Customizations untouched: zero diff in src/Utils/messages.ts (carousel/buttons/
lists), src/Socket/groups.ts, WAProto/*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:59:54 -03:00
Renato Alcara 4fe708445a chore: update WhatsApp Web version to v2.3000.1038167900 (#389)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-26 06:18:52 -03:00
github-actions[bot] 336ed64a44 chore: update proto/version to v2.3000.1038164556 (#388)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-26 01:07:43 -03:00
Renato Alcara 65cb09e4df perf(history-sync): parallelise tcToken JID resolution per chunk (#387)
perf(history-sync): parallelise tcToken JID resolution per chunk (#387)
2026-04-25 23:52:37 -03:00
Renato Alcara 9ff21db749 feat(tctoken): complete lifecycle (TIER 1 + 2 + 3 of upstream PR)
feat(tctoken): complete lifecycle (TIER 1 + 2 + 3 of upstream PR)
2026-04-25 18:52:45 -03:00
Renato Alcara 9525f42cb2 perf: optimize history sync memory and CPU usage (#385)
* 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.
2026-04-25 15:11:34 -03:00
Renato Alcara e117456079 test(binary): cover reconnection sync skip for both signals (#384)
* 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
2026-04-25 14:16:14 -03:00
Renato Alcara c179c82cca perf(messages-recv): early-ignore JIDs before buffer/queue (#383)
* 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
2026-04-25 13:16:22 -03:00
Renato Alcara d08a09c35f feat: add inbound username support and USync username protocol (#382)
* 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
2026-04-25 12:39:31 -03:00
Renato Alcara cffaad9e5a fix(chats): use abt xmlns and protocol
fix(chats): use abt xmlns and protocol
2026-04-25 11:08:47 -03:00
Renato Alcara 104d2c47f5 chore: update WhatsApp Web version to v2.3000.1038147544 (#380)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-25 06:17:10 -03:00
Renato Alcara 924c54f1f8 chore: update WhatsApp Web version to v2.3000.1038063777 (#378)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-24 06:31:31 -03:00
github-actions[bot] e34ef5479d chore: update proto/version to v2.3000.1038024963 (#377)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-24 01:03:36 -03:00
Renato Alcara 5e8d30d709 chore: update WhatsApp Web version to v2.3000.1037968143 (#376)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-23 06:31:41 -03:00
github-actions[bot] ac93080595 chore: update proto/version to v2.3000.1037953056 (#375)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-23 01:01:23 -03:00
Renato Alcara 37d1bdfb76 chore: update WhatsApp Web version to v2.3000.1037878879 (#374)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-22 06:29:59 -03:00
github-actions[bot] 76b2007f27 chore: update proto/version to v2.3000.1037847281 (#373)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-22 01:00:13 -03:00
Renato Alcara 676912c295 Fix/remove vo raw debug logs (#372)
* chore: remove temporary [VO_RAW] debug logs from handleMessage

* chore: remove temporary VO_TRACE debug logger.info from handleMessage
2026-04-21 15:09:00 -03:00
Renato Alcara 06d9f35ca9 chore: remove temporary [VO_RAW] debug logs from handleMessage (#371)
chore: remove temporary [VO_RAW] debug logs from handleMessage (#371)
2026-04-21 15:00:00 -03:00
Renato Alcara 4cadcd1c57 chore: update WhatsApp Web version to v2.3000.1037786138 (#370)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-21 06:30:57 -03:00
github-actions[bot] 937b26edcd chore: update proto/version to v2.3000.1037753511 (#368)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-21 01:01:09 -03:00
Renato Alcara acad0c06dc chore: update WhatsApp Web version to v2.3000.1037679412 (#369)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-20 06:33:26 -03:00
Renato Alcara 98995aae89 chore: update WhatsApp Web version to v2.3000.1037659536 (#367)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-19 06:15:22 -03:00
github-actions[bot] 7a7d92e3f8 chore: update proto/version to v2.3000.1037654574 (#366)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-19 01:01:54 -03:00
Renato Alcara 1fbaa284f9 chore: update WhatsApp Web version to v2.3000.1037639827 (#365)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-18 06:15:03 -03:00
github-actions[bot] 9c7dd0460c chore: update proto/version to v2.3000.1037622359 (#364)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-18 00:55:26 -03:00
Renato Alcara 579277cb29 chore: update WhatsApp Web version to v2.3000.1037554587 (#363)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-17 06:29:43 -03:00
github-actions[bot] bb0cb90260 chore: update proto/version to v2.3000.1037539916 (#358)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-17 01:01:19 -03:00
Renato Alcara 56955e12f5 chore: update WhatsApp Web version to v2.3000.1037471135 (#362)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-16 06:29:30 -03:00
Renato Alcara b7d9f2b866 chore: update WhatsApp Web version to v2.3000.1037376462 (#361)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-15 06:30:13 -03:00
Renato Alcara daaf0bc813 chore: update WhatsApp Web version to v2.3000.1037280436 (#360)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-14 06:29:53 -03:00
Renato Alcara bf95867089 Merge branch 'master' of https://github.com/rsalcara/InfiniteAPI 2026-04-13 07:20:06 -03:00
Renato Alcara 9fac156474 feat: add dual protocol support (web/native) via BAILEYS_PROTOCOL env
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>
2026-04-13 07:19:55 -03:00
Renato Alcara 230e08037b chore: update WhatsApp Web version to v2.3000.1037186195 (#359)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-13 06:33:07 -03:00
Renato Alcara 77ffe59911 Revert "feat: add WebdPayload with E2E media support and features bitmask"
This reverts commit 313f304ddf.
2026-04-13 00:01:32 -03:00
Renato Alcara 313f304ddf feat: add WebdPayload with E2E media support and features bitmask
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>
2026-04-12 23:43:17 -03:00
Renato Alcara a74fbb08a6 fix: request view-once content via PDO from primary phone
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>
2026-04-12 22:21:02 -03:00
Renato Alcara 0e6bad72bb fix: emit view-once placeholder from unavailable handler
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>
2026-04-12 16:14:13 -03:00
Renato Alcara 08c19e2951 merge: resolve conflict in messages-recv.ts (keep viewOnceMessage placeholder) 2026-04-12 11:55:12 -03:00
Renato Alcara 90061dd6a1 fix: allow msmsg decryption and emit view-once unavailable fanout (#355)
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
2026-04-12 11:46:42 -03:00
Renato Alcara d014a2496e fix: allow msmsg decryption and emit view-once unavailable fanout (#355)
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
2026-04-12 10:02:30 -03:00
Renato Alcara 47b0940428 chore: update WhatsApp Web version to v2.3000.1037166441 (#357)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-12 06:15:16 -03:00
github-actions[bot] 90937af374 chore: update proto/version to v2.3000.1037162330 (#356)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-12 01:00:42 -03:00
Renato Alcara 703b5da554 chore: update WhatsApp Web version to v2.3000.1037137890 (#354)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-11 06:13:10 -03:00
github-actions[bot] 8c61ce5f8e chore: update proto/version to v2.3000.1037082622 (#353)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-11 00:48:54 -03:00
Renato Alcara 000932accd chore: update WhatsApp Web version to v2.3000.1037061899 (#352)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-10 06:25:40 -03:00
github-actions[bot] 8df39c320e chore: update proto/version to v2.3000.1037038379 (#351)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-10 01:00:21 -03:00
Renato Alcara 22e758b1e6 chore: update WhatsApp Web version to v2.3000.1036978211 (#350)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-09 06:26:10 -03:00
github-actions[bot] f473484852 chore: update proto/version to v2.3000.1036961647 (#349)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-09 00:55:22 -03:00
Renato Alcara 4dde4c5500 chore: update WhatsApp Web version to v2.3000.1036893687 (#348)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-08 06:22:46 -03:00
github-actions[bot] e3cf104b59 chore: update proto/version to v2.3000.1036861941 (#347)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-08 00:56:53 -03:00
Renato Alcara aa0a3c2b71 chore: update WhatsApp Web version to v2.3000.1036798793 (#346)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-07 06:23:04 -03:00
github-actions[bot] bdbad50074 chore: update proto/version to v2.3000.1036772203 (#345)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-07 00:56:17 -03:00
Renato Alcara 45b322abac chore: update WhatsApp Web version to v2.3000.1036709653 (#344)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-06 06:25:21 -03:00
github-actions[bot] 3c3cf32027 chore: update proto/version to v2.3000.1036704126 (#343)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-06 00:59:26 -03:00
Renato Alcara 99d85498fb chore: update WhatsApp Web version to v2.3000.1036691526 (#342)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-05 06:12:28 -03:00
github-actions[bot] d6414f203b chore: update proto/version to v2.3000.1036688876 (#340)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-05 00:58:23 -03:00
Renato Alcara b98df92d8c chore: update WhatsApp Web version to v2.3000.1036673976 (#341)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-04 06:11:52 -03:00
Renato Alcara 00a8113d47 chore: update WhatsApp Web version to v2.3000.1036606219 (#339)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-03 06:17:29 -03:00
github-actions[bot] 670ac582f9 chore: update proto/version to v2.3000.1036588306 (#338)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-03 00:54:16 -03:00
Renato Alcara 08910c61e1 chore: update WhatsApp Web version to v2.3000.1036512893 (#337)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-02 06:20:16 -03:00
github-actions[bot] cb5e42c46e chore: update proto/version to v2.3000.1036469328 (#336)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-02 00:53:53 -03:00
Renato Alcara 30e986ee67 chore: update WhatsApp Web version to v2.3000.1036420555 (#335)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-01 06:24:15 -03:00
github-actions[bot] 60302d73d5 chore: update proto/version to v2.3000.1036375660 (#334)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-04-01 01:01:11 -03:00
Renato Alcara 3baa494fad chore: update WhatsApp Web version to v2.3000.1036326359 (#333)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-31 06:54:53 -03:00
github-actions[bot] 59cad85544 chore: update proto/version to v2.3000.1036244875 (#332)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-31 02:02:42 -03:00
Renato Alcara ad53bc0982 chore: update WhatsApp Web version to v2.3000.1036229535 (#331)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-30 07:06:44 -03:00
github-actions[bot] fb8f51be52 chore: update proto/version to v2.3000.1036224280 (#330)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-30 02:15:18 -03:00
Renato Alcara 9a7d43d4ba chore: update WhatsApp Web version to v2.3000.1036208776 (#329)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-29 06:27:46 -03:00
github-actions[bot] 578efe945f chore: update proto/version to v2.3000.1036204984 (#328)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-29 02:03:52 -03:00
Renato Alcara e999fc1aa1 chore: update WhatsApp Web version to v2.3000.1036185592 (#327)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-28 06:27:30 -03:00
github-actions[bot] 9fd88b533d chore: update proto/version to v2.3000.1036173230 (#326)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-28 01:46:40 -03:00
Renato Alcara 43f3525437 chore: update WhatsApp Web version to v2.3000.1036094556 (#325)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-27 06:44:22 -03:00
github-actions[bot] fdcbd3fc1d chore: update proto/version to v2.3000.1036065881 (#324)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-27 02:00:33 -03:00
Renato Alcara cdc89316f0 chore: update WhatsApp Web version to v2.3000.1036016053 (#323)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-26 06:53:02 -03:00
github-actions[bot] 3050c329c5 chore: update proto/version to v2.3000.1036000318 (#319)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-26 01:59:23 -03:00
Renato Alcara 3ef7474842 fix: respect caller-provided waveform for PTT audio messages (#322)
* 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
2026-03-25 15:51:52 -03:00
Renato Alcara 9dfd31d109 chore: update WhatsApp Web version to v2.3000.1035913242 (#321)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-25 06:42:47 -03:00
Renato Alcara e686c5a2e1 chore: update WhatsApp Web version to v2.3000.1035819858 (#320)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-24 06:44:53 -03:00
Renato Alcara 466c14ff82 chore: update WhatsApp Web version to v2.3000.1035718650 (#318)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-23 06:53:55 -03:00
Renato Alcara 03bc03b318 Merge branch 'master' of https://github.com/rsalcara/InfiniteAPI 2026-03-23 01:59:15 -03:00
Renato Alcara d83971d15e fix: remove duplicate isViewOnceMsg/viewOnceMeRecipients declarations
Merge duplicou o bloco — TS2451 Cannot redeclare block-scoped variable.
Mantida a versão correta (verifica viewOnceInner?.imageMessage?.viewOnce).

Co-Authored-By: Renato Alcara
2026-03-23 01:59:08 -03:00
github-actions[bot] 14f0321d7c chore: update proto/version to v2.3000.1035715252 (#317)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-23 01:56:01 -03:00
Renato Alcara b65a8a334b chore: resolve merge conflict in messages-send.ts (comment-only)
Conflict was between Portuguese and English comment for phash/DSM block.
Kept HEAD (our) version.

Co-Authored-By: Renato Alcara
2026-03-23 01:54:46 -03:00
Renato Alcara 28e9e5b912 feat: implement view-once message sending and receiving
- Add viewonce-image/video/audio to MEDIA_PATH_MAP and MEDIA_HKDF_KEY_MAPPING
- messages-send: use viewOnceMessageV2 wrapper, omit companion devices for DSM
  (server auto-generates <unavailable type="view_once"/> for linked devices)
- messages-send: fix mediatype detection by unwrapping viewOnce before getMediaType
- messages-recv: handle <unavailable type="view_once"/> sync from primary device
- messages-recv: note — do NOT send view_once_read from linked device

Co-Authored-By: Renato Alcara
2026-03-23 01:53:55 -03:00
Renato Alcara 962e7de0bf chore: mark upstream commits as reviewed (3841abca35 ad5ea817f7 d077902695 c4e5d1262a 802d5f6efa)
- 3841abca35 / ad5ea817f7: WA version updates — applied separately via our own version bumps
- d077902695: WA version update — same as above
- c4e5d1262a: Fix ack handling — high-risk 2718-line diff in messages-recv.ts, intentionally skipped
- 802d5f6efa: Fix mentions regression — already covered by existing rsalcara contextInfo logic
- 6afde71691: feat groups mentionAll — applied in previous commit
2026-03-23 01:53:00 -03:00
Renato Alcara c6cb29fd80 feat: groups mention all + fix view-once upload path and wrapper
- 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á
2026-03-23 01:52:43 -03:00
Renato Alcara a283d858c6 docs: rewrite INTERACTIVE_MESSAGES.md — complete API guide
Full rewrite covering all 8 interactive message types:
1. Menu Texto
2. Botões Quick Reply (tested up to 16 buttons)
3. Botões CTA — URL, Copy, Call
4. Lista Dropdown (10 sections × 3 rows = 30 items max)
5. Enquete/Poll
6. Apenas Botões Reply sem CTA
7. Apenas CTAs sem Quick Reply
8. Carrossel com Imagens (up to 10 cards)

Each type includes:
- Complete curl example with realistic data
- All parameters documented with types and limits
- Webhook payload structure received on click/select
- JavaScript helpers to parse each response type

Also includes:
- Platform compatibility table (Android / iOS / WA Web)
- Universal webhook router for all interactive response types
- Complete limits table per message type
- Account restrictions (hosted vs non-hosted)

Co-Authored-By: Renato Alará
2026-03-23 01:27:45 -03:00
Renato Alcara 9c626801b0 docs: add hosted account restriction warning to VIEW_ONCE.md
Accounts registered as hosted (Meta Business Cloud API) have view-once
blocked by the WA server for all clients (Android, iOS, Web).
This is a server-side policy with no code workaround.

Added prominent warning block in section 1 and dedicated row in the
limitations table.

Co-Authored-By: Renato Alcará
2026-03-23 01:17:27 -03:00
Renato Alcara 4ca1878705 docs: add VIEW_ONCE.md — view-once implementation guide
Complete guide for the view-once (visualização única) feature:
- Send examples (curl) for image, video and audio
- Webhook payload structure with key.isViewOnce detection
- JavaScript helpers to identify and access view-once media
- Frontend/CRM rendering guide with placeholder after open
- Implemented code with inline explanations
- Full send/receive flow diagram
- Known limitations and validation checklist

Co-Authored-By: Renato Alcara
2026-03-23 01:12:36 -03:00
Renato Alcara 452df5d409 Feat/view once receive (#316)
* fix: detect view-once media (image/video/audio) on stanza 1 for linked devices
2026-03-23 00:38:05 -03:00
Renato Alcara e633d62a3b chore: update WhatsApp Web version to v2.3000.1035697879 (#315)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-22 06:22:56 -03:00
github-actions[bot] 2a321b881e chore: update proto/version to v2.3000.1035693752 (#314)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-22 01:46:05 -03:00
Rajeh Taher 802d5f6efa messages: Fix regression where missing contextInfo caused no mentions 2026-03-22 02:43:03 +02:00
Renato Alcara e0038bde36 chore: update WhatsApp Web version to v2.3000.1035675348 (#313)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-21 06:22:45 -03:00
github-actions[bot] 1cbf1b18b7 chore: update proto/version to v2.3000.1035661795 (#312)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-21 01:22:14 -03:00
Renato Alcara c3a021fcde fix: use newsletter-specific upload paths for channel media (#311)
fix: use newsletter-specific upload paths for channel media (#311)
2026-03-20 12:02:36 -03:00
Renato Alcara e6ea7e4563 Feat/view once receive (#310)
* fix: unwrap viewOnceMessage in getMediaType so enc node gets mediatype attribute

Without this fix, view-once media (image/video/audio) was sent without
mediatype="image/video/audio" on the enc node because getMediaType only
checked the top-level message fields. The viewOnceMessage wrapper hides
the inner imageMessage, causing mediatype to be empty and WA servers to
silently drop the message on the recipient side.
2026-03-20 10:49:25 -03:00
Renato Alcara 3ab578aadf fix: detect view-once media (image/video/audio) (#309)
* 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
2026-03-20 10:11:49 -03:00
Renato Alcara 5926da9493 Merge branch 'master' of https://github.com/rsalcara/InfiniteAPI 2026-03-19 12:22:07 -03:00
Renato Alcara 2b2c3f9155 Merge branch 'master' of https://github.com/rsalcara/InfiniteAPI 2026-03-19 11:24:01 -03:00
github-actions[bot] d077902695 chore: update WhatsApp Web version (#2420)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-15 22:49:39 +02:00
João Lucas c4e5d1262a Fix ack handling (#2373)
* fix: ack handling to match wa web

* fix: prevent duplicate ack on message errors
2026-03-13 21:45:49 +02:00
Matheus Filype 6afde71691 feat: groups mention all (#2396)
* fix: improve message resend logic by adding checks for message IDs

* Revert "fix: improve message resend logic by adding checks for message IDs"

This reverts commit c03f9d8e6fc6cbfbb9d1f8f67c169700e704213d.

* feat: add mentionAll support to message context for group mentions

* fix: improve readability of condition for mentions and mentionAll in generateWAMessageContent

* Apply suggestions from code review

Co-authored-by: Rajeh Taher <rajeh@reforward.dev>

---------

Co-authored-by: Rajeh Taher <rajeh@reforward.dev>
2026-03-13 19:49:16 +02:00
Renato Alcara 6060293793 fix: jimp detection for thumbnail generation
Fix typeof check: Jimp constructor is 'function' not 'object' in
jimp v1.6+. Without this fix, jpegThumbnail is never generated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:41:54 -03:00
Renato Alcara afce764ce9 Revert "fix: carousel renders on WhatsApp Web without F5"
This reverts commit f2c14f9ad6.
2026-03-07 00:41:38 -03:00
ShellTear ad5ea817f7 Merge pull request #2360 from WhiskeySockets/update-version/stable 2026-02-25 01:33:33 +09:00
github-actions[bot] 3841abca35 chore: update WhatsApp Web version 2026-02-22 00:43:34 +00:00
36 changed files with 5282 additions and 670 deletions
+92 -2
View File
@@ -1,7 +1,7 @@
syntax = "proto3";
package proto;
/// WhatsApp Version: 2.3000.1035577069
/// WhatsApp Version: 2.3000.1038164556
message ADVDeviceIdentity {
optional uint32 rawId = 1;
@@ -421,6 +421,12 @@ message BotCapabilityMetadata {
}
}
message BotCommandMetadata {
optional string commandName = 1;
optional string commandDescription = 2;
optional string commandPrompt = 3;
}
message BotDocumentMessageMetadata {
optional DocumentPluginType pluginType = 1;
enum DocumentPluginType {
@@ -652,6 +658,7 @@ message BotMetadata {
optional BotRenderingConfigMetadata botRenderingConfigMetadata = 36;
optional BotInfrastructureDiagnostics botInfrastructureDiagnostics = 37;
optional AIMediaCollectionMetadata aiMediaCollectionMetadata = 38;
optional BotCommandMetadata commandMetadata = 39;
optional bytes internalMetadata = 999;
}
@@ -703,6 +710,7 @@ enum BotMetricsEntryPoint {
WEB_NAVIGATION_BAR = 47;
GROUP_MEMBER = 54;
CHATLIST_SEARCH = 55;
NEW_CHAT_LIST = 56;
}
message BotMetricsMetadata {
optional string destinationId = 1;
@@ -1467,6 +1475,10 @@ message ContextInfo {
optional string wtwaWebsiteUrl = 26;
optional string adPreviewUrl = 27;
optional bool containsCtwaFlowsAutoReply = 28;
optional int32 agmThumbnailStrategy = 29;
optional int32 agmTitleStrategy = 30;
optional int32 agmSubtitleStrategy = 31;
optional int32 agmHeaderInteractionStrategy = 32;
enum AdType {
CTWA = 0;
CAWC = 1;
@@ -1652,6 +1664,7 @@ message DeviceCapabilities {
optional bool companionSupportEnabled = 2;
optional bool campaignSyncEnabled = 3;
optional bool insightsSyncEnabled = 4;
optional int32 recipientLimit = 5;
}
enum ChatLockSupportLevel {
@@ -1727,6 +1740,8 @@ message DeviceProps {
optional uint32 initialSyncMaxMessagesPerChat = 20;
optional bool supportManusHistory = 21;
optional bool supportHatchHistory = 22;
repeated string supportedBotChannelFbids = 23;
optional bool supportInlineContacts = 24;
}
enum PlatformType {
@@ -1913,6 +1928,16 @@ message GroupParticipant {
}
}
message GroupRootKeyShare {
repeated GroupRootKeyShareEntry keys = 1;
}
message GroupRootKeyShareEntry {
optional bytes groupRootKey = 1;
optional string keyId = 2;
optional int64 expiryTimestampMs = 3;
}
message HandshakeMessage {
optional ClientHello clientHello = 2;
optional ServerHello serverHello = 3;
@@ -1935,6 +1960,7 @@ message HandshakeMessage {
optional bool sendServerHelloPaddedBytes = 7;
optional bool simulateXxkemFs = 8;
optional HandshakeMessage.HandshakePqMode pqMode = 9;
optional bytes extendedEphemeral = 10;
}
enum HandshakePqMode {
@@ -1954,6 +1980,7 @@ message HandshakeMessage {
optional bytes payload = 3;
optional bytes extendedStatic = 4;
optional bytes paddingBytes = 5;
optional bytes extendedCiphertext = 6;
}
}
@@ -1977,6 +2004,8 @@ message HistorySync {
optional bytes shareableChatIdentifierEncryptionKey = 17;
repeated Account accounts = 18;
optional bytes nctSalt = 19;
repeated InlineContact inlineContacts = 20;
optional bool inlineContactsProvided = 21;
enum BotAIWaitListState {
IN_WAITLIST = 0;
AI_AVAILABLE = 1;
@@ -2071,6 +2100,14 @@ message InThreadSurveyMetadata {
}
message InlineContact {
optional string pnJid = 1;
optional string lidJid = 2;
optional string fullName = 3;
optional string firstName = 4;
optional string username = 5;
}
message InteractiveAnnotation {
repeated Point polygonVertices = 1;
optional bool shouldSkipConfirmation = 4;
@@ -2310,6 +2347,7 @@ message Message {
optional ConditionalRevealMessage conditionalRevealMessage = 120;
optional PollAddOptionMessage pollAddOptionMessage = 121;
optional EventInviteMessage eventInviteMessage = 122;
optional GroupRootKeyShare groupRootKeyShare = 123;
message AlbumMessage {
optional uint32 expectedImageCount = 2;
optional uint32 expectedVideoCount = 3;
@@ -2501,6 +2539,7 @@ message Message {
UNKNOWN = 0;
CONTROL_PASSED = 1;
CONTROL_TAKEN = 2;
INFO = 3;
}
message CloudAPIThreadControlNotificationContent {
optional string handoffNotificationText = 1;
@@ -3282,6 +3321,7 @@ message Message {
optional int32 onDemandMsgCount = 4;
optional int64 oldestMsgTimestampMs = 5;
optional string accountLid = 6;
optional bool supportInlineResponse = 7;
}
message PlaceholderMessageResendRequest {
@@ -3368,6 +3408,7 @@ message Message {
ERROR_REQUEST_ON_NON_SMB_PRIMARY = 4;
ERROR_HOSTED_DEVICE_NOT_CONNECTED = 5;
ERROR_HOSTED_DEVICE_LOGIN_TIME_NOT_SET = 6;
ERROR_MULTI_PROVIDER_NOT_CONFIGURED = 7;
}
message HistorySyncChunkRetryResponse {
optional Message.HistorySyncType syncType = 1;
@@ -3616,7 +3657,6 @@ message Message {
GROUP_MEMBER_LABEL_CHANGE = 30;
AI_MEDIA_COLLECTION_MESSAGE = 31;
MESSAGE_UNSCHEDULE = 32;
BOT_UNLINK_MESSAGE = 33;
}
}
@@ -3767,6 +3807,7 @@ message Message {
optional bool isLottie = 21;
optional string accessibilityLabel = 22;
optional int32 premium = 24;
optional string emojis = 25;
}
message StickerPackMessage {
@@ -3987,6 +4028,7 @@ message MessageContextInfo {
optional LimitSharing limitSharingV2 = 14;
repeated ThreadID threadId = 15;
optional WebLinkRenderConfig weblinkRenderConfig = 16;
optional bytes teeBotMetadata = 17;
enum MessageAddonExpiryType {
STATIC = 1;
DEPENDENT_ON_PARENT = 2;
@@ -4179,6 +4221,8 @@ enum MutationProps {
CUSTOMER_DATA_ACTION = 83;
SUBSCRIPTIONS_SYNC_V2_ACTION = 84;
THREAD_PIN_ACTION = 85;
AUTO_ORGANIZE_BUSINESS_CHAT_SETTING = 86;
BIZ_AI_SETTINGS_NUDGE_ACTION = 87;
SHARE_OWN_PN = 10001;
BUSINESS_BROADCAST_ACTION = 10002;
AI_THREAD_DELETE_ACTION = 10003;
@@ -4492,6 +4536,12 @@ message ReportingTokenInfo {
optional bytes reportingTag = 1;
}
message ScheduledMessageMetadata {
optional string revealKeyId = 1;
optional bytes revealKey = 2;
optional uint64 scheduledTime = 3;
}
message SenderKeyDistributionMessage {
optional uint32 id = 1;
optional uint32 iteration = 2;
@@ -4818,6 +4868,8 @@ message SyncActionValue {
optional CustomerDataAction customerDataAction = 83;
optional SubscriptionsSyncV2Action subscriptionsSyncV2Action = 84;
optional ThreadPinAction threadPinAction = 85;
optional AutoOrganizeBusinessChatSetting autoOrganizeBusinessChatSetting = 86;
optional BizAISettingsNudgeAction bizAiSettingsNudgeAction = 87;
message AgentAction {
optional string name = 1;
optional int32 deviceID = 2;
@@ -4837,6 +4889,10 @@ message SyncActionValue {
optional SyncActionValue.SyncActionMessageRange messageRange = 2;
}
message AutoOrganizeBusinessChatSetting {
optional bool autoOrganize = 1;
}
message AvatarUpdatedAction {
optional AvatarEventType eventType = 1;
repeated SyncActionValue.StickerAction recentAvatarStickers = 2;
@@ -4847,6 +4903,20 @@ message SyncActionValue {
}
}
message BizAISettingsNudgeAction {
optional BizAISettingsCategory category = 1;
optional int64 version = 2;
optional int64 updatedAtMs = 3;
enum BizAISettingsCategory {
UNKNOWN = 0;
INSTRUCTIONS = 1;
RESPONSE_SETTINGS = 2;
EXAMPLE_RESPONSES = 3;
KNOWLEDGE = 4;
LEAD_GEN = 5;
}
}
message BotWelcomeRequestAction {
optional bool isSent = 1;
}
@@ -5022,6 +5092,7 @@ message SyncActionValue {
DRAFTED = 8;
AI_HANDOFF = 9;
CHANNELS = 10;
AI_RESPONDING = 11;
}
}
@@ -5237,6 +5308,8 @@ message SyncActionValue {
optional bool isStatusNotificationEnabled = 29;
optional int32 statusNotificationToneId = 30;
optional bool shouldPlaySoundForCallNotification = 31;
optional string chatThemeId = 32;
optional string colorSchemeId = 33;
enum DisplayMode {
DISPLAY_MODE_UNKNOWN = 0;
ALWAYS = 1;
@@ -5281,6 +5354,8 @@ message SyncActionValue {
IS_STATUS_NOTIFICATION_ENABLED = 29;
STATUS_NOTIFICATION_TONE_ID = 30;
SHOULD_PLAY_SOUND_FOR_CALL_NOTIFICATION = 31;
CHAT_THEME_ID = 32;
COLOR_SCHEME_ID = 33;
}
enum SettingPlatform {
PLATFORM_UNKNOWN = 0;
@@ -5304,11 +5379,22 @@ message SyncActionValue {
repeated string userJid = 2;
optional bool shareToFB = 3;
optional bool shareToIG = 4;
repeated CustomList customLists = 5;
repeated StatusDistributionMode modes = 6;
message CustomList {
optional string listId = 1;
optional string name = 2;
optional string emoji = 3;
optional bool isSelected = 4;
repeated string userJid = 5;
}
enum StatusDistributionMode {
ALLOW_LIST = 0;
DENY_LIST = 1;
CONTACTS = 2;
CLOSE_FRIENDS = 3;
CUSTOM_LIST = 4;
}
}
@@ -5695,6 +5781,8 @@ message WebMessageInfo {
optional QuarantinedMessage quarantinedMessage = 77;
optional uint32 nonJidMentions = 78;
optional string hsmTag = 79;
optional uint64 ephemeralExpirationTimestamp = 80;
optional ScheduledMessageMetadata scheduledMessageMetadata = 81;
enum BizPrivacyStatus {
E2EE = 0;
FB = 2;
@@ -5934,6 +6022,8 @@ message WebMessageInfo {
GROUP_MEMBER_SHARE_GROUP_HISTORY_MODE = 221;
GROUP_OPEN_BOT_ADDED = 222;
GROUP_TEE_BOT_ADDED = 223;
CONTACT_INFO = 224;
SCHEDULED_MESSAGE_CREATED = 225;
}
}
+26 -18
View File
@@ -18,14 +18,21 @@ try {
'\tif (typeof value === "number") {\n' +
'\t\treturn String(value);\n' +
'\t}\n' +
'\tif (!$util.Long) {\n' +
'\t\treturn String(value);\n' +
'\t// Fast path: convert Long {low, high} directly via native BigInt\n' +
'\t// BigInt.toString() is a native C++ operation, much faster than Long\'s pure JS division loops\n' +
'\tif (value && typeof value.low === "number" && typeof value.high === "number") {\n' +
'\t\t// Normalize high to signed int32 so the sign-bit check works for inputs\n' +
'\t\t// where high is stored unsigned (e.g. raw {low, high} JSON).\n' +
'\t\tconst high = value.high | 0;\n' +
'\t\tconst lo = BigInt(value.low >>> 0);\n' +
'\t\tconst hi = BigInt(value.high >>> 0);\n' +
'\t\tconst combined = (hi << 32n) | lo;\n' +
'\t\tif (!unsigned && high < 0) {\n' +
'\t\t\treturn (combined - (1n << 64n)).toString();\n' +
'\t\t}\n' +
'\t\treturn combined.toString();\n' +
'\t}\n' +
'\tconst normalized = $util.Long.fromValue(value);\n' +
'\tconst prepared = unsigned && normalized && typeof normalized.toUnsigned === "function"\n' +
'\t\t? normalized.toUnsigned()\n' +
'\t\t: normalized;\n' +
'\treturn prepared.toString();\n' +
'\treturn String(value);\n' +
'}\n\n'
const longToNumberHelper =
'function longToNumber(value, unsigned) {\n' +
@@ -33,19 +40,20 @@ try {
'\t\treturn value;\n' +
'\t}\n' +
'\tif (typeof value === "string") {\n' +
'\t\tconst numeric = Number(value);\n' +
'\t\treturn numeric;\n' +
'\t}\n' +
'\tif (!$util.Long) {\n' +
'\t\treturn Number(value);\n' +
'\t}\n' +
'\tconst normalized = $util.Long.fromValue(value);\n' +
'\tconst prepared = unsigned && normalized && typeof normalized.toUnsigned === "function"\n' +
'\t\t? normalized.toUnsigned()\n' +
'\t\t: typeof normalized.toSigned === "function"\n' +
'\t\t\t? normalized.toSigned()\n' +
'\t\t\t: normalized;\n' +
'\treturn prepared.toNumber();\n' +
'\t// Fast path: convert Long {low, high} directly via native BigInt\n' +
'\tif (value && typeof value.low === "number" && typeof value.high === "number") {\n' +
'\t\tconst high = value.high | 0;\n' +
'\t\tconst lo = BigInt(value.low >>> 0);\n' +
'\t\tconst hi = BigInt(value.high >>> 0);\n' +
'\t\tconst combined = (hi << 32n) | lo;\n' +
'\t\tif (!unsigned && high < 0) {\n' +
'\t\t\treturn Number(combined - (1n << 64n));\n' +
'\t\t}\n' +
'\t\treturn Number(combined);\n' +
'\t}\n' +
'\treturn Number(value);\n' +
'}\n\n'
if (!content.includes('function longToString(')) {
+239 -9
View File
@@ -1097,6 +1097,26 @@ export namespace proto {
}
}
interface IBotCommandMetadata {
commandName?: (string|null);
commandDescription?: (string|null);
commandPrompt?: (string|null);
}
class BotCommandMetadata implements IBotCommandMetadata {
constructor(p?: proto.IBotCommandMetadata);
public commandName?: (string|null);
public commandDescription?: (string|null);
public commandPrompt?: (string|null);
public static create(properties?: proto.IBotCommandMetadata): proto.BotCommandMetadata;
public static encode(m: proto.IBotCommandMetadata, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.BotCommandMetadata;
public static fromObject(d: { [k: string]: any }): proto.BotCommandMetadata;
public static toObject(m: proto.BotCommandMetadata, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface IBotDocumentMessageMetadata {
pluginType?: (proto.BotDocumentMessageMetadata.DocumentPluginType|null);
}
@@ -1681,6 +1701,7 @@ export namespace proto {
botRenderingConfigMetadata?: (proto.IBotRenderingConfigMetadata|null);
botInfrastructureDiagnostics?: (proto.IBotInfrastructureDiagnostics|null);
aiMediaCollectionMetadata?: (proto.IAIMediaCollectionMetadata|null);
commandMetadata?: (proto.IBotCommandMetadata|null);
internalMetadata?: (Uint8Array|null);
}
@@ -1723,6 +1744,7 @@ export namespace proto {
public botRenderingConfigMetadata?: (proto.IBotRenderingConfigMetadata|null);
public botInfrastructureDiagnostics?: (proto.IBotInfrastructureDiagnostics|null);
public aiMediaCollectionMetadata?: (proto.IAIMediaCollectionMetadata|null);
public commandMetadata?: (proto.IBotCommandMetadata|null);
public internalMetadata?: (Uint8Array|null);
public static create(properties?: proto.IBotMetadata): proto.BotMetadata;
public static encode(m: proto.IBotMetadata, w?: $protobuf.Writer): $protobuf.Writer;
@@ -1780,7 +1802,8 @@ export namespace proto {
WEB_INTRO_PANEL = 46,
WEB_NAVIGATION_BAR = 47,
GROUP_MEMBER = 54,
CHATLIST_SEARCH = 55
CHATLIST_SEARCH = 55,
NEW_CHAT_LIST = 56
}
interface IBotMetricsMetadata {
@@ -3592,6 +3615,10 @@ export namespace proto {
wtwaWebsiteUrl?: (string|null);
adPreviewUrl?: (string|null);
containsCtwaFlowsAutoReply?: (boolean|null);
agmThumbnailStrategy?: (number|null);
agmTitleStrategy?: (number|null);
agmSubtitleStrategy?: (number|null);
agmHeaderInteractionStrategy?: (number|null);
}
class ExternalAdReplyInfo implements IExternalAdReplyInfo {
@@ -3624,6 +3651,10 @@ export namespace proto {
public wtwaWebsiteUrl?: (string|null);
public adPreviewUrl?: (string|null);
public containsCtwaFlowsAutoReply?: (boolean|null);
public agmThumbnailStrategy?: (number|null);
public agmTitleStrategy?: (number|null);
public agmSubtitleStrategy?: (number|null);
public agmHeaderInteractionStrategy?: (number|null);
public static create(properties?: proto.ContextInfo.IExternalAdReplyInfo): proto.ContextInfo.ExternalAdReplyInfo;
public static encode(m: proto.ContextInfo.IExternalAdReplyInfo, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.ContextInfo.ExternalAdReplyInfo;
@@ -4028,6 +4059,7 @@ export namespace proto {
companionSupportEnabled?: (boolean|null);
campaignSyncEnabled?: (boolean|null);
insightsSyncEnabled?: (boolean|null);
recipientLimit?: (number|null);
}
class BusinessBroadcast implements IBusinessBroadcast {
@@ -4036,6 +4068,7 @@ export namespace proto {
public companionSupportEnabled?: (boolean|null);
public campaignSyncEnabled?: (boolean|null);
public insightsSyncEnabled?: (boolean|null);
public recipientLimit?: (number|null);
public static create(properties?: proto.DeviceCapabilities.IBusinessBroadcast): proto.DeviceCapabilities.BusinessBroadcast;
public static encode(m: proto.DeviceCapabilities.IBusinessBroadcast, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.DeviceCapabilities.BusinessBroadcast;
@@ -4211,6 +4244,8 @@ export namespace proto {
initialSyncMaxMessagesPerChat?: (number|null);
supportManusHistory?: (boolean|null);
supportHatchHistory?: (boolean|null);
supportedBotChannelFbids?: (string[]|null);
supportInlineContacts?: (boolean|null);
}
class HistorySyncConfig implements IHistorySyncConfig {
@@ -4237,6 +4272,8 @@ export namespace proto {
public initialSyncMaxMessagesPerChat?: (number|null);
public supportManusHistory?: (boolean|null);
public supportHatchHistory?: (boolean|null);
public supportedBotChannelFbids: string[];
public supportInlineContacts?: (boolean|null);
public static create(properties?: proto.DeviceProps.IHistorySyncConfig): proto.DeviceProps.HistorySyncConfig;
public static encode(m: proto.DeviceProps.IHistorySyncConfig, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.DeviceProps.HistorySyncConfig;
@@ -4705,6 +4742,42 @@ export namespace proto {
}
}
interface IGroupRootKeyShare {
keys?: (proto.IGroupRootKeyShareEntry[]|null);
}
class GroupRootKeyShare implements IGroupRootKeyShare {
constructor(p?: proto.IGroupRootKeyShare);
public keys: proto.IGroupRootKeyShareEntry[];
public static create(properties?: proto.IGroupRootKeyShare): proto.GroupRootKeyShare;
public static encode(m: proto.IGroupRootKeyShare, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.GroupRootKeyShare;
public static fromObject(d: { [k: string]: any }): proto.GroupRootKeyShare;
public static toObject(m: proto.GroupRootKeyShare, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface IGroupRootKeyShareEntry {
groupRootKey?: (Uint8Array|null);
keyId?: (string|null);
expiryTimestampMs?: (number|Long|null);
}
class GroupRootKeyShareEntry implements IGroupRootKeyShareEntry {
constructor(p?: proto.IGroupRootKeyShareEntry);
public groupRootKey?: (Uint8Array|null);
public keyId?: (string|null);
public expiryTimestampMs?: (number|Long|null);
public static create(properties?: proto.IGroupRootKeyShareEntry): proto.GroupRootKeyShareEntry;
public static encode(m: proto.IGroupRootKeyShareEntry, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.GroupRootKeyShareEntry;
public static fromObject(d: { [k: string]: any }): proto.GroupRootKeyShareEntry;
public static toObject(m: proto.GroupRootKeyShareEntry, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface IHandshakeMessage {
clientHello?: (proto.HandshakeMessage.IClientHello|null);
serverHello?: (proto.HandshakeMessage.IServerHello|null);
@@ -4761,6 +4834,7 @@ export namespace proto {
sendServerHelloPaddedBytes?: (boolean|null);
simulateXxkemFs?: (boolean|null);
pqMode?: (proto.HandshakeMessage.HandshakePqMode|null);
extendedEphemeral?: (Uint8Array|null);
}
class ClientHello implements IClientHello {
@@ -4774,6 +4848,7 @@ export namespace proto {
public sendServerHelloPaddedBytes?: (boolean|null);
public simulateXxkemFs?: (boolean|null);
public pqMode?: (proto.HandshakeMessage.HandshakePqMode|null);
public extendedEphemeral?: (Uint8Array|null);
public static create(properties?: proto.HandshakeMessage.IClientHello): proto.HandshakeMessage.ClientHello;
public static encode(m: proto.HandshakeMessage.IClientHello, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.HandshakeMessage.ClientHello;
@@ -4801,6 +4876,7 @@ export namespace proto {
payload?: (Uint8Array|null);
extendedStatic?: (Uint8Array|null);
paddingBytes?: (Uint8Array|null);
extendedCiphertext?: (Uint8Array|null);
}
class ServerHello implements IServerHello {
@@ -4810,6 +4886,7 @@ export namespace proto {
public payload?: (Uint8Array|null);
public extendedStatic?: (Uint8Array|null);
public paddingBytes?: (Uint8Array|null);
public extendedCiphertext?: (Uint8Array|null);
public static create(properties?: proto.HandshakeMessage.IServerHello): proto.HandshakeMessage.ServerHello;
public static encode(m: proto.HandshakeMessage.IServerHello, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.HandshakeMessage.ServerHello;
@@ -4839,6 +4916,8 @@ export namespace proto {
shareableChatIdentifierEncryptionKey?: (Uint8Array|null);
accounts?: (proto.IAccount[]|null);
nctSalt?: (Uint8Array|null);
inlineContacts?: (proto.IInlineContact[]|null);
inlineContactsProvided?: (boolean|null);
}
class HistorySync implements IHistorySync {
@@ -4861,6 +4940,8 @@ export namespace proto {
public shareableChatIdentifierEncryptionKey?: (Uint8Array|null);
public accounts: proto.IAccount[];
public nctSalt?: (Uint8Array|null);
public inlineContacts: proto.IInlineContact[];
public inlineContactsProvided?: (boolean|null);
public static create(properties?: proto.IHistorySync): proto.HistorySync;
public static encode(m: proto.IHistorySync, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.HistorySync;
@@ -5128,6 +5209,30 @@ export namespace proto {
}
}
interface IInlineContact {
pnJid?: (string|null);
lidJid?: (string|null);
fullName?: (string|null);
firstName?: (string|null);
username?: (string|null);
}
class InlineContact implements IInlineContact {
constructor(p?: proto.IInlineContact);
public pnJid?: (string|null);
public lidJid?: (string|null);
public fullName?: (string|null);
public firstName?: (string|null);
public username?: (string|null);
public static create(properties?: proto.IInlineContact): proto.InlineContact;
public static encode(m: proto.IInlineContact, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.InlineContact;
public static fromObject(d: { [k: string]: any }): proto.InlineContact;
public static toObject(m: proto.InlineContact, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface IInteractiveAnnotation {
polygonVertices?: (proto.IPoint[]|null);
shouldSkipConfirmation?: (boolean|null);
@@ -5619,6 +5724,7 @@ export namespace proto {
conditionalRevealMessage?: (proto.Message.IConditionalRevealMessage|null);
pollAddOptionMessage?: (proto.Message.IPollAddOptionMessage|null);
eventInviteMessage?: (proto.Message.IEventInviteMessage|null);
groupRootKeyShare?: (proto.IGroupRootKeyShare|null);
}
class Message implements IMessage {
@@ -5725,6 +5831,7 @@ export namespace proto {
public conditionalRevealMessage?: (proto.Message.IConditionalRevealMessage|null);
public pollAddOptionMessage?: (proto.Message.IPollAddOptionMessage|null);
public eventInviteMessage?: (proto.Message.IEventInviteMessage|null);
public groupRootKeyShare?: (proto.IGroupRootKeyShare|null);
public static create(properties?: proto.IMessage): proto.Message;
public static encode(m: proto.IMessage, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.Message;
@@ -6264,7 +6371,8 @@ export namespace proto {
enum CloudAPIThreadControl {
UNKNOWN = 0,
CONTROL_PASSED = 1,
CONTROL_TAKEN = 2
CONTROL_TAKEN = 2,
INFO = 3
}
interface ICloudAPIThreadControlNotificationContent {
@@ -8372,6 +8480,7 @@ export namespace proto {
onDemandMsgCount?: (number|null);
oldestMsgTimestampMs?: (number|Long|null);
accountLid?: (string|null);
supportInlineResponse?: (boolean|null);
}
class HistorySyncOnDemandRequest implements IHistorySyncOnDemandRequest {
@@ -8382,6 +8491,7 @@ export namespace proto {
public onDemandMsgCount?: (number|null);
public oldestMsgTimestampMs?: (number|Long|null);
public accountLid?: (string|null);
public supportInlineResponse?: (boolean|null);
public static create(properties?: proto.Message.PeerDataOperationRequestMessage.IHistorySyncOnDemandRequest): proto.Message.PeerDataOperationRequestMessage.HistorySyncOnDemandRequest;
public static encode(m: proto.Message.PeerDataOperationRequestMessage.IHistorySyncOnDemandRequest, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.Message.PeerDataOperationRequestMessage.HistorySyncOnDemandRequest;
@@ -8655,7 +8765,8 @@ export namespace proto {
GENERIC_ERROR = 3,
ERROR_REQUEST_ON_NON_SMB_PRIMARY = 4,
ERROR_HOSTED_DEVICE_NOT_CONNECTED = 5,
ERROR_HOSTED_DEVICE_LOGIN_TIME_NOT_SET = 6
ERROR_HOSTED_DEVICE_LOGIN_TIME_NOT_SET = 6,
ERROR_MULTI_PROVIDER_NOT_CONFIGURED = 7
}
interface IHistorySyncChunkRetryResponse {
@@ -9281,8 +9392,7 @@ export namespace proto {
AI_QUERY_FANOUT = 29,
GROUP_MEMBER_LABEL_CHANGE = 30,
AI_MEDIA_COLLECTION_MESSAGE = 31,
MESSAGE_UNSCHEDULE = 32,
BOT_UNLINK_MESSAGE = 33
MESSAGE_UNSCHEDULE = 32
}
}
@@ -9661,6 +9771,7 @@ export namespace proto {
isLottie?: (boolean|null);
accessibilityLabel?: (string|null);
premium?: (number|null);
emojis?: (string|null);
}
class StickerMessage implements IStickerMessage {
@@ -9686,6 +9797,7 @@ export namespace proto {
public isLottie?: (boolean|null);
public accessibilityLabel?: (string|null);
public premium?: (number|null);
public emojis?: (string|null);
public static create(properties?: proto.Message.IStickerMessage): proto.Message.StickerMessage;
public static encode(m: proto.Message.IStickerMessage, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.Message.StickerMessage;
@@ -10181,6 +10293,7 @@ export namespace proto {
limitSharingV2?: (proto.ILimitSharing|null);
threadId?: (proto.IThreadID[]|null);
weblinkRenderConfig?: (proto.WebLinkRenderConfig|null);
teeBotMetadata?: (Uint8Array|null);
}
class MessageContextInfo implements IMessageContextInfo {
@@ -10201,6 +10314,7 @@ export namespace proto {
public limitSharingV2?: (proto.ILimitSharing|null);
public threadId: proto.IThreadID[];
public weblinkRenderConfig?: (proto.WebLinkRenderConfig|null);
public teeBotMetadata?: (Uint8Array|null);
public static create(properties?: proto.IMessageContextInfo): proto.MessageContextInfo;
public static encode(m: proto.IMessageContextInfo, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.MessageContextInfo;
@@ -10578,6 +10692,8 @@ export namespace proto {
CUSTOMER_DATA_ACTION = 83,
SUBSCRIPTIONS_SYNC_V2_ACTION = 84,
THREAD_PIN_ACTION = 85,
AUTO_ORGANIZE_BUSINESS_CHAT_SETTING = 86,
BIZ_AI_SETTINGS_NUDGE_ACTION = 87,
SHARE_OWN_PN = 10001,
BUSINESS_BROADCAST_ACTION = 10002,
AI_THREAD_DELETE_ACTION = 10003
@@ -11389,6 +11505,26 @@ export namespace proto {
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface IScheduledMessageMetadata {
revealKeyId?: (string|null);
revealKey?: (Uint8Array|null);
scheduledTime?: (number|Long|null);
}
class ScheduledMessageMetadata implements IScheduledMessageMetadata {
constructor(p?: proto.IScheduledMessageMetadata);
public revealKeyId?: (string|null);
public revealKey?: (Uint8Array|null);
public scheduledTime?: (number|Long|null);
public static create(properties?: proto.IScheduledMessageMetadata): proto.ScheduledMessageMetadata;
public static encode(m: proto.IScheduledMessageMetadata, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.ScheduledMessageMetadata;
public static fromObject(d: { [k: string]: any }): proto.ScheduledMessageMetadata;
public static toObject(m: proto.ScheduledMessageMetadata, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface ISenderKeyDistributionMessage {
id?: (number|null);
iteration?: (number|null);
@@ -12175,6 +12311,8 @@ export namespace proto {
customerDataAction?: (proto.SyncActionValue.ICustomerDataAction|null);
subscriptionsSyncV2Action?: (proto.SyncActionValue.ISubscriptionsSyncV2Action|null);
threadPinAction?: (proto.SyncActionValue.IThreadPinAction|null);
autoOrganizeBusinessChatSetting?: (proto.SyncActionValue.IAutoOrganizeBusinessChatSetting|null);
bizAiSettingsNudgeAction?: (proto.SyncActionValue.IBizAISettingsNudgeAction|null);
}
class SyncActionValue implements ISyncActionValue {
@@ -12255,6 +12393,8 @@ export namespace proto {
public customerDataAction?: (proto.SyncActionValue.ICustomerDataAction|null);
public subscriptionsSyncV2Action?: (proto.SyncActionValue.ISubscriptionsSyncV2Action|null);
public threadPinAction?: (proto.SyncActionValue.IThreadPinAction|null);
public autoOrganizeBusinessChatSetting?: (proto.SyncActionValue.IAutoOrganizeBusinessChatSetting|null);
public bizAiSettingsNudgeAction?: (proto.SyncActionValue.IBizAISettingsNudgeAction|null);
public static create(properties?: proto.ISyncActionValue): proto.SyncActionValue;
public static encode(m: proto.ISyncActionValue, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue;
@@ -12336,6 +12476,22 @@ export namespace proto {
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface IAutoOrganizeBusinessChatSetting {
autoOrganize?: (boolean|null);
}
class AutoOrganizeBusinessChatSetting implements IAutoOrganizeBusinessChatSetting {
constructor(p?: proto.SyncActionValue.IAutoOrganizeBusinessChatSetting);
public autoOrganize?: (boolean|null);
public static create(properties?: proto.SyncActionValue.IAutoOrganizeBusinessChatSetting): proto.SyncActionValue.AutoOrganizeBusinessChatSetting;
public static encode(m: proto.SyncActionValue.IAutoOrganizeBusinessChatSetting, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.AutoOrganizeBusinessChatSetting;
public static fromObject(d: { [k: string]: any }): proto.SyncActionValue.AutoOrganizeBusinessChatSetting;
public static toObject(m: proto.SyncActionValue.AutoOrganizeBusinessChatSetting, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface IAvatarUpdatedAction {
eventType?: (proto.SyncActionValue.AvatarUpdatedAction.AvatarEventType|null);
recentAvatarStickers?: (proto.SyncActionValue.IStickerAction[]|null);
@@ -12363,6 +12519,38 @@ export namespace proto {
}
}
interface IBizAISettingsNudgeAction {
category?: (proto.SyncActionValue.BizAISettingsNudgeAction.BizAISettingsCategory|null);
version?: (number|Long|null);
updatedAtMs?: (number|Long|null);
}
class BizAISettingsNudgeAction implements IBizAISettingsNudgeAction {
constructor(p?: proto.SyncActionValue.IBizAISettingsNudgeAction);
public category?: (proto.SyncActionValue.BizAISettingsNudgeAction.BizAISettingsCategory|null);
public version?: (number|Long|null);
public updatedAtMs?: (number|Long|null);
public static create(properties?: proto.SyncActionValue.IBizAISettingsNudgeAction): proto.SyncActionValue.BizAISettingsNudgeAction;
public static encode(m: proto.SyncActionValue.IBizAISettingsNudgeAction, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.BizAISettingsNudgeAction;
public static fromObject(d: { [k: string]: any }): proto.SyncActionValue.BizAISettingsNudgeAction;
public static toObject(m: proto.SyncActionValue.BizAISettingsNudgeAction, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
namespace BizAISettingsNudgeAction {
enum BizAISettingsCategory {
UNKNOWN = 0,
INSTRUCTIONS = 1,
RESPONSE_SETTINGS = 2,
EXAMPLE_RESPONSES = 3,
KNOWLEDGE = 4,
LEAD_GEN = 5
}
}
interface IBotWelcomeRequestAction {
isSent?: (boolean|null);
}
@@ -12920,7 +13108,8 @@ export namespace proto {
SERVER_ASSIGNED = 7,
DRAFTED = 8,
AI_HANDOFF = 9,
CHANNELS = 10
CHANNELS = 10,
AI_RESPONDING = 11
}
}
@@ -13562,6 +13751,8 @@ export namespace proto {
isStatusNotificationEnabled?: (boolean|null);
statusNotificationToneId?: (number|null);
shouldPlaySoundForCallNotification?: (boolean|null);
chatThemeId?: (string|null);
colorSchemeId?: (string|null);
}
class SettingsSyncAction implements ISettingsSyncAction {
@@ -13597,6 +13788,8 @@ export namespace proto {
public isStatusNotificationEnabled?: (boolean|null);
public statusNotificationToneId?: (number|null);
public shouldPlaySoundForCallNotification?: (boolean|null);
public chatThemeId?: (string|null);
public colorSchemeId?: (string|null);
public static create(properties?: proto.SyncActionValue.ISettingsSyncAction): proto.SyncActionValue.SettingsSyncAction;
public static encode(m: proto.SyncActionValue.ISettingsSyncAction, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.SettingsSyncAction;
@@ -13653,7 +13846,9 @@ export namespace proto {
IS_GROUP_REACTIONS_NOTIFICATION_ENABLED = 28,
IS_STATUS_NOTIFICATION_ENABLED = 29,
STATUS_NOTIFICATION_TONE_ID = 30,
SHOULD_PLAY_SOUND_FOR_CALL_NOTIFICATION = 31
SHOULD_PLAY_SOUND_FOR_CALL_NOTIFICATION = 31,
CHAT_THEME_ID = 32,
COLOR_SCHEME_ID = 33
}
enum SettingPlatform {
@@ -13702,6 +13897,8 @@ export namespace proto {
userJid?: (string[]|null);
shareToFB?: (boolean|null);
shareToIG?: (boolean|null);
customLists?: (proto.SyncActionValue.StatusPrivacyAction.ICustomList[]|null);
modes?: (proto.SyncActionValue.StatusPrivacyAction.StatusDistributionMode[]|null);
}
class StatusPrivacyAction implements IStatusPrivacyAction {
@@ -13710,6 +13907,8 @@ export namespace proto {
public userJid: string[];
public shareToFB?: (boolean|null);
public shareToIG?: (boolean|null);
public customLists: proto.SyncActionValue.StatusPrivacyAction.ICustomList[];
public modes: proto.SyncActionValue.StatusPrivacyAction.StatusDistributionMode[];
public static create(properties?: proto.SyncActionValue.IStatusPrivacyAction): proto.SyncActionValue.StatusPrivacyAction;
public static encode(m: proto.SyncActionValue.IStatusPrivacyAction, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.StatusPrivacyAction;
@@ -13721,11 +13920,36 @@ export namespace proto {
namespace StatusPrivacyAction {
interface ICustomList {
listId?: (string|null);
name?: (string|null);
emoji?: (string|null);
isSelected?: (boolean|null);
userJid?: (string[]|null);
}
class CustomList implements ICustomList {
constructor(p?: proto.SyncActionValue.StatusPrivacyAction.ICustomList);
public listId?: (string|null);
public name?: (string|null);
public emoji?: (string|null);
public isSelected?: (boolean|null);
public userJid: string[];
public static create(properties?: proto.SyncActionValue.StatusPrivacyAction.ICustomList): proto.SyncActionValue.StatusPrivacyAction.CustomList;
public static encode(m: proto.SyncActionValue.StatusPrivacyAction.ICustomList, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.StatusPrivacyAction.CustomList;
public static fromObject(d: { [k: string]: any }): proto.SyncActionValue.StatusPrivacyAction.CustomList;
public static toObject(m: proto.SyncActionValue.StatusPrivacyAction.CustomList, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
enum StatusDistributionMode {
ALLOW_LIST = 0,
DENY_LIST = 1,
CONTACTS = 2,
CLOSE_FRIENDS = 3
CLOSE_FRIENDS = 3,
CUSTOM_LIST = 4
}
}
@@ -14733,6 +14957,8 @@ export namespace proto {
quarantinedMessage?: (proto.IQuarantinedMessage|null);
nonJidMentions?: (number|null);
hsmTag?: (string|null);
ephemeralExpirationTimestamp?: (number|Long|null);
scheduledMessageMetadata?: (proto.IScheduledMessageMetadata|null);
}
class WebMessageInfo implements IWebMessageInfo {
@@ -14805,6 +15031,8 @@ export namespace proto {
public quarantinedMessage?: (proto.IQuarantinedMessage|null);
public nonJidMentions?: (number|null);
public hsmTag?: (string|null);
public ephemeralExpirationTimestamp?: (number|Long|null);
public scheduledMessageMetadata?: (proto.IScheduledMessageMetadata|null);
public static create(properties?: proto.IWebMessageInfo): proto.WebMessageInfo;
public static encode(m: proto.IWebMessageInfo, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.WebMessageInfo;
@@ -15056,7 +15284,9 @@ export namespace proto {
QUARANTINED_MESSAGE = 220,
GROUP_MEMBER_SHARE_GROUP_HISTORY_MODE = 221,
GROUP_OPEN_BOT_ADDED = 222,
GROUP_TEE_BOT_ADDED = 223
GROUP_TEE_BOT_ADDED = 223,
CONTACT_INFO = 224,
SCHEDULED_MESSAGE_CREATED = 225
}
}
+1807 -23
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+531
View File
@@ -0,0 +1,531 @@
# Mensagens View-Once (Visualização Única) — Guia de Implementação
**Data:** 2026-03-23
**Versão:** 1.0
---
## 1. O que é View-Once
Mensagem de visualização única é um tipo especial de mídia (imagem, vídeo ou áudio) que o destinatário pode abrir apenas uma vez. Após abrir, o conteúdo some permanentemente.
---
> ## ⚠️ RESTRIÇÃO CRÍTICA — Contas Hosted (Meta Business Cloud API)
>
> **Contas registradas como "hosted" na Meta Business Cloud API têm view-once bloqueado pelo servidor do WhatsApp.**
>
> Essa restrição se aplica a **todos os clientes** (Android, iOS, WA Web) e **não há como contornar por código** — é uma política imposta diretamente pelo servidor WA para contas hosted.
>
> **Como identificar se sua conta é hosted:**
> - A conta foi criada via Meta Business Cloud API (WABA)
> - Aparece como "Meta" ou "Business Cloud" nas configurações do WhatsApp Business Manager
>
> **Contas não-hosted** (registradas diretamente via QR code ou pair code) **funcionam normalmente** com view-once.
---
**Comportamento em cada dispositivo após envio via API (contas não-hosted):**
| Dispositivo | O que aparece |
|---|---|
| Destinatário (Android/iOS) | Recebe a mídia com ícone de olhinho — pode abrir uma vez |
| Android principal do remetente | Mostra na conversa que a mensagem foi enviada (ícone de olhinho) |
| WA Web do remetente | "Aguardando mensagem. Abra o WhatsApp no seu celular." |
> **Nota:** A mensagem "Aguardando mensagem..." no WA Web é o comportamento correto e esperado quando o envio vem de um companion (API). É uma limitação do protocolo WhatsApp — o servidor só permite que o celular principal notifique companions com o conteúdo da view-once.
---
## 2. Como Enviar via API
### 2.1 Imagem
```bash
curl -X POST https://sua-api.com/v1/messages/send_image \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"caption": "Olha só isso!",
"imageUrl": "https://exemplo.com/foto.jpg",
"viewOnce": true
}'
```
**Resposta de sucesso:**
```json
{ "ok": true }
```
### 2.2 Vídeo
```bash
curl -X POST https://sua-api.com/v1/messages/send_video \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"caption": "Assista uma vez",
"videoUrl": "https://exemplo.com/video.mp4",
"viewOnce": true
}'
```
### 2.3 Áudio
```bash
curl -X POST https://sua-api.com/v1/messages/send_audio \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"audioUrl": "https://exemplo.com/audio.ogg",
"ptt": false,
"viewOnce": true
}'
```
### 2.4 Usando Base64 (sem URL pública)
```bash
curl -X POST https://sua-api.com/v1/messages/send_image \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"imageBase64": "data:image/jpeg;base64,/9j/4AAQSkZJRgAB...",
"viewOnce": true
}'
```
---
## 3. Como Receber via Webhook
Quando um contato envia uma mensagem de visualização única para o número conectado na API, o webhook recebe o evento com `key.isViewOnce = true`.
### 3.1 Estrutura do evento recebido
```json
{
"event": "messages.upsert",
"instance": "nome-da-instancia",
"data": {
"messages": [
{
"key": {
"remoteJid": "5511999999999@s.whatsapp.net",
"fromMe": false,
"id": "3EB0ABCDEF123456",
"isViewOnce": true
},
"message": {
"viewOnceMessageV2": {
"message": {
"imageMessage": {
"url": "https://mmg.whatsapp.net/...",
"mimetype": "image/jpeg",
"mediaKey": "base64...",
"fileEncSha256": "base64...",
"directPath": "/v/...",
"viewOnce": true
}
}
}
},
"messageTimestamp": 1742700000,
"pushName": "João Silva"
}
],
"type": "notify"
}
}
```
### 3.2 Como identificar que é view-once
O campo principal para identificar é `key.isViewOnce = true`. Existem dois caminhos para confirmar:
```javascript
function isViewOnce(msg) {
// Caminho 1: flag direta (mais simples)
if (msg.key?.isViewOnce) return true
// Caminho 2: verificar o wrapper da mensagem
const inner =
msg.message?.viewOnceMessageV2?.message ||
msg.message?.viewOnceMessage?.message ||
msg.message?.viewOnceMessageV2Extension?.message
return !!(
inner?.imageMessage?.viewOnce ||
inner?.videoMessage?.viewOnce ||
inner?.audioMessage?.viewOnce
)
}
```
### 3.3 Como acessar a mídia
```javascript
function getViewOnceMedia(msg) {
const inner =
msg.message?.viewOnceMessageV2?.message ||
msg.message?.viewOnceMessage?.message ||
msg.message?.viewOnceMessageV2Extension?.message
if (!inner) return null
if (inner.imageMessage) return { type: 'image', media: inner.imageMessage }
if (inner.videoMessage) return { type: 'video', media: inner.videoMessage }
if (inner.audioMessage) return { type: 'audio', media: inner.audioMessage }
return null
}
// Uso:
const media = getViewOnceMedia(msg)
if (media) {
console.log(`View-once ${media.type}`)
console.log('URL:', media.media.url)
console.log('DirectPath:', media.media.directPath)
console.log('MediaKey:', media.media.mediaKey) // necessário para download
}
```
---
## 4. Como Exibir numa Ferramenta (Frontend/CRM)
### 4.1 Lógica de detecção e exibição
```javascript
function renderMessage(msg) {
if (!isViewOnce(msg)) {
// Renderizar mensagem normal
return renderNormalMessage(msg)
}
const media = getViewOnceMedia(msg)
if (!media) return
// Verificar se já foi aberta (controle local da ferramenta)
const alreadyOpened = localStorage.getItem(`vo_${msg.key.id}`)
if (alreadyOpened) {
// Mostrar placeholder de "já visualizado"
return renderViewOncePlaceholder(media.type, msg.key.fromMe)
}
if (msg.key.fromMe) {
// Mensagem enviada por mim — nunca mostrar conteúdo
return renderSentViewOnce(media.type)
}
// Mensagem recebida — mostrar botão para "abrir uma vez"
return renderViewOnceButton(media.type, msg)
}
```
### 4.2 Componente de exibição — mensagem recebida
```javascript
function renderViewOnceButton(type, msg) {
const icons = { image: '📷', video: '🎥', audio: '🎵' }
const labels = { image: 'Foto', video: 'Vídeo', áudio: 'Áudio' }
return `
<div class="view-once-bubble received">
<span class="view-once-icon">${icons[type]}</span>
<span class="view-once-label">${labels[type]} de visualização única</span>
<button onclick="openViewOnce('${msg.key.id}', '${type}')">
Abrir
</button>
</div>
`
}
async function openViewOnce(msgId, type) {
// 1. Marcar como aberta ANTES de mostrar (só uma chance)
localStorage.setItem(`vo_${msgId}`, '1')
// 2. Baixar e mostrar a mídia
const mediaUrl = await downloadViewOnceMedia(msgId)
showMedia(type, mediaUrl)
// 3. Após fechar/visualizar, substituir pelo placeholder
onMediaClosed(() => {
replaceWithPlaceholder(msgId, type)
})
}
```
### 4.3 Componente de exibição — mensagem enviada
```javascript
function renderSentViewOnce(type) {
const labels = { image: 'Foto', video: 'Vídeo', audio: 'Áudio' }
return `
<div class="view-once-bubble sent">
<span class="view-once-icon">👁️</span>
<span>${labels[type]} de visualização única</span>
<span class="view-once-status">Enviada</span>
</div>
`
}
```
### 4.4 Placeholder após visualização
```javascript
function renderViewOncePlaceholder(type, fromMe) {
const labels = { image: 'foto', video: 'vídeo', audio: 'áudio' }
const text = fromMe
? `Você enviou uma ${labels[type]} de visualização única`
: `${labels[type]} de visualização única aberta`
return `
<div class="view-once-bubble placeholder">
<span class="view-once-icon">🚫</span>
<span>${text}</span>
</div>
`
}
```
### 4.5 CSS sugerido
```css
.view-once-bubble {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 12px;
max-width: 280px;
}
.view-once-bubble.received {
background: #f0f0f0;
color: #333;
}
.view-once-bubble.sent {
background: #dcf8c6;
color: #333;
align-self: flex-end;
}
.view-once-bubble.placeholder {
background: #e8e8e8;
color: #999;
font-style: italic;
}
.view-once-bubble button {
background: #25d366;
color: white;
border: none;
border-radius: 8px;
padding: 6px 12px;
cursor: pointer;
font-size: 13px;
}
```
---
## 5. Código Implementado na API
### 5.1 Geração da mensagem — `src/Utils/messages.ts`
Quando `viewOnce: true` é passado, a mídia é encapsulada no wrapper moderno `viewOnceMessageV2` (campo 55 do proto) e o flag `viewOnce=true` é setado na mídia interna:
```typescript
if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) {
// Seta viewOnce=true na mensagem de mídia interna
if (m.imageMessage) m.imageMessage.viewOnce = true
else if (m.videoMessage) m.videoMessage.viewOnce = true
else if (m.audioMessage) m.audioMessage.viewOnce = true
// Usa viewOnceMessageV2 (campo 55) — formato atual do WA
m = { viewOnceMessageV2: { message: m } }
}
```
### 5.2 Envio — `src/Socket/messages-send.ts`
**Detecção de view-once real** (não confunde com botões/listas que também usam o wrapper `viewOnceMessage`):
```typescript
// Detecta view-once real pela flag viewOnce na mídia interna.
// viewOnceMessage* wrappers são também usados por botões/listas/carrosséis
// (que carregam interactiveMessage dentro) — esses NÃO têm viewOnce=true na mídia.
const viewOnceInner =
message.viewOnceMessageV2?.message ||
message.viewOnceMessage?.message ||
message.viewOnceMessageV2Extension?.message
const isViewOnceMsg = !!(
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
)
// Para view-once: DSM enviado apenas para o celular principal (device=0).
// Companions (device>0) são omitidos — o servidor WA gera
// <unavailable type="view_once"/> automaticamente para eles.
const viewOnceMeRecipients = isViewOnceMsg
? meRecipients.filter(jid => !jidDecode(jid)?.device)
: meRecipients
// assertSessions apenas para quem vai realmente receber
await assertSessions([...viewOnceMeRecipients, ...otherRecipients])
const [meNodes, otherNodes] = await Promise.all([
createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs),
createParticipantNodes(otherRecipients, message, extraAttrs)
])
participants.push(...meNodes)
participants.push(...otherNodes)
// phash recalculado com os participantes reais
const phashRecipients = isViewOnceMsg
? [...viewOnceMeRecipients, ...otherRecipients]
: [...meRecipients, ...otherRecipients]
if (phashRecipients.length > 0) {
extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
}
```
**`getMediaType()` — garante que vídeo e áudio view-once sejam entregues:**
```typescript
const getMediaType = (message: proto.IMessage) => {
// Desencapsula wrapper view-once antes de verificar o tipo de mídia.
// Sem isso, message.videoMessage e message.audioMessage são undefined
// (estão dentro do wrapper) e o atributo mediatype ficaria ausente no
// enc node — o servidor WA descarta silenciosamente vídeo/áudio view-once.
const inner =
message.viewOnceMessage?.message ||
message.viewOnceMessageV2?.message ||
message.viewOnceMessageV2Extension?.message
if (inner) {
return getMediaType(inner)
}
if (message.imageMessage) return 'image'
else if (message.videoMessage) return 'video'
// ...
}
```
### 5.3 Recepção — `src/Utils/decode-wa-message.ts`
Quando a API recebe uma view-once como companion (linked device), o servidor envia dois stanzas:
- **Stanza 1** (`enc`): conteúdo completo com metadados da mídia
- **Stanza 2** (`unavailable type="view_once"`): sinal de sync
O stanza 1 é detectado e marcado corretamente:
```typescript
// Detecta view-once na stanza 1 recebida por linked device.
// O wrapper viewOnceMessage também é usado por mensagens interativas
// (interactiveMessage, listMessage) — esses NÃO têm viewOnce=true na mídia.
const viewOnceInner =
msg.viewOnceMessage?.message ||
msg.viewOnceMessageV2?.message ||
msg.viewOnceMessageV2Extension?.message
if (
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
) {
fullMessage.key.isViewOnce = true
}
```
---
## 6. Fluxo Completo
```
API envia view-once para destinatário
├── Protobuf gerado:
│ viewOnceMessageV2 {
│ message {
│ imageMessage { viewOnce: true, url, mediaKey, ... }
│ }
│ }
├── Stanza enviado ao servidor WA:
│ <message to="destinatario@s.whatsapp.net" type="media">
│ <enc type="pkmsg">...viewOnceMessageV2 cifrado...</enc> ← para o destinatário
│ <participants>
│ <to jid="meu-numero:0@lid"> ← celular principal
│ <enc type="msg">...DSM{viewOnceMessageV2}...</enc>
│ </to>
│ <!-- device>0 omitidos — servidor gera unavailable -->
│ </participants>
│ </message>
└── Distribuição pelo servidor:
├── Destinatário → recebe enc → abre uma vez ✅
├── Celular principal → recebe DSM → mostra "você enviou" ✅
└── WA Web → recebe <unavailable> → "Aguardando..." ✅
Destinatário envia view-once de volta para a API
├── Stanza recebido pela API (stanza 1 — enc com conteúdo):
│ <message from="destinatario@s.whatsapp.net" type="media">
│ <enc type="msg">...viewOnceMessageV2 cifrado...</enc>
│ </message>
├── decode-wa-message.ts:
│ └── Descriptografa → detecta viewOnce=true → seta key.isViewOnce=true
├── Stanza recebido (stanza 2 — unavailable, ignorado):
│ <message from="...">
│ <unavailable type="view_once"/>
│ </message>
└── Webhook emitido:
messages.upsert → msg.key.isViewOnce = true ✅
```
---
## 7. Limitações
| Situação | Comportamento | Motivo |
|---|---|---|
| **Conta hosted (Meta Business Cloud API)** | **View-once bloqueado para todos os clientes** | **Política do servidor WA para contas hosted — sem workaround** |
| WA Web do remetente | "Aguardando mensagem. Abra no celular." | Servidor WA só aceita `<unavailable>` vindo do celular principal, não de companions |
| View-once de documento/sticker | Não suportado | Limitação do protocolo WA — só imagem, vídeo e áudio |
| `error_479` nos logs | Normal, não é erro | TcToken é atualizado após o envio de view-once |
| Companion não consegue abrir | Por design | O conteúdo não é entregue para linked devices |
---
## 8. Checklist de Validação
**Envio:**
- [ ] Destinatário recebe com ícone de olhinho
- [ ] Destinatário consegue abrir a mídia
- [ ] Após abrir, a mídia some
- [ ] Celular principal do remetente mostra "você enviou" com ícone de olhinho
- [ ] WA Web do remetente mostra "Aguardando mensagem..."
- [ ] Funciona para imagem, vídeo e áudio
- [ ] Logs mostram `📤 Message sent` (error_479 é normal)
**Recepção:**
- [ ] Webhook recebe `key.isViewOnce = true`
- [ ] `viewOnceMessageV2.message.imageMessage` (ou video/audio) está presente
- [ ] API não crasha ao receber view-once de outro dispositivo
- [ ] Mensagem interativa (botão/lista) enviada após view-once não é afetada
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1035595667]}
{"version":[2,3000,1038167900]}
+38 -4
View File
@@ -31,9 +31,26 @@ export const STATUS_EXPIRY_SECONDS = 24 * 60 * 60
export const PLACEHOLDER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
export const NOISE_MODE = 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0'
export const DICT_VERSION = 3
/**
* Protocol mode: 'web' (default) or 'native' (experimental)
* - web: WA\x06\x03 standard WhatsApp Web protocol
* - native: WAM\x05 native Android protocol (may enable view-once media on companions)
*
* Set via BAILEYS_PROTOCOL env var:
* BAILEYS_PROTOCOL=native
*/
const PROTOCOL_MODE = process.env.BAILEYS_PROTOCOL?.trim().toLowerCase() === 'native' ? 'native' : 'web'
export const DICT_VERSION = PROTOCOL_MODE === 'native' ? 5 : 3
export const KEY_BUNDLE_TYPE = Buffer.from([5])
export const NOISE_WA_HEADER = Buffer.from([87, 65, 6, DICT_VERSION]) // last is "DICT_VERSION"
// WA\x06\x03 = web protocol, WAM\x05 = native Android protocol
export const NOISE_WA_HEADER = PROTOCOL_MODE === 'native'
? Buffer.from([87, 65, 77, DICT_VERSION]) // WAM\x05
: Buffer.from([87, 65, 6, DICT_VERSION]) // WA\x06\x03
export { PROTOCOL_MODE }
/** from: https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url */
export const URL_REGEX = /https:\/\/(?![^:@\/\s]+:[^:@\/\s]+@)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(:\d+)?(\/[^\s]*)?/g
@@ -154,7 +171,20 @@ export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
'md-msg-hist': '/mms/md-app-state',
'biz-cover-photo': '/pps/biz-cover-photo',
'sticker-pack': '/mms/sticker-pack',
'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack'
'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack',
// View-once types — same upload path as regular media (server CDN routing TBD)
'viewonce-image': '/mms/image',
'viewonce-video': '/mms/video',
'viewonce-audio': '/mms/audio'
}
export const NEWSLETTER_MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
image: '/newsletter/newsletter-image',
video: '/newsletter/newsletter-video',
document: '/newsletter/newsletter-document',
audio: '/newsletter/newsletter-audio',
sticker: '/newsletter/newsletter-image',
'thumbnail-link': '/newsletter/newsletter-image'
}
export const MEDIA_HKDF_KEY_MAPPING = {
@@ -178,7 +208,11 @@ export const MEDIA_HKDF_KEY_MAPPING = {
ptv: 'Video',
'biz-cover-photo': 'Image',
'sticker-pack': 'Sticker Pack',
'thumbnail-sticker-pack': 'Sticker Pack Thumbnail'
'thumbnail-sticker-pack': 'Sticker Pack Thumbnail',
// View-once types — same HKDF keys as regular types (recipients decrypt with normal keys)
'viewonce-image': 'Image',
'viewonce-video': 'Video',
'viewonce-audio': 'Audio'
}
export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING
+46 -7
View File
@@ -98,6 +98,24 @@ export const makeChatsSocket = (config: SocketConfig) => {
let privacySettings: { [_: string]: string } | undefined
/**
* Server-assigned AB props that gate tctoken-related protocol behavior.
* Defaults match WA Web (safe avoids spurious error 463 if the prop never
* arrives). Populated from `fetchProps()` on connection.
*
* - `privacyTokenOn1to1` (AB prop 10518 / `privacy_token_sending_on_all_1_on_1_messages`):
* include tctoken in 1:1 messages.
* - `profilePicPrivacyToken` (AB prop 9666 / `profile_scraping_privacy_token_in_photo_iq`):
* include tctoken in profile picture IQs.
* - `lidTrustedTokenIssueToLid` (AB prop 14303 / `lid_trusted_token_issue_to_lid`):
* issue privacy tokens to the contact's LID instead of the PN.
*/
const serverProps = {
privacyTokenOn1to1: true,
profilePicPrivacyToken: true,
lidTrustedTokenIssueToLid: false
}
let syncState: SyncState = SyncState.Connecting
/** this mutex ensures that messages from the same chat are processed in order, while allowing parallel processing of messages from different chats */
@@ -791,7 +809,10 @@ export const makeChatsSocket = (config: SocketConfig) => {
me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid)))
let content: BinaryNode[] | undefined = baseContent
if (isUserJid && !isSelf) {
// Gate inclusion on AB prop 9666 (profile_scraping_privacy_token_in_photo_iq).
// WA Web defaults to true; if the server flips it off, we mirror that to
// avoid divergence with the spec-compliant client.
if (serverProps.profilePicPrivacyToken && isUserJid && !isSelf) {
content = await buildTcTokenFromJid({
authState,
jid: normalizedJid,
@@ -1060,22 +1081,21 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
}
/** sending non-abt props may fix QR scan fail if server expects */
/** fetch AB props */
const fetchProps = async () => {
//TODO: implement both protocol 1 and protocol 2 prop fetching, specially for abKey for WM
const resultNode = await query({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
xmlns: 'w',
xmlns: 'abt',
type: 'get'
},
content: [
{
tag: 'props',
attrs: {
protocol: '2',
hash: authState?.creds?.lastPropHash || ''
protocol: '1',
...(authState?.creds?.lastPropHash ? { hash: authState.creds.lastPropHash } : {})
}
}
]
@@ -1094,7 +1114,25 @@ export const makeChatsSocket = (config: SocketConfig) => {
props = reduceBinaryNodeToDictionary(propsNode, 'prop')
}
logger.debug('fetched props')
// Extract protocol-relevant AB props (defaults match WA Web; see serverProps doc).
// We accept both numeric IDs and human-readable names so the parser is resilient
// to upstream renaming and to future-versioned WA Web servers.
const privacyTokenProp = props['10518'] ?? props['privacy_token_sending_on_all_1_on_1_messages']
if (privacyTokenProp !== undefined) {
serverProps.privacyTokenOn1to1 = privacyTokenProp === 'true' || privacyTokenProp === '1'
}
const profilePicProp = props['9666'] ?? props['profile_scraping_privacy_token_in_photo_iq']
if (profilePicProp !== undefined) {
serverProps.profilePicPrivacyToken = profilePicProp === 'true' || profilePicProp === '1'
}
const lidIssueProp = props['14303'] ?? props['lid_trusted_token_issue_to_lid']
if (lidIssueProp !== undefined) {
serverProps.lidTrustedTokenIssueToLid = lidIssueProp === 'true' || lidIssueProp === '1'
}
logger.debug({ serverProps }, 'fetched props')
return props
}
@@ -1612,6 +1650,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
return {
...sock,
serverProps,
createCallLink,
getBotListV2,
messageMutex,
+6
View File
@@ -343,11 +343,13 @@ export const extractGroupMetadata = (result: BinaryNode) => {
let descId: string | undefined
let descOwner: string | undefined
let descOwnerPn: string | undefined
let descOwnerUsername: string | undefined
let descTime: number | undefined
if (descChild) {
desc = getBinaryNodeChildString(descChild, 'body')
descOwner = descChild.attrs.participant ? jidNormalizedUser(descChild.attrs.participant) : undefined
descOwnerPn = descChild.attrs.participant_pn ? jidNormalizedUser(descChild.attrs.participant_pn) : undefined
descOwnerUsername = descChild.attrs.participant_username || undefined
descTime = +descChild.attrs.t!
descId = descChild.attrs.id
}
@@ -362,16 +364,19 @@ export const extractGroupMetadata = (result: BinaryNode) => {
subject: group.attrs.subject!,
subjectOwner: group.attrs.s_o,
subjectOwnerPn: group.attrs.s_o_pn,
subjectOwnerUsername: group.attrs.s_o_username,
subjectTime: +(group.attrs.s_t ?? '0'),
size: group.attrs.size ? +group.attrs.size : getBinaryNodeChildren(group, 'participant').length,
creation: +(group.attrs.creation ?? '0'),
owner: group.attrs.creator ? jidNormalizedUser(group.attrs.creator) : undefined,
ownerPn: group.attrs.creator_pn ? jidNormalizedUser(group.attrs.creator_pn) : undefined,
ownerUsername: group.attrs.creator_username || undefined,
owner_country_code: group.attrs.creator_country_code,
desc,
descId,
descOwner,
descOwnerPn,
descOwnerUsername,
descTime,
linkedParent: getBinaryNodeChild(group, 'linked_parent')?.attrs.jid || undefined,
restrict: !!getBinaryNodeChild(group, 'locked'),
@@ -386,6 +391,7 @@ export const extractGroupMetadata = (result: BinaryNode) => {
id: attrs.jid!,
phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined,
lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined,
username: attrs.participant_username || attrs.username || undefined,
admin: (attrs.type || null) as GroupParticipant['admin']
}
}),
+242 -113
View File
@@ -73,7 +73,14 @@ import {
recordMessageReceived,
recordMessageRetry
} from '../Utils/prometheus-metrics.js'
import { isTcTokenExpired, resolveTcTokenJid, storeTcTokensFromIqResult } from '../Utils/tc-token-utils'
import {
buildMergedTcTokenIndexWrite,
isTcTokenExpired,
resolveIssuanceJid,
resolveTcTokenJid,
storeTcTokensFromIqResult,
TC_TOKEN_INDEX_KEY
} from '../Utils/tc-token-utils'
import {
areJidsSameUser,
type BinaryNode,
@@ -164,7 +171,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let sendActiveReceipts = false
// ======= tctoken index tracking for cross-session pruning =======
const TC_TOKEN_INDEX_KEY = '__index'
// TC_TOKEN_INDEX_KEY is imported from tc-token-utils so the value stays in sync
// with messages-send/process-message writes (avoids string drift on rename).
// Race note: this prune-driven index write may interleave with
// buildMergedTcTokenIndexWrite calls from issuance/history-sync paths. Worst
// case: a JID resurrected by a stale read gets pruned again on the next 24h
// sweep — no data loss, just one extra cycle.
const TC_TOKEN_PRUNE_TS_KEY = '__prune_ts'
const tcTokenKnownJids = new Set<string>()
const tcTokenRetriedMsgIds = new Set<string>()
@@ -198,16 +210,25 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}
})()
/** Debounced save of the tctoken JID index (5s) */
/**
* Debounced save of the tctoken JID index (5s).
*
* Merges with the persisted index instead of overwriting other layers
* (messages-send fire-and-forget issuance, process-message history sync) may
* write JIDs to the index via `buildMergedTcTokenIndexWrite` without updating
* `tcTokenKnownJids`. Without the merge those JIDs would be silently dropped
* the next time this debounced save fires.
*/
const scheduleTcTokenIndexSave = () => {
if (tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer)
tcTokenIndexSaveTimer = setTimeout(async () => {
try {
const arr = Array.from(tcTokenKnownJids)
const merged = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids)
await authState.keys.set({
tctoken: {
...merged,
[TC_TOKEN_INDEX_KEY]: {
token: Buffer.from(JSON.stringify(arr), 'utf8'),
...merged[TC_TOKEN_INDEX_KEY],
timestamp: unixTimestampSeconds().toString()
}
}
@@ -1434,6 +1455,58 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}, authState?.creds?.me?.id || 'sendRetryRequest')
}
/**
* Fire-and-forget tctoken re-issuance after a peer's device identity changed.
* Mirrors WAWebSendTcTokenWhenDeviceIdentityChange runs in PARALLEL with the
* session refresh (not after it).
*
* Why parallel and not sequential:
* - WA Web invokes this BEFORE assertSessions to maximise the chance the contact
* has a fresh tctoken by the time the next outbound send executes.
* - Running after assertSessions (the fork's previous behaviour) races with the
* next send and risks error 463 when the contact reinstalls and the user replies
* immediately afterwards.
*
* Gated on `entry.senderTimestamp` (we previously issued a token to this peer in the
* current bucket window). Preserves the existing senderTimestamp instead of issuing
* a fresh one keeps WA Web's bucket coalescing semantics: same token, same window.
*/
const reissueTcTokenAfterIdentityChange = (from: string): void => {
void (async () => {
const normalizedJid = jidNormalizedUser(from)
const tcJid = await resolveTcTokenJid(normalizedJid, getLIDForPN)
const tcTokenData = await authState.keys.get('tctoken', [tcJid])
const senderTs = tcTokenData?.[tcJid]?.senderTimestamp
if (senderTs === null || senderTs === undefined || isTcTokenExpired(senderTs)) {
return
}
logTcToken('reissue', { jid: normalizedJid, reason: 'identity_changed', senderTimestamp: senderTs })
const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping)
const issueJid = await resolveIssuanceJid(
normalizedJid,
sock.serverProps.lidTrustedTokenIssueToLid,
getLIDForPN,
getPNForLID
)
const result = await getPrivacyTokens([issueJid], senderTs)
await storeTcTokensFromIqResult({
result,
fallbackJid: tcJid,
keys: authState.keys,
getLIDForPN,
onNewJidStored: storedJid => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
logTcToken('reissue_ok', { jid: normalizedJid, reason: 'identity_changed' })
})().catch(err => {
logTcToken('reissue_fail', { jid: from, error: err?.message })
})
}
const handleEncryptNotification = async (node: BinaryNode) => {
const from = node.attrs.from
if (from === S_WHATSAPP_NET) {
@@ -1453,57 +1526,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
validateSession: signalRepository.validateSession,
assertSessions,
debounceCache: identityAssertDebounce,
logger
logger,
// Fire reissue in parallel with the session refresh, NOT after it.
// See reissueTcTokenAfterIdentityChange doc for the rationale.
onBeforeSessionRefresh: reissueTcTokenAfterIdentityChange
})
// When a session is refreshed (identity change), re-issue tctoken fire-and-forget
// WABA Android: reissue stores senderTimestamp + realIssueTimestamp after IQ success
if (result.action === 'session_refreshed') {
const normalizedJid = jidNormalizedUser(from)
resolveTcTokenJid(normalizedJid, getLIDForPN)
.then(async tcJid => {
const tcData = await authState.keys.get('tctoken', [tcJid])
const entry = tcData[tcJid]
if (entry?.token?.length && !isTcTokenExpired(entry.timestamp)) {
const senderTs = unixTimestampSeconds()
logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' })
getPrivacyTokens([normalizedJid], senderTs)
.then(async (iqResult) => {
await storeTcTokensFromIqResult({
result: iqResult,
fallbackJid: normalizedJid,
keys: authState.keys,
getLIDForPN,
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
// Persist senderTimestamp + realIssueTimestamp after IQ success
const currentData = await authState.keys.get('tctoken', [tcJid])
const currentEntry = currentData[tcJid]
await authState.keys.set({
tctoken: {
[tcJid]: {
...currentEntry,
token: currentEntry?.token ?? Buffer.alloc(0),
senderTimestamp: senderTs,
realIssueTimestamp: 0
}
}
})
logTcToken('reissue_ok', { jid: normalizedJid, reason: 'session_refreshed' })
})
.catch(err => {
logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message })
})
}
})
.catch(() => {
/* ignore resolution errors */
})
}
if (result.action === 'no_identity_node') {
logger.info({ node }, 'unknown encrypt notification')
}
@@ -2124,12 +2152,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
if (resolvedRemoteJid) key.remoteJid = resolvedRemoteJid
if (resolvedParticipant) key.participant = resolvedParticipant
if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) {
logger.trace({ remoteJid }, 'ignoring receipt from jid')
await sendMessageAck(node)
return
}
const ids = [attrs.id!]
if (Array.isArray(content)) {
const items = getBinaryNodeChildren(content[0], 'item')
@@ -2205,11 +2227,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const handleNotification = async (node: BinaryNode) => {
const remoteJid = node.attrs.from
if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) {
logger.trace({ remoteJid }, 'ignored notification')
await sendMessageAck(node)
return
}
try {
await Promise.all([
@@ -2223,6 +2240,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
fromMe,
participant: node.attrs.participant,
participantAlt,
participantUsername: node.attrs.participant_username,
addressingMode,
id: node.attrs.id,
...(msg.key || {})
@@ -2231,6 +2249,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
msg.messageTimestamp = +node.attrs.t!
const fullMsg = proto.WebMessageInfo.fromObject(msg) as WAMessage
// Preserve custom WAMessageKey fields (participantAlt, participantUsername,
// addressingMode) that proto.WebMessageInfo.fromObject strips because
// they aren't part of the proto.MessageKey schema.
fullMsg.key = msg.key
await upsertMessage(fullMsg, 'append')
}
})
@@ -2241,20 +2263,56 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}
const handleMessage = async (node: BinaryNode) => {
if (shouldIgnoreJid(node.attrs.from!) && node.attrs.from !== S_WHATSAPP_NET) {
logger.trace({ from: node.attrs.from }, 'ignored message')
// Send a clean ACK (no error code) so the server considers the
// message delivered. Using error 500 (UnhandledError) previously
// caused the server to retry delivery, generating duplicate traffic.
await sendMessageAck(node)
return
}
const encNode = getBinaryNodeChild(node, 'enc')
// TODO: temporary fix for crashes and issues resulting of failed msmsg decryption
if (encNode?.attrs.type === 'msmsg') {
logger.debug({ key: node.attrs.key }, 'ignored msmsg')
await sendMessageAck(node, NACK_REASONS.MissingMessageSecret)
const unavailableNode = getBinaryNodeChild(node, 'unavailable')
// msmsg block removed — allow msmsg decryption (required for view-once on linked devices)
if (unavailableNode?.attrs?.type === 'view_once') {
const { fullMessage: voMsg } = decryptMessageNode(
node,
authState.creds.me!.id,
authState.creds.me!.lid || '',
signalRepository,
logger,
)
voMsg.key.isViewOnce = true
// Try to request the actual view-once content from the primary phone via PDO
// The phone may resend the full media (url, mediaKey, directPath, etc.)
console.log(`[VO_PDO] Requesting view-once content via PDO for ${voMsg.key.id} from ${voMsg.key.remoteJid}`)
try {
const requestId = await requestPlaceholderResend(voMsg.key, {
key: { ...voMsg.key },
pushName: voMsg.pushName,
messageTimestamp: voMsg.messageTimestamp,
participant: voMsg.key.participant,
participantAlt: voMsg.key.participantAlt
})
if (requestId && requestId !== 'RESOLVED') {
console.log(`[VO_PDO] PDO request sent! requestId=${requestId} — waiting for phone to resend`)
} else if (requestId === 'RESOLVED') {
console.log(`[VO_PDO] Message arrived during PDO delay!`)
} else {
console.log(`[VO_PDO] Already requested or no response`)
}
} catch (err: any) {
console.log(`[VO_PDO] PDO request failed: ${err.message} — falling back to placeholder`)
}
// Emit placeholder immediately so consumers see the notification
// If PDO succeeds, the real message will arrive via messages.upsert later
voMsg.message = {
viewOnceMessage: {
message: {
imageMessage: {
viewOnce: true,
}
}
}
}
const upsertType = node.attrs.offline ? 'append' : 'notify'
await upsertMessage(voMsg, upsertType)
await sendMessageAck(node)
return
}
@@ -2272,44 +2330,54 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
)
const alt = msg.key.participantAlt || msg.key.remoteJidAlt
// Handle LID/PN mappings with hybrid approach:
// - Store mapping operation runs in background (non-critical for decrypt)
// - Session migration MUST complete before decrypt() to avoid "No session record" errors
// This addresses Codex/Copilot review concerns about race conditions with decrypt()
// Handle LID/PN mappings with optimized hot-path:
// - storeLIDPNMappings is fire-and-forget (background) — does NOT block decrypt
// - migrateSession is SYNC (await) — REQUIRED for decrypt to find session
//
// SAFETY: 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. Subsequent messages in the same chat hit the
// store after the background write is done (ms later).
//
// Pre-check (getPNForLID/getLIDForPN) was removed — storeLIDPNMappings has internal
// LRU cache + dedup, the pre-check was a redundant store round-trip per inbound
// message that added latency under load.
//
// HISTORICAL: this restores the intent of d73cd28d39 (2026-02-03) which was
// partially reverted by c3fc792351 the same day due to a race-condition concern
// with migrateSession (kept sync here). storeLIDPNMappings was over-protected:
// it persists a mapping that downstream consumers can re-derive from key.*Alt,
// while migrateSession actually moves the Signal session record that decrypt()
// will load microseconds later — those two have very different criticality.
//
// DO NOT make migrateSession async — decrypt() depends on the session being at
// the correct identifier (LID vs PN) when it runs. Other code paths (USync
// device lookup in messages-send.ts) create LID/PN mappings without migrating
// the session, so we cannot skip migration even when the mapping already exists.
if (!!alt) {
const altServer = jidDecode(alt)?.server
const primaryJid = msg.key.participant || msg.key.remoteJid!
if (altServer === 'lid') {
// Check if mapping already exists to avoid unnecessary storage operations
const existingMapping = await signalRepository.lidMapping.getPNForLID(alt)
if (!existingMapping) {
// MUST await: normalizeMessageJids() runs after this and needs the mapping
// in the LIDMappingStore to resolve LID→PN for events delivered to consumers
await signalRepository.lidMapping
.storeLIDPNMappings([{ lid: alt, pn: primaryJid }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'LID mapping storage failed'))
}
// Fire-and-forget: storeLIDPNMappings has internal cache+dedup,
// pre-check (getPNForLID) was redundant.
signalRepository.lidMapping
.storeLIDPNMappings([{ lid: alt, pn: primaryJid }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'background LID mapping store failed'))
// CRITICAL: ALWAYS migrate session, even if mapping exists
// Other code paths (e.g., USync device lookup in messages-send.ts:310-319)
// may create mappings via storeLIDPNMappings() without calling migrateSession()
// This leaves sessions under PN format while decrypt() expects LID format
// Skipping migration based on mapping existence causes "No session record" errors
// CRITICAL: ALWAYS migrate session SYNC, even if mapping exists.
// Other code paths (e.g., USync device lookup in messages-send.ts) may create
// mappings via storeLIDPNMappings() without calling migrateSession(). This
// leaves sessions under PN format while decrypt() expects LID format.
// Skipping migration based on mapping existence causes "No session record" errors.
await signalRepository.migrateSession(primaryJid, alt)
} else {
// Check if reverse mapping exists
const existingMapping = await signalRepository.lidMapping.getLIDForPN(alt)
if (!existingMapping) {
// MUST await: normalizeMessageJids() runs after this and needs the mapping
// in the LIDMappingStore to resolve LID→PN for events delivered to consumers
await signalRepository.lidMapping
.storeLIDPNMappings([{ lid: primaryJid, pn: alt }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'LID mapping storage failed'))
}
// Fire-and-forget: same rationale as above.
signalRepository.lidMapping
.storeLIDPNMappings([{ lid: primaryJid, pn: alt }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'background LID mapping store failed'))
// CRITICAL: ALWAYS migrate session, even if mapping exists
// Same reasoning as above - mapping existence doesn't guarantee session migration
// CRITICAL: ALWAYS migrate session SYNC.
await signalRepository.migrateSession(alt, primaryJid)
}
}
@@ -2371,6 +2439,23 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
{ msgId: msg.key?.id, messageType },
'CTWA: Skipping placeholder resend for unavailable fanout type'
)
// For view-once: emit the message with a viewOnceMessage placeholder
// so consumers (zpro, etc.) can detect and display it.
// Include a message wrapper so isValidMsg accepts it.
if (messageType === 'view_once_unavailable_fanout') {
msg.key.isViewOnce = true
msg.message = {
viewOnceMessage: {
message: {
imageMessage: {
viewOnce: true,
}
}
}
}
const upsertType = node.attrs.offline ? 'append' : 'notify'
await upsertMessage(msg, upsertType)
}
metrics.ctwaRecoveryFailures.inc({ reason: 'unavailable_fanout' })
return sendMessageAck(node)
}
@@ -2552,6 +2637,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await sendReceipt(msg.key.remoteJid!, participant!, [msg.key.id!], type)
// NOTE: do NOT send view_once_read from a linked device (API).
// WA shows view-once as view_once_unavailable_fanout on linked devices — only the
// primary phone sends view_once_read when the user actually opens it.
// Sending it from a linked device causes WA to flag the account and disables view-once.
// send ack for history message
const isAnyHistoryMsg = getHistoryMsg(msg.message!)
if (isAnyHistoryMsg) {
@@ -3036,6 +3126,40 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
identifier: string,
exec: (node: BinaryNode) => Promise<void>
) => {
// Fast path: ack and drop ignored JIDs before entering the buffer/queue.
// Skips type='call' so call events are never silently dropped via
// shouldIgnoreJid (preserves InfiniteAPI's pre-existing behavior).
// Wrapped in try/catch so a throw from shouldIgnoreJid (user callback)
// or sendMessageAck (e.g. websocket closed) is routed through
// onUnexpectedError instead of becoming an unhandled rejection —
// matches the protection processNodeWithBuffer provides.
if (type !== 'call') {
try {
const from = node.attrs.from
let ignoreJid = from
if (type === 'receipt' && from) {
const attrs = node.attrs
const isLid = attrs.from!.includes('lid')
const isNodeFromMe = areJidsSameUser(
attrs.participant || attrs.from,
isLid ? authState.creds.me?.lid : authState.creds.me?.id
)
ignoreJid = !isNodeFromMe || isJidGroup(attrs.from) ? attrs.from : attrs.recipient
}
if (ignoreJid && ignoreJid !== S_WHATSAPP_NET && shouldIgnoreJid(ignoreJid)) {
// Plain ack (no NACK error code) preserves InfiniteAPI's prior
// behavior — ignored stanzas are an intentional drop, not a
// processing failure, so we don't want server-side retries.
await sendMessageAck(node)
return
}
} catch (error) {
onUnexpectedError(error as Error, identifier)
return
}
}
const isOffline = !!node.attrs.offline
if (isOffline) {
@@ -3134,19 +3258,24 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Await index load first — prevents overwriting a more complete persisted index
// if the connection closes before the initial load finishes.
tcTokenIndexLoaded
.then(() => {
Promise.resolve(
authState.keys.set({
.then(async () => {
try {
// Same merge-with-persisted invariant as scheduleTcTokenIndexSave —
// other layers may have written cross-layer JIDs to the index since
// our in-memory set was last updated.
const merged = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids)
await authState.keys.set({
tctoken: {
...merged,
[TC_TOKEN_INDEX_KEY]: {
token: Buffer.from(JSON.stringify([...tcTokenKnownJids]), 'utf8'),
...merged[TC_TOKEN_INDEX_KEY],
timestamp: unixTimestampSeconds().toString()
}
}
})
).catch(() => {
} catch {
/* non-critical */
})
}
})
.catch(() => {
/* non-critical */
+105 -13
View File
@@ -46,7 +46,10 @@ import { makeKeyedMutex } from '../Utils/make-mutex'
import { metrics, recordMessageFailure, recordMessageSent } from '../Utils/prometheus-metrics'
import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils'
import {
buildMergedTcTokenIndexWrite,
isRegularUser,
isTcTokenExpired,
resolveIssuanceJid,
resolveTcTokenJid,
shouldSendNewTcToken,
storeTcTokensFromIqResult
@@ -125,6 +128,15 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Tracks JIDs with an in-flight getPrivacyTokens IQ to avoid duplicate concurrent fetches
const tcTokenFetchingJids = new Set<string>()
/**
* Set of tctoken storage JIDs with a fire-and-forget `issuePrivacyTokens` IQ in flight.
* Distinct from `tcTokenFetchingJids` (which dedupes inbound *fetches* of the peer's
* token). Prevents duplicate IQs when a caller fires several rapid back-to-back sends
* to the same contact before `senderTimestamp` persists. Entries are always removed
* in `.finally()`, so the set is bounded by current concurrency.
*/
const inFlightTcTokenIssuance = new Set<string>()
let mediaConn: Promise<MediaConnInfo>
const refreshMediaConn = async (forceGet = false) => {
const media = await mediaConn
@@ -1041,7 +1053,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
await authState.keys.transaction(async () => {
const mediaType = getMediaType(message)
// normalizeMessageContent unwraps viewOnceMessage/viewOnceMessageV2 so getMediaType works correctly
const mediaType = getMediaType(normalizeMessageContent(message) || message)
if (mediaType) {
extraAttrs['mediatype'] = mediaType
}
@@ -1051,7 +1064,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const bytes = encodeNewsletterMessage(patched as proto.IMessage)
binaryNodeContent.push({
tag: 'plaintext',
attrs: {},
attrs: mediaType ? { mediatype: mediaType } : {},
content: bytes
})
const stanza: BinaryNode = {
@@ -1073,6 +1086,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
extraAttrs['decrypt-fail'] = 'hide' // todo: expand for reactions and other types
}
// view_once_write disabled — causes server to drop message (validation fails for linked device sessions)
if (isGroupOrStatus && !isRetryResend) {
const [groupData, senderKeyMap] = await Promise.all([
(async () => {
@@ -1277,21 +1292,48 @@ export const makeMessagesSocket = (config: SocketConfig) => {
: otherRecipients
const effectiveAllRecipients = [...effectiveMeRecipients, ...effectiveOtherRecipients]
await assertSessions(effectiveAllRecipients)
// P2: detect actual view-once media by checking inner message's viewOnce flag.
// viewOnceMessage wrapper is also used for interactive messages (buttons, lists, etc)
// which do NOT carry viewOnce=true on the media — those must NOT be filtered.
const viewOnceInner =
message.viewOnceMessageV2?.message ||
message.viewOnceMessage?.message ||
message.viewOnceMessageV2Extension?.message
const isViewOnceMsg = !!(
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
)
// For view-once: only send DSM to primary phone (device=0).
// Companion devices (device>0) are omitted — WA server generates
// <unavailable type="view_once"/> for them automatically.
// Sending explicit <unavailable> from a companion is rejected by the server.
const viewOnceMeRecipients = isViewOnceMsg
? effectiveMeRecipients.filter(jid => !jidDecode(jid)?.device)
: effectiveMeRecipients
// P3: assert sessions only for recipients we actually encrypt for.
// For view-once, companions are omitted — asserting their sessions is wasteful
// and could block the send if a companion session is corrupted.
await assertSessions([...viewOnceMeRecipients, ...effectiveOtherRecipients])
const [
{ nodes: meNodes, shouldIncludeDeviceIdentity: s1 },
{ nodes: otherNodes, shouldIncludeDeviceIdentity: s2 }
] = await Promise.all([
// For own devices: use DSM (deviceSentMessage) wrapper
createParticipantNodes(effectiveMeRecipients, meMsg || message, extraAttrs),
createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs),
createParticipantNodes(effectiveOtherRecipients, message, extraAttrs)
])
participants.push(...meNodes)
participants.push(...otherNodes)
if (effectiveMeRecipients.length > 0 || effectiveOtherRecipients.length > 0) {
extraAttrs['phash'] = generateParticipantHashV2(effectiveAllRecipients)
// phash: para view-once inclui só celular principal + destinatários
const phashRecipients = isViewOnceMsg
? [...viewOnceMeRecipients, ...effectiveOtherRecipients]
: effectiveAllRecipients
if (phashRecipients.length > 0) {
extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
}
shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1 || s2
@@ -1685,7 +1727,16 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
}
if (tcTokenBuffer?.length) {
// Gate inclusion on AB prop 10518 (privacy_token_sending_on_all_1_on_1_messages).
// WA Web defaults to true; if the server flips it off we mirror that to avoid
// divergence with the spec-compliant client.
//
// CARROUSEL EXCEPTION: Pastorini-validated carousel stanzas REQUIRE tctoken
// (CDP capture confirms it). If the AB prop ever flips off, dropping the
// tctoken from carousel would break rendering on Android. Carousel always
// includes the tctoken when one is available, regardless of the prop —
// matching the fork's existing behaviour pre-PR #2339.
if (tcTokenBuffer?.length && (sock.serverProps.privacyTokenOn1to1 || isCarousel)) {
;(stanza.content as BinaryNode[]).push({
tag: 'tctoken',
attrs: {},
@@ -1785,13 +1836,39 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Gated only by shouldSendNewTcToken — removed tcTokenBuffer?.length guard so
// issuance fires even when we don't yet hold a token (bucket boundary crossed).
// IMPORTANT: must run AFTER sendNode — issuing before the message causes error 463.
if (is1on1Send && shouldSendNewTcToken(existingTokenEntry?.senderTimestamp)) {
//
// WA Web also skips issuance for:
// - protocol messages (revoke, ephemeral settings, etc — see TcTokenChatAction)
// - PSA, bots, MetaAI (isRegularUser filter)
// and dedupes back-to-back issuances with `inFlightTcTokenIssuance` so a burst of
// rapid sends to the same contact only triggers a single IQ before senderTimestamp
// is persisted.
const isProtocolMsg = !!normalizeMessageContent(message)?.protocolMessage
// Use isRegularUser (the same Wid.isRegularUser() port that gates the store
// path) so we filter PSA/bot/MetaAI consistently regardless of JID server
// (@c.us vs @s.whatsapp.net) and device suffix. The previous PSA_WID/isJidBot
// checks only matched @c.us forms — destinationJid arrives normalized.
const isBotOrPSA = !isRegularUser(destinationJid)
if (
is1on1Send &&
!isProtocolMsg &&
!isBotOrPSA &&
shouldSendNewTcToken(existingTokenEntry?.senderTimestamp) &&
!inFlightTcTokenIssuance.has(tcTokenJid)
) {
inFlightTcTokenIssuance.add(tcTokenJid)
const issueTimestamp = unixTimestampSeconds()
logTcToken('reissue', { jid: destinationJid })
getPrivacyTokens([destinationJid], issueTimestamp)
const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping)
resolveIssuanceJid(
destinationJid,
sock.serverProps.lidTrustedTokenIssueToLid,
getLIDForPN,
getPNForLID
)
.then(issueJid => getPrivacyTokens([issueJid], issueTimestamp))
.then(async result => {
// Store any tokens received in the IQ response.
// onNewJidStored not passed — pruning index lives in messages-recv (higher layer).
await storeTcTokensFromIqResult({
result,
fallbackJid: tcTokenJid,
@@ -1802,9 +1879,11 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Persist senderTimestamp unconditionally — WA Web stores it in the chat table
// regardless of whether a token exists. Spread preserves token+timestamp if present.
// WABA Android: INSERT INTO wa_trusted_contacts_send (jid, sent_tc_token_timestamp, real_issue_timestamp)
// VALUES (?, ?, 0) — realIssueTimestamp=0 means issued but not yet confirmed by server
// VALUES (?, ?, 0) — realIssueTimestamp=0 means issued but not yet confirmed by server.
// Also bump the cross-session prune index so this JID is tracked persistently.
const currentData = await authState.keys.get('tctoken', [tcTokenJid])
const currentEntry = currentData[tcTokenJid]
const indexWrite = await buildMergedTcTokenIndexWrite(authState.keys, [tcTokenJid])
await authState.keys.set({
tctoken: {
[tcTokenJid]: {
@@ -1812,7 +1891,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
token: currentEntry?.token ?? Buffer.alloc(0),
senderTimestamp: issueTimestamp,
realIssueTimestamp: 0
}
},
...indexWrite
}
})
@@ -1821,6 +1901,9 @@ export const makeMessagesSocket = (config: SocketConfig) => {
.catch(err => {
logTcToken('reissue_fail', { jid: destinationJid, error: err?.message })
})
.finally(() => {
inFlightTcTokenIssuance.delete(tcTokenJid)
})
}
// Log with [BAILEYS] prefix
@@ -1897,6 +1980,15 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
const getMediaType = (message: proto.IMessage) => {
// For view-once media, unwrap the viewOnceMessage wrapper before checking media type
const inner =
message.viewOnceMessage?.message ||
message.viewOnceMessageV2?.message ||
message.viewOnceMessageV2Extension?.message
if (inner) {
return getMediaType(inner)
}
if (message.imageMessage) {
return 'image'
} else if (message.videoMessage) {
+2
View File
@@ -9,6 +9,8 @@ export interface Contact {
name?: string
/** name of the contact, the contact has set on their own on WA */
notify?: string
/** username associated with this contact, when provided by WA */
username?: string
/** I have no idea */
verifiedName?: string
// Baileys Added
+2
View File
@@ -81,6 +81,7 @@ export type BaileysEventMap = {
id: string
author: string
authorPn?: string
authorUsername?: string
participants: GroupParticipant[]
action: ParticipantAction
}
@@ -88,6 +89,7 @@ export type BaileysEventMap = {
id: string
author: string
authorPn?: string
authorUsername?: string
participant: string
participantPn?: string
action: RequestJoinAction
+4
View File
@@ -20,17 +20,20 @@ export interface GroupMetadata {
addressingMode?: WAMessageAddressingMode
owner: string | undefined
ownerPn?: string | undefined
ownerUsername?: string | undefined
owner_country_code?: string | undefined
subject: string
/** group subject owner */
subjectOwner?: string
subjectOwnerPn?: string
subjectOwnerUsername?: string
/** group subject modification date */
subjectTime?: number
creation?: number
desc?: string
descOwner?: string
descOwnerPn?: string
descOwnerUsername?: string
descId?: string
descTime?: number
/** if this group is part of a community, it returns the jid of the community to which it belongs */
@@ -56,6 +59,7 @@ export interface GroupMetadata {
/** the person who added you to group or changed some setting in group */
author?: string
authorPn?: string
authorUsername?: string
}
export interface WAGroupCreateResponse {
+15 -2
View File
@@ -19,7 +19,9 @@ export type WAContactMessage = proto.Message.IContactMessage
export type WAContactsArrayMessage = proto.Message.IContactsArrayMessage
export type WAMessageKey = proto.IMessageKey & {
remoteJidAlt?: string
remoteJidUsername?: string
participantAlt?: string
participantUsername?: string
server_id?: string
addressingMode?: string
isViewOnce?: boolean // TODO: remove out of the message key, place in WebMessageInfo
@@ -155,6 +157,7 @@ export type MessageReceiptType =
| 'sender'
| 'inactive'
| 'played'
| 'view_once_read'
| undefined
export type MediaConnInfo = {
@@ -178,6 +181,8 @@ export interface WAUrlInfo {
type Mentionable = {
/** list of jids that are mentioned in the accompanying text */
mentions?: string[]
/** mention all participants in the group */
mentionAll?: boolean
}
type Contextable = {
/** add contextInfo to the message */
@@ -990,8 +995,16 @@ export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions &
export type WAMediaUploadFunction = (
encFilePath: string,
opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number }
) => Promise<{ mediaUrl: string; directPath: string; meta_hmac?: string; ts?: number; fbid?: number }>
opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number; newsletter?: boolean }
) => Promise<{
mediaUrl: string | undefined
directPath: string
meta_hmac?: string
ts?: number
fbid?: number
thumbnailDirectPath?: string
thumbnailSha256?: string
}>
export type MediaGenerationOptions = {
logger?: ILogger
+1
View File
@@ -1083,6 +1083,7 @@ export const processSyncAction = (
action.lidContactAction.firstName ||
action.lidContactAction.username ||
undefined,
username: action.lidContactAction.username || undefined,
lid: id!,
phoneNumber: undefined
}
+21
View File
@@ -242,10 +242,14 @@ export function decodeMessageNode(stanza: BinaryNode, meId: string, meLid: strin
const key: WAMessageKey = {
remoteJid: chatId,
remoteJidAlt: !isJidGroup(chatId) ? addressingContext.senderAlt : undefined,
remoteJidUsername: !isJidGroup(chatId)
? stanza.attrs.peer_recipient_username || stanza.attrs.recipient_username
: undefined,
fromMe,
id: msgId,
participant,
participantAlt: isJidGroup(chatId) ? addressingContext.senderAlt : undefined,
participantUsername: stanza.attrs.participant ? stanza.attrs.participant_username : undefined,
addressingMode: addressingContext.addressingMode,
...(msgType === 'newsletter' && stanza.attrs.server_id ? { server_id: stanza.attrs.server_id } : {})
}
@@ -392,6 +396,23 @@ export const decryptMessageNode = (
} else {
fullMessage.message = msg
}
// Detect view-once media on stanza 1 received by linked device.
// viewOnceMessage wrapper is also used for interactive messages
// (interactiveMessage, listMessage, nativeFlowMessage) -- those do NOT have
// imageMessage.viewOnce / videoMessage.viewOnce / audioMessage.viewOnce = true.
// Only real view-once media carries viewOnce: true on the inner media message.
const viewOnceInner =
msg.viewOnceMessage?.message ||
msg.viewOnceMessageV2?.message ||
msg.viewOnceMessageV2Extension?.message
if (
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
) {
fullMessage.key.isViewOnce = true
}
} catch (err: any) {
// Check if this is a final failure after all retries exhausted
const isRetryExhausted = err instanceof RetryExhaustedError
+12 -11
View File
@@ -1,5 +1,6 @@
import { pipeline } from 'stream/promises'
import { promisify } from 'util'
import { inflate } from 'zlib'
import { createInflate, inflate } from 'zlib'
import { proto } from '../../WAProto/index.js'
import type { Chat, Contact, LIDMapping, WAMessage } from '../Types'
import { WAMessageStubType } from '../Types'
@@ -29,16 +30,15 @@ const inflatePromise = promisify(inflate)
*/
export const downloadHistory = async (msg: proto.Message.IHistorySyncNotification, options: RequestInit) => {
const stream = await downloadContentFromMessage(msg, 'md-msg-hist', { options })
const bufferArray: Buffer[] = []
for await (const chunk of stream) {
bufferArray.push(chunk)
}
let buffer: Buffer = Buffer.concat(bufferArray)
// decompress buffer
buffer = await inflatePromise(buffer)
// Pipe decrypted stream directly through zlib inflate.
// Avoids allocating an intermediate buffer for the compressed payload —
// memory peaks during 50MB history syncs drop ~50% (PR upstream #2333).
const inflater = createInflate()
const chunks: Buffer[] = []
inflater.on('data', (chunk: Buffer) => chunks.push(chunk))
await pipeline(stream, inflater)
const buffer = Buffer.concat(chunks)
const syncData = proto.HistorySync.decode(buffer)
return syncData
}
@@ -315,6 +315,7 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
contacts.push({
id: chatId,
name: chat.displayName || chat.name || chat.username || undefined,
username: chat.username || undefined,
lid: chat.lidJid || chat.accountLid || undefined,
phoneNumber: chat.pnJid || undefined
})
@@ -360,7 +361,7 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
}
}
chats.push({ ...chat })
chats.push(chat)
}
break
+23
View File
@@ -57,6 +57,16 @@ export type IdentityChangeContext = {
debounceCache: NodeCache<boolean>
/** Logger instance for debugging and monitoring */
logger: ILogger
/**
* Invoked right before `assertSessions` is called for an existing-session identity
* change. Used to kick off fire-and-forget side effects (e.g. tctoken re-issuance)
* in the same order WA Web does i.e. before the E2E session is re-established.
* Must not throw; implementations are responsible for their own error handling.
*
* Skipped when the refresh itself is skipped (no_identity_node, invalid_notification,
* skipped_companion_device, skipped_self_primary, debounced, skipped_offline).
*/
onBeforeSessionRefresh?: (jid: string) => void
}
// ============================================================================
@@ -170,6 +180,19 @@ export async function handleIdentityChange(
// This ensures we don't incorrectly debounce when we exit early (offline, etc.)
ctx.debounceCache.set(from, true)
// Fire-and-forget side effects (e.g. tctoken re-issuance) BEFORE the session is
// re-established. WA Web runs these in parallel with the session refresh —
// running afterwards would race with the next outbound send and risk error 463.
//
// Wrapped in try/catch so a misbehaving consumer callback cannot abort identity
// change recovery. We log and continue — assertSessions still runs so the E2E
// session always gets refreshed.
try {
ctx.onBeforeSessionRefresh?.(from)
} catch (error) {
ctx.logger.warn({ error, jid: from }, 'onBeforeSessionRefresh callback threw — continuing with session refresh')
}
// Attempt session refresh/creation
try {
await ctx.assertSessions([from], true)
+33 -7
View File
@@ -10,7 +10,13 @@ import { join } from 'path'
import { Readable, Transform } from 'stream'
import { URL } from 'url'
import { proto } from '../../WAProto/index.js'
import { DEFAULT_ORIGIN, MEDIA_HKDF_KEY_MAPPING, MEDIA_PATH_MAP, type MediaType } from '../Defaults'
import {
DEFAULT_ORIGIN,
MEDIA_HKDF_KEY_MAPPING,
MEDIA_PATH_MAP,
type MediaType,
NEWSLETTER_MEDIA_PATH_MAP
} from '../Defaults'
import type {
BaileysEventMap,
DownloadableMessage,
@@ -612,7 +618,7 @@ export const downloadEncryptedContent = async (
const output = new Transform({
transform(chunk, _, callback) {
let data = Buffer.concat([remainingBytes, chunk])
let data = remainingBytes.length ? Buffer.concat([remainingBytes, chunk]) : chunk
const decryptLength = toSmallestChunkSize(data.length)
remainingBytes = data.slice(decryptLength)
@@ -681,6 +687,10 @@ type MediaUploadResult = {
meta_hmac?: string
ts?: number
fbid?: number
thumbnail_info?: {
thumbnail_sha256?: string
thumbnail_direct_path?: string
}
}
export type UploadParams = {
@@ -825,11 +835,21 @@ export const getWAUploadToServer = (
{ customUploadHosts, fetchAgent, logger, options }: SocketConfig,
refreshMediaConn: (force: boolean) => Promise<MediaConnInfo>
): WAMediaUploadFunction => {
return async (filePath, { mediaType, fileEncSha256B64, timeoutMs }) => {
return async (filePath, { mediaType, fileEncSha256B64, timeoutMs, newsletter }) => {
// send a query JSON to obtain the url & auth token to upload our media
let uploadInfo = await refreshMediaConn(false)
let urls: { mediaUrl: string; directPath: string; meta_hmac?: string; ts?: number; fbid?: number } | undefined
let urls:
| {
mediaUrl: string
directPath: string
meta_hmac?: string
ts?: number
fbid?: number
thumbnailDirectPath?: string
thumbnailSha256?: string
}
| undefined
const hosts = [...customUploadHosts, ...uploadInfo.hosts]
fileEncSha256B64 = encodeBase64EncodedStringForUpload(fileEncSha256B64)
@@ -851,7 +871,11 @@ export const getWAUploadToServer = (
logger.debug(`uploading to "${hostname}"`)
const auth = encodeURIComponent(uploadInfo.auth)
const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
const mediaPath = (newsletter ? NEWSLETTER_MEDIA_PATH_MAP[mediaType] : undefined) || MEDIA_PATH_MAP[mediaType]
let url = `https://${hostname}${mediaPath}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
if (newsletter) {
url += '&server_thumb_gen=1'
}
let result: MediaUploadResult | undefined
try {
@@ -868,11 +892,13 @@ export const getWAUploadToServer = (
if (result?.url || result?.direct_path) {
urls = {
mediaUrl: result.url!,
mediaUrl: result.url || result.direct_path!,
directPath: result.direct_path!,
meta_hmac: result.meta_hmac,
fbid: result.fbid,
ts: result.ts
ts: result.ts,
thumbnailDirectPath: result.thumbnail_info?.thumbnail_direct_path,
thumbnailSha256: result.thumbnail_info?.thumbnail_sha256
}
break
} else {
+46 -14
View File
@@ -196,10 +196,11 @@ export const prepareWAMessageMedia = async (
)
const fileSha256B64 = fileSha256.toString('base64')
const { mediaUrl, directPath } = await options.upload(filePath, {
const { directPath, thumbnailDirectPath, thumbnailSha256 } = await options.upload(filePath, {
fileEncSha256B64: fileSha256B64,
mediaType: mediaType,
timeoutMs: options.mediaUploadTimeoutMs
timeoutMs: options.mediaUploadTimeoutMs,
newsletter: true
})
await fs.unlink(filePath)
@@ -207,10 +208,12 @@ export const prepareWAMessageMedia = async (
const obj = WAProto.Message.fromObject({
// todo: add more support here
[`${mediaType}Message`]: (MessageTypeProto as any)[mediaType].fromObject({
url: mediaUrl,
// url intentionally omitted — newsletters use directPath only
directPath,
fileSha256,
fileLength,
thumbnailDirectPath,
thumbnailSha256: thumbnailSha256 ? Buffer.from(thumbnailSha256, 'base64') : undefined,
...uploadData,
media: undefined
})
@@ -236,7 +239,7 @@ export const prepareWAMessageMedia = async (
const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'
const requiresThumbnailComputation =
(mediaType === 'image' || mediaType === 'video') && typeof uploadData['jpegThumbnail'] === 'undefined'
const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === true
const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === true && typeof uploadData.waveform === 'undefined'
const requiresAudioBackground = options.backgroundColor && mediaType === 'audio' && uploadData.ptt === true
const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation
const { mediaKey, encFilePath, originalFilePath, fileEncSha256, fileSha256, fileLength } = await encryptedStream(
@@ -254,7 +257,9 @@ export const prepareWAMessageMedia = async (
(async () => {
const result = await options.upload(encFilePath, {
fileEncSha256B64,
mediaType,
// Use mediaTypeOverride for upload path (e.g. 'viewonce-image' → /o1/mms/image)
// Encryption already used the override via encryptedStream above
mediaType: options.mediaTypeOverride || mediaType,
timeoutMs: options.mediaUploadTimeoutMs
})
logger?.debug({ mediaType, cacheableKey }, 'uploaded media')
@@ -292,7 +297,7 @@ export const prepareWAMessageMedia = async (
logger?.debug('computed audio duration')
}
if (requiresWaveformProcessing) {
if (requiresWaveformProcessing && !uploadData.waveform) {
uploadData.waveform = await getAudioWaveform(filePath, logger)
logger?.debug('processed waveform')
}
@@ -1747,22 +1752,49 @@ export const generateWAMessageContent = async (
}
}
} else {
m = await prepareWAMessageMedia(message, options)
// For view-once media, upload to the one-time CDN (/o1/mms/*) instead of regular CDN
// WA Web view-once messages always have directPath: /o1/v/t24/... (not /v/t62.7118-24/...)
// Using the wrong CDN causes WA server to flag the account and breaks view-once capability
const isViewOnce = hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce
const viewOnceTypeMap: Record<string, string> = {
image: 'viewonce-image',
video: 'viewonce-video',
audio: 'viewonce-audio'
}
const viewOnceOverride = isViewOnce
? viewOnceTypeMap[Object.keys(message).find(k => k in viewOnceTypeMap) || '']
: undefined
m = await prepareWAMessageMedia(message, {
...options,
mediaTypeOverride: (viewOnceOverride as any) || options.mediaTypeOverride
})
}
if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) {
m = { viewOnceMessage: { message: m } }
// Also set viewOnce=true on the inner media message (required by WA app to render as view-once)
if (m.imageMessage) m.imageMessage.viewOnce = true
else if (m.videoMessage) m.videoMessage.viewOnce = true
else if (m.audioMessage) m.audioMessage.viewOnce = true
// Use viewOnceMessageV2 (field 55) — modern WA clients use this wrapper
// viewOnceMessage (field 37) is the old format which WA server no longer accepts with view_once_write
m = { viewOnceMessageV2: { message: m } }
}
if (hasOptionalProperty(message, 'mentions') && message.mentions?.length) {
if (
(hasOptionalProperty(message, 'mentions') && message.mentions?.length) ||
(hasOptionalProperty(message, 'mentionAll') && message.mentionAll)
) {
const messageType = Object.keys(m)[0] as Extract<keyof proto.IMessage, MessageWithContextInfo>
if (messageType) {
const key = m[messageType]
if (key && 'contextInfo' in key && !!key.contextInfo) {
key.contextInfo.mentionedJid = message.mentions
} else if (key) {
key.contextInfo = {
mentionedJid: message.mentions
if (key && 'contextInfo' in key) {
key.contextInfo = key.contextInfo || {}
if (message.mentions?.length) {
key.contextInfo.mentionedJid = message.mentions
}
if (message.mentionAll) {
key.contextInfo.nonJidMentions = 1
}
}
}
+196 -1
View File
@@ -40,6 +40,7 @@ import { getKeyAuthor, toNumber } from './generics'
import { downloadAndProcessHistorySyncNotification } from './history'
import type { ILogger } from './logger'
import { metrics, recordHistorySyncMessages } from './prometheus-metrics.js'
import { buildMergedTcTokenIndexWrite } from './tc-token-utils'
type ProcessMessageContext = {
shouldProcessHistoryMsg: boolean
@@ -62,6 +63,163 @@ const REAL_MSG_STUB_TYPES = new Set([
const REAL_MSG_REQ_ME_STUB_TYPES = new Set([WAMessageStubType.GROUP_PARTICIPANT_ADD])
/**
* Extract tctoken / tcTokenTimestamp / tcTokenSenderTimestamp from history-sync chats
* and persist them to the `tctoken` store. Mirrors WA Web's `bulkCreateOrMerge` pass
* over the chat table during history sync.
*
* Why this matters: when a user logs in on a new device, the multi-device history sync
* is the only way that device learns about tctokens issued/received on the original
* device. Without this pass, the new device sends 1:1 messages with no tctoken until
* the contact triggers a fresh notification which surfaces as error 463 in production.
*
* Monotonicity: we only overwrite an existing entry if the incoming timestamp is
* STRICTLY newer (`incoming > existing`). Equal timestamps are skipped to avoid
* reverting senderTimestamp / realIssueTimestamp set by other layers (e.g. a
* reissue that fired between history-sync chunks).
*
* Index hygiene: every JID we write here is added to the persistent prune index
* (TC_TOKEN_INDEX_KEY) via buildMergedTcTokenIndexWrite, so the 24h prune sweep in
* messages-recv picks them up across sessions.
*/
/**
* Single-concurrency queue for `storeTcTokensFromHistorySync` calls.
*
* Why: the function does read-then-write merges (`keyStore.get('tctoken', ...)`
* compute `keyStore.set(...)`) which are NOT atomic at the store level. If two
* history-sync chunks invoke this concurrently (common during reconnect / QR
* scan), an older chunk that started first can `keyStore.set` AFTER a newer
* chunk, overwriting the newer entry and worse, the merged `__index` write
* can drop JIDs the other chunk just added. Result: stale tcTokens / repeat 463
* sends until the next opportunistic refetch.
*
* Serialising via a chained Promise keeps the runs ordered while still freeing
* the calling `processMessage` to emit `messaging-history.set` immediately
* (the chain is fire-and-forget at the call site). Errors don't break the chain
* each `catch` resets it to `Promise.resolve()` so a single failure can't
* stall future runs.
*
* The chain is module-scoped (one per Node process). Multiple Baileys instances
* sharing this module will serialise across instances too, but their writes
* target different keyStores so there's no correctness gain only a tiny loss
* of inter-instance parallelism for tcToken syncs, which is acceptable given
* how rarely this runs vs. how rare cross-instance contention is.
*/
let historyTcTokenChain: Promise<void> = Promise.resolve()
function scheduleHistoryTcTokenSync(
chats: Chat[],
signalRepository: SignalRepositoryWithLIDStore,
keyStore: SignalKeyStoreWithTransaction,
logger?: ILogger
): void {
historyTcTokenChain = historyTcTokenChain
.catch(() => {
/* swallow prior error so chain stays alive */
})
.then(() => storeTcTokensFromHistorySync(chats, signalRepository, keyStore, logger))
.catch(err => {
logger?.warn({ err }, 'background tctoken history-sync persistence failed')
})
}
async function storeTcTokensFromHistorySync(
chats: Chat[],
signalRepository: SignalRepositoryWithLIDStore,
keyStore: SignalKeyStoreWithTransaction,
logger?: ILogger
) {
// Cheap filter first — most chats in a sync chunk don't carry tcToken at all,
// and we want to avoid spinning up promises for them.
const tokenChats = chats.filter(chat => {
const ts = chat.tcTokenTimestamp ? toNumber(chat.tcTokenTimestamp) : 0
return !!chat.tcToken?.length && ts > 0
})
if (!tokenChats.length) {
return
}
// Pre-normalize so the rest of the pipeline is a synchronous join.
const normalized = tokenChats.map(chat => ({
chat,
ts: toNumber(chat.tcTokenTimestamp!),
jid: jidNormalizedUser(chat.id!)
}))
// BATCHED LID resolution. The previous shape called getLIDForPN once per
// chat (sequential await inside a for-of), which became the bottleneck
// during heavy history sync — every cold-cache hit was a DB round-trip,
// stalling messaging-history.set and spilling into the event-buffer.
// `getLIDsForPNs` resolves a deduped list in ONE batched query (and shares
// USync retry across PNs that miss cache), turning O(N) round-trips into 1.
//
// LID inputs (and `@hosted.lid`) skip the lookup entirely — they're already
// the storage form. Failures degrade gracefully: a missing mapping just
// stores under the original jid, matching `resolveTcTokenJid`'s null branch.
const pnsToResolve = [...new Set(normalized.filter(({ jid }) => !isLidUser(jid)).map(({ jid }) => jid))]
const pnToLid = new Map<string, string>()
if (pnsToResolve.length) {
try {
const mappings = await signalRepository.lidMapping.getLIDsForPNs(pnsToResolve)
// Flat loop (continue-on-skip) keeps max nesting depth at 4 for lint.
for (const { pn, lid } of mappings ?? []) {
if (!pn || !lid) continue
pnToLid.set(jidNormalizedUser(pn), lid)
}
} catch (err) {
// Per-chat fallback below (storageJid := jid). Don't abort the chunk —
// CodeRabbit noted that all-or-nothing rejection here would drop every
// tctoken in the batch AND prevent messaging-history.set from firing.
logger?.warn({ err }, 'storeTcTokensFromHistorySync: getLIDsForPNs batch failed; falling back to per-chat jid')
}
}
const candidates = normalized.map(({ chat, ts, jid }) => ({
storageJid: pnToLid.get(jid) ?? jid,
token: Buffer.from(chat.tcToken!),
ts,
senderTs: chat.tcTokenSenderTimestamp ? toNumber(chat.tcTokenSenderTimestamp) : undefined
}))
const jids = candidates.map(c => c.storageJid)
const existing = await keyStore.get('tctoken', jids)
const entries: Record<string, { token: Buffer; timestamp?: string; senderTimestamp?: number }> = {}
for (const c of candidates) {
// Same-batch dedup: when two chats resolve to the same storageJid (e.g. PN+LID
// aliases collapsing through resolveTcTokenJid, or duplicate chunks across
// retries), prefer the value already written by an earlier iteration so a
// lower-ts entry can't overwrite a higher-ts one captured from `existing`.
const existingEntry = entries[c.storageJid] ?? existing[c.storageJid]
const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0
// Strict > guard: equal timestamps are skipped so we never clobber
// senderTimestamp written by other layers (issuance after send, etc).
if (existingTs > 0 && existingTs >= c.ts) {
continue
}
entries[c.storageJid] = {
...existingEntry,
token: c.token,
timestamp: String(c.ts),
...(c.senderTs !== undefined ? { senderTimestamp: c.senderTs } : {})
}
}
if (Object.keys(entries).length) {
logger?.debug({ count: Object.keys(entries).length }, 'storing tctokens from history sync')
try {
// Include updated __index so cross-session pruning picks these JIDs up.
const indexWrite = await buildMergedTcTokenIndexWrite(keyStore, Object.keys(entries))
await keyStore.set({ tctoken: { ...entries, ...indexWrite } })
} catch (err) {
logger?.warn({ err }, 'failed to store tctokens from history sync')
}
}
}
/** Cleans a received message to further processing */
export const cleanMessage = (message: WAMessage, meId: string, meLid: string) => {
// ensure remoteJid and participant doesn't have device or agent in it
@@ -418,6 +576,12 @@ const processMessage = async (
// Emit LID-PN mappings from history sync
// This is how WhatsApp Web learns mappings for chats with non-contacts
//
// MUST run BEFORE storeTcTokensFromHistorySync — otherwise resolveTcTokenJid()
// can't resolve PN→LID for fresh-device chats (mapping cache is empty), tokens
// get persisted under PN keys, and the send path (which resolves to LID first)
// misses them — exactly the error 463 scenario this whole change is meant to
// prevent. Catches Codex P1 review on PR #386.
if (data.lidPnMappings?.length) {
logger?.debug({ count: data.lidPnMappings.length }, 'processing LID-PN mappings from history sync')
// eslint-disable-next-line max-depth
@@ -442,6 +606,29 @@ const processMessage = async (
}
}
// Persist tctokens carried by history-sync chats in BACKGROUND, serialised.
//
// Originally awaited (PR #386) to avoid 463 on first multi-device send, but in
// production this drained the event buffer per-chunk and added visible delivery
// latency (especially after restart / QR scan when many chunks arrived at once).
//
// `scheduleHistoryTcTokenSync` enqueues onto a single-concurrency promise chain
// (see definition above) — chunks persist sequentially in the order they were
// emitted, preserving timestamp monotonicity AND keeping the `__index` write
// safe from concurrent merge clobbers. The call returns immediately so the
// `messaging-history.set` emit is not blocked.
//
// TRADE-OFF: a listener that fires an outbound send IMMEDIATELY after the emit
// may race the still-pending persistence and get a 463 on that specific send.
// The existing 463 handler in messages-recv.ts triggers a getPrivacyTokens()
// refetch that auto-recovers within seconds. Net result is much better UX than
// per-chunk stalls.
//
// DO NOT add `await` back here without re-evaluating production latency, AND
// DO NOT call storeTcTokensFromHistorySync directly — it must go through the
// chain to preserve write ordering across overlapping chunks.
scheduleHistoryTcTokenSync(data.chats, signalRepository, keyStore, logger)
ev.emit('messaging-history.set', {
...data,
isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined,
@@ -763,12 +950,19 @@ const processMessage = async (
id: jid,
author: message.key.participant!,
authorPn: message.key.participantAlt!,
authorUsername: message.key.participantUsername!,
participants,
action
})
const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
ev.emit('groups.update', [
{ id: jid, ...update, author: message.key.participant ?? undefined, authorPn: message.key.participantAlt }
{
id: jid,
...update,
author: message.key.participant ?? undefined,
authorPn: message.key.participantAlt,
authorUsername: message.key.participantUsername
}
])
}
@@ -777,6 +971,7 @@ const processMessage = async (
id: jid,
author: message.key.participant!,
authorPn: message.key.participantAlt!,
authorUsername: message.key.participantUsername!,
participant: participant.lid,
participantPn: participant.pn,
action,
+1
View File
@@ -46,6 +46,7 @@ export const processContactAction = (
{
id,
name: action.fullName || action.firstName || action.username || undefined,
username: action.username || undefined,
lid: lidJid || undefined,
phoneNumber
}
+119 -2
View File
@@ -1,12 +1,86 @@
import type { SignalKeyStoreWithTransaction } from '../Types'
import type { BinaryNode } from '../WABinary'
import { getBinaryNodeChild, getBinaryNodeChildren, isLidUser, jidNormalizedUser } from '../WABinary'
import {
getBinaryNodeChild,
getBinaryNodeChildren,
isAnyLidUser,
isAnyPnUser,
isHostedLidUser,
isHostedPnUser,
isJidMetaAI,
isLidUser,
isPnUser,
jidNormalizedUser
} from '../WABinary'
/** 7 days in seconds — matches WA Web AB prop tctoken_duration */
const TC_TOKEN_BUCKET_DURATION = 604800
/** 4 buckets → ~28-day rolling window — matches WA Web AB prop tctoken_num_buckets */
const TC_TOKEN_NUM_BUCKETS = 4
/**
* Sentinel key under the `tctoken` store holding a JSON array of tracked storage JIDs
* for cross-session pruning. Mirrors WA Web's CLEAN_TC_TOKENS index lookup.
*
* Exported so other modules (messages-recv, messages-send, process-message) reference
* the same constant. The fork previously inlined this string in messages-recv.ts;
* keep the value identical (`'__index'`) for backward compatibility with persisted state.
*/
export const TC_TOKEN_INDEX_KEY = '__index'
// Phone-number pattern matching WABinary's isJidBot, applied against the user part so
// the check is invariant to @c.us ↔ @s.whatsapp.net normalization.
const BOT_PHONE_REGEX = /^1313555\d{4}$|^131655500\d{2}$/
/**
* Mirrors WA Web's `Wid.isRegularUser()` (user ¬PSA ¬Bot). Used to gate tctoken
* storage against malformed notifications WA Web filters server-side but we
* defend here for parity with `WAWebSetTcTokenChatAction.handleIncomingTcToken`.
* Works for both pre- and post-normalized JIDs (`@c.us` vs `@s.whatsapp.net`).
*/
export function isRegularUser(jid: string | undefined): boolean {
if (!jid) return false
const user = jid.split('@')[0] ?? ''
if (user === '0') return false // PSA
if (BOT_PHONE_REGEX.test(user)) return false // Bot by phone pattern
if (isJidMetaAI(jid)) return false // MetaAI (@bot server)
return !!(isPnUser(jid) || isLidUser(jid) || isHostedPnUser(jid) || isHostedLidUser(jid) || jid.endsWith('@c.us'))
}
/** Read the persisted tctoken JID index and return its entries (never contains the sentinel key itself). */
export async function readTcTokenIndex(keys: SignalKeyStoreWithTransaction): Promise<string[]> {
const data = await keys.get('tctoken', [TC_TOKEN_INDEX_KEY])
const entry = data[TC_TOKEN_INDEX_KEY]
if (!entry?.token?.length) return []
try {
const parsed = JSON.parse(Buffer.from(entry.token).toString())
if (!Array.isArray(parsed)) return []
return parsed.filter((j): j is string => typeof j === 'string' && j.length > 0 && j !== TC_TOKEN_INDEX_KEY)
} catch {
return []
}
}
/**
* Build a SignalDataSet fragment that writes the merged index (persisted added)
* under the sentinel key. Lets callers update the index without clobbering writes
* made by other layers (history sync, concurrent sessions on the same store).
*/
export async function buildMergedTcTokenIndexWrite(
keys: SignalKeyStoreWithTransaction,
addedJids: Iterable<string>
): Promise<{ [TC_TOKEN_INDEX_KEY]: { token: Buffer } }> {
const persisted = await readTcTokenIndex(keys)
const merged = new Set(persisted)
for (const jid of addedJids) {
if (jid && jid !== TC_TOKEN_INDEX_KEY) merged.add(jid)
}
return {
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify([...merged])) }
}
}
/**
* Check if a received token is expired using WA Web's rolling bucket algorithm.
* Reference: WAWebTrustedContactsUtils.isTokenExpired
@@ -63,6 +137,43 @@ export async function resolveTcTokenJid(
return lid ?? normalized
}
/**
* Resolve target JID for issuing privacy token based on AB prop 14303
* (`lid_trusted_token_issue_to_lid`). When the prop is on, issuance goes to
* the LID; when off, it goes to the PN. Returns the original JID if no
* mapping is found in either direction.
*
* Normalizes the JID upfront and uses the `isAny*` helpers so callers can pass
* `@c.us`, `@s.whatsapp.net`, `@hosted`, `@hosted.lid`, `@lid` or device-specific
* forms `LIDMappingStore.getLIDForPN` early-returns unless `isAnyPnUser`,
* so unnormalized inputs would silently bypass routing.
*
* Reference: WAWebTrustedContactsManager.issuePrivacyTokens
*/
export async function resolveIssuanceJid(
jid: string,
issueToLid: boolean,
getLIDForPN: (pn: string) => Promise<string | null>,
getPNForLID?: (lid: string) => Promise<string | null>
): Promise<string> {
const normalized = jidNormalizedUser(jid)
if (issueToLid) {
if (isAnyLidUser(normalized)) return normalized
if (!isAnyPnUser(normalized)) return normalized
const lid = await getLIDForPN(normalized)
return lid ?? normalized
}
if (!isAnyLidUser(normalized)) return normalized
if (getPNForLID) {
const pn = await getPNForLID(normalized)
return pn ?? normalized
}
return normalized
}
type TcTokenParams = {
jid: string
baseContent?: BinaryNode[]
@@ -135,7 +246,13 @@ export async function storeTcTokensFromIqResult({
continue
}
const rawJid = jidNormalizedUser(tokenNode.attrs.jid || fallbackJid)
// In notifications, tokenNode.attrs.jid is OUR own device JID, not the sender's.
// Prefer fallbackJid (resolved from notification's `from` / `sender_lid`) so the
// token is stored under the peer's JID, never under self.
const rawJid = jidNormalizedUser(fallbackJid || tokenNode.attrs.jid)
// Defense against malformed notifications (PSA WID '0', bots, MetaAI). WA Web
// filters these server-side; we mirror Wid.isRegularUser() locally.
if (!isRegularUser(rawJid)) continue
const storageJid = await resolveTcTokenJid(rawJid, getLIDForPN)
const existingTcData = await keys.get('tctoken', [storageJid])
const existingEntry = existingTcData[storageJid]
+5 -1
View File
@@ -3,6 +3,7 @@ import { createHash } from 'crypto'
import { proto } from '../../WAProto/index.js'
import {
KEY_BUNDLE_TYPE,
PROTOCOL_MODE,
WA_ADV_ACCOUNT_SIG_PREFIX,
WA_ADV_DEVICE_SIG_PREFIX,
WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
@@ -58,7 +59,10 @@ const getClientPayload = (config: SocketConfig) => {
userAgent: getUserAgent(config)
}
payload.webInfo = getWebInfo(config)
// Native protocol (WAM\x05) does not use WebInfo — only web protocol does
if (PROTOCOL_MODE !== 'native') {
payload.webInfo = getWebInfo(config)
}
return payload
}
+29 -3
View File
@@ -13,11 +13,37 @@ export class USyncContactProtocol implements USyncQueryProtocol {
}
getUserElement(user: USyncUser): BinaryNode {
//TODO: Implement type / username fields (not yet supported)
if (user.phone) {
return {
tag: 'contact',
attrs: {},
content: user.phone
}
}
if (user.username) {
return {
tag: 'contact',
attrs: {
username: user.username,
...(user.usernameKey ? { pin: user.usernameKey } : {}),
...(user.lid ? { lid: user.lid } : {})
}
}
}
if (user.type) {
return {
tag: 'contact',
attrs: {
type: user.type
}
}
}
return {
tag: 'contact',
attrs: {},
content: user.phone
attrs: {}
}
}
@@ -0,0 +1,28 @@
import type { USyncQueryProtocol } from '../../Types/USync'
import { assertNodeErrorFree, type BinaryNode } from '../../WABinary'
import { USyncUser } from '../USyncUser'
export class USyncUsernameProtocol implements USyncQueryProtocol {
name = 'username'
getQueryElement(): BinaryNode {
return {
tag: 'username',
attrs: {}
}
}
getUserElement(user: USyncUser): BinaryNode | null {
void user
return null
}
parser(node: BinaryNode): string | null {
if (node.tag === 'username') {
assertNodeErrorFree(node)
return typeof node.content === 'string' ? node.content : null
}
return null
}
}
+1
View File
@@ -2,3 +2,4 @@ export * from './USyncDeviceProtocol'
export * from './USyncContactProtocol'
export * from './USyncStatusProtocol'
export * from './USyncDisappearingModeProtocol'
export * from './USyncUsernameProtocol'
+7 -1
View File
@@ -6,7 +6,8 @@ import {
USyncContactProtocol,
USyncDeviceProtocol,
USyncDisappearingModeProtocol,
USyncStatusProtocol
USyncStatusProtocol,
USyncUsernameProtocol
} from './Protocols'
import { USyncUser } from './USyncUser'
@@ -137,4 +138,9 @@ export class USyncQuery {
this.protocols.push(new USyncLIDProtocol())
return this
}
withUsernameProtocol() {
this.protocols.push(new USyncUsernameProtocol())
return this
}
}
+12
View File
@@ -2,6 +2,8 @@ export class USyncUser {
id?: string
lid?: string
phone?: string
username?: string
usernameKey?: string
type?: string
personaId?: string
@@ -20,6 +22,16 @@ export class USyncUser {
return this
}
withUsername(username: string) {
this.username = username
return this
}
withUsernameKey(usernameKey: string) {
this.usernameKey = usernameKey
return this
}
withType(type: string) {
this.type = type
return this
@@ -297,4 +297,52 @@ describe('Identity Change Handling', () => {
expect(result.device).toBe(5)
})
})
describe('onBeforeSessionRefresh callback', () => {
it('fires before assertSessions when a session refresh is about to run', async () => {
mockValidateSession.mockResolvedValue({ exists: true })
const callOrder: string[] = []
mockAssertSessions.mockImplementation(async () => {
callOrder.push('assertSessions')
return true
})
const onBeforeSessionRefresh = jest.fn((jid: string) => {
callOrder.push(`before:${jid}`)
})
const node = createIdentityChangeNode('user@s.whatsapp.net')
const ctx = { ...createContext(), onBeforeSessionRefresh }
const result = await handleIdentityChange(node, ctx)
expect(result.action).toBe('session_refreshed')
expect(callOrder).toEqual(['before:user@s.whatsapp.net', 'assertSessions'])
})
it('does not fire when the refresh is skipped (no_identity / offline / self)', async () => {
const onBeforeSessionRefresh = jest.fn()
// no identity node
const noIdentityNode: BinaryNode = {
tag: 'notification',
attrs: { from: 'a@s.whatsapp.net', type: 'encrypt' },
content: []
}
await handleIdentityChange(noIdentityNode, { ...createContext(), onBeforeSessionRefresh })
// offline notification
mockValidateSession.mockResolvedValue({ exists: true })
await handleIdentityChange(createIdentityChangeNode('b@s.whatsapp.net', '0'), {
...createContext(),
onBeforeSessionRefresh
})
// self primary
await handleIdentityChange(createIdentityChangeNode(mockMeId!), {
...createContext(),
onBeforeSessionRefresh
})
expect(onBeforeSessionRefresh).not.toHaveBeenCalled()
})
})
})
+348 -1
View File
@@ -1,7 +1,17 @@
import { jest } from '@jest/globals'
import { DisconnectReason, type SignalKeyStoreWithTransaction } from '../../Types'
import { getErrorCodeFromStreamError, SERVER_ERROR_CODES } from '../../Utils'
import { buildTcTokenFromJid, isTcTokenExpired, shouldSendNewTcToken } from '../../Utils/tc-token-utils'
import {
buildMergedTcTokenIndexWrite,
buildTcTokenFromJid,
isRegularUser,
isTcTokenExpired,
readTcTokenIndex,
resolveIssuanceJid,
shouldSendNewTcToken,
storeTcTokensFromIqResult,
TC_TOKEN_INDEX_KEY
} from '../../Utils/tc-token-utils'
import type { BinaryNode } from '../../WABinary'
/** 7 days in seconds — matches WA Web tctoken_duration */
@@ -848,3 +858,340 @@ describe('tctoken integration scenarios', () => {
})
})
})
// ─── isRegularUser (PSA / bot / MetaAI gating) ─────────────────────────
describe('isRegularUser', () => {
it('rejects undefined / empty', () => {
expect(isRegularUser(undefined)).toBe(false)
expect(isRegularUser('')).toBe(false)
})
it('rejects PSA WID (user "0")', () => {
expect(isRegularUser('0@s.whatsapp.net')).toBe(false)
expect(isRegularUser('0@c.us')).toBe(false)
})
it('rejects bot phone numbers (1313555XXXX, 131655500XX)', () => {
expect(isRegularUser('13135550000@s.whatsapp.net')).toBe(false)
expect(isRegularUser('13135559999@s.whatsapp.net')).toBe(false)
// /^131655500\d{2}$/ — 11 digits: 131655500 + 2 trailing
expect(isRegularUser('13165550000@s.whatsapp.net')).toBe(false)
expect(isRegularUser('13165550099@s.whatsapp.net')).toBe(false)
})
it('rejects MetaAI (@bot server)', () => {
expect(isRegularUser('foo@bot')).toBe(false)
})
it('accepts regular PN users (@s.whatsapp.net, @c.us)', () => {
expect(isRegularUser('5511999999999@s.whatsapp.net')).toBe(true)
expect(isRegularUser('5511999999999@c.us')).toBe(true)
})
it('accepts LID users', () => {
expect(isRegularUser('123456@lid')).toBe(true)
})
it('accepts hosted PN/LID users', () => {
expect(isRegularUser('5511999999999@hosted')).toBe(true)
expect(isRegularUser('123456@hosted.lid')).toBe(true)
})
})
// ─── resolveIssuanceJid (AB prop 14303 routing) ────────────────────────
describe('resolveIssuanceJid', () => {
const PN = '5511999999999@s.whatsapp.net'
const LID = '123456@lid'
const PN_NO_LID = '5511888888888@s.whatsapp.net'
const LID_NO_PN = '999999@lid'
const getLIDForPN = jest.fn<(pn: string) => Promise<string | null>>(async (pn: string) => {
if (pn === PN) return LID
return null
})
const getPNForLID = jest.fn<(lid: string) => Promise<string | null>>(async (lid: string) => {
if (lid === LID) return PN
return null
})
beforeEach(() => {
getLIDForPN.mockClear()
getPNForLID.mockClear()
})
describe('AB prop 14303 ON (issueToLid=true)', () => {
it('returns LID unchanged when input is LID', async () => {
expect(await resolveIssuanceJid(LID, true, getLIDForPN, getPNForLID)).toBe(LID)
expect(getLIDForPN).not.toHaveBeenCalled()
})
it('resolves PN to LID via lidMapping', async () => {
expect(await resolveIssuanceJid(PN, true, getLIDForPN, getPNForLID)).toBe(LID)
expect(getLIDForPN).toHaveBeenCalledWith(PN)
})
it('falls back to original PN when no LID mapping exists', async () => {
expect(await resolveIssuanceJid(PN_NO_LID, true, getLIDForPN, getPNForLID)).toBe(PN_NO_LID)
})
})
describe('AB prop 14303 OFF (issueToLid=false)', () => {
it('returns PN unchanged when input is PN', async () => {
expect(await resolveIssuanceJid(PN, false, getLIDForPN, getPNForLID)).toBe(PN)
expect(getPNForLID).not.toHaveBeenCalled()
})
it('resolves LID to PN via lidMapping', async () => {
expect(await resolveIssuanceJid(LID, false, getLIDForPN, getPNForLID)).toBe(PN)
expect(getPNForLID).toHaveBeenCalledWith(LID)
})
it('falls back to original LID when no PN mapping exists', async () => {
expect(await resolveIssuanceJid(LID_NO_PN, false, getLIDForPN, getPNForLID)).toBe(LID_NO_PN)
})
it('returns LID unchanged when getPNForLID is omitted', async () => {
expect(await resolveIssuanceJid(LID, false, getLIDForPN)).toBe(LID)
})
})
describe('JID normalization (handles @c.us and hosted forms)', () => {
const PN_CUS = '5511999999999@c.us'
const PN_NORMALIZED = '5511999999999@s.whatsapp.net'
const HOSTED_LID = '999999@hosted.lid'
it('normalizes @c.us before resolving (issueToLid=true)', async () => {
const fn = jest.fn<(pn: string) => Promise<string | null>>(async (pn: string) => {
if (pn === PN_NORMALIZED) return LID
return null
})
const result = await resolveIssuanceJid(PN_CUS, true, fn, getPNForLID)
expect(result).toBe(LID)
// Mapping store called with NORMALIZED form
expect(fn).toHaveBeenCalledWith(PN_NORMALIZED)
})
it('treats hosted LID as LID input (issueToLid=true returns it unchanged)', async () => {
expect(await resolveIssuanceJid(HOSTED_LID, true, getLIDForPN, getPNForLID)).toBe(HOSTED_LID)
expect(getLIDForPN).not.toHaveBeenCalled()
})
it('treats hosted LID as LID input (issueToLid=false converts via getPNForLID)', async () => {
const fn = jest.fn<(lid: string) => Promise<string | null>>(async () => null)
await resolveIssuanceJid(HOSTED_LID, false, getLIDForPN, fn)
expect(fn).toHaveBeenCalledWith(HOSTED_LID)
})
})
})
// ─── readTcTokenIndex / buildMergedTcTokenIndexWrite ───────────────────
describe('tctoken cross-session prune index', () => {
it('exports the sentinel key as "__index"', () => {
// Persisted state compatibility — tests freeze the value to catch unintended renames.
expect(TC_TOKEN_INDEX_KEY).toBe('__index')
})
describe('readTcTokenIndex', () => {
it('returns [] when the index entry is absent', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns [] when the index token buffer is empty', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.alloc(0) } })
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns [] when the JSON payload is corrupted', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.from('not-json') } })
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns [] when the JSON payload is not an array', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.from('{"a":1}') } })
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns the persisted JIDs', async () => {
const keys = createMockKeys()
const stored = ['a@lid', 'b@lid', 'c@s.whatsapp.net']
;(keys.get as any).mockResolvedValue({
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(stored)) }
})
expect(await readTcTokenIndex(keys)).toEqual(stored)
})
it('filters out the sentinel key itself, empty strings, and non-strings', async () => {
const keys = createMockKeys()
const stored = ['a@lid', '', TC_TOKEN_INDEX_KEY, 42, null, 'b@lid']
;(keys.get as any).mockResolvedValue({
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(stored)) }
})
expect(await readTcTokenIndex(keys)).toEqual(['a@lid', 'b@lid'])
})
})
describe('buildMergedTcTokenIndexWrite', () => {
it('merges added JIDs with the persisted set (de-duplicated)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(['a@lid', 'b@lid'])) }
})
const write = await buildMergedTcTokenIndexWrite(keys, ['b@lid', 'c@lid'])
const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[]
expect(decoded.sort()).toEqual(['a@lid', 'b@lid', 'c@lid'])
})
it('drops the sentinel key from the added set', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
const write = await buildMergedTcTokenIndexWrite(keys, [TC_TOKEN_INDEX_KEY, 'a@lid'])
const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[]
expect(decoded).toEqual(['a@lid'])
})
it('handles empty inputs', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
const write = await buildMergedTcTokenIndexWrite(keys, [])
const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[]
expect(decoded).toEqual([])
})
})
})
// ─── storeTcTokensFromIqResult — isRegularUser gating + monotonicity ───
describe('storeTcTokensFromIqResult — gating and monotonicity', () => {
const PEER_PN = '5511999999999@s.whatsapp.net'
const PEER_LID = '123456@lid'
const getLIDForPN = jest.fn<(pn: string) => Promise<string | null>>(async (pn: string) => {
if (pn === PEER_PN) return PEER_LID
return null
})
beforeEach(() => {
getLIDForPN.mockClear()
})
const buildIqResult = (jid: string, tokenBytes: Uint8Array, t: string): BinaryNode => ({
tag: 'iq',
attrs: {},
content: [
{
tag: 'tokens',
attrs: {},
content: [
{
tag: 'token',
attrs: { jid, type: 'trusted_contact', t },
content: tokenBytes
}
]
}
]
})
it('skips PSA WID (jid="0") even if returned in tokens block', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'),
fallbackJid: '0@s.whatsapp.net',
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('skips bot phone numbers', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'),
fallbackJid: '13135550000@s.whatsapp.net',
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('skips MetaAI (@bot server)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'),
fallbackJid: 'meta-ai@bot',
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('uses fallbackJid (NOT the token node jid which is own device) as storage source', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
const onNewJidStored = jest.fn()
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid:1@s.whatsapp.net', Buffer.from([0xaa, 0xbb]), '1700000000'),
fallbackJid: PEER_PN,
keys,
getLIDForPN,
onNewJidStored
})
expect(keys.set).toHaveBeenCalled()
const setArgs = (keys.set.mock.calls[0]?.[0] as { tctoken: Record<string, unknown> }).tctoken
expect(Object.keys(setArgs)).toContain(PEER_LID)
expect(onNewJidStored).toHaveBeenCalledWith(PEER_LID)
})
it('skips when incoming timestamp is older than existing (monotonicity)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({
[PEER_LID]: { token: Buffer.from([0x99]), timestamp: '1800000000' }
})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0xaa]), '1700000000'),
fallbackJid: PEER_PN,
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('skips timestamp-less tokens (would be immediately expired)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: {
tag: 'iq',
attrs: {},
content: [
{
tag: 'tokens',
attrs: {},
content: [
{
tag: 'token',
attrs: { jid: 'd@s.whatsapp.net', type: 'trusted_contact' },
content: Buffer.from([0x01])
}
]
}
]
},
fallbackJid: PEER_PN,
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
})
+209
View File
@@ -0,0 +1,209 @@
import Long from 'long'
import $protobuf from 'protobufjs/minimal.js'
const $util = $protobuf.util
// proto implementation
function longToStringOld(value: any, unsigned?: boolean): string {
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
if (!$util.Long) return String(value)
const normalized = ($util.Long as any).fromValue(value)
const prepared =
unsigned && normalized && typeof normalized.toUnsigned === 'function' ? normalized.toUnsigned() : normalized
return prepared.toString()
}
// bigint implementation
function longToStringNew(value: any, unsigned?: boolean): string {
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
if (value && typeof value.low === 'number' && typeof value.high === 'number') {
const high = value.high | 0
const lo = BigInt(value.low >>> 0)
const hi = BigInt(value.high >>> 0)
const combined = (hi << 32n) | lo
if (!unsigned && high < 0) {
return (combined - (1n << 64n)).toString()
}
return combined.toString()
}
return String(value)
}
function longToNumberOld(value: any, unsigned?: boolean): number {
if (typeof value === 'number') return value
if (typeof value === 'string') return Number(value)
if (!$util.Long) return Number(value)
const normalized = ($util.Long as any).fromValue(value)
const prepared =
unsigned && normalized && typeof normalized.toUnsigned === 'function'
? normalized.toUnsigned()
: typeof normalized.toSigned === 'function'
? normalized.toSigned()
: normalized
return prepared.toNumber()
}
function longToNumberNew(value: any, unsigned?: boolean): number {
if (typeof value === 'number') return value
if (typeof value === 'string') return Number(value)
if (value && typeof value.low === 'number' && typeof value.high === 'number') {
const high = value.high | 0
const lo = BigInt(value.low >>> 0)
const hi = BigInt(value.high >>> 0)
const combined = (hi << 32n) | lo
if (!unsigned && high < 0) {
return Number(combined - (1n << 64n))
}
return Number(combined)
}
return Number(value)
}
describe('BigInt vs Long equivalence validation', () => {
// Test cases: [description, Long value, unsigned flag]
const testCases: [string, Long, boolean][] = [
// Basic values
['zero unsigned', Long.fromNumber(0, true), true],
['zero signed', Long.fromNumber(0, false), false],
['one unsigned', Long.fromNumber(1, true), true],
['one signed', Long.fromNumber(1, false), false],
// Typical WhatsApp timestamps (seconds since epoch)
['timestamp 2023', Long.fromNumber(1700000000, true), true],
['timestamp 2025', Long.fromNumber(1750000000, true), true],
['timestamp as signed', Long.fromNumber(1700000000, false), false],
// File sizes
['small file', Long.fromNumber(1024, true), true],
['medium file 10MB', Long.fromNumber(10485760, true), true],
['large file 2GB', Long.fromNumber(2147483648, true), true],
// Boundary values - 32-bit
['max int32', Long.fromNumber(2147483647, false), false],
['min int32', Long.fromNumber(-2147483648, false), false],
['max uint32', Long.fromNumber(4294967295, true), true],
// Around MAX_SAFE_INTEGER
['MAX_SAFE_INTEGER', Long.fromString('9007199254740991', false), false],
['MAX_SAFE_INTEGER + 1', Long.fromString('9007199254740992', false), false],
['MAX_SAFE_INTEGER unsigned', Long.fromString('9007199254740991', true), true],
// Large unsigned values
['large unsigned', Long.fromString('9999999999999999999', true), true],
['max uint64', Long.fromString('18446744073709551615', true), true],
['max uint64 - 1', Long.fromString('18446744073709551614', true), true],
// Signed negative values
['negative one', Long.fromNumber(-1, false), false],
['negative small', Long.fromNumber(-100, false), false],
['negative large', Long.fromNumber(-2147483648, false), false],
['min int64', Long.fromString('-9223372036854775808', false), false],
['max int64', Long.fromString('9223372036854775807', false), false],
['-1 as signed Long', Long.fromBits(-1, -1, false), false],
// Tricky bit patterns
['high=1, low=0', Long.fromBits(0, 1, true), true],
['high=0, low=-1 (0xFFFFFFFF)', Long.fromBits(-1, 0, true), true],
['high=-1, low=-1 unsigned (max uint64)', Long.fromBits(-1, -1, true), true],
['high=0x7FFFFFFF, low=0xFFFFFFFF (max int64)', Long.fromBits(-1, 0x7fffffff, false), false],
['high=0x80000000, low=0 (min int64)', Long.fromBits(0, -2147483648, false), false],
// Powers of 2
['2^32', Long.fromString('4294967296', true), true],
['2^32 signed', Long.fromString('4294967296', false), false],
['2^48', Long.fromString('281474976710656', true), true],
['2^63', Long.fromString('9223372036854775808', true), true]
]
describe('longToString equivalence', () => {
for (const [desc, longVal, unsigned] of testCases) {
it(`${desc}: Long(${longVal.low}, ${longVal.high}, ${unsigned})`, () => {
const oldResult = longToStringOld(longVal, unsigned)
const newResult = longToStringNew(longVal, unsigned)
expect(newResult).toBe(oldResult)
})
}
})
describe('longToNumber equivalence', () => {
for (const [desc, longVal, unsigned] of testCases) {
it(`${desc}: Long(${longVal.low}, ${longVal.high}, ${unsigned})`, () => {
const oldResult = longToNumberOld(longVal, unsigned)
const newResult = longToNumberNew(longVal, unsigned)
// For large values, both may lose precision equally (Number limitation)
// So we compare string representations
expect(newResult.toString()).toBe(oldResult.toString())
})
}
})
describe('non-Long inputs (fallback paths)', () => {
it('string passthrough', () => {
expect(longToStringNew('12345')).toBe('12345')
expect(longToStringNew('0')).toBe('0')
})
it('number passthrough', () => {
expect(longToStringNew(42)).toBe('42')
expect(longToStringNew(0)).toBe('0')
expect(longToStringNew(-1)).toBe('-1')
})
it('null/undefined fallback', () => {
expect(longToStringNew(null)).toBe('null')
expect(longToStringNew(undefined)).toBe('undefined')
})
it('object without low/high', () => {
expect(longToStringNew({ foo: 'bar' })).toBe('[object Object]')
})
// Defensive: a raw {low, high} JSON object can carry `high` as an
// unsigned 32-bit value (Long.fromBits would normalize via `| 0`,
// but plain JSON deserialization does not). Without `value.high | 0`
// the sign-bit check fails and the result is interpreted as unsigned.
// Upstream Baileys PR #2333 has this latent bug; InfiniteAPI fixes it.
it('signed sign-bit detection on raw unsigned high (-1 as {low: -1, high: 4294967295})', () => {
const raw = { low: -1, high: 0xffffffff }
// signed: -1
expect(longToStringNew(raw, false)).toBe('-1')
// unsigned: max uint64
expect(longToStringNew(raw, true)).toBe('18446744073709551615')
})
it('signed sign-bit detection on raw unsigned high (-4294967296 as {low: 0, high: 4294967295})', () => {
const raw = { low: 0, high: 0xffffffff }
// signed: -4294967296 (high word = -1 after normalization)
expect(longToStringNew(raw, false)).toBe('-4294967296')
// unsigned: 0xFFFFFFFF00000000 = 18446744069414584320
expect(longToStringNew(raw, true)).toBe('18446744069414584320')
})
})
describe('fuzz: random Long values', () => {
it('100 random unsigned values match', () => {
for (let i = 0; i < 100; i++) {
const low = (Math.random() * 0xffffffff) | 0
const high = (Math.random() * 0xffffffff) | 0
const longVal = Long.fromBits(low, high, true)
const oldResult = longToStringOld(longVal, true)
const newResult = longToStringNew(longVal, true)
expect(newResult).toBe(oldResult)
}
})
it('100 random signed values match', () => {
for (let i = 0; i < 100; i++) {
const low = (Math.random() * 0xffffffff) | 0
const high = (Math.random() * 0xffffffff) | 0
const longVal = Long.fromBits(low, high, false)
const oldResult = longToStringOld(longVal, false)
const newResult = longToStringNew(longVal, false)
expect(newResult).toBe(oldResult)
}
})
})
})
@@ -0,0 +1,146 @@
import { jest } from '@jest/globals'
import { proto, type WAMessage } from '../..'
import { DEFAULT_CONNECTION_CONFIG } from '../../Defaults'
import makeWASocket from '../../Socket'
import { makeSession, mockWebSocket } from '../TestUtils/session'
mockWebSocket()
// chats.ts treats a connection as a reconnection when EITHER signal is true:
// 1. authState.creds.accountSyncCounter > 0
// (at least one full history sync completed in a previous session)
// 2. socketSkippedOfflineBuffer (forwarded from socket.ts as `skipOfflineBuffer`)
// (socket.ts already decided to skip the offline buffer, e.g. because
// routingInfo was stale and was discarded on startup)
// On reconnection, AwaitingInitialSync is skipped and the buffer is flushed
// immediately so live messages are not held in the initial-sync window.
describe('Reconnection Sync Skip', () => {
it('should skip the history sync wait on reconnection (accountSyncCounter > 0)', async () => {
const { state, clear } = await makeSession()
state.creds.me = { id: '1234567890:1@s.whatsapp.net', name: 'Test User' }
// Simulate a session that has already synced before
state.creds.accountSyncCounter = 1
const sock = makeWASocket({
...DEFAULT_CONNECTION_CONFIG,
auth: state
})
const messageListener = jest.fn()
sock.ev.on('messages.upsert', messageListener)
// Simulate receiving pending notifications (triggers AwaitingInitialSync)
sock.ev.emit('connection.update', { receivedPendingNotifications: true })
// Emit a message immediately after — if the wait is skipped,
// the buffer should already be flushed and this message should be delivered.
const msg = proto.WebMessageInfo.fromObject({
key: { remoteJid: '1234567890@s.whatsapp.net', fromMe: false, id: 'MSG_AFTER_RECONNECT' },
messageTimestamp: Date.now() / 1000,
message: { conversation: 'Hello after reconnect' }
}) as WAMessage
sock.ev.emit('messages.upsert', { messages: [msg], type: 'notify' })
await new Promise(resolve => setTimeout(resolve, 50))
// Message should be delivered immediately, NOT buffered
expect(messageListener).toHaveBeenCalledTimes(1)
expect(messageListener).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([expect.objectContaining({ key: msg.key })]),
type: 'notify'
})
)
await sock.end(new Error('Test completed'))
await clear()
})
it('should skip the history sync wait when socketSkippedOfflineBuffer is true (stale routingInfo, accountSyncCounter === 0)', async () => {
const { state, clear } = await makeSession()
state.creds.me = { id: '1234567890:1@s.whatsapp.net', name: 'Test User' }
// Fresh-looking session from the counter perspective
state.creds.accountSyncCounter = 0
// But routingInfo is present and we ask the socket to discard it on start.
// socket.ts will set hadStaleRoutingInfo=true → skipOfflineBuffer=true,
// which chats.ts forwards as socketSkippedOfflineBuffer. Without this
// branch, the buffers would be misaligned (offline buffer skipped while
// AwaitingInitialSync still waits) and live messages would stall.
state.creds.routingInfo = Buffer.from([1, 2, 3, 4])
const sock = makeWASocket({
...DEFAULT_CONNECTION_CONFIG,
auth: state,
clearRoutingInfoOnStart: true
})
const messageListener = jest.fn()
sock.ev.on('messages.upsert', messageListener)
sock.ev.emit('connection.update', { receivedPendingNotifications: true })
const msg = proto.WebMessageInfo.fromObject({
key: { remoteJid: '1234567890@s.whatsapp.net', fromMe: false, id: 'MSG_AFTER_STALE_ROUTING' },
messageTimestamp: Date.now() / 1000,
message: { conversation: 'Hello after stale routing reconnect' }
}) as WAMessage
sock.ev.emit('messages.upsert', { messages: [msg], type: 'notify' })
await new Promise(resolve => setTimeout(resolve, 50))
expect(messageListener).toHaveBeenCalledTimes(1)
expect(messageListener).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([expect.objectContaining({ key: msg.key })]),
type: 'notify'
})
)
await sock.end(new Error('Test completed'))
await clear()
})
it('should still wait for history sync on fresh pairing (both signals false)', async () => {
const { state, clear } = await makeSession()
state.creds.me = { id: '1234567890:1@s.whatsapp.net', name: 'Test User' }
// Fresh pairing — both reconnect signals are false:
// accountSyncCounter is 0 (default) and no stale routingInfo to clear.
state.creds.accountSyncCounter = 0
const sock = makeWASocket({
...DEFAULT_CONNECTION_CONFIG,
auth: state
})
const messageListener = jest.fn()
sock.ev.on('messages.upsert', messageListener)
sock.ev.emit('connection.update', { receivedPendingNotifications: true })
const msg = proto.WebMessageInfo.fromObject({
key: { remoteJid: '1234567890@s.whatsapp.net', fromMe: false, id: 'MSG_DURING_INITIAL_SYNC' },
messageTimestamp: Date.now() / 1000,
message: { conversation: 'Hello during initial sync' }
}) as WAMessage
sock.ev.emit('messages.upsert', { messages: [msg], type: 'notify' })
await new Promise(resolve => setTimeout(resolve, 50))
// Message should be BUFFERED (not delivered) because we're waiting for history sync
expect(messageListener).toHaveBeenCalledTimes(0)
// Drain the 2s AwaitingInitialSync timeout (chats.ts:1522) so its callback
// doesn't fire after Jest considers the test done — otherwise we get a
// "Cannot log after tests are done" warning from the post-teardown flush
// and the suite is flagged with --detectOpenHandles. After the timer
// fires, syncState becomes Online and the buffer flushes; that delivery
// is post-assertion and irrelevant to the test goal.
await new Promise(resolve => setTimeout(resolve, 2_100))
await sock.end(new Error('Test completed'))
await clear()
}, 10_000)
})
+88
View File
@@ -1,3 +1,4 @@
import Long from 'long'
import '../index.js'
import { proto } from '../../WAProto/index.js'
@@ -28,4 +29,91 @@ describe('proto serialization', () => {
const json = message.toJSON()
expect(json.message?.imageMessage?.fileLength).toBe('1234567890123456789')
})
it('converts Long objects to strings correctly via BigInt fast path', () => {
const message = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromNumber(1700000000, true),
message: {
imageMessage: {
fileLength: Long.fromString('9876543210', true)
}
}
})
const json = message.toJSON()
expect(json.messageTimestamp).toBe('1700000000')
expect(json.message?.imageMessage?.fileLength).toBe('9876543210')
})
it('handles large unsigned Long values (> MAX_SAFE_INTEGER)', () => {
const message = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromString('18446744073709551615', true), // max uint64
message: {
imageMessage: {
fileLength: Long.fromString('9999999999999999999', true)
}
}
})
const json = message.toJSON()
expect(json.messageTimestamp).toBe('18446744073709551615')
expect(json.message?.imageMessage?.fileLength).toBe('9999999999999999999')
})
it('handles zero and small Long values', () => {
const message = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromNumber(0, true),
message: {
imageMessage: {
fileLength: Long.fromNumber(1, true)
}
}
})
const json = message.toJSON()
expect(json.messageTimestamp).toBe('0')
expect(json.message?.imageMessage?.fileLength).toBe('1')
})
it('roundtrips encode/decode with Long fields preserving values (> MAX_SAFE_INTEGER)', () => {
// Use uint64 values above Number.MAX_SAFE_INTEGER (2^53 - 1) so the
// roundtrip actually exercises the BigInt fast path on decode->toJSON.
// Safe integers would silently work even if longToString fell back to
// Number(value).
const original = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromString('18446744073709551615', true),
message: {
imageMessage: {
fileLength: Long.fromString('9999999999999999999', true)
}
}
})
const encoded = proto.WebMessageInfo.encode(original).finish()
const decoded = proto.WebMessageInfo.decode(encoded)
const json = decoded.toJSON()
expect(json.messageTimestamp).toBe('18446744073709551615')
expect(json.message?.imageMessage?.fileLength).toBe('9999999999999999999')
})
})