feat(buttons): add call button, list messages and legacy functions

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 <noreply@anthropic.com>
This commit is contained in:
Renato Alcara
2026-01-25 23:47:58 -03:00
parent 5d84974088
commit e534202c6c
2 changed files with 261 additions and 5 deletions
+88 -2
View File
@@ -262,9 +262,11 @@ export type Listable = {
* Button types supported by WhatsApp Native Flow * Button types supported by WhatsApp Native Flow
* - cta_url: Opens a URL * - cta_url: Opens a URL
* - cta_copy: Copies text to clipboard * - cta_copy: Copies text to clipboard
* - cta_call: Initiates a phone call
* - quick_reply: Sends a quick reply with ID * - 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 * URL button - opens a link when clicked
@@ -273,6 +275,8 @@ export type UrlButton = {
type: 'url' type: 'url'
text: string text: string
url: string url: string
/** Optional merchant URL for tracking */
merchantUrl?: string
} }
/** /**
@@ -293,10 +297,19 @@ export type QuickReplyButton = {
id: 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 * 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) * Formatted button for Native Flow (internal use)
@@ -306,6 +319,44 @@ export type NativeFlowButton = {
buttonParamsJson: 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 * Options for generating a button message
*/ */
@@ -322,6 +373,8 @@ export type ButtonMessageOptions = {
headerImage?: WAMediaUpload headerImage?: WAMediaUpload
/** Header video (optional) */ /** Header video (optional) */
headerVideo?: WAMediaUpload headerVideo?: WAMediaUpload
/** Message version (default: 2) */
messageVersion?: number
} }
/** /**
@@ -580,6 +633,39 @@ export type AnyRegularMessageContent = (
text?: string text?: string
footer?: 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 * Album message - send multiple images/videos grouped together
+173 -3
View File
@@ -17,6 +17,7 @@ import type {
ButtonMessageOptions, ButtonMessageOptions,
CarouselMessageOptions, CarouselMessageOptions,
DownloadableMessage, DownloadableMessage,
ListMessageOptions,
MessageContentGenerationOptions, MessageContentGenerationOptions,
MessageGenerationOptions, MessageGenerationOptions,
MessageGenerationOptionsFromContent, MessageGenerationOptionsFromContent,
@@ -404,7 +405,7 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton =
buttonParamsJson: JSON.stringify({ buttonParamsJson: JSON.stringify({
display_text: button.text, display_text: button.text,
url: button.url, url: button.url,
merchant_url: button.url merchant_url: button.merchantUrl || button.url
}) })
} }
case 'copy': case 'copy':
@@ -423,6 +424,14 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton =
id: button.id id: button.id
}) })
} }
case 'call':
return {
name: 'cta_call',
buttonParamsJson: JSON.stringify({
display_text: button.text,
phone_number: button.phoneNumber
})
}
default: default:
throw new Boom('Invalid button type', { statusCode: 400 }) throw new Boom('Invalid button type', { statusCode: 400 })
} }
@@ -447,7 +456,7 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton =
* ``` * ```
*/ */
export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageContent => { 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) { 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 })
@@ -475,7 +484,8 @@ export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageC
header, header,
nativeFlowMessage: { nativeFlowMessage: {
buttons: formattedButtons, 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<T, K extends PropertyKey>(obj: T, key: K): obj is WithKey<T, K> { function hasOptionalProperty<T, K extends PropertyKey>(obj: T, key: K): obj is WithKey<T, K> {
return typeof obj === 'object' && obj !== null && key in obj && (obj as any)[key] !== null 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 m.viewOnceMessage = generated.viewOnceMessage
options.logger?.info('Sending carouselMessage with viewOnceMessage wrapper') 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) // ⚠️ EXPERIMENTAL: Check for interactive messages FIRST (buttons, lists, templates)
// These use the older API which may not work reliably // These use the older API which may not work reliably
else if (hasNonNullishProperty(message, 'text') && hasNonNullishProperty(message, 'buttons')) { else if (hasNonNullishProperty(message, 'text') && hasNonNullishProperty(message, 'buttons')) {