Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/api/dto/chat.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -84,6 +85,7 @@ export class ArchiveChatDto {

export class MarkChatUnreadDto {
lastMessage?: LastMessage;
lastMessages?: LastMessage[];
chat?: string;
}

Expand Down
67 changes: 50 additions & 17 deletions src/api/integrations/channel/whatsapp/read-state.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Expand All @@ -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<string>();
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',
)
Expand All @@ -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' };
Expand Down
33 changes: 16 additions & 17 deletions src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()],
Expand Down
44 changes: 26 additions & 18 deletions src/validate/chat.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 = {
Expand Down Expand Up @@ -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: {
Expand All @@ -131,7 +139,7 @@ export const markChatUnreadSchema: JSONSchema7 = {
...isNotEmpty('messageTimestamp'),
},
},
required: ['lastMessage'],
anyOf: [{ required: ['lastMessage'] }, { required: ['lastMessages'] }, { required: ['chat'] }],
};

export const deleteMessageSchema: JSONSchema7 = {
Expand Down
50 changes: 34 additions & 16 deletions test/read-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {}) {
Expand All @@ -36,50 +39,65 @@ 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 () => {
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 },
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);
Expand All @@ -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']);
});

Expand Down
Loading