From 3d4fa3e007995c09451e2ad9a7598efa01ea3f17 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Mon, 26 Jan 2026 17:43:35 -0300 Subject: [PATCH 1/3] feat: add Product Carousel message support - Add ProductCarouselCard and ProductCarouselMessageOptions types - Add productCarousel to AnyRegularMessageContent union type - Create generateProductCarouselMessage function with validation - Add productCarousel support in generateWAMessageContent - Uses viewOnceMessage wrapper for iOS/Android compatibility - Requires WhatsApp Business account with configured catalog Usage: await sock.sendMessage(jid, { productCarousel: { catalogId: '123456789', products: [ { productId: 'produto_001' }, { productId: 'produto_002' } ] }, body: 'Confira nossos produtos!' }) Co-Authored-By: Claude Opus 4.5 --- src/Types/Message.ts | 62 +++++++++++++++++++++++++++++ src/Utils/messages.ts | 91 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 219c06ed..31fb3b95 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -613,6 +613,45 @@ export type AlbumSendResult = { totalLatencyMs: number } +// ========== Product Carousel Message Types ========== + +/** + * Single product card in a product carousel + * References a product from WhatsApp Business catalog + */ +export type ProductCarouselCard = { + /** Product retailer ID from the catalog */ + productId: string +} + +/** + * Options for generating a product carousel message + * Uses products from WhatsApp Business catalog + * + * @example + * ```typescript + * await sock.sendMessage(jid, { + * productCarousel: { + * catalogId: '123456789', + * products: [ + * { productId: 'iphone_15' }, + * { productId: 'macbook_air' }, + * { productId: 'apple_watch' } + * ] + * }, + * body: 'Check out our featured products!' + * }) + * ``` + */ +export type ProductCarouselMessageOptions = { + /** Catalog ID from WhatsApp Business */ + catalogId: string + /** Products to display (2-10 cards required) */ + products: ProductCarouselCard[] + /** Body text for the message */ + body?: string +} + export type AnyRegularMessageContent = ( | ({ text: string @@ -747,6 +786,29 @@ export type AnyRegularMessageContent = ( title?: string footer?: string } + | { + /** + * Product Carousel Message - Swipeable product cards from WhatsApp Business catalog + * Requires: WhatsApp Business account with configured catalog + * + * @example + * ```typescript + * await sock.sendMessage(jid, { + * productCarousel: { + * catalogId: '123456789', + * products: [ + * { productId: 'produto_001' }, + * { productId: 'produto_002' }, + * { productId: 'produto_003' } + * ] + * }, + * body: 'Confira nossos produtos em destaque!' + * }) + * ``` + */ + productCarousel: ProductCarouselMessageOptions + body?: string + } | { /** * Album message - send multiple images/videos grouped together diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 5cbfd37e..9a6f9bcd 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -25,6 +25,7 @@ import type { MessageWithContextInfo, NativeButton, NativeFlowButton, +ProductCarouselMessageOptions, ProductListMessageOptions, WAMediaUpload, WAMessage, @@ -883,6 +884,82 @@ export const generateProductListMessage = (options: ProductListMessageOptions): } } +/** + * Generates a product carousel message using products from WhatsApp Business catalog + * Uses viewOnceMessage wrapper for better iOS/Android compatibility + * + * @example + * ```typescript + * const msg = generateProductCarouselMessage({ + * catalogId: '123456789', + * products: [ + * { productId: 'produto_001' }, + * { productId: 'produto_002' }, + * { productId: 'produto_003' } + * ], + * body: 'Confira nossos produtos em destaque!' + * }) + * await sock.sendMessage(jid, msg) + * ``` + */ +export const generateProductCarouselMessage = ( + options: ProductCarouselMessageOptions +): WAMessageContent => { + const { catalogId, products, body } = options + + if (!catalogId || typeof catalogId !== 'string' || catalogId.trim().length === 0) { + throw new Boom('catalogId is required and must be a non-empty string', { statusCode: 400 }) + } + + if (!products || products.length < 2) { + throw new Boom('Product carousel requires at least 2 products', { statusCode: 400 }) + } + + if (products.length > 10) { + throw new Boom('Maximum 10 products allowed in carousel', { statusCode: 400 }) + } + + // Validate each product has a valid productId + for (let i = 0; i < products.length; i++) { + const product = products[i]! + if (!product.productId || typeof product.productId !== 'string' || product.productId.trim().length === 0) { + throw new Boom(`Product at index ${i} must have a non-empty productId`, { statusCode: 400 }) + } + } + + // Build cards array with product references + const cards = products.map((product, index) => ({ + card_index: index, + type: 'product' as const, + action: { + product_retailer_id: product.productId, + catalog_id: catalogId + } + })) + + // Build the interactive message with carousel type + const interactiveMessage: proto.Message.IInteractiveMessage = { + body: { text: body || '' }, + carouselMessage: { + cards: cards as any, + messageVersion: 1 + } + } + + // Wrap in viewOnceMessage for better compatibility + return { + viewOnceMessage: { + message: { + messageContextInfo: { + deviceListMetadata: {}, + deviceListMetadataVersion: 2 + }, + interactiveMessage + } + } + } +} + // ========== Legacy Message Functions ========== /** @@ -1003,7 +1080,7 @@ export const generateWAMessageContent = async ( m.viewOnceMessage = generated.viewOnceMessage options.logger?.info('Sending listMessage with viewOnceMessage wrapper') } - // Check for productList (multi-product message from catalog) +// Check for productList (multi-product message from catalog) else if (hasNonNullishProperty(message, 'productList')) { const productMsg = message as any const productListOptions: ProductListMessageOptions = { @@ -1019,6 +1096,18 @@ export const generateWAMessageContent = async ( m.listMessage = generated.listMessage options.logger?.info('Sending productListMessage (multi-product from catalog)') } + // Check for productCarousel (WhatsApp Business catalog products) + else if (hasNonNullishProperty(message, 'productCarousel')) { + const productCarouselMsg = message as any + const productCarouselOptions: ProductCarouselMessageOptions = { + catalogId: productCarouselMsg.productCarousel.catalogId, + products: productCarouselMsg.productCarousel.products, + body: productCarouselMsg.body + } + const generated = generateProductCarouselMessage(productCarouselOptions) + m.viewOnceMessage = generated.viewOnceMessage + options.logger?.info('Sending productCarouselMessage with viewOnceMessage wrapper') + } // ⚠️ EXPERIMENTAL: Check for interactive messages FIRST (buttons, lists, templates) // These use the older API which may not work reliably else if (hasNonNullishProperty(message, 'text') && hasNonNullishProperty(message, 'buttons')) { From 0d569752af34d065420cd64423935caa7253c08b Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Mon, 26 Jan 2026 18:32:45 -0300 Subject: [PATCH 2/3] fix(product-carousel): address PR review comments - Replace catalogId with businessOwnerJid (required for catalog reference) - Fix cards structure to use proper IInteractiveMessage[] format - Each card now uses collectionMessage with bizJid and id - Fix body reading from productCarousel.body (was reading from message.body) - Remove 'as any' type casting by using correct proto types - Update examples in types and function documentation Addresses: - Schema mismatch in carousel cards - Body text being silently ignored when nested in productCarousel - Improper type casting masking validation errors Co-Authored-By: Claude Opus 4.5 --- src/Types/Message.ts | 23 +++++++++++------------ src/Utils/messages.ts | 34 ++++++++++++++++++++-------------- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 31fb3b95..74f44a55 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -632,23 +632,23 @@ export type ProductCarouselCard = { * ```typescript * await sock.sendMessage(jid, { * productCarousel: { - * catalogId: '123456789', + * businessOwnerJid: '5511999999999@s.whatsapp.net', * products: [ * { productId: 'iphone_15' }, * { productId: 'macbook_air' }, * { productId: 'apple_watch' } - * ] - * }, - * body: 'Check out our featured products!' + * ], + * body: 'Check out our featured products!' + * } * }) * ``` */ export type ProductCarouselMessageOptions = { - /** Catalog ID from WhatsApp Business */ - catalogId: string + /** JID of the business owner (catalog owner) - e.g., '5511999999999@s.whatsapp.net' */ + businessOwnerJid: string /** Products to display (2-10 cards required) */ products: ProductCarouselCard[] - /** Body text for the message */ + /** Body text for the carousel message */ body?: string } @@ -795,19 +795,18 @@ export type AnyRegularMessageContent = ( * ```typescript * await sock.sendMessage(jid, { * productCarousel: { - * catalogId: '123456789', + * businessOwnerJid: '5511999999999@s.whatsapp.net', * products: [ * { productId: 'produto_001' }, * { productId: 'produto_002' }, * { productId: 'produto_003' } - * ] - * }, - * body: 'Confira nossos produtos em destaque!' + * ], + * body: 'Confira nossos produtos em destaque!' + * } * }) * ``` */ productCarousel: ProductCarouselMessageOptions - body?: string } | { /** diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 9a6f9bcd..ef9e44b8 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -888,10 +888,13 @@ export const generateProductListMessage = (options: ProductListMessageOptions): * Generates a product carousel message using products from WhatsApp Business catalog * Uses viewOnceMessage wrapper for better iOS/Android compatibility * + * Each card in the carousel references a product from the business catalog using + * collectionMessage with bizJid (business owner) and id (product ID). + * * @example * ```typescript * const msg = generateProductCarouselMessage({ - * catalogId: '123456789', + * businessOwnerJid: '5511999999999@s.whatsapp.net', * products: [ * { productId: 'produto_001' }, * { productId: 'produto_002' }, @@ -905,10 +908,10 @@ export const generateProductListMessage = (options: ProductListMessageOptions): export const generateProductCarouselMessage = ( options: ProductCarouselMessageOptions ): WAMessageContent => { - const { catalogId, products, body } = options + const { businessOwnerJid, products, body } = options - if (!catalogId || typeof catalogId !== 'string' || catalogId.trim().length === 0) { - throw new Boom('catalogId is required and must be a non-empty string', { statusCode: 400 }) + if (!businessOwnerJid || typeof businessOwnerJid !== 'string' || businessOwnerJid.trim().length === 0) { + throw new Boom('businessOwnerJid is required and must be a non-empty string', { statusCode: 400 }) } if (!products || products.length < 2) { @@ -927,13 +930,16 @@ export const generateProductCarouselMessage = ( } } - // Build cards array with product references - const cards = products.map((product, index) => ({ - card_index: index, - type: 'product' as const, - action: { - product_retailer_id: product.productId, - catalog_id: catalogId + // Normalize business owner JID + const normalizedBizJid = jidNormalizedUser(businessOwnerJid) + + // Build cards array - each card is an IInteractiveMessage with collectionMessage + // collectionMessage references a product from the business catalog + const cards: proto.Message.IInteractiveMessage[] = products.map((product) => ({ + collectionMessage: { + bizJid: normalizedBizJid, + id: product.productId, + messageVersion: 1 } })) @@ -941,7 +947,7 @@ export const generateProductCarouselMessage = ( const interactiveMessage: proto.Message.IInteractiveMessage = { body: { text: body || '' }, carouselMessage: { - cards: cards as any, + cards, messageVersion: 1 } } @@ -1100,9 +1106,9 @@ export const generateWAMessageContent = async ( else if (hasNonNullishProperty(message, 'productCarousel')) { const productCarouselMsg = message as any const productCarouselOptions: ProductCarouselMessageOptions = { - catalogId: productCarouselMsg.productCarousel.catalogId, + businessOwnerJid: productCarouselMsg.productCarousel.businessOwnerJid, products: productCarouselMsg.productCarousel.products, - body: productCarouselMsg.body + body: productCarouselMsg.productCarousel.body } const generated = generateProductCarouselMessage(productCarouselOptions) m.viewOnceMessage = generated.viewOnceMessage From c20193041e9205edd7d7f6b71e25cf2cb86a8ab3 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Mon, 26 Jan 2026 22:16:50 -0300 Subject: [PATCH 3/3] style: fix indentation in imports and comments - Fix ProductCarouselMessageOptions import indentation - Fix productList comment indentation to align with else-if chain Co-Authored-By: Claude Opus 4.5 --- src/Utils/messages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index ef9e44b8..c3ddf38a 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -25,7 +25,7 @@ import type { MessageWithContextInfo, NativeButton, NativeFlowButton, -ProductCarouselMessageOptions, + ProductCarouselMessageOptions, ProductListMessageOptions, WAMediaUpload, WAMessage, @@ -1086,7 +1086,7 @@ export const generateWAMessageContent = async ( m.viewOnceMessage = generated.viewOnceMessage options.logger?.info('Sending listMessage with viewOnceMessage wrapper') } -// Check for productList (multi-product message from catalog) + // Check for productList (multi-product message from catalog) else if (hasNonNullishProperty(message, 'productList')) { const productMsg = message as any const productListOptions: ProductListMessageOptions = {