Skip to content

fix(chatwoot): skip duplicate messages when a device redelivers the same key.id - #2716

Open
vin1i wants to merge 1 commit into
evolution-foundation:developfrom
vin1i:fix/chatwoot-dedup-source-id
Open

fix(chatwoot): skip duplicate messages when a device redelivers the same key.id#2716
vin1i wants to merge 1 commit into
evolution-foundation:developfrom
vin1i:fix/chatwoot-dedup-source-id

Conversation

@vin1i

@vin1i vin1i commented Sep 1, 2026

Copy link
Copy Markdown

Fixes #2675

Root cause

When a sender device redelivers a message with the same key.id (for example an unofficial client stuck in a resend loop), the live event path (messages.upsert / send.message) posts a new Chatwoot message on every arrival. There is no idempotency check on that path: the reporter accumulated 837 copies of a single message in one conversation, while WhatsApp itself shows it once thanks to native key.id dedup.

Fix

Reuse the exact dedup pattern sendData already applies to media messages: query Chatwoot's messages.source_id via chatwootImport.getExistingSourceIds before posting, gated by isImportHistoryAvailable(), and skip the message when WAID:<key.id> already exists in the target conversation. The check runs once at the top of the messages.upsert / send.message block, right after the conversation is resolved, so a single block covers every live path: plain text (group and direct), reactions, interactive button messages, ads and media.

Design decisions

  • Gated by isImportHistoryAvailable(), matching sendData: the lookup needs the Chatwoot database connection; without it the check is skipped and behavior is unchanged.
  • Fail open: getExistingSourceIds catches its own errors, logs them and returns an empty set, so a Chatwoot DB outage never blocks delivery. Losing dedup is acceptable; losing a message is not. This is also why there is no extra try/catch in the caller, unlike the snippet suggested in the issue.
  • The check is deliberately not inside createMessage, where the issue's suggested patch placed it. The messages.edit / send.message.update path re-posts an edited-message notice using the original message's WAID source id, so a check inside createMessage would silently drop every edit. Checking once per event also keeps multi-button interactive messages intact (they post several Chatwoot messages sharing one key.id within a single event).

Verification

The repository has no unit test infrastructure (npm test points to a non-existent test/all.test.ts), so no automated test is included. npm run lint:check and npm run build pass. Behavior per scenario:

  • New message: created in Chatwoot normally (lookup misses, flow unchanged).
  • Same key.id redelivered to the same conversation: skipped, logs "Message already saved on chatwoot".
  • Chatwoot DB connection failing: helper logs "Error on getExistingSourceIds" and returns an empty set, the message is still created (fail open).
  • Import database URI unset or left as the placeholder: check skipped entirely, behavior identical to today.
  • Edited message: still posted (that path is untouched).

Known limitations (shared with the existing sendData check)

  • getExistingSourceIds filters by the conversation id returned by the Chatwoot API, which is the per-account display_id, while messages.conversation_id in the Chatwoot schema is the conversation primary key. On single-account installs they coincide; on multi-account installs the lookup can miss. This limitation already applies to the media path today and is worth a separate fix in the helper.
  • The Chatwoot import Pool is created without connection or statement timeouts, so the lookup inherits pg defaults. Also pre-existing on the media path; a separate hardening PR could add timeouts.

Note: open PR #2614 changes the second argument of getExistingSourceIds from a number to an options object; whichever lands second needs a one-line adjustment in this call.

Summary by Sourcery

Skip live messages whose WhatsApp key IDs are already recorded in the target Chatwoot conversation.

Bug Fixes:

  • Prevent duplicate live Chatwoot messages when WhatsApp redelivers an event with an already-seen message key ID.

Enhancements:

  • Apply source-ID deduplication across supported live message types while preserving edited-message delivery and allowing delivery to continue when the lookup is unavailable.

…y.id

When a sender device redelivers a message with the same key.id (for
example an unofficial client stuck in a resend loop), the live event
path posted a new Chatwoot message on every arrival; one production
report accumulated 837 copies of a single message.

