fix(chatwoot-adapter): stop the own-send mirror looping back to WhatsApp (0.5.4) - #42
Conversation
…App (0.5.4)
The adapter mirrors the account's own outbound sends into the Chatwoot thread as
'outgoing' (#615). Chatwoot announces that mirror back over the webhook, and
shouldRelayOutbound only drops 'incoming' posts — so the mirror passed the filter,
found no dedup marker, and was relayed to WhatsApp again. Every outgoing message
reached the recipient twice, whatever composed it (the WhatsApp app, a linked
phone, or the OpenWA API).
The reverse leg was already guarded: outbound.relay marks the WA id of every reply
it sends, so handleSent skips its own replies. Only the Chatwoot-side marker was
missing. relayMessage now records the id of the message it creates for an
'outgoing' post, which the existing hasSeen('cw', …) check in outbound.relay then
recognises. The marker is written under the per-chat lock the mirror already
holds, so it lands before the webhook can acquire the same key.
The marker is written both session-scoped and unscoped because the two sides key
differently: the mirror runs on a WA event (scope = the WA session) while the
check runs on an ingress delivery scoped `instance.sessionScope ?? undefined` —
undefined for an unscoped instance, which would otherwise read the unscoped key
and miss a session-scoped marker. Same scoped+unscoped pair, and the same reason,
as MappingStore.link's reverse-lookup keys.
Guarding inside relayMessage rather than at the call site also fixes the history
backfill, which replays fromMe history as 'outgoing' through the same function and
could have re-sent imported messages to the contact when backfillLimit > 0.
De-duplication stays keyed on the Chatwoot message id, never on content or
timestamp — the recipient really was receiving two messages, and matching on
content would have masked a genuine double-delivery instead of fixing one.
Regression coverage in echo-loop.test.ts drives both handlers over one real
MappingStore and one lock, covering the session-scoped and the unscoped delivery,
plus a control asserting a genuine agent reply still reaches WhatsApp.
Live verification on the test server0.5.4 deployed to the staging gateway (0.10.2, Baileys engine) and re-enabled, then one tagged message sent to a real recipient through Result: the loop is closed.
The Confirming the new code was the code running. The unscoped marker cannot be produced by 0.5.3 by any path. Not verified live: the control case (a genuine agent reply still reaching WhatsApp). It is covered by unit test, but worth one manual reply from the Chatwoot UI before this is considered fully exercised. One design question before mergeThe unscoped marker is a global namespace keyed by the bare Chatwoot message id. On a multi-tenant install — two Chatwoot accounts against one gateway, with an unscoped instance — tenant B's agent reply could collide with tenant A's marker id and be suppressed. That would silently drop a genuine reply, which is a worse failure than the duplicate this PR fixes. It cannot occur on a single-tenant install, which is why the live test is unaffected. There is a cleaner option: It touches the existing outbound dedup path, so it deserves its own test rather than being folded in silently. Happy to do it here before merge, or as a follow-up. |
…sion The echo marker was written under both the WA session scope and unscoped, so that an ingress delivery carrying no session scope could still find it. That unscoped marker is a global namespace keyed by the bare Chatwoot message id, and Chatwoot numbers messages per account — so two Chatwoot accounts on one gateway could collide there and suppress each other's agent replies. Silently dropping a genuine reply is a worse failure than the duplicate the guard exists to prevent. outbound.relay already resolves target.sessionId from the conversation mapping before it checks, so it can scope the dedup on that instead of on the delivery. That value is always defined and is exactly what relayMessage marks the mirror under, so both halves of the guard now agree by construction and the unscoped marker is gone. Legacy unscoped markers are deliberately abandoned rather than read as a fallback: honouring them would carry the same cross-tenant collision forward for their lifetime. They are a 3-day cache rather than a ledger, and duplicate deliveries are already dropped upstream by the ingress providerDeliveryId dedup, so the exposure is one repeated reply only if Chatwoot re-announces an already-relayed message under a NEW delivery id during the upgrade window. The added test asserts one tenant's mirror marker cannot suppress another tenant's reply that happens to share a Chatwoot message id. It is red against the previous commit and green here; the delivery is deliberately unscoped, since a scoped delivery passes either way and would not have exercised the collision.
Follow-up applied: dedup is now scoped by the conversation's WA sessionThe unscoped marker raised above is gone. Legacy unscoped markers are deliberately abandoned rather than read as a fallback. Honouring them would carry the same cross-tenant collision forward for their lifetime, and they are a 3-day cache rather than a ledger. Duplicate deliveries are already dropped upstream by the ingress New test: one tenant's mirror marker must not suppress another tenant's reply sharing a Chatwoot message id. The delivery in it is deliberately unscoped — a scoped delivery passes either way and would not have exercised the collision at all. Verified red against the previous commit, green here. Suite: 405/405, Re-verified liveThe build changed, so the earlier verification was re-run against the refined code (now on gateway 0.10.4, upgraded since the first run):
The last two rows are the point: the new build writes only the scoped marker, and the guard still holds. |
Summary
With this adapter enabled, every outgoing WhatsApp message was delivered to the recipient twice — regardless of what composed it (the WhatsApp mobile app, a linked phone, or the OpenWA REST API). Found by live reproduction against a real recipient; disabling the plugin stopped the duplicates.
The loop
fromMemessage:sent.handleSentmirrors it into the Chatwoot thread viarelayMessage(..., 'outgoing')— the own-send mirror feature.message_createdfor that mirror:message_type: 'outgoing',private: false, our inbox.shouldRelayOutboundonly drops'incoming'posts, so the mirror passes the filter.outbound.relay'shasSeen('cw', id)finds no marker — nothing ever wrote one.The reverse leg was already guarded:
outbound.relaymarks the WA id of every reply it sends (markSeen('wa', …)), sohandleSentskips the adapter's own agent replies. Only the Chatwoot-side marker was missing, andrelayMessagereturnedvoid, so the created id was not even available to mark.The fix
relayMessagenow marks the Chatwoot message it creates for an'outgoing'post, which the existinghasSeen('cw', …)check recognises. The mark lands under the per-chat lock the mirror already holds, so it is written before the webhook can acquire the same key — the same discipline the'wa'guard uses.Two details worth review:
instance.sessionScope ?? undefined—undefinedfor an unscoped instance, which would read the unscoped key and miss a session-scoped marker. This mirrors the scoped + unscoped reverse-lookup pairMappingStore.linkalready writes, and for the same reason.relayMessage, not at the call site. That also fixes the history backfill, which replaysfromMehistory as'outgoing'through the same function — withbackfillLimit > 0it could have re-sent imported messages to the contact. Latent today (the default is0), but the same defect.De-duplication remains keyed on the Chatwoot message id, never on content or timestamp. The recipient genuinely received two messages; matching on content would have masked a real double-delivery rather than fixing one.
Testing
New
echo-loop.test.tsdrives both handlers over one realMappingStoreand one shared lock, so the marker's actual storage key and session scope are exercised rather than stubbed:Both failure cases were confirmed red against the unfixed code before the change was written.
Full suite: 404/404 pass,
tsc --noEmitclean, catalog regenerated.