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
16 changes: 16 additions & 0 deletions chatwoot-adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form

## [Unreleased]

### Added

- **An agent's "Reply to" in Chatwoot now reaches WhatsApp as a real quote.** The adapter posts every
message with its WhatsApp id as `source_id`, and when an agent replies to a specific message Chatwoot
hands that id back as `content_attributes.in_reply_to_external_id` — but the outbound relay ignored it,
so the reply arrived as a plain message and the person on WhatsApp couldn't tell which message it
answered. The relay now passes it through as the send envelope's `replyTo`.

Quoting is best-effort, and delivering the reply always wins over decorating it. A **text** reply is
quoted; if the quoted message has fallen outside the engine's retained window (whatsapp-web.js keeps
about 100 messages per chat, Baileys 5,000 overall) the engine refuses the quote, and the reply is
re-sent unquoted instead of failing. A reply carrying an **attachment** is never quoted: the engine's
media path cannot quote at all, so the attachment goes out with its caption and no quote. A reply whose
quoted message has no external id (for example a note imported by an external tool without a
`source_id`) goes out unquoted, as before.

## [0.8.0] — 2026-08-01

### Added
Expand Down
3 changes: 3 additions & 0 deletions chatwoot-adapter/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export interface ChatwootWebhookMessage {
inbox?: { id?: number };
sender?: { type?: string };
attachments?: Array<{ id?: number; file_type?: string; data_url?: string }>;
// Set when the agent used "Reply to": `in_reply_to_external_id` is the quoted message's source_id,
// which is the WhatsApp message id for everything this adapter posts — so it can ride out as a quote.
content_attributes?: { in_reply_to?: number; in_reply_to_external_id?: string };
changed_attributes?: Array<Record<string, { current_value?: unknown; previous_value?: unknown }>>;
}

Expand Down
77 changes: 75 additions & 2 deletions chatwoot-adapter/outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,25 @@ function fakeStorage(): PluginStorage {
}
const fakeMappings: PluginMappingsCapability = { upsert: async () => {}, get: async () => null, getByProvider: async () => null };

