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:
+88
-2
@@ -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
@@ -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')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user