Skip to content

fix(chatwoot): resolve instanceId before looking up reaction targets - #2731

Closed
ticczaleski wants to merge 9 commits into
evolution-foundation:mainfrom
ticczaleski:fix/reaction-webhook-instance-id
Closed

ticczaleski wants to merge 9 commits into
evolution-foundation:mainfrom
ticczaleski:fix/reaction-webhook-instance-id

Conversation

@ticczaleski

@ticczaleski ticczaleski commented Sep 21, 2026 •

Copy link
Copy Markdown

Summary

  • ChatwootRouter's webhook route builds instance from the URL's :instanceName param alone (RouterBroker#dataValidate never sets instanceId). The message_created path patches instance.instanceId = waInstance.instanceId right after resolving the running instance, but handleReactionWebhook never did — so getMessageByKeyId's WHERE "instanceId" = ${instance.instanceId} predicate always compared against undefined and matched nothing.
  • Effect in production: every agent reaction sent from the Chatwoot dashboard logged Could not resolve WhatsApp key for chatwoot message X; acknowledging without retry and was silently dropped, even though the target message existed in Evolution's local Message table. Confirmed live: the reaction webhook fired correctly from Chatwoot (capability gate, event dispatch, delivery all healthy), reached Evolution, but failed the lookup — while the reverse direction (WhatsApp contact reaction -> Chatwoot) already worked since it runs through the in-process messages.upsert handler, which does set instanceId correctly.
  • Fix: fill in instance.instanceId from waInstance.instanceId in handleReactionWebhook, same as the sibling code path.

Test plan

  • npx vitest run src/api/integrations/chatbot/chatwoot/services/chatwoot-reactions.spec.ts — 10/10 passing, including a new regression test that reproduces a bare {instanceName} instance (as the real webhook route produces) and asserts getMessageByKeyId is called with the resolved instanceId.
  • eslint / tsc --noEmit via pre-commit hook — clean.
  • Manual: verified on production (evolution-api-ti1) that before this fix, an agent reaction on a real message never reached WhatsApp; the code path was confirmed via live logs against message id 1246 which existed in Message with the correct key.id but was never found due to the instanceId mismatch.

Summary by Sourcery

Improve Chatwoot delivery reliability and reaction synchronization across Chatwoot and WhatsApp.

New Features:

  • Support bidirectional Chatwoot and WhatsApp reactions, including reaction creation, updates, and removals.
  • Preserve WhatsApp message identifiers on Chatwoot messages to support quoted-message resolution.

Bug Fixes:

  • Resolve the running WhatsApp instance ID before looking up Chatwoot reaction targets, preventing valid targets from being missed.
  • Report outbound delivery failures on the original Chatwoot message instead of creating separate private notes.
  • Prevent duplicate Chatwoot webhook deliveries from sending duplicate WhatsApp messages or reactions.

Enhancements:

  • Add cache-backed atomic delivery claims with local and Redis implementations.
  • Improve quoted-message lookup by preferring WhatsApp-native external identifiers.

Build:

  • Switch the test scripts to Vitest and add Vitest configuration and dependencies.

CI:

  • Update Docker publishing workflows with the new image namespace, manual tagging, version and SHA tags, concurrency controls, and build caching.

Tests:

  • Add regression and service tests covering reaction bridging, delivery handling, cache claims, and instance ID resolution.

Chores:

  • Update package lockfile dependencies.

luizceconi-ccz and others added 9 commits September 19, 2026 10:09
…lures as private notes

The Chatwoot -> WhatsApp send path in receiveWebhook() had no
deduplication: a retried/duplicated Chatwoot message_created webhook
would call waInstance.textMessage/sendAttachment a second time for
the same message, and a blind unconditional 500ms sleep at the top
of the handler masked (without fixing) any race it was meant to
avoid, at the cost of latency on every webhook.

- Remove the unconditional 500ms sleep.
- Add ChatwootDeliveryService.claim(instanceName, chatwootMessageId,
  operation), an atomic idempotency claim keyed on the tuple the
  webhook carries, so a duplicate delivery is acknowledged without
  re-sending to WhatsApp. Backed by a new ICache.setNX (SET NX EX in
  Redis; synchronous check-and-set in the in-process LocalCache,
  which is race-free because no await separates the check from the
  set). CacheService.setNX fails open (claim succeeds) when caching
  is disabled, so behavior is unchanged for deployments without a
  cache configured.
- Replace onSendMessageError's private-note creation with
  ChatwootDeliveryService.reportFailure, which calls Chatwoot's
  existing authenticated message update endpoint (PATCH
  .../messages/:id with { status: 'failed', external_error }) so the
  original message becomes retryable instead of spawning a second,
  unrelated message in the thread.

Also introduces vitest (the "test" script previously pointed at a
non-existent test/all.test.ts) with 10 passing unit tests covering
the new idempotency claim and failure-reporting behavior, plus the
underlying cache primitives. Full end-to-end coverage of
receiveWebhook() itself is out of scope for this change given the
size of that method and the absence of any prior test harness for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bound WhatsApp keys

After WhatsApp accepts an outbound send, Evolution only recorded the
WhatsApp key on its own local DB row (updateChatwootMessageId) — it
never told Chatwoot, so Chatwoot's source_id for agent-sent messages
stayed empty forever. That broke quoting an outgoing message from the
WhatsApp side, since Chatwoot's InReplyToMessageBuilder resolves
parents by source_id.

- Add ChatwootDeliveryService.registerExternalId: after a successful
  textMessage/sendAttachment, calls Chatwoot's existing authenticated
  message update endpoint with { source_id: 'WAID:' + key.id } —
  matching the same prefix convention already used for inbound
  messages, so the reply-matching and echo-guard logic stay
  consistent. Never writes to Chatwoot's database directly.
- getQuotedMessage now prefers content_attributes.in_reply_to_external_id
  (identifies the parent directly, on either side of the conversation)
  and falls back to Evolution's own chatwootMessageId mapping only
  when it is absent, per the plan: Evolution's local mapping only
  ever covers messages Evolution itself sent.

Depends on the ChatwootDeliveryService introduced in
fix/chatwoot-idempotent-delivery (PR #1) — this branch is stacked on
top of it.

Companion Chatwoot-side change (same repo family, chatwoot fork):
Api::V1::Accounts::Conversations::MessagesController#update now
accepts source_id (API inboxes only, unique per inbox_id, validated
at the model level with a matching partial unique index), which is
what this PR's registerExternalId call relies on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d-replies

fix(chatwoot): resolve quoted replies by external id and register outbound WhatsApp keys
fix(chatwoot): make outbound delivery idempotent and stop masking failures as private notes
Two problems this closes:

1. Inbound: a WhatsApp contact's reaction was turned into a plain
   Chatwoot text message whose content was just the emoji
   (chatwoot.service.ts, the reactionMessage branch of eventWhatsapp).
   That inflated unread counts, changed the conversation's last
   message, could trigger automations, and could never be removed
   when the contact un-reacted (empty text was silently dropped).

2. Outbound: nothing relayed an agent's reaction (created via
   Chatwoot's new PUT .../messages/:id/reaction endpoint) to WhatsApp
   at all.

Changes:
- receiveWebhook now branches on the three new Chatwoot events
  (message_reaction_created/updated/deleted) before the existing
  message-shaped filtering, since their payload has no `conversation`
  object. handleReactionWebhook only relays actor_type: 'User'
  (agent) reactions to WhatsApp via waInstance.reactionMessage —
  actor_type: 'Contact' reactions already came from WhatsApp, so
  relaying them back would echo. Deletion sends an empty reaction
  string (Baileys' native "remove reaction" signal). Deduped via the
  same ChatwootDeliveryService.claim(...) used for the outbound
  message-send path, keyed by (instance, chatwootMessageId, event).
  An unresolvable target (unknown source_id, or the target message
  hasn't finished sending yet) is logged and acknowledged rather than
  retried.
- The reactionMessage branch of eventWhatsapp now calls
  handleInboundContactReaction, which resolves the target message's
  chatwootMessageId/chatwootConversationId via the existing
  getMessageByKeyId lookup and PUTs Chatwoot's reaction endpoint
  directly (message_type: 'incoming' attributes it to the contact),
  instead of createMessage(...reactionMessage.text...). An unknown
  parent is logged and acknowledged, never retried. createMessage is
  never called on this path.

Depends on Chatwoot's new reaction endpoint and the
Channel::Api#provider_capability?('reactions') gate (already merged
in the chatwoot fork) — Chatwoot only sends these events to inboxes
that declared the capability, so no capability check was needed here.

9 new unit tests on ChatwootService's two new methods (constructed
with mocked constructor dependencies, matching the existing
ChatwootDeliveryService test style since receiveWebhook itself has
no test harness).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
feat(chatwoot): synchronize WhatsApp reactions without fake messages
The three inherited Docker publish workflows all targeted
evoapicloud/evolution-api — the upstream project's own Docker Hub
namespace. This fork has no credentials for (and no rights to push
to) that namespace, so every run would fail at push, and in practice
none had ever run successfully (zero recorded workflow runs on this
fork before this change).

- Point all three workflows at ticczaleski/evolution-api instead,
  reusing the same DOCKER_USERNAME/DOCKER_PASSWORD secret names
  already configured the same way on the ticczaleski/chatwoot fork.
- publish_docker_image_latest.yml (push to main) now mirrors
  chatwoot's docker-build.yml pattern: concurrency group per ref,
  a workflow_dispatch custom_tag input, and metadata-action tags for
  latest + the package.json version + a short commit sha, instead of
  a single hardcoded "latest" tag.

This is CI-only; no application behavior changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…pace

ci(docker): publish images to this fork's own Docker Hub namespace
The HTTP webhook route builds `instance` from the URL's :instanceName
param alone (ChatwootRouter -> RouterBroker#dataValidate never sets
instanceId), same as the message_created path which patches it in via
`instance.instanceId = waInstance.instanceId` right after resolving the
running instance. handleReactionWebhook was missing that same patch, so
getMessageByKeyId's `instanceId = ${instance.instanceId}` predicate was
always comparing against undefined and silently matched no rows -
every agent reaction sent from the dashboard was acknowledged as
"could not resolve WhatsApp key" and never relayed to WhatsApp, even
though the target message existed in the local Message table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer's Guide

The PR fixes the production Chatwoot-to-WhatsApp reaction lookup by populating instanceId from the running instance, and also introduces broader Chatwoot delivery reliability improvements: deduplication, failure status updates, external-ID registration, quoted-reply resolution, and inbound reaction synchronization. Supporting changes add atomic cache claims, Vitest coverage/configuration, and revised Docker image publishing workflows.

Sequence diagram for Chatwoot agent reaction delivery

sequenceDiagram
    participant Chatwoot
    participant ChatwootService
    participant waInstance as WhatsAppInstance
    participant MessageDB as MessageDatabase

    Chatwoot->>ChatwootService: receiveWebhook(instance, body)
    ChatwootService->>waInstance: resolve waInstances[instanceName]
    ChatwootService->>ChatwootService: set instance.instanceId = waInstance.instanceId
    ChatwootService->>MessageDB: getMessageByKeyId(instance, rawKeyId)
    MessageDB-->>ChatwootService: targetMessage.key
    ChatwootService->>waInstance: reactionMessage({key, reaction})
    waInstance-->>ChatwootService: reaction delivered
Loading

Sequence diagram for deduplicated Chatwoot outbound delivery

sequenceDiagram
    participant Chatwoot
    participant ChatwootService
    participant Cache
    participant WhatsApp
    participant ChatwootAPI

    Chatwoot->>ChatwootService: receiveWebhook(instance, body)
    ChatwootService->>Cache: claim(instanceName, body.id, operation)
    Cache-->>ChatwootService: true or false
    alt claim accepted
        ChatwootService->>WhatsApp: send message
        WhatsApp-->>ChatwootService: message key
        ChatwootService->>ChatwootAPI: registerExternalId(..., source_id)
    else duplicate delivery
        ChatwootService-->>Chatwoot: acknowledge webhook
    end
Loading

Sequence diagram for WhatsApp reaction synchronization to Chatwoot

sequenceDiagram
    participant WhatsApp
    participant ChatwootService
    participant MessageDB as MessageDatabase
    participant ChatwootAPI

    WhatsApp->>ChatwootService: messages.upsert reaction
    ChatwootService->>MessageDB: getMessageByKeyId(instance, reactionMessage.key.id)
    MessageDB-->>ChatwootService: Chatwoot message and conversation IDs
    ChatwootService->>ChatwootAPI: PUT reaction endpoint
    ChatwootAPI-->>ChatwootService: reaction updated
Loading

File-Level Changes

Change Details Files
Fix Chatwoot agent reactions by resolving the running WhatsApp instance ID before querying the target message.
  • Copies waInstance.instanceId onto the route-provided instance.
  • Routes reaction webhook events to dedicated handling.
  • Adds regression coverage for a route instance containing only instanceName.
  • Relays created/updated reactions and clears deleted reactions, while ignoring contact-originated events and unknown targets.
src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts
src/api/integrations/chatbot/chatwoot/services/chatwoot-reactions.spec.ts
Prevent duplicate outbound Chatwoot deliveries and improve failure/reply metadata synchronization.
  • Adds cache-backed atomic delivery claims with a five-minute TTL.
  • Marks the original Chatwoot message failed instead of creating a private failure note.
  • Registers the WhatsApp key as Chatwoot source_id after successful sends.
  • Uses WhatsApp external IDs to resolve quoted-message parents before legacy mappings.
  • Relays inbound WhatsApp reactions through Chatwoot's reaction API instead of creating messages.
src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.ts
src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.spec.ts
src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts
src/api/integrations/chatbot/chatwoot/services/chatwoot-reactions.spec.ts
Add atomic cache-claim support across local, Redis, and cache-service implementations.
  • Extends the cache interface and service with setNX.
  • Implements namespaced in-process claims and Redis SET NX EX claims.
  • Fails open when caching is disabled, while Redis errors return an unsuccessful claim.
  • Adds unit tests for delegation, disabled caching, TTL-backed claims, deletion, and namespace isolation.
src/api/abstract/abstract.cache.ts
src/api/services/cache.service.ts
src/api/services/cache.service.spec.ts
src/cache/localcache.ts
src/cache/localcache.spec.ts
src/cache/rediscache.ts
Modernize the test setup and expand Docker publishing controls.
  • Replaces the watch-based test script with Vitest and adds watch mode/configuration.
  • Adds reaction and delivery service tests.
  • Publishes images to the ticczaleski/evolution-api repository.
  • Adds manual dispatch custom tags, semantic/version/SHA tags, concurrency cancellation, and GitHub Actions build caching.
package.json
package-lock.json
vitest.config.ts
.github/workflows/publish_docker_image.yml
.github/workflows/publish_docker_image_homolog.yml
.github/workflows/publish_docker_image_latest.yml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@ticczaleski

Copy link
Copy Markdown
Author

Opened by mistake against the wrong repo (targeted the upstream project instead of our internal fork). Closing; the real PR is in our fork.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've reviewed your changes and they look great!

Sourcery assessment

Needs a human reviewer. This changes production outbound messaging and reaction handling, including deduplication claims, WhatsApp sends, and persistent Chatwoot message updates; an error can send duplicate or missing messages and those external effects are not undone by reverting. It also changes Docker publishing to a different image repository, so a workflow mistake could disrupt the image consumed by deployments.


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

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.

2 participants