fix(chatwoot-adapter): auto-recover from deleted Chatwoot conversations - #45
Conversation
rmyndharis
left a comment
There was a problem hiding this comment.
Requested changes — thanks for tackling this, @i350. The problem is real, the write-up is one of the clearest I've seen here, and the overall approach is the right one. There are just a few gaps to close before this can merge.
Verified against main: the dangling-mapping scenario plays out exactly as described. relayInbound resolves the cached conversation id, the dead id 404s, the message cycles through the retry queue against the same stale id and is dead-lettered after 5 attempts — and every subsequent message from that chat repeats the cycle. Own-send drops the message with only a log line. No self-healing exists today, so this fix is genuinely needed. Detecting the 404 mid-relay and rebuilding through ensureConversation is a sound, minimal approach — better than relying on a conversation_deleted webhook, which would be fragile and more intrusive. The safety bounds (404-only, one rebuild per message, second failure falls through to existing paths) are well thought out.
That said, a few things need attention:
-
npm run typecheckfails. Both new tests uselog: logCalls.push.bind(logCalls)—Array.pushreturnsnumberand takesstring[], which doesn't satisfy(m: string, e?: unknown) => void. TS2322 atinbound.test.ts:309andsent.test.ts:277. This one is red on CI, so it's a blocker. -
Media messages aren't covered.
postMedia(chatwoot-client.ts:154) throwsnew Error('Chatwoot postMedia -> ${res.status}')without settingerr.status, so a 404 on a photo/voice/document relay never triggers the recovery — it still dead-letters. It's a one-line fix in the client, and without it the coverage doesn't quite match what the PR body promises. -
The @lid-migrated case doesn't recover on inbound. The mapping can live under the canonical key (@c.us) while
msg.chatIdis @lid (the dual-lookup inresolveConversation). The recovery only callsunlinkByChatId(sessionId, msg.chatId), which deletes nothing in that case —resolveConversationthen re-finds the same stale row via the canonical key, posts to the dead id again, and the second 404 sends the message off to be dead-lettered. Given how much of this codebase deals with the @lid migration (#609, #615), this one is worth handling rather than documenting away.foundKeyis already available from the dual-lookup — unlink the key the mapping actually lives under (or both keys). -
Minor: recovery also runs inside the retry drain (drain calls
relayInbound), so in the catastrophic double-404 scenario each drain attempt can mint a fresh orphan conversation — bounded at 5, but you may want to skip the rebuild when invoked from the drain.
One small correction to the PR body: without the patch the message isn't re-posted "forever" — it's retried 5 times and dead-lettered. The practical impact is as you describe (the chat is permanently broken for all subsequent messages), but it's worth stating the mechanism precisely.
The tests themselves are genuinely good — seeding the mapping in the same backing map the unlink helpers delete against exercises the real semantics, not just the mocks. Once the typecheck errors and the unlink-key issue are fixed (and ideally the postMedia status), I'll be happy to merge this.
rmyndharis
left a comment
There was a problem hiding this comment.
Nice iteration — the foundKey refactor and carrying err.status through postMedia both look right, and the suite is green. Two things I'd resolve before merge:
1. recoverOn404 isn't wired into the drain path. relayInbound takes opts.recoverOn404 (default true) and the comment says the drain flips it to false, but the drain closure in index.ts never passes it:
(sessionId, _chatId, msg) => relayInbound(buildDeps(readConfig(ctx.config), sessionId), sessionId, msg),So it stays true. If a queued message keeps 404-ing after a rebuild, each drain attempt (up to the retry cap) unlinks + rebuilds and mints a new orphan conversation instead of just burning the retry budget. Narrow case, but the guard is inert as written — either pass { recoverOn404: false } here, or drop the flag and its comment.
2. Own-send recovery still unlinks msg.chatId, not the found key. The foundKey fix landed in relayInbound but not handleSent:
await deps.store.unlinkByChatId(sessionId, msg.chatId);
conversationId = await ensureConversation(deps, sessionId, msg.chatId, { ... });For a contact migrated to @lid, findMappedConversation matches via the canonical @c.us fallback, but the row lives under the @c.us forward key — so unlinking msg.chatId (@lid) is a no-op, the stale @c.us → deadConv row survives, and ensureConversation(@lid) → searchContact(@lid) misses the existing @c.us contact → duplicate contact + split conversation (the #31/#42 case). findMappedConversation should return the key it matched on (as resolveConversation now does) so the recovery unlinks the right one; reusing the mapping's contactId/sourceId before unlinking would avoid the contact lookup entirely.
A migrated-contact recovery test would lock both paths down — the current 404 tests all use a non-migrated key.
…ut of band An operator deleting a Chatwoot conversation directly leaves the plugin's cached mapping (conv:<sessionId>:<chatId> -> conversationId) pointing at a dead id. Every relay attempt then 404s against that same id and the message retries forever until it dead-letters, even though the fix is just to re-resolve the conversation. relayInbound now catches a 404 raised mid-relay, drops the stale forward + scoped-reverse + legacy-reverse mapping keys, re-resolves through ensureConversation, and retries the post once against the freshly minted conversation. A second failure (404 or otherwise) is left alone and falls through to the existing retry queue, so a wedged Chatwoot or a misconfigured inbox still converges via the normal attempt cap instead of looping here. Two details this depends on: - The retry-drain path passes recoverOn404: false, because a drain attempt is already a re-attempt of a previously failed relay. Rebuilding there too would mint a fresh orphan conversation on every attempt and never converge; leaving it alone lets the retry budget run out and the next live inbound self-heal the chat instead. - The unlink targets the key the mapping actually lives under, not msg.chatId. A migrated contact's mapping is often keyed under the canonical @c.us while msg.chatId is still @lid, so unlinking the raw id would miss the stale row entirely and the next relay would 404 again. The rebuild also re-imports history through the same backfill gate the live path uses: a freshly minted Chatwoot conversation has no context of its own, and skipping backfill after a rebuild would defeat the point of importing history in the first place.
…elay
relayInbound accepts opts.recoverOn404 specifically so the retry-drain
timer can opt out of the unlink-and-rebuild recovery a live inbound
relay uses on a 404 (a Chatwoot conversation deleted out of band). The
drain call site in onEnable never passed the option, so it silently
kept the default of true: every drain attempt against a still-dangling
mapping would unlink it and mint a brand-new Chatwoot conversation
instead of letting the retry budget run its course, defeating the
whole point of the option and producing a fresh orphan conversation on
each of the (up to 5) retry attempts.
Pass { recoverOn404: false } at that call site so a drain attempt -
already a re-attempt of a previously failed relay - leaves the
dangling mapping alone and lets the attempt counter run out, same as
the next live inbound is meant to self-heal the chat.
7b8a74b to
d161437
Compare
|
Rebased this onto Why it conflicted. What that meant for your recovery path. You deliberately re-import history after a rebuild — a fresh Chatwoot thread without context defeats the point of backfill, as your comment says. That intent is unchanged, but the gate is now the durable marker rather than One defect found while rebasing. Suite is at 527 passing and typecheck is clean. The 404-recovery behaviour is covered by tests that were confirmed to fail when the guard is removed. |
Problem
When an operator deletes a Chatwoot conversation out-of-band, the adapter's cached mapping
conv:<sessionId>:<chatId> → { conversationId: <deleted id> }becomes dangling. The next inbound WhatsApp message relays to the dead conversation id and gets a 404. Without this patch:Solution
Detect the 404 mid-relay, drop the stale forward + reverse mapping, re-resolve through the existing
ensureConversationpath (reusing the Chatwoot contact when it still exists), and re-post the message into a fresh conversation.What changes
mapping-store.tsunlinkByChatIdandunlinkByConversationIdhelpers (delete forward key, scoped reverse key, legacy reverse key)inbound.tsrelayMessagein try/catch; on 404, unlink stale mapping, callresolveConversationto rebuild, retry once. Second failure surfaces to the existing retry queue (5-attempt cap).sent.tsensureConversationdirectly (noresolveConversationon this path), retry once. At-most-once — logs and stops, no retry queue.inbound.test.tssent.test.tsSafety bounds
store.linkafter recovery callsmappings.upsertwhich hits the existing branch and updates the row in place.Tests
All 33 tests pass (15 sent + 18 inbound). New tests verify:
ensureConversationre-resolves the contact by JID (notcreateContact)