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.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 |
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.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
Expand Down
6 changes: 3 additions & 3 deletions chatwoot-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
<!-- END DETAILS -->
Expand Down
22 changes: 22 additions & 0 deletions chatwoot-adapter/chatwoot-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"/);
});
30 changes: 24 additions & 6 deletions chatwoot-adapter/chatwoot-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> = { 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;
}
Expand All @@ -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`, {
Expand Down
1 change: 1 addition & 0 deletions chatwoot-adapter/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, { current_value?: unknown; previous_value?: unknown }>>;
}

Expand Down
31 changes: 31 additions & 0 deletions chatwoot-adapter/inbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }]);
});
31 changes: 25 additions & 6 deletions chatwoot-adapter/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<number> {
const existing = await deps.store.getByChat(sessionId, msg.chatId); // re-read inside the lock
if (existing) return existing.conversationId;
Expand Down
6 changes: 3 additions & 3 deletions 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.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.",
Expand All @@ -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"] },
Expand Down
28 changes: 28 additions & 0 deletions chatwoot-adapter/outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand Down
31 changes: 29 additions & 2 deletions chatwoot-adapter/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ export async function handleOutbound(deps: OutboundDeps, req: WebhookRequest): P
async function relay(deps: OutboundDeps, sessionId: string | undefined, evt: ChatwootWebhookMessage): Promise<void> {
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}`);
Expand All @@ -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 } {
Expand Down
10 changes: 5 additions & 5 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.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.",
Expand All @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion types/openwa.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
}
Loading