diff --git a/README.md b/README.md index 9ce8c42..566fd0e 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.1.1 | 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.2.0 | 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 4e9d2e2..421c902 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.2.0] — 2026-07-03 + +### Added + +- **Reply/quote context is forwarded to Chatwoot.** Every relayed message now carries its WhatsApp id as + `source_id`, and a reply carries `content_attributes.in_reply_to_external_id`, so a swipe-to-reply shows + its quoted bubble in Chatwoot instead of a bare, context-less line. (#606) +- **Voice notes relay both ways.** Inbound WhatsApp voice notes are uploaded as Chatwoot voice messages + (`is_voice_message`, `voice.ogg`); a voice note whose blob was dropped for size posts a short + 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) + ## [0.1.1] — 2026-07-02 ### Fixed diff --git a/chatwoot-adapter/README.md b/chatwoot-adapter/README.md index a2d7138..dcb7df0 100644 --- a/chatwoot-adapter/README.md +++ b/chatwoot-adapter/README.md @@ -11,13 +11,13 @@ mirror it. Runs sandboxed in the plugin worker; no server, no extra port. | Field | Value | | ----- | ----- | | **Identifier** | `chatwoot-adapter` | -| **Version** | 0.1.1 | -| **Released** | 2026-07-02 | +| **Version** | 0.2.0 | +| **Released** | 2026-07-03 | | **Status** | beta | | **Author** | Yudhi Armyndharis | | **License** | MIT | | **Type** | `extension` | -| **Requires OpenWA** | ≥ 0.8.0 (tested 0.8.1) | +| **Requires OpenWA** | ≥ 0.8.3 (tested 0.8.3) | | **Keywords** | chatwoot, helpdesk, inbox, handover, two-way, agent, whatsapp, openwa | | **Repository** | [OpenWA-plugins/chatwoot-adapter](https://github.com/rmyndharis/OpenWA-plugins/tree/main/chatwoot-adapter) | diff --git a/chatwoot-adapter/chatwoot-client.test.ts b/chatwoot-adapter/chatwoot-client.test.ts index d39d6af..fff1e9f 100644 --- a/chatwoot-adapter/chatwoot-client.test.ts +++ b/chatwoot-adapter/chatwoot-client.test.ts @@ -48,3 +48,25 @@ test('postText posts an incoming message with the api token header', async () => assert.deepEqual(JSON.parse(last.init!.body as string), { content: 'hello', message_type: 'incoming', private: false }); assert.equal(last.init!.headers!['api_access_token'], 'tok'); }); + +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' }); + const body = JSON.parse(calls.at(-1)!.init!.body as string); + assert.equal(body.source_id, 'wa1'); + assert.deepEqual(body.content_attributes, { in_reply_to_external_id: 'wa0' }); +}); + +test('postMedia marks a voice note and threads it (is_voice_message + source_id in the multipart body)', async () => { + const { fn, calls } = fakeFetch({ 'POST /api/v1/accounts/3/conversations/55/messages': { body: { id: 2 } } }); + await new ChatwootClient(fn, cfg).postMedia( + 55, + '', + { filename: 'voice.ogg', contentType: 'audio/ogg', data: new Uint8Array([1, 2, 3]) }, + { sourceId: 'wa5', isVoiceMessage: true }, + ); + const raw = Buffer.from(calls.at(-1)!.init!.body as Uint8Array).toString('latin1'); + assert.match(raw, /name="is_voice_message"\r\n\r\ntrue/); + assert.match(raw, /name="source_id"\r\n\r\nwa5/); + assert.match(raw, /filename="voice.ogg"/); +}); diff --git a/chatwoot-adapter/chatwoot-client.ts b/chatwoot-adapter/chatwoot-client.ts index 9aa3e52..658a247 100644 --- a/chatwoot-adapter/chatwoot-client.ts +++ b/chatwoot-adapter/chatwoot-client.ts @@ -7,6 +7,13 @@ export interface ChatwootConfig { inboxId: number; } +// Threading metadata for a relayed message. `sourceId` is the WhatsApp message id (so later replies can +// reference it); `inReplyToExternalId` is the quoted message's WA id when this message is a reply. +export interface MessagePostOptions { + sourceId?: string; + inReplyToExternalId?: string; +} + // The slice of ctx.net.fetch this client needs (host-proxied, SSRF-guarded). Injectable for tests. export type NetFetch = ( url: string, @@ -99,10 +106,13 @@ export class ChatwootClient { return data.id; } - async postText(conversationId: number, content: string): Promise<{ id: number }> { + async postText(conversationId: number, content: string, opts: MessagePostOptions = {}): Promise<{ id: number }> { + const payload: Record = { content, message_type: 'incoming', private: false }; + if (opts.sourceId) payload.source_id = opts.sourceId; + if (opts.inReplyToExternalId) payload.content_attributes = { in_reply_to_external_id: opts.inReplyToExternalId }; const { data } = await this.json<{ id: number }>(`${this.base()}/conversations/${conversationId}/messages`, { method: 'POST', - body: JSON.stringify({ content, message_type: 'incoming', private: false }), + body: JSON.stringify(payload), }); return data; } @@ -111,14 +121,22 @@ export class ChatwootClient { conversationId: number, content: string, file: { filename: string; contentType: string; data: Uint8Array }, + opts: MessagePostOptions & { isVoiceMessage?: boolean } = {}, ): Promise<{ id: number }> { const boundary = `----cw${conversationId}${file.data.byteLength}`; + const fields = [ + { name: 'content', value: content }, + { name: 'message_type', value: 'incoming' }, + ]; + // Rails parses bracket notation into nested params; source_id + in_reply_to_external_id give Chatwoot + // the threading it uses for the native WhatsApp integration, is_voice_message renders a voice bubble. + if (opts.sourceId) fields.push({ name: 'source_id', value: opts.sourceId }); + if (opts.inReplyToExternalId) + fields.push({ name: 'content_attributes[in_reply_to_external_id]', value: opts.inReplyToExternalId }); + if (opts.isVoiceMessage) fields.push({ name: 'is_voice_message', value: 'true' }); const body = buildMultipartBody( boundary, - [ - { name: 'content', value: content }, - { name: 'message_type', value: 'incoming' }, - ], + fields, [{ name: 'attachments[]', filename: file.filename, contentType: file.contentType, data: file.data }], ); const res = await this.fetch(`${this.base()}/conversations/${conversationId}/messages`, { diff --git a/chatwoot-adapter/filters.ts b/chatwoot-adapter/filters.ts index 84f1686..89c18af 100644 --- a/chatwoot-adapter/filters.ts +++ b/chatwoot-adapter/filters.ts @@ -17,6 +17,7 @@ export interface ChatwootWebhookMessage { conversation?: { id?: number; status?: string; meta?: { assignee?: { id?: number } | null } }; inbox?: { id?: number }; sender?: { type?: string }; + attachments?: Array<{ id?: number; file_type?: string; data_url?: string }>; changed_attributes?: Array>; } diff --git a/chatwoot-adapter/inbound.test.ts b/chatwoot-adapter/inbound.test.ts index c8029c6..e46b7b5 100644 --- a/chatwoot-adapter/inbound.test.ts +++ b/chatwoot-adapter/inbound.test.ts @@ -60,3 +60,34 @@ test('skips fromMe and is idempotent (already seen → no post)', async () => { await handleInbound(d, 'sess', 'Engine', { ...msg, fromMe: true }); assert.equal(posted.length, 0); }); + +test('forwards the quote context (source_id + in_reply_to_external_id) on a reply (#606)', async () => { + let opts: unknown; + const { deps: d } = deps({ + client: { postText: async (_id: number, _c: string, o: unknown) => { opts = o; return { id: 1 }; } }, + }); + const reply = { ...msg, id: 'r1', quotedMessage: { id: 'orig', body: 'earlier' } } as IncomingMessage; + await handleInbound(d, 'sess', 'Engine', reply); + assert.deepEqual(opts, { sourceId: 'r1', inReplyToExternalId: 'orig' }); +}); + +test('relays an inbound voice note as a Chatwoot voice message (#607)', async () => { + let call: { file: { filename: string; contentType: string }; o: { isVoiceMessage?: boolean; sourceId?: string } } | undefined; + const { deps: d } = deps({ + client: { + postMedia: async (_id: number, _c: string, file: never, o: never) => { call = { file, o }; return { id: 2 }; }, + }, + }); + const voice = { ...msg, id: 'v1', body: '', type: 'voice', media: { mimetype: 'audio/ogg; codecs=opus', data: 'AAA' } } as IncomingMessage; + await handleInbound(d, 'sess', 'Engine', voice); + assert.equal(call!.file.filename, 'voice.ogg'); + assert.equal(call!.o.isVoiceMessage, true); + assert.equal(call!.o.sourceId, 'v1'); +}); + +test('a voice note with an omitted blob posts a placeholder, not an empty bubble (#607)', async () => { + const { deps: d, posted } = deps(); + const voice = { ...msg, id: 'v2', body: '', type: 'voice', media: { mimetype: 'audio/ogg', omitted: true, sizeBytes: 999999 } } as IncomingMessage; + await handleInbound(d, 'sess', 'Engine', voice); + assert.deepEqual(posted, [{ id: 55, c: '🎤 Voice message' }]); +}); diff --git a/chatwoot-adapter/inbound.ts b/chatwoot-adapter/inbound.ts index 76e2d51..88a4033 100644 --- a/chatwoot-adapter/inbound.ts +++ b/chatwoot-adapter/inbound.ts @@ -27,14 +27,25 @@ export async function handleInbound(deps: InboundDeps, sessionId: string, source await deps.store.markSeen('wa', msg.id, sessionId); const conversationId = await resolveConversation(deps, sessionId, msg); const content = prefixSender(msg); + // source_id lets a later reply thread against this message; in_reply_to_external_id forwards the + // quote context (#606) so a short reply like ".." keeps the bubble it answered. + const post = { sourceId: msg.id, inReplyToExternalId: msg.quotedMessage?.id }; + const isVoice = msg.type === 'voice'; if (deps.relayMedia && msg.media?.data && !msg.media.omitted) { - await deps.client.postMedia(conversationId, content, { - filename: msg.media.filename ?? 'file', - contentType: msg.media.mimetype, - data: Buffer.from(msg.media.data, 'base64'), - }); + await deps.client.postMedia( + conversationId, + content, + { + filename: isVoice ? 'voice.ogg' : msg.media.filename ?? 'file', + contentType: msg.media.mimetype || (isVoice ? 'audio/ogg' : 'application/octet-stream'), + data: Buffer.from(msg.media.data, 'base64'), + }, + { ...post, isVoiceMessage: isVoice }, + ); } else { - await deps.client.postText(conversationId, content); + // No relayable blob (plain text, or media dropped/omitted). Never post an empty bubble for a + // media message — surface a short placeholder so the agent knows something arrived (#607). + await deps.client.postText(conversationId, msg.body?.trim() ? content : placeholderFor(msg), post); } } catch (err) { deps.log('inbound relay failed', err); @@ -48,6 +59,14 @@ function prefixSender(msg: IncomingMessage): string { return `*${who}:* ${msg.body}`; } +// A short stand-in for a bodyless message we couldn't relay as media (e.g. a voice note whose blob was +// omitted for size), so Chatwoot shows a meaningful line instead of an empty bubble. +function placeholderFor(msg: IncomingMessage): string { + if (msg.type === 'voice') return '🎤 Voice message'; + if (msg.media) return `📎 ${msg.media.filename ?? 'Attachment'}`; + return msg.body; +} + 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; diff --git a/chatwoot-adapter/manifest.json b/chatwoot-adapter/manifest.json index faac619..0798173 100644 --- a/chatwoot-adapter/manifest.json +++ b/chatwoot-adapter/manifest.json @@ -1,7 +1,7 @@ { "id": "chatwoot-adapter", "name": "Chatwoot Adapter", - "version": "0.1.1", + "version": "0.2.0", "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.", @@ -11,8 +11,8 @@ "repository": "https://github.com/rmyndharis/OpenWA-plugins", "keywords": ["chatwoot", "helpdesk", "inbox", "handover", "two-way", "agent", "whatsapp", "openwa"], "status": "beta", - "minOpenWAVersion": "0.8.0", - "testedOpenWAVersion": "0.8.1", + "minOpenWAVersion": "0.8.3", + "testedOpenWAVersion": "0.8.3", "sdkVersion": "1", "permissions": ["net:fetch", "conversation:send", "webhook:ingress"], "net": { "allow": [], "allowConfigHosts": ["baseUrl"] }, diff --git a/chatwoot-adapter/outbound.test.ts b/chatwoot-adapter/outbound.test.ts index 2093b0e..f624893 100644 --- a/chatwoot-adapter/outbound.test.ts +++ b/chatwoot-adapter/outbound.test.ts @@ -57,6 +57,34 @@ test('relays an outgoing agent reply with an explicit chatId', async () => { assert.deepEqual(sent, [{ sessionId: 'sess', chatId: 'c@wa', type: 'text', text: 'hi' }]); }); +test('relays an outbound audio attachment as a WhatsApp voice note (#607)', async () => { + const { deps: d, sent } = deps(); + await handleOutbound( + d, + req({ + event: 'message_created', message_type: 'outgoing', private: false, id: 8, inbox: { id: 7 }, conversation: { id: 55 }, + attachments: [{ id: 1, file_type: 'audio', data_url: 'https://chat.acme.com/blob/a.ogg' }], + }), + ); + assert.deepEqual(sent, [ + { sessionId: 'sess', chatId: 'c@wa', type: 'voice', mediaUrl: 'https://chat.acme.com/blob/a.ogg', text: undefined }, + ]); +}); + +test('relays an outbound image attachment with its caption (#607)', async () => { + const { deps: d, sent } = deps(); + await handleOutbound( + d, + req({ + event: 'message_created', message_type: 'outgoing', private: false, id: 9, content: 'look', inbox: { id: 7 }, conversation: { id: 55 }, + attachments: [{ id: 1, file_type: 'image', data_url: 'https://chat.acme.com/blob/x.jpg' }], + }), + ); + assert.deepEqual(sent, [ + { sessionId: 'sess', chatId: 'c@wa', type: 'image', mediaUrl: 'https://chat.acme.com/blob/x.jpg', text: 'look' }, + ]); +}); + test('drops the incoming echo and private notes', async () => { const { deps: d, sent } = deps(); await handleOutbound(d, req({ message_type: 'incoming', inbox: { id: 7 }, conversation: { id: 55 }, content: 'x' })); diff --git a/chatwoot-adapter/outbound.ts b/chatwoot-adapter/outbound.ts index 8dbf7d2..f04b1e6 100644 --- a/chatwoot-adapter/outbound.ts +++ b/chatwoot-adapter/outbound.ts @@ -41,7 +41,10 @@ export async function handleOutbound(deps: OutboundDeps, req: WebhookRequest): P async function relay(deps: OutboundDeps, sessionId: string | undefined, evt: ChatwootWebhookMessage): Promise { const conversationId = evt.conversation?.id; const text = evt.content; - if (!conversationId || !text) return; + const media = firstMediaAttachment(evt); + // A media-only agent reply (voice note, image, …) has no `content`, so gate on either text or media — + // the old text-only guard dropped every attachment silently (#607). + if (!conversationId || (!text && !media)) return; const target = await deps.store.getByConversation(conversationId, sessionId); if (!target) { deps.log(`no WA mapping for conversation ${conversationId}`); @@ -52,11 +55,35 @@ async function relay(deps: OutboundDeps, sessionId: string | undefined, evt: Cha // Dedup, but mark only AFTER a successful send: a transient send failure must retry the reply, not be // silently suppressed as "already seen". Scope the marker by the delivery's session (F-02/F-03). if (id && (await deps.store.hasSeen('cw', id, sessionId))) return; - await deps.conversations.send({ sessionId: target.sessionId, chatId: target.chatId, type: 'text', text }); + if (media) { + await deps.conversations.send({ + sessionId: target.sessionId, + chatId: target.chatId, + type: media.type, + mediaUrl: media.url, + text: text || undefined, + }); + } else { + await deps.conversations.send({ sessionId: target.sessionId, chatId: target.chatId, type: 'text', text }); + } if (id) await deps.store.markSeen('cw', id, sessionId); }); } +// First attachment with a downloadable URL wins (a WhatsApp message carries one media). The host fetches +// the URL by its SSRF-guarded media-by-URL path, so no bytes cross the sandbox. Audio relays as a PTT +// voice note — the common agent action is recording voice; a plain audio file is a rare exception. +function firstMediaAttachment( + evt: ChatwootWebhookMessage, +): { type: 'image' | 'video' | 'voice' | 'file'; url: string } | undefined { + for (const a of evt.attachments ?? []) { + if (!a?.data_url) continue; + const type = a.file_type === 'image' ? 'image' : a.file_type === 'video' ? 'video' : a.file_type === 'audio' ? 'voice' : 'file'; + return { type, url: a.data_url }; + } + return undefined; +} + // Human handover is driven by the assignee_id transition, NOT by status (status:'open' is not a human // signal — the adapter itself opens conversations). resolved => closed; assignee set => human; cleared => bot. function assigneeChange(evt: ChatwootWebhookMessage): { changed: boolean; assignee: number | undefined } { diff --git a/plugins.json b/plugins.json index 24d8f3e..17aad1d 100644 --- a/plugins.json +++ b/plugins.json @@ -369,7 +369,7 @@ { "id": "chatwoot-adapter", "name": "Chatwoot Adapter", - "version": "0.1.1", + "version": "0.2.0", "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.", @@ -385,13 +385,13 @@ "whatsapp", "openwa" ], - "minOpenWAVersion": "0.8.0", - "testedOpenWAVersion": "0.8.1", - "releasedAt": "2026-07-02", + "minOpenWAVersion": "0.8.3", + "testedOpenWAVersion": "0.8.3", + "releasedAt": "2026-07-03", "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.1.1/chatwoot-adapter.zip" + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chatwoot-adapter-v0.2.0/chatwoot-adapter.zip" }, { "id": "faq-bot", diff --git a/types/openwa.d.ts b/types/openwa.d.ts index 376b7a7..5cbd3a2 100644 --- a/types/openwa.d.ts +++ b/types/openwa.d.ts @@ -207,7 +207,7 @@ export interface ConversationSendEnvelope { sessionId?: string; instanceId?: string; chatId?: string; - type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'location'; + type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'voice' | 'location'; text?: string; mediaUrl?: string; replyTo?: string; @@ -263,4 +263,7 @@ export interface IncomingMessage { omitted?: boolean; sizeBytes?: number; }; + // The message this one replies to (swipe-to-reply / quote), when present. `id` is the quoted WhatsApp + // message id; `body` is its text. Carried on the inbound hook payload for reply-threading relays. + quotedMessage?: { id: string; body: string }; }