From 02298134d2a44f8ac1065dae43167ddf9decca73 Mon Sep 17 00:00:00 2001 From: Vilsonei Machado Date: Mon, 13 Jul 2026 12:20:38 -0300 Subject: [PATCH 01/10] fix(meta): prevent TypeError when contact name is missing --- .../channel/meta/whatsapp.business.service.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index 1e4808c156..f1c67cf88e 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -387,7 +387,10 @@ export class BusinessStartupService extends ChannelStartupService { 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 @@ -702,7 +705,7 @@ export class BusinessStartupService extends ChannelStartupService { }); const contactRaw: any = { - remoteJid: received.contacts[0].profile.phone, + remoteJid, pushName, // profilePicUrl: '', instanceId: this.instanceId, @@ -714,7 +717,7 @@ export class BusinessStartupService extends ChannelStartupService { if (contact) { const contactRaw: any = { - remoteJid: received.contacts[0].profile.phone, + remoteJid, pushName, // profilePicUrl: '', instanceId: this.instanceId, From 694dd65233982fcd560f25249f91b05f9d7669a2 Mon Sep 17 00:00:00 2001 From: Vilsonei Machado Date: Mon, 13 Jul 2026 12:35:55 -0300 Subject: [PATCH 02/10] fix(meta): dispatch webhooks to all instances linked to the same phone number Replace findFirst() with findMany() to support multiple instances per phone number. Replace async forEach() with safe asynchronous iteration. Dispatch webhooks concurrently using Promise.allSettled(). Ensure one instance failure does not prevent delivery to the remaining instances. Add validation and logging for missing or unloaded instances. Process each webhook change independently before dispatching. --- .../channel/meta/meta.controller.ts | 132 +++++++++++++----- 1 file changed, 98 insertions(+), 34 deletions(-) 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 { From 1cb137a88066845e5ac9cefa9e54256b248679c1 Mon Sep 17 00:00:00 2001 From: Vilsonei Machado Date: Mon, 13 Jul 2026 14:09:44 -0300 Subject: [PATCH 03/10] fix(meta): handle message echoes when resolving webhook phone number --- .../channel/meta/whatsapp.business.service.ts | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index f1c67cf88e..95eec55e16 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -126,17 +126,38 @@ 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(); + const message = Array.isArray(content.messages) ? content.messages[0] : undefined; + + const status = Array.isArray(content.statuses) ? content.statuses[0] : undefined; + + const messageEcho = Array.isArray(content.message_echoes) ? content.message_echoes[0] : undefined; + + const phoneNumber = message?.from ?? status?.recipient_id ?? messageEcho?.to; - this.eventHandler(content); + if (!phoneNumber) { + this.logger.error( + 'ChannelStartupService -> connectToWhatsapp -> phone number not found in messages, statuses or message_echoes', + ); + return; + } + + this.phoneNumber = createJid(phoneNumber); - this.phoneNumber = createJid(content.messages ? content.messages[0].from : content.statuses[0]?.recipient_id); + 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)); } } From ade26ff6bca3cf1a4d62974e91184dce18acd848 Mon Sep 17 00:00:00 2001 From: Vilsonei Machado Date: Tue, 14 Jul 2026 15:30:00 -0300 Subject: [PATCH 04/10] fix(database): change instance token column to TEXT for WABA access tokens --- prisma/mysql-schema.prisma | 2 +- .../migration.sql | 2 ++ prisma/postgresql-migrations/migration_lock.toml | 2 +- prisma/postgresql-schema.prisma | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 prisma/postgresql-migrations/20260714182607_20260714152530_change_type_instance_token/migration.sql 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 From ccf7ba35dd895868dcd86282e8fb2326c4db5143 Mon Sep 17 00:00:00 2001 From: Vilsonei Machado Date: Tue, 14 Jul 2026 15:50:33 -0300 Subject: [PATCH 05/10] fix(chatwoot): improve media MIME type handling for outgoing messages --- .../channel/meta/whatsapp.business.service.ts | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index 95eec55e16..ca32165b76 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -1040,7 +1040,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', @@ -1049,14 +1050,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']) { @@ -1198,6 +1210,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( From 4fe005ad31b57f09049538ee56512ca1cdb1395a Mon Sep 17 00:00:00 2001 From: Vilsonei Machado Date: Wed, 16 Sep 2026 11:40:53 -0300 Subject: [PATCH 06/10] feat(meta): retransmit smb message echoes --- .../channel/meta/whatsapp.business.service.ts | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index ca32165b76..ceaae592fb 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -847,6 +847,47 @@ export class BusinessStartupService extends ChannelStartupService { } } + protected async messageEchoHandle(received: any, database: Database, settings: any) { + const messageEchoes = Array.isArray(received.message_echoes) ? received.message_echoes : []; + + for await (const messageEcho of messageEchoes) { + const remoteNumber = messageEcho?.to; + + if (!remoteNumber) { + this.logger.error('ChannelStartupService -> messageEchoHandle -> message echo recipient not found'); + continue; + } + + this.phoneNumber = createJid(remoteNumber); + + const businessNumber = + messageEcho?.from ?? received.metadata?.display_phone_number ?? received.metadata?.phone_number_id; + + const echoReceived = { + ...received, + metadata: { + ...received.metadata, + phone_number_id: businessNumber, + }, + contacts: + Array.isArray(received.contacts) && received.contacts.length > 0 + ? received.contacts + : [ + { + profile: { + phone: remoteNumber, + name: '', + }, + wa_id: remoteNumber, + }, + ], + messages: [messageEcho], + }; + + await this.messageHandle(echoReceived, database, settings); + } + } + private convertMessageToRaw(message: any, content: any) { let convertMessage: any; @@ -950,11 +991,16 @@ export class BusinessStartupService extends ChannelStartupService { } else { this.logger.warn(`Tipo de mensaje no reconocido: ${message.type}`); } + } 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 if (content.statuses) { // Procesar actualizaciones de estado this.messageHandle(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:'); From ac202ca9949ec7fbbcd320aef7b35d5c902f8970 Mon Sep 17 00:00:00 2001 From: Vilsonei Machado Date: Sun, 20 Sep 2026 12:30:45 -0300 Subject: [PATCH 07/10] fix(meta): include recipient ID and name in message echoes --- .../channel/meta/whatsapp.business.service.ts | 88 +++++++++++-------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index ceaae592fb..a217543d48 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -403,7 +403,7 @@ 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; @@ -418,8 +418,8 @@ export class BusinessStartupService extends ChannelStartupService { 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') { @@ -692,7 +692,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 }, @@ -722,11 +722,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, + remoteJid: echoRecipient && contact ? contact.remoteJid : remoteJid, pushName, // profilePicUrl: '', instanceId: this.instanceId, @@ -737,13 +740,6 @@ export class BusinessStartupService extends ChannelStartupService { } if (contact) { - const contactRaw: any = { - remoteJid, - pushName, - // profilePicUrl: '', - instanceId: this.instanceId, - }; - this.sendDataWebhook(Events.CONTACTS_UPDATE, contactRaw); if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { @@ -755,7 +751,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; @@ -849,42 +845,62 @@ export class BusinessStartupService extends ChannelStartupService { 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) { - const remoteNumber = messageEcho?.to; + // 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 not found'); + this.logger.error( + 'ChannelStartupService -> messageEchoHandle -> message echo recipient phone number not found', + ); continue; } - this.phoneNumber = createJid(remoteNumber); + const echoRecipient = `${remoteNumber}@s.whatsapp.net`; + this.phoneNumber = echoRecipient; - const businessNumber = - messageEcho?.from ?? received.metadata?.display_phone_number ?? received.metadata?.phone_number_id; + 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'); + } + } const echoReceived = { ...received, - metadata: { - ...received.metadata, - phone_number_id: businessNumber, - }, - contacts: - Array.isArray(received.contacts) && received.contacts.length > 0 - ? received.contacts - : [ - { - profile: { - phone: remoteNumber, - name: '', - }, - wa_id: remoteNumber, - }, - ], + contacts: [ + { + ...receivedContact, + profile: { + ...receivedContact?.profile, + phone: remoteNumber, + name: pushName, + }, + wa_id: remoteNumber, + }, + ], messages: [messageEcho], }; - await this.messageHandle(echoReceived, database, settings); + await this.messageHandle(echoReceived, database, settings, echoRecipient); } } From 724fbb25040465bb2b235ba1bdf8f126348d9a35 Mon Sep 17 00:00:00 2001 From: Vilsonei Machado Date: Sun, 20 Sep 2026 12:54:12 -0300 Subject: [PATCH 08/10] fix(meta): publish message statuses to instance webhook Publish sent, delivered, read, and failed statuses as messages.update without requiring contacts or messages to exist in the database. Remove status event persistence and preserve the message ID, recipient, and error details provided by Meta. Validated with TypeScript, ESLint, and 25 simulated scenarios. --- .../channel/meta/whatsapp.business.service.ts | 124 ++++++++---------- 1 file changed, 52 insertions(+), 72 deletions(-) diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index a217543d48..026e99fa5c 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -134,6 +134,16 @@ export class BusinessStartupService extends ChannelStartupService { } try { + 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; @@ -763,83 +773,48 @@ 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, - }, - }, - }); - - if (!findMessage) { - return; - } + } catch (error) { + this.logger.error(error); + } + } - 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, - }; + protected async messageStatusHandle(received: any) { + const statuses = Array.isArray(received.statuses) ? received.statuses : []; - await this.prismaRepository.messageUpdate.create({ - data: message, - }); + for (const item of statuses) { + const recipientNumber = + typeof item?.recipient_id === 'string' + ? /^(\d+)(?:@s\.whatsapp\.net)?$/.exec(item.recipient_id)?.[1] + : undefined; - if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { - this.chatwootService.eventWhatsapp( - Events.MESSAGES_DELETE, - { instanceName: this.instance.name, instanceId: this.instanceId }, - { key: key }, - ); - } + if (!item?.id || !recipientNumber) { + this.logger.warn('ChannelStartupService -> messageStatusHandle -> message ID or recipient not found'); + continue; + } - return; - } + const remoteJid = `${recipientNumber}@s.whatsapp.net`; - 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 (item.message === null && item.status === undefined) { + await this.sendDataWebhook(Events.MESSAGES_DELETE, { id: item.id, remoteJid, fromMe: true }, true, ['webhook']); + continue; + } - this.sendDataWebhook(Events.MESSAGES_UPDATE, message); + if (typeof item.status !== 'string' || !item.status) { + this.logger.warn('ChannelStartupService -> messageStatusHandle -> message status not found'); + continue; + } - await this.prismaRepository.messageUpdate.create({ - data: message, - }); + const message = { + ...item, + keyId: item.id, + remoteJid, + fromMe: true, + participant: remoteJid, + status: item.status.toUpperCase(), + instanceId: this.instanceId, + }; - if (findMessage.webhookUrl) { - await axios.post(findMessage.webhookUrl, message); - } - } - } - } - } catch (error) { - this.logger.error(error); + await this.sendDataWebhook(Events.MESSAGES_UPDATE, message, true, ['webhook']); } } @@ -980,6 +955,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(); @@ -1012,9 +995,6 @@ export class BusinessStartupService extends ChannelStartupService { this.logger.log(`Tipo de message echo recebido: ${messageEcho.type}`); await this.messageEchoHandle(content, database, settings); - } else if (content.statuses) { - // Procesar actualizaciones de estado - this.messageHandle(content, database, settings); } else { this.logger.warn('No se encontraron mensajes, ecos de mensajes ni estados en el contenido recibido'); } From 32a91348914a1b6e5fe18cbb247f074888fb689e Mon Sep 17 00:00:00 2001 From: Vilsonei Machado Date: Sun, 20 Sep 2026 13:49:19 -0300 Subject: [PATCH 09/10] fix(meta): forward failed statuses without valid recipient IDs Forward FAILED events when a message ID is available, even if recipient_id is missing or cannot be resolved to a phone number. Preserve error details and use null for unresolved remoteJid values. Add diagnostic logging with the message ID and Meta error details. Add regression tests covering recipient variations, batch processing, and unchanged success notifications without database persistence. Validated with TypeScript, ESLint, and regression tests. --- .gitignore | 3 ++- .../channel/meta/whatsapp.business.service.ts | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) 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/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index 026e99fa5c..72e6a6d4d5 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -782,17 +782,19 @@ export class BusinessStartupService extends ChannelStartupService { const statuses = Array.isArray(received.statuses) ? received.statuses : []; 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; - if (!item?.id || !recipientNumber) { + if (!item?.id || (!recipientNumber && !isFailed)) { this.logger.warn('ChannelStartupService -> messageStatusHandle -> message ID or recipient not found'); continue; } - const remoteJid = `${recipientNumber}@s.whatsapp.net`; + // A failed delivery can still be correlated by its message ID without a recipient phone number. + const remoteJid = recipientNumber ? `${recipientNumber}@s.whatsapp.net` : null; if (item.message === null && item.status === undefined) { await this.sendDataWebhook(Events.MESSAGES_DELETE, { id: item.id, remoteJid, fromMe: true }, true, ['webhook']); @@ -814,6 +816,18 @@ export class BusinessStartupService extends ChannelStartupService { instanceId: this.instanceId, }; + 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, + }); + } + await this.sendDataWebhook(Events.MESSAGES_UPDATE, message, true, ['webhook']); } } From 48e80c55a0d2d56fc4929066878336b0a0e1c668 Mon Sep 17 00:00:00 2001 From: Vilsonei Machado Date: Sun, 20 Sep 2026 14:23:51 -0300 Subject: [PATCH 10/10] feat(meta): add WEBHOOKMETA logging for incoming webhooks - Add WEBHOOKMETA to log levels and environment configuration - Log the complete original Meta payload before processing when enabled - Support WEBHOOKMETA independently of the LOG level - Add tests for conditional logging and payload preservation --- .env.example | 3 +- .../integrations/channel/meta/meta.router.ts | 8 +- src/config/env.config.ts | 12 +- src/config/logger.config.ts | 8 + test/meta-message-status.test.cjs | 502 ++++++++++++++++++ 5 files changed, 530 insertions(+), 3 deletions(-) create mode 100644 test/meta-message-status.test.cjs 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/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/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'); + }); +}