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 <noreply@anthropic.com>
This commit is contained in:
+128
-14
@@ -1251,6 +1251,11 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
const isCatalog = isCatalogMessage(message)
|
const isCatalog = isCatalogMessage(message)
|
||||||
const isCarousel = isCarouselMessage(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
|
// EXPERIMENTAL: Try biz node injection for ALL interactive messages including catalog
|
||||||
// Previously we skipped catalog messages but they weren't rendering properly
|
// Previously we skipped catalog messages but they weren't rendering properly
|
||||||
if (buttonType && enableInteractiveMessages) {
|
if (buttonType && enableInteractiveMessages) {
|
||||||
@@ -1265,7 +1270,13 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
message.listMessage ||
|
message.listMessage ||
|
||||||
message.viewOnceMessage?.message?.listMessage ||
|
message.viewOnceMessage?.message?.listMessage ||
|
||||||
message.viewOnceMessageV2?.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)
|
const isListDetected = isListNativeFlow(message)
|
||||||
|
|
||||||
// Log full button details including buttonParamsJson for debugging
|
// Log full button details including buttonParamsJson for debugging
|
||||||
@@ -1307,7 +1318,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
// For listMessage (legacy format), use direct <list> tag
|
// For listMessage (legacy format), use direct <list> tag
|
||||||
// This matches pastorini's working implementation
|
// This matches pastorini's working implementation
|
||||||
if (buttonType === 'list') {
|
if (buttonType === 'list') {
|
||||||
;(stanza.content as BinaryNode[]).push({
|
deferredNodes.push({
|
||||||
tag: 'biz',
|
tag: 'biz',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
content: [
|
content: [
|
||||||
@@ -1325,9 +1336,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
'[EXPERIMENTAL] Injected biz node for listMessage (legacy format)'
|
'[EXPERIMENTAL] Injected biz node for listMessage (legacy format)'
|
||||||
)
|
)
|
||||||
} else {
|
} 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> = {
|
const SPECIAL_FLOW_NAMES: Record<string, string> = {
|
||||||
review_and_pay: 'payment_info',
|
review_and_pay: 'payment_info',
|
||||||
payment_info: 'payment_info',
|
payment_info: 'payment_info',
|
||||||
@@ -1342,10 +1350,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
'[EXPERIMENTAL] Determined native_flow name based on button types'
|
'[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'
|
const interactiveType = 'native_flow'
|
||||||
;(stanza.content as BinaryNode[]).push({
|
deferredNodes.push({
|
||||||
tag: 'biz',
|
tag: 'biz',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
content: [
|
content: [
|
||||||
@@ -1369,11 +1375,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bot node (<bot biz_bot="1"/>) MUST NOT be injected for native_flow buttons
|
// Bot node — skip for native_flow buttons (breaks Web rendering)
|
||||||
// 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 ❌
|
|
||||||
const isNativeFlowButtons = buttonType === 'native_flow'
|
const isNativeFlowButtons = buttonType === 'native_flow'
|
||||||
|
|
||||||
const isPrivateUserChat =
|
const isPrivateUserChat =
|
||||||
@@ -1381,7 +1383,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
!isJidBot(destinationJid)
|
!isJidBot(destinationJid)
|
||||||
|
|
||||||
if (isPrivateUserChat && !isCarousel && !isCatalog && buttonType !== 'list' && !isNativeFlowButtons) {
|
if (isPrivateUserChat && !isCarousel && !isCatalog && buttonType !== 'list' && !isNativeFlowButtons) {
|
||||||
;(stanza.content as BinaryNode[]).push({
|
deferredNodes.push({
|
||||||
tag: 'bot',
|
tag: 'bot',
|
||||||
attrs: { biz_bot: '1' }
|
attrs: { biz_bot: '1' }
|
||||||
})
|
})
|
||||||
@@ -1490,8 +1492,120 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
;(stanza.content as BinaryNode[]).push(...additionalNodes)
|
;(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`)
|
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]</${tag}>`
|
||||||
|
}
|
||||||
|
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}</${tag}>`
|
||||||
|
}
|
||||||
|
return `${pad}<${tag}${attrStr}>${String(node.content).slice(0, 100)}</${tag}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
await sendNode(stanza)
|
||||||
|
|
||||||
// Log with [BAILEYS] prefix
|
// Log with [BAILEYS] prefix
|
||||||
|
|||||||
+65
-26
@@ -495,7 +495,7 @@ export const generateButtonMessage = async (
|
|||||||
options: ButtonMessageOptions,
|
options: ButtonMessageOptions,
|
||||||
mediaOptions?: MessageContentGenerationOptions
|
mediaOptions?: MessageContentGenerationOptions
|
||||||
): Promise<WAMessageContent> => {
|
): 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) {
|
if (!buttons || buttons.length === 0) {
|
||||||
throw new Boom('At least one button is required', { statusCode: 400 })
|
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 })
|
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 = {
|
const interactiveMessage: proto.Message.IInteractiveMessage = {
|
||||||
body: { text: text || '' },
|
body: { text: text || '' },
|
||||||
footer: footer ? { text: footer } : undefined,
|
footer: footer ? { text: footer } : undefined,
|
||||||
@@ -542,11 +542,11 @@ export const generateButtonMessage = async (
|
|||||||
nativeFlowMessage: {
|
nativeFlowMessage: {
|
||||||
buttons: formattedButtons,
|
buttons: formattedButtons,
|
||||||
messageParamsJson: JSON.stringify({}),
|
messageParamsJson: JSON.stringify({}),
|
||||||
messageVersion
|
messageVersion: 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap in viewOnceMessage for better compatibility
|
// Wrap in viewOnceMessage for Web/Android compatibility
|
||||||
return {
|
return {
|
||||||
viewOnceMessage: {
|
viewOnceMessage: {
|
||||||
message: {
|
message: {
|
||||||
@@ -651,6 +651,24 @@ export const generateCarouselMessage = async (
|
|||||||
if (hasMedia && mediaOptions) {
|
if (hasMedia && mediaOptions) {
|
||||||
if (card.image) {
|
if (card.image) {
|
||||||
const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, mediaOptions)
|
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
|
header.imageMessage = imageMessage
|
||||||
} else if (card.video) {
|
} else if (card.video) {
|
||||||
const { videoMessage } = await prepareWAMessageMedia({ video: card.video }, mediaOptions)
|
const { videoMessage } = await prepareWAMessageMedia({ video: card.video }, mediaOptions)
|
||||||
@@ -693,6 +711,10 @@ export const generateCarouselMessage = async (
|
|||||||
index: i,
|
index: i,
|
||||||
hasMedia: c.header?.hasMediaAttachment,
|
hasMedia: c.header?.hasMediaAttachment,
|
||||||
mediaType: c.header?.imageMessage ? 'image' : c.header?.videoMessage ? 'video' : 'none',
|
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,
|
hasBody: !!c.body?.text,
|
||||||
hasFooter: !!c.footer?.text,
|
hasFooter: !!c.footer?.text,
|
||||||
buttonsCount: c.nativeFlowMessage?.buttons?.length || 0
|
buttonsCount: c.nativeFlowMessage?.buttons?.length || 0
|
||||||
@@ -703,14 +725,10 @@ export const generateCarouselMessage = async (
|
|||||||
'[CAROUSEL] Structure summary'
|
'[CAROUSEL] Structure summary'
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pastorini sends interactiveMessage DIRECTLY at root level (NO viewOnceMessage wrapper)
|
// Direct interactiveMessage at root - NO viewOnceMessage, NO messageContextInfo
|
||||||
// messageKeys: ['interactiveMessage'] - confirmed from Pastorini's working logs
|
// viewOnceMessage breaks iOS, messageContextInfo breaks iOS
|
||||||
// messageContextInfo at the message level for multi-device rendering
|
// Web shows header-only fallback (carousel is mobile-only on WhatsApp)
|
||||||
return {
|
return {
|
||||||
messageContextInfo: {
|
|
||||||
deviceListMetadata: {},
|
|
||||||
deviceListMetadataVersion: 2
|
|
||||||
},
|
|
||||||
interactiveMessage
|
interactiveMessage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1178,18 +1196,39 @@ export const generateWAMessageContent = async (
|
|||||||
// Check for nativeButtons first - this is the recommended modern approach
|
// Check for nativeButtons first - this is the recommended modern approach
|
||||||
if (hasNonNullishProperty(message, 'nativeButtons')) {
|
if (hasNonNullishProperty(message, 'nativeButtons')) {
|
||||||
const nativeMsg = message as any
|
const nativeMsg = message as any
|
||||||
const buttonOptions: ButtonMessageOptions = {
|
const buttons = nativeMsg.nativeButtons as any[]
|
||||||
buttons: nativeMsg.nativeButtons,
|
|
||||||
text: nativeMsg.text || '',
|
// Check if ALL buttons are quick_reply (type: 'reply')
|
||||||
footer: nativeMsg.footer,
|
const allQuickReply = buttons.every((btn: any) => btn.type === 'reply')
|
||||||
headerTitle: nativeMsg.headerTitle,
|
|
||||||
headerImage: nativeMsg.headerImage,
|
if (allQuickReply) {
|
||||||
headerVideo: nativeMsg.headerVideo
|
// 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
|
// Check for nativeCarousel
|
||||||
else if (hasNonNullishProperty(message, 'nativeCarousel')) {
|
else if (hasNonNullishProperty(message, 'nativeCarousel')) {
|
||||||
@@ -1201,11 +1240,11 @@ export const generateWAMessageContent = async (
|
|||||||
}
|
}
|
||||||
// Pass options for media processing if cards have images/videos
|
// Pass options for media processing if cards have images/videos
|
||||||
const generated = await generateCarouselMessage(carouselOptions, options)
|
const generated = await generateCarouselMessage(carouselOptions, options)
|
||||||
// Pastorini sends interactiveMessage DIRECTLY at root (no viewOnceMessage wrapper)
|
// Wrap carousel in viewOnceMessage for Web/Desktop compatibility
|
||||||
// messageKeys: ['interactiveMessage'] - confirmed from Pastorini's working logs
|
// Same wrapper format as CTA buttons which render correctly on all platforms
|
||||||
|
// Direct interactiveMessage - no viewOnceMessage, no messageContextInfo
|
||||||
m.interactiveMessage = generated.interactiveMessage
|
m.interactiveMessage = generated.interactiveMessage
|
||||||
m.messageContextInfo = generated.messageContextInfo
|
options.logger?.info('Sending carousel as direct interactiveMessage')
|
||||||
options.logger?.info('Sending carousel as direct interactiveMessage (Pastorini approach, no viewOnce wrapper)')
|
|
||||||
// Return plain JS object - no fromObject() to avoid corrupting nested carousel structures
|
// Return plain JS object - no fromObject() to avoid corrupting nested carousel structures
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user