From 70451ee38594f13b22034c36c3e6bb13858b1587 Mon Sep 17 00:00:00 2001 From: Alpes Digital Date: Mon, 28 Sep 2026 15:58:54 -0300 Subject: [PATCH] fix(read-state): validate complete original message ranges before SDK writes --- src/api/dto/chat.dto.ts | 2 + .../channel/whatsapp/read-state.ts | 67 ++++++++++++++----- .../whatsapp/whatsapp.baileys.service.ts | 33 +++++---- src/validate/chat.schema.ts | 44 +++++++----- test/read-state.test.ts | 50 +++++++++----- 5 files changed, 128 insertions(+), 68 deletions(-) diff --git a/src/api/dto/chat.dto.ts b/src/api/dto/chat.dto.ts index 0c1207195..1eaa063f8 100644 --- a/src/api/dto/chat.dto.ts +++ b/src/api/dto/chat.dto.ts @@ -69,6 +69,7 @@ class Key { export class ReadMessageDto { readMessages: Key[]; lastMessage?: { key: Key; messageTimestamp: number }; + lastMessages?: Array<{ key: Key; messageTimestamp: number }>; } export class LastMessage { @@ -84,6 +85,7 @@ export class ArchiveChatDto { export class MarkChatUnreadDto { lastMessage?: LastMessage; + lastMessages?: LastMessage[]; chat?: string; } diff --git a/src/api/integrations/channel/whatsapp/read-state.ts b/src/api/integrations/channel/whatsapp/read-state.ts index d8eb89ede..e2663f949 100644 --- a/src/api/integrations/channel/whatsapp/read-state.ts +++ b/src/api/integrations/channel/whatsapp/read-state.ts @@ -1,4 +1,4 @@ -import type { ReadMessageDto } from '@api/dto/chat.dto'; +import type { LastMessage, ReadMessageDto } from '@api/dto/chat.dto'; import type { proto, WASocket } from 'baileys'; export class ReadStateValidationError extends Error {} @@ -11,33 +11,66 @@ function isChatJid(jid: string): boolean { 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. */ -export async function syncMessageReadState(client: ReadStateClient, data: ReadMessageDto) { - if (!Array.isArray(data.readMessages)) { - throw new ReadStateValidationError('readMessages must be an array'); +/** The first entry is the displayed cursor; the final entry is the latest inbound message. */ +export function readStateMessageRange(data: { + lastMessage?: LastMessage; + lastMessages?: LastMessage[]; +}): LastMessage[] | undefined { + if (data.lastMessages && data.lastMessage) { + throw new ReadStateValidationError('Use lastMessages or lastMessage, not both'); } - - const lastMessage = data.lastMessage; - if (lastMessage) { - const { key, messageTimestamp } = lastMessage; + const range = data.lastMessages ?? (data.lastMessage ? [data.lastMessage] : undefined); + if (!range) return undefined; + if (!Array.isArray(range) || range.length < 1 || range.length > 500) { + throw new ReadStateValidationError('lastMessages must contain 1 to 500 messages'); + } + const jid = range[0]?.key?.remoteJid; + if (typeof jid !== 'string' || !isChatJid(jid)) { + throw new ReadStateValidationError('lastMessages requires a valid chat JID'); + } + const ids = new Set(); + for (let index = 0; index < range.length; index++) { + const { key, messageTimestamp } = range[index] ?? {}; if ( !key || - typeof key.remoteJid !== 'string' || - !isChatJid(key.remoteJid) || + key.remoteJid !== jid || typeof key.id !== 'string' || !key.id.trim() || typeof key.fromMe !== 'boolean' || + typeof messageTimestamp !== 'number' || !Number.isSafeInteger(messageTimestamp) || - messageTimestamp <= 0 + messageTimestamp < 100_000_000 || + messageTimestamp >= 100_000_000_000 || + (index > 0 && messageTimestamp > range[index - 1].messageTimestamp!) || + ids.has(key.id) || + (index < range.length - 1 && key.fromMe === false) ) { - throw new ReadStateValidationError('lastMessage requires a valid chat key and epoch-seconds messageTimestamp'); + throw new ReadStateValidationError( + 'lastMessages must be a complete reverse-chronological range ending at the latest inbound message', + ); } + ids.add(key.id); + } + if (range[range.length - 1].key.fromMe !== false) { + throw new ReadStateValidationError('lastMessages must end at an inbound message'); + } + return range; +} + +/** 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 range = readStateMessageRange(data); + if (range) { if ( data.readMessages.some( (read) => !read || !isChatJid(read.remoteJid) || - read.remoteJid !== key.remoteJid || + read.remoteJid !== range[0].key.remoteJid || !read.id || typeof read.fromMe !== 'boolean', ) @@ -53,10 +86,10 @@ export async function syncMessageReadState(client: ReadStateClient, data: ReadMe .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 (range) { + await client.chatModify({ markRead: true, lastMessages: range }, range[0].key.remoteJid); } - if (keys.length > 0 || !lastMessage) { + if (keys.length > 0 || !range) { await client.readMessages(keys); } return { message: 'Read messages', read: 'success' }; diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 796536aed..58c8ce101 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, + readStateMessageRange, + ReadStateValidationError, + syncMessageReadState, +} from './read-state'; import { useVoiceCallsBaileys } from './voiceCalls/useVoiceCallsBaileys'; export interface ExtendedIMessageKey extends proto.IMessageKey { @@ -3762,25 +3767,19 @@ export class BaileysStartupService extends ChannelStartupService { public async markChatUnread(data: MarkChatUnreadDto) { try { - let last_message = data.lastMessage; - let number = data.chat; - - if (!last_message && number) { - last_message = await this.getLastMessage(number); - } else { - last_message = data.lastMessage; - last_message.messageTimestamp = last_message?.messageTimestamp ?? Math.floor(Date.now() / 1000); - number = last_message?.key?.remoteJid; - } - - if (!last_message || Object.keys(last_message).length === 0) { - throw new NotFoundException('Last message not found'); + const supplied = data.lastMessages ?? data.lastMessage; + const range = readStateMessageRange( + supplied ? data : { lastMessage: data.chat ? await this.getLastMessage(data.chat) : undefined }, + ); + if (!range) throw new ReadStateValidationError('Last message not found'); + if (data.chat && createJid(data.chat) !== createJid(range[0].key.remoteJid)) { + throw new ReadStateValidationError('chat and lastMessages must belong to the same chat'); } + await this.client.chatModify({ markRead: false, lastMessages: range }, createJid(range[0].key.remoteJid)); - await this.client.chatModify({ markRead: false, lastMessages: [last_message] }, createJid(number)); - - return { chatId: number, markedChatUnread: true }; + return { chatId: range[0].key.remoteJid, markedChatUnread: true }; } catch (error) { + if (error instanceof ReadStateValidationError) throw new BadRequestException(error.message); throw new InternalServerErrorException({ markedChatUnread: false, message: ['An error occurred while marked unread the chat. Open a calling.', error.toString()], diff --git a/src/validate/chat.schema.ts b/src/validate/chat.schema.ts index fc219bdd6..1ef834719 100644 --- a/src/validate/chat.schema.ts +++ b/src/validate/chat.schema.ts @@ -41,6 +41,23 @@ export const whatsappNumberSchema: JSONSchema7 = { }, }; +const readStateMessageSchema: JSONSchema7 = { + 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'], +}; + export const readMessageSchema: JSONSchema7 = { $id: v4(), type: 'object', @@ -58,25 +75,15 @@ 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'], - }, + lastMessage: readStateMessageSchema, + lastMessages: { type: 'array', minItems: 1, maxItems: 500, items: readStateMessageSchema }, }, required: ['readMessages'], - anyOf: [{ properties: { readMessages: { minItems: 1 } } }, { required: ['lastMessage'] }], + anyOf: [ + { properties: { readMessages: { minItems: 1 } } }, + { required: ['lastMessage'] }, + { required: ['lastMessages'] }, + ], }; export const archiveChatSchema: JSONSchema7 = { @@ -112,6 +119,7 @@ export const markChatUnreadSchema: JSONSchema7 = { type: 'object', properties: { chat: { type: 'string' }, + lastMessages: { type: 'array', minItems: 1, maxItems: 500, items: readStateMessageSchema }, lastMessage: { type: 'object', properties: { @@ -131,7 +139,7 @@ export const markChatUnreadSchema: JSONSchema7 = { ...isNotEmpty('messageTimestamp'), }, }, - required: ['lastMessage'], + anyOf: [{ required: ['lastMessage'] }, { required: ['lastMessages'] }, { required: ['chat'] }], }; export const deleteMessageSchema: JSONSchema7 = { diff --git a/test/read-state.test.ts b/test/read-state.test.ts index 771f72f94..55bc886e9 100644 --- a/test/read-state.test.ts +++ b/test/read-state.test.ts @@ -3,15 +3,18 @@ import { test } from 'node:test'; import type { ReadMessageDto } from '../src/api/dto/chat.dto'; import { chatReadStateUpdates, + readStateMessageRange, ReadStateValidationError, syncMessageReadState, } from '../src/api/integrations/channel/whatsapp/read-state'; -import { readMessageSchema } from '../src/validate/chat.schema'; +import { markChatUnreadSchema, 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 inboundMessage = { key: { remoteJid: jid, fromMe: false, id: 'inbound-1' }, messageTimestamp: 1_789_999_999 }; +const lastMessages = [lastMessage, inboundMessage]; const receipt = { remoteJid: jid, fromMe: false, id: 'inbound-1' }; function mockClient(options: { failModify?: boolean; failReceipts?: boolean } = {}) { @@ -36,42 +39,57 @@ test('legacy readMessages sends receipts without modifying chat state', async () assert.deepEqual(calls, [{ command: 'readMessages', payload: [receipt] }]); }); -test('lastMessage synchronizes linked-device read state and sends inbound receipts', async () => { +test('lastMessages synchronizes the complete reverse-chronological range and sends inbound receipts', async () => { const { client, calls } = mockClient(); - await syncMessageReadState(client, { readMessages: [receipt], lastMessage }); + await syncMessageReadState(client, { readMessages: [receipt], lastMessages }); assert.deepEqual(calls, [ - { command: 'chatModify', payload: { markRead: true, lastMessages: [lastMessage] }, jid }, + { command: 'chatModify', payload: { markRead: true, lastMessages }, jid }, { command: 'readMessages', payload: [receipt] }, ]); }); -test('outbound lastMessage can clear a manually unread chat without inbound receipts', async () => { +test('outbound cursor can clear a manually unread chat with a complete range but no receipts', async () => { const { client, calls } = mockClient(); - await syncMessageReadState(client, { readMessages: [], lastMessage }); - assert.deepEqual(calls, [{ command: 'chatModify', payload: { markRead: true, lastMessages: [lastMessage] }, jid }]); + await syncMessageReadState(client, { readMessages: [], lastMessages }); + assert.deepEqual(calls, [{ command: 'chatModify', payload: { markRead: true, lastMessages }, 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 }), + syncMessageReadState(client, { readMessages: [{ ...receipt, remoteJid: otherJid }], lastMessages }), ReadStateValidationError, ); await assert.rejects( - syncMessageReadState(client, { readMessages: [receipt], lastMessage: { key: lastMessage.key } } as ReadMessageDto), + syncMessageReadState(client, { readMessages: [receipt], lastMessages: [{ key: lastMessage.key }] } as ReadMessageDto), ReadStateValidationError, ); + await assert.rejects(syncMessageReadState(client, { readMessages: [receipt], lastMessage }), ReadStateValidationError); + await assert.rejects(syncMessageReadState(client, { readMessages: [receipt], lastMessages: [lastMessage] }), ReadStateValidationError); + await assert.rejects(syncMessageReadState(client, { readMessages: [receipt], lastMessages: [inboundMessage, lastMessage] }), ReadStateValidationError); + await assert.rejects(syncMessageReadState(client, { readMessages: [receipt], lastMessages: [lastMessage, { ...inboundMessage, messageTimestamp: 1_790_000_001 }] }), ReadStateValidationError); + await assert.rejects(syncMessageReadState(client, { readMessages: [receipt], lastMessages: [lastMessage, { ...inboundMessage, key: { ...inboundMessage.key, id: lastMessage.key.id } }] }), ReadStateValidationError); assert.deepEqual(calls, []); }); -test('request schema requires an epoch-seconds timestamp and allows empty receipts only with lastMessage', () => { +test('request schema requires an epoch-seconds timestamp and allows empty receipts only with a range', () => { 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: [], lastMessages }), 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); + assert.equal(valid({ readMessages: [receipt], lastMessages: [{ key: lastMessage.key }] }), false); + assert.equal(valid({ readMessages: [receipt], lastMessages: [{ ...lastMessage, messageTimestamp: 0 }] }), false); + assert.equal(valid({ readMessages: [receipt], lastMessages: null }), false); +}); + +test('unread accepts a complete range and rejects an outbound-only legacy cursor before SDK use', () => { + const validator = new Validator(); + assert.equal(validator.validate({ chat: jid, lastMessages }, markChatUnreadSchema).valid, true); + assert.deepEqual(readStateMessageRange({ lastMessages }), lastMessages); + assert.throws(() => readStateMessageRange({ lastMessage }), ReadStateValidationError); + assert.throws(() => readStateMessageRange({ lastMessages: [lastMessage, { ...inboundMessage, key: { ...inboundMessage.key, remoteJid: otherJid } }] }), ReadStateValidationError); + assert.equal(validator.validate({ chat: jid, lastMessages: [{ key: inboundMessage.key }] }, markChatUnreadSchema).valid, false); }); test('LID chats are accepted by the same JID suffix contract', async () => { @@ -79,7 +97,7 @@ test('LID chats are accepted by the same JID suffix contract', async () => { 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 }, + lastMessages: [{ 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); @@ -88,11 +106,11 @@ test('LID chats are accepted by the same JID suffix contract', async () => { test('partial SDK failure never reports success', async () => { const modify = mockClient({ failModify: true }); - await assert.rejects(syncMessageReadState(modify.client, { readMessages: [receipt], lastMessage }), /modify failed/); + await assert.rejects(syncMessageReadState(modify.client, { readMessages: [receipt], lastMessages }), /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/); + await assert.rejects(syncMessageReadState(receipts.client, { readMessages: [receipt], lastMessages }), /receipts failed/); assert.deepEqual(receipts.calls.map((call) => call.command), ['chatModify', 'readMessages']); });