From cc1443cb44d21d0862cd56c1aaab15f0b963d66f Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sat, 14 Feb 2026 21:29:06 -0300 Subject: [PATCH] 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 }