From df36b6c0f6d1d5473f003073085215a9a519df66 Mon Sep 17 00:00:00 2001 From: Vinicius Sossei Sakugawa Date: Wed, 7 Oct 2026 15:22:35 -0300 Subject: [PATCH 1/3] fix(baileys): do not resend deleted messages on retry receipts 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 --- .../whatsapp/whatsapp.baileys.service.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 22839fd451..6994934ab3 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -656,6 +656,16 @@ export class BaileysStartupService extends ChannelStartupService { if (full) { return webMessageInfo[0]; } + + // Baileys answers retry receipts with whatever getMessage returns. A message revoked with + // "delete for everyone" keeps its content in the Message table, so returning it here makes + // the deleted text reappear for the members whose devices ask for a retry. + if (await this.isMessageDeleted(key.id, webMessageInfo[0])) { + this.logger.verbose(`getMessage: message ${key.id} was deleted, skipping retry resend`); + + return undefined; + } + if (webMessageInfo[0].message?.pollCreationMessage) { const messageSecretBase64 = webMessageInfo[0].message?.messageContextInfo?.messageSecret; @@ -677,6 +687,27 @@ export class BaileysStartupService extends ChannelStartupService { } } + // deleteMessage marks the row (status/key.deleted); both it and the messages.update revoke record a + // 'DELETED' MessageUpdate. The row lookup in deleteMessage is not scoped to the instance, so in a group + // shared by several instances the mark can land on another instance's copy; matching the MessageUpdate + // by instance + key id still finds it. + private async isMessageDeleted(keyId: string, row: any): Promise { + if (!row) { + return false; + } + + if (row.status === 'DELETED' || row.key?.deleted === true) { + return true; + } + + const deletion = await this.prismaRepository.messageUpdate.findFirst({ + where: { instanceId: this.instanceId, keyId, status: 'DELETED' }, + select: { id: true }, + }); + + return !!deletion; + } + private async defineAuthState() { const db = this.configService.get('DATABASE'); const cache = this.configService.get('CACHE'); @@ -1889,8 +1920,9 @@ export class BaileysStartupService extends ChannelStartupService { if (update.message === null && update.status === undefined) { this.sendDataWebhook(Events.MESSAGES_DELETE, { ...key, status: 'DELETED' }); + // Recorded as DELETED (not the SERVER_ACK fallback) so getMessage can refuse to resend it. if (this.configService.get('DATABASE').SAVE_DATA.MESSAGE_UPDATE) - await this.prismaRepository.messageUpdate.create({ data: message }); + await this.prismaRepository.messageUpdate.create({ data: { ...message, status: 'DELETED' } }); if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { this.chatwootService.eventWhatsapp( From 33758f075afdfc543261b929ab4592bd04033697 Mon Sep 17 00:00:00 2001 From: Vinicius Sossei Sakugawa Date: Sun, 25 Oct 2026 20:21:31 -0300 Subject: [PATCH 2/3] fix(baileys): scope the deleteMessage row lookup to the instance deleteMessage looked up the stored message by key id only. In a group shared by several instances the same key id exists once per instance, so the lookup could mark (or hard delete) another instance's copy and leave this instance's row live, which getMessage would then resend on a retry receipt. Co-Authored-By: Claude Opus 5.5 --- .../integrations/channel/whatsapp/whatsapp.baileys.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 6994934ab3..0649bd0042 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -4303,7 +4303,7 @@ export class BaileysStartupService extends ChannelStartupService { if (messageId) { const isLogicalDeleted = configService.get('DATABASE').DELETE_DATA.LOGICAL_MESSAGE_DELETE; let message = await this.prismaRepository.message.findFirst({ - where: { key: { path: ['id'], equals: messageId } }, + where: { instanceId: this.instanceId, key: { path: ['id'], equals: messageId } }, }); if (isLogicalDeleted) { if (!message) return response; From 34606f1d868ab3ccca9aa8a6fde693e6a303167e Mon Sep 17 00:00:00 2001 From: Vinicius Sossei Sakugawa Date: Sun, 25 Oct 2026 20:21:31 -0300 Subject: [PATCH 3/3] fix(baileys): mark revoked messages as deleted on the stored row The messages.update revoke branch only recorded a DELETED MessageUpdate, and only when SAVE_DATA.MESSAGE_UPDATE is on. It now also marks this instance's Message row as DELETED, so getMessage refuses the retry resend without depending on that setting. A failure to write the mark is logged and the rest of the handler continues. The MessageUpdate check stays matched by instance + key id: tombstones written before deleteMessage was scoped can point at another instance's row, and the row mark can still be overwritten by a later EDITED or ack write. Co-Authored-By: Claude Opus 5.5 --- .../whatsapp/whatsapp.baileys.service.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 0649bd0042..1267a46cab 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -687,10 +687,11 @@ export class BaileysStartupService extends ChannelStartupService { } } - // deleteMessage marks the row (status/key.deleted); both it and the messages.update revoke record a - // 'DELETED' MessageUpdate. The row lookup in deleteMessage is not scoped to the instance, so in a group - // shared by several instances the mark can land on another instance's copy; matching the MessageUpdate - // by instance + key id still finds it. + // deleteMessage and the messages.update revoke mark the row as DELETED and record a 'DELETED' MessageUpdate. + // The row mark can be overwritten later (a protocolMessage revoke arriving through messages.upsert writes + // EDITED, an ack writes its status), so the MessageUpdate is checked too. It is matched by instance + key id, + // not messageId: before deleteMessage was scoped to the instance, its MessageUpdate could point at another + // instance's copy of the message. private async isMessageDeleted(keyId: string, row: any): Promise { if (!row) { return false; @@ -1920,6 +1921,20 @@ export class BaileysStartupService extends ChannelStartupService { if (update.message === null && update.status === undefined) { this.sendDataWebhook(Events.MESSAGES_DELETE, { ...key, status: 'DELETED' }); + // Marking the row lets getMessage refuse the resend even when SAVE_DATA.MESSAGE_UPDATE is off. + if (findMessage?.id) { + try { + await this.prismaRepository.message.update({ + where: { id: findMessage.id }, + data: { status: 'DELETED' }, + }); + } catch (error) { + this.logger.warn( + `Failed to mark revoked message ${findMessage.id} as deleted: ${error?.message ?? error}`, + ); + } + } + // Recorded as DELETED (not the SERVER_ACK fallback) so getMessage can refuse to resend it. if (this.configService.get('DATABASE').SAVE_DATA.MESSAGE_UPDATE) await this.prismaRepository.messageUpdate.create({ data: { ...message, status: 'DELETED' } });