fix(chatwoot-adapter): adopt a contact that owns the phone under a different identifier - #62
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a production dead-lettering scenario in the Chatwoot adapter where createContact repeatedly fails with 422 when the phone number already belongs to an existing Chatwoot contact keyed under a different identifier. The change adds a phone-based fallback that adopts the existing contact and (best-effort) re-keys its identifier to the WhatsApp JID for stable future lookups.
Changes:
- On
createContact422 wheresearchContact(identifier)misses, fall back to searching by phone number and adopt the matching contact. - Best-effort re-key of the adopted contact’s
identifierto the JID so subsequent deliveries resolve viasearchContact(). - Add three tests covering phone adoption, best-effort re-key failure, and “no match” rethrow behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| chatwoot-adapter/chatwoot-client.ts | Adds phone-based adoption fallback and best-effort identifier re-key on 422 contact creation collisions. |
| chatwoot-adapter/chatwoot-client.test.ts | Adds unit tests for adoption-by-phone behavior and error propagation. |
| chatwoot-adapter/CHANGELOG.md | Documents the dead-lettering fix and production verification details. |
Suppressed comments (1)
chatwoot-adapter/chatwoot-client.ts:101
adoptContactByPhonerequires an exactphone_number === phonematch. Contacts created manually or by other integrations often store formatted numbers (spaces/dashes) whileresolvePhonenormalizes to E.164+<digits>, so this strict comparison can miss the intended contact and the adapter will still rethrow the original 422.
}>(`${this.base()}/contacts/search?q=${encodeURIComponent(phone)}`);
const hit = (data.payload ?? []).find(c => c.phone_number === phone);
if (!hit) return null;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -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 | |||
|
Good find, and the reproduction detail is what makes it easy to act on. The fix is correct — I've pushed one commit on top with a guard and a comment correction. Don't re-key a contact that already holds a WhatsApp JID. The The comment above the branch said the opposite of what the code needs. It read "Chatwoot doesn't enforce phone uniqueness but does on identifier", which is precisely the assumption your new fallback disproves. Left as-is it invites someone to delete the fallback as unreachable later. Worth recording for anyone reading this thread: adoption only fires when a phone is resolvable, so it does not cover a group or a contact whose Suite is 539 passing, |
f93fddd to
bc9c2b0
Compare
…fferent 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:<digits>" - 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 <noreply@anthropic.com>
…sApp JID 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.
bc9c2b0 to
4aab0bc
Compare
The bug
When the Chatwoot account already has a contact holding the chat's phone number but keyed under a different
identifier(created by hand, by another integration, or before the adapter took over the inbox), inbound relay for that chat permanently dead-letters:createContact422s (phone taken),searchContact(identifier)misses — the existing contact's identifier doesn't match the JID,The fix
On a 422 where the identifier search misses, fall back to searching by phone number, adopt the matching contact, and re-key its
identifierto the JID so every futuresearchContact()resolves it directly. The re-key is best-effort: if it fails (e.g. a conflicting identifier on another contact), the phone match alone still delivers the message — the next delivery just takes the fallback again instead of dead-lettering. Contacts without a phone (groups, unresolved@lid) are unaffected: the fallback only runs when a phone was provided.Reproduced in production
Live OpenWA 0.12.1 host (Baileys engine) + self-hosted Chatwoot v4.16.2. The API-channel inbox had previously been fed by a custom bridge that keyed contacts as
wa:<digits>. After switching to this adapter, every inbound message from those chats 422'd into the dead-letter queue (2 dead-lettered after 5 attempts). Manually re-keying the contacts' identifiers to the JID — exactly what this change automates — immediately restored inbound relay.Tests
Three new cases in
chatwoot-client.test.ts(adopts by phone + re-keys, delivers even when the re-key fails, rethrows when neither identifier nor phone matches). Full suite: 539 pass,tsc --noEmitclean,node package.mjs chatwoot-adapterbuilds.