From cc1443cb44d21d0862cd56c1aaab15f0b963d66f Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sat, 14 Feb 2026 21:29:06 -0300 Subject: [PATCH 1/6] fix: carousel rendering on iOS and quick_reply buttons on all platforms Carousel (interactiveMessage): - Remove messageContextInfo from carousel (breaks iOS delivery) - Remove empty subtitle field from card headers - Add jpegThumbnail/dimensions validation for card images - Return direct interactiveMessage at root (no viewOnceMessage wrapper) - Skip DSM (deviceSentMessage) for own devices (causes error 479) - Skip bot node injection for carousel messages Quick Reply Buttons (buttonsMessage): - Separate quick_reply buttons from CTA buttons in generateWAMessageContent - quick_reply (reply only) uses legacy buttonsMessage format (works on Android + iOS + Web) - CTA buttons (url, copy, call) use nativeFlowMessage with viewOnceMessage wrapper Stanza construction (messages-send.ts): - Defer biz/bot nodes to be appended LAST (after device-identity, tctoken) - Fix button name extraction for carousel (flatMap from cards) - Add protobuf roundtrip test and protocol dump for debugging Tested: Carousel renders on Android + iOS. Quick reply buttons render on Android + iOS + Web. Co-Authored-By: Claude Opus 4.6 --- src/Socket/messages-send.ts | 142 ++++++++++++++++++++++++++++++++---- src/Utils/messages.ts | 91 ++++++++++++++++------- 2 files changed, 193 insertions(+), 40 deletions(-) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 29ddf207..669658a1 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1251,6 +1251,11 @@ export const makeMessagesSocket = (config: SocketConfig) => { const isCatalog = isCatalogMessage(message) const isCarousel = isCarouselMessage(message) + // Collect biz/bot nodes to append AFTER device-identity and tctoken + // Pastorini order: participants → device-identity → tctoken → biz + // The biz node MUST be last for WhatsApp Web carousel rendering + const deferredNodes: BinaryNode[] = [] + // EXPERIMENTAL: Try biz node injection for ALL interactive messages including catalog // Previously we skipped catalog messages but they weren't rendering properly if (buttonType && enableInteractiveMessages) { @@ -1265,7 +1270,13 @@ export const makeMessagesSocket = (config: SocketConfig) => { message.listMessage || message.viewOnceMessage?.message?.listMessage || message.viewOnceMessageV2?.message?.listMessage - const nativeFlowButtons = interactiveMsg?.nativeFlowMessage?.buttons || [] + // For carousel messages, buttons are inside each card's nativeFlowMessage + let nativeFlowButtons = interactiveMsg?.nativeFlowMessage?.buttons || [] + if (nativeFlowButtons.length === 0 && interactiveMsg?.carouselMessage?.cards?.length) { + nativeFlowButtons = interactiveMsg.carouselMessage.cards.flatMap( + (card: any) => card?.nativeFlowMessage?.buttons || [] + ) + } const isListDetected = isListNativeFlow(message) // Log full button details including buttonParamsJson for debugging @@ -1307,7 +1318,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { // For listMessage (legacy format), use direct tag // This matches pastorini's working implementation if (buttonType === 'list') { - ;(stanza.content as BinaryNode[]).push({ + deferredNodes.push({ tag: 'biz', attrs: {}, content: [ @@ -1325,9 +1336,6 @@ export const makeMessagesSocket = (config: SocketConfig) => { '[EXPERIMENTAL] Injected biz node for listMessage (legacy format)' ) } else { - // Use 'mixed' as the native_flow name - this is required for message delivery - // Note: '' (empty) causes error 405 rejection from WhatsApp server - // Special flows (payment, mpm, order) use specific names const SPECIAL_FLOW_NAMES: Record = { review_and_pay: 'payment_info', payment_info: 'payment_info', @@ -1342,10 +1350,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { '[EXPERIMENTAL] Determined native_flow name based on button types' ) - // Use nested structure: biz > interactive > native_flow - // For buttons, carousels, and other interactive messages const interactiveType = 'native_flow' - ;(stanza.content as BinaryNode[]).push({ + deferredNodes.push({ tag: 'biz', attrs: {}, content: [ @@ -1369,11 +1375,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { }) } - // Bot node () MUST NOT be injected for native_flow buttons - // It prevents WhatsApp Web/Desktop from rendering ALL button types - // (both quick_reply and CTA buttons). Confirmed by testing: - // - CTA buttons without bot node: works on Web ✅ - // - quick_reply buttons with bot node: only smartphone, not Web ❌ + // Bot node — skip for native_flow buttons (breaks Web rendering) const isNativeFlowButtons = buttonType === 'native_flow' const isPrivateUserChat = @@ -1381,7 +1383,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { !isJidBot(destinationJid) if (isPrivateUserChat && !isCarousel && !isCatalog && buttonType !== 'list' && !isNativeFlowButtons) { - ;(stanza.content as BinaryNode[]).push({ + deferredNodes.push({ tag: 'bot', attrs: { biz_bot: '1' } }) @@ -1490,8 +1492,120 @@ export const makeMessagesSocket = (config: SocketConfig) => { ;(stanza.content as BinaryNode[]).push(...additionalNodes) } + // Append deferred biz/bot nodes LAST (after device-identity, tctoken) + // Pastorini order: participants → device-identity → tctoken → biz + if (deferredNodes.length > 0) { + ;(stanza.content as BinaryNode[]).push(...deferredNodes) + } + logger.debug({ msgId }, `sending message to ${participants.length} devices`) + // ======= PROTOBUF ROUNDTRIP TEST: Verify encoding preserves carousel ======= + if (isCarousel) { + try { + const encoded = proto.Message.encode(message).finish() + const decoded = proto.Message.decode(encoded) + // Check both direct and viewOnceMessage-wrapped carousel + const decodedInteractive = decoded.interactiveMessage || decoded.viewOnceMessage?.message?.interactiveMessage + const hasCarousel = !!decodedInteractive?.carouselMessage + const cardsCount = decodedInteractive?.carouselMessage?.cards?.length || 0 + const card0 = decodedInteractive?.carouselMessage?.cards?.[0] + const card0Header = card0?.header + const card0HasImage = !!card0Header?.imageMessage + const card0HasThumb = !!(card0Header?.imageMessage as any)?.jpegThumbnail + const card0HasNativeFlow = !!card0?.nativeFlowMessage + const card0ButtonCount = card0?.nativeFlowMessage?.buttons?.length || 0 + logger.info( + { + msgId, + encodedSize: encoded.length, + hasViewOnceMessage: !!decoded.viewOnceMessage, + hasInteractiveMessage: !!decodedInteractive, + hasCarouselAfterDecode: hasCarousel, + cardsCount, + card0: { + hasHeader: !!card0Header, + title: card0Header?.title, + subtitle: card0Header?.subtitle, + hasMediaAttachment: card0Header?.hasMediaAttachment, + hasImageMessage: card0HasImage, + hasJpegThumbnail: card0HasThumb, + imgHeight: (card0Header?.imageMessage as any)?.height, + imgWidth: (card0Header?.imageMessage as any)?.width, + hasBody: !!card0?.body?.text, + hasFooter: !!card0?.footer?.text, + hasNativeFlow: card0HasNativeFlow, + buttonCount: card0ButtonCount + } + }, + '[ROUNDTRIP] Protobuf encode→decode verification' + ) + } catch (err) { + logger.error({ msgId, err: (err as Error).message }, '[ROUNDTRIP] Failed to verify protobuf encoding') + } + } + + // ======= PROTOCOL INTERCEPTOR: Dump complete stanza for debugging ======= + if (buttonType || isCarousel) { + const dumpBinaryNode = (node: any, indent = 0): string => { + if (!node) return '' + const pad = ' '.repeat(indent) + const tag = node.tag || '?' + const attrs = node.attrs ? Object.entries(node.attrs) + .filter(([_, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}="${v}"`) + .join(' ') : '' + const attrStr = attrs ? ` ${attrs}` : '' + + if (!node.content) return `${pad}<${tag}${attrStr}/>` + if (Buffer.isBuffer(node.content) || node.content instanceof Uint8Array) { + return `${pad}<${tag}${attrStr}>[binary ${node.content.length} bytes]` + } + if (Array.isArray(node.content)) { + const children = node.content.map((c: any) => dumpBinaryNode(c, indent + 1)).join('\n') + return `${pad}<${tag}${attrStr}>\n${children}\n${pad}` + } + return `${pad}<${tag}${attrStr}>${String(node.content).slice(0, 100)}` + } + + // Dump protobuf message structure (which fields are set) + const protoFields: string[] = [] + const dumpProtoFields = (obj: any, prefix = '') => { + if (!obj || typeof obj !== 'object') return + for (const [key, value] of Object.entries(obj)) { + if (value === null || value === undefined) continue + if (Buffer.isBuffer(value) || value instanceof Uint8Array) { + protoFields.push(`${prefix}${key}: [binary ${(value as any).length}b]`) + } else if (Array.isArray(value)) { + protoFields.push(`${prefix}${key}: [array ${value.length} items]`) + if (value.length > 0 && typeof value[0] === 'object') { + dumpProtoFields(value[0], `${prefix}${key}[0].`) + } + } else if (typeof value === 'object') { + protoFields.push(`${prefix}${key}:`) + dumpProtoFields(value, `${prefix} `) + } else { + const strVal = String(value).slice(0, 200) + protoFields.push(`${prefix}${key}: ${strVal}`) + } + } + } + dumpProtoFields(message) + + const stanzaDump = dumpBinaryNode(stanza) + logger.info( + { + msgId, + to: destinationJid, + buttonType: buttonType || 'carousel', + stanzaXML: '\n' + stanzaDump, + protobufMessage: '\n' + protoFields.join('\n') + }, + '[PROTOCOL-DUMP] Complete stanza and protobuf before send' + ) + } + // ======= END PROTOCOL INTERCEPTOR ======= + await sendNode(stanza) // Log with [BAILEYS] prefix diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 8af35b64..c77b9462 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -495,7 +495,7 @@ export const generateButtonMessage = async ( options: ButtonMessageOptions, mediaOptions?: MessageContentGenerationOptions ): Promise => { - const { buttons, text, footer, headerTitle, headerImage, headerVideo, messageVersion = 2 } = options + const { buttons, text, footer, headerTitle, headerImage, headerVideo } = options if (!buttons || buttons.length === 0) { throw new Boom('At least one button is required', { statusCode: 400 }) @@ -534,7 +534,7 @@ export const generateButtonMessage = async ( throw new Boom('mediaOptions required for processing header media', { statusCode: 400 }) } - // Build the interactive message + // Build the interactive message (used for CTA buttons: url, copy, call) const interactiveMessage: proto.Message.IInteractiveMessage = { body: { text: text || '' }, footer: footer ? { text: footer } : undefined, @@ -542,11 +542,11 @@ export const generateButtonMessage = async ( nativeFlowMessage: { buttons: formattedButtons, messageParamsJson: JSON.stringify({}), - messageVersion + messageVersion: 2 } } - // Wrap in viewOnceMessage for better compatibility + // Wrap in viewOnceMessage for Web/Android compatibility return { viewOnceMessage: { message: { @@ -651,6 +651,24 @@ export const generateCarouselMessage = async ( if (hasMedia && mediaOptions) { if (card.image) { const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, mediaOptions) + if (imageMessage) { + // Ensure jpegThumbnail, height, width are present (required for WhatsApp Web rendering) + if (!imageMessage.jpegThumbnail) { + mediaOptions.logger?.warn( + { cardTitle: card.title }, + '[CAROUSEL] imageMessage missing jpegThumbnail - Web may not render' + ) + } + if (!imageMessage.height || !imageMessage.width) { + mediaOptions.logger?.warn( + { cardTitle: card.title, height: imageMessage.height, width: imageMessage.width }, + '[CAROUSEL] imageMessage missing dimensions - setting defaults' + ) + // Set default dimensions if missing + if (!imageMessage.height) imageMessage.height = 500 + if (!imageMessage.width) imageMessage.width = 500 + } + } header.imageMessage = imageMessage } else if (card.video) { const { videoMessage } = await prepareWAMessageMedia({ video: card.video }, mediaOptions) @@ -693,6 +711,10 @@ export const generateCarouselMessage = async ( index: i, hasMedia: c.header?.hasMediaAttachment, mediaType: c.header?.imageMessage ? 'image' : c.header?.videoMessage ? 'video' : 'none', + hasSubtitle: c.header?.subtitle !== undefined, + hasJpegThumbnail: !!c.header?.imageMessage?.jpegThumbnail, + imgHeight: c.header?.imageMessage?.height, + imgWidth: c.header?.imageMessage?.width, hasBody: !!c.body?.text, hasFooter: !!c.footer?.text, buttonsCount: c.nativeFlowMessage?.buttons?.length || 0 @@ -703,14 +725,10 @@ export const generateCarouselMessage = async ( '[CAROUSEL] Structure summary' ) - // Pastorini sends interactiveMessage DIRECTLY at root level (NO viewOnceMessage wrapper) - // messageKeys: ['interactiveMessage'] - confirmed from Pastorini's working logs - // messageContextInfo at the message level for multi-device rendering + // Direct interactiveMessage at root - NO viewOnceMessage, NO messageContextInfo + // viewOnceMessage breaks iOS, messageContextInfo breaks iOS + // Web shows header-only fallback (carousel is mobile-only on WhatsApp) return { - messageContextInfo: { - deviceListMetadata: {}, - deviceListMetadataVersion: 2 - }, interactiveMessage } } @@ -1178,18 +1196,39 @@ export const generateWAMessageContent = async ( // Check for nativeButtons first - this is the recommended modern approach if (hasNonNullishProperty(message, 'nativeButtons')) { const nativeMsg = message as any - const buttonOptions: ButtonMessageOptions = { - buttons: nativeMsg.nativeButtons, - text: nativeMsg.text || '', - footer: nativeMsg.footer, - headerTitle: nativeMsg.headerTitle, - headerImage: nativeMsg.headerImage, - headerVideo: nativeMsg.headerVideo + const buttons = nativeMsg.nativeButtons as any[] + + // Check if ALL buttons are quick_reply (type: 'reply') + const allQuickReply = buttons.every((btn: any) => btn.type === 'reply') + + if (allQuickReply) { + // Use legacy buttonsMessage format — works on iOS + Android + Web + const buttonsMessage: proto.Message.IButtonsMessage = { + contentText: nativeMsg.text || '', + footerText: nativeMsg.footer || undefined, + headerType: proto.Message.ButtonsMessage.HeaderType.EMPTY + } + buttonsMessage.buttons = buttons.map((btn: any, idx: number) => ({ + buttonId: btn.id || `btn_${idx}`, + buttonText: { displayText: btn.text || `Button ${idx + 1}` }, + type: proto.Message.ButtonsMessage.Button.Type.RESPONSE + })) + m.buttonsMessage = buttonsMessage + options.logger?.info('Sending quick_reply as legacy buttonsMessage (iOS compatible)') + } else { + // CTA buttons (url, copy, call) — use nativeFlowMessage + const buttonOptions: ButtonMessageOptions = { + buttons, + text: nativeMsg.text || '', + footer: nativeMsg.footer, + headerTitle: nativeMsg.headerTitle, + headerImage: nativeMsg.headerImage, + headerVideo: nativeMsg.headerVideo + } + const generated = await generateButtonMessage(buttonOptions, options) + m.viewOnceMessage = generated.viewOnceMessage + options.logger?.info('Sending CTA buttons as nativeFlowMessage with viewOnceMessage wrapper') } - // Pass options for media processing if header has image/video - const generated = await generateButtonMessage(buttonOptions, options) - m.viewOnceMessage = generated.viewOnceMessage - options.logger?.info('Sending nativeFlowMessage with viewOnceMessage wrapper') } // Check for nativeCarousel else if (hasNonNullishProperty(message, 'nativeCarousel')) { @@ -1201,11 +1240,11 @@ export const generateWAMessageContent = async ( } // Pass options for media processing if cards have images/videos const generated = await generateCarouselMessage(carouselOptions, options) - // Pastorini sends interactiveMessage DIRECTLY at root (no viewOnceMessage wrapper) - // messageKeys: ['interactiveMessage'] - confirmed from Pastorini's working logs + // Wrap carousel in viewOnceMessage for Web/Desktop compatibility + // Same wrapper format as CTA buttons which render correctly on all platforms + // Direct interactiveMessage - no viewOnceMessage, no messageContextInfo m.interactiveMessage = generated.interactiveMessage - m.messageContextInfo = generated.messageContextInfo - options.logger?.info('Sending carousel as direct interactiveMessage (Pastorini approach, no viewOnce wrapper)') + options.logger?.info('Sending carousel as direct interactiveMessage') // Return plain JS object - no fromObject() to avoid corrupting nested carousel structures return m } From fcbfbafc067fc72ddbd2f05d0645217f31d83ce5 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sat, 14 Feb 2026 21:56:39 -0300 Subject: [PATCH 2/6] fix: address lint errors and review feedback Lint fixes: - Replace `any` types with proper proto interfaces (IInteractiveMessage) - Fix missing blank lines before statements (eslint padding-line-between-statements) - Fix unused variable and indentation in protocol dump - Remove pre-existing `any` casts in createParticipantNodes Review feedback (Copilot/Codex): - Gate protobuf roundtrip test and protocol dump behind debug level (avoids CPU overhead and sensitive data exposure in production) - Add empty array validation for nativeButtons (fixes [].every() edge case) - Support headerTitle in quick_reply buttonsMessage (HeaderType.TEXT) - Fix stale comment that said "wrap viewOnceMessage" when code sends direct Co-Authored-By: Claude Opus 4.6 --- src/Socket/messages-send.ts | 95 +++++++++++-------------------------- src/Utils/messages.ts | 16 +++++-- 2 files changed, 41 insertions(+), 70 deletions(-) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 669658a1..6c2a0f4f 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -552,8 +552,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { const meLid = authState.creds.me?.lid const meLidUser = meLid ? jidDecode(meLid)?.user : null - const encryptionPromises = (patchedMessages as any).map( - async ({ recipientJid: jid, message: patchedMessage }: any) => { + const encryptionPromises = (patchedMessages as { recipientJid: string; message: proto.IMessage }[]).map( + async ({ recipientJid: jid, message: patchedMessage }: { recipientJid: string; message: proto.IMessage }) => { try { if (!jid) return null @@ -647,7 +647,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { // Check if it's a carousel with nativeFlowMessage buttons in cards if (message.interactiveMessage.carouselMessage?.cards?.length) { const hasNativeFlowButtons = message.interactiveMessage.carouselMessage.cards.some( - (card: any) => card?.nativeFlowMessage?.buttons?.length + (card: proto.Message.IInteractiveMessage) => card?.nativeFlowMessage?.buttons?.length ) if (hasNativeFlowButtons) { return 'native_flow' @@ -1274,9 +1274,10 @@ export const makeMessagesSocket = (config: SocketConfig) => { let nativeFlowButtons = interactiveMsg?.nativeFlowMessage?.buttons || [] if (nativeFlowButtons.length === 0 && interactiveMsg?.carouselMessage?.cards?.length) { nativeFlowButtons = interactiveMsg.carouselMessage.cards.flatMap( - (card: any) => card?.nativeFlowMessage?.buttons || [] + (card: proto.Message.IInteractiveMessage) => card?.nativeFlowMessage?.buttons || [] ) } + const isListDetected = isListNativeFlow(message) // Log full button details including buttonParamsJson for debugging @@ -1501,42 +1502,25 @@ export const makeMessagesSocket = (config: SocketConfig) => { logger.debug({ msgId }, `sending message to ${participants.length} devices`) // ======= PROTOBUF ROUNDTRIP TEST: Verify encoding preserves carousel ======= - if (isCarousel) { + // Only runs at debug level to avoid performance overhead in production + if (isCarousel && logger.level === 'debug') { try { const encoded = proto.Message.encode(message).finish() const decoded = proto.Message.decode(encoded) - // Check both direct and viewOnceMessage-wrapped carousel const decodedInteractive = decoded.interactiveMessage || decoded.viewOnceMessage?.message?.interactiveMessage - const hasCarousel = !!decodedInteractive?.carouselMessage const cardsCount = decodedInteractive?.carouselMessage?.cards?.length || 0 const card0 = decodedInteractive?.carouselMessage?.cards?.[0] const card0Header = card0?.header - const card0HasImage = !!card0Header?.imageMessage - const card0HasThumb = !!(card0Header?.imageMessage as any)?.jpegThumbnail - const card0HasNativeFlow = !!card0?.nativeFlowMessage - const card0ButtonCount = card0?.nativeFlowMessage?.buttons?.length || 0 - logger.info( + + logger.debug( { msgId, encodedSize: encoded.length, - hasViewOnceMessage: !!decoded.viewOnceMessage, - hasInteractiveMessage: !!decodedInteractive, - hasCarouselAfterDecode: hasCarousel, + hasCarouselAfterDecode: !!decodedInteractive?.carouselMessage, cardsCount, - card0: { - hasHeader: !!card0Header, - title: card0Header?.title, - subtitle: card0Header?.subtitle, - hasMediaAttachment: card0Header?.hasMediaAttachment, - hasImageMessage: card0HasImage, - hasJpegThumbnail: card0HasThumb, - imgHeight: (card0Header?.imageMessage as any)?.height, - imgWidth: (card0Header?.imageMessage as any)?.width, - hasBody: !!card0?.body?.text, - hasFooter: !!card0?.footer?.text, - hasNativeFlow: card0HasNativeFlow, - buttonCount: card0ButtonCount - } + card0Title: card0Header?.title, + card0HasImage: !!card0Header?.imageMessage, + card0Buttons: card0?.nativeFlowMessage?.buttons?.length || 0 }, '[ROUNDTRIP] Protobuf encode→decode verification' ) @@ -1546,62 +1530,41 @@ export const makeMessagesSocket = (config: SocketConfig) => { } // ======= PROTOCOL INTERCEPTOR: Dump complete stanza for debugging ======= - if (buttonType || isCarousel) { - const dumpBinaryNode = (node: any, indent = 0): string => { + // Only runs at debug level to avoid logging sensitive content in production + if ((buttonType || isCarousel) && logger.level === 'debug') { + const dumpBinaryNode = (node: BinaryNode, indent = 0): string => { if (!node) return '' const pad = ' '.repeat(indent) const tag = node.tag || '?' - const attrs = node.attrs ? Object.entries(node.attrs) - .filter(([_, v]) => v !== undefined && v !== null) - .map(([k, v]) => `${k}="${v}"`) - .join(' ') : '' - const attrStr = attrs ? ` ${attrs}` : '' + const attrEntries = node.attrs + ? Object.entries(node.attrs).filter(([, v]) => v !== undefined && v !== null) + : [] + const attrStr = attrEntries.length > 0 + ? ' ' + attrEntries.map(([k, v]) => `${k}="${v}"`).join(' ') + : '' if (!node.content) return `${pad}<${tag}${attrStr}/>` + if (Buffer.isBuffer(node.content) || node.content instanceof Uint8Array) { return `${pad}<${tag}${attrStr}>[binary ${node.content.length} bytes]` } + if (Array.isArray(node.content)) { - const children = node.content.map((c: any) => dumpBinaryNode(c, indent + 1)).join('\n') + const children = node.content.map((c: BinaryNode) => dumpBinaryNode(c, indent + 1)).join('\n') return `${pad}<${tag}${attrStr}>\n${children}\n${pad}` } + return `${pad}<${tag}${attrStr}>${String(node.content).slice(0, 100)}` } - // Dump protobuf message structure (which fields are set) - const protoFields: string[] = [] - const dumpProtoFields = (obj: any, prefix = '') => { - if (!obj || typeof obj !== 'object') return - for (const [key, value] of Object.entries(obj)) { - if (value === null || value === undefined) continue - if (Buffer.isBuffer(value) || value instanceof Uint8Array) { - protoFields.push(`${prefix}${key}: [binary ${(value as any).length}b]`) - } else if (Array.isArray(value)) { - protoFields.push(`${prefix}${key}: [array ${value.length} items]`) - if (value.length > 0 && typeof value[0] === 'object') { - dumpProtoFields(value[0], `${prefix}${key}[0].`) - } - } else if (typeof value === 'object') { - protoFields.push(`${prefix}${key}:`) - dumpProtoFields(value, `${prefix} `) - } else { - const strVal = String(value).slice(0, 200) - protoFields.push(`${prefix}${key}: ${strVal}`) - } - } - } - dumpProtoFields(message) - - const stanzaDump = dumpBinaryNode(stanza) - logger.info( + logger.debug( { msgId, to: destinationJid, buttonType: buttonType || 'carousel', - stanzaXML: '\n' + stanzaDump, - protobufMessage: '\n' + protoFields.join('\n') + stanzaXML: '\n' + dumpBinaryNode(stanza) }, - '[PROTOCOL-DUMP] Complete stanza and protobuf before send' + '[PROTOCOL-DUMP] Stanza structure before send' ) } // ======= END PROTOCOL INTERCEPTOR ======= diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index c77b9462..e276a1a3 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -659,6 +659,7 @@ export const generateCarouselMessage = async ( '[CAROUSEL] imageMessage missing jpegThumbnail - Web may not render' ) } + if (!imageMessage.height || !imageMessage.width) { mediaOptions.logger?.warn( { cardTitle: card.title, height: imageMessage.height, width: imageMessage.width }, @@ -1198,15 +1199,23 @@ export const generateWAMessageContent = async ( const nativeMsg = message as any const buttons = nativeMsg.nativeButtons as any[] + if (!buttons || buttons.length === 0) { + throw new Boom('nativeButtons requires at least one button', { statusCode: 400 }) + } + // Check if ALL buttons are quick_reply (type: 'reply') const allQuickReply = buttons.every((btn: any) => btn.type === 'reply') if (allQuickReply) { // Use legacy buttonsMessage format — works on iOS + Android + Web + const hasHeaderTitle = !!nativeMsg.headerTitle const buttonsMessage: proto.Message.IButtonsMessage = { contentText: nativeMsg.text || '', footerText: nativeMsg.footer || undefined, - headerType: proto.Message.ButtonsMessage.HeaderType.EMPTY + headerType: hasHeaderTitle + ? proto.Message.ButtonsMessage.HeaderType.TEXT + : proto.Message.ButtonsMessage.HeaderType.EMPTY, + ...(hasHeaderTitle ? { text: nativeMsg.headerTitle } : {}) } buttonsMessage.buttons = buttons.map((btn: any, idx: number) => ({ buttonId: btn.id || `btn_${idx}`, @@ -1240,9 +1249,8 @@ export const generateWAMessageContent = async ( } // Pass options for media processing if cards have images/videos const generated = await generateCarouselMessage(carouselOptions, options) - // Wrap carousel in viewOnceMessage for Web/Desktop compatibility - // Same wrapper format as CTA buttons which render correctly on all platforms - // Direct interactiveMessage - no viewOnceMessage, no messageContextInfo + // Send carousel as direct interactiveMessage (no viewOnceMessage wrapper) + // viewOnceMessage breaks iOS; messageContextInfo breaks iOS delivery m.interactiveMessage = generated.interactiveMessage options.logger?.info('Sending carousel as direct interactiveMessage') // Return plain JS object - no fromObject() to avoid corrupting nested carousel structures From 02e32b49062ead2694955002fd91cdedcf4c11c0 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sat, 14 Feb 2026 22:01:10 -0300 Subject: [PATCH 3/6] fix: resolve remaining lint errors (max-depth, prettier, blank line) - Flatten image validation nesting to max 4 levels (eslint max-depth) - Collapse multi-line ternary to single line (prettier) - Add missing blank line before statement Co-Authored-By: Claude Opus 4.6 --- src/Socket/messages-send.ts | 8 ++------ src/Utils/messages.ts | 34 ++++++++++++++++------------------ 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 6c2a0f4f..b3fcf6ee 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1536,12 +1536,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { if (!node) return '' const pad = ' '.repeat(indent) const tag = node.tag || '?' - const attrEntries = node.attrs - ? Object.entries(node.attrs).filter(([, v]) => v !== undefined && v !== null) - : [] - const attrStr = attrEntries.length > 0 - ? ' ' + attrEntries.map(([k, v]) => `${k}="${v}"`).join(' ') - : '' + const attrEntries = node.attrs ? Object.entries(node.attrs).filter(([, v]) => v != null) : [] + const attrStr = attrEntries.length > 0 ? ' ' + attrEntries.map(([k, v]) => `${k}="${v}"`).join(' ') : '' if (!node.content) return `${pad}<${tag}${attrStr}/>` diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index e276a1a3..8502d685 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -651,25 +651,23 @@ export const generateCarouselMessage = async ( if (hasMedia && mediaOptions) { if (card.image) { const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, mediaOptions) - if (imageMessage) { - // Ensure jpegThumbnail, height, width are present (required for WhatsApp Web rendering) - if (!imageMessage.jpegThumbnail) { - mediaOptions.logger?.warn( - { cardTitle: card.title }, - '[CAROUSEL] imageMessage missing jpegThumbnail - Web may not render' - ) - } - - if (!imageMessage.height || !imageMessage.width) { - mediaOptions.logger?.warn( - { cardTitle: card.title, height: imageMessage.height, width: imageMessage.width }, - '[CAROUSEL] imageMessage missing dimensions - setting defaults' - ) - // Set default dimensions if missing - if (!imageMessage.height) imageMessage.height = 500 - if (!imageMessage.width) imageMessage.width = 500 - } + // Validate image fields needed for WhatsApp rendering + if (imageMessage && !imageMessage.jpegThumbnail) { + mediaOptions.logger?.warn( + { cardTitle: card.title }, + '[CAROUSEL] imageMessage missing jpegThumbnail - Web may not render' + ) } + + if (imageMessage && (!imageMessage.height || !imageMessage.width)) { + mediaOptions.logger?.warn( + { cardTitle: card.title, height: imageMessage.height, width: imageMessage.width }, + '[CAROUSEL] imageMessage missing dimensions - setting defaults' + ) + if (!imageMessage.height) imageMessage.height = 500 + if (!imageMessage.width) imageMessage.width = 500 + } + header.imageMessage = imageMessage } else if (card.video) { const { videoMessage } = await prepareWAMessageMedia({ video: card.video }, mediaOptions) From dffe7cbbd28e865ee3b2d5bcfe035e8d0f6d4a60 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sat, 14 Feb 2026 22:04:48 -0300 Subject: [PATCH 4/6] fix: use strict equality !== instead of != (eqeqeq) Co-Authored-By: Claude Opus 4.6 --- src/Socket/messages-send.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index b3fcf6ee..a94edb36 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1536,7 +1536,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { if (!node) return '' const pad = ' '.repeat(indent) const tag = node.tag || '?' - const attrEntries = node.attrs ? Object.entries(node.attrs).filter(([, v]) => v != null) : [] + const attrEntries = node.attrs ? Object.entries(node.attrs).filter(([, v]) => v !== undefined && v !== null) : [] const attrStr = attrEntries.length > 0 ? ' ' + attrEntries.map(([k, v]) => `${k}="${v}"`).join(' ') : '' if (!node.content) return `${pad}<${tag}${attrStr}/>` From be15192f63a49fcea634ab8596a41f5bbc902ab2 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sat, 14 Feb 2026 22:09:01 -0300 Subject: [PATCH 5/6] fix: prettier formatting on long line (line 1539) Co-Authored-By: Claude Opus 4.6 --- src/Socket/messages-send.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index a94edb36..18bdcf67 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1536,8 +1536,14 @@ export const makeMessagesSocket = (config: SocketConfig) => { if (!node) return '' const pad = ' '.repeat(indent) const tag = node.tag || '?' - const attrEntries = node.attrs ? Object.entries(node.attrs).filter(([, v]) => v !== undefined && v !== null) : [] - const attrStr = attrEntries.length > 0 ? ' ' + attrEntries.map(([k, v]) => `${k}="${v}"`).join(' ') : '' + const attrEntries = node.attrs + ? Object.entries(node.attrs).filter( + ([, v]) => v !== undefined && v !== null + ) + : [] + const attrStr = attrEntries.length > 0 + ? ' ' + attrEntries.map(([k, v]) => `${k}="${v}"`).join(' ') + : '' if (!node.content) return `${pad}<${tag}${attrStr}/>` From ad2d859743fb3cd4068338b6506433a64b418ba8 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sat, 14 Feb 2026 22:10:53 -0300 Subject: [PATCH 6/6] fix: prettier formatting - extract filter callback to avoid line breaks Co-Authored-By: Claude Opus 4.6 --- src/Socket/messages-send.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 18bdcf67..66514053 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1536,14 +1536,9 @@ export const makeMessagesSocket = (config: SocketConfig) => { if (!node) return '' const pad = ' '.repeat(indent) const tag = node.tag || '?' - const attrEntries = node.attrs - ? Object.entries(node.attrs).filter( - ([, v]) => v !== undefined && v !== null - ) - : [] - const attrStr = attrEntries.length > 0 - ? ' ' + attrEntries.map(([k, v]) => `${k}="${v}"`).join(' ') - : '' + const filteredAttrs = ([, v]: [string, unknown]) => v !== undefined && v !== null + const attrEntries = node.attrs ? Object.entries(node.attrs).filter(filteredAttrs) : [] + const attrStr = attrEntries.length > 0 ? ' ' + attrEntries.map(([k, v]) => `${k}="${v}"`).join(' ') : '' if (!node.content) return `${pad}<${tag}${attrStr}/>`