function deps(over: { store?: Record<string, unknown> } = {}) {
// `rejectReplyTo` models the engine refusing one specific quote target (a message outside the
// engine's retained window), the way ctx.conversations.send surfaces MessageNotFoundError.
function deps(over: { store?: Record<string, unknown>; rejectReplyTo?: string } = {}) {
const sent: Array<{ sessionId?: string; chatId?: string; type: string; text?: string }> = [];
const handovers: Array<[unknown, string]> = [];
const d = {
lock: new KeyedAsyncLock(),
conversations: { send: async (e: { sessionId?: string; chatId?: string; type: string; text?: string }) => void sent.push(e) },
conversations: {
// Mirrors the host facade (core conversation-send-facade.ts): a media envelope carrying BOTH a
// mediaUrl and a replyTo is rejected outright — the engine media path cannot quote. Modelling it
// here is what stops a "quoted attachment" envelope from passing the suite and dead-lettering live.
send: async (e: { sessionId?: string; chatId?: string; type: string; text?: string; mediaUrl?: string; replyTo?: string }) => {
if (e.replyTo && e.mediaUrl && e.type !== 'text') {
throw new Error('conversation.send: replyTo is not supported for media messages');
}
if (e.replyTo && e.replyTo === over.rejectReplyTo) throw new Error('MessageNotFoundError');
sent.push(e);
},
},
handover: { set: async (k: unknown, s: string) => void handovers.push([k, s]) },
engine: { canonicalChatId: async (_s: string, c: string) => c },
store: {
Expand All @@ -58,6 +71,66 @@ test('relays an outgoing agent reply with an explicit chatId', async () => {
assert.deepEqual(sent, [{ sessionId: 'sess', chatId: 'c@wa', type: 'text', text: 'hi' }]);
});

test('an agent "Reply to" rides out as a WhatsApp quote (replyTo = quoted source_id)', async () => {
const { deps: d, sent } = deps();
await handleOutbound(
d,
req({
event: 'message_created', message_type: 'outgoing', private: false, id: 6, content: 'quoted answer',
inbox: { id: 7 }, conversation: { id: 55 },
content_attributes: { in_reply_to: 41, in_reply_to_external_id: 'WA_QUOTED_1' },
}),
);
assert.deepEqual(sent, [{ sessionId: 'sess', chatId: 'c@wa', type: 'text', text: 'quoted answer', replyTo: 'WA_QUOTED_1' }]);
});

test('a reply whose quoted message has no external id goes unquoted, not dropped', async () => {
const { deps: d, sent } = deps();
await handleOutbound(
d,
req({
event: 'message_created', message_type: 'outgoing', private: false, id: 7, content: 'plain',
inbox: { id: 7 }, conversation: { id: 55 },
content_attributes: { in_reply_to: 41 },
}),
);
assert.deepEqual(sent, [{ sessionId: 'sess', chatId: 'c@wa', type: 'text', text: 'plain' }]);
});

test('a quoted reply with an attachment omits replyTo — the media envelope must not carry a quote', async () => {
// The engine media path cannot quote, and the host REJECTS an envelope that carries both (see the
// fake send above). Delivering the attachment unquoted beats dead-lettering it for a quote decoration.
const { deps: d, sent } = deps();
await handleOutbound(
d,
req({
event: 'message_created', message_type: 'outgoing', private: false, id: 10, content: 'see this',
inbox: { id: 7 }, conversation: { id: 55 },
attachments: [{ id: 1, file_type: 'image', data_url: 'https://chat.acme.com/blob/x.jpg' }],
content_attributes: { in_reply_to_external_id: 'WA_QUOTED_2' },
}),
);
assert.deepEqual(sent, [
{ sessionId: 'sess', chatId: 'c@wa', type: 'image', mediaUrl: 'https://chat.acme.com/blob/x.jpg', text: 'see this' },
]);
});

test('an unresolvable quote target still delivers the reply, unquoted', async () => {
// The quoted message can fall outside the engine's retained window (wwjs keeps ~100 per chat, Baileys
// 5000 overall), and Chatwoot happily hands back its id anyway. Losing the quote is acceptable; losing
// the agent's reply to the dead-letter queue is not.
const { deps: d, sent } = deps({ rejectReplyTo: 'WA_GONE' });
await handleOutbound(
d,
req({
event: 'message_created', message_type: 'outgoing', private: false, id: 11, content: 'still answers',
inbox: { id: 7 }, conversation: { id: 55 },
content_attributes: { in_reply_to: 41, in_reply_to_external_id: 'WA_GONE' },
}),
);
assert.deepEqual(sent, [{ sessionId: 'sess', chatId: 'c@wa', type: 'text', text: 'still answers' }]);
});

test('relays an outbound audio attachment as a WhatsApp voice note (#607)', async () => {
const { deps: d, sent } = deps();
await handleOutbound(
Expand Down
18 changes: 17 additions & 1 deletion chatwoot-adapter/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ async function relay(deps: OutboundDeps, sessionId: string | undefined, evt: Cha
if (id && (await deps.store.hasSeen('cw', id, target.sessionId))) return;
let res: unknown;
if (media) {
// No replyTo on a media envelope: the engine media path cannot quote, and the host REJECTS an
// envelope carrying both — which would dead-letter the whole reply for a quote decoration. A
// quoted attachment therefore goes out unquoted, with its caption intact.
res = await deps.conversations.send({
sessionId: target.sessionId,
chatId: target.chatId,
Expand All @@ -93,7 +96,20 @@ async function relay(deps: OutboundDeps, sessionId: string | undefined, evt: Cha
text: text || undefined,
});
} else {
res = await deps.conversations.send({ sessionId: target.sessionId, chatId: target.chatId, type: 'text', text });
const env = { sessionId: target.sessionId, chatId: target.chatId, type: 'text' as const, text };
// An agent "Reply to" carries the quoted message's source_id — a WA message id for everything this
// adapter posts — and the engine renders it as a real WhatsApp quote. Best-effort: the target can
// sit outside the engine's retained window (whatsapp-web.js keeps ~100 messages per chat, Baileys
// 5000 overall) and the engine then throws, so a refused quote retries unquoted rather than burning
// the retry budget into the dead-letter queue. The re-send matches how a failed send is already
// handled here — mark-after-success, so a duplicate is possible but a lost agent reply is not.
const replyTo = evt.content_attributes?.in_reply_to_external_id;
res = replyTo
? await deps.conversations.send({ ...env, replyTo }).catch(err => {
deps.log('quote target unresolvable; sending the reply unquoted', err);
return deps.conversations.send(env);
})
: await deps.conversations.send(env);
}
if (id) await deps.store.markSeen('cw', id, target.sessionId);
// Echo guard for the own-send relay (#615): the message we just sent to WhatsApp will come back as a
Expand Down
Loading