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
22 changes: 22 additions & 0 deletions chatwoot-adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,28 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form
quoted message has no external id (for example a note imported by an external tool without a
`source_id`) goes out unquoted, as before.

### Fixed

- **Inbound messages no longer dead-letter when the phone number already belongs to a Chatwoot contact
keyed under a different identifier.** A contact created by hand, by another integration, or before this
adapter took over the inbox can hold the chat's phone number without the adapter's JID identifier. The
create then 422s, the identifier-based fallback search misses the contact, and — because the same
collision recurs on every delivery — each message from that chat burned its whole retry budget into the
dead-letter queue while the plugin's health stayed green until the retries were exhausted. The adapter
now falls back to searching by phone number, adopts the matching contact, and re-keys its identifier to
the JID so future lookups resolve it directly. The re-key is best-effort: if it fails (say, a
conflicting identifier on another contact), the phone match alone still delivers the message — merging
the duplicate contacts in Chatwoot remains safe, as before. A contact already keyed to a WhatsApp JID is
adopted but never re-keyed, so a chat seen under both JID forms cannot flip the contact back and forth.

### Verified

- **Reproduced and confirmed against a live OpenWA 0.12.1 host** (Baileys engine) and a self-hosted
Chatwoot v4.16.2: an API-channel inbox previously fed by a custom bridge had contacts keyed
`wa:<digits>`. Every inbound WhatsApp message from those chats 422'd and dead-lettered
("2 dead-lettered after 5 attempts"). Manually re-keying the contacts' identifiers to the JID — exactly
what this fix automates — immediately restored inbound relay for those chats.

## [0.8.0] — 2026-08-01

### Added
Expand Down
56 changes: 56 additions & 0 deletions chatwoot-adapter/chatwoot-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,62 @@ test('createContact on 422 re-searches and returns the existing contact (find-ex
assert.deepEqual(await c.createContact('621@c.us', 'Budi'), { id: 11, sourceId: 'src-11' });
});

test('createContact on 422 adopts a contact that owns the phone under a different identifier', async () => {
// A contact created by hand or by another integration holds the phone but not our JID identifier —
// the identifier search misses it, and before the fallback every message from the chat dead-lettered.
const { fn, calls } = fakeFetch({
'POST /api/v1/accounts/3/contacts': { status: 422, body: { message: 'Phone number has already been taken' } },
'GET /api/v1/accounts/3/contacts/search': {
body: { payload: [{ id: 21, identifier: 'wa:628123', phone_number: '+628123', contact_inboxes: [{ inbox: { id: 7 }, source_id: 'src-21' }] }] },
},
'PUT /api/v1/accounts/3/contacts/21': { body: { id: 21 } },
});
const c = new ChatwootClient(fn, cfg);
assert.deepEqual(await c.createContact('628123@c.us', 'Budi', '+628123'), { id: 21, sourceId: 'src-21' });
// The contact is re-keyed to the JID so future identifier searches resolve it directly.
const put = calls.find(x => x.init?.method === 'PUT' && x.url.endsWith('/contacts/21'))!;
assert.deepEqual(JSON.parse(put.init!.body as string), { identifier: '628123@c.us' });
});

test('createContact adoption never re-keys a contact already keyed to a WA JID', async () => {
// The phone can collide with a contact this adapter itself minted under the OTHER JID form (a chat seen
// as @lid before the lid->phone cache warmed, or the reverse). Adopting it is right; overwriting its
// identifier is not — that would flip the contact between the two forms on every mapping-loss event.
const { fn, calls } = fakeFetch({
'POST /api/v1/accounts/3/contacts': { status: 422, body: { message: 'Phone number has already been taken' } },
'GET /api/v1/accounts/3/contacts/search': {
body: { payload: [{ id: 23, identifier: '628123@c.us', phone_number: '+628123', contact_inboxes: [{ inbox: { id: 7 }, source_id: 'src-23' }] }] },
},
});
const c = new ChatwootClient(fn, cfg);
assert.deepEqual(await c.createContact('118367890123478@lid', 'Budi', '+628123'), { id: 23, sourceId: 'src-23' });
assert.equal(calls.some(x => x.init?.method === 'PUT'), false);
});

