|
|
|
@@ -41,9 +41,11 @@ import type { ILogger } from './logger'
|
|
|
|
|
import {
|
|
|
|
|
downloadContentFromMessage,
|
|
|
|
|
encryptedStream,
|
|
|
|
|
extractImageThumb,
|
|
|
|
|
generateThumbnail,
|
|
|
|
|
getAudioDuration,
|
|
|
|
|
getAudioWaveform,
|
|
|
|
|
getStream,
|
|
|
|
|
getRawMediaUploadData,
|
|
|
|
|
type MediaDownloadOptions
|
|
|
|
|
} from './messages-media'
|
|
|
|
@@ -633,6 +635,59 @@ export const generateCarouselMessage = async (
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const recoverCarouselImageMetadata = async (
|
|
|
|
|
imageMessage: proto.Message.IImageMessage | null | undefined,
|
|
|
|
|
cardImage: WAMediaUpload,
|
|
|
|
|
cardTitle: string | undefined,
|
|
|
|
|
uploadOptions: MessageContentGenerationOptions
|
|
|
|
|
) => {
|
|
|
|
|
if (!imageMessage) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const missingThumbnail = !imageMessage.jpegThumbnail
|
|
|
|
|
const missingWidth = !imageMessage.width
|
|
|
|
|
const missingHeight = !imageMessage.height
|
|
|
|
|
if (!missingThumbnail && !missingWidth && !missingHeight) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const { stream } = await getStream(cardImage, uploadOptions.options)
|
|
|
|
|
const { buffer, original } = await extractImageThumb(stream)
|
|
|
|
|
|
|
|
|
|
if (missingThumbnail) {
|
|
|
|
|
imageMessage.jpegThumbnail = buffer.toString('base64')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (missingWidth && original.width) {
|
|
|
|
|
imageMessage.width = original.width
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (missingHeight && original.height) {
|
|
|
|
|
imageMessage.height = original.height
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uploadOptions.logger?.info(
|
|
|
|
|
{
|
|
|
|
|
cardTitle,
|
|
|
|
|
recoveredThumbnail: !!imageMessage.jpegThumbnail,
|
|
|
|
|
width: imageMessage.width,
|
|
|
|
|
height: imageMessage.height
|
|
|
|
|
},
|
|
|
|
|
'[CAROUSEL] Recovered image metadata from source media'
|
|
|
|
|
)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
uploadOptions.logger?.warn(
|
|
|
|
|
{
|
|
|
|
|
cardTitle,
|
|
|
|
|
trace: error instanceof Error ? error.stack : String(error)
|
|
|
|
|
},
|
|
|
|
|
'[CAROUSEL] Failed source-media thumbnail fallback'
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Map cards to the carousel format (processing media)
|
|
|
|
|
const carouselCards = await Promise.all(
|
|
|
|
|
cards.map(async card => {
|
|
|
|
@@ -648,6 +703,11 @@ export const generateCarouselMessage = async (
|
|
|
|
|
if (hasMedia && mediaOptions) {
|
|
|
|
|
if (card.image) {
|
|
|
|
|
const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, mediaOptions)
|
|
|
|
|
|
|
|
|
|
// Mirror the working Pastorini-style result: every carousel image card should
|
|
|
|
|
// carry a jpegThumbnail and dimensions before it reaches the Web live renderer.
|
|
|
|
|
await recoverCarouselImageMetadata(imageMessage, card.image, card.title, mediaOptions)
|
|
|
|
|
|
|
|
|
|
// Validate image fields needed for WhatsApp rendering
|
|
|
|
|
if (imageMessage && !imageMessage.jpegThumbnail) {
|
|
|
|
|
mediaOptions.logger?.warn(
|
|
|
|
@@ -1228,57 +1288,20 @@ export const generateWAMessageContent = async (
|
|
|
|
|
options.logger?.info('Sending CTA buttons as nativeFlowMessage with viewOnceMessage wrapper')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Check for nativeCarousel — inline handler (validated on Android, iOS, Web)
|
|
|
|
|
// Direct interactiveMessage at root (field 45), NO viewOnceMessage wrapper,
|
|
|
|
|
// NO messageContextInfo, NO biz/bot stanza nodes needed
|
|
|
|
|
// Check for nativeCarousel
|
|
|
|
|
else if (hasNonNullishProperty(message, 'nativeCarousel')) {
|
|
|
|
|
const carouselMsg = message as any
|
|
|
|
|
const cards = carouselMsg.nativeCarousel.cards || []
|
|
|
|
|
const title = carouselMsg.nativeCarousel.title || carouselMsg.title
|
|
|
|
|
const text = carouselMsg.text
|
|
|
|
|
const footer = carouselMsg.footer
|
|
|
|
|
|
|
|
|
|
const carouselCards = await Promise.all(
|
|
|
|
|
cards.map(async (card: any) => {
|
|
|
|
|
const hasMedia = !!(card.image || card.video)
|
|
|
|
|
const header: any = {
|
|
|
|
|
title: card.title || '',
|
|
|
|
|
subtitle: card.footer || '',
|
|
|
|
|
hasMediaAttachment: hasMedia
|
|
|
|
|
}
|
|
|
|
|
if (hasMedia && card.image) {
|
|
|
|
|
const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, options)
|
|
|
|
|
if (imageMessage && !imageMessage.height) imageMessage.height = 500
|
|
|
|
|
if (imageMessage && !imageMessage.width) imageMessage.width = 500
|
|
|
|
|
header.imageMessage = imageMessage
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
header,
|
|
|
|
|
body: { text: card.body || '' },
|
|
|
|
|
footer: card.footer ? { text: card.footer } : undefined,
|
|
|
|
|
nativeFlowMessage: {
|
|
|
|
|
buttons: (card.buttons || []).map((btn: any) => {
|
|
|
|
|
switch (btn.type) {
|
|
|
|
|
case 'url': return { name: 'cta_url', buttonParamsJson: JSON.stringify({ display_text: btn.text, url: btn.url, merchant_url: btn.url }) }
|
|
|
|
|
case 'copy': return { name: 'cta_copy', buttonParamsJson: JSON.stringify({ display_text: btn.text, copy_code: btn.copyText }) }
|
|
|
|
|
case 'call': return { name: 'cta_call', buttonParamsJson: JSON.stringify({ display_text: btn.text, phone_number: btn.phoneNumber }) }
|
|
|
|
|
default: return { name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: btn.text, id: btn.id }) }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
m.interactiveMessage = {
|
|
|
|
|
header: { title: title || ' ', hasMediaAttachment: false },
|
|
|
|
|
body: { text: text || '' },
|
|
|
|
|
footer: footer ? { text: footer } : undefined,
|
|
|
|
|
carouselMessage: {
|
|
|
|
|
cards: carouselCards,
|
|
|
|
|
messageVersion: 1
|
|
|
|
|
}
|
|
|
|
|
const carouselOptions: CarouselMessageOptions = {
|
|
|
|
|
cards: carouselMsg.nativeCarousel.cards,
|
|
|
|
|
title: carouselMsg.nativeCarousel.title || carouselMsg.title,
|
|
|
|
|
text: carouselMsg.text,
|
|
|
|
|
footer: carouselMsg.footer
|
|
|
|
|
}
|
|
|
|
|
// Pass options for media processing if cards have images/videos
|
|
|
|
|
const generated = await generateCarouselMessage(carouselOptions, options)
|
|
|
|
|
// Frida capture shows interactiveMessage DIRECT (field 45) in DSM — no viewOnceMessage wrapper
|
|
|
|
|
// Testing without wrapper: biz node + quality_control already match Pastorini CDP stanza
|
|
|
|
|
m.interactiveMessage = generated.interactiveMessage
|
|
|
|
|
return m
|
|
|
|
|
}
|
|
|
|
|
// Check for nativeList
|
|
|
|
|