Compare commits

..

136 Commits

Author SHA1 Message Date
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 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 d2db4cec97 chore: update WhatsApp Web version to v2.3000.1035595667 (#308)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-20 06:31:30 -03:00
github-actions[bot] be44c5fdb0 chore: update proto/version to v2.3000.1035577069 (#307)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-20 01:30:09 -03:00
Renato Alcara f63f0a766f fix: align Bad MAC retry receipt with WA Desktop behavior
fix: align Bad MAC retry receipt with WA Desktop behavior
2026-03-19 23:48:44 -03:00
Renato Alcara fbefa6a0ad fix: replace magic numbers with RetryReason enum constants
Address remaining Copilot review comments on PR #306:

- Import RetryReason enum from Utils (already re-exported via Utils/index.ts)
- Replace all numeric literals (0/1/2/3/4/7) in retryErrorCode with the
  corresponding enum members (UnknownError / SignalErrorNoSession / etc.)
- Removes inline comments that were only needed to explain magic numbers
- If RetryReason values ever change, the compiler will surface the drift

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:44:34 -03:00
Renato Alcara 9c4cdc3e02 fix: use DECRYPTION_RETRY_CONFIG constants for retry error code derivation
Address Copilot review comments on PR #306:

1. Replace ad-hoc regexes with the canonical DECRYPTION_RETRY_CONFIG error
   lists from decode-wa-message.ts (single source of truth). Any future
   additions to those lists are automatically picked up here.

2. Add coverage for MessageCounterError ('Key used already or never filled')
   which was previously returning code 0. It now correctly maps to code 4
   (SignalErrorInvalidMessage) via corruptedSessionErrors.

3. Broaden session-error matching to also catch libsignal variants like
   'No sessions', 'No open session', 'No sessions available' via the
   /no (open )?sessions?/ regex alongside sessionRecordErrors.

4. Fix comment: clarify that code 7 = BadMac and code 4 = InvalidMessage
   are distinct codes (previous comment conflated them).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:29:46 -03:00
Renato Alcara ba1c1b0fdb fix: align Bad MAC retry receipt with WA Desktop behavior
Three issues corrected in sendRetryRequest (receiver side):

1. BUG — error code hardcoded as '0' (UnknownError) in retry receipt.
   The peer (especially another InfiniteAPI instance) could not detect
   the failure type and would fall back to the 1-hour session recreation
   timeout instead of recreating immediately.
   Fix: derive error code from the actual libsignal decryption error
   message stored in messageStubParameters[0].

2. BUG — shouldRecreateSession block was reading the wrong node: it
   called getBinaryNodeChild(node, 'retry') on the incoming bad-MAC
   message, which never has a <retry> child. errorCode was always
   undefined, so MAC_ERROR_CODES never matched — the logic was dead.

3. BUG (consequence of #2) — when shouldRecreateSession accidentally
   did fire (no-session or 1-hour timeout), it deleted the receiver's
   session BEFORE the sender's pkmsg arrived. This opened a race window
   where concurrent messages would fail with "No Session".
   Fix: remove the entire session-deletion block from sendRetryRequest.
   The Signal Protocol pkmsg automatically overwrites the corrupted
   session — no explicit delete needed on the receiver side.

WA Desktop reference (CDP capture 2026-03-19):
- Retry receipt sent ~99ms after Bad MAC with specific error code
- pkmsg arrives ~304ms later, new session established automatically
- Old session is never deleted; pkmsg overwrites it implicitly
- Full recovery in ~1.3s without any race window

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:10:59 -03:00
Renato Alcara 876af2e96c fix: HistorySync LID improvements — raw_id mapping, prekey pool strategy, keepalive jitter, CDN cleanup
fix: HistorySync LID improvements — raw_id mapping, prekey pool strategy, keepalive jitter, CDN cleanup
2026-03-19 18:24:20 -03:00
Renato Alcara 5bb18da6bf fix: address PR #305 review comments — Origin header, lowServerCount threshold, keepalive guard
- history.ts: add Origin: DEFAULT_ORIGIN to CDN DELETE request headers —
  the download path injects it internally but options does not carry it
- socket.ts: fix lowServerCount comparison — was preKeyCount <= topUpAmount
  which triggered uploads even when above MIN_PREKEY_COUNT (e.g. 250 prekeys
  → topUp=550 → 250<=550=true → unnecessary upload). Now uses correct
  threshold: preKeyCount < MIN_PREKEY_COUNT
- socket.ts: guard scheduleNextKeepAlive() with !closed check to prevent
  orphan timers when end() was called concurrently on a different path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:23:41 -03:00
Renato Alcara 52d8ddae32 fix: prevent double prekey upload when concurrent uploads race on count=0
uploadPreKeys() waited for a concurrent upload but then proceeded to upload
again. With top-up logic and count=0, both handleEncryptNotification and
uploadPreKeysToServerIfRequired fire simultaneously, both see count=0, first
wins and uploads 800, second waits then uploads another 800 → 1600 prekeys.

Fix: return immediately after awaiting the concurrent upload — it already
replenished the pool.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:23:41 -03:00
Renato Alcara 8c8c5a312e fix: address PR review comments — CDN DELETE timing, keepalive leak, spread order
- history.ts: move CDN DELETE from downloadHistory() to
  downloadAndProcessHistorySyncNotification(), after processHistoryMessage()
  succeeds — prevents permanent history loss if processing throws post-download
- history.ts: fix fetch spread order: { ...options, method: 'DELETE' } so
  options.method cannot shadow the intended DELETE verb
- socket.ts: add early return after void end() in onKeepAliveTick to prevent
  scheduleNextKeepAlive() from leaking a timer while connection is closing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:23:28 -03:00
Renato Alcara 52756fb50d fix: histsync LID improvements — raw_id mapping, prekeys, keepalive jitter, CDN DELETE
1. USyncQuery: extract raw_id attr from node attrs into rawId field; implement
   side_list parsing (was TODO/commented) reusing shared parseNodeList helper

2. getUSyncDevices: add 4th LID→PN source via raw_id pairing from device-list
   WA Business sends zero phoneNumberToLidMappings in HistorySync — raw_id is
   the only way to resolve LID↔PN for those accounts during message send

3. MIN_PREKEY_COUNT: 5 → 25 (WA Business maintains ~812; 5 was too low a buffer
   before triggering replenishment upload)

4. startKeepAliveRequest: replace fixed setInterval with recursive setTimeout
   + ±15% jitter, matching WA Desktop's ~25-30s variable heartbeat pattern;
   clearInterval → clearTimeout for the handle

5. downloadHistory: fire-and-forget DELETE to CDN after successful download,
   mirroring WA Desktop behaviour (server-side one-time file cleanup)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:23:27 -03:00
Renato Alcara bd8465d9b8 fix: histsync LID improvements — raw_id mapping, prekeys, keepalive j… (#304)
* fix: histsync LID improvements — raw_id mapping, prekeys, keepalive jitter, CDN DELETE

* fix: prekey pool strategy — 800 initial, top-up to 800 when below 200

- INITIAL_PREKEY_COUNT: 812 → 800 (rounded, matches WA Business ~812 from CDP capture)
- MIN_PREKEY_COUNT: 25 → 200 (replenishment trigger threshold)
- uploadPreKeysToServerIfRequired: top-up to INITIAL_PREKEY_COUNT instead of
  uploading a flat MIN_PREKEY_COUNT — restores full 800-key pool on each replenish
- handleEncryptNotification: same top-up logic (INITIAL_PREKEY_COUNT - count)
  so server notification path also restores to 800, not just adds 200

uploadPreKeys(5) in error recovery path intentionally left unchanged.
2026-03-19 17:58:30 -03:00
Renato Alcara 6c52b01ea7 fix: add pastParticipants to HistorySync with LID normalization and deduplication (#303)
Two bugs fixed compared to prior implementation:

1. history.ts — normalize userJid LID→PN
   pastParticipants[].userJid may arrive as a LID identifier (e.g. "46802258641027@lid").
   The consumer expects a phone number. After building the lidPnMap from conversations
   and phoneNumberToLidMappings, each userJid is resolved to its PN equivalent.
   If the mapping is not available the original value is preserved (no data loss).

2. event-buffer.ts — deduplicate by groupJid + userJid across chunks
   Multiple HistorySync chunks can contain the same group. The previous approach
   concatenated blindly, producing duplicate entries. Now a keyed map
   { [groupJid]: IPastParticipant[] } is used so each group appears once and
   each participant within a group appears at most once.

Types updated (Events.ts):
   - messaging-history.set event includes pastParticipants?: IPastParticipants[]
   - BufferedEventData.historySets.pastParticipants typed as keyed map
2026-03-19 16:44:14 -03:00
Renato Alcara 5926da9493 Merge branch 'master' of https://github.com/rsalcara/InfiniteAPI 2026-03-19 12:22:07 -03:00
Renato Alcara a9e926b907 fix: correct w:mex QueryIds and response fields for newsletter operations (#302)
Reverse-engineered from WA Web JS bundle and live CDP interception.
All QueryIds and XWAPaths were wrong; mute/unmute also had wrong variables structure.

- FOLLOW QueryId: 7871414976211147 → 24404358912487870
- UNFOLLOW QueryId: 7238632346214362 → 9767147403369991
- MUTE QueryId: 29766401636284406 → 31938993655691868
- UNMUTE QueryId: 9864994326891137 → 31938993655691868 (same mutation as MUTE)
- xwa2_newsletter_follow: 'xwa2_newsletter_follow' → 'xwa2_newsletter_join_v2'
- xwa2_newsletter_unfollow: 'xwa2_newsletter_unfollow' → 'xwa2_newsletter_leave_v2'
- xwa2_newsletter_mute_v2: 'xwa2_newsletter_mute_v2' → 'xwa2_newsletter_update_user_setting'
- xwa2_newsletter_unmute_v2: 'xwa2_newsletter_unmute_v2' → 'xwa2_newsletter_update_user_setting'
- newsletterMute/Unmute variables: flat {newsletter_id} → {input: {newsletter_id, type, value}}

Fixes #2346
2026-03-19 11:49:42 -03:00
Renato Alcara 2b2c3f9155 Merge branch 'master' of https://github.com/rsalcara/InfiniteAPI 2026-03-19 11:24:01 -03:00
Renato Alcara 71fe69fd21 chore: update WhatsApp Web version to v2.3000.1035504971 (#301)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-19 06:33:42 -03:00
github-actions[bot] eecf6df2a2 chore: update proto/version to v2.3000.1035484955 (#300)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-19 01:48:35 -03:00
Renato Alcara d04a3e1af8 fix: restore working carousel send/render path (#297)
fix: restore working carousel send/render path (#297)
2026-03-18 20:30:18 -03:00
Renato Alcara 21462c26d5 chore: update WhatsApp Web version to v2.3000.1035406580 (#299)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-18 06:42:44 -03:00
github-actions[bot] fc2122b11a chore: update proto/version to v2.3000.1035370989 (#298)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-18 01:49:58 -03:00
Renato Alcara 11eed3aedc chore: update WhatsApp Web version to v2.3000.1035317910 (#296)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-17 06:16:35 -03:00
github-actions[bot] dbc6dd6aed chore: update proto/version to v2.3000.1035302375 (#295)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-17 00:46:29 -03:00
Renato Alcara e152aee740 Fix/pn lid session reuse (#294)
* fix: unify PN and LID session reuse for 1:1 sends

* fix: limit PN/LID reuse changes to session cache
2026-03-16 23:22:05 -03:00
Renato Alcara ff9b84c561 fix: unify PN and LID session reuse for 1:1 sends (#293)
fix: unify PN and LID session reuse for 1:1 sends (#293)
2026-03-16 22:59:28 -03:00
Renato Alcara 50c13398c4 fix: harden carousel live rendering path (#292)
* fix: harden carousel live rendering path

* fix: flatten carousel thumbnail fallback
2026-03-16 22:09:14 -03:00
Renato Alcara d991d0212d fix: add biz node injection and quality_control for carousel WhatsApp Web rendering (#291)
- Add interactive message detection helpers (getButtonType, isCarouselMessage, etc.)
- Inject biz node with interactive/native_flow for non-carousel interactive messages
- Inject biz + quality_control with decision_id for carousel messages
- Skip bot node for native_flow/carousel/catalog (breaks Web rendering)
- Force device-identity inclusion for carousel messages
- Append deferred biz/bot/quality_control nodes after device-identity and tctoken
- Store tctoken under both LID and PN for reliable lookup
2026-03-16 21:25:29 -03:00
Renato Alcara 7c095c84a5 chore: update WhatsApp Web version to v2.3000.1035223526 (#289)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-16 06:20:34 -03:00
github-actions[bot] a25e6740fd chore: update proto/version to v2.3000.1035216863 (#288)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-16 00:58:19 -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
Renato Alcara 986ac588a9 chore: update WhatsApp Web version to v2.3000.1035203283 (#287)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-15 06:08:25 -03:00
github-actions[bot] 64c73faab2 chore: update proto/version to v2.3000.1035198198 (#286)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-15 00:56:35 -03:00
Renato Alcara b5464bec73 chore: update WhatsApp Web version to v2.3000.1035176028 (#285)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-14 06:07:31 -03:00
github-actions[bot] 9f8f74fc7a chore: update proto/version to v2.3000.1035169204 (#284)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-14 00:44:33 -03: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 aaf3bd83eb chore: update WhatsApp Web version to v2.3000.1035102652 (#283)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-13 06:11:31 -03:00
github-actions[bot] 8fe71b03d5 chore: update proto/version to v2.3000.1035083696 (#282)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-13 00:45:20 -03:00
Renato Alcara c2c156d707 chore(deps): update music-metadata from 11.12.0 to 11.12.3 (#281)
Includes security fix for CWE-85 (infinite loop in ASF parsing),
Ogg/Vorbis duration fix, and restored TypeScript declaration files.
2026-03-12 23:52:05 -03:00
Renato Alcara e46b9750c2 chore: update WhatsApp Web version to v2.3000.1035018135 (#280)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-12 06:13:34 -03:00
github-actions[bot] 542b7a278d chore: update proto/version to v2.3000.1034989030 (#279)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-12 00:45:49 -03:00
Renato Alcara 6d7e0d7b35 chore: update WhatsApp Web version to v2.3000.1034928492 (#278)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-11 06:12:36 -03:00
github-actions[bot] 9daee4d8fd chore: update proto/version to v2.3000.1034906703 (#277)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-11 00:44:13 -03:00
Renato Alcara d7b2ceb161 chore: update WhatsApp Web version to v2.3000.1034844076 (#276)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-10 06:12:48 -03:00
github-actions[bot] 2236ef83a9 chore: update proto/version to v2.3000.1034813620 (#275)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-10 00:44:00 -03:00
Renato Alcara 07f775dd18 chore: update WhatsApp Web version to v2.3000.1034756368 (#274)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-09 06:14:04 -03:00
github-actions[bot] ad81d1535d chore: update proto/version to v2.3000.1034750325 (#273)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-09 00:46:14 -03:00
Renato Alcara 7ecfaefcfa chore: update WhatsApp Web version to v2.3000.1034735671 (#272)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-08 06:05:18 -03:00
github-actions[bot] ad4f676e2b chore: update proto/version to v2.3000.1034732001 (#271)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-08 00:45:02 -03:00
Renato Alcara 54d6a023a4 chore: update WhatsApp Web version to v2.3000.1034715486 (#270)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-07 06:05:25 -03: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
github-actions[bot] 935484c790 chore: update proto/version to v2.3000.1034696150 (#269)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-07 00:37:49 -03:00
Renato Alcara f2c14f9ad6 fix: carousel renders on WhatsApp Web without F5
Two fixes:
1. Stanza type="media" for carousel messages — WhatsApp Web needs this
   to render carousel in real-time. Without it, only shows header until
   page refresh (F5).
2. Fix jimp detection: typeof Jimp === 'function' (not just 'object')
   so jpegThumbnail is generated for card images.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:25:07 -03:00
Renato Alcara ac2acf3d87 Fix/carousel working minimal (#268)
Fix/carousel working minimal (#268)
2026-03-06 23:58:59 -03:00
Renato Alcara 0b527b0855 fix: inline carousel handler for WhatsApp Web rendering (#267)
fix: inline carousel handler for WhatsApp Web rendering (#267)
2026-03-06 23:26:43 -03:00
Renato Alcara 9f8b603e28 fix: skip biz node for carousel messages (fixes WhatsApp Web rendering) (#265)
fix: skip biz node for carousel messages (fixes WhatsApp Web rendering) (#265)
2026-03-06 21:29:26 -03:00
Renato Alcara 6c904484e4 chore: update WhatsApp Web version to v2.3000.1034641024 (#264)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-06 06:09:40 -03:00
github-actions[bot] 5382d03f93 chore: update proto/version to v2.3000.1034621774 (#263)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-06 00:44:39 -03:00
Renato Alcara 5e602ebbf8 chore: update WhatsApp Web version to v2.3000.1034553229 (#262)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-05 06:12:30 -03:00
github-actions[bot] 12c400cf1e chore: update proto/version to v2.3000.1034530777 (#261)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-05 00:45:05 -03:00
Renato Alcara 92085d283a fix(sticker): align sticker pack with WABA Android (#260)
fix(sticker): align sticker pack with WABA Android (#260)
2026-03-04 10:19:25 -03:00
Renato Alcara 38aa34ffb0 chore: update WhatsApp Web version to v2.3000.1034439448 (#259)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-04 06:10:49 -03:00
Renato Alcara 52aa6402c0 fix(signal): align Signal Protocol handling with WABA Android behavior (#257)
7 corrections based on reverse-engineering of WhatsApp Business Android:

1. resolveCanonicalJid: PN→LID resolution for transaction lock keys (prevent race conditions)
2. Retain PN session during LID migration (avoid No Session errors)
3. Delayed PreKey deletion with 5-min grace period (prevent Invalid PreKey ID races)
4. Surgical session cleanup: only delete the specific corrupted device, not all devices
5. Identity dual storage: save identity key in both LID and PN addresses
6. MAC error cooldown reduced 10s→1s (faster recovery, aligned with WABA)
7. Allow session recreation on first retry (retryCount >= 1 instead of > 1)

* fix: remove unused jidDecode import from decode-wa-message.ts
* fix: add try/catch to delayed PreKey deletion to prevent unhandled rejection

The setTimeout async callback in removePreKey could cause unhandled promise
rejection if the keystore was destroyed (connection closed) before the
5-min grace period expired.
2026-03-04 00:51:20 -03:00
github-actions[bot] 8ea7f5434e chore: update proto/version to v2.3000.1034427372 (#258)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-04 00:43:59 -03:00
Renato Alcara cf72068b6c fix(tctoken): align lifecycle with WABA Android behavior (#256)
- Error 463 (MissingTcToken): add getPrivacyTokens() re-fetch before retry
- Error 479 (SmaxInvalid): add getPrivacyTokens() re-fetch (fire-and-forget)
- Add realIssueTimestamp field matching wa_trusted_contacts_send schema
- Session refresh reissue: store IQ result + persist senderTimestamp/realIssueTimestamp
2026-03-03 22:03:02 -03:00
Renato Alcara 9165a4941e chore: update WhatsApp Web version to v2.3000.1034382497 (#255)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-03 06:12:34 -03:00
github-actions[bot] a8d9e308bc chore: update proto/version to v2.3000.1034302344 (#254)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-03 00:46:23 -03:00
Renato Alcara c29fe8e5ac fix: use MACOS UserAgent for Android browser to fix pair code registration (#253)
The web protocol (WA\x06\x03) requires a web-compatible UserAgent.
Using SMB_ANDROID in the UserAgent caused pair code registration to
fail with "cannot connect device" even though the phone confirmation
appeared. The Android identity is now only set in DeviceProps.platformType
(ANDROID_PHONE) in the registration node, which correctly determines
the display name in "Linked Devices" as "Android (14)".

Changes:
- getUserAgent(): always returns MACOS platform and Desktop device
- getClientPayload(): always includes webInfo (no longer skipped for Android)
- Remove unused isAndroidBrowser import from validate-connection.ts
- Update pair code comments documenting tested platform IDs
- Add structured logging to pair code request
2026-03-02 22:06:55 -03:00
Renato Alcara d2ceeaadc4 feat: change default browser to Android (SMB_ANDROID) (#252)
* feat: add connection and pair code presentation logs

Single-line logs showing how the lib presents to WhatsApp:
- 📱/🖥️ Connection: phone number, platform, device, type
- 🔗 Pair code: companion platform, android override status
2026-03-02 15:03:41 -03:00
Renato Alcara 32a2a9b15c feat: add connection and pair code presentation logs (#251)
Single-line logs showing how the lib presents to WhatsApp:
- 📱/🖥️ Connection: phone number, platform, device, type
- 🔗 Pair code: companion platform, android override status

* fix: move connection log to CB:success and rename type to platformType

Move the presentation log from pre-handshake (validateConnection) to
CB:success where the connection is actually confirmed. Rename ambiguous
'type' label to 'platformType' matching DeviceProps.PlatformType.
2026-03-02 14:18:17 -03:00
Renato Alcara 31ac5aeb11 feat: add Android browser preset for companion device registration (#248)
* feat: add Android browser preset for companion device registration

Add Browsers.android(apiLevel) preset that identifies as an Android
companion device (SMB_ANDROID platform, ANDROID_PHONE device type).
Validated against Frida captures of real WhatsApp Android protocol.


* feat: support BAILEYS_BROWSER env var for browser selection

Allows selecting Android companion mode via environment variable
without code changes. Set BAILEYS_BROWSER=android (or android:15
for a specific API level) to register as an Android device.

* fix: auto-detect Android browser in pair code and fall back to Chrome

Pair code companion registration fails with Android browser because the
WA server rejects the ANDROID_PHONE companion_platform_id over the web
Noise protocol. When Android is detected, requestPairingCode() now
transparently uses Chrome/macOS platform values for the pair code stanza
while QR code pairing continues to work as Android (SMB_ANDROID).

* docs: add android to isValidBrowserPreset JSDoc
2026-03-02 12:02:08 -03:00
Renato Alcara 45885c7a01 chore: update WhatsApp Web version to v2.3000.1034300341 (#250)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-02 06:14:04 -03:00
github-actions[bot] f1b43e26a1 chore: update proto/version to v2.3000.1034293476 (#249)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-02 00:45:19 -03:00
Renato Alcara a3dd21c9d7 fix: break infinite Bad MAC + session recreation loop (#247)
* fix: break infinite Bad MAC + session recreation loop

Remove aggressive session cleanup from the decryption hot path that caused
cascading failures when multiple messages from the same contact arrived
simultaneously. The Signal Protocol naturally recovers via retry+pkmsg flow.

Changes:
- Remove cleanupCorruptedSession() from per-message error handler (hot path)
- Move session cleanup to retry-exhausted handler as safety net
- Add per-JID retry request deduplication (5s window)
- Add 10s cooldown on MAC error session recreation
- Export cleanupCorruptedSession/getDecryptionJid for use in messages-recv

Tested: corrupted session with fake keys → Bad MAC → retry receipt →
pkmsg recovery → all 7 messages delivered, zero infinite loop.


* fix: address PR review — lint, dedup scope, and cleanup targeting

- Remove unused `autoCleanCorrupted` param from decryptMessageNode (lint fix)
- Scope retry dedup by participant JID in group chats (not group JID)
- Move dedup registration after early-return checks to avoid blocking
  subsequent messages when max retries reached
- Use participant JID for session cleanup in groups (Signal sessions
  are per-participant, not per-group)
2026-03-01 20:59:47 -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
30 changed files with 7247 additions and 861 deletions
+212 -4
View File
@@ -1,7 +1,7 @@
syntax = "proto3";
package proto;
/// WhatsApp Version: 2.3000.1034274421
/// WhatsApp Version: 2.3000.1037162330
message ADVDeviceIdentity {
optional uint32 rawId = 1;
@@ -74,6 +74,7 @@ message AIMediaCollectionMessage {
message AIMediaCollectionMetadata {
optional string collectionId = 1;
optional uint32 uploadOrderIndex = 2;
}
message AIQueryFanout {
@@ -251,10 +252,12 @@ message AIThreadInfo {
optional AIThreadClientInfo clientInfo = 2;
message AIThreadClientInfo {
optional AIThreadType type = 1;
optional string sourceChatJid = 2;
enum AIThreadType {
UNKNOWN = 0;
DEFAULT = 1;
INCOGNITO = 2;
SIDE_CHAT = 3;
}
}
@@ -342,6 +345,14 @@ message BotAgeCollectionMetadata {
}
}
message BotAgentDeepLinkMetadata {
optional string token = 1;
}
message BotAgentMetadata {
optional BotAgentDeepLinkMetadata deepLinkMetadata = 1;
}
message BotCapabilityMetadata {
repeated BotCapabilityType capabilities = 1;
enum BotCapabilityType {
@@ -403,9 +414,19 @@ message BotCapabilityMetadata {
RICH_RESPONSE_UR_BLOKS_ENABLED = 55;
RICH_RESPONSE_INLINE_LINKS_ENABLED = 56;
RICH_RESPONSE_UR_IMAGINE_VIDEO = 57;
JSON_PATCH_STREAMING = 58;
AI_TAB_FORCE_CLIPPY = 59;
UNIFIED_RESPONSE_EMBEDDED_SCREENS = 60;
AI_SUBSCRIPTION_ENABLED = 61;
}
}
message BotCommandMetadata {
optional string commandName = 1;
optional string commandDescription = 2;
optional string commandPrompt = 3;
}
message BotDocumentMessageMetadata {
optional DocumentPluginType pluginType = 1;
enum DocumentPluginType {
@@ -637,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;
}
@@ -687,6 +709,8 @@ enum BotMetricsEntryPoint {
WEB_INTRO_PANEL = 46;
WEB_NAVIGATION_BAR = 47;
GROUP_MEMBER = 54;
CHATLIST_SEARCH = 55;
NEW_CHAT_LIST = 56;
}
message BotMetricsMetadata {
optional string destinationId = 1;
@@ -1069,6 +1093,7 @@ message ClientPairingProps {
optional bool isSyncdPureLidSession = 2;
optional bool isSyncdSnapshotRecoveryEnabled = 3;
optional bool isHsThumbnailSyncEnabled = 4;
optional bytes subscriptionSyncPayload = 5;
}
message ClientPayload {
@@ -1106,6 +1131,7 @@ message ClientPayload {
optional bool paaLink = 44;
optional int32 preacksCount = 45;
optional int32 processingQueueSize = 46;
repeated string pairedPeripherals = 47;
enum AccountType {
DEFAULT = 0;
GUEST = 1;
@@ -1448,6 +1474,11 @@ message ContextInfo {
optional AdType adType = 25;
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;
@@ -1602,6 +1633,8 @@ message Conversation {
optional bool limitSharingInitiatedByMe = 53;
optional bool maibaAiThreadEnabled = 54;
optional bool isMarketingMessageThread = 55;
optional bool isSenderNewAccount = 56;
optional uint32 afterReadDuration = 57;
enum EndOfHistoryTransferType {
COMPLETE_BUT_MORE_MESSAGES_REMAIN_ON_PRIMARY = 0;
COMPLETE_AND_NO_MORE_MESSAGE_REMAIN_ON_PRIMARY = 1;
@@ -1628,6 +1661,10 @@ message DeviceCapabilities {
message BusinessBroadcast {
optional bool importListEnabled = 1;
optional bool companionSupportEnabled = 2;
optional bool campaignSyncEnabled = 3;
optional bool insightsSyncEnabled = 4;
optional int32 recipientLimit = 5;
}
enum ChatLockSupportLevel {
@@ -1702,6 +1739,9 @@ message DeviceProps {
optional uint32 thumbnailSyncDaysLimit = 19;
optional uint32 initialSyncMaxMessagesPerChat = 20;
optional bool supportManusHistory = 21;
optional bool supportHatchHistory = 22;
repeated string supportedBotChannelFbids = 23;
optional bool supportInlineContacts = 24;
}
enum PlatformType {
@@ -1888,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;
@@ -1896,6 +1946,8 @@ message HandshakeMessage {
optional bytes static = 1;
optional bytes payload = 2;
optional bytes extendedCiphertext = 3;
optional bytes paddedBytes = 4;
optional bool simulateXxkemFs = 5;
}
message ClientHello {
@@ -1904,13 +1956,31 @@ message HandshakeMessage {
optional bytes payload = 3;
optional bool useExtended = 4;
optional bytes extendedCiphertext = 5;
optional bytes paddedBytes = 6;
optional bool sendServerHelloPaddedBytes = 7;
optional bool simulateXxkemFs = 8;
optional HandshakeMessage.HandshakePqMode pqMode = 9;
optional bytes extendedEphemeral = 10;
}
enum HandshakePqMode {
HANDSHAKE_PQ_MODE_UNKNOWN = 0;
XXKEM = 1;
XXKEM_FS = 2;
WA_CLASSICAL = 3;
WA_PQ = 4;
IKKEM = 5;
IKKEM_FS = 6;
XXKEM_2 = 7;
IKKEM_2 = 8;
}
message ServerHello {
optional bytes ephemeral = 1;
optional bytes static = 2;
optional bytes payload = 3;
optional bytes extendedStatic = 4;
optional bytes paddingBytes = 5;
optional bytes extendedCiphertext = 6;
}
}
@@ -1934,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;
@@ -2028,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;
@@ -2106,7 +2186,6 @@ message LimitSharing {
CHAT_SETTING = 1;
BIZ_SUPPORTS_FB_HOSTING = 2;
UNKNOWN_GROUP = 3;
DEPRECATION = 4;
}
}
@@ -2267,6 +2346,8 @@ message Message {
optional PollCreationMessage pollCreationMessageV6 = 119;
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;
@@ -2458,6 +2539,7 @@ message Message {
UNKNOWN = 0;
CONTROL_PASSED = 1;
CONTROL_TAKEN = 2;
INFO = 3;
}
message CloudAPIThreadControlNotificationContent {
optional string handoffNotificationText = 1;
@@ -2547,6 +2629,16 @@ message Message {
optional bytes encIv = 3;
}
message EventInviteMessage {
optional ContextInfo contextInfo = 1;
optional string eventId = 2;
optional string eventTitle = 3;
optional bytes jpegThumbnail = 4;
optional int64 startTime = 5;
optional string caption = 6;
optional bool isCanceled = 7;
}
message EventMessage {
optional ContextInfo contextInfo = 1;
optional bool isCanceled = 2;
@@ -2798,6 +2890,13 @@ message Message {
optional bool securityNotificationEnabled = 1;
}
enum InsightDeliveryState {
SENT = 0;
DELIVERED = 1;
READ = 2;
REPLIED = 3;
QUICK_REPLIED = 4;
}
message InteractiveMessage {
optional Header header = 1;
optional Body body = 2;
@@ -3066,9 +3165,10 @@ message Message {
message MessageHistoryMetadata {
repeated string historyReceivers = 1;
optional int64 oldestMessageTimestamp = 2;
optional int64 oldestMessageTimestampInWindow = 2;
optional int64 messageCount = 3;
repeated string nonHistoryReceivers = 4;
optional int64 oldestMessageTimestampInBundle = 5;
}
message MessageHistoryNotice {
@@ -3129,6 +3229,11 @@ message Message {
optional int64 expiryTimestamp = 2;
optional bool incentiveEligible = 3;
optional string referralId = 4;
optional InviteType inviteType = 5;
enum InviteType {
DEFAULT = 0;
MAPPER = 1;
}
enum ServiceType {
UNKNOWN = 0;
FBPAY = 1;
@@ -3170,6 +3275,16 @@ message Message {
optional HistorySyncChunkRetryRequest historySyncChunkRetryRequest = 8;
optional GalaxyFlowAction galaxyFlowAction = 9;
optional CompanionCanonicalUserNonceFetchRequest companionCanonicalUserNonceFetchRequest = 10;
optional BizBroadcastInsightsContactListRequest bizBroadcastInsightsContactListRequest = 11;
optional BizBroadcastInsightsRefreshRequest bizBroadcastInsightsRefreshRequest = 12;
message BizBroadcastInsightsContactListRequest {
optional string campaignId = 1;
}
message BizBroadcastInsightsRefreshRequest {
optional string campaignId = 1;
}
message CompanionCanonicalUserNonceFetchRequest {
optional string registrationTraceId = 1;
}
@@ -3206,6 +3321,7 @@ message Message {
optional int32 onDemandMsgCount = 4;
optional int64 oldestMsgTimestampMs = 5;
optional string accountLid = 6;
optional bool supportInlineResponse = 7;
}
message PlaceholderMessageResendRequest {
@@ -3244,6 +3360,18 @@ message Message {
optional CompanionCanonicalUserNonceFetchResponse companionCanonicalUserNonceFetchRequestResponse = 9;
optional HistorySyncChunkRetryResponse historySyncChunkRetryResponse = 10;
optional FlowResponsesCsvBundle flowResponsesCsvBundle = 11;
optional BizBroadcastInsightsContactListResponse bizBroadcastInsightsContactListResponse = 12;
message BizBroadcastInsightsContactListResponse {
optional string campaignId = 1;
optional int64 timestampMs = 2;
repeated Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.BizBroadcastInsightsContactState contacts = 3;
}
message BizBroadcastInsightsContactState {
optional string contactJid = 1;
optional Message.InsightDeliveryState state = 2;
}
message CompanionCanonicalUserNonceFetchResponse {
optional string nonce = 1;
optional string waFbid = 2;
@@ -3280,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;
@@ -3357,6 +3486,8 @@ message Message {
COMPANION_CANONICAL_USER_NONCE_FETCH = 9;
HISTORY_SYNC_CHUNK_RETRY = 10;
GALAXY_FLOW_ACTION = 11;
BUSINESS_BROADCAST_INSIGHTS_DELIVERED_TO = 12;
BUSINESS_BROADCAST_INSIGHTS_REFRESH = 13;
}
message PinInChatMessage {
optional MessageKey key = 1;
@@ -3558,6 +3689,7 @@ message Message {
message RequestWelcomeMessageMetadata {
optional LocalChatState localChatState = 1;
optional WelcomeTrigger welcomeTrigger = 2;
optional BotAgentMetadata botAgentMetadata = 3;
enum LocalChatState {
EMPTY = 0;
NON_EMPTY = 1;
@@ -3675,6 +3807,7 @@ message Message {
optional bool isLottie = 21;
optional string accessibilityLabel = 22;
optional int32 premium = 24;
optional string emojis = 25;
}
message StickerPackMessage {
@@ -3895,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;
@@ -3967,6 +4101,7 @@ message MsgOpaqueData {
optional string quarantineExtractedText = 48;
optional int64 pollEndTime = 49;
optional bool pollHideVoterNames = 50;
optional bool pollAllowAddOption = 52;
message EventLocation {
optional double degreesLatitude = 1;
optional double degreesLongitude = 2;
@@ -4083,6 +4218,9 @@ enum MutationProps {
NCT_SALT_SYNC_ACTION = 80;
BUSINESS_BROADCAST_CAMPAIGN_ACTION = 81;
BUSINESS_BROADCAST_INSIGHTS_ACTION = 82;
CUSTOMER_DATA_ACTION = 83;
SUBSCRIPTIONS_SYNC_V2_ACTION = 84;
THREAD_PIN_ACTION = 85;
SHARE_OWN_PN = 10001;
BUSINESS_BROADCAST_ACTION = 10002;
AI_THREAD_DELETE_ACTION = 10003;
@@ -4396,6 +4534,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;
@@ -4551,6 +4695,7 @@ message StatusAttribution {
APPLE_MUSIC = 8;
SHARECHAT = 9;
GOOGLE_PHOTOS = 10;
SOUNDCLOUD = 11;
}
}
@@ -4718,6 +4863,9 @@ message SyncActionValue {
optional NctSaltSyncAction nctSaltSyncAction = 80;
optional BusinessBroadcastCampaignAction businessBroadcastCampaignAction = 81;
optional BusinessBroadcastInsightsAction businessBroadcastInsightsAction = 82;
optional CustomerDataAction customerDataAction = 83;
optional SubscriptionsSyncV2Action subscriptionsSyncV2Action = 84;
optional ThreadPinAction threadPinAction = 85;
message AgentAction {
optional string name = 1;
optional int32 deviceID = 2;
@@ -4792,6 +4940,7 @@ message SyncActionValue {
repeated SyncActionValue.BroadcastListParticipant participants = 2;
optional string listName = 3;
repeated string labelIds = 4;
optional string audienceExpression = 5;
}
message CallLogAction {
@@ -4839,6 +4988,20 @@ message SyncActionValue {
repeated SyncActionValue.CustomPaymentMethod customPaymentMethods = 1;
}
message CustomerDataAction {
optional string chatJid = 1;
optional int32 contactType = 2;
optional string email = 3;
optional string altPhoneNumbers = 4;
optional int64 birthday = 5;
optional string address = 6;
optional int32 acquisitionSource = 7;
optional int32 leadStage = 8;
optional int64 lastOrder = 9;
optional int64 createdAt = 10;
optional int64 modifiedAt = 11;
}
message DeleteChatAction {
optional SyncActionValue.SyncActionMessageRange messageRange = 1;
}
@@ -4907,6 +5070,7 @@ message SyncActionValue {
DRAFTED = 8;
AI_HANDOFF = 9;
CHANNELS = 10;
AI_RESPONDING = 11;
}
}
@@ -5122,6 +5286,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;
@@ -5166,6 +5332,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;
@@ -5189,11 +5357,21 @@ message SyncActionValue {
repeated string userJid = 2;
optional bool shareToFB = 3;
optional bool shareToIG = 4;
repeated CustomList customLists = 5;
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;
}
}
@@ -5219,6 +5397,29 @@ message SyncActionValue {
optional int64 expirationDate = 3;
}
message SubscriptionsSyncV2Action {
repeated SubscriptionInfo subscriptions = 1;
repeated PaidFeature paidFeature = 2;
message PaidFeature {
optional string name = 1;
optional bool enabled = 2;
optional int32 limit = 3;
optional int64 expirationTime = 4;
}
message SubscriptionInfo {
optional string id = 1;
optional int32 tier = 2;
optional string status = 3;
optional int64 startTime = 4;
optional int64 endTime = 5;
optional bool isPlatformChanged = 6;
optional string source = 7;
optional int64 creationTime = 8;
}
}
message SyncActionMessage {
optional MessageKey key = 1;
optional int64 timestamp = 2;
@@ -5230,6 +5431,10 @@ message SyncActionValue {
repeated SyncActionValue.SyncActionMessage messages = 3;
}
message ThreadPinAction {
optional bool pinned = 1;
}
message TimeFormatAction {
optional bool isTwentyFourHourFormatEnabled = 1;
}
@@ -5351,7 +5556,6 @@ message TemplateButton {
message ThreadID {
optional ThreadType threadType = 1;
optional MessageKey threadKey = 2;
optional string sourceChatJid = 3;
enum ThreadType {
UNKNOWN = 0;
VIEW_REPLIES = 1;
@@ -5554,6 +5758,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;
@@ -5793,6 +5999,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;
}
}
+541 -17
View File
File diff suppressed because it is too large Load Diff
+4077 -49
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
@@ -47,7 +47,7 @@
"fflate": "^0.8.2",
"libsignal": "github:whiskeysockets/libsignal-node",
"lru-cache": "^11.2.6",
"music-metadata": "^11.12.0",
"music-metadata": "^11.12.3",
"p-queue": "^9.0.0",
"pino": "^10.3.1",
"prom-client": "^15.1.3",
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1034279434]}
{"version":[2,3000,1037166441]}
+48 -6
View File
@@ -56,10 +56,33 @@ export const PROCESSABLE_HISTORY_TYPES = [
// 6 hours in milliseconds
const SIX_HOURS_MS = 6 * 60 * 60 * 1000
/**
* Resolves the default browser tuple from the BAILEYS_BROWSER env var.
* Default: Android companion (SMB_ANDROID) — matches upstream PR #2201.
* Pair code auto-detects Android and falls back to Chrome in socket.ts.
*
* unset / 'android' → Browsers.android('14')
* 'android:15' → Browsers.android('15')
* 'chrome' / 'macos' → Browsers.macOS('Chrome')
*/
const resolveDefaultBrowser = (): [string, string, string] => {
const env = process.env.BAILEYS_BROWSER?.trim().toLowerCase()
if (env === 'chrome' || env === 'macos') {
return Browsers.macOS('Chrome')
}
if (env?.startsWith('android:')) {
const apiLevel = env.split(':')[1] || '14'
return Browsers.android(apiLevel)
}
return Browsers.android('14')
}
export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
version: version as WAVersion,
versionCheckIntervalMs: SIX_HOURS_MS,
browser: Browsers.macOS('Chrome'),
browser: resolveDefaultBrowser(),
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
connectTimeoutMs: 20_000,
keepAliveIntervalMs: 15_000,
@@ -131,7 +154,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 = {
@@ -155,7 +191,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
@@ -165,10 +205,12 @@ export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[]
/** 120s timeout for history sync stall detection, same as WA Web's handleChunkProgress / restartPausedTimer (g = 120) */
export const HISTORY_SYNC_PAUSED_TIMEOUT_MS = 120_000
export const MIN_PREKEY_COUNT = 5
// Replenishment threshold: when server count drops below this, top-up back to INITIAL_PREKEY_COUNT
export const MIN_PREKEY_COUNT = 200
// Moderate prekey count (upstream uses 812, reduced to balance rate limiting and availability)
export const INITIAL_PREKEY_COUNT = 200
// Initial pool size matching WA Business (CDP IDB capture: prekey-store = 812 on registration)
// Rounded to 800 for cleanliness; replenishment always tops up to this value
export const INITIAL_PREKEY_COUNT = 800
export const UPLOAD_TIMEOUT = 30000 // 30 seconds
// Moderate upload interval to balance rate limiting and responsiveness (was 5000)
+79 -13
View File
@@ -302,6 +302,25 @@ export function makeLibSignalRepository(
// Promise instead of each spawning their own DB transactions.
const migrationInFlight = new Map<string, Promise<{ migrated: number; skipped: number; total: number }>>()
// Resolve PN JID to its canonical LID JID for transaction locking.
// This prevents PN/LID race conditions where concurrent operations for the
// same logical contact acquire different mutex locks because one uses PN
// and the other uses LID. (Aligned with WABA behavior — all operations use LID internally.)
const resolveCanonicalJid = async(jid: string): Promise<string> => {
if (isAnyLidUser(jid)) {
return jid
}
if (isAnyPnUser(jid)) {
const lid = await lidMapping.getLIDForPN(jid)
if (lid) {
return lid
}
}
return jid
}
const repository: SignalRepositoryWithLIDStore = {
decryptGroupMessage({ group, authorJid, msg }) {
const senderName = jidToSignalSenderKeyName(group, authorJid)
@@ -396,23 +415,24 @@ export function makeLibSignalRepository(
return result
}
// If it's not a sync message, we need to ensure atomicity
// For regular messages, we use a transaction to ensure atomicity
// Use canonical JID (PN→LID resolved) as transaction key to prevent
// PN/LID race conditions on the same logical session.
const canonicalJid = await resolveCanonicalJid(jid)
return parsedKeys.transaction(async () => {
return await doDecrypt()
}, jid)
}, canonicalJid)
},
async encryptMessage({ jid, data }) {
const addr = jidToSignalProtocolAddress(jid)
const cipher = new libsignal.SessionCipher(storage, addr)
// Use transaction to ensure atomicity
const canonicalJid = await resolveCanonicalJid(jid)
return parsedKeys.transaction(async () => {
const { type: sigType, body } = await cipher.encrypt(data)
const type = sigType === 3 ? 'pkmsg' : 'msg'
return { type, ciphertext: Buffer.from(body, 'binary') }
}, jid)
}, canonicalJid)
},
async encryptGroupMessage({ group, meId, data }) {
@@ -654,9 +674,10 @@ export function makeLibSignalRepository(
// Session exists (guaranteed from device discovery)
const fromSession = libsignal.SessionRecord.deserialize(pnSession)
if (fromSession.haveOpenSession()) {
// Queue for bulk update: copy to LID, delete from PN
// Queue for bulk update: copy to LID, retain PN session.
// WABA retains both PN and LID sessions during migration to avoid
// No Session errors if messages arrive via PN before migration completes.
sessionUpdates[lidAddrStr] = fromSession.serialize()
sessionUpdates[pnAddrStr] = null
migratedCount++
}
@@ -776,6 +797,13 @@ function signalStorage(
return id
}
// Delayed PreKey deletion: grace period to handle race conditions
// where two pkmsg with the same preKeyId arrive nearly simultaneously.
// WABA deletes immediately (33ms), but we add a 5-min grace period
// because we can't handle "Invalid PreKey ID" errors at the native level.
const PREKEY_GRACE_PERIOD_MS = 5 * 60 * 1000 // 5 minutes
const pendingPreKeyDeletions = new Map<string, ReturnType<typeof setTimeout>>()
return {
loadSession: async (id: string) => {
try {
@@ -808,7 +836,26 @@ function signalStorage(
}
}
},
removePreKey: (id: number) => keys.set({ 'pre-key': { [id]: null } }),
removePreKey: (id: number) => {
const keyId = id.toString()
// Clear any existing timer for this key
const existing = pendingPreKeyDeletions.get(keyId)
if (existing) {
clearTimeout(existing)
}
// Schedule deletion after grace period
const timer = setTimeout(async () => {
pendingPreKeyDeletions.delete(keyId)
try {
await keys.set({ 'pre-key': { [id]: null } })
} catch {
// Keystore may be destroyed if connection closed — safe to ignore
}
}, PREKEY_GRACE_PERIOD_MS)
pendingPreKeyDeletions.set(keyId, timer)
},
loadSignedPreKey: () => {
const key = creds.signedPreKey
return {
@@ -898,14 +945,24 @@ function signalStorage(
// IDENTITY KEY CHANGED - contact reinstalled WhatsApp or switched devices
const previousFingerprint = generateKeyFingerprint(existingKey)
// Delete old session and save new identity key atomically
// Delete old session and save new identity key atomically.
// Store identity in BOTH LID and PN addresses (WABA stores in both
// recipient_account_type=0 and type=1 with CONFLICT_REPLACE).
const identityUpdates: Record<string, Uint8Array> = { [wireJid]: identityKey }
if (wireJid !== id) {
identityUpdates[id] = identityKey
}
await keys.set({
session: { [wireJid]: null },
'identity-key': { [wireJid]: identityKey }
'identity-key': identityUpdates
})
// Update cache
// Update cache for both addresses
identityKeyCache.set(wireJid, identityKey)
if (wireJid !== id) {
identityKeyCache.set(id, identityKey)
}
// Record metrics
metrics.signalIdentityChanges?.inc({ type: 'changed' })
@@ -941,10 +998,19 @@ function signalStorage(
if (!existingKey) {
// NEW CONTACT - Trust On First Use (TOFU)
await keys.set({ 'identity-key': { [wireJid]: identityKey } })
// Store in both LID and PN addresses (aligned with WABA dual identity storage)
const identityUpdates: Record<string, Uint8Array> = { [wireJid]: identityKey }
if (wireJid !== id) {
identityUpdates[id] = identityKey
}
// Update cache
await keys.set({ 'identity-key': identityUpdates })
// Update cache for both addresses
identityKeyCache.set(wireJid, identityKey)
if (wireJid !== id) {
identityKeyCache.set(id, identityKey)
}
// Record metrics
metrics.signalIdentityChanges?.inc({ type: 'new' })
+149 -51
View File
@@ -7,6 +7,7 @@ import { proto } from '../../WAProto/index.js'
import {
DEFAULT_CACHE_TTLS,
DEFAULT_SESSION_CLEANUP_CONFIG,
INITIAL_PREKEY_COUNT,
KEY_BUNDLE_TYPE,
MIN_PREKEY_COUNT,
PLACEHOLDER_MAX_AGE_SECONDS,
@@ -48,9 +49,12 @@ import {
getStatusFromReceiptType,
handleIdentityChange,
hkdf,
BAD_MAC_ERROR_TEXT,
DECRYPTION_RETRY_CONFIG,
MISSING_KEYS_ERROR_TEXT,
NACK_REASONS,
NO_MESSAGE_FOUND_ERROR_TEXT,
RetryReason,
normalizeKeyLidToPn,
normalizeMessageJids,
resolveLidToPn,
@@ -69,7 +73,7 @@ import {
recordMessageReceived,
recordMessageRetry
} from '../Utils/prometheus-metrics.js'
import { isTcTokenExpired, resolveTcTokenJid } from '../Utils/tc-token-utils'
import { isTcTokenExpired, resolveTcTokenJid, storeTcTokensFromIqResult } from '../Utils/tc-token-utils'
import {
areJidsSameUser,
type BinaryNode,
@@ -1212,7 +1216,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return await query(stanza)
}
const sendRetryRequest = async (node: BinaryNode, forceIncludeKeys = false) => {
const sendRetryRequest = async (node: BinaryNode, forceIncludeKeys = false, decryptionError?: string) => {
const { fullMessage } = decodeMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '')
const { key: msgKey } = fullMessage
const msgId = msgKey.id!
@@ -1310,39 +1314,39 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds
const fromJid = node.attrs.from!
// Check if we should recreate the session
let shouldRecreateSession = false
let recreateReason = ''
if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) {
try {
// Check if we have a session with this JID
const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid)
const hasSession = await signalRepository.validateSession(fromJid)
// Extract error code from retry node if present (for MAC error detection)
const retryNode = getBinaryNodeChild(node, 'retry')
const errorAttr = retryNode?.attrs?.error
const errorCode = messageRetryManager.parseRetryErrorCode(errorAttr)
const result = messageRetryManager.shouldRecreateSession(fromJid, hasSession.exists, errorCode)
shouldRecreateSession = result.recreate
recreateReason = result.reason
if (shouldRecreateSession) {
logger.debug({ fromJid, retryCount, reason: recreateReason, errorCode }, 'recreating session for retry')
// Delete existing session to force recreation
// CRITICAL: Use same transaction key as encrypt/decrypt operations to prevent race
// Using meId ensures this delete serializes with sendMessage() and other session operations
await authState.keys.transaction(async () => {
await authState.keys.set({ session: { [sessionId]: null } })
}, authState.creds.me?.id || 'session-operation')
forceIncludeKeys = true
}
} catch (error) {
logger.warn({ error, fromJid }, 'failed to check session recreation')
}
}
// Derive the Signal error code from the actual decryption failure message.
// Sent in the retry receipt so the peer (even another InfiniteAPI instance)
// knows the exact failure type and can recreate the session immediately
// instead of falling back to the 1-hour timeout.
//
// Codes mirror RetryReason enum in message-retry-manager.ts:
// 0 = UnknownError | 1 = NoSession | 2 = InvalidKey
// 3 = InvalidKeyId | 4 = InvalidMessage | 7 = BadMac
//
// Uses DECRYPTION_RETRY_CONFIG error lists (single source of truth in
// decode-wa-message.ts) so additions to those lists are picked up here
// automatically.
//
// NOTE: We do NOT delete the session here (receiver side). The Signal Protocol
// recovers automatically when the sender's pkmsg arrives — it overwrites the
// corrupted session. Deleting prematurely creates a race window where no session
// exists, which can cause "No Session" errors on concurrent messages.
const retryErrorCode = (() => {
if (!decryptionError) return RetryReason.UnknownError
// Bad MAC must be checked first — it is also in corruptedSessionErrors
// but warrants the more specific code 7 over the generic code 4.
if (decryptionError.includes(BAD_MAC_ERROR_TEXT)) return RetryReason.SignalErrorBadMac
// MessageCounterError and other corrupted-session variants
if (DECRYPTION_RETRY_CONFIG.corruptedSessionErrors.some(e => decryptionError.includes(e))) return RetryReason.SignalErrorInvalidMessage
// Missing / invalid session record
if (DECRYPTION_RETRY_CONFIG.sessionRecordErrors.some(e => decryptionError.includes(e)) ||
/no\s+(open\s+)?sessions?/i.test(decryptionError)) return RetryReason.SignalErrorNoSession
// PreKey / key-id errors
if (/pre\s*key/i.test(decryptionError)) return RetryReason.SignalErrorInvalidKeyId
// Identity / key errors
if (/invalid\s*key|untrusted\s*identity/i.test(decryptionError)) return RetryReason.SignalErrorInvalidKey
return RetryReason.UnknownError
})()
if (retryCount <= 2) {
// Use new retry manager for phone requests if available
@@ -1383,8 +1387,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
id: node.attrs.id!,
t: node.attrs.t!,
v: '1',
// ADD ERROR FIELD
error: '0'
error: retryErrorCode.toString()
}
},
{
@@ -1403,7 +1406,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
receipt.attrs.participant = node.attrs.participant
}
if (retryCount > 1 || forceIncludeKeys || shouldRecreateSession) {
if (retryCount > 1 || forceIncludeKeys) {
const { update, preKeys } = await getNextPreKeys(authState, 1)
const [keyId] = Object.keys(preKeys)
@@ -1440,7 +1443,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
logger.debug({ count, shouldUploadMorePreKeys }, 'recv pre-key count')
if (shouldUploadMorePreKeys) {
await uploadPreKeys()
// Top-up back to INITIAL_PREKEY_COUNT so the pool is always restored to full size
await uploadPreKeys(Math.max(1, INITIAL_PREKEY_COUNT - count))
}
} else {
const result = await handleIdentityChange(node, {
@@ -1453,6 +1457,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
})
// 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)
@@ -1462,9 +1467,36 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
if (entry?.token?.length && !isTcTokenExpired(entry.timestamp)) {
const senderTs = unixTimestampSeconds()
logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' })
getPrivacyTokens([normalizedJid], senderTs).catch(err => {
logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message })
})
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(() => {
@@ -1912,7 +1944,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
[storageJid]: {
...existing,
token: Buffer.from(content),
timestamp
timestamp,
// WABA Android: resets real_issue_timestamp when a new incoming token arrives
// (UPDATE wa_trusted_contacts_send SET real_issue_timestamp=null)
realIssueTimestamp: null
}
}
})
@@ -2002,7 +2037,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let shouldRecreateSession = false
let recreateReason = ''
if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) {
if (enableAutoSessionRecreation && messageRetryManager && retryCount >= 1) {
try {
const sessionId = signalRepository.jidToSignalProtocolAddress(participant)
@@ -2216,10 +2251,16 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}
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)
// msmsg block removed — allow msmsg decryption (required for view-once on linked devices)
// Handle view-once unavailable sync from primary device.
// When the primary device sends a view-once, linked companions receive
// <unavailable type="view_once"/> — no enc content, just a sync signal.
// Acknowledge and skip decryption (nothing to decrypt).
const unavailableNode = getBinaryNodeChild(node, 'unavailable')
if (unavailableNode?.attrs?.type === 'view_once') {
logger.debug({ id: node.attrs.id, from: node.attrs.from }, 'received view_once unavailable sync from primary device')
await sendMessageAck(node)
return
}
@@ -2336,6 +2377,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)
}
@@ -2474,7 +2532,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}
const encNode = getBinaryNodeChild(node, 'enc')
await sendRetryRequest(node, !encNode)
await sendRetryRequest(node, !encNode, errorMessage)
if (retryRequestDelayMs) {
await delay(retryRequestDelayMs)
}
@@ -2483,7 +2541,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Still attempt retry even if pre-key upload failed
try {
const encNode = getBinaryNodeChild(node, 'enc')
await sendRetryRequest(node, !encNode)
await sendRetryRequest(node, !encNode, errorMessage)
} catch (retryErr) {
logger.error({ retryErr }, 'Failed to send retry after error handling')
}
@@ -2517,6 +2575,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) {
@@ -2823,6 +2886,24 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const jid = jidNormalizedUser(attrs.from)
logTcToken('error_463', { jid, msgId })
// WABA Android: error 463 triggers getPrivacyTokens() fire-and-forget
// to ensure token is available for the retry below
getPrivacyTokens([jid])
.then(async (result) => {
await storeTcTokensFromIqResult({
result,
fallbackJid: jid,
keys: authState.keys,
getLIDForPN,
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
logTcToken('fetched', { jid, reason: 'error_463' })
})
.catch(() => { /* fire-and-forget */ })
// Single-retry: wait 1.5s for the server's tctoken notification to arrive,
// then resend. A Set prevents infinite retry loops.
// Composite key (jid:msgId) ensures retries are isolated per destination.
@@ -2858,7 +2939,24 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return
}
} else if (attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
logTcToken('error_479', { jid: attrs.from, msgId: attrs.id })
const jid479 = jidNormalizedUser(attrs.from)
logTcToken('error_479', { jid: jid479, msgId: attrs.id })
// WABA Android: error 479 (SmaxInvalid) also triggers token re-fetch
getPrivacyTokens([jid479])
.then(async (result) => {
await storeTcTokensFromIqResult({
result,
fallbackJid: jid479,
keys: authState.keys,
getLIDForPN,
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
logTcToken('fetched', { jid: jid479, reason: 'error_479' })
})
.catch(() => { /* fire-and-forget */ })
} else {
logger.warn({ attrs }, 'received error in ack')
}
+337 -105
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'crypto'
import NodeCache from '@cacheable/node-cache'
import { Boom } from '@hapi/boom'
import { proto } from '../../WAProto/index.js'
@@ -8,6 +9,7 @@ import type {
AlbumMessageOptions,
AlbumSendResult,
AnyMessageContent,
LIDMapping,
MediaConnInfo,
MessageReceiptType,
MessageRelayOptions,
@@ -337,6 +339,33 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
}
// 4th LID→PN source: device-list entries sharing the same raw_id
// WA Business uses this for accounts where HistorySync sends zero phoneNumberToLidMappings
const allEntries = [...result.list, ...result.sideList]
const rawIdMap = new Map<number, { pn?: string; lid?: string }>()
for (const item of allEntries) {
if (typeof item.rawId !== 'number' || isNaN(item.rawId)) continue
const decoded = jidDecode(item.id)
if (!decoded) continue
const entry = rawIdMap.get(item.rawId) || {}
if (decoded.server === 'lid' || decoded.server === 'hosted.lid') {
entry.lid = item.id
} else if (decoded.server === 's.whatsapp.net' || decoded.server === 'c.us') {
entry.pn = item.id
}
rawIdMap.set(item.rawId, entry)
}
const rawIdMappings: LIDMapping[] = []
for (const { pn, lid } of rawIdMap.values()) {
if (pn && lid) {
rawIdMappings.push({ lid: jidNormalizedUser(lid), pn: jidNormalizedUser(pn) })
}
}
if (rawIdMappings.length > 0) {
await signalRepository.lidMapping.storeLIDPNMappings(rawIdMappings)
logger.debug({ count: rawIdMappings.length }, 'stored LID-PN mappings from raw_id pairing')
}
const meId = authState.creds.me?.id
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const meLid = authState.creds.me?.lid || ''
@@ -507,6 +536,38 @@ export const makeMessagesSocket = (config: SocketConfig) => {
return didFetchNewSession
}
const canonicalizeCarouselRecipients = async (recipientJids: string[]): Promise<string[]> => {
if (!recipientJids.length) {
return recipientJids
}
const pnRecipients = [...new Set(recipientJids.filter(jid => isAnyPnUser(jid)))]
if (!pnRecipients.length) {
return recipientJids
}
const mappings = (await signalRepository.lidMapping.getLIDsForPNs(pnRecipients)) || []
if (!mappings.length) {
logger.debug({ recipientCount: recipientJids.length }, '[CAROUSEL] No PN→LID recipient mappings found')
return recipientJids
}
const pnToLid = new Map(mappings.map(item => [item.pn, item.lid]))
const mapped = recipientJids.map(jid => pnToLid.get(jid) || jid)
const changed = mapped.filter((jid, index) => jid !== recipientJids[index]).length
logger.info(
{
recipientCount: recipientJids.length,
pnRecipients: pnRecipients.length,
mappedRecipients: changed
},
'[CAROUSEL] Canonicalized recipient addressing to LID before session assert/encrypt'
)
return mapped
}
const sendPeerDataOperationMessage = async (
pdoMessage: proto.Message.IPeerDataOperationRequestMessage
): Promise<string> => {
@@ -541,6 +602,82 @@ export const makeMessagesSocket = (config: SocketConfig) => {
return msgId
}
const resolveDsmMessageForRecipient = (
jid: string,
patchedMessage: proto.IMessage,
dsmMessage: proto.IMessage | undefined,
meId: string,
meLid: string | undefined,
meLidUser: string | null | undefined
) => {
if (!dsmMessage) {
return patchedMessage
}
const { user: targetUser } = jidDecode(jid)!
const { user: ownPnUser } = jidDecode(meId)!
const isOwnUser = targetUser === ownPnUser || (meLidUser && targetUser === meLidUser)
const isExactSenderDevice = jid === meId || (meLid && jid === meLid)
if (isOwnUser && !isExactSenderDevice) {
logger.debug({ jid, targetUser }, 'Using DSM for own device')
return dsmMessage
}
return patchedMessage
}
const encryptPatchedMessageForRecipient = async ({
jid,
patchedMessage,
dsmMessage,
meId,
meLid,
meLidUser,
extraAttrs,
onPkmsg
}: {
jid: string
patchedMessage: proto.IMessage
dsmMessage: proto.IMessage | undefined
meId: string
meLid: string | undefined
meLidUser: string | null | undefined
extraAttrs: BinaryNode['attrs'] | undefined
onPkmsg: () => void
}) => {
if (!jid) return null
try {
const msgToEncrypt = resolveDsmMessageForRecipient(jid, patchedMessage, dsmMessage, meId, meLid, meLidUser)
const bytes = encodeWAMessage(msgToEncrypt)
const mutexKey = jid
return await encryptionMutex.mutex(mutexKey, async () => {
const { type, ciphertext } = await signalRepository.encryptMessage({ jid, data: bytes })
if (type === 'pkmsg') {
onPkmsg()
}
return {
tag: 'to',
attrs: { jid },
content: [
{
tag: 'enc',
attrs: { v: '2', type, ...(extraAttrs || {}) },
content: ciphertext
}
]
} as BinaryNode
})
} catch (err) {
logger.error({ jid, err }, 'Failed to encrypt for recipient')
return null
}
}
const createParticipantNodes = async (
recipientJids: string[],
message: proto.IMessage,
@@ -560,58 +697,23 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const meId = authState.creds.me?.id
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const meLid = authState.creds.me?.lid
const meLidUser = meLid ? jidDecode(meLid)?.user : null
const meLidUser = meLid ? (jidDecode(meLid)?.user ?? null) : null
const onPkmsg = () => {
shouldIncludeDeviceIdentity = true
}
const encryptionPromises = (patchedMessages as { recipientJid: string; message: proto.IMessage }[]).map(
async ({ recipientJid: jid, message: patchedMessage }: { recipientJid: string; message: proto.IMessage }) => {
try {
if (!jid) return null
let msgToEncrypt = patchedMessage
if (dsmMessage) {
const { user: targetUser } = jidDecode(jid)!
const { user: ownPnUser } = jidDecode(meId)!
const ownLidUser = meLidUser
const isOwnUser = targetUser === ownPnUser || (ownLidUser && targetUser === ownLidUser)
const isExactSenderDevice = jid === meId || (meLid && jid === meLid)
if (isOwnUser && !isExactSenderDevice) {
msgToEncrypt = dsmMessage
logger.debug({ jid, targetUser }, 'Using DSM for own device')
}
}
const bytes = encodeWAMessage(msgToEncrypt)
const mutexKey = jid
const node = await encryptionMutex.mutex(mutexKey, async () => {
const { type, ciphertext } = await signalRepository.encryptMessage({ jid, data: bytes })
if (type === 'pkmsg') {
shouldIncludeDeviceIdentity = true
}
return {
tag: 'to',
attrs: { jid },
content: [
{
tag: 'enc',
attrs: { v: '2', type, ...(extraAttrs || {}) },
content: ciphertext
}
]
}
})
return node
} catch (err) {
logger.error({ jid, err }, 'Failed to encrypt for recipient')
return null
}
}
({ recipientJid: jid, message: patchedMessage }: { recipientJid: string; message: proto.IMessage }) =>
encryptPatchedMessageForRecipient({
jid,
patchedMessage,
dsmMessage,
meId,
meLid,
meLidUser,
extraAttrs,
onPkmsg
})
)
const nodes = (await Promise.all(encryptionPromises)).filter(node => node !== null) as BinaryNode[]
@@ -939,7 +1041,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
}
@@ -949,7 +1052,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 = {
@@ -971,6 +1074,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 () => {
@@ -1166,21 +1271,57 @@ export const makeMessagesSocket = (config: SocketConfig) => {
allRecipients.push(jid)
}
await assertSessions(allRecipients)
const isCarouselFanout = isCarouselMessage(message)
const effectiveMeRecipients = isCarouselFanout
? await canonicalizeCarouselRecipients(meRecipients)
: meRecipients
const effectiveOtherRecipients = isCarouselFanout
? await canonicalizeCarouselRecipients(otherRecipients)
: otherRecipients
const effectiveAllRecipients = [...effectiveMeRecipients, ...effectiveOtherRecipients]
// 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(meRecipients, meMsg || message, extraAttrs),
createParticipantNodes(otherRecipients, message, extraAttrs)
createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs),
createParticipantNodes(effectiveOtherRecipients, message, extraAttrs)
])
participants.push(...meNodes)
participants.push(...otherNodes)
if (meRecipients.length > 0 || otherRecipients.length > 0) {
extraAttrs['phash'] = generateParticipantHashV2([...meRecipients, ...otherRecipients])
// 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
@@ -1253,13 +1394,15 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const isCarousel = isCarouselMessage(message)
// Collect biz/bot nodes to append AFTER device-identity and tctoken
// Stanza order: participants → device-identity → tctoken → biz
// The biz node MUST be last for WhatsApp Web carousel rendering
// Stanza order: participants → device-identity → tctoken → biz (when applicable)
// CDP capture confirms Pastorini INCLUDES biz node for carousel (native_flow v=9, name=mixed)
const deferredNodes: BinaryNode[] = []
// Inject biz node for interactive messages (including carousel)
if (buttonType && enableInteractiveMessages) {
// Inject biz node for interactive messages (including carousel — CDP evidence from Pastorini)
if ((buttonType || isCarousel) && enableInteractiveMessages) {
const startTime = Date.now()
// When entering via isCarousel, buttonType may be undefined — default to 'native_flow'
const effectiveButtonType = buttonType || 'native_flow'
// Debug: Log message structure to diagnose list detection
const interactiveMsg =
@@ -1297,7 +1440,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
)
// Track that we're sending an interactive message
metrics.interactiveMessagesSent.inc({ type: buttonType })
metrics.interactiveMessagesSent.inc({ type: effectiveButtonType })
try {
// Classify button types for native_flow name and bot node decisions
@@ -1340,27 +1483,49 @@ export const makeMessagesSocket = (config: SocketConfig) => {
)
const interactiveType = 'native_flow'
const bizContent: BinaryNode[] = [
{
tag: 'interactive',
attrs: {
type: interactiveType,
v: '1'
},
content: [
{
tag: interactiveType,
attrs: {
v: '9',
name: nativeFlowName
}
}
]
}
]
// CDP capture shows Pastorini includes quality_control inside biz for carousel
if (isCarousel) {
const decisionId = randomBytes(20).toString('hex')
bizContent.push({
tag: 'quality_control',
attrs: {
decision_id: decisionId
},
content: [
{
tag: 'decision_source',
attrs: {
value: 'df'
}
}
]
})
logger.info({ msgId, decisionId }, '[BIZ NODE] Added quality_control for carousel')
}
deferredNodes.push({
tag: 'biz',
attrs: {},
content: [
{
tag: 'interactive',
attrs: {
type: interactiveType,
v: '1'
},
content: [
{
tag: interactiveType,
attrs: {
v: '9',
name: nativeFlowName
}
}
]
}
]
content: bizContent
})
}
@@ -1386,11 +1551,11 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
// Track success and latency after message is sent
metrics.interactiveMessagesSuccess.inc({ type: buttonType })
metrics.interactiveMessagesLatency.observe({ type: buttonType }, Date.now() - startTime)
metrics.interactiveMessagesSuccess.inc({ type: effectiveButtonType })
metrics.interactiveMessagesLatency.observe({ type: effectiveButtonType }, Date.now() - startTime)
} catch (error) {
logger.error({ error, msgId, buttonType }, '[BIZ NODE] Failed to inject biz node')
metrics.interactiveMessagesFailures.inc({ type: buttonType, reason: 'injection_failed' })
logger.error({ error, msgId, buttonType: effectiveButtonType }, '[BIZ NODE] Failed to inject biz node')
metrics.interactiveMessagesFailures.inc({ type: effectiveButtonType, reason: 'injection_failed' })
}
} else if (buttonType && !enableInteractiveMessages) {
logger.warn(
@@ -1417,7 +1582,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
stanza.attrs.to = destinationJid
}
if (shouldIncludeDeviceIdentity) {
// Always include device-identity for carousel (Pastorini stanza always has it)
if (shouldIncludeDeviceIdentity || isCarousel) {
;(stanza.content as BinaryNode[]).push({
tag: 'device-identity',
attrs: {},
@@ -1474,27 +1640,79 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
}
// If tctoken is missing for a 1:1 send, fire-and-forget fetch so the
// retry path (error 463 → handleBadAck) can pick it up on resend
// If tctoken is missing for a 1:1 send, fetch it
// CDP capture confirms Pastorini stanza includes tctoken for carousel
if (!tcTokenBuffer?.length && is1on1Send && !tcTokenFetchingJids.has(tcTokenJid)) {
tcTokenFetchingJids.add(tcTokenJid)
logTcToken('fetch', { jid: destinationJid })
getPrivacyTokens([destinationJid])
.then(async fetchResult => {
if (isCarousel) {
// BLOCKING fetch for carousel — tctoken is required (Pastorini stanza has it)
try {
const fetchResult = await getPrivacyTokens([destinationJid])
// Direct extraction from IQ result — bypass store/read key mismatch
const tokensNode = getBinaryNodeChild(fetchResult, 'tokens')
if (tokensNode) {
const tokenNodes = getBinaryNodeChildren(tokensNode, 'token')
for (const tokenNode of tokenNodes) {
if (tokenNode.attrs.type === 'trusted_contact' && tokenNode.content instanceof Uint8Array) {
tcTokenBuffer = Buffer.from(tokenNode.content)
logger.info(
{
jid: destinationJid,
tokenLen: tcTokenBuffer.length,
tokenJid: tokenNode.attrs.jid,
timestamp: tokenNode.attrs.t
},
'[CAROUSEL] tctoken extracted directly from IQ result'
)
break
}
}
}
if (!tcTokenBuffer?.length) {
// Debug: dump the IQ result structure
const childTags = Array.isArray(fetchResult.content)
? (fetchResult.content as BinaryNode[]).map(n => `${n.tag}(${JSON.stringify(n.attrs)})`)
: []
logger.warn(
{ jid: destinationJid, resultTag: fetchResult.tag, resultAttrs: fetchResult.attrs, childTags },
'[CAROUSEL] tctoken fetch completed but NO valid token in IQ result'
)
}
// Also store for future use
await storeTcTokensFromIqResult({
result: fetchResult,
fallbackJid: destinationJid,
keys: authState.keys,
getLIDForPN
// onNewJidStored not passed — the pruning index lives in messages-recv (higher layer)
})
})
.catch(err => {
logger.debug({ jid: destinationJid, err: err?.message }, 'fire-and-forget tctoken fetch failed')
})
.finally(() => {
}).catch(() => {})
} catch (err: any) {
logger.warn({ jid: destinationJid, err: err?.message }, '[CAROUSEL] Blocking tctoken fetch failed')
} finally {
tcTokenFetchingJids.delete(tcTokenJid)
})
}
} else {
// Fire-and-forget for non-carousel
getPrivacyTokens([destinationJid])
.then(async fetchResult => {
await storeTcTokensFromIqResult({
result: fetchResult,
fallbackJid: destinationJid,
keys: authState.keys,
getLIDForPN
})
})
.catch(err => {
logger.debug({ jid: destinationJid, err: err?.message }, 'fire-and-forget tctoken fetch failed')
})
.finally(() => {
tcTokenFetchingJids.delete(tcTokenJid)
})
}
}
if (tcTokenBuffer?.length) {
@@ -1525,8 +1743,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
logger.debug({ msgId }, `sending message to ${participants.length} devices`)
// ======= PROTOBUF ROUNDTRIP TEST: Verify encoding preserves carousel =======
// Only runs at debug level to avoid performance overhead in production
if (isCarousel && logger.level === 'debug') {
if (isCarousel) {
try {
const encoded = proto.Message.encode(message).finish()
const decoded = proto.Message.decode(encoded)
@@ -1535,15 +1752,19 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const card0 = decodedInteractive?.carouselMessage?.cards?.[0]
const card0Header = card0?.header
logger.debug(
logger.info(
{
msgId,
encodedSize: encoded.length,
messageKeys: Object.keys(message),
hasInteractive: !!message.interactiveMessage,
hasViewOnce: !!message.viewOnceMessage,
hasCarouselAfterDecode: !!decodedInteractive?.carouselMessage,
cardsCount,
card0Title: card0Header?.title,
card0HasImage: !!card0Header?.imageMessage,
card0Buttons: card0?.nativeFlowMessage?.buttons?.length || 0
card0Buttons: card0?.nativeFlowMessage?.buttons?.length || 0,
messageVersion: decodedInteractive?.carouselMessage?.messageVersion
},
'[ROUNDTRIP] Protobuf encode→decode verification'
)
@@ -1553,8 +1774,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
// ======= PROTOCOL INTERCEPTOR: Dump complete stanza for debugging =======
// Only runs at debug level to avoid logging sensitive content in production
if ((buttonType || isCarousel) && logger.level === 'debug') {
if (buttonType || isCarousel) {
const dumpBinaryNode = (node: BinaryNode, indent = 0): string => {
if (!node) return ''
const pad = ' '.repeat(indent)
@@ -1611,6 +1831,8 @@ 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
const currentData = await authState.keys.get('tctoken', [tcTokenJid])
const currentEntry = currentData[tcTokenJid]
await authState.keys.set({
@@ -1618,7 +1840,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
[tcTokenJid]: {
...currentEntry,
token: currentEntry?.token ?? Buffer.alloc(0),
senderTimestamp: issueTimestamp
senderTimestamp: issueTimestamp,
realIssueTimestamp: 0
}
}
})
@@ -1704,6 +1927,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 -2
View File
@@ -123,11 +123,11 @@ export const makeNewsletterSocket = (config: SocketConfig) => {
},
newsletterMute: (jid: string) => {
return executeWMexQuery({ newsletter_id: jid }, QueryIds.MUTE, XWAPaths.xwa2_newsletter_mute_v2)
return executeWMexQuery({ input: { newsletter_id: jid, type: 'MUTE_ADMIN_ACTIVITY', value: 'OFF' } }, QueryIds.MUTE, XWAPaths.xwa2_newsletter_mute_v2)
},
newsletterUnmute: (jid: string) => {
return executeWMexQuery({ newsletter_id: jid }, QueryIds.UNMUTE, XWAPaths.xwa2_newsletter_unmute_v2)
return executeWMexQuery({ input: { newsletter_id: jid, type: 'MUTE_ADMIN_ACTIVITY', value: 'ON' } }, QueryIds.UNMUTE, XWAPaths.xwa2_newsletter_unmute_v2)
},
newsletterUpdateName: async (jid: string, name: string) => {
+53 -14
View File
@@ -38,7 +38,7 @@ import {
signedKeyPair,
xmppSignedPreKey
} from '../Utils'
import { getPlatformId } from '../Utils/browser-utils'
import { getPlatformId, isAndroidBrowser } from '../Utils/browser-utils'
import {
CircuitBreaker,
CircuitOpenError,
@@ -634,8 +634,6 @@ export const makeSocket = (config: SocketConfig) => {
}
helloMsg = proto.HandshakeMessage.fromObject(helloMsg)
logger.info({ browser, helloMsg }, 'connected to WA')
const init = proto.HandshakeMessage.encode(helloMsg).finish()
const result = await awaitNextMessage<Uint8Array>(init)
@@ -703,10 +701,12 @@ export const makeSocket = (config: SocketConfig) => {
}
}
// Prevent multiple concurrent uploads
// Prevent multiple concurrent uploads — if one is already running, wait for it and return:
// the concurrent upload already replenished the pool, so there is nothing left to do.
if (uploadPreKeysPromise) {
logger.debug('Pre-key upload already in progress, waiting for completion')
await uploadPreKeysPromise
return
}
const uploadLogic = async () => {
@@ -786,14 +786,15 @@ export const makeSocket = (config: SocketConfig) => {
try {
let count = 0
const preKeyCount = await getAvailablePreKeysOnServer()
if (preKeyCount === 0) count = INITIAL_PREKEY_COUNT
else count = MIN_PREKEY_COUNT
// How many to upload: top-up to INITIAL_PREKEY_COUNT from whatever remains on server
count = Math.max(0, INITIAL_PREKEY_COUNT - preKeyCount)
const { exists: currentPreKeyExists, currentPreKeyId } = await verifyCurrentPreKeyExists()
logger.info(`${preKeyCount} pre-keys found on server`)
logger.info(`Current prekey ID: ${currentPreKeyId}, exists in storage: ${currentPreKeyExists}`)
const lowServerCount = preKeyCount <= count
// Trigger upload when below the replenishment threshold, not when count < topUp amount
const lowServerCount = preKeyCount < MIN_PREKEY_COUNT
const missingCurrentPreKey = !currentPreKeyExists && currentPreKeyId > 0
const shouldUpload = lowServerCount || missingCurrentPreKey
@@ -1153,7 +1154,7 @@ export const makeSocket = (config: SocketConfig) => {
// Decrement active connections
decrementActiveConnections()
clearInterval(keepAliveReq)
clearTimeout(keepAliveReq)
clearTimeout(qrTimer)
// Clear offline-buffer safety timer so its callback cannot call ev.flush()
@@ -1264,8 +1265,16 @@ export const makeSocket = (config: SocketConfig) => {
})
}
const startKeepAliveRequest = () =>
(keepAliveReq = setInterval(() => {
const startKeepAliveRequest = () => {
// Use recursive setTimeout with ±15% jitter to match WA Desktop behaviour
// (WA Business Desktop: ~25-30s intervals with natural variance)
const scheduleNextKeepAlive = () => {
const jitter = keepAliveIntervalMs * 0.15
const delay = keepAliveIntervalMs + Math.floor((Math.random() * 2 - 1) * jitter)
keepAliveReq = setTimeout(onKeepAliveTick, delay)
}
const onKeepAliveTick = () => {
if (!lastDateRecv) {
lastDateRecv = new Date()
}
@@ -1277,6 +1286,7 @@ export const makeSocket = (config: SocketConfig) => {
*/
if (diff > keepAliveIntervalMs + 5000) {
void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
return // connection closing — do not reschedule
} else if (ws.isOpen) {
// Send keep-alive ping via sendNode() (fire-and-forget) instead of query().
// query() wraps the ping in the query circuit breaker — when that breaker is
@@ -1299,7 +1309,15 @@ export const makeSocket = (config: SocketConfig) => {
} else {
logger.warn('keep alive called when WS not open')
}
}, keepAliveIntervalMs))
// Do not reschedule once shutdown has started (closed set by end() on any concurrent path)
if (!closed) {
scheduleNextKeepAlive()
}
}
scheduleNextKeepAlive()
}
/** i have no idea why this exists. pls enlighten me */
const sendPassiveIq = (tag: 'passive' | 'active') =>
query({
@@ -1352,6 +1370,25 @@ export const makeSocket = (config: SocketConfig) => {
id: jidEncode(phoneNumber, 's.whatsapp.net'),
name: '~'
}
// Pair code companion_platform_id must be Chrome (1) when using Android
// browser preset. ANDROID_PHONE (16) causes silent timeout (server ignores),
// UWP (21) causes "cannot connect device" rejection. Only Chrome (1) works
// for pair code via web protocol (WA\x06\x03). The device still appears as
// "Android" in linked devices because DeviceProps.platformType=ANDROID_PHONE
// is set separately in the registration node.
const isAndroid = isAndroidBrowser(browser)
const pairPlatformId = isAndroid ? getPlatformId('Chrome') : getPlatformId(browser[1])
const pairPlatformDisplay = isAndroid ? 'Chrome (Mac OS)' : `${browser[1]} (${browser[0]})`
logger.info({
pairCode: pairingCode,
jid: authState.creds.me.id,
companionPlatformId: pairPlatformId,
companionPlatformDisplay: pairPlatformDisplay,
isAndroid,
}, `pair code requested | companion: ${pairPlatformDisplay} | ${isAndroid ? 'android override -> Chrome' : 'native platform'}`)
ev.emit('creds.update', authState.creds)
await sendNode({
tag: 'iq',
@@ -1384,12 +1421,12 @@ export const makeSocket = (config: SocketConfig) => {
{
tag: 'companion_platform_id',
attrs: {},
content: getPlatformId(browser[1])
content: pairPlatformId
},
{
tag: 'companion_platform_display',
attrs: {},
content: `${browser[1]} (${browser[0]})`
content: pairPlatformDisplay
},
{
tag: 'link_code_pairing_nonce',
@@ -1519,7 +1556,9 @@ export const makeSocket = (config: SocketConfig) => {
})
// login complete
ws.on('CB:success', async (node: BinaryNode) => {
logger.info('opened connection to WA')
const isAndroid = isAndroidBrowser(browser)
const phoneId = authState.creds.me?.id?.split(':')[0]?.split('@')[0] || 'new session'
logger.info(`${isAndroid ? '\uD83D\uDCF1' : '\uD83D\uDDA5\uFE0F'} Connected to WA | ${phoneId} | platform: ${isAndroid ? 'SMB_ANDROID' : 'MACOS'} | device: ${isAndroid ? 'Android' : 'Desktop'} | platformType: ${isAndroid ? 'ANDROID_PHONE' : 'CHROME'}`)
clearTimeout(qrTimer) // will never happen in all likelyhood -- but just in case WA sends success on first try
ev.emit('creds.update', { me: { ...authState.creds.me!, lid: node.attrs.lid } })
+1 -1
View File
@@ -80,7 +80,7 @@ export type SignalDataTypeMap = {
'app-state-sync-version': LTHashState
'lid-mapping': string
'device-list': string[]
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number }
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number; realIssueTimestamp?: number | null }
/** Identity key for Signal Protocol - used for detecting contact reinstalls */
'identity-key': Uint8Array
}
+4
View File
@@ -27,6 +27,8 @@ export type BaileysEventMap = {
chats: Chat[]
contacts: Contact[]
messages: WAMessage[]
/** Past participants for group chats (people who left/were removed). userJid is always a phone number (PN), never a LID. */
pastParticipants?: proto.IPastParticipants[] | null
isLatest?: boolean
progress?: number | null
syncType?: proto.HistorySync.HistorySyncType | null
@@ -185,6 +187,8 @@ export type BufferedEventData = {
chats: { [jid: string]: Chat }
contacts: { [jid: string]: Contact }
messages: { [uqId: string]: WAMessage }
/** Keyed by groupJid for O(1) deduplication across chunks */
pastParticipants: { [groupJid: string]: proto.IPastParticipant[] }
empty: boolean
isLatest: boolean
progress?: number | null
+16 -3
View File
@@ -54,12 +54,14 @@ export type WAMediaUpload = Buffer | WAMediaPayloadStream | WAMediaPayloadURL
* Individual sticker in a sticker pack
*/
export type Sticker = {
/** Buffer, Stream or URL of the sticker image (will be converted to WebP if needed) */
/** Buffer, Stream or URL of the sticker image (WebP or Lottie/WAS format) */
data: WAMediaUpload
/** Array of emojis associated with this sticker (max 3 recommended per WhatsApp standards) */
emojis?: string[]
/** Accessibility label for screen readers (max 125 chars for static, 255 for animated) */
accessibilityLabel?: string
/** Force Lottie format detection (auto-detected if omitted) */
isLottie?: boolean
}
/**
@@ -153,6 +155,7 @@ export type MessageReceiptType =
| 'sender'
| 'inactive'
| 'played'
| 'view_once_read'
| undefined
export type MediaConnInfo = {
@@ -176,6 +179,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 */
@@ -988,8 +993,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
+8 -8
View File
@@ -4,10 +4,10 @@ export enum XWAPaths {
xwa2_newsletter_view = 'xwa2_newsletter_view',
xwa2_newsletter_metadata = 'xwa2_newsletter',
xwa2_newsletter_admin_count = 'xwa2_newsletter_admin',
xwa2_newsletter_mute_v2 = 'xwa2_newsletter_mute_v2',
xwa2_newsletter_unmute_v2 = 'xwa2_newsletter_unmute_v2',
xwa2_newsletter_follow = 'xwa2_newsletter_follow',
xwa2_newsletter_unfollow = 'xwa2_newsletter_unfollow',
xwa2_newsletter_mute_v2 = 'xwa2_newsletter_update_user_setting',
xwa2_newsletter_unmute_v2 = 'xwa2_newsletter_update_user_setting',
xwa2_newsletter_follow = 'xwa2_newsletter_join_v2',
xwa2_newsletter_unfollow = 'xwa2_newsletter_leave_v2',
xwa2_newsletter_change_owner = 'xwa2_newsletter_change_owner',
xwa2_newsletter_demote = 'xwa2_newsletter_demote',
xwa2_newsletter_delete_v2 = 'xwa2_newsletter_delete_v2'
@@ -17,10 +17,10 @@ export enum QueryIds {
UPDATE_METADATA = '24250201037901610',
METADATA = '6563316087068696',
SUBSCRIBERS = '9783111038412085',
FOLLOW = '7871414976211147',
UNFOLLOW = '7238632346214362',
MUTE = '29766401636284406',
UNMUTE = '9864994326891137',
FOLLOW = '24404358912487870',
UNFOLLOW = '9767147403369991',
MUTE = '31938993655691868',
UNMUTE = '31938993655691868',
ADMIN_COUNT = '7130823597031706',
CHANGE_OWNER = '7341777602580933',
DEMOTE = '6551828931592903',
+1
View File
@@ -23,6 +23,7 @@ export type BrowsersMap = {
baileys(browser: string): [string, string, string]
windows(browser: string): [string, string, string]
appropriate(browser: string): [string, string, string]
android(apiLevel: string): [string, string, string]
}
export enum DisconnectReason {
+20 -3
View File
@@ -94,7 +94,13 @@ const BROWSER_TO_PLATFORM_ID: ReadonlyMap<string, string> = (() => {
}
}
return new Map(entries)
const map = new Map(entries)
// ANDROID → ANDROID_PHONE alias (DeviceProps.PlatformType has ANDROID_PHONE but not ANDROID)
if (!map.has('ANDROID') && map.has('ANDROID_PHONE')) {
map.set('ANDROID', map.get('ANDROID_PHONE')!)
}
return map
})()
// ============================================================
@@ -331,7 +337,8 @@ export const Browsers: BrowsersMap = {
macOS: (browser: string): [string, string, string] => ['Mac OS', browser, OS_VERSIONS.macOS],
windows: (browser: string): [string, string, string] => ['Windows', browser, OS_VERSIONS.windows],
baileys: (browser: string): [string, string, string] => ['Baileys', browser, OS_VERSIONS.baileys],
appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()]
appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()],
android: (apiLevel: string): [string, string, string] => [apiLevel, 'Android', '']
} as const
/**
@@ -378,7 +385,7 @@ export const getPlatformId = (browser: unknown): string => {
* properties like 'toString' or 'constructor'.
*
* @param value - Value to check
* @returns True if value is a valid browser preset key ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate')
* @returns True if value is a valid browser preset key ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate', 'android')
*
* @example
* isValidBrowserPreset('ubuntu') // true
@@ -391,6 +398,16 @@ export const getPlatformId = (browser: unknown): string => {
* const config = Browsers[userInput]('MyApp')
* }
*/
/**
* Checks if the browser tuple represents an Android companion device.
*
* @param browser - Browser tuple [os, platform, version]
* @returns True if platform is 'Android' (case-insensitive)
*/
export const isAndroidBrowser = (browser: [string, string, string]): boolean => {
return browser[1]?.toUpperCase() === 'ANDROID'
}
export const isValidBrowserPreset = (value: unknown): value is keyof BrowsersMap => {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value)
}
+23 -44
View File
@@ -14,7 +14,6 @@ import {
isJidStatusBroadcast,
isLidUser,
isPnUser,
jidDecode
// transferDevice
} from '../WABinary'
import { unpadRandomMax16 } from './generics'
@@ -393,6 +392,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
@@ -488,8 +504,9 @@ export function isCorruptedSessionError(error: any): boolean {
}
/**
* Clean up corrupted session by deleting all device sessions for a JID.
* Signal Protocol will automatically recreate the session on next message.
* Clean up corrupted session for a specific device JID.
* WABA behavior: DELETE sessions WHERE recipient_id=? AND device_id=?
* Only deletes the exact device that was corrupted, not all devices.
*
* NOTE: This should NOT be called on every Bad MAC error (hot path).
* Instead, let the retry+pkmsg flow handle recovery naturally (like WhatsApp does).
@@ -500,45 +517,7 @@ export async function cleanupCorruptedSession(
repository: SignalRepositoryWithLIDStore,
logger: ILogger
): Promise<number> {
const { user, device } = jidDecode(jid) || {}
if (!user) {
logger.warn({ jid }, 'Cannot cleanup session - invalid JID')
return 0
}
// Build list of JIDs to delete (primary + secondary devices)
const jidsToDelete: string[] = []
// Determine domain type correctly (handle hosted JIDs)
// JID formats:
// - PN: user@s.whatsapp.net
// - LID: user@lid
// - Hosted PN: user@hosted
// - Hosted LID: user@hosted.lid
const isLID = jid.endsWith('@lid') || jid.endsWith('@hosted.lid')
const isHosted = jid.includes('@hosted')
let domain: string
if (isLID) {
domain = isHosted ? 'hosted.lid' : 'lid'
} else {
domain = isHosted ? 'hosted' : 's.whatsapp.net'
}
// Primary device (0)
jidsToDelete.push(`${user}@${domain}`)
// Secondary devices (1-5 common range for Web/Desktop/etc)
for (let i = 1; i <= 5; i++) {
jidsToDelete.push(`${user}:${i}@${domain}`)
}
// If specific device was identified and > 5, ensure it's included
if (device !== undefined && device > 5) {
jidsToDelete.push(`${user}:${device}@${domain}`)
}
await repository.deleteSession(jidsToDelete)
return jidsToDelete.length
await repository.deleteSession([jid])
logger.info({ jid }, 'Cleaned up corrupted session for specific device')
return 1
}
+28
View File
@@ -1,4 +1,5 @@
import EventEmitter from 'events'
import { proto } from '../../WAProto/index.js'
import type {
BaileysEvent,
BaileysEventEmitter,
@@ -890,6 +891,7 @@ const makeBufferData = (): BufferedEventData => {
chats: {},
messages: {},
contacts: {},
pastParticipants: {},
isLatest: false,
empty: true
},
@@ -967,6 +969,28 @@ function append<E extends BufferableEvent>(
}
}
// Merge pastParticipants with deduplication by groupJid and by userJid within each group.
// Multiple HistorySync chunks can carry the same group -- we merge rather than concatenate
// to avoid duplicate entries in the final event delivered to the consumer.
for (const group of (eventData.pastParticipants ?? []) as proto.IPastParticipants[]) {
const groupJid = group.groupJid
if (!groupJid) continue
if (!data.historySets.pastParticipants[groupJid]) {
data.historySets.pastParticipants[groupJid] = []
}
const existing = data.historySets.pastParticipants[groupJid]
const seenJids = new Set(existing.map(p => p.userJid).filter(Boolean))
for (const participant of (group.pastParticipants ?? []) as proto.IPastParticipant[]) {
if (participant.userJid && !seenJids.has(participant.userJid)) {
existing.push(participant)
seenJids.add(participant.userJid)
}
}
}
data.historySets.empty = false
data.historySets.syncType = eventData.syncType
data.historySets.progress = eventData.progress
@@ -1292,6 +1316,10 @@ function consolidateEvents(data: BufferedEventData) {
chats: Object.values(data.historySets.chats),
messages: Object.values(data.historySets.messages),
contacts: Object.values(data.historySets.contacts),
// Convert dedup map back to array. Each entry has groupJid + participants (all unique userJids).
pastParticipants: Object.entries(data.historySets.pastParticipants).map(
([groupJid, participants]) => ({ groupJid, pastParticipants: participants })
),
syncType: data.historySets.syncType,
progress: data.historySets.progress,
isLatest: data.historySets.isLatest,
+34 -2
View File
@@ -15,7 +15,8 @@ import {
import { toNumber } from './generics'
import type { ILogger } from './logger.js'
import { normalizeMessageContent } from './messages'
import { downloadContentFromMessage } from './messages-media'
import { DEFAULT_ORIGIN } from '../Defaults'
import { downloadContentFromMessage, getUrlFromDirectPath } from './messages-media'
const inflatePromise = promisify(inflate)
@@ -374,10 +375,25 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
// Convert Map back to array for return
const lidPnMappings = Array.from(lidPnMap.values())
// Normalize pastParticipants: resolve LID userJids → PN so the consumer always
// receives a phone number, never an opaque LID identifier.
// Uses the lidPnMap built above (populated from phoneNumberToLidMappings + conversations).
// If a LID cannot be resolved, the original value is kept rather than dropping the participant.
const pastParticipants: proto.IPastParticipants[] = (item.pastParticipants ?? []).map(group => ({
groupJid: group.groupJid,
pastParticipants: (group.pastParticipants ?? []).map(participant => {
const userJid = participant.userJid
if (!userJid || !isAnyLidUser(userJid)) return participant
const mapping = lidPnMap.get(jidNormalizedUser(userJid))
return mapping?.pn ? { ...participant, userJid: mapping.pn } : participant
})
}))
return {
chats,
contacts,
messages,
pastParticipants,
lidPnMappings,
syncType: item.syncType,
progress: item.progress
@@ -408,7 +424,23 @@ export const downloadAndProcessHistorySyncNotification = async (
historyMsg = await downloadHistory(msg, options)
}
return processHistoryMessage(historyMsg, logger)
const result = processHistoryMessage(historyMsg, logger)
// Mirror WA Desktop behaviour: DELETE the CDN blob only after processing succeeds.
// Doing this earlier (e.g. inside downloadHistory) risks permanent history loss if
// processing throws — the server copy would be gone and retry after reconnect would fail.
if (msg.directPath) {
const cdnUrl = getUrlFromDirectPath(msg.directPath)
fetch(cdnUrl, {
...options,
method: 'DELETE',
headers: { ...((options as RequestInit).headers ?? {}), Origin: DEFAULT_ORIGIN }
}).catch(() => {
// non-fatal — server will expire it anyway
})
}
return result
}
/**
+1 -1
View File
@@ -218,7 +218,7 @@ export class MessageRetryManager {
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
const now = Date.now()
const prevTime = this.sessionRecreateHistory.get(jid)
const MAC_ERROR_COOLDOWN_MS = 10_000 // 10 seconds
const MAC_ERROR_COOLDOWN_MS = 1_000 // 1 second — WABA recovers faster
if (prevTime && now - prevTime < MAC_ERROR_COOLDOWN_MS) {
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
+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,
@@ -160,7 +166,7 @@ export const extractImageThumb = async (bufferOrFilePath: Readable | Buffer | st
height: dimensions.height
}
}
} else if ('jimp' in lib && typeof lib.jimp?.Jimp === 'object') {
} else if ('jimp' in lib && typeof lib.jimp?.Jimp === 'function') {
const jimp = await (lib.jimp.Jimp as any).read(bufferOrFilePath)
const dimensions = {
width: jimp.width,
@@ -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 {
+108 -15
View File
@@ -41,9 +41,11 @@ import type { ILogger } from './logger'
import {
downloadContentFromMessage,
encryptedStream,
extractImageThumb,
generateThumbnail,
getAudioDuration,
getAudioWaveform,
getStream,
getRawMediaUploadData,
type MediaDownloadOptions
} from './messages-media'
@@ -194,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)
@@ -205,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
})
@@ -234,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(
@@ -252,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')
@@ -290,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')
}
@@ -633,6 +640,59 @@ export const generateCarouselMessage = async (
)
}
const recoverCarouselImageMetadata = async (
imageMessage: proto.Message.IImageMessage | null | undefined,
cardImage: WAMediaUpload,
cardTitle: string | undefined,
uploadOptions: MessageContentGenerationOptions
) => {
if (!imageMessage) {
return
}
const missingThumbnail = !imageMessage.jpegThumbnail
const missingWidth = !imageMessage.width
const missingHeight = !imageMessage.height
if (!missingThumbnail && !missingWidth && !missingHeight) {
return
}
try {
const { stream } = await getStream(cardImage, uploadOptions.options)
const { buffer, original } = await extractImageThumb(stream)
if (missingThumbnail) {
imageMessage.jpegThumbnail = buffer.toString('base64')
}
if (missingWidth && original.width) {
imageMessage.width = original.width
}
if (missingHeight && original.height) {
imageMessage.height = original.height
}
uploadOptions.logger?.info(
{
cardTitle,
recoveredThumbnail: !!imageMessage.jpegThumbnail,
width: imageMessage.width,
height: imageMessage.height
},
'[CAROUSEL] Recovered image metadata from source media'
)
} catch (error) {
uploadOptions.logger?.warn(
{
cardTitle,
trace: error instanceof Error ? error.stack : String(error)
},
'[CAROUSEL] Failed source-media thumbnail fallback'
)
}
}
// Map cards to the carousel format (processing media)
const carouselCards = await Promise.all(
cards.map(async card => {
@@ -648,6 +708,11 @@ export const generateCarouselMessage = async (
if (hasMedia && mediaOptions) {
if (card.image) {
const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, mediaOptions)
// Mirror the working Pastorini-style result: every carousel image card should
// carry a jpegThumbnail and dimensions before it reaches the Web live renderer.
await recoverCarouselImageMetadata(imageMessage, card.image, card.title, mediaOptions)
// Validate image fields needed for WhatsApp rendering
if (imageMessage && !imageMessage.jpegThumbnail) {
mediaOptions.logger?.warn(
@@ -1239,7 +1304,8 @@ export const generateWAMessageContent = async (
}
// Pass options for media processing if cards have images/videos
const generated = await generateCarouselMessage(carouselOptions, options)
// Direct interactiveMessage — no viewOnceMessage wrapper, no root header/body/footer
// Frida capture shows interactiveMessage DIRECT (field 45) in DSM — no viewOnceMessage wrapper
// Testing without wrapper: biz node + quality_control already match Pastorini CDP stanza
m.interactiveMessage = generated.interactiveMessage
return m
}
@@ -1686,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
}
}
}
+120 -19
View File
@@ -2,6 +2,7 @@ import { Boom } from '@hapi/boom'
import { createHash } from 'crypto'
import { zipSync } from 'fflate'
import { promises as fs } from 'fs'
import { gzipSync, gunzipSync } from 'zlib'
import { proto } from '../../WAProto/index.js'
import type { MediaType } from '../Defaults/index.js'
import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js'
@@ -101,6 +102,49 @@ export const isAnimatedWebP = (buffer: Buffer): boolean => {
return false
}
/**
* Detecta se um buffer é Lottie JSON (raw ou gzip-compressed/WAS)
*
* WABA usa mimetype `application/was` para stickers Lottie.
* WAS (WhatsApp Animated Sticker) = gzip-compressed Lottie JSON.
*
* Detecção:
* - Gzip (0x1f 0x8b): descomprime e verifica se é Lottie JSON
* - JSON bruto: verifica campos Lottie obrigatórios (v, ip, op, layers)
*
* @param buffer - Buffer to check
* @returns true if buffer is Lottie/WAS format
*/
export const isLottieBuffer = (buffer: Buffer): boolean => {
if (buffer.length < 2) return false
let jsonBuffer: Buffer
// Check if gzip-compressed (WAS format)
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
try {
// SECURITY: Limit decompressed output to 50MB to prevent decompression bombs
jsonBuffer = gunzipSync(buffer, { maxOutputLength: 50 * 1024 * 1024 }) as Buffer
} catch {
return false
}
} else if (buffer[0] === 0x7b) {
// Starts with '{' - could be raw Lottie JSON
jsonBuffer = buffer
} else {
return false
}
// Validate Lottie JSON structure: must have v, ip, op, AND layers
try {
const str = jsonBuffer.toString('utf8', 0, Math.min(jsonBuffer.length, 4096))
// Quick check for ALL required Lottie fields
return str.includes('"v"') && str.includes('"layers"') && str.includes('"ip"') && str.includes('"op"')
} catch {
return false
}
}
/**
* Converte uma imagem para WebP usando Sharp
* Preserva o buffer original se for WebP para manter EXIF e animações
@@ -114,12 +158,25 @@ export const isAnimatedWebP = (buffer: Buffer): boolean => {
const convertToWebP = async (
buffer: Buffer,
logger?: ILogger
): Promise<{ webpBuffer: Buffer; isAnimated: boolean }> => {
): Promise<{ webpBuffer: Buffer; isAnimated: boolean; isLottie: boolean }> => {
// Lottie/WAS: ensure gzip-compressed format (WAS = gzip Lottie JSON)
if (isLottieBuffer(buffer)) {
let wasBuffer = buffer
// Raw Lottie JSON (starts with '{') must be gzipped to produce valid WAS
if (buffer[0] === 0x7b) {
logger?.trace('Raw Lottie JSON detected, gzip-compressing to WAS format')
wasBuffer = gzipSync(buffer) as Buffer
}
logger?.trace('Input is Lottie/WAS format')
return { webpBuffer: wasBuffer, isAnimated: true, isLottie: true }
}
// Se já é WebP, preserva o buffer original (mantém EXIF e animações)
if (isWebPBuffer(buffer)) {
const isAnimated = isAnimatedWebP(buffer)
logger?.trace({ isAnimated }, 'Input is already WebP, preserving original buffer')
return { webpBuffer: buffer, isAnimated }
return { webpBuffer: buffer, isAnimated, isLottie: false }
}
// Tenta usar Sharp para converter
@@ -135,7 +192,7 @@ const convertToWebP = async (
logger?.trace('Converting image to WebP using Sharp')
const webpBuffer = await lib.sharp.default(buffer).webp().toBuffer()
return { webpBuffer, isAnimated: false }
return { webpBuffer, isAnimated: false, isLottie: false }
}
/**
@@ -338,9 +395,11 @@ export type PrepareStickerPackMessageOptions = {
*
* **Especificações WhatsApp:**
* - 3-30 stickers por pack (oficial)
* - WebP obrigatório
* - WebP ou Lottie/WAS (application/was)
* - Stickers: 512x512 pixels (auto-resize)
* - Recomendado: 100KB por sticker estático, 500KB animado
* - Tray icon: 252x252 pixels
* - Tray icon: 96x96 pixels (PNG no ZIP)
* - Thumbnail: 252x252 pixels (JPEG, upload separado)
*
* @param stickerPack - Sticker pack data with stickers, cover, name, publisher
* @param options - Upload function and optional logger
@@ -464,9 +523,36 @@ export const prepareStickerPackMessage = async (
// Obtém buffer do sticker
const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`)
// Converte para WebP
// Detecta formato e converte se necessário
// eslint-disable-next-line prefer-const
let { webpBuffer, isAnimated } = await convertToWebP(buffer, logger)
let { webpBuffer, isAnimated, isLottie } = await convertToWebP(buffer, logger)
// Validate explicit isLottie flag — must match detected format
if (sticker.isLottie !== undefined && sticker.isLottie !== isLottie) {
throw new Boom(
`Sticker ${i + 1}: explicit isLottie=${sticker.isLottie} does not match detected format (detected=${isLottie})`,
{ statusCode: 400 }
)
}
// WABA: Enforce 512x512 dimensions for WebP stickers (Lottie is vector, skip)
if (!isLottie && isWebPBuffer(webpBuffer)) {
const lib = await getImageProcessingLibrary()
if (lib?.sharp) {
const metadata = await lib.sharp.default(webpBuffer).metadata()
if (metadata.width !== 512 || metadata.height !== 512) {
logger?.trace(
{ index: i, width: metadata.width, height: metadata.height },
'Resizing sticker to 512x512 (WABA standard)'
)
webpBuffer = await lib.sharp
.default(webpBuffer)
.resize(512, 512, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.webp()
.toBuffer()
}
}
}
// ENHANCEMENT: Auto-compression if exceeds 1MB (try quality 70, then 50)
const MAX_STICKER_SIZE = 1024 * 1024 // 1MB
@@ -483,7 +569,7 @@ export const prepareStickerPackMessage = async (
if (lib?.sharp) {
// Try quality 70
try {
const compressed70 = await lib.sharp.default(buffer).webp({ quality: 70 }).toBuffer()
const compressed70 = await lib.sharp.default(webpBuffer).webp({ quality: 70 }).toBuffer()
// eslint-disable-next-line max-depth
if (compressed70.length <= MAX_STICKER_SIZE) {
@@ -498,7 +584,7 @@ export const prepareStickerPackMessage = async (
)
} else {
// Try quality 50
const compressed50 = await lib.sharp.default(buffer).webp({ quality: 50 }).toBuffer()
const compressed50 = await lib.sharp.default(webpBuffer).webp({ quality: 50 }).toBuffer()
// eslint-disable-next-line max-depth
if (compressed50.length <= MAX_STICKER_SIZE) {
@@ -547,12 +633,13 @@ export const prepareStickerPackMessage = async (
)
}
// Gera nome do arquivo: hash.webp (deduplicação automática)
// Gera nome do arquivo: hash.webp ou hash.was (WABA: plain_file_hash.ext)
const sha256Hash = generateSha256Hash(webpBuffer)
const fileName = `${sha256Hash}.webp`
const extension = isLottie ? 'was' : 'webp'
const fileName = `${sha256Hash}.${extension}`
logger?.trace(
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated },
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated, isLottie },
'Sticker processed successfully'
)
@@ -560,6 +647,7 @@ export const prepareStickerPackMessage = async (
fileName,
webpBuffer,
isAnimated,
isLottie,
emojis: sticker.emojis || [],
accessibilityLabel: sticker.accessibilityLabel
}
@@ -578,7 +666,7 @@ export const prepareStickerPackMessage = async (
for (const result of processedStickers) {
if (!result) continue
const { fileName, webpBuffer, isAnimated, emojis, accessibilityLabel } = result
const { fileName, webpBuffer, isAnimated, isLottie, emojis, accessibilityLabel } = result
// SECURITY FIX #7: Check if this hash already exists (duplicate sticker)
const existingMetadata = metadataByHash.get(fileName)
@@ -605,13 +693,14 @@ export const prepareStickerPackMessage = async (
// New sticker - add to ZIP and create metadata
stickerData[fileName] = [new Uint8Array(webpBuffer), { level: 0 as 0 }]
// WABA: mimetype é 'application/was' para Lottie, 'image/webp' para WebP
const metadata: proto.Message.StickerPackMessage.ISticker = {
fileName,
isAnimated,
emojis,
accessibilityLabel,
isLottie: false,
mimetype: 'image/webp'
isLottie,
mimetype: isLottie ? 'application/was' : 'image/webp'
}
metadataByHash.set(fileName, metadata)
@@ -636,10 +725,22 @@ export const prepareStickerPackMessage = async (
logger?.trace('Processing cover image')
coverBuffer = await mediaToBuffer(cover, 'cover image')
// Converte cover para WebP e adiciona ao ZIP
const result = await convertToWebP(coverBuffer, logger)
coverWebP = result.webpBuffer
coverFileName = `${stickerPackId}.webp`
// Tray icon uses PNG format, 96x96 pixels (official client standard)
const lib = await getImageProcessingLibrary()
if (!lib?.sharp) {
throw new Boom(
'Sharp library is required for cover/tray icon processing. Install with: yarn add sharp',
{ statusCode: 400 }
)
}
coverWebP = await lib.sharp
.default(coverBuffer)
.resize(96, 96, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer()
coverFileName = `${stickerPackId}.png`
stickerData[coverFileName] = [new Uint8Array(coverWebP), { level: 0 as 0 }]
} catch (error) {
throw new Boom(`Failed to process cover image: ${(error as Error).message}`, {
+20 -9
View File
@@ -154,15 +154,26 @@ export async function storeTcTokensFromIqResult({
continue
}
await keys.set({
tctoken: {
[storageJid]: {
...existingEntry,
token: Buffer.from(tokenNode.content),
timestamp: tokenNode.attrs.t
}
}
})
const tokenEntry = {
...existingEntry,
token: Buffer.from(tokenNode.content),
timestamp: tokenNode.attrs.t,
// WABA Android: resets real_issue_timestamp to null when storing a new token
// (UPDATE wa_trusted_contacts_send SET real_issue_timestamp=null)
realIssueTimestamp: null
}
// Store under resolved storageJid AND under fallbackJid (PN) for reliable lookup
// The read path may resolve to a different LID than the store path
const normalizedFallback = jidNormalizedUser(fallbackJid)
const keysToStore: Record<string, typeof tokenEntry | null> = {
[storageJid]: tokenEntry
}
if (normalizedFallback !== storageJid) {
keysToStore[normalizedFallback] = tokenEntry
}
await keys.set({ tctoken: keysToStore })
onNewJidStored?.(storageJid)
}
}
+13 -30
View File
@@ -20,7 +20,7 @@ const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => {
secondary: config.version[1],
tertiary: config.version[2]
},
platform: proto.ClientPayload.UserAgent.Platform.MACOS,
platform: proto.ClientPayload.UserAgent.Platform.WEB,
releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
osVersion: '0.1',
device: 'Desktop',
@@ -64,12 +64,7 @@ const getClientPayload = (config: SocketConfig) => {
}
export const generateLoginNode = (userJid: string, config: SocketConfig): proto.IClientPayload => {
const decoded = jidDecode(userJid)
if (!decoded) {
throw new Boom('Invalid user JID', { statusCode: 400 })
}
const { user, device } = decoded
const { user, device } = jidDecode(userJid)!
const payload: proto.IClientPayload = {
...getClientPayload(config),
passive: true,
@@ -158,9 +153,6 @@ export const configureSuccessfulPairing = (
}: Pick<AuthenticationCreds, 'advSecretKey' | 'signedIdentityKey' | 'signalIdentities'>
) => {
const msgId = stanza.attrs.id
if (!msgId) {
throw new Boom('Missing message ID', { statusCode: 400 })
}
const pairSuccessNode = getBinaryNodeChild(stanza, 'pair-success')
@@ -176,51 +168,42 @@ export const configureSuccessfulPairing = (
const bizName = businessNode?.attrs.name
const jid = deviceNode.attrs.jid
const lid = deviceNode.attrs.lid
if (!jid || !lid) {
throw new Boom('Missing JID or LID in device node', { statusCode: 400 })
}
const { details, hmac, accountType } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentityNode.content as Buffer)
if (!details || !hmac) {
throw new Boom('Missing ADV signature fields', { statusCode: 400 })
}
let hmacPrefix = Buffer.from([])
if (accountType !== undefined && accountType === proto.ADVEncryptionType.HOSTED) {
hmacPrefix = WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
}
const advSign = hmacSign(Buffer.concat([hmacPrefix, details]), Buffer.from(advSecretKey, 'base64'))
if (Buffer.compare(hmac, advSign) !== 0) {
const advSign = hmacSign(Buffer.concat([hmacPrefix, details!]), Buffer.from(advSecretKey, 'base64'))
if (Buffer.compare(hmac!, advSign) !== 0) {
throw new Boom('Invalid account signature')
}
const account = proto.ADVSignedDeviceIdentity.decode(details)
const account = proto.ADVSignedDeviceIdentity.decode(details!)
const { accountSignatureKey, accountSignature, details: deviceDetails } = account
if (!accountSignatureKey || !accountSignature || !deviceDetails) {
throw new Boom('Missing ADV account fields', { statusCode: 400 })
}
const deviceIdentity = proto.ADVDeviceIdentity.decode(deviceDetails)
const deviceIdentity = proto.ADVDeviceIdentity.decode(deviceDetails!)
const accountSignaturePrefix =
deviceIdentity.deviceType === proto.ADVEncryptionType.HOSTED
? WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
: WA_ADV_ACCOUNT_SIG_PREFIX
const accountMsg = Buffer.concat([accountSignaturePrefix, deviceDetails, signedIdentityKey.public])
if (!Curve.verify(accountSignatureKey, accountMsg, accountSignature)) {
const accountMsg = Buffer.concat([accountSignaturePrefix, deviceDetails!, signedIdentityKey.public])
if (!Curve.verify(accountSignatureKey!, accountMsg, accountSignature!)) {
throw new Boom('Failed to verify account signature')
}
const deviceMsg = Buffer.concat([
WA_ADV_DEVICE_SIG_PREFIX,
deviceDetails,
deviceDetails!,
signedIdentityKey.public,
accountSignatureKey
accountSignatureKey!
])
account.deviceSignature = Curve.sign(signedIdentityKey.private, deviceMsg)
const identity = createSignalIdentity(lid, accountSignatureKey)
const identity = createSignalIdentity(lid!, accountSignatureKey!)
const accountEnc = encodeSignedDeviceIdentity(account, false)
const reply: BinaryNode = {
@@ -228,7 +211,7 @@ export const configureSuccessfulPairing = (
attrs: {
to: S_WHATSAPP_NET,
type: 'result',
id: msgId
id: msgId!
},
content: [
{
@@ -247,7 +230,7 @@ export const configureSuccessfulPairing = (
const authUpdate: Partial<AuthenticationCreds> = {
account,
me: { id: jid, name: bizName, lid },
me: { id: jid!, name: bizName, lid },
signalIdentities: [...(signalIdentities || []), identity],
platform: platformNode?.attrs.name
}
+15 -8
View File
@@ -10,7 +10,7 @@ import {
} from './Protocols'
import { USyncUser } from './USyncUser'
export type USyncQueryResultList = { [protocol: string]: unknown; id: string }
export type USyncQueryResultList = { [protocol: string]: unknown; id: string; rawId?: number }
export type USyncQueryResult = {
list: USyncQueryResultList[]
@@ -68,10 +68,8 @@ export class USyncQuery {
//TODO: see if there are any errors in the result node
//const resultNode = getBinaryNodeChild(usyncNode, 'result')
const listNode = usyncNode ? getBinaryNodeChild(usyncNode, 'list') : undefined
if (listNode?.content && Array.isArray(listNode.content)) {
queryResult.list = listNode.content.reduce((acc: USyncQueryResultList[], node) => {
const parseNodeList = (content: BinaryNode[]): USyncQueryResultList[] =>
content.reduce((acc: USyncQueryResultList[], node) => {
const id = node?.attrs.jid
if (id) {
const data = Array.isArray(node?.content)
@@ -89,15 +87,24 @@ export class USyncQuery {
.filter(([, b]) => b !== null) as [string, unknown][]
)
: {}
acc.push({ ...data, id })
const rawIdAttr = node?.attrs?.['raw_id']
const rawId = rawIdAttr !== undefined ? Number(rawIdAttr) : undefined
acc.push({ ...data, id, ...(rawId !== undefined && !isNaN(rawId) ? { rawId } : {}) })
}
return acc
}, [])
const listNode = usyncNode ? getBinaryNodeChild(usyncNode, 'list') : undefined
if (listNode?.content && Array.isArray(listNode.content)) {
queryResult.list = parseNodeList(listNode.content)
}
const sideListNode = usyncNode ? getBinaryNodeChild(usyncNode, 'side_list') : undefined
if (sideListNode?.content && Array.isArray(sideListNode.content)) {
queryResult.sideList = parseNodeList(sideListNode.content)
}
//TODO: implement side list
//const sideListNode = getBinaryNodeChild(usyncNode, 'side_list')
return queryResult
}
+18 -11
View File
@@ -388,6 +388,13 @@ __metadata:
languageName: node
linkType: hard
"@borewit/text-codec@npm:^0.2.2":
version: 0.2.2
resolution: "@borewit/text-codec@npm:0.2.2"
checksum: 10c0/2d3fb132bc6a132914a8fbf8e9ff2fa1ead210ecc395b28bb7355bd7719548a5e351ffe39f21c3bee8048f6cabd99eabd404bb5cc809cad9cba25abed19d271f
languageName: node
linkType: hard
"@cacheable/node-cache@npm:^2.0.1":
version: 2.0.1
resolution: "@cacheable/node-cache@npm:2.0.1"
@@ -3105,7 +3112,7 @@ __metadata:
libsignal: "github:whiskeysockets/libsignal-node"
link-preview-js: "npm:^4.0.0"
lru-cache: "npm:^11.2.6"
music-metadata: "npm:^11.12.0"
music-metadata: "npm:^11.12.3"
open: "npm:^11.0.0"
p-queue: "npm:^9.0.0"
pino: "npm:^10.3.1"
@@ -4556,15 +4563,15 @@ __metadata:
languageName: node
linkType: hard
"file-type@npm:^21.3.0":
version: 21.3.0
resolution: "file-type@npm:21.3.0"
"file-type@npm:^21.3.1":
version: 21.3.1
resolution: "file-type@npm:21.3.1"
dependencies:
"@tokenizer/inflate": "npm:^0.4.1"
strtok3: "npm:^10.3.4"
token-types: "npm:^6.1.1"
uint8array-extras: "npm:^1.4.0"
checksum: 10c0/1b1fa909e6063044a6da1d2ea348ee4d747ed9286382d3f0d4d6532c11fb2ea9f2e7e67b2bc7d745d1bc937e05dee1aa8cb912c64250933bcb393a3744f4e284
checksum: 10c0/66a8eda781c803c6fc372464abba88cee564cfe30f029716f06909da1564bc51d0a4e8d34654b881dc751cdb0513338b3c0747e06a608cee0dd5c5499b018d47
languageName: node
linkType: hard
@@ -6504,21 +6511,21 @@ __metadata:
languageName: node
linkType: hard
"music-metadata@npm:^11.12.0":
version: 11.12.0
resolution: "music-metadata@npm:11.12.0"
"music-metadata@npm:^11.12.3":
version: 11.12.3
resolution: "music-metadata@npm:11.12.3"
dependencies:
"@borewit/text-codec": "npm:^0.2.1"
"@borewit/text-codec": "npm:^0.2.2"
"@tokenizer/token": "npm:^0.3.0"
content-type: "npm:^1.0.5"
debug: "npm:^4.4.3"
file-type: "npm:^21.3.0"
file-type: "npm:^21.3.1"
media-typer: "npm:^1.1.0"
strtok3: "npm:^10.3.4"
token-types: "npm:^6.1.2"
uint8array-extras: "npm:^1.5.0"
win-guid: "npm:^0.2.1"
checksum: 10c0/014a120b8941ab80452171def0ec3cba013041ba4b94f8061ad493a037d3423e6d316131a13fb0433404685ebd3c8e3ae2255c1bafaeb20673fcd73af2df0148
checksum: 10c0/57df905f51ec792e5b4c994645eab64d47d5608b9de11151f54b26fbd5f563598ec04d8aed7f244ef1479030d78ddab8af1ee84dfcee40a93b3241941b50999d
languageName: node
linkType: hard