Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 10 additions & 5 deletions docs/06-api-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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" }
```
Expand Down
5 changes: 4 additions & 1 deletion docs/07-api-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
27 changes: 26 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1237,7 +1237,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MarkChatReadDto"
"$ref": "#/components/schemas/MarkChatUnreadDto"
}
}
}
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": {
Expand Down
20 changes: 12 additions & 8 deletions scripts/check-contract-shapes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ const MAPPINGS = {
GroupSubjectRequest: 'GroupSubjectDto',
GroupSummary: 'GroupSummaryDto',
JoinGroupRequest: 'JoinGroupDto',
MarkChatRequest: 'MarkChatReadDto',
MarkChatReadRequest: 'MarkChatReadDto',
MarkChatRequest: 'MarkChatUnreadDto',
MessageListResponse: 'MessageListResponseDto',
MessageRecord: 'MessageListItemDto',
MessageResponse: 'MessageResponseDto',
Expand Down Expand Up @@ -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. */
Expand Down Expand 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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion sdk/go/chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
2 changes: 1 addition & 1 deletion sdk/go/routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
11 changes: 10 additions & 1 deletion sdk/go/types_chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> messageIds) {
public static Builder builder() {
return new Builder();
}

public static final class Builder {
private String chatId;
private List<String> 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<String> v) {
this.messageIds = v;
return this;
}

public MarkChatReadRequest build() {
return new MarkChatReadRequest(chatId, messageIds);
}
}
}
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"));
Expand Down
3 changes: 2 additions & 1 deletion sdk/javascript/src/resources/chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
ChatSummary,
DeleteChatRequest,
MarkChatRequest,
MarkChatReadRequest,
ChatPresence,
SendChatStateRequest,
SuccessResult,
Expand Down Expand Up @@ -67,7 +68,7 @@ export class ChatsResource {
}

/** Mark a chat as read/seen. */
markRead(sessionId: string, body: MarkChatRequest): Promise<SuccessResult> {
markRead(sessionId: string, body: MarkChatReadRequest): Promise<SuccessResult> {
return this.client.request<SuccessResult>({
method: 'POST',
path: `/api/sessions/${encodeSegment(sessionId)}/chats/read`,
Expand Down
13 changes: 13 additions & 0 deletions sdk/javascript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion sdk/python/openwa/resources/chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
MuteChatRequest,
ChatSummary,
DeleteChatRequest,
MarkChatReadRequest,
MarkChatRequest,
SendChatStateRequest,
SuccessResult,
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions sdk/python/openwa/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/core/agent-tools/tools/session.tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')!, {
Expand Down
Loading
Loading