From 5d849740885125b561c31c5e28d80f30790b99af Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sun, 25 Jan 2026 23:24:50 -0300 Subject: [PATCH 1/3] feat(buttons): add Native Flow buttons and carousel with viewOnceMessage wrapper Implements modern WhatsApp button messages using the nativeFlowMessage format wrapped in viewOnceMessage for better iOS/Android compatibility. ## New Types (Message.ts) - `NativeButton` - Union type for all button types: - `UrlButton` - Opens a URL (cta_url) - `CopyButton` - Copies text to clipboard (cta_copy) - `QuickReplyButton` - Sends a quick reply (quick_reply) - `ButtonMessageOptions` - Options for generateButtonMessage - `CarouselCardInput` - Card definition for carousel - `CarouselMessageOptions` - Options for generateCarouselMessage ## New Functions (messages.ts) - `formatNativeFlowButton()` - Converts NativeButton to WhatsApp format - `generateButtonMessage()` - Creates button message with viewOnceMessage wrapper - `generateCarouselMessage()` - Creates carousel message with viewOnceMessage wrapper ## Usage via sendMessage ```typescript // Native Flow Buttons await sock.sendMessage(jid, { text: 'Choose an option:', nativeButtons: [ { type: 'url', text: 'Visit Site', url: 'https://example.com' }, { type: 'copy', text: 'Copy Code', copyText: 'ABC123' }, { type: 'reply', text: 'Support', id: 'btn_support' } ], footer: 'Powered by InfiniteAPI' }) // Native Carousel await sock.sendMessage(jid, { text: 'Our Products', nativeCarousel: { cards: [ { title: 'Item 1', body: 'Description', buttons: [...] }, { title: 'Item 2', body: 'Description', buttons: [...] } ] } }) ``` ## Direct Function Usage ```typescript import { generateButtonMessage, generateCarouselMessage } from '@whiskeysockets/baileys' const msg = generateButtonMessage({ buttons: [...], text: '...' }) await sock.sendMessage(jid, msg) ``` ## Changes to Existing Code - Carousel messages in generateWAMessageContent now use viewOnceMessage wrapper - Added nativeButtons and nativeCarousel checks before legacy button handling Co-Authored-By: Claude Opus 4.5 --- src/Types/Message.ts | 147 +++++++++++++++++++++++++ src/Utils/messages.ts | 244 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 387 insertions(+), 4 deletions(-) diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 48dc4294..a0906ce1 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -256,6 +256,104 @@ export type Listable = { buttonText?: string } +// ========== Native Flow Button Types ========== + +/** + * Button types supported by WhatsApp Native Flow + * - cta_url: Opens a URL + * - cta_copy: Copies text to clipboard + * - quick_reply: Sends a quick reply with ID + */ +export type NativeFlowButtonType = 'cta_url' | 'cta_copy' | 'quick_reply' + +/** + * URL button - opens a link when clicked + */ +export type UrlButton = { + type: 'url' + text: string + url: string +} + +/** + * Copy button - copies text to clipboard when clicked + */ +export type CopyButton = { + type: 'copy' + text: string + copyText: string +} + +/** + * Quick reply button - sends a reply with an ID + */ +export type QuickReplyButton = { + type: 'reply' + text: string + id: string +} + +/** + * Union type for all button types + */ +export type NativeButton = UrlButton | CopyButton | QuickReplyButton + +/** + * Formatted button for Native Flow (internal use) + */ +export type NativeFlowButton = { + name: string + buttonParamsJson: string +} + +/** + * Options for generating a button message + */ +export type ButtonMessageOptions = { + /** Array of buttons (2-3 recommended) */ + buttons: NativeButton[] + /** Main text/body of the message */ + text: string + /** Footer text (optional) */ + footer?: string + /** Header title (optional, used if no media) */ + headerTitle?: string + /** Header image (optional) */ + headerImage?: WAMediaUpload + /** Header video (optional) */ + headerVideo?: WAMediaUpload +} + +/** + * Single card in a carousel message + */ +export type CarouselCardInput = { + /** Card title in header */ + title: string + /** Card body text */ + body: string + /** Card footer text (optional) */ + footer?: string + /** Card image (optional) */ + image?: WAMediaUpload + /** Card video (optional) */ + video?: WAMediaUpload + /** Buttons for this card */ + buttons: NativeButton[] +} + +/** + * Options for generating a carousel message + */ +export type CarouselMessageOptions = { + /** Cards in the carousel (2-10 recommended) */ + cards: CarouselCardInput[] + /** Main body text */ + text?: string + /** Footer text */ + footer?: string +} + export type CarouselCard = { header: { title: string @@ -433,6 +531,55 @@ export type AnyRegularMessageContent = ( body?: string footer?: string } + | { + /** + * Native Flow Buttons - Modern button message format + * Works reliably on iOS and Android with viewOnceMessage wrapper + * + * @example + * ```typescript + * await sock.sendMessage(jid, { + * text: 'Choose an option:', + * nativeButtons: [ + * { type: 'url', text: 'Visit Site', url: 'https://example.com' }, + * { type: 'copy', text: 'Copy Code', copyText: 'ABC123' }, + * { type: 'reply', text: 'Contact Us', id: 'btn_contact' } + * ], + * footer: 'Powered by InfiniteAPI' + * }) + * ``` + */ + nativeButtons: NativeButton[] + text?: string + footer?: string + headerTitle?: string + headerImage?: WAMediaUpload + headerVideo?: WAMediaUpload + } + | { + /** + * Native Carousel Message - Multiple swipeable cards with buttons + * + * @example + * ```typescript + * await sock.sendMessage(jid, { + * text: 'Our Products', + * nativeCarousel: { + * cards: [ + * { title: 'Item 1', body: 'Description', buttons: [...] }, + * { title: 'Item 2', body: 'Description', buttons: [...] } + * ] + * }, + * footer: 'Swipe for more' + * }) + * ``` + */ + nativeCarousel: { + cards: CarouselCardInput[] + } + text?: string + footer?: string + } | { /** * Album message - send multiple images/videos grouped together diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index ae2befe3..464b5497 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -14,12 +14,16 @@ import { import type { AnyMediaMessageContent, AnyMessageContent, + ButtonMessageOptions, + CarouselMessageOptions, DownloadableMessage, MessageContentGenerationOptions, MessageGenerationOptions, MessageGenerationOptionsFromContent, MessageUserReceipt, MessageWithContextInfo, + NativeButton, + NativeFlowButton, WAMediaUpload, WAMessage, WAMessageContent, @@ -387,6 +391,200 @@ export const hasNonNullishProperty = ( ) } +// ========== Native Flow Button Utilities ========== + +/** + * Converts a NativeButton to the WhatsApp Native Flow format + */ +export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton => { + switch (button.type) { + case 'url': + return { + name: 'cta_url', + buttonParamsJson: JSON.stringify({ + display_text: button.text, + url: button.url, + merchant_url: button.url + }) + } + case 'copy': + return { + name: 'cta_copy', + buttonParamsJson: JSON.stringify({ + display_text: button.text, + copy_code: button.copyText + }) + } + case 'reply': + return { + name: 'quick_reply', + buttonParamsJson: JSON.stringify({ + display_text: button.text, + id: button.id + }) + } + default: + throw new Boom('Invalid button type', { statusCode: 400 }) + } +} + +/** + * Generates a button message using Native Flow format wrapped in viewOnceMessage + * This is the modern approach for button messages that works on iOS and Android + * + * @example + * ```typescript + * const msg = generateButtonMessage({ + * buttons: [ + * { type: 'url', text: 'Visit Site', url: 'https://example.com' }, + * { type: 'copy', text: 'Copy Code', copyText: 'ABC123' }, + * { type: 'reply', text: 'Contact Support', id: 'btn_support' } + * ], + * text: 'Choose an option:', + * footer: 'Powered by InfiniteAPI' + * }) + * await sock.sendMessage(jid, msg) + * ``` + */ +export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageContent => { + const { buttons, text, footer, headerTitle, headerImage, headerVideo } = options + + if (!buttons || buttons.length === 0) { + throw new Boom('At least one button is required', { statusCode: 400 }) + } + + if (buttons.length > 3) { + throw new Boom('Maximum 3 buttons allowed', { statusCode: 400 }) + } + + // Format buttons to Native Flow format + const formattedButtons = buttons.map(formatNativeFlowButton) + + // Determine header configuration + const hasMedia = !!(headerImage || headerVideo) + const header: proto.Message.InteractiveMessage.IHeader = { + title: hasMedia ? '' : (headerTitle || ''), + subtitle: '', + hasMediaAttachment: hasMedia + } + + // Build the interactive message + const interactiveMessage: proto.Message.IInteractiveMessage = { + body: { text: text || '' }, + footer: footer ? { text: footer } : undefined, + header, + nativeFlowMessage: { + buttons: formattedButtons, + messageParamsJson: '' + } + } + + // Wrap in viewOnceMessage for better compatibility + return { + viewOnceMessage: { + message: { + messageContextInfo: { + deviceListMetadata: {}, + deviceListMetadataVersion: 2 + }, + interactiveMessage + } + } + } +} + +/** + * Generates a carousel message with multiple cards, each with their own buttons + * Uses viewOnceMessage wrapper for better iOS/Android compatibility + * + * @example + * ```typescript + * const msg = generateCarouselMessage({ + * cards: [ + * { + * title: 'Product 1', + * body: 'Amazing product description', + * footer: '$99.00', + * buttons: [ + * { type: 'url', text: 'Buy Now', url: 'https://shop.com/item1' } + * ] + * }, + * { + * title: 'Product 2', + * body: 'Another great product', + * footer: '$149.00', + * buttons: [ + * { type: 'url', text: 'Buy Now', url: 'https://shop.com/item2' } + * ] + * } + * ], + * text: 'Check out our products!', + * footer: 'Swipe to see more' + * }) + * await sock.sendMessage(jid, msg) + * ``` + */ +export const generateCarouselMessage = (options: CarouselMessageOptions): WAMessageContent => { + const { cards, text, footer } = options + + if (!cards || cards.length < 2) { + throw new Boom('Carousel requires at least 2 cards', { statusCode: 400 }) + } + + if (cards.length > 10) { + throw new Boom('Maximum 10 cards allowed in carousel', { statusCode: 400 }) + } + + // Map cards to the carousel format + const carouselCards = cards.map((card) => { + const hasMedia = !!(card.image || card.video) + + return { + header: { + title: card.title || '', + subtitle: '', + hasMediaAttachment: hasMedia, + ...(card.image ? { imageMessage: card.image } : {}), + ...(card.video ? { videoMessage: card.video } : {}) + }, + body: { text: card.body || '' }, + footer: card.footer ? { text: card.footer } : undefined, + nativeFlowMessage: { + buttons: card.buttons.map(formatNativeFlowButton), + messageParamsJson: '' + } + } + }) + + // Build the interactive message with carousel + const interactiveMessage: proto.Message.IInteractiveMessage = { + body: { text: text || '' }, + footer: footer ? { text: footer } : undefined, + header: { + title: '', + subtitle: '', + hasMediaAttachment: false + }, + carouselMessage: { + cards: carouselCards, + messageVersion: 1 + } + } + + // Wrap in viewOnceMessage for better compatibility + return { + viewOnceMessage: { + message: { + messageContextInfo: { + deviceListMetadata: {}, + deviceListMetadataVersion: 2 + }, + interactiveMessage + } + } + } +} + function hasOptionalProperty(obj: T, key: K): obj is WithKey { return typeof obj === 'object' && obj !== null && key in obj && (obj as any)[key] !== null } @@ -397,8 +595,37 @@ export const generateWAMessageContent = async ( ) => { let m: WAMessageContent = {} + // ========== NATIVE FLOW BUTTONS (Modern approach) ========== + // 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 generated = generateButtonMessage(buttonOptions) + m.viewOnceMessage = generated.viewOnceMessage + options.logger?.info('Sending nativeFlowMessage with viewOnceMessage wrapper') + } + // Check for nativeCarousel + else if (hasNonNullishProperty(message, 'nativeCarousel')) { + const carouselMsg = message as any + const carouselOptions: CarouselMessageOptions = { + cards: carouselMsg.nativeCarousel.cards, + text: carouselMsg.text, + footer: carouselMsg.footer + } + const generated = generateCarouselMessage(carouselOptions) + m.viewOnceMessage = generated.viewOnceMessage + options.logger?.info('Sending carouselMessage with viewOnceMessage wrapper') + } // ⚠️ EXPERIMENTAL: Check for interactive messages FIRST (buttons, lists, templates) - if (hasNonNullishProperty(message, 'text') && hasNonNullishProperty(message, 'buttons')) { + // These use the older API which may not work reliably + else if (hasNonNullishProperty(message, 'text') && hasNonNullishProperty(message, 'buttons')) { // Process buttons for text messages const buttonsMessage: proto.Message.IButtonsMessage = { contentText: (message as any).text, @@ -458,7 +685,7 @@ export const generateWAMessageContent = async ( m.listMessage = listMessage options.logger?.warn('[EXPERIMENTAL] Sending listMessage - this may not work and can cause bans') } else if (hasNonNullishProperty(message, 'carousel')) { - // Process carousel/interactive messages + // Process carousel/interactive messages with viewOnceMessage wrapper const carousel = (message as any).carousel const interactiveMessage: proto.Message.IInteractiveMessage = { header: carousel.header || { title: carousel.title || 'Carousel', hasMediaAttachment: false }, @@ -475,8 +702,17 @@ export const generateWAMessageContent = async ( } } - m.interactiveMessage = interactiveMessage - options.logger?.warn('[EXPERIMENTAL] Sending carouselMessage - this may not work and can cause bans') + // Wrap in viewOnceMessage for better iOS/Android compatibility + m.viewOnceMessage = { + message: { + messageContextInfo: { + deviceListMetadata: {}, + deviceListMetadataVersion: 2 + }, + interactiveMessage + } + } + options.logger?.warn('[EXPERIMENTAL] Sending carouselMessage with viewOnceMessage wrapper') } else if (hasNonNullishProperty(message, 'album')) { // Album message validation - actual sending is handled in messages-send.ts const { medias } = message.album From e534202c6ce056881775b144f802e7a813776605 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sun, 25 Jan 2026 23:47:58 -0300 Subject: [PATCH 2/3] feat(buttons): add call button, list messages and legacy functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the Native Flow implementation with additional features: ## New Button Type - `CallButton` - `cta_call` type for initiating phone calls ```typescript { type: 'call', text: 'Call Us', phoneNumber: '+5511999999999' } ``` ## New List Message Support - `generateListMessage()` - Creates interactive list with single_select - `nativeList` type for sendMessage integration ```typescript await sock.sendMessage(jid, { text: 'Choose:', nativeList: { buttonText: 'View Options', sections: [{ title: 'Section', rows: [...] }] } }) ``` ## Legacy Functions (for backward compatibility) - `generateButtonMessageLegacy()` - Old buttonsMessage format - `generateListMessageLegacy()` - Old listMessage format ⚠️ These are deprecated and may not work on all devices ## Other Improvements - Added `merchantUrl` support for URL buttons - Added `messageVersion` parameter (default: 2) - Added `messageParamsJson` to nativeFlowMessage - Created `NativeListSection` type to avoid conflict with legacy `ListSection` Co-Authored-By: Claude Opus 4.5 --- src/Types/Message.ts | 90 ++++++++++++++++++++- src/Utils/messages.ts | 176 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 261 insertions(+), 5 deletions(-) diff --git a/src/Types/Message.ts b/src/Types/Message.ts index a0906ce1..7e726cbc 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -262,9 +262,11 @@ export type Listable = { * Button types supported by WhatsApp Native Flow * - cta_url: Opens a URL * - cta_copy: Copies text to clipboard + * - cta_call: Initiates a phone call * - quick_reply: Sends a quick reply with ID + * - single_select: Opens a list selection */ -export type NativeFlowButtonType = 'cta_url' | 'cta_copy' | 'quick_reply' +export type NativeFlowButtonType = 'cta_url' | 'cta_copy' | 'cta_call' | 'quick_reply' | 'single_select' /** * URL button - opens a link when clicked @@ -273,6 +275,8 @@ export type UrlButton = { type: 'url' text: string url: string + /** Optional merchant URL for tracking */ + merchantUrl?: string } /** @@ -293,10 +297,19 @@ export type QuickReplyButton = { id: string } +/** + * Call button - initiates a phone call when clicked + */ +export type CallButton = { + type: 'call' + text: string + phoneNumber: string +} + /** * Union type for all button types */ -export type NativeButton = UrlButton | CopyButton | QuickReplyButton +export type NativeButton = UrlButton | CopyButton | QuickReplyButton | CallButton /** * Formatted button for Native Flow (internal use) @@ -306,6 +319,44 @@ export type NativeFlowButton = { buttonParamsJson: string } +/** + * Row item in a list section + */ +export type ListRow = { + /** Unique ID returned when selected */ + id: string + /** Display title */ + title: string + /** Optional description */ + description?: string +} + +/** + * Section in a native list message (uses ListRow with id) + */ +export type NativeListSection = { + /** Section title */ + title: string + /** Rows in this section */ + rows: ListRow[] +} + +/** + * Options for generating a list message + */ +export type ListMessageOptions = { + /** Button text to open the list */ + buttonText: string + /** Sections with selectable items */ + sections: NativeListSection[] + /** Main text/body of the message */ + text: string + /** Title shown in header */ + title?: string + /** Footer text */ + footer?: string +} + /** * Options for generating a button message */ @@ -322,6 +373,8 @@ export type ButtonMessageOptions = { headerImage?: WAMediaUpload /** Header video (optional) */ headerVideo?: WAMediaUpload + /** Message version (default: 2) */ + messageVersion?: number } /** @@ -580,6 +633,39 @@ export type AnyRegularMessageContent = ( text?: string footer?: string } + | { + /** + * Native List Message - Interactive list with sections + * + * @example + * ```typescript + * await sock.sendMessage(jid, { + * text: 'Choose an option:', + * title: 'Menu', + * nativeList: { + * buttonText: 'View Options', + * sections: [ + * { + * title: 'Category 1', + * rows: [ + * { id: 'opt1', title: 'Option 1', description: 'Desc' }, + * { id: 'opt2', title: 'Option 2' } + * ] + * } + * ] + * }, + * footer: 'Select one' + * }) + * ``` + */ + nativeList: { + buttonText: string + sections: NativeListSection[] + } + text?: string + title?: string + footer?: string + } | { /** * Album message - send multiple images/videos grouped together diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 464b5497..1102f848 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -17,6 +17,7 @@ import type { ButtonMessageOptions, CarouselMessageOptions, DownloadableMessage, + ListMessageOptions, MessageContentGenerationOptions, MessageGenerationOptions, MessageGenerationOptionsFromContent, @@ -404,7 +405,7 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton = buttonParamsJson: JSON.stringify({ display_text: button.text, url: button.url, - merchant_url: button.url + merchant_url: button.merchantUrl || button.url }) } case 'copy': @@ -423,6 +424,14 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton = id: button.id }) } + case 'call': + return { + name: 'cta_call', + buttonParamsJson: JSON.stringify({ + display_text: button.text, + phone_number: button.phoneNumber + }) + } default: throw new Boom('Invalid button type', { statusCode: 400 }) } @@ -447,7 +456,7 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton = * ``` */ export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageContent => { - const { buttons, text, footer, headerTitle, headerImage, headerVideo } = options + const { buttons, text, footer, headerTitle, headerImage, headerVideo, messageVersion = 2 } = options if (!buttons || buttons.length === 0) { throw new Boom('At least one button is required', { statusCode: 400 }) @@ -475,7 +484,8 @@ export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageC header, nativeFlowMessage: { buttons: formattedButtons, - messageParamsJson: '' + messageParamsJson: JSON.stringify({}), + messageVersion } } @@ -585,6 +595,152 @@ export const generateCarouselMessage = (options: CarouselMessageOptions): WAMess } } +/** + * Generates a list message using Native Flow format (single_select) + * Uses viewOnceMessage wrapper for better iOS/Android compatibility + * + * @example + * ```typescript + * const msg = generateListMessage({ + * buttonText: 'View Options', + * text: 'Choose an option:', + * title: 'Menu', + * sections: [ + * { + * title: 'Category 1', + * rows: [ + * { id: 'opt1', title: 'Option 1', description: 'Description 1' }, + * { id: 'opt2', title: 'Option 2', description: 'Description 2' } + * ] + * } + * ], + * footer: 'Select one item' + * }) + * await sock.sendMessage(jid, msg) + * ``` + */ +export const generateListMessage = (options: ListMessageOptions): WAMessageContent => { + const { buttonText, sections, text, title, footer } = options + + if (!sections || sections.length === 0) { + throw new Boom('At least one section is required', { statusCode: 400 }) + } + + // Build sections for single_select + const formattedSections = sections.map(section => ({ + title: section.title, + rows: section.rows.map(row => ({ + id: row.id, + title: row.title, + description: row.description || '' + })) + })) + + // Create native flow message with single_select button + const nativeFlowMessage = { + buttons: [{ + name: 'single_select', + buttonParamsJson: JSON.stringify({ + title: buttonText, + sections: formattedSections + }) + }], + messageParamsJson: JSON.stringify({}), + messageVersion: 2 + } + + // Build the interactive message + const interactiveMessage: proto.Message.IInteractiveMessage = { + body: { text: text || '' }, + footer: footer ? { text: footer } : undefined, + header: title ? { + title, + subtitle: '', + hasMediaAttachment: false + } : undefined, + nativeFlowMessage + } + + // Wrap in viewOnceMessage for better compatibility + return { + viewOnceMessage: { + message: { + messageContextInfo: { + deviceListMetadata: {}, + deviceListMetadataVersion: 2 + }, + interactiveMessage + } + } + } +} + +// ========== Legacy Message Functions ========== + +/** + * Generates a button message using the legacy buttonsMessage format + * ⚠️ WARNING: This format is deprecated and may not work on all devices + * + * @deprecated Use generateButtonMessage instead for better compatibility + */ +export const generateButtonMessageLegacy = ( + buttons: Array<{ id?: string; text: string }>, + text: string, + footer?: string +): WAMessageContent => { + const formattedButtons = buttons.map((button, index) => ({ + buttonId: button.id || `btn_${index}`, + buttonText: { displayText: button.text }, + type: proto.Message.ButtonsMessage.Button.Type.RESPONSE + })) + + return { + buttonsMessage: WAProto.Message.ButtonsMessage.fromObject({ + contentText: text, + footerText: footer, + buttons: formattedButtons, + headerType: proto.Message.ButtonsMessage.HeaderType.EMPTY + }) + } +} + +/** + * Generates a list message using the legacy listMessage format + * ⚠️ WARNING: This format is deprecated and may not work on all devices + * + * @deprecated Use generateListMessage instead for better compatibility + */ +export const generateListMessageLegacy = ( + listInfo: { + sections: Array<{ + title: string + rows: Array<{ id?: string; rowId?: string; title: string; description?: string }> + }> + }, + title: string, + description: string, + buttonText: string, + footer?: string +): WAMessageContent => { + return { + listMessage: WAProto.Message.ListMessage.fromObject({ + title, + description, + buttonText, + footerText: footer, + listType: WAProto.Message.ListMessage.ListType.SINGLE_SELECT, + sections: listInfo.sections.map(section => ({ + title: section.title, + rows: section.rows.map(row => ({ + rowId: row.id || row.rowId, + title: row.title, + description: row.description + })) + })) + }) + } +} + function hasOptionalProperty(obj: T, key: K): obj is WithKey { return typeof obj === 'object' && obj !== null && key in obj && (obj as any)[key] !== null } @@ -623,6 +779,20 @@ export const generateWAMessageContent = async ( m.viewOnceMessage = generated.viewOnceMessage options.logger?.info('Sending carouselMessage with viewOnceMessage wrapper') } + // Check for nativeList + else if (hasNonNullishProperty(message, 'nativeList')) { + const listMsg = message as any + const listOptions: ListMessageOptions = { + buttonText: listMsg.nativeList.buttonText, + sections: listMsg.nativeList.sections, + text: listMsg.text || '', + title: listMsg.title, + footer: listMsg.footer + } + const generated = generateListMessage(listOptions) + m.viewOnceMessage = generated.viewOnceMessage + options.logger?.info('Sending listMessage with viewOnceMessage wrapper') + } // ⚠️ EXPERIMENTAL: Check for interactive messages FIRST (buttons, lists, templates) // These use the older API which may not work reliably else if (hasNonNullishProperty(message, 'text') && hasNonNullishProperty(message, 'buttons')) { From 2ccdd0df01a0801b26ce226ecdf9d95c42336555 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sun, 25 Jan 2026 23:57:07 -0300 Subject: [PATCH 3/3] 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') }