From 5a6a4d391ee0f291033eae9a73ab455888c05b42 Mon Sep 17 00:00:00 2001 From: Kirlos Osama <74070855+ker00sama-dev@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:29:47 +0100 Subject: [PATCH 1/2] fix(chatwoot-adapter): adopt a contact that owns the phone under a different identifier A contact created by hand, by another integration, or before the adapter took over the inbox can hold the chat's phone number without the JID identifier. createContact then 422s, the identifier fallback search misses it, and the same collision recurs on every delivery - each message from that chat burns its retry budget into the dead-letter queue. On a 422 with no identifier match, fall back to searching by phone, adopt the matching contact, and re-key its identifier to the JID so future lookups resolve directly. The re-key is best-effort: if it fails, the phone match alone still delivers the message. Reproduced against a live OpenWA 0.12.1 host (Baileys) and self-hosted Chatwoot v4.16.2, where an API-channel inbox previously fed by a custom bridge had contacts keyed "wa:" - every message from those chats dead-lettered until the contacts were re-keyed by hand, which this change automates. Co-Authored-By: Claude Fable 5 --- chatwoot-adapter/CHANGELOG.md | 21 ++++++++++++ chatwoot-adapter/chatwoot-client.test.ts | 41 ++++++++++++++++++++++++ chatwoot-adapter/chatwoot-client.ts | 24 ++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/chatwoot-adapter/CHANGELOG.md b/chatwoot-adapter/CHANGELOG.md index eb1337b..c504b92 100644 --- a/chatwoot-adapter/CHANGELOG.md +++ b/chatwoot-adapter/CHANGELOG.md @@ -22,6 +22,27 @@ 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. + +### 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:`. 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 diff --git a/chatwoot-adapter/chatwoot-client.test.ts b/chatwoot-adapter/chatwoot-client.test.ts index cd0d0cd..c5466dc 100644 --- a/chatwoot-adapter/chatwoot-client.test.ts +++ b/chatwoot-adapter/chatwoot-client.test.ts @@ -40,6 +40,47 @@ 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 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'); diff --git a/chatwoot-adapter/chatwoot-client.ts b/chatwoot-adapter/chatwoot-client.ts index e70b5cc..8ab66a0 100644 --- a/chatwoot-adapter/chatwoot-client.ts +++ b/chatwoot-adapter/chatwoot-client.ts @@ -78,11 +78,35 @@ export class ChatwootClient { 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; 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; + 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 { const { data } = await this.json<{ payload?: { source_id?: string }; source_id?: string }>( `${this.base()}/contacts/${contactId}/contact_inboxes`, From 4aab0bc8582408e7a1f03617f1cd6570c45c99e3 Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Wed, 5 Aug 2026 20:24:48 +0700 Subject: [PATCH 2/2] fix(chatwoot-adapter): don't re-key a contact already keyed to a WhatsApp JID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adoption-by-phone rewrote the matched contact's identifier unconditionally. 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 — and overwriting it there flips the contact between the two forms on every mapping-loss event, which is the opposite of the stable keying the identifier exists to provide. The contact is still adopted, so the message is delivered either way; only the rewrite is skipped. Also corrects the comment above the 422 branch, which claimed Chatwoot does not enforce phone uniqueness — the branch immediately below it depends on the fact that it does. --- chatwoot-adapter/CHANGELOG.md | 3 ++- chatwoot-adapter/chatwoot-client.test.ts | 15 +++++++++++++++ chatwoot-adapter/chatwoot-client.ts | 23 +++++++++++++++++------ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/chatwoot-adapter/CHANGELOG.md b/chatwoot-adapter/CHANGELOG.md index c504b92..20c141b 100644 --- a/chatwoot-adapter/CHANGELOG.md +++ b/chatwoot-adapter/CHANGELOG.md @@ -33,7 +33,8 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form 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. + 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 diff --git a/chatwoot-adapter/chatwoot-client.test.ts b/chatwoot-adapter/chatwoot-client.test.ts index c5466dc..edcf89f 100644 --- a/chatwoot-adapter/chatwoot-client.test.ts +++ b/chatwoot-adapter/chatwoot-client.test.ts @@ -57,6 +57,21 @@ test('createContact on 422 adopts a contact that owns the phone under a differen 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' } }, diff --git a/chatwoot-adapter/chatwoot-client.ts b/chatwoot-adapter/chatwoot-client.ts index 8ab66a0..8a55a10 100644 --- a/chatwoot-adapter/chatwoot-client.ts +++ b/chatwoot-adapter/chatwoot-client.ts @@ -74,7 +74,8 @@ 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)) }; @@ -95,14 +96,24 @@ export class ChatwootClient { // 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; phone_number?: string; contact_inboxes?: Array<{ inbox?: { id?: number }; source_id?: string }> }>; + 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; - 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. + // 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 }; }