diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 29ddf207..66514053 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' @@ -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,14 @@ 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: proto.Message.IInteractiveMessage) => card?.nativeFlowMessage?.buttons || [] + ) + } + const isListDetected = isListNativeFlow(message) // Log full button details including buttonParamsJson for debugging @@ -1307,7 +1319,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 +1337,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 +1351,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 +1376,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 +1384,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 +1493,79 @@ 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 ======= + // 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) + const decodedInteractive = decoded.interactiveMessage || decoded.viewOnceMessage?.message?.interactiveMessage + const cardsCount = decodedInteractive?.carouselMessage?.cards?.length || 0 + const card0 = decodedInteractive?.carouselMessage?.cards?.[0] + const card0Header = card0?.header + + logger.debug( + { + msgId, + encodedSize: encoded.length, + hasCarouselAfterDecode: !!decodedInteractive?.carouselMessage, + cardsCount, + card0Title: card0Header?.title, + card0HasImage: !!card0Header?.imageMessage, + card0Buttons: card0?.nativeFlowMessage?.buttons?.length || 0 + }, + '[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 ======= + // 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 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}/>` + + 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: 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)}` + } + + logger.debug( + { + msgId, + to: destinationJid, + buttonType: buttonType || 'carousel', + stanzaXML: '\n' + dumpBinaryNode(stanza) + }, + '[PROTOCOL-DUMP] Stanza structure 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..8502d685 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,23 @@ export const generateCarouselMessage = async ( if (hasMedia && mediaOptions) { if (card.image) { const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, mediaOptions) + // 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) @@ -693,6 +710,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 +724,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 +1195,47 @@ 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[] + + 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: 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}`, + 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 +1247,10 @@ 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 + // Send carousel as direct interactiveMessage (no viewOnceMessage wrapper) + // viewOnceMessage breaks iOS; messageContextInfo breaks iOS delivery 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 }