diff --git a/README.md b/README.md index c2d6446..6c7c777 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.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 | diff --git a/chatwoot-adapter/CHANGELOG.md b/chatwoot-adapter/CHANGELOG.md index c6b628c..8ba081c 100644 --- a/chatwoot-adapter/CHANGELOG.md +++ b/chatwoot-adapter/CHANGELOG.md @@ -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 `@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 diff --git a/chatwoot-adapter/README.md b/chatwoot-adapter/README.md index 4171871..58b6538 100644 --- a/chatwoot-adapter/README.md +++ b/chatwoot-adapter/README.md @@ -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 | @@ -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 diff --git a/chatwoot-adapter/inbound.test.ts b/chatwoot-adapter/inbound.test.ts index 87658ee..985ce6c 100644 --- a/chatwoot-adapter/inbound.test.ts +++ b/chatwoot-adapter/inbound.test.ts @@ -9,7 +9,7 @@ const msg = { timestamp: 0, fromMe: false, isGroup: false, senderPhone: '+621', contact: { pushName: 'Budi' }, } as IncomingMessage; -function deps(over: { client?: Record; store?: Record } = {}) { +function deps(over: { client?: Record; store?: Record; engine?: Record } = {}) { let contacts = 0; let convs = 0; const posted: Array<{ id: number; c: string }> = []; @@ -31,13 +31,65 @@ function deps(over: { client?: Record; store?: Record {}, ...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({ diff --git a/chatwoot-adapter/inbound.ts b/chatwoot-adapter/inbound.ts index f8e33fa..138eae4 100644 --- a/chatwoot-adapter/inbound.ts +++ b/chatwoot-adapter/inbound.ts @@ -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 { - const { conversationId, created } = await resolveConversation(deps, sessionId, msg); + // Best-effort @lid -> @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. @@ -29,6 +38,13 @@ export async function handleInbound( msg: IncomingMessage, ): Promise { 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 @@ -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 diff --git a/chatwoot-adapter/manifest.json b/chatwoot-adapter/manifest.json index 4a790dc..3d9455a 100644 --- a/chatwoot-adapter/manifest.json +++ b/chatwoot-adapter/manifest.json @@ -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.", diff --git a/chatwoot-adapter/relay.ts b/chatwoot-adapter/relay.ts index 1a8fe6a..089016a 100644 --- a/chatwoot-adapter/relay.ts +++ b/chatwoot-adapter/relay.ts @@ -125,13 +125,17 @@ export async function refreshContactName( sessionId: string, msg: IncomingMessage, link: ChatLink, + chatKey: string, ): Promise { 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); } diff --git a/chatwoot-adapter/retry.ts b/chatwoot-adapter/retry.ts index 3efaf44..da7907f 100644 --- a/chatwoot-adapter/retry.ts +++ b/chatwoot-adapter/retry.ts @@ -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 { diff --git a/plugins.json b/plugins.json index ef7a3d0..826877b 100644 --- a/plugins.json +++ b/plugins.json @@ -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.", @@ -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",