From 2f31829c82b5a36d00545bdb299e456601475480 Mon Sep 17 00:00:00 2001 From: menachem mendel kuperman Date: Tue, 22 Sep 2026 15:31:08 +0300 Subject: [PATCH 1/2] fix(chat): confirm and auto-detect message deletion --- .github/workflows/publish-fork-ghcr.yml | 14 ++ Dockerfile | 12 +- README.md | 1 + docs/README.md | 2 + docs/delete-message.md | 80 +++++++++ docs/runtime-routes.json | 26 +-- package-lock.json | 4 +- package.json | 2 +- src/api/dto/chat.dto.ts | 2 +- .../whatsapp/whatsapp.baileys.service.ts | 152 ++++++++++++++++-- src/api/routes/chat.router.ts | 2 +- src/api/routes/index.router.ts | 2 + src/api/services/message-key.service.ts | 66 ++++++++ src/exceptions/504.exception.ts | 11 ++ src/exceptions/index.ts | 1 + src/validate/chat.schema.ts | 19 ++- test/message-key.test.ts | 45 +++++- 17 files changed, 399 insertions(+), 42 deletions(-) create mode 100644 docs/delete-message.md create mode 100644 src/exceptions/504.exception.ts diff --git a/.github/workflows/publish-fork-ghcr.yml b/.github/workflows/publish-fork-ghcr.yml index c3170c40cf..820edfa883 100644 --- a/.github/workflows/publish-fork-ghcr.yml +++ b/.github/workflows/publish-fork-ghcr.yml @@ -25,6 +25,17 @@ jobs: with: submodules: recursive + - name: Read and verify application version + id: app + shell: bash + run: | + version="$(node -p "require('./package.json').version")" + echo "version=$version" >> "$GITHUB_OUTPUT" + if [[ "$GITHUB_REF" == refs/tags/v* && "$GITHUB_REF_NAME" != "v$version" ]]; then + echo "Release tag $GITHUB_REF_NAME does not match package.json v$version" >&2 + exit 1 + fi + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -56,5 +67,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + APP_VERSION=${{ steps.app.outputs.version }} + SOURCE_REVISION=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 6aa9491963..0ac7172775 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,10 +3,6 @@ FROM node:24-alpine AS builder RUN apk update && \ apk add --no-cache git ffmpeg wget curl bash openssl -LABEL version="2.3.1" description="Api to control whatsapp features through http requests." -LABEL maintainer="Davidson Gomes" git="https://github.com/DavidsonGomes" -LABEL contact="contato@evolution-api.com" - WORKDIR /evolution COPY ./package*.json ./ @@ -34,11 +30,19 @@ RUN npm run build FROM node:24-alpine AS final +ARG APP_VERSION=development +ARG SOURCE_REVISION=unknown + +LABEL org.opencontainers.image.version="$APP_VERSION" +LABEL org.opencontainers.image.revision="$SOURCE_REVISION" + RUN apk update && \ apk add tzdata ffmpeg bash openssl ENV TZ=America/Sao_Paulo ENV DOCKER_ENV=true +ENV APP_VERSION="$APP_VERSION" +ENV SOURCE_REVISION="$SOURCE_REVISION" WORKDIR /evolution diff --git a/README.md b/README.md index 24d8459593..2508b16a05 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ Local storage or S3/MinIO. Automatic media download from WhatsApp. Optional audi | WhatsApp archive | [`docs/archive.md`](./docs/archive.md) | | Local-first reads | [`docs/local-first-reads.md`](./docs/local-first-reads.md) | | Every instance setting | [`docs/settings.md`](./docs/settings.md) | +| Delete messages for everyone | [`docs/delete-message.md`](./docs/delete-message.md) | | Community | [evolutionfoundation.com.br/community](https://evolutionfoundation.com.br/community) | | Docker Hub | [evoapicloud/evolution-api](https://hub.docker.com/r/evoapicloud/evolution-api) | | Changelog | [CHANGELOG.md](./CHANGELOG.md) | diff --git a/docs/README.md b/docs/README.md index c2e3becafd..cd98be38ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,6 +67,8 @@ Username-only contacts and server-issued LIDs are documented in [WhatsApp userna The local `Message` table now distinguishes a provisional API send from WhatsApp server acceptance and recipient delivery, deduplicates future events by WhatsApp message ID, and reconciles PN/LID aliases without rewriting either identity. See [Operational message integrity](./message-archive-integrity.md) for every field, state transition, search response, migration rule, and workflow requirement. +Message revocation automatically detects connected-account messages versus group-participant messages, uses admin deletion when permitted, and waits for WhatsApp acknowledgement before reporting success. See [Delete a message for everyone](./delete-message.md) for ownership rules, permissions, request examples, response fields, and n8n mappings. + Instance restart/connect behavior and immediate multi-webhook runtime application are documented in [Runtime actions and webhook reloads](./runtime-actions-and-webhooks.md). For teaching and defense, [Defensive detection of automation disguise](./defensive-automation-detection.md) describes typo/cadence evasion signals and countermeasures without providing a production evasion engine. Configure global defaults with `DATABASE_READ_THROUGH_ENABLED`, `DATABASE_READ_THROUGH_TTL_SECONDS`, and `DATABASE_READ_THROUGH_TTL_OVERRIDES`. Configure each instance's TTL and method overrides under **Manager → Instance → Settings → Local data cache**. A live refresh failure is returned to the caller; stale data is never substituted silently. diff --git a/docs/delete-message.md b/docs/delete-message.md new file mode 100644 index 0000000000..4504c1f7a7 --- /dev/null +++ b/docs/delete-message.md @@ -0,0 +1,80 @@ +# Delete a message for everyone + +Use `DELETE /chat/deleteMessageForEveryone/{instanceName}` to revoke a message from WhatsApp. The route detects whether the original message belongs to the connected account or to another group participant, chooses the strongest valid WhatsApp revoke mode, and waits for a server acknowledgement before reporting success. + +## Ownership detection + +The API resolves the operation in this order: + +1. If the original message exists in the instance-scoped `Message` table, its stored `key.fromMe`, chat JID, and participant JID are authoritative. +2. Otherwise, an explicit `fromMe` value is used as a compatibility override. +3. Otherwise, a group request with `participant` is treated as another participant's message and uses group-admin deletion. +4. Otherwise, the message is treated as one sent by the connected account. + +The response reports the selected `deletion.target` and `deletion.ownershipSource`. Clients normally should omit `fromMe` and let the API detect the correct mode. + +## Permissions and limitations + +- A message sent by the connected account can be revoked in a direct chat or group, subject to WhatsApp's deletion window and server policy. +- Another participant's group message can be revoked only when the connected account is a group admin. The original participant JID is required. +- WhatsApp does not permit revoking another person's incoming direct message for everyone. The API returns `CANNOT_REVOKE_INCOMING_DIRECT_MESSAGE` instead of a misleading pending success. +- The API cannot elevate a non-admin account. When group metadata identifies the connected account as a non-admin, the request returns `GROUP_ADMIN_REQUIRED`. + +## Recommended request + +When message storage is enabled, only the message ID and chat JID are needed: + +```http +DELETE /chat/deleteMessageForEveryone/chabad-info +apikey: YOUR_GLOBAL_API_KEY +Content-Type: application/json + +{ + "id": "3A222167F7B463F69944", + "remoteJid": "120363043342036080@g.us" +} +``` + +When the original message is not stored, copy the participant identities from the `MESSAGES_UPSERT` webhook key: + +```json +{ + "id": "3A222167F7B463F69944", + "remoteJid": "120363043342036080@g.us", + "participant": "184353602666626@lid", + "participantAlt": "972559128260@s.whatsapp.net" +} +``` + +`participantAlt` helps identity resolution, but the WhatsApp revoke protobuf itself contains only `remoteJid`, `fromMe`, `id`, and `participant`. This is why retaining extra LID metadata alone did not fix incomplete revoke keys in earlier releases. + +## Confirmed response + +The route no longer treats Baileys's immediate `PENDING` object as proof of deletion. It listens for the outgoing revoke message's WhatsApp server acknowledgement. Only then does it update the local message state, emit `MESSAGES_DELETE`, and return success: + +```json +{ + "status": "SERVER_ACK", + "deletion": { + "requestedMessageId": "3A222167F7B463F69944", + "target": "GROUP_PARTICIPANT_MESSAGE", + "ownershipSource": "ARCHIVED_MESSAGE_KEY", + "submitted": true, + "serverAcknowledged": true, + "status": "SERVER_ACK" + } +} +``` + +If no acknowledgement arrives within ten seconds, the API returns `DELETE_NOT_ACKNOWLEDGED` and does not mark the local message as deleted. + +## n8n node + +In `n8n-nodes-evolution-api-en` 4.3.1 or newer, choose **Auto Detect** for **Message Ownership**. For a group message, map these webhook values when available: + +- **Contact**: `{{$json.data.key.remoteJid}}` +- **Message ID**: `{{$json.data.key.id}}` +- **Original Participant JID**: `{{$json.data.key.participant}}` +- **Participant Alternate JID**: `{{$json.data.key.participantAlt}}` + +The node throws an error for an unconfirmed `PENDING` response instead of returning `success: true`. diff --git a/docs/runtime-routes.json b/docs/runtime-routes.json index 8e31571f4f..8f42649974 100644 --- a/docs/runtime-routes.json +++ b/docs/runtime-routes.json @@ -7781,7 +7781,8 @@ "enum": [ true, false - ] + ], + "description": "Optional ownership override. Omit it to detect ownership from the archived original message. True means the connected account sent it; false selects admin deletion for another participant’s group message." }, "remoteJid": { "type": "string", @@ -7792,10 +7793,12 @@ "type": "string" }, "participant": { - "type": "string" + "type": "string", + "description": "Original group participant JID from the webhook message key. Required for admin deletion." }, "participantAlt": { - "type": "string" + "type": "string", + "description": "Alternate participant JID from the webhook message key, used as a fallback for LID/PN resolution." }, "addressingMode": { "type": "string", @@ -7807,7 +7810,6 @@ }, "required": [ "id", - "fromMe", "remoteJid" ], "if": { @@ -7832,7 +7834,7 @@ } }, "multipart": false, - "runtimeStatus": "CREATED", + "runtimeStatus": "OK", "operation": { "tags": [ "Chats" @@ -7852,8 +7854,8 @@ } ], "responses": { - "201": { - "description": "Resource created. See the domain documentation for the operation-specific response fields.", + "200": { + "description": "Successful response. See the domain documentation for the operation-specific response fields.", "content": { "application/json": { "schema": { @@ -8117,7 +8119,8 @@ "enum": [ true, false - ] + ], + "description": "Optional ownership override. Omit it to detect ownership from the archived original message. True means the connected account sent it; false selects admin deletion for another participant’s group message." }, "remoteJid": { "type": "string", @@ -8128,10 +8131,12 @@ "type": "string" }, "participant": { - "type": "string" + "type": "string", + "description": "Original group participant JID from the webhook message key. Required for admin deletion." }, "participantAlt": { - "type": "string" + "type": "string", + "description": "Alternate participant JID from the webhook message key, used as a fallback for LID/PN resolution." }, "addressingMode": { "type": "string", @@ -8143,7 +8148,6 @@ }, "required": [ "id", - "fromMe", "remoteJid" ], "if": { diff --git a/package-lock.json b/package-lock.json index 7398391d27..8d87d71505 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "evolution-api", - "version": "5.3.0", + "version": "5.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "evolution-api", - "version": "5.3.0", + "version": "5.4.2", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 2cb669991a..c9ab134e49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "evolution-api", - "version": "5.3.0", + "version": "5.4.2", "description": "Rest api for communication with WhatsApp", "main": "./dist/main.js", "type": "commonjs", diff --git a/src/api/dto/chat.dto.ts b/src/api/dto/chat.dto.ts index e5b72f2a03..39cc3c5311 100644 --- a/src/api/dto/chat.dto.ts +++ b/src/api/dto/chat.dto.ts @@ -66,7 +66,7 @@ export class ProfilePictureDto { class Key { id: string; - fromMe: boolean; + fromMe?: boolean; remoteJid: string; remoteJidAlt?: string; participant?: string; diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index d34f1a5732..e1bc630128 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -67,7 +67,11 @@ import { messageArchiveStateFromStatus, messageUpdateIdentity, } from '@api/services/message-archive.service'; -import { hydrateMessageKey, preserveMessageKey } from '@api/services/message-key.service'; +import { + buildDeleteMessageKey, + preserveMessageKey, + resolveDeleteMessageOwnership, +} from '@api/services/message-key.service'; import { applyMistakesToMessage } from '@api/services/mistakes-generator.service'; import { OutboundSafetyService } from '@api/services/outbound-safety.service'; import { SettingsTemplateService } from '@api/services/settings-template.service'; @@ -89,6 +93,8 @@ import { } from '@config/env.config'; import { BadRequestException, + ForbiddenException, + GatewayTimeoutException, InternalServerErrorException, NotFoundException, TooManyRequestsException, @@ -117,6 +123,7 @@ import { import axios from 'axios'; import makeWASocket, { AnyMessageContent, + bindWaitForEvent, BufferedEventData, BufferJSON, CacheStore, @@ -129,6 +136,7 @@ import makeWASocket, { DisconnectReason, downloadContentFromMessage, downloadMediaMessage, + generateMessageIDV2, generateWAMessageFromContent, getAggregateVotesInPollMessage, GetCatalogOptions, @@ -153,6 +161,7 @@ import makeWASocket, { WAMediaUpload, WAMessage, WAMessageKey, + WAMessageStatus, WAPresence, WASocket, } from 'baileys'; @@ -4180,24 +4189,123 @@ export class BaileysStartupService extends ChannelStartupService { remoteJid: del.remoteJid, }); } - const storedMessage = await this.prismaRepository.message.findFirst({ - where: { - instanceId: this.instanceId, - key: { path: ['id'], equals: del.id }, - OR: [ - { key: { path: ['remoteJid'], equals: del.remoteJid } }, - { key: { path: ['remoteJidAlt'], equals: del.remoteJid } }, - { remoteJid: del.remoteJid }, - { remoteJidAlt: del.remoteJid }, - ], - }, + let storedMessage = await this.prismaRepository.message.findUnique({ + where: { instanceId_waMessageId: { instanceId: this.instanceId, waMessageId: del.id } }, }); + if (!storedMessage) { + const jsonPath = this.configService.get('DATABASE').PROVIDER === 'mysql' ? '$.id' : ['id']; + storedMessage = await this.prismaRepository.message.findFirst({ + where: { + instanceId: this.instanceId, + key: { path: jsonPath, equals: del.id } as any, + }, + }); + } const storedKey = storedMessage?.key && typeof storedMessage.key === 'object' && !Array.isArray(storedMessage.key) - ? storedMessage.key + ? (storedMessage.key as Record) : null; - const deleteKey = hydrateMessageKey(del, storedKey); - const response = await this.client.sendMessage(deleteKey.remoteJid, { delete: deleteKey }); + if (storedMessage) { + const storedJids = [ + storedMessage.remoteJid, + storedMessage.remoteJidAlt, + storedKey?.remoteJid, + storedKey?.remoteJidAlt, + ].filter(Boolean); + if (!storedJids.includes(del.remoteJid)) { + throw new BadRequestException({ + code: 'MESSAGE_CHAT_MISMATCH', + message: 'The message ID does not belong to the supplied remoteJid.', + remoteJid: del.remoteJid, + id: del.id, + }); + } + } + + const ownership = resolveDeleteMessageOwnership(del, storedKey, storedMessage?.participant); + const detectedFromMe = ownership.fromMe; + const ownershipSource = ownership.source; + const deleteKey = buildDeleteMessageKey( + { ...del, fromMe: detectedFromMe }, + storedKey ? { ...storedKey, fromMe: detectedFromMe } : null, + storedMessage?.participant, + ); + if (!deleteKey.remoteJid) { + throw new BadRequestException({ + code: 'DELETE_REMOTE_JID_REQUIRED', + message: 'The original WhatsApp chat JID is required to delete a message.', + }); + } + const deletingAnotherUsersMessage = deleteKey.fromMe === false; + if (deletingAnotherUsersMessage && !isJidGroup(deleteKey.remoteJid)) { + throw new BadRequestException({ + code: 'CANNOT_REVOKE_INCOMING_DIRECT_MESSAGE', + message: + 'WhatsApp does not allow deleting another user’s direct message for everyone. Use deleteMessageForEveryone only for messages sent by this account, or for group messages when this account is an admin.', + }); + } + if (deletingAnotherUsersMessage && !deleteKey.participant) { + throw new BadRequestException({ + code: 'GROUP_DELETE_PARTICIPANT_REQUIRED', + message: + 'Deleting another participant’s group message requires the original participant JID. Enable message storage or send participant from the webhook message key.', + }); + } + if (deletingAnotherUsersMessage) { + try { + const metadata = await this.client.groupMetadata(deleteKey.remoteJid); + const ownJids = [this.instance.wuid, this.client.user?.id, this.client.user?.lid] + .filter(Boolean) + .map((jid) => jidNormalizedUser(jid)); + const ownParticipant = metadata.participants.find((candidate) => + [candidate.id, candidate.lid, candidate.phoneNumber] + .filter(Boolean) + .map((jid) => jidNormalizedUser(jid)) + .some((jid) => ownJids.includes(jid)), + ); + if (ownParticipant && !ownParticipant.isAdmin && !ownParticipant.isSuperAdmin && !ownParticipant.admin) { + throw new ForbiddenException({ + code: 'GROUP_ADMIN_REQUIRED', + message: 'The connected WhatsApp account must be a group admin to delete another participant’s message.', + remoteJid: deleteKey.remoteJid, + }); + } + } catch (error) { + if (error?.status) throw error; + this.logger.warn( + `Could not verify group-admin permissions before deleting message ${del.id}: ${error?.message ?? error}`, + ); + } + } + + const revokeMessageId = generateMessageIDV2(this.client.user?.id); + let acknowledgedStatus: number | undefined; + const waitForServerAcknowledgement = bindWaitForEvent(this.client.ev, 'messages.update'); + const acknowledgement = waitForServerAcknowledgement(async (updates) => { + const matchingUpdate = updates.find(({ key }) => key.id === revokeMessageId); + if (!matchingUpdate?.update.status) return false; + acknowledgedStatus = matchingUpdate.update.status; + return matchingUpdate.update.status >= WAMessageStatus.SERVER_ACK; + }, 10_000).then( + () => ({ acknowledged: true as const }), + (error) => ({ acknowledged: false as const, error }), + ); + + const response = await this.client.sendMessage( + deleteKey.remoteJid, + { delete: deleteKey }, + { messageId: revokeMessageId }, + ); + const acknowledgementResult = await acknowledgement; + if (!acknowledgementResult.acknowledged) { + throw new GatewayTimeoutException({ + code: 'DELETE_NOT_ACKNOWLEDGED', + message: + 'WhatsApp did not acknowledge the revoke request within 10 seconds. The local message was not marked as deleted.', + id: del.id, + remoteJid: del.remoteJid, + }); + } if (response) { const messageId = deleteKey.id; if (messageId) { @@ -4241,8 +4349,20 @@ export class BaileysStartupService extends ChannelStartupService { } } - return response; + return { + ...response, + status: acknowledgedStatus ?? WAMessageStatus.SERVER_ACK, + deletion: { + requestedMessageId: del.id, + target: detectedFromMe ? 'CONNECTED_ACCOUNT_MESSAGE' : 'GROUP_PARTICIPANT_MESSAGE', + ownershipSource, + submitted: true, + serverAcknowledged: true, + status: status[acknowledgedStatus ?? WAMessageStatus.SERVER_ACK], + }, + }; } catch (error) { + if (error?.status) throw error; throw new InternalServerErrorException('Error while deleting message for everyone', error?.toString()); } } diff --git a/src/api/routes/chat.router.ts b/src/api/routes/chat.router.ts index 158947ed22..bfb3a2427d 100644 --- a/src/api/routes/chat.router.ts +++ b/src/api/routes/chat.router.ts @@ -98,7 +98,7 @@ export class ChatRouter extends RouterBroker { execute: (instance, data) => chatController.deleteMessage(instance, data), }); - return res.status(HttpStatus.CREATED).json(response); + return res.status(HttpStatus.OK).json(response); }) .post(this.routerPath('fetchProfilePictureUrl'), ...guards, async (req, res) => { const response = await this.dataValidate({ diff --git a/src/api/routes/index.router.ts b/src/api/routes/index.router.ts index 3df704736e..aed4038446 100644 --- a/src/api/routes/index.router.ts +++ b/src/api/routes/index.router.ts @@ -37,6 +37,7 @@ enum HttpStatus { BAD_REQUEST = 400, UNAUTHORIZED = 401, INTERNAL_SERVER_ERROR = 500, + GATEWAY_TIMEOUT = 504, TOO_MANY_REQUESTS = 429, } @@ -250,6 +251,7 @@ router status: HttpStatus.OK, message: 'Welcome to the Evolution API, it is working!', version: packageJson.version, + sourceRevision: process.env.SOURCE_REVISION || undefined, clientName: databaseConfig.CONNECTION.CLIENT_NAME, manager: !serverConfig.DISABLE_MANAGER ? `${req.protocol}://${req.get('host')}/manager` : undefined, swagger: `${req.protocol}://${req.get('host')}/docs`, diff --git a/src/api/services/message-key.service.ts b/src/api/services/message-key.service.ts index 4b5c6b20bd..c88bd840b3 100644 --- a/src/api/services/message-key.service.ts +++ b/src/api/services/message-key.service.ts @@ -6,6 +6,42 @@ export type MessageKeyLike = proto.IMessageKey & { addressingMode?: string | null; }; +export type DeleteOwnershipSource = + | 'ARCHIVED_MESSAGE_KEY' + | 'REQUEST_OVERRIDE' + | 'INFERRED_CONNECTED_ACCOUNT' + | 'INFERRED_GROUP_PARTICIPANT'; + +export function resolveDeleteMessageOwnership( + requested: MessageKeyLike, + stored?: MessageKeyLike | null, + storedParticipant?: string | null, +): { fromMe: boolean; participant?: string; source: DeleteOwnershipSource } { + const participant = + stored?.participant ?? + storedParticipant ?? + requested.participant ?? + stored?.participantAlt ?? + requested.participantAlt ?? + undefined; + const storedFromMe = typeof stored?.fromMe === 'boolean' ? stored.fromMe : undefined; + const fromMe = + storedFromMe ?? + (typeof requested.fromMe === 'boolean' + ? requested.fromMe + : !(requested.remoteJid?.endsWith('@g.us') && Boolean(participant))); + const source: DeleteOwnershipSource = + storedFromMe !== undefined + ? 'ARCHIVED_MESSAGE_KEY' + : typeof requested.fromMe === 'boolean' + ? 'REQUEST_OVERRIDE' + : fromMe + ? 'INFERRED_CONNECTED_ACCOUNT' + : 'INFERRED_GROUP_PARTICIPANT'; + + return { fromMe, ...(participant ? { participant } : {}), source }; +} + /** * Return a copy of a WhatsApp message key without normalising PN/LID addresses. * @@ -37,3 +73,33 @@ export function hydrateMessageKey(requested: T, stored if (!stored || typeof stored !== 'object') return preserveMessageKey(requested); return preserveMessageKey({ ...requested, ...stored, id: requested.id } as T); } + +/** + * Build the key that Baileys actually serializes inside a revoke protocol message. + * + * WhatsApp's protobuf MessageKey contains only remoteJid, fromMe, id and + * participant. Fields such as participantAlt and addressingMode are useful for + * identity resolution, but forwarding them does not put them on the wire. + */ +export function buildDeleteMessageKey( + requested: MessageKeyLike, + stored?: MessageKeyLike | null, + storedParticipant?: string | null, +): proto.IMessageKey { + const authoritative = stored && typeof stored === 'object' ? stored : requested; + const remoteJid = authoritative.remoteJid ?? requested.remoteJid; + const fromMe = typeof authoritative.fromMe === 'boolean' ? authoritative.fromMe : requested.fromMe; + const participant = + authoritative.participant ?? + storedParticipant ?? + requested.participant ?? + authoritative.participantAlt ?? + requested.participantAlt; + + return { + remoteJid, + fromMe, + id: requested.id, + ...(participant ? { participant } : {}), + }; +} diff --git a/src/exceptions/504.exception.ts b/src/exceptions/504.exception.ts new file mode 100644 index 0000000000..2dacdc515e --- /dev/null +++ b/src/exceptions/504.exception.ts @@ -0,0 +1,11 @@ +import { HttpStatus } from '@api/routes/index.router'; + +export class GatewayTimeoutException { + constructor(...objectError: any[]) { + throw { + status: HttpStatus.GATEWAY_TIMEOUT, + error: 'Gateway Timeout', + message: objectError.length > 0 ? objectError : undefined, + }; + } +} diff --git a/src/exceptions/index.ts b/src/exceptions/index.ts index e1abd8c784..490f3ded6b 100644 --- a/src/exceptions/index.ts +++ b/src/exceptions/index.ts @@ -4,3 +4,4 @@ export * from './403.exception'; export * from './404.exception'; export * from './429.exception'; export * from './500.exception'; +export * from './504.exception'; diff --git a/src/validate/chat.schema.ts b/src/validate/chat.schema.ts index 9203ab1db0..696818e7e1 100644 --- a/src/validate/chat.schema.ts +++ b/src/validate/chat.schema.ts @@ -123,18 +123,29 @@ export const deleteMessageSchema: JSONSchema7 = { type: 'object', properties: { id: { type: 'string' }, - fromMe: { type: 'boolean', enum: [true, false] }, + fromMe: { + type: 'boolean', + enum: [true, false], + description: + 'Optional ownership override. Omit it to detect ownership from the archived original message. True means the connected account sent it; false selects admin deletion for another participant’s group message.', + }, remoteJid: { type: 'string', pattern: '^(?:\\d+@(?:s\\.whatsapp\\.net|lid)|\\d+-\\d+@g\\.us|status@broadcast|[^@\\s]+@broadcast)$', description: 'remoteJid must be a full WhatsApp JID, not an internal database row ID.', }, remoteJidAlt: { type: 'string' }, - participant: { type: 'string' }, - participantAlt: { type: 'string' }, + participant: { + type: 'string', + description: 'Original group participant JID from the webhook message key. Required for admin deletion.', + }, + participantAlt: { + type: 'string', + description: 'Alternate participant JID from the webhook message key, used as a fallback for LID/PN resolution.', + }, addressingMode: { type: 'string', enum: ['lid', 'pn'] }, }, - required: ['id', 'fromMe', 'remoteJid'], + required: ['id', 'remoteJid'], ...isNotEmpty('id', 'remoteJid'), }; diff --git a/test/message-key.test.ts b/test/message-key.test.ts index 5be25840bc..c2915cf8e7 100644 --- a/test/message-key.test.ts +++ b/test/message-key.test.ts @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; -import { hydrateMessageKey, preserveMessageKey } from '../src/api/services/message-key.service'; +import { + buildDeleteMessageKey, + hydrateMessageKey, + preserveMessageKey, + resolveDeleteMessageOwnership, +} from '../src/api/services/message-key.service'; const groupLidKey = { id: '3EB0A_MESSAGE', @@ -29,7 +34,6 @@ assert.deepEqual(preserveMessageKey(directLidKey), directLidKey); console.log('message key tests passed'); - const partialGroupDelete = { id: '3EB0A_MESSAGE', fromMe: false, @@ -44,3 +48,40 @@ assert.equal(hydrated.participantAlt, groupLidKey.participantAlt); assert.equal(hydrated.addressingMode, groupLidKey.addressingMode); assert.equal(hydrated.fromMe, groupLidKey.fromMe); assert.deepEqual(hydrateMessageKey(partialGroupDelete), partialGroupDelete); + +const deleteKey = buildDeleteMessageKey(partialGroupDelete, groupLidKey); +assert.deepEqual(deleteKey, { + id: partialGroupDelete.id, + remoteJid: groupLidKey.remoteJid, + fromMe: groupLidKey.fromMe, + participant: groupLidKey.participant, +}); +assert.equal('participantAlt' in deleteKey, false); +assert.equal('addressingMode' in deleteKey, false); + +const deleteKeyWithRowParticipant = buildDeleteMessageKey( + { id: 'MESSAGE_WITHOUT_KEY_PARTICIPANT', remoteJid: groupLidKey.remoteJid, fromMe: false }, + { id: 'MESSAGE_WITHOUT_KEY_PARTICIPANT', remoteJid: groupLidKey.remoteJid, fromMe: false }, + '972500000000@s.whatsapp.net', +); +assert.equal(deleteKeyWithRowParticipant.participant, '972500000000@s.whatsapp.net'); + +assert.deepEqual(resolveDeleteMessageOwnership({ id: 'OWN_DIRECT', remoteJid: '972500000001@s.whatsapp.net' }), { + fromMe: true, + source: 'INFERRED_CONNECTED_ACCOUNT', +}); +assert.deepEqual( + resolveDeleteMessageOwnership({ + id: 'OTHER_GROUP', + remoteJid: groupLidKey.remoteJid, + participant: groupLidKey.participant, + }), + { fromMe: false, participant: groupLidKey.participant, source: 'INFERRED_GROUP_PARTICIPANT' }, +); +assert.equal( + resolveDeleteMessageOwnership( + { id: 'REQUEST_DISAGREES', remoteJid: groupLidKey.remoteJid, fromMe: false }, + groupLidKey, + ).source, + 'ARCHIVED_MESSAGE_KEY', +); From af08e0ef445074fe72df518c3ed34f8115966ab4 Mon Sep 17 00:00:00 2001 From: menachem mendel kuperman Date: Tue, 22 Sep 2026 15:35:43 +0300 Subject: [PATCH 2/2] fix(chat): accept group revoke acknowledgements --- .../whatsapp/whatsapp.baileys.service.ts | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index e1bc630128..b0faea4acc 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -4280,13 +4280,29 @@ export class BaileysStartupService extends ChannelStartupService { const revokeMessageId = generateMessageIDV2(this.client.user?.id); let acknowledgedStatus: number | undefined; - const waitForServerAcknowledgement = bindWaitForEvent(this.client.ev, 'messages.update'); - const acknowledgement = waitForServerAcknowledgement(async (updates) => { - const matchingUpdate = updates.find(({ key }) => key.id === revokeMessageId); - if (!matchingUpdate?.update.status) return false; - acknowledgedStatus = matchingUpdate.update.status; - return matchingUpdate.update.status >= WAMessageStatus.SERVER_ACK; - }, 10_000).then( + const waitForMessageUpdate = bindWaitForEvent(this.client.ev, 'messages.update'); + const waitForMessageUpsert = bindWaitForEvent(this.client.ev, 'messages.upsert'); + const waitForMessageReceipt = bindWaitForEvent(this.client.ev, 'message-receipt.update'); + const acknowledgement = Promise.race([ + waitForMessageUpdate(async (updates) => { + const matchingUpdate = updates.find(({ key }) => key.id === revokeMessageId); + if (!matchingUpdate?.update.status) return false; + acknowledgedStatus = matchingUpdate.update.status; + return matchingUpdate.update.status >= WAMessageStatus.SERVER_ACK; + }, 10_000), + waitForMessageUpsert(async ({ messages }) => { + const matchingMessage = messages.find(({ key }) => key.id === revokeMessageId); + if (!matchingMessage?.status || matchingMessage.status < WAMessageStatus.SERVER_ACK) return false; + acknowledgedStatus = matchingMessage.status; + return true; + }, 10_000), + waitForMessageReceipt(async (receipts) => { + const matchingReceipt = receipts.some(({ key }) => key.id === revokeMessageId); + if (!matchingReceipt) return false; + acknowledgedStatus = WAMessageStatus.DELIVERY_ACK; + return true; + }, 10_000), + ]).then( () => ({ acknowledged: true as const }), (error) => ({ acknowledged: false as const, error }), );