Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
647306c
test(typebot-connector): prove the file-upload path end to end
rmyndharis Jul 31, 2026
7d44355
fix(chatwoot-adapter): bound the history media budget and surface fet…
rmyndharis Jul 31, 2026
817ba3e
test(chatwoot-adapter): make the bulk-sweep failure test actually dis…
rmyndharis Jul 31, 2026
dc18895
fix(chatwoot-adapter): make history-import state durable per chat
rmyndharis Jul 31, 2026
b00519c
fix(chatwoot-adapter): close review round 1 findings on the backfill …
rmyndharis Jul 31, 2026
5d9e8e8
fix(chatwoot-adapter): report gave-up history imports and cap the bac…
rmyndharis Jul 31, 2026
d0c3e2c
docs(standard): define hook ordering bands and the claim contract
rmyndharis Jul 31, 2026
d0681d4
fix(gsheets-logger,chatwoot-adapter): register at observer priority
rmyndharis Jul 31, 2026
0c4f0b2
fix(voice-transcription,group-translate): register in the transformer…
rmyndharis Jul 31, 2026
2ee69ff
fix(chat-flow,faq-bot,after-hours): claim the messages they answer
rmyndharis Jul 31, 2026
4d27f0f
fix(http-action,typebot-connector): claim the messages addressed to them
rmyndharis Jul 31, 2026
e3049c5
chore: release the nine plugins that changed hook ordering and claiming
rmyndharis Jul 31, 2026
e9c936d
docs(group-translate): correct changelog claim-behavior wording
rmyndharis Jul 31, 2026
0a135f2
fix(group-translate): correct false claim-behavior statements in chan…
rmyndharis Jul 31, 2026
405da2d
fix(chatwoot-adapter): only import history for chats this version cre…
rmyndharis Jul 31, 2026
eb319b4
test: pin the claim behaviour faq-bot and group-translate publish
rmyndharis Jul 31, 2026
75b140c
docs: correct the co-installation and backfill descriptions
rmyndharis Jul 31, 2026
314afbb
docs(chatwoot-adapter): clarify bulk-import re-imports existing chats
rmyndharis Jul 31, 2026
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
63 changes: 63 additions & 0 deletions PLUGIN-STANDARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,69 @@ 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>`.

## Co-installation: ordering and claiming

Several plugins can subscribe to the same event. Two rules keep a multi-plugin install predictable.

### Ordering

`ctx.registerHook(event, handler, priority?)` sorts **ascending** — a lower number runs earlier. The
default is `100`. Without an explicit priority, the chain order is registration order: the loader's
directory scan at boot, and click order after an operator enables a plugin by hand. That means a
different plugin can win after a restart than after a manual enable.

Pick a priority from the band that matches what your plugin does:

| Band | Range | What belongs here |
|---|---|---|
| Observer | 10-29 | Logs, mirrors, or exports the message. Must never claim. |
| Transformer | 40-59 | Acts on the message before any responder sees it. |
| Responder | 70-99 | Answers the contact as "the bot". |

The official plugins occupy: `gsheets-logger` 10, `chatwoot-adapter` 20, `voice-transcription` 40,
`group-translate` 50, `http-action` 70, `chat-flow` 75, `faq-bot` 80, `typebot-connector` 85,
`after-hours` 95.

Responders are ordered from the most specific trigger to the most sweeping: a command prefix, then an
in-flow state machine, then keyword rules, then a bot that auto-starts every chat, then a time window.

### Claiming

Returning `{continue: false}` from a `message:received` handler stops the remaining handler chain. On a
notification event that is a claim against sibling plugins only — the host still persists the message,
still dispatches it to webhooks, and still pushes it over the websocket. Never use it to hide an event.

**An observer must never return `{continue: false}`.** This is a correctness requirement, not style.
Observers run first precisely so a responder's claim cannot cost them the message; a claiming observer
would take the message away from everything after it.

**A claim means "this message is mine", decided synchronously.** Evaluate a pure predicate — does the
command prefix match, is this chat in scope, did a rule match — and claim on that. A plugin that does its
work off-dispatch (returning immediately and floating the request, to stay inside the ~5 s hook budget)
may still claim: it knows whether the message is addressed to it even before it knows whether the reply
will succeed. A claim followed by a failure should produce your own fallback message or silence, never a
different bot answering something unrelated.

### Known interactions

Both of these follow from the priority table above; neither is a bug in the plugins involved.

**`faq-bot` (80) partially starves `after-hours` (95).** With `fallbackReply` set, `faq-bot` answers and
claims any message no rule matched — but only the first one in each `fallbackCooldownSec` window
(default `600`). Later messages in that window are not claimed and reach `after-hours`, which sends the
away message subject to its own `cooldownSec`. A contact messaging out of hours therefore gets the
faq-bot fallback first and the after-hours notice afterwards: two different answers to one conversation.
Leave `fallbackReply` empty when both are enabled, so `after-hours` is the only voice outside business
hours. (At `fallbackCooldownSec: 0` the fallback claims every message and `after-hours` never sends.)

**`typebot-connector` (85) fully starves `after-hours` (95).** It claims every message in its scope, and
its scope is every engine-sourced, non-`fromMe` message with a chat id: one-to-one chats always, group
chats too when `respondInGroups` is on. There is no cooldown and no setting that narrows the one-to-one
case, so while `typebot-connector` is enabled `after-hours` never fires for a direct chat. This is
intended: a Typebot bot owns every chat it is in scope for, and handing one of its chats to a second
responder mid-flow is worse than the starvation. Put out-of-hours messaging inside the Typebot flow
itself — a business-hours condition at the top of the flow — rather than in `after-hours`.

## Runtime contract (observed)

These behaviors are **observed from the host, not a written host contract** — they are load-bearing for
Expand Down
18 changes: 9 additions & 9 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.1.4 | 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.8 | 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.6.0 | stable |
| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.1.8 | 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.7 | stable |
| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.3.1 | stable |
| [`http-action`](./http-action) | Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat. | 0.1.2 | beta |
| [`after-hours`](./after-hours) | Auto-replies with a configurable away/closing message to messages received outside business hours. | 0.2.0 | 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.0 | 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.7.0 | stable |
| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.2.0 | 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.1.0 | stable |
| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.3.2 | stable |
| [`http-action`](./http-action) | Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat. | 0.2.0 | beta |
| [`supabase-otp-hook`](./supabase-otp-hook) | Deliver Supabase Auth phone OTPs over WhatsApp. | 0.3.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.1 | 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.1.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.2.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.2.0 | beta |
<!-- END PLUGIN CATALOG -->

The table above is generated from each plugin's `manifest.json` + `CHANGELOG.md` by `npm run catalog`
Expand Down
12 changes: 12 additions & 0 deletions after-hours/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ The version here always matches `manifest.json`'s `version`.

## [Unreleased]

## [0.2.0] — 2026-07-31

### Fixed

- **A delivered away message could draw a second answer from another auto-reply plugin.** This plugin
knew whether it had replied but never acted on it, so a co-installed bot behind it in the hook chain
could answer the same message too. It now claims a message only when the away reply actually sent — a
suppressed or failed send still leaves the next plugin free to answer.
- **This plugin now registers last among responders**, since the away message is a catch-all that should
only speak when nothing more specific has already answered — instead of the registration-order default,
which could put it ahead of another responder depending on enable order.

## [0.1.4] — 2026-07-30

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions after-hours/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
| Field | Value |
| ----- | ----- |
| **Identifier** | `after-hours` |
| **Version** | 0.1.4 |
| **Released** | 2026-07-30 |
| **Version** | 0.2.0 |
| **Released** | 2026-07-31 |
| **Status** | stable |
| **Author** | Yudhi Armyndharis |
| **License** | MIT |
Expand Down
96 changes: 96 additions & 0 deletions after-hours/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,102 @@ import { allowCooldown as allowReply } from './cooldown.ts';

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

// Minimal ctx builder shared by the two tests below. The file's regression/throttle tests further down
// build their own inline ctx (they need a live `config` getter or attempt-counting), so this one only
// needs static overrides.
function makeCtx(overrides: {
config?: Record<string, unknown>;
registerHook?: (event: string, handler: unknown, priority?: number) => void;
reply?: (sessionId: string, chatId: string, quoted: string, text: string) => Promise<{ messageId: string; timestamp: number }>;
} = {}) {
return {
config: overrides.config ?? {},
logger: { log() {}, debug() {}, warn() {}, error() {} },
registerHook: overrides.registerHook ?? (() => {}),
messages: {
reply: overrides.reply ?? (async () => ({ messageId: 'x', timestamp: 0 })),
sendText: async () => ({ messageId: 'x', timestamp: 0 }),
},
};
}

// Schedule opens only Thursday 09:00-17:00 UTC; runHook() below pins the clock to a Thursday well before
// that window opens (same construction as "a failed away reply is throttled" further down), so
// isAfterHours is deterministically true regardless of when the suite runs.
const closedNowConfig = {
schedule: JSON.stringify({ thu: '09:00-17:00' }),
timezone: 'UTC',
awayMessage: 'tutup',
cooldownSec: 3600,
};

// Enables a plugin per distinct config object and fires message:received carrying `body`, returning the
// {continue} result the host would see. Reuses the SAME plugin instance (and its cooldown/backoff state)
// across calls that pass the identical config reference — a fresh instance per call would reset the
// cooldown map and hide the very suppression the cooldown test exists to prove; a real host likewise
// keeps one plugin instance alive across messages for an enabled session.
const sessions = new WeakMap<object, (hook: unknown) => Promise<{ continue: boolean }>>();

async function runHook(config: Record<string, unknown>, body: string) {
let handler = sessions.get(config);
if (!handler) {
const ctx = makeCtx({ config, registerHook: (_e, h) => { handler = h as (hook: unknown) => Promise<{ continue: boolean }>; } });
const { default: AfterHours } = await import('./index.ts');
await new AfterHours().onEnable(ctx as never);
sessions.set(config, handler!);
}
return handler!({
source: 'Engine', sessionId: 's1', timestamp: new Date(),
data: { id: 'm1', chatId: 'c@x', body, fromMe: false, isGroup: false },
});
}

// Responder band, last (PLUGIN-STANDARD.md "Co-installation"): the away message is the catch-all that
// speaks only when nothing more specific answered.
test('registers at the after-hours responder priority', async () => {
let priority: number | undefined;
const ctx = makeCtx({ config: { schedule, awayMessage: 'Closed' }, registerHook: (_e, _h, p) => { priority = p; } });
const { default: AfterHours } = await import('./index.ts');
await new AfterHours().onEnable(ctx as never);
assert.equal(priority, 95);
});

test('claims only when it actually replied; a cooldown-suppressed message is passed on', async () => {
mock.timers.enable({ apis: ['Date'], now: 1_000_000 });
try {
const first = await runHook(closedNowConfig, 'anybody there');
assert.equal(first.continue, false, 'the away message went out — claim it');

const second = await runHook(closedNowConfig, 'hello again'); // same chat, inside the cooldown
assert.equal(second.continue, true, 'nothing was sent, so nothing was claimed');
} finally {
mock.timers.reset();
}
});

// A send that throws delivers nothing. Claiming it anyway would silence the chat entirely — every later
// plugin sees a message that was "handled" when in fact no away message ever reached the contact.
test('a reply that throws does not claim the message', async () => {
let handler: ((hook: unknown) => Promise<{ continue: boolean }>) | undefined;
const ctx = makeCtx({
config: closedNowConfig,
registerHook: (_e, h) => { handler = h as (hook: unknown) => Promise<{ continue: boolean }>; },
reply: async () => { throw new Error('blocked by plugin'); },
});
const { default: AfterHours } = await import('./index.ts');
await new AfterHours().onEnable(ctx as never);
mock.timers.enable({ apis: ['Date'], now: 1_000_000 });
try {
const result = await handler!({
source: 'Engine', sessionId: 's1', timestamp: new Date(),
data: { id: 'm1', chatId: 'c@x', body: 'anybody there', fromMe: false, isGroup: false },
});
assert.equal(result.continue, true, 'the send failed — a later plugin may still have an answer');
} finally {
mock.timers.reset();
}
});

test('parseConfig requires schedule and awayMessage', () => {
assert.throws(() => parseConfig({ awayMessage: 'x' }), /schedule is required/);
assert.throws(() => parseConfig({ schedule, awayMessage: '' }), /awayMessage is required/);
Expand Down
33 changes: 21 additions & 12 deletions after-hours/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export function parseConfig(raw: Record<string, unknown>): { config: AfterHoursC
// cannot turn every inbound message into another send attempt.
const RETRY_BACKOFF_MS = 60_000;

// Responder band, last: the away message is the catch-all that speaks only when nothing more specific
// answered.
const HOOK_PRIORITY = 95;

export default class AfterHours implements IPlugin {
private readonly repliedAt = new Map<string, number>();
// Absolute "do not retry before" deadline per chat, set when a reply FAILS. Kept separately instead of
Expand All @@ -54,20 +58,23 @@ export default class AfterHours implements IPlugin {

async onEnable(ctx: PluginContext): Promise<void> {
parseConfig(ctx.config); // fail-fast: surface invalid config at enable, not per-message
ctx.registerHook('message:received', async (hook: HookContext) => {
await this.onMessage(ctx, hook);
return { continue: true };
});
ctx.registerHook(
'message:received',
async (hook: HookContext) => ({ continue: !(await this.onMessage(ctx, hook)) }),
HOOK_PRIORITY,
);
}

async onConfigChange(ctx: PluginContext, _newConfig: Record<string, unknown>): Promise<void> {
parseConfig(ctx.config); // re-validate on change (fail-fast feedback in the dashboard)
}

private async onMessage(ctx: PluginContext, hook: HookContext): Promise<void> {
if (hook.source !== 'Engine' || !hook.sessionId) return;
// 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<boolean> {
if (hook.source !== 'Engine' || !hook.sessionId) return false;
const m = (hook.data ?? {}) as Partial<IncomingMessage>;
if (m.fromMe || typeof m.body !== 'string' || !m.chatId || !m.id) return;
if (m.fromMe || typeof m.body !== 'string' || !m.chatId || !m.id) 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.
Expand All @@ -76,22 +83,23 @@ export default class AfterHours implements IPlugin {
cfg = parseConfig(ctx.config);
} catch (e) {
ctx.logger.warn(`after-hours: skipping message, config invalid: ${e instanceof Error ? e.message : String(e)}`);
return;
return false;
}

if (m.isGroup && !cfg.config.respondInGroups) return;
if (!isAfterHours(new Date(), cfg.schedule, cfg.config.timezone)) return;
if (m.isGroup && !cfg.config.respondInGroups) return false;
if (!isAfterHours(new Date(), cfg.schedule, cfg.config.timezone)) return false;

const sessionId = hook.sessionId;
const key = `${sessionId}:${m.chatId}`;
const cooldownMs = Math.max(0, cfg.config.cooldownSec) * 1000;
const notBefore = this.retryNotBefore.get(key);
if (notBefore !== undefined && Date.now() < notBefore) return; // still inside a failure backoff
if (!allowCooldown(this.repliedAt, key, Date.now(), cooldownMs)) return;
if (notBefore !== undefined && Date.now() < notBefore) return false; // still inside a failure backoff
if (!allowCooldown(this.repliedAt, key, Date.now(), cooldownMs)) return false;

try {
await ctx.messages.reply(sessionId, m.chatId, m.id, cfg.config.awayMessage);
this.retryNotBefore.delete(key); // delivered — the backoff no longer applies
return true;
} catch (err) {
// The cooldown slot is burned BEFORE the send (allowCooldown records on the allow path), so a
// failed reply would otherwise silence this chat for the whole window with nothing delivered —
Expand All @@ -106,6 +114,7 @@ export default class AfterHours implements IPlugin {
this.repliedAt.delete(key);
this.retryNotBefore.set(key, Date.now() + RETRY_BACKOFF_MS);
ctx.logger.error('after-hours: reply failed', err);
return false; // nothing was delivered, so nothing is claimed
}
}
}
2 changes: 1 addition & 1 deletion after-hours/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "after-hours",
"name": "After-Hours Auto-Reply",
"version": "0.1.4",
"version": "0.2.0",
"type": "extension",
"main": "dist/index.js",
"description": "Auto-replies with a configurable away/closing message to messages received outside business hours.",
Expand Down
Loading
Loading