diff --git a/chatwoot-adapter/CHANGELOG.md b/chatwoot-adapter/CHANGELOG.md index b8775c5..eb1337b 100644 --- a/chatwoot-adapter/CHANGELOG.md +++ b/chatwoot-adapter/CHANGELOG.md @@ -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 diff --git a/chatwoot-adapter/filters.ts b/chatwoot-adapter/filters.ts index b091e8c..697b9b9 100644 --- a/chatwoot-adapter/filters.ts +++ b/chatwoot-adapter/filters.ts @@ -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>; } diff --git a/chatwoot-adapter/outbound.test.ts b/chatwoot-adapter/outbound.test.ts index b47834f..ca1b596 100644 --- a/chatwoot-adapter/outbound.test.ts +++ b/chatwoot-adapter/outbound.test.ts @@ -28,12 +28,25 @@ function fakeStorage(): PluginStorage { } const fakeMappings: PluginMappingsCapability = { upsert: async () => {}, get: async () => null, getByProvider: async () => null }; -function deps(over: { store?: Record } = {}) { +// `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; 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: { @@ -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( diff --git a/chatwoot-adapter/outbound.ts b/chatwoot-adapter/outbound.ts index 7ba7e0c..dcb88fe 100644 --- a/chatwoot-adapter/outbound.ts +++ b/chatwoot-adapter/outbound.ts @@ -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, @@ -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