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.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 |
Expand Down
13 changes: 13 additions & 0 deletions chatwoot-adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion chatwoot-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
34 changes: 34 additions & 0 deletions chatwoot-adapter/backfill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ function makeDeps(
over: {
engine?: Record<string, unknown>;
store?: Record<string, unknown>;
client?: Record<string, unknown>;
relayGroups?: boolean;
backfillLimit?: number;
failOn?: string;
Expand All @@ -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),
Expand Down Expand Up @@ -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
});
9 changes: 7 additions & 2 deletions chatwoot-adapter/backfill.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
Expand Down
74 changes: 74 additions & 0 deletions chatwoot-adapter/inbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<phone>@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');
});
6 changes: 4 additions & 2 deletions chatwoot-adapter/inbound.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 };
}
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.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.",
Expand Down
60 changes: 60 additions & 0 deletions chatwoot-adapter/relay.test.ts
Original file line number Diff line number Diff line change
@@ -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 `<digits>@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);
});
30 changes: 30 additions & 0 deletions chatwoot-adapter/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.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.",
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.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",
Expand Down
Loading