Skip to content

fix(baileys): do not resend deleted messages on retry receipts - #2751

Open
sossei wants to merge 1 commit into
evolution-foundation:developfrom
sossei:fix/skip-retry-resend-of-deleted-messages
Open

sossei wants to merge 1 commit into
evolution-foundation:developfrom
sossei:fix/skip-retry-resend-of-deleted-messages

Conversation

@sossei

@sossei sossei commented Oct 7, 2026 •

Copy link
Copy Markdown

📋 Description

A message deleted for everyone no longer comes back. Before this change, when a group member's device sent a retry receipt (common after a group session is re-established, for example when an instance reconnects or re-pairs), Evolution answered with the original content of the deleted message, and the text reappeared for the other members, sometimes days after it was deleted.

Why it happened. Baileys answers a retry receipt with whatever getMessage returns, and skips the resend only when it returns a falsy value (sendMessagesAgain in Socket/messages-recv). Deleting a message keeps its content in the Message table, and getMessage never looked at the deletion, so it returned the deleted payload and Baileys relayed it.

What changed

  • getMessage (non-full path only) returns undefined when the message is marked deleted, so Baileys logs recv retry request, but message not available and sends nothing. The full = true path used by edits, quoted replies, media download and updateMessage is unchanged.
  • A message counts as deleted when the row has status = 'DELETED' or key.deleted = true (set by deleteMessage with LOGICAL_MESSAGE_DELETE), or when a DELETED MessageUpdate exists for this instance and key id. The lookup is by instance and key id, not by messageId: the row lookup in deleteMessage is not scoped to the instance, so in a group shared by several instances the deletion can be linked to another instance's copy (about 4% of deletions in the deployment we looked at).
  • The messages.update revoke branch (message === null) now stores its MessageUpdate as DELETED instead of the SERVER_ACK fallback, so revokes that arrive as events (for example, deleted from the phone) are also honored. The MESSAGES_DELETE webhook already reported this status.

How to reproduce (before this change)

  1. Create a WhatsApp group with: instance A (sender), instance B (another Evolution instance) and a regular phone as a third member.
  2. Through the API, send a text from instance A to the group, then delete it for everyone (DELETE /chat/deleteMessageForEveryone/{instance}). The phone shows "This message was deleted".
  3. Force the group session to be re-established: log out instance B and pair it again with a new QR code (restarting the sender instance alone usually does not trigger it).
  4. Within a few minutes, the deleted text reappears for the members whose devices sent a retry receipt. In the database, new Message rows show up for the deleted key id with the full content.

Checking it without a phone (PostgreSQL), messages of instance A that were deleted and later re-delivered:

select m."instanceId", m.key->>'id' as id, to_timestamp(m."messageTimestamp") as received_at,
       length(m.message->>'conversation') as text_len
from "Message" m
where m.key->>'fromMe' = 'false'
  and m.key->>'id' in (
    select u."keyId" from "MessageUpdate" u where u.status = 'DELETED' and u."fromMe" = true
  )
order by received_at;

Rows received after the deletion with text_len > 0 are deleted messages that were resent.

To see who asked for the retry, run with LOG_BAILEYS=debug and look for recv retry request.

Known limitation

Baileys keeps a recent-message cache (enableRecentMessageCache, 512 messages, 5 minute TTL) and checks it before calling getMessage. A retry that arrives within 5 minutes of the original send can still be answered from that cache, and Evolution does not control that path. The cases we observed were days old and are covered by this change.

🔗 Related Issue

No open issue. Complements #2728, which stops getMessage from returning { conversation: '' } when the message is not found. The two changes do not overlap: #2728 covers a missing row, this PR covers a row that exists but was deleted.

🧪 Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📚 Documentation update
  • 🔧 Refactoring (no functional changes)
  • ⚡ Performance improvement
  • 🧹 Code cleanup
  • 🔒 Security fix

🧪 Testing

  • Manual testing completed
  • Functionality verified in development environment
  • No breaking changes introduced
  • Tested with different connection types (if applicable)

What was verified:

  • Found on a production deployment running v2.3.7: deleted messages reappeared on group members' phones after instances were reconnected, across 4 separate reconnection events in 30 days. A member replied to a message that had been deleted three days earlier.
  • Isolated check of the new deletion lookup against a stubbed Prisma repository, 6 cases: row not found, normal message, status = 'DELETED', key.deleted, deletion recorded only as an instance-scoped MessageUpdate, and a DELETED update from another instance (must not count). All pass.
  • tsc --noEmit, eslint and prettier --check are clean.
  • Not yet done: the end-to-end reproduction above running against this branch.

✅ Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have manually tested my changes thoroughly
  • I have verified the changes work with different scenarios
  • Any dependent changes have been merged and published

📝 Additional Notes

Trade-off: a device that asks for a retry of a deleted message receives nothing. That matches what "delete for everyone" promises; previously it received the deleted content.

🤖 Generated with Claude Code

Summary by Sourcery

Stop Baileys retry handling from restoring messages that were deleted for everyone.

Bug Fixes:

  • Prevent deleted WhatsApp messages from being resent in response to Baileys retry receipts.
  • Record revoke events as deleted message updates so deleted content is consistently excluded from retry responses.

Baileys answers a retry receipt with whatever getMessage returns. A message
deleted for everyone keeps its content in the Message table, so when another
device asks for a retry (for example after a group session is re-established
on reconnection) the deleted text was relayed again and reappeared for the
other members.

