From 6c52b01ea70a5860596fbf91fed1d1f5a0509c63 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 16:44:14 -0300 Subject: [PATCH 01/22] fix: add pastParticipants to HistorySync with LID normalization and deduplication (#303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/Types/Events.ts | 4 ++++ src/Utils/event-buffer.ts | 28 ++++++++++++++++++++++++++++ src/Utils/history.ts | 15 +++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/src/Types/Events.ts b/src/Types/Events.ts index d97320c0..96a14096 100644 --- a/src/Types/Events.ts +++ b/src/Types/Events.ts @@ -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 diff --git a/src/Utils/event-buffer.ts b/src/Utils/event-buffer.ts index 1b19a3e8..027d8798 100644 --- a/src/Utils/event-buffer.ts +++ b/src/Utils/event-buffer.ts @@ -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( } } + // 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, diff --git a/src/Utils/history.ts b/src/Utils/history.ts index 6442abf2..58ff7bb4 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -374,10 +374,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 From bd8465d9b8b1110d829bcc134af6d2f26907c3dd Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 17:58:30 -0300 Subject: [PATCH 02/22] =?UTF-8?q?fix:=20histsync=20LID=20improvements=20?= =?UTF-8?q?=E2=80=94=20raw=5Fid=20mapping,=20prekeys,=20keepalive=20j?= =?UTF-8?q?=E2=80=A6=20(#304)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- src/Defaults/index.ts | 8 +++++--- src/Socket/messages-recv.ts | 4 +++- src/Socket/messages-send.ts | 28 ++++++++++++++++++++++++++++ src/Socket/socket.ts | 24 +++++++++++++++++++----- src/Utils/history.ts | 16 ++++++++++++++-- src/WAUSync/USyncQuery.ts | 23 +++++++++++++++-------- 6 files changed, 84 insertions(+), 19 deletions(-) diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 7f7a140d..7898c4a6 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -188,10 +188,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) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 1a08ea88..22267b4b 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -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, @@ -1440,7 +1441,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, { diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index cb2ec727..2fe9ae8e 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -9,6 +9,7 @@ import type { AlbumMessageOptions, AlbumSendResult, AnyMessageContent, + LIDMapping, MediaConnInfo, MessageReceiptType, MessageRelayOptions, @@ -338,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() + 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 || '' diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index 0fdc80d6..a4fc06f7 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -785,7 +785,7 @@ export const makeSocket = (config: SocketConfig) => { let count = 0 const preKeyCount = await getAvailablePreKeysOnServer() if (preKeyCount === 0) count = INITIAL_PREKEY_COUNT - else count = MIN_PREKEY_COUNT + else count = Math.max(0, INITIAL_PREKEY_COUNT - preKeyCount) const { exists: currentPreKeyExists, currentPreKeyId } = await verifyCurrentPreKeyExists() logger.info(`${preKeyCount} pre-keys found on server`) @@ -1151,7 +1151,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() @@ -1262,8 +1262,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() } @@ -1275,6 +1283,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 @@ -1297,7 +1306,12 @@ export const makeSocket = (config: SocketConfig) => { } else { logger.warn('keep alive called when WS not open') } - }, keepAliveIntervalMs)) + + scheduleNextKeepAlive() + } + + scheduleNextKeepAlive() + } /** i have no idea why this exists. pls enlighten me */ const sendPassiveIq = (tag: 'passive' | 'active') => query({ diff --git a/src/Utils/history.ts b/src/Utils/history.ts index 58ff7bb4..61b20871 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -15,7 +15,7 @@ import { import { toNumber } from './generics' import type { ILogger } from './logger.js' import { normalizeMessageContent } from './messages' -import { downloadContentFromMessage } from './messages-media' +import { downloadContentFromMessage, getUrlFromDirectPath } from './messages-media' const inflatePromise = promisify(inflate) @@ -423,7 +423,19 @@ 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' }).catch(() => { + // non-fatal — server will expire it anyway + }) + } + + return result } /** diff --git a/src/WAUSync/USyncQuery.ts b/src/WAUSync/USyncQuery.ts index 15df485f..39b8b9e2 100644 --- a/src/WAUSync/USyncQuery.ts +++ b/src/WAUSync/USyncQuery.ts @@ -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 } From 52756fb50d18e1fe5de324b5417055863c8f7abe Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 17:27:09 -0300 Subject: [PATCH 03/22] =?UTF-8?q?fix:=20histsync=20LID=20improvements=20?= =?UTF-8?q?=E2=80=94=20raw=5Fid=20mapping,=20prekeys,=20keepalive=20jitter?= =?UTF-8?q?,=20CDN=20DELETE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/Utils/history.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Utils/history.ts b/src/Utils/history.ts index 61b20871..294f5e66 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -39,6 +39,16 @@ export const downloadHistory = async (msg: proto.Message.IHistorySyncNotificatio buffer = await inflatePromise(buffer) const syncData = proto.HistorySync.decode(buffer) + + // Mirror WA Desktop behaviour: DELETE the CDN blob after successful download + // so the server cleans up the one-time history sync file (fire-and-forget) + if (msg.directPath) { + const cdnUrl = getUrlFromDirectPath(msg.directPath) + fetch(cdnUrl, { method: 'DELETE', ...options }).catch(() => { + // non-fatal — server will expire it anyway + }) + } + return syncData } From 8c8c5a312e652aa49ee791a904f4a3e3821bc4c0 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 17:37:37 -0300 Subject: [PATCH 04/22] =?UTF-8?q?fix:=20address=20PR=20review=20comments?= =?UTF-8?q?=20=E2=80=94=20CDN=20DELETE=20timing,=20keepalive=20leak,=20spr?= =?UTF-8?q?ead=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/Utils/history.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/Utils/history.ts b/src/Utils/history.ts index 294f5e66..61b20871 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -39,16 +39,6 @@ export const downloadHistory = async (msg: proto.Message.IHistorySyncNotificatio buffer = await inflatePromise(buffer) const syncData = proto.HistorySync.decode(buffer) - - // Mirror WA Desktop behaviour: DELETE the CDN blob after successful download - // so the server cleans up the one-time history sync file (fire-and-forget) - if (msg.directPath) { - const cdnUrl = getUrlFromDirectPath(msg.directPath) - fetch(cdnUrl, { method: 'DELETE', ...options }).catch(() => { - // non-fatal — server will expire it anyway - }) - } - return syncData } From 52d8ddae32ac87988e8dccf3770a7289fbaa5270 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 18:06:20 -0300 Subject: [PATCH 05/22] fix: prevent double prekey upload when concurrent uploads race on count=0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/Socket/socket.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index a4fc06f7..f4080284 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -701,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 () => { From 5bb18da6bf214670a4fe7c390d3aef6511a94fce Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 18:21:43 -0300 Subject: [PATCH 06/22] =?UTF-8?q?fix:=20address=20PR=20#305=20review=20com?= =?UTF-8?q?ments=20=E2=80=94=20Origin=20header,=20lowServerCount=20thresho?= =?UTF-8?q?ld,=20keepalive=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/Socket/socket.ts | 12 ++++++++---- src/Utils/history.ts | 7 ++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index f4080284..becfe657 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -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 = Math.max(0, INITIAL_PREKEY_COUNT - preKeyCount) + // 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 @@ -1309,7 +1310,10 @@ export const makeSocket = (config: SocketConfig) => { logger.warn('keep alive called when WS not open') } - scheduleNextKeepAlive() + // Do not reschedule once shutdown has started (closed set by end() on any concurrent path) + if (!closed) { + scheduleNextKeepAlive() + } } scheduleNextKeepAlive() diff --git a/src/Utils/history.ts b/src/Utils/history.ts index 61b20871..e58aa8b9 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -15,6 +15,7 @@ import { import { toNumber } from './generics' import type { ILogger } from './logger.js' import { normalizeMessageContent } from './messages' +import { DEFAULT_ORIGIN } from '../Defaults' import { downloadContentFromMessage, getUrlFromDirectPath } from './messages-media' const inflatePromise = promisify(inflate) @@ -430,7 +431,11 @@ export const downloadAndProcessHistorySyncNotification = async ( // 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' }).catch(() => { + fetch(cdnUrl, { + ...options, + method: 'DELETE', + headers: { ...((options as RequestInit).headers ?? {}), Origin: DEFAULT_ORIGIN } + }).catch(() => { // non-fatal — server will expire it anyway }) } From ba1c1b0fdb15738bafa9e022e98667293061ff0f Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 23:10:59 -0300 Subject: [PATCH 07/22] fix: align Bad MAC retry receipt with WA Desktop behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- src/Socket/messages-recv.ts | 65 +++++++++++++++---------------------- 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 22267b4b..b704a78e 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -1213,7 +1213,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! @@ -1311,39 +1311,27 @@ 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. + // This is sent in the retry receipt so the peer (even another InfiniteAPI instance) + // knows the exact reason and can recreate the session immediately instead of waiting + // for the 1-hour timeout fallback. + // + // Codes mirror RetryReason enum in message-retry-manager.ts: + // 0 = UnknownError | 1 = NoSession | 2 = InvalidKey + // 3 = InvalidKeyId | 7 = BadMac (= SignalErrorInvalidMessage/InvalidCipherKey) + // + // 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 0 + if (/bad\s*mac/i.test(decryptionError)) return 7 // SignalErrorBadMac + if (/no\s*session/i.test(decryptionError)) return 1 // SignalErrorNoSession + if (/pre\s*key/i.test(decryptionError)) return 3 // SignalErrorInvalidKeyId + if (/invalid\s*key/i.test(decryptionError)) return 2 // SignalErrorInvalidKey + return 0 + })() if (retryCount <= 2) { // Use new retry manager for phone requests if available @@ -1384,8 +1372,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { id: node.attrs.id!, t: node.attrs.t!, v: '1', - // ADD ERROR FIELD - error: '0' + error: retryErrorCode.toString() } }, { @@ -1404,7 +1391,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) @@ -2507,7 +2494,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) } @@ -2516,7 +2503,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') } From 9c4cdc3e02c8707422a4afb7cdfe3da40a56ceda Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 23:29:46 -0300 Subject: [PATCH 08/22] 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 --- src/Socket/messages-recv.ts | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index b704a78e..5ef6d722 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -49,6 +49,8 @@ import { getStatusFromReceiptType, handleIdentityChange, hkdf, + BAD_MAC_ERROR_TEXT, + DECRYPTION_RETRY_CONFIG, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, @@ -1312,13 +1314,17 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const fromJid = node.attrs.from! // Derive the Signal error code from the actual decryption failure message. - // This is sent in the retry receipt so the peer (even another InfiniteAPI instance) - // knows the exact reason and can recreate the session immediately instead of waiting - // for the 1-hour timeout fallback. + // 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 | 7 = BadMac (= SignalErrorInvalidMessage/InvalidCipherKey) + // 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 @@ -1326,10 +1332,18 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { // exists, which can cause "No Session" errors on concurrent messages. const retryErrorCode = (() => { if (!decryptionError) return 0 - if (/bad\s*mac/i.test(decryptionError)) return 7 // SignalErrorBadMac - if (/no\s*session/i.test(decryptionError)) return 1 // SignalErrorNoSession + // 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 7 // SignalErrorBadMac + // MessageCounterError and other corrupted-session variants (code 4) + if (DECRYPTION_RETRY_CONFIG.corruptedSessionErrors.some(e => decryptionError.includes(e))) return 4 // SignalErrorInvalidMessage + // Missing / invalid session record (code 1) + if (DECRYPTION_RETRY_CONFIG.sessionRecordErrors.some(e => decryptionError.includes(e)) || + /no\s+(open\s+)?sessions?/i.test(decryptionError)) return 1 // SignalErrorNoSession + // PreKey / key-id errors (code 3) if (/pre\s*key/i.test(decryptionError)) return 3 // SignalErrorInvalidKeyId - if (/invalid\s*key/i.test(decryptionError)) return 2 // SignalErrorInvalidKey + // Identity / key errors (code 2) + if (/invalid\s*key|untrusted\s*identity/i.test(decryptionError)) return 2 // SignalErrorInvalidKey return 0 })() From fbefa6a0ad70d33cd006930b08934f3f20596deb Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 23:44:34 -0300 Subject: [PATCH 09/22] 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 --- src/Socket/messages-recv.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 5ef6d722..9318a1b3 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -54,6 +54,7 @@ import { MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, + RetryReason, normalizeKeyLidToPn, normalizeMessageJids, resolveLidToPn, @@ -1331,20 +1332,20 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { // 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 0 + 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 7 // SignalErrorBadMac - // MessageCounterError and other corrupted-session variants (code 4) - if (DECRYPTION_RETRY_CONFIG.corruptedSessionErrors.some(e => decryptionError.includes(e))) return 4 // SignalErrorInvalidMessage - // Missing / invalid session record (code 1) + 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 1 // SignalErrorNoSession - // PreKey / key-id errors (code 3) - if (/pre\s*key/i.test(decryptionError)) return 3 // SignalErrorInvalidKeyId - // Identity / key errors (code 2) - if (/invalid\s*key|untrusted\s*identity/i.test(decryptionError)) return 2 // SignalErrorInvalidKey - return 0 + /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) { From be44c5fdb00c6ff638d8c1cae03da707ec005f6e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 01:30:09 -0300 Subject: [PATCH 10/22] chore: update proto/version to v2.3000.1035577069 (#307) Co-authored-by: rsalcara --- WAProto/WAProto.proto | 4 ++-- WAProto/index.d.ts | 5 +++-- WAProto/index.js | 30 +++++++++++++++++++++--------- src/Defaults/baileys-version.json | 2 +- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/WAProto/WAProto.proto b/WAProto/WAProto.proto index af98dad1..68842023 100644 --- a/WAProto/WAProto.proto +++ b/WAProto/WAProto.proto @@ -1,7 +1,7 @@ syntax = "proto3"; package proto; -/// WhatsApp Version: 2.3000.1035484955 +/// WhatsApp Version: 2.3000.1035577069 message ADVDeviceIdentity { optional uint32 rawId = 1; @@ -2149,7 +2149,6 @@ message LimitSharing { CHAT_SETTING = 1; BIZ_SUPPORTS_FB_HOSTING = 2; UNKNOWN_GROUP = 3; - DEPRECATION = 4; } } @@ -4893,6 +4892,7 @@ message SyncActionValue { repeated SyncActionValue.BroadcastListParticipant participants = 2; optional string listName = 3; repeated string labelIds = 4; + optional string audienceExpression = 5; } message CallLogAction { diff --git a/WAProto/index.d.ts b/WAProto/index.d.ts index 8d42202c..e13454c7 100644 --- a/WAProto/index.d.ts +++ b/WAProto/index.d.ts @@ -5356,8 +5356,7 @@ export namespace proto { UNKNOWN = 0, CHAT_SETTING = 1, BIZ_SUPPORTS_FB_HOSTING = 2, - UNKNOWN_GROUP = 3, - DEPRECATION = 4 + UNKNOWN_GROUP = 3 } } @@ -12483,6 +12482,7 @@ export namespace proto { participants?: (proto.SyncActionValue.IBroadcastListParticipant[]|null); listName?: (string|null); labelIds?: (string[]|null); + audienceExpression?: (string|null); } class BusinessBroadcastListAction implements IBusinessBroadcastListAction { @@ -12491,6 +12491,7 @@ export namespace proto { public participants: proto.SyncActionValue.IBroadcastListParticipant[]; public listName?: (string|null); public labelIds: string[]; + public audienceExpression?: (string|null); public static create(properties?: proto.SyncActionValue.IBusinessBroadcastListAction): proto.SyncActionValue.BusinessBroadcastListAction; public static encode(m: proto.SyncActionValue.IBusinessBroadcastListAction, w?: $protobuf.Writer): $protobuf.Writer; public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.BusinessBroadcastListAction; diff --git a/WAProto/index.js b/WAProto/index.js index 4daf4116..ae74c415 100644 --- a/WAProto/index.js +++ b/WAProto/index.js @@ -26692,10 +26692,6 @@ export const proto = $root.proto = (() => { case 3: m.limitSharingTrigger = 3; break; - case "DEPRECATION": - case 4: - m.limitSharingTrigger = 4; - break; } if (d.limitSharingInitiatedByMe != null) { m.limitSharingInitiatedByMe = Boolean(d.limitSharingInitiatedByMe); @@ -36778,10 +36774,6 @@ export const proto = $root.proto = (() => { case 3: m.trigger = 3; break; - case "DEPRECATION": - case 4: - m.trigger = 4; - break; } if (d.limitSharingSettingTimestamp != null) { if ($util.Long) @@ -36846,7 +36838,6 @@ export const proto = $root.proto = (() => { values[valuesById[1] = "CHAT_SETTING"] = 1; values[valuesById[2] = "BIZ_SUPPORTS_FB_HOSTING"] = 2; values[valuesById[3] = "UNKNOWN_GROUP"] = 3; - values[valuesById[4] = "DEPRECATION"] = 4; return values; })(); @@ -87581,6 +87572,7 @@ export const proto = $root.proto = (() => { BusinessBroadcastListAction.prototype.participants = $util.emptyArray; BusinessBroadcastListAction.prototype.listName = null; BusinessBroadcastListAction.prototype.labelIds = $util.emptyArray; + BusinessBroadcastListAction.prototype.audienceExpression = null; let $oneOfFields; @@ -87596,6 +87588,12 @@ export const proto = $root.proto = (() => { set: $util.oneOfSetter($oneOfFields) }); + // Virtual OneOf for proto3 optional field + Object.defineProperty(BusinessBroadcastListAction.prototype, "_audienceExpression", { + get: $util.oneOfGetter($oneOfFields = ["audienceExpression"]), + set: $util.oneOfSetter($oneOfFields) + }); + BusinessBroadcastListAction.create = function create(properties) { return new BusinessBroadcastListAction(properties); }; @@ -87615,6 +87613,8 @@ export const proto = $root.proto = (() => { for (var i = 0; i < m.labelIds.length; ++i) w.uint32(34).string(m.labelIds[i]); } + if (m.audienceExpression != null && Object.hasOwnProperty.call(m, "audienceExpression")) + w.uint32(42).string(m.audienceExpression); return w; }; @@ -87647,6 +87647,10 @@ export const proto = $root.proto = (() => { m.labelIds.push(r.string()); break; } + case 5: { + m.audienceExpression = r.string(); + break; + } default: r.skipType(t & 7); break; @@ -87683,6 +87687,9 @@ export const proto = $root.proto = (() => { m.labelIds[i] = String(d.labelIds[i]); } } + if (d.audienceExpression != null) { + m.audienceExpression = String(d.audienceExpression); + } return m; }; @@ -87716,6 +87723,11 @@ export const proto = $root.proto = (() => { d.labelIds[j] = m.labelIds[j]; } } + if (m.audienceExpression != null && m.hasOwnProperty("audienceExpression")) { + d.audienceExpression = m.audienceExpression; + if (o.oneofs) + d._audienceExpression = "audienceExpression"; + } return d; }; diff --git a/src/Defaults/baileys-version.json b/src/Defaults/baileys-version.json index a181c943..154112d5 100644 --- a/src/Defaults/baileys-version.json +++ b/src/Defaults/baileys-version.json @@ -1 +1 @@ -{"version":[2,3000,1035504971]} +{"version": [2, 3000, 1035577069]} From d2db4cec97b8299f028e53ea8ddd77b1eefdbb2c Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Fri, 20 Mar 2026 06:31:30 -0300 Subject: [PATCH 11/22] chore: update WhatsApp Web version to v2.3000.1035595667 (#308) Co-authored-by: github-actions[bot] --- src/Defaults/baileys-version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Defaults/baileys-version.json b/src/Defaults/baileys-version.json index 154112d5..4ef8e298 100644 --- a/src/Defaults/baileys-version.json +++ b/src/Defaults/baileys-version.json @@ -1 +1 @@ -{"version": [2, 3000, 1035577069]} +{"version":[2,3000,1035595667]} From 3ab578aadf6c2bc7f0d0ef58baa26aadc68173d0 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Fri, 20 Mar 2026 10:11:49 -0300 Subject: [PATCH 12/22] 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 --- src/Utils/decode-wa-message.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Utils/decode-wa-message.ts b/src/Utils/decode-wa-message.ts index bd2ded9e..4f00b1b9 100644 --- a/src/Utils/decode-wa-message.ts +++ b/src/Utils/decode-wa-message.ts @@ -392,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 From e6ea7e456344e2735a80c5f76fdc7437fbc0ccd0 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Fri, 20 Mar 2026 10:49:25 -0300 Subject: [PATCH 13/22] 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. --- src/Socket/messages-send.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 2fe9ae8e..211b6280 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1897,6 +1897,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) { From c3a021fcded2bb89e271a916724603c4bad1eafa Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Fri, 20 Mar 2026 12:02:36 -0300 Subject: [PATCH 14/22] fix: use newsletter-specific upload paths for channel media (#311) fix: use newsletter-specific upload paths for channel media (#311) --- src/Defaults/index.ts | 9 +++++++++ src/Socket/messages-send.ts | 2 +- src/Types/Message.ts | 12 ++++++++++-- src/Utils/messages-media.ts | 38 +++++++++++++++++++++++++++++++------ src/Utils/messages.ts | 9 ++++++--- 5 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 7898c4a6..b05a9c23 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -157,6 +157,15 @@ export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = { 'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack' } +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 = { audio: 'Audio', document: 'Document', diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 211b6280..445036bb 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1051,7 +1051,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 = { diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 12b43aad..f8f53c90 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -990,8 +990,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 diff --git a/src/Utils/messages-media.ts b/src/Utils/messages-media.ts index 5d232796..580f0600 100644 --- a/src/Utils/messages-media.ts +++ b/src/Utils/messages-media.ts @@ -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, @@ -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 ): 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 { diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 782682db..8138ef48 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -196,10 +196,11 @@ export const prepareWAMessageMedia = async ( ) const fileSha256B64 = fileSha256.toString('base64') - const { mediaUrl, directPath } = await options.upload(filePath, { + const { directPath, thumbnailDirectPath, thumbnailSha256 } = await options.upload(filePath, { fileEncSha256B64: fileSha256B64, mediaType: mediaType, - timeoutMs: options.mediaUploadTimeoutMs + timeoutMs: options.mediaUploadTimeoutMs, + newsletter: true }) await fs.unlink(filePath) @@ -207,10 +208,12 @@ export const prepareWAMessageMedia = async ( const obj = WAProto.Message.fromObject({ // todo: add more support here [`${mediaType}Message`]: (MessageTypeProto as any)[mediaType].fromObject({ - url: mediaUrl, + // url intentionally omitted — newsletters use directPath only directPath, fileSha256, fileLength, + thumbnailDirectPath, + thumbnailSha256: thumbnailSha256 ? Buffer.from(thumbnailSha256, 'base64') : undefined, ...uploadData, media: undefined }) From 1cbf1b18b717ea450ff34ba52ae21f96ac3345f7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:22:14 -0300 Subject: [PATCH 15/22] chore: update proto/version to v2.3000.1035661795 (#312) Co-authored-by: rsalcara --- WAProto/WAProto.proto | 12 +- WAProto/index.d.ts | 29 +++- WAProto/index.js | 224 ++++++++++++++++++++++++++++++ src/Defaults/baileys-version.json | 2 +- 4 files changed, 264 insertions(+), 3 deletions(-) diff --git a/WAProto/WAProto.proto b/WAProto/WAProto.proto index 68842023..1403b286 100644 --- a/WAProto/WAProto.proto +++ b/WAProto/WAProto.proto @@ -1,7 +1,7 @@ syntax = "proto3"; package proto; -/// WhatsApp Version: 2.3000.1035577069 +/// WhatsApp Version: 2.3000.1035661795 message ADVDeviceIdentity { optional uint32 rawId = 1; @@ -5304,11 +5304,21 @@ message SyncActionValue { repeated string userJid = 2; optional bool shareToFB = 3; optional bool shareToIG = 4; + repeated CustomList customLists = 5; + message CustomList { + optional int64 id = 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; } } diff --git a/WAProto/index.d.ts b/WAProto/index.d.ts index e13454c7..04ac6ec7 100644 --- a/WAProto/index.d.ts +++ b/WAProto/index.d.ts @@ -13702,6 +13702,7 @@ export namespace proto { userJid?: (string[]|null); shareToFB?: (boolean|null); shareToIG?: (boolean|null); + customLists?: (proto.SyncActionValue.StatusPrivacyAction.ICustomList[]|null); } class StatusPrivacyAction implements IStatusPrivacyAction { @@ -13710,6 +13711,7 @@ export namespace proto { public userJid: string[]; public shareToFB?: (boolean|null); public shareToIG?: (boolean|null); + public customLists: proto.SyncActionValue.StatusPrivacyAction.ICustomList[]; public static create(properties?: proto.SyncActionValue.IStatusPrivacyAction): proto.SyncActionValue.StatusPrivacyAction; public static encode(m: proto.SyncActionValue.IStatusPrivacyAction, w?: $protobuf.Writer): $protobuf.Writer; public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.StatusPrivacyAction; @@ -13721,11 +13723,36 @@ export namespace proto { namespace StatusPrivacyAction { + interface ICustomList { + id?: (number|Long|null); + name?: (string|null); + emoji?: (string|null); + isSelected?: (boolean|null); + userJid?: (string[]|null); + } + + class CustomList implements ICustomList { + constructor(p?: proto.SyncActionValue.StatusPrivacyAction.ICustomList); + public id?: (number|Long|null); + public name?: (string|null); + public emoji?: (string|null); + public isSelected?: (boolean|null); + public userJid: string[]; + public static create(properties?: proto.SyncActionValue.StatusPrivacyAction.ICustomList): proto.SyncActionValue.StatusPrivacyAction.CustomList; + public static encode(m: proto.SyncActionValue.StatusPrivacyAction.ICustomList, w?: $protobuf.Writer): $protobuf.Writer; + public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.StatusPrivacyAction.CustomList; + public static fromObject(d: { [k: string]: any }): proto.SyncActionValue.StatusPrivacyAction.CustomList; + public static toObject(m: proto.SyncActionValue.StatusPrivacyAction.CustomList, o?: $protobuf.IConversionOptions): { [k: string]: any }; + public toJSON(): { [k: string]: any }; + public static getTypeUrl(typeUrlPrefix?: string): string; + } + enum StatusDistributionMode { ALLOW_LIST = 0, DENY_LIST = 1, CONTACTS = 2, - CLOSE_FRIENDS = 3 + CLOSE_FRIENDS = 3, + CUSTOM_LIST = 4 } } diff --git a/WAProto/index.js b/WAProto/index.js index ae74c415..af13dd6d 100644 --- a/WAProto/index.js +++ b/WAProto/index.js @@ -94967,6 +94967,7 @@ export const proto = $root.proto = (() => { function StatusPrivacyAction(p) { this.userJid = []; + this.customLists = []; if (p) for (var ks = Object.keys(p), i = 0; i < ks.length; ++i) if (p[ks[i]] != null) @@ -94977,6 +94978,7 @@ export const proto = $root.proto = (() => { StatusPrivacyAction.prototype.userJid = $util.emptyArray; StatusPrivacyAction.prototype.shareToFB = null; StatusPrivacyAction.prototype.shareToIG = null; + StatusPrivacyAction.prototype.customLists = $util.emptyArray; let $oneOfFields; @@ -95015,6 +95017,10 @@ export const proto = $root.proto = (() => { w.uint32(24).bool(m.shareToFB); if (m.shareToIG != null && Object.hasOwnProperty.call(m, "shareToIG")) w.uint32(32).bool(m.shareToIG); + if (m.customLists != null && m.customLists.length) { + for (var i = 0; i < m.customLists.length; ++i) + $root.proto.SyncActionValue.StatusPrivacyAction.CustomList.encode(m.customLists[i], w.uint32(42).fork()).ldelim(); + } return w; }; @@ -95045,6 +95051,12 @@ export const proto = $root.proto = (() => { m.shareToIG = r.bool(); break; } + case 5: { + if (!(m.customLists && m.customLists.length)) + m.customLists = []; + m.customLists.push($root.proto.SyncActionValue.StatusPrivacyAction.CustomList.decode(r, r.uint32())); + break; + } default: r.skipType(t & 7); break; @@ -95080,6 +95092,10 @@ export const proto = $root.proto = (() => { case 3: m.mode = 3; break; + case "CUSTOM_LIST": + case 4: + m.mode = 4; + break; } if (d.userJid) { if (!Array.isArray(d.userJid)) @@ -95095,6 +95111,16 @@ export const proto = $root.proto = (() => { if (d.shareToIG != null) { m.shareToIG = Boolean(d.shareToIG); } + if (d.customLists) { + if (!Array.isArray(d.customLists)) + throw TypeError(".proto.SyncActionValue.StatusPrivacyAction.customLists: array expected"); + m.customLists = []; + for (var i = 0; i < d.customLists.length; ++i) { + if (typeof d.customLists[i] !== "object") + throw TypeError(".proto.SyncActionValue.StatusPrivacyAction.customLists: object expected"); + m.customLists[i] = $root.proto.SyncActionValue.StatusPrivacyAction.CustomList.fromObject(d.customLists[i]); + } + } return m; }; @@ -95104,6 +95130,7 @@ export const proto = $root.proto = (() => { var d = {}; if (o.arrays || o.defaults) { d.userJid = []; + d.customLists = []; } if (m.mode != null && m.hasOwnProperty("mode")) { d.mode = o.enums === String ? $root.proto.SyncActionValue.StatusPrivacyAction.StatusDistributionMode[m.mode] === undefined ? m.mode : $root.proto.SyncActionValue.StatusPrivacyAction.StatusDistributionMode[m.mode] : m.mode; @@ -95126,6 +95153,12 @@ export const proto = $root.proto = (() => { if (o.oneofs) d._shareToIG = "shareToIG"; } + if (m.customLists && m.customLists.length) { + d.customLists = []; + for (var j = 0; j < m.customLists.length; ++j) { + d.customLists[j] = $root.proto.SyncActionValue.StatusPrivacyAction.CustomList.toObject(m.customLists[j], o); + } + } return d; }; @@ -95140,12 +95173,203 @@ export const proto = $root.proto = (() => { return typeUrlPrefix + "/proto.SyncActionValue.StatusPrivacyAction"; }; + StatusPrivacyAction.CustomList = (function() { + + function CustomList(p) { + this.userJid = []; + if (p) + for (var ks = Object.keys(p), i = 0; i < ks.length; ++i) + if (p[ks[i]] != null) + this[ks[i]] = p[ks[i]]; + } + + CustomList.prototype.id = null; + CustomList.prototype.name = null; + CustomList.prototype.emoji = null; + CustomList.prototype.isSelected = null; + CustomList.prototype.userJid = $util.emptyArray; + + let $oneOfFields; + + // Virtual OneOf for proto3 optional field + Object.defineProperty(CustomList.prototype, "_id", { + get: $util.oneOfGetter($oneOfFields = ["id"]), + set: $util.oneOfSetter($oneOfFields) + }); + + // Virtual OneOf for proto3 optional field + Object.defineProperty(CustomList.prototype, "_name", { + get: $util.oneOfGetter($oneOfFields = ["name"]), + set: $util.oneOfSetter($oneOfFields) + }); + + // Virtual OneOf for proto3 optional field + Object.defineProperty(CustomList.prototype, "_emoji", { + get: $util.oneOfGetter($oneOfFields = ["emoji"]), + set: $util.oneOfSetter($oneOfFields) + }); + + // Virtual OneOf for proto3 optional field + Object.defineProperty(CustomList.prototype, "_isSelected", { + get: $util.oneOfGetter($oneOfFields = ["isSelected"]), + set: $util.oneOfSetter($oneOfFields) + }); + + CustomList.create = function create(properties) { + return new CustomList(properties); + }; + + CustomList.encode = function encode(m, w) { + if (!w) + w = $Writer.create(); + if (m.id != null && Object.hasOwnProperty.call(m, "id")) + w.uint32(8).int64(m.id); + if (m.name != null && Object.hasOwnProperty.call(m, "name")) + w.uint32(18).string(m.name); + if (m.emoji != null && Object.hasOwnProperty.call(m, "emoji")) + w.uint32(26).string(m.emoji); + if (m.isSelected != null && Object.hasOwnProperty.call(m, "isSelected")) + w.uint32(32).bool(m.isSelected); + if (m.userJid != null && m.userJid.length) { + for (var i = 0; i < m.userJid.length; ++i) + w.uint32(42).string(m.userJid[i]); + } + return w; + }; + + CustomList.decode = function decode(r, l, e) { + if (!(r instanceof $Reader)) + r = $Reader.create(r); + var c = l === undefined ? r.len : r.pos + l, m = new $root.proto.SyncActionValue.StatusPrivacyAction.CustomList(); + while (r.pos < c) { + var t = r.uint32(); + if (t === e) + break; + switch (t >>> 3) { + case 1: { + m.id = r.int64(); + break; + } + case 2: { + m.name = r.string(); + break; + } + case 3: { + m.emoji = r.string(); + break; + } + case 4: { + m.isSelected = r.bool(); + break; + } + case 5: { + if (!(m.userJid && m.userJid.length)) + m.userJid = []; + m.userJid.push(r.string()); + break; + } + default: + r.skipType(t & 7); + break; + } + } + return m; + }; + + CustomList.fromObject = function fromObject(d) { + if (d instanceof $root.proto.SyncActionValue.StatusPrivacyAction.CustomList) + return d; + var m = new $root.proto.SyncActionValue.StatusPrivacyAction.CustomList(); + if (d.id != null) { + if ($util.Long) + (m.id = $util.Long.fromValue(d.id)).unsigned = false; + else if (typeof d.id === "string") + m.id = parseInt(d.id, 10); + else if (typeof d.id === "number") + m.id = d.id; + else if (typeof d.id === "object") + m.id = new $util.LongBits(d.id.low >>> 0, d.id.high >>> 0).toNumber(); + } + if (d.name != null) { + m.name = String(d.name); + } + if (d.emoji != null) { + m.emoji = String(d.emoji); + } + if (d.isSelected != null) { + m.isSelected = Boolean(d.isSelected); + } + if (d.userJid) { + if (!Array.isArray(d.userJid)) + throw TypeError(".proto.SyncActionValue.StatusPrivacyAction.CustomList.userJid: array expected"); + m.userJid = []; + for (var i = 0; i < d.userJid.length; ++i) { + m.userJid[i] = String(d.userJid[i]); + } + } + return m; + }; + + CustomList.toObject = function toObject(m, o) { + if (!o) + o = {}; + var d = {}; + if (o.arrays || o.defaults) { + d.userJid = []; + } + if (m.id != null && m.hasOwnProperty("id")) { + if (typeof m.id === "number") + d.id = o.longs === String ? String(m.id) : m.id; + else + d.id = o.longs === String ? longToString(m.id) : o.longs === Number ? longToNumber(m.id) : m.id; + if (o.oneofs) + d._id = "id"; + } + if (m.name != null && m.hasOwnProperty("name")) { + d.name = m.name; + if (o.oneofs) + d._name = "name"; + } + if (m.emoji != null && m.hasOwnProperty("emoji")) { + d.emoji = m.emoji; + if (o.oneofs) + d._emoji = "emoji"; + } + if (m.isSelected != null && m.hasOwnProperty("isSelected")) { + d.isSelected = m.isSelected; + if (o.oneofs) + d._isSelected = "isSelected"; + } + if (m.userJid && m.userJid.length) { + d.userJid = []; + for (var j = 0; j < m.userJid.length; ++j) { + d.userJid[j] = m.userJid[j]; + } + } + return d; + }; + + CustomList.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + CustomList.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = "type.googleapis.com"; + } + return typeUrlPrefix + "/proto.SyncActionValue.StatusPrivacyAction.CustomList"; + }; + + return CustomList; + })(); + StatusPrivacyAction.StatusDistributionMode = (function() { const valuesById = {}, values = Object.create(valuesById); values[valuesById[0] = "ALLOW_LIST"] = 0; values[valuesById[1] = "DENY_LIST"] = 1; values[valuesById[2] = "CONTACTS"] = 2; values[valuesById[3] = "CLOSE_FRIENDS"] = 3; + values[valuesById[4] = "CUSTOM_LIST"] = 4; return values; })(); diff --git a/src/Defaults/baileys-version.json b/src/Defaults/baileys-version.json index 4ef8e298..a027709b 100644 --- a/src/Defaults/baileys-version.json +++ b/src/Defaults/baileys-version.json @@ -1 +1 @@ -{"version":[2,3000,1035595667]} +{"version": [2, 3000, 1035661795]} From e0038bde36f6557dcab0626407be5f62d654e383 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sat, 21 Mar 2026 06:22:45 -0300 Subject: [PATCH 16/22] chore: update WhatsApp Web version to v2.3000.1035675348 (#313) Co-authored-by: github-actions[bot] --- src/Defaults/baileys-version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Defaults/baileys-version.json b/src/Defaults/baileys-version.json index a027709b..021f5adf 100644 --- a/src/Defaults/baileys-version.json +++ b/src/Defaults/baileys-version.json @@ -1 +1 @@ -{"version": [2, 3000, 1035661795]} +{"version":[2,3000,1035675348]} From 2a321b881e6b62ff9296dbcd831da8b3cf0cd012 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 01:46:05 -0300 Subject: [PATCH 17/22] chore: update proto/version to v2.3000.1035693752 (#314) Co-authored-by: rsalcara --- WAProto/WAProto.proto | 2 +- src/Defaults/baileys-version.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/WAProto/WAProto.proto b/WAProto/WAProto.proto index 1403b286..472579b2 100644 --- a/WAProto/WAProto.proto +++ b/WAProto/WAProto.proto @@ -1,7 +1,7 @@ syntax = "proto3"; package proto; -/// WhatsApp Version: 2.3000.1035661795 +/// WhatsApp Version: 2.3000.1035693752 message ADVDeviceIdentity { optional uint32 rawId = 1; diff --git a/src/Defaults/baileys-version.json b/src/Defaults/baileys-version.json index 021f5adf..5fe3d09e 100644 --- a/src/Defaults/baileys-version.json +++ b/src/Defaults/baileys-version.json @@ -1 +1 @@ -{"version":[2,3000,1035675348]} +{"version": [2, 3000, 1035693752]} From e633d62a3bd8fe8ae4b37f2a454c266b28c45500 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sun, 22 Mar 2026 06:22:56 -0300 Subject: [PATCH 18/22] chore: update WhatsApp Web version to v2.3000.1035697879 (#315) Co-authored-by: github-actions[bot] --- src/Defaults/baileys-version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Defaults/baileys-version.json b/src/Defaults/baileys-version.json index 5fe3d09e..70d30ba0 100644 --- a/src/Defaults/baileys-version.json +++ b/src/Defaults/baileys-version.json @@ -1 +1 @@ -{"version": [2, 3000, 1035693752]} +{"version":[2,3000,1035697879]} From 452df5d40970559d725eb665f533b028391b6a00 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Mon, 23 Mar 2026 00:38:05 -0300 Subject: [PATCH 19/22] Feat/view once receive (#316) * fix: detect view-once media (image/video/audio) on stanza 1 for linked devices --- src/Socket/messages-send.ts | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 445036bb..3cff8d7e 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1277,21 +1277,48 @@ export const makeMessagesSocket = (config: SocketConfig) => { : otherRecipients const effectiveAllRecipients = [...effectiveMeRecipients, ...effectiveOtherRecipients] - await assertSessions(effectiveAllRecipients) + // P2: detect actual view-once media by checking inner message's viewOnce flag. + // viewOnceMessage wrapper is also used for interactive messages (buttons, lists, etc) + // which do NOT carry viewOnce=true on the media — those must NOT be filtered. + const viewOnceInner = + message.viewOnceMessageV2?.message || + message.viewOnceMessage?.message || + message.viewOnceMessageV2Extension?.message + const isViewOnceMsg = !!( + viewOnceInner?.imageMessage?.viewOnce || + viewOnceInner?.videoMessage?.viewOnce || + viewOnceInner?.audioMessage?.viewOnce + ) + + // For view-once: only send DSM to primary phone (device=0). + // Companion devices (device>0) are omitted — WA server generates + // for them automatically. + // Sending explicit from a companion is rejected by the server. + const viewOnceMeRecipients = isViewOnceMsg + ? effectiveMeRecipients.filter(jid => !jidDecode(jid)?.device) + : effectiveMeRecipients + + // P3: assert sessions only for recipients we actually encrypt for. + // For view-once, companions are omitted — asserting their sessions is wasteful + // and could block the send if a companion session is corrupted. + await assertSessions([...viewOnceMeRecipients, ...effectiveOtherRecipients]) const [ { nodes: meNodes, shouldIncludeDeviceIdentity: s1 }, { nodes: otherNodes, shouldIncludeDeviceIdentity: s2 } ] = await Promise.all([ // For own devices: use DSM (deviceSentMessage) wrapper - createParticipantNodes(effectiveMeRecipients, meMsg || message, extraAttrs), + createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs), createParticipantNodes(effectiveOtherRecipients, message, extraAttrs) ]) participants.push(...meNodes) participants.push(...otherNodes) - if (effectiveMeRecipients.length > 0 || effectiveOtherRecipients.length > 0) { - extraAttrs['phash'] = generateParticipantHashV2(effectiveAllRecipients) + const phashRecipients = isViewOnceMsg + ? [...viewOnceMeRecipients, ...effectiveOtherRecipients] + : effectiveAllRecipients + if (phashRecipients.length > 0) { + extraAttrs['phash'] = generateParticipantHashV2(phashRecipients) } shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1 || s2 From 4ca1878705573072bb0ceca43dec6183a15bb17d Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Mon, 23 Mar 2026 01:12:36 -0300 Subject: [PATCH 20/22] =?UTF-8?q?docs:=20add=20VIEW=5FONCE.md=20=E2=80=94?= =?UTF-8?q?=20view-once=20implementation=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/VIEW_ONCE.md | 514 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 docs/VIEW_ONCE.md diff --git a/docs/VIEW_ONCE.md b/docs/VIEW_ONCE.md new file mode 100644 index 00000000..c0fdeb33 --- /dev/null +++ b/docs/VIEW_ONCE.md @@ -0,0 +1,514 @@ +# 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. + +**Comportamento em cada dispositivo após envio via API:** + +| 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 ` +
+ ${icons[type]} + ${labels[type]} de visualização única + +
+ ` +} + +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 ` +
+ 👁️ + ${labels[type]} de visualização única + Enviada +
+ ` +} +``` + +### 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 ` +
+ 🚫 + ${text} +
+ ` +} +``` + +### 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 +// 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: +│ +│ ...viewOnceMessageV2 cifrado... ← para o destinatário +│ +│ ← celular principal +│ ...DSM{viewOnceMessageV2}... +│ +│ +│ +│ +│ +└── Distribuição pelo servidor: + ├── Destinatário → recebe enc → abre uma vez ✅ + ├── Celular principal → recebe DSM → mostra "você enviou" ✅ + └── WA Web → recebe → "Aguardando..." ✅ + + +Destinatário envia view-once de volta para a API +│ +├── Stanza recebido pela API (stanza 1 — enc com conteúdo): +│ +│ ...viewOnceMessageV2 cifrado... +│ +│ +├── decode-wa-message.ts: +│ └── Descriptografa → detecta viewOnce=true → seta key.isViewOnce=true +│ +├── Stanza recebido (stanza 2 — unavailable, ignorado): +│ +│ +│ +│ +└── Webhook emitido: + messages.upsert → msg.key.isViewOnce = true ✅ +``` + +--- + +## 7. Limitações + +| Situação | Comportamento | Motivo | +|---|---|---| +| WA Web do remetente | "Aguardando mensagem. Abra no celular." | Servidor WA só aceita `` 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 From 9c626801b08847e993d9fa2281d3083e61ddd4e0 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Mon, 23 Mar 2026 01:17:27 -0300 Subject: [PATCH 21/22] docs: add hosted account restriction warning to VIEW_ONCE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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á --- docs/VIEW_ONCE.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/VIEW_ONCE.md b/docs/VIEW_ONCE.md index c0fdeb33..cae80628 100644 --- a/docs/VIEW_ONCE.md +++ b/docs/VIEW_ONCE.md @@ -9,7 +9,23 @@ 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. -**Comportamento em cada dispositivo após envio via API:** +--- + +> ## ⚠️ 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 | |---|---| @@ -489,6 +505,7 @@ Destinatário envia view-once de volta para a API | 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 `` 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 | From a283d858c694147cf6e1471727964ea12cfd72e3 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Mon, 23 Mar 2026 01:27:45 -0300 Subject: [PATCH 22/22] =?UTF-8?q?docs:=20rewrite=20INTERACTIVE=5FMESSAGES.?= =?UTF-8?q?md=20=E2=80=94=20complete=20API=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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á --- docs/INTERACTIVE_MESSAGES.md | 1190 +++++++++++++++++++++------------- 1 file changed, 753 insertions(+), 437 deletions(-) diff --git a/docs/INTERACTIVE_MESSAGES.md b/docs/INTERACTIVE_MESSAGES.md index a09c6bb3..02b71f01 100644 --- a/docs/INTERACTIVE_MESSAGES.md +++ b/docs/INTERACTIVE_MESSAGES.md @@ -1,506 +1,822 @@ -# ⚠️ EXPERIMENTAL: Interactive Messages Guide +# Mensagens Interativas — Guia Completo -## 🚨 **CRITICAL WARNING** - -**These features MAY NOT WORK and can cause ACCOUNT BANS.** - -- ❌ WhatsApp actively blocks non-business accounts from sending interactive messages -- ❌ Can result in temporary or permanent account bans -- ❌ Messages may not be delivered -- ✅ **Use ONLY for testing with DISPOSABLE accounts in DEV environment** - -## 📋 Table of Contents - -1. [Configuration](#configuration) -2. [Simple Text Buttons](#1-simple-text-buttons) -3. [Buttons with Image](#2-buttons-with-image) -4. [Buttons with Video](#3-buttons-with-video) -5. [List Messages](#4-list-messages) -6. [Template Buttons (Action Buttons)](#5-template-buttons-action-buttons) -7. [Carousel Messages](#6-carousel-messages) -8. [Metrics and Monitoring](#metrics-and-monitoring) +Guia de referência para envio e consumo de todos os tipos de mensagens interativas suportados pela API. --- -## Configuration +## Compatibilidade por Plataforma -### Enable Interactive Messages +| Tipo | Android | iOS | WA Web | +|---|---|---|---| +| Menu Texto | ✅ | ✅ | ✅ | +| Botões Quick Reply | ✅ | ✅ | ✅ | +| Botões CTA (URL / Copy / Call) | ✅ | ✅ | ✅ | +| Lista Dropdown | ✅ | ✅ | ✅ | +| Enquete/Poll | ✅ | ✅ | ✅ | +| Carrossel com Imagens | ✅ | ✅ | ✅ | -```typescript -import makeWASocket from '@whiskeysockets/baileys' - -const sock = makeWASocket({ - auth: state, - // ⚠️ Enable experimental interactive messages - // WARNING: Can cause account bans! - enableInteractiveMessages: true, // default: true - logger: pino({ level: 'warn' }) -}) -``` - -### Disable in Production - -```typescript -const sock = makeWASocket({ - auth: state, - // ✅ Disable for production safety - enableInteractiveMessages: false, - logger: pino({ level: 'info' }) -}) -``` +> **Nota:** Todos os tipos exigem conta **WhatsApp Business** ativa e **não-hosted** (registrada via QR/pair code). Contas hosted (Meta Business Cloud API) podem ter restrições de entrega de mensagens interativas impostas pelo servidor WA. --- -## 1. Simple Text Buttons +## Índice -Send a message with up to 3 clickable buttons. - -### Example - -```typescript -await sock.sendMessage(jid, { - text: 'Choose an option:', - buttons: [ - { - buttonId: 'btn1', - buttonText: { displayText: 'Option 1' } - }, - { - buttonId: 'btn2', - buttonText: { displayText: 'Option 2' } - }, - { - buttonId: 'btn3', - buttonText: { displayText: 'Option 3' } - } - ], - footerText: 'Optional footer text' -}) -``` - -### Parameters - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `text` | string | ✅ | Main message text | -| `buttons` | ButtonInfo[] | ✅ | Array of buttons (max 3) | -| `footerText` | string | ❌ | Footer text below buttons | +1. [Menu Texto](#1-menu-texto) +2. [Botões Quick Reply](#2-botões-quick-reply) +3. [Botões CTA — URL, Copy, Call](#3-botões-cta--url-copy-call) +4. [Lista Dropdown](#4-lista-dropdown) +5. [Enquete / Poll](#5-enquete--poll) +6. [Apenas Botões Reply sem CTA](#6-apenas-botões-reply-sem-cta) +7. [Apenas CTAs sem Quick Reply](#7-apenas-ctas-sem-quick-reply) +8. [Carrossel com Imagens](#8-carrossel-com-imagens) +9. [Como Consumir Respostas via Webhook](#9-como-consumir-respostas-via-webhook) +10. [Limites e Restrições](#10-limites-e-restrições) --- -## 2. Buttons with Image +## 1. Menu Texto -Send an image with interactive buttons. +Envia uma mensagem de texto simples com uma lista numerada de opções. Funciona em todas as plataformas sem binary nodes. -### Example +### Curl -```typescript -await sock.sendMessage(jid, { - image: { url: 'https://example.com/image.jpg' }, - caption: 'Image description', - buttons: [ - { - buttonId: 'view_more', - buttonText: { displayText: 'View More' } - }, - { - buttonId: 'buy_now', - buttonText: { displayText: 'Buy Now' } - } - ], - footerText: 'Available now' -}) +```bash +curl -X POST https://sua-api.com/v1/messages/send_menu \ + -H "Content-Type: application/json" \ + -H "x-api-key: SEU_API_KEY" \ + -d '{ + "instance": "minha-instancia", + "to": "5511900000001", + "title": "Menu de Opções", + "text": "Escolha uma opção:", + "options": ["Opção 1", "Opção 2", "Opção 3"], + "footer": "Responda com o número" + }' ``` -### Parameters +### Parâmetros -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `image` | WAMediaUpload | ✅ | Image URL or Buffer | -| `caption` | string | ❌ | Image caption | -| `buttons` | ButtonInfo[] | ✅ | Array of buttons (max 3) | -| `footerText` | string | ❌ | Footer text | +| Campo | Tipo | Obrigatório | Descrição | +|---|---|---|---| +| `instance` | string | ✅ | Nome da instância conectada | +| `to` | string | ✅ | Número do destinatário (DDI+DDD+número) | +| `title` | string | ❌ | Título do menu | +| `text` | string | ✅ | Texto da mensagem | +| `options` | string[] | ✅ | Lista de opções (sem limite fixo) | +| `footer` | string | ❌ | Texto de rodapé | + +### Resposta da API + +```json +{ "ok": true } +``` + +### Como o destinatário vê + +O usuário recebe uma mensagem de texto com as opções numeradas e responde digitando o número correspondente. Não há interatividade nativa — a resposta chega como mensagem de texto comum. --- -## 3. Buttons with Video +## 2. Botões Quick Reply -Send a video with interactive buttons. +Botões de resposta rápida que o usuário toca para selecionar. Testado com até **16 botões**. Renderiza nativamente em Android, iOS e WA Web. -### Example +### Curl -```typescript -await sock.sendMessage(jid, { - video: { url: 'https://example.com/video.mp4' }, - caption: 'Watch this!', - buttons: [ - { - buttonId: 'watch', - buttonText: { displayText: 'Watch Now' } - } - ], - footerText: 'New release' -}) +```bash +curl -X POST https://sua-api.com/v1/messages/send_buttons_helpers \ + -H "Content-Type: application/json" \ + -H "x-api-key: SEU_API_KEY" \ + -d '{ + "instance": "minha-instancia", + "to": "5511900000001", + "text": "👋 Olá! Como posso ajudar?", + "footer": "Atendimento 24h", + "buttons": [ + {"id": "vendas", "text": "🛒 Fazer Pedido"}, + {"id": "suporte", "text": "🔧 Suporte Técnico"}, + {"id": "financeiro", "text": "💰 Financeiro"}, + {"id": "comercial", "text": "📊 Comercial"}, + {"id": "contabil", "text": "📋 Contábil"}, + {"id": "rh", "text": "👥 Recursos Humanos"}, + {"id": "secretaria", "text": "📞 Secretaria"}, + {"id": "diplomas", "text": "🎓 Diplomas"}, + {"id": "diretoria", "text": "🏛️ Diretoria"}, + {"id": "compliance", "text": "✅ Compliance"}, + {"id": "juridico", "text": "⚖️ Jurídico"}, + {"id": "social", "text": "🤝 Ass. Social"}, + {"id": "contratos", "text": "📄 Contratos"}, + {"id": "ti", "text": "💻 Tecnologia da Informação"}, + {"id": "assessoria", "text": "📣 Assessoria"}, + {"id": "voip", "text": "📡 VOIP"} + ] + }' ``` ---- +### Parâmetros -## 4. List Messages +| Campo | Tipo | Obrigatório | Descrição | +|---|---|---|---| +| `instance` | string | ✅ | Nome da instância | +| `to` | string | ✅ | Número do destinatário | +| `text` | string | ✅ | Texto principal | +| `footer` | string | ❌ | Rodapé | +| `buttons` | array | ✅ | Lista de botões (ver abaixo) | +| `buttons[].id` | string | ✅ | ID do botão (retornado no webhook) | +| `buttons[].text` | string | ✅ | Texto exibido no botão | -Send a message with a menu of up to 10 options organized in sections. +### Webhook recebido ao clicar -### Example - -```typescript -await sock.sendMessage(jid, { - text: 'Choose a product:', - title: 'Product Catalog', - buttonText: 'View Options', - sections: [ - { - title: 'Electronics', - rows: [ - { - rowId: 'laptop_1', - title: 'Laptop Pro', - description: '$999 - High performance' - }, - { - rowId: 'phone_1', - title: 'Smartphone X', - description: '$699 - Latest model' +```json +{ + "event": "messages.upsert", + "data": { + "messages": [{ + "key": { "remoteJid": "5511900000001@s.whatsapp.net", "fromMe": false }, + "message": { + "interactiveResponseMessage": { + "body": { "text": "🛒 Fazer Pedido" }, + "nativeFlowResponseMessage": { + "name": "quick_reply", + "paramsJson": "{\"id\":\"vendas\"}" + } } - ] - }, - { - title: 'Accessories', - rows: [ - { - rowId: 'case_1', - title: 'Phone Case', - description: '$29 - Protective' - } - ] - } - ], - footerText: 'Free shipping' -}) + } + }] + } +} ``` -### Parameters +### Como ler a resposta -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `text` | string | ✅ | Message description | -| `title` | string | ❌ | List title | -| `buttonText` | string | ✅ | Text on button that opens list | -| `sections` | ListSection[] | ✅ | Array of sections (max 10 items total) | -| `footerText` | string | ❌ | Footer text | +```javascript +function parseButtonReply(msg) { + const interactive = msg.message?.interactiveResponseMessage + if (!interactive) return null -### Limits + const params = JSON.parse( + interactive.nativeFlowResponseMessage?.paramsJson || '{}' + ) + return { + buttonId: params.id, + buttonText: interactive.body?.text + } +} -- Maximum 10 sections -- Maximum 10 rows total across all sections -- Row title: 24 characters max -- Row description: 72 characters max +// Exemplo: +// { buttonId: 'vendas', buttonText: '🛒 Fazer Pedido' } +``` --- -## 5. Template Buttons (Action Buttons) +## 3. Botões CTA — URL, Copy, Call -Send buttons with actions: Quick Reply, URL, or Call. +Botões de ação especiais: abrir URL, copiar código (PIX, cupom, etc.) e ligar. Podem ser combinados na mesma mensagem. -### Example +### Curl — Combinação URL + Copy + Call -```typescript -await sock.sendMessage(jid, { - text: 'Contact us or visit our website', - templateButtons: [ - // Quick Reply Button - { - index: 1, - quickReplyButton: { - displayText: 'Quick Reply', - id: 'reply_1' - } - }, - // URL Button - { - index: 2, - urlButton: { - displayText: 'Visit Website', - url: 'https://example.com' - } - }, - // Call Button - { - index: 3, - callButton: { - displayText: 'Call Us', - phoneNumber: '+551199999999' - } - } - ], - footer: 'We are here to help' -}) -``` - -### Button Types - -| Type | Description | Parameters | -|------|-------------|------------| -| `quickReplyButton` | Quick response button | `displayText`, `id` | -| `urlButton` | Opens URL in browser | `displayText`, `url` | -| `callButton` | Initiates phone call | `displayText`, `phoneNumber` | - ---- - -## 6. Carousel Messages - -Send up to 10 scrollable cards with images/videos and buttons. - -### Example - -```typescript -await sock.sendMessage(jid, { - text: 'Check out our products', - carousel: { - cards: [ - // Card 1 +```bash +curl -X POST https://sua-api.com/v1/messages/send_interactive_helpers \ + -H "Content-Type: application/json" \ + -H "x-api-key: SEU_API_KEY" \ + -d '{ + "instance": "minha-instancia", + "to": "5511900000001", + "text": "💳 Pagamento via PIX\nValor: R$ 150,00\nPedido: #12345", + "footer": "Obrigado pela preferência!", + "buttons": [ { - header: { - title: 'Product 1', - imageMessage: { - url: 'https://example.com/product1.jpg', - mimetype: 'image/jpeg' - }, - hasMediaAttachment: true - }, - body: { text: 'Amazing product - $99.90' }, - footer: { text: 'Limited stock' }, - nativeFlowMessage: { - buttons: [ - { - name: 'quick_reply', - buttonParamsJson: JSON.stringify({ - display_text: 'Buy Now', - id: 'buy_1' - }) - }, - { - name: 'cta_url', - buttonParamsJson: JSON.stringify({ - display_text: 'More Info', - url: 'https://example.com/product1' - }) - } - ] - } + "type": "call", + "text": "📞 Ligar Suporte", + "phoneNumber": "+5511900000002" }, - // Card 2 { - header: { - title: 'Product 2', - imageMessage: { - url: 'https://example.com/product2.jpg', - mimetype: 'image/jpeg' + "type": "copy", + "text": "📋 Copiar Chave PIX", + "copyCode": "00020126580014br.gov.bcb.pix0136123e4567-e89b-12d3-a456-426614174000" + }, + { + "type": "url", + "text": "🔗 Ver Pedido", + "url": "https://sualoja.com.br/pedido/12345" + } + ] + }' +``` + +### Parâmetros de cada tipo de botão + +**Tipo `url`:** +| Campo | Tipo | Descrição | +|---|---|---| +| `type` | `"url"` | Tipo do botão | +| `text` | string | Texto do botão | +| `url` | string | URL a abrir (http/https) | + +**Tipo `copy`:** +| Campo | Tipo | Descrição | +|---|---|---| +| `type` | `"copy"` | Tipo do botão | +| `text` | string | Texto do botão | +| `copyCode` | string | Texto copiado ao clicar | + +**Tipo `call`:** +| Campo | Tipo | Descrição | +|---|---|---| +| `type` | `"call"` | Tipo do botão | +| `text` | string | Texto do botão | +| `phoneNumber` | string | Número a ligar (com +DDI) | + +### Webhook recebido ao clicar em URL + +```json +{ + "message": { + "interactiveResponseMessage": { + "body": { "text": "🔗 Ver Pedido" }, + "nativeFlowResponseMessage": { + "name": "cta_open_url", + "paramsJson": "{\"url\":\"https://sualoja.com.br/pedido/12345\",\"from_notification\":false}" + } + } + } +} +``` + +### Webhook recebido ao clicar em Copy + +```json +{ + "message": { + "interactiveResponseMessage": { + "nativeFlowResponseMessage": { + "name": "cta_copy", + "paramsJson": "{\"copy_code\":\"00020126580014br.gov.bcb.pix...\"}" + } + } + } +} +``` + +### Como ler a resposta CTA + +```javascript +function parseCTAReply(msg) { + const flow = msg.message?.interactiveResponseMessage?.nativeFlowResponseMessage + if (!flow) return null + + const params = JSON.parse(flow.paramsJson || '{}') + switch (flow.name) { + case 'cta_open_url': return { action: 'url', url: params.url } + case 'cta_copy': return { action: 'copy', code: params.copy_code } + case 'cta_call': return { action: 'call', phone: params.phone_number } + default: return { action: flow.name, params } + } +} +``` + +--- + +## 4. Lista Dropdown + +Menu suspenso com seções e itens selecionáveis. Suporta até **10 seções × 3 linhas = 30 itens** no total. + +### Curl — Cardápio completo (10 seções, 30 itens) + +```bash +curl -X POST https://sua-api.com/v1/messages/send_list_helpers \ + -H "Content-Type: application/json" \ + -H "x-api-key: SEU_API_KEY" \ + -d '{ + "instance": "minha-instancia", + "to": "5511900000001", + "text": "Cardápio completo do restaurante.\nEscolha uma categoria abaixo:", + "footer": "Delivery grátis acima de R$ 50", + "buttonText": "Ver Cardápio", + "sections": [ + { + "title": "Hambúrgueres", + "rows": [ + {"id": "burger_1", "title": "Clássico", "description": "Pão, carne 180g, queijo, alface, tomate - R$ 28,90"}, + {"id": "burger_2", "title": "Bacon Lovers", "description": "Pão, carne 180g, bacon crocante, cheddar - R$ 34,90"}, + {"id": "burger_3", "title": "Veggie Burger", "description": "Pão, hambúrguer de grão de bico, rúcula - R$ 32,90"} + ] + }, + { + "title": "Pizzas", + "rows": [ + {"id": "pizza_1", "title": "Margherita", "description": "Molho de tomate, mussarela, manjericão - R$ 49,90"}, + {"id": "pizza_2", "title": "Pepperoni", "description": "Molho, mussarela, pepperoni fatiado - R$ 54,90"}, + {"id": "pizza_3", "title": "Quatro Queijos","description": "Mussarela, gorgonzola, parmesão, brie - R$ 52,90"} + ] + }, + { + "title": "Massas", + "rows": [ + {"id": "massa_1", "title": "Espaguete Bolonhesa", "description": "Massa al dente com molho bolonhesa caseiro - R$ 38,90"}, + {"id": "massa_2", "title": "Fettuccine Alfredo", "description": "Fettuccine com molho branco cremoso - R$ 42,90"}, + {"id": "massa_3", "title": "Lasanha Especial", "description": "Camadas de massa, carne, presunto, queijo - R$ 45,90"} + ] + }, + { + "title": "Saladas", + "rows": [ + {"id": "salada_1", "title": "Caesar", "description": "Alface romana, croutons, parmesão, molho - R$ 32,90"}, + {"id": "salada_2", "title": "Tropical", "description": "Mix de folhas, manga, palmito, molho - R$ 29,90"}, + {"id": "salada_3", "title": "Caprese", "description": "Tomate, mussarela búfala, manjericão - R$ 34,90"} + ] + }, + { + "title": "Frutos do Mar", + "rows": [ + {"id": "mar_1", "title": "Camarão Grelhado", "description": "Camarões grelhados com manteiga e alho - R$ 62,90"}, + {"id": "mar_2", "title": "Filé de Salmão", "description": "Salmão grelhado com legumes no vapor - R$ 58,90"}, + {"id": "mar_3", "title": "Moqueca de Peixe", "description": "Peixe, leite de coco, dendê, pimentão - R$ 55,90"} + ] + }, + { + "title": "Sobremesas", + "rows": [ + {"id": "doce_1", "title": "Petit Gâteau", "description": "Bolo de chocolate com sorvete de creme - R$ 28,90"}, + {"id": "doce_2", "title": "Pudim", "description": "Pudim de leite condensado tradicional - R$ 18,90"}, + {"id": "doce_3", "title": "Açaí 500ml", "description": "Açaí com granola, banana e leite ninho - R$ 24,90"} + ] + }, + { + "title": "Bebidas", + "rows": [ + {"id": "bebida_1", "title": "Refrigerante 350ml", "description": "Coca-Cola, Guaraná, Sprite, Fanta - R$ 6,90"}, + {"id": "bebida_2", "title": "Suco Natural 500ml", "description": "Laranja, limão, maracujá, abacaxi - R$ 12,90"}, + {"id": "bebida_3", "title": "Água Mineral", "description": "Com ou sem gás 500ml - R$ 4,90"} + ] + }, + { + "title": "Cervejas", + "rows": [ + {"id": "cerveja_1", "title": "Pilsen 600ml", "description": "Brahma, Skol, Antarctica - R$ 12,90"}, + {"id": "cerveja_2", "title": "IPA 473ml", "description": "Colorado, Lagunitas, Goose Island - R$ 18,90"}, + {"id": "cerveja_3", "title": "Weiss 500ml", "description": "Erdinger, Paulaner, Blue Moon - R$ 22,90"} + ] + }, + { + "title": "Vinhos", + "rows": [ + {"id": "vinho_1", "title": "Tinto Seco", "description": "Cabernet Sauvignon, taça 150ml - R$ 25,90"}, + {"id": "vinho_2", "title": "Branco Suave", "description": "Chardonnay, taça 150ml - R$ 23,90"}, + {"id": "vinho_3", "title": "Rosé", "description": "Rosé Provence, taça 150ml - R$ 27,90"} + ] + }, + { + "title": "Combos", + "rows": [ + {"id": "combo_1", "title": "Combo Single", "description": "1 hambúrguer + batata + refri - R$ 39,90"}, + {"id": "combo_2", "title": "Combo Casal", "description": "2 hambúrgueres + batata grande + 2 refri - R$ 69,90"}, + {"id": "combo_3", "title": "Combo Família", "description": "4 hambúrgueres + 2 batatas + jarra - R$ 119,90"} + ] + } + ] + }' +``` + +### Parâmetros + +| Campo | Tipo | Obrigatório | Descrição | +|---|---|---|---| +| `text` | string | ✅ | Texto da mensagem | +| `footer` | string | ❌ | Rodapé | +| `buttonText` | string | ✅ | Texto do botão que abre a lista (max 20 chars) | +| `sections` | array | ✅ | Seções da lista (max 10) | +| `sections[].title` | string | ✅ | Título da seção (max 24 chars) | +| `sections[].rows` | array | ✅ | Itens da seção (max 3 por seção) | +| `rows[].id` | string | ✅ | ID do item (retornado no webhook) | +| `rows[].title` | string | ✅ | Título do item (max 24 chars) | +| `rows[].description` | string | ❌ | Descrição do item (max 72 chars) | + +### Webhook recebido ao selecionar item + +```json +{ + "message": { + "interactiveResponseMessage": { + "body": { "text": "Clássico" }, + "nativeFlowResponseMessage": { + "name": "single_select", + "paramsJson": "{\"id\":\"burger_1\",\"title\":\"Clássico\"}" + } + } + } +} +``` + +### Como ler a resposta + +```javascript +function parseListReply(msg) { + const flow = msg.message?.interactiveResponseMessage?.nativeFlowResponseMessage + if (flow?.name !== 'single_select') return null + + const params = JSON.parse(flow.paramsJson || '{}') + return { + id: params.id, + title: params.title + } +} + +// Exemplo: { id: 'burger_1', title: 'Clássico' } +``` + +--- + +## 5. Enquete / Poll + +Enquete nativa do WhatsApp. Funciona em todas as plataformas sem restrições. O usuário seleciona uma ou mais opções e o resultado é atualizado em tempo real. + +### Curl + +```bash +curl -X POST https://sua-api.com/v1/messages/send_poll \ + -H "Content-Type: application/json" \ + -H "x-api-key: SEU_API_KEY" \ + -d '{ + "instance": "minha-instancia", + "to": "5511900000001", + "name": "Qual sua linguagem favorita?", + "options": ["JavaScript", "Python", "TypeScript", "Go"], + "selectableCount": 1 + }' +``` + +### Parâmetros + +| Campo | Tipo | Obrigatório | Descrição | +|---|---|---|---| +| `name` | string | ✅ | Pergunta da enquete | +| `options` | string[] | ✅ | Opções (min 2, max 12) | +| `selectableCount` | number | ✅ | Quantas opções o usuário pode marcar (0 = ilimitado) | + +### Webhook recebido ao votar + +```json +{ + "event": "messages.upsert", + "data": { + "messages": [{ + "key": { "remoteJid": "5511900000001@s.whatsapp.net" }, + "message": { + "pollUpdateMessage": { + "pollCreationMessageKey": { + "id": "ID_DA_ENQUETE_ORIGINAL" }, - hasMediaAttachment: true - }, - body: { text: 'Great deal - $149.90' }, - footer: { text: 'Best seller' }, - nativeFlowMessage: { - buttons: [ - { - name: 'quick_reply', - buttonParamsJson: JSON.stringify({ - display_text: 'Buy Now', - id: 'buy_2' - }) - } - ] + "vote": { + "selectedOptions": ["TypeScript"] + } } } - ], - messageVersion: 1 + }] } -}) -``` - -### Limits - -- Maximum 10 cards per carousel -- Each card requires an image or video -- Up to 3 buttons per card -- All cards must have same media type (all images or all videos) - ---- - -## Metrics and Monitoring - -All interactive messages are tracked with Prometheus metrics: - -### Available Metrics - -```typescript -// Messages sent by type -metrics.interactiveMessagesSent.inc({ type: 'buttons' }) - -// Successful sends -metrics.interactiveMessagesSuccess.inc({ type: 'list' }) - -// Failures with reason -metrics.interactiveMessagesFailures.inc({ - type: 'template', - reason: 'feature_disabled' -}) - -// Send latency -metrics.interactiveMessagesLatency.observe({ type: 'carousel' }, 1234) -``` - -### Monitoring Dashboard - -Query these metrics in Prometheus/Grafana: - -```promql -# Total interactive messages sent -sum(interactive_messages_sent_total) by (type) - -# Success rate -sum(interactive_messages_success_total) / sum(interactive_messages_sent_total) - -# Failure breakdown -sum(interactive_messages_failures_total) by (type, reason) - -# P95 latency -histogram_quantile(0.95, interactive_messages_latency_ms) -``` - ---- - -## Handling Responses - -### Button Response - -```typescript -sock.ev.on('messages.upsert', async ({ messages }) => { - const msg = messages[0] - - // Button response - if (msg.message?.buttonsResponseMessage) { - const buttonId = msg.message.buttonsResponseMessage.selectedButtonId - const displayText = msg.message.buttonsResponseMessage.selectedDisplayText - - console.log(`User clicked: ${buttonId} - "${displayText}"`) - } - - // Template button response - if (msg.message?.templateButtonReplyMessage) { - const selectedId = msg.message.templateButtonReplyMessage.selectedId - const selectedText = msg.message.templateButtonReplyMessage.selectedDisplayText - - console.log(`User clicked template: ${selectedId} - "${selectedText}"`) - } - - // List response - if (msg.message?.listResponseMessage) { - const rowId = msg.message.listResponseMessage.singleSelectReply.selectedRowId - const title = msg.message.listResponseMessage.title - - console.log(`User selected from list: ${rowId} - "${title}"`) - } -}) -``` - ---- - -## Troubleshooting - -### Message Not Showing - -- ✅ Verify `enableInteractiveMessages` is `true` -- ✅ Check logs for `[EXPERIMENTAL]` warnings -- ✅ Ensure you're using a test/disposable account -- ❌ Interactive messages don't work on production accounts - -### Account Banned/Restricted - -- ⚠️ This is expected behavior -- ⚠️ WhatsApp blocks non-business accounts -- ✅ Create a new test account -- ✅ Use official WhatsApp Business API for production - -### Metrics Not Showing - -Check feature flag: -```typescript -if (buttonType && !enableInteractiveMessages) { - // Metrics will show reason: 'feature_disabled' } ``` ---- +### Como ler a resposta -## Migration to WhatsApp Business API +```javascript +function parsePollVote(msg) { + const poll = msg.message?.pollUpdateMessage + if (!poll) return null -For production use, migrate to official API: - -### Evolution API (Cloud Mode) - -```typescript -// config.ts -export const evolutionConfig = { - apiUrl: 'https://your-evolution-api.com', - apiKey: 'your-api-key', - instance: 'your-instance' + return { + pollId: poll.pollCreationMessageKey?.id, + selectedOptions: poll.vote?.selectedOptions || [] + } } -// Send button via Evolution API -await fetch(`${evolutionConfig.apiUrl}/message/sendButton`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'apikey': evolutionConfig.apiKey - }, - body: JSON.stringify({ - number: '5511999999999', - options: { - delay: 1200, - presence: 'composing' - }, - buttonMessage: { - text: 'Choose an option', - buttons: [ - { id: '1', text: 'Option 1' }, - { id: '2', text: 'Option 2' } - ], - footer: 'Footer text' +// Exemplo: { pollId: '3EB0...', selectedOptions: ['TypeScript'] } +``` + +--- + +## 6. Apenas Botões Reply sem CTA + +Botões simples de confirmação/seleção sem nenhum botão de ação externa. + +### Curl + +```bash +curl -X POST https://sua-api.com/v1/messages/send_buttons_helpers \ + -H "Content-Type: application/json" \ + -H "x-api-key: SEU_API_KEY" \ + -d '{ + "instance": "minha-instancia", + "to": "5511900000001", + "text": "Confirma o pedido #12345?", + "footer": "Pedido no valor de R$ 150,00", + "buttons": [ + {"id": "confirmar", "text": "✅ Confirmar"}, + {"id": "cancelar", "text": "❌ Cancelar"}, + {"id": "adiar", "text": "⏰ Adiar"} + ] + }' +``` + +O webhook e a leitura da resposta seguem o mesmo padrão da [Seção 2](#2-botões-quick-reply). + +--- + +## 7. Apenas CTAs sem Quick Reply + +Mensagem com somente botões de ação (URL / Call) sem botões de resposta rápida. + +### Curl + +```bash +curl -X POST https://sua-api.com/v1/messages/send_interactive_helpers \ + -H "Content-Type: application/json" \ + -H "x-api-key: SEU_API_KEY" \ + -d '{ + "instance": "minha-instancia", + "to": "5511900000001", + "text": "🏪 Loja Virtual\nConfira nossos canais de atendimento:", + "footer": "Atendimento 24h", + "buttons": [ + {"type": "url", "text": "🌐 Site Oficial", "url": "https://www.sualoja.com.br"}, + {"type": "url", "text": "📸 Instagram", "url": "https://instagram.com/sualoja"}, + {"type": "call", "text": "📞 WhatsApp Vendas", "phoneNumber": "+5511900000002"} + ] + }' +``` + +O webhook e a leitura da resposta seguem o mesmo padrão da [Seção 3](#3-botões-cta--url-copy-call). + +--- + +## 8. Carrossel com Imagens + +Cards deslizáveis com imagem, texto e botões. Suporta até **10 cards**, cada um com até **2 botões**. Renderiza nativamente em Android, iOS e WA Web. + +### Curl — 10 cards (limite máximo) + +```bash +curl -X POST https://sua-api.com/v1/messages/send_carousel_helpers \ + -H "Content-Type: application/json" \ + -H "x-api-key: SEU_API_KEY" \ + -d '{ + "instance": "minha-instancia", + "to": "5511900000001", + "text": "🛍️ Ofertas Especiais", + "footer": "Loja Virtual — Entrega Grátis", + "cards": [ + { + "body": "iPhone 15 Pro Max 256GB\nR$ 8.999,00 à vista\n12x R$ 833,25", + "footer": "Frete Grátis", + "imageUrl": "https://images.unsplash.com/photo-1592750475338-74b7b21085ab?w=400", + "buttons": [{"id": "buy_1", "text": "Comprar"}, {"id": "info_1", "text": "Detalhes"}] + }, + { + "body": "MacBook Air M3 8GB 256GB\nR$ 12.499,00 à vista\n12x R$ 1.166,58", + "footer": "Garantia 1 ano", + "imageUrl": "https://images.unsplash.com/photo-1517336714731-489689fd1ca8?w=400", + "buttons": [{"id": "buy_2", "text": "Comprar"}, {"id": "info_2", "text": "Detalhes"}] + }, + { + "body": "Apple Watch Series 9 45mm\nR$ 5.299,00 à vista\n12x R$ 491,58", + "footer": "Pronta Entrega", + "imageUrl": "https://images.unsplash.com/photo-1546868871-7041f2a55e12?w=400", + "buttons": [{"id": "buy_3", "text": "Comprar"}, {"id": "info_3", "text": "Detalhes"}] + }, + { + "body": "AirPods Pro 2ª Geração\nR$ 2.499,00 à vista\n12x R$ 233,25", + "footer": "Cancelamento de Ruído", + "imageUrl": "https://images.unsplash.com/photo-1600294037681-c80b4cb5b434?w=400", + "buttons": [{"id": "buy_4", "text": "Comprar"}, {"id": "info_4", "text": "Detalhes"}] + }, + { + "body": "iPad Pro M2 11pol 128GB\nR$ 9.999,00 à vista\n12x R$ 916,58", + "footer": "Chip M2", + "imageUrl": "https://images.unsplash.com/photo-1544244015-0df4b3ffc6b0?w=400", + "buttons": [{"id": "buy_5", "text": "Comprar"}, {"id": "info_5", "text": "Detalhes"}] + }, + { + "body": "Samsung Galaxy S24 Ultra\nR$ 7.499,00 à vista\n12x R$ 691,58", + "footer": "Câmera 200MP", + "imageUrl": "https://images.unsplash.com/photo-1610945265064-0e34e5519bbf?w=400", + "buttons": [{"id": "buy_6", "text": "Comprar"}, {"id": "info_6", "text": "Detalhes"}] + }, + { + "body": "Sony WH-1000XM5\nR$ 2.299,00 à vista\n12x R$ 208,25", + "footer": "Melhor ANC do mercado", + "imageUrl": "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400", + "buttons": [{"id": "buy_7", "text": "Comprar"}, {"id": "info_7", "text": "Detalhes"}] + }, + { + "body": "Nintendo Switch OLED\nR$ 2.699,00 à vista\n12x R$ 249,92", + "footer": "Com Joy-Con", + "imageUrl": "https://images.unsplash.com/photo-1578303512597-81e6cc155b3e?w=400", + "buttons": [{"id": "buy_8", "text": "Comprar"}, {"id": "info_8", "text": "Detalhes"}] + }, + { + "body": "PlayStation 5 Slim\nR$ 4.499,00 à vista\n12x R$ 416,58", + "footer": "1TB SSD", + "imageUrl": "https://images.unsplash.com/photo-1606144042614-b2417e99c4e3?w=400", + "buttons": [{"id": "buy_9", "text": "Comprar"}, {"id": "info_9", "text": "Detalhes"}] + }, + { + "body": "DJI Mini 4 Pro Drone\nR$ 6.999,00 à vista\n12x R$ 641,58", + "footer": "4K 60fps", + "imageUrl": "https://images.unsplash.com/photo-1473968512647-3e447244af8f?w=400", + "buttons": [{"id": "buy_10", "text": "Comprar"}, {"id": "info_10", "text": "Detalhes"}] + } + ] + }' +``` + +### Parâmetros + +| Campo | Tipo | Obrigatório | Descrição | +|---|---|---|---| +| `text` | string | ✅ | Texto acima do carrossel | +| `footer` | string | ❌ | Rodapé | +| `cards` | array | ✅ | Cards (min 2, max 10) | +| `cards[].body` | string | ✅ | Texto do card | +| `cards[].footer` | string | ❌ | Rodapé do card | +| `cards[].imageUrl` | string | ✅ | URL da imagem do card | +| `cards[].buttons` | array | ✅ | Botões do card (max 2) | +| `buttons[].id` | string | ✅ | ID do botão | +| `buttons[].text` | string | ✅ | Texto do botão | + +### Webhook recebido ao clicar em botão do carrossel + +```json +{ + "message": { + "interactiveResponseMessage": { + "body": { "text": "Comprar" }, + "nativeFlowResponseMessage": { + "name": "quick_reply", + "paramsJson": "{\"id\":\"buy_3\"}" + } } - }) -}) + } +} +``` + +### Como ler a resposta + +```javascript +function parseCarouselReply(msg) { + const flow = msg.message?.interactiveResponseMessage?.nativeFlowResponseMessage + if (flow?.name !== 'quick_reply') return null + + const params = JSON.parse(flow.paramsJson || '{}') + return { + buttonId: params.id, + buttonText: msg.message.interactiveResponseMessage.body?.text + } +} + +// Exemplo: { buttonId: 'buy_3', buttonText: 'Comprar' } ``` --- -## References +## 9. Como Consumir Respostas via Webhook -- [WhatsApp Official Business API](https://business.whatsapp.com/products/business-platform) -- [Evolution API Documentation](https://doc.evolution-api.com/) -- [Baileys GitHub Issues #56](https://github.com/WhiskeySockets/Baileys/issues/56) -- [Baileys GitHub Issues #25](https://github.com/WhiskeySockets/Baileys/issues/25) +### 9.1 Estrutura geral do evento + +```json +{ + "event": "messages.upsert", + "instance": "minha-instancia", + "data": { + "messages": [{ ...mensagem... }], + "type": "notify" + } +} +``` + +### 9.2 Roteador universal de respostas interativas + +```javascript +function parseInteractiveReply(msg) { + const interactive = msg.message?.interactiveResponseMessage + if (!interactive) return null + + const flow = interactive.nativeFlowResponseMessage + const body = interactive.body?.text + const params = JSON.parse(flow?.paramsJson || '{}') + + switch (flow?.name) { + // Botão quick_reply ou carrossel + case 'quick_reply': + return { type: 'button', id: params.id, text: body } + + // Lista dropdown + case 'single_select': + return { type: 'list', id: params.id, title: params.title } + + // CTA — abrir URL + case 'cta_open_url': + return { type: 'url', url: params.url } + + // CTA — copiar código + case 'cta_copy': + return { type: 'copy', code: params.copy_code } + + // CTA — ligar + case 'cta_call': + return { type: 'call', phone: params.phone_number } + + default: + return { type: 'unknown', name: flow?.name, params } + } +} +``` + +### 9.3 Enquete — roteador + +```javascript +function parsePollUpdate(msg) { + const poll = msg.message?.pollUpdateMessage + if (!poll) return null + + return { + type: 'poll_vote', + pollId: poll.pollCreationMessageKey?.id, + selectedOptions: poll.vote?.selectedOptions || [] + } +} +``` + +### 9.4 Handler completo de webhook + +```javascript +app.post('/webhook', (req, res) => { + const { event, instance, data } = req.body + + if (event !== 'messages.upsert') return res.sendStatus(200) + + for (const msg of data.messages) { + const from = msg.key.remoteJid.replace('@s.whatsapp.net', '') + const fromMe = msg.key.fromMe + + if (fromMe) continue // ignora mensagens enviadas pela própria instância + + // Verificar tipo de resposta interativa + const reply = parseInteractiveReply(msg) || parsePollUpdate(msg) + + if (reply) { + console.log(`[${instance}] Resposta de ${from}:`, reply) + handleInteractiveReply(instance, from, reply) + } + } + + res.sendStatus(200) +}) + +function handleInteractiveReply(instance, from, reply) { + switch (reply.type) { + case 'button': + console.log(`Botão clicado: ${reply.id} — "${reply.text}"`) + break + case 'list': + console.log(`Item selecionado: ${reply.id} — "${reply.title}"`) + break + case 'url': + console.log(`URL aberta: ${reply.url}`) + break + case 'copy': + console.log(`Código copiado: ${reply.code}`) + break + case 'poll_vote': + console.log(`Voto em enquete ${reply.pollId}:`, reply.selectedOptions) + break + } +} +``` --- -## ⚠️ Final Reminder +## 10. Limites e Restrições -**THESE FEATURES ARE EXPERIMENTAL AND UNRELIABLE** +### Limites por tipo -- Use only for testing -- Expect failures and bans -- Not suitable for production -- Official WhatsApp Business API is the only supported way +| Tipo | Limite | +|---|---| +| Botões Quick Reply | Testado com até 16 botões | +| Lista — seções | Máximo 10 seções | +| Lista — itens por seção | Máximo 3 itens | +| Lista — total de itens | Máximo 30 itens | +| Lista — título de seção | Máximo 24 caracteres | +| Lista — título de item | Máximo 24 caracteres | +| Lista — descrição de item | Máximo 72 caracteres | +| Lista — texto do botão | Máximo 20 caracteres | +| Carrossel — cards | Mínimo 2, máximo 10 | +| Carrossel — botões por card | Máximo 2 botões | +| Poll — opções | Mínimo 2, máximo 12 | ---- +### Restrições de conta -**Generated by rsalcara/InfiniteAPI** +| Restrição | Detalhe | +|---|---| +| Conta hosted (Meta Business Cloud API) | Mensagens interativas podem ser bloqueadas pelo servidor WA — política do servidor, sem workaround | +| Conta pessoal (não-Business) | Servidor WA pode rejeitar o envio de mensagens interativas | +| Conta Business não verificada | Funciona, mas pode ter limitações de alcance |