Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
33 changes: 33 additions & 0 deletions chatwoot-adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions chatwoot-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion chatwoot-adapter/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
179 changes: 179 additions & 0 deletions chatwoot-adapter/echo-loop.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>();
return {
get: async <T>(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');
});
2 changes: 1 addition & 1 deletion chatwoot-adapter/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion chatwoot-adapter/manifest.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
21 changes: 18 additions & 3 deletions chatwoot-adapter/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>` 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({
Expand All @@ -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
Expand Down
19 changes: 16 additions & 3 deletions chatwoot-adapter/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
{
Expand All @@ -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
Expand Down
Loading
Loading