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.2 | 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.5 | 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.3.0 | 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.4.0 | beta |
| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.1.6 | 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.5 | stable |
| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.2.3 | stable |
Expand Down
13 changes: 13 additions & 0 deletions chatwoot-adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form

## [Unreleased]

## [0.4.0] — 2026-07-03

### Added

- **Relay your own outbound sends** into Chatwoot, so a conversation isn't one-sided when you reply from a
linked phone, the WhatsApp app, or the OpenWA API (#615). These mirror into the contact's **existing**
mapped Chatwoot conversation as `outgoing` messages (a send to a chat not yet in Chatwoot is skipped —
it appears once the contact replies, never as a duplicate conversation). Replies you send from within
Chatwoot are recognized and never duplicated. New `relayOwnMessages` setting, **on by default**; turn it
off to keep phone-composed messages out of the helpdesk. When the `@lid` mapping is resolvable, own
sends to a contact WhatsApp has migrated to `@lid` land in their existing conversation instead of a
duplicate, via the new host `canonicalChatId` resolver. Requires OpenWA 0.8.7+.

## [0.3.0] — 2026-07-03

### Added
Expand Down
4 changes: 2 additions & 2 deletions chatwoot-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ mirror it. Runs sandboxed in the plugin worker; no server, no extra port.
| Field | Value |
| ----- | ----- |
| **Identifier** | `chatwoot-adapter` |
| **Version** | 0.3.0 |
| **Version** | 0.4.0 |
| **Released** | 2026-07-03 |
| **Status** | beta |
| **Author** | Yudhi Armyndharis |
| **License** | MIT |
| **Type** | `extension` |
| **Requires OpenWA** | ≥ 0.8.6 (tested 0.8.6) |
| **Requires OpenWA** | ≥ 0.8.7 (tested 0.8.7) |
| **Keywords** | chatwoot, helpdesk, inbox, handover, two-way, agent, whatsapp, openwa |
| **Repository** | [OpenWA-plugins/chatwoot-adapter](https://github.com/rmyndharis/OpenWA-plugins/tree/main/chatwoot-adapter) |
<!-- END DETAILS -->
Expand Down
12 changes: 11 additions & 1 deletion chatwoot-adapter/filters.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shouldRelayInbound, shouldRelayOutbound } from './filters.ts';
import { shouldRelayInbound, shouldRelayOutbound, shouldRelayOwn } from './filters.ts';

const base = {
id: 'm1',
Expand All @@ -22,6 +22,16 @@ test('inbound: Engine + not fromMe + has chatId; drops API, fromMe, and groups w
assert.equal(shouldRelayInbound({ ...base, isGroup: true }, 'Engine', true), true);
});

test('own-outbound: Engine + fromMe + has chatId; drops inbound, API source, empty chatId, and groups when relayGroups=false', () => {
const own = { ...base, fromMe: true } as const;
assert.equal(shouldRelayOwn(own, 'Engine', true), true);
assert.equal(shouldRelayOwn({ ...own, fromMe: false }, 'Engine', true), false); // inbound is not an own send
assert.equal(shouldRelayOwn(own, 'API', true), false); // only engine-delivered creates
assert.equal(shouldRelayOwn({ ...own, chatId: '' }, 'Engine', true), false); // no target chat
assert.equal(shouldRelayOwn({ ...own, isGroup: true }, 'Engine', false), false);
assert.equal(shouldRelayOwn({ ...own, isGroup: true }, 'Engine', true), true);
});

test('outbound: strict private — relay only outgoing + private===false in the configured inbox', () => {
const ok = { message_type: 'outgoing', private: false, inbox: { id: 7 }, conversation: { id: 1 }, content: 'r' };
assert.equal(shouldRelayOutbound(ok, 7), true);
Expand Down
8 changes: 8 additions & 0 deletions chatwoot-adapter/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ export function shouldRelayInbound(msg: IncomingMessage, source: string, relayGr
return source === 'Engine' && !msg.fromMe && !!msg.chatId && (!msg.isGroup || relayGroups);
}

// Relay the account's OWN outbound sends (composed on a linked phone or via the OpenWA API) so the
// Chatwoot thread mirrors WhatsApp (#615). The mirror of shouldRelayInbound with fromMe===true. The
// adapter's own Chatwoot-agent replies are ALSO fromMe and reach message:sent, but they're excluded
// out-of-band by the 'wa' send-id echo marker, not here. Pure — no ctx.
export function shouldRelayOwn(msg: IncomingMessage, source: string, relayGroups: boolean): boolean {
return source === 'Engine' && msg.fromMe && !!msg.chatId && (!msg.isGroup || relayGroups);
}

// The subset of a Chatwoot account-level webhook payload the adapter reads (message_created +
// conversation_updated). Everything is optional — Chatwoot omits fields per event/version.
export interface ChatwootWebhookMessage {
Expand Down
32 changes: 27 additions & 5 deletions chatwoot-adapter/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,51 @@ import type { PluginContext } from '../types/openwa';
function fakeCtx(config: Record<string, unknown>) {
const hooks: string[] = [];
const routes: string[] = [];
const cbs: Record<string, (h: unknown) => Promise<{ continue: boolean }>> = {};
let fetches = 0;
const ctx = {
config,
storage: { get: async () => null, set: async () => {}, delete: async () => {}, list: async () => [] },
mappings: { upsert: async () => {}, get: async () => null, getByProvider: async () => null },
net: { fetch: async () => ({ ok: true, status: 200, headers: {}, body: '{}' }) },
net: { fetch: async () => { fetches++; return { ok: true, status: 200, headers: {}, body: '{}' }; } },
conversations: { send: async () => ({}) },
handover: { set: async () => ({}) },
engine: { canonicalChatId: async (_s: string, c: string) => c },
logger: { log: () => {}, debug: () => {}, warn: () => {}, error: () => {} },
registerHook: (event: string) => void hooks.push(event),
registerHook: (event: string, cb: (h: unknown) => Promise<{ continue: boolean }>) => { hooks.push(event); cbs[event] = cb; },
registerWebhook: (route: string) => void routes.push(route),
} as unknown as PluginContext;
return { ctx, hooks, routes };
return { ctx, hooks, routes, cbs, fetches: () => fetches };
}

const goodConfig = { baseUrl: 'https://chat.acme.com', apiToken: 'tok', accountId: 3, inboxId: 7 };

test('onEnable registers the message:received hook and the chatwoot ingress route', async () => {
test('onEnable registers the message:received + message:sent hooks and the chatwoot ingress route', async () => {
const { ctx, hooks, routes } = fakeCtx(goodConfig);
await new ChatwootAdapter().onEnable(ctx);
assert.deepEqual(hooks, ['message:received']);
assert.deepEqual(hooks, ['message:received', 'message:sent']);
assert.deepEqual(routes, ['chatwoot']);
});

test('message:sent is registered even when relayOwnMessages=false (gate is per-event, not at enable)', async () => {
const { ctx, hooks } = fakeCtx({ ...goodConfig, relayOwnMessages: false });
await new ChatwootAdapter().onEnable(ctx);
assert.ok(hooks.includes('message:sent'));
});

test('relayOwnMessages=false gates the message:sent handler off (no Chatwoot API call)', async () => {
const { ctx, cbs, fetches } = fakeCtx({ ...goodConfig, relayOwnMessages: false });
await new ChatwootAdapter().onEnable(ctx);
const r = await cbs['message:sent']({
sessionId: 'sess',
source: 'Engine',
data: { id: 'x', fromMe: true, chatId: 'c@wa', body: 'hi', type: 'chat', isGroup: false },
});
await new Promise(res => setImmediate(res)); // let any detached work settle (there should be none)
assert.deepEqual(r, { continue: true });
assert.equal(fetches(), 0);
});

test('onEnable throws on missing / invalid config', async () => {
const { ctx } = fakeCtx({ baseUrl: 'https://x' }); // missing apiToken, accountId, inboxId
await assert.rejects(new ChatwootAdapter().onEnable(ctx), /missing\/invalid config/);
Expand Down
50 changes: 38 additions & 12 deletions chatwoot-adapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ChatwootClient } from './chatwoot-client.ts';
import { MappingStore } from './mapping-store.ts';
import { KeyedAsyncLock } from './chat-lock.ts';
import { handleInbound } from './inbound.ts';
import { handleSent } from './sent.ts';
import { backfillAllChats } from './backfill.ts';
import { handleOutbound } from './outbound.ts';

Expand All @@ -13,6 +14,7 @@ interface ChatwootFullConfig {
inboxId: number;
relayGroups: boolean;
relayMedia: boolean;
relayOwnMessages: boolean;
backfillLimit: number;
backfillAllOnce: boolean;
}
Expand Down Expand Up @@ -49,6 +51,7 @@ function readConfig(raw: Record<string, unknown>): ChatwootFullConfig {
inboxId,
relayGroups: raw.relayGroups !== false,
relayMedia: raw.relayMedia !== false,
relayOwnMessages: raw.relayOwnMessages !== false,
backfillLimit: Number.isFinite(rawLimit) ? Math.max(0, Math.trunc(rawLimit)) : 0,
backfillAllOnce: raw.backfillAllOnce === true || raw.backfillAllOnce === 'true',
};
Expand All @@ -62,23 +65,27 @@ export default class ChatwootAdapter implements IPlugin {
// Re-read config per event so a per-session/instance override (PR E) is picked up live.
const clientFor = () => new ChatwootClient(ctx.net.fetch.bind(ctx.net), readConfig(ctx.config));

// Shared per-event dependency bag for the inbound and own-send relays (both render into Chatwoot the
// same way). instanceId = sessionId (a session-scoped instance is 1:1 with its session).
const buildDeps = (cfg: ChatwootFullConfig, sessionId: string) => ({
lock,
client: clientFor(),
store,
engine: ctx.engine,
instanceId: sessionId,
relayGroups: cfg.relayGroups,
relayMedia: cfg.relayMedia,
backfillLimit: cfg.backfillLimit,
backfillAllOnce: cfg.backfillAllOnce,
log: (m: string, e?: unknown) => ctx.logger.error(m, e),
});

ctx.registerHook('message:received', async (h: HookContext): Promise<HookResult> => {
const sessionId = h.sessionId;
const msg = h.data as IncomingMessage;
if (sessionId && msg) {
const cfg = readConfig(ctx.config);
const deps = {
lock,
client: clientFor(),
store,
engine: ctx.engine,
instanceId: sessionId,
relayGroups: cfg.relayGroups,
relayMedia: cfg.relayMedia,
backfillLimit: cfg.backfillLimit,
backfillAllOnce: cfg.backfillAllOnce,
log: (m: string, e?: unknown) => ctx.logger.error(m, e),
};
const deps = buildDeps(cfg, sessionId);
// Fire-and-forget off the hook so a slow/failing Chatwoot API never blocks the WA pipeline. The
// mapping mirror is keyed on sessionId (a session-scoped instance is 1:1 with its session).
void handleInbound(deps, sessionId, h.source, msg).catch(e => ctx.logger.error('inbound hook failed', e));
Expand All @@ -91,12 +98,31 @@ export default class ChatwootAdapter implements IPlugin {
return { continue: true };
});

// The account's OWN outbound sends (linked phone / WhatsApp app / OpenWA REST API) arrive on
// message:sent, not message:received. Relay them as 'outgoing' so the Chatwoot thread mirrors the full
// WhatsApp conversation (#615). The adapter's own Chatwoot-agent replies also surface here but are
// echo-suppressed inside handleSent. Gated per-event so a live relayOwnMessages flip applies at once.
ctx.registerHook('message:sent', async (h: HookContext): Promise<HookResult> => {
const sessionId = h.sessionId;
const msg = h.data as IncomingMessage;
if (sessionId && msg) {
const cfg = readConfig(ctx.config);
if (cfg.relayOwnMessages) {
void handleSent(buildDeps(cfg, sessionId), sessionId, h.source, msg).catch(e =>
ctx.logger.error('sent hook failed', e),
);
}
}
return { continue: true };
});

ctx.registerWebhook('chatwoot', async (req: WebhookRequest) =>
handleOutbound(
{
lock,
conversations: ctx.conversations,
handover: ctx.handover,
engine: ctx.engine,
store,
inboxId: readConfig(ctx.config).inboxId,
log: (m, e) => ctx.logger.error(m, e),
Expand Down
14 changes: 10 additions & 4 deletions 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.3.0",
"version": "0.4.0",
"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 All @@ -11,14 +11,14 @@
"repository": "https://github.com/rmyndharis/OpenWA-plugins",
"keywords": ["chatwoot", "helpdesk", "inbox", "handover", "two-way", "agent", "whatsapp", "openwa"],
"status": "beta",
"minOpenWAVersion": "0.8.6",
"testedOpenWAVersion": "0.8.6",
"minOpenWAVersion": "0.8.7",
"testedOpenWAVersion": "0.8.7",
"sdkVersion": "1",
"permissions": ["net:fetch", "conversation:send", "webhook:ingress", "engine:read"],
"net": { "allow": [], "allowConfigHosts": ["baseUrl"] },
"sessionScoped": true,
"sessions": ["*"],
"hooks": ["message:received"],
"hooks": ["message:received", "message:sent"],
"ingress": [
{
"route": "chatwoot",
Expand Down Expand Up @@ -78,6 +78,12 @@
"default": true,
"description": "Upload inbound WhatsApp media (images, files, audio) to Chatwoot as attachments."
},
"relayOwnMessages": {
"type": "boolean",
"title": "Relay your own outbound sends",
"default": true,
"description": "Mirror messages you send from a linked phone / the WhatsApp app / the OpenWA API into Chatwoot as outgoing messages, so the thread isn't one-sided. Your Chatwoot agent replies are never duplicated. Turn off to keep phone-composed messages out of the helpdesk."
},
"backfillLimit": {
"type": "number",
"title": "History backfill (messages per chat)",
Expand Down
58 changes: 58 additions & 0 deletions chatwoot-adapter/outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function deps(over: { store?: Record<string, unknown> } = {}) {
lock: new KeyedAsyncLock(),
conversations: { send: async (e: { sessionId?: string; chatId?: string; type: string; text?: string }) => void sent.push(e) },
handover: { set: async (k: unknown, s: string) => void handovers.push([k, s]) },
engine: { canonicalChatId: async (_s: string, c: string) => c },
store: {
hasSeen: async () => false,
markSeen: async () => {},
Expand Down Expand Up @@ -124,6 +125,7 @@ test('cross-tenant: two accounts sharing conversation id 55 route to the correct
lock: new KeyedAsyncLock(),
conversations: { send: async (e: { sessionId?: string; chatId?: string; text?: string }) => void sent.push(e) },
handover: { set: async () => {} },
engine: { canonicalChatId: async (_s: string, c: string) => c },
store,
inboxId: 7,
log: () => {},
Expand Down Expand Up @@ -151,6 +153,7 @@ test('a transient send failure does not poison dedup: the retry re-sends the rep
},
},
handover: { set: async () => {} },
engine: { canonicalChatId: async (_s: string, c: string) => c },
store,
inboxId: 7,
log: () => {},
Expand All @@ -162,6 +165,61 @@ test('a transient send failure does not poison dedup: the retry re-sends the rep
assert.equal(sent[0].text, 'hi');
});

test('marks the WA send id as seen (scoped by the WA session) so message:sent does not echo the agent reply', async () => {
const marks: Array<[string, string, string | undefined]> = [];
const d = {
lock: new KeyedAsyncLock(),
// conversations.send returns { messageId, timestamp } — the engine's serialized id of the sent
// message, which is the same id the subsequent message:sent event carries.
conversations: { send: async () => ({ messageId: 'WA_9', timestamp: 0 }) },
handover: { set: async () => {} },
engine: { canonicalChatId: async (_s: string, c: string) => c },
store: {
hasSeen: async () => false,
markSeen: async (kind: string, id: string, scope?: string) => void marks.push([kind, id, scope]),
// The WA session that owns this chat (target.sessionId) differs from the delivery scope on purpose.
getByConversation: async () => ({ sessionId: 'sessX', chatId: 'c@wa' }),
},
inboxId: 7,
log: () => {},
} as unknown as OutboundDeps;
await handleOutbound(
d,
req({ event: 'message_created', message_type: 'outgoing', private: false, id: 5, content: 'reply', inbox: { id: 7 }, conversation: { id: 55 } }),
);
assert.ok(
marks.some(([k, id, scope]) => k === 'wa' && id === 'WA_9' && scope === 'sessX'),
`expected a wa:WA_9 marker scoped by the WA session sessX, got ${JSON.stringify(marks)}`,
);
});

test('relay canonicalizes the WA chat id (via engine.canonicalChatId) so its lock serializes with the own-send handler', async () => {
const calls: Array<[string, string]> = [];
const d = {
lock: new KeyedAsyncLock(),
conversations: { send: async () => ({ messageId: 'X', timestamp: 0 }) },
handover: { set: async () => {} },
engine: {
canonicalChatId: async (s: string, c: string) => {
calls.push([s, c]);
return c === '628@lid' ? '628@c.us' : c;
},
},
store: {
hasSeen: async () => false,
markSeen: async () => {},
getByConversation: async () => ({ sessionId: 'sessX', chatId: '628@lid' }),
},
inboxId: 7,
log: () => {},
} as unknown as OutboundDeps;
await handleOutbound(
d,
req({ event: 'message_created', message_type: 'outgoing', private: false, id: 5, content: 'r', inbox: { id: 7 }, conversation: { id: 55 } }),
);
assert.deepEqual(calls, [['sessX', '628@lid']]); // canonicalized with the target's session + raw chatId, before locking
});

test('handover guards a non-object changed_attributes element (no throw / retry loop)', async () => {
const { deps: d, handovers } = deps();
const r = await handleOutbound(d, req({ event: 'conversation_updated', conversation: { id: 55 }, changed_attributes: [null, 'x', { assignee_id: { current_value: 3 } }] }));
Expand Down
Loading
Loading