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:
+93
-18
@@ -394,12 +394,26 @@ export const hasNonNullishProperty = <K extends PropertyKey>(
|
|||||||
|
|
||||||
// ========== Native Flow Button Utilities ==========
|
// ========== 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
|
* Converts a NativeButton to the WhatsApp Native Flow format
|
||||||
|
* Includes validation for required fields
|
||||||
*/
|
*/
|
||||||
export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton => {
|
export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton => {
|
||||||
|
// Validate common field
|
||||||
|
validateNonEmptyString(button.text, 'text')
|
||||||
|
|
||||||
switch (button.type) {
|
switch (button.type) {
|
||||||
case 'url':
|
case 'url':
|
||||||
|
validateNonEmptyString(button.url, 'url')
|
||||||
return {
|
return {
|
||||||
name: 'cta_url',
|
name: 'cta_url',
|
||||||
buttonParamsJson: JSON.stringify({
|
buttonParamsJson: JSON.stringify({
|
||||||
@@ -409,6 +423,7 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton =
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
case 'copy':
|
case 'copy':
|
||||||
|
validateNonEmptyString(button.copyText, 'copyText')
|
||||||
return {
|
return {
|
||||||
name: 'cta_copy',
|
name: 'cta_copy',
|
||||||
buttonParamsJson: JSON.stringify({
|
buttonParamsJson: JSON.stringify({
|
||||||
@@ -417,6 +432,7 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton =
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
case 'reply':
|
case 'reply':
|
||||||
|
validateNonEmptyString(button.id, 'id')
|
||||||
return {
|
return {
|
||||||
name: 'quick_reply',
|
name: 'quick_reply',
|
||||||
buttonParamsJson: JSON.stringify({
|
buttonParamsJson: JSON.stringify({
|
||||||
@@ -425,6 +441,7 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton =
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
case 'call':
|
case 'call':
|
||||||
|
validateNonEmptyString(button.phoneNumber, 'phoneNumber')
|
||||||
return {
|
return {
|
||||||
name: 'cta_call',
|
name: 'cta_call',
|
||||||
buttonParamsJson: JSON.stringify({
|
buttonParamsJson: JSON.stringify({
|
||||||
@@ -443,7 +460,7 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton =
|
|||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* const msg = generateButtonMessage({
|
* const msg = await generateButtonMessage({
|
||||||
* buttons: [
|
* buttons: [
|
||||||
* { type: 'url', text: 'Visit Site', url: 'https://example.com' },
|
* { type: 'url', text: 'Visit Site', url: 'https://example.com' },
|
||||||
* { type: 'copy', text: 'Copy Code', copyText: 'ABC123' },
|
* { type: 'copy', text: 'Copy Code', copyText: 'ABC123' },
|
||||||
@@ -451,11 +468,14 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton =
|
|||||||
* ],
|
* ],
|
||||||
* text: 'Choose an option:',
|
* text: 'Choose an option:',
|
||||||
* footer: 'Powered by InfiniteAPI'
|
* footer: 'Powered by InfiniteAPI'
|
||||||
* })
|
* }, options)
|
||||||
* await sock.sendMessage(jid, msg)
|
* 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
|
const { buttons, text, footer, headerTitle, headerImage, headerVideo, messageVersion = 2 } = options
|
||||||
|
|
||||||
if (!buttons || buttons.length === 0) {
|
if (!buttons || buttons.length === 0) {
|
||||||
@@ -466,6 +486,11 @@ export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageC
|
|||||||
throw new Boom('Maximum 3 buttons allowed', { statusCode: 400 })
|
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
|
// Format buttons to Native Flow format
|
||||||
const formattedButtons = buttons.map(formatNativeFlowButton)
|
const formattedButtons = buttons.map(formatNativeFlowButton)
|
||||||
|
|
||||||
@@ -477,6 +502,19 @@ export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageC
|
|||||||
hasMediaAttachment: hasMedia
|
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
|
// Build the interactive message
|
||||||
const interactiveMessage: proto.Message.IInteractiveMessage = {
|
const interactiveMessage: proto.Message.IInteractiveMessage = {
|
||||||
body: { text: text || '' },
|
body: { text: text || '' },
|
||||||
@@ -509,7 +547,7 @@ export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageC
|
|||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* const msg = generateCarouselMessage({
|
* const msg = await generateCarouselMessage({
|
||||||
* cards: [
|
* cards: [
|
||||||
* {
|
* {
|
||||||
* title: 'Product 1',
|
* title: 'Product 1',
|
||||||
@@ -530,11 +568,14 @@ export const generateButtonMessage = (options: ButtonMessageOptions): WAMessageC
|
|||||||
* ],
|
* ],
|
||||||
* text: 'Check out our products!',
|
* text: 'Check out our products!',
|
||||||
* footer: 'Swipe to see more'
|
* footer: 'Swipe to see more'
|
||||||
* })
|
* }, options)
|
||||||
* await sock.sendMessage(jid, msg)
|
* 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
|
const { cards, text, footer } = options
|
||||||
|
|
||||||
if (!cards || cards.length < 2) {
|
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 })
|
throw new Boom('Maximum 10 cards allowed in carousel', { statusCode: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map cards to the carousel format
|
// Validate cards
|
||||||
const carouselCards = cards.map((card) => {
|
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 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 {
|
return {
|
||||||
header: {
|
header,
|
||||||
title: card.title || '',
|
|
||||||
subtitle: '',
|
|
||||||
hasMediaAttachment: hasMedia,
|
|
||||||
...(card.image ? { imageMessage: card.image } : {}),
|
|
||||||
...(card.video ? { videoMessage: card.video } : {})
|
|
||||||
},
|
|
||||||
body: { text: card.body || '' },
|
body: { text: card.body || '' },
|
||||||
footer: card.footer ? { text: card.footer } : undefined,
|
footer: card.footer ? { text: card.footer } : undefined,
|
||||||
nativeFlowMessage: {
|
nativeFlowMessage: {
|
||||||
@@ -564,7 +637,7 @@ export const generateCarouselMessage = (options: CarouselMessageOptions): WAMess
|
|||||||
messageParamsJson: ''
|
messageParamsJson: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|
||||||
// Build the interactive message with carousel
|
// Build the interactive message with carousel
|
||||||
const interactiveMessage: proto.Message.IInteractiveMessage = {
|
const interactiveMessage: proto.Message.IInteractiveMessage = {
|
||||||
@@ -763,7 +836,8 @@ export const generateWAMessageContent = async (
|
|||||||
headerImage: nativeMsg.headerImage,
|
headerImage: nativeMsg.headerImage,
|
||||||
headerVideo: nativeMsg.headerVideo
|
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
|
m.viewOnceMessage = generated.viewOnceMessage
|
||||||
options.logger?.info('Sending nativeFlowMessage with viewOnceMessage wrapper')
|
options.logger?.info('Sending nativeFlowMessage with viewOnceMessage wrapper')
|
||||||
}
|
}
|
||||||
@@ -775,7 +849,8 @@ export const generateWAMessageContent = async (
|
|||||||
text: carouselMsg.text,
|
text: carouselMsg.text,
|
||||||
footer: carouselMsg.footer
|
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
|
m.viewOnceMessage = generated.viewOnceMessage
|
||||||
options.logger?.info('Sending carouselMessage with viewOnceMessage wrapper')
|
options.logger?.info('Sending carouselMessage with viewOnceMessage wrapper')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user