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
7 changes: 7 additions & 0 deletions chatwoot-adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form
placeholder instead of an empty bubble. Outbound audio attachments from Chatwoot are sent back to
WhatsApp as PTT voice notes, and image/video/file attachments are relayed as their native media type —
previously any attachment without text was silently dropped. Requires OpenWA 0.8.3+. (#607)
- **Contact names self-heal for `@lid` chats.** A chat first seen from a privacy-id (`@lid`) sender is
seeded in Chatwoot with the bare id; once a real WhatsApp display name arrives on a later message, the
Chatwoot contact is renamed to it. Best-effort, only when the name actually changed, and never for
group contacts. (#609)
- **Self-hosted Chatwoot guidance** in the README: `baseUrl` must be a public `https` URL (LAN/`localhost`
are rejected by the SSRF guard), how to expose a self-hosted instance, and how to avoid 502/530 on large
media uploads through a tunnel. (#609)

## [0.1.1] — 2026-07-02

Expand Down
17 changes: 15 additions & 2 deletions chatwoot-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,23 @@ sees it.
The Chatwoot webhook **secret** and the instance's **session scope** are set when you mint the instance (they
are not part of the config above).

## Self-hosted Chatwoot

`baseUrl` must be a **public `https` URL** — `localhost`, a LAN IP, or a Docker-internal host such as
`host.docker.internal` are rejected (OpenWA's SSRF guard blocks private addresses, and only the configured
public host is added to the outbound allowlist). To use a self-hosted Chatwoot:

- Put it behind a public domain with TLS, or expose it through a tunnel (Cloudflare Tunnel, ngrok, …) and set
`baseUrl` to that public URL.
- Media flows through this URL both ways — inbound uploads (`attachments[]`) and the host fetching an outbound
attachment. A tunnel or reverse proxy with a small body-size limit can return **502/530** on large files; raise
the limit (e.g. nginx `client_max_body_size`, or your tunnel's max request size). Oversized inbound media that
the engine already dropped for size is relayed as a short placeholder instead of an empty message.

## Compatibility

- **OpenWA** ≥ 0.8.0 — needs Integration SDK v1 (webhook ingress, `ctx.mappings`, the session+chat handover
gate, and `net.allowConfigHosts`).
- **OpenWA** ≥ 0.8.3 — needs Integration SDK v1 (webhook ingress, `ctx.mappings`, the session+chat handover
gate, `net.allowConfigHosts`) and the `conversation.send` media/voice types used for outbound attachments.
- **Chatwoot** — account-level webhooks with timestamped HMAC signing (see Setup).

## Security
Expand Down
8 changes: 8 additions & 0 deletions chatwoot-adapter/chatwoot-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ test('postText posts an incoming message with the api token header', async () =>
assert.equal(last.init!.headers!['api_access_token'], 'tok');
});

test('updateContact PUTs the new name to the contact', async () => {
const { fn, calls } = fakeFetch({ 'PUT /api/v1/accounts/3/contacts/9': { body: { id: 9 } } });
await new ChatwootClient(fn, cfg).updateContact(9, 'Budi');
const last = calls.at(-1)!;
assert.equal(last.init!.method, 'PUT');
assert.deepEqual(JSON.parse(last.init!.body as string), { name: 'Budi' });
});

test('postText forwards source_id and the in_reply_to_external_id thread pointer when given', async () => {
const { fn, calls } = fakeFetch({ 'POST /api/v1/accounts/3/conversations/55/messages': { body: { id: 1 } } });
await new ChatwootClient(fn, cfg).postText(55, '..', { sourceId: 'wa1', inReplyToExternalId: 'wa0' });
Expand Down
4 changes: 4 additions & 0 deletions chatwoot-adapter/chatwoot-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export class ChatwootClient {
return src;
}

async updateContact(contactId: number, name: string): Promise<void> {
await this.json(`${this.base()}/contacts/${contactId}`, { method: 'PUT', body: JSON.stringify({ name }) });
}

async findOpenConversation(contactId: number): Promise<number | null> {
const { data } = await this.json<{ payload?: Array<{ id: number; inbox_id?: number; status?: string }> }>(
`${this.base()}/contacts/${contactId}/conversations`,
Expand Down
37 changes: 37 additions & 0 deletions chatwoot-adapter/inbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,40 @@ test('a voice note with an omitted blob posts a placeholder, not an empty bubble
await handleInbound(d, 'sess', 'Engine', voice);
assert.deepEqual(posted, [{ id: 55, c: '🎤 Voice message' }]);
});

test('refreshes an @lid contact name once a real pushName arrives (#609)', async () => {
const updates: Array<[number, string]> = [];
const patches: Array<{ name?: string }> = [];
const { deps: d } = deps({
client: { updateContact: async (id: number, name: string) => void updates.push([id, name]) },
store: {
getByChat: async () => ({ conversationId: 55, contactId: 9, sourceId: 'src', name: '621@lid' }),
patch: async (_s: string, _c: string, p: { name?: string }) => void patches.push(p),
},
});
const lidMsg = { ...msg, id: 'x1', chatId: '621@lid', contact: { pushName: 'Budi' } } as IncomingMessage;
await handleInbound(d, 'sess', 'Engine', lidMsg);
assert.deepEqual(updates, [[9, 'Budi']]);
assert.deepEqual(patches, [{ name: 'Budi' }]);
});

test('does not rename when the stored name already matches (#609)', async () => {
const updates: unknown[] = [];
const { deps: d } = deps({
client: { updateContact: async (id: number, name: string) => void updates.push([id, name]) },
store: { getByChat: async () => ({ conversationId: 55, contactId: 9, sourceId: 'src', name: 'Budi' }) },
});
await handleInbound(d, 'sess', 'Engine', { ...msg, chatId: '621@lid', contact: { pushName: 'Budi' } } as IncomingMessage);
assert.equal(updates.length, 0);
});

test('never renames a group contact from a member pushName (#609)', async () => {
const updates: unknown[] = [];
const { deps: d } = deps({
client: { updateContact: async (...a: unknown[]) => void updates.push(a) },
store: { getByChat: async () => ({ conversationId: 55, contactId: 9, sourceId: 'src', name: 'Group 12@g.us' }) },
});
const grp = { ...msg, isGroup: true, chatId: '12@g.us', author: '621@c.us', contact: { pushName: 'Budi' } } as IncomingMessage;
await handleInbound(d, 'sess', 'Engine', grp);
assert.equal(updates.length, 0);
});
25 changes: 22 additions & 3 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 type { ChatwootClient } from './chatwoot-client.ts';
import type { MappingStore } from './mapping-store.ts';
import type { MappingStore, ChatLink } from './mapping-store.ts';
import type { KeyedAsyncLock } from './chat-lock.ts';
import { shouldRelayInbound } from './filters.ts';

Expand Down Expand Up @@ -69,7 +69,10 @@ function placeholderFor(msg: IncomingMessage): string {

async function resolveConversation(deps: InboundDeps, sessionId: string, msg: IncomingMessage): Promise<number> {
const existing = await deps.store.getByChat(sessionId, msg.chatId); // re-read inside the lock
if (existing) return existing.conversationId;
if (existing) {
await refreshContactName(deps, sessionId, msg, existing);
return existing.conversationId;
}
const identifier = msg.chatId; // WA JID — individual @c.us/@lid or group JID (stable across @lid migration)
const name = msg.isGroup ? `Group ${msg.chatId}` : msg.contact?.pushName || msg.contact?.name || msg.senderPhone || identifier;
const phone = msg.isGroup ? undefined : msg.senderPhone ?? undefined;
Expand All @@ -79,6 +82,22 @@ async function resolveConversation(deps: InboundDeps, sessionId: string, msg: In
: await deps.client.createContact(identifier, name, phone);
const conversationId =
(await deps.client.findOpenConversation(contact.id)) ?? (await deps.client.createConversation(contact.id, contact.sourceId));
await deps.store.link(sessionId, msg.chatId, deps.instanceId, { conversationId, contactId: contact.id, sourceId: contact.sourceId });
await deps.store.link(sessionId, msg.chatId, deps.instanceId, { conversationId, contactId: contact.id, sourceId: contact.sourceId, name });
return conversationId;
}

// A 1:1 chat first seen from an @lid sender is seeded with the bare JID as its Chatwoot name (no pushName
// yet). Once a real pushName arrives, update the contact so agents see a human name instead of an id
// (#609 P1). Best-effort and only when the name actually changed — never blocks the relay, never overwrites
// a real name with a fallback (only pushName/name qualify, not senderPhone/JID).
async function refreshContactName(deps: InboundDeps, sessionId: string, msg: IncomingMessage, link: ChatLink): Promise<void> {
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 });
} catch (err) {
deps.log('contact name refresh failed', err);
}
}
3 changes: 3 additions & 0 deletions chatwoot-adapter/mapping-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export interface ChatLink {
contactId: number;
sourceId: string;
handoverState?: 'bot' | 'human' | 'closed';
// Last name synced to the Chatwoot contact. Lets inbound skip a redundant rename and detect when a real
// pushName has arrived for a contact first seeded with a bare JID. Absent on pre-0.2.0 rows.
name?: string;
}

// Single-document-per-chat mapping over ctx.storage, mirrored into the core ctx.mappings row so the
Expand Down
Loading