diff --git a/chatwoot-adapter/CHANGELOG.md b/chatwoot-adapter/CHANGELOG.md index eb1337b..20c141b 100644 --- a/chatwoot-adapter/CHANGELOG.md +++ b/chatwoot-adapter/CHANGELOG.md @@ -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:`. 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..edcf89f 100644 --- a/chatwoot-adapter/chatwoot-client.test.ts +++ b/chatwoot-adapter/chatwoot-client.test.ts @@ -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'); diff --git a/chatwoot-adapter/chatwoot-client.ts b/chatwoot-adapter/chatwoot-client.ts index e70b5cc..8a55a10 100644 --- a/chatwoot-adapter/chatwoot-client.ts +++ b/chatwoot-adapter/chatwoot-client.ts @@ -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 { const { data } = await this.json<{ payload?: { source_id?: string }; source_id?: string }>( `${this.base()}/contacts/${contactId}/contact_inboxes`,