diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 48dc4294..7e726cbc 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -256,6 +256,157 @@ 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 + * - 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' | 'cta_call' | 'quick_reply' | 'single_select' + +/** + * URL button - opens a link when clicked + */ +export type UrlButton = { + type: 'url' + text: string + url: string + /** Optional merchant URL for tracking */ + merchantUrl?: 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 +} + +/** + * 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 | CallButton + +/** + * Formatted button for Native Flow (internal use) + */ +export type NativeFlowButton = { + name: string + 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 + */ +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 + /** Message version (default: 2) */ + messageVersion?: number +} + +/** + * 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 +584,88 @@ 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 + } + | { + /** + * 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 ae2befe3..d8b64a34 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -14,12 +14,17 @@ import { import type { AnyMediaMessageContent, AnyMessageContent, + ButtonMessageOptions, + CarouselMessageOptions, DownloadableMessage, + ListMessageOptions, MessageContentGenerationOptions, MessageGenerationOptions, MessageGenerationOptionsFromContent, MessageUserReceipt, MessageWithContextInfo, + NativeButton, + NativeFlowButton, WAMediaUpload, WAMessage, WAMessageContent, @@ -387,6 +392,428 @@ 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({ + display_text: button.text, + url: button.url, + merchant_url: button.merchantUrl || button.url + }) + } + case 'copy': + validateNonEmptyString(button.copyText, 'copyText') + return { + name: 'cta_copy', + buttonParamsJson: JSON.stringify({ + display_text: button.text, + copy_code: button.copyText + }) + } + case 'reply': + validateNonEmptyString(button.id, 'id') + return { + name: 'quick_reply', + buttonParamsJson: JSON.stringify({ + display_text: button.text, + id: button.id + }) + } + case 'call': + validateNonEmptyString(button.phoneNumber, 'phoneNumber') + return { + name: 'cta_call', + buttonParamsJson: JSON.stringify({ + display_text: button.text, + phone_number: button.phoneNumber + }) + } + 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 = await 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' + * }, options) + * await sock.sendMessage(jid, msg) + * ``` + */ +export const generateButtonMessage = async ( + options: ButtonMessageOptions, + mediaOptions?: MessageContentGenerationOptions +): Promise => { + 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 }) + } + + if (buttons.length > 3) { + 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) + + // Determine header configuration + const hasMedia = !!(headerImage || headerVideo) + const header: proto.Message.InteractiveMessage.IHeader = { + title: hasMedia ? '' : (headerTitle || ''), + subtitle: '', + 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 || '' }, + footer: footer ? { text: footer } : undefined, + header, + nativeFlowMessage: { + buttons: formattedButtons, + messageParamsJson: JSON.stringify({}), + messageVersion + } + } + + // 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 = await 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' + * }, options) + * await sock.sendMessage(jid, msg) + * ``` + */ +export const generateCarouselMessage = async ( + options: CarouselMessageOptions, + mediaOptions?: MessageContentGenerationOptions +): Promise => { + 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 }) + } + + // 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, + 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 + } + } + } +} + +/** + * 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 } @@ -397,8 +824,53 @@ 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 + } + // 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')) { + const carouselMsg = message as any + const carouselOptions: CarouselMessageOptions = { + cards: carouselMsg.nativeCarousel.cards, + text: carouselMsg.text, + footer: carouselMsg.footer + } + // 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') + } + // 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) - 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 +930,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 +947,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