From 7d460e9106e6f51e76cae67ce4291e81440ab2cb Mon Sep 17 00:00:00 2001 From: raffareis <4365623+raffareis@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:05:49 -0300 Subject: [PATCH 1/2] fix(chatwoot): group message without participant or with LID no longer kills the sink (xmacna/elysium#961) The group branches of eventWhatsapp called participant.split() without a guard and parsePhoneNumberFromString(...).formatInternational() without checking the result. Group messages with no participant, or with a LID participant and no participantAlt, threw a TypeError; in enforce the durable inbox retried the receipt 10 times and marked it dead. Resolve the participant as participantAlt (LID addressing) -> participant -> participantAlt, show the raw identifier for LIDs or unparsable numbers, and drop the identifier from the header when there is none. MAR-552 Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- .../chatwoot/chatwootGroupParticipant.ts | 30 +++++++ .../chatwoot/services/chatwoot.service.ts | 22 ++--- .../xmacna-chatwoot-group-participant.test.ts | 82 +++++++++++++++++++ 4 files changed, 118 insertions(+), 18 deletions(-) create mode 100644 src/api/integrations/chatbot/chatwoot/chatwootGroupParticipant.ts create mode 100644 test/xmacna-chatwoot-group-participant.test.ts diff --git a/package.json b/package.json index 854e5a317c..991dda83a5 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "dev:server": "tsx watch ./src/main.ts", "test": "tsx watch ./test/all.test.ts", "test:xmacna-compat": "node --test test/xmacna-baileys-compat.test.cjs", - "test:xmacna-inbox": "tsx --test test/xmacna-s3-storage.test.ts test/xmacna-inbound-inbox.test.ts test/xmacna-inbound-sink-dispatch.test.ts test/xmacna-inbound-service-contracts.test.ts test/xmacna-baileys-message-processor.test.ts test/xmacna-chatbot-debounce.test.ts test/xmacna-outgoing-lid.test.ts test/xmacna-inbound-identity.test.ts test/xmacna-inbound-media-upload.test.ts test/xmacna-inbound-media-guard.test.ts test/xmacna-get-conversation-message.test.ts", + "test:xmacna-inbox": "tsx --test test/xmacna-s3-storage.test.ts test/xmacna-inbound-inbox.test.ts test/xmacna-inbound-sink-dispatch.test.ts test/xmacna-inbound-service-contracts.test.ts test/xmacna-baileys-message-processor.test.ts test/xmacna-chatbot-debounce.test.ts test/xmacna-outgoing-lid.test.ts test/xmacna-inbound-identity.test.ts test/xmacna-inbound-media-upload.test.ts test/xmacna-inbound-media-guard.test.ts test/xmacna-get-conversation-message.test.ts test/xmacna-chatwoot-group-participant.test.ts", "test:xmacna-inbox:postgres": "tsx --test test/xmacna-inbound-inbox.postgres.test.ts test/xmacna-inbound-identity.postgres.test.ts", "inbound-inbox:maintenance": "tsx src/cli/inboundInboxMaintenance.ts", "lint": "eslint --fix --ext .ts src", diff --git a/src/api/integrations/chatbot/chatwoot/chatwootGroupParticipant.ts b/src/api/integrations/chatbot/chatwoot/chatwootGroupParticipant.ts new file mode 100644 index 0000000000..2d58e8fcc9 --- /dev/null +++ b/src/api/integrations/chatbot/chatwoot/chatwootGroupParticipant.ts @@ -0,0 +1,30 @@ +import { parsePhoneNumberFromString } from 'libphonenumber-js'; + +export type GroupParticipantKey = { + participant?: string | null; + participantAlt?: string | null; + addressingMode?: string | null; + fromMe?: boolean | null; +}; + +// xmacna/elysium#961: a group message may arrive without `participant`, or with a LID that is not +// a phone number. Both used to throw inside the Chatwoot sink (`.split` / `.formatInternational` +// on undefined) and the durable inbox retried the receipt until it became `dead`. +export function resolveGroupParticipantJid(key: GroupParticipantKey): string | undefined { + if (key.addressingMode === 'lid' && !key.fromMe && key.participantAlt) return key.participantAlt; + return key.participant || key.participantAlt || undefined; +} + +export function formatGroupParticipantLabel(key: GroupParticipantKey): string | undefined { + const participantJid = resolveGroupParticipantJid(key); + const identifier = participantJid?.split('@')[0]?.split(':')[0]; + if (!identifier) return undefined; + if (participantJid.endsWith('@lid')) return identifier; + + return parsePhoneNumberFromString(`+${identifier}`)?.formatInternational() || identifier; +} + +export function formatGroupParticipantHeader(key: GroupParticipantKey, participantName?: string | null): string { + const author = [formatGroupParticipantLabel(key), participantName].filter(Boolean).join(' - '); + return author ? `**${author}:**` : ''; +} diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts index b45e4bce15..93412051c1 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts @@ -1,6 +1,7 @@ import { InstanceDto } from '@api/dto/instance.dto'; import { Options, Quoted, SendAudioDto, SendMediaDto, SendTextDto } from '@api/dto/sendMessage.dto'; import { resolveInboundMode } from '@api/integrations/channel/whatsapp/inboundInbox'; +import { formatGroupParticipantHeader } from '@api/integrations/chatbot/chatwoot/chatwootGroupParticipant'; import { buildChatwootEditSourceId } from '@api/integrations/chatbot/chatwoot/chatwootMessageIdentity'; import { ChatwootDto } from '@api/integrations/chatbot/chatwoot/dto/chatwoot.dto'; import { postgresClient } from '@api/integrations/chatbot/chatwoot/libs/postgres.client'; @@ -29,7 +30,6 @@ import { WAMessageContent, WAMessageKey } from 'baileys'; import dayjs from 'dayjs'; import FormData from 'form-data'; import { Jimp, JimpMime } from 'jimp'; -import { parsePhoneNumberFromString } from 'libphonenumber-js'; import Long from 'long'; import mimeTypes from 'mime-types'; import path from 'path'; @@ -2111,19 +2111,12 @@ export class ChatwootService { fileStream.push(null); if (body.key.remoteJid.includes('@g.us')) { - const participantName = body.pushName; - const rawPhoneNumber = - body.key.addressingMode === 'lid' && !body.key.fromMe && body.key.participantAlt - ? body.key.participantAlt.split('@')[0].split(':')[0] - : body.key.participant.split('@')[0].split(':')[0]; - const formattedPhoneNumber = parsePhoneNumberFromString(`+${rawPhoneNumber}`).formatInternational(); + const participantHeader = formatGroupParticipantHeader(body.key, body.pushName); let content: string; if (!body.key.fromMe) { - content = bodyMessage - ? `**${formattedPhoneNumber} - ${participantName}:**\n\n${bodyMessage}` - : `**${formattedPhoneNumber} - ${participantName}:**`; + content = [participantHeader, bodyMessage].filter(Boolean).join('\n\n'); } else { content = bodyMessage || ''; } @@ -2294,17 +2287,12 @@ export class ChatwootService { } if (body.key.remoteJid.includes('@g.us')) { - const participantName = body.pushName; - const rawPhoneNumber = - body.key.addressingMode === 'lid' && !body.key.fromMe && body.key.participantAlt - ? body.key.participantAlt.split('@')[0].split(':')[0] - : body.key.participant.split('@')[0].split(':')[0]; - const formattedPhoneNumber = parsePhoneNumberFromString(`+${rawPhoneNumber}`).formatInternational(); + const participantHeader = formatGroupParticipantHeader(body.key, body.pushName); let content: string; if (!body.key.fromMe) { - content = `**${formattedPhoneNumber} - ${participantName}:**\n\n${bodyMessage}`; + content = [participantHeader, bodyMessage].filter(Boolean).join('\n\n'); } else { content = `${bodyMessage}`; } diff --git a/test/xmacna-chatwoot-group-participant.test.ts b/test/xmacna-chatwoot-group-participant.test.ts new file mode 100644 index 0000000000..13137175c2 --- /dev/null +++ b/test/xmacna-chatwoot-group-participant.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + formatGroupParticipantHeader, + formatGroupParticipantLabel, +} from '../src/api/integrations/chatbot/chatwoot/chatwootGroupParticipant'; +import { ChatwootService } from '../src/api/integrations/chatbot/chatwoot/services/chatwoot.service'; + +// xmacna/elysium#961: group messages without `participant`, or whose participant is a LID, threw +// `Cannot read properties of undefined (reading 'split' | 'formatInternational')` in the Chatwoot +// sink. In `enforce` the durable inbox retried the receipt 10 times and marked it `dead`. + +const GROUP = '120363000000000000@g.us'; +const PHONE = '5511987654321@s.whatsapp.net'; +const LID = '123456789012345@lid'; + +test('phone participant keeps the international format', () => { + assert.equal(formatGroupParticipantLabel({ participant: PHONE }), '+55 11 98765 4321'); + assert.equal(formatGroupParticipantLabel({ participant: '5511987654321:12@s.whatsapp.net' }), '+55 11 98765 4321'); +}); + +test('LID addressing prefers participantAlt and falls back to participant', () => { + const lidKey = { addressingMode: 'lid', fromMe: false, participant: LID }; + assert.equal(formatGroupParticipantLabel({ ...lidKey, participantAlt: PHONE }), '+55 11 98765 4321'); + assert.equal(formatGroupParticipantLabel(lidKey), '123456789012345'); +}); + +test('missing participant falls back to participantAlt, then to the push name only', () => { + assert.equal(formatGroupParticipantLabel({ participantAlt: PHONE }), '+55 11 98765 4321'); + assert.equal(formatGroupParticipantLabel({}), undefined); + assert.equal(formatGroupParticipantHeader({}, 'Maria'), '**Maria:**'); + assert.equal(formatGroupParticipantHeader({}, undefined), ''); +}); + +test('identifier that is not a phone number is shown raw', () => { + assert.equal(formatGroupParticipantLabel({ participant: '0@s.whatsapp.net' }), '0'); +}); + +const runGroupText = async (key: Record, pushName?: string) => { + const sent: string[] = []; + const instance = { instanceName: 'local-961' }; + const self = Object.create(ChatwootService.prototype); + Object.assign(self, { + logger: { info: () => undefined, warn: () => undefined, error: () => undefined, verbose: () => undefined }, + provider: {}, + waMonitor: { waInstances: { [instance.instanceName]: { instance: { inboundInboxMode: 'enforce' } } } }, + configService: { get: () => ({ MODE: 'enforce' }) }, + prismaRepository: { message: { findFirst: async () => null } }, + clientCw: async () => ({}), + createConversation: async () => 42, + createMessage: async (_instance: unknown, _conversation: unknown, content: string) => { + sent.push(content); + return { id: 1 }; + }, + }); + + const body = { + key: { id: 'LOCAL961', remoteJid: GROUP, fromMe: false, ...key }, + pushName, + message: { conversation: 'oi' }, + messageType: 'conversation', + }; + const result = await self.eventWhatsapp('messages.upsert', instance, body); + return { result, sent }; +}; + +test('group text without participant reaches Chatwoot instead of throwing', async () => { + const { result, sent } = await runGroupText({}, 'Maria'); + assert.deepEqual(result, { id: 1 }); + assert.deepEqual(sent, ['**Maria:**\n\noi']); +}); + +test('group text from a LID participant without participantAlt reaches Chatwoot', async () => { + const { sent } = await runGroupText({ addressingMode: 'lid', participant: LID }, 'Maria'); + assert.deepEqual(sent, ['**123456789012345 - Maria:**\n\noi']); +}); + +test('group text from a phone participant keeps the existing header', async () => { + const { sent } = await runGroupText({ participant: PHONE }, 'Maria'); + assert.deepEqual(sent, ['**+55 11 98765 4321 - Maria:**\n\noi']); +}); From 5955acd0eb15d9c25659f6716fe6c40dde07aa2b Mon Sep 17 00:00:00 2001 From: raffareis <4365623+raffareis@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:48:10 -0300 Subject: [PATCH 2/2] chore(deps): follow-redirects 1.16.1 (GHSA-q3jh-wrc6-jgq8, GHSA-8r9p-f939-6h3c) Runtime audit gate of validate-inbound-inbox-source fails on 1.16.0. Lockfile-only bump within axios range. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 80e1af9877..2d56a326e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7683,9 +7683,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual",