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.5.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.5.1 | 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
11 changes: 11 additions & 0 deletions chatwoot-adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form

## [Unreleased]

## [0.5.1] — 2026-07-03

### Fixed

- **A contact who migrates to `@lid` no longer splits into a duplicate Chatwoot conversation on inbound.**
Their `@lid` messages now resolve to the existing `<phone>@c.us` conversation (via the host
`canonicalChatId` resolver + a dual lookup), mirroring the outbound fix in 0.4.0. Best-effort — it
applies whenever the lid→phone mapping is known: after any reply to the contact, or on every inbound
when OpenWA's `RESOLVE_LID_TO_PHONE=true` is set (recommended to fully close the gap; it also helps the
outbound path).

## [0.5.0] — 2026-07-03

### Added
Expand Down
13 changes: 9 additions & 4 deletions chatwoot-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ mirror it. Runs sandboxed in the plugin worker; no server, no extra port.
| Field | Value |
| ----- | ----- |
| **Identifier** | `chatwoot-adapter` |
| **Version** | 0.5.0 |
| **Version** | 0.5.1 |
| **Released** | 2026-07-03 |
| **Status** | beta |
| **Author** | Yudhi Armyndharis |
Expand Down Expand Up @@ -87,9 +87,14 @@ public host is added to the outbound allowlist). To use a self-hosted Chatwoot:

## Compatibility

- **OpenWA** ≥ 0.8.6 — needs Integration SDK v1 (webhook ingress, `ctx.mappings`, the session+chat handover
gate, `net.allowConfigHosts`), the `conversation.send` media/voice types for outbound attachments, and the
sandbox-bridged `engine.getChatHistory` for the history backfill.
- **OpenWA** ≥ 0.8.7 — needs Integration SDK v1 (webhook ingress, `ctx.mappings`, the session+chat handover
gate, `net.allowConfigHosts`), the `conversation.send` media/voice types for outbound attachments, the
sandbox-bridged `engine.getChatHistory` for the history backfill, and `engine.canonicalChatId` for `@lid`
resolution.
- **`@lid` migration** — when a contact migrates to WhatsApp's `@lid` addressing, their conversation is kept
from splitting as long as the lid→phone mapping is known (after any reply to them). To resolve it on
every inbound too — closing the gap for a contact you've only ever received from — set
`RESOLVE_LID_TO_PHONE=true` in OpenWA.
- **Chatwoot** — account-level webhooks with timestamped HMAC signing (see Setup).

## Security
Expand Down
56 changes: 54 additions & 2 deletions chatwoot-adapter/inbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const msg = {
timestamp: 0, fromMe: false, isGroup: false, senderPhone: '+621', contact: { pushName: 'Budi' },
} as IncomingMessage;