getMessage now returns undefined on the non-full path when the message is
marked deleted: on the row (status DELETED or key.deleted, set by
deleteMessage with logical delete) or by a DELETED MessageUpdate for this
instance and key id. The messages.update revoke branch now records its
MessageUpdate as DELETED instead of the SERVER_ACK fallback, so revokes that
arrive as events are covered too. The full path is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Stops Baileys retry receipts from resurrecting deleted message content by filtering deleted records in the retry-specific getMessage path and recording revoke events as DELETED, while leaving full-message retrieval unchanged.

Sequence diagram for preventing deleted message retry resends

sequenceDiagram
    participant Device as GroupDevice
    participant Baileys
    participant Service as BaileysStartupService
    participant Messages as MessageRepository
    participant Updates as MessageUpdateRepository

    Device->>Baileys: retry receipt
    Baileys->>Service: getMessage(key, false)
    Service->>Messages: find message by key
    Messages-->>Service: message row
    Service->>Updates: findFirst(instanceId, keyId, status=DELETED)
    Updates-->>Service: deletion match or none
    alt message is deleted
        Service-->>Baileys: undefined
        Baileys-->>Device: no resend
    else message is available
        Service-->>Baileys: message content
        Baileys-->>Device: resend message
    end
Loading

File-Level Changes

Change Details Files
Prevent deleted messages from being returned for Baileys retry receipts while preserving full-message retrieval behavior.
  • Check deletion markers only on the non-full getMessage path used for retry responses.
  • Treat row status, key deletion flags, and instance-scoped DELETED MessageUpdate records as deletion indicators.
  • Return undefined and log when a deleted message is requested for resend.
src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts
Persist revoke events with an explicit deletion status so they participate in retry suppression.
  • Store messages.update revoke records as DELETED instead of the SERVER_ACK fallback.
  • Continue emitting the existing deletion webhook behavior.
src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts

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 2 issues

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

## Individual Comments

### Comment 1
<location path="src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts" line_range="703-706" />
<code_context>
+      return true;
+    }
+
+    const deletion = await this.prismaRepository.messageUpdate.findFirst({
+      where: { instanceId: this.instanceId, keyId, status: 'DELETED' },
+      select: { id: true },
+    });
+
+    return !!deletion;
</code_context>
<issue_to_address>
**Another instance’s message is deleted**

When a shared-group deletion selects a different instance’s copy, `deleteMessage` marks the wrong instance’s row, leaving the requesting instance’s row live. Its deletion marker is absent or is later removed with the other row, so `isMessageDeleted` finds no deletion and a retry returns the content.

Scope the `deleteMessage` lookup to the current instance so it marks that instance’s message row.

Also at `src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts:1893-1925`.
</issue_to_address>

### Comment 2
<location path="src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts" line_range="703-706" />
<code_context>
+      return true;
+    }
+
+    const deletion = await this.prismaRepository.messageUpdate.findFirst({
+      where: { instanceId: this.instanceId, keyId, status: 'DELETED' },
+      select: { id: true },
+    });
+
+    return !!deletion;
</code_context>
<issue_to_address>
**Deletion checks scan update history**

When an instance has accumulated many `MessageUpdate` rows, the lookup filters by `instanceId`, `keyId`, and `status` without a matching index, so retry checks scan the instance’s update rows and add increasing database work.

Add an index that supports the deletion lookup’s filter fields.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 2 findings to address first, and if the deletion check is wrong or misses a recorded deletion, a retry receipt can resend deleted message content to other devices, and reverting cannot undo that disclosure. If it incorrectly suppresses a live message, the delivery failure is bounded and can be repaired by retrying after a fix.

Blocking findings: src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts:706, src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts:706


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

Comment on lines +703 to +706
const deletion = await this.prismaRepository.messageUpdate.findFirst({
where: { instanceId: this.instanceId, keyId, status: 'DELETED' },
select: { id: true },
});

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.

🟠 High · Another instance’s message is deleted

When a shared-group deletion selects a different instance’s copy, deleteMessage marks the wrong instance’s row, leaving the requesting instance’s row live. Its deletion marker is absent or is later removed with the other row, so isMessageDeleted finds no deletion and a retry returns the content.

Scope the deleteMessage lookup to the current instance so it marks that instance’s message row.

Also at src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts:1893-1925.

Prompt for AI agents
In `src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts` at lines 703-706:

**Another instance’s message is deleted**

When a shared-group deletion selects a different instance’s copy, `deleteMessage` marks the wrong instance’s row, leaving the requesting instance’s row live. Its deletion marker is absent or is later removed with the other row, so `isMessageDeleted` finds no deletion and a retry returns the content.

Scope the `deleteMessage` lookup to the current instance so it marks that instance’s message row.

Also at `src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts:1893-1925`.

Comment on lines +703 to +706
const deletion = await this.prismaRepository.messageUpdate.findFirst({
where: { instanceId: this.instanceId, keyId, status: 'DELETED' },
select: { id: true },
});

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.

🟡 Medium · Deletion checks scan update history

When an instance has accumulated many MessageUpdate rows, the lookup filters by instanceId, keyId, and status without a matching index, so retry checks scan the instance’s update rows and add increasing database work.

Add an index that supports the deletion lookup’s filter fields.

Prompt for AI agents
In `src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts` at lines 703-706:

**Deletion checks scan update history**

When an instance has accumulated many `MessageUpdate` rows, the lookup filters by `instanceId`, `keyId`, and `status` without a matching index, so retry checks scan the instance’s update rows and add increasing database work.

Add an index that supports the deletion lookup’s filter fields.

This branch has not been deployed

No deployments
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