test('createContact adoption still returns the contact when the identifier re-key fails', async () => {
const { fn } = fakeFetch({
'POST /api/v1/accounts/3/contacts': { status: 422, body: { message: 'taken' } },
'GET /api/v1/accounts/3/contacts/search': {
body: { payload: [{ id: 22, phone_number: '+628123', contact_inboxes: [{ inbox: { id: 7 }, source_id: 'src-22' }] }] },
},
'PUT /api/v1/accounts/3/contacts/22': { status: 422, body: { message: 'identifier taken elsewhere' } },
});
const c = new ChatwootClient(fn, cfg);
assert.deepEqual(await c.createContact('628123@c.us', 'Budi', '+628123'), { id: 22, sourceId: 'src-22' });
});

test('createContact on 422 with no identifier or phone match rethrows the original error', async () => {
const { fn } = fakeFetch({
'POST /api/v1/accounts/3/contacts': { status: 422, body: { message: 'taken' } },
'GET /api/v1/accounts/3/contacts/search': { body: { payload: [{ id: 30, phone_number: '+9999' }] } },
});
const c = new ChatwootClient(fn, cfg);
await assert.rejects(
c.createContact('628123@c.us', 'Budi', '+628123'),
(err: Error & { status?: number }) => err.status === 422,
);
});

test('postText posts an incoming message with the api token header', async () => {
const { fn, calls } = fakeFetch({ 'POST /api/v1/accounts/3/conversations/55/messages': { body: { id: 999 } } });
const res = await new ChatwootClient(fn, cfg).postText(55, 'hello');
Expand Down
37 changes: 36 additions & 1 deletion chatwoot-adapter/chatwoot-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,50 @@ export class ChatwootClient {
const src = contact.contact_inboxes?.find(ci => ci.inbox?.id === this.cfg.inboxId)?.source_id;
return { id: contact.id, sourceId: src ?? (await this.ensureContactInbox(contact.id)) };
} catch (err) {
// 422 "already exists" (Chatwoot doesn't enforce phone uniqueness but does on identifier) → reuse.
// 422 "already exists": Chatwoot enforces uniqueness on BOTH identifier and phone_number, account-wide.
// Try the identifier first (the key this adapter owns), then the phone.
if ((err as { status?: number }).status === 422) {
const found = await this.searchContact(identifier);
if (found) return { id: found.id, sourceId: found.sourceId ?? (await this.ensureContactInbox(found.id)) };
// Identifier free, yet create still 422s: the phone belongs to a contact keyed under a DIFFERENT
// identifier — created by hand, by another integration, or before this adapter took over the inbox.
// Without this fallback the identifier search misses it on every delivery and each message from
// the chat burns its whole retry budget into the dead-letter queue. Adopt that contact instead.
const adopted = phone ? await this.adoptContactByPhone(phone, identifier) : null;
if (adopted) return { id: adopted.id, sourceId: adopted.sourceId ?? (await this.ensureContactInbox(adopted.id)) };
}
throw err;
}
}

// Find the contact that owns `phone` and re-key it to our JID `identifier`, so every future
// searchContact() resolves it directly. The re-key is best-effort: if it fails (e.g. a conflicting
// identifier elsewhere), the phone match alone still threads this message — next delivery just takes
// this fallback again instead of dead-lettering.
private async adoptContactByPhone(phone: string, identifier: string): Promise<{ id: number; sourceId?: string } | null> {
const { data } = await this.json<{
payload?: Array<{
id: number;
identifier?: string;
phone_number?: string;
contact_inboxes?: Array<{ inbox?: { id?: number }; source_id?: string }>;
}>;
}>(`${this.base()}/contacts/search?q=${encodeURIComponent(phone)}`);
const hit = (data.payload ?? []).find(c => c.phone_number === phone);
if (!hit) return null;
// Only re-key a contact that isn't already keyed to a WhatsApp JID. A contact holding one — @lid vs
// @c.us for the same person, say — was minted by this adapter, and overwriting it would flip the
// contact between the two forms on every mapping-loss event. Adopting it is still correct.
if (!/@(c\.us|lid|g\.us)$/.test(hit.identifier ?? '')) {
try {
await this.json(`${this.base()}/contacts/${hit.id}`, { method: 'PUT', body: JSON.stringify({ identifier }) });
} catch {
// Keep the match — adoption is an optimization, not a requirement for delivering this message.
}
}
return { id: hit.id, sourceId: hit.contact_inboxes?.find(ci => ci.inbox?.id === this.cfg.inboxId)?.source_id };
}

async ensureContactInbox(contactId: number): Promise<string> {
const { data } = await this.json<{ payload?: { source_id?: string }; source_id?: string }>(
`${this.base()}/contacts/${contactId}/contact_inboxes`,
Expand Down
Loading