Skip to content

fix(chatwoot-adapter): adopt a contact that owns the phone under a different identifier - #62

Merged
rmyndharis merged 2 commits into
rmyndharis:mainfrom
ker00sama-dev:fix/chatwoot-adapter-adopt-contact-by-phone
Aug 5, 2026
Merged

fix(chatwoot-adapter): adopt a contact that owns the phone under a different identifier#62
rmyndharis merged 2 commits into
rmyndharis:mainfrom
ker00sama-dev:fix/chatwoot-adapter-adopt-contact-by-phone

Conversation

@ker00sama-dev

@ker00sama-dev ker00sama-dev commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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:

  1. createContact 422s (phone taken),
  2. the fallback searchContact(identifier) misses — the existing contact's identifier doesn't match the JID,
  3. the original 422 is rethrown, and since the same collision recurs on every delivery, each message from that chat burns its whole retry budget into the dead-letter queue. Plugin health stays green until the retries are exhausted, so the drop is easy to miss.

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 identifier to the JID so every future searchContact() 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 --noEmit clean, node package.mjs chatwoot-adapter builds.

Copilot AI review requested due to automatic review settings August 3, 2026 11:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 createContact 422 where searchContact(identifier) misses, fall back to searching by phone number and adopt the matching contact.
  • Best-effort re-key of the adopted contact’s identifier to the JID so subsequent deliveries resolve via searchContact().
  • 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

  • adoptContactByPhone requires an exact phone_number === phone match. Contacts created manually or by other integrations often store formatted numbers (spaces/dashes) while resolvePhone normalizes 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.

Comment thread chatwoot-adapter/chatwoot-client.ts Outdated
Comment on lines +77 to +81
@@ -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
@rmyndharis

Copy link
Copy Markdown
Owner

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 PUT rewrote the matched contact's identifier unconditionally. The phone can collide with a contact this adapter itself created under the other JID form — a chat first 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. That's 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 when the current identifier already ends @c.us, @lid or @g.us.

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 @lid hasn't resolved to a number yet. Those still dead-letter the same way, and that's a separate gap rather than something this PR should grow to cover.

Suite is 539 passing, tsc --noEmit clean, packaging builds. Minor: the PR body says 539 tests, the branch had 538 — small, but worth re-running before quoting.

@rmyndharis
rmyndharis force-pushed the fix/chatwoot-adapter-adopt-contact-by-phone branch from f93fddd to bc9c2b0 Compare August 5, 2026 13:50
ker00sama-dev and others added 2 commits August 5, 2026 21:00
…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.
@rmyndharis
rmyndharis force-pushed the fix/chatwoot-adapter-adopt-contact-by-phone branch from bc9c2b0 to 4aab0bc Compare August 5, 2026 14:01
@rmyndharis
rmyndharis merged commit 93fddea into rmyndharis:main Aug 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants