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
11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -299,10 +299,17 @@ jobs:
- name: Install sqlite3
# smoke-test-backup-restore.sh skips its sqlite3 .backup roundtrip case (with a notice) when
# the host lacks the CLI; installing it here exercises that path on every run instead.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends sqlite3
# Bounded, and skipped when the runner image already ships the CLI. An unbounded apt-get
# against a slow mirror held this job open for over an hour with every other job already
# green, so the run never reported and main went without a signal.
timeout-minutes: 5
run: command -v sqlite3 >/dev/null || { sudo apt-get update && sudo apt-get install -y --no-install-recommends sqlite3; }

- name: Install shellcheck
run: sudo apt-get install -y --no-install-recommends shellcheck
# Runs its own update rather than inheriting one from the step above, which no longer
# always runs.
timeout-minutes: 5
run: command -v shellcheck >/dev/null || { sudo apt-get update && sudo apt-get install -y --no-install-recommends shellcheck; }

- name: shellcheck every shell script
# Widened from the original three (#926) now that the rest are clean too. The narrow scope was
Expand Down
11 changes: 9 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -278,10 +278,17 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Install sqlite3
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends sqlite3
# Bounded, and skipped when the runner image already ships the CLI. An unbounded apt-get
# against a slow mirror held this job open for over an hour with every other job already
# green, so the run never reported and main went without a signal.
timeout-minutes: 5
run: command -v sqlite3 >/dev/null || { sudo apt-get update && sudo apt-get install -y --no-install-recommends sqlite3; }

- name: Install shellcheck
run: sudo apt-get install -y --no-install-recommends shellcheck
# Runs its own update rather than inheriting one from the step above, which no longer
# always runs.
timeout-minutes: 5
run: command -v shellcheck >/dev/null || { sudo apt-get update && sudo apt-get install -y --no-install-recommends shellcheck; }

