Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
32 changes: 26 additions & 6 deletions PLUGIN-STANDARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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**
Expand Down Expand Up @@ -285,14 +288,27 @@ 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
fetch response body; 200 log lines / 10 s at 8 KiB per line.
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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -406,8 +423,11 @@ alongside uploads.
| Script | What it does |
| ------ | ------------ |
| `node package.mjs <id>` | Validate manifest (required fields + `version` == top CHANGELOG heading), bundle to `dist/index.js`, zip to `<id>.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). |
Expand Down
36 changes: 22 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,16 @@ This repository provides:
<!-- BEGIN PLUGIN CATALOG -->
| 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 |
<!-- END PLUGIN CATALOG -->

The table above is generated from each plugin's `manifest.json` + `CHANGELOG.md` by `npm run catalog`
Expand All @@ -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.
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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 |
| ---------- | ------- | ---------- |
Expand Down
23 changes: 23 additions & 0 deletions after-hours/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading