fix: carousel rendering on iOS and quick_reply buttons on all platforms

fix: carousel rendering on iOS and quick_reply buttons on all platforms
This commit is contained in:
Renato Alcara
2026-02-14 22:12:29 -03:00
committed by GitHub
2 changed files with 162 additions and 43 deletions
+91 -17
View File
@@ -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 <list> 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<string, string> = {
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 (<bot biz_bot="1"/>) 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]</${tag}>`
}
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}</${tag}>`
}
return `${pad}<${tag}${attrStr}>${String(node.content).slice(0, 100)}</${tag}>`
}
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
+71 -26
View File
@@ -495,7 +495,7 @@ export const generateButtonMessage = async (
options: ButtonMessageOptions,
mediaOptions?: MessageContentGenerationOptions
): Promise<WAMessageContent> => {
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
}