function deps(over: { client?: Record<string, unknown>; store?: Record<string, unknown> } = {}) {
function deps(over: { client?: Record<string, unknown>; store?: Record<string, unknown>; engine?: Record<string, unknown> } = {}) {
let contacts = 0;
let convs = 0;
const posted: Array<{ id: number; c: string }> = [];
Expand All @@ -31,13 +31,65 @@ function deps(over: { client?: Record<string, unknown>; store?: Record<string, u
markSeen: async () => {},
...over.store,
};
// Default: identity canonicalization (@lid resolution exercised explicitly below).
const engine = { canonicalChatId: async (_s: string, c: string) => c, ...over.engine };
const d = {
lock: new KeyedAsyncLock(), client, store: mapping, instanceId: 'inst',
lock: new KeyedAsyncLock(), client, store: mapping, engine, instanceId: 'inst',
relayGroups: true, relayMedia: true, log: () => {},
} as unknown as InboundDeps;
return { deps: d, counts: () => ({ contacts, convs }), posted };
}

test('a migrated contact (@lid inbound, @c.us-keyed conversation) reuses the EXISTING conversation via dual-lookup, no split', async () => {
const { deps: d, posted, counts } = deps({
engine: { canonicalChatId: async (_s: string, c: string) => (c === '621@lid' ? '621@c.us' : c) },
store: {
getByChat: async (_s: string, c: string) =>
c === '621@c.us' ? { conversationId: 77, contactId: 9, sourceId: 'src', name: 'Budi' } : null,
},
});
const lidMsg = { ...msg, id: 'x1', chatId: '621@lid' } as IncomingMessage;
await handleInbound(d, 'sess', 'Engine', lidMsg);
assert.deepEqual(posted, [{ id: 77, c: 'hello' }]); // posted into the existing @c.us conversation
assert.deepEqual(counts(), { contacts: 0, convs: 0 }); // no duplicate conversation created
});

test('cold lid (@lid unresolvable) still creates — documented residual closed by RESOLVE_LID_TO_PHONE', async () => {
const { deps: d, posted, counts } = deps({
engine: { canonicalChatId: async (_s: string, c: string) => c }, // cold: @lid stays @lid
});
const lidMsg = { ...msg, id: 'x2', chatId: '621@lid' } as IncomingMessage;
await handleInbound(d, 'sess', 'Engine', lidMsg);
assert.equal(posted.length, 1);
assert.deepEqual(counts(), { contacts: 1, convs: 1 }); // no @c.us mapping resolvable while cold → creates
});

test('canonicalChatId throwing (session down) falls back to the raw id and still relays — never drops the message', async () => {
const { deps: d, posted } = deps({
engine: { canonicalChatId: async () => { throw new Error('session not active'); } },
});
await handleInbound(d, 'sess', 'Engine', msg);
assert.equal(posted.length, 1); // relayed via the raw fallback, not lost before markSeen/enqueue
});

test('reusing a @c.us mapping via @lid dual-lookup patches the name under the @c.us key (no repeated updateContact)', async () => {
const patches: Array<[string, { name?: string }]> = [];
const renames: string[] = [];
const { deps: d } = deps({
engine: { canonicalChatId: async (_s: string, c: string) => (c === '621@lid' ? '621@c.us' : c) },
store: {
getByChat: async (_s: string, c: string) =>
c === '621@c.us' ? { conversationId: 77, contactId: 9, sourceId: 'src', name: 'Old Name' } : null,
patch: async (_s: string, c: string, p: { name?: string }) => void patches.push([c, p]),
},
client: { updateContact: async (_id: number, name: string) => void renames.push(name) },
});
const lidMsg = { ...msg, id: 'x3', chatId: '621@lid', contact: { pushName: 'Budi' } } as IncomingMessage;
await handleInbound(d, 'sess', 'Engine', lidMsg);
assert.deepEqual(renames, ['Budi']); // the @c.us contact is renamed correctly
assert.deepEqual(patches, [['621@c.us', { name: 'Budi' }]]); // and the name is recorded under the @c.us key
});

test('a failed relay queues the message for retry (at-least-once), not dropped', async () => {
const enqueued: string[] = [];
const { deps: d } = deps({
Expand Down
32 changes: 29 additions & 3 deletions chatwoot-adapter/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ export type { InboundDeps };
// The resolve + backfill + relay core, lock-free, that THROWS on failure. Shared by the live inbound
// handler and the retry drain (retry.ts) so a retried message follows the exact same path.
export async function relayInbound(deps: InboundDeps, sessionId: string, msg: IncomingMessage): Promise<void> {
const { conversationId, created } = await resolveConversation(deps, sessionId, msg);
// Best-effort @lid -> <phone>@c.us for the LOOKUP only (never the lock — see handleInbound). Raw-fallback:
// canonicalChatId needs a live engine, but the retry drain re-relays messages whose WA session may be
// offline (the relay is a Chatwoot post that doesn't need it), so a failure must not block the relay.
let canonical = msg.chatId;
try {
canonical = await deps.engine.canonicalChatId(sessionId, msg.chatId);
} catch {
/* session down / unresolvable — fall back to the raw id; dedup is best-effort */
}
const { conversationId, created } = await resolveConversation(deps, sessionId, msg, canonical);
// Lazy backfill: the first time this chat maps, replay its recent history (older messages, both
// directions, deduped) BEFORE posting this one — so the thread reads chronologically and this message's
// quote resolves against a just-posted source_id. This message is already markSeen, so backfill skips it.
Expand All @@ -29,6 +38,13 @@ export async function handleInbound(
msg: IncomingMessage,
): Promise<void> {
if (!shouldRelayInbound(msg, source, deps.relayGroups)) return;
// Lock on the RAW chatId, NOT the canonical one. canonicalChatId is best-effort and non-deterministic
// (it returns @c.us when the lid->phone cache is warm, @lid when cold, and can throw when the session is
// down), so using it as a lock key wouldn't reliably serialize two inbound for the same chat — that would
// reintroduce the duplicate-conversation double-create. The raw id is deterministic and already converges
// a migrated contact's inbound (they all carry chatId=@lid). The @lid dedup is done by the dual-lookup
// inside relayInbound. Keeping the canonicalChatId call OUT of this path also means it can never throw
// here and drop the message before it is markSeen/enqueued.
await deps.lock.run(`${sessionId}:${msg.chatId}`, async () => {
// markSeen stays BEFORE the relay: it dedups WA re-deliveries and makes backfill skip this live
// message. It is NOT the "relayed" signal — a failed relay is enqueued below, and the pending-queue
Expand Down Expand Up @@ -57,10 +73,20 @@ async function resolveConversation(
deps: InboundDeps,
sessionId: string,
msg: IncomingMessage,
canonicalChatId: string,
): Promise<{ conversationId: number; created: boolean }> {
const existing = await deps.store.getByChat(sessionId, msg.chatId); // re-read inside the lock
// Dual lookup (re-read inside the lock): the raw chatId finds a mapping keyed by @lid, the canonical
// chatId finds one keyed by @c.us (a contact that has since migrated to @lid, when the lid resolves) —
// so a migrated contact's inbound lands in its EXISTING conversation instead of splitting a duplicate.
// `foundKey` is the key the mapping actually lives under, so refreshContactName patches the right doc.
let existing = await deps.store.getByChat(sessionId, msg.chatId);
let foundKey = msg.chatId;
if (!existing && canonicalChatId !== msg.chatId) {
existing = await deps.store.getByChat(sessionId, canonicalChatId);
foundKey = canonicalChatId;
}
if (existing) {
await refreshContactName(deps, sessionId, msg, existing);
await refreshContactName(deps, sessionId, msg, existing, foundKey);
return { conversationId: existing.conversationId, created: false };
}
const name = msg.isGroup
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.0",
"version": "0.5.1",
"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
6 changes: 5 additions & 1 deletion chatwoot-adapter/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,17 @@ export async function refreshContactName(
sessionId: string,
msg: IncomingMessage,
link: ChatLink,
chatKey: string,
): Promise<void> {
if (msg.isGroup) return; // a group contact is named for the group, not whoever sent this message
const desired = msg.contact?.pushName || msg.contact?.name;
if (!desired || desired === link.name) return;
try {
await deps.client.updateContact(link.contactId, desired);
await deps.store.patch(sessionId, msg.chatId, { name: desired });
// Patch under the key the mapping ACTUALLY lives under (`chatKey`), not msg.chatId: on the @lid dual-
// lookup path the mapping is keyed @c.us while msg.chatId is @lid, so patching msg.chatId would be a
// no-op and the name would never be recorded — re-issuing updateContact on every later inbound.
await deps.store.patch(sessionId, chatKey, { name: desired });
} catch (err) {
deps.log('contact name refresh failed', err);
}
Expand Down
2 changes: 2 additions & 0 deletions chatwoot-adapter/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export async function drainRetries(
for (const key of keys) {
const e = await deps.store.getRetry(key);
if (!e) continue; // key vanished since the scan (already drained/dropped) — nothing to do
// Lock on the RAW chatId, same deterministic key live inbound uses for this chat. @lid canonicalization
// is a lookup concern handled inside relayInbound (best-effort), not a lock concern.
await deps.lock.run(`${e.sessionId}:${e.chatId}`, async () => {
let relayed = false;
try {
Expand Down
4 changes: 2 additions & 2 deletions plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@
{
"id": "chatwoot-adapter",
"name": "Chatwoot Adapter",
"version": "0.5.0",
"version": "0.5.1",
"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.",
Expand All @@ -391,7 +391,7 @@
"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.0/chatwoot-adapter.zip"
"download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chatwoot-adapter-v0.5.1/chatwoot-adapter.zip"
},
{
"id": "faq-bot",
Expand Down
Loading