diff --git a/src/api/controllers/sendMessage.controller.ts b/src/api/controllers/sendMessage.controller.ts index 8414c67db..c25cc0792 100644 --- a/src/api/controllers/sendMessage.controller.ts +++ b/src/api/controllers/sendMessage.controller.ts @@ -8,6 +8,7 @@ import { SendLocationDto, SendMediaDto, SendPollDto, + SendProductDto, SendPtvDto, SendReactionDto, SendStatusDto, @@ -106,6 +107,13 @@ export class SendMessageController { return await this.waMonitor.waInstances[instanceName].pollMessage(data); } + public async sendProduct({ instanceName }: InstanceDto, data: SendProductDto) { + if (!isURL(data?.productImage) && !isBase64(data?.productImage)) { + throw new BadRequestException('productImage must be a URL or base64 string'); + } + return await this.waMonitor.waInstances[instanceName].productMessage(data); + } + public async sendStatus({ instanceName }: InstanceDto, data: SendStatusDto, file?: any) { return await this.waMonitor.waInstances[instanceName].statusMessage(data, file); } diff --git a/src/api/dto/sendMessage.dto.ts b/src/api/dto/sendMessage.dto.ts index b3d87e5e0..608888258 100644 --- a/src/api/dto/sendMessage.dto.ts +++ b/src/api/dto/sendMessage.dto.ts @@ -186,3 +186,28 @@ export class SendCarouselDto extends Metadata { body: string; cards: CarouselCard[]; } + +export class SendProductDto extends Metadata { + /** WhatsApp internal product id (from /business/getCatalog `id`) */ + productId: string; + /** Business owner JID — `@s.whatsapp.net` of the catalog owner */ + businessOwnerJid: string; + /** Product image — URL or base64 */ + productImage: string; + /** Merchant-side retailer id (e.g. `BD3`). Optional. */ + retailerId?: string; + /** Product title shown to recipients as a fallback. */ + title?: string; + /** Product description shown as a fallback. */ + description?: string; + /** ISO 4217 currency code (e.g. `ILS`, `USD`). Defaults to `USD`. */ + currencyCode?: string; + /** Price × 1000 (e.g. 5500 ILS → `5500000`). */ + priceAmount1000?: number; + /** Product landing URL. Optional. */ + url?: string; + /** How many images the product has in the catalog. Defaults to 1. */ + productImageCount?: number; + /** Optional caption sent alongside the product card. */ + caption?: string; +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 22839fd45..1d7255c4c 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -48,6 +48,7 @@ import { SendLocationDto, SendMediaDto, SendPollDto, + SendProductDto, SendPtvDto, SendReactionDto, SendStatusDto, @@ -2506,6 +2507,13 @@ export class BaileysStartupService extends ChannelStartupService { ); } + if (message['product']) { + return await this.client.sendMessage( + sender, + message as unknown as AnyMessageContent, + option as unknown as MiscMessageGenerationOptions, + ); + } if (!message['audio'] && !message['poll'] && !message['sticker'] && sender != 'status@broadcast') { return await this.client.sendMessage( sender, @@ -2959,6 +2967,46 @@ export class BaileysStartupService extends ChannelStartupService { ); } + public async productMessage(data: SendProductDto, isIntegration = false) { + if (!data.productId || data.productId.trim().length === 0) { + throw new BadRequestException('productId is required'); + } + if (!data.businessOwnerJid || data.businessOwnerJid.trim().length === 0) { + throw new BadRequestException('businessOwnerJid is required'); + } + if (!data.productImage || data.productImage.trim().length === 0) { + throw new BadRequestException('productImage is required'); + } + + const productImage = /^https?:\/\//i.test(data.productImage) + ? { url: data.productImage } + : Buffer.from(data.productImage, 'base64'); + + return await this.sendMessageWithTyping( + data.number, + { + product: { + productImage, + productId: data.productId, + title: data.title ?? '', + description: data.description ?? '', + currencyCode: data.currencyCode ?? 'USD', + priceAmount1000: data.priceAmount1000 != null ? String(data.priceAmount1000) : undefined, + retailerId: data.retailerId ?? '', + url: data.url ?? '', + productImageCount: data.productImageCount ?? 1, + }, + businessOwnerJid: data.businessOwnerJid, + caption: data.caption ?? '', + }, + { + delay: data?.delay, + presence: 'composing', + quoted: data?.quoted, + }, + isIntegration, + ); + } public async pollMessage(data: SendPollDto) { return await this.sendMessageWithTyping( data.number, diff --git a/src/api/routes/sendMessage.router.ts b/src/api/routes/sendMessage.router.ts index 51876483d..19486f33e 100644 --- a/src/api/routes/sendMessage.router.ts +++ b/src/api/routes/sendMessage.router.ts @@ -8,6 +8,7 @@ import { SendLocationDto, SendMediaDto, SendPollDto, + SendProductDto, SendPtvDto, SendReactionDto, SendStatusDto, @@ -25,6 +26,7 @@ import { locationMessageSchema, mediaMessageSchema, pollMessageSchema, + productMessageSchema, ptvMessageSchema, reactionMessageSchema, statusMessageSchema, @@ -164,6 +166,16 @@ export class MessageRouter extends RouterBroker { return res.status(HttpStatus.CREATED).json(response); }) + .post(this.routerPath('sendProduct'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: productMessageSchema, + ClassRef: SendProductDto, + execute: (instance, data) => sendMessageController.sendProduct(instance, data), + }); + + return res.status(HttpStatus.CREATED).json(response); + }) .post(this.routerPath('sendList'), ...guards, async (req, res) => { const response = await this.dataValidate({ request: req, diff --git a/src/validate/message.schema.ts b/src/validate/message.schema.ts index db76fe1c8..df916b8c3 100644 --- a/src/validate/message.schema.ts +++ b/src/validate/message.schema.ts @@ -481,7 +481,7 @@ export const carouselMessageSchema: JSONSchema7 = { items: { type: 'object', properties: { - type: { type: 'string', enum: ['reply', 'copy', 'url', 'call'] }, + type: { type: 'string', enum: ['reply', 'copy', 'url', 'call', 'pix'] }, displayText: { type: 'string' }, id: { type: 'string' }, url: { type: 'string' }, @@ -501,7 +501,7 @@ export const carouselMessageSchema: JSONSchema7 = { description: 'Enter a value in milliseconds', }, quoted: { ...quotedOptionsSchema }, - everyOne: { type: 'boolean', enum: [true, false] }, + mentionsEveryOne: { type: 'boolean', enum: [true, false] }, mentioned: { type: 'array', minItems: 1, @@ -516,6 +516,44 @@ export const carouselMessageSchema: JSONSchema7 = { required: ['number', 'body', 'cards'], }; +export const productMessageSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + number: { ...numberDefinition }, + productId: { type: 'string', minLength: 1 }, + businessOwnerJid: { + type: 'string', + pattern: '^[0-9]+@s[.]whatsapp[.]net$', + description: '"businessOwnerJid" must look like "@s.whatsapp.net"', + }, + productImage: { type: 'string', minLength: 1 }, + retailerId: { type: 'string' }, + title: { type: 'string' }, + description: { type: 'string' }, + currencyCode: { type: 'string', minLength: 3, maxLength: 3 }, + priceAmount1000: { type: 'integer', minimum: 0 }, + url: { type: 'string' }, + productImageCount: { type: 'integer', minimum: 1 }, + caption: { type: 'string' }, + delay: { type: 'integer', description: 'Enter a value in milliseconds' }, + quoted: { ...quotedOptionsSchema }, + mentionsEveryOne: { type: 'boolean', enum: [true, false] }, + mentioned: { + type: 'array', + minItems: 1, + uniqueItems: true, + items: { + type: 'string', + pattern: '^\\d+', + description: '"mentioned" must be an array of numeric strings', + }, + }, + }, + required: ['number', 'productId', 'businessOwnerJid', 'productImage'], + ...isNotEmpty('number', 'productId', 'businessOwnerJid', 'productImage'), +}; + export const decryptPollVoteSchema: JSONSchema7 = { $id: v4(), type: 'object',