diff --git a/.env.example b/.env.example index 73a3b40d35..7bbe392c0c 100644 --- a/.env.example +++ b/.env.example @@ -37,7 +37,8 @@ CORS_METHODS=GET,POST,PUT,DELETE CORS_CREDENTIALS=true # Determine the logs to be displayed -LOG_LEVEL=ERROR,WARN,DEBUG,INFO,LOG,VERBOSE,DARK,WEBHOOKS,WEBSOCKET +# WEBHOOKMETA logs the full incoming Meta payload, including message content and contact data. +LOG_LEVEL=ERROR,WARN,DEBUG,INFO,LOG,VERBOSE,DARK,WEBHOOKS,WEBSOCKET,WEBHOOKMETA LOG_COLOR=true # Log Baileys - "fatal" | "error" | "warn" | "info" | "debug" | "trace" LOG_BAILEYS=error diff --git a/.gitignore b/.gitignore index 768d8afa41..b65cf4ed90 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,8 @@ lerna-debug.log* # Project related /instances/* !/instances/.gitkeep -/test/ +/test/* +!/test/meta-message-status.test.cjs /src/env.yml /store *.env diff --git a/prisma/mysql-schema.prisma b/prisma/mysql-schema.prisma index 71b5a743f0..16c42cac39 100644 --- a/prisma/mysql-schema.prisma +++ b/prisma/mysql-schema.prisma @@ -70,7 +70,7 @@ model Instance { integration String? @db.VarChar(100) number String? @db.VarChar(100) businessId String? @db.VarChar(100) - token String? @db.VarChar(255) + token String? @db.Text clientName String? @db.VarChar(100) disconnectionReasonCode Int? @db.Int disconnectionObject Json? @db.Json diff --git a/prisma/postgresql-migrations/20260714182607_20260714152530_change_type_instance_token/migration.sql b/prisma/postgresql-migrations/20260714182607_20260714152530_change_type_instance_token/migration.sql new file mode 100644 index 0000000000..16c4f7aae6 --- /dev/null +++ b/prisma/postgresql-migrations/20260714182607_20260714152530_change_type_instance_token/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Instance" ALTER COLUMN "token" SET DATA TYPE TEXT; diff --git a/prisma/postgresql-migrations/migration_lock.toml b/prisma/postgresql-migrations/migration_lock.toml index 648c57fd59..044d57cdb0 100644 --- a/prisma/postgresql-migrations/migration_lock.toml +++ b/prisma/postgresql-migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "postgresql" \ No newline at end of file +provider = "postgresql" diff --git a/prisma/postgresql-schema.prisma b/prisma/postgresql-schema.prisma index 6b98f88da4..7fb40fd7c1 100644 --- a/prisma/postgresql-schema.prisma +++ b/prisma/postgresql-schema.prisma @@ -70,7 +70,7 @@ model Instance { integration String? @db.VarChar(100) number String? @db.VarChar(100) businessId String? @db.VarChar(100) - token String? @db.VarChar(255) + token String? @db.Text clientName String? @db.VarChar(100) disconnectionReasonCode Int? @db.Integer disconnectionObject Json? @db.JsonB diff --git a/src/api/integrations/channel/meta/meta.controller.ts b/src/api/integrations/channel/meta/meta.controller.ts index 558a22e98b..a886f5a315 100644 --- a/src/api/integrations/channel/meta/meta.controller.ts +++ b/src/api/integrations/channel/meta/meta.controller.ts @@ -15,54 +15,118 @@ export class MetaController extends ChannelController implements ChannelControll integrationEnabled: boolean; public async receiveWebhook(data: any) { - if (data.object === 'whatsapp_business_account') { - if (data.entry[0]?.changes[0]?.field === 'message_template_status_update') { - const template = await this.prismaRepository.template.findFirst({ - where: { templateId: `${data.entry[0].changes[0].value.message_template_id}` }, - }); + if (data.object !== 'whatsapp_business_account') { + return { + status: 'success', + }; + } - if (!template) { - console.log('template not found'); - return; - } + const entries = data.entry ?? []; - const { webhookUrl } = template; + for (const entry of entries) { + const changes = entry.changes ?? []; - await axios.post(webhookUrl, data.entry[0].changes[0].value, { - headers: { - 'Content-Type': 'application/json', - }, - }); - return; - } + for (const change of changes) { + if (change?.field === 'message_template_status_update') { + const templateId = change?.value?.message_template_id; + + if (!templateId) { + this.logger.error('WebhookService -> receiveWebhookMeta -> templateId not found'); + continue; + } + + const template = await this.prismaRepository.template.findFirst({ + where: { + templateId: String(templateId), + }, + }); + + if (!template) { + this.logger.error(`WebhookService -> receiveWebhookMeta -> template not found: ${templateId}`); + continue; + } + + if (!template.webhookUrl) { + this.logger.error(`WebhookService -> receiveWebhookMeta -> template webhookUrl not found: ${templateId}`); + continue; + } + + try { + await axios.post(template.webhookUrl, change.value, { + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + this.logger.error( + `WebhookService -> receiveWebhookMeta -> error sending template webhook: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + continue; + } - data.entry?.forEach(async (entry: any) => { - const numberId = entry.changes[0].value.metadata.phone_number_id; + const numberId = change?.value?.metadata?.phone_number_id; if (!numberId) { this.logger.error('WebhookService -> receiveWebhookMeta -> numberId not found'); - return { - status: 'success', - }; + continue; } - const instance = await this.prismaRepository.instance.findFirst({ - where: { number: numberId }, + const instances = await this.prismaRepository.instance.findMany({ + where: { + number: String(numberId), + }, }); - if (!instance) { - this.logger.error('WebhookService -> receiveWebhookMeta -> instance not found'); - return { - status: 'success', - }; + if (!instances.length) { + this.logger.error(`WebhookService -> receiveWebhookMeta -> instances not found for numberId: ${numberId}`); + continue; } - await this.waMonitor.waInstances[instance.name].connectToWhatsapp(data); - - return { - status: 'success', + const webhookData = { + ...data, + entry: [ + { + ...entry, + changes: [change], + }, + ], }; - }); + + const results = await Promise.allSettled( + instances.map(async (instance) => { + const waInstance = this.waMonitor.waInstances[instance.name]; + + if (!waInstance) { + throw new Error(`Instance not loaded: ${instance.name}`); + } + + await waInstance.connectToWhatsapp(webhookData); + + return instance.name; + }), + ); + + results.forEach((result, index) => { + const instanceName = instances[index].name; + + if (result.status === 'rejected') { + this.logger.error( + `WebhookService -> receiveWebhookMeta -> error processing webhook for instance ${instanceName}: ${ + result.reason instanceof Error ? result.reason.message : String(result.reason) + }`, + ); + return; + } + + this.logger.log( + `WebhookService -> receiveWebhookMeta -> webhook processed successfully for instance ${instanceName}`, + ); + }); + } } return { diff --git a/src/api/integrations/channel/meta/meta.router.ts b/src/api/integrations/channel/meta/meta.router.ts index b0fc43ce4d..82e7cbf142 100644 --- a/src/api/integrations/channel/meta/meta.router.ts +++ b/src/api/integrations/channel/meta/meta.router.ts @@ -1,9 +1,12 @@ import { RouterBroker } from '@api/abstract/abstract.router'; import { metaController } from '@api/server.module'; -import { ConfigService, WaBusiness } from '@config/env.config'; +import { ConfigService, Log, WaBusiness } from '@config/env.config'; +import { Logger } from '@config/logger.config'; import { Router } from 'express'; export class MetaRouter extends RouterBroker { + private readonly logger = new Logger('MetaRouter'); + constructor(readonly configService: ConfigService) { super(); this.router @@ -14,6 +17,9 @@ export class MetaRouter extends RouterBroker { }) .post(this.routerPath('webhook/meta', false), async (req, res) => { const { body } = req; + if (this.configService.get('LOG').LEVEL.includes('WEBHOOKMETA')) { + this.logger.webhookMeta(JSON.stringify(body)); + } const response = await metaController.receiveWebhook(body); return res.status(200).json(response); diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index 1e4808c156..72e6a6d4d5 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -126,17 +126,48 @@ export class BusinessStartupService extends ChannelStartupService { public async connectToWhatsapp(data?: any): Promise { if (!data) return; - const content = data.entry[0].changes[0].value; + const content = data?.entry?.[0]?.changes?.[0]?.value; + + if (!content) { + this.logger.error('ChannelStartupService -> connectToWhatsapp -> webhook content not found'); + return; + } try { - this.loadChatwoot(); + if ( + Array.isArray(content.statuses) && + content.statuses.length > 0 && + !content.messages?.length && + !content.message_echoes?.length + ) { + await this.eventHandler(content); + return; + } + + const message = Array.isArray(content.messages) ? content.messages[0] : undefined; + + const status = Array.isArray(content.statuses) ? content.statuses[0] : undefined; - this.eventHandler(content); + const messageEcho = Array.isArray(content.message_echoes) ? content.message_echoes[0] : undefined; - this.phoneNumber = createJid(content.messages ? content.messages[0].from : content.statuses[0]?.recipient_id); + const phoneNumber = message?.from ?? status?.recipient_id ?? messageEcho?.to; + + if (!phoneNumber) { + this.logger.error( + 'ChannelStartupService -> connectToWhatsapp -> phone number not found in messages, statuses or message_echoes', + ); + return; + } + + this.phoneNumber = createJid(phoneNumber); + + this.loadChatwoot(); + + await this.eventHandler(content); } catch (error) { this.logger.error(error); - throw new InternalServerErrorException(error?.toString()); + + throw new InternalServerErrorException(error instanceof Error ? error.message : String(error)); } } @@ -382,20 +413,23 @@ export class BusinessStartupService extends ChannelStartupService { return messageType; } - protected async messageHandle(received: any, database: Database, settings: any) { + protected async messageHandle(received: any, database: Database, settings: any, echoRecipient?: string) { try { let messageRaw: any; let pushName: any; - if (received.contacts) pushName = received.contacts[0].profile.name; + const receivedContact = received.contacts[0]; + const remoteJid = receivedContact.profile?.phone || receivedContact.wa_id; + + if (received.contacts) pushName = receivedContact.profile?.name || ''; if (received.messages) { const message = received.messages[0]; // Añadir esta línea para definir message const key = { id: message.id, - remoteJid: this.phoneNumber, - fromMe: message.from === received.metadata.phone_number_id, + remoteJid: echoRecipient ?? this.phoneNumber, + fromMe: !!echoRecipient || message.from === received.metadata.phone_number_id, }; if (message.type === 'sticker') { @@ -668,7 +702,7 @@ export class BusinessStartupService extends ChannelStartupService { sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`); - this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw); + this.sendDataWebhook(Events.MESSAGES_UPSERT, echoRecipient ? { ...messageRaw, to: echoRecipient } : messageRaw); await chatbotController.emit({ instance: { instanceName: this.instance.name, instanceId: this.instanceId }, @@ -698,11 +732,14 @@ export class BusinessStartupService extends ChannelStartupService { } const contact = await this.prismaRepository.contact.findFirst({ - where: { instanceId: this.instanceId, remoteJid: key.remoteJid }, + where: { + instanceId: this.instanceId, + remoteJid: echoRecipient ? { in: [key.remoteJid, remoteJid] } : key.remoteJid, + }, }); const contactRaw: any = { - remoteJid: received.contacts[0].profile.phone, + remoteJid: echoRecipient && contact ? contact.remoteJid : remoteJid, pushName, // profilePicUrl: '', instanceId: this.instanceId, @@ -713,13 +750,6 @@ export class BusinessStartupService extends ChannelStartupService { } if (contact) { - const contactRaw: any = { - remoteJid: received.contacts[0].profile.phone, - pushName, - // profilePicUrl: '', - instanceId: this.instanceId, - }; - this.sendDataWebhook(Events.CONTACTS_UPDATE, contactRaw); if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { @@ -731,7 +761,7 @@ export class BusinessStartupService extends ChannelStartupService { } await this.prismaRepository.contact.updateMany({ - where: { remoteJid: contact.remoteJid }, + where: { instanceId: this.instanceId, remoteJid: contact.remoteJid }, data: contactRaw, }); return; @@ -743,83 +773,123 @@ export class BusinessStartupService extends ChannelStartupService { data: contactRaw, }); } - if (received.statuses) { - for await (const item of received.statuses) { - const key = { - id: item.id, - remoteJid: this.phoneNumber, - fromMe: this.phoneNumber === received.metadata.phone_number_id, - }; - if (settings?.groups_ignore && key.remoteJid.includes('@g.us')) { - return; - } - if (key.remoteJid !== 'status@broadcast' && !key?.remoteJid?.match(/(:\d+)/)) { - const findMessage = await this.prismaRepository.message.findFirst({ - where: { - instanceId: this.instanceId, - key: { - path: ['id'], - equals: key.id, - }, - }, - }); + } catch (error) { + this.logger.error(error); + } + } - if (!findMessage) { - return; - } + protected async messageStatusHandle(received: any) { + const statuses = Array.isArray(received.statuses) ? received.statuses : []; - if (item.message === null && item.status === undefined) { - this.sendDataWebhook(Events.MESSAGES_DELETE, key); - - const message: any = { - messageId: findMessage.id, - keyId: key.id, - remoteJid: key.remoteJid, - fromMe: key.fromMe, - participant: key?.remoteJid, - status: 'DELETED', - instanceId: this.instanceId, - }; + for (const item of statuses) { + const isFailed = typeof item?.status === 'string' && item.status.toUpperCase() === 'FAILED'; + const recipientNumber = + typeof item?.recipient_id === 'string' + ? /^(\d+)(?:@s\.whatsapp\.net)?$/.exec(item.recipient_id)?.[1] + : undefined; - await this.prismaRepository.messageUpdate.create({ - data: message, - }); + if (!item?.id || (!recipientNumber && !isFailed)) { + this.logger.warn('ChannelStartupService -> messageStatusHandle -> message ID or recipient not found'); + continue; + } - if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { - this.chatwootService.eventWhatsapp( - Events.MESSAGES_DELETE, - { instanceName: this.instance.name, instanceId: this.instanceId }, - { key: key }, - ); - } + // A failed delivery can still be correlated by its message ID without a recipient phone number. + const remoteJid = recipientNumber ? `${recipientNumber}@s.whatsapp.net` : null; - return; - } + if (item.message === null && item.status === undefined) { + await this.sendDataWebhook(Events.MESSAGES_DELETE, { id: item.id, remoteJid, fromMe: true }, true, ['webhook']); + continue; + } - const message: any = { - messageId: findMessage.id, - keyId: key.id, - remoteJid: key.remoteJid, - fromMe: key.fromMe, - participant: key?.remoteJid, - status: item.status.toUpperCase(), - instanceId: this.instanceId, - }; + if (typeof item.status !== 'string' || !item.status) { + this.logger.warn('ChannelStartupService -> messageStatusHandle -> message status not found'); + continue; + } - this.sendDataWebhook(Events.MESSAGES_UPDATE, message); + const message = { + ...item, + keyId: item.id, + remoteJid, + fromMe: true, + participant: remoteJid, + status: item.status.toUpperCase(), + instanceId: this.instanceId, + }; - await this.prismaRepository.messageUpdate.create({ - data: message, - }); + if (isFailed) { + this.logger.warn({ + local: 'BusinessStartupService.messageStatusHandle', + message: 'Meta message delivery failed; forwarding status to instance webhook', + instanceId: this.instanceId, + keyId: item.id, + recipientId: item.recipient_id ?? null, + remoteJid, + errors: item.errors, + }); + } - if (findMessage.webhookUrl) { - await axios.post(findMessage.webhookUrl, message); - } - } + await this.sendDataWebhook(Events.MESSAGES_UPDATE, message, true, ['webhook']); + } + } + + protected async messageEchoHandle(received: any, database: Database, settings: any) { + const messageEchoes = Array.isArray(received.message_echoes) ? received.message_echoes : []; + const contacts = Array.isArray(received.contacts) ? received.contacts : []; + + for await (const messageEcho of messageEchoes) { + // Preserve Meta's WhatsApp ID without phone-number rewriting or treating a LID as a phone number. + const remoteNumber = + typeof messageEcho?.to === 'string' ? /^(\d+)(?:@s\.whatsapp\.net)?$/.exec(messageEcho.to)?.[1] : undefined; + + if (!remoteNumber) { + this.logger.error( + 'ChannelStartupService -> messageEchoHandle -> message echo recipient phone number not found', + ); + continue; + } + + const echoRecipient = `${remoteNumber}@s.whatsapp.net`; + this.phoneNumber = echoRecipient; + + const recipientIds = [remoteNumber, echoRecipient]; + const receivedContact = contacts.find((contact) => + recipientIds.includes(contact?.wa_id || contact?.profile?.phone), + ); + let pushName = receivedContact?.profile?.name?.trim() || ''; + + if (!pushName) { + try { + const savedContact = await this.prismaRepository.contact.findFirst({ + where: { + instanceId: this.instanceId, + remoteJid: { in: recipientIds }, + pushName: { not: '' }, + }, + select: { pushName: true }, + }); + pushName = savedContact?.pushName?.trim() || ''; + } catch { + this.logger.warn('ChannelStartupService -> messageEchoHandle -> could not load recipient name'); } } - } catch (error) { - this.logger.error(error); + + const echoReceived = { + ...received, + contacts: [ + { + ...receivedContact, + profile: { + ...receivedContact?.profile, + phone: remoteNumber, + name: pushName, + }, + wa_id: remoteNumber, + }, + ], + messages: [messageEcho], + }; + + await this.messageHandle(echoReceived, database, settings, echoRecipient); } } @@ -899,6 +969,14 @@ export class BusinessStartupService extends ChannelStartupService { this.logger.log('Contenido recibido en eventHandler:'); this.logger.log(JSON.stringify(content, null, 2)); + if (Array.isArray(content.statuses) && content.statuses.length > 0) { + await this.messageStatusHandle(content); + + if (!content.messages?.length && !content.message_echoes?.length) { + return; + } + } + const database = this.configService.get('DATABASE'); const settings = await this.findSettings(); @@ -926,11 +1004,13 @@ export class BusinessStartupService extends ChannelStartupService { } else { this.logger.warn(`Tipo de mensaje no reconocido: ${message.type}`); } - } else if (content.statuses) { - // Procesar actualizaciones de estado - this.messageHandle(content, database, settings); + } else if (content.message_echoes && content.message_echoes.length > 0) { + const messageEcho = content.message_echoes[0]; + this.logger.log(`Tipo de message echo recebido: ${messageEcho.type}`); + + await this.messageEchoHandle(content, database, settings); } else { - this.logger.warn('No se encontraron mensajes ni estados en el contenido recibido'); + this.logger.warn('No se encontraron mensajes, ecos de mensajes ni estados en el contenido recibido'); } } catch (error) { this.logger.error('Error en eventHandler:'); @@ -1016,7 +1096,8 @@ export class BusinessStartupService extends ChannelStartupService { return await this.post(content, 'messages'); } if (message['media']) { - const isImage = message['mimetype']?.startsWith('image/'); + const mimeType = this.normalizeMimeType(message['mimetype']); + const isImage = mimeType.startsWith('image/'); content = { messaging_product: 'whatsapp', @@ -1025,14 +1106,25 @@ export class BusinessStartupService extends ChannelStartupService { to: number.replace(/\D/g, ''), [message['mediaType']]: { [message['type']]: message['id'], + ...(message['mediaType'] !== 'audio' && message['mediaType'] !== 'video' && message['fileName'] && - !isImage && { filename: message['fileName'] }), - ...(message['mediaType'] !== 'audio' && message['caption'] && { caption: message['caption'] }), + !isImage && { + filename: message['fileName'], + }), + + ...(message['mediaType'] !== 'audio' && + message['caption'] && { + caption: message['caption'], + }), }, }; - quoted ? (content.context = { message_id: quoted.id }) : content; + + if (quoted) { + content.context = { message_id: quoted.id }; + } + return await this.post(content, 'messages'); } if (message['audio']) { @@ -1174,6 +1266,35 @@ export class BusinessStartupService extends ChannelStartupService { } } + private normalizeMimeType(value: unknown): string { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + const mimeType = value.find((item) => typeof item === 'string'); + return typeof mimeType === 'string' ? mimeType : ''; + } + + if (value && typeof value === 'object') { + const mimeTypeObject = value as Record; + + const candidates = [ + mimeTypeObject.mimetype, + mimeTypeObject.mimeType, + mimeTypeObject.contentType, + mimeTypeObject.content_type, + mimeTypeObject.type, + ]; + + const mimeType = candidates.find((item) => typeof item === 'string'); + + return typeof mimeType === 'string' ? mimeType : ''; + } + + return ''; + } + // Send Message Controller public async textMessage(data: SendTextDto, isIntegration = false) { const res = await this.sendMessageWithTyping( diff --git a/src/config/env.config.ts b/src/config/env.config.ts index 7c4e382e7e..35c3dcd3ed 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -21,7 +21,17 @@ export type Cors = { export type LogBaileys = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; -export type LogLevel = 'ERROR' | 'WARN' | 'DEBUG' | 'INFO' | 'LOG' | 'VERBOSE' | 'DARK' | 'WEBHOOKS' | 'WEBSOCKET'; +export type LogLevel = + | 'ERROR' + | 'WARN' + | 'DEBUG' + | 'INFO' + | 'LOG' + | 'VERBOSE' + | 'DARK' + | 'WEBHOOKS' + | 'WEBSOCKET' + | 'WEBHOOKMETA'; export type Log = { LEVEL: LogLevel[]; diff --git a/src/config/logger.config.ts b/src/config/logger.config.ts index bc27db5ce5..a3ccf2f094 100644 --- a/src/config/logger.config.ts +++ b/src/config/logger.config.ts @@ -18,6 +18,7 @@ enum Color { DEBUG = '\x1b[36m', VERBOSE = '\x1b[37m', DARK = '\x1b[30m', + WEBHOOKMETA = '\x1b[35m', } enum Command { @@ -34,6 +35,7 @@ enum Level { ERROR = Color.ERROR + '%s' + Command.RESET, DEBUG = Color.DEBUG + '%s' + Command.RESET, VERBOSE = Color.VERBOSE + '%s' + Command.RESET, + WEBHOOKMETA = Color.WEBHOOKMETA + '%s' + Command.RESET, } enum Type { @@ -44,6 +46,7 @@ enum Type { ERROR = 'ERROR', DEBUG = 'DEBUG', VERBOSE = 'VERBOSE', + WEBHOOKMETA = 'WEBHOOKMETA', } enum Background { @@ -54,6 +57,7 @@ enum Background { ERROR = '\x1b[41m', DEBUG = '\x1b[46m', VERBOSE = '\x1b[47m', + WEBHOOKMETA = '\x1b[45m', } export class Logger { @@ -151,4 +155,8 @@ export class Logger { public dark(value: any) { this.console(value, Type.DARK); } + + public webhookMeta(value: any) { + this.console(value, Type.WEBHOOKMETA); + } } diff --git a/test/meta-message-status.test.cjs b/test/meta-message-status.test.cjs new file mode 100644 index 0000000000..7a5ba74775 --- /dev/null +++ b/test/meta-message-status.test.cjs @@ -0,0 +1,502 @@ +const assert = require('node:assert/strict'); +const { once } = require('node:events'); +const { readFileSync } = require('node:fs'); +const { createServer } = require('node:http'); +const { join } = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); +const ts = require('typescript'); +const axios = require('axios'); +const express = require('express'); + +const filename = join(__dirname, '../src/api/integrations/channel/meta/whatsapp.business.service.ts'); +const { outputText } = ts.transpileModule(readFileSync(filename, 'utf8'), { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, esModuleInterop: true }, +}); +const moduleExports = {}; +const dependencies = { + '@api/integrations/storage/s3/libs/minio.server': {}, + '@api/server.module': {}, + '@api/services/channel.service': { ChannelStartupService: class {} }, + '@api/types/wa.types': { Events: { MESSAGES_UPDATE: 'messages.update', MESSAGES_DELETE: 'messages.delete' } }, + '@exceptions': { InternalServerErrorException: Error }, + '@utils/createJid': {}, + '@utils/renderStatus': {}, + '@utils/sendTelemetry': {}, + axios: {}, + 'class-validator': {}, + 'form-data': {}, + 'mime-types': {}, + path: require('node:path'), +}; + +// Mock infrastructure imports so loading the service does not start application connections. +vm.runInNewContext( + outputText, + { + exports: moduleExports, + require(name) { + assert.ok(Object.hasOwn(dependencies, name), `Unexpected dependency: ${name}`); + return dependencies[name]; + }, + }, + { filename }, +); + +async function receive(statuses) { + const events = []; + const warnings = []; + const errors = []; + const forbiddenCalls = []; + const forbid = (name) => { + forbiddenCalls.push(name); + throw new Error(`Unexpected status-processing dependency: ${name}`); + }; + const service = Object.assign(Object.create(moduleExports.BusinessStartupService.prototype), { + instanceId: 'instance-under-test', + phoneNumber: '5511888888888@s.whatsapp.net', + configService: { get: () => forbid('configService.get') }, + prismaRepository: new Proxy({}, { get: (_, name) => forbid(`prisma.${String(name)}`) }), + findSettings: () => forbid('findSettings'), + loadChatwoot: () => forbid('loadChatwoot'), + logger: { + log() {}, + warn(value) { + warnings.push(value); + }, + error(value) { + errors.push(value); + }, + }, + async sendDataWebhook(event, data, local, integration) { + events.push(JSON.parse(JSON.stringify({ event, data, local, integration }))); + }, + }); + const payload = { + object: 'whatsapp_business_account', + entry: [ + { + changes: [ + { + field: 'messages', + value: { + metadata: { phone_number_id: 'business-phone-number-id' }, + statuses, + }, + }, + ], + }, + ], + }; + const original = JSON.stringify(payload); + + await service.connectToWhatsapp(payload); + + assert.deepEqual(errors, []); + assert.deepEqual(forbiddenCalls, [], 'Status forwarding must not depend on database operations'); + assert.equal(JSON.stringify(payload), original, 'The Meta payload must not be mutated'); + return { events, warnings }; +} + +const failed = { + id: 'wamid.marketing-message', + status: 'failed', + timestamp: '1700000000', + recipient_id: '5531999999999', + errors: [ + { + code: 131049, + title: 'Meta chose not to deliver', + error_data: { details: 'This message was not delivered to maintain healthy ecosystem engagement.' }, + }, + ], +}; + +test('forwards marketing delivery errors with their original message ID and details', async () => { + const { events, warnings } = await receive([failed]); + assert.equal(events.length, 1); + assert.equal(events[0].event, 'messages.update'); + assert.equal(events[0].local, true); + assert.deepEqual(events[0].integration, ['webhook']); + assert.equal(events[0].data.keyId, failed.id); + assert.equal(events[0].data.status, 'FAILED'); + assert.equal(events[0].data.fromMe, true); + assert.equal(events[0].data.instanceId, 'instance-under-test'); + assert.equal(events[0].data.remoteJid, '5531999999999@s.whatsapp.net'); + assert.deepEqual(events[0].data.errors, failed.errors); + assert.ok(warnings.some((warning) => warning.keyId === failed.id && warning.errors?.[0]?.code === 131049)); +}); + +for (const recipient of [undefined, null, '123456789012345@lid', 'business-scoped-user-id']) { + test(`forwards failures without a usable phone number: ${String(recipient)}`, async () => { + const { events } = await receive([{ ...failed, recipient_id: recipient }]); + assert.equal(events.length, 1, 'A known message failure must not be discarded because of its recipient'); + assert.equal(events[0].data.keyId, failed.id); + assert.equal(events[0].data.status, 'FAILED'); + assert.equal(events[0].data.remoteJid, null); + assert.equal(events[0].data.participant, null); + assert.equal(events[0].data.recipient_id, recipient); + assert.deepEqual(events[0].data.errors, failed.errors); + }); +} + +test('keeps valid success notifications unchanged', async () => { + const statuses = ['sent', 'delivered', 'read'].map((status) => ({ + id: `wamid.${status}`, + recipient_id: '5511999999999', + status, + })); + const { events } = await receive(statuses); + assert.deepEqual( + events.map(({ data }) => data.status), + ['SENT', 'DELIVERED', 'READ'], + ); + assert.ok(events.every(({ data }) => data.remoteJid === '5511999999999@s.whatsapp.net')); +}); + +test('does not lose later failures after an invalid status in the same batch', async () => { + const { events } = await receive([ + null, + { status: 'failed', errors: failed.errors }, + { id: 'wamid.invalid', status: 'sent' }, + { ...failed, recipient_id: undefined }, + { ...failed, id: 'wamid.other-message', recipient_id: '15551234567' }, + ]); + assert.deepEqual( + events.map(({ data }) => data.keyId), + [failed.id, 'wamid.other-message'], + ); +}); + +function loadService(relativePath, mocks, globals = {}) { + const sourcePath = join(__dirname, '..', relativePath); + const compiled = ts.transpileModule(readFileSync(sourcePath, 'utf8'), { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, esModuleInterop: true }, + }); + const exports = {}; + vm.runInNewContext( + compiled.outputText, + { + exports, + require(name) { + assert.ok(Object.hasOwn(mocks, name), `Unexpected dependency in ${relativePath}: ${name}`); + return mocks[name]; + }, + setTimeout, + ...globals, + }, + { filename: sourcePath }, + ); + return exports; +} + +const examplePayload = { + object: 'whatsapp_business_account', + entry: [ + { + id: 'WHATSAPP_BUSINESS_ACCOUNT_ID', + changes: [ + { + value: { + messaging_product: 'whatsapp', + metadata: { display_phone_number: 'PHONE_NUMBER', phone_number_id: 'PHONE_NUMBER_ID' }, + statuses: [ + { + id: 'wamid.HBgNNTUx...', + status: 'failed', + timestamp: '1710000000', + recipient_id: 'RECIPIENT_PHONE_NUMBER', + errors: [ + { + code: 131049, + title: 'Message undeliverable', + message: 'This message was not delivered to maintain healthy ecosystem engagement.', + error_data: { details: 'This message was not delivered to maintain healthy ecosystem engagement.' }, + }, + ], + }, + ], + }, + field: 'messages', + }, + ], + }, + ], +}; + +async function receiveOverHttp(t, payload, options = {}) { + const requests = []; + const logs = []; + const queries = []; + const serverModule = {}; + const monitor = { waInstances: {} }; + const metadata = payload.entry[0].changes[0].value.metadata; + const instance = { + id: 'instance-under-test', + name: 'meta-instance', + number: options.instanceNumber ?? metadata.phone_number_id, + token: 'test-token', + wuid: '5511000000000@s.whatsapp.net', + }; + let webhookUrl; + const configService = { + get(name) { + const config = { + SERVER: { URL: 'http://localhost' }, + AUTHENTICATION: { EXPOSE_IN_FETCH_INSTANCES: false }, + LOG: { LEVEL: options.logLevels ?? [] }, + WEBHOOK: { + GLOBAL: { ENABLED: false, URL: '' }, + RETRY: { MAX_ATTEMPTS: 1 }, + REQUEST: { TIMEOUT_MS: 2000 }, + }, + }; + assert.ok(Object.hasOwn(config, name), `Unexpected configuration lookup: ${name}`); + return config[name]; + }, + }; + const repository = new Proxy( + { + instance: { + async findMany({ where }) { + queries.push({ model: 'instance', where: JSON.parse(JSON.stringify(where)) }); + return where.number === instance.number ? [instance] : []; + }, + }, + webhook: { + async findUnique({ where }) { + assert.equal(where.instanceId, instance.id); + queries.push({ model: 'webhook', where: JSON.parse(JSON.stringify(where)) }); + return { enabled: true, events: ['MESSAGES_UPDATE'], url: webhookUrl }; + }, + }, + }, + { + get(target, name) { + assert.ok(Object.hasOwn(target, name), `Unexpected database access: ${String(name)}`); + return target[name]; + }, + }, + ); + class Logger { + webhookMeta(value) { + logs.push({ level: 'WEBHOOKMETA', value }); + } + log(value) { + logs.push({ level: 'log', value }); + } + warn(value) { + logs.push({ level: 'warn', value }); + } + error(value) { + logs.push({ level: 'error', value }); + } + } + const { ChannelStartupService } = loadService('src/api/services/channel.service.ts', { + '@api/integrations/chatbot/chatwoot/services/chatwoot.service': {}, + '@api/integrations/chatbot/dify/services/dify.service': {}, + '@api/integrations/chatbot/openai/services/openai.service': {}, + '@api/integrations/chatbot/typebot/services/typebot.service': {}, + '@api/server.module': serverModule, + '@api/types/wa.types': dependencies['@api/types/wa.types'], + '@config/logger.config': { Logger }, + '@exceptions': {}, + '@prisma/client': {}, + '@utils/createJid': {}, + 'class-validator': {}, + uuid: {}, + }); + const { EventController } = loadService('src/api/integrations/event/event.controller.ts', {}); + const { WebhookController } = loadService('src/api/integrations/event/webhook/webhook.controller.ts', { + '@config/env.config': { configService }, + '@config/logger.config': { Logger }, + '../event.controller': { EventController }, + jsonwebtoken: {}, + axios: { create: (config) => axios.create({ ...config, proxy: false }) }, + }); + class InactiveIntegration { + async emit(event) { + assert.deepEqual(Array.from(event.integration), ['webhook']); + } + } + const { EventManager } = loadService('src/api/integrations/event/event.manager.ts', { + '@api/integrations/event/kafka/kafka.controller': { KafkaController: InactiveIntegration }, + '@api/integrations/event/nats/nats.controller': { NatsController: InactiveIntegration }, + '@api/integrations/event/pusher/pusher.controller': { PusherController: InactiveIntegration }, + '@api/integrations/event/rabbitmq/rabbitmq.controller': { RabbitmqController: InactiveIntegration }, + '@api/integrations/event/sqs/sqs.controller': { SqsController: InactiveIntegration }, + '@api/integrations/event/webhook/webhook.controller': { WebhookController }, + '@api/integrations/event/websocket/websocket.controller': { WebsocketController: InactiveIntegration }, + }); + serverModule.eventManager = new EventManager(repository, monitor); + const service = Object.assign(Object.create(moduleExports.BusinessStartupService.prototype), { + instance, + instanceId: instance.id, + token: instance.token, + wuid: instance.wuid, + logger: new Logger(), + configService, + prismaRepository: repository, + sendDataWebhook: ChannelStartupService.prototype.sendDataWebhook, + }); + monitor.waInstances[instance.name] = service; + const { ChannelController } = loadService('src/api/integrations/channel/channel.controller.ts', { + '@api/types/wa.types': {}, + '@exceptions': {}, + './evolution/evolution.channel.service': {}, + './meta/whatsapp.business.service': {}, + './whatsapp/whatsapp.baileys.service': {}, + }); + const { MetaController } = loadService('src/api/integrations/channel/meta/meta.controller.ts', { + '@config/logger.config': { Logger }, + '../channel.controller': { ChannelController }, + axios, + }); + serverModule.metaController = new MetaController(repository, monitor); + const { RouterBroker } = loadService('src/api/abstract/abstract.router.ts', { + 'express-async-errors': require('express-async-errors'), + '@config/logger.config': { Logger }, + '@exceptions': {}, + jsonschema: {}, + }); + const { MetaRouter } = loadService('src/api/integrations/channel/meta/meta.router.ts', { + '@api/abstract/abstract.router': { RouterBroker }, + '@api/server.module': serverModule, + '@config/logger.config': { Logger }, + express, + }); + const app = express(); + app.use(express.json()); + app.post('/instance-webhook', (req, res) => { + requests.push(req.body); + res.status(options.receiverStatus ?? 200).json({ received: true }); + }); + app.use(new MetaRouter(configService).router); + const server = createServer(app); + t.after( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }), + ); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const baseURL = `http://127.0.0.1:${server.address().port}`; + webhookUrl = `${baseURL}/instance-webhook`; + const response = await axios.post(`${baseURL}/webhook/meta`, payload, { proxy: false, timeout: 5000 }); + assert.equal(response.status, 200); + assert.deepEqual(response.data, { status: 'success' }); + return { requests, logs, queries }; +} + +test('accepts the exact reported payload and delivers its errors through the HTTP webhook', async (t) => { + const { requests, logs, queries } = await receiveOverHttp(t, examplePayload); + assert.equal(requests.length, 1); + assert.equal(requests[0].event, 'messages.update'); + assert.equal(requests[0].instance, 'meta-instance'); + assert.equal(requests[0].data.status, 'FAILED'); + assert.equal(requests[0].data.keyId, 'wamid.HBgNNTUx...'); + assert.equal(requests[0].data.remoteJid, null); + assert.deepEqual(requests[0].data.errors, examplePayload.entry[0].changes[0].value.statuses[0].errors); + assert.deepEqual( + logs.filter(({ level }) => level === 'error'), + [], + ); + assert.deepEqual( + queries.map(({ model }) => model), + ['instance', 'webhook'], + ); +}); + +test('delivers the reported payload with a real phone-number format and preserves its WhatsApp ID', async (t) => { + const payload = structuredClone(examplePayload); + payload.entry[0].changes[0].value.statuses[0].recipient_id = '5531999999999'; + const { requests, logs } = await receiveOverHttp(t, payload); + assert.equal(requests.length, 1); + assert.equal(requests[0].data.remoteJid, '5531999999999@s.whatsapp.net'); + assert.deepEqual(requests[0].data.errors, payload.entry[0].changes[0].value.statuses[0].errors); + assert.deepEqual( + logs.filter(({ level }) => level === 'error'), + [], + ); +}); + +test('identifies missing instance routing even when the Meta endpoint acknowledges the payload', async (t) => { + const { requests, logs } = await receiveOverHttp(t, examplePayload, { instanceNumber: 'ANOTHER_PHONE_NUMBER_ID' }); + assert.equal(requests.length, 0); + assert.ok(logs.some(({ value }) => String(value).includes('instances not found for numberId: PHONE_NUMBER_ID'))); +}); + +test('records a webhook receiver rejection even when the Meta endpoint acknowledges the payload', async (t) => { + const { requests, logs } = await receiveOverHttp(t, examplePayload, { receiverStatus: 400 }); + assert.equal(requests.length, 1); + assert.ok(logs.some(({ level, value }) => level === 'error' && value.statusCode === 400)); +}); + +test('does not log the incoming Meta payload without WEBHOOKMETA', async (t) => { + const { requests, logs } = await receiveOverHttp(t, examplePayload, { logLevels: ['LOG', 'WEBHOOKS'] }); + assert.equal(requests.length, 1); + assert.equal(logs.filter(({ level }) => level === 'WEBHOOKMETA').length, 0); +}); + +test('logs the entire original Meta batch once with WEBHOOKMETA enabled', async (t) => { + const payload = structuredClone(examplePayload); + payload.entry.push(structuredClone(payload.entry[0])); + payload.entry[1].id = 'SECOND_WHATSAPP_BUSINESS_ACCOUNT_ID'; + const { requests, logs } = await receiveOverHttp(t, payload, { logLevels: ['WEBHOOKMETA'] }); + const metaLogs = logs.filter(({ level }) => level === 'WEBHOOKMETA'); + assert.equal(metaLogs.length, 1); + assert.deepEqual(JSON.parse(metaLogs[0].value), payload); + assert.equal(requests.length, 2); + assert.equal(logs[0].level, 'WEBHOOKMETA', 'The original payload must be logged before status processing'); +}); + +test('logs the original Meta payload even when no matching instance exists', async (t) => { + const { requests, logs } = await receiveOverHttp(t, examplePayload, { + logLevels: ['WEBHOOKMETA'], + instanceNumber: 'ANOTHER_PHONE_NUMBER_ID', + }); + assert.equal(requests.length, 0); + assert.equal(logs[0].level, 'WEBHOOKMETA'); + assert.deepEqual(JSON.parse(logs[0].value), examplePayload); + assert.ok(logs.some(({ level }) => level === 'error')); +}); + +test('logs the original Meta payload before controller object filtering', async (t) => { + const payload = { ...examplePayload, object: 'unsupported_object' }; + const { requests, logs, queries } = await receiveOverHttp(t, payload, { logLevels: ['WEBHOOKMETA'] }); + assert.equal(requests.length, 0); + assert.equal(queries.length, 0); + assert.equal(logs[0].level, 'WEBHOOKMETA'); + assert.deepEqual(JSON.parse(logs[0].value), payload); +}); + +for (const color of [false, true]) { + test(`WEBHOOKMETA logger works without LOG and respects its own flag (color: ${color})`, () => { + const output = []; + const logConfig = { LEVEL: ['WEBHOOKMETA'], COLOR: color }; + const { Logger } = loadService( + 'src/config/logger.config.ts', + { + './env.config': { configService: { get: () => logConfig } }, + dayjs: require('dayjs'), + fs: { readFileSync: () => JSON.stringify({ version: 'test' }) }, + }, + { process: { pid: 1 }, console: { log: (...args) => output.push(args) } }, + ); + const logger = new Logger('MetaRouter'); + const payload = JSON.stringify(examplePayload); + logger.webhookMeta(payload); + logger.log('This level is disabled'); + assert.equal(output.length, 1); + assert.ok(output[0].includes(payload), 'Nested errors must not be truncated by object inspection'); + assert.ok(output[0].some((part) => part.includes('WEBHOOKMETA'))); + assert.ok(output[0].some((part) => part.includes('[MetaRouter]'))); + assert.ok(output[0].every((part) => !part.includes('undefined'))); + + logConfig.LEVEL = ['LOG', 'WEBHOOKS']; + logger.webhookMeta(payload); + assert.equal(output.length, 1, 'WEBHOOKMETA must not log when only other levels are enabled'); + }); +}