diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be08db98..84c502fd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `POST /sessions/{sessionId}/chats/read` accepts an optional `messageIds` array (up to 100) naming exactly which messages to acknowledge. Baileys acknowledges individual messages, so without it only the newest message still held in memory got a receipt: a burst left its earlier messages unread, and a session restarted since the message arrived had nothing to acknowledge and answered `false` under a `200`. Each id is resolved through the message store, so a group receipt carries the `participant` that attributes it. Omitting the field keeps the previous behaviour, an empty array is refused with a `400` rather than silently acknowledging the newest message, and the whatsapp-web.js engine ignores the field because its own receipt is chat-level. The `SessionMarkChatRead` agent tool takes the same list, and all five clients expose the field through a dedicated read-request type. Thanks @m7fz7. + +### Changed + +- `POST /sessions/{sessionId}/chats/unread` publishes its own `MarkChatUnreadDto` instead of sharing `MarkChatReadDto` with the read route. The request body is unchanged (`chatId` alone), but a client generated from the contract sees the schema under a new name. Sharing the class would have advertised `messageIds` on a route that discards it. + ### Fixed - The dashboard CSP nonce is substituted at every occurrence in the served document, not only the first. One placeholder exists today, so a second would have been left reading the literal text and its script refused by the browser. diff --git a/docs/06-api-specification.md b/docs/06-api-specification.md index 41d036c0f..2a0dada86 100644 --- a/docs/06-api-specification.md +++ b/docs/06-api-specification.md @@ -836,12 +836,15 @@ Mark a chat as read/seen. **Request body** — `MarkChatReadDto` -| Field | Type | Required | Constraints | Description | -| -------- | ------ | -------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| `chatId` | string | Yes | `@IsString`; `@IsNotEmpty`; `@Matches(/^[^\s@]+@[^\s@]+$/)` (localpart@host, no whitespace) | Engine-native JID, e.g. `1234567890@c.us` (wwebjs) or `1234@s.whatsapp.net` (Baileys) | +| Field | Type | Required | Constraints | Description | +| ------------ | -------- | -------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `chatId` | string | Yes | `@IsString`; `@IsNotEmpty`; `@Matches(/^[^\s@]+@[^\s@]+$/)` (localpart@host, no whitespace) | Engine-native JID, e.g. `1234567890@c.us` (wwebjs) or `1234@s.whatsapp.net` (Baileys) | +| `messageIds` | string[] | No | `@IsArray`; `@ArrayNotEmpty`; `@ArrayMaxSize(100)`; each a non-empty token with no whitespace | Messages to acknowledge. Omit the field to acknowledge only the newest message; an empty array is rejected. | + +Baileys acknowledges individual messages rather than chats, and the receipt enumerates ids instead of carrying a read-up-to watermark. Without `messageIds` only the newest message the engine still holds in memory gets a receipt, so a burst leaves its earlier messages unread and a session restarted since the message arrived has nothing to acknowledge at all. Each supplied id is resolved through the message store, which is what carries the `participant` a group receipt needs. Ignored by whatsapp-web.js, whose own `sendSeen` is chat-level. ```json -{ "chatId": "1234567890@c.us" } +{ "chatId": "1234567890@c.us", "messageIds": ["3EB0C767D26B8A3F1A2B", "3EB0C767D26B8A3F1A2C"] } ``` **Response** `200` @@ -871,12 +874,14 @@ Mark a chat as unread. | ----------- | ------ | ------------ | | `sessionId` | string | Session UUID | -**Request body** — `MarkChatReadDto` (reused) +**Request body** — `MarkChatUnreadDto` | Field | Type | Required | Constraints | Description | | -------- | ------ | -------- | ----------------------------------------------------------- | ----------------------------------------- | | `chatId` | string | Yes | `@IsString`; `@IsNotEmpty`; `@Matches(/^[^\s@]+@[^\s@]+$/)` | Engine-native JID, e.g. `1234567890@c.us` | +This route takes `chatId` alone. It previously shared `MarkChatReadDto`, which is why the two are described separately now that the read body carries `messageIds`. + ```json { "chatId": "1234567890@c.us" } ``` diff --git a/docs/07-api-collection.md b/docs/07-api-collection.md index 0f047c9e5..5e1acd41f 100644 --- a/docs/07-api-collection.md +++ b/docs/07-api-collection.md @@ -214,9 +214,12 @@ Mark a chat as read/seen (OPERATOR). curl -X POST "$BASE/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/chats/read" \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ - -d '{ "chatId": "1234567890@c.us" }' + -d '{ "chatId": "1234567890@c.us", "messageIds": ["3EB0C767D26B8A3F1A2B"] }' ``` +`messageIds` is optional and holds up to 100 ids. Omit it and only the newest message the engine still +holds in memory is acknowledged, which on Baileys leaves the earlier messages of a burst unread. + #### POST /api/sessions/:sessionId/chats/unread Mark a chat as unread (OPERATOR). diff --git a/openapi.json b/openapi.json index 8d4b75aae..fcd120859 100644 --- a/openapi.json +++ b/openapi.json @@ -1237,7 +1237,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MarkChatReadDto" + "$ref": "#/components/schemas/MarkChatUnreadDto" } } } @@ -10261,6 +10261,18 @@ "type": "string", "description": "Chat ID in the active engine's native format (e.g. 1234567890@c.us on whatsapp-web.js)", "example": "1234567890@c.us" + }, + "messageIds": { + "description": "Specific message IDs to mark read. Baileys acknowledges individual messages, so without this only the newest message the engine still holds in memory gets a receipt — a burst leaves its earlier messages unread forever, and a restarted session has no message to acknowledge at all. Callers that persist inbound message IDs should send them here. Ignored by whatsapp-web.js, whose own sendSeen is chat-level.", + "maxItems": 100, + "example": [ + "3EB0C767D26B8A3F1A2B", + "3EB0C767D26B8A3F1A2C" + ], + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -10368,6 +10380,19 @@ "observedAt" ] }, + "MarkChatUnreadDto": { + "type": "object", + "properties": { + "chatId": { + "type": "string", + "description": "Chat ID in the active engine's native format (e.g. 1234567890@c.us on whatsapp-web.js)", + "example": "1234567890@c.us" + } + }, + "required": [ + "chatId" + ] + }, "ArchiveChatDto": { "type": "object", "properties": { diff --git a/scripts/check-contract-shapes.mjs b/scripts/check-contract-shapes.mjs index 48ef83c5e..a52cda7bb 100644 --- a/scripts/check-contract-shapes.mjs +++ b/scripts/check-contract-shapes.mjs @@ -85,7 +85,8 @@ const MAPPINGS = { GroupSubjectRequest: 'GroupSubjectDto', GroupSummary: 'GroupSummaryDto', JoinGroupRequest: 'JoinGroupDto', - MarkChatRequest: 'MarkChatReadDto', + MarkChatReadRequest: 'MarkChatReadDto', + MarkChatRequest: 'MarkChatUnreadDto', MessageListResponse: 'MessageListResponseDto', MessageRecord: 'MessageListItemDto', MessageResponse: 'MessageResponseDto', @@ -164,11 +165,11 @@ const MAPPINGS = { * these floors as pairs are added makes the shrink loud. */ const MINIMUM_MAPPED = { - 'sdk/javascript/src/types.ts': 78, + 'sdk/javascript/src/types.ts': 79, 'dashboard/src/services/api.ts': 20, - 'sdk/python/openwa/types.py': 73, - 'sdk/go': 74, - 'sdk/java': 78, + 'sdk/python/openwa/types.py': 74, + 'sdk/go': 75, + 'sdk/java': 79, }; /** Known drift, deliberately not gated yet — each line is a to-adjudicate follow-up. */ @@ -230,7 +231,8 @@ const PYTHON_MAPPING = { GroupParticipant: 'GroupParticipantDto', GroupSummary: 'GroupSummaryDto', JoinGroupRequest: 'JoinGroupDto', - MarkChatRequest: 'MarkChatReadDto', + MarkChatReadRequest: 'MarkChatReadDto', + MarkChatRequest: 'MarkChatUnreadDto', MessageListResponse: 'MessageListResponseDto', MessageRecord: 'MessageListItemDto', MessageResponse: 'MessageResponseDto', @@ -307,7 +309,8 @@ const GO_MAPPING = { GroupParticipant: 'GroupParticipantDto', GroupSummary: 'GroupSummaryDto', JoinGroupRequest: 'JoinGroupDto', - MarkChatRequest: 'MarkChatReadDto', + MarkChatReadRequest: 'MarkChatReadDto', + MarkChatRequest: 'MarkChatUnreadDto', MessageListResponse: 'MessageListResponseDto', MessageRecord: 'MessageListItemDto', MessageResponse: 'MessageResponseDto', @@ -388,7 +391,8 @@ const JAVA_MAPPING = { GroupSubjectRequest: 'GroupSubjectDto', GroupSummary: 'GroupSummaryDto', JoinGroupRequest: 'JoinGroupDto', - MarkChatRequest: 'MarkChatReadDto', + MarkChatReadRequest: 'MarkChatReadDto', + MarkChatRequest: 'MarkChatUnreadDto', MessageListResponse: 'MessageListResponseDto', MessageRecord: 'MessageListItemDto', MessageResponse: 'MessageResponseDto', diff --git a/sdk/go/chats.go b/sdk/go/chats.go index 146dbe8f1..52f693a1c 100644 --- a/sdk/go/chats.go +++ b/sdk/go/chats.go @@ -41,7 +41,7 @@ func (s *ChatsService) GetPresence(ctx context.Context, sessionID, chatID string } // MarkRead marks a chat as read. -func (s *ChatsService) MarkRead(ctx context.Context, sessionID string, body MarkChatRequest) (*SuccessResult, error) { +func (s *ChatsService) MarkRead(ctx context.Context, sessionID string, body MarkChatReadRequest) (*SuccessResult, error) { return s.post(ctx, sessionID, "/read", body) } diff --git a/sdk/go/routing_test.go b/sdk/go/routing_test.go index c360a266a..089edaa3a 100644 --- a/sdk/go/routing_test.go +++ b/sdk/go/routing_test.go @@ -94,7 +94,7 @@ func TestRouting(t *testing.T) { {"Webhooks.Test", func(c *Client) { c.Webhooks.Test(ctx, "s1", "w1") }, "POST", "/api/sessions/s1/webhooks/w1/test"}, {"Chats.List", func(c *Client) { c.Chats.List(ctx, "s1", nil) }, "GET", "/api/sessions/s1/chats"}, - {"Chats.MarkRead", func(c *Client) { c.Chats.MarkRead(ctx, "s1", MarkChatRequest{}) }, "POST", "/api/sessions/s1/chats/read"}, + {"Chats.MarkRead", func(c *Client) { c.Chats.MarkRead(ctx, "s1", MarkChatReadRequest{}) }, "POST", "/api/sessions/s1/chats/read"}, {"Chats.SubscribePresence", func(c *Client) { c.Chats.SubscribePresence(ctx, "s1", MarkChatRequest{}) }, "POST", "/api/sessions/s1/presence/subscribe"}, {"Channels.Create", func(c *Client) { c.Channels.Create(ctx, "s1", CreateChannelRequest{}) }, "POST", "/api/sessions/s1/channels"}, {"Channels.Delete", func(c *Client) { c.Channels.Delete(ctx, "s1", "ch1") }, "POST", "/api/sessions/s1/channels/ch1/delete"}, diff --git a/sdk/go/types_chat.go b/sdk/go/types_chat.go index 431452ecb..f6f24ac50 100644 --- a/sdk/go/types_chat.go +++ b/sdk/go/types_chat.go @@ -19,11 +19,20 @@ type SetOwnPresenceRequest struct { Available bool `json:"available"` } -// MarkChatRequest marks a chat read/unread. +// MarkChatRequest marks a chat unread, or subscribes to its presence. type MarkChatRequest struct { ChatID string `json:"chatId"` } +// MarkChatReadRequest marks a chat read, optionally naming the messages to acknowledge. +type MarkChatReadRequest struct { + ChatID string `json:"chatId"` + // MessageIDs are the messages to acknowledge (at most 100; an empty list is refused). Baileys + // acknowledges individual messages, so without this only the newest message the engine still + // holds in memory gets a receipt. Ignored by whatsapp-web.js, whose own sendSeen is chat-level. + MessageIDs []string `json:"messageIds,omitempty"` +} + // ChatState is the typing indicator a chat shows. type ChatState string diff --git a/sdk/java/src/main/java/com/rmyndharis/openwa/model/MarkChatReadRequest.java b/sdk/java/src/main/java/com/rmyndharis/openwa/model/MarkChatReadRequest.java new file mode 100644 index 000000000..d46dc497f --- /dev/null +++ b/sdk/java/src/main/java/com/rmyndharis/openwa/model/MarkChatReadRequest.java @@ -0,0 +1,35 @@ +package com.rmyndharis.openwa.model; + +import java.util.List; + +/** Request body for marking a chat read. */ +public record MarkChatReadRequest(String chatId, List messageIds) { + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String chatId; + private List messageIds; + + /** WhatsApp chat id (JID), e.g. {@code 628123456789@c.us}. */ + public Builder chatId(String v) { + this.chatId = v; + return this; + } + + /** + * Messages to acknowledge (at most 100; an empty list is refused). Baileys acknowledges + * individual messages, so without this only the newest message the engine still holds in + * memory gets a receipt. Ignored by whatsapp-web.js, whose own sendSeen is chat-level. + */ + public Builder messageIds(List v) { + this.messageIds = v; + return this; + } + + public MarkChatReadRequest build() { + return new MarkChatReadRequest(chatId, messageIds); + } + } +} diff --git a/sdk/java/src/main/java/com/rmyndharis/openwa/model/MarkChatRequest.java b/sdk/java/src/main/java/com/rmyndharis/openwa/model/MarkChatRequest.java index 2dd067d92..c11efb5de 100644 --- a/sdk/java/src/main/java/com/rmyndharis/openwa/model/MarkChatRequest.java +++ b/sdk/java/src/main/java/com/rmyndharis/openwa/model/MarkChatRequest.java @@ -1,6 +1,6 @@ package com.rmyndharis.openwa.model; -/** Request body for marking a chat read/unread. */ +/** Request body for marking a chat unread, or subscribing to its presence. */ public record MarkChatRequest(String chatId) { public static Builder builder() { return new Builder(); diff --git a/sdk/java/src/main/java/com/rmyndharis/openwa/resources/ChatsResource.java b/sdk/java/src/main/java/com/rmyndharis/openwa/resources/ChatsResource.java index ea910e54a..6fd66a25e 100644 --- a/sdk/java/src/main/java/com/rmyndharis/openwa/resources/ChatsResource.java +++ b/sdk/java/src/main/java/com/rmyndharis/openwa/resources/ChatsResource.java @@ -9,6 +9,7 @@ import com.rmyndharis.openwa.model.ChatSummary; import com.rmyndharis.openwa.model.DeleteChatRequest; import com.rmyndharis.openwa.model.ListChatsQuery; +import com.rmyndharis.openwa.model.MarkChatReadRequest; import com.rmyndharis.openwa.model.MarkChatRequest; import com.rmyndharis.openwa.model.MuteChatRequest; import com.rmyndharis.openwa.model.PinChatRequest; @@ -72,7 +73,7 @@ public ChatPresence getPresence(String sessionId, String chatId) { } /** Mark a chat as read/seen. */ - public SuccessResult markRead(String sessionId, MarkChatRequest body) { + public SuccessResult markRead(String sessionId, MarkChatReadRequest body) { return client.request( HttpMethod.POST, "/api/sessions/" + encodeSegment(sessionId) + "/chats/read", null, body, SuccessResult.class); } diff --git a/sdk/java/src/test/java/com/rmyndharis/openwa/resources/ChatsResourceTest.java b/sdk/java/src/test/java/com/rmyndharis/openwa/resources/ChatsResourceTest.java index 979ec295e..03f6091a8 100644 --- a/sdk/java/src/test/java/com/rmyndharis/openwa/resources/ChatsResourceTest.java +++ b/sdk/java/src/test/java/com/rmyndharis/openwa/resources/ChatsResourceTest.java @@ -12,6 +12,7 @@ import com.rmyndharis.openwa.model.MuteChatRequest; import com.rmyndharis.openwa.model.PinChatRequest; import com.rmyndharis.openwa.model.ListChatsQuery; +import com.rmyndharis.openwa.model.MarkChatReadRequest; import com.rmyndharis.openwa.model.MarkChatRequest; import com.rmyndharis.openwa.model.SendChatStateRequest; import com.rmyndharis.openwa.support.MockTransport; @@ -48,7 +49,7 @@ void listSerializesQuery() { @Test void markReadSendsBody() { tx.respond(200, "{\"success\":true}"); - client.chats.markRead("s", MarkChatRequest.builder().chatId("628123@c.us").build()); + client.chats.markRead("s", MarkChatReadRequest.builder().chatId("628123@c.us").build()); assertEquals("http://h/api/sessions/s/chats/read", tx.lastRequest().url()); assertEquals(HttpMethod.POST, tx.lastRequest().method()); assertTrue(tx.lastRequest().body().contains("628123@c.us")); diff --git a/sdk/javascript/src/resources/chats.ts b/sdk/javascript/src/resources/chats.ts index bece30025..61da4559b 100644 --- a/sdk/javascript/src/resources/chats.ts +++ b/sdk/javascript/src/resources/chats.ts @@ -17,6 +17,7 @@ import type { ChatSummary, DeleteChatRequest, MarkChatRequest, + MarkChatReadRequest, ChatPresence, SendChatStateRequest, SuccessResult, @@ -67,7 +68,7 @@ export class ChatsResource { } /** Mark a chat as read/seen. */ - markRead(sessionId: string, body: MarkChatRequest): Promise { + markRead(sessionId: string, body: MarkChatReadRequest): Promise { return this.client.request({ method: 'POST', path: `/api/sessions/${encodeSegment(sessionId)}/chats/read`, diff --git a/sdk/javascript/src/types.ts b/sdk/javascript/src/types.ts index f940b305c..a77e31041 100644 --- a/sdk/javascript/src/types.ts +++ b/sdk/javascript/src/types.ts @@ -968,10 +968,23 @@ export interface TransferChannelOwnershipRequest { newOwnerId: Jid; } +/** Body for {@link ChatsResource.markUnread} and {@link ChatsResource.subscribePresence}. */ export interface MarkChatRequest { chatId: Jid; } +/** Body for {@link ChatsResource.markRead}. */ +export interface MarkChatReadRequest extends MarkChatRequest { + /** + * Specific message IDs to acknowledge. Baileys acknowledges individual messages, so without this + * only the newest message the engine still holds in memory gets a receipt: a burst leaves its + * earlier messages unread forever, and a restarted session has no message to acknowledge at all. + * Callers that persist inbound message IDs should send them here. Ignored by whatsapp-web.js, + * whose own sendSeen is chat-level. At most 100 per request; an empty array is rejected. + */ + messageIds?: string[]; +} + export type ChatState = 'typing' | 'recording' | 'paused'; export interface SendChatStateRequest { diff --git a/sdk/python/openwa/resources/chats.py b/sdk/python/openwa/resources/chats.py index 3ec599ccc..13866069a 100644 --- a/sdk/python/openwa/resources/chats.py +++ b/sdk/python/openwa/resources/chats.py @@ -16,6 +16,7 @@ MuteChatRequest, ChatSummary, DeleteChatRequest, + MarkChatReadRequest, MarkChatRequest, SendChatStateRequest, SuccessResult, @@ -59,7 +60,7 @@ def get_presence(self, session_id: str, chat_id: str) -> ChatPresence | None: "GET", f"/api/sessions/{quote_segment(session_id)}/presence/{quote_segment(chat_id)}" ) - def mark_read(self, session_id: str, body: MarkChatRequest) -> SuccessResult: + def mark_read(self, session_id: str, body: MarkChatReadRequest) -> SuccessResult: return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/chats/read", body=body) def mark_unread(self, session_id: str, body: MarkChatRequest) -> SuccessResult: diff --git a/sdk/python/openwa/types.py b/sdk/python/openwa/types.py index 6216d872d..182c03f97 100644 --- a/sdk/python/openwa/types.py +++ b/sdk/python/openwa/types.py @@ -882,9 +882,19 @@ class ChatSummary(TypedDict): class MarkChatRequest(TypedDict): + # Body for mark_unread and subscribe_presence, both of which take the chat id alone. chatId: Jid +class MarkChatReadRequest(TypedDict): + # Body for mark_read. + chatId: Jid + # Messages to acknowledge (at most 100; an empty list is refused). Baileys acknowledges + # individual messages, so without this only the newest message the engine still holds in + # memory gets a receipt. Ignored by whatsapp-web.js, whose own sendSeen is chat-level. + messageIds: NotRequired[list[str]] + + class SendChatStateRequest(TypedDict): chatId: Jid state: ChatState diff --git a/src/core/agent-tools/tools/session.tools.spec.ts b/src/core/agent-tools/tools/session.tools.spec.ts index 5a001b0e9..84cbf657b 100644 --- a/src/core/agent-tools/tools/session.tools.spec.ts +++ b/src/core/agent-tools/tools/session.tools.spec.ts @@ -90,10 +90,20 @@ describe('sessionTools', () => { sessionId: 's1', chatId: '628111@c.us', }); - expect(sendSeen).toHaveBeenCalledWith('s1', '628111@c.us'); + expect(sendSeen).toHaveBeenCalledWith('s1', '628111@c.us', undefined); expect(out).toEqual({ success: true }); }); + it('SessionMarkChatRead forwards the message ids it was given', async () => { + const sendSeen = jest.fn().mockResolvedValue(true); + await run(makeTools({ sendSeen } as unknown as SessionService).get('SessionMarkChatRead')!, { + sessionId: 's1', + chatId: '628111@c.us', + messageIds: ['M1', 'M2'], + }); + expect(sendSeen).toHaveBeenCalledWith('s1', '628111@c.us', ['M1', 'M2']); + }); + it('SessionMarkChatUnread maps the markUnread result to a success field', async () => { const markUnread = jest.fn().mockResolvedValue(true); const out = await run(makeTools({ markUnread } as unknown as SessionService).get('SessionMarkChatUnread')!, { diff --git a/src/core/agent-tools/tools/session.tools.ts b/src/core/agent-tools/tools/session.tools.ts index 0d9767a52..916d203e8 100644 --- a/src/core/agent-tools/tools/session.tools.ts +++ b/src/core/agent-tools/tools/session.tools.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { ApiKeyRole } from '../../../modules/auth/entities/api-key.entity'; import type { SessionService } from '../../../modules/session/session.service'; import { SessionResponseDto } from '../../../modules/session/dto/session-response.dto'; +import { MARK_READ_MESSAGE_IDS_MAX } from '../../../modules/session/dto/mark-chat-read.dto'; import { defineTool, type AnyToolDescriptor } from '../tool-descriptor'; const sessionId = z.string().min(1).describe('Session UUID (the session id, not the name)'); @@ -89,8 +90,18 @@ export function sessionTools(session: SessionService): AnyToolDescriptor[] { inputSchema: z.object({ sessionId, chatId: z.string().describe('Chat JID (e.g. 1234567890@c.us)'), + messageIds: z + .array(z.string()) + .nonempty() + .max(MARK_READ_MESSAGE_IDS_MAX) + .optional() + .describe( + 'Specific message IDs to acknowledge. Baileys acknowledges individual messages, so without ' + + 'this only the newest message still held in memory gets a receipt.', + ), }), - handler: input => session.sendSeen(input.sessionId, input.chatId).then(success => ({ success })), + handler: input => + session.sendSeen(input.sessionId, input.chatId, input.messageIds).then(success => ({ success })), }), defineTool({ name: 'SessionMarkChatUnread', diff --git a/src/engine/adapters/baileys-contacts.ts b/src/engine/adapters/baileys-contacts.ts index 19f48e6a0..c176bef37 100644 --- a/src/engine/adapters/baileys-contacts.ts +++ b/src/engine/adapters/baileys-contacts.ts @@ -1,4 +1,4 @@ -import type { WAMessageKey, WASocket } from '@whiskeysockets/baileys'; +import type { WAMessage, WAMessageKey, WASocket } from '@whiskeysockets/baileys'; import { ChatSummary, Contact, MediaInput } from '../interfaces/whatsapp-engine.interface'; import { resolveMediaBuffer } from './baileys-messaging'; import { type createLogger } from '../../common/services/logger.service'; @@ -23,6 +23,12 @@ export interface BaileysContactsHost { listChats(): ChatSummary[]; /** The chat's last known message (the handle readMessages/chatModify need), or null when none. */ lastMessage(chatId: string): { key: WAMessageKey; timestamp: number } | null; + /** + * Stored copies of the named messages, in whatever order the store returns them. Ids the store + * has never seen are absent, so neither the length nor the order tracks the input. `undefined` + * when the session was built without a message store. + */ + getStoredMessages(messageIds: string[]): Promise | undefined; /** Fold a neutral @c.us id to the engine @s.whatsapp.net form used as the app-state index key. */ toEngineJid(jid: string): string; /** Fold an engine jid back to the neutral dialect before it crosses the engine boundary. */ @@ -318,19 +324,50 @@ export class BaileysContacts { return this.host.listChats(); } - async sendSeen(chatId: string): Promise { + async sendSeen(chatId: string, messageIds?: string[]): Promise { this.host.ensureReady(); - const last = this.host.lastMessage(chatId); - if (!last) { + const keys = await this.receiptKeys(chatId, messageIds); + if (keys.length === 0) { return false; // nothing known to mark read } // readMessages reaches fetchPrivacySettings, which destructures the query result and throws a // raw TypeError on an unanswered one — no Boom, so nothing downstream can classify it. Marking // a chat read is idempotent, so bounding it is safe: a repeat costs nothing. - await this.confirmed(this.sock().readMessages([last.key]), 'the read receipt'); + await this.confirmed(this.sock().readMessages(keys), 'the read receipt'); return true; } + /** + * The keys a read receipt should acknowledge: the messages the caller named, or the chat's newest + * one when it named none. + * + * Baileys acknowledges individual messages, not chats, and the receipt node enumerates ids rather + * than carrying a read-up-to watermark. Caller-supplied ids are what make that correct: the + * lastMessage fallback holds only the newest message, so a burst of three inbound messages left + * the first two permanently unread, and a session that restarted since the message arrived had + * nothing to acknowledge at all (a silent false under a 200). + * + * Named ids are resolved through the message store rather than synthesised, because the receipt + * needs the whole key. A synthesised key carries no `participant`, so a group receipt names no + * sender; its hardcoded `fromMe: false` is wrong for an id that belongs to an outbound message; + * and its jid is whichever dialect the caller happened to send. The stored key has all three + * right. Ids the store has never seen — history backfill is emitted but not persisted — keep the + * synthesised key, which is what the 1:1 case ran on before. + */ + private async receiptKeys(chatId: string, messageIds?: string[]): Promise { + if (messageIds === undefined) { + const last = this.host.lastMessage(chatId); + return last ? [last.key] : []; + } + if (messageIds.length === 0) { + return []; // an explicit empty list asks for nothing to be acknowledged, not for the newest + } + const remoteJid = this.host.toEngineJid(chatId); + const stored = (await this.host.getStoredMessages(messageIds)) ?? []; + const keyById = new Map(stored.filter(msg => msg.key?.id).map(msg => [msg.key.id as string, msg.key])); + return messageIds.map(id => keyById.get(id) ?? { remoteJid, id, fromMe: false }); + } + async markUnread(chatId: string): Promise { this.host.ensureReady(); const last = this.host.lastMessage(chatId); diff --git a/src/engine/adapters/baileys-message-store.service.spec.ts b/src/engine/adapters/baileys-message-store.service.spec.ts index 65e5fa167..273f43a96 100644 --- a/src/engine/adapters/baileys-message-store.service.spec.ts +++ b/src/engine/adapters/baileys-message-store.service.spec.ts @@ -181,6 +181,39 @@ describe('BaileysMessageStoreService', () => { await expect(service.put('s1', msg('M1'))).rejects.toThrow('disk full'); }); + describe('getMessages', () => { + it('returns the batch in one query, skipping ids it has never seen', async () => { + await seedSession('s1'); + await service.put('s1', msg('M1')); + await service.put('s1', msg('M2')); + const found = await service.getMessages('s1', ['M1', 'MISSING', 'M2']); + expect(found.map(m => m.key.id).sort()).toEqual(['M1', 'M2']); + }); + + it('stays scoped to its own session', async () => { + await seedSession('s1'); + await seedSession('s2'); + await service.put('s2', msg('M1')); + expect(await service.getMessages('s1', ['M1'])).toEqual([]); + }); + + it('short-circuits on an empty or all-falsy id list instead of querying', async () => { + // An empty In() clause is a SQL syntax error on some drivers and matches everything on others. + const find = jest.spyOn(repo, 'find'); + expect(await service.getMessages('s1', [])).toEqual([]); + expect(await service.getMessages('s1', [''])).toEqual([]); + expect(find).not.toHaveBeenCalled(); + }); + + it('round-trips binary fields the same way getMessage does', async () => { + await seedSession('s1'); + await service.put('s1', msg('M1')); + const [found] = await service.getMessages('s1', ['M1']); + // mediaKey is off the public WAMessage type, like the fixture that wrote it. + expect(Buffer.isBuffer((found as unknown as { mediaKey: unknown }).mediaKey)).toBe(true); + }); + }); + it('clearSession removes only that session', async () => { await seedSession('s1'); await seedSession('s2'); diff --git a/src/engine/adapters/baileys-message-store.service.ts b/src/engine/adapters/baileys-message-store.service.ts index 1f4bb623d..5d3d210ca 100644 --- a/src/engine/adapters/baileys-message-store.service.ts +++ b/src/engine/adapters/baileys-message-store.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import type * as BaileysLib from '@whiskeysockets/baileys'; import type { WAMessage } from '@whiskeysockets/baileys'; import { BaileysStoredMessage } from './baileys-stored-message.entity'; @@ -99,6 +99,21 @@ export class BaileysMessageStoreService implements BaileysMessageStore { return JSON.parse(row.serializedMessage, BufferJSON.reviver) as WAMessage; } + async getMessages(sessionId: string, messageIds: string[]): Promise { + // One query for the whole batch: the read-receipt path resolves up to a hundred ids at a time, + // and a findOne apiece would be a hundred sequential round trips for a single request. + const ids = messageIds.filter(Boolean); + if (ids.length === 0) { + return []; + } + const rows = await this.repo.find({ where: { sessionId, waMessageId: In(ids) } }); + if (rows.length === 0) { + return []; + } + const { BufferJSON } = await this.loadLib(); + return rows.map(row => JSON.parse(row.serializedMessage, BufferJSON.reviver) as WAMessage); + } + async clearSession(sessionId: string): Promise { await this.repo.delete({ sessionId }); } diff --git a/src/engine/adapters/baileys-send-seen.spec.ts b/src/engine/adapters/baileys-send-seen.spec.ts index 992a4c658..b9e735399 100644 --- a/src/engine/adapters/baileys-send-seen.spec.ts +++ b/src/engine/adapters/baileys-send-seen.spec.ts @@ -1,4 +1,4 @@ -import type { WASocket } from '@whiskeysockets/baileys'; +import type { WAMessage, WASocket } from '@whiskeysockets/baileys'; import { BaileysContacts, BaileysContactsHost } from './baileys-contacts'; import { EngineTransportError } from '../../common/errors/engine-transport.error'; @@ -12,10 +12,19 @@ import { EngineTransportError } from '../../common/errors/engine-transport.error */ const never = (): Promise => new Promise(() => undefined); -function contacts(sock: Record, budgetMs: number): BaileysContacts { - const host = { +/** + * The real fold, not an identity stub. A stub that returned its argument unchanged let the whole + * suite pass with the `toEngineJid` call deleted outright, and left the fallback case asserting + * `@s.whatsapp.net` while the supplied-id cases asserted `@c.us` for the same chat. + */ +const toEngineJid = (jid: string): string => { + const [userPart, host] = jid.split('@'); + return host === 'c.us' || host === 's.whatsapp.net' ? `${userPart}@s.whatsapp.net` : jid; +}; + +function makeHost(overrides: Partial>): BaileysContactsHost { + return { ensureReady: () => undefined, - getSocket: () => sock as unknown as WASocket, logger: { warn: jest.fn(), debug: jest.fn(), info: jest.fn(), error: jest.fn() }, normalizedSelfJid: () => '628177@s.whatsapp.net', listContacts: () => [], @@ -23,11 +32,19 @@ function contacts(sock: Record, budgetMs: number): BaileysCon resolvePhone: () => null, listChats: () => [], lastMessage: () => ({ key: { id: 'M1', remoteJid: '628123@s.whatsapp.net' }, timestamp: 1 }), - toEngineJid: (j: string) => j, + getStoredMessages: () => Promise.resolve([]), + toEngineJid, + ...overrides, } as unknown as BaileysContactsHost; - return new BaileysContacts(host, budgetMs); } +function contacts(sock: Record, budgetMs: number): BaileysContacts { + return new BaileysContacts(makeHost({ getSocket: () => sock as unknown as WASocket }), budgetMs); +} + +/** A stored message as the message store hands it back: the whole key, not a synthesised one. */ +const stored = (key: WAMessage['key']): WAMessage => ({ key, message: {}, messageTimestamp: 1 }); + describe('sendSeen', () => { it('reports an unanswered read receipt instead of a bare 500', async () => { await expect(contacts({ readMessages: jest.fn(never) }, 15).sendSeen('628123@c.us')).rejects.toBeInstanceOf( @@ -40,13 +57,97 @@ describe('sendSeen', () => { await expect(contacts({ readMessages }, 500).sendSeen('628123@c.us')).resolves.toBe(true); }); + it('acknowledges every supplied message, not just the newest one', async () => { + // The whole point of the caller-supplied list: a burst of three inbound messages used to leave + // the first two unread forever, because only lastMessage() was ever acknowledged. + const readMessages = jest.fn().mockResolvedValue(undefined); + await expect(contacts({ readMessages }, 500).sendSeen('628123@c.us', ['M1', 'M2', 'M3'])).resolves.toBe(true); + expect(readMessages).toHaveBeenCalledWith([ + { remoteJid: '628123@s.whatsapp.net', id: 'M1', fromMe: false }, + { remoteJid: '628123@s.whatsapp.net', id: 'M2', fromMe: false }, + { remoteJid: '628123@s.whatsapp.net', id: 'M3', fromMe: false }, + ]); + }); + + it('carries the participant on a group receipt, which a synthesised key cannot', async () => { + // A group receipt with no `participant` names no sender, so WhatsApp cannot attribute it and + // the message stays unread on the sender's side while the API answers success: true. + const readMessages = jest.fn().mockResolvedValue(undefined); + const host = makeHost({ + getSocket: () => ({ readMessages }) as unknown as WASocket, + getStoredMessages: () => + Promise.resolve([ + stored({ id: 'G1', remoteJid: '628999-1@g.us', fromMe: false, participant: '628123@s.whatsapp.net' }), + ]), + }); + await expect(new BaileysContacts(host, 500).sendSeen('628999-1@g.us', ['G1'])).resolves.toBe(true); + expect(readMessages).toHaveBeenCalledWith([ + { id: 'G1', remoteJid: '628999-1@g.us', fromMe: false, participant: '628123@s.whatsapp.net' }, + ]); + }); + + it('takes fromMe from the stored key rather than assuming inbound', async () => { + const readMessages = jest.fn().mockResolvedValue(undefined); + const host = makeHost({ + getSocket: () => ({ readMessages }) as unknown as WASocket, + getStoredMessages: () => + Promise.resolve([stored({ id: 'O1', remoteJid: '628123@s.whatsapp.net', fromMe: true })]), + }); + await expect(new BaileysContacts(host, 500).sendSeen('628123@c.us', ['O1'])).resolves.toBe(true); + expect(readMessages).toHaveBeenCalledWith([{ id: 'O1', remoteJid: '628123@s.whatsapp.net', fromMe: true }]); + }); + + it('synthesises a key only for ids the store has never seen', async () => { + // History-sync backfill is emitted but never persisted, so a legitimate id can miss. Falling + // back keeps the 1:1 case working instead of silently dropping the message from the receipt. + const readMessages = jest.fn().mockResolvedValue(undefined); + const host = makeHost({ + getSocket: () => ({ readMessages }) as unknown as WASocket, + getStoredMessages: () => + Promise.resolve([stored({ id: 'K1', remoteJid: '628123@s.whatsapp.net', fromMe: true })]), + }); + await expect(new BaileysContacts(host, 500).sendSeen('628123@c.us', ['K1', 'MISSING'])).resolves.toBe(true); + expect(readMessages).toHaveBeenCalledWith([ + { id: 'K1', remoteJid: '628123@s.whatsapp.net', fromMe: true }, + { remoteJid: '628123@s.whatsapp.net', id: 'MISSING', fromMe: false }, + ]); + }); + + it('marks supplied messages even when the store has nothing cached', async () => { + // The restart case: the in-memory store is empty, so the old code returned false under a 200 + // and no receipt was ever sent. A caller that persisted the IDs is not subject to that. + const readMessages = jest.fn().mockResolvedValue(undefined); + const host = makeHost({ getSocket: () => ({ readMessages }) as unknown as WASocket, lastMessage: () => null }); + await expect(new BaileysContacts(host, 500).sendSeen('628123@c.us', ['M9'])).resolves.toBe(true); + expect(readMessages).toHaveBeenCalledWith([{ remoteJid: '628123@s.whatsapp.net', id: 'M9', fromMe: false }]); + }); + + it('works without a message store at all', async () => { + const readMessages = jest.fn().mockResolvedValue(undefined); + const host = makeHost({ + getSocket: () => ({ readMessages }) as unknown as WASocket, + getStoredMessages: () => undefined, + }); + await expect(new BaileysContacts(host, 500).sendSeen('628123@c.us', ['M9'])).resolves.toBe(true); + expect(readMessages).toHaveBeenCalledWith([{ remoteJid: '628123@s.whatsapp.net', id: 'M9', fromMe: false }]); + }); + + it('acknowledges nothing when the caller supplies an empty list', async () => { + // An empty list is a caller that computed its unread set and found none. Folding it into the + // no-list branch would acknowledge the newest message, which is the opposite of the request. + const readMessages = jest.fn().mockResolvedValue(undefined); + await expect(contacts({ readMessages }, 500).sendSeen('628123@c.us', [])).resolves.toBe(false); + expect(readMessages).not.toHaveBeenCalled(); + }); + + it('falls back to the cached last message when no list is supplied', async () => { + const readMessages = jest.fn().mockResolvedValue(undefined); + await expect(contacts({ readMessages }, 500).sendSeen('628123@c.us')).resolves.toBe(true); + expect(readMessages).toHaveBeenCalledWith([{ id: 'M1', remoteJid: '628123@s.whatsapp.net' }]); + }); + it('still short-circuits when there is no last message to mark', async () => { - const host = { - ensureReady: () => undefined, - getSocket: () => ({}) as unknown as WASocket, - logger: { warn: jest.fn(), debug: jest.fn(), info: jest.fn(), error: jest.fn() }, - lastMessage: () => null, - } as unknown as BaileysContactsHost; + const host = makeHost({ getSocket: () => ({}) as unknown as WASocket, lastMessage: () => null }); await expect(new BaileysContacts(host, 15).sendSeen('628123@c.us')).resolves.toBe(false); }); }); diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 029ac4d70..5e790a664 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -150,6 +150,7 @@ import { loadRemoteMediaBuffer } from '../../common/media/load-remote-media'; const fakeStore = { put: jest.fn().mockResolvedValue(undefined), getMessage: jest.fn(), + getMessages: jest.fn().mockResolvedValue([]), clearSession: jest.fn().mockResolvedValue(undefined), }; diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 2f78c75ea..f577fde50 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -153,6 +153,7 @@ export class BaileysAdapter implements IWhatsAppEngine { toEngineJid: jid => this.sessionStore.toEngineJid(jid), getEphemeralExpiration: chatId => this.sessionStore.getEphemeralExpiration(chatId), getStoredMessage: messageId => this.config.messageStore?.getMessage(this.config.dbSessionId, messageId), + getStoredMessages: messageIds => this.config.messageStore?.getMessages(this.config.dbSessionId, messageIds), recordLidMapping: (lid, pn) => this.sessionStore.addLidMappings([{ lid: `${lid.split('@')[0].split(':')[0]}@lid`, pn }]), mapMessage: (msg, contentType, opts) => this.events.mapMessage(msg, contentType, opts), @@ -509,8 +510,8 @@ export class BaileysAdapter implements IWhatsAppEngine { return this.messaging.subscribeToPresence(chatId); } - async sendSeen(chatId: string): Promise { - return this.contacts.sendSeen(chatId); + async sendSeen(chatId: string, messageIds?: string[]): Promise { + return this.contacts.sendSeen(chatId, messageIds); } async markUnread(chatId: string): Promise { diff --git a/src/engine/adapters/whatsapp-web-js.adapter.ts b/src/engine/adapters/whatsapp-web-js.adapter.ts index 20d0cdd4f..d6fa18e09 100644 --- a/src/engine/adapters/whatsapp-web-js.adapter.ts +++ b/src/engine/adapters/whatsapp-web-js.adapter.ts @@ -889,6 +889,8 @@ export class WhatsAppWebJsAdapter extends EventEmitter implements IWhatsAppEngin return this.chats.getChats(); } + // messageIds is dropped on purpose: whatsapp-web.js exposes only a chat-level sendSeen, which + // already marks every message in the chat. sendSeen(chatId: string): Promise { return this.chats.sendSeen(chatId); } diff --git a/src/engine/builtin/baileys/index.spec.ts b/src/engine/builtin/baileys/index.spec.ts index c1c9d3f6f..f4c6bd450 100644 --- a/src/engine/builtin/baileys/index.spec.ts +++ b/src/engine/builtin/baileys/index.spec.ts @@ -59,7 +59,7 @@ describe('BaileysPlugin.createEngine (opaque config)', () => { }); it('passes the message store to the adapter', () => { - const store = { put: jest.fn(), getMessage: jest.fn(), clearSession: jest.fn() }; + const store = { put: jest.fn(), getMessage: jest.fn(), getMessages: jest.fn(), clearSession: jest.fn() }; const plugin = new BaileysPlugin(store); plugin.createEngine({ sessionId: 'sess-1' }); expect(BaileysAdapter).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-1', messageStore: store })); diff --git a/src/engine/interfaces/whatsapp-engine.interface.ts b/src/engine/interfaces/whatsapp-engine.interface.ts index 2a1ef12e8..7a2557159 100644 --- a/src/engine/interfaces/whatsapp-engine.interface.ts +++ b/src/engine/interfaces/whatsapp-engine.interface.ts @@ -1297,7 +1297,12 @@ export interface CatalogCapability { export interface ChatCapability { getChats(): Promise; - sendSeen(chatId: string): Promise; + /** + * `messageIds` names exactly which messages to acknowledge. Engines that acknowledge per + * message (Baileys) need it to mark a burst, or anything at all after a restart; engines with a + * chat-level receipt (whatsapp-web.js) ignore it. + */ + sendSeen(chatId: string, messageIds?: string[]): Promise; markUnread(chatId: string): Promise; diff --git a/src/engine/types/baileys.types.ts b/src/engine/types/baileys.types.ts index 418aa697b..dfa2d11ce 100644 --- a/src/engine/types/baileys.types.ts +++ b/src/engine/types/baileys.types.ts @@ -10,6 +10,11 @@ export interface BaileysMessageStore { put(sessionId: string, msg: WAMessage): Promise; /** Look up a previously-seen message by its id, or null. */ getMessage(sessionId: string, messageId: string): Promise; + /** + * Look up many messages in one query. Ids the store has never seen are simply absent from the + * result, so the caller cannot assume the order or the length matches its input. + */ + getMessages(sessionId: string, messageIds: string[]): Promise; /** Remove all stored messages for a session (called on logout). */ clearSession(sessionId: string): Promise; } diff --git a/src/modules/session/dto/index.ts b/src/modules/session/dto/index.ts index 42b372d44..ceb019f79 100644 --- a/src/modules/session/dto/index.ts +++ b/src/modules/session/dto/index.ts @@ -2,6 +2,7 @@ export * from './create-session.dto'; export * from './session-config.dto'; export * from './session-response.dto'; export * from './mark-chat-read.dto'; +export * from './mark-chat-unread.dto'; export * from './archive-chat.dto'; export * from './mute-chat.dto'; export * from './pin-chat.dto'; diff --git a/src/modules/session/dto/mark-chat-read.dto.spec.ts b/src/modules/session/dto/mark-chat-read.dto.spec.ts index f62f04268..24a88ff3f 100644 --- a/src/modules/session/dto/mark-chat-read.dto.spec.ts +++ b/src/modules/session/dto/mark-chat-read.dto.spec.ts @@ -1,6 +1,6 @@ import { validateSync } from 'class-validator'; import { plainToInstance } from 'class-transformer'; -import { MarkChatReadDto } from './mark-chat-read.dto'; +import { MARK_READ_MESSAGE_IDS_MAX, MarkChatReadDto } from './mark-chat-read.dto'; const errorCount = (chatId: unknown): number => validateSync(plainToInstance(MarkChatReadDto, { chatId })).length; @@ -21,3 +21,35 @@ describe('MarkChatReadDto chatId validation', () => { }, ); }); + +const messageIdErrors = (messageIds: unknown): number => + validateSync(plainToInstance(MarkChatReadDto, { chatId: '1234567890@c.us', messageIds })).length; + +describe('MarkChatReadDto messageIds validation', () => { + it('accepts the field being absent — it is what every pre-existing caller sends', () => { + expect(validateSync(plainToInstance(MarkChatReadDto, { chatId: '1234567890@c.us' })).length).toBe(0); + }); + + it('accepts a list of ids', () => { + expect(messageIdErrors(['3EB0C767D26B8A3F1A2B', '3EB0C767D26B8A3F1A2C'])).toBe(0); + }); + + it('rejects an empty list rather than reading it as "the newest message"', () => { + // The engine treats a missing list as "acknowledge the newest message". A caller that computed + // its unread set and got none back must not land in that branch. + expect(messageIdErrors([])).toBeGreaterThan(0); + }); + + it('accepts a full batch and rejects one over the cap', () => { + const id = (n: number): string => `MSG${n}`; + expect(messageIdErrors(Array.from({ length: MARK_READ_MESSAGE_IDS_MAX }, (_, i) => id(i)))).toBe(0); + expect(messageIdErrors(Array.from({ length: MARK_READ_MESSAGE_IDS_MAX + 1 }, (_, i) => id(i)))).toBeGreaterThan(0); + }); + + it.each([[[' ']], [['']], [['has space']], [['ok', ' ']], [[123]], ['not-an-array']])( + 'rejects a malformed entry: %j', + messageIds => { + expect(messageIdErrors(messageIds)).toBeGreaterThan(0); + }, + ); +}); diff --git a/src/modules/session/dto/mark-chat-read.dto.ts b/src/modules/session/dto/mark-chat-read.dto.ts index 08511923e..d47648721 100644 --- a/src/modules/session/dto/mark-chat-read.dto.ts +++ b/src/modules/session/dto/mark-chat-read.dto.ts @@ -1,5 +1,12 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString, Matches } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator'; + +/** + * Ceiling on one request's receipt batch, so a single call cannot hand the engine an unbounded key + * list to round-trip. A caller with more than this to acknowledge is catching up on history rather + * than marking a conversation read. + */ +export const MARK_READ_MESSAGE_IDS_MAX = 100; export class MarkChatReadDto { @ApiProperty({ @@ -15,4 +22,32 @@ export class MarkChatReadDto { message: 'chatId must be a valid chat JID in the form localpart@host', }) chatId!: string; + + @ApiPropertyOptional({ + description: + 'Specific message IDs to mark read. Baileys acknowledges individual messages, so without this ' + + 'only the newest message the engine still holds in memory gets a receipt — a burst leaves its ' + + 'earlier messages unread forever, and a restarted session has no message to acknowledge at all. ' + + 'Callers that persist inbound message IDs should send them here. Ignored by whatsapp-web.js, ' + + 'whose own sendSeen is chat-level.', + type: [String], + // @ArrayMaxSize is runtime-only; @nestjs/swagger does not derive maxItems from it, so without + // this the published schema advertises an unbounded array and a caller batching more than the + // cap discovers the limit as a 400. + maxItems: MARK_READ_MESSAGE_IDS_MAX, + example: ['3EB0C767D26B8A3F1A2B', '3EB0C767D26B8A3F1A2C'], + }) + @IsOptional() + @IsArray() + // An empty array asks for nothing to be acknowledged. Rejected rather than accepted, because the + // engine reads a missing list as "the newest message" and the two must not collapse: a caller that + // computed its unread set and got none back would otherwise acknowledge a message it never named. + @ArrayNotEmpty() + @ArrayMaxSize(MARK_READ_MESSAGE_IDS_MAX) + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + // Engine-neutral structural check: a message id is one non-whitespace token. @IsNotEmpty alone + // accepts ' ', which would reach sendNode as a receipt key. + @Matches(/^\S{1,128}$/, { each: true, message: 'each messageIds entry must be a non-empty id with no whitespace' }) + messageIds?: string[]; } diff --git a/src/modules/session/dto/mark-chat-unread.dto.ts b/src/modules/session/dto/mark-chat-unread.dto.ts new file mode 100644 index 000000000..4d8ca6d69 --- /dev/null +++ b/src/modules/session/dto/mark-chat-unread.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, Matches } from 'class-validator'; + +/** + * Deliberately separate from {@link MarkChatReadDto}, which the unread route used to share. The read + * route accepts `messageIds`; the unread route has nothing to do with them and ignores them, so + * sharing the class published a field on `POST /chats/unread` that it silently discarded. + */ +export class MarkChatUnreadDto { + @ApiProperty({ + description: "Chat ID in the active engine's native format (e.g. 1234567890@c.us on whatsapp-web.js)", + example: '1234567890@c.us', + }) + @IsString() + @IsNotEmpty() + // Engine-neutral structural check (localpart@host, no whitespace) so a different engine's JID + // scheme (e.g. Baileys 1234@s.whatsapp.net) is accepted too; the adapter normalises further. + @Matches(/^[^\s@]+@[^\s@]+$/, { + message: 'chatId must be a valid chat JID in the form localpart@host', + }) + chatId!: string; +} diff --git a/src/modules/session/session.controller.ts b/src/modules/session/session.controller.ts index b43955db4..7f5b9aab0 100644 --- a/src/modules/session/session.controller.ts +++ b/src/modules/session/session.controller.ts @@ -21,6 +21,7 @@ import { SessionResponseDto, QRCodeResponseDto, MarkChatReadDto, + MarkChatUnreadDto, SubscribePresenceDto, SetOwnPresenceDto, ChatPresenceResponseDto, @@ -469,7 +470,7 @@ export class SessionController { @Param('sessionId', ParseUUIDPipe) id: string, @Body() dto: MarkChatReadDto, ): Promise<{ success: boolean }> { - const success = await this.sessionService.sendSeen(id, dto.chatId); + const success = await this.sessionService.sendSeen(id, dto.chatId, dto.messageIds); return { success }; } @@ -572,7 +573,7 @@ export class SessionController { @ApiResponse({ status: 409, description: ENGINE_NOT_READY_409 }) async markChatUnread( @Param('sessionId', ParseUUIDPipe) id: string, - @Body() dto: MarkChatReadDto, + @Body() dto: MarkChatUnreadDto, ): Promise<{ success: boolean }> { const success = await this.sessionService.markUnread(id, dto.chatId); return { success }; diff --git a/src/modules/session/session.service.spec.ts b/src/modules/session/session.service.spec.ts index 657f619d6..73713e650 100644 --- a/src/modules/session/session.service.spec.ts +++ b/src/modules/session/session.service.spec.ts @@ -5151,10 +5151,25 @@ describe('SessionService', () => { const result = await service.sendSeen('sess-uuid-1', '123@c.us'); - expect(mockEngine.sendSeen).toHaveBeenCalledWith('123@c.us'); + // Second argument is the optional messageIds, absent here — the engine falls back to the + // newest message it still holds. + expect(mockEngine.sendSeen).toHaveBeenCalledWith('123@c.us', undefined); expect(result).toBe(true); }); + it('should forward caller-supplied message IDs to the engine', async () => { + const session = createMockSession(); + (repository.findOne as jest.Mock).mockResolvedValue(session); + (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); + + await service.start('sess-uuid-1'); + mockEngine.sendSeen.mockResolvedValue(true); + + await service.sendSeen('sess-uuid-1', '123@c.us', ['M1', 'M2']); + + expect(mockEngine.sendSeen).toHaveBeenCalledWith('123@c.us', ['M1', 'M2']); + }); + it('should throw BadRequestException when session is not started', async () => { const session = createMockSession(); (repository.findOne as jest.Mock).mockResolvedValue(session); diff --git a/src/modules/session/session.service.ts b/src/modules/session/session.service.ts index f0cce08ea..3c48ff734 100644 --- a/src/modules/session/session.service.ts +++ b/src/modules/session/session.service.ts @@ -664,11 +664,11 @@ export class SessionService implements OnModuleDestroy, OnModuleInit, OnApplicat return this.presence.get(id, chatId); } - async sendSeen(id: string, chatId: string): Promise { + async sendSeen(id: string, chatId: string, messageIds?: string[]): Promise { await this.findOne(id); // Verify session exists const engine = this.requireEngine(id); - return engine.sendSeen(chatId); + return engine.sendSeen(chatId, messageIds); } async markUnread(id: string, chatId: string): Promise {