diff --git a/.env.example b/.env.example index 76a94ddcc..3883a8437 100644 --- a/.env.example +++ b/.env.example @@ -689,9 +689,11 @@ PLUGINS_DIR=./data/plugins # Plugin directory (default: ./data/plugins) # this one. # Moving it does NOT carry the existing state across: point it at an empty directory and the # gateway starts with no record of installed plugins, even though their packages are still under -# PLUGINS_DIR. Move the whole `/plugins` tree yourself when you change this, not just -# registry.json: each plugin's persisted ctx.storage lives beside it as `/key-*.json`, -# so carrying the registry alone leaves every plugin enabled but with its state gone. +# PLUGINS_DIR. Copy `registry.json` and every `/key-*.json` from `/plugins` +# into `/plugins` when you change this: carrying the registry alone leaves each plugin +# enabled with its persisted ctx.storage gone. Copy those files rather than the directory, because +# on the default layout `/plugins` IS PLUGINS_DIR, and moving it would take the installed +# packages away from the loader. # PLUGIN_STATE_DIR=./data # Cap on a plugin .zip downloaded by install-from-URL (matches the 5 MB upload limit). A non-positive # or non-numeric value falls back to the default. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1045acaf7..263f32f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.23.0] - 2026-08-20 + ### 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 +- The agent tools accept `mentions` on every send whose engine carries it (text, the four media sends, sticker, template and reply) and `customLinkPreview` on the text send, matching the REST routes. A tool schema is not strict, so an agent that passed either field before had it dropped without an error. +- `mentions` reaches every route whose engine can carry it: `reply`, `edit`, `send-template` and each `send-bulk` item, alongside the send routes that already had it. `send-template` also gained `linkPreview`. On `edit` the tags are re-applied rather than preserved, because an edit replaces the message content. Thanks @Magnarks for the report. - `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, Java and typed Python callers).** `markRead` and `subscribePresence` each take their own request type rather than the shared `MarkChatRequest`, which now serves `markUnread` alone. Swap the type at both call sites; the wire body is unchanged and the JavaScript and PHP clients are unaffected. `SubscribePresenceDto` had no contract-gate coverage while one type stood for two routes. +- ⚠️ **Breaking (Go, Java and typed Python callers).** `markRead` and `subscribePresence` each take their own request type rather than the shared `MarkChatRequest`, which now serves `markUnread` alone. Go and Java need the swap at both call sites; typed Python only at `markRead`, its `subscribePresence` body being structurally identical. The wire body is unchanged, and JavaScript and PHP are unaffected. ### Fixed +- `POST /messages/send-sticker` applies the `mentions` it accepts. The route shares `SendMediaMessageDto` and docs/06 lists it among the media sends that take the field, but both adapters built the sticker content without a tag list, so a documented capability did nothing on either engine. + - `POST /chats/read` answers 400 for `"messageIds": null` instead of 500. `@IsOptional` skips every validator for null as well as undefined, so the value reached the Baileys adapter and was dereferenced there. The published schema now carries `minItems` too, so it no longer advertises an empty array the server refuses. - A read receipt goes only to the chat the caller named. A message id belonging to another chat in the same session carried that chat's address out of the message store, so the receipt landed there while the route reported success for the chat in the path. - The Go client can express an empty `messageIds` again. `omitempty` on a plain slice dropped it, so a caller asking for nothing to be acknowledged silently acknowledged the newest message; the field is a pointer, so absent and empty are distinct on the wire. @@ -27,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Restoring a backup no longer aborts when the target already holds the outbound delivery records. The table has no session foreign key, so the replace never cleared it and every overlapping row collided, rolling the whole import back. - 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. -- `backup.sh` and `restore.sh` follow `PLUGIN_STATE_DIR`. Both hardcoded the plugin state under the data dir, so with the knob set the archive carried neither the registry nor any plugin's persisted storage, and a restore put nothing back. The knob's own note now says to move the whole tree, not just `registry.json`. +- `backup.sh` and `restore.sh` follow `PLUGIN_STATE_DIR`. Both hardcoded the plugin state under the data dir, so with the knob set the archive carried neither the registry nor any plugin's persisted storage, and a restore put nothing back. The knob's own note now spells out which files to carry across when the knob changes. - 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. diff --git a/SECURITY.md b/SECURITY.md index ad3413b7d..c48db8a28 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,13 +6,13 @@ socket. Security matters here, and we appreciate responsible disclosure. ## Supported versions -Security fixes land on the latest minor release (currently 0.22.x). Older minor +Security fixes land on the latest minor release (currently 0.23.x). Older minor lines receive no backports — please upgrade older deployments. | Version | Supported | | ------- | ------------------ | -| 0.22.x | :white_check_mark: | -| < 0.22 | :x: | +| 0.23.x | :white_check_mark: | +| < 0.23 | :x: | ## Reporting a vulnerability diff --git a/charts/openwa/Chart.yaml b/charts/openwa/Chart.yaml index 68047d897..2ebb24416 100644 --- a/charts/openwa/Chart.yaml +++ b/charts/openwa/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: openwa description: OpenWA — WhatsApp API. Single-instance StatefulSet (see values.yaml replicaCount warning). type: application -version: 0.1.16 -appVersion: '0.22.0' +version: 0.1.17 +appVersion: '0.23.0' keywords: - whatsapp - openwa diff --git a/docs/06-api-specification.md b/docs/06-api-specification.md index 2a0dada86..ef593682c 100644 --- a/docs/06-api-specification.md +++ b/docs/06-api-specification.md @@ -163,7 +163,7 @@ There is a **single shared media byte cap**, not a per-type table. A base64 (or ### Mentions -`send-text` and the media send routes accept an optional `mentions` array of WIDs (`@c.us`) to tag participants — most useful in groups. Two things are required for WhatsApp to render a tag and notify the participant: +`send-text`, the media send routes, `send-template`, `send-bulk` (per item), `reply` and `edit` all accept an optional `mentions` array of WIDs (`@c.us`) to tag participants — most useful in groups. On `edit` the tags are re-applied rather than preserved: an edit replaces the message content, so a rewritten body that still reads `@62811` loses the tag unless the list is sent again. Two things are required for WhatsApp to render a tag and notify the participant: 1. The `mentions` array lists the WID(s), e.g. `["62811@c.us"]`. 2. The `text`/`caption` contains the matching `@` token, e.g. `Hello @62811`. @@ -1571,8 +1571,8 @@ rejected with `400` rather than guessing which half was meant. Nine `send-*` routes accept an optional `quotedMessageId`: `send-text` above, and `send-image`, `send-video`, `send-audio`, `send-document`, `send-sticker`, `send-location`, `send-contact` and `send-poll` below. Supplying it turns that send into a reply, so a reply can carry media, a location, -a contact card or a poll — not only text. `POST .../messages/reply` is unchanged and remains the text -shorthand. +a contact card or a poll — not only text. `POST .../messages/reply` remains the text shorthand, and +like the send routes it accepts `mentions`. `send-template`, `send-bulk` and `send-product` do NOT accept the field, and reject it as an unknown property. @@ -1626,6 +1626,8 @@ Render a stored text template (header/body/footer joined by blank lines, `{{vars | templateId | string | Conditional | non-empty; required when `templateName` is absent | Stored template id | | templateName | string | Conditional | non-empty; required when `templateId` is absent | Stored template name | | vars | Record\ | No | object | Substituted into `{{placeholder}}` tokens; defaults to `{}` | +| mentions | string[] | No | array of WIDs | WIDs to @mention in the rendered body | +| linkPreview | boolean | No | — | Same engine split as `send-text`; see **Link previews** | ```json { @@ -1919,11 +1921,12 @@ Reply to a message, quoting a prior message. **Request body** — `ReplyMessageDto` -| Field | Type | Required | Constraints | Description | -| --------------- | ------ | -------- | ----------- | --------------------------------------- | -| chatId | string | Yes | non-empty | Target chat | -| quotedMessageId | string | Yes | non-empty | WhatsApp id of the message being quoted | -| text | string | Yes | non-empty | Reply text | +| Field | Type | Required | Constraints | Description | +| --------------- | -------- | -------- | ------------- | ---------------------------------------- | +| chatId | string | Yes | non-empty | Target chat | +| quotedMessageId | string | Yes | non-empty | WhatsApp id of the message being quoted | +| text | string | Yes | non-empty | Reply text | +| mentions | string[] | No | array of WIDs | WIDs to @mention. See **Mentions** above | ```json { "chatId": "628123456789@c.us", "quotedMessageId": "true_628123456789@c.us_3EB0ABCD", "text": "Replying to you" } @@ -2055,11 +2058,12 @@ Edit the text of a message sent by this account; also updates the stored record' **Request body** — `EditMessageDto` -| Field | Type | Required | Constraints | Description | -| --------- | ------ | -------- | ----------------------- | ------------------------------------------------- | -| chatId | string | Yes | non-empty | Chat containing the message | -| messageId | string | Yes | non-empty | Message to edit (the send response's `messageId`) | -| body | string | Yes | non-empty, ≤ 4096 chars | New text content | +| Field | Type | Required | Constraints | Description | +| --------- | -------- | -------- | ----------------------- | ------------------------------------------------- | +| chatId | string | Yes | non-empty | Chat containing the message | +| messageId | string | Yes | non-empty | Message to edit (the send response's `messageId`) | +| body | string | Yes | non-empty, ≤ 4096 chars | New text content | +| mentions | string[] | No | array of WIDs | Re-applies participant tags to the new body | ```json { "chatId": "628123456789@c.us", "messageId": "true_628123456789@c.us_3EB0ABCD", "body": "Corrected text" } @@ -2095,7 +2099,7 @@ Send messages to multiple recipients as an async batch — returns immediately a | messages | BulkMessageItemDto[] | Yes | array, max 100, nested-validated | The batch items (see below); duplicate `chatId`s are collapsed before processing — first occurrence wins, order preserved | | options | BulkMessageOptionsDto | No | nested-validated | Pacing/error options (see below) | -Each `BulkMessageItemDto`: `{ chatId: string, type: 'text'|'image'|'video'|'audio'|'document', content: BulkMessageContentDto, variables?: Record }`. `content` (all fields optional, nested-validated): `text?: string`, `image?`/`video?`/`audio?`/`document?`: `{ url?, base64?, mimetype?, filename? }`, `caption?: string`. +Each `BulkMessageItemDto`: `{ chatId: string, type: 'text'|'image'|'video'|'audio'|'document', content: BulkMessageContentDto, variables?: Record }`. `content` (all fields optional, nested-validated): `text?: string`, `image?`/`video?`/`audio?`/`document?`: `{ url?, base64?, mimetype?, filename? }`, `caption?: string`, `mentions?: string[]` (per item; a batch fans out to many chats, and a WID is only taggable in a chat the participant is in). `BulkMessageOptionsDto`: `{ delayBetweenMessages?: number (1000–60000, default 3000), randomizeDelay?: boolean (default true), stopOnError?: boolean (default false) }`. diff --git a/docs/14-migration-guide.md b/docs/14-migration-guide.md index c6fb4c4c1..11e976022 100644 --- a/docs/14-migration-guide.md +++ b/docs/14-migration-guide.md @@ -729,6 +729,7 @@ docker compose run --rm openwa-api npm run migration:run:prod | Release | Change | Action | | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0.23.0` | Typed SDK clients: `markRead` and `subscribePresence` each take their own request type instead of the shared `MarkChatRequest`, which now serves `markUnread` alone | Go and Java: swap the type at both call sites. Typed Python: only at `markRead`, its `subscribePresence` body being structurally identical. JavaScript and PHP need no change; the wire body is unchanged | | `0.22.0` | Baileys refuses a reply whose quoted id, or a forward whose `fromChatId`, does not name the addressed chat, with the `404` whatsapp-web.js already answered; leaving a group, unsubscribing from a channel and labelling a channel surface WhatsApp's refusal; membership requests for an id that is not a group are refused | Handle a refusal on those six calls, which previously answered `200` whatever happened | | `0.22.0` | Typed SDK clients narrow their request bodies: 19 Python request types mark the fields the server requires, and Go and Java type the proxy scheme, call kind, membership method, chat state, pin window and status font as enums | Pass the named constants instead of bare strings or numbers and supply every required field; untyped callers are unaffected | | `0.22.0` | `isReadOnly` on a group answers for the calling account rather than repeating the group setting, and `isMyContact` reflects whether the contact is actually saved | Re-read either field wherever logic branched on the old value | diff --git a/openapi.json b/openapi.json index ba8ff23e8..9bf4b4361 100644 --- a/openapi.json +++ b/openapi.json @@ -9425,7 +9425,7 @@ "info": { "title": "OpenWA API", "description": "Open Source WhatsApp API Gateway - Free, Self-Hosted HTTP API\n\n**Gateway-wide responses.** Two statuses are returned by middleware before routing, so any operation can emit them:\n\n- `415 Unsupported Media Type` — the request body carries a `Content-Encoding` other than `identity`. The aggregate in-flight body cap counts wire bytes, so a compressed body would be admitted on its compressed size and then inflated past the memory it is meant to bound. Send the body uncompressed.\n- `503 Service Unavailable` with `Retry-After` — the gateway already has too much request body data in flight. The body is not read; retry after the given delay.", - "version": "0.22.0", + "version": "0.23.0", "contact": { "name": "OpenWA", "url": "https://github.com/rmyndharis/OpenWA", @@ -11363,6 +11363,21 @@ "customer": "Alice", "orderId": "1234" } + }, + "mentions": { + "description": "WIDs to @mention (e.g. [\"62811@c.us\"]). The text/caption must also contain the @ token.", + "example": [ + "628123456789@c.us" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "linkPreview": { + "type": "boolean", + "description": "Controls the URL preview on the rendered body, with the same engine split as send-text: whatsapp-web.js builds one by default and `false` suppresses it, while on Baileys previews are opt-in and only `true` attaches one.", + "example": false } }, "required": [ @@ -11593,6 +11608,16 @@ "text": { "type": "string", "maxLength": 4096 + }, + "mentions": { + "description": "WIDs to @mention (e.g. [\"62811@c.us\"]). The text/caption must also contain the @ token.", + "example": [ + "628123456789@c.us" + ], + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -12130,6 +12155,16 @@ "type": "string", "description": "New text body for the message", "maxLength": 4096 + }, + "mentions": { + "description": "WIDs to @mention (e.g. [\"62811@c.us\"]). The text/caption must also contain the @ token.", + "example": [ + "628123456789@c.us" + ], + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -12207,6 +12242,16 @@ "type": "string", "description": "Caption for media messages", "maxLength": 1024 + }, + "mentions": { + "description": "WIDs to @mention (e.g. [\"62811@c.us\"]). The text/caption must also contain the @ token.", + "example": [ + "628123456789@c.us" + ], + "type": "array", + "items": { + "type": "string" + } } } }, diff --git a/package-lock.json b/package-lock.json index d2f99ddba..93e49843a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openwa", - "version": "0.22.0", + "version": "0.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openwa", - "version": "0.22.0", + "version": "0.23.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index c60152b55..1eecbb949 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openwa", - "version": "0.22.0", + "version": "0.23.0", "description": "Open Source WhatsApp API Gateway - Free, Self-Hosted HTTP API for WhatsApp", "author": "Yudhi Armyndharis & OpenWA Contributors", "private": true, diff --git a/sdk/README.md b/sdk/README.md index 2f6e5ebd8..109a3a869 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -156,7 +156,7 @@ handler is a `MockHandler` — no global state, no network. com.rmyndharis openwa - 0.4.0 + 0.5.0 ``` diff --git a/sdk/go/README.md b/sdk/go/README.md index e725597dd..121f45023 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -186,10 +186,10 @@ subdirectory rather than at the repository root: ```bash # Correct — `sdk/go/` prefix, matching `module github.com/rmyndharis/OpenWA/sdk/go` -git tag sdk/go/v0.4.0 && git push origin sdk/go/v0.4.0 +git tag sdk/go/v0.5.0 && git push origin sdk/go/v0.5.0 ``` -A bare `v0.4.0` tag is the _app_ version and does nothing for this module. +A bare `v0.5.0` tag is the _app_ version and does nothing for this module. Without a prefixed tag, `go get` resolves a pseudo-version (`v0.0.0--`) — usable, but callers cannot pin a release. diff --git a/sdk/go/options.go b/sdk/go/options.go index b62390dab..b625799da 100644 --- a/sdk/go/options.go +++ b/sdk/go/options.go @@ -10,7 +10,7 @@ const DefaultTimeout = 30 * time.Second // DefaultUserAgent is sent on every request unless overridden with // WithUserAgent or a caller-supplied User-Agent header. -const DefaultUserAgent = "openwa-go/0.4.0" +const DefaultUserAgent = "openwa-go/0.5.0" // config is the resolved, internal configuration assembled from the options. type config struct { diff --git a/sdk/go/types_message.go b/sdk/go/types_message.go index 47a38d551..012a1aa35 100644 --- a/sdk/go/types_message.go +++ b/sdk/go/types_message.go @@ -99,6 +99,11 @@ type SendTemplateRequest struct { TemplateID string `json:"templateId,omitempty"` TemplateName string `json:"templateName,omitempty"` Vars map[string]string `json:"vars,omitempty"` + // Mentions lists WIDs to @mention in the rendered body, which must carry the @ token. + Mentions []string `json:"mentions,omitempty"` + // LinkPreview controls the URL preview on the rendered body, with the same engine split as + // SendTextRequest. A pointer so an explicit false is distinguishable from "not set". + LinkPreview *bool `json:"linkPreview,omitempty"` } // SendPollRequest sends a native WhatsApp poll. Options holds the choices to @@ -121,6 +126,8 @@ type ReplyMessageRequest struct { ChatID string `json:"chatId"` QuotedMessageID string `json:"quotedMessageId"` Text string `json:"text"` + // Mentions lists WIDs to @mention. The text must also contain the @ token. + Mentions []string `json:"mentions,omitempty"` } // ForwardMessageRequest forwards a message between chats. @@ -151,6 +158,9 @@ type EditMessageRequest struct { ChatID string `json:"chatId"` MessageID string `json:"messageId"` Body string `json:"body"` + // Mentions re-applies participant tags: an edit REPLACES the body rather than amending it, so + // tags the original carried are lost unless resent. + Mentions []string `json:"mentions,omitempty"` } // ListMessagesQuery filters GET /sessions/:id/messages. @@ -340,6 +350,9 @@ type BulkMessageContent struct { Audio *BulkMediaContent `json:"audio,omitempty"` Document *BulkMediaContent `json:"document,omitempty"` Caption string `json:"caption,omitempty"` + // Mentions is per item: a batch fans out to many chats, and a WID is only taggable in a chat + // the participant is in. + Mentions []string `json:"mentions,omitempty"` } // BulkMessageItem is one message in a bulk send. Type is one of: text, image, diff --git a/sdk/java/README.md b/sdk/java/README.md index 042527826..3c5175d57 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -16,14 +16,14 @@ Java 17+, one runtime dependency ([Gson](https://github.com/google/gson)). com.rmyndharis openwa - 0.4.0 + 0.5.0 ``` **Gradle** ```groovy -implementation 'com.rmyndharis:openwa:0.4.0' +implementation 'com.rmyndharis:openwa:0.5.0' ``` ## Quickstart @@ -155,7 +155,7 @@ before tagging rather than tagging to see what happens. Cutting a release: 1. Bump `` in `pom.xml` and land it on `main`. -2. Tag that commit `java-sdk-v` (e.g. `java-sdk-v0.4.0`) and push the +2. Tag that commit `java-sdk-v` (e.g. `java-sdk-v0.5.0`) and push the tag. The SDK has its own version line — the monorepo's `v*` tags are the app version and never trigger an SDK publish. 3. The workflow builds, signs, and publishes; Central syncs within a few hours. diff --git a/sdk/java/pom.xml b/sdk/java/pom.xml index b500b2118..f73380441 100644 --- a/sdk/java/pom.xml +++ b/sdk/java/pom.xml @@ -6,7 +6,7 @@ com.rmyndharis openwa - 0.4.0 + 0.5.0 jar OpenWA Java SDK diff --git a/sdk/java/src/main/java/com/rmyndharis/openwa/model/BulkMessageContent.java b/sdk/java/src/main/java/com/rmyndharis/openwa/model/BulkMessageContent.java index 80855ba3f..b2cb1b8cd 100644 --- a/sdk/java/src/main/java/com/rmyndharis/openwa/model/BulkMessageContent.java +++ b/sdk/java/src/main/java/com/rmyndharis/openwa/model/BulkMessageContent.java @@ -1,5 +1,7 @@ package com.rmyndharis.openwa.model; +import java.util.List; + /** Content payload for one bulk-send item. Populate the field matching the item's {@link BulkMessageType}. */ public record BulkMessageContent( String text, @@ -7,7 +9,14 @@ public record BulkMessageContent( BulkMediaRequest video, BulkMediaRequest audio, BulkMediaRequest document, - String caption) { + String caption, + List mentions) { + /** Back-compatible constructor without mentions. */ + public BulkMessageContent( + String text, BulkMediaRequest image, BulkMediaRequest video, BulkMediaRequest audio, + BulkMediaRequest document, String caption) { + this(text, image, video, audio, document, caption, null); + } public static Builder builder() { return new Builder(); @@ -20,6 +29,7 @@ public static final class Builder { private BulkMediaRequest audio; private BulkMediaRequest document; private String caption; + private List mentions; public Builder text(String v) { this.text = v; @@ -51,8 +61,14 @@ public Builder caption(String v) { return this; } + /** Per item: a batch fans out to many chats, and a WID is only taggable in a chat it is in. */ + public Builder mentions(List v) { + this.mentions = v; + return this; + } + public BulkMessageContent build() { - return new BulkMessageContent(text, image, video, audio, document, caption); + return new BulkMessageContent(text, image, video, audio, document, caption, mentions); } } } diff --git a/sdk/java/src/main/java/com/rmyndharis/openwa/model/EditMessageRequest.java b/sdk/java/src/main/java/com/rmyndharis/openwa/model/EditMessageRequest.java index ecbb72324..e3033c96f 100644 --- a/sdk/java/src/main/java/com/rmyndharis/openwa/model/EditMessageRequest.java +++ b/sdk/java/src/main/java/com/rmyndharis/openwa/model/EditMessageRequest.java @@ -1,7 +1,14 @@ package com.rmyndharis.openwa.model; +import java.util.List; + /** Request body for editing the text of a message sent by this account. */ -public record EditMessageRequest(String chatId, String messageId, String body) { +public record EditMessageRequest(String chatId, String messageId, String body, List mentions) { + /** Back-compatible constructor without mentions. */ + public EditMessageRequest(String chatId, String messageId, String body) { + this(chatId, messageId, body, null); + } + public static Builder builder() { return new Builder(); } @@ -10,6 +17,7 @@ public static final class Builder { private String chatId; private String messageId; private String body; + private List mentions; public Builder chatId(String v) { this.chatId = v; @@ -27,8 +35,14 @@ public Builder body(String v) { return this; } + /** An edit REPLACES the body, so tags are re-applied rather than preserved. */ + public Builder mentions(List v) { + this.mentions = v; + return this; + } + public EditMessageRequest build() { - return new EditMessageRequest(chatId, messageId, body); + return new EditMessageRequest(chatId, messageId, body, mentions); } } } diff --git a/sdk/java/src/main/java/com/rmyndharis/openwa/model/ReplyMessageRequest.java b/sdk/java/src/main/java/com/rmyndharis/openwa/model/ReplyMessageRequest.java index d8ab534ac..f0c298668 100644 --- a/sdk/java/src/main/java/com/rmyndharis/openwa/model/ReplyMessageRequest.java +++ b/sdk/java/src/main/java/com/rmyndharis/openwa/model/ReplyMessageRequest.java @@ -1,7 +1,14 @@ package com.rmyndharis.openwa.model; +import java.util.List; + /** Request body for replying to a specific message. */ -public record ReplyMessageRequest(String chatId, String quotedMessageId, String text) { +public record ReplyMessageRequest(String chatId, String quotedMessageId, String text, List mentions) { + /** Back-compatible constructor without mentions. */ + public ReplyMessageRequest(String chatId, String quotedMessageId, String text) { + this(chatId, quotedMessageId, text, null); + } + public static Builder builder() { return new Builder(); } @@ -10,6 +17,7 @@ public static final class Builder { private String chatId; private String quotedMessageId; private String text; + private List mentions; public Builder chatId(String v) { this.chatId = v; @@ -26,8 +34,14 @@ public Builder text(String v) { return this; } + /** WIDs to @mention; the text must also contain the matching @<number> token. */ + public Builder mentions(List v) { + this.mentions = v; + return this; + } + public ReplyMessageRequest build() { - return new ReplyMessageRequest(chatId, quotedMessageId, text); + return new ReplyMessageRequest(chatId, quotedMessageId, text, mentions); } } } diff --git a/sdk/java/src/main/java/com/rmyndharis/openwa/model/SendTemplateRequest.java b/sdk/java/src/main/java/com/rmyndharis/openwa/model/SendTemplateRequest.java index c6e19fcb4..0f6781e89 100644 --- a/sdk/java/src/main/java/com/rmyndharis/openwa/model/SendTemplateRequest.java +++ b/sdk/java/src/main/java/com/rmyndharis/openwa/model/SendTemplateRequest.java @@ -1,9 +1,17 @@ package com.rmyndharis.openwa.model; +import java.util.List; import java.util.Map; /** Request body for rendering and sending a stored message template. */ -public record SendTemplateRequest(String chatId, String templateId, String templateName, Map vars) { +public record SendTemplateRequest( + String chatId, String templateId, String templateName, Map vars, List mentions, + Boolean linkPreview) { + /** Back-compatible constructor without the send-text passthrough options. */ + public SendTemplateRequest(String chatId, String templateId, String templateName, Map vars) { + this(chatId, templateId, templateName, vars, null, null); + } + public static Builder builder() { return new Builder(); } @@ -13,6 +21,8 @@ public static final class Builder { private String templateId; private String templateName; private Map vars; + private List mentions; + private Boolean linkPreview; public Builder chatId(String v) { this.chatId = v; @@ -37,8 +47,20 @@ public Builder vars(Map v) { return this; } + /** WIDs to @mention in the rendered body, which must carry the matching @<number> token. */ + public Builder mentions(List v) { + this.mentions = v; + return this; + } + + /** Controls the URL preview on the rendered body, with the same engine split as send-text. */ + public Builder linkPreview(Boolean v) { + this.linkPreview = v; + return this; + } + public SendTemplateRequest build() { - return new SendTemplateRequest(chatId, templateId, templateName, vars); + return new SendTemplateRequest(chatId, templateId, templateName, vars, mentions, linkPreview); } } } diff --git a/sdk/javascript/README.md b/sdk/javascript/README.md index a4ebb4f5a..3f20c98ab 100644 --- a/sdk/javascript/README.md +++ b/sdk/javascript/README.md @@ -71,7 +71,7 @@ rejects the publish, so configure it first. Cutting a release: 1. Bump `version` in `package.json` and land it on `main`. -2. Tag that commit `js-sdk-v` (e.g. `js-sdk-v0.4.0`) and push the tag. +2. Tag that commit `js-sdk-v` (e.g. `js-sdk-v0.5.0`) and push the tag. The SDK has its own version line — the monorepo's `v*` tags are the app version and never trigger an SDK publish. 3. The workflow re-runs the SDK's tests, typecheck, build and dual CJS/ESM diff --git a/sdk/javascript/package-lock.json b/sdk/javascript/package-lock.json index 84156a6c1..5b8bde730 100644 --- a/sdk/javascript/package-lock.json +++ b/sdk/javascript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rmyndharis/openwa", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openwa/sdk", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "devDependencies": { "typescript": "^5.7.3", diff --git a/sdk/javascript/package.json b/sdk/javascript/package.json index 978d0d768..6e4dfad45 100644 --- a/sdk/javascript/package.json +++ b/sdk/javascript/package.json @@ -1,6 +1,6 @@ { "name": "@rmyndharis/openwa", - "version": "0.4.0", + "version": "0.5.0", "description": "Official JavaScript/TypeScript SDK for OpenWA WhatsApp API Gateway", "type": "module", "main": "./dist/cjs/index.js", diff --git a/sdk/javascript/src/types.ts b/sdk/javascript/src/types.ts index 2a845881e..68c66c182 100644 --- a/sdk/javascript/src/types.ts +++ b/sdk/javascript/src/types.ts @@ -314,6 +314,8 @@ export interface ReplyMessageRequest { chatId: Jid; quotedMessageId: string; text: string; + /** WIDs to @mention (e.g. `["62811@c.us"]`). The text/caption must also contain the `@` token. */ + mentions?: string[]; } export interface ForwardMessageRequest { @@ -414,6 +416,8 @@ export interface EditMessageRequest { messageId: string; /** New text body; max 4096 chars (same cap as a send). Own messages only — 404 if not found. */ body: string; + /** WIDs to @mention. An edit REPLACES the body, so tags are re-applied rather than preserved. */ + mentions?: string[]; } export interface SendTemplateRequest { @@ -424,6 +428,10 @@ export interface SendTemplateRequest { templateName?: string; /** Template variables (server DTO field is `vars`). */ vars?: Record; + /** WIDs to @mention (e.g. `["62811@c.us"]`). The text/caption must also contain the `@` token. */ + mentions?: string[]; + /** Controls the URL preview on the rendered body, with the same engine split as `send-text`. */ + linkPreview?: boolean; } export interface SendPollRequest { @@ -600,6 +608,8 @@ export interface BulkMessageContent { audio?: BulkMediaRequest; document?: BulkMediaRequest; caption?: string; + /** WIDs to @mention (e.g. `["62811@c.us"]`). The text/caption must also contain the `@` token. */ + mentions?: string[]; } export interface BulkMessageItem { diff --git a/sdk/php/README.md b/sdk/php/README.md index 909bbfd2a..cf200bd00 100644 --- a/sdk/php/README.md +++ b/sdk/php/README.md @@ -100,7 +100,7 @@ Cutting a release: 1. If the minor line changes, update `extra.branch-alias.dev-main` in `composer.json` (e.g. `0.1.x-dev` → `0.2.x-dev`) and land it on `main`. The release workflow refuses to publish when the alias does not match the tag. -2. Tag that commit `php-sdk-v` (e.g. `php-sdk-v0.4.0`) and push the +2. Tag that commit `php-sdk-v` (e.g. `php-sdk-v0.5.0`) and push the tag. The SDK has its own version line — the monorepo's `v*` tags are the app version and never trigger an SDK release. 3. The workflow runs the test suite, then tags the mirror `` (no `v` diff --git a/sdk/php/composer.json b/sdk/php/composer.json index a958c454d..1dbf99969 100644 --- a/sdk/php/composer.json +++ b/sdk/php/composer.json @@ -37,7 +37,7 @@ }, "extra": { "branch-alias": { - "dev-main": "0.4.x-dev" + "dev-main": "0.5.x-dev" } } } diff --git a/sdk/python/README.md b/sdk/python/README.md index c6c50fc6f..51661e12e 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -111,7 +111,7 @@ rejects the upload, so configure it first. Cutting a release: 1. Bump `version` in `pyproject.toml` and land it on `main`. -2. Tag that commit `py-sdk-v` (e.g. `py-sdk-v0.4.0`) and push the tag. +2. Tag that commit `py-sdk-v` (e.g. `py-sdk-v0.5.0`) and push the tag. The SDK has its own version line — the monorepo's `v*` tags are the app version and never trigger an SDK publish. 3. The workflow re-runs the test suite, builds the sdist and wheel, and diff --git a/sdk/python/openwa/types.py b/sdk/python/openwa/types.py index 0ac6401f9..b120b3f11 100644 --- a/sdk/python/openwa/types.py +++ b/sdk/python/openwa/types.py @@ -379,6 +379,8 @@ class ReplyMessageRequest(TypedDict): chatId: Jid quotedMessageId: str text: str + # WIDs to @mention; the text must also contain the matching @ token. + mentions: NotRequired[list[str]] class ForwardMessageRequest(TypedDict): @@ -405,6 +407,8 @@ class EditMessageRequest(TypedDict): messageId: str # Same 4096-char cap as SendTextRequest.text — an edit cannot exceed what a send allows. body: str + # An edit REPLACES the body, so tags are re-applied rather than preserved. + mentions: NotRequired[list[str]] class SendTemplateRequest(TypedDict): @@ -414,6 +418,9 @@ class SendTemplateRequest(TypedDict): templateId: NotRequired[str] templateName: NotRequired[str] vars: NotRequired[dict[str, str]] + # The rendered body is dispatched like a send-text, so it carries the same two optionals. + mentions: NotRequired[list[str]] + linkPreview: NotRequired[bool] class SendPollRequest(TypedDict): @@ -583,6 +590,8 @@ class BulkMessageContent(TypedDict, total=False): audio: BulkMediaRequest document: BulkMediaRequest caption: str + # Per item: a batch fans out to many chats, and a WID is only taggable in a chat it is in. + mentions: list[str] class BulkMessageItem(TypedDict): diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 350581e80..c34426a27 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rmyndharis-openwa" -version = "0.4.0" +version = "0.5.0" description = "Official Python SDK for OpenWA WhatsApp API Gateway" readme = "README.md" license = { text = "MIT" } diff --git a/src/core/agent-tools/tools/message.tools.spec.ts b/src/core/agent-tools/tools/message.tools.spec.ts index 03f05a149..b7d9c56e6 100644 --- a/src/core/agent-tools/tools/message.tools.spec.ts +++ b/src/core/agent-tools/tools/message.tools.spec.ts @@ -267,6 +267,100 @@ describe('messageTools', () => { expect(out).toEqual({ id: 'm2' }); }); + it('forwards a tag list on the text and reply tools', async () => { + const sendText = jest.fn().mockResolvedValue({ id: 'm1' }); + const reply = jest.fn().mockResolvedValue({ id: 'm2' }); + const tools = makeTools({ sendText, reply } as unknown as MessageService); + + await run(tools.get('MessageSendText')!, { + sessionId: 's1', + chatId: '120363@g.us', + text: 'hi @62811', + mentions: ['62811@c.us'], + }); + expect(sendText).toHaveBeenCalledWith('s1', expect.objectContaining({ mentions: ['62811@c.us'] })); + + await run(tools.get('MessageReply')!, { + sessionId: 's1', + chatId: '120363@g.us', + quotedMessageId: 'm1', + text: 'hi @62811', + mentions: ['62811@c.us'], + }); + expect(reply).toHaveBeenCalledWith('s1', expect.objectContaining({ mentions: ['62811@c.us'] })); + }); + + it('refuses a tag that is not an individual WID, instead of dropping it', async () => { + // The whole point of declaring the field here. A tool schema is a plain z.object, so before it was + // declared an agent's `mentions` was stripped silently and the send reported success; and this path + // calls the service directly, so no ValidationPipe ever sees the value. + const sendText = jest.fn().mockResolvedValue({ id: 'm1' }); + const tools = makeTools({ sendText } as unknown as MessageService); + + // The detail lives on the response, not on .message, which reads "Bad Request Exception". + await expect( + run(tools.get('MessageSendText')!, { + sessionId: 's1', + chatId: '120363@g.us', + text: 'hi', + mentions: ['120363000000000000@g.us'], + }), + ).rejects.toMatchObject({ + response: { message: [expect.stringContaining('individual WID') as unknown as string] }, + }); + expect(sendText).not.toHaveBeenCalled(); + }); + + it('forwards a caller-supplied link preview, and enforces the title WhatsApp requires', async () => { + // REST declares customLinkPreview; the tool did not, so an agent's object was stripped before the + // handler ran and the send went out with whatever preview the engine chose on its own. + const sendText = jest.fn().mockResolvedValue({ id: 'm1' }); + const tools = makeTools({ sendText } as unknown as MessageService); + + await run(tools.get('MessageSendText')!, { + sessionId: 's1', + chatId: '628111@c.us', + text: 'see https://example.com/launch', + customLinkPreview: { url: 'https://example.com/launch', title: 'We just launched' }, + }); + expect(sendText).toHaveBeenCalledWith( + 's1', + expect.objectContaining({ + customLinkPreview: { url: 'https://example.com/launch', title: 'We just launched' }, + }), + ); + + // Control: the required title is enforced here, not left to the DTO, because this path never + // reaches the ValidationPipe. + sendText.mockClear(); + await expect( + run(tools.get('MessageSendText')!, { + sessionId: 's1', + chatId: '628111@c.us', + text: 'see https://example.com/launch', + customLinkPreview: { url: 'https://example.com/launch' }, + }), + ).rejects.toBeDefined(); + expect(sendText).not.toHaveBeenCalled(); + }); + + it('tags a sticker too, now that both adapters carry the list', async () => { + // Excluded when this tool set was written, because neither adapter built a mention list for a + // sticker. Both do now, and the route has always accepted the field, so withholding it here + // would leave the MCP surface silently weaker than REST for the same send. + const sendSticker = jest.fn().mockResolvedValue({ id: 'm1' }); + const tools = makeTools({ sendSticker } as unknown as MessageService); + + await run(tools.get('MessageSendSticker')!, { + sessionId: 's1', + chatId: '120363@g.us', + url: 'https://example.test/s.webp', + mentions: ['62811@c.us'], + }); + + expect(sendSticker).toHaveBeenCalledWith('s1', expect.objectContaining({ mentions: ['62811@c.us'] })); + }); + it('MessageForward delegates to forward', async () => { const forward = jest.fn().mockResolvedValue({ id: 'm2' }); const tools = makeTools({ forward } as unknown as MessageService); diff --git a/src/core/agent-tools/tools/message.tools.ts b/src/core/agent-tools/tools/message.tools.ts index 59d76c0f4..54bc2d5e5 100644 --- a/src/core/agent-tools/tools/message.tools.ts +++ b/src/core/agent-tools/tools/message.tools.ts @@ -1,7 +1,15 @@ import { z } from 'zod'; import { ApiKeyRole } from '../../../modules/auth/entities/api-key.entity'; import type { MessageService } from '../../../modules/message/message.service'; -import { MESSAGE_TEXT_MAX_LENGTH } from '../../../modules/message/dto/send-message.dto'; +import { + CUSTOM_PREVIEW_DESCRIPTION_MAX_LENGTH, + CUSTOM_PREVIEW_TITLE_MAX_LENGTH, + CUSTOM_PREVIEW_URL_MAX_LENGTH, + MENTIONS_MAX, + MENTION_WID_MAX_LENGTH, + MESSAGE_TEXT_MAX_LENGTH, +} from '../../../modules/message/dto/send-message.dto'; +import { isMentionWid } from '../../../modules/message/dto/is-mention-wid.validator'; import { CONTACT_NAME_MAX_LENGTH, CONTACT_NUMBER_MAX_LENGTH, @@ -25,6 +33,45 @@ const quotedMessageIdSchema = z 'the serialized message id, Baileys the raw key id of a message it has already stored.', ); +/** + * Mirrors the REST `mentions` field. The element rule and both caps come from the DTO rather than + * being restated here: a tool handler calls the service directly, so the ValidationPipe never runs + * and this schema is the only thing standing between an agent and the engine. + */ +const mentionsSchema = z + .array(z.string().max(MENTION_WID_MAX_LENGTH).refine(isMentionWid, 'must be an individual WID, e.g. 62811@c.us')) + .max(MENTIONS_MAX) + .optional() + .describe( + 'WIDs to @mention (e.g. ["62811@c.us"]). The text or caption must also carry the matching ' + + '@ token for WhatsApp to render the tag.', + ); + +/** + * Mirrors the REST `customLinkPreview` field, whose caps come from the DTO for the same reason as + * `mentionsSchema` above. Baileys only: whatsapp-web.js takes a boolean and answers 501, and the + * field cannot be combined with `linkPreview: false`, which asks for the opposite. + */ +const customLinkPreviewSchema = z + .object({ + url: z + .string() + .min(1) + .max(CUSTOM_PREVIEW_URL_MAX_LENGTH) + .describe('The URL as it appears in the message text; WhatsApp anchors the preview to it.'), + title: z + .string() + .min(1) + .max(CUSTOM_PREVIEW_TITLE_MAX_LENGTH) + .describe('Required: WhatsApp renders no preview without a title.'), + description: z.string().max(CUSTOM_PREVIEW_DESCRIPTION_MAX_LENGTH).optional(), + }) + .optional() + .describe( + 'Attach a preview you supply instead of one fetched from the URL, so nothing is fetched and it ' + + 'works for a URL the gateway cannot reach. Baileys only: whatsapp-web.js answers 501.', + ); + export function messageTools(message: MessageService): AnyToolDescriptor[] { return [ defineTool({ @@ -91,7 +138,7 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { inputSchema: z.object({ sessionId, chatId: z.string().describe('Chat JID (e.g. 628123456789@c.us or groupId@g.us)'), - text: z.string().min(1).max(4096).describe('Text message content'), + text: z.string().min(1).max(MESSAGE_TEXT_MAX_LENGTH).describe('Text message content'), linkPreview: z .boolean() .optional() @@ -100,6 +147,8 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { 'unset means the engine default, and the engines differ.', ), quotedMessageId: quotedMessageIdSchema, + mentions: mentionsSchema, + customLinkPreview: customLinkPreviewSchema, }), handler: input => message.sendText(input.sessionId, { @@ -107,6 +156,8 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { text: input.text, ...(input.linkPreview === undefined ? {} : { linkPreview: input.linkPreview }), quotedMessageId: input.quotedMessageId, + mentions: input.mentions, + customLinkPreview: input.customLinkPreview, }), }), defineTool({ @@ -124,6 +175,7 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { filename: z.string().max(255).optional(), caption: z.string().max(1024).optional(), quotedMessageId: quotedMessageIdSchema, + mentions: mentionsSchema, }), handler: input => message.sendImage(input.sessionId, { @@ -134,6 +186,7 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { filename: input.filename, caption: input.caption, quotedMessageId: input.quotedMessageId, + mentions: input.mentions, }), }), defineTool({ @@ -151,6 +204,7 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { filename: z.string().max(255).optional(), caption: z.string().max(1024).optional(), quotedMessageId: quotedMessageIdSchema, + mentions: mentionsSchema, }), handler: input => message.sendVideo(input.sessionId, { @@ -161,6 +215,7 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { filename: input.filename, caption: input.caption, quotedMessageId: input.quotedMessageId, + mentions: input.mentions, }), }), defineTool({ @@ -179,6 +234,7 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { caption: z.string().max(1024).optional(), ptt: z.boolean().optional().describe('Send as a WhatsApp voice note (PTT)'), quotedMessageId: quotedMessageIdSchema, + mentions: mentionsSchema, }), handler: input => message.sendAudio(input.sessionId, { @@ -190,6 +246,7 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { caption: input.caption, ptt: input.ptt, quotedMessageId: input.quotedMessageId, + mentions: input.mentions, }), }), defineTool({ @@ -207,6 +264,7 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { filename: z.string().max(255).optional(), caption: z.string().max(1024).optional(), quotedMessageId: quotedMessageIdSchema, + mentions: mentionsSchema, }), handler: input => message.sendDocument(input.sessionId, { @@ -217,6 +275,7 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { filename: input.filename, caption: input.caption, quotedMessageId: input.quotedMessageId, + mentions: input.mentions, }), }), defineTool({ @@ -284,6 +343,7 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { filename: z.string().max(255).optional(), caption: z.string().max(1024).optional(), quotedMessageId: quotedMessageIdSchema, + mentions: mentionsSchema, }), handler: input => message.sendSticker(input.sessionId, { @@ -294,6 +354,7 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { filename: input.filename, caption: input.caption, quotedMessageId: input.quotedMessageId, + mentions: input.mentions, }), }), defineTool({ @@ -312,6 +373,7 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { .record(z.string(), z.string()) .optional() .describe('Variables to substitute into {{placeholder}} tokens'), + mentions: mentionsSchema, }), handler: input => message.sendTemplate(input.sessionId, { @@ -319,6 +381,7 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { templateId: input.templateId, templateName: input.templateName, vars: input.vars, + mentions: input.mentions, }), }), defineTool({ @@ -332,12 +395,14 @@ export function messageTools(message: MessageService): AnyToolDescriptor[] { chatId: z.string().describe('Chat JID'), quotedMessageId: z.string().describe('ID of the message to quote/reply to'), text: z.string().min(1).max(MESSAGE_TEXT_MAX_LENGTH).describe('Reply text content'), + mentions: mentionsSchema, }), handler: input => message.reply(input.sessionId, { chatId: input.chatId, quotedMessageId: input.quotedMessageId, text: input.text, + mentions: input.mentions, }), }), defineTool({ diff --git a/src/core/agent-tools/tools/tool-input-caps.spec.ts b/src/core/agent-tools/tools/tool-input-caps.spec.ts index 024e856fd..7dcddc4c5 100644 --- a/src/core/agent-tools/tools/tool-input-caps.spec.ts +++ b/src/core/agent-tools/tools/tool-input-caps.spec.ts @@ -2,7 +2,11 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import type { MessageService } from '../../../modules/message/message.service'; import type { GroupService } from '../../../modules/group/group.service'; -import { MESSAGE_TEXT_MAX_LENGTH } from '../../../modules/message/dto/send-message.dto'; +import { + MENTIONS_MAX, + MESSAGE_TEXT_MAX_LENGTH, + SendTextMessageDto, +} from '../../../modules/message/dto/send-message.dto'; import { CONTACT_NAME_MAX_LENGTH, CONTACT_NUMBER_MAX_LENGTH, @@ -156,6 +160,17 @@ const PARTICIPANT_CASES: CapCase[] = [ dtoClass: ParticipantsDto, dtoPayload: {}, }, + { + // The tool path calls the service directly, so the ValidationPipe never runs and the zod schema + // is the only cap between an agent and the engine. This case fails if either side moves. + label: 'MessageSendText.mentions ↔ SendTextMessageDto.mentions', + toolName: 'MessageSendText', + field: 'mentions', + cap: MENTIONS_MAX, + toolInput: { sessionId: 's1', chatId: '120363@g.us', text: 'hi' }, + dtoClass: SendTextMessageDto, + dtoPayload: { chatId: '120363@g.us', text: 'hi' }, + }, ]; async function dtoFieldErrors(c: CapCase, value: unknown): Promise { diff --git a/src/database/migrations/1786300000000-AddWebhookDeliveryFailureLookupIndex.ts b/src/database/migrations/1786300000000-AddWebhookDeliveryFailureLookupIndex.ts new file mode 100644 index 000000000..d6078850e --- /dev/null +++ b/src/database/migrations/1786300000000-AddWebhookDeliveryFailureLookupIndex.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Composite index on `webhook_delivery_failures (webhookId, idempotencyKey)`, backing the + * before-insert lookup in `recordWebhookDeliveryFailure` that keeps one row per lost delivery + * rather than one per replay attempt. The table is append-only and never pruned, so without an + * index that lookup scans it once per terminal failure: during exactly the receiver outage the + * table exists to record, the scan grows with the rows the outage is adding. + * + * Hand-authored because `synchronize` is off for the data connection on PostgreSQL (and optional on + * SQLite). The explicit name matches the entity's @Index, so the synchronize and migration schema + * paths converge on one index. Idempotent via IF NOT EXISTS (supported by both dialects). + */ +export class AddWebhookDeliveryFailureLookupIndex1786300000000 implements MigrationInterface { + name = 'AddWebhookDeliveryFailureLookupIndex1786300000000'; + + public async up(queryRunner: QueryRunner): Promise { + // The data pool boots with a runtime statement_timeout (default 30s), and MigrationExecutor + // wraps a lone pending migration in its own transaction, so no earlier SET LOCAL is in effect. + // Lift it for this transaction only, exactly like AddMessageMediaPathIndex. + if (queryRunner.dataSource.options.type === 'postgres') { + await queryRunner.query('SET LOCAL statement_timeout = 0'); + } + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_webhook_delivery_failures_delivery" ON "webhook_delivery_failures" ("webhookId", "idempotencyKey")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_webhook_delivery_failures_delivery"`); + } +} diff --git a/src/database/migrations/__tests__/1786300000000-AddWebhookDeliveryFailureLookupIndex.spec.ts b/src/database/migrations/__tests__/1786300000000-AddWebhookDeliveryFailureLookupIndex.spec.ts new file mode 100644 index 000000000..b1e9346b1 --- /dev/null +++ b/src/database/migrations/__tests__/1786300000000-AddWebhookDeliveryFailureLookupIndex.spec.ts @@ -0,0 +1,70 @@ +import { DataSource } from 'typeorm'; +import { AddWebhookDeliveryFailureLookupIndex1786300000000 } from '../1786300000000-AddWebhookDeliveryFailureLookupIndex'; + +describe('AddWebhookDeliveryFailureLookupIndex migration', () => { + let ds: DataSource; + + beforeEach(async () => { + ds = new DataSource({ type: 'better-sqlite3', database: ':memory:' }); + await ds.initialize(); + // Post-AddWebhookDeliveryFailures shape, reduced to the columns this index covers. + await ds.query( + `CREATE TABLE "webhook_delivery_failures" ("id" varchar PRIMARY KEY NOT NULL, ` + + `"webhookId" varchar NOT NULL, "sessionId" varchar NOT NULL, "idempotencyKey" varchar NULL)`, + ); + // Enough rows that the planner has a reason to prefer the index over a scan. + const rows: string[] = []; + for (let i = 0; i < 200; i++) { + rows.push(`('id${i}', 'wh-${i % 5}', 's1', 'key-${i}')`); + } + await ds.query( + `INSERT INTO "webhook_delivery_failures" ("id","webhookId","sessionId","idempotencyKey") VALUES ${rows.join(',')}`, + ); + }); + + afterEach(async () => { + await ds.destroy(); + }); + + const indexNames = async (): Promise => { + const rows = await ds.query<{ name: string }[]>(`PRAGMA index_list("webhook_delivery_failures")`); + return rows.map(r => r.name).sort(); + }; + + it('creates the delivery lookup index', async () => { + const runner = ds.createQueryRunner(); + await new AddWebhookDeliveryFailureLookupIndex1786300000000().up(runner); + + expect(await indexNames()).toContain('IDX_webhook_delivery_failures_delivery'); + }); + + it('is idempotent (re-running up is a no-op) and down() drops the index', async () => { + const runner = ds.createQueryRunner(); + const migration = new AddWebhookDeliveryFailureLookupIndex1786300000000(); + + await migration.up(runner); + await expect(migration.up(runner)).resolves.toBeUndefined(); + expect(await indexNames()).toContain('IDX_webhook_delivery_failures_delivery'); + + await migration.down(runner); + expect(await indexNames()).not.toContain('IDX_webhook_delivery_failures_delivery'); + }); + + it('the duplicate-record lookup is served by the index, not a table scan', async () => { + const runner = ds.createQueryRunner(); + const countQuery = + `EXPLAIN QUERY PLAN SELECT COUNT(*) FROM "webhook_delivery_failures" ` + + `WHERE "webhookId" = 'wh-1' AND "idempotencyKey" = 'key-6'`; + + // Baseline first: without the index this really is a scan, so the assertion below is measuring + // the index rather than a plan that was already index-served for some other reason. + const before = (await ds.query<{ detail: string }[]>(countQuery)).map(r => r.detail).join(' '); + expect(before).toContain('SCAN'); + + await new AddWebhookDeliveryFailureLookupIndex1786300000000().up(runner); + + const after = (await ds.query<{ detail: string }[]>(countQuery)).map(r => r.detail).join(' '); + expect(after).toContain('IDX_webhook_delivery_failures_delivery'); + expect(after).not.toContain('SCAN'); + }); +}); diff --git a/src/engine/adapters/baileys-contacts.ts b/src/engine/adapters/baileys-contacts.ts index a68bf41cb..f18e886f5 100644 --- a/src/engine/adapters/baileys-contacts.ts +++ b/src/engine/adapters/baileys-contacts.ts @@ -368,13 +368,17 @@ export class BaileysContacts { const stored = (await this.host.getStoredMessages(messageIds)) ?? []; // A stored key is only usable when it belongs to THIS chat. Without the check, an id from // another chat in the same session carried that chat's remoteJid into readMessages, so the - // receipt landed there while the route answered success for the chat the caller named. Both - // sides fold through toEngineJid so the @c.us and @s.whatsapp.net spellings of one chat still - // match; anything that still differs falls back to the synthesised key for the ADDRESSED chat, - // which is exactly what every id ran on before stored keys existed. + // receipt landed there while the route answered success for the chat the caller named. + // The comparison runs in the NEUTRAL dialect rather than the engine one: toEngineJid folds + // @c.us and @s.whatsapp.net together but returns @lid untouched, and Baileys stores a DM key + // under the peer's lid once WhatsApp addresses the chat that way. toNeutralJid resolves that + // lid to its phone user-part through the session's lid mapping, so both spellings of one chat + // still meet. Anything that still differs falls back to the synthesised key for the ADDRESSED + // chat, which is exactly what every id ran on before stored keys existed. + const chatKey = this.host.toNeutralJid(chatId); const keyById = new Map( stored - .filter(msg => msg.key?.id && msg.key.remoteJid && this.host.toEngineJid(msg.key.remoteJid) === remoteJid) + .filter(msg => msg.key?.id && msg.key.remoteJid && this.host.toNeutralJid(msg.key.remoteJid) === chatKey) .map(msg => [msg.key.id as string, msg.key]), ); return messageIds.map(id => keyById.get(id) ?? { remoteJid, id, fromMe: false }); diff --git a/src/engine/adapters/baileys-messaging.ts b/src/engine/adapters/baileys-messaging.ts index e5b32a15c..3086fa48a 100644 --- a/src/engine/adapters/baileys-messaging.ts +++ b/src/engine/adapters/baileys-messaging.ts @@ -377,9 +377,13 @@ export class BaileysMessaging { async sendStickerMessage(chatId: string, media: MediaInput): Promise { this.host.ensureReady(); const { data, mimetype } = await resolveMediaBuffer(media); + // A sticker has neither text nor caption, but stickerMessage carries a contextInfo like every + // other content type, so a mention still tags the participant. The route accepts the field + // (send-sticker shares SendMediaMessageDto) and docs/06 lists it among the media sends that + // take one, so dropping it here left a documented capability doing nothing. return this.sendContent( chatId, - { sticker: await toWebpSticker(data, mimetype) }, + { sticker: await toWebpSticker(data, mimetype), ...this.withMentions(media.mentions) }, await this.quoteOption(media.quotedMessageId), ); } @@ -428,7 +432,7 @@ export class BaileysMessaging { ); } - async replyToMessage(chatId: string, quotedMsgId: string, text: string): Promise { + async replyToMessage(chatId: string, quotedMsgId: string, text: string, mentions?: string[]): Promise { this.host.ensureReady(); const quoted = await this.requireStored(quotedMsgId); // The one requireStored path that had no chat check. whatsapp-web.js resolves the quote by @@ -438,7 +442,7 @@ export class BaileysMessaging { // NOT applied to quoteOption: cross-chat quoting on the send-* routes is deliberate and // published in docs/06. this.assertStoredInChat(quoted, chatId, quotedMsgId); - return this.sendContent(chatId, { text }, { quoted }); + return this.sendContent(chatId, { text, ...this.withMentions(mentions) }, { quoted }); } async forwardMessage(fromChatId: string, toChatId: string, messageId: string): Promise { @@ -484,7 +488,7 @@ export class BaileysMessaging { ); } - async editMessage(chatId: string, messageId: string, body: string): Promise { + async editMessage(chatId: string, messageId: string, body: string, mentions?: string[]): Promise { this.host.ensureReady(); const target = await this.requireStored(messageId); // Only the account's own messages are editable: WhatsApp refuses the edit of an inbound message @@ -502,10 +506,14 @@ export class BaileysMessaging { const jid = await this.toDeliverableJid(chatId); // Same guard as sendContent: an edit carries text, so without it the library would fetch // every URL in the new body through its own vulnerable generator. + // Tags are applied to the inner message's contextInfo BEFORE the library wraps it in the + // protocolMessage edit envelope, so an edit can re-tag participants. An edit REPLACES the + // content, so omitting mentions drops whatever tags the original carried. + const editContent = { text: body, ...this.withMentions(mentions), edit: target.key }; const sent = await this.sock().sendMessage( jid, - this.previewSafe({ text: body, edit: target.key }), - this.previewSafeOptions({ text: body, edit: target.key }), + this.previewSafe(editContent), + this.previewSafeOptions(editContent), ); return { id: sent?.key?.id ?? messageId, timestamp: this.host.toUnixSeconds(sent?.messageTimestamp) }; } diff --git a/src/engine/adapters/baileys-send-seen.spec.ts b/src/engine/adapters/baileys-send-seen.spec.ts index ed7693951..0634807ac 100644 --- a/src/engine/adapters/baileys-send-seen.spec.ts +++ b/src/engine/adapters/baileys-send-seen.spec.ts @@ -1,6 +1,7 @@ import type { WAMessage, WASocket } from '@whiskeysockets/baileys'; import { BaileysContacts, BaileysContactsHost } from './baileys-contacts'; import { EngineTransportError } from '../../common/errors/engine-transport.error'; +import { toNeutralJid } from '../identity/wa-id'; /** * `readMessages` reaches `fetchPrivacySettings`, whose body is @@ -23,7 +24,7 @@ const toEngineJid = (jid: string): string => { }; function makeHost(overrides: Partial>): BaileysContactsHost { - return { + const host = { ensureReady: () => undefined, logger: { warn: jest.fn(), debug: jest.fn(), info: jest.fn(), error: jest.fn() }, normalizedSelfJid: () => '628177@s.whatsapp.net', @@ -36,6 +37,10 @@ function makeHost(overrides: Partial> toEngineJid, ...overrides, } as unknown as BaileysContactsHost; + // Wired the way the adapter wires it: the real fold, reading the session's lid mapping through + // this host's own resolvePhone, so a test can hand it a mapping and exercise the lid dialect. + host.toNeutralJid = (jid: string): string => toNeutralJid(jid, id => host.resolvePhone(id)); + return host; } function contacts(sock: Record, budgetMs: number): BaileysContacts { @@ -86,6 +91,38 @@ describe('sendSeen', () => { expect(readMessages).toHaveBeenCalledWith([{ remoteJid: '628123@s.whatsapp.net', id: 'X1', fromMe: false }]); }); + it('still uses a stored key stored under the peer lid for the addressed chat', async () => { + // Baileys addresses a DM by the peer's lid, so the stored key's remoteJid is `@lid` while + // the caller names the chat by phone number. Folding through the ENGINE dialect cannot reduce a + // lid, so every stored key was discarded and the receipt fell back to a synthesised key that + // lost the real fromMe and participant. + const readMessages = jest.fn().mockResolvedValue(undefined); + const host = makeHost({ + getSocket: () => ({ readMessages }) as unknown as WASocket, + resolvePhone: (jid: string) => (jid === '9988@lid' ? '628123' : null), + getStoredMessages: () => + Promise.resolve([stored({ id: 'L1', remoteJid: '9988@lid', fromMe: true, participant: '9988@lid' })]), + }); + + await expect(new BaileysContacts(host, 500).sendSeen('628123@c.us', ['L1'])).resolves.toBe(true); + expect(readMessages).toHaveBeenCalledWith([ + { id: 'L1', remoteJid: '9988@lid', fromMe: true, participant: '9988@lid' }, + ]); + }); + + it('still rejects a lid-addressed key from another chat', async () => { + // Negative twin of the case above: reducing through the neutral dialect must not turn the scope + // check into "accept anything spelled @lid". An unmapped lid stays itself and cannot match. + const readMessages = jest.fn().mockResolvedValue(undefined); + const host = makeHost({ + getSocket: () => ({ readMessages }) as unknown as WASocket, + getStoredMessages: () => Promise.resolve([stored({ id: 'L2', remoteJid: '7777@lid', fromMe: true })]), + }); + + await expect(new BaileysContacts(host, 500).sendSeen('628123@c.us', ['L2'])).resolves.toBe(true); + expect(readMessages).toHaveBeenCalledWith([{ remoteJid: '628123@s.whatsapp.net', id: 'L2', fromMe: false }]); + }); + it('still uses a stored key whose chat is spelled in the other dialect', async () => { // Control for the check above: the same chat written @c.us must still resolve, or the fix would // have cost every receipt its participant and fromMe. diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 5e790a664..ee4227506 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -2530,6 +2530,26 @@ describe('BaileysAdapter media sends', () => { expect(fakeSock.sendMessage).toHaveBeenCalledWith('628111@s.whatsapp.net', { sticker: webp }); }); + it('sendStickerMessage tags the participants it was given', async () => { + // A sticker has neither text nor caption, but stickerMessage carries a contextInfo like any other + // content type, so the tag still reaches the participant. The route accepts the field, so dropping + // it here left a documented capability doing nothing. + const adapter = await ready(); + const webp = Buffer.from( + 'UklGRlgAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAAAf1ZQOCAwAAAA0AEAnQEqAQABAAFAJiWgAnS6AfgAA7AA/vLrf/zYFc1z7/f/0uD9Lg/S4P/SkAAA', + 'base64', + ); + await adapter.sendStickerMessage('628111@s.whatsapp.net', { + mimetype: 'image/webp', + data: webp, + mentions: ['62811@c.us'], + }); + expect(fakeSock.sendMessage).toHaveBeenCalledWith('628111@s.whatsapp.net', { + sticker: webp, + mentions: ['62811@s.whatsapp.net'], + }); + }); + it('uses the caller-declared mimetype over the fetched content-type for a URL', async () => { (loadRemoteMediaBuffer as jest.Mock).mockResolvedValue({ data: Buffer.from([1]), @@ -2595,6 +2615,30 @@ describe('BaileysAdapter store-backed ops', () => { ); }); + it('replyToMessage tags the participants it was given, de-normalized to the engine dialect', async () => { + fakeStore.getMessage.mockResolvedValue(stored); + const adapter = await ready(); + await adapter.replyToMessage('628111@s.whatsapp.net', 'TARGET', 'hi @62811', ['62811@c.us']); + expect(fakeSock.sendMessage).toHaveBeenCalledWith( + '628111@s.whatsapp.net', + { text: 'hi @62811', mentions: ['62811@s.whatsapp.net'], linkPreview: null }, + expect.objectContaining({ quoted: stored }), + ); + }); + + it('replyToMessage sends no mentions key for an empty list, keeping an untagged reply byte-identical', async () => { + // Control for the case above: an empty array must not add the key, or every untagged reply would + // start carrying an empty contextInfo tag list. + fakeStore.getMessage.mockResolvedValue(stored); + const adapter = await ready(); + await adapter.replyToMessage('628111@s.whatsapp.net', 'TARGET', 'my reply', []); + expect(fakeSock.sendMessage).toHaveBeenCalledWith( + '628111@s.whatsapp.net', + { text: 'my reply', linkPreview: null }, + expect.objectContaining({ quoted: stored }), + ); + }); + // The one requireStored path that had no chat check, while whatsapp-web.js resolves the quote by // fetching from the named chat and 404s when the id is not in it. it('replyToMessage throws MessageNotFoundError when the quoted key belongs to another chat', async () => { @@ -2759,6 +2803,25 @@ describe('BaileysAdapter store-backed ops', () => { expect(fakeSock.sendMessage).not.toHaveBeenCalled(); }); + it('editMessage re-applies participant tags to the new body', async () => { + // An edit REPLACES the content, so a body that still reads "@62811" needs the tag list resent or + // the rewritten message loses the tag the original had. + fakeStore.getMessage.mockResolvedValue(ownStored); + fakeSock.sendMessage.mockResolvedValue({ key: { ...ownStored.key }, messageTimestamp: 1700000010 }); + const adapter = await ready(); + await adapter.editMessage('628111@s.whatsapp.net', 'TARGET', 'edited @62811', ['62811@c.us']); + expect(fakeSock.sendMessage).toHaveBeenCalledWith( + '628111@s.whatsapp.net', + { + text: 'edited @62811', + mentions: ['62811@s.whatsapp.net'], + edit: ownStored.key, + linkPreview: null, + }, + expect.objectContaining({ getUrlInfo: expect.any(Function) as unknown }) as unknown, + ); + }); + it('editMessage edits via the stored key and returns the (unchanged) message id', async () => { fakeStore.getMessage.mockResolvedValue(ownStored); fakeSock.sendMessage.mockResolvedValue({ key: { ...ownStored.key }, messageTimestamp: 1700000010 }); diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index f577fde50..a86d1238e 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -317,8 +317,8 @@ export class BaileysAdapter implements IWhatsAppEngine { return this.messaging.sendPollMessage(chatId, poll); } - async replyToMessage(chatId: string, quotedMsgId: string, text: string): Promise { - return this.messaging.replyToMessage(chatId, quotedMsgId, text); + async replyToMessage(chatId: string, quotedMsgId: string, text: string, mentions?: string[]): Promise { + return this.messaging.replyToMessage(chatId, quotedMsgId, text, mentions); } async forwardMessage(fromChatId: string, toChatId: string, messageId: string): Promise { @@ -345,8 +345,8 @@ export class BaileysAdapter implements IWhatsAppEngine { return this.messaging.unpinMessage(chatId, messageId); } - async editMessage(chatId: string, messageId: string, body: string): Promise { - return this.messaging.editMessage(chatId, messageId, body); + async editMessage(chatId: string, messageId: string, body: string, mentions?: string[]): Promise { + return this.messaging.editMessage(chatId, messageId, body, mentions); } // ----- Groups ----- diff --git a/src/engine/adapters/whatsapp-web-js.adapter.spec.ts b/src/engine/adapters/whatsapp-web-js.adapter.spec.ts index 1a02084f2..bdf9df3fa 100644 --- a/src/engine/adapters/whatsapp-web-js.adapter.spec.ts +++ b/src/engine/adapters/whatsapp-web-js.adapter.spec.ts @@ -3937,6 +3937,33 @@ describe('outbound document mode (#989)', () => { ); }); + it('passes a sticker tag list through the same options bag as any other media send', async () => { + const sendMessage = jest.fn().mockResolvedValue(sentMessage); + + await ready({ sendMessage }).sendStickerMessage('120363@g.us', { + mimetype: 'image/webp', + data: Buffer.from([1]).toString('base64'), + mentions: ['62811@c.us'], + }); + + expect(sendMessage).toHaveBeenCalledWith( + '120363@g.us', + expect.anything(), + expect.objectContaining({ sendMediaAsSticker: true, mentions: ['62811@c.us'] }), + ); + + // Control: without a list the options bag must not gain a mentions key at all, or every untagged + // sticker send changes shape. + sendMessage.mockClear(); + await ready({ sendMessage }).sendStickerMessage('120363@g.us', { + mimetype: 'image/webp', + data: Buffer.from([1]).toString('base64'), + mentions: [], + }); + const [, , opts] = sendMessage.mock.calls[0] as [string, unknown, Record]; + expect(Object.keys(opts)).not.toContain('mentions'); + }); + // A sticker's mimetype is an instruction, not a label: whatsapp-web.js returns the media // unconverted once it reads as webp, so trusting a declared image/webp over bytes that are not // webp ships raw bytes as a sticker. The response saw the bytes; the caller did not. @@ -4249,6 +4276,21 @@ describe('LID resolution for individual sends (#573 — WhatsApp @c.us → @lid expect(reply).toHaveBeenCalledWith('hi', '628@c.us'); }); + it('reply passes the tag list through as send options, and omits the options bag without one', async () => { + const reply = jest.fn().mockResolvedValue(sentMessage); + const quoted = { id: { _serialized: 'Q1' }, reply }; + const getChatById = jest.fn().mockResolvedValue({ fetchMessages: jest.fn().mockResolvedValue([quoted]) }); + const getNumberId = jest.fn().mockResolvedValue({ _serialized: '628@c.us' }); + + await ready({ getChatById, getNumberId }).replyToMessage('628@c.us', 'Q1', 'hi @62811', ['62811@c.us']); + expect(reply).toHaveBeenCalledWith('hi @62811', '628@c.us', { mentions: ['62811@c.us'] }); + + // Control: an empty list must not start passing an options bag to every untagged reply. + reply.mockClear(); + await ready({ getChatById, getNumberId }).replyToMessage('628@c.us', 'Q1', 'hi', []); + expect(reply).toHaveBeenCalledWith('hi', '628@c.us'); + }); + it('forward routes to the resolved @lid and recovers the id from that chat (#583 R1)', async () => { const forward = jest.fn().mockResolvedValue(undefined); const srcMsg = { id: { _serialized: 'M1' }, forward }; @@ -4282,6 +4324,19 @@ describe('editMessage', () => { expect(res).toEqual({ id: 'M1', timestamp: 1700000002 }); }); + it('edit re-applies participant tags, and sends no options bag when none were asked for', async () => { + const edit = jest.fn().mockResolvedValue({ id: { _serialized: 'M1' }, timestamp: 1700000002 }); + const adapter = ready(chatWith([{ id: { _serialized: 'M1', id: 'RAW1' }, edit }])); + + await adapter.editMessage('628@c.us', 'M1', 'new @62811', ['62811@c.us']); + expect(edit).toHaveBeenCalledWith('new @62811', { mentions: ['62811@c.us'] }); + + // Control: without tags the library call keeps its single-argument shape. + edit.mockClear(); + await adapter.editMessage('628@c.us', 'M1', 'new body', []); + expect(edit).toHaveBeenCalledWith('new body'); + }); + it('also matches the bare id.id fallback (like deleteMessage)', async () => { const edit = jest.fn().mockResolvedValue({ id: { _serialized: 'true_628@c.us_RAW1' }, timestamp: 1700000002 }); const adapter = ready(chatWith([{ id: { _serialized: 'true_628@c.us_RAW1', id: 'RAW1' }, edit }])); diff --git a/src/engine/adapters/whatsapp-web-js.adapter.ts b/src/engine/adapters/whatsapp-web-js.adapter.ts index d6fa18e09..5e2255ab6 100644 --- a/src/engine/adapters/whatsapp-web-js.adapter.ts +++ b/src/engine/adapters/whatsapp-web-js.adapter.ts @@ -591,8 +591,8 @@ export class WhatsAppWebJsAdapter extends EventEmitter implements IWhatsAppEngin return this.messaging.sendPollMessage(chatId, poll); } - replyToMessage(chatId: string, quotedMsgId: string, text: string): Promise { - return this.messaging.replyToMessage(chatId, quotedMsgId, text); + replyToMessage(chatId: string, quotedMsgId: string, text: string, mentions?: string[]): Promise { + return this.messaging.replyToMessage(chatId, quotedMsgId, text, mentions); } forwardMessage(fromChatId: string, toChatId: string, messageId: string): Promise { @@ -722,8 +722,8 @@ export class WhatsAppWebJsAdapter extends EventEmitter implements IWhatsAppEngin } // Edit Message - editMessage(chatId: string, messageId: string, body: string): Promise { - return this.messaging.editMessage(chatId, messageId, body); + editMessage(chatId: string, messageId: string, body: string, mentions?: string[]): Promise { + return this.messaging.editMessage(chatId, messageId, body, mentions); } // Get Profile Picture diff --git a/src/engine/adapters/wwebjs-messaging.ts b/src/engine/adapters/wwebjs-messaging.ts index 5023c3806..bff4d2aed 100644 --- a/src/engine/adapters/wwebjs-messaging.ts +++ b/src/engine/adapters/wwebjs-messaging.ts @@ -489,6 +489,9 @@ export class WwebjsMessaging { this.client().sendMessage(to, messageMedia, { sendMediaAsSticker: true, ...this.quoteOptions(media.quotedMessageId), + // Same options bag every other media send uses, so the library tags a sticker exactly as it + // tags an image. Omitted when empty to leave an untagged sticker call unchanged. + ...(media.mentions?.length ? { mentions: media.mentions } : {}), }), media.quotedMessageId, ); @@ -521,7 +524,7 @@ export class WwebjsMessaging { return toMessageResult(msg); } - async replyToMessage(chatId: string, quotedMsgId: string, text: string): Promise { + async replyToMessage(chatId: string, quotedMsgId: string, text: string, mentions?: string[]): Promise { this.host.ensureReady(); try { // Find the message to quote @@ -536,7 +539,13 @@ export class WwebjsMessaging { // Reply's send leg hits the same `No LID for user` path as a normal send for a migrated contact, // so route it through sendResolved (resolve @c.us->@lid, cache, self-heal). reply(content, chatId) // accepts an explicit target (#583 R1). - const msg = await this.sendResolved(chatId, to => quotedMsg.reply(text, to)); + // reply(content, chatId, options) takes the same options bag as sendMessage, so a quoted send + // tags participants exactly as a plain one does. The options argument is omitted entirely when + // no tags were asked for, keeping an ordinary reply's call shape untouched (same rule as the + // send path). + const msg = await this.sendResolved(chatId, to => + mentions?.length ? quotedMsg.reply(text, to, { mentions }) : quotedMsg.reply(text, to), + ); return toMessageResult(msg); } catch (error) { this.host.reportIfPageTransportError(error, 'replyToMessage'); @@ -750,7 +759,7 @@ export class WwebjsMessaging { this.host.logger.log(`Deleted message ${messageId} from chat ${chatId} (forEveryone: ${forEveryone})`); } - async editMessage(chatId: string, messageId: string, body: string): Promise { + async editMessage(chatId: string, messageId: string, body: string, mentions?: string[]): Promise { this.host.ensureReady(); // Same lookup window as react/delete: fetchMessages sees only the 100 most recent messages. // NOTE: do NOT resolve chatId to @lid here — edit operates on the found message's own key, not @@ -767,7 +776,9 @@ export class WwebjsMessaging { if (!message) { throw new MessageNotFoundError(messageId, chatId); } - const edited = await message.edit(body); + // An edit REPLACES the content, so tags are re-applied rather than preserved: omitting mentions + // drops whatever the original carried. Options omitted entirely when none were asked for. + const edited = mentions?.length ? await message.edit(body, { mentions }) : await message.edit(body); if (!edited) { // wwebjs RESOLVES null (instead of throwing) when the page-side edit is refused — only the // account's own text messages are editable; surface the refusal, not a phantom success. diff --git a/src/engine/interfaces/whatsapp-engine.interface.ts b/src/engine/interfaces/whatsapp-engine.interface.ts index 7a2557159..e2b1d19fa 100644 --- a/src/engine/interfaces/whatsapp-engine.interface.ts +++ b/src/engine/interfaces/whatsapp-engine.interface.ts @@ -909,7 +909,11 @@ export interface MessagingCapability { sendPollMessage(chatId: string, poll: PollInput): Promise; - replyToMessage(chatId: string, quotedMsgId: string, text: string): Promise; + /** + * Reply to a message, quoting it. `mentions` tags participants exactly as on the send routes: the + * text must also carry the matching `@` token for WhatsApp to render the tag. + */ + replyToMessage(chatId: string, quotedMsgId: string, text: string, mentions?: string[]): Promise; forwardMessage(fromChatId: string, toChatId: string, messageId: string): Promise; } @@ -930,8 +934,11 @@ export interface MessageOperationsCapability { /** * Edit the body of a text message. Only the account's OWN messages can be edited; engines reject * non-text or foreign messages at their own layer — the engine's error is surfaced as-is. + * + * `mentions` re-applies participant tags to the new body. An edit REPLACES the message content, so + * omitting it drops whatever tags the original carried rather than preserving them. */ - editMessage(chatId: string, messageId: string, body: string): Promise; + editMessage(chatId: string, messageId: string, body: string, mentions?: string[]): Promise; /** * Star (bookmark) a message, or remove its star. Starring is a private, account-local marker — diff --git a/src/modules/message/bulk-message.service.spec.ts b/src/modules/message/bulk-message.service.spec.ts index fa0dc0d4c..f4205a3bd 100644 --- a/src/modules/message/bulk-message.service.spec.ts +++ b/src/modules/message/bulk-message.service.spec.ts @@ -267,7 +267,11 @@ describe('BulkMessageService.processBatch', () => { }) as unknown as MessageBatch; beforeEach(async () => { - engine = { sendTextMessage: jest.fn().mockResolvedValue({ id: 'wa1', timestamp: 111 }) }; + engine = { + sendTextMessage: jest.fn().mockResolvedValue({ id: 'wa1', timestamp: 111 }), + sendImageMessage: jest.fn().mockResolvedValue({ id: 'wa1', timestamp: 111 }), + sendAudioMessage: jest.fn().mockResolvedValue({ id: 'wa1', timestamp: 111 }), + }; engines = new EngineRegistry(); engines.set('s1', engine as unknown as IWhatsAppEngine); messageService = { saveOutgoingMessage: jest.fn().mockResolvedValue(undefined) }; @@ -772,6 +776,76 @@ describe('BulkMessageService.processBatch', () => { expect(engine.sendTextMessage).toHaveBeenCalledWith('c0@c.us', 'Hi Sam'); }); + it('tags participants per item, on the text body and on a media caption alike', async () => { + // Each item names its own list: a batch fans out to many chats, and a WID is only taggable in a + // chat the participant is actually in. + const batch = makeBatch(1); + batch.messages[0].content = { text: 'Hi @62811', mentions: ['62811@c.us'] }; + repo.findOne.mockResolvedValue(batch); + + await runProcessBatch(); + + expect(engine.sendTextMessage).toHaveBeenCalledWith('c0@c.us', 'Hi @62811', ['62811@c.us']); + + const media = makeBatch(1); + media.messages[0].type = 'image'; + media.messages[0].content = { + image: { base64: 'AAAA', mimetype: 'image/jpeg' }, + caption: 'look @62811', + mentions: ['62811@c.us'], + }; + repo.findOne.mockResolvedValue(media); + + await runProcessBatch(); + + expect(engine.sendImageMessage).toHaveBeenCalledWith( + 'c0@c.us', + expect.objectContaining({ caption: 'look @62811', mentions: ['62811@c.us'] }), + ); + }); + + it('substitutes variables inside mentions, so a campaign can tag a different participant per item', async () => { + // applyVariables walks the whole content tree, so a placeholder in a WID is rendered like one in + // the body. That is load-bearing for a personalised batch, and nothing else pins it: a future + // rewrite of applyVariables that rebuilt the object field by field would drop mentions silently. + const batch = makeBatch(1); + batch.messages[0].content = { text: 'Hi @{{num}}', mentions: ['{{num}}@c.us'] }; + batch.messages[0].variables = { num: '62811' }; + repo.findOne.mockResolvedValue(batch); + + await runProcessBatch(); + + expect(engine.sendTextMessage).toHaveBeenCalledWith('c0@c.us', 'Hi @62811', ['62811@c.us']); + }); + + it('tags an audio item too, which has no caption to anchor a visible @token', async () => { + // Audio carries no caption, but a mention still tags through contextInfo, which is why the + // single-send audio route accepts the field. Skipping it here would accept the list and deliver + // an untagged voice note, turning a clear 400 into a silent no-op for one bulk type only. + const batch = makeBatch(1); + batch.messages[0].type = 'audio'; + batch.messages[0].content = { audio: { base64: 'AAAA', mimetype: 'audio/mpeg' }, mentions: ['62811@c.us'] }; + repo.findOne.mockResolvedValue(batch); + + await runProcessBatch(); + + expect(engine.sendAudioMessage).toHaveBeenCalledWith( + 'c0@c.us', + expect.objectContaining({ mentions: ['62811@c.us'] }), + ); + }); + + it('leaves an untagged batch item on its two-argument send', async () => { + // Control: without a list the call shape every existing batch makes is untouched. + const batch = makeBatch(1); + batch.messages[0].content = { text: 'Hi there', mentions: [] }; + repo.findOne.mockResolvedValue(batch); + + await runProcessBatch(); + + expect(engine.sendTextMessage).toHaveBeenCalledWith('c0@c.us', 'Hi there'); + }); + it('fails an item whose rendered variables grow its base64 media past the cap (no send, explicit failure)', async () => { process.env.MEDIA_DOWNLOAD_MAX_BYTES = '16'; try { diff --git a/src/modules/message/bulk-message.service.ts b/src/modules/message/bulk-message.service.ts index 92aa6621e..faef5df15 100644 --- a/src/modules/message/bulk-message.service.ts +++ b/src/modules/message/bulk-message.service.ts @@ -40,6 +40,7 @@ import { resolveNonNegativeIntEnv } from '../../config/configuration'; interface BulkMessageContent { text?: string; caption?: string; + mentions?: string[]; image?: { url?: string; base64?: string; mimetype?: string; filename?: string }; video?: { url?: string; base64?: string; mimetype?: string; filename?: string }; audio?: { url?: string; base64?: string; mimetype?: string; filename?: string; ptt?: boolean }; @@ -767,24 +768,33 @@ export class BulkMessageService implements OnApplicationBootstrap { ): Promise { switch (type) { case 'text': - return engine.sendTextMessage(chatId, content.text || ''); + return content.mentions?.length + ? engine.sendTextMessage(chatId, content.text || '', content.mentions) + : engine.sendTextMessage(chatId, content.text || ''); case 'image': return engine.sendImageMessage(chatId, { mimetype: content.image?.mimetype || 'image/jpeg', data: stripBase64DataUri(content.image?.base64) || content.image?.url || '', caption: content.caption, + mentions: content.mentions, }); case 'video': return engine.sendVideoMessage(chatId, { mimetype: content.video?.mimetype || 'video/mp4', data: stripBase64DataUri(content.video?.base64) || content.video?.url || '', caption: content.caption, + mentions: content.mentions, }); case 'audio': + // Forwarded even though audio carries no caption: a mention tags the recipient through + // contextInfo without visible @text, which is why the single-send audio route accepts it too + // (see sendAudioMessage in baileys-messaging.ts). Dropping it here would accept the field and + // then deliver an untagged voice note with nothing to say so. return engine.sendAudioMessage(chatId, { mimetype: content.audio?.mimetype || (content.audio?.ptt ? 'audio/ogg; codecs=opus' : 'audio/mpeg'), data: stripBase64DataUri(content.audio?.base64) || content.audio?.url || '', ptt: content.audio?.ptt, + mentions: content.mentions, }); case 'document': return engine.sendDocumentMessage(chatId, { @@ -792,6 +802,7 @@ export class BulkMessageService implements OnApplicationBootstrap { data: stripBase64DataUri(content.document?.base64) || content.document?.url || '', filename: content.document?.filename, caption: content.caption, + mentions: content.mentions, }); default: return Promise.reject(new Error(`Unsupported message type: ${type}`)); diff --git a/src/modules/message/dto/bulk-message.dto.ts b/src/modules/message/dto/bulk-message.dto.ts index c07194fa8..bf34d26c7 100644 --- a/src/modules/message/dto/bulk-message.dto.ts +++ b/src/modules/message/dto/bulk-message.dto.ts @@ -14,7 +14,15 @@ import { ArrayMaxSize, } from 'class-validator'; import { Type } from 'class-transformer'; +import { Validate } from 'class-validator'; import { ToStrictBoolean } from '../../../common/utils/strict-boolean'; +import { + MENTIONS_DESCRIPTION, + MENTIONS_MAX, + MENTION_WID_MAX_LENGTH, + MESSAGE_TEXT_MAX_LENGTH, +} from './send-message.dto'; +import { IsMentionWidConstraint } from './is-mention-wid.validator'; import { BatchMessageStatus, BatchStatus } from '../entities/message-batch.entity'; class BulkMediaDto { @@ -46,10 +54,10 @@ class BulkMediaDto { } class BulkMessageContentDto { - @ApiPropertyOptional({ description: 'Text content for text messages', maxLength: 4096 }) + @ApiPropertyOptional({ description: 'Text content for text messages', maxLength: MESSAGE_TEXT_MAX_LENGTH }) @IsOptional() @IsString() - @MaxLength(4096) + @MaxLength(MESSAGE_TEXT_MAX_LENGTH) text?: string; // Typed nested DTOs (not bare object literals) so the global ValidationPipe's whitelist / @@ -84,6 +92,18 @@ class BulkMessageContentDto { @IsString() @MaxLength(1024) caption?: string; + + // Applies to the text body and to a media caption alike, matching the single-send routes. Every + // item in a batch names its own list: a batch fans out to many chats, and a WID is only taggable + // in a chat the participant is in. + @ApiPropertyOptional({ description: MENTIONS_DESCRIPTION, example: ['628123456789@c.us'], type: [String] }) + @IsOptional() + @IsArray() + @ArrayMaxSize(MENTIONS_MAX) + @IsString({ each: true }) + @MaxLength(MENTION_WID_MAX_LENGTH, { each: true }) + @Validate(IsMentionWidConstraint, { each: true }) + mentions?: string[]; } class BulkMessageItemDto { diff --git a/src/modules/message/dto/message-actions.dto.spec.ts b/src/modules/message/dto/message-actions.dto.spec.ts index 670d52522..a1816c1e0 100644 --- a/src/modules/message/dto/message-actions.dto.spec.ts +++ b/src/modules/message/dto/message-actions.dto.spec.ts @@ -1,3 +1,4 @@ +import { ValidationPipe } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { validate, ValidationError } from 'class-validator'; import { @@ -7,7 +8,9 @@ import { DeleteMessageDto, ForwardMessageDto, EditMessageDto, + ReplyMessageDto, } from './message-actions.dto'; +import { GLOBAL_VALIDATION_OPTIONS } from '../../../config/app-validation'; /** * Regression locks: these endpoints previously took inline @Body literals (no @@ -141,3 +144,47 @@ describe('message action DTOs', () => { expect(errs.some(e => e.property === 'messageId')).toBe(true); }); }); + +/** + * These run through the REAL production pipe rather than bare `validate()`, because the defect they + * lock is a whitelist rejection: `forbidNonWhitelisted` answers 400 "property X should not exist" + * for anything the DTO does not declare, and a plain validate() call cannot see that at all. + */ +describe('mentions on the quoted-send and edit routes (whitelist behaviour)', () => { + const pipe = new ValidationPipe(GLOBAL_VALIDATION_OPTIONS); + const through = (metatype: unknown, value: object): Promise => + pipe.transform(value, { type: 'body', metatype: metatype as never }); + + const REPLY = { chatId: '120363000000000000@g.us', quotedMessageId: 'Q1', text: 'hi @62811' }; + const EDIT = { chatId: '120363000000000000@g.us', messageId: 'M1', body: 'hi @62811' }; + + it('ReplyMessageDto accepts a mentions array', async () => { + await expect(through(ReplyMessageDto, { ...REPLY, mentions: ['62811@c.us'] })).resolves.toMatchObject({ + mentions: ['62811@c.us'], + }); + }); + + it('EditMessageDto accepts a mentions array', async () => { + await expect(through(EditMessageDto, { ...EDIT, mentions: ['62811@c.us'] })).resolves.toMatchObject({ + mentions: ['62811@c.us'], + }); + }); + + // Control for both cases above. Without it, a pipe whose whitelist had been switched off entirely + // would satisfy them just as well, and the assertions would prove nothing about the new field. + it('still refuses a property no DTO declares', async () => { + await expect(through(ReplyMessageDto, { ...REPLY, notAField: 1 })).rejects.toMatchObject({ + response: { message: ['property notAField should not exist'] }, + }); + await expect(through(EditMessageDto, { ...EDIT, notAField: 1 })).rejects.toMatchObject({ + response: { message: ['property notAField should not exist'] }, + }); + }); + + it('rejects a mentions entry that is not an individual WID', async () => { + // A group id is not a participant, and the shared constraint refuses it on every route that + // takes the field. + await expect(through(ReplyMessageDto, { ...REPLY, mentions: ['120363000000000000@g.us'] })).rejects.toBeDefined(); + await expect(through(EditMessageDto, { ...EDIT, mentions: ['not-a-wid'] })).rejects.toBeDefined(); + }); +}); diff --git a/src/modules/message/dto/message-actions.dto.ts b/src/modules/message/dto/message-actions.dto.ts index e1b43d065..e02a487a2 100644 --- a/src/modules/message/dto/message-actions.dto.ts +++ b/src/modules/message/dto/message-actions.dto.ts @@ -11,9 +11,18 @@ import { ArrayMaxSize, MaxLength, IsIn, + Validate, } from 'class-validator'; import { ToStrictBoolean, ToStrictNumber } from '../../../common/utils/strict-boolean'; -import { MESSAGE_TEXT_MAX_LENGTH, QUOTED_MESSAGE_ID_DESCRIPTION, QUOTED_MESSAGE_ID_EXAMPLE } from './send-message.dto'; +import { + MENTIONS_DESCRIPTION, + MENTIONS_MAX, + MENTION_WID_MAX_LENGTH, + MESSAGE_TEXT_MAX_LENGTH, + QUOTED_MESSAGE_ID_DESCRIPTION, + QUOTED_MESSAGE_ID_EXAMPLE, +} from './send-message.dto'; +import { IsMentionWidConstraint } from './is-mention-wid.validator'; /** * Validated DTOs for the message action endpoints. These replaced inline @@ -148,6 +157,15 @@ export class ReplyMessageDto { @IsNotEmpty() @MaxLength(MESSAGE_TEXT_MAX_LENGTH) text!: string; + + @ApiPropertyOptional({ description: MENTIONS_DESCRIPTION, example: ['628123456789@c.us'], type: [String] }) + @IsOptional() + @IsArray() + @ArrayMaxSize(MENTIONS_MAX) + @IsString({ each: true }) + @MaxLength(MENTION_WID_MAX_LENGTH, { each: true }) + @Validate(IsMentionWidConstraint, { each: true }) + mentions?: string[]; } export class ForwardMessageDto { @@ -311,10 +329,22 @@ export class EditMessageDto { @IsNotEmpty() messageId!: string; - // Same body cap as SendTextMessageDto.text — an edit cannot exceed what a send allows. - @ApiProperty({ description: 'New text body for the message', maxLength: 4096 }) + // Same body cap as SendTextMessageDto.text — an edit cannot exceed what a send allows. Bound to the + // shared constant rather than restated, so the two cannot drift apart. + @ApiProperty({ description: 'New text body for the message', maxLength: MESSAGE_TEXT_MAX_LENGTH }) @IsString() @IsNotEmpty() - @MaxLength(4096) + @MaxLength(MESSAGE_TEXT_MAX_LENGTH) body!: string; + + // An edit REPLACES the message content, so tags are re-applied rather than preserved: omitting + // this drops whatever the original body carried. + @ApiPropertyOptional({ description: MENTIONS_DESCRIPTION, example: ['628123456789@c.us'], type: [String] }) + @IsOptional() + @IsArray() + @ArrayMaxSize(MENTIONS_MAX) + @IsString({ each: true }) + @MaxLength(MENTION_WID_MAX_LENGTH, { each: true }) + @Validate(IsMentionWidConstraint, { each: true }) + mentions?: string[]; } diff --git a/src/modules/message/dto/send-message.dto.ts b/src/modules/message/dto/send-message.dto.ts index bf257b1d2..e443a75fb 100644 --- a/src/modules/message/dto/send-message.dto.ts +++ b/src/modules/message/dto/send-message.dto.ts @@ -16,9 +16,27 @@ import { Type } from 'class-transformer'; import { IsMentionWidConstraint } from './is-mention-wid.validator'; import { ToStrictBoolean } from '../../../common/utils/strict-boolean'; -const MENTIONS_DESCRIPTION = +export const MENTIONS_DESCRIPTION = 'WIDs to @mention (e.g. ["62811@c.us"]). The text/caption must also contain the @ token.'; +/** + * Caps for a `mentions` array, exported because the field appears on every REST route whose engine + * can carry it (send, reply, edit, template, bulk) and on the matching agent-tool schemas. Inline + * literals repeated per site is how the two numbers would drift apart between endpoints that share + * one documented contract, and the tool path needs them most: it calls the service directly, so the + * ValidationPipe never runs and the zod schema is the only cap there is. + */ +export const MENTIONS_MAX = 1024; +export const MENTION_WID_MAX_LENGTH = 64; + +/** + * Caps for a custom link preview, exported for the same reason as the mentions pair above: the + * agent-tool schema restates nothing, and that path never reaches the ValidationPipe. + */ +export const CUSTOM_PREVIEW_URL_MAX_LENGTH = 2048; +export const CUSTOM_PREVIEW_TITLE_MAX_LENGTH = 256; +export const CUSTOM_PREVIEW_DESCRIPTION_MAX_LENGTH = 1024; + // Single source of truth for the text-body cap, shared with the agent-tool input schemas // (src/core/agent-tools/tools/message.tools.ts) so MCP and REST enforce the same limit. export const MESSAGE_TEXT_MAX_LENGTH = 4096; @@ -42,7 +60,7 @@ export class CustomLinkPreviewDto { }) @IsString() @IsNotEmpty() - @MaxLength(2048) + @MaxLength(CUSTOM_PREVIEW_URL_MAX_LENGTH) url!: string; @ApiProperty({ @@ -52,13 +70,13 @@ export class CustomLinkPreviewDto { }) @IsString() @IsNotEmpty() - @MaxLength(256) + @MaxLength(CUSTOM_PREVIEW_TITLE_MAX_LENGTH) title!: string; @ApiPropertyOptional({ description: 'Preview description', example: 'Read the announcement.', maxLength: 1024 }) @IsOptional() @IsString() - @MaxLength(1024) + @MaxLength(CUSTOM_PREVIEW_DESCRIPTION_MAX_LENGTH) description?: string; } @@ -84,9 +102,9 @@ export class SendTextMessageDto { @ApiPropertyOptional({ description: MENTIONS_DESCRIPTION, example: ['628123456789@c.us'], type: [String] }) @IsOptional() @IsArray() - @ArrayMaxSize(1024) + @ArrayMaxSize(MENTIONS_MAX) @IsString({ each: true }) - @MaxLength(64, { each: true }) + @MaxLength(MENTION_WID_MAX_LENGTH, { each: true }) @Validate(IsMentionWidConstraint, { each: true }) mentions?: string[]; @@ -212,9 +230,9 @@ export class SendMediaMessageDto { @ApiPropertyOptional({ description: MENTIONS_DESCRIPTION, example: ['628123456789@c.us'], type: [String] }) @IsOptional() @IsArray() - @ArrayMaxSize(1024) + @ArrayMaxSize(MENTIONS_MAX) @IsString({ each: true }) - @MaxLength(64, { each: true }) + @MaxLength(MENTION_WID_MAX_LENGTH, { each: true }) @Validate(IsMentionWidConstraint, { each: true }) mentions?: string[]; diff --git a/src/modules/message/dto/send-template.dto.ts b/src/modules/message/dto/send-template.dto.ts index 1a6b0ebb3..bfb955c46 100644 --- a/src/modules/message/dto/send-template.dto.ts +++ b/src/modules/message/dto/send-template.dto.ts @@ -1,5 +1,19 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString, IsNotEmpty, IsOptional, IsObject, ValidateIf } from 'class-validator'; +import { + IsString, + IsNotEmpty, + IsOptional, + IsObject, + ValidateIf, + IsArray, + ArrayMaxSize, + MaxLength, + IsBoolean, + Validate, +} from 'class-validator'; +import { ToStrictBoolean } from '../../../common/utils/strict-boolean'; +import { MENTIONS_DESCRIPTION, MENTIONS_MAX, MENTION_WID_MAX_LENGTH } from './send-message.dto'; +import { IsMentionWidConstraint } from './is-mention-wid.validator'; export class SendTemplateMessageDto { @ApiProperty({ @@ -37,4 +51,29 @@ export class SendTemplateMessageDto { @IsOptional() @IsObject() vars?: Record; + + // The rendered body is dispatched through the same path as send-text, so it carries the same two + // optionals. `quotedMessageId` is deliberately NOT among them: docs/06 publishes this route as one + // that rejects it. + @ApiPropertyOptional({ description: MENTIONS_DESCRIPTION, example: ['628123456789@c.us'], type: [String] }) + @IsOptional() + @IsArray() + @ArrayMaxSize(MENTIONS_MAX) + @IsString({ each: true }) + @MaxLength(MENTION_WID_MAX_LENGTH, { each: true }) + @Validate(IsMentionWidConstraint, { each: true }) + mentions?: string[]; + + @ApiPropertyOptional({ + description: + 'Controls the URL preview on the rendered body, with the same engine split as send-text: ' + + 'whatsapp-web.js builds one by default and `false` suppresses it, while on Baileys previews ' + + 'are opt-in and only `true` attaches one.', + example: false, + }) + // Same guard as send-text: implicit conversion would turn the string "false" into true. + @ToStrictBoolean() + @IsOptional() + @IsBoolean() + linkPreview?: boolean; } diff --git a/src/modules/message/message-send.service.spec.ts b/src/modules/message/message-send.service.spec.ts index debd70294..436465781 100644 --- a/src/modules/message/message-send.service.spec.ts +++ b/src/modules/message/message-send.service.spec.ts @@ -426,6 +426,33 @@ describe('MessageSendService', () => { expect(mockEngine.sendTextMessage).toHaveBeenCalledWith('test@c.us', 'Hi Alice {{unknown}}'); }); + it('carries mentions and linkPreview from the template body through to the send', async () => { + // The rendered body is dispatched through sendText, so the two optionals it already honours + // reach the engine unchanged rather than being dropped at the template DTO. + (templateService.resolve as jest.Mock).mockResolvedValue(mockTemplate({ body: 'Hi @62811' })); + + await service.sendTemplate('sess-1', { + chatId: 'group@g.us', + templateId: 'tpl-1', + mentions: ['62811@c.us'], + linkPreview: false, + }); + + expect(mockEngine.sendTextMessage).toHaveBeenCalledWith('group@g.us', 'Hi @62811', ['62811@c.us'], { + linkPreview: false, + }); + }); + + it('leaves a plain template send on its two-argument call shape', async () => { + // Control for the case above: without either optional the call must not gain arguments, or + // every existing template send changes shape. + (templateService.resolve as jest.Mock).mockResolvedValue(mockTemplate({ body: 'Hi there' })); + + await service.sendTemplate('sess-1', { chatId: 'test@c.us', templateId: 'tpl-1' }); + + expect(mockEngine.sendTextMessage).toHaveBeenCalledWith('test@c.us', 'Hi there'); + }); + it('should propagate NotFoundException when the template cannot be resolved', async () => { (templateService.resolve as jest.Mock).mockRejectedValue(new NotFoundException('Template not found')); @@ -925,6 +952,53 @@ describe('MessageSendService', () => { expect(mockEngine.replyToMessage).toHaveBeenCalledWith('test@c.us', 'wa-quoted-1', 'This is a reply'); }); + + it('forwards the tag list to the engine, and keeps the untagged call three-argument', async () => { + await service.reply('sess-1', { + chatId: 'group@g.us', + quotedMessageId: 'wa-quoted-1', + text: 'hi @62811', + mentions: ['62811@c.us'], + }); + + expect(mockEngine.replyToMessage).toHaveBeenCalledWith('group@g.us', 'wa-quoted-1', 'hi @62811', ['62811@c.us']); + + // Control: an empty list is not a tag request, so the call shape every existing reply makes is + // left untouched rather than gaining a trailing argument. + mockEngine.replyToMessage.mockClear(); + await service.reply('sess-1', { + chatId: 'group@g.us', + quotedMessageId: 'wa-quoted-1', + text: 'plain', + mentions: [], + }); + expect(mockEngine.replyToMessage).toHaveBeenCalledWith('group@g.us', 'wa-quoted-1', 'plain'); + }); + + it('honours a plugin that rewrites the tag list, not the list the caller sent', async () => { + // message:sending is a moderation chokepoint. Reading the caller's own dto here instead of the + // gated result would send the unredacted tags while the hook reported success. + (hookManager.execute as jest.Mock).mockResolvedValueOnce({ + continue: true, + data: { + input: { + chatId: 'group@g.us', + quotedMessageId: 'wa-quoted-1', + text: 'hi @62811', + mentions: ['62811@c.us'], + }, + }, + }); + + await service.reply('sess-1', { + chatId: 'group@g.us', + quotedMessageId: 'wa-quoted-1', + text: 'hi @62811 @62999', + mentions: ['62811@c.us', '62999@c.us'], + }); + + expect(mockEngine.replyToMessage).toHaveBeenCalledWith('group@g.us', 'wa-quoted-1', 'hi @62811', ['62811@c.us']); + }); }); describe('forward', () => { diff --git a/src/modules/message/message-send.service.ts b/src/modules/message/message-send.service.ts index c3e58d6a7..2afadef19 100644 --- a/src/modules/message/message-send.service.ts +++ b/src/modules/message/message-send.service.ts @@ -238,7 +238,12 @@ export class MessageSendService { ); } - return this.sendText(sessionId, { chatId: dto.chatId, text }); + return this.sendText(sessionId, { + chatId: dto.chatId, + text, + mentions: dto.mentions, + linkPreview: dto.linkPreview, + }); } async sendImage(sessionId: string, dto: SendMediaMessageDto): Promise { @@ -471,7 +476,7 @@ export class MessageSendService { async reply( sessionId: string, - dto: { chatId: string; quotedMessageId: string; text: string }, + dto: { chatId: string; quotedMessageId: string; text: string; mentions?: string[] }, ): Promise { const finalDto = await this.applySendingGate(sessionId, 'reply', dto); const engine = this.getEngine(sessionId); @@ -499,7 +504,11 @@ export class MessageSendService { let result: MessageResult; try { - result = await engine.replyToMessage(finalDto.chatId, finalDto.quotedMessageId, finalDto.text); + // Widened only as far as the caller asked, exactly like the send path: a reply with no tags + // keeps its three-argument shape. + result = finalDto.mentions?.length + ? await engine.replyToMessage(finalDto.chatId, finalDto.quotedMessageId, finalDto.text, finalDto.mentions) + : await engine.replyToMessage(finalDto.chatId, finalDto.quotedMessageId, finalDto.text); } catch (error) { return this.failSend(sessionId, 'reply', message, finalDto, error); } diff --git a/src/modules/message/message.service.spec.ts b/src/modules/message/message.service.spec.ts index dbc84c141..f1f5dc679 100644 --- a/src/modules/message/message.service.spec.ts +++ b/src/modules/message/message.service.spec.ts @@ -115,6 +115,27 @@ describe('MessageService', () => { expect(sendText).toHaveBeenCalledWith('sess-1', { chatId: 'test@c.us', text: 'hi' }); expect(result).toEqual({ messageId: 'wa-msg-1', timestamp: 1706868000 }); }); + + it('passes a reply straight through with its tag list intact', async () => { + // This forwarder is the entry point the controller and the agent tool both call. Its parameter + // was an inline three-field literal while the controller already handed it a fourth, so the + // body reached the sender only because structural typing does not strip excess properties. + const reply = jest.fn().mockResolvedValue({ messageId: 'wa-msg-2', timestamp: 1706868001 }); + const facade = new MessageService( + repository as Repository, + engines, + messageProjector as unknown as MessageProjector, + hookManager as HookManager, + lidMappingStore as unknown as LidMappingStoreService, + inertPacing(), + { reply } as unknown as MessageSendService, + ); + + const body = { chatId: 'g@g.us', quotedMessageId: 'Q1', text: 'hi @62811', mentions: ['62811@c.us'] }; + await facade.reply('sess-1', body); + + expect(reply).toHaveBeenCalledWith('sess-1', body); + }); }); // ── getMessages pagination guard ────────────────────────────────── @@ -621,6 +642,25 @@ describe('MessageService', () => { expect(mockEngine.editMessage).toHaveBeenCalledWith('test@c.us', 'wa-msg-1', 'redacted'); expect(messageProjector.recordOutboundMessageEdit).toHaveBeenCalledWith('sess-1', 'wa-msg-1', 'redacted'); }); + + it('honours a plugin that rewrites the tag list, not the list the caller sent', async () => { + // message:sending is a moderation chokepoint, so a handler that drops a WID from the list must + // win. Reading the caller's own dto here instead of the gated one would send the unredacted + // tags while the hook reported success, and no other assertion in this file would notice. + (hookManager.execute as jest.Mock).mockResolvedValueOnce({ + continue: true, + data: { input: { chatId: 'g@g.us', messageId: 'wa-msg-1', body: 'hi @62811', mentions: ['62811@c.us'] } }, + }); + + await service.editMessage('sess-1', { + chatId: 'g@g.us', + messageId: 'wa-msg-1', + body: 'hi @62811 @62999', + mentions: ['62811@c.us', '62999@c.us'], + }); + + expect(mockEngine.editMessage).toHaveBeenCalledWith('g@g.us', 'wa-msg-1', 'hi @62811', ['62811@c.us']); + }); }); // ── pin / unpin ─────────────────────────────────────────────────── diff --git a/src/modules/message/message.service.ts b/src/modules/message/message.service.ts index 248668b01..aef529d67 100644 --- a/src/modules/message/message.service.ts +++ b/src/modules/message/message.service.ts @@ -5,6 +5,7 @@ import { EngineRegistry } from '../../engine/engine-registry.service'; import { MessageProjector } from '../session/message-projector.service'; import { SendTextMessageDto, SendMediaMessageDto, SendAudioMessageDto, MessageResponseDto } from './dto'; import { SendTemplateMessageDto } from './dto/send-template.dto'; +import { ReplyMessageDto } from './dto/message-actions.dto'; import { Message, MessageDirection } from './entities/message.entity'; import { HookManager, applySendingGate } from '../../core/hooks'; import { SendPacingService } from './send-pacing.service'; @@ -193,10 +194,10 @@ export class MessageService { return this.sender.sendSticker(sessionId, dto); } - reply( - sessionId: string, - dto: { chatId: string; quotedMessageId: string; text: string }, - ): Promise { + // Typed by the DTO rather than an inline literal, like every sibling forwarder here. The literal + // listed three fields while the controller already handed it a fourth, so the declaration said + // less than what flowed through, and a non-REST caller (the agent tool) could not pass it at all. + reply(sessionId: string, dto: ReplyMessageDto): Promise { return this.sender.reply(sessionId, dto); } @@ -480,14 +481,16 @@ export class MessageService { async editMessage( sessionId: string, - dto: { chatId: string; messageId: string; body: string }, + dto: { chatId: string; messageId: string; body: string; mentions?: string[] }, ): Promise { const engine = this.getEngine(sessionId); // An edit replaces the text the recipient sees, so it is content leaving the account and goes // through the same moderation chokepoint as every other sender. A plugin can rewrite `body` // here exactly as it can for a first send. const finalDto = await this.applySendingGate(sessionId, 'edit', dto); - const result = await engine.editMessage(finalDto.chatId, finalDto.messageId, finalDto.body); + const result = finalDto.mentions?.length + ? await engine.editMessage(finalDto.chatId, finalDto.messageId, finalDto.body, finalDto.mentions) + : await engine.editMessage(finalDto.chatId, finalDto.messageId, finalDto.body); // Best-effort: reflect the new body in the stored copy (mirrors deleteMessage's revoked flag), // serialized with the inbound edit/reaction writers through the session's per-message mutation diff --git a/src/modules/queue/processors/webhook.processor.spec.ts b/src/modules/queue/processors/webhook.processor.spec.ts index 9ff5b0b3d..e7ef5f45a 100644 --- a/src/modules/queue/processors/webhook.processor.spec.ts +++ b/src/modules/queue/processors/webhook.processor.spec.ts @@ -23,7 +23,7 @@ jest.mock('undici', () => { describe('WebhookProcessor', () => { let processor: WebhookProcessor; let repo: { update: jest.Mock }; - let failureRepo: { insert: jest.Mock }; + let failureRepo: { insert: jest.Mock; count: jest.Mock }; let hookManager: { execute: jest.Mock }; let configService: { get: jest.Mock }; let mockFetch: jest.Mock; @@ -54,7 +54,24 @@ describe('WebhookProcessor', () => { beforeEach(() => { repo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; - failureRepo = { insert: jest.fn().mockResolvedValue({}) }; + // Stateful like the real table: the recorder counts existing rows for the delivery before it + // inserts, so a constant would leave that guard unexercised here and let a duplicated row pass. + const insertedFailures: Array<{ webhookId?: string; idempotencyKey?: string | null }> = []; + failureRepo = { + insert: jest.fn().mockImplementation((rowToInsert: { webhookId?: string; idempotencyKey?: string | null }) => { + insertedFailures.push(rowToInsert); + return Promise.resolve({}); + }), + count: jest + .fn() + .mockImplementation((opts: { where: { webhookId?: string; idempotencyKey?: string } }) => + Promise.resolve( + insertedFailures.filter( + r => r.webhookId === opts.where.webhookId && r.idempotencyKey === opts.where.idempotencyKey, + ).length, + ), + ), + }; hookManager = { execute: jest.fn().mockResolvedValue({ continue: true, data: {} }) }; configService = { get: jest.fn((key: string, def?: unknown) => (key === 'webhook.timeout' ? 25000 : def)) }; processor = new WebhookProcessor( diff --git a/src/modules/queue/processors/webhook.processor.ts b/src/modules/queue/processors/webhook.processor.ts index 766fe66d6..2b44c376f 100644 --- a/src/modules/queue/processors/webhook.processor.ts +++ b/src/modules/queue/processors/webhook.processor.ts @@ -214,7 +214,7 @@ export class WebhookProcessor extends WorkerHost { }, { sessionId, source: 'WebhookProcessor' }, ); - await recordWebhookDeliveryFailure(this.failureRepository, this.logger, { + const recorded = await recordWebhookDeliveryFailure(this.failureRepository, this.logger, { webhookId, sessionId, event, @@ -225,7 +225,9 @@ export class WebhookProcessor extends WorkerHost { lastStatusCode: statusCodeFromError(errorMessage), lastError: clientError, }); - incrementWebhookDeliveryFailures(); + if (recorded) { + incrementWebhookDeliveryFailures(); + } } } @@ -270,7 +272,7 @@ export class WebhookProcessor extends WorkerHost { { sessionId, source: 'WebhookProcessor' }, ); - await recordWebhookDeliveryFailure(this.failureRepository, this.logger, { + const recorded = await recordWebhookDeliveryFailure(this.failureRepository, this.logger, { webhookId, sessionId, event, @@ -281,6 +283,8 @@ export class WebhookProcessor extends WorkerHost { lastStatusCode: null, // no HTTP exchange completed on the stalled attempts lastError: error.message, }); - incrementWebhookDeliveryFailures(); + if (recorded) { + incrementWebhookDeliveryFailures(); + } } } diff --git a/src/modules/webhook/entities/webhook-delivery-failure.entity.ts b/src/modules/webhook/entities/webhook-delivery-failure.entity.ts index 0d0d0bab2..2839c4e06 100644 --- a/src/modules/webhook/entities/webhook-delivery-failure.entity.ts +++ b/src/modules/webhook/entities/webhook-delivery-failure.entity.ts @@ -4,13 +4,17 @@ import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from * A durable record of a webhook delivery that exhausted all of its retries. The queued path (BullMQ) * otherwise only leaves a `failed` job that the queue evicts after a day, and the direct fallback path * swallowed the final error entirely — so a receiver outage longer than the retry window silently lost - * events with no operator-visible trail. Each terminal failure is appended here (see - * `recordWebhookDeliveryFailure`) and surfaced via the ADMIN `GET /webhooks/delivery-failures` endpoint. + * events with no operator-visible trail. Each lost delivery is appended here once (see + * `recordWebhookDeliveryFailure`, which skips a delivery it has already recorded so a replayed one is + * not reported several times) and surfaced via the ADMIN `GET /webhooks/delivery-failures` endpoint. * * Lives on the `data` connection (auto-loaded by the webhook entity glob). */ @Entity('webhook_delivery_failures') @Index('IDX_webhook_delivery_failures_sessionId', ['sessionId']) +// Backs the before-insert duplicate lookup that keeps one row per lost delivery rather than one per +// reconciler replay. See AddWebhookDeliveryFailureLookupIndex1786300000000. +@Index('IDX_webhook_delivery_failures_delivery', ['webhookId', 'idempotencyKey']) export class WebhookDeliveryFailure { @PrimaryGeneratedColumn('uuid') id!: string; diff --git a/src/modules/webhook/utils/deliver-once.ts b/src/modules/webhook/utils/deliver-once.ts index dec98a4f2..acf475433 100644 --- a/src/modules/webhook/utils/deliver-once.ts +++ b/src/modules/webhook/utils/deliver-once.ts @@ -35,16 +35,17 @@ export async function postWebhookPayload( /** * Record a terminal webhook delivery failure (all retries exhausted) to the durable table. Shared * wrapper so both paths write the identical row shape. Best-effort: never throws back into the - * delivery result (the caller's semantics depend on that). + * delivery result (the caller's semantics depend on that). Returns false when an identical failure + * was already recorded, so the caller can keep the failure metric in step with the table. */ export async function recordTerminalFailure( failureRepository: Repository, logger: LoggerService, input: Omit[2], 'lastStatusCode' | 'lastError'> & { error: unknown }, -): Promise { +): Promise { const { error, ...row } = input; const errMessage = redactSsrfError(error); - await recordWebhookDeliveryFailure(failureRepository, logger, { + return recordWebhookDeliveryFailure(failureRepository, logger, { ...row, lastStatusCode: statusCodeFromError(errMessage), lastError: errMessage, diff --git a/src/modules/webhook/utils/record-delivery-failure.spec.ts b/src/modules/webhook/utils/record-delivery-failure.spec.ts index 8c546b41e..005d3ddd3 100644 --- a/src/modules/webhook/utils/record-delivery-failure.spec.ts +++ b/src/modules/webhook/utils/record-delivery-failure.spec.ts @@ -27,23 +27,53 @@ describe('recordWebhookDeliveryFailure', () => { lastError: 'HTTP 503: x', }; + const repoWith = (insert: jest.Mock, existing = 0): Repository => + ({ insert, count: jest.fn().mockResolvedValue(existing) }) as unknown as Repository; + it('inserts the failure record, defaulting a missing lastStatusCode to null', async () => { const insert = jest.fn().mockResolvedValue({}); - const repo = { insert } as unknown as Repository; const logger = { error: jest.fn() }; - await recordWebhookDeliveryFailure(repo, logger, { ...input, lastStatusCode: undefined }); + await expect( + recordWebhookDeliveryFailure(repoWith(insert), logger, { ...input, lastStatusCode: undefined }), + ).resolves.toBe(true); expect(insert).toHaveBeenCalledWith(expect.objectContaining({ webhookId: 'wh-1', lastStatusCode: null })); expect(logger.error).not.toHaveBeenCalled(); }); + it('skips a delivery it has already recorded, so one lost event is one row', async () => { + // The reconciler replays a stranded delivery once per sweep until its budget is spent, and each + // failed replay lands here with the SAME idempotency key. + const insert = jest.fn().mockResolvedValue({}); + const logger = { error: jest.fn() }; + + await expect(recordWebhookDeliveryFailure(repoWith(insert, 1), logger, input)).resolves.toBe(false); + + expect(insert).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('still records a failure that carries no idempotency key', async () => { + // Control: without a key there is no identity to dedupe on, so the guard must not swallow the + // row. Otherwise "skips a duplicate" would be satisfied by a helper that stopped inserting. + const insert = jest.fn().mockResolvedValue({}); + const logger = { error: jest.fn() }; + + await expect( + recordWebhookDeliveryFailure(repoWith(insert, 1), logger, { ...input, idempotencyKey: undefined }), + ).resolves.toBe(true); + + expect(insert).toHaveBeenCalled(); + }); + it('swallows a repository error so a logging hiccup cannot re-poison the delivery', async () => { const insert = jest.fn().mockRejectedValue(new Error('db down')); - const repo = { insert } as unknown as Repository; const logger = { error: jest.fn() }; - await expect(recordWebhookDeliveryFailure(repo, logger, input)).resolves.toBeUndefined(); + // Reported as recorded even though the row was lost: the delivery really did fail, and the + // caller's failure metric must count it rather than hide it behind a database problem. + await expect(recordWebhookDeliveryFailure(repoWith(insert), logger, input)).resolves.toBe(true); expect(logger.error).toHaveBeenCalled(); }); }); diff --git a/src/modules/webhook/utils/record-delivery-failure.ts b/src/modules/webhook/utils/record-delivery-failure.ts index 523ce7c07..ee9a1f921 100644 --- a/src/modules/webhook/utils/record-delivery-failure.ts +++ b/src/modules/webhook/utils/record-delivery-failure.ts @@ -34,14 +34,30 @@ export async function recordWebhookDeliveryFailure( repo: Repository, logger: ErrorLogger, input: WebhookDeliveryFailureInput, -): Promise { +): Promise { try { + // One row per lost delivery, not one per attempt. The reconciler replays a pending outbox row + // up to its budget and every failed replay arrives here, so without this guard a single lost + // event is reported to the operator as several and counted that many times in the failure + // metric. An absent idempotencyKey carries no identity to dedupe on, so it is always appended. + if (input.idempotencyKey) { + const existing = await repo.count({ + where: { webhookId: input.webhookId, idempotencyKey: input.idempotencyKey }, + }); + if (existing > 0) { + return false; + } + } await repo.insert({ ...input, lastStatusCode: input.lastStatusCode ?? null }); + return true; } catch (err) { logger.error( 'Failed to persist webhook delivery-failure record', err instanceof Error ? err.message : String(err), { webhookId: input.webhookId, deliveryId: input.deliveryId, action: 'webhook_failure_record_error' }, ); + // The delivery really did fail; only the bookkeeping did. Report it as recorded so the metric + // still counts the loss rather than hiding it behind a database problem. + return true; } } diff --git a/src/modules/webhook/webhook-delivery.service.spec.ts b/src/modules/webhook/webhook-delivery.service.spec.ts index 60299310c..237c2cd9b 100644 --- a/src/modules/webhook/webhook-delivery.service.spec.ts +++ b/src/modules/webhook/webhook-delivery.service.spec.ts @@ -65,8 +65,24 @@ describe('WebhookDeliveryService', () => { update: jest.fn().mockResolvedValue({ affected: 1 }), }; + // Backed by the rows it accepts, not a constant: the dedupe guard reads count() before every + // insert, and a jest.fn() returning undefined would make the guard silently inert here while + // still passing every assertion. + const insertedFailures: Array<{ webhookId?: string; idempotencyKey?: string | null }> = []; failureRepository = { - insert: jest.fn().mockResolvedValue({}), + insert: jest.fn().mockImplementation((rowToInsert: { webhookId?: string; idempotencyKey?: string | null }) => { + insertedFailures.push(rowToInsert); + return Promise.resolve({}); + }), + count: jest + .fn() + .mockImplementation((opts: { where: { webhookId?: string; idempotencyKey?: string } }) => + Promise.resolve( + insertedFailures.filter( + r => r.webhookId === opts.where.webhookId && r.idempotencyKey === opts.where.idempotencyKey, + ).length, + ), + ), find: jest.fn().mockResolvedValue([]), delete: jest.fn().mockResolvedValue({ affected: 0 }), }; @@ -631,6 +647,52 @@ describe('WebhookDeliveryService', () => { ).resolves.toBe('delivered'); }); + it('reports a plugin-cancelled dispatch as cancelled, recording no failure and sending nothing', async () => { + // A before-hook that stops the dispatch is a deliberate drop. Reported as 'failed' it looked + // identical to a lost delivery, so the reconciler replayed it once per sweep until the budget + // ran out and then marked it terminally lost against a failure row nothing ever wrote. + const webhook = createMockWebhook({ events: ['message.received'], retryCount: 1 }); + (hookManager.execute as jest.Mock).mockResolvedValue({ continue: false, data: {} }); + const failuresBefore = getWebhookDeliveryFailuresTotal(); + mockFetch.mockReset(); + + await expect( + service.redeliver(webhook, 'sess-1', 'message.received', 'cancelled-key', { from: 'x@c.us' }), + ).resolves.toBe('cancelled'); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(failureRepository.insert).not.toHaveBeenCalled(); + expect(getWebhookDeliveryFailuresTotal()).toBe(failuresBefore); + }); + + it('records one failure row per lost delivery however many times the reconciler replays it', async () => { + // The reconciler leaves a failed row pending and sweeps it again until the attempt budget is + // spent. Every replay reaching the dead-letter table turned one lost event into as many rows + // and as many increments of the loss metric as the budget allowed. + const webhook = createMockWebhook({ events: ['message.received'], retryCount: 1 }); + (hookManager.execute as jest.Mock).mockResolvedValue({ continue: true, data: {} }); + mockFetch.mockReset(); + mockFetch.mockRejectedValue(new Error('receiver down')); + const failuresBefore = getWebhookDeliveryFailuresTotal(); + + for (let sweep = 0; sweep < 3; sweep++) { + await expect( + service.redeliver(webhook, 'sess-1', 'message.received', 'stranded-key', { from: 'x@c.us' }), + ).resolves.toBe('failed'); + } + + expect(failureRepository.insert).toHaveBeenCalledTimes(1); + expect(getWebhookDeliveryFailuresTotal()).toBe(failuresBefore + 1); + + // Control: a genuinely different delivery must still be recorded, or the assertion above is + // satisfied by a guard that suppresses every row after the first. + await expect( + service.redeliver(webhook, 'sess-1', 'message.received', 'other-key', { from: 'x@c.us' }), + ).resolves.toBe('failed'); + expect(failureRepository.insert).toHaveBeenCalledTimes(2); + expect(getWebhookDeliveryFailuresTotal()).toBe(failuresBefore + 2); + }); + it("isolates each webhook's data so an in-place before-hook mutation cannot bleed across webhooks", async () => { const a = createMockWebhook({ id: 'wh-a', events: ['message.received'] }); const b = createMockWebhook({ id: 'wh-b', events: ['message.received'] }); diff --git a/src/modules/webhook/webhook-delivery.service.ts b/src/modules/webhook/webhook-delivery.service.ts index 6ba2fd7b7..898382b7d 100644 --- a/src/modules/webhook/webhook-delivery.service.ts +++ b/src/modules/webhook/webhook-delivery.service.ts @@ -63,9 +63,10 @@ const DEFAULT_WEBHOOK_SHUTDOWN_DRAIN_MS = 5000; /** * The result of one delivery attempt. Reported, not thrown: every failure below is already handled * in place, so nothing reaches a caller as an exception and a try/catch cannot tell a delivered - * event from a dead-lettered one. + * event from a dead-lettered one. 'cancelled' is a plugin suppressing the dispatch on purpose: it + * is terminal like 'delivered' and must never be replayed, but nothing left the process. */ -export type WebhookDeliveryOutcome = 'delivered' | 'enqueued' | 'failed'; +export type WebhookDeliveryOutcome = 'delivered' | 'enqueued' | 'cancelled' | 'failed'; interface DispatchEventContext { sessionId: string; @@ -255,7 +256,7 @@ export class WebhookDeliveryService implements OnModuleInit, OnModuleDestroy { ): Promise { const { sessionId, event } = ctx; const lastError = redactSsrfError(error, this.logger, 'webhook dispatch'); - await recordWebhookDeliveryFailure(this.failureRepository, this.logger, { + const recorded = await recordWebhookDeliveryFailure(this.failureRepository, this.logger, { webhookId: webhook.id, sessionId, event, @@ -266,7 +267,9 @@ export class WebhookDeliveryService implements OnModuleInit, OnModuleDestroy { lastStatusCode: null, lastError, }); - incrementWebhookDeliveryFailures(); + if (recorded) { + incrementWebhookDeliveryFailures(); + } try { await this.hookManager.execute( 'webhook:error', @@ -297,7 +300,7 @@ export class WebhookDeliveryService implements OnModuleInit, OnModuleDestroy { deliveryId: string, idempotencyKey: string, ctx: DispatchEventContext, - ): Promise<{ finalPayload: WebhookPayload; body: string; headers: Record } | null> { + ): Promise<{ finalPayload: WebhookPayload; body: string; headers: Record } | 'cancelled' | null> { const { sessionId, event, baseData } = ctx; try { const payload: WebhookPayload = { @@ -325,7 +328,7 @@ export class WebhookDeliveryService implements OnModuleInit, OnModuleDestroy { webhookId: webhook.id, action: 'webhook_cancelled_by_plugin', }); - return null; + return 'cancelled'; } // Null/undefined hook results mean "no override", matching an object without payload. @@ -415,8 +418,14 @@ export class WebhookDeliveryService implements OnModuleInit, OnModuleDestroy { ctx: DispatchEventContext, ): Promise { const preflight = await this.preflightDelivery(webhook, deliveryId, idempotencyKey, ctx); + if (preflight === 'cancelled') { + // A plugin suppressed this dispatch deliberately. There is no failure to record and nothing + // to retry: reporting it as failed made the reconciler replay a deliberately dropped event + // until the budget ran out, then mark it lost against a failure row that never existed. + return 'cancelled'; + } if (!preflight) { - // Preflight records its own undelivered row and returns null; nothing left the process. + // The remaining bail-outs record their own undelivered row before returning null. return 'failed'; } const { finalPayload, body, headers } = preflight; @@ -738,7 +747,7 @@ export class WebhookDeliveryService implements OnModuleInit, OnModuleDestroy { } // All direct-path retries exhausted — persist a durable failure record before giving up, mirroring // the queued processor's final-attempt path so the queue-disabled path isn't a blind spot. - await recordTerminalFailure(this.failureRepository, this.logger, { + const recorded = await recordTerminalFailure(this.failureRepository, this.logger, { webhookId: webhook.id, sessionId: payload.sessionId, event: payload.event, @@ -748,7 +757,9 @@ export class WebhookDeliveryService implements OnModuleInit, OnModuleDestroy { attempts: attempt, error, }); - incrementWebhookDeliveryFailures(); + if (recorded) { + incrementWebhookDeliveryFailures(); + } throw error; } } diff --git a/src/modules/webhook/webhook-reconciler.service.spec.ts b/src/modules/webhook/webhook-reconciler.service.spec.ts index 6d9188ada..23b6cf0ee 100644 --- a/src/modules/webhook/webhook-reconciler.service.spec.ts +++ b/src/modules/webhook/webhook-reconciler.service.spec.ts @@ -95,6 +95,19 @@ describe('WebhookReconcilerService', () => { expect(stats).toMatchObject({ replayed: 1, failed: 0 }); }); + it('retires the row when a plugin cancelled the dispatch, instead of replaying it to death', async () => { + // A cancelled dispatch is a deliberate drop, not a loss. Reported as 'failed' it was replayed + // once per sweep until the budget ran out and then marked terminally lost, pointing operators + // at a delivery-failure row that was never written. + outbox.findStale.mockResolvedValue([row({ attempts: 1 })]); + delivery.redeliver.mockResolvedValue('cancelled'); + + const stats = await service.sweep(OPTS); + + expect(outbox.close).toHaveBeenCalledWith('wh-1', 'stored-key_wh-1', 'dispatched'); + expect(stats).toMatchObject({ replayed: 1, failed: 0 }); + }); + it('keeps a row pending when the replay throws an unexpected fault', async () => { outbox.findStale.mockResolvedValue([row({ attempts: 1 })]); delivery.redeliver.mockRejectedValue(new Error('boom')); diff --git a/src/modules/webhook/webhook-reconciler.service.ts b/src/modules/webhook/webhook-reconciler.service.ts index 834540ded..b1360f184 100644 --- a/src/modules/webhook/webhook-reconciler.service.ts +++ b/src/modules/webhook/webhook-reconciler.service.ts @@ -127,6 +127,8 @@ export class WebhookReconcilerService implements OnModuleInit, OnModuleDestroy { stats.failed++; continue; } + // 'delivered', 'enqueued' and 'cancelled' all retire the row: the delivery either reached a + // durable owner or a plugin dropped it on purpose. Only 'failed' is worth another sweep. await this.outbox.close(row.webhookId, row.idempotencyKey, 'dispatched'); stats.replayed++; } catch (error) {