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

## [Unreleased]

## [0.5.5] — 2026-07-22

### Fixed

- **Two of your own messages in a row could go missing from the Chatwoot thread.** When WhatsApp does
not return an id for a message you send — which happens occasionally, and more often on the Baileys
engine — the adapter used that missing id as its "already relayed" key. Every such message therefore
shared one key: the first reached Chatwoot, and any later one was treated as a duplicate and dropped
for the next three days. Silently, and only in Chatwoot: the message itself reached the recipient
normally. Messages without an id are now always relayed. They cannot be de-duplicated, so on the rare
occasion WhatsApp re-delivers one you may see it twice in the thread — visible, unlike losing it.

### Changed

- **Promoted from beta to stable.** The two-way relay has now been exercised end to end on both engines
(whatsapp-web.js and Baileys) against a live WhatsApp account: inbound relay, agent replies with text
and media, your own outbound messages mirrored without being re-sent, and a real history import.
Tested against OpenWA 0.10.5.

One limitation to be aware of, unchanged by this release: WhatsApp is migrating contacts to privacy
ids (`@lid`), and a contact first seen under its privacy id creates a separate Chatwoot contact from
one already known by phone number. The adapter resolves the two together whenever it can, and never
creates a second conversation for a chat it has already mapped, but a contact whose mapping is lost or
who arrives privacy-id-first can appear twice. See the README.

## [0.5.4] — 2026-07-21

### Fixed
Expand Down
14 changes: 10 additions & 4 deletions chatwoot-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
| Field | Value |
| ----- | ----- |
| **Identifier** | `chatwoot-adapter` |
| **Version** | 0.5.4 |
| **Released** | 2026-07-21 |
| **Status** | beta |
| **Version** | 0.5.5 |
| **Released** | 2026-07-22 |
| **Status** | stable |
| **Author** | Yudhi Armyndharis |
| **License** | MIT |
| **Type** | `extension` |
| **Requires OpenWA** | ≥ 0.8.7 (tested 0.8.7) |
| **Requires OpenWA** | ≥ 0.8.7 (tested 0.10.5) |
| **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 Expand Up @@ -158,6 +158,12 @@ instance id or route — so re-copy the ingress URL from the mint response.
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.

Two residual cases remain, and both produce a second Chatwoot contact for the same person rather than
any loss of messages: a contact whose very first message arrives under their privacy id while Chatwoot
already knows them by phone number, and a chat whose stored mapping is lost. Once a chat is mapped the
adapter never creates a second conversation for it, so this cannot happen to a thread already in use.
If you do end up with a duplicate, merging the two contacts in Chatwoot is safe.
- **Chatwoot** — account-level webhooks with timestamped HMAC signing (see Setup).

### Per-session config
Expand Down
6 changes: 3 additions & 3 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.5.4",
"version": "0.5.5",
"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 @@ -10,9 +10,9 @@
"homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/chatwoot-adapter",
"repository": "https://github.com/rmyndharis/OpenWA-plugins",
"keywords": ["chatwoot", "helpdesk", "inbox", "handover", "two-way", "agent", "whatsapp", "openwa"],
"status": "beta",
"status": "stable",
"minOpenWAVersion": "0.8.7",
"testedOpenWAVersion": "0.8.7",
"testedOpenWAVersion": "0.10.5",
"sdkVersion": "1",
"permissions": ["net:fetch", "conversation:send", "webhook:ingress", "engine:read"],
"net": { "allow": [], "allowConfigHosts": ["baseUrl"] },
Expand Down
25 changes: 25 additions & 0 deletions chatwoot-adapter/sent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,31 @@ test('cold lid->phone table (canonicalChatId cannot resolve @lid) DROPS the own
assert.deepEqual(counts(), { contacts: 0, convs: 0 }); // crucially: NO duplicate conversation created
});

test('an own send with no engine id is still mirrored, and cannot silence later ones', async () => {
// An engine that cannot read an id back reports the empty sentinel (Baileys' `?? ''`), which the
// codebase already treats as a real hazard elsewhere (outbound.ts logs it; the gateway's unique index
// exempts NULL because '' would collide). Here it collapsed every id-less own send onto ONE dedup key:
// the first marked `seen:<sess>:wa:`, and every later one was skipped as "already seen" for the
// marker's whole 3-day life — silently, and only for the operator's Chatwoot thread.
//
// An id-less message cannot be de-duplicated by definition, so the choice is which way to fail. A
// duplicate in the helpdesk is visible and harmless; a missing customer message is neither.
const { deps: d, posted } = deps({
store: { getByChat: async () => ({ conversationId: 55, contactId: 9, sourceId: 'src', name: 'x' }) },
});
const first = { ...own, id: '' } as IncomingMessage;
const second = { ...own, id: '', body: 'a different message' } as IncomingMessage;

await handleSent(d, 'sess', 'Engine', first);
await handleSent(d, 'sess', 'Engine', second);

assert.equal(posted.length, 2, 'the second id-less own send was swallowed by the first one\'s marker');
assert.deepEqual(
posted.map(p => p.c),
['from my phone', 'a different message'],
);
});

test('respects relayGroups=false for a fromMe group send', async () => {
const { deps: d, posted } = deps({ relayGroups: false });
const grp = { ...own, id: 'o4', chatId: '12@g.us', isGroup: true } as IncomingMessage;
Expand Down
12 changes: 10 additions & 2 deletions chatwoot-adapter/sent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,18 @@ export async function handleSent(
const key = await deps.engine.canonicalChatId(sessionId, msg.chatId);
await deps.lock.run(`${sessionId}:${key}`, async () => {
try {
if (await deps.store.hasSeen('wa', msg.id, sessionId)) return;
// An engine that cannot read an id back reports the empty sentinel (Baileys' `?? ''`; outbound.ts
// logs the same case for its own echo guard). Such a message cannot be de-duplicated — and worse,
// keying on it collapses EVERY id-less own send onto one marker: the first would be relayed, and
// every later one skipped as "already seen" for the marker's full retention, silently and only in
// the operator's Chatwoot thread. Relay it and skip both marker operations instead. A duplicate in
// the helpdesk is visible and harmless; a missing customer message is neither.
const identifiable = Boolean(msg.id);
if (identifiable && (await deps.store.hasSeen('wa', msg.id, sessionId))) return;
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);
if (identifiable) await deps.store.markSeen('wa', msg.id, sessionId);
else deps.log('own send has no engine message id; relaying it without de-duplication');
await relayMessage(deps, sessionId, conversationId, msg, 'outgoing');
} catch (err) {
deps.log('own-send relay failed', err);
Expand Down
10 changes: 5 additions & 5 deletions plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -369,9 +369,9 @@
{
"id": "chatwoot-adapter",
"name": "Chatwoot Adapter",
"version": "0.5.4",
"version": "0.5.5",
"type": "extension",
"status": "beta",
"status": "stable",
"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.",
"author": "Yudhi Armyndharis <yudhi@rmyndharis.com>",
"license": "MIT",
Expand All @@ -386,12 +386,12 @@
"openwa"
],
"minOpenWAVersion": "0.8.7",
"testedOpenWAVersion": "0.8.7",
"releasedAt": "2026-07-21",
"testedOpenWAVersion": "0.10.5",
"releasedAt": "2026-07-22",
"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.4/chatwoot-adapter.zip"
"download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chatwoot-adapter-v0.5.5/chatwoot-adapter.zip"
},
{
"id": "faq-bot",
Expand Down
Loading