Skip to content

feat(chatwoot-adapter): relay an agent's reply-to as a WhatsApp quote - #64

Merged
rmyndharis merged 2 commits into
rmyndharis:mainfrom
ker00sama-dev:feat/chatwoot-adapter-outbound-quoted-replies
Aug 5, 2026
Merged

feat(chatwoot-adapter): relay an agent's reply-to as a WhatsApp quote#64
rmyndharis merged 2 commits into
rmyndharis:mainfrom
ker00sama-dev:feat/chatwoot-adapter-outbound-quoted-replies

Conversation

@ker00sama-dev

Copy link
Copy Markdown
Contributor

What

When an agent uses "Reply to" on a specific message in Chatwoot, the reply now arrives on WhatsApp as a real quoted reply instead of a plain message.

Why

The adapter already posts every relayed message with its WhatsApp id as source_id, and Chatwoot hands that id back on agent replies as content_attributes.in_reply_to_external_id — but handleOutbound ignored the field, so the quote context was silently lost. On a busy chat the person on WhatsApp can't tell which message the agent is answering.

How

  • ChatwootWebhookMessage gains the (optional) content_attributes shape.
  • relay() passes in_reply_to_external_id through as the ConversationSendEnvelope.replyTo the SDK already supports — for text and attachment replies alike. No new permissions, no envelope changes.
  • A reply whose quoted message carries no external id (e.g. a message imported by an external tool without a source_id) still relays — just unquoted, as before — rather than being dropped.

Tests

Three new cases in outbound.test.ts (text quote, attachment quote, missing-external-id fallback). Full suite: 542 pass, tsc --noEmit clean, packaging builds.

Observed on a live OpenWA 0.12.1 host + Chatwoot v4.16.2: agent replies-to arrived on WhatsApp with empty/missing quote context; with this change wired in, the quoted id rides the envelope.

The adapter posts every message with its WA id as source_id, and
Chatwoot hands that id back as content_attributes.in_reply_to_external_id
when an agent replies to a specific message - but the outbound relay
dropped it, so the reply arrived unquoted and the recipient couldn't
tell which message it answered. Pass it through as the send envelope's
replyTo, for text and media replies alike; a reply whose quoted message
has no external id still goes out unquoted rather than being dropped.
Copilot AI review requested due to automatic review settings August 3, 2026 11:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Chatwoot adapter’s outbound relay so that when a Chatwoot agent uses “Reply to”, the outbound WhatsApp message can be sent with quote context by passing Chatwoot’s content_attributes.in_reply_to_external_id through to the OpenWA send envelope as replyTo.

Changes:

  • Extend ChatwootWebhookMessage typing to include optional content_attributes with reply-to metadata.
  • Populate ConversationSendEnvelope.replyTo for outbound sends when in_reply_to_external_id is present.
  • Add tests covering quoted text replies and the missing-external-id fallback; update documentation to describe the new behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
chatwoot-adapter/outbound.ts Adds extraction of in_reply_to_external_id and forwards it as replyTo on outbound sends.
chatwoot-adapter/outbound.test.ts Adds test cases for reply-to quoting and fallback behavior.
chatwoot-adapter/filters.ts Expands webhook message type to include content_attributes used by outbound relay.
chatwoot-adapter/CHANGELOG.md Documents the new reply-to/quote behavior in Unreleased notes.
Suppressed comments (1)

chatwoot-adapter/outbound.ts:99

  • ConversationSendEnvelope documents that setting replyTo on media envelopes (image/file/audio/video/voice with mediaUrl) throws because the engine media path cannot quote. This code currently adds replyTo to media sends, which can break outbound attachment replies at runtime.
        type: media.type,
        mediaUrl: media.url,
        text: text || undefined,
        ...(replyTo ? { replyTo } : {}),
      });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread chatwoot-adapter/outbound.ts Outdated
Comment on lines +86 to +88
// 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. Absent or foreign (a Chatwoot
// message without source_id quotes as external id undefined), the reply just goes unquoted.
Comment thread chatwoot-adapter/outbound.test.ts Outdated
Comment on lines +87 to +101
test('a quoted reply with an attachment carries replyTo on the media envelope', async () => {
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', replyTo: 'WA_QUOTED_2' },
]);
});
Comment thread chatwoot-adapter/CHANGELOG.md Outdated
Comment on lines +15 to +18
answered. The relay now passes it through as the send envelope's `replyTo`, for text and attachment
replies alike. A reply whose quoted message has no external id (for example a note imported by an
external tool without a `source_id`) still goes out — just unquoted, as before, rather than being
dropped.
@rmyndharis

Copy link
Copy Markdown
Owner

Thanks for this — the feature is worth having, and the source_id plumbing was already in place exactly as you describe. I've pushed one commit to the branch rather than sending you back and forth, since two of the three points are one-liners.

The media envelope can't carry a quote. conversations.send rejects an envelope that has both mediaUrl and replyTo — the engine's media path has no way to quote — and raises PluginCapabilityError. That throw propagates out of relay(), handleOutbound rethrows it, and the ingress layer retries the identical payload until the budget is gone. Every agent "Reply to" carrying an attachment would dead-letter, and because markSeen only runs after a successful send, nothing deduped the retries. Chatwoot would still show the reply as sent. typebot-connector/turn.ts carries the same guard for the same reason. A media reply now goes out unquoted with its caption intact.

A quote target can be gone. in_reply_to_external_id is whatever Chatwoot stored, and the engine only retains a bounded window (whatsapp-web.js keeps roughly 100 messages per chat, Baileys 5,000 overall). Quoting a message older than that throws, so the quote is now best-effort: on failure the reply is re-sent unquoted. The comment and changelog entry promised this degrade, so this makes them true.

The test double now models the host. The fake send in outbound.test.ts accepted any envelope, which is why the attachment case passed while the real path would have failed. It now rejects media-plus-quote the way the host does, the attachment test asserts the absence of replyTo, and there's a new case for an unresolvable quote target.

Suite is 539 passing, tsc --noEmit clean, packaging builds. One small thing for next time: the PR body quotes 542 tests, actual count on the branch was 538 — worth re-running before quoting numbers, since a mismatch makes the rest harder to trust.

… a refused quote

The outbound relay set `replyTo` on the media envelope as well as the text one.
The host rejects that combination outright — the engine's media path cannot
quote — so every agent "Reply to" carrying an attachment threw, retried
identically until the ingress budget was gone, and dead-lettered while Chatwoot
showed the reply as sent. The echo marker is written only after a successful
send, so nothing deduped the retries either.

A media reply now goes out unquoted, with its caption intact. A text reply is
still quoted, but best-effort: a quoted message that has fallen outside the
engine's retained window makes the engine refuse the quote, so the reply is
re-sent unquoted rather than lost. Both paths favour delivering the reply over
decorating it.

The test double now mirrors the host contract and rejects a media envelope
carrying a quote, so this class of defect cannot pass the suite again; the
attachment case asserts the absence of `replyTo`, and a new case covers the
unresolvable quote target.
@rmyndharis
rmyndharis force-pushed the feat/chatwoot-adapter-outbound-quoted-replies branch from dd30735 to ea5bd81 Compare August 5, 2026 13:51
@rmyndharis
rmyndharis merged commit 17d9a8e into rmyndharis:main Aug 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants