From 5ebc66d5a8c56a695c7cf6580db3c4ce6de04cb8 Mon Sep 17 00:00:00 2001 From: Alpes Digital Date: Sat, 20 Jun 2026 15:12:40 -0300 Subject: [PATCH 01/22] feat(group): member-add-mode, join-approval-mode, participant requests + message forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes Baileys group/message capabilities that already exist in the underlying library but had no REST endpoints: - POST /group/memberAddMode -> groupMemberAddMode (admin_add | all_member_add) - POST /group/joinApprovalMode -> groupJoinApprovalMode (on | off) - GET /group/participantRequests -> groupRequestParticipantsList (pending join requests) - POST /group/updateParticipantRequests -> groupRequestParticipantsUpdate (approve | reject) - POST /message/forwardMessage -> sendMessage(jid, { forward }) (keeps isForwarded) Each follows the existing pattern (DTO + JSONSchema + router + controller + baileys service). No new dependencies — baileys 7.0.0-rc.9 already ships these. Co-Authored-By: Claude Opus 4.8 --- src/api/controllers/group.controller.ts | 19 +++++++ src/api/controllers/sendMessage.controller.ts | 5 ++ src/api/dto/group.dto.ts | 13 +++++ src/api/dto/sendMessage.dto.ts | 4 ++ .../whatsapp/whatsapp.baileys.service.ts | 55 +++++++++++++++++++ src/api/routes/group.router.ts | 46 ++++++++++++++++ src/api/routes/sendMessage.router.ts | 12 ++++ src/validate/group.schema.ts | 39 +++++++++++++ src/validate/message.schema.ts | 11 ++++ 9 files changed, 204 insertions(+) diff --git a/src/api/controllers/group.controller.ts b/src/api/controllers/group.controller.ts index ebe7c03671..f2e9a2eb8e 100644 --- a/src/api/controllers/group.controller.ts +++ b/src/api/controllers/group.controller.ts @@ -5,11 +5,14 @@ import { GroupDescriptionDto, GroupInvite, GroupJid, + GroupJoinApprovalModeDto, + GroupMemberAddModeDto, GroupPictureDto, GroupSendInvite, GroupSubjectDto, GroupToggleEphemeralDto, GroupUpdateParticipantDto, + GroupUpdateParticipantRequestDto, GroupUpdateSettingDto, } from '@api/dto/group.dto'; import { InstanceDto } from '@api/dto/instance.dto'; @@ -78,6 +81,22 @@ export class GroupController { return await this.waMonitor.waInstances[instance.instanceName].toggleEphemeral(update); } + public async updateMemberAddMode(instance: InstanceDto, update: GroupMemberAddModeDto) { + return await this.waMonitor.waInstances[instance.instanceName].updateMemberAddMode(update); + } + + public async updateJoinApprovalMode(instance: InstanceDto, update: GroupJoinApprovalModeDto) { + return await this.waMonitor.waInstances[instance.instanceName].updateJoinApprovalMode(update); + } + + public async findParticipantRequests(instance: InstanceDto, groupJid: GroupJid) { + return await this.waMonitor.waInstances[instance.instanceName].findParticipantRequests(groupJid); + } + + public async updateParticipantRequests(instance: InstanceDto, update: GroupUpdateParticipantRequestDto) { + return await this.waMonitor.waInstances[instance.instanceName].updateParticipantRequests(update); + } + public async leaveGroup(instance: InstanceDto, groupJid: GroupJid) { return await this.waMonitor.waInstances[instance.instanceName].leaveGroup(groupJid); } diff --git a/src/api/controllers/sendMessage.controller.ts b/src/api/controllers/sendMessage.controller.ts index 64aa1c8468..68c55b0a73 100644 --- a/src/api/controllers/sendMessage.controller.ts +++ b/src/api/controllers/sendMessage.controller.ts @@ -1,5 +1,6 @@ import { InstanceDto } from '@api/dto/instance.dto'; import { + ForwardMessageDto, SendAudioDto, SendButtonsDto, SendContactDto, @@ -39,6 +40,10 @@ export class SendMessageController { return await this.waMonitor.waInstances[instanceName].textMessage(data); } + public async forwardMessage({ instanceName }: InstanceDto, data: ForwardMessageDto) { + return await this.waMonitor.waInstances[instanceName].forwardMessage(data); + } + public async sendMedia({ instanceName }: InstanceDto, data: SendMediaDto, file?: any) { if (isBase64(data?.media) && !data?.fileName && data?.mediatype === 'document') { throw new BadRequestException('For base64 the file name must be informed.'); diff --git a/src/api/dto/group.dto.ts b/src/api/dto/group.dto.ts index 293329d2ed..b9975d3f12 100644 --- a/src/api/dto/group.dto.ts +++ b/src/api/dto/group.dto.ts @@ -54,3 +54,16 @@ export class GroupUpdateSettingDto extends GroupJid { export class GroupToggleEphemeralDto extends GroupJid { expiration: 0 | 86400 | 604800 | 7776000; } + +export class GroupMemberAddModeDto extends GroupJid { + mode: 'admin_add' | 'all_member_add'; +} + +export class GroupJoinApprovalModeDto extends GroupJid { + mode: 'on' | 'off'; +} + +export class GroupUpdateParticipantRequestDto extends GroupJid { + action: 'approve' | 'reject'; + participants: string[]; +} diff --git a/src/api/dto/sendMessage.dto.ts b/src/api/dto/sendMessage.dto.ts index ba9ecf527c..1ba725dfbb 100644 --- a/src/api/dto/sendMessage.dto.ts +++ b/src/api/dto/sendMessage.dto.ts @@ -50,6 +50,10 @@ export class Metadata { export class SendTextDto extends Metadata { text: string; } +export class ForwardMessageDto { + number: string; + messageId: string; +} export class SendPresence extends Metadata { text: string; } diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 60e857fcc1..e717544420 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -22,11 +22,14 @@ import { GroupDescriptionDto, GroupInvite, GroupJid, + GroupJoinApprovalModeDto, + GroupMemberAddModeDto, GroupPictureDto, GroupSendInvite, GroupSubjectDto, GroupToggleEphemeralDto, GroupUpdateParticipantDto, + GroupUpdateParticipantRequestDto, GroupUpdateSettingDto, } from '@api/dto/group.dto'; import { InstanceDto, SetPresenceDto } from '@api/dto/instance.dto'; @@ -34,6 +37,7 @@ import { HandleLabelDto, LabelDto } from '@api/dto/label.dto'; import { Button, ContactMessage, + ForwardMessageDto, KeyType, MediaMessage, Options, @@ -553,6 +557,20 @@ export class BaileysStartupService extends ChannelStartupService { } } + public async forwardMessage(data: ForwardMessageDto) { + try { + const fullMsg = (await this.getMessage({ id: data.messageId }, true)) as unknown as WAMessage; + if (!fullMsg?.message) { + throw new BadRequestException('Message not found'); + } + const number = data.number.replace(/\D/g, ''); + const jid = data.number.includes('@') ? data.number : `${number}@s.whatsapp.net`; + return await this.client.sendMessage(jid, { forward: fullMsg }); + } catch (error) { + throw new BadRequestException('Error forwarding message', error.toString()); + } + } + private async defineAuthState() { const db = this.configService.get('DATABASE'); const cache = this.configService.get('CACHE'); @@ -4597,6 +4615,43 @@ export class BaileysStartupService extends ChannelStartupService { } } + public async updateMemberAddMode(update: GroupMemberAddModeDto) { + try { + await this.client.groupMemberAddMode(update.groupJid, update.mode); + return { success: true }; + } catch (error) { + throw new BadRequestException('Error updating member add mode', error.toString()); + } + } + + public async updateJoinApprovalMode(update: GroupJoinApprovalModeDto) { + try { + await this.client.groupJoinApprovalMode(update.groupJid, update.mode); + return { success: true }; + } catch (error) { + throw new BadRequestException('Error updating join approval mode', error.toString()); + } + } + + public async findParticipantRequests(id: GroupJid) { + try { + const requests = await this.client.groupRequestParticipantsList(id.groupJid); + return { requests: requests || [] }; + } catch (error) { + throw new BadRequestException('Error fetching participant requests', error.toString()); + } + } + + public async updateParticipantRequests(update: GroupUpdateParticipantRequestDto) { + try { + const participants = update.participants.map((p) => (p.includes('@') ? p : `${p}@s.whatsapp.net`)); + const result = await this.client.groupRequestParticipantsUpdate(update.groupJid, participants, update.action); + return { updateParticipantRequests: result }; + } catch (error) { + throw new BadRequestException('Error updating participant requests', error.toString()); + } + } + public async leaveGroup(id: GroupJid) { try { await this.client.groupLeave(id.groupJid); diff --git a/src/api/routes/group.router.ts b/src/api/routes/group.router.ts index 7086b11769..c16d951d18 100644 --- a/src/api/routes/group.router.ts +++ b/src/api/routes/group.router.ts @@ -6,11 +6,14 @@ import { GroupDescriptionDto, GroupInvite, GroupJid, + GroupJoinApprovalModeDto, + GroupMemberAddModeDto, GroupPictureDto, GroupSendInvite, GroupSubjectDto, GroupToggleEphemeralDto, GroupUpdateParticipantDto, + GroupUpdateParticipantRequestDto, GroupUpdateSettingDto, } from '@api/dto/group.dto'; import { groupController } from '@api/server.module'; @@ -21,10 +24,13 @@ import { groupInviteSchema, groupJidSchema, groupSendInviteSchema, + joinApprovalModeSchema, + memberAddModeSchema, toggleEphemeralSchema, updateGroupDescriptionSchema, updateGroupPictureSchema, updateGroupSubjectSchema, + updateParticipantRequestSchema, updateParticipantsSchema, updateSettingsSchema, } from '@validate/validate.schema'; @@ -186,6 +192,46 @@ export class GroupRouter extends RouterBroker { res.status(HttpStatus.CREATED).json(response); }) + .post(this.routerPath('memberAddMode'), ...guards, async (req, res) => { + const response = await this.groupValidate({ + request: req, + schema: memberAddModeSchema, + ClassRef: GroupMemberAddModeDto, + execute: (instance, data) => groupController.updateMemberAddMode(instance, data), + }); + + res.status(HttpStatus.CREATED).json(response); + }) + .post(this.routerPath('joinApprovalMode'), ...guards, async (req, res) => { + const response = await this.groupValidate({ + request: req, + schema: joinApprovalModeSchema, + ClassRef: GroupJoinApprovalModeDto, + execute: (instance, data) => groupController.updateJoinApprovalMode(instance, data), + }); + + res.status(HttpStatus.CREATED).json(response); + }) + .get(this.routerPath('participantRequests'), ...guards, async (req, res) => { + const response = await this.groupValidate({ + request: req, + schema: groupJidSchema, + ClassRef: GroupJid, + execute: (instance, data) => groupController.findParticipantRequests(instance, data), + }); + + res.status(HttpStatus.OK).json(response); + }) + .post(this.routerPath('updateParticipantRequests'), ...guards, async (req, res) => { + const response = await this.groupValidate({ + request: req, + schema: updateParticipantRequestSchema, + ClassRef: GroupUpdateParticipantRequestDto, + execute: (instance, data) => groupController.updateParticipantRequests(instance, data), + }); + + res.status(HttpStatus.CREATED).json(response); + }) .delete(this.routerPath('leaveGroup'), ...guards, async (req, res) => { const response = await this.groupValidate({ request: req, diff --git a/src/api/routes/sendMessage.router.ts b/src/api/routes/sendMessage.router.ts index cd073dba3d..dee71cbf08 100644 --- a/src/api/routes/sendMessage.router.ts +++ b/src/api/routes/sendMessage.router.ts @@ -1,5 +1,6 @@ import { RouterBroker } from '@api/abstract/abstract.router'; import { + ForwardMessageDto, SendAudioDto, SendButtonsDto, SendContactDto, @@ -19,6 +20,7 @@ import { audioMessageSchema, buttonsMessageSchema, contactMessageSchema, + forwardMessageSchema, listMessageSchema, locationMessageSchema, mediaMessageSchema, @@ -61,6 +63,16 @@ export class MessageRouter extends RouterBroker { return res.status(HttpStatus.CREATED).json(response); }) + .post(this.routerPath('forwardMessage'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: forwardMessageSchema, + ClassRef: ForwardMessageDto, + execute: (instance, data) => sendMessageController.forwardMessage(instance, data), + }); + + return res.status(HttpStatus.CREATED).json(response); + }) .post(this.routerPath('sendMedia'), ...guards, upload.single('file'), async (req, res) => { const bodyData = req.body; diff --git a/src/validate/group.schema.ts b/src/validate/group.schema.ts index 8da188d87f..75db868953 100644 --- a/src/validate/group.schema.ts +++ b/src/validate/group.schema.ts @@ -159,6 +159,45 @@ export const toggleEphemeralSchema: JSONSchema7 = { ...isNotEmpty('groupJid', 'expiration'), }; +export const memberAddModeSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + groupJid: { type: 'string' }, + mode: { type: 'string', enum: ['admin_add', 'all_member_add'] }, + }, + required: ['groupJid', 'mode'], + ...isNotEmpty('groupJid', 'mode'), +}; + +export const joinApprovalModeSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + groupJid: { type: 'string' }, + mode: { type: 'string', enum: ['on', 'off'] }, + }, + required: ['groupJid', 'mode'], + ...isNotEmpty('groupJid', 'mode'), +}; + +export const updateParticipantRequestSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + groupJid: { type: 'string' }, + action: { type: 'string', enum: ['approve', 'reject'] }, + participants: { + type: 'array', + minItems: 1, + uniqueItems: true, + items: { type: 'string' }, + }, + }, + required: ['groupJid', 'action', 'participants'], + ...isNotEmpty('groupJid', 'action'), +}; + export const updateGroupPictureSchema: JSONSchema7 = { $id: v4(), type: 'object', diff --git a/src/validate/message.schema.ts b/src/validate/message.schema.ts index d514c6199e..fcb138e4d0 100644 --- a/src/validate/message.schema.ts +++ b/src/validate/message.schema.ts @@ -65,6 +65,17 @@ export const offerCallSchema: JSONSchema7 = { required: ['number', 'callDuration'], }; +export const forwardMessageSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + number: { ...numberDefinition }, + messageId: { type: 'string' }, + }, + required: ['number', 'messageId'], + ...isNotEmpty('number', 'messageId'), +}; + export const textMessageSchema: JSONSchema7 = { $id: v4(), type: 'object', From 3efa13ce549e3f356493f361ee38213a2ef4b19b Mon Sep 17 00:00:00 2001 From: root Date: Mon, 6 Jul 2026 23:40:17 +0000 Subject: [PATCH 02/22] fix(groups): throttle groupMetadata (evita 429 em contas com centenas de grupos) + baileys rc13 --- package-lock.json | 768 +++++++----------- package.json | 4 +- .../whatsapp/whatsapp.baileys.service.ts | 37 +- 3 files changed, 311 insertions(+), 498 deletions(-) diff --git a/package-lock.json b/package-lock.json index c45e8fef38..8eda484379 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "amqplib": "^0.10.5", "audio-decode": "^2.2.3", "axios": "^1.7.9", - "baileys": "7.0.0-rc.9", + "baileys": "^7.0.0-rc13", "class-validator": "^0.14.1", "compression": "^1.7.5", "cors": "^2.8.5", @@ -829,9 +829,9 @@ } }, "node_modules/@borewit/text-codec": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.0.tgz", - "integrity": "sha512-X999CKBxGwX8wW+4gFibsbiNdwqmdQEXmUejIWaIqdrHBgS5ARIOOeyiQbHjP9G58xVEPcuvP6VwwH3A0OFTOA==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", "license": "MIT", "funding": { "type": "github", @@ -2372,17 +2372,17 @@ } }, "node_modules/@jimp/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.0.tgz", - "integrity": "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz", + "integrity": "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==", "license": "MIT", "dependencies": { - "@jimp/file-ops": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/file-ops": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", - "file-type": "^16.0.0", + "file-type": "^21.3.3", "mime": "3" }, "engines": { @@ -2402,14 +2402,14 @@ } }, "node_modules/@jimp/diff": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.0.tgz", - "integrity": "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.1.tgz", + "integrity": "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==", "license": "MIT", "dependencies": { - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "pixelmatch": "^5.3.0" }, "engines": { @@ -2417,23 +2417,23 @@ } }, "node_modules/@jimp/file-ops": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.0.tgz", - "integrity": "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.1.tgz", + "integrity": "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@jimp/js-bmp": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.0.tgz", - "integrity": "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.1.tgz", + "integrity": "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "bmp-ts": "^1.0.9" }, "engines": { @@ -2441,13 +2441,13 @@ } }, "node_modules/@jimp/js-gif": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.0.tgz", - "integrity": "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.1.tgz", + "integrity": "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "gifwrap": "^0.10.1", "omggif": "^1.0.10" }, @@ -2456,13 +2456,13 @@ } }, "node_modules/@jimp/js-jpeg": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.0.tgz", - "integrity": "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.1.tgz", + "integrity": "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "jpeg-js": "^0.4.4" }, "engines": { @@ -2470,13 +2470,13 @@ } }, "node_modules/@jimp/js-png": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.0.tgz", - "integrity": "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.1.tgz", + "integrity": "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "pngjs": "^7.0.0" }, "engines": { @@ -2484,13 +2484,13 @@ } }, "node_modules/@jimp/js-tiff": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.0.tgz", - "integrity": "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.1.tgz", + "integrity": "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "utif2": "^4.1.0" }, "engines": { @@ -2498,13 +2498,13 @@ } }, "node_modules/@jimp/plugin-blit": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.0.tgz", - "integrity": "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.1.tgz", + "integrity": "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2512,25 +2512,25 @@ } }, "node_modules/@jimp/plugin-blur": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.0.tgz", - "integrity": "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz", + "integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/utils": "1.6.0" + "@jimp/core": "1.6.1", + "@jimp/utils": "1.6.1" }, "engines": { "node": ">=18" } }, "node_modules/@jimp/plugin-circle": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.0.tgz", - "integrity": "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.1.tgz", + "integrity": "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2538,14 +2538,14 @@ } }, "node_modules/@jimp/plugin-color": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.0.tgz", - "integrity": "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.1.tgz", + "integrity": "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "tinycolor2": "^1.6.0", "zod": "^3.23.8" }, @@ -2554,16 +2554,16 @@ } }, "node_modules/@jimp/plugin-contain": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.0.tgz", - "integrity": "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.1.tgz", + "integrity": "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/plugin-blit": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2571,15 +2571,15 @@ } }, "node_modules/@jimp/plugin-cover": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.0.tgz", - "integrity": "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.1.tgz", + "integrity": "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/plugin-crop": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2587,14 +2587,14 @@ } }, "node_modules/@jimp/plugin-crop": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.0.tgz", - "integrity": "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.1.tgz", + "integrity": "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2602,13 +2602,13 @@ } }, "node_modules/@jimp/plugin-displace": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.0.tgz", - "integrity": "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.1.tgz", + "integrity": "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2616,25 +2616,25 @@ } }, "node_modules/@jimp/plugin-dither": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.0.tgz", - "integrity": "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.1.tgz", + "integrity": "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0" + "@jimp/types": "1.6.1" }, "engines": { "node": ">=18" } }, "node_modules/@jimp/plugin-fisheye": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.0.tgz", - "integrity": "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.1.tgz", + "integrity": "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2642,12 +2642,12 @@ } }, "node_modules/@jimp/plugin-flip": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.0.tgz", - "integrity": "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.1.tgz", + "integrity": "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2655,20 +2655,20 @@ } }, "node_modules/@jimp/plugin-hash": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.0.tgz", - "integrity": "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q==", - "license": "MIT", - "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/js-bmp": "1.6.0", - "@jimp/js-jpeg": "1.6.0", - "@jimp/js-png": "1.6.0", - "@jimp/js-tiff": "1.6.0", - "@jimp/plugin-color": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.1.tgz", + "integrity": "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "any-base": "^1.1.0" }, "engines": { @@ -2676,12 +2676,12 @@ } }, "node_modules/@jimp/plugin-mask": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.0.tgz", - "integrity": "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.1.tgz", + "integrity": "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2689,16 +2689,16 @@ } }, "node_modules/@jimp/plugin-print": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.0.tgz", - "integrity": "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.1.tgz", + "integrity": "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/js-jpeg": "1.6.0", - "@jimp/js-png": "1.6.0", - "@jimp/plugin-blit": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/types": "1.6.1", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", @@ -2710,9 +2710,9 @@ } }, "node_modules/@jimp/plugin-quantize": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.0.tgz", - "integrity": "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.1.tgz", + "integrity": "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==", "license": "MIT", "dependencies": { "image-q": "^4.0.0", @@ -2723,13 +2723,13 @@ } }, "node_modules/@jimp/plugin-resize": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.0.tgz", - "integrity": "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz", + "integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2737,16 +2737,16 @@ } }, "node_modules/@jimp/plugin-rotate": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.0.tgz", - "integrity": "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.1.tgz", + "integrity": "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/plugin-crop": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2754,16 +2754,16 @@ } }, "node_modules/@jimp/plugin-threshold": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.0.tgz", - "integrity": "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.1.tgz", + "integrity": "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/plugin-color": "1.6.0", - "@jimp/plugin-hash": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2771,9 +2771,9 @@ } }, "node_modules/@jimp/types": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.0.tgz", - "integrity": "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.1.tgz", + "integrity": "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==", "license": "MIT", "dependencies": { "zod": "^3.23.8" @@ -2783,12 +2783,12 @@ } }, "node_modules/@jimp/utils": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.0.tgz", - "integrity": "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", + "@jimp/types": "1.6.1", "tinycolor2": "^1.6.0" }, "engines": { @@ -3578,25 +3578,24 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -3605,12 +3604,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -3624,9 +3617,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, "node_modules/@redis/bloom": { @@ -4758,34 +4751,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/@tokenizer/inflate/node_modules/@borewit/text-codec": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", - "integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/@tokenizer/inflate/node_modules/token-types": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", - "integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==", - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.1.0", - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -4895,12 +4860,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT" - }, "node_modules/@types/mime": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-4.0.0.tgz", @@ -5814,21 +5773,22 @@ } }, "node_modules/baileys": { - "version": "7.0.0-rc.9", - "resolved": "https://registry.npmjs.org/baileys/-/baileys-7.0.0-rc.9.tgz", - "integrity": "sha512-Txd2dZ9MHbojvsHckeuCnAKPO/bQjKxua/0tQSJwOKXffK5vpS82k4eA/Nb46K0cK0Bx+fyY0zhnQHYMBriQcw==", + "version": "7.0.0-rc13", + "resolved": "https://registry.npmjs.org/baileys/-/baileys-7.0.0-rc13.tgz", + "integrity": "sha512-v8k74K8B5R7WNYGa26MyJAYEu3Wc4BSuK01QaK8lr30lhE8Nga31nWNu8KN0NDDt+Fsvkq4SQFFI8Q13ghjKmA==", "hasInstallScript": true, "license": "MIT", "dependencies": { "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", - "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", + "libsignal": "^6.0.0", "lru-cache": "^11.1.0", - "music-metadata": "^11.7.0", + "music-metadata": "^11.12.3", "p-queue": "^9.0.0", "pino": "^9.6", - "protobufjs": "^7.2.4", + "protobufjs": "^7.5.6", + "whatsapp-rust-bridge": "0.5.4", "ws": "^8.13.0" }, "engines": { @@ -5836,7 +5796,7 @@ }, "peerDependencies": { "audio-decode": "^2.1.3", - "jimp": "^1.6.0", + "jimp": "^1.6.1", "link-preview-js": "^3.0.0", "sharp": "*" }, @@ -5877,6 +5837,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -8336,15 +8297,6 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/exif-parser": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", @@ -8629,17 +8581,18 @@ } }, "node_modules/file-type": { - "version": "16.5.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", - "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", "license": "MIT", "dependencies": { - "readable-web-to-node-stream": "^3.0.0", - "strtok3": "^6.2.4", - "token-types": "^4.1.1" + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sindresorhus/file-type?sponsor=1" @@ -10351,38 +10304,38 @@ "license": "ISC" }, "node_modules/jimp": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.0.tgz", - "integrity": "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==", - "license": "MIT", - "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/diff": "1.6.0", - "@jimp/js-bmp": "1.6.0", - "@jimp/js-gif": "1.6.0", - "@jimp/js-jpeg": "1.6.0", - "@jimp/js-png": "1.6.0", - "@jimp/js-tiff": "1.6.0", - "@jimp/plugin-blit": "1.6.0", - "@jimp/plugin-blur": "1.6.0", - "@jimp/plugin-circle": "1.6.0", - "@jimp/plugin-color": "1.6.0", - "@jimp/plugin-contain": "1.6.0", - "@jimp/plugin-cover": "1.6.0", - "@jimp/plugin-crop": "1.6.0", - "@jimp/plugin-displace": "1.6.0", - "@jimp/plugin-dither": "1.6.0", - "@jimp/plugin-fisheye": "1.6.0", - "@jimp/plugin-flip": "1.6.0", - "@jimp/plugin-hash": "1.6.0", - "@jimp/plugin-mask": "1.6.0", - "@jimp/plugin-print": "1.6.0", - "@jimp/plugin-quantize": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/plugin-rotate": "1.6.0", - "@jimp/plugin-threshold": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0" + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz", + "integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/diff": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-gif": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-blur": "1.6.1", + "@jimp/plugin-circle": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-contain": "1.6.1", + "@jimp/plugin-cover": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-displace": "1.6.1", + "@jimp/plugin-dither": "1.6.1", + "@jimp/plugin-fisheye": "1.6.1", + "@jimp/plugin-flip": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/plugin-mask": "1.6.1", + "@jimp/plugin-print": "1.6.1", + "@jimp/plugin-quantize": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/plugin-rotate": "1.6.1", + "@jimp/plugin-threshold": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1" }, "engines": { "node": ">=18" @@ -10610,51 +10563,13 @@ "license": "MIT" }, "node_modules/libsignal": { - "name": "@whiskeysockets/libsignal-node", - "version": "2.0.1", - "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/libsignal/-/libsignal-6.0.0.tgz", + "integrity": "sha512-d/5V3YFtDljbFMufz4ncyUYGYhJl+vzAe+c2EFFBQ6bz1h8Q3IOMEGXYMzlibU60I+e8GagMMpji18iez3P1hA==", "license": "GPL-3.0", "dependencies": { "curve25519-js": "^0.0.4", - "protobufjs": "6.8.8" - } - }, - "node_modules/libsignal/node_modules/@types/node": { - "version": "10.17.60", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", - "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", - "license": "MIT" - }, - "node_modules/libsignal/node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "license": "Apache-2.0" - }, - "node_modules/libsignal/node_modules/protobufjs": { - "version": "6.8.8", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz", - "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "@types/node": "^10.1.0", - "long": "^4.0.0" - }, - "bin": { - "pbjs": "bin/pbjs", - "pbts": "bin/pbts" + "protobufjs": "^7.5.5" } }, "node_modules/lilconfig": { @@ -11232,12 +11147,16 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz", + "integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mediainfo.js": { @@ -11656,9 +11575,9 @@ } }, "node_modules/music-metadata": { - "version": "11.10.3", - "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.10.3.tgz", - "integrity": "sha512-j0g/x4cNNZW6I5gdcPAY+GFkJY9WHTpkFDMBJKQLxJQyvSfQbXm57fTE3haGFFuOzCgtsTd4Plwc49Sn9RacDQ==", + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.13.0.tgz", + "integrity": "sha512-uXRaov9dfjSpQufXIU7sMxVZnh+FilCQv2mXn+K5EJ/decP3dTWrgvPYa5r6MtRbieNSCE708Da4J0u1UGfQIw==", "funding": [ { "type": "github", @@ -11671,80 +11590,32 @@ ], "license": "MIT", "dependencies": { - "@borewit/text-codec": "^0.2.0", + "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "file-type": "^21.1.1", - "media-typer": "^1.1.0", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.5.0" + "file-type": "^21.3.4", + "media-typer": "^2.0.0", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0", + "win-guid": "^0.2.1" }, "engines": { "node": ">=18" } }, - "node_modules/music-metadata/node_modules/file-type": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.1.1.tgz", - "integrity": "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg==", - "license": "MIT", - "dependencies": { - "@tokenizer/inflate": "^0.4.1", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, - "node_modules/music-metadata/node_modules/strtok3": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", - "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "node_modules/music-metadata/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0" - }, "engines": { "node": ">=18" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/music-metadata/node_modules/token-types": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", - "integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==", - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.1.0", - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/music-metadata/node_modules/token-types/node_modules/@borewit/text-codec": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", - "integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mute-stream": { @@ -12576,19 +12447,6 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, - "node_modules/peek-readable": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", - "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -12957,15 +12815,6 @@ } } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -12983,24 +12832,23 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -13411,62 +13259,6 @@ "node": ">= 6" } }, - "node_modules/readable-web-to-node-stream": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", - "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", - "license": "MIT", - "dependencies": { - "readable-stream": "^4.7.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/readable-web-to-node-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -14176,9 +13968,9 @@ "license": "ISC" }, "node_modules/simple-xml-to-json": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.3.tgz", - "integrity": "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.7.tgz", + "integrity": "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==", "license": "MIT", "engines": { "node": ">=20.12.2" @@ -14640,16 +14432,15 @@ "license": "MIT" }, "node_modules/strtok3": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", - "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", "license": "MIT", "dependencies": { - "@tokenizer/token": "^0.3.0", - "peek-readable": "^4.1.0" + "@tokenizer/token": "^0.3.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "type": "github", @@ -14914,16 +14705,17 @@ } }, "node_modules/token-types": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", - "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", "license": "MIT", "dependencies": { + "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" }, "engines": { - "node": ">=10" + "node": ">=14.16" }, "funding": { "type": "github", @@ -15944,6 +15736,12 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatsapp-rust-bridge": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/whatsapp-rust-bridge/-/whatsapp-rust-bridge-0.5.4.tgz", + "integrity": "sha512-yYO1qSs0Fe7tGtnxOFHomocUD6IZtoAgmA4oDFyGIRZ67D3QZk3w7swA6XXFXNQngiyrg2k7tul6IrM3eUFh7A==", + "license": "MIT" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -16064,6 +15862,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/win-guid": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", + "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", + "license": "MIT" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 56e32fcc8a..7fb7f03b51 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "amqplib": "^0.10.5", "audio-decode": "^2.2.3", "axios": "^1.7.9", - "baileys": "7.0.0-rc.9", + "baileys": "^7.0.0-rc13", "class-validator": "^0.14.1", "compression": "^1.7.5", "cors": "^2.8.5", @@ -87,10 +87,10 @@ "eventemitter2": "^6.4.9", "express": "^4.21.2", "express-async-errors": "^3.1.1", + "fetch-socks": "^1.3.2", "fluent-ffmpeg": "^2.1.3", "form-data": "^4.0.1", "https-proxy-agent": "^7.0.6", - "fetch-socks": "^1.3.2", "i18next": "^23.7.19", "jimp": "^1.6.0", "json-schema": "^0.4.0", diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index e717544420..be97c90a01 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -228,6 +228,12 @@ async function getVideoDuration(input: Buffer | string | Readable): Promise = Promise.resolve(); +let __groupMetaLastAt = 0; +const __GROUP_META_MIN_INTERVAL_MS = 2000; + export class BaileysStartupService extends ChannelStartupService { private messageProcessor = new BaileysMessageProcessor(); @@ -4303,21 +4309,24 @@ export class BaileysStartupService extends ChannelStartupService { // Group private async updateGroupMetadataCache(groupJid: string) { - try { - const meta = await this.client.groupMetadata(groupJid); - - const cacheConf = this.configService.get('CACHE'); - - if ((cacheConf?.REDIS?.ENABLED && cacheConf?.REDIS?.URI !== '') || cacheConf?.LOCAL?.ENABLED) { - this.logger.verbose(`Updating cache for group: ${groupJid}`); - await groupMetadataCache.set(groupJid, { timestamp: Date.now(), data: meta }); + const task = __groupMetaChain.then(async () => { + const wait = __GROUP_META_MIN_INTERVAL_MS - (Date.now() - __groupMetaLastAt); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + __groupMetaLastAt = Date.now(); + try { + const meta = await this.client.groupMetadata(groupJid); + const cacheConf = this.configService.get('CACHE'); + if ((cacheConf?.REDIS?.ENABLED && cacheConf?.REDIS?.URI !== '') || cacheConf?.LOCAL?.ENABLED) { + await groupMetadataCache.set(groupJid, { timestamp: Date.now(), data: meta }); + } + return meta; + } catch (error) { + this.logger.error(error); + return null; } - - return meta; - } catch (error) { - this.logger.error(error); - return null; - } + }); + __groupMetaChain = task.then(() => undefined, () => undefined); + return task; } private getGroupMetadataCache = async (groupJid: string) => { From 6d3ed8a9c622e6863c74f8a767c201779c5bbb1d Mon Sep 17 00:00:00 2001 From: root Date: Mon, 6 Jul 2026 23:58:24 +0000 Subject: [PATCH 03/22] =?UTF-8?q?feat(groups):=20modo=20lite=20no=20fetchA?= =?UTF-8?q?llGroups=20(getParticipants=3Dfalse=20pula=20profilePicture=20?= =?UTF-8?q?=E2=80=94=20listagem=20sem=20rajada)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integrations/channel/whatsapp/whatsapp.baileys.service.ts | 3 ++- 1 file changed, 2 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 be97c90a01..7f37a4ed9b 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -4477,7 +4477,8 @@ export class BaileysStartupService extends ChannelStartupService { let groups = []; for (const group of fetch) { - const picture = await this.profilePicture(group.id); + const wantDetails = getParticipants.getParticipants == 'true'; + const picture = wantDetails ? await this.profilePicture(group.id) : null; const result = { id: group.id, From d771f48c307e386263d05f91a424adcd30307817 Mon Sep 17 00:00:00 2001 From: alpesdigital-adm Date: Wed, 9 Sep 2026 09:21:45 -0300 Subject: [PATCH 04/22] fix(groups): expose observed group metadata settings (#1) Co-authored-by: root --- .../whatsapp/whatsapp.baileys.service.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 7f37a4ed9b..010e5deb8d 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -4459,6 +4459,15 @@ export class BaileysStartupService extends ChannelStartupService { descId: group.descId, restrict: group.restrict, announce: group.announce, + joinApprovalMode: group.joinApprovalMode, + memberAddMode: group.memberAddMode, + // In complete Baileys metadata, an absent ephemeral node means disabled. + ephemeralDuration: + typeof group.joinApprovalMode === 'boolean' && typeof group.memberAddMode === 'boolean' + ? group.ephemeralDuration === undefined + ? 0 + : group.ephemeralDuration + : group.ephemeralDuration, participants: group.participants, isCommunity: group.isCommunity, isCommunityAnnounce: group.isCommunityAnnounce, @@ -4493,6 +4502,15 @@ export class BaileysStartupService extends ChannelStartupService { descId: group.descId, restrict: group.restrict, announce: group.announce, + joinApprovalMode: group.joinApprovalMode, + memberAddMode: group.memberAddMode, + // In complete Baileys metadata, an absent ephemeral node means disabled. + ephemeralDuration: + typeof group.joinApprovalMode === 'boolean' && typeof group.memberAddMode === 'boolean' + ? group.ephemeralDuration === undefined + ? 0 + : group.ephemeralDuration + : group.ephemeralDuration, isCommunity: group.isCommunity, isCommunityAnnounce: group.isCommunityAnnounce, linkedParent: group.linkedParent, From ab7854382b201b9a3f91a69f19c274b2b22544ef Mon Sep 17 00:00:00 2001 From: Alpes Digital Date: Mon, 28 Sep 2026 14:58:55 -0300 Subject: [PATCH 05/22] feat(chat): synchronize linked-device read state --- .github/workflows/release-read-state.yml | 72 +++++++++++ src/api/dto/chat.dto.ts | 1 + .../channel/whatsapp/read-state.ts | 74 +++++++++++ .../whatsapp/whatsapp.baileys.service.ts | 25 ++-- src/validate/chat.schema.ts | 20 ++- test/read-state.test.ts | 117 ++++++++++++++++++ 6 files changed, 294 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/release-read-state.yml create mode 100644 src/api/integrations/channel/whatsapp/read-state.ts create mode 100644 test/read-state.test.ts diff --git a/.github/workflows/release-read-state.yml b/.github/workflows/release-read-state.yml new file mode 100644 index 0000000000..97bb2986a3 --- /dev/null +++ b/.github/workflows/release-read-state.yml @@ -0,0 +1,72 @@ +name: Read-state release candidate + +on: + push: + branches: + - codex/read-state-sync + - feat/group-admin-controls-and-forward + workflow_dispatch: + +permissions: + contents: read + +jobs: + release-image: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + submodules: recursive + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npm run db:generate + env: + DATABASE_PROVIDER: postgresql + + - name: Test read-state contract + run: node --import tsx --test test/read-state.test.ts + + - name: Check code quality + run: npm run lint:check + + - name: Build application + run: npm run build + + - name: Set image names + id: image + shell: bash + run: | + echo "tag=evolution-api-fork:release-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + echo "archive=evolution-api-fork-release-${GITHUB_SHA}.tar.gz" >> "$GITHUB_OUTPUT" + + - name: Build Docker candidate + run: docker build --tag "${{ steps.image.outputs.tag }}" . + + - name: Export image and checksum + shell: bash + run: | + set -euo pipefail + docker save "${{ steps.image.outputs.tag }}" | gzip -1 > "${{ steps.image.outputs.archive }}" + sha256sum "${{ steps.image.outputs.archive }}" > "${{ steps.image.outputs.archive }}.sha256" + + - name: Upload release candidate + uses: actions/upload-artifact@v4 + with: + name: release-image-${{ github.sha }} + path: | + ${{ steps.image.outputs.archive }} + ${{ steps.image.outputs.archive }}.sha256 + if-no-files-found: error + retention-days: 1 + compression-level: 0 diff --git a/src/api/dto/chat.dto.ts b/src/api/dto/chat.dto.ts index b11f32b054..0c12071956 100644 --- a/src/api/dto/chat.dto.ts +++ b/src/api/dto/chat.dto.ts @@ -68,6 +68,7 @@ class Key { } export class ReadMessageDto { readMessages: Key[]; + lastMessage?: { key: Key; messageTimestamp: number }; } export class LastMessage { diff --git a/src/api/integrations/channel/whatsapp/read-state.ts b/src/api/integrations/channel/whatsapp/read-state.ts new file mode 100644 index 0000000000..a579f98501 --- /dev/null +++ b/src/api/integrations/channel/whatsapp/read-state.ts @@ -0,0 +1,74 @@ +import type { ReadMessageDto } from '@api/dto/chat.dto'; +import { isJidGroup, isLidUser, isPnUser, type proto, type WASocket } from 'baileys'; + +export class ReadStateValidationError extends Error {} + +type ReadStateClient = Pick; + +function isChatJid(jid: string): boolean { + return !!(isJidGroup(jid) || isPnUser(jid) || isLidUser(jid)); +} + +/** The legacy request still sends receipts alone. A lastMessage also syncs chat state to linked devices. */ +export async function syncMessageReadState(client: ReadStateClient, data: ReadMessageDto) { + if (!Array.isArray(data.readMessages)) { + throw new ReadStateValidationError('readMessages must be an array'); + } + + const lastMessage = data.lastMessage; + if (lastMessage) { + const { key, messageTimestamp } = lastMessage; + if ( + !key || + typeof key.remoteJid !== 'string' || + !isChatJid(key.remoteJid) || + typeof key.id !== 'string' || + !key.id.trim() || + typeof key.fromMe !== 'boolean' || + !Number.isSafeInteger(messageTimestamp) || + messageTimestamp <= 0 + ) { + throw new ReadStateValidationError('lastMessage requires a valid chat key and epoch-seconds messageTimestamp'); + } + if ( + data.readMessages.some( + (read) => + !read || + !isChatJid(read.remoteJid) || + read.remoteJid !== key.remoteJid || + !read.id || + typeof read.fromMe !== 'boolean', + ) + ) { + throw new ReadStateValidationError('readMessages and lastMessage must belong to the same chat'); + } + } else if (data.readMessages.length === 0) { + throw new ReadStateValidationError('readMessages cannot be empty without lastMessage'); + } + + const keys: proto.IMessageKey[] = data.readMessages + .filter((read) => isChatJid(read.remoteJid)) + .map((read) => ({ remoteJid: read.remoteJid, fromMe: read.fromMe, id: read.id })); + + // Run validation before either external call. A failure in either call propagates to the HTTP request. + if (lastMessage) { + await client.chatModify({ markRead: true, lastMessages: [lastMessage] }, lastMessage.key.remoteJid); + } + if (keys.length > 0 || !lastMessage) { + await client.readMessages(keys); + } + return { message: 'Read messages', read: 'success' }; +} + +export function chatReadStateUpdates( + chats: Array>, + instanceId: string, + readStateObservedAt: string, +) { + return chats.map((chat) => ({ + remoteJid: chat.id, + instanceId, + readStateObservedAt, + ...(Number.isInteger(chat.unreadCount) ? { unreadCount: chat.unreadCount } : {}), + })); +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 010e5deb8d..796536aed5 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -157,6 +157,7 @@ import { PassThrough, Readable } from 'stream'; import { v4 } from 'uuid'; import { BaileysMessageProcessor } from './baileysMessage.processor'; +import { chatReadStateUpdates, ReadStateValidationError, syncMessageReadState } from './read-state'; import { useVoiceCallsBaileys } from './voiceCalls/useVoiceCallsBaileys'; export interface ExtendedIMessageKey extends proto.IMessageKey { @@ -805,9 +806,8 @@ export class BaileysStartupService extends ChannelStartupService { } >[], ) => { - const chatsRaw = chats.map((chat) => { - return { remoteJid: chat.id, instanceId: this.instanceId }; - }); + const readStateObservedAt = new Date().toISOString(); + const chatsRaw = chatReadStateUpdates(chats, this.instanceId, readStateObservedAt); this.sendDataWebhook(Events.CHATS_UPDATE, chatsRaw); @@ -3699,15 +3699,11 @@ export class BaileysStartupService extends ChannelStartupService { public async markMessageAsRead(data: ReadMessageDto) { try { - const keys: proto.IMessageKey[] = []; - data.readMessages.forEach((read) => { - if (isJidGroup(read.remoteJid) || isPnUser(read.remoteJid)) { - keys.push({ remoteJid: read.remoteJid, fromMe: read.fromMe, id: read.id }); - } - }); - await this.client.readMessages(keys); - return { message: 'Read messages', read: 'success' }; + return await syncMessageReadState(this.client, data); } catch (error) { + if (error instanceof ReadStateValidationError) { + throw new BadRequestException(error.message); + } throw new InternalServerErrorException('Read messages fail', error.toString()); } } @@ -3773,7 +3769,7 @@ export class BaileysStartupService extends ChannelStartupService { last_message = await this.getLastMessage(number); } else { last_message = data.lastMessage; - last_message.messageTimestamp = last_message?.messageTimestamp ?? Date.now(); + last_message.messageTimestamp = last_message?.messageTimestamp ?? Math.floor(Date.now() / 1000); number = last_message?.key?.remoteJid; } @@ -4325,7 +4321,10 @@ export class BaileysStartupService extends ChannelStartupService { return null; } }); - __groupMetaChain = task.then(() => undefined, () => undefined); + __groupMetaChain = task.then( + () => undefined, + () => undefined, + ); return task; } diff --git a/src/validate/chat.schema.ts b/src/validate/chat.schema.ts index 7dae44539b..fc219bdd63 100644 --- a/src/validate/chat.schema.ts +++ b/src/validate/chat.schema.ts @@ -47,7 +47,6 @@ export const readMessageSchema: JSONSchema7 = { properties: { readMessages: { type: 'array', - minItems: 1, uniqueItems: true, items: { properties: { @@ -59,8 +58,25 @@ export const readMessageSchema: JSONSchema7 = { ...isNotEmpty('id', 'remoteJid'), }, }, + lastMessage: { + type: 'object', + properties: { + key: { + type: 'object', + properties: { + id: { type: 'string', minLength: 1 }, + fromMe: { type: 'boolean' }, + remoteJid: { type: 'string', minLength: 1 }, + }, + required: ['id', 'fromMe', 'remoteJid'], + }, + messageTimestamp: { type: 'integer', minimum: 1 }, + }, + required: ['key', 'messageTimestamp'], + }, }, required: ['readMessages'], + anyOf: [{ properties: { readMessages: { minItems: 1 } } }, { required: ['lastMessage'] }], }; export const archiveChatSchema: JSONSchema7 = { @@ -109,7 +125,7 @@ export const markChatUnreadSchema: JSONSchema7 = { required: ['id', 'fromMe', 'remoteJid'], ...isNotEmpty('id', 'remoteJid'), }, - messageTimestamp: { type: 'integer', minLength: 1 }, + messageTimestamp: { type: 'integer', minimum: 1 }, }, required: ['key'], ...isNotEmpty('messageTimestamp'), diff --git a/test/read-state.test.ts b/test/read-state.test.ts new file mode 100644 index 0000000000..cf6c91ca70 --- /dev/null +++ b/test/read-state.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { ReadMessageDto } from '../src/api/dto/chat.dto'; +import { + chatReadStateUpdates, + ReadStateValidationError, + syncMessageReadState, +} from '../src/api/integrations/channel/whatsapp/read-state'; +import { readMessageSchema } from '../src/validate/chat.schema'; +import { Validator } from 'jsonschema'; + +const jid = '5511999999999@s.whatsapp.net'; +const otherJid = '5511888888888@s.whatsapp.net'; +const lastMessage = { key: { remoteJid: jid, fromMe: true, id: 'outbound-1' }, messageTimestamp: 1_790_000_000 }; +const receipt = { remoteJid: jid, fromMe: false, id: 'inbound-1' }; + +function mockClient(options: { failModify?: boolean; failReceipts?: boolean } = {}) { + const calls: Array<{ command: string; payload: unknown; jid?: string }> = []; + const client = { + async chatModify(payload: unknown, chatJid: string) { + calls.push({ command: 'chatModify', payload, jid: chatJid }); + if (options.failModify) throw new Error('modify failed'); + }, + async readMessages(keys: unknown) { + calls.push({ command: 'readMessages', payload: keys }); + if (options.failReceipts) throw new Error('receipts failed'); + }, + } as Parameters[0]; + return { client, calls }; +} + +test('legacy readMessages sends receipts without modifying chat state', async () => { + const { client, calls } = mockClient(); + const result = await syncMessageReadState(client, { readMessages: [receipt] }); + assert.deepEqual(result, { message: 'Read messages', read: 'success' }); + assert.deepEqual(calls, [{ command: 'readMessages', payload: [receipt] }]); +}); + +test('lastMessage synchronizes linked-device read state and sends inbound receipts', async () => { + const { client, calls } = mockClient(); + await syncMessageReadState(client, { readMessages: [receipt], lastMessage }); + assert.deepEqual(calls, [ + { command: 'chatModify', payload: { markRead: true, lastMessages: [lastMessage] }, jid }, + { command: 'readMessages', payload: [receipt] }, + ]); +}); + +test('outbound lastMessage can clear a manually unread chat without inbound receipts', async () => { + const { client, calls } = mockClient(); + await syncMessageReadState(client, { readMessages: [], lastMessage }); + assert.deepEqual(calls, [{ command: 'chatModify', payload: { markRead: true, lastMessages: [lastMessage] }, jid }]); +}); + +test('mixed chats and missing timestamps fail before SDK effects', async () => { + const { client, calls } = mockClient(); + await assert.rejects( + syncMessageReadState(client, { readMessages: [{ ...receipt, remoteJid: otherJid }], lastMessage }), + ReadStateValidationError, + ); + await assert.rejects( + syncMessageReadState(client, { readMessages: [receipt], lastMessage: { key: lastMessage.key } } as ReadMessageDto), + ReadStateValidationError, + ); + assert.deepEqual(calls, []); +}); + +test('request schema requires an epoch-seconds timestamp and allows empty receipts only with lastMessage', () => { + const validator = new Validator(); + const valid = (value: unknown) => validator.validate(value, readMessageSchema).valid; + assert.equal(valid({ readMessages: [receipt] }), true); + assert.equal(valid({ readMessages: [], lastMessage }), true); + assert.equal(valid({ readMessages: [] }), false); + assert.equal(valid({ readMessages: [receipt], lastMessage: { key: lastMessage.key } }), false); + assert.equal(valid({ readMessages: [receipt], lastMessage: { ...lastMessage, messageTimestamp: 0 } }), false); +}); + +test('LID chats are accepted when the installed Baileys SDK recognizes them', async () => { + const lid = '123456789@lid'; + const { client, calls } = mockClient(); + await syncMessageReadState(client, { + readMessages: [{ remoteJid: lid, fromMe: false, id: 'lid-inbound' }], + lastMessage: { key: { remoteJid: lid, fromMe: false, id: 'lid-inbound' }, messageTimestamp: 1_790_000_001 }, + }); + assert.equal(calls[0].command, 'chatModify'); + assert.equal(calls[0].jid, lid); + assert.equal(calls[1].command, 'readMessages'); +}); + +test('partial SDK failure never reports success', async () => { + const modify = mockClient({ failModify: true }); + await assert.rejects(syncMessageReadState(modify.client, { readMessages: [receipt], lastMessage }), /modify failed/); + assert.deepEqual(modify.calls.map((call) => call.command), ['chatModify']); + + const receipts = mockClient({ failReceipts: true }); + await assert.rejects(syncMessageReadState(receipts.client, { readMessages: [receipt], lastMessage }), /receipts failed/); + assert.deepEqual(receipts.calls.map((call) => call.command), ['chatModify', 'readMessages']); +}); + +test('chats.update retains zero, manual-unread marker, and positive delta with observation time', () => { + const observedAt = '2026-09-28T12:34:56.000Z'; + const updates = chatReadStateUpdates( + [ + { id: jid, unreadCount: 0 }, + { id: otherJid, unreadCount: -1 }, + { id: '123@g.us', unreadCount: 3 }, + { id: '456@g.us', unreadCount: 1.5 }, + ], + 'instance-1', + observedAt, + ); + assert.deepEqual(updates, [ + { remoteJid: jid, instanceId: 'instance-1', readStateObservedAt: observedAt, unreadCount: 0 }, + { remoteJid: otherJid, instanceId: 'instance-1', readStateObservedAt: observedAt, unreadCount: -1 }, + { remoteJid: '123@g.us', instanceId: 'instance-1', readStateObservedAt: observedAt, unreadCount: 3 }, + { remoteJid: '456@g.us', instanceId: 'instance-1', readStateObservedAt: observedAt }, + ]); +}); From b24f9d0d73ca72d7c1dd67aac6aa94c56d7d003c Mon Sep 17 00:00:00 2001 From: Alpes Digital Date: Mon, 28 Sep 2026 15:09:03 -0300 Subject: [PATCH 06/22] fix(test): isolate read-state tests from Baileys runtime --- src/api/integrations/channel/whatsapp/read-state.ts | 6 ++++-- test/read-state.test.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/read-state.ts b/src/api/integrations/channel/whatsapp/read-state.ts index a579f98501..d8eb89edee 100644 --- a/src/api/integrations/channel/whatsapp/read-state.ts +++ b/src/api/integrations/channel/whatsapp/read-state.ts @@ -1,12 +1,14 @@ import type { ReadMessageDto } from '@api/dto/chat.dto'; -import { isJidGroup, isLidUser, isPnUser, type proto, type WASocket } from 'baileys'; +import type { proto, WASocket } from 'baileys'; export class ReadStateValidationError extends Error {} type ReadStateClient = Pick; function isChatJid(jid: string): boolean { - return !!(isJidGroup(jid) || isPnUser(jid) || isLidUser(jid)); + // Baileys' isPnUser/isJidGroup/isLidUser check these suffixes. Keep this + // predicate local so focused tests need not load the full Baileys runtime. + return typeof jid === 'string' && /[^@\s]+@(s\.whatsapp\.net|g\.us|lid)$/.test(jid); } /** The legacy request still sends receipts alone. A lastMessage also syncs chat state to linked devices. */ diff --git a/test/read-state.test.ts b/test/read-state.test.ts index cf6c91ca70..771f72f94f 100644 --- a/test/read-state.test.ts +++ b/test/read-state.test.ts @@ -74,7 +74,7 @@ test('request schema requires an epoch-seconds timestamp and allows empty receip assert.equal(valid({ readMessages: [receipt], lastMessage: { ...lastMessage, messageTimestamp: 0 } }), false); }); -test('LID chats are accepted when the installed Baileys SDK recognizes them', async () => { +test('LID chats are accepted by the same JID suffix contract', async () => { const lid = '123456789@lid'; const { client, calls } = mockClient(); await syncMessageReadState(client, { From 1d92ff002647a7d7cc88793a041e8bf500f0513b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:51:11 +0000 Subject: [PATCH 07/22] fix(chat): re-key LID read-state updates to the phone JID chats.update arrives keyed by LID on accounts migrated to LID addressing, while inbound messages are already normalized to the phone JID. Webhook consumers could not match the read state to the conversation, so reading on the phone never cleared unread counts downstream. Co-Authored-By: Claude Opus 5.5 --- .../channel/whatsapp/read-state.ts | 28 +++++++++++++++ .../whatsapp/whatsapp.baileys.service.ts | 12 +++++-- test/read-state.test.ts | 34 +++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/read-state.ts b/src/api/integrations/channel/whatsapp/read-state.ts index d8eb89edee..10b393887e 100644 --- a/src/api/integrations/channel/whatsapp/read-state.ts +++ b/src/api/integrations/channel/whatsapp/read-state.ts @@ -74,3 +74,31 @@ export function chatReadStateUpdates( ...(Number.isInteger(chat.unreadCount) ? { unreadCount: chat.unreadCount } : {}), })); } + +type ChatReadStateUpdate = ReturnType[number]; + +/** + * Chats are keyed by LID on recent accounts, but inbound messages are already + * normalized to the phone JID (key.remoteJidAlt). Emit the phone JID too so + * webhook consumers can match the read state to the same conversation. + * The original LID is kept in `lid`; unresolved LIDs pass through unchanged. + */ +export async function resolveReadStateLids( + updates: ChatReadStateUpdate[], + resolvePn: (lid: string) => Promise, +) { + return Promise.all( + updates.map(async (update) => { + if (typeof update.remoteJid !== 'string' || !update.remoteJid.endsWith('@lid')) return update; + let pn: string | null | undefined; + try { + pn = await resolvePn(update.remoteJid); + } catch { + pn = null; + } + const user = typeof pn === 'string' ? pn.split('@')[0].split(':')[0] : ''; + if (!/^\d{8,15}$/.test(user)) return update; + return { ...update, remoteJid: `${user}@s.whatsapp.net`, lid: update.remoteJid }; + }), + ); +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 796536aed5..dca5556bc8 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -157,7 +157,12 @@ import { PassThrough, Readable } from 'stream'; import { v4 } from 'uuid'; import { BaileysMessageProcessor } from './baileysMessage.processor'; -import { chatReadStateUpdates, ReadStateValidationError, syncMessageReadState } from './read-state'; +import { + chatReadStateUpdates, + ReadStateValidationError, + resolveReadStateLids, + syncMessageReadState, +} from './read-state'; import { useVoiceCallsBaileys } from './voiceCalls/useVoiceCallsBaileys'; export interface ExtendedIMessageKey extends proto.IMessageKey { @@ -807,7 +812,10 @@ export class BaileysStartupService extends ChannelStartupService { >[], ) => { const readStateObservedAt = new Date().toISOString(); - const chatsRaw = chatReadStateUpdates(chats, this.instanceId, readStateObservedAt); + const chatsRaw = await resolveReadStateLids( + chatReadStateUpdates(chats, this.instanceId, readStateObservedAt), + (lid) => this.client.signalRepository.lidMapping.getPNForLID(lid), + ); this.sendDataWebhook(Events.CHATS_UPDATE, chatsRaw); diff --git a/test/read-state.test.ts b/test/read-state.test.ts index 771f72f94f..0a20298971 100644 --- a/test/read-state.test.ts +++ b/test/read-state.test.ts @@ -4,6 +4,7 @@ import type { ReadMessageDto } from '../src/api/dto/chat.dto'; import { chatReadStateUpdates, ReadStateValidationError, + resolveReadStateLids, syncMessageReadState, } from '../src/api/integrations/channel/whatsapp/read-state'; import { readMessageSchema } from '../src/validate/chat.schema'; @@ -115,3 +116,36 @@ test('chats.update retains zero, manual-unread marker, and positive delta with o { remoteJid: '456@g.us', instanceId: 'instance-1', readStateObservedAt: observedAt }, ]); }); + +test('chats.update keyed by LID is re-keyed to the phone JID, keeping the LID', async () => { + const observedAt = '2026-10-08T12:00:00.000Z'; + const resolved = await resolveReadStateLids( + chatReadStateUpdates( + [ + { id: '123456789012345@lid', unreadCount: 0 }, + { id: '999999999999999@lid', unreadCount: -1 }, + { id: '888888888888888@lid', unreadCount: 0 }, + { id: jid, unreadCount: 0 }, + ], + 'instance-1', + observedAt, + ), + async (lid) => { + if (lid === '123456789012345@lid') return '5548991554955:12@s.whatsapp.net'; + if (lid === '888888888888888@lid') throw new Error('mapping store down'); + return null; + }, + ); + assert.deepEqual(resolved, [ + { + remoteJid: '5548991554955@s.whatsapp.net', + lid: '123456789012345@lid', + instanceId: 'instance-1', + readStateObservedAt: observedAt, + unreadCount: 0, + }, + { remoteJid: '999999999999999@lid', instanceId: 'instance-1', readStateObservedAt: observedAt, unreadCount: -1 }, + { remoteJid: '888888888888888@lid', instanceId: 'instance-1', readStateObservedAt: observedAt, unreadCount: 0 }, + { remoteJid: jid, instanceId: 'instance-1', readStateObservedAt: observedAt, unreadCount: 0 }, + ]); +}); From e2469c868f6a6cab7e062b4eaf28cd1c0ec04c56 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 11:04:35 +0000 Subject: [PATCH 08/22] fix(chat): address read/unread commands to the LID the phone uses Webhook consumers only see the phone JID, but LID-addressed chats are indexed by LID on the primary device. Receipts and app-state patches sent to the phone JID target a chat the phone does not know. Co-Authored-By: Claude Opus 5.5 --- .../channel/whatsapp/read-state.ts | 46 +++++++++++++++++++ .../whatsapp/whatsapp.baileys.service.ts | 17 ++++++- test/read-state.test.ts | 21 +++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/read-state.ts b/src/api/integrations/channel/whatsapp/read-state.ts index 10b393887e..06d94e5105 100644 --- a/src/api/integrations/channel/whatsapp/read-state.ts +++ b/src/api/integrations/channel/whatsapp/read-state.ts @@ -102,3 +102,49 @@ export async function resolveReadStateLids( }), ); } + +/** + * The inverse of resolveReadStateLids. Webhook consumers only ever see the + * phone JID, but chats migrated to LID addressing are indexed by LID on the + * phone: receipts and app-state patches must address the LID or they target a + * chat the primary device does not know. + */ +export async function toChatAddressingJid( + jid: string, + resolveLid: (pn: string) => Promise, +): Promise { + if (typeof jid !== 'string' || !jid.endsWith('@s.whatsapp.net')) return jid; + let lid: string | null | undefined; + try { + lid = await resolveLid(jid); + } catch { + lid = null; + } + const user = typeof lid === 'string' && lid.endsWith('@lid') ? lid.split('@')[0].split(':')[0] : ''; + return /^\d{8,20}$/.test(user) ? `${user}@lid` : jid; +} + +export async function readRequestToChatAddressing( + data: ReadMessageDto, + resolveLid: (pn: string) => Promise, +): Promise { + const jids = new Set(); + if (data.lastMessage?.key?.remoteJid) jids.add(data.lastMessage.key.remoteJid); + if (Array.isArray(data.readMessages)) for (const read of data.readMessages) if (read?.remoteJid) jids.add(read.remoteJid); + const mapped = new Map(); + for (const jid of jids) mapped.set(jid, await toChatAddressingJid(jid, resolveLid)); + return { + ...data, + readMessages: Array.isArray(data.readMessages) + ? data.readMessages.map((read) => (read?.remoteJid ? { ...read, remoteJid: mapped.get(read.remoteJid) } : read)) + : data.readMessages, + ...(data.lastMessage?.key + ? { + lastMessage: { + ...data.lastMessage, + key: { ...data.lastMessage.key, remoteJid: mapped.get(data.lastMessage.key.remoteJid) ?? data.lastMessage.key.remoteJid }, + }, + } + : {}), + } as ReadMessageDto; +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index dca5556bc8..b95aa0a3ec 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -160,8 +160,10 @@ import { BaileysMessageProcessor } from './baileysMessage.processor'; import { chatReadStateUpdates, ReadStateValidationError, + readRequestToChatAddressing, resolveReadStateLids, syncMessageReadState, + toChatAddressingJid, } from './read-state'; import { useVoiceCallsBaileys } from './voiceCalls/useVoiceCallsBaileys'; @@ -3707,7 +3709,10 @@ export class BaileysStartupService extends ChannelStartupService { public async markMessageAsRead(data: ReadMessageDto) { try { - return await syncMessageReadState(this.client, data); + const addressed = await readRequestToChatAddressing(data, (pn) => + this.client.signalRepository.lidMapping.getLIDForPN(pn), + ); + return await syncMessageReadState(this.client, addressed); } catch (error) { if (error instanceof ReadStateValidationError) { throw new BadRequestException(error.message); @@ -3785,7 +3790,15 @@ export class BaileysStartupService extends ChannelStartupService { throw new NotFoundException('Last message not found'); } - await this.client.chatModify({ markRead: false, lastMessages: [last_message] }, createJid(number)); + const resolveLid = (pn: string) => this.client.signalRepository.lidMapping.getLIDForPN(pn); + const chatJid = await toChatAddressingJid(createJid(number), resolveLid); + if (last_message?.key?.remoteJid) { + last_message = { + ...last_message, + key: { ...last_message.key, remoteJid: await toChatAddressingJid(last_message.key.remoteJid, resolveLid) }, + }; + } + await this.client.chatModify({ markRead: false, lastMessages: [last_message] }, chatJid); return { chatId: number, markedChatUnread: true }; } catch (error) { diff --git a/test/read-state.test.ts b/test/read-state.test.ts index 0a20298971..5151b1da53 100644 --- a/test/read-state.test.ts +++ b/test/read-state.test.ts @@ -4,7 +4,9 @@ import type { ReadMessageDto } from '../src/api/dto/chat.dto'; import { chatReadStateUpdates, ReadStateValidationError, + readRequestToChatAddressing, resolveReadStateLids, + toChatAddressingJid, syncMessageReadState, } from '../src/api/integrations/channel/whatsapp/read-state'; import { readMessageSchema } from '../src/validate/chat.schema'; @@ -149,3 +151,22 @@ test('chats.update keyed by LID is re-keyed to the phone JID, keeping the LID', { remoteJid: jid, instanceId: 'instance-1', readStateObservedAt: observedAt, unreadCount: 0 }, ]); }); + +test('commands from webhook consumers are re-addressed to the LID the phone uses', async () => { + const resolveLid = async (pn: string) => (pn === jid ? '55761694654630:3@lid' : null); + assert.equal(await toChatAddressingJid(jid, resolveLid), '55761694654630@lid'); + assert.equal(await toChatAddressingJid(otherJid, resolveLid), otherJid); + assert.equal(await toChatAddressingJid('123@g.us', resolveLid), '123@g.us'); + assert.equal(await toChatAddressingJid(jid, async () => { throw new Error('down'); }), jid); + + const addressed = await readRequestToChatAddressing( + { readMessages: [receipt], lastMessage } as unknown as ReadMessageDto, + resolveLid, + ); + assert.equal(addressed.readMessages[0].remoteJid, '55761694654630@lid'); + assert.equal(addressed.lastMessage?.key.remoteJid, '55761694654630@lid'); + assert.equal(addressed.lastMessage?.key.id, 'outbound-1'); + const { client, calls } = mockClient(); + await syncMessageReadState(client, addressed); + assert.equal(calls[0].jid, '55761694654630@lid'); +}); From de9f4491cc1381a48b43d10bc3f587eddfc2a642 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:04:06 +0000 Subject: [PATCH 09/22] fix(auth): revive app-state sync keys with fromObject, not create The auth-state adapters rebuilt AppStateSyncKeyData with create(), which keeps keyData as the stored base64 string. Baileys then derived the app-state keys from the 44-char text instead of the 32 key bytes: every incoming patch failed to decrypt and was skipped silently (MAC checks are off by default), and every outgoing chatModify patch was encrypted with the wrong keys, which the server answers with 401 device_removed. fromObject decodes base64 into bytes, as Baileys useMultiFileAuthState. Co-Authored-By: Claude Opus 5.5 --- src/utils/use-multi-file-auth-state-prisma.ts | 2 +- .../use-multi-file-auth-state-provider-files.ts | 2 +- src/utils/use-multi-file-auth-state-redis-db.ts | 2 +- test/auth-state-key.test.ts | 12 ++++++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 test/auth-state-key.test.ts diff --git a/src/utils/use-multi-file-auth-state-prisma.ts b/src/utils/use-multi-file-auth-state-prisma.ts index d090780234..bd11afa2fe 100644 --- a/src/utils/use-multi-file-auth-state-prisma.ts +++ b/src/utils/use-multi-file-auth-state-prisma.ts @@ -182,7 +182,7 @@ export default async function useMultiFileAuthStatePrisma( ids.map(async (id) => { let value = await readData(`${type}-${id}`); if (type === 'app-state-sync-key' && value) { - value = proto.Message.AppStateSyncKeyData.create(value); + value = proto.Message.AppStateSyncKeyData.fromObject(value); } data[id] = value; diff --git a/src/utils/use-multi-file-auth-state-provider-files.ts b/src/utils/use-multi-file-auth-state-provider-files.ts index eecc3100e5..157918169d 100644 --- a/src/utils/use-multi-file-auth-state-provider-files.ts +++ b/src/utils/use-multi-file-auth-state-provider-files.ts @@ -116,7 +116,7 @@ export class AuthStateProvider { ids.map(async (id) => { let value = await readData(`${type}-${id}`); if (type === 'app-state-sync-key' && value) { - value = proto.Message.AppStateSyncKeyData.create(value); + value = proto.Message.AppStateSyncKeyData.fromObject(value); } data[id] = value; diff --git a/src/utils/use-multi-file-auth-state-redis-db.ts b/src/utils/use-multi-file-auth-state-redis-db.ts index e0981c700c..4d5d5a5457 100644 --- a/src/utils/use-multi-file-auth-state-redis-db.ts +++ b/src/utils/use-multi-file-auth-state-redis-db.ts @@ -61,7 +61,7 @@ export async function useMultiFileAuthStateRedisDb( ids.map(async (id) => { let value = await readData(`${type}-${id}`); if (type === 'app-state-sync-key' && value) { - value = proto.Message.AppStateSyncKeyData.create(value); + value = proto.Message.AppStateSyncKeyData.fromObject(value); } data[id] = value; diff --git a/test/auth-state-key.test.ts b/test/auth-state-key.test.ts new file mode 100644 index 0000000000..ed2fd88bf9 --- /dev/null +++ b/test/auth-state-key.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +test("stored app-state keys are revived as bytes, not as their base64 text", async () => { + const { proto } = await import("baileys"); + const stored = JSON.parse(JSON.stringify({ keyData: Buffer.alloc(32, 7).toString("base64"), timestamp: "1791458000000" })); + const keyData = proto.Message.AppStateSyncKeyData.fromObject(stored).keyData as Uint8Array; + assert.equal(keyData.length, 32); + assert.equal(Buffer.from(keyData).equals(Buffer.alloc(32, 7)), true); + // create() keeps the 44-char base64 string: HKDF over it derives the wrong keys. + assert.equal(typeof proto.Message.AppStateSyncKeyData.create(stored).keyData, "string"); +}); From 96d47dd0e3165238b8ecba2f5ca3a638025da198 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Tue, 19 May 2026 12:30:26 -0300 Subject: [PATCH 10/22] security: prevent cross-instance auth bypass via query/body override (CVE pending, #2435) (#2549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * security: prevent cross-instance auth bypass via query/body override (#2435) The auth guard in src/api/guards/auth.guard.ts validates instance ownership using req.params.instanceName. The abstract router then merged req.query (and, on /instance/create, req.body) into the instance object via Object.assign — which silently overwrote the already-authenticated instanceName. An attacker with one valid token could send: GET /chat/findMessages/MY_INSTANCE?instanceName=VICTIM_INSTANCE Auth passed for MY_INSTANCE, but dataValidate() then replaced the instance with VICTIM_INSTANCE before execute() ran — giving the caller full access to read/send messages, modify settings, and delete other tenants' instances. CWE-639: Authorization Bypass Through User-Controlled Key Fix: introduce sanitizeUntrustedInput() that strips PROTECTED_INSTANCE_FIELDS (instanceName, instanceId) from any untrusted source before merging into the instance object. Logs a warning on attempts so abuse is auditable. Closes #2435 Reported by @lighthousekeeper1212 via static analysis. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(lint): remove extra blank line introduced by recent merge Pre-push lint flagged whatsapp.baileys.service.ts:531 (double blank line after the stream:error 515 fix merged via #2509). Trivial prettier fix. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) (cherry picked from commit 7a55a2bfcca5bff7df48a76893398d7b5aed5d56) --- src/api/abstract/abstract.router.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/api/abstract/abstract.router.ts b/src/api/abstract/abstract.router.ts index e8449a8c8a..2a64296ded 100644 --- a/src/api/abstract/abstract.router.ts +++ b/src/api/abstract/abstract.router.ts @@ -17,6 +17,21 @@ type DataValidate = { const logger = new Logger('Validate'); +const PROTECTED_INSTANCE_FIELDS = ['instanceName', 'instanceId'] as const; + +function sanitizeUntrustedInput(source: Record | undefined): Record { + if (!source || typeof source !== 'object') return {}; + const sanitized: Record = {}; + for (const [key, value] of Object.entries(source)) { + if ((PROTECTED_INSTANCE_FIELDS as readonly string[]).includes(key)) { + logger.warn(`Ignoring attempt to override protected field "${key}" via untrusted input`); + continue; + } + sanitized[key] = value; + } + return sanitized; +} + export abstract class RouterBroker { constructor() {} public routerPath(path: string, param = true) { @@ -34,11 +49,11 @@ export abstract class RouterBroker { const instance = request.params as unknown as InstanceDto; if (request?.query && Object.keys(request.query).length > 0) { - Object.assign(instance, request.query); + Object.assign(instance, sanitizeUntrustedInput(request.query as Record)); } if (request.originalUrl.includes('/instance/create')) { - Object.assign(instance, body); + Object.assign(instance, sanitizeUntrustedInput(body)); } Object.assign(ref, body); From ddd36b9d1363279227b86ff7992e222f5d226cb9 Mon Sep 17 00:00:00 2001 From: Octopus Date: Tue, 19 May 2026 22:42:28 +0800 Subject: [PATCH 11/22] fix(baileys): prevent healthy instances from being killed after stream:error 515 (#2509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(baileys): prevent healthy instances from being killed after stream:error 515 When WhatsApp sends stream:error code=515 (Connection Replaced), Baileys handles the reconnect correctly and fires connection.update with state='open'. However, WhatsApp then sends a 401 (loggedOut) to clean up the old session slot, which Evolution API incorrectly treated as a real logout, killing the newly-connected healthy instance. The fix tracks when a stream:error 515 node arrives via the CB:stream:error WebSocket event. If a loggedOut (401) close event fires within 30 seconds of a 515, it is treated as a transient reconnect rather than a real logout. Fixes #2498 * fix(baileys): name the 515 reconnect grace + tighten stream-error types Addresses sourcery-ai review feedback on the previous commit: - Extract the 30 000ms reconnect grace window into a named class constant STREAM_515_RECONNECT_GRACE_MS so future tuning is self-documenting rather than a literal scattered through the close handler. - Extract the magic '515' string into STREAM_ERROR_CODE_RECONNECT. - Replace the loose 'node: any' on the 'CB:stream:error' handler with a minimal structural type ({ attrs?: { code?: string | number } }) so the payload shape is documented and type-checked. - Compare the code via String(...) so a numeric 515 from the underlying socket library still triggers the grace window — the original literal '515' check would have silently broken on a type change. * fix(baileys): satisfy prettier — collapse 515 reconnect guard onto one line Check Code Quality lint failed on prettier/prettier (the recentStream515 expression fits within the project's 120-col printWidth on a single line, while shouldReconnect still needs to break across two). Co-authored-by: Octopus --------- Co-authored-by: octo-patch Co-authored-by: octo-patch (cherry picked from commit 1d38d4f6c5c1b17caddf6e420d7f8379960b0e86) --- .../whatsapp/whatsapp.baileys.service.ts | 22 ++++++++++++++++++- 1 file changed, 21 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 b95aa0a3ec..7feb0e9d73 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -274,6 +274,15 @@ export class BaileysStartupService extends ChannelStartupService { private readonly MESSAGE_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes - avoid duplicate message processing private readonly UPDATE_CACHE_TTL_SECONDS = 30 * 60; // 30 minutes - avoid duplicate status updates + private _lastStream515At = 0; + + // Reconnect behaviour for the Baileys "stream:error 515" sequence. + // After WhatsApp emits 515 it usually closes with `loggedOut`; that close is *not* a real logout + // and we should reconnect. We treat any close arriving within this grace window as 515-driven. + private static readonly STREAM_515_RECONNECT_GRACE_MS = 30_000; + // The numeric WhatsApp stream-error code that triggers the grace-period reconnect above. + private static readonly STREAM_ERROR_CODE_RECONNECT = '515'; + public stateConnection: wa.StateConnection = { state: 'close' }; public phoneNumber: string; @@ -444,7 +453,12 @@ export class BaileysStartupService extends ChannelStartupService { if (connection === 'close') { const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406]; - const shouldReconnect = !codesToNotReconnect.includes(statusCode); + // If a stream:error 515 (Baileys' "restart needed" handshake) just fired, + // a follow-up loggedOut is the expected restart signal — not an actual + // logout — so reconnect anyway. + const recentStream515 = Date.now() - this._lastStream515At < BaileysStartupService.STREAM_515_RECONNECT_GRACE_MS; + const shouldReconnect = + !codesToNotReconnect.includes(statusCode) || (statusCode === DisconnectReason.loggedOut && recentStream515); if (shouldReconnect) { await this.connectToWhatsapp(this.phoneNumber); } else { @@ -735,6 +749,12 @@ export class BaileysStartupService extends ChannelStartupService { this.eventHandler(); + this.client.ws.on('CB:stream:error', (node: { attrs?: { code?: string | number } }) => { + if (String(node?.attrs?.code) === BaileysStartupService.STREAM_ERROR_CODE_RECONNECT) { + this._lastStream515At = Date.now(); + } + }); + this.client.ws.on('CB:call', (packet) => { console.log('CB:call', packet); const payload = { event: 'CB:call', packet: packet }; From 9e8e3474ecf7a8c259578524109f40ee80235ce9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:25:41 +0000 Subject: [PATCH 12/22] fix(router): keep the create body intact after the cross-instance guard sanitizeUntrustedInput on /instance/create removed instanceName from the body, which is the only source of the new instance name. Create has no authenticated instance in the path, so there is nothing to override there. Co-Authored-By: Claude Opus 5.5 --- src/api/abstract/abstract.router.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/api/abstract/abstract.router.ts b/src/api/abstract/abstract.router.ts index 2a64296ded..68fdb5c6a4 100644 --- a/src/api/abstract/abstract.router.ts +++ b/src/api/abstract/abstract.router.ts @@ -53,7 +53,10 @@ export abstract class RouterBroker { } if (request.originalUrl.includes('/instance/create')) { - Object.assign(instance, sanitizeUntrustedInput(body)); + // No authenticated instance exists in the path on create, so there is + // nothing to override; the body IS the instance (its name included). + // Sanitizing it here stripped instanceName and broke instance creation. + Object.assign(instance, body); } Object.assign(ref, body); From 032878c2c725cfa6680bdae62f7fae1d422336f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:35:43 +0000 Subject: [PATCH 13/22] chore(deps): baileys 7.0.0-rc14 (pinned) Nests the tc token under the picture query in profilePictureUrl, so profile pictures of privacy-restricted contacts resolve. The Android browser mode is opt-in by browser name and stays off here. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8eda484379..b25f0f7c97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "amqplib": "^0.10.5", "audio-decode": "^2.2.3", "axios": "^1.7.9", - "baileys": "^7.0.0-rc13", + "baileys": "7.0.0-rc14", "class-validator": "^0.14.1", "compression": "^1.7.5", "cors": "^2.8.5", @@ -5773,9 +5773,9 @@ } }, "node_modules/baileys": { - "version": "7.0.0-rc13", - "resolved": "https://registry.npmjs.org/baileys/-/baileys-7.0.0-rc13.tgz", - "integrity": "sha512-v8k74K8B5R7WNYGa26MyJAYEu3Wc4BSuK01QaK8lr30lhE8Nga31nWNu8KN0NDDt+Fsvkq4SQFFI8Q13ghjKmA==", + "version": "7.0.0-rc14", + "resolved": "https://registry.npmjs.org/baileys/-/baileys-7.0.0-rc14.tgz", + "integrity": "sha512-pewtrljhWx5JTUBvvkXZz1fL3JPiwzjBsnhx/DWf2LWBx1cZNH7J/sF22xDehba5mySWUQU4a1Pwt7lWARUxaA==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 7fb7f03b51..369296c3cd 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "amqplib": "^0.10.5", "audio-decode": "^2.2.3", "axios": "^1.7.9", - "baileys": "^7.0.0-rc13", + "baileys": "7.0.0-rc14", "class-validator": "^0.14.1", "compression": "^1.7.5", "cors": "^2.8.5", From 56603b0421159c07b5199c19f9ef4e5f3a30350c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:56:33 +0000 Subject: [PATCH 14/22] fix(messages): fill remoteJidAlt from the LID mapping when it is missing History sync after a fresh pairing (and some live messages) deliver LID-only keys. Without the phone JID, webhook consumers cannot identify the sender and drop the message. Resolve it with getPNForLID, live and in messaging-history.set; unknown mappings leave the key untouched. Co-Authored-By: Claude Opus 5.5 --- .../channel/whatsapp/read-state.ts | 21 ++++++++++++++++ .../whatsapp/whatsapp.baileys.service.ts | 4 ++++ test/read-state.test.ts | 24 +++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/src/api/integrations/channel/whatsapp/read-state.ts b/src/api/integrations/channel/whatsapp/read-state.ts index 06d94e5105..034162451d 100644 --- a/src/api/integrations/channel/whatsapp/read-state.ts +++ b/src/api/integrations/channel/whatsapp/read-state.ts @@ -148,3 +148,24 @@ export async function readRequestToChatAddressing( : {}), } as ReadMessageDto; } + +/** + * Messages addressed by LID sometimes arrive without remoteJidAlt (history sync + * after a fresh pairing, contacts not yet resolved by the server). Without the + * phone JID, webhook consumers cannot tell who sent the message. Fill it from + * Baileys' LID mapping; leave the key untouched when the mapping is unknown. + */ +export async function fillRemoteJidAlt( + key: { remoteJid?: string | null; remoteJidAlt?: string | null } | null | undefined, + resolvePn: (lid: string) => Promise, +): Promise { + if (!key || typeof key.remoteJid !== 'string' || !key.remoteJid.endsWith('@lid') || key.remoteJidAlt) return; + let pn: string | null | undefined; + try { + pn = await resolvePn(key.remoteJid); + } catch { + return; + } + const user = typeof pn === 'string' ? pn.split('@')[0].split(':')[0] : ''; + if (/^\d{8,15}$/.test(user)) key.remoteJidAlt = `${user}@s.whatsapp.net`; +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 7feb0e9d73..321b98183f 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -159,6 +159,7 @@ import { v4 } from 'uuid'; import { BaileysMessageProcessor } from './baileysMessage.processor'; import { chatReadStateUpdates, + fillRemoteJidAlt, ReadStateValidationError, readRequestToChatAddressing, resolveReadStateLids, @@ -1088,6 +1089,8 @@ export class BaileysStartupService extends ChannelStartupService { continue; } + await fillRemoteJidAlt(m.key, (lid) => this.client.signalRepository.lidMapping.getPNForLID(lid)); + if (!m.pushName && !m.key.fromMe) { const participantJid = m.participant || m.key.participant || m.key.remoteJid; if (participantJid && contactsMap.has(participantJid)) { @@ -1529,6 +1532,7 @@ export class BaileysStartupService extends ChannelStartupService { this.logger.verbose(messageRaw); sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`); + await fillRemoteJidAlt(messageRaw.key, (lid) => this.client.signalRepository.lidMapping.getPNForLID(lid)); if (messageRaw.key.remoteJid?.includes('@lid') && messageRaw.key.remoteJidAlt) { messageRaw.key.remoteJid = messageRaw.key.remoteJidAlt; } diff --git a/test/read-state.test.ts b/test/read-state.test.ts index 5151b1da53..f5eeda5778 100644 --- a/test/read-state.test.ts +++ b/test/read-state.test.ts @@ -3,6 +3,7 @@ import { test } from 'node:test'; import type { ReadMessageDto } from '../src/api/dto/chat.dto'; import { chatReadStateUpdates, + fillRemoteJidAlt, ReadStateValidationError, readRequestToChatAddressing, resolveReadStateLids, @@ -170,3 +171,26 @@ test('commands from webhook consumers are re-addressed to the LID the phone uses await syncMessageReadState(client, addressed); assert.equal(calls[0].jid, '55761694654630@lid'); }); + +test('LID-only message keys get the phone JID from the LID mapping', async () => { + const resolve = async (lid: string) => (lid === '202860566425607@lid' ? '5511964242104:0@s.whatsapp.net' : null); + const known = { remoteJid: '202860566425607@lid' } as { remoteJid: string; remoteJidAlt?: string }; + await fillRemoteJidAlt(known, resolve); + assert.equal(known.remoteJidAlt, '5511964242104@s.whatsapp.net'); + + const unknown = { remoteJid: '999999999999999@lid' } as { remoteJid: string; remoteJidAlt?: string }; + await fillRemoteJidAlt(unknown, resolve); + assert.equal(unknown.remoteJidAlt, undefined); + + const already = { remoteJid: '202860566425607@lid', remoteJidAlt: '5500000000000@s.whatsapp.net' }; + await fillRemoteJidAlt(already, resolve); + assert.equal(already.remoteJidAlt, '5500000000000@s.whatsapp.net'); + + const phone = { remoteJid: jid } as { remoteJid: string; remoteJidAlt?: string }; + await fillRemoteJidAlt(phone, resolve); + assert.equal(phone.remoteJidAlt, undefined); + + const failing = { remoteJid: '202860566425607@lid' } as { remoteJid: string; remoteJidAlt?: string }; + await fillRemoteJidAlt(failing, async () => { throw new Error('down'); }); + assert.equal(failing.remoteJidAlt, undefined); +}); From 7c799c39187181435b9184ea7a75f87c5a501894 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:14:47 +0000 Subject: [PATCH 15/22] fix(baileys): verify app-state MACs (patch and snapshot) Co-Authored-By: Claude Opus 5.5 --- .../channel/whatsapp/whatsapp.baileys.service.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 321b98183f..07026653e4 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -693,6 +693,11 @@ export class BaileysStartupService extends ChannelStartupService { }, msgRetryCounterCache: this.msgRetryCounterCache, generateHighQualityLinkPreview: true, + // Baileys ships with app-state MAC checks off. With them off, an app-state + // patch decoded with the wrong key is dropped silently and our own patches + // can go out inconsistent (the server answers 401 device_removed). Fail + // closed: an invalid patch MAC makes Baileys refetch a snapshot instead. + appStateMacVerification: { patch: true, snapshot: true }, getMessage: async (key) => (await this.getMessage(key)) as Promise, ...browserOptions, markOnlineOnConnect: this.localSettings.alwaysOnline, From 2765f4288561d9da55f4e3e9c1f9c41a72dc138b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:20:38 +0000 Subject: [PATCH 16/22] fix(history): never use the JID digits as pushName History messages without a known contact got pushName = JID user part (LID digits included), which CRMs then stored as the contact name. Look the contact up by the phone JID as well and otherwise leave it empty. Co-Authored-By: Claude Opus 5.5 --- .../channel/whatsapp/whatsapp.baileys.service.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 07026653e4..667ab94768 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -1098,11 +1098,12 @@ export class BaileysStartupService extends ChannelStartupService { if (!m.pushName && !m.key.fromMe) { const participantJid = m.participant || m.key.participant || m.key.remoteJid; - if (participantJid && contactsMap.has(participantJid)) { - m.pushName = contactsMap.get(participantJid).name; - } else if (participantJid) { - m.pushName = participantJid.split('@')[0]; - } + // Contacts may be keyed by the phone JID while the message is LID-addressed. + const known = [participantJid, m.key.participantAlt, m.key.remoteJidAlt].find( + (jid) => jid && contactsMap.has(jid) && contactsMap.get(jid).name, + ); + // No digits fallback: a JID is not a name, and consumers would store it as one. + if (known) m.pushName = contactsMap.get(known).name; } messagesRaw.push(this.prepareMessage(m)); From 33dbeae88f14718649086f2d17cf7d434a282ef6 Mon Sep 17 00:00:00 2001 From: Astra Date: Fri, 9 Oct 2026 18:36:30 -0300 Subject: [PATCH 17/22] feat(chat): aceitar participant na marcacao de leitura de grupo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Em grupo o recibo de leitura endereca quem enviou, nao a conversa: o Baileys agrupa recibo por `remoteJid:participant` e recusa um markRead cujo key de mensagem recebida nao tenha participant ("Expected not from me message to have participant", Utils/chat-utils). A key chegava aqui e era remontada sem esse campo, entao nenhuma marcacao de leitura de grupo podia funcionar. Agora o campo e declarado no DTO e no schema de entrada, repassado como veio do provider, e a ausencia dele vira 400 com o campo nomeado em vez de erro opaco do SDK — mas so no caminho com lastMessage, que e o que o Baileys realmente recusa. Pedido legado so com recibo (Chatwoot) segue aceito como antes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XxnyvfDrgNppE1PftV4gTa --- src/api/dto/chat.dto.ts | 7 ++ .../channel/whatsapp/read-state.ts | 29 +++++++- src/validate/chat.schema.ts | 2 + test/read-state.test.ts | 67 +++++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/api/dto/chat.dto.ts b/src/api/dto/chat.dto.ts index 0c12071956..7de5e7f30d 100644 --- a/src/api/dto/chat.dto.ts +++ b/src/api/dto/chat.dto.ts @@ -65,6 +65,13 @@ class Key { id: string; fromMe: boolean; remoteJid: string; + /** + * Group receipts address the sender, not the chat: Baileys aggregates read + * receipts by `remoteJid:participant` and rejects a markRead range whose + * not-from-me group key has no participant. Round-trip whatever the provider + * gave us in the original key. + */ + participant?: string; } export class ReadMessageDto { readMessages: Key[]; diff --git a/src/api/integrations/channel/whatsapp/read-state.ts b/src/api/integrations/channel/whatsapp/read-state.ts index 034162451d..21a5b4dff1 100644 --- a/src/api/integrations/channel/whatsapp/read-state.ts +++ b/src/api/integrations/channel/whatsapp/read-state.ts @@ -5,6 +5,22 @@ export class ReadStateValidationError extends Error {} type ReadStateClient = Pick; +function isGroupJid(jid: unknown): boolean { + return typeof jid === 'string' && jid.endsWith('@g.us'); +} + +/** + * Baileys throws on a group read whose not-from-me key has no participant + * (Utils/chat-utils getMessageRange) and aggregates receipts by + * `remoteJid:participant` (Utils/messages). Catch it here so the caller gets a + * 400 naming the missing field instead of an opaque provider failure. + */ +function missingGroupParticipant(key: { remoteJid?: unknown; fromMe?: unknown; participant?: unknown }): boolean { + return ( + isGroupJid(key.remoteJid) && key.fromMe !== true && (typeof key.participant !== 'string' || !key.participant.trim()) + ); +} + function isChatJid(jid: string): boolean { // Baileys' isPnUser/isJidGroup/isLidUser check these suffixes. Keep this // predicate local so focused tests need not load the full Baileys runtime. @@ -32,6 +48,9 @@ export async function syncMessageReadState(client: ReadStateClient, data: ReadMe ) { throw new ReadStateValidationError('lastMessage requires a valid chat key and epoch-seconds messageTimestamp'); } + if (missingGroupParticipant(key)) { + throw new ReadStateValidationError('lastMessage from a group requires key.participant when fromMe is false'); + } if ( data.readMessages.some( (read) => @@ -44,13 +63,21 @@ export async function syncMessageReadState(client: ReadStateClient, data: ReadMe ) { throw new ReadStateValidationError('readMessages and lastMessage must belong to the same chat'); } + if (data.readMessages.some((read) => read && missingGroupParticipant(read))) { + throw new ReadStateValidationError('readMessages from a group require participant when fromMe is false'); + } } else if (data.readMessages.length === 0) { throw new ReadStateValidationError('readMessages cannot be empty without lastMessage'); } const keys: proto.IMessageKey[] = data.readMessages .filter((read) => isChatJid(read.remoteJid)) - .map((read) => ({ remoteJid: read.remoteJid, fromMe: read.fromMe, id: read.id })); + .map((read) => ({ + remoteJid: read.remoteJid, + fromMe: read.fromMe, + id: read.id, + ...(read.participant ? { participant: read.participant } : {}), + })); // Run validation before either external call. A failure in either call propagates to the HTTP request. if (lastMessage) { diff --git a/src/validate/chat.schema.ts b/src/validate/chat.schema.ts index fc219bdd63..c04d55fcfd 100644 --- a/src/validate/chat.schema.ts +++ b/src/validate/chat.schema.ts @@ -53,6 +53,7 @@ export const readMessageSchema: JSONSchema7 = { id: { type: 'string' }, fromMe: { type: 'boolean', enum: [true, false] }, remoteJid: { type: 'string' }, + participant: { type: 'string', minLength: 1 }, }, required: ['id', 'fromMe', 'remoteJid'], ...isNotEmpty('id', 'remoteJid'), @@ -67,6 +68,7 @@ export const readMessageSchema: JSONSchema7 = { id: { type: 'string', minLength: 1 }, fromMe: { type: 'boolean' }, remoteJid: { type: 'string', minLength: 1 }, + participant: { type: 'string', minLength: 1 }, }, required: ['id', 'fromMe', 'remoteJid'], }, diff --git a/test/read-state.test.ts b/test/read-state.test.ts index f5eeda5778..0026e88be7 100644 --- a/test/read-state.test.ts +++ b/test/read-state.test.ts @@ -194,3 +194,70 @@ test('LID-only message keys get the phone JID from the LID mapping', async () => await fillRemoteJidAlt(failing, async () => { throw new Error('down'); }); assert.equal(failing.remoteJidAlt, undefined); }); + +const groupJid = '120363295648424210@g.us'; +const senderJid = '5511977777777@s.whatsapp.net'; +const groupReceipt = { remoteJid: groupJid, fromMe: false, id: 'group-inbound-1', participant: senderJid }; +const groupLastMessage = { key: groupReceipt, messageTimestamp: 1_790_000_100 }; + +test('group receipts carry the participant through to the SDK', async () => { + const { client, calls } = mockClient(); + await syncMessageReadState(client, { readMessages: [groupReceipt], lastMessage: groupLastMessage }); + assert.deepEqual(calls[0], { command: 'chatModify', payload: { markRead: true, lastMessages: [groupLastMessage] }, jid: groupJid }); + assert.deepEqual(calls[1], { command: 'readMessages', payload: [groupReceipt] }); +}); + +test('a group key without participant fails before any SDK effect', async () => { + const { client, calls } = mockClient(); + const { participant: _ignored, ...noParticipant } = groupReceipt; + await assert.rejects( + syncMessageReadState(client, { readMessages: [noParticipant], lastMessage: groupLastMessage }), + ReadStateValidationError, + ); + await assert.rejects( + syncMessageReadState(client, { + readMessages: [groupReceipt], + lastMessage: { key: noParticipant, messageTimestamp: groupLastMessage.messageTimestamp }, + }), + ReadStateValidationError, + ); + await assert.rejects( + syncMessageReadState(client, { + readMessages: [{ ...groupReceipt, participant: ' ' }], + lastMessage: groupLastMessage, + }), + ReadStateValidationError, + ); + assert.deepEqual(calls, []); +}); + +test('our own group message needs no participant', async () => { + const { client, calls } = mockClient(); + const mine = { key: { remoteJid: groupJid, fromMe: true, id: 'group-outbound-1' }, messageTimestamp: 1_790_000_101 }; + await syncMessageReadState(client, { readMessages: [], lastMessage: mine }); + assert.deepEqual(calls, [{ command: 'chatModify', payload: { markRead: true, lastMessages: [mine] }, jid: groupJid }]); +}); + +test('group addressing is left alone: only phone JIDs are mapped to LID', async () => { + const addressed = await readRequestToChatAddressing( + { readMessages: [groupReceipt], lastMessage: groupLastMessage }, + async () => '999999999999@lid', + ); + assert.equal(addressed.readMessages[0].remoteJid, groupJid); + assert.equal(addressed.readMessages[0].participant, senderJid); + assert.equal(addressed.lastMessage?.key.remoteJid, groupJid); +}); + +test('request schema accepts the participant on both keys', () => { + const validator = new Validator(); + const valid = (value: unknown) => validator.validate(value, readMessageSchema).valid; + assert.equal(valid({ readMessages: [groupReceipt], lastMessage: groupLastMessage }), true); + assert.equal(valid({ readMessages: [{ ...groupReceipt, participant: '' }] }), false); +}); + +test('a legacy receipts-only group request is not forced to carry the participant', async () => { + const { client, calls } = mockClient(); + const { participant: _ignored, ...noParticipant } = groupReceipt; + await syncMessageReadState(client, { readMessages: [noParticipant] }); + assert.deepEqual(calls, [{ command: 'readMessages', payload: [noParticipant] }]); +}); From c14f3310e7196fa44ffa5ca6a4eff668b57e9646 Mon Sep 17 00:00:00 2001 From: Alpes Digital Date: Thu, 22 Oct 2026 20:09:13 -0300 Subject: [PATCH 18/22] fix(baileys): self-heal app-state LTHash mismatch, forward group read-self, keep participant on read 1. App-state self-heal. Baileys decodePatches only warns on an LTHash mismatch ("LTHash verification failed, skipping remaining patches"), then returns the already-advanced version with the mismatching hash and the caller persists it. Every later patch then fails the same way, so phone read/unread changes (regular_low) never become chats.update. The logger handed to makeWASocket is now wrapped (Proxy, works at any log level, covers child loggers) to spot that exact warning; a per-instance poller (outside Baileys' sync transaction) then clears app-state-sync-version- through the socket key store (null, in a transaction on the same mutex key as resyncAppState, so the in-memory cache and Redis are both cleared) and runs resyncAppState([name], false) so the snapshot rebuilds the hash and its mutations still flow to chats.update. Guards: socket must be open, 10 min cooldown and 3 attempts/hour per collection, WARN log "app-state self-heal: v -> snapshot" plus outcome. The snapshot variant of the warning is deliberately ignored. MAC verification stays enabled. 2. Group read-self. Baileys emits message-receipt.update (not messages.update) for a group read; the reader is receipt.userJid (the node's participant), key.fromMe is always true. When the reader is the instance's own account (phone JID or LID, device suffix stripped) and groupsIgnore is off, send one MESSAGES_UPDATE READ webhook per group per batch (fromMe=false, keyId = newest message of the batch) so the CRM group fallback fires. Receipts from other participants are ignored. updateMessagesReadedByTimestamp is unchanged. 3. syncMessageReadState keeps `participant` on readMessages keys (needed by WhatsApp for group receipts); DTO and request schema accept it. Co-Authored-By: Claude Opus 5.5 --- .../channel/whatsapp/app-state-self-heal.ts | 246 ++++++++++++++++ .../channel/whatsapp/read-state.ts | 120 +++++++- .../whatsapp/whatsapp.baileys.service.ts | 69 ++++- test/app-state-self-heal.test.ts | 266 ++++++++++++++++++ test/read-state.test.ts | 123 ++++++++ 5 files changed, 820 insertions(+), 4 deletions(-) create mode 100644 src/api/integrations/channel/whatsapp/app-state-self-heal.ts create mode 100644 test/app-state-self-heal.test.ts diff --git a/src/api/integrations/channel/whatsapp/app-state-self-heal.ts b/src/api/integrations/channel/whatsapp/app-state-self-heal.ts new file mode 100644 index 0000000000..0a1afd848e --- /dev/null +++ b/src/api/integrations/channel/whatsapp/app-state-self-heal.ts @@ -0,0 +1,246 @@ +/** + * Self-heal for a corrupted WhatsApp app-state (LTHash) collection. + * + * Baileys (decodePatches, Utils/chat-utils.js) reacts to an LTHash mismatch by + * logging a warning, `break`ing out of the patch loop and returning the state it + * had already advanced (new version + the hash of the mismatching patch). The + * caller persists that state. Nothing throws, so resyncAppState never forces a + * snapshot: every later patch starts from a wrong hash and fails the same way, + * forever, while the stored version keeps advancing. Symptom: read/unread + * changes made on the phone (regular_low) stop producing `chats.update`. + * + * The cure is the one we applied by hand: drop `app-state-sync-version-` + * so the next resync starts at v0 and asks the server for a full snapshot. + * + * This module has no Baileys runtime import so it can be unit-tested alone. + */ + +export const APP_STATE_COLLECTIONS = [ + 'critical_block', + 'critical_unblock_low', + 'regular_high', + 'regular_low', + 'regular', +] as const; +export type AppStateCollection = (typeof APP_STATE_COLLECTIONS)[number]; + +/** + * The exact Baileys warning for a patch mismatch. The snapshot variant + * ("LTHash verification failed on snapshot, continuing with partial state") is + * deliberately NOT matched: a snapshot is already the full resync, so retrying it + * could only loop. + */ +export const LTHASH_PATCH_FAILURE_MSG = 'LTHash verification failed, skipping remaining patches'; + +export type LtHashFailure = { name: AppStateCollection; version?: number }; + +/** Recognises pino-style `warn({ name, version }, msg)` calls carrying the patch failure. */ +export function parseLtHashPatchFailure(args: unknown[]): LtHashFailure | null { + const [obj, msg] = args; + if (msg !== LTHASH_PATCH_FAILURE_MSG || !obj || typeof obj !== 'object') return null; + const { name, version } = obj as { name?: unknown; version?: unknown }; + if (typeof name !== 'string' || !(APP_STATE_COLLECTIONS as readonly string[]).includes(name)) return null; + return { name: name as AppStateCollection, version: typeof version === 'number' ? version : undefined }; +} + +/** + * Wraps a pino logger so `warn` calls are inspected before being forwarded + * untouched. A Proxy (instead of a pino `logMethod` hook) keeps working when the + * configured level is above `warn`, where pino replaces the method with a no-op + * and hooks never run. `child()` loggers are wrapped too. + */ +export function watchAppStateFailures(logger: T, onFailure: (failure: LtHashFailure) => void): T { + return new Proxy(logger, { + get(target, prop) { + const value = Reflect.get(target, prop, target); + if (typeof value !== 'function') return value; + if (prop === 'warn') { + return (...args: unknown[]) => { + try { + const failure = parseLtHashPatchFailure(args); + if (failure) onFailure(failure); + } catch { + // Detection must never break Baileys' own logging. + } + return value.apply(target, args); + }; + } + if (prop === 'child') { + return (...args: unknown[]) => watchAppStateFailures(value.apply(target, args), onFailure); + } + return value.bind(target); + }, + set(target, prop, value) { + (target as Record)[prop] = value; + return true; + }, + }); +} + +export interface AppStateHealTarget { + /** True only while the socket is open; the heal never runs otherwise. */ + isOpen(): boolean; + /** Stored version of the collection, undefined when nothing is stored. */ + readVersion(name: AppStateCollection): Promise; + /** Removes the stored version (through the socket's key store, so its in-memory cache is cleared too). */ + clearVersion(name: AppStateCollection): Promise; + /** Runs Baileys' resyncAppState([name], false). */ + resync(name: AppStateCollection): Promise; +} + +/** Structural subset of a Baileys socket, so this file needs no Baileys import. */ +export interface AppStateSocketLike { + authState: { + creds?: { me?: { id?: string } }; + keys: { + get( + type: 'app-state-sync-version', + ids: string[], + ): MaybePromise>; + set(data: { 'app-state-sync-version': Record }): MaybePromise; + transaction(work: () => Promise, key: string): Promise; + }; + }; + resyncAppState(collections: AppStateCollection[], isInitialSync: boolean): Promise; +} + +type MaybePromise = T | PromiseLike; + +/** + * Bridges the healer to a socket. Clearing goes through the socket's own key + * store (not Redis directly) so the in-memory makeCacheableSignalKeyStore cache + * is cleared too, inside a transaction on the same mutex key resyncAppState + * uses, so it queues behind a sync that is still running. + */ +export function createSocketHealTarget(socket: AppStateSocketLike, isOpen: () => boolean): AppStateHealTarget { + const { keys, creds } = socket.authState; + return { + isOpen, + readVersion: async (name) => (await keys.get('app-state-sync-version', [name]))?.[name]?.version, + clearVersion: async (name) => { + await keys.transaction(async () => { + await keys.set({ 'app-state-sync-version': { [name]: null } }); + }, creds?.me?.id || 'resync-app-state'); + }, + resync: (name) => socket.resyncAppState([name], false), + }; +} + +type HealLog = { warn: (msg: string) => void; error: (msg: string) => void }; + +export interface AppStateHealerOptions { + instanceName: string; + /** Resolved on every attempt: the socket changes on each reconnect. */ + getTarget: () => AppStateHealTarget | undefined; + log: HealLog; + cooldownMs?: number; + maxAttemptsPerHour?: number; + pollMs?: number; + now?: () => number; +} + +const HOUR_MS = 60 * 60 * 1000; + +/** + * One healer per instance: it only ever acts through that instance's target. + * + * Detection happens synchronously inside Baileys' sync transaction, and the + * failing state is committed when that transaction ends. Healing therefore never + * runs from the log callback: it is queued and executed later by a poller that + * is not part of the transaction (a timer started from inside it would inherit + * its AsyncLocalStorage context and write into an already-closed transaction). + */ +export class AppStateSelfHealer { + private readonly pending = new Map(); + private readonly attempts = new Map(); + private timer?: ReturnType; + private busy = false; + + private readonly cooldownMs: number; + private readonly maxAttemptsPerHour: number; + private readonly pollMs: number; + private readonly now: () => number; + + constructor(private readonly options: AppStateHealerOptions) { + this.cooldownMs = options.cooldownMs ?? 10 * 60 * 1000; + this.maxAttemptsPerHour = options.maxAttemptsPerHour ?? 3; + this.pollMs = options.pollMs ?? 5_000; + this.now = options.now ?? Date.now; + } + + /** Synchronous and side-effect free beyond queueing: safe to call from a logger. */ + report(failure: LtHashFailure): void { + this.pending.set(failure.name, failure.version); + } + + get pendingCollections(): AppStateCollection[] { + return [...this.pending.keys()]; + } + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => void this.flush(), this.pollMs); + this.timer.unref?.(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = undefined; + } + + /** Processes the queue once. Collections stay queued while the socket is not open. */ + async flush(): Promise { + if (this.busy || this.pending.size === 0) return; + const target = this.options.getTarget(); + if (!target || !target.isOpen()) return; + + this.busy = true; + try { + for (const [name, reportedVersion] of [...this.pending]) { + this.pending.delete(name); + await this.heal(target, name, reportedVersion); + } + } finally { + this.busy = false; + } + } + + private async heal(target: AppStateHealTarget, name: AppStateCollection, reportedVersion?: number): Promise { + const { instanceName, log } = this.options; + const now = this.now(); + const recent = (this.attempts.get(name) ?? []).filter((at) => now - at < HOUR_MS); + const last = recent[recent.length - 1]; + + if (last !== undefined && now - last < this.cooldownMs) { + log.warn( + `app-state self-heal: ${instanceName} ${name} skipped (cooldown, last attempt ${Math.round((now - last) / 1000)}s ago)`, + ); + this.attempts.set(name, recent); + return; + } + if (recent.length >= this.maxAttemptsPerHour) { + log.warn(`app-state self-heal: ${instanceName} ${name} skipped (${recent.length} attempts in the last hour)`); + this.attempts.set(name, recent); + return; + } + recent.push(now); + this.attempts.set(name, recent); + + try { + const oldVersion = (await target.readVersion(name)) ?? reportedVersion; + log.warn(`app-state self-heal: ${instanceName} ${name} v${oldVersion ?? '?'} → snapshot`); + await target.clearVersion(name); + await target.resync(name); + const newVersion = await target.readVersion(name); + if (newVersion === undefined) { + log.error(`app-state self-heal: ${instanceName} ${name} FAILED (no state stored after resync)`); + } else { + log.warn(`app-state self-heal: ${instanceName} ${name} OK (snapshot restored at v${newVersion})`); + } + } catch (error) { + log.error( + `app-state self-heal: ${instanceName} ${name} FAILED (${error instanceof Error ? error.message : String(error)})`, + ); + } + } +} diff --git a/src/api/integrations/channel/whatsapp/read-state.ts b/src/api/integrations/channel/whatsapp/read-state.ts index 21a5b4dff1..464d928cbf 100644 --- a/src/api/integrations/channel/whatsapp/read-state.ts +++ b/src/api/integrations/channel/whatsapp/read-state.ts @@ -157,7 +157,8 @@ export async function readRequestToChatAddressing( ): Promise { const jids = new Set(); if (data.lastMessage?.key?.remoteJid) jids.add(data.lastMessage.key.remoteJid); - if (Array.isArray(data.readMessages)) for (const read of data.readMessages) if (read?.remoteJid) jids.add(read.remoteJid); + if (Array.isArray(data.readMessages)) + for (const read of data.readMessages) if (read?.remoteJid) jids.add(read.remoteJid); const mapped = new Map(); for (const jid of jids) mapped.set(jid, await toChatAddressingJid(jid, resolveLid)); return { @@ -169,7 +170,10 @@ export async function readRequestToChatAddressing( ? { lastMessage: { ...data.lastMessage, - key: { ...data.lastMessage.key, remoteJid: mapped.get(data.lastMessage.key.remoteJid) ?? data.lastMessage.key.remoteJid }, + key: { + ...data.lastMessage.key, + remoteJid: mapped.get(data.lastMessage.key.remoteJid) ?? data.lastMessage.key.remoteJid, + }, }, } : {}), @@ -196,3 +200,115 @@ export async function fillRemoteJidAlt( const user = typeof pn === 'string' ? pn.split('@')[0].split(':')[0] : ''; if (/^\d{8,15}$/.test(user)) key.remoteJidAlt = `${user}@s.whatsapp.net`; } + +/** `5511999999999:12@s.whatsapp.net` and `5511999999999@c.us` → `5511999999999@s.whatsapp.net`; LIDs keep their server. */ +export function normalizeAccountJid(jid: unknown): string | null { + if (typeof jid !== 'string') return null; + const at = jid.lastIndexOf('@'); + if (at <= 0) return null; + const user = jid.slice(0, at).split(':')[0].split('_')[0]; + const server = jid.slice(at + 1) === 'c.us' ? 's.whatsapp.net' : jid.slice(at + 1); + return user && server ? `${user}@${server}` : null; +} + +/** The instance's own account in every addressing mode (phone JID and LID), device suffix stripped. */ +export function ownAccountJids(user: { id?: string | null; lid?: string | null } | null | undefined): Set { + const own = new Set(); + for (const jid of [user?.id, user?.lid]) { + const normalized = normalizeAccountJid(jid); + if (normalized) own.add(normalized); + } + return own; +} + +export type ReceiptUpdateLike = { + key?: { remoteJid?: string | null; id?: string | null } | null; + receipt?: { userJid?: string | null; readTimestamp?: unknown } | null; +}; + +export type GroupReadSelf = { remoteJid: string; ids: string[]; readTimestamp: number }; + +/** + * Baileys reports a group receipt as `message-receipt.update` with + * `receipt.userJid` = whoever read (messages-recv.js handleReceipt: the node's + * `participant`). `key.participant` is that same reader and `key.fromMe` is + * always true there, so neither says who wrote the message. A read by the + * instance's own account is the phone reading the group (read-self); a read by + * anyone else is a participant reading OUR message and must not be reported as ours. + */ +export function collectGroupReadSelf( + updates: ReceiptUpdateLike[], + own: Set, + opts: { groupsIgnore?: boolean } = {}, +): GroupReadSelf[] { + if (opts.groupsIgnore || own.size === 0) return []; + const byGroup = new Map(); + for (const update of updates) { + const remoteJid = update?.key?.remoteJid; + const id = update?.key?.id; + const readTimestamp = update?.receipt?.readTimestamp; + if (typeof remoteJid !== 'string' || !remoteJid.endsWith('@g.us')) continue; + if (typeof id !== 'string' || !id || typeof readTimestamp !== 'number' || !Number.isFinite(readTimestamp)) continue; + const reader = normalizeAccountJid(update?.receipt?.userJid); + if (!reader || !own.has(reader)) continue; + + const entry = byGroup.get(remoteJid) ?? { remoteJid, ids: [], readTimestamp }; + if (!entry.ids.includes(id)) entry.ids.push(id); + entry.readTimestamp = Math.max(entry.readTimestamp, readTimestamp); + byGroup.set(remoteJid, entry); + } + return [...byGroup.values()]; +} + +/** + * One webhook per group is enough: the consumer clears the group's unread up to + * the message it is given. A receipt lists many ids with no order guarantee, so + * when a resolver for message timestamps is available the newest stored message + * wins; otherwise (or on any resolver failure) the last id listed is used. + */ +export async function pickLatestMessageId( + ids: string[], + resolveTimestamps?: (ids: string[]) => Promise>, +): Promise { + const last = ids[ids.length - 1]; + if (ids.length < 2 || !resolveTimestamps) return last; + try { + const candidates = ids.slice(-200); + const stamps = await resolveTimestamps(candidates); + let best = last; + let bestStamp = -Infinity; + for (const id of candidates) { + const raw = stamps?.[id]; + if (raw === null || raw === undefined) continue; // Number(null) would read as timestamp 0 + const stamp = Number(raw); + if (Number.isFinite(stamp) && stamp >= bestStamp) { + best = id; + bestStamp = stamp; + } + } + return bestStamp === -Infinity ? last : best; + } catch { + return last; + } +} + +/** Same shape the `messages.update` handler sends for a status change (consumed as MESSAGES_UPDATE). */ +export function buildGroupReadSelfPayload(input: { + keyId: string; + remoteJid: string; + instanceId: string; + readTimestamp: number; + messageId?: string; +}) { + return { + keyId: input.keyId, + remoteJid: input.remoteJid, + fromMe: false, + participant: undefined, + status: 'READ', + pollUpdates: undefined, + instanceId: input.instanceId, + ...(input.messageId ? { messageId: input.messageId } : {}), + readTimestamp: input.readTimestamp, + }; +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 667ab94768..cffee06732 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -156,12 +156,17 @@ import sharp from 'sharp'; import { PassThrough, Readable } from 'stream'; import { v4 } from 'uuid'; +import { AppStateSelfHealer, createSocketHealTarget, watchAppStateFailures } from './app-state-self-heal'; import { BaileysMessageProcessor } from './baileysMessage.processor'; import { + buildGroupReadSelfPayload, chatReadStateUpdates, + collectGroupReadSelf, fillRemoteJidAlt, - ReadStateValidationError, + ownAccountJids, + pickLatestMessageId, readRequestToChatAddressing, + ReadStateValidationError, resolveReadStateLids, syncMessageReadState, toChatAddressingJid, @@ -270,6 +275,20 @@ export class BaileysStartupService extends ChannelStartupService { private endSession = false; private logBaileys = this.configService.get('LOG').BAILEYS; private eventProcessingQueue: Promise = Promise.resolve(); + // Survives reconnects (the socket is rebuilt each time) so cooldown/attempt limits hold. + private appStateHealer?: AppStateSelfHealer; + + private getAppStateHealer(): AppStateSelfHealer { + this.appStateHealer ??= new AppStateSelfHealer({ + instanceName: this.instance.name, + log: this.logger, + getTarget: () => + this.client + ? createSocketHealTarget(this.client, () => !this.endSession && this.stateConnection?.state === 'open') + : undefined, + }); + return this.appStateHealer; + } // Cache TTL constants (in seconds) private readonly MESSAGE_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes - avoid duplicate message processing @@ -452,6 +471,7 @@ export class BaileysStartupService extends ChannelStartupService { } if (connection === 'close') { + this.appStateHealer?.stop(); const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406]; // If a stream:error 515 (Baileys' "restart needed" handshake) just fired, @@ -685,7 +705,10 @@ export class BaileysStartupService extends ChannelStartupService { const socketConfig: UserFacingSocketConfig = { ...options, version, - logger: P({ level: this.logBaileys }), + // Watches Baileys' warnings for an app-state LTHash mismatch (see app-state-self-heal.ts). + logger: watchAppStateFailures(P({ level: this.logBaileys }), (failure) => { + this.getAppStateHealer().report(failure); + }), printQRInTerminal: false, auth: { creds: this.instance.authState.state.creds, @@ -748,6 +771,7 @@ export class BaileysStartupService extends ChannelStartupService { this.endSession = false; this.client = makeWASocket(socketConfig); + this.getAppStateHealer().start(); if (this.localSettings.wavoipToken && this.localSettings.wavoipToken.length > 0) { useVoiceCallsBaileys(this.localSettings.wavoipToken, this.client, this.connectionStatus.state as any, true); @@ -2003,6 +2027,8 @@ export class BaileysStartupService extends ChannelStartupService { this.updateMessagesReadedByTimestamp(remoteJid, remotesJidMap[remoteJid]), ), ); + + await this.forwardGroupReadSelf(payload, settings); } if (events['presence.update']) { @@ -4864,6 +4890,45 @@ export class BaileysStartupService extends ChannelStartupService { } } + /** + * The phone reading a group arrives as a receipt whose reader is our own account. + * Unlike 1:1 chats (messages.update), Baileys emits no messages.update for it, so + * forward it as the same MESSAGES_UPDATE READ webhook, once per group per batch, + * for the newest message of the batch. + */ + private async forwardGroupReadSelf(updates: MessageUserReceiptUpdate[], settings: any): Promise { + try { + const own = ownAccountJids(this.client?.user); + for (const group of collectGroupReadSelf(updates, own, { groupsIgnore: settings?.groupsIgnore })) { + const keyId = await pickLatestMessageId(group.ids, async (ids) => { + const rows = (await this.prismaRepository.$queryRaw` + SELECT "key"->>'id' AS "id", "messageTimestamp" AS "ts" FROM "Message" + WHERE "instanceId" = ${this.instanceId} + AND "key"->>'remoteJid' = ${group.remoteJid} + AND "key"->>'id' = ANY(${ids}) + `) as { id: string; ts: number | bigint | null }[]; + return Object.fromEntries(rows.map((row) => [row.id, row.ts === null ? null : Number(row.ts)])); + }); + + const dedupKey = `${this.instance.id}_${keyId}_READ_SELF`; + if (await this.baileysCache.get(dedupKey)) continue; + await this.baileysCache.set(dedupKey, true, this.UPDATE_CACHE_TTL_SECONDS); + + this.sendDataWebhook( + Events.MESSAGES_UPDATE, + buildGroupReadSelfPayload({ + keyId, + remoteJid: group.remoteJid, + instanceId: this.instanceId, + readTimestamp: group.readTimestamp, + }), + ); + } + } catch (error) { + this.logger.error(`group read-self forward failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + private async updateMessagesReadedByTimestamp(remoteJid: string, timestamp?: number): Promise { if (timestamp === undefined || timestamp === null) return 0; diff --git a/test/app-state-self-heal.test.ts b/test/app-state-self-heal.test.ts new file mode 100644 index 0000000000..51fb0fb634 --- /dev/null +++ b/test/app-state-self-heal.test.ts @@ -0,0 +1,266 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + AppStateSelfHealer, + createSocketHealTarget, + LTHASH_PATCH_FAILURE_MSG, + parseLtHashPatchFailure, + watchAppStateFailures, + type AppStateCollection, + type AppStateHealTarget, +} from '../src/api/integrations/channel/whatsapp/app-state-self-heal'; + +function fakeTarget(opts: { open?: boolean; failResync?: boolean; versions?: Record } = {}) { + const calls: string[] = []; + const versions: Record = { regular_low: 108, ...(opts.versions ?? {}) }; + const target: AppStateHealTarget & { open: boolean } = { + open: opts.open ?? true, + isOpen() { + return this.open; + }, + async readVersion(name) { + return versions[name]; + }, + async clearVersion(name) { + calls.push(`clear:${name}`); + versions[name] = undefined; // keys.set(null) + }, + async resync(name) { + calls.push(`resync:${name}`); + if (opts.failResync) throw new Error('boom'); + versions[name] = 215; // snapshot restored + }, + }; + return { target, calls, versions }; +} + +function fakeLog() { + const lines: string[] = []; + return { lines, log: { warn: (m: string) => lines.push(`WARN ${m}`), error: (m: string) => lines.push(`ERROR ${m}`) } }; +} + +function healer(target: AppStateHealTarget | undefined, extra: { now?: () => number; instanceName?: string } = {}) { + const { lines, log } = fakeLog(); + const h = new AppStateSelfHealer({ + instanceName: extra.instanceName ?? 'Leandro', + getTarget: () => target, + log, + now: extra.now, + }); + return { h, lines }; +} + +test('only the patch mismatch warning is recognised (not the snapshot variant, not other collections)', () => { + assert.deepEqual(parseLtHashPatchFailure([{ name: 'regular_low', version: 105 }, LTHASH_PATCH_FAILURE_MSG]), { + name: 'regular_low', + version: 105, + }); + assert.equal( + parseLtHashPatchFailure([ + { name: 'regular_low', version: 1 }, + 'LTHash verification failed on snapshot, continuing with partial state', + ]), + null, + ); + assert.equal(parseLtHashPatchFailure([{ name: 'bogus', version: 1 }, LTHASH_PATCH_FAILURE_MSG]), null); + assert.equal(parseLtHashPatchFailure([LTHASH_PATCH_FAILURE_MSG]), null); +}); + +test('logger proxy reports the warning, still forwards it, and covers child loggers and silenced levels', () => { + const forwarded: unknown[][] = []; + const base = { + level: 'error', + warn: (...args: unknown[]) => forwarded.push(args), + info: () => undefined, + child() { + return { warn: (...args: unknown[]) => forwarded.push(['child', ...args]) }; + }, + }; + const seen: unknown[] = []; + const logger = watchAppStateFailures(base, (f) => seen.push(f)); + + logger.warn({ name: 'regular_low', version: 106 }, LTHASH_PATCH_FAILURE_MSG); + logger.warn('unrelated'); + logger.child().warn({ name: 'regular_high', version: 7 }, LTHASH_PATCH_FAILURE_MSG); + + assert.deepEqual(seen, [ + { name: 'regular_low', version: 106 }, + { name: 'regular_high', version: 7 }, + ]); + assert.equal(forwarded.length, 3); + assert.equal(logger.level, 'error'); +}); + +test('a failing callback never breaks logging', () => { + const forwarded: unknown[][] = []; + const logger = watchAppStateFailures({ warn: (...a: unknown[]) => forwarded.push(a) }, () => { + throw new Error('x'); + }); + logger.warn({ name: 'regular_low', version: 1 }, LTHASH_PATCH_FAILURE_MSG); + assert.equal(forwarded.length, 1); +}); + +test('failure clears the stored version, resyncs that collection only, and logs old version and outcome', async () => { + const { target, calls } = fakeTarget(); + const { h, lines } = healer(target); + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + + assert.deepEqual(calls, ['clear:regular_low', 'resync:regular_low']); + assert.match(lines[0], /^WARN app-state self-heal: Leandro regular_low v108 → snapshot$/); + assert.match(lines[1], /^WARN app-state self-heal: Leandro regular_low OK \(snapshot restored at v215\)$/); + assert.deepEqual(h.pendingCollections, []); +}); + +test('report itself never touches the store (it runs inside Baileys\' sync transaction)', () => { + const { target, calls } = fakeTarget(); + const { h } = healer(target); + h.report({ name: 'regular_low', version: 108 }); + assert.deepEqual(calls, []); + assert.deepEqual(h.pendingCollections, ['regular_low']); +}); + +test('nothing runs while the socket is not open; the heal stays queued and runs once it opens', async () => { + const { target, calls } = fakeTarget({ open: false }); + const { h } = healer(target); + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + assert.deepEqual(calls, []); + assert.deepEqual(h.pendingCollections, ['regular_low']); + + target.open = true; + await h.flush(); + assert.deepEqual(calls, ['clear:regular_low', 'resync:regular_low']); +}); + +test('no target (no socket yet) is a no-op', async () => { + const { h } = healer(undefined); + h.report({ name: 'regular_low', version: 1 }); + await h.flush(); + assert.deepEqual(h.pendingCollections, ['regular_low']); +}); + +test('cooldown suppresses a second attempt for the same collection, other collections are independent', async () => { + const { target, calls } = fakeTarget({ versions: { regular_high: 3 } }); + let now = 1_000_000; + const { h, lines } = healer(target, { now: () => now }); + + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + assert.equal(calls.length, 2); + + now += 60_000; // 1 min later: still inside the 10 min cooldown + h.report({ name: 'regular_low', version: 109 }); + await h.flush(); + assert.equal(calls.length, 2); + assert.ok(lines.some((l) => /regular_low skipped \(cooldown/.test(l))); + + h.report({ name: 'regular_high', version: 3 }); + await h.flush(); + assert.deepEqual(calls.slice(2), ['clear:regular_high', 'resync:regular_high']); + + now += 11 * 60_000; // past the cooldown + h.report({ name: 'regular_low', version: 120 }); + await h.flush(); + assert.deepEqual(calls.slice(4), ['clear:regular_low', 'resync:regular_low']); +}); + +test('at most 3 attempts per hour per collection', async () => { + const { target, calls } = fakeTarget(); + let now = 5_000_000; + const { h, lines } = healer(target, { now: () => now }); + + for (let i = 0; i < 3; i++) { + h.report({ name: 'regular_low', version: i }); + await h.flush(); + now += 11 * 60_000; + } + assert.equal(calls.filter((c) => c.startsWith('resync')).length, 3); + + h.report({ name: 'regular_low', version: 99 }); // 4th within the hour + await h.flush(); + assert.equal(calls.filter((c) => c.startsWith('resync')).length, 3); + assert.ok(lines.some((l) => /skipped \(3 attempts in the last hour\)/.test(l))); + + now += 60 * 60_000; // window slides + h.report({ name: 'regular_low', version: 100 }); + await h.flush(); + assert.equal(calls.filter((c) => c.startsWith('resync')).length, 4); +}); + +test('a failing resync is logged as an error, not thrown, and still counts as an attempt', async () => { + const { target, calls } = fakeTarget({ failResync: true }); + let now = 1; + const { h, lines } = healer(target, { now: () => now }); + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + assert.ok(lines.some((l) => /^ERROR app-state self-heal: Leandro regular_low FAILED \(boom\)$/.test(l))); + + now += 1000; + h.report({ name: 'regular_low', version: 109 }); + await h.flush(); + assert.equal(calls.filter((c) => c.startsWith('resync')).length, 1); // cooldown +}); + +test('healers of two instances are fully independent', async () => { + const a = fakeTarget(); + const b = fakeTarget(); + const first = healer(a.target, { instanceName: 'Leandro' }); + const second = healer(b.target, { instanceName: 'Outra' }); + + first.h.report({ name: 'regular_low', version: 108 }); + await first.h.flush(); + await second.h.flush(); + + assert.deepEqual(a.calls, ['clear:regular_low', 'resync:regular_low']); + assert.deepEqual(b.calls, []); + assert.equal(second.lines.length, 0); +}); + +test('proxy + healer: a Baileys warn on the wrapped logger ends in a snapshot resync', async () => { + const { target, calls } = fakeTarget(); + const { h } = healer(target); + const logger = watchAppStateFailures({ warn: () => undefined }, (f) => h.report(f)); + logger.warn({ name: 'regular_low' satisfies AppStateCollection, version: 108 }, LTHASH_PATCH_FAILURE_MSG); + await h.flush(); + assert.deepEqual(calls, ['clear:regular_low', 'resync:regular_low']); +}); + +test('socket target: version is cleared with null through the key store inside a transaction, then resync([name], false)', async () => { + const events: string[] = []; + let inTx = false; + const socket = { + authState: { + creds: { me: { id: '5511999999999:12@s.whatsapp.net' } }, + keys: { + async get() { + return { regular_low: { version: 109 } }; + }, + async set(data: unknown) { + events.push(`set:${JSON.stringify(data)}:tx=${inTx}`); + }, + async transaction(work: () => Promise, key: string) { + events.push(`tx:${key}`); + inTx = true; + try { + return await work(); + } finally { + inTx = false; + } + }, + }, + }, + async resyncAppState(collections: unknown, initial: boolean) { + events.push(`resync:${JSON.stringify(collections)}:${initial}`); + }, + }; + const target = createSocketHealTarget(socket as never, () => true); + assert.equal(await target.readVersion('regular_low'), 109); + await target.clearVersion('regular_low'); + await target.resync('regular_low'); + assert.deepEqual(events, [ + 'tx:5511999999999:12@s.whatsapp.net', + 'set:{"app-state-sync-version":{"regular_low":null}}:tx=true', + 'resync:["regular_low"]:false', + ]); +}); diff --git a/test/read-state.test.ts b/test/read-state.test.ts index 0026e88be7..5eb5528aa7 100644 --- a/test/read-state.test.ts +++ b/test/read-state.test.ts @@ -2,7 +2,12 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import type { ReadMessageDto } from '../src/api/dto/chat.dto'; import { + buildGroupReadSelfPayload, chatReadStateUpdates, + collectGroupReadSelf, + normalizeAccountJid, + ownAccountJids, + pickLatestMessageId, fillRemoteJidAlt, ReadStateValidationError, readRequestToChatAddressing, @@ -261,3 +266,121 @@ test('a legacy receipts-only group request is not forced to carry the participan await syncMessageReadState(client, { readMessages: [noParticipant] }); assert.deepEqual(calls, [{ command: 'readMessages', payload: [noParticipant] }]); }); + +test('participant is preserved for group read receipts and omitted when absent', async () => { + const groupJid = '120363000000000000@g.us'; + const { client, calls } = mockClient(); + await syncMessageReadState(client, { + readMessages: [ + { remoteJid: groupJid, fromMe: false, id: 'g1', participant: '5511888888888@s.whatsapp.net' }, + { remoteJid: groupJid, fromMe: false, id: 'g2' }, + ], + }); + assert.deepEqual(calls, [ + { + command: 'readMessages', + payload: [ + { remoteJid: groupJid, fromMe: false, id: 'g1', participant: '5511888888888@s.whatsapp.net' }, + { remoteJid: groupJid, fromMe: false, id: 'g2' }, + ], + }, + ]); + const schema = new Validator().validate( + { readMessages: [{ remoteJid: groupJid, fromMe: false, id: 'g1', participant: 'x@lid' }] }, + readMessageSchema, + ); + assert.equal(schema.valid, true); +}); + +const group = '120363000000000000@g.us'; +const own = ownAccountJids({ id: '5511999999999:12@s.whatsapp.net', lid: '202860566425607:12@lid' }); +const receiptFor = (id: string, userJid: string, readTimestamp: unknown = 1_790_000_100, remoteJid = group) => ({ + // Shape emitted by Baileys handleReceipt for group receipts: fromMe is always true, participant = reader. + key: { remoteJid, id, fromMe: true, participant: userJid }, + receipt: { userJid, readTimestamp }, +}); + +test('own account is normalised across device suffix, c.us and LID addressing', () => { + assert.equal(normalizeAccountJid('5511999999999:12@s.whatsapp.net'), '5511999999999@s.whatsapp.net'); + assert.equal(normalizeAccountJid('5511999999999@c.us'), '5511999999999@s.whatsapp.net'); + assert.equal(normalizeAccountJid('202860566425607:3@lid'), '202860566425607@lid'); + assert.equal(normalizeAccountJid('nonsense'), null); + assert.deepEqual([...own].sort(), ['202860566425607@lid', '5511999999999@s.whatsapp.net']); +}); + +test('group read-self receipts (phone JID or LID) are collected once per group, newest timestamp wins', () => { + const result = collectGroupReadSelf( + [ + receiptFor('m1', '5511999999999@s.whatsapp.net', 1_790_000_100), + receiptFor('m2', '202860566425607@lid', 1_790_000_200), + receiptFor('m2', '5511999999999@s.whatsapp.net', 1_790_000_150), + receiptFor('o1', '5511999999999@s.whatsapp.net', 1_790_000_300, '120363111111111111@g.us'), + ], + own, + ); + assert.deepEqual(result, [ + { remoteJid: group, ids: ['m1', 'm2'], readTimestamp: 1_790_000_200 }, + { remoteJid: '120363111111111111@g.us', ids: ['o1'], readTimestamp: 1_790_000_300 }, + ]); +}); + +test('a receipt from another participant (someone read OUR message) is never forwarded as our read', () => { + const others = [ + receiptFor('m1', '5511888888888@s.whatsapp.net'), + receiptFor('m2', '999999999999999@lid'), + receiptFor('m3', '5511999999998@s.whatsapp.net'), // similar number, different user + ]; + assert.deepEqual(collectGroupReadSelf(others, own), []); +}); + +test('non-group chats, delivery-only receipts and malformed updates are ignored', () => { + const me = '5511999999999@s.whatsapp.net'; + assert.deepEqual( + collectGroupReadSelf( + [ + receiptFor('d1', me, 1_790_000_100, '5511888888888@s.whatsapp.net'), + receiptFor('d2', me, null), + receiptFor('d3', me, 'soon'), + { key: { remoteJid: group }, receipt: { userJid: me, readTimestamp: 1 } }, + { key: null, receipt: null }, + ], + own, + ), + [], + ); +}); + +test('groupsIgnore disables the forward; unknown own account disables it too', () => { + const updates = [receiptFor('m1', '5511999999999@s.whatsapp.net')]; + assert.equal(collectGroupReadSelf(updates, own, { groupsIgnore: true }).length, 0); + assert.equal(collectGroupReadSelf(updates, own, { groupsIgnore: false }).length, 1); + assert.equal(collectGroupReadSelf(updates, ownAccountJids(undefined)).length, 0); +}); + +test('latest message id: newest stored timestamp wins, falls back to the last id on missing data or errors', async () => { + assert.equal(await pickLatestMessageId(['a']), 'a'); + assert.equal(await pickLatestMessageId(['a', 'b', 'c']), 'c'); + assert.equal(await pickLatestMessageId(['a', 'b', 'c'], async () => ({ a: 30, b: 50, c: 10 })), 'b'); + assert.equal(await pickLatestMessageId(['a', 'b', 'c'], async () => ({ a: null })), 'c'); + assert.equal(await pickLatestMessageId(['a', 'b'], async () => { throw new Error('db down'); }), 'b'); +}); + +test('group read-self payload has the messages.update status shape the CRM parser consumes', () => { + const payload = buildGroupReadSelfPayload({ + keyId: 'm2', + remoteJid: group, + instanceId: 'inst-1', + readTimestamp: 1_790_000_200, + }); + assert.deepEqual(JSON.parse(JSON.stringify(payload)), { + keyId: 'm2', + remoteJid: group, + fromMe: false, + status: 'READ', + instanceId: 'inst-1', + readTimestamp: 1_790_000_200, + }); + // parseEvolutionEvents('messages.update') needs: id (keyId), status READ, remoteJid, fromMe=false. + assert.equal(payload.fromMe, false); + assert.equal(payload.status, 'READ'); +}); From ccc94b258469f88a816d8ffb568d387d9f3c2f2a Mon Sep 17 00:00:00 2001 From: Alpes Digital Date: Thu, 22 Oct 2026 20:38:47 -0300 Subject: [PATCH 19/22] fix(baileys): self-heal must not replay the snapshot as live events nor leave the collection cleared 1. A resync from a cleared version has no minimum version, so Baileys rc14 (processSyncAction, isInitialSync=false, no `conditional`) re-emits every record of the snapshot: stale markChatAsRead -> chats.update unreadCount 0/-1 that the CRM applies as "observed now" (clears chats that still have unread messages / re-marks read ones unread); regular_high replays chats.delete and messages.delete for every chat/message ever deleted. Events chats.update, chats.delete, chats.lock, messages.update and messages.delete emitted from inside the heal resync are now dropped (AsyncLocalStorage scope + guard on socket.ev.emit, so concurrent live events pass); the count is logged. isInitialSync=true was not used: its conditional chat updates stay buffered and swallow later live updates for the same chats. 2. If the heal resync stores nothing (missing key -> parked, giving up) or throws, the previous state is written back (only if nothing was stored meanwhile, same tx key). Leaving it cleared made the next ordinary resync (server_sync / key arrival, isInitialSync=false) replay the whole snapshot unguarded. Tests: 39 node:test pass; tsc clean against baileys 7.0.0-rc13 (local) and 7.0.0-rc14 (production pin) types. Co-Authored-By: Claude Opus 5.5 --- .../channel/whatsapp/app-state-self-heal.ts | 124 ++++++++++++-- .../whatsapp/whatsapp.baileys.service.ts | 9 +- test/app-state-self-heal.test.ts | 162 +++++++++++++++++- 3 files changed, 278 insertions(+), 17 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/app-state-self-heal.ts b/src/api/integrations/channel/whatsapp/app-state-self-heal.ts index 0a1afd848e..afb12307b4 100644 --- a/src/api/integrations/channel/whatsapp/app-state-self-heal.ts +++ b/src/api/integrations/channel/whatsapp/app-state-self-heal.ts @@ -12,9 +12,18 @@ * The cure is the one we applied by hand: drop `app-state-sync-version-` * so the next resync starts at v0 and asks the server for a full snapshot. * + * A resync from v0 has no minimum version, so Baileys (processSyncAction, not an + * initial sync) re-emits EVERY record of the snapshot as a live event: stale + * read/unread markers become chats.update unreadCount 0/-1 "observed now", + * regular_high replays chats.delete / messages.delete for every chat or message + * ever deleted. Those replayed chat/message events are dropped (see + * suppressHealReplay); the state itself is still rebuilt. + * * This module has no Baileys runtime import so it can be unit-tested alone. */ +import { AsyncLocalStorage } from 'node:async_hooks'; + export const APP_STATE_COLLECTIONS = [ 'critical_block', 'critical_unblock_low', @@ -77,13 +86,20 @@ export function watchAppStateFailures(logger: T, onFailure: (f }); } +/** Stored LTHash state of a collection (opaque here: only `version` is read, the rest is written back as is). */ +export type StoredAppState = { version?: number }; + export interface AppStateHealTarget { /** True only while the socket is open; the heal never runs otherwise. */ isOpen(): boolean; /** Stored version of the collection, undefined when nothing is stored. */ readVersion(name: AppStateCollection): Promise; + /** Stored state of the collection, undefined when nothing is stored. */ + readState(name: AppStateCollection): Promise; /** Removes the stored version (through the socket's key store, so its in-memory cache is cleared too). */ clearVersion(name: AppStateCollection): Promise; + /** Puts `state` back only if nothing is stored now; true when it did. */ + restoreIfEmpty(name: AppStateCollection, state: StoredAppState): Promise; /** Runs Baileys' resyncAppState([name], false). */ resync(name: AppStateCollection): Promise; } @@ -96,8 +112,8 @@ export interface AppStateSocketLike { get( type: 'app-state-sync-version', ids: string[], - ): MaybePromise>; - set(data: { 'app-state-sync-version': Record }): MaybePromise; + ): MaybePromise>; + set(data: { 'app-state-sync-version'?: Record }): MaybePromise; transaction(work: () => Promise, key: string): Promise; }; }; @@ -114,18 +130,81 @@ type MaybePromise = T | PromiseLike; */ export function createSocketHealTarget(socket: AppStateSocketLike, isOpen: () => boolean): AppStateHealTarget { const { keys, creds } = socket.authState; + const txKey = () => creds?.me?.id || 'resync-app-state'; + const read = async (name: AppStateCollection) => + (await keys.get('app-state-sync-version', [name]))?.[name] ?? undefined; return { isOpen, - readVersion: async (name) => (await keys.get('app-state-sync-version', [name]))?.[name]?.version, + readVersion: async (name) => (await read(name))?.version, + readState: read, clearVersion: async (name) => { await keys.transaction(async () => { await keys.set({ 'app-state-sync-version': { [name]: null } }); - }, creds?.me?.id || 'resync-app-state'); + }, txKey()); }, + restoreIfEmpty: (name, state) => + keys.transaction(async () => { + if (await read(name)) return false; + await keys.set({ 'app-state-sync-version': { [name]: state } }); + return true; + }, txKey()), resync: (name) => socket.resyncAppState([name], false), }; } +/** + * Chat/message events a snapshot replay would re-emit as if they were new. + * creds.update, contacts, labels and settings are left alone (current state, + * idempotent for consumers). + */ +export const HEAL_REPLAY_SUPPRESSED_EVENTS: ReadonlySet = new Set([ + 'chats.update', + 'chats.delete', + 'chats.lock', + 'messages.update', + 'messages.delete', +]); + +type HealReplayScope = { active: boolean; suppressed: number }; +const healReplayScope = new AsyncLocalStorage(); +const REPLAY_GUARD = Symbol.for('evolution.appStateHealReplayGuard'); + +type EmitterLike = { emit: (event: any, ...args: any[]) => any }; + +/** + * Drops the events listed above when they are emitted from inside a self-heal + * resync (tracked with AsyncLocalStorage, so live events emitted concurrently by + * other nodes pass untouched). Install once per socket on `socket.ev`: Baileys' + * processSyncAction emits through that same object. + */ +export function suppressHealReplay(ev: T): T { + const marked = ev as T & { [REPLAY_GUARD]?: boolean }; + if (marked[REPLAY_GUARD]) return ev; + const emit = ev.emit; + ev.emit = function (this: unknown, event: unknown, ...args: unknown[]) { + const scope = healReplayScope.getStore(); + if (scope?.active && typeof event === 'string' && HEAL_REPLAY_SUPPRESSED_EVENTS.has(event)) { + scope.suppressed += 1; + return false; + } + return emit.call(this ?? ev, event, ...args); + } as T['emit']; + marked[REPLAY_GUARD] = true; + return ev; +} + +/** Runs `work` as a self-heal replay; returns how many events were suppressed. */ +export async function runAsHealReplay(work: () => Promise): Promise { + const scope: HealReplayScope = { active: true, suppressed: 0 }; + try { + await healReplayScope.run(scope, work); + } finally { + // Timers created inside (Baileys' buffer flush) inherit the store; they must not suppress anything. + scope.active = false; + } + return scope.suppressed; +} + type HealLog = { warn: (msg: string) => void; error: (msg: string) => void }; export interface AppStateHealerOptions { @@ -226,21 +305,42 @@ export class AppStateSelfHealer { recent.push(now); this.attempts.set(name, recent); + let previous: StoredAppState | undefined; + let cleared = false; + let failure: string | undefined; try { - const oldVersion = (await target.readVersion(name)) ?? reportedVersion; + previous = await target.readState(name); + const oldVersion = previous?.version ?? reportedVersion; log.warn(`app-state self-heal: ${instanceName} ${name} v${oldVersion ?? '?'} → snapshot`); await target.clearVersion(name); - await target.resync(name); + cleared = true; + const suppressed = await runAsHealReplay(() => target.resync(name)); + if (suppressed > 0) { + log.warn(`app-state self-heal: ${instanceName} ${name} suppressed ${suppressed} replayed chat/message events`); + } const newVersion = await target.readVersion(name); - if (newVersion === undefined) { - log.error(`app-state self-heal: ${instanceName} ${name} FAILED (no state stored after resync)`); - } else { + if (newVersion !== undefined) { log.warn(`app-state self-heal: ${instanceName} ${name} OK (snapshot restored at v${newVersion})`); + return; } + failure = 'no state stored after resync'; } catch (error) { - log.error( - `app-state self-heal: ${instanceName} ${name} FAILED (${error instanceof Error ? error.message : String(error)})`, - ); + failure = error instanceof Error ? error.message : String(error); + } + + // Leaving the collection cleared would let the next ordinary resync replay the + // whole snapshot unguarded; the previous (mismatching) state is the lesser evil. + let restored = false; + if (cleared && previous) { + try { + restored = await target.restoreIfEmpty(name, previous); + } catch { + // reported below as not restored + } } + log.error( + `app-state self-heal: ${instanceName} ${name} FAILED (${failure})` + + (restored ? `; previous state v${previous?.version ?? '?'} restored` : ''), + ); } } diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index cffee06732..5de132481e 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -156,7 +156,12 @@ import sharp from 'sharp'; import { PassThrough, Readable } from 'stream'; import { v4 } from 'uuid'; -import { AppStateSelfHealer, createSocketHealTarget, watchAppStateFailures } from './app-state-self-heal'; +import { + AppStateSelfHealer, + createSocketHealTarget, + suppressHealReplay, + watchAppStateFailures, +} from './app-state-self-heal'; import { BaileysMessageProcessor } from './baileysMessage.processor'; import { buildGroupReadSelfPayload, @@ -771,6 +776,8 @@ export class BaileysStartupService extends ChannelStartupService { this.endSession = false; this.client = makeWASocket(socketConfig); + // A self-heal snapshot replays every record as a live event (stale reads, old deletes): drop those. + suppressHealReplay(this.client.ev); this.getAppStateHealer().start(); if (this.localSettings.wavoipToken && this.localSettings.wavoipToken.length > 0) { diff --git a/test/app-state-self-heal.test.ts b/test/app-state-self-heal.test.ts index 51fb0fb634..06a12e7f8d 100644 --- a/test/app-state-self-heal.test.ts +++ b/test/app-state-self-heal.test.ts @@ -1,16 +1,28 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { EventEmitter } from 'node:events'; + import { AppStateSelfHealer, createSocketHealTarget, LTHASH_PATCH_FAILURE_MSG, parseLtHashPatchFailure, + runAsHealReplay, + suppressHealReplay, watchAppStateFailures, type AppStateCollection, type AppStateHealTarget, } from '../src/api/integrations/channel/whatsapp/app-state-self-heal'; -function fakeTarget(opts: { open?: boolean; failResync?: boolean; versions?: Record } = {}) { +function fakeTarget( + opts: { + open?: boolean; + failResync?: boolean; + storeNothing?: boolean; + versions?: Record; + onResync?: () => void; + } = {}, +) { const calls: string[] = []; const versions: Record = { regular_low: 108, ...(opts.versions ?? {}) }; const target: AppStateHealTarget & { open: boolean } = { @@ -21,14 +33,24 @@ function fakeTarget(opts: { open?: boolean; failResync?: boolean; versions?: Rec async readVersion(name) { return versions[name]; }, + async readState(name) { + return versions[name] === undefined ? undefined : { version: versions[name] }; + }, async clearVersion(name) { calls.push(`clear:${name}`); versions[name] = undefined; // keys.set(null) }, + async restoreIfEmpty(name, state) { + if (versions[name] !== undefined) return false; + calls.push(`restore:${name}:v${state.version}`); + versions[name] = state.version; + return true; + }, async resync(name) { calls.push(`resync:${name}`); + opts.onResync?.(); if (opts.failResync) throw new Error('boom'); - versions[name] = 215; // snapshot restored + if (!opts.storeNothing) versions[name] = 215; // snapshot restored }, }; return { target, calls, versions }; @@ -189,12 +211,18 @@ test('at most 3 attempts per hour per collection', async () => { }); test('a failing resync is logged as an error, not thrown, and still counts as an attempt', async () => { - const { target, calls } = fakeTarget({ failResync: true }); + const { target, calls, versions } = fakeTarget({ failResync: true }); let now = 1; const { h, lines } = healer(target, { now: () => now }); h.report({ name: 'regular_low', version: 108 }); await h.flush(); - assert.ok(lines.some((l) => /^ERROR app-state self-heal: Leandro regular_low FAILED \(boom\)$/.test(l))); + assert.ok( + lines.some((l) => + /^ERROR app-state self-heal: Leandro regular_low FAILED \(boom\); previous state v108 restored$/.test(l), + ), + ); + // Never left cleared: an ordinary resync from v0 would replay the whole snapshot unguarded. + assert.equal(versions.regular_low, 108); now += 1000; h.report({ name: 'regular_low', version: 109 }); @@ -202,6 +230,96 @@ test('a failing resync is logged as an error, not thrown, and still counts as an assert.equal(calls.filter((c) => c.startsWith('resync')).length, 1); // cooldown }); +test('a resync that stores nothing (e.g. collection parked on a missing key) restores the previous state', async () => { + const { target, calls, versions } = fakeTarget({ storeNothing: true }); + const { h, lines } = healer(target); + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + assert.deepEqual(calls, ['clear:regular_low', 'resync:regular_low', 'restore:regular_low:v108']); + assert.equal(versions.regular_low, 108); + assert.ok(lines.some((l) => /FAILED \(no state stored after resync\); previous state v108 restored$/.test(l))); +}); + +test('the previous state is not restored over a state another resync stored meanwhile', async () => { + const { target, calls, versions } = fakeTarget({ failResync: true }); + target.resync = async (name) => { + calls.push(`resync:${name}`); + versions[name] = 300; // an ordinary resync won the mutex and stored a fresh snapshot + throw new Error('timed out'); + }; + const { h, lines } = healer(target); + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + assert.equal(versions.regular_low, 300); + assert.ok(!calls.some((c) => c.startsWith('restore'))); + assert.ok(lines.some((l) => /FAILED \(timed out\)$/.test(l))); +}); + +test('replay guard: chat/message events emitted inside a heal resync are dropped, live ones pass', async () => { + const delivered: string[] = []; + const ev = suppressHealReplay({ + emit(event: string, data: unknown) { + delivered.push(`${event}:${JSON.stringify(data)}`); + return true; + }, + }); + suppressHealReplay(ev); // idempotent: a second install must not double-wrap + + let timerEmit: Promise | undefined; + const suppressed = await runAsHealReplay(async () => { + await Promise.resolve(); + ev.emit('chats.update', [{ id: 'g@g.us', unreadCount: 0 }]); // stale read marker + ev.emit('chats.delete', ['old@s.whatsapp.net']); // chat deleted months ago + ev.emit('messages.delete', { keys: [] }); + ev.emit('creds.update', { me: { name: 'x' } }); // not a chat/message replay + // A timer started inside (Baileys' buffer flush) runs after the heal: not suppressed. + timerEmit = new Promise((resolve) => + setTimeout(() => { + ev.emit('chats.update', [{ id: 'after@s.whatsapp.net', unreadCount: 1 }]); + resolve(); + }, 5), + ); + }); + ev.emit('chats.update', [{ id: 'live@s.whatsapp.net', unreadCount: 0 }]); // outside the heal + await timerEmit; + + assert.equal(suppressed, 3); + assert.deepEqual(delivered, [ + 'creds.update:{"me":{"name":"x"}}', + 'chats.update:[{"id":"live@s.whatsapp.net","unreadCount":0}]', + 'chats.update:[{"id":"after@s.whatsapp.net","unreadCount":1}]', + ]); +}); + +test('replay guard on a real EventEmitter keeps `this` and listeners working', async () => { + const emitter = new EventEmitter(); + const got: string[] = []; + emitter.on('chats.update', (d) => got.push(JSON.stringify(d))); + suppressHealReplay(emitter); + await runAsHealReplay(async () => { + emitter.emit('chats.update', [{ id: 'replayed' }]); + }); + emitter.emit('chats.update', [{ id: 'live' }]); + assert.deepEqual(got, ['[{"id":"live"}]']); +}); + +test('heal: replayed events of the snapshot are suppressed and counted in the log', async () => { + const delivered: string[] = []; + const ev = suppressHealReplay({ emit: (event: string) => delivered.push(event) > 0 }); + const { target } = fakeTarget({ + onResync: () => { + ev.emit('chats.update', [{ id: 'a', unreadCount: 0 }]); + ev.emit('chats.update', [{ id: 'b', unreadCount: -1 }]); + }, + }); + const { h, lines } = healer(target); + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + assert.deepEqual(delivered, []); + assert.ok(lines.some((l) => /regular_low suppressed 2 replayed chat\/message events$/.test(l))); + assert.ok(lines.some((l) => /regular_low OK \(snapshot restored at v215\)$/.test(l))); +}); + test('healers of two instances are fully independent', async () => { const a = fakeTarget(); const b = fakeTarget(); @@ -264,3 +382,39 @@ test('socket target: version is cleared with null through the key store inside a 'resync:["regular_low"]:false', ]); }); + +test('socket target: restoreIfEmpty writes back inside the same-key transaction only when nothing is stored', async () => { + const store: Record = { regular_low: { version: 109, hash: 'h' } }; + const events: string[] = []; + const socket = { + authState: { + creds: { me: { id: '5511999999999:12@s.whatsapp.net' } }, + keys: { + async get(_type: string, ids: string[]) { + return Object.fromEntries(ids.map((id) => [id, store[id] ?? null])); + }, + async set(data: { 'app-state-sync-version': Record }) { + Object.assign(store, data['app-state-sync-version']); + events.push(`set:${JSON.stringify(data)}`); + }, + async transaction(work: () => Promise, key: string) { + events.push(`tx:${key}`); + return work(); + }, + }, + }, + async resyncAppState() {}, + }; + const target = createSocketHealTarget(socket as never, () => true); + const previous = await target.readState('regular_low'); + assert.deepEqual(previous, { version: 109, hash: 'h' }); + + assert.equal(await target.restoreIfEmpty('regular_low', { version: 1 }), false); // something stored: untouched + assert.deepEqual(store.regular_low, { version: 109, hash: 'h' }); + + await target.clearVersion('regular_low'); + assert.equal(await target.readState('regular_low'), undefined); + assert.equal(await target.restoreIfEmpty('regular_low', previous!), true); + assert.deepEqual(store.regular_low, { version: 109, hash: 'h' }); + assert.ok(events.every((e) => !e.startsWith('tx:') || e === 'tx:5511999999999:12@s.whatsapp.net')); +}); From 5ec8e75560a530d63ab03fa5cd7da590b2d7fa2b Mon Sep 17 00:00:00 2001 From: Alpes Digital Date: Thu, 22 Oct 2026 22:16:06 -0300 Subject: [PATCH 20/22] fix(baileys): ack the top-level stanza (drop every ~50 min) WhatsApp now delivers some accounts' contact statuses as a stanza instead of . Baileys 7.0.0-rc14/master registers no CB:status handler, so it is never acked; ~50 min after the connection opens the server closes the stream with (Stream Errored (ack) -> badSession 500), redelivers the same pending stanza on reconnect, and the drop repeats. readStatus/shouldIgnoreJid do not matter: the stanza never reaches handleMessage. Register ws.on('CB:status') after makeWASocket and send a plain ack (class = node tag, id, to, participant/recipient/type, no error attribute, same as Baileys' buildAckStanza; a NACK would keep it pending). Same fix as upstream WhiskeySockets/Baileys#2858 / PR #2866 and whatsmeow a238105488; remove once a Baileys release includes it. Co-Authored-By: Claude Opus 5.5 --- .../channel/whatsapp/status-ack.ts | 63 +++++++++++++ .../whatsapp/whatsapp.baileys.service.ts | 3 + test/status-ack.test.ts | 92 +++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 src/api/integrations/channel/whatsapp/status-ack.ts create mode 100644 test/status-ack.test.ts diff --git a/src/api/integrations/channel/whatsapp/status-ack.ts b/src/api/integrations/channel/whatsapp/status-ack.ts new file mode 100644 index 0000000000..a3a1729b54 --- /dev/null +++ b/src/api/integrations/channel/whatsapp/status-ack.ts @@ -0,0 +1,63 @@ +/** + * Ack for the top-level `` stanza. + * + * WhatsApp now delivers some accounts' contact status updates (stories) as a + * `` stanza instead of + * ``. Baileys (7.0.0-rc14 and master) registers + * no handler for it (only message, call, receipt, notification and + * `ack,class:message`), so it is never acked. About 50 minutes after the + * connection opens the server closes the stream naming the ack it is missing: + * + * + * + * Baileys maps it to "Stream Errored (ack)" / badSession (500), and on the + * reconnect the server redelivers the same pending stanza offline: the drop + * repeats every ~50 minutes. It does not depend on readStatus/shouldIgnoreJid, + * the stanza never reaches handleMessage. + * + * Upstream: WhiskeySockets/Baileys#2858 (issue), #2866 (open PR, same fix); + * reference implementation tulir/whatsmeow@a238105488 ("handle nodes"). + * + * The ack is a plain ack (no `error` attribute): a NACK keeps the stanza pending + * on the server (see Baileys#2779). It mirrors Baileys' own buildAckStanza. + * Drop this module once a Baileys release registers `CB:status` itself. + */ + +export interface StatusAckNode { + tag: string; + attrs: Record; +} + +export interface StatusAckStanza { + tag: 'ack'; + attrs: Record; +} + +export interface StatusAckSocket { + ws: { on(event: string, listener: (node: StatusAckNode) => void): unknown }; + sendNode(stanza: StatusAckStanza): Promise; +} + +/** Same shape as Baileys' buildAckStanza for a node that is not `message`. Null when the node cannot be acked. */ +export function buildStatusAckStanza(node: StatusAckNode): StatusAckStanza | null { + const { attrs } = node ?? {}; + if (!attrs?.id || !attrs.from) return null; + + const stanza: StatusAckStanza = { + tag: 'ack', + attrs: { id: attrs.id, to: attrs.from, class: node.tag }, + }; + if (attrs.participant) stanza.attrs.participant = attrs.participant; + if (attrs.recipient) stanza.attrs.recipient = attrs.recipient; + if (attrs.type) stanza.attrs.type = attrs.type; + return stanza; +} + +/** Acks every incoming `` stanza, live or offline. Never throws. */ +export function attachStatusAck(socket: StatusAckSocket, onError: (error: unknown) => void): void { + socket.ws.on('CB:status', (node) => { + const stanza = buildStatusAckStanza(node); + if (!stanza) return; + socket.sendNode(stanza).catch(onError); + }); +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 5de132481e..09e4ac33a8 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -176,6 +176,7 @@ import { syncMessageReadState, toChatAddressingJid, } from './read-state'; +import { attachStatusAck } from './status-ack'; import { useVoiceCallsBaileys } from './voiceCalls/useVoiceCallsBaileys'; export interface ExtendedIMessageKey extends proto.IMessageKey { @@ -779,6 +780,8 @@ export class BaileysStartupService extends ChannelStartupService { // A self-heal snapshot replays every record as a live event (stale reads, old deletes): drop those. suppressHealReplay(this.client.ev); this.getAppStateHealer().start(); + // Baileys never acks the top-level stanza: the server drops the stream ~50 min later (Baileys#2858). + attachStatusAck(this.client as any, (error) => this.logger.warn(`status ack failed: ${String(error)}`)); if (this.localSettings.wavoipToken && this.localSettings.wavoipToken.length > 0) { useVoiceCallsBaileys(this.localSettings.wavoipToken, this.client, this.connectionStatus.state as any, true); diff --git a/test/status-ack.test.ts b/test/status-ack.test.ts new file mode 100644 index 0000000000..d8a3f40213 --- /dev/null +++ b/test/status-ack.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { test } from 'node:test'; +import { + attachStatusAck, + buildStatusAckStanza, + type StatusAckNode, + type StatusAckStanza, +} from '../src/api/integrations/channel/whatsapp/status-ack'; + +const statusNode: StatusAckNode = { + tag: 'status', + attrs: { id: '3A3ED33A9E8247EFA6B71DC531204430', from: '5511999999999@s.whatsapp.net', type: 'media', offline: '0' }, +}; + +function fakeSocket(sendNode: (s: StatusAckStanza) => Promise = async () => undefined) { + const ws = new EventEmitter(); + const sent: StatusAckStanza[] = []; + return { + ws, + sent, + socket: { + ws: { on: (event: string, listener: (node: StatusAckNode) => void) => ws.on(event, listener) }, + sendNode: async (s: StatusAckStanza) => { + sent.push(s); + return sendNode(s); + }, + }, + }; +} + +test('builds the ack the server asked for: class=status, same id and type, no error attribute', () => { + assert.deepEqual(buildStatusAckStanza(statusNode), { + tag: 'ack', + attrs: { + id: '3A3ED33A9E8247EFA6B71DC531204430', + to: '5511999999999@s.whatsapp.net', + class: 'status', + type: 'media', + }, + }); +}); + +test('keeps participant and recipient when present, and omits type when absent', () => { + const stanza = buildStatusAckStanza({ + tag: 'status', + attrs: { id: 'X1', from: 'status@broadcast', participant: '1@lid', recipient: '2@s.whatsapp.net' }, + }); + assert.deepEqual(stanza?.attrs, { + id: 'X1', + to: 'status@broadcast', + class: 'status', + participant: '1@lid', + recipient: '2@s.whatsapp.net', + }); + assert.equal('error' in (stanza?.attrs ?? {}), false); +}); + +test('a node without id or from cannot be acked', () => { + assert.equal(buildStatusAckStanza({ tag: 'status', attrs: { from: 'a@s.whatsapp.net' } }), null); + assert.equal(buildStatusAckStanza({ tag: 'status', attrs: { id: 'X' } }), null); +}); + +test('CB:status is acked, live and offline; other events are not touched', () => { + const { ws, sent, socket } = fakeSocket(); + attachStatusAck(socket, () => assert.fail('no error expected')); + + ws.emit('CB:status', statusNode); + ws.emit('CB:status', { ...statusNode, attrs: { ...statusNode.attrs, id: 'OFF1', offline: 'true', type: 'text' } }); + ws.emit('CB:message', statusNode); + + assert.deepEqual( + sent.map((s) => [s.attrs.id, s.attrs.class, s.attrs.type]), + [ + ['3A3ED33A9E8247EFA6B71DC531204430', 'status', 'media'], + ['OFF1', 'status', 'text'], + ], + ); +}); + +test('a failing send is reported and never throws out of the listener', async () => { + const boom = new Error('socket closed'); + const { ws, socket } = fakeSocket(async () => { + throw boom; + }); + const errors: unknown[] = []; + attachStatusAck(socket, (e) => errors.push(e)); + + assert.doesNotThrow(() => ws.emit('CB:status', statusNode)); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(errors, [boom]); +}); From 3d1597b183de0aaaded0a67d806957645cc15b20 Mon Sep 17 00:00:00 2001 From: Alpes Digital Date: Thu, 22 Oct 2026 22:22:45 -0300 Subject: [PATCH 21/22] fix(baileys): status ack never double-acks nor throws into the frame loop - attachStatusAck skips (returns false) when the socket already has a CB:status listener, i.e. a Baileys release that ships #2866 and acks the stanza itself; the service logs that the module can be dropped. - Baileys' sendNode encodes synchronously before returning the send promise, so a sync throw is now caught and reported instead of escaping ws.emit. Verified against a real 7.0.0-rc14 socket: no CB:status listener of its own, the ack goes out as , identical to the PR #2866 test and whatsmeow sendAck. Co-Authored-By: Claude Opus 5.5 --- .../channel/whatsapp/status-ack.ts | 28 +++++++++++++++---- .../whatsapp/whatsapp.baileys.service.ts | 4 ++- test/status-ack.test.ts | 28 +++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/status-ack.ts b/src/api/integrations/channel/whatsapp/status-ack.ts index a3a1729b54..5fedccaf9e 100644 --- a/src/api/integrations/channel/whatsapp/status-ack.ts +++ b/src/api/integrations/channel/whatsapp/status-ack.ts @@ -34,7 +34,10 @@ export interface StatusAckStanza { } export interface StatusAckSocket { - ws: { on(event: string, listener: (node: StatusAckNode) => void): unknown }; + ws: { + on(event: string, listener: (node: StatusAckNode) => void): unknown; + listenerCount?(event: string): number; + }; sendNode(stanza: StatusAckStanza): Promise; } @@ -53,11 +56,24 @@ export function buildStatusAckStanza(node: StatusAckNode): StatusAckStanza | nul return stanza; } -/** Acks every incoming `` stanza, live or offline. Never throws. */ -export function attachStatusAck(socket: StatusAckSocket, onError: (error: unknown) => void): void { +/** + * Acks every incoming `` stanza, live or offline. Never throws. + * + * Call it right after makeWASocket. It does nothing and returns false when the socket + * already has a `CB:status` listener: a Baileys release with #2866 registers one inside + * makeWASocket and acks the stanza itself, so registering ours would ack it twice. + */ +export function attachStatusAck(socket: StatusAckSocket, onError: (error: unknown) => void): boolean { + if (socket.ws.listenerCount?.('CB:status')) return false; socket.ws.on('CB:status', (node) => { - const stanza = buildStatusAckStanza(node); - if (!stanza) return; - socket.sendNode(stanza).catch(onError); + try { + const stanza = buildStatusAckStanza(node); + if (!stanza) return; + // Baileys' sendNode encodes synchronously before returning the send promise. + socket.sendNode(stanza).catch(onError); + } catch (error) { + onError(error); + } }); + return true; } diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 09e4ac33a8..7bdafba408 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -781,7 +781,9 @@ export class BaileysStartupService extends ChannelStartupService { suppressHealReplay(this.client.ev); this.getAppStateHealer().start(); // Baileys never acks the top-level stanza: the server drops the stream ~50 min later (Baileys#2858). - attachStatusAck(this.client as any, (error) => this.logger.warn(`status ack failed: ${String(error)}`)); + if (!attachStatusAck(this.client as any, (error) => this.logger.warn(`status ack failed: ${String(error)}`))) { + this.logger.info('Baileys already handles CB:status: status-ack.ts is no longer needed'); + } if (this.localSettings.wavoipToken && this.localSettings.wavoipToken.length > 0) { useVoiceCallsBaileys(this.localSettings.wavoipToken, this.client, this.connectionStatus.state as any, true); diff --git a/test/status-ack.test.ts b/test/status-ack.test.ts index d8a3f40213..6307559c30 100644 --- a/test/status-ack.test.ts +++ b/test/status-ack.test.ts @@ -90,3 +90,31 @@ test('a failing send is reported and never throws out of the listener', async () await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(errors, [boom]); }); + +test('a sendNode that throws synchronously (Baileys encodes before the promise) is reported too', () => { + const ws = new EventEmitter(); + const boom = new Error('encode failed'); + const errors: unknown[] = []; + const socket = { + ws, + sendNode: (() => { + throw boom; + }) as (s: StatusAckStanza) => Promise, + }; + assert.equal(attachStatusAck(socket, (e) => errors.push(e)), true); + + assert.doesNotThrow(() => ws.emit('CB:status', statusNode)); + assert.deepEqual(errors, [boom]); +}); + +test('does not register a second ack when Baileys already listens to CB:status (no double ack)', () => { + const ws = new EventEmitter(); + const sent: StatusAckStanza[] = []; + ws.on('CB:status', () => undefined); // what a Baileys release with #2866 registers inside makeWASocket + const socket = { ws, sendNode: async (s: StatusAckStanza) => sent.push(s) }; + + assert.equal(attachStatusAck(socket, () => assert.fail('no error expected')), false); + ws.emit('CB:status', statusNode); + assert.equal(ws.listenerCount('CB:status'), 1); + assert.deepEqual(sent, []); +}); From 31ccfc7a5f7c512395f51b281643c6f831f91c8d Mon Sep 17 00:00:00 2001 From: Alpes Digital Date: Fri, 23 Oct 2026 13:42:24 -0300 Subject: [PATCH 22/22] feat(chat): opt-in LID in POST /chat/whatsappNumbers (includeLid) Baileys onWhatsApp only uses the USync contact protocol, so the endpoint never returned the account LID. With `includeLid: true`, confirmed numbers (exists) are resolved in ONE lidMapping.getLIDsForPNs call (cache -> auth keys -> USync LID query) and each item gets `lid: "@lid"` or null. - default response unchanged when the flag is absent - lookup is keyed by the jid WhatsApp confirmed (9th-digit variants) - capped at 50 numbers per request; the rest return null with a WARN - a failing lookup logs WARN and returns lid null, never fails the request - runs after the IsOnWhatsapp cache write; that column keeps its 'lid' marker Co-Authored-By: Claude Opus 5.5 --- src/api/dto/chat.dto.ts | 2 + .../channel/whatsapp/numbers-lid.ts | 98 ++++++++++++ .../whatsapp/whatsapp.baileys.service.ts | 9 ++ src/validate/chat.schema.ts | 4 + test/numbers-lid.test.ts | 143 ++++++++++++++++++ 5 files changed, 256 insertions(+) create mode 100644 src/api/integrations/channel/whatsapp/numbers-lid.ts create mode 100644 test/numbers-lid.test.ts diff --git a/src/api/dto/chat.dto.ts b/src/api/dto/chat.dto.ts index 7de5e7f30d..61301b0214 100644 --- a/src/api/dto/chat.dto.ts +++ b/src/api/dto/chat.dto.ts @@ -24,6 +24,8 @@ export class getBase64FromMediaMessageDto { export class WhatsAppNumberDto { numbers: string[]; + /** Opt-in: add each confirmed number's WhatsApp LID (`@lid` or null) to the response. */ + includeLid?: boolean; } export class NumberDto { diff --git a/src/api/integrations/channel/whatsapp/numbers-lid.ts b/src/api/integrations/channel/whatsapp/numbers-lid.ts new file mode 100644 index 0000000000..b447d02889 --- /dev/null +++ b/src/api/integrations/channel/whatsapp/numbers-lid.ts @@ -0,0 +1,98 @@ +/** + * Opt-in LID enrichment for POST /chat/whatsappNumbers (`includeLid: true`). + * + * Kept free of Baileys/Prisma imports so it can be tested in isolation. + */ + +/** Max numbers resolved per request. Each miss costs a USync LID query; WhatsApp may rate-limit bursts. */ +export const WHATSAPP_NUMBERS_LID_LIMIT = 50; + +export type LidPair = { lid?: string | null; pn?: string | null }; + +/** Shape of `sock.signalRepository.lidMapping.getLIDsForPNs` (one batched call, cache -> auth keys -> USync). */ +export type LidBatchLookup = (pns: string[]) => Promise; + +type LidCandidate = { jid: string; exists: boolean }; + +/** `5511999999999:12@s.whatsapp.net` -> `5511999999999`; anything that is not a phone user JID -> null. */ +function phoneUser(jid: unknown): string | null { + if (typeof jid !== 'string' || !jid.endsWith('@s.whatsapp.net')) return null; + const user = jid.split('@')[0].split(':')[0]; + return /^\d{5,20}$/.test(user) ? user : null; +} + +/** `12345:3@lid` -> `12345@lid`. Hosted LIDs and malformed values are rejected. */ +function normalizeLid(lid: unknown): string | null { + if (typeof lid !== 'string' || !lid.endsWith('@lid')) return null; + const user = lid.split('@')[0].split(':')[0]; + return /^\d{5,25}$/.test(user) ? `${user}@lid` : null; +} + +/** + * Resolves the LID of every confirmed (`exists`) phone JID with ONE batched lookup. + * The map key is the JID exactly as WhatsApp confirmed it (so a Brazilian number + * with or without the 9th digit gets the LID of the account that actually exists). + * Never throws: a failed lookup or an unresolved number yields `null`. + */ +export async function resolveLidsForConfirmedJids( + items: LidCandidate[], + lookup: LidBatchLookup, + opts: { limit?: number; warn?: (message: string) => void } = {}, +): Promise> { + const limit = opts.limit ?? WHATSAPP_NUMBERS_LID_LIMIT; + const warn = opts.warn ?? (() => undefined); + const result = new Map(); + + const jidByUser = new Map(); + for (const item of items) { + if (!item?.exists) continue; + // Already addressed by LID: nothing to look up. + if (typeof item.jid === 'string' && item.jid.endsWith('@lid')) { + result.set(item.jid, normalizeLid(item.jid)); + continue; + } + const user = phoneUser(item.jid); + if (user && !jidByUser.has(user)) jidByUser.set(user, item.jid); + result.set(item.jid, null); + } + + const users = [...jidByUser.keys()]; + if (users.length > limit) { + warn(`includeLid: ${users.length} numbers requested, resolving only the first ${limit}; the rest return lid null`); + } + const batch = users.slice(0, limit).map((user) => `${user}@s.whatsapp.net`); + if (batch.length === 0) return result; + + let pairs: LidPair[] | null | undefined; + try { + pairs = await lookup(batch); + } catch (error) { + warn(`includeLid: LID lookup failed for ${batch.length} numbers, returning lid null: ${String(error)}`); + return result; + } + + const lidByUser = new Map(); + for (const pair of Array.isArray(pairs) ? pairs : []) { + const user = phoneUser(pair?.pn); + const lid = normalizeLid(pair?.lid); + if (user && lid && !lidByUser.has(user)) lidByUser.set(user, lid); + } + + for (const item of items) { + if (!item?.exists) continue; + const user = phoneUser(item.jid); + const lid = user ? lidByUser.get(user) : undefined; + if (lid) result.set(item.jid, lid); + } + return result; +} + +/** Adds `lid` to every item. Items that are not confirmed phone numbers (groups, missing) get `lid: null`. */ +export async function attachLids( + items: T[], + lookup: LidBatchLookup, + opts: { limit?: number; warn?: (message: string) => void } = {}, +): Promise & { lid: string | null }>> { + const lids = await resolveLidsForConfirmedJids(items, lookup, opts); + return items.map((item) => ({ ...item, lid: (item.exists && lids.get(item.jid)) || null })); +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 7bdafba408..c9919ce253 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -163,6 +163,7 @@ import { watchAppStateFailures, } from './app-state-self-heal'; import { BaileysMessageProcessor } from './baileysMessage.processor'; +import { attachLids } from './numbers-lid'; import { buildGroupReadSelfPayload, chatReadStateUpdates, @@ -3772,6 +3773,14 @@ export class BaileysStartupService extends ChannelStartupService { ); } + // Opt-in only: the default response shape must not change for existing callers. + // Runs after the cache write so the real LID never leaks into IsOnWhatsapp.lid (a 'lid' marker there). + if (data.includeLid === true) { + return await attachLids(onWhatsapp, (pns) => this.client.signalRepository.lidMapping.getLIDsForPNs(pns), { + warn: (message) => this.logger.warn(message), + }); + } + return onWhatsapp; } diff --git a/src/validate/chat.schema.ts b/src/validate/chat.schema.ts index c04d55fcfd..17f5d126c6 100644 --- a/src/validate/chat.schema.ts +++ b/src/validate/chat.schema.ts @@ -38,6 +38,10 @@ export const whatsappNumberSchema: JSONSchema7 = { description: '"numbers" must be an array of numeric strings', }, }, + includeLid: { + type: 'boolean', + description: '"includeLid" must be a boolean', + }, }, }; diff --git a/test/numbers-lid.test.ts b/test/numbers-lid.test.ts new file mode 100644 index 0000000000..8ace3d525d --- /dev/null +++ b/test/numbers-lid.test.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Validator } from 'jsonschema'; +import { + attachLids, + resolveLidsForConfirmedJids, + WHATSAPP_NUMBERS_LID_LIMIT, +} from '../src/api/integrations/channel/whatsapp/numbers-lid'; +import { whatsappNumberSchema } from '../src/validate/chat.schema'; + +const pnJid = (n: string) => `${n}@s.whatsapp.net`; + +/** Mimics lidMapping.getLIDsForPNs: returns only the PNs it knows, in the {lid, pn} pair shape. */ +function mockLookup(known: Record) { + const calls: string[][] = []; + const lookup = async (pns: string[]) => { + calls.push(pns); + const pairs = pns.filter((pn) => known[pn]).map((pn) => ({ pn, lid: known[pn] })); + return pairs.length ? pairs : null; + }; + return { lookup, calls }; +} + +test('legacy body (no includeLid) still passes the schema', () => { + const v = new Validator(); + assert.equal(v.validate({ numbers: ['5511999999999'] }, whatsappNumberSchema).valid, true); +}); + +test('schema: includeLid must be a boolean', () => { + const v = new Validator(); + assert.equal(v.validate({ numbers: ['5511999999999'], includeLid: true }, whatsappNumberSchema).valid, true); + assert.equal(v.validate({ numbers: ['5511999999999'], includeLid: 'yes' }, whatsappNumberSchema).valid, false); +}); + +test('adds lid from the mapping only for exists=true, in ONE batched call', async () => { + const { lookup, calls } = mockLookup({ [pnJid('5511999999999')]: '111222333444@lid' }); + const items = [ + { jid: pnJid('5511999999999'), exists: true, number: '5511999999999', name: 'Ana' }, + { jid: pnJid('5511888888888'), exists: true, number: '5511888888888' }, + { jid: pnJid('5511777777777'), exists: false, number: '5511777777777' }, + { jid: '120363000000000000@g.us', exists: true, number: '120363000000000000@g.us' }, + ]; + const out = await attachLids(items, lookup); + assert.deepEqual( + out.map((o) => o.lid), + ['111222333444@lid', null, null, null], + ); + assert.equal(out[0].name, 'Ana'); + assert.equal(calls.length, 1, 'a single batched lookup'); + assert.deepEqual(calls[0], [pnJid('5511999999999'), pnJid('5511888888888')]); +}); + +test('lid corresponds to the jid WhatsApp confirmed (9th digit), not to the typed number', async () => { + // typed 5511999999999 (with 9) but WhatsApp confirmed the 12-digit jid + const confirmed = pnJid('551199999999'); + const { lookup, calls } = mockLookup({ [confirmed]: '999888777666@lid', [pnJid('5511999999999')]: '000@lid' }); + const out = await attachLids([{ jid: confirmed, exists: true, number: '5511999999999' }], lookup); + assert.deepEqual(calls[0], [confirmed]); + assert.equal(out[0].lid, '999888777666@lid'); +}); + +test('device suffix on the returned lid is stripped; hosted/malformed lids are ignored', async () => { + const lookup = async () => [ + { pn: pnJid('5511999999999'), lid: '111222333444:7@lid' }, + { pn: pnJid('5511888888888'), lid: '555666777888@hosted.lid' }, + { pn: pnJid('5511777777777'), lid: 'garbage' }, + ]; + const out = await attachLids( + ['5511999999999', '5511888888888', '5511777777777'].map((n) => ({ jid: pnJid(n), exists: true, number: n })), + lookup, + ); + assert.deepEqual( + out.map((o) => o.lid), + ['111222333444@lid', null, null], + ); +}); + +test('lookup throws: lid null for everyone, no throw, WARN logged', async () => { + const warnings: string[] = []; + const out = await attachLids( + [{ jid: pnJid('5511999999999'), exists: true, number: '5511999999999' }], + async () => { + throw new Error('usync timeout'); + }, + { warn: (m) => warnings.push(m) }, + ); + assert.equal(out[0].lid, null); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /usync timeout/); +}); + +test('lookup returns null (no pairs): lid null', async () => { + const out = await attachLids([{ jid: pnJid('5511999999999'), exists: true, number: 'x' }], async () => null); + assert.equal(out[0].lid, null); +}); + +test('cap: only the first N unique numbers are looked up, the rest get null + a WARN', async () => { + const known: Record = {}; + const items = Array.from({ length: WHATSAPP_NUMBERS_LID_LIMIT + 5 }, (_, i) => { + const n = `55119${String(10000000 + i)}`; + known[pnJid(n)] = `${900000000 + i}@lid`; + return { jid: pnJid(n), exists: true, number: n }; + }); + const { lookup, calls } = mockLookup(known); + const warnings: string[] = []; + const out = await attachLids(items, lookup, { warn: (m) => warnings.push(m) }); + assert.equal(calls.length, 1); + assert.equal(calls[0].length, WHATSAPP_NUMBERS_LID_LIMIT); + assert.equal(out.filter((o) => o.lid).length, WHATSAPP_NUMBERS_LID_LIMIT); + assert.equal(out[WHATSAPP_NUMBERS_LID_LIMIT - 1].lid, `${900000000 + WHATSAPP_NUMBERS_LID_LIMIT - 1}@lid`); + assert.equal(out[WHATSAPP_NUMBERS_LID_LIMIT].lid, null); + assert.equal(warnings.length, 1); +}); + +test('duplicate confirmed jids are looked up once and both items get the lid', async () => { + const jid = pnJid('5511999999999'); + const { lookup, calls } = mockLookup({ [jid]: '111222333444@lid' }); + const out = await attachLids( + [ + { jid, exists: true, number: '5511999999999' }, + { jid, exists: true, number: '551199999999' }, + ], + lookup, + ); + assert.deepEqual(calls[0], [jid]); + assert.deepEqual( + out.map((o) => o.lid), + ['111222333444@lid', '111222333444@lid'], + ); +}); + +test('already-LID jid (cache hit) is returned as is, without a lookup', async () => { + const { lookup, calls } = mockLookup({}); + const map = await resolveLidsForConfirmedJids([{ jid: '111222333444@lid', exists: true }], lookup); + assert.equal(calls.length, 0); + assert.equal(map.get('111222333444@lid'), '111222333444@lid'); +}); + +test('nothing to resolve: no lookup call at all', async () => { + const { lookup, calls } = mockLookup({}); + await attachLids([{ jid: pnJid('5511999999999'), exists: false, number: 'x' }], lookup); + assert.equal(calls.length, 0); +});