Reuse the dedup check that sendData already applies to media messages:
query Chatwoot's messages.source_id through getExistingSourceIds before
posting, gated by isImportHistoryAvailable, and skip the message when
the WAID source id already exists in the target conversation. The
helper is fail open: on lookup errors it logs and returns an empty set,
so delivery is never blocked.

The check sits at the top of the messages.upsert/send.message block so
it covers text, reactions, interactive buttons, ads and media, and does
not touch the messages.edit path, which legitimately re-posts using the
original message's source id.

Fixes evolution-foundation#2675
@sourcery-ai

sourcery-ai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor
Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

The live messages.upsert/send.message flow now performs a single Chatwoot source-ID lookup immediately after resolving the conversation, preventing duplicate key.id deliveries across text, reactions, interactive, ads, and media while preserving edits, multi-message interactive events, and fail-open delivery behavior.

Sequence diagram for live message deduplication

sequenceDiagram
    participant WA as WhatsApp
    participant CS as ChatwootService
    participant CI as chatwootImport
    participant CW as Chatwoot

    WA->>CS: messages.upsert/send.message
    CS->>CS: isImportHistoryAvailable()
    alt import history available and key.id exists
        CS->>CI: getExistingSourceIds([WAID:key.id], getConversation)
        alt source id already exists
            CI-->>CS: existing source id
            CS->>CS: logger.warn(Message already saved on chatwoot)
            CS-->>WA: skip duplicate
        else source id not found
            CI-->>CS: empty set
            CS->>CW: create live message
        end
    else import history unavailable or key.id absent
        CS->>CW: create live message
    end

    alt lookup error
        CI-->>CS: empty set
        CS->>CW: create live message
    end
Loading

File-Level Changes

Change Details Files
Add live-message idempotency filtering before message-type-specific processing.
  • Build the WAID source ID from the incoming key.id.
  • When import-history support is available, query existing Chatwoot source IDs for the resolved conversation.
  • Skip redelivered messages and log a warning when the source ID already exists.
  • Leave delivery fail-open when the lookup is unavailable or returns no match.
src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts

Possibly linked issues


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

@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 found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts" line_range="2243-2245" />
<code_context>
           return;
         }

+        if (body.key.id && this.isImportHistoryAvailable()) {
+          const sourceId = 'WAID:' + body.key.id;
+          const messageAlreadySaved = await chatwootImport.getExistingSourceIds([sourceId], getConversation);
+          if (messageAlreadySaved) {
+            if (messageAlreadySaved.size > 0) {
</code_context>
<issue_to_address>
**issue (bug_risk):** The deduplication is a non-atomic read-then-post sequence: two redelivered events for the same `key.id` can both query before either Chatwoot message is created, both receive an empty set, and both post duplicate messages.

**Triggers:** When duplicate deliveries for the same key.id are processed concurrently.

**Suggested fix:** Serialize deduplication per source ID or enforce a unique Chatwoot/database constraint so the lookup and insertion cannot succeed concurrently.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 1 finding to address first, and if the source-ID lookup is wrong or a key is reused, a legitimate message can be skipped, or a duplicate can already be persisted in Chatwoot before reverting stops the behavior. Reverting does not restore a skipped message or remove duplicates that were already sent.

Blocking findings: src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts:2245


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +2243 to +2245
if (body.key.id && this.isImportHistoryAvailable()) {
const sourceId = 'WAID:' + body.key.id;
const messageAlreadySaved = await chatwootImport.getExistingSourceIds([sourceId], getConversation);

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.

issue (bug_risk): The deduplication is a non-atomic read-then-post sequence: two redelivered events for the same key.id can both query before either Chatwoot message is created, both receive an empty set, and both post duplicate messages.

Triggers: When duplicate deliveries for the same key.id are processed concurrently.

Suggested fix: Serialize deduplication per source ID or enforce a unique Chatwoot/database constraint so the lookup and insertion cannot succeed concurrently.

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.

1 participant