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
38 changes: 35 additions & 3 deletions PLUGIN-STANDARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ OpenWA-plugins/
}
```

Repo gates (enforced by `scripts/catalog.mjs`, hard failure in CI): every manifest must declare
`sessionScoped` explicitly, and a `status: "stable"` manifest must declare `testedOpenWAVersion`. A
missing `i18n` block (or missing locales) is a warning, not a failure.

### `configSchema` field vocabulary (v0.7)

A JSON-Schema-ish object the host renders into an authenticated form (writes go through
Expand Down Expand Up @@ -149,6 +153,32 @@ session"). See the per-plugin README convention below.
**Package limits** (enforced by OpenWA at install): ≤ 5 MB compressed, ≤ 200 files, ≤ 20 MB uncompressed.
**Ship compiled JS** — the loader `require()`s `main`; build with `node package.mjs <id>`.

## Runtime contract (observed)

These behaviors are **observed from the host, not a written host contract** — they are load-bearing for
correct plugins and were each learned the hard way. Re-verify against OpenWA core when upgrading.

1. **`ctx.net.fetch` responses have no working `.json()` / `.text()` / `.arrayBuffer()`** — those method
forms exist on `PluginNetResponse` only so older plugins still type-check; calling them at runtime
throws (functions cannot cross the worker structuredClone boundary). Always read `res.body` (a UTF-8
string, capped at 10 MiB host-side) and `JSON.parse(res.body)`.
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).
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.
4. **Host boot resets every plugin to INSTALLED** — operators must re-enable plugins after a restart.
`ctx.config` and `ctx.storage` survive, so dedup/state markers in storage are safe.
5. **Cross-host redirects cannot be blocked plugin-side** (`PluginNetRequestInit` exposes no redirect
option) — redirect-based SSRF defense is the host's job; do not treat it as a plugin release gate.

`minOpenWAVersion` is advisory (never enforced by the host). Still bump it when a plugin *requires* a
newer capability: `canonicalChatId` → 0.8.7, Integration SDK v1 (`sdkVersion: 1`) → 0.8.x,
`getChatHistory` → 0.8.5. Keep `testedOpenWAVersion` honest: it is the newest host the plugin was
actually smoke-tested against.

## `README.md` — required sections (in order)

1. **Title + one-line tagline**, then a small badge row in this exact order:
Expand Down Expand Up @@ -208,10 +238,12 @@ for a future OpenWA in-dashboard marketplace.

| 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`, print size + sha256. |
| `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 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 or a version↔changelog drift exists (CI). |
| `npm test` / `npm run typecheck` | Run the test suite (`node --test` + `tsx`) and `tsc --noEmit`. |
| `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 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). |
| `node scripts/download-badges.mjs [out-dir]` | Sum per-plugin .zip downloads across all GitHub Releases and write shields.io endpoint JSON files (run by the `download-badges` workflow; also runnable locally to preview the numbers). |

