diff --git a/README.md b/README.md index 03f8cc4..6a548cd 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.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 | diff --git a/chatwoot-adapter/CHANGELOG.md b/chatwoot-adapter/CHANGELOG.md index e18e672..f747119 100644 --- a/chatwoot-adapter/CHANGELOG.md +++ b/chatwoot-adapter/CHANGELOG.md @@ -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 diff --git a/chatwoot-adapter/README.md b/chatwoot-adapter/README.md index 4c626b3..0cd6195 100644 --- a/chatwoot-adapter/README.md +++ b/chatwoot-adapter/README.md @@ -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) | @@ -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 diff --git a/chatwoot-adapter/manifest.json b/chatwoot-adapter/manifest.json index 45f81b1..12e4d5c 100644 --- a/chatwoot-adapter/manifest.json +++ b/chatwoot-adapter/manifest.json @@ -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.", @@ -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"] }, diff --git a/chatwoot-adapter/sent.test.ts b/chatwoot-adapter/sent.test.ts index 18e11a4..7687017 100644 --- a/chatwoot-adapter/sent.test.ts +++ b/chatwoot-adapter/sent.test.ts @@ -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::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; diff --git a/chatwoot-adapter/sent.ts b/chatwoot-adapter/sent.ts index 1204e04..93e5f18 100644 --- a/chatwoot-adapter/sent.ts +++ b/chatwoot-adapter/sent.ts @@ -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); diff --git a/plugins.json b/plugins.json index 61744e3..f4a51cb 100644 --- a/plugins.json +++ b/plugins.json @@ -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 ", "license": "MIT", @@ -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",