diff --git a/README.md b/README.md index d76d8a3..0f4cafd 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ This repository provides: | ------ | ----------- | ------- | ------ | | [`after-hours`](./after-hours) | Auto-replies with a configurable away/closing message to messages received outside business hours. | 0.1.3 | stable | | [`chat-flow`](./chat-flow) | Interactive, stateful auto-reply: a trigger word starts a greeting + numbered menu, replies traverse a configurable menu tree, and per-chat state expires after 15 minutes. | 1.0.6 | stable | -| [`chatwoot-adapter`](./chatwoot-adapter) | Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker. | 0.5.3 | beta | +| [`chatwoot-adapter`](./chatwoot-adapter) | Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker. | 0.5.4 | beta | | [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.1.7 | stable | | [`group-translate`](./group-translate) | Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled. | 1.0.6 | stable | | [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.2.3 | stable | diff --git a/chatwoot-adapter/CHANGELOG.md b/chatwoot-adapter/CHANGELOG.md index dbada0a..e18e672 100644 --- a/chatwoot-adapter/CHANGELOG.md +++ b/chatwoot-adapter/CHANGELOG.md @@ -6,6 +6,39 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form ## [Unreleased] +## [0.5.4] — 2026-07-21 + +### Fixed + +- **Every outgoing WhatsApp message was delivered twice while the adapter was enabled.** With "Relay your + own outbound sends" on (the default), the adapter mirrors anything you send — from the WhatsApp app, a + linked phone, or the OpenWA API — into the Chatwoot thread as an outgoing message. Chatwoot then + announces that mirror back over the webhook, and because the adapter only ignored *incoming* Chatwoot + posts, it treated its own mirror as a fresh agent reply and sent it to WhatsApp a second time. The + recipient genuinely received two copies of every message. The adapter now records the Chatwoot message + it creates and recognises the announcement as its own, exactly as it already did in the other + direction. The one-time history import was affected the same way and is fixed by the same change — with + "History backfill" enabled it could have re-sent imported messages to the contact. + + No action is needed beyond updating; de-duplication is keyed on the Chatwoot message id, never on + message content, so genuine repeat messages are still delivered. + +### Changed + +- **Reply de-duplication is now scoped by the WhatsApp session that owns the conversation**, rather than + by the session scope attached to the incoming webhook delivery. The two agree on a normal + session-scoped setup, but an integration instance configured without a session scope previously fell + back to a global namespace keyed by the bare Chatwoot message id — and because Chatwoot numbers + messages per account, two Chatwoot accounts on one gateway could collide there and suppress each + other's agent replies. The new scope is always defined and always matches, so that collision cannot + occur. + + Upgrade note, and only for an instance running **without** a session scope: de-duplication markers + written before this release are not carried over. If Chatwoot re-announces an already-relayed message + under a new delivery id during the upgrade, that reply may be sent once more. Duplicate deliveries are + already discarded by the gateway ahead of the plugin, so this is unlikely; markers are short-lived + either way and the situation resolves itself immediately after the upgrade. + ## [0.5.3] — 2026-07-20 ### Fixed diff --git a/chatwoot-adapter/README.md b/chatwoot-adapter/README.md index 1f878d7..4c626b3 100644 --- a/chatwoot-adapter/README.md +++ b/chatwoot-adapter/README.md @@ -14,8 +14,8 @@ | Field | Value | | ----- | ----- | | **Identifier** | `chatwoot-adapter` | -| **Version** | 0.5.3 | -| **Released** | 2026-07-20 | +| **Version** | 0.5.4 | +| **Released** | 2026-07-21 | | **Status** | beta | | **Author** | Yudhi Armyndharis | | **License** | MIT | diff --git a/chatwoot-adapter/backfill.ts b/chatwoot-adapter/backfill.ts index 0ade1c1..626802d 100644 --- a/chatwoot-adapter/backfill.ts +++ b/chatwoot-adapter/backfill.ts @@ -26,7 +26,7 @@ async function replayHistory( for (const msg of ordered) { if (await deps.store.hasSeen('wa', msg.id, sessionId)) continue; try { - await relayMessage(deps, conversationId, msg, msg.fromMe ? 'outgoing' : 'incoming'); + await relayMessage(deps, sessionId, conversationId, msg, msg.fromMe ? 'outgoing' : 'incoming'); await deps.store.markSeen('wa', msg.id, sessionId); } catch (err) { deps.log(`history message ${msg.id} failed`, err); diff --git a/chatwoot-adapter/echo-loop.test.ts b/chatwoot-adapter/echo-loop.test.ts new file mode 100644 index 0000000..fd3e2f3 --- /dev/null +++ b/chatwoot-adapter/echo-loop.test.ts @@ -0,0 +1,179 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { handleSent } from './sent.ts'; +import { handleOutbound, type OutboundDeps } from './outbound.ts'; +import type { InboundDeps } from './relay.ts'; +import { KeyedAsyncLock } from './chat-lock.ts'; +import { MappingStore } from './mapping-store.ts'; +import type { + IncomingMessage, + WebhookRequest, + PluginStorage, + PluginMappingsCapability, +} from '../types/openwa'; + +// The WhatsApp -> Chatwoot -> WhatsApp echo loop (#615 regression). +// +// handleSent mirrors an own send into Chatwoot as 'outgoing'. Chatwoot then fires message_created for +// that very mirror, and it is 'outgoing' + non-private + in our inbox — so shouldRelayOutbound passes it +// and the adapter sends it to WhatsApp AGAIN. The recipient really does get two messages. +// +// The reverse direction was already guarded (outbound marks the WA id it sends, so handleSent skips its +// own agent replies). These tests exercise the missing half end-to-end: both handlers over ONE real +// MappingStore and ONE lock, so the marker's storage key — and its session scope — are the real ones. + +const CONVERSATION_ID = 55; +const INBOX_ID = 7; +const CHAT_ID = '621@c.us'; + +const own = { + id: 'o1', from: 'me@c.us', to: CHAT_ID, chatId: CHAT_ID, body: 'from my phone', type: 'chat', + timestamp: 0, fromMe: true, isGroup: false, +} as IncomingMessage; + +function fakeStorage(): PluginStorage { + const m = new Map(); + return { + get: async (k: string) => (m.has(k) ? (m.get(k) as T) : null), + set: async (k: string, v: unknown) => void m.set(k, v), + delete: async (k: string) => void m.delete(k), + list: async (prefix?: string) => [...m.keys()].filter(k => !prefix || k.startsWith(prefix)), + }; +} +const fakeMappings: PluginMappingsCapability = { + upsert: async () => {}, get: async () => null, getByProvider: async () => null, +}; + +// One store + one lock shared by both directions, as in the running plugin. +async function wire(sessionId = 'sess') { + const store = new MappingStore(fakeStorage(), fakeMappings); + const lock = new KeyedAsyncLock(); + const engine = { canonicalChatId: async (_s: string, c: string) => c }; + // Chatwoot assigns an autoincrement id to every message the adapter posts. + let nextChatwootId = 4242; + const posted: Array<{ conversationId: number; content: string; messageType?: string; id: number }> = []; + const client = { + postText: async (conversationId: number, content: string, o?: { messageType?: string }) => { + const id = nextChatwootId++; + posted.push({ conversationId, content, messageType: o?.messageType, id }); + return { id }; + }, + postMedia: async (conversationId: number, content: string, _f: unknown, o?: { messageType?: string }) => { + const id = nextChatwootId++; + posted.push({ conversationId, content, messageType: o?.messageType, id }); + return { id }; + }, + }; + + // The chat is already mapped — handleSent relays into an existing conversation, never creates. + await store.link(sessionId, CHAT_ID, 'inst', { + conversationId: CONVERSATION_ID, contactId: 9, sourceId: 'src', name: 'Budi', + }); + + const inbound = { + lock, client, store, engine, instanceId: 'inst', + relayGroups: true, relayMedia: true, backfillLimit: 0, backfillAllOnce: false, log: () => {}, + } as unknown as InboundDeps; + + const sent: Array<{ chatId?: string; text?: string }> = []; + const outbound = { + lock, store, engine, + conversations: { send: async (e: { chatId?: string; text?: string }) => { sent.push(e); return { messageId: 'wa-reply' }; } }, + handover: { set: async () => {} }, + inboxId: INBOX_ID, + log: () => {}, + } as unknown as OutboundDeps; + + return { store, inbound, outbound, posted, sent }; +} + +// The webhook Chatwoot fires for a message the ADAPTER just created via postText. +function mirrorWebhook(chatwootMessageId: number, sessionId?: string): WebhookRequest { + const body = JSON.stringify({ + event: 'message_created', + message_type: 'outgoing', // the mirror is 'outgoing' — shouldRelayOutbound does NOT drop it + private: false, + id: chatwootMessageId, + content: own.body, + inbox: { id: INBOX_ID }, + conversation: { id: CONVERSATION_ID }, + }); + return { + instanceId: 'inst', sessionId, method: 'POST', headers: {}, query: {}, + body, rawBody: body, verified: true, deliveryId: 'd1', + }; +} + +test('an own send mirrored into Chatwoot is NOT sent back to WhatsApp (session-scoped delivery)', async () => { + const { inbound, outbound, posted, sent } = await wire(); + + await handleSent(inbound, 'sess', 'Engine', own); + assert.equal(posted.length, 1, 'the own send should be mirrored into Chatwoot exactly once'); + assert.equal(posted[0].messageType, 'outgoing'); + + const res = await handleOutbound(outbound, mirrorWebhook(posted[0].id, 'sess')); + + assert.deepEqual(res, { status: 200 }); + assert.deepEqual(sent, [], 'the mirror bounced back out of Chatwoot and was re-sent — the recipient gets two messages'); +}); + +test('an own send mirrored into Chatwoot is NOT sent back to WhatsApp (delivery with NO session scope)', async () => { + // An integration instance without a session scope yields sessionId: undefined on every delivery + // (ingress.service: `instance.sessionScope ?? undefined`). The guard must not depend on that value: + // outbound.relay scopes its dedup on target.sessionId, resolved from the conversation mapping, which + // is the same scope relayMessage marked the mirror under. Keying on the delivery scope instead would + // read a different marker here and loop. + const { inbound, outbound, posted, sent } = await wire(); + + await handleSent(inbound, 'sess', 'Engine', own); + const res = await handleOutbound(outbound, mirrorWebhook(posted[0].id, undefined)); + + assert.deepEqual(res, { status: 200 }); + assert.deepEqual(sent, [], 'an unscoped delivery missed the session-scoped marker and re-sent the mirror'); +}); + +test('a genuine agent reply from Chatwoot IS still relayed to WhatsApp', async () => { + // The guard must suppress only the adapter's own mirror, not a real agent reply — which carries a + // Chatwoot id the adapter never created. + const { outbound, sent } = await wire(); + + await handleOutbound(outbound, mirrorWebhook(999, 'sess')); + + assert.equal(sent.length, 1, 'a real agent reply must still reach WhatsApp'); +}); + +test("one tenant's mirror marker does not suppress another tenant's reply with the same Chatwoot id", async () => { + // Chatwoot message ids are per-account autoincrement, so two tenants collide on low ids routinely. + // The echo marker must therefore never live in a global namespace keyed by the bare id — suppressing a + // genuine agent reply is a worse failure than the duplicate this guard exists to prevent. + const store = new MappingStore(fakeStorage(), fakeMappings); + const lock = new KeyedAsyncLock(); + const engine = { canonicalChatId: async (_s: string, c: string) => c }; + // Two WA sessions whose Chatwoot accounts both number this conversation 55. + await store.link('sessA', 'alice@c.us', 'instA', { conversationId: 55, contactId: 1, sourceId: 'a' }); + await store.link('sessB', 'bob@c.us', 'instB', { conversationId: 55, contactId: 2, sourceId: 'b' }); + + // Tenant A mirrors an own send; Chatwoot numbers it 60, and the guard marks it. + const posted: Array<{ id: number }> = []; + const inboundA = { + lock, store, engine, instanceId: 'instA', relayGroups: true, relayMedia: true, + backfillLimit: 0, backfillAllOnce: false, log: () => {}, + client: { postText: async () => { posted.push({ id: 60 }); return { id: 60 }; }, postMedia: async () => ({ id: 60 }) }, + } as unknown as InboundDeps; + await handleSent(inboundA, 'sessA', 'Engine', { ...own, chatId: 'alice@c.us' } as IncomingMessage); + assert.equal(posted.length, 1); + + // Tenant B's agent now replies, and Chatwoot happens to number THAT message 60 as well. The delivery + // is UNSCOPED, which is what makes this bite: a guard that keyed the marker on the delivery scope would + // fall back to a global `seen:cw:60` and find tenant A's marker sitting there. + const sent: Array<{ chatId?: string }> = []; + const outboundB = { + lock, store, engine, inboxId: INBOX_ID, log: () => {}, + conversations: { send: async (e: { chatId?: string }) => { sent.push(e); return { messageId: 'wa-b' }; } }, + handover: { set: async () => {} }, + } as unknown as OutboundDeps; + await handleOutbound(outboundB, mirrorWebhook(60, undefined)); + + assert.equal(sent.length, 1, "tenant B's genuine reply was suppressed by tenant A's echo marker"); + assert.equal(sent[0].chatId, 'bob@c.us'); +}); diff --git a/chatwoot-adapter/inbound.ts b/chatwoot-adapter/inbound.ts index 138eae4..1e66192 100644 --- a/chatwoot-adapter/inbound.ts +++ b/chatwoot-adapter/inbound.ts @@ -25,7 +25,7 @@ export async function relayInbound(deps: InboundDeps, sessionId: string, msg: In if (created && deps.backfillLimit > 0) { await backfillHistory(deps, sessionId, msg.chatId, conversationId); } - await relayMessage(deps, conversationId, msg, 'incoming'); + await relayMessage(deps, sessionId, conversationId, msg, 'incoming'); } // WhatsApp → Chatwoot. Filter, then run resolve+post under the per-chat lock so two near-simultaneous diff --git a/chatwoot-adapter/manifest.json b/chatwoot-adapter/manifest.json index a6f9750..45f81b1 100644 --- a/chatwoot-adapter/manifest.json +++ b/chatwoot-adapter/manifest.json @@ -1,7 +1,7 @@ { "id": "chatwoot-adapter", "name": "Chatwoot Adapter", - "version": "0.5.3", + "version": "0.5.4", "type": "extension", "main": "dist/index.js", "description": "Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker.", diff --git a/chatwoot-adapter/outbound.ts b/chatwoot-adapter/outbound.ts index 2058be3..0bee9f5 100644 --- a/chatwoot-adapter/outbound.ts +++ b/chatwoot-adapter/outbound.ts @@ -63,8 +63,23 @@ async function relay(deps: OutboundDeps, sessionId: string | undefined, evt: Cha await deps.lock.run(`${target.sessionId}:${lockKey}`, async () => { const id = evt.id !== undefined ? String(evt.id) : undefined; // Dedup, but mark only AFTER a successful send: a transient send failure must retry the reply, not be - // silently suppressed as "already seen". Scope the marker by the delivery's session (F-02/F-03). - if (id && (await deps.store.hasSeen('cw', id, sessionId))) return; + // silently suppressed as "already seen". + // + // Scoped by target.sessionId — the WA session that owns this conversation, just resolved from the + // mapping — NOT the delivery's `sessionId`. Both identify the tenant (F-02/F-03), but the delivery + // scope is `instance.sessionScope ?? undefined` and so is UNDEFINED for an unscoped instance, which + // would put its markers in a global namespace keyed by bare Chatwoot message id: two tenants whose + // ids collide could then suppress each other's replies. target.sessionId is always defined, and is + // the same value relayMessage marks the own-send mirror under, so both halves of the echo guard + // agree by construction. + // + // A pre-0.5.4 unscoped install has legacy `seen:cw:` markers that this no longer reads. They are + // deliberately abandoned rather than migrated: these markers are a short-lived (3-day TTL) cache, not + // a ledger, duplicate deliveries are already dropped upstream by the ingress layer's providerDeliveryId + // dedup, and honouring them would carry the cross-tenant collision forward. Worst case is one repeated + // reply if Chatwoot re-announces an already-relayed message under a NEW delivery id during the upgrade + // window — visible and self-correcting, unlike silently dropping a genuine agent reply. + if (id && (await deps.store.hasSeen('cw', id, target.sessionId))) return; let res: unknown; if (media) { res = await deps.conversations.send({ @@ -77,7 +92,7 @@ async function relay(deps: OutboundDeps, sessionId: string | undefined, evt: Cha } else { res = await deps.conversations.send({ sessionId: target.sessionId, chatId: target.chatId, type: 'text', text }); } - if (id) await deps.store.markSeen('cw', id, sessionId); + if (id) await deps.store.markSeen('cw', id, target.sessionId); // Echo guard for the own-send relay (#615): the message we just sent to WhatsApp will come back as a // fromMe message:sent event. Mark its WA id seen — scoped by the WA session that will emit it // (target.sessionId, NOT the delivery scope) — so handleSent recognizes it as ours and skips it. Held diff --git a/chatwoot-adapter/relay.ts b/chatwoot-adapter/relay.ts index 089016a..c808cbc 100644 --- a/chatwoot-adapter/relay.ts +++ b/chatwoot-adapter/relay.ts @@ -60,8 +60,10 @@ function placeholderFor(msg: IncomingMessage): string { // Render one WhatsApp message into Chatwoot (text / media / location / sticker / voice, with quote // threading). Live inbound always passes 'incoming'; history backfill derives the direction per message. +// An 'outgoing' post is echo-guarded before returning (see below) — the caller need not. export async function relayMessage( deps: InboundDeps, + sessionId: string, conversationId: number, msg: IncomingMessage, messageType: 'incoming' | 'outgoing', @@ -70,10 +72,11 @@ export async function relayMessage( const post = { sourceId: msg.id, inReplyToExternalId: msg.quotedMessage?.id, messageType }; const isVoice = msg.type === 'voice'; const isSticker = msg.type === 'sticker'; + let created: { id: number }; if (msg.type === 'location' && msg.location) { - await deps.client.postText(conversationId, locationText(msg), post); + created = await deps.client.postText(conversationId, locationText(msg), post); } else if (deps.relayMedia && msg.media?.data && !msg.media.omitted) { - await deps.client.postMedia( + created = await deps.client.postMedia( conversationId, content, { @@ -85,8 +88,18 @@ export async function relayMessage( { ...post, isVoiceMessage: isVoice }, ); } else { - await deps.client.postText(conversationId, msg.body?.trim() ? content : placeholderFor(msg), post); + created = await deps.client.postText(conversationId, msg.body?.trim() ? content : placeholderFor(msg), post); } + // Echo guard for the own-send mirror (#615), the mirror image of the 'wa' marker outbound.relay writes. + // A message posted as 'outgoing' comes straight back as a Chatwoot `message_created` that + // shouldRelayOutbound accepts (it only drops 'incoming'), so without this marker outbound.relay would + // send it to WhatsApp a SECOND time — the recipient really receives two messages. + // + // Scoped by the WA session that owns the conversation. outbound.relay resolves the SAME value from the + // conversation mapping (target.sessionId) before it checks, so both sides always agree on a scope that + // is always defined — never the ingress delivery's `instance.sessionScope ?? undefined`, which is + // undefined for an unscoped instance and would key a different marker. + if (messageType === 'outgoing') await deps.store.markSeen('cw', String(created.id), sessionId); } // Get-or-create the Chatwoot contact + conversation for a chat and mirror the mapping. Self-contained so diff --git a/chatwoot-adapter/sent.ts b/chatwoot-adapter/sent.ts index a781018..1204e04 100644 --- a/chatwoot-adapter/sent.ts +++ b/chatwoot-adapter/sent.ts @@ -16,6 +16,10 @@ import { relayMessage, type InboundDeps } from './relay.ts'; // outbound.relay marks the WA id of every reply it sends (markSeen('wa', …)) on the same canonical // per-chat lock, so the hasSeen check below recognizes and skips them. At-most-once (a failed post is // not retried), like inbound. +// +// The OTHER leg of the loop is guarded inside relayMessage: the Chatwoot message this mirror creates is +// marked 'cw'-seen (under the same lock, before it is released), so the `message_created` Chatwoot fires +// for it is not relayed back out to WhatsApp as a duplicate. export async function handleSent( deps: InboundDeps, sessionId: string, @@ -34,7 +38,7 @@ export async function handleSent( const conversationId = await findMappedConversation(deps, sessionId, msg, key); if (conversationId === null) return; // unmapped chat — drop, never create (no split) await deps.store.markSeen('wa', msg.id, sessionId); - await relayMessage(deps, conversationId, msg, 'outgoing'); + await relayMessage(deps, sessionId, conversationId, msg, 'outgoing'); } catch (err) { deps.log('own-send relay failed', err); } diff --git a/plugins.json b/plugins.json index cbafacd..235bf45 100644 --- a/plugins.json +++ b/plugins.json @@ -369,7 +369,7 @@ { "id": "chatwoot-adapter", "name": "Chatwoot Adapter", - "version": "0.5.3", + "version": "0.5.4", "type": "extension", "status": "beta", "description": "Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker.", @@ -387,11 +387,11 @@ ], "minOpenWAVersion": "0.8.7", "testedOpenWAVersion": "0.8.7", - "releasedAt": "2026-07-20", + "releasedAt": "2026-07-21", "repoPath": "chatwoot-adapter", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/chatwoot-adapter", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chatwoot-adapter-v0.5.3/chatwoot-adapter.zip" + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chatwoot-adapter-v0.5.4/chatwoot-adapter.zip" }, { "id": "faq-bot",