From 2ccdd0df01a0801b26ce226ecdf9d95c42336555 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sun, 25 Jan 2026 23:57:07 -0300 Subject: [PATCH] fix(buttons): address all PR review comments Fixes all 9 issues identified in PR #49 review: ## 1. Input Validation (formatNativeFlowButton) - Added validateNonEmptyString helper function - Validates required fields: text, url, copyText, id, phoneNumber - Throws Boom error with descriptive message for empty/whitespace values ## 2-3. Async Media Processing (generateButtonMessage) - Function is now async, returns Promise - Accepts optional MessageContentGenerationOptions parameter - Calls prepareWAMessageMedia() for headerImage/headerVideo - Throws error if media provided without mediaOptions ## 4-7. Async Media Processing (generateCarouselMessage) - Function is now async, returns Promise - Uses Promise.all to process all card media in parallel - Properly converts WAMediaUpload to IImageMessage/IVideoMessage ## 5. Mutual Exclusivity Validation - generateButtonMessage: Throws if both headerImage AND headerVideo provided - generateCarouselMessage: Throws if card has both image AND video ## 6. Empty Button Array Validation - Each carousel card must have at least one button - Throws descriptive error with card index ## 8. Updated Async Calls - generateWAMessageContent now awaits generateButtonMessage - generateWAMessageContent now awaits generateCarouselMessage - Both pass MessageContentGenerationOptions for media processing ## 9. Type Support - Functions accept MessageContentGenerationOptions as optional param - Enables access to upload, mediaCache, logger options Co-Authored-By: Claude Opus 4.5 --- src/Utils/messages.ts | 111 +++++++++++++++++++++++++++++++++++------- 1 file changed, 93 insertions(+), 18 deletions(-) diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 1102f848..d8b64a34 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -394,12 +394,26 @@ export const hasNonNullishProperty = ( // ========== Native Flow Button Utilities ========== +/** + * Validates that a string is not empty or whitespace-only + */ +const validateNonEmptyString = (value: string | undefined, fieldName: string): void => { + if (!value || value.trim().length === 0) { + throw new Boom(`Button ${fieldName} is required and cannot be empty`, { statusCode: 400 }) + } +} + /** * Converts a NativeButton to the WhatsApp Native Flow format + * Includes validation for required fields */ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton => { + // Validate common field + validateNonEmptyString(button.text, 'text') + switch (button.type) { case 'url': + validateNonEmptyString(button.url, 'url') return { name: 'cta_url', buttonParamsJson: JSON.stringify({ @@ -409,6 +423,7 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton = }) } case 'copy': + validateNonEmptyString(button.copyText, 'copyText') return { name: 'cta_copy', buttonParamsJson: JSON.stringify({ @@ -417,6 +432,7 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton = }) } case 'reply': + validateNonEmptyString(button.id, 'id') return { name: 'quick_reply', buttonParamsJson: JSON.stringify({ @@ -425,6 +441,7 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton = }) } case 'call': + validateNonEmptyString(button.phoneNumber, 'phoneNumber') return { name: 'cta_call', buttonParamsJson: JSON.stringify({ @@ -443,7 +460,7 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton = * * @example * ```typescript - * const msg = generateButtonMessage({ + * const msg = await generateButtonMessage({ * buttons: [ * { type: 'url', text: 'Visit Site', url: 'https://example.com' }, * { type: 'copy', text: 'Copy Code', copyText: 'ABC123' }, @@ -451,11 +468,14 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton = * ], * text: 'Choose an option:', * footer: 'Powered by InfiniteAPI' - * }) + * }, options) * await sock.sendMessage(jid, msg) * ``` */ -export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageContent => { +export const generateButtonMessage = async ( + options: ButtonMessageOptions, + mediaOptions?: MessageContentGenerationOptions +): Promise => { const { buttons, text, footer, headerTitle, headerImage, headerVideo, messageVersion = 2 } = options if (!buttons || buttons.length === 0) { @@ -466,6 +486,11 @@ export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageC throw new Boom('Maximum 3 buttons allowed', { statusCode: 400 }) } + // Validate mutual exclusivity of media types + if (headerImage && headerVideo) { + throw new Boom('Cannot have both headerImage and headerVideo. Choose one.', { statusCode: 400 }) + } + // Format buttons to Native Flow format const formattedButtons = buttons.map(formatNativeFlowButton) @@ -477,6 +502,19 @@ export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageC hasMediaAttachment: hasMedia } + // Process media if present + if (hasMedia && mediaOptions) { + if (headerImage) { + const { imageMessage } = await prepareWAMessageMedia({ image: headerImage }, mediaOptions) + header.imageMessage = imageMessage + } else if (headerVideo) { + const { videoMessage } = await prepareWAMessageMedia({ video: headerVideo }, mediaOptions) + header.videoMessage = videoMessage + } + } else if (hasMedia && !mediaOptions) { + throw new Boom('mediaOptions required for processing header media', { statusCode: 400 }) + } + // Build the interactive message const interactiveMessage: proto.Message.IInteractiveMessage = { body: { text: text || '' }, @@ -509,7 +547,7 @@ export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageC * * @example * ```typescript - * const msg = generateCarouselMessage({ + * const msg = await generateCarouselMessage({ * cards: [ * { * title: 'Product 1', @@ -530,11 +568,14 @@ export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageC * ], * text: 'Check out our products!', * footer: 'Swipe to see more' - * }) + * }, options) * await sock.sendMessage(jid, msg) * ``` */ -export const generateCarouselMessage = (options: CarouselMessageOptions): WAMessageContent => { +export const generateCarouselMessage = async ( + options: CarouselMessageOptions, + mediaOptions?: MessageContentGenerationOptions +): Promise => { const { cards, text, footer } = options if (!cards || cards.length < 2) { @@ -545,18 +586,50 @@ export const generateCarouselMessage = (options: CarouselMessageOptions): WAMess throw new Boom('Maximum 10 cards allowed in carousel', { statusCode: 400 }) } - // Map cards to the carousel format - const carouselCards = cards.map((card) => { + // Validate cards + for (let i = 0; i < cards.length; i++) { + const card = cards[i]! + + // Validate mutual exclusivity of media types + if (card.image && card.video) { + throw new Boom(`Card ${i}: Cannot have both image and video. Choose one.`, { statusCode: 400 }) + } + + // Validate buttons are not empty + if (!card.buttons || card.buttons.length === 0) { + throw new Boom(`Card ${i}: At least one button is required per card`, { statusCode: 400 }) + } + } + + // Check if any card has media + const hasAnyMedia = cards.some(card => card.image || card.video) + if (hasAnyMedia && !mediaOptions) { + throw new Boom('mediaOptions required for processing card media', { statusCode: 400 }) + } + + // Map cards to the carousel format (processing media) + const carouselCards = await Promise.all(cards.map(async (card) => { const hasMedia = !!(card.image || card.video) + const header: any = { + title: card.title || '', + subtitle: '', + hasMediaAttachment: hasMedia + } + + // Process media if present + if (hasMedia && mediaOptions) { + if (card.image) { + const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, mediaOptions) + header.imageMessage = imageMessage + } else if (card.video) { + const { videoMessage } = await prepareWAMessageMedia({ video: card.video }, mediaOptions) + header.videoMessage = videoMessage + } + } + return { - header: { - title: card.title || '', - subtitle: '', - hasMediaAttachment: hasMedia, - ...(card.image ? { imageMessage: card.image } : {}), - ...(card.video ? { videoMessage: card.video } : {}) - }, + header, body: { text: card.body || '' }, footer: card.footer ? { text: card.footer } : undefined, nativeFlowMessage: { @@ -564,7 +637,7 @@ export const generateCarouselMessage = (options: CarouselMessageOptions): WAMess messageParamsJson: '' } } - }) + })) // Build the interactive message with carousel const interactiveMessage: proto.Message.IInteractiveMessage = { @@ -763,7 +836,8 @@ export const generateWAMessageContent = async ( headerImage: nativeMsg.headerImage, headerVideo: nativeMsg.headerVideo } - const generated = generateButtonMessage(buttonOptions) + // 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') } @@ -775,7 +849,8 @@ export const generateWAMessageContent = async ( text: carouselMsg.text, footer: carouselMsg.footer } - const generated = generateCarouselMessage(carouselOptions) + // Pass options for media processing if cards have images/videos + const generated = await generateCarouselMessage(carouselOptions, options) m.viewOnceMessage = generated.viewOnceMessage options.logger?.info('Sending carouselMessage with viewOnceMessage wrapper') }