diff --git a/README.md b/README.md index 680e177..4dad293 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.6 | 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.7 | 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.3.0 | stable | diff --git a/chatwoot-adapter/CHANGELOG.md b/chatwoot-adapter/CHANGELOG.md index d06bc5b..97d3ba6 100644 --- a/chatwoot-adapter/CHANGELOG.md +++ b/chatwoot-adapter/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form ## [Unreleased] +## [0.5.7] — 2026-07-23 + +### Fixed + +- **New Chatwoot contacts were almost always created without a `phone_number`.** The phone was set + only from `msg.senderPhone`, which the host populates solely for `@lid` senders under + `RESOLVE_LID_TO_PHONE=true` — so plain `@c.us` chats, and `@lid` chats whose lid→phone mapping was + already warmed, reached Chatwoot with no phone, breaking contact search and downstream merges. A new + `resolvePhone` helper now derives the number from the host-resolved sender or the canonical `@c.us` + chat id (whose JID user-part is the MSISDN), while deliberately ignoring `contact.number` (it carries + LID digits, not the real phone, for `@lid` senders). Groups and genuinely unresolved `@lid` chats + still create without a phone. Applies to new contacts only; existing rows are untouched. + ## [0.5.6] — 2026-07-23 ### Fixed diff --git a/chatwoot-adapter/README.md b/chatwoot-adapter/README.md index 7ca4aaf..2ccf876 100644 --- a/chatwoot-adapter/README.md +++ b/chatwoot-adapter/README.md @@ -15,7 +15,7 @@ | Field | Value | | ----- | ----- | | **Identifier** | `chatwoot-adapter` | -| **Version** | 0.5.6 | +| **Version** | 0.5.7 | | **Released** | 2026-07-23 | | **Status** | stable | | **Author** | Yudhi Armyndharis | diff --git a/chatwoot-adapter/backfill.test.ts b/chatwoot-adapter/backfill.test.ts index 0115100..22496bf 100644 --- a/chatwoot-adapter/backfill.test.ts +++ b/chatwoot-adapter/backfill.test.ts @@ -11,6 +11,7 @@ function makeDeps( over: { engine?: Record; store?: Record; + client?: Record; relayGroups?: boolean; backfillLimit?: number; failOn?: string; @@ -34,6 +35,7 @@ function makeDeps( }, postMedia: async () => ({ id: 2 }), updateContact: async () => {}, + ...over.client, }; const store = { hasSeen: async (_k: string, id: string) => seen.has(id), @@ -161,3 +163,35 @@ test('bulk creates NO empty conversation for a chat with no fetchable history (B assert.equal(creates.length, 0); // ensureConversation/createConversation never called assert.equal(posts.length, 0); }); + +test('bulk sweep populates `phone_number` on the new Chatwoot contact when the chat id is resolvable', async () => { + // Three chats in one sweep: a plain @c.us (gets its real phone from the JID user-part), a group + // (no phone — groups never get one), and a cold @lid (no phone — the mapping is genuinely unknown). + const chats = [ + { id: '1234567890@c.us', name: 'A', isGroup: false, unreadCount: 0, timestamp: 1 }, + { id: '120363@g.us', name: 'G', isGroup: true, unreadCount: 0, timestamp: 2 }, + { id: '118367890123478@lid', name: 'L', isGroup: false, unreadCount: 0, timestamp: 3 }, + ]; + const createCalls: Array<{ identifier: string; phone: string | undefined }> = []; + const { deps } = makeDeps({ + engine: { + getChats: async () => chats, + // Each chat has one history message so ensureConversation fires on the sweep. + getChatHistory: async (_s: string, chatId: string) => [ + { ...hist(`${chatId}-1`, 10, false, 'hello'), chatId }, + ], + }, + client: { + createContact: async (identifier: string, _name: string, phone?: string) => { + createCalls.push({ identifier, phone }); + return { id: 9, sourceId: 'src' }; + }, + }, + }); + await backfillAllChats(deps, 'sessPhone'); + assert.equal(createCalls.length, 3); + const byId = Object.fromEntries(createCalls.map(c => [c.identifier, c.phone])); + assert.equal(byId['1234567890@c.us'], '+1234567890'); // resolved from the neutral @c.us id + assert.equal(byId['120363@g.us'], undefined); // groups never carry a phone + assert.equal(byId['118367890123478@lid'], undefined); // cold lid — pre-fix behavior preserved +}); diff --git a/chatwoot-adapter/backfill.ts b/chatwoot-adapter/backfill.ts index 626802d..5a76d09 100644 --- a/chatwoot-adapter/backfill.ts +++ b/chatwoot-adapter/backfill.ts @@ -1,5 +1,5 @@ import type { ChatSummary, IncomingMessage } from '../types/openwa'; -import { relayMessage, ensureConversation, type InboundDeps } from './relay.ts'; +import { relayMessage, ensureConversation, resolvePhone, type InboundDeps } from './relay.ts'; // Fetch a chat's recent history oldest->newest. Best-effort: any failure (including an engine that does // not support history, e.g. Baileys, which rejects) yields an empty list so callers degrade cleanly. @@ -67,7 +67,12 @@ export async function backfillAllChats(deps: InboundDeps, sessionId: string): Pr try { const ordered = await fetchHistory(deps, sessionId, chat.id); if (!ordered.length) return; // nothing to import -> don't create an empty Chatwoot conversation - const conversationId = await ensureConversation(deps, sessionId, chat.id, { name: chat.name || chat.id }); + // chat.id is already the neutral JID on this path (anti-corruption layer), so the phone can be + // resolved from it without an engine call. Groups skip (no MSISDN); a cold @lid stays no-phone. + const conversationId = await ensureConversation(deps, sessionId, chat.id, { + name: chat.name || chat.id, + phone: resolvePhone(chat, chat.id), + }); await replayHistory(deps, sessionId, conversationId, ordered); } catch (err) { deps.log(`bulk backfill failed for ${chat.id}`, err); diff --git a/chatwoot-adapter/inbound.test.ts b/chatwoot-adapter/inbound.test.ts index 985ce6c..ca2e685 100644 --- a/chatwoot-adapter/inbound.test.ts +++ b/chatwoot-adapter/inbound.test.ts @@ -210,3 +210,77 @@ test('never renames a group contact from a member pushName (#609)', async () => await handleInbound(d, 'sess', 'Engine', grp); assert.equal(updates.length, 0); }); + +// Helpers for the create-contact phone assertions below: capture the (identifier, name, phone) triple +// every createContact call was made with. Phone is the new behavior under test (resolvePhone feeds it). +function captureCreateContact() { + const calls: Array<{ identifier: string; name: string; phone: string | undefined }> = []; + const client = { + createContact: async (identifier: string, name: string, phone?: string) => { + calls.push({ identifier, name, phone }); + return { id: 9, sourceId: 'src' }; + }, + }; + return { calls, client }; +} + +test('@lid inbound with senderPhone set (RESOLVE_LID_TO_PHONE=true) creates the contact with the real phone', async () => { + const { calls, client } = captureCreateContact(); + const { deps: d } = deps({ client }); + // senderPhone is what the host populates when the env flag is on — MSISDN digits, no `+` guaranteed. + const lid = { ...msg, id: 'lid-pn', chatId: '118367890123478@lid' } as IncomingMessage; + lid.senderPhone = '1234567890'; + await handleInbound(d, 'sess', 'Engine', lid); + assert.equal(calls.length, 1); + assert.equal(calls[0].phone, '+1234567890'); +}); + +test('@lid inbound with a warm lid->phone mapping (canonicalChatId resolves) creates the contact with the real phone', async () => { + const { calls, client } = captureCreateContact(); + const { deps: d } = deps({ + client, + // The engine's in-memory lidMappingStore returns the chat's `@c.us` once any reply to them + // has warmed it, with RESOLVE_LID_TO_PHONE off. + engine: { canonicalChatId: async (_s: string, c: string) => (c === '118367890123478@lid' ? '1234567890@c.us' : c) }, + }); + const lid = { ...msg, id: 'lid-warm', chatId: '118367890123478@lid' } as IncomingMessage; + lid.senderPhone = undefined; + await handleInbound(d, 'sess', 'Engine', lid); + assert.equal(calls.length, 1); + assert.equal(calls[0].phone, '+1234567890'); +}); + +test('@lid inbound with COLD lid mapping (RESOLVE_LID_TO_PHONE off, no reply yet) creates the contact without a phone — no regression vs pre-fix behavior', async () => { + const { calls, client } = captureCreateContact(); + const { deps: d } = deps({ + client, + engine: { canonicalChatId: async (_s: string, c: string) => c }, // unresolved → @lid stays @lid + }); + const lid = { ...msg, id: 'lid-cold', chatId: '118367890123478@lid' } as IncomingMessage; + lid.senderPhone = undefined; + await handleInbound(d, 'sess', 'Engine', lid); + assert.equal(calls.length, 1); + assert.equal(calls[0].phone, undefined); // genuinely unknown — pre-fix behavior preserved +}); + +test('plain `@c.us` inbound without senderPhone creates the contact with the phone (resolved from the chatId user-part)', async () => { + const { calls, client } = captureCreateContact(); + const { deps: d } = deps({ client }); + // senderPhone on the host is lid-only, so a plain @c.us sender usually has no senderPhone at all. + const c_us = { ...msg, id: 'cn1', chatId: '1234567890@c.us' } as IncomingMessage; + c_us.senderPhone = undefined; + await handleInbound(d, 'sess', 'Engine', c_us); + assert.equal(calls.length, 1); + assert.equal(calls[0].phone, '+1234567890'); +}); + +test('a group inbound creates the group contact without a phone, regardless of senderPhone', async () => { + const { calls, client } = captureCreateContact(); + const { deps: d } = deps({ client }); + const grp = { ...msg, id: 'g1', isGroup: true, chatId: '120363@g.us', author: '621@c.us' } as IncomingMessage; + grp.senderPhone = '1234567890'; + await handleInbound(d, 'sess', 'Engine', grp); + assert.equal(calls.length, 1); + assert.equal(calls[0].phone, undefined); // groups never get a phone + assert.equal(calls[0].name, 'Group 120363@g.us'); +}); diff --git a/chatwoot-adapter/inbound.ts b/chatwoot-adapter/inbound.ts index 1e66192..08a88b7 100644 --- a/chatwoot-adapter/inbound.ts +++ b/chatwoot-adapter/inbound.ts @@ -1,6 +1,6 @@ import type { IncomingMessage } from '../types/openwa'; import { shouldRelayInbound } from './filters.ts'; -import { relayMessage, ensureConversation, refreshContactName, type InboundDeps } from './relay.ts'; +import { relayMessage, ensureConversation, refreshContactName, resolvePhone, type InboundDeps } from './relay.ts'; import { backfillHistory } from './backfill.ts'; import { MAX_PENDING_RETRIES, slimForRetry } from './retry.ts'; @@ -94,7 +94,9 @@ async function resolveConversation( : msg.contact?.pushName || msg.contact?.name || msg.senderPhone || msg.chatId; const conversationId = await ensureConversation(deps, sessionId, msg.chatId, { name, - phone: msg.isGroup ? undefined : msg.senderPhone ?? undefined, + // Phone from the host-resolved sender (RESOLVE_LID_TO_PHONE), or the canonical chat id (warm lid→pn + // cache / every plain @c.us chat); undefined when genuinely unknown so the contact still creates. + phone: resolvePhone(msg, canonicalChatId), }); return { conversationId, created: true }; } diff --git a/chatwoot-adapter/manifest.json b/chatwoot-adapter/manifest.json index c024642..06586df 100644 --- a/chatwoot-adapter/manifest.json +++ b/chatwoot-adapter/manifest.json @@ -1,7 +1,7 @@ { "id": "chatwoot-adapter", "name": "Chatwoot Adapter", - "version": "0.5.6", + "version": "0.5.7", "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.test.ts b/chatwoot-adapter/relay.test.ts new file mode 100644 index 0000000..786d2eb --- /dev/null +++ b/chatwoot-adapter/relay.test.ts @@ -0,0 +1,60 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolvePhone } from './relay.ts'; +import type { IncomingMessage } from '../types/openwa'; + +test('group messages never get a phone, regardless of senderPhone or canonical', () => { + assert.equal(resolvePhone({ isGroup: true, senderPhone: '+621' }, '621@c.us'), undefined); + assert.equal(resolvePhone({ isGroup: true }, '120@g.us'), undefined); +}); + +test('senderPhone wins when set — normalized to +digits regardless of host-side formatting', () => { + assert.equal(resolvePhone({ isGroup: false, senderPhone: '1234567890' }, '118367890123478@lid'), '+1234567890'); + assert.equal(resolvePhone({ isGroup: false, senderPhone: '+1234567890' }, '118367890123478@lid'), '+1234567890'); + // Separators / spaces / country-code prefixes the host might pass through. + assert.equal(resolvePhone({ isGroup: false, senderPhone: '+1 123 456 7890' }, '118367890123478@lid'), '+11234567890'); + assert.equal(resolvePhone({ isGroup: false, senderPhone: '62-81-234-567' }, '6281234567@c.us'), '+6281234567'); +}); + +test('senderPhone that strips to empty digits falls through to the canonical source', () => { + // A malformed sender like '--' yields no digits; the helper must not emit a bare '+'. + assert.equal( + resolvePhone({ isGroup: false, senderPhone: '--' }, '1234567890@c.us'), + '+1234567890', + ); +}); + +test('canonical `@c.us` is the phone when senderPhone is absent', () => { + assert.equal(resolvePhone({ isGroup: false }, '1234567890@c.us'), '+1234567890'); + assert.equal(resolvePhone({ isGroup: false, senderPhone: null }, '6281234567@c.us'), '+6281234567'); + assert.equal(resolvePhone({ isGroup: false, senderPhone: undefined }, '6281234567@c.us'), '+6281234567'); + assert.equal(resolvePhone({ isGroup: false, senderPhone: '' }, '6281234567@c.us'), '+6281234567'); +}); + +test('unresolved `@lid` (canonical stays `@lid`) yields no phone — pre-fix behavior preserved', () => { + assert.equal(resolvePhone({ isGroup: false }, '118367890123478@lid'), undefined); + assert.equal(resolvePhone({ isGroup: false, senderPhone: null }, '118367890123478@lid'), undefined); +}); + +test('groups (`@g.us`) and special channels (`@broadcast`, `@newsletter`) yield no phone', () => { + assert.equal(resolvePhone({ isGroup: false }, '120363@g.us'), undefined); + // Defensive: a chat-the-platform-doesn't-know should never produce a spurious +digits. + assert.equal(resolvePhone({ isGroup: false }, 'newsletter@newsletter'), undefined); + assert.equal(resolvePhone({ isGroup: false }, 'something@broadcast'), undefined); + // A bare id (no @-suffixed domain, treated as `unknown` by the host's toNeutral) → no phone; the helper + // does not guess at unrecognised formats, so a downstream createContact falls back to identifier-only. + assert.equal(resolvePhone({ isGroup: false }, 'not-a-jid'), undefined); +}); + +test('`msg.contact?.number` is intentionally NOT consulted — it can carry lid digits for @lid senders', () => { + // Even if the host happened to populate contact.number (= LID digits, never the real phone), the helper + // drops it: using it would silently set a wrong phone on the contact and corrupt future merges. + // We model the runtime shape (senderPhone undefined, contact.number the lid digits) and assert the + // helper returns the canonical phone — which for an unresolved id is undefined, not the lid digits. + const msgWithContact = { + id: 'x', from: 'x', to: 'y', chatId: '118369936273478@lid', body: 'hi', type: 'chat', + timestamp: 0, fromMe: false, isGroup: false, + senderPhone: undefined, contact: { number: '118369936273478' }, + } as IncomingMessage; + assert.equal(resolvePhone(msgWithContact, '118369936273478@lid'), undefined); +}); diff --git a/chatwoot-adapter/relay.ts b/chatwoot-adapter/relay.ts index 6b9ef9d..487fb24 100644 --- a/chatwoot-adapter/relay.ts +++ b/chatwoot-adapter/relay.ts @@ -108,6 +108,36 @@ export async function relayMessage( }); } +// Best phone for a brand-new Chatwoot contact, or `undefined` when no source knows it. Priority: +// 1. `msg.senderPhone` — populated by the host ONLY for `@lid` senders under `RESOLVE_LID_TO_PHONE=true`; +// MSISDN digits, no `+` guaranteed, so we normalize. +// 2. User-part of `canonicalChatId` when it ends `@c.us` — covers a warmed lid→pn mapping (the id is +// @lid-aware, resolved via the engine's in-memory map — no network) AND every plain `@c.us` chat, +// whose JID user-part is by definition the MSISDN (previously created with no phone at all). +// +// Deliberately NOT consulted: `msg.contact?.number`. For an `@lid` sender it carries the LID digits, not +// the real phone — matching on it would corrupt Chatwoot's contact search and future merges. +// +// Groups and an unresolved `@lid` (canonical stays `@lid`) yield `undefined` — pre-fix behavior preserved. +// Pure & synchronous, so the bulk sweep (ChatSummary, id already neutral) and retry drain reuse it freely. +export function resolvePhone( + msg: { isGroup: boolean; senderPhone?: string | null }, + canonicalChatId: string, +): string | undefined { + if (msg.isGroup) return undefined; + // Digits only, prefixed `+`; undefined if nothing survives (a host error like '--' must not emit a bare '+'). + const e164 = (raw: string): string | undefined => { + const digits = raw.replace(/\D/g, ''); + return digits ? `+${digits}` : undefined; + }; + if (msg.senderPhone) { + const phone = e164(msg.senderPhone); + if (phone) return phone; + } + if (canonicalChatId.endsWith('@c.us')) return e164(canonicalChatId.slice(0, -'@c.us'.length)); + return undefined; +} + // Get-or-create the Chatwoot contact + conversation for a chat and mirror the mapping. Self-contained so // the bulk backfill can call it from a chat summary (no triggering message), and idempotent so a chat // already mapped by the live path is a no-op. diff --git a/plugins.json b/plugins.json index c4a121b..96aa969 100644 --- a/plugins.json +++ b/plugins.json @@ -369,7 +369,7 @@ { "id": "chatwoot-adapter", "name": "Chatwoot Adapter", - "version": "0.5.6", + "version": "0.5.7", "type": "extension", "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.", @@ -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.6/chatwoot-adapter.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chatwoot-adapter-v0.5.7/chatwoot-adapter.zip", "i18n": { "es": { "name": "Adaptador de Chatwoot",