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<WAMessageContent>
- 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<WAMessageContent>
- 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 <noreply@anthropic.com>
This commit is contained in:
Renato Alcara
2026-01-25 23:57:07 -03:00
parent e534202c6c
commit 2ccdd0df01
+93 -18
View File
@@ -394,12 +394,26 @@ export const hasNonNullishProperty = <K extends PropertyKey>(
// ========== 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<WAMessageContent> => {
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<WAMessageContent> => {
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')
}