- name: shellcheck every shell script
run: shellcheck docker-entrypoint.sh scripts/*.sh
Expand Down
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `POST /sessions/{sessionId}/chats/read` takes an optional `messageIds` array (up to 100) naming which messages to acknowledge. Baileys acknowledges individual messages, so without it a burst left its earlier messages unread. Ids resolve through the message store, so a group receipt carries its `participant`. Available on the agent tool and all five clients; ignored by whatsapp-web.js. Thanks @m7fz7.

### Changed

- `POST /sessions/{sessionId}/chats/unread` publishes its own `MarkChatUnreadDto` rather than sharing `MarkChatReadDto`. The body is unchanged (`chatId` alone), but a generated client sees the schema under a new name.
- ⚠️ **Breaking (Go and Java clients).** `subscribePresence` takes its own `SubscribePresenceRequest` rather than the shared `MarkChatRequest`, which now serves `markUnread` alone. Swap the type at the call site; the wire body is unchanged. `SubscribePresenceDto` had no contract-gate coverage while one type stood for two routes.

### 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.
- Outbound webhook deliveries survive a hard crash. Fan-out is fire-and-forget, so a crash between persisting a message and completing its POST lost the delivery with no record in either mode, while the documented contract promises at-least-once. Each delivery is now recorded before it is attempted, retired once the queue or the inline send owns it, and a bounded sweep replays whatever is left stranded using the stored idempotency key so the retry stays deduplicable at the receiver.
- Outbound webhook deliveries survive a hard crash. Fan-out was fire-and-forget, so a crash between persisting a message and completing its POST lost the delivery, against a documented at-least-once contract. Deliveries are now recorded before they are attempted, and a bounded sweep replays whatever is stranded under its stored idempotency key.
- Settled outbound delivery records are pruned after `WEBHOOK_OUTBOX_RETENTION_DAYS` (default 7). A record that can still be replayed is never pruned on age, and a non-positive window falls back to the default rather than letting the table grow without bound.
- `PLUGIN_STATE_DIR` moves the plugin registry and per-plugin storage off the default `./data`. It was the one piece of state with no path knob, so a test run rewrote the developer's own registry and every suite in that run inherited whatever the previous one left in it.
- `PLUGIN_STATE_DIR` moves the plugin registry and per-plugin storage off the default `./data`. It was the one piece of state with no path knob, so a test run rewrote the developer's own registry.
- The e2e lane sweeps the throwaway state roots it creates. Each suite gets its own, nothing removed them, and the temp directory accumulated hundreds of entries over a few days of runs.
- e2e assertions are no longer answered by unrelated processes on the host. supertest binds its per-request listener to the wildcard address and then dials 127.0.0.1, which on macOS lets a process holding that port on 127.0.0.1 answer instead. Each suite's server now listens on loopback during init, which supertest reuses.
- A stalled `apt-get` can no longer hold a CI run open. The scripts-smoke job installed sqlite3 and shellcheck unbounded, so a slow mirror held two main runs past an hour with every other job already green. Both steps now time out and skip the install when the runner already ships the tool.

### Tests

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
17 changes: 13 additions & 4 deletions docs/09-testing-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,10 @@ E2E smoke tests live in `test/` and use `test/jest-e2e.json`.

> **They run one at a time (`maxWorkers: 1`), and that is a correctness requirement, not a
> performance preference.** Each suite boots a real application, and not every piece of application
> state is redirected to a per-worker location. `dataDir` is a hard-coded `./data` with no
> environment lever, so every worker's plugin loader read-modify-writes the same
> `data/plugins/registry.json`. Measured on a single parallel run: 52 writes from 12 processes, 30
> state is redirected to a per-worker location. The plugin registry once had no environment
> lever, so every worker's plugin loader read-modify-wrote the same
> `data/plugins/registry.json`. `PLUGIN_STATE_DIR` now redirects it and each suite takes its own state
> roots, so that particular collision is closed. Measured on a single parallel run: 52 writes from 12 processes, 30
> of them within 500ms of a write by a different process. Individual writes are atomic; the
> read-modify-write cycle is not.
>
Expand All @@ -137,7 +138,15 @@ E2E smoke tests live in `test/` and use `test/jest-e2e.json`.
> reproduced when a suite ran on its own, which is what made it look like flakiness. Serially it
> does not occur.
>
> Adding a suite is safe. Restoring parallelism is not, until each worker gets its own data root.
> Adding a suite is safe. Restoring parallelism has not been retried since those roots landed, so it
> is untested rather than known-safe.
>
> A second requirement has nothing to do with parallelism: each suite's server is put on a **loopback**
> port while it initialises (`test/setup-e2e.ts`). Left alone, supertest starts a listener per request
> on the wildcard address and then dials 127.0.0.1, and macOS both permits that bind over a port
> another process holds on 127.0.0.1 specifically and routes the connection to the more specific
> holder. An assertion then reads a status from an unrelated program on the host, which is why the lane
> could fail on a status no route can return. `setup-e2e-env.e2e-spec.ts` holds that contract.

```text
test/
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
24 changes: 16 additions & 8 deletions scripts/check-contract-shapes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ const MAPPINGS = {
GroupSubjectRequest: 'GroupSubjectDto',
GroupSummary: 'GroupSummaryDto',
JoinGroupRequest: 'JoinGroupDto',
MarkChatRequest: 'MarkChatReadDto',
MarkChatReadRequest: 'MarkChatReadDto',
MarkChatRequest: 'MarkChatUnreadDto',
SubscribePresenceRequest: 'SubscribePresenceDto',
MessageListResponse: 'MessageListResponseDto',
MessageRecord: 'MessageListItemDto',
MessageResponse: 'MessageResponseDto',
Expand Down Expand Up @@ -164,11 +166,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': 80,
'dashboard/src/services/api.ts': 20,
'sdk/python/openwa/types.py': 73,
'sdk/go': 74,
'sdk/java': 78,
'sdk/python/openwa/types.py': 75,
'sdk/go': 76,
'sdk/java': 80,
};

/** Known drift, deliberately not gated yet — each line is a to-adjudicate follow-up. */
Expand Down Expand Up @@ -230,7 +232,9 @@ const PYTHON_MAPPING = {
GroupParticipant: 'GroupParticipantDto',
GroupSummary: 'GroupSummaryDto',
JoinGroupRequest: 'JoinGroupDto',
MarkChatRequest: 'MarkChatReadDto',
MarkChatReadRequest: 'MarkChatReadDto',
MarkChatRequest: 'MarkChatUnreadDto',
SubscribePresenceRequest: 'SubscribePresenceDto',
MessageListResponse: 'MessageListResponseDto',
MessageRecord: 'MessageListItemDto',
MessageResponse: 'MessageResponseDto',
Expand Down Expand Up @@ -307,7 +311,9 @@ const GO_MAPPING = {
GroupParticipant: 'GroupParticipantDto',
GroupSummary: 'GroupSummaryDto',
JoinGroupRequest: 'JoinGroupDto',
MarkChatRequest: 'MarkChatReadDto',
MarkChatReadRequest: 'MarkChatReadDto',
MarkChatRequest: 'MarkChatUnreadDto',
SubscribePresenceRequest: 'SubscribePresenceDto',
MessageListResponse: 'MessageListResponseDto',
MessageRecord: 'MessageListItemDto',
MessageResponse: 'MessageResponseDto',
Expand Down Expand Up @@ -388,7 +394,9 @@ const JAVA_MAPPING = {
GroupSubjectRequest: 'GroupSubjectDto',
GroupSummary: 'GroupSummaryDto',
JoinGroupRequest: 'JoinGroupDto',
MarkChatRequest: 'MarkChatReadDto',
MarkChatReadRequest: 'MarkChatReadDto',
MarkChatRequest: 'MarkChatUnreadDto',
SubscribePresenceRequest: 'SubscribePresenceDto',
MessageListResponse: 'MessageListResponseDto',
MessageRecord: 'MessageListItemDto',
MessageResponse: 'MessageResponseDto',
Expand Down
4 changes: 2 additions & 2 deletions sdk/go/chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func (s *ChatsService) List(ctx context.Context, sessionID string, query *ListCh
// The subscription belongs to the connection and does NOT survive a restart or an automatic
// reconnect, so re-issue it when the session comes back. Subscribe per chat: WhatsApp emits an
// update on every transition, so a broad subscription is a firehose. whatsapp-web.js answers 501.
func (s *ChatsService) SubscribePresence(ctx context.Context, sessionID string, body MarkChatRequest) (*SuccessResult, error) {
func (s *ChatsService) SubscribePresence(ctx context.Context, sessionID string, body SubscribePresenceRequest) (*SuccessResult, error) {
var out SuccessResult
err := s.client.do(ctx, "POST", "/api/sessions/"+pathEscape(sessionID)+"/presence/subscribe", nil, body, &out)
return &out, err
Expand All @@ -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
4 changes: 2 additions & 2 deletions sdk/go/routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ 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.SubscribePresence", func(c *Client) { c.Chats.SubscribePresence(ctx, "s1", MarkChatRequest{}) }, "POST", "/api/sessions/s1/presence/subscribe"},
{"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", SubscribePresenceRequest{}) }, "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"},
{"Channels.Mute", func(c *Client) { c.Channels.Mute(ctx, "s1", "ch1", MuteChannelRequest{}) }, "POST", "/api/sessions/s1/channels/ch1/mute"},
Expand Down
16 changes: 15 additions & 1 deletion sdk/go/types_chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,25 @@ type SetOwnPresenceRequest struct {
Available bool `json:"available"`
}

// MarkChatRequest marks a chat read/unread.
// MarkChatRequest marks a chat unread.
type MarkChatRequest struct {
ChatID string `json:"chatId"`
}

// SubscribePresenceRequest subscribes to a chat's presence.
type SubscribePresenceRequest 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
Loading
Loading