## Release process
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@ This repository provides:
| ------ | ----------- | ------- | ------ |
| [`after-hours`](./after-hours) | Auto-replies with a configurable away/closing message to messages received outside business hours. | 0.1.3 | 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.0.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.5.5 | 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.5.6 | stable |
| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.1.7 | 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.0.6 | stable |
| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.2.3 | stable |
| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.3.0 | stable |
| [`http-action`](./http-action) | Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat. | 0.1.0 | beta |
| [`supabase-otp-hook`](./supabase-otp-hook) | Deliver Supabase Auth phone OTPs over WhatsApp. | 0.1.0 | beta |
| [`supabase-otp-hook`](./supabase-otp-hook) | Deliver Supabase Auth phone OTPs over WhatsApp. | 0.2.0 | 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.1.0 | beta |
| [`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.0.2 | beta |
<!-- END PLUGIN CATALOG -->
Expand Down
7 changes: 7 additions & 0 deletions after-hours/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ so schedule edits apply live without a re-enable).
the next inbound message (config is re-read per event). For example, two sessions can have different
business hours and away messages.

### Known limitations

- **WhatsApp `@lid` migration:** the per-chat cooldown is keyed by the chat id as received. If a contact
migrates between a `@lid` privacy id and a phone-based `@c.us` id, the old entry is orphaned and the
new id is treated as a fresh chat (the cooldown simply resets — harmless). A proper fix needs a
host-side lid↔phone resolver; tracked upstream.

## Security

The plugin declares only `messages:send` and makes no outbound network calls. The away message is the
Expand Down
21 changes: 21 additions & 0 deletions after-hours/cooldown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// In-memory per-key cooldown with an LRU cap. 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.

const MAX_COOLDOWN_ENTRIES = 5000;

/**
* Decide whether an action may go to `key` now. On allow, records `nowMs` (re-inserting so the map
* evicts least-recently-used) and caps the map by dropping the LRU entry. A `cooldownMs` of 0 always allows.
*/
export function allowCooldown(map: Map<string, number>, key: string, nowMs: number, cooldownMs: number): boolean {
const last = map.get(key);
if (last !== undefined && nowMs - last < cooldownMs) return false;
map.delete(key); // re-insert so iteration order tracks recency (LRU by touch)
map.set(key, nowMs);
if (map.size > MAX_COOLDOWN_ENTRIES) {
const oldest = map.keys().next().value as string | undefined;
if (oldest !== undefined) map.delete(oldest);
}
return true;
}
3 changes: 2 additions & 1 deletion after-hours/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseConfig, allowReply } from './index.ts';
import { parseConfig } from './index.ts';
import { allowCooldown as allowReply } from './cooldown.ts';

const schedule = JSON.stringify({ mon: '09:00-17:00', sun: null });

Expand Down
22 changes: 2 additions & 20 deletions after-hours/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import type { IPlugin, PluginContext, HookContext, IncomingMessage } from '../types/openwa';
import { parseSchedule, assertValidTimezone, isAfterHours, Schedule } from './schedule.ts';

/** Cap on the per-chat cooldown map (drop oldest past this) so it can't grow unbounded. */
const MAX_COOLDOWN_ENTRIES = 5000;
import { allowCooldown } from './cooldown.ts';

export interface AfterHoursConfig {
timezone: string;
Expand Down Expand Up @@ -40,22 +38,6 @@ export function parseConfig(raw: Record<string, unknown>): { config: AfterHoursC
};
}

/**
* Decide whether an after-hours reply may go to `key` now. On allow, records `nowMs` (re-inserting so
* the map evicts least-recently-used) and caps the map by dropping the LRU entry. `cooldownMs` of 0 always allows.
*/
export function allowReply(map: Map<string, number>, key: string, nowMs: number, cooldownMs: number): boolean {
const last = map.get(key);
if (last !== undefined && nowMs - last < cooldownMs) return false;
map.delete(key); // re-insert so iteration order tracks recency (LRU by touch)
map.set(key, nowMs);
if (map.size > MAX_COOLDOWN_ENTRIES) {
const oldest = map.keys().next().value as string | undefined;
if (oldest !== undefined) map.delete(oldest);
}
return true;
}

export default class AfterHours implements IPlugin {
private readonly repliedAt = new Map<string, number>();

Expand Down Expand Up @@ -92,7 +74,7 @@ export default class AfterHours implements IPlugin {
const sessionId = hook.sessionId;
const key = `${sessionId}:${m.chatId}`;
const cooldownMs = Math.max(0, cfg.config.cooldownSec) * 1000;
if (!allowReply(this.repliedAt, key, Date.now(), cooldownMs)) return;
if (!allowCooldown(this.repliedAt, key, Date.now(), cooldownMs)) return;

try {
await ctx.messages.reply(sessionId, m.chatId, m.id, cfg.config.awayMessage);
Expand Down
1 change: 1 addition & 0 deletions after-hours/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"permissions": [
"messages:send"
],
"sessionScoped": true,
"sessions": [
"*"
],
Expand Down
7 changes: 7 additions & 0 deletions chat-flow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ message (config is re-read per event). For example, two sessions can run differe
state is keyed per `(session, chat)` in storage, so flows for sessions with different menus never
interfere.

### Known limitations

- **WhatsApp `@lid` migration:** flow state is keyed by the chat id as received. If a contact migrates
between a `@lid` privacy id and a phone-based `@c.us` id mid-flow, the in-progress menu state is
orphaned and the contact restarts from the greeting on the new id. A proper fix needs a host-side
lid↔phone resolver; tracked upstream.

## Security

Flow state lives in `ctx.storage`, keyed per `(session, chat)` and expiring after 15 minutes — no
Expand Down
12 changes: 12 additions & 0 deletions chatwoot-adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form

## [Unreleased]

## [0.5.6] — 2026-07-23

### Fixed

- **A backfilled or mirrored message could be delivered twice when Chatwoot's echo webhook arrived
mid-post.** The adapter marks its own `outgoing` posts as "seen" only after Chatwoot confirms the
post, but the `message_created` echo webhook can be processed before that confirmation returns —
and inbound/backfill hold a different per-chat lock than the webhook path, so nothing serialized
the two. The echo then looked unmarked and was relayed to WhatsApp as a duplicate. The post and its
echo-marker write now hold a conversation-scoped lock that the webhook dedup also takes, so the
echo always waits for the marker and is correctly skipped (regression test included).

## [0.5.5] — 2026-07-22

### Fixed
Expand Down
13 changes: 11 additions & 2 deletions chatwoot-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
| Field | Value |
| ----- | ----- |
| **Identifier** | `chatwoot-adapter` |
| **Version** | 0.5.5 |
| **Released** | 2026-07-22 |
| **Version** | 0.5.6 |
| **Released** | 2026-07-23 |
| **Status** | stable |
| **Author** | Yudhi Armyndharis |
| **License** | MIT |
Expand Down Expand Up @@ -167,6 +167,15 @@ instance id or route — so re-copy the ingress URL from the mint response.
If you do end up with a duplicate, merging the two contacts in Chatwoot is safe.
- **Chatwoot** — account-level webhooks with timestamped HMAC signing (see Setup).

### 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.

### Per-session config

**Supported.** Every config field (`baseUrl`, `apiToken`, `accountId`, `inboxId`, `relayGroups`,
Expand Down
7 changes: 4 additions & 3 deletions chatwoot-adapter/chat-lock.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// 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. Used to serialize inbound + outbound work
// per `${sessionId}:${chatId}` so a cold-start burst can't create duplicate Chatwoot contacts. A rejecting
// section is isolated (the chain recovers), and the map entry is dropped once its tail settles. Pure — no ctx.
// 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<string, Promise<unknown>>();

Expand Down
44 changes: 43 additions & 1 deletion chatwoot-adapter/echo-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { handleSent } from './sent.ts';
import { handleOutbound, type OutboundDeps } from './outbound.ts';
import type { InboundDeps } from './relay.ts';
import { relayMessage, type InboundDeps } from './relay.ts';
import { KeyedAsyncLock } from './chat-lock.ts';
import { MappingStore } from './mapping-store.ts';
import type {
Expand Down Expand Up @@ -177,3 +177,45 @@ test("one tenant's mirror marker does not suppress another tenant's reply with t
assert.equal(sent.length, 1, "tenant B's genuine reply was suppressed by tenant A's echo marker");
assert.equal(sent[0].chatId, 'bob@c.us');
});

test('an echo webhook processed while the adapter post is still in flight is NOT re-sent (backfill race)', async () => {
// relayMessage marks the 'cw' echo marker AFTER the POST returns. Backfill holds the RAW chat lock
// while outbound.relay dedups under the CANONICAL lock — two different locks — so a Chatwoot
// message_created webhook processed inside that window (the webhook can arrive before the POST
// response does) would see no marker and re-send a backfilled message to the customer.
const store = new MappingStore(fakeStorage(), fakeMappings);
const lock = new KeyedAsyncLock();
const engine = { canonicalChatId: async (_s: string, c: string) => c };
await store.link('sess', CHAT_ID, 'inst', {
conversationId: CONVERSATION_ID, contactId: 9, sourceId: 'src', name: 'Budi',
});

let releasePost = (): void => {};
const postGate = new Promise<void>((resolve) => { releasePost = resolve; });
const client = {
postText: async () => { await postGate; return { id: 4242 }; },
postMedia: async () => { throw new Error('unused in this test'); },
};
const inbound = {
lock, client, store, engine, instanceId: 'inst',
relayGroups: true, relayMedia: true, backfillLimit: 0, backfillAllOnce: false, log: () => {},
} as unknown as InboundDeps;
const sent: Array<{ chatId?: string }> = [];
const outbound = {
lock, store, engine,
conversations: { send: async (e: { chatId?: string }) => { sent.push(e); return { messageId: 'wa-r' }; } },
handover: { set: async () => {} },
inboxId: INBOX_ID,
log: () => {},
} as unknown as OutboundDeps;

// The relay runs with no shared lock held (the backfill case: raw-key lock, which outbound never
// takes), and the echo webhook is processed while the POST is still in flight.
const relayPromise = relayMessage(inbound, 'sess', CONVERSATION_ID, own, 'outgoing');
const echoPromise = handleOutbound(outbound, mirrorWebhook(4242, 'sess'));
for (let i = 0; i < 5; i++) await new Promise<void>((r) => setImmediate(r)); // let the echo race ahead
releasePost();
await Promise.all([relayPromise, echoPromise]);

assert.deepEqual(sent, [], 'the echo was processed before the marker landed and got re-sent to WhatsApp');
});
Loading
Loading