diff --git a/src/api/controllers/sendMessage.controller.ts b/src/api/controllers/sendMessage.controller.ts index 64aa1c8468..6e82909018 100644 --- a/src/api/controllers/sendMessage.controller.ts +++ b/src/api/controllers/sendMessage.controller.ts @@ -7,6 +7,7 @@ import { SendLocationDto, SendMediaDto, SendPollDto, + SendProductDto, SendPtvDto, SendReactionDto, SendStatusDto, @@ -101,6 +102,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 ba9ecf527c..e707027046 100644 --- a/src/api/dto/sendMessage.dto.ts +++ b/src/api/dto/sendMessage.dto.ts @@ -167,3 +167,28 @@ export class SendReactionDto { key: proto.IMessageKey; reaction: string; } + +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 60e857fcc1..cdd4655aef 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -44,6 +44,7 @@ import { SendLocationDto, SendMediaDto, SendPollDto, + SendProductDto, SendPtvDto, SendReactionDto, SendStatusDto, @@ -2207,6 +2208,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, @@ -2648,6 +2656,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 cd073dba3d..69cda16e04 100644 --- a/src/api/routes/sendMessage.router.ts +++ b/src/api/routes/sendMessage.router.ts @@ -7,6 +7,7 @@ import { SendLocationDto, SendMediaDto, SendPollDto, + SendProductDto, SendPtvDto, SendReactionDto, SendStatusDto, @@ -23,6 +24,7 @@ import { locationMessageSchema, mediaMessageSchema, pollMessageSchema, + productMessageSchema, ptvMessageSchema, reactionMessageSchema, statusMessageSchema, @@ -162,6 +164,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 d514c6199e..4768810731 100644 --- a/src/validate/message.schema.ts +++ b/src/validate/message.schema.ts @@ -447,3 +447,30 @@ export const buttonsMessageSchema: JSONSchema7 = { }, required: ['number'], }; + +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 }, + }, + required: ['number', 'productId', 'businessOwnerJid', 'productImage'], + ...isNotEmpty('number', 'productId', 'businessOwnerJid', 'productImage'), +};