Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions src/api/integrations/chatbot/chatwoot/chatwootGroupParticipant.ts
Original file line number Diff line number Diff line change
@@ -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}:**` : '';
}
22 changes: 5 additions & 17 deletions src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 || '';
}
Expand Down Expand Up @@ -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}`;
}
Expand Down
82 changes: 82 additions & 0 deletions test/xmacna-chatwoot-group-participant.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, 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']);
});
Loading