diff --git a/chatwoot-adapter/CHANGELOG.md b/chatwoot-adapter/CHANGELOG.md index 421c902..3bcf207 100644 --- a/chatwoot-adapter/CHANGELOG.md +++ b/chatwoot-adapter/CHANGELOG.md @@ -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 diff --git a/chatwoot-adapter/README.md b/chatwoot-adapter/README.md index dcb7df0..91600b3 100644 --- a/chatwoot-adapter/README.md +++ b/chatwoot-adapter/README.md @@ -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 diff --git a/chatwoot-adapter/chatwoot-client.test.ts b/chatwoot-adapter/chatwoot-client.test.ts index fff1e9f..1f9bb76 100644 --- a/chatwoot-adapter/chatwoot-client.test.ts +++ b/chatwoot-adapter/chatwoot-client.test.ts @@ -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' }); diff --git a/chatwoot-adapter/chatwoot-client.ts b/chatwoot-adapter/chatwoot-client.ts index 658a247..57d8c77 100644 --- a/chatwoot-adapter/chatwoot-client.ts +++ b/chatwoot-adapter/chatwoot-client.ts @@ -90,6 +90,10 @@ export class ChatwootClient { return src; } + async updateContact(contactId: number, name: string): Promise { + await this.json(`${this.base()}/contacts/${contactId}`, { method: 'PUT', body: JSON.stringify({ name }) }); + } + async findOpenConversation(contactId: number): Promise { const { data } = await this.json<{ payload?: Array<{ id: number; inbox_id?: number; status?: string }> }>( `${this.base()}/contacts/${contactId}/conversations`, diff --git a/chatwoot-adapter/inbound.test.ts b/chatwoot-adapter/inbound.test.ts index e46b7b5..6a82b1b 100644 --- a/chatwoot-adapter/inbound.test.ts +++ b/chatwoot-adapter/inbound.test.ts @@ -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); +}); diff --git a/chatwoot-adapter/inbound.ts b/chatwoot-adapter/inbound.ts index 88a4033..68c1dbc 100644 --- a/chatwoot-adapter/inbound.ts +++ b/chatwoot-adapter/inbound.ts @@ -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'; @@ -69,7 +69,10 @@ function placeholderFor(msg: IncomingMessage): string { async function resolveConversation(deps: InboundDeps, sessionId: string, msg: IncomingMessage): Promise { 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; @@ -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 { + 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); + } +} diff --git a/chatwoot-adapter/mapping-store.ts b/chatwoot-adapter/mapping-store.ts index 92e8ce2..60ec51b 100644 --- a/chatwoot-adapter/mapping-store.ts +++ b/chatwoot-adapter/mapping-store.ts @@ -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