diff --git a/PLUGIN-STANDARD.md b/PLUGIN-STANDARD.md index f3e092f..2591e2c 100644 --- a/PLUGIN-STANDARD.md +++ b/PLUGIN-STANDARD.md @@ -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 `. +## 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 diff --git a/README.md b/README.md index 3f27300..3b93013 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.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 | The table above is generated from each plugin's `manifest.json` + `CHANGELOG.md` by `npm run catalog` diff --git a/after-hours/CHANGELOG.md b/after-hours/CHANGELOG.md index bfc1568..a11dc6c 100644 --- a/after-hours/CHANGELOG.md +++ b/after-hours/CHANGELOG.md @@ -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 diff --git a/after-hours/README.md b/after-hours/README.md index fadea59..2d4b5ef 100644 --- a/after-hours/README.md +++ b/after-hours/README.md @@ -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 | diff --git a/after-hours/index.test.ts b/after-hours/index.test.ts index 5aa81e6..9f53aa1 100644 --- a/after-hours/index.test.ts +++ b/after-hours/index.test.ts @@ -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; + 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 Promise<{ continue: boolean }>>(); + +async function runHook(config: Record, 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/); diff --git a/after-hours/index.ts b/after-hours/index.ts index c2e954a..0a991a0 100644 --- a/after-hours/index.ts +++ b/after-hours/index.ts @@ -43,6 +43,10 @@ export function parseConfig(raw: Record): { 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(); // Absolute "do not retry before" deadline per chat, set when a reply FAILS. Kept separately instead of @@ -54,20 +58,23 @@ export default class AfterHours implements IPlugin { async onEnable(ctx: PluginContext): Promise { 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): Promise { parseConfig(ctx.config); // re-validate on change (fail-fast feedback in the dashboard) } - private async onMessage(ctx: PluginContext, hook: HookContext): Promise { - 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 { + if (hook.source !== 'Engine' || !hook.sessionId) return false; const m = (hook.data ?? {}) as Partial; - 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. @@ -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 — @@ -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 } } } diff --git a/after-hours/manifest.json b/after-hours/manifest.json index e51169c..ab535be 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.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.", diff --git a/chat-flow/CHANGELOG.md b/chat-flow/CHANGELOG.md index c00d943..319eff4 100644 --- a/chat-flow/CHANGELOG.md +++ b/chat-flow/CHANGELOG.md @@ -8,6 +8,17 @@ The version here always matches `manifest.json`'s `version`. ## [Unreleased] +## [1.1.0] — 2026-07-31 + +### Fixed + +- **Which bot answered a message inside an open flow depended on plugin enable order.** This plugin ran + at the default hook priority, the same one other auto-reply plugins use by default, so whether this + plugin's in-flow menu or a co-installed bot's reply won for a given message could change across a + restart or re-enable — even though this plugin already correctly claimed only the messages it answered. + It now registers at an explicit responder priority: after a command prefix, before a keyword bot, so the + order is the same on every restart no matter which plugins are also installed. + ## [1.0.8] — 2026-07-30 ### Fixed diff --git a/chat-flow/README.md b/chat-flow/README.md index 5eeca49..ff8ccba 100644 --- a/chat-flow/README.md +++ b/chat-flow/README.md @@ -14,8 +14,8 @@ | Field | Value | | ----- | ----- | | **Identifier** | `chat-flow` | -| **Version** | 1.0.8 | -| **Released** | 2026-07-30 | +| **Version** | 1.1.0 | +| **Released** | 2026-07-31 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | diff --git a/chat-flow/index.test.ts b/chat-flow/index.test.ts index f10413b..af198c5 100644 --- a/chat-flow/index.test.ts +++ b/chat-flow/index.test.ts @@ -2,6 +2,32 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { parseConfig, toFlowNodes } from './index.ts'; +// Minimal ctx builder for the priority test below — the file's other tests build their own inline ctx +// per-case (different storage/message needs), so this one only needs registerHook + a config that +// passes onEnable's fail-fast parseConfig. +function makeCtx(overrides: { + config?: Record; + registerHook?: (event: string, handler: unknown, priority?: number) => void; +} = {}) { + return { + config: overrides.config ?? { greeting: 'menu', options: [{ key: '1', text: 'A' }] }, + logger: { log() {}, debug() {}, warn() {}, error() {} }, + registerHook: overrides.registerHook ?? (() => {}), + storage: { get: async () => null, set: async () => {}, delete: async () => {}, list: async () => [] }, + messages: { reply: async () => ({ messageId: 'x', timestamp: 0 }), sendText: async () => ({ messageId: 'x', timestamp: 0 }) }, + }; +} + +// Responder band (PLUGIN-STANDARD.md "Co-installation"): an in-flow state machine is more specific than +// keyword rules but less specific than a command prefix. Already claims correctly via `continue: !handled`. +test('registers at the chat-flow responder priority', async () => { + let priority: number | undefined; + const ctx = makeCtx({ registerHook: (_e, _h, p) => { priority = p; } }); + const { default: ChatFlow } = await import('./index.ts'); + await new ChatFlow().onEnable(ctx as never); + assert.equal(priority, 75); +}); + test('parseConfig requires greeting and at least one option', () => { assert.throws(() => parseConfig({ options: [{ key: '1', text: 'a' }] }), /greeting is required/); assert.throws(() => parseConfig({ greeting: 'hi' }), /at least one menu option/); diff --git a/chat-flow/index.ts b/chat-flow/index.ts index 6980369..121c75e 100644 --- a/chat-flow/index.ts +++ b/chat-flow/index.ts @@ -44,13 +44,19 @@ export function parseConfig(raw: Record): ChatFlowConfig { /** How often to sweep abandoned flow states from storage (state TTL is 15 min). */ const SWEEP_INTERVAL_MS = 30 * 60 * 1000; +// Responder band: an in-flow state machine is more specific than keyword rules but less specific than a +// command prefix. Already claims correctly via `continue: !handled`. +const HOOK_PRIORITY = 75; + export default class ChatFlow implements IPlugin { private sweepTimer: ReturnType | null = null; async onEnable(ctx: PluginContext): Promise { parseConfig(ctx.config); // fail-fast: surface invalid config at enable, not per-message - ctx.registerHook('message:received', hook => - this.onMessage(ctx, hook as HookContext), + ctx.registerHook( + 'message:received', + hook => this.onMessage(ctx, hook as HookContext), + HOOK_PRIORITY, ); // Reclaim states abandoned before this enable, then keep sweeping — lazy per-key expiry only fires // when a conversation messages again, so an abandoned flow would otherwise linger in storage forever. diff --git a/chat-flow/manifest.json b/chat-flow/manifest.json index 03f547f..d4ebd1d 100644 --- a/chat-flow/manifest.json +++ b/chat-flow/manifest.json @@ -1,7 +1,7 @@ { "id": "chat-flow", "name": "Chat Flow", - "version": "1.0.8", + "version": "1.1.0", "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.", diff --git a/chatwoot-adapter/CHANGELOG.md b/chatwoot-adapter/CHANGELOG.md index 50ab328..b7a2e50 100644 --- a/chatwoot-adapter/CHANGELOG.md +++ b/chatwoot-adapter/CHANGELOG.md @@ -6,6 +6,34 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form ## [Unreleased] +## [0.7.0] — 2026-07-31 + +History-backfill reliability release, plus a registration-order fix. + +### Fixed + +- **A large history-backfill window could fail outright.** Requesting attachments for every message in a + wide window could exceed the host's 30-second import budget, so the whole import failed rather than + arriving late. Attachments are now fetched only when the configured window is 25 messages or smaller; + above that, older media arrives in Chatwoot as a placeholder line instead of the file. The history + backfill setting is clamped to 100, and its description explains the 25-message attachment threshold. +- **A failed history import for a chat was gone for good.** Whether a chat had been imported used to be + inferred rather than recorded, so an import that failed partway had no way to know it needed to try + again. It's now stored on the chat's own record, so a chat that failed its import is retried + automatically on its next message — up to three attempts before the plugin gives up on that chat. + **Upgrading does not re-import your existing conversations.** History import applies to chats first + seen from this version onward; a chat that was already open in Chatwoot before the upgrade is left + exactly as it is, so no thread gets a second copy of its history — unless you turn on the one-time + bulk-import option afterward, which re-imports history into chats that already have it. +- **A Chatwoot outage during import could mark a chat as "imported" with nothing in it.** The import is + now only recorded as done once every message in the window has actually posted to Chatwoot. +- **Chats that gave up on history import are now visible.** They're counted in this plugin's health + status, instead of failing silently. +- **Logged/mirrored messages could go missing when another plugin was also installed.** This plugin + registered its inbound and outbound hooks at the default priority, tied with (or behind) plugins that + end the event chain for the message they handle. It now registers early, at observer priority, so it + always sees a message before anything else decides that message is spoken for. + ## [0.6.0] — 2026-07-30 Storage-behaviour release, prompted by OpenWA 0.12.0. The host now enforces a 50 MiB per-plugin storage diff --git a/chatwoot-adapter/README.md b/chatwoot-adapter/README.md index f9a5b8f..d2e83bf 100644 --- a/chatwoot-adapter/README.md +++ b/chatwoot-adapter/README.md @@ -15,8 +15,8 @@ | Field | Value | | ----- | ----- | | **Identifier** | `chatwoot-adapter` | -| **Version** | 0.6.0 | -| **Released** | 2026-07-30 | +| **Version** | 0.7.0 | +| **Released** | 2026-07-31 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | @@ -113,7 +113,7 @@ curl -X POST "$OPENWA/api/plugins/chatwoot-adapter/enable" \ | `inboxId` | number | yes | The API-channel inbox id this adapter posts into and relays replies from. | | `relayGroups` | boolean | no (default `true`) | Relay group chats (one synthetic contact per group, sender-prefixed). | | `relayMedia` | boolean | no (default `true`) | Upload inbound media to Chatwoot as attachments. | -| `backfillLimit` | number | no (default `0`) | When a chat first opens in Chatwoot, import this many recent messages (both directions, with media) so agents see prior context. `0` disables it; clamped to 100 host-side. Needs OpenWA 0.8.6+ and the whatsapp-web.js engine (Baileys has no history support). | +| `backfillLimit` | number | no (default `0`) | When a chat first opens in Chatwoot, import this many recent messages (both directions) so agents see prior context. Attachments are imported only at 25 or below — above that the import would exceed the host's 30-second budget and fail, so older media arrives as a placeholder line instead. `0` disables it; clamped to 100 host-side. Needs OpenWA 0.8.6+ and the whatsapp-web.js engine (Baileys has no history support). | | `backfillAllOnce` | boolean | no (default `false`) | Also run a one-time sweep importing every existing chat's history on setup. Needs `backfillLimit` > 0. Runs once per session. | The Chatwoot webhook **secret** and the instance's **session scope** are set when you mint the instance (they diff --git a/chatwoot-adapter/backfill.test.ts b/chatwoot-adapter/backfill.test.ts index b7f8546..c755e3e 100644 --- a/chatwoot-adapter/backfill.test.ts +++ b/chatwoot-adapter/backfill.test.ts @@ -58,7 +58,9 @@ function makeDeps( backfillLimit: over.backfillLimit ?? 20, backfillAllOnce: false, log: () => {}, - onInboundLost: () => {}, // required on InboundDeps; the cast would hide its absence + // Both callbacks are required on InboundDeps; the cast would hide an omitted one. + onInboundLost: () => {}, + onBackfillExhausted: () => {}, } as unknown as InboundDeps; return { deps, posts, creates, seen }; } @@ -91,7 +93,7 @@ test('backfillHistory skips messages already seen (dedup with the live path)', a assert.deepEqual(posts.map(p => p.body), ['new']); }); -test('backfillHistory swallows a getChatHistory failure (best-effort)', async () => { +test('backfillHistory returns false on a getChatHistory failure, without posting anything', async () => { const { deps, posts } = makeDeps({ engine: { getChatHistory: async () => { @@ -196,3 +198,117 @@ test('bulk sweep populates `phone_number` on the new Chatwoot contact when the c assert.equal(byId['120363@g.us'], undefined); // groups never carry a phone assert.equal(byId['118367890123478@lid'], undefined); // cold lid — pre-fix behavior preserved }); + +test('media is requested only for a window small enough to fit the 30s capability budget', async () => { + const seenArgs: Array<{ limit: number; includeMedia: boolean }> = []; + const engine = { + getChatHistory: async (_s: string, _c: string, limit: number, includeMedia: boolean) => { + seenArgs.push({ limit, includeMedia }); + return []; + }, + }; + const small = makeDeps({ engine, backfillLimit: 25 }); + await backfillHistory(small.deps, 'sess', 'c@c.us', 55); + const large = makeDeps({ engine, backfillLimit: 26 }); + await backfillHistory(large.deps, 'sess', 'c@c.us', 55); + + assert.deepEqual(seenArgs, [ + { limit: 25, includeMedia: true }, + { limit: 26, includeMedia: false }, + ]); +}); + +test('backfillHistory distinguishes a failed fetch from a genuinely empty history', async () => { + const emptyChat = makeDeps({ engine: { getChatHistory: async () => [] } }); + assert.equal(await backfillHistory(emptyChat.deps, 'sess', 'c@c.us', 55), true); + + const failing = makeDeps({ + engine: { + getChatHistory: async () => { + throw new Error("capability 'engine.getChatHistory' timed out after 30000ms"); + }, + }, + }); + assert.equal(await backfillHistory(failing.deps, 'sess', 'c@c.us', 55), false); +}); + +test('bulk sweep isolates a failed chat: the rest of the sweep still creates and replays (#609)', async () => { + const chats = [ + { id: 'fail@c.us', name: 'F', isGroup: false }, + { id: 'ok@c.us', name: 'OK', isGroup: false }, + ]; + const createCalls: string[] = []; + const logs: string[] = []; + const { deps, posts } = makeDeps({ + engine: { + getChats: async () => chats, + getChatHistory: async (_s: string, chatId: string) => { + if (chatId === 'fail@c.us') throw new Error('timed out'); + return [{ ...hist('ok1', 10, false, 'hello from ok'), chatId }]; + }, + }, + client: { + createContact: async (identifier: string) => { + createCalls.push(identifier); + return { id: 9, sourceId: 'src' }; + }, + }, + }); + deps.log = (m: string) => void logs.push(m); + await backfillAllChats(deps, 'sessIsolate'); + + assert.deepEqual(createCalls, ['ok@c.us']); // only the surviving chat gets a conversation + assert.deepEqual(posts.map(p => p.body), ['hello from ok']); // and its history is replayed, unaffected + // fetchHistory's own catch is the ONLY thing that should log for the failed chat. A guard that regressed + // to `ordered.length` (no optional chaining) would throw on the `null`, get caught by the surrounding + // per-chat catch, and log a SECOND time -- misreporting an already-explained failed fetch as a crashed + // bulk-backfill step. + assert.deepEqual(logs, ['history fetch failed for fail@c.us']); +}); + +test('the bulk sweep records each imported chat so the lazy path does not refetch it', async () => { + const patches: Array<{ chatId: string; patch: Record }> = []; + const { deps } = makeDeps({ + engine: { + getChats: async () => [{ id: 'c@c.us', name: 'C', isGroup: false }], + getChatHistory: async () => [hist('m1', 10, false, 'earlier')], + }, + store: { + patch: async (_s: string, chatId: string, patch: Record) => + void patches.push({ chatId, patch }), + }, + }); + await backfillAllChats(deps, 'sess'); + assert.deepEqual(patches, [{ chatId: 'c@c.us', patch: { backfillDone: true } }]); +}); + +// ── A successful fetch followed by a failed replay must not be recorded as a completed import ───────── +// backfillHistory previously returned true whenever the FETCH succeeded, even if every post then failed +// (Chatwoot down at the moment this chat's first message arrived). The caller durably wrote backfillDone +// on that `true`, so the chat's history was permanently skipped: no retry, no onBackfillExhausted, no +// visibility — the exact silent loss this feature exists to remove, now recorded to disk as "done". + +test('backfillHistory returns false when the fetch succeeds but every post fails — a partial replay is not a completed import', async () => { + const history = [hist('m1', 10, false, 'boom-one'), hist('m2', 20, false, 'boom-two')]; + const { deps, posts } = makeDeps({ engine: { getChatHistory: async () => history }, failOn: 'boom' }); + const result = await backfillHistory(deps, 'sess', 'c@c.us', 55); + assert.equal(result, false, 'a fetch that succeeded but replayed nothing is not a completed import'); + assert.equal(posts.length, 0); +}); + +test('bulk sweep does not record backfillDone when every post in the replay fails', async () => { + const patches: Array<{ chatId: string; patch: Record }> = []; + const { deps } = makeDeps({ + engine: { + getChats: async () => [{ id: 'c@c.us', name: 'C', isGroup: false }], + getChatHistory: async () => [hist('m1', 10, false, 'boom')], + }, + store: { + patch: async (_s: string, chatId: string, patch: Record) => + void patches.push({ chatId, patch }), + }, + failOn: 'boom', + }); + await backfillAllChats(deps, 'sess'); + assert.deepEqual(patches, [], 'a chat the sweep could not actually post into must not be marked imported'); +}); diff --git a/chatwoot-adapter/backfill.ts b/chatwoot-adapter/backfill.ts index 5a76d09..d6b2bfd 100644 --- a/chatwoot-adapter/backfill.ts +++ b/chatwoot-adapter/backfill.ts @@ -1,28 +1,46 @@ import type { ChatSummary, IncomingMessage } from '../types/openwa'; import { relayMessage, ensureConversation, resolvePhone, type InboundDeps } from './relay.ts'; -// Fetch a chat's recent history oldest->newest. Best-effort: any failure (including an engine that does -// not support history, e.g. Baileys, which rejects) yields an empty list so callers degrade cleanly. -async function fetchHistory(deps: InboundDeps, sessionId: string, chatId: string): Promise { +// Media is inlined only for a window small enough to plausibly fit the host's 30 s per-capability budget. +// Above it the host downloads every blob serially — one Puppeteer round trip each, each separately bounded +// at 30 s — and the whole call times out with the history discarded. There is no cursor on getChatHistory, +// so paging is not an option; this is the only lever besides a smaller limit. Media messages above the +// threshold still relay as placeholderFor() lines, so the conversation's shape survives, only old +// attachments are absent. +const BACKFILL_MEDIA_MAX_LIMIT = 25; + +// Fetch a chat's recent history oldest->newest. Returns `null` when the FETCH FAILED — a capability +// timeout, or an engine without history support (Baileys rejects) — and `[]` when the chat genuinely has +// no history. Callers MUST NOT treat the two alike: consuming a done-marker on a failure loses that +// chat's history permanently, which is exactly the bug this signature exists to prevent. +async function fetchHistory(deps: InboundDeps, sessionId: string, chatId: string): Promise { try { - const history = await deps.engine.getChatHistory(sessionId, chatId, deps.backfillLimit, true); + const history = await deps.engine.getChatHistory( + sessionId, + chatId, + deps.backfillLimit, + deps.backfillLimit <= BACKFILL_MEDIA_MAX_LIMIT, + ); return [...history].sort((a, b) => a.timestamp - b.timestamp); } catch (err) { deps.log(`history fetch failed for ${chatId}`, err); - return []; + return null; } } // Replay ordered history into a Chatwoot conversation. Deduped against the same markSeen store the live // path uses. Per-message isolation: one failed post is logged and skipped, never aborting the rest; the // message is marked seen only AFTER a successful post so a transient error stays retryable rather than a -// silent drop. The caller holds the per-chat lock. +// silent drop. Returns whether every attempted message actually posted — a caller that ignores this and +// records a completed import anyway would durably lose the messages a down Chatwoot rejected, which is +// the exact silent-loss bug this whole marker exists to remove. The caller holds the per-chat lock. async function replayHistory( deps: InboundDeps, sessionId: string, conversationId: number, ordered: IncomingMessage[], -): Promise { +): Promise { + let allPosted = true; for (const msg of ordered) { if (await deps.store.hasSeen('wa', msg.id, sessionId)) continue; try { @@ -30,19 +48,25 @@ async function replayHistory( await deps.store.markSeen('wa', msg.id, sessionId); } catch (err) { deps.log(`history message ${msg.id} failed`, err); + allPosted = false; } } + return allPosted; } // Lazy per-conversation backfill: fetch + replay this chat's history into its (already-created) -// conversation. Empty/unsupported history is a no-op. +// conversation. Returns false when the fetch failed OR when any message failed to post — either way the +// caller must retry later, never record a completed import. A genuinely empty history (nothing to post) +// is a successful no-op. markSeen dedup makes a retry cheap: only the messages that failed post again. export async function backfillHistory( deps: InboundDeps, sessionId: string, chatId: string, conversationId: number, -): Promise { - await replayHistory(deps, sessionId, conversationId, await fetchHistory(deps, sessionId, chatId)); +): Promise { + const ordered = await fetchHistory(deps, sessionId, chatId); + if (ordered === null) return false; + return replayHistory(deps, sessionId, conversationId, ordered); } // In-memory guard so rapid successive inbounds can't launch the one-time sweep twice for a session. @@ -66,14 +90,21 @@ export async function backfillAllChats(deps: InboundDeps, sessionId: string): Pr await deps.lock.run(`${sessionId}:${chat.id}`, async () => { try { const ordered = await fetchHistory(deps, sessionId, chat.id); - if (!ordered.length) return; // nothing to import -> don't create an empty Chatwoot conversation + // null (fetch failed) and [] (no history) both skip: never create an empty Chatwoot + // conversation, and never post a partial import. A failed chat is picked up later by the lazy + // path, which counts its own attempts. + if (!ordered?.length) return; // chat.id is already the neutral JID on this path (anti-corruption layer), so the phone can be // resolved from it without an engine call. Groups skip (no MSISDN); a cold @lid stays no-phone. const conversationId = await ensureConversation(deps, sessionId, chat.id, { name: chat.name || chat.id, phone: resolvePhone(chat, chat.id), }); - await replayHistory(deps, sessionId, conversationId, ordered); + const allPosted = await replayHistory(deps, sessionId, conversationId, ordered); + // Share the lazy path's marker — but only when every message actually posted. A sweep that hit a + // down Chatwoot partway through must not record a completed import, or the unposted messages are + // lost for good instead of being picked up (and counted) by the lazy path's own retry budget. + if (allPosted) await deps.store.patch(sessionId, chat.id, { backfillDone: true }); } catch (err) { deps.log(`bulk backfill failed for ${chat.id}`, err); } diff --git a/chatwoot-adapter/echo-loop.test.ts b/chatwoot-adapter/echo-loop.test.ts index fd9020d..cc271b3 100644 --- a/chatwoot-adapter/echo-loop.test.ts +++ b/chatwoot-adapter/echo-loop.test.ts @@ -73,7 +73,9 @@ async function wire(sessionId = 'sess') { const inbound = { lock, client, store, engine, instanceId: 'inst', relayGroups: true, relayMedia: true, backfillLimit: 0, backfillAllOnce: false, log: () => {}, - onInboundLost: () => {}, // required on InboundDeps; the cast would hide its absence + // Both callbacks are required on InboundDeps; the cast would hide an omitted one. + onInboundLost: () => {}, + onBackfillExhausted: () => {}, } as unknown as InboundDeps; const sent: Array<{ chatId?: string; text?: string }> = []; @@ -160,7 +162,9 @@ test("one tenant's mirror marker does not suppress another tenant's reply with t lock, store, engine, instanceId: 'instA', relayGroups: true, relayMedia: true, backfillLimit: 0, backfillAllOnce: false, log: () => {}, client: { postText: async () => { posted.push({ id: 60 }); return { id: 60 }; }, postMedia: async () => ({ id: 60 }) }, - onInboundLost: () => {}, // required on InboundDeps; the cast would hide its absence + // Both callbacks are required on InboundDeps; the cast would hide an omitted one. + onInboundLost: () => {}, + onBackfillExhausted: () => {}, } as unknown as InboundDeps; await handleSent(inboundA, 'sessA', 'Engine', { ...own, chatId: 'alice@c.us' } as IncomingMessage); assert.equal(posted.length, 1); @@ -201,7 +205,9 @@ test('an echo webhook processed while the adapter post is still in flight is NOT const inbound = { lock, client, store, engine, instanceId: 'inst', relayGroups: true, relayMedia: true, backfillLimit: 0, backfillAllOnce: false, log: () => {}, - onInboundLost: () => {}, // required on InboundDeps; the cast would hide its absence + // Both callbacks are required on InboundDeps; the cast would hide an omitted one. + onInboundLost: () => {}, + onBackfillExhausted: () => {}, } as unknown as InboundDeps; const sent: Array<{ chatId?: string }> = []; const outbound = { diff --git a/chatwoot-adapter/inbound.test.ts b/chatwoot-adapter/inbound.test.ts index 2d3119f..016af23 100644 --- a/chatwoot-adapter/inbound.test.ts +++ b/chatwoot-adapter/inbound.test.ts @@ -9,10 +9,21 @@ const msg = { timestamp: 0, fromMe: false, isGroup: false, senderPhone: '+621', contact: { pushName: 'Budi' }, } as IncomingMessage; -function deps(over: { client?: Record; store?: Record; engine?: Record } = {}) { +function makeDeps( + over: { + client?: Record; + store?: Record; + engine?: Record; + backfillLimit?: number; + onBackfillExhausted?: (chatId: string) => void; + } = {}, +) { let contacts = 0; let convs = 0; const posted: Array<{ id: number; c: string }> = []; + // Same postText calls as `posted`, reshaped to {conversationId, type, body} — the shape the backfill + // regression tests need to tell a replayed history line (body 'earlier') from the live message. + const posts: Array<{ conversationId: number; type: string; body: string }> = []; const lost: string[] = []; const store = new Map(); const client = { @@ -20,7 +31,11 @@ function deps(over: { client?: Record; store?: Record { contacts++; return { id: 9, sourceId: 'src' }; }, findOpenConversation: async () => null, createConversation: async () => { convs++; return 55; }, - postText: async (id: number, c: string) => { posted.push({ id, c }); return { id: 1 }; }, + postText: async (id: number, c: string, o?: { messageType?: string }) => { + posted.push({ id, c }); + posts.push({ conversationId: id, type: o?.messageType ?? 'incoming', body: c }); + return { id: 1 }; + }, postMedia: async () => ({ id: 2 }), ...over.client, }; @@ -30,20 +45,22 @@ function deps(over: { client?: Record; store?: Record void store.set(`${s}:${c}`, l), hasSeen: async () => false, markSeen: async () => {}, + patch: async () => {}, ...over.store, }; // Default: identity canonicalization (@lid resolution exercised explicitly below). const engine = { canonicalChatId: async (_s: string, c: string) => c, ...over.engine }; const d = { lock: new KeyedAsyncLock(), client, store: mapping, engine, instanceId: 'inst', - relayGroups: true, relayMedia: true, log: () => {}, + relayGroups: true, relayMedia: true, backfillLimit: over.backfillLimit ?? 0, log: () => {}, onInboundLost: (msgId: string) => void lost.push(msgId), + onBackfillExhausted: over.onBackfillExhausted ?? (() => {}), } as unknown as InboundDeps; - return { deps: d, counts: () => ({ contacts, convs }), posted, lost }; + return { deps: d, counts: () => ({ contacts, convs }), posted, posts, lost }; } test('a migrated contact (@lid inbound, @c.us-keyed conversation) reuses the EXISTING conversation via dual-lookup, no split', async () => { - const { deps: d, posted, counts } = deps({ + const { deps: d, posted, counts } = makeDeps({ engine: { canonicalChatId: async (_s: string, c: string) => (c === '621@lid' ? '621@c.us' : c) }, store: { getByChat: async (_s: string, c: string) => @@ -57,7 +74,7 @@ test('a migrated contact (@lid inbound, @c.us-keyed conversation) reuses the EXI }); test('cold lid (@lid unresolvable) still creates — documented residual closed by RESOLVE_LID_TO_PHONE', async () => { - const { deps: d, posted, counts } = deps({ + const { deps: d, posted, counts } = makeDeps({ engine: { canonicalChatId: async (_s: string, c: string) => c }, // cold: @lid stays @lid }); const lidMsg = { ...msg, id: 'x2', chatId: '621@lid' } as IncomingMessage; @@ -67,7 +84,7 @@ test('cold lid (@lid unresolvable) still creates — documented residual closed }); test('canonicalChatId throwing (session down) falls back to the raw id and still relays — never drops the message', async () => { - const { deps: d, posted } = deps({ + const { deps: d, posted } = makeDeps({ engine: { canonicalChatId: async () => { throw new Error('session not active'); } }, }); await handleInbound(d, 'sess', 'Engine', msg); @@ -77,7 +94,7 @@ test('canonicalChatId throwing (session down) falls back to the raw id and still test('reusing a @c.us mapping via @lid dual-lookup patches the name under the @c.us key (no repeated updateContact)', async () => { const patches: Array<[string, { name?: string }]> = []; const renames: string[] = []; - const { deps: d } = deps({ + const { deps: d } = makeDeps({ engine: { canonicalChatId: async (_s: string, c: string) => (c === '621@lid' ? '621@c.us' : c) }, store: { getByChat: async (_s: string, c: string) => @@ -94,7 +111,7 @@ test('reusing a @c.us mapping via @lid dual-lookup patches the name under the @c test('a failed relay queues the message for retry (at-least-once), not dropped', async () => { const enqueued: string[] = []; - const { deps: d } = deps({ + const { deps: d } = makeDeps({ client: { postText: async () => { throw new Error('chatwoot 503'); } }, store: { enqueueRetry: async (e: { msg: { id: string } }) => void enqueued.push(e.msg.id) }, }); @@ -103,14 +120,14 @@ test('a failed relay queues the message for retry (at-least-once), not dropped', }); test('creates contact + conversation and posts an incoming message', async () => { - const { deps: d, posted, counts } = deps(); + const { deps: d, posted, counts } = makeDeps(); await handleInbound(d, 'sess', 'Engine', msg); assert.deepEqual(posted, [{ id: 55, c: 'hello' }]); assert.deepEqual(counts(), { contacts: 1, convs: 1 }); }); test('two concurrent inbounds for a NEW chat make exactly ONE contact + conversation', async () => { - const { deps: d, counts } = deps(); + const { deps: d, counts } = makeDeps(); await Promise.all([ handleInbound(d, 'sess', 'Engine', msg), handleInbound(d, 'sess', 'Engine', { ...msg, id: 'm2' }), @@ -119,7 +136,7 @@ test('two concurrent inbounds for a NEW chat make exactly ONE contact + conversa }); test('skips fromMe and is idempotent (already seen → no post)', async () => { - const { deps: d, posted } = deps({ store: { hasSeen: async () => true } }); + const { deps: d, posted } = makeDeps({ store: { hasSeen: async () => true } }); await handleInbound(d, 'sess', 'Engine', msg); await handleInbound(d, 'sess', 'Engine', { ...msg, fromMe: true }); assert.equal(posted.length, 0); @@ -127,7 +144,7 @@ test('skips fromMe and is idempotent (already seen → no post)', async () => { test('forwards the quote context (source_id + in_reply_to_external_id) on a reply (#606)', async () => { let opts: unknown; - const { deps: d } = deps({ + const { deps: d } = makeDeps({ client: { postText: async (_id: number, _c: string, o: unknown) => { opts = o; return { id: 1 }; } }, }); const reply = { ...msg, id: 'r1', quotedMessage: { id: 'orig', body: 'earlier' } } as IncomingMessage; @@ -137,7 +154,7 @@ test('forwards the quote context (source_id + in_reply_to_external_id) on a repl test('relays an inbound voice note as a Chatwoot voice message (#607)', async () => { let call: { file: { filename: string; contentType: string }; o: { isVoiceMessage?: boolean; sourceId?: string } } | undefined; - const { deps: d } = deps({ + const { deps: d } = makeDeps({ client: { postMedia: async (_id: number, _c: string, file: never, o: never) => { call = { file, o }; return { id: 2 }; }, }, @@ -150,14 +167,14 @@ test('relays an inbound voice note as a Chatwoot voice message (#607)', async () }); test('a voice note with an omitted blob posts a placeholder, not an empty bubble (#607)', async () => { - const { deps: d, posted } = deps(); + const { deps: d, posted } = makeDeps(); const voice = { ...msg, id: 'v2', body: '', type: 'voice', media: { mimetype: 'audio/ogg', omitted: true, sizeBytes: 999999 } } as IncomingMessage; await handleInbound(d, 'sess', 'Engine', voice); assert.deepEqual(posted, [{ id: 55, c: '🎤 Voice message' }]); }); test('relays a shared location as a text bubble with a maps link (#609 P2)', async () => { - const { deps: d, posted } = deps(); + const { deps: d, posted } = makeDeps(); const loc = { ...msg, id: 'loc1', body: '', type: 'location', location: { latitude: -6.2, longitude: 106.8, description: 'Office' } } as IncomingMessage; await handleInbound(d, 'sess', 'Engine', loc); assert.equal(posted.length, 1); @@ -167,7 +184,7 @@ test('relays a shared location as a text bubble with a maps link (#609 P2)', asy test('relays a sticker as a webp image attachment (#609 P2)', async () => { let file: { filename: string; contentType: string } | undefined; - const { deps: d } = deps({ + const { deps: d } = makeDeps({ client: { postMedia: async (_id: number, _c: string, f: never) => { file = f; return { id: 2 }; } }, }); const sticker = { ...msg, id: 's1', body: '', type: 'sticker', media: { mimetype: 'image/webp', data: 'AAA' } } as IncomingMessage; @@ -179,7 +196,7 @@ test('relays a sticker as a webp image attachment (#609 P2)', async () => { test('refreshes an @lid contact name once a real pushName arrives (#609)', async () => { const updates: Array<[number, string]> = []; const patches: Array<{ name?: string }> = []; - const { deps: d } = deps({ + const { deps: d } = makeDeps({ client: { updateContact: async (id: number, name: string) => void updates.push([id, name]) }, store: { getByChat: async () => ({ conversationId: 55, contactId: 9, sourceId: 'src', name: '621@lid' }), @@ -194,7 +211,7 @@ test('refreshes an @lid contact name once a real pushName arrives (#609)', async test('does not rename when the stored name already matches (#609)', async () => { const updates: unknown[] = []; - const { deps: d } = deps({ + const { deps: d } = makeDeps({ client: { updateContact: async (id: number, name: string) => void updates.push([id, name]) }, store: { getByChat: async () => ({ conversationId: 55, contactId: 9, sourceId: 'src', name: 'Budi' }) }, }); @@ -204,7 +221,7 @@ test('does not rename when the stored name already matches (#609)', async () => test('never renames a group contact from a member pushName (#609)', async () => { const updates: unknown[] = []; - const { deps: d } = deps({ + const { deps: d } = makeDeps({ client: { updateContact: async (...a: unknown[]) => void updates.push(a) }, store: { getByChat: async () => ({ conversationId: 55, contactId: 9, sourceId: 'src', name: 'Group 12@g.us' }) }, }); @@ -228,7 +245,7 @@ function captureCreateContact() { test('@lid inbound with senderPhone set (RESOLVE_LID_TO_PHONE=true) creates the contact with the real phone', async () => { const { calls, client } = captureCreateContact(); - const { deps: d } = deps({ client }); + const { deps: d } = makeDeps({ client }); // senderPhone is what the host populates when the env flag is on — MSISDN digits, no `+` guaranteed. const lid = { ...msg, id: 'lid-pn', chatId: '118367890123478@lid' } as IncomingMessage; lid.senderPhone = '1234567890'; @@ -239,7 +256,7 @@ test('@lid inbound with senderPhone set (RESOLVE_LID_TO_PHONE=true) creates the test('@lid inbound with a warm lid->phone mapping (canonicalChatId resolves) creates the contact with the real phone', async () => { const { calls, client } = captureCreateContact(); - const { deps: d } = deps({ + const { deps: d } = makeDeps({ client, // The engine's in-memory lidMappingStore returns the chat's `@c.us` once any reply to them // has warmed it, with RESOLVE_LID_TO_PHONE off. @@ -254,7 +271,7 @@ test('@lid inbound with a warm lid->phone mapping (canonicalChatId resolves) cre test('@lid inbound with COLD lid mapping (RESOLVE_LID_TO_PHONE off, no reply yet) creates the contact without a phone — no regression vs pre-fix behavior', async () => { const { calls, client } = captureCreateContact(); - const { deps: d } = deps({ + const { deps: d } = makeDeps({ client, engine: { canonicalChatId: async (_s: string, c: string) => c }, // unresolved → @lid stays @lid }); @@ -267,7 +284,7 @@ test('@lid inbound with COLD lid mapping (RESOLVE_LID_TO_PHONE off, no reply yet test('plain `@c.us` inbound without senderPhone creates the contact with the phone (resolved from the chatId user-part)', async () => { const { calls, client } = captureCreateContact(); - const { deps: d } = deps({ client }); + const { deps: d } = makeDeps({ client }); // senderPhone on the host is lid-only, so a plain @c.us sender usually has no senderPhone at all. const c_us = { ...msg, id: 'cn1', chatId: '1234567890@c.us' } as IncomingMessage; c_us.senderPhone = undefined; @@ -278,7 +295,7 @@ test('plain `@c.us` inbound without senderPhone creates the contact with the pho test('a group inbound creates the group contact without a phone, regardless of senderPhone', async () => { const { calls, client } = captureCreateContact(); - const { deps: d } = deps({ client }); + const { deps: d } = makeDeps({ client }); const grp = { ...msg, id: 'g1', isGroup: true, chatId: '120363@g.us', author: '621@c.us' } as IncomingMessage; grp.senderPhone = '1234567890'; await handleInbound(d, 'sess', 'Engine', grp); @@ -293,7 +310,7 @@ test('a group inbound creates the group contact without a phone, regardless of s // the drop-oldest policy never ran, and healthCheck kept reporting green while messages vanished. test('a relay failure that also fails to enqueue is reported as LOST, not swallowed', async () => { - const { deps: d, lost } = deps({ + const { deps: d, lost } = makeDeps({ client: { postText: async () => { throw new Error('chatwoot down'); } }, store: { enqueueRetry: async () => { throw new Error('storage quota exceeded'); } }, }); @@ -303,7 +320,7 @@ test('a relay failure that also fails to enqueue is reported as LOST, not swallo test('a relay failure that DOES enqueue is not reported as lost', async () => { const queued: unknown[] = []; - const { deps: d, lost } = deps({ + const { deps: d, lost } = makeDeps({ client: { postText: async () => { throw new Error('chatwoot down'); } }, store: { enqueueRetry: async (e: unknown) => { queued.push(e); return null; } }, }); @@ -316,7 +333,7 @@ test('a relay failure that DOES enqueue is not reported as lost', async () => { // the hook with the message neither relayed nor queued — and nothing counted it. test('a markSeen failure takes the retry path instead of escaping the handler', async () => { const queued: unknown[] = []; - const { deps: d, lost, posted } = deps({ + const { deps: d, lost, posted } = makeDeps({ store: { markSeen: async () => { throw new Error('storage quota exceeded'); }, enqueueRetry: async (e: unknown) => { queued.push(e); return null; }, @@ -327,3 +344,227 @@ test('a markSeen failure takes the retry path instead of escaping the handler', assert.deepEqual(lost, []); assert.deepEqual(posted, [], 'nothing was relayed'); }); + +// ── Durable per-chat backfill marker: the trigger reads back the stored state, not a one-shot inference ── + +// A distinct base from `msg` above (different chatId: 'c@c.us') so these tests can drive several inbound +// messages into the SAME chat via the store fake's `links` map without colliding with the other fixture's +// hardcoded '621@c.us'. +const msgWith = (over: Partial = {}): IncomingMessage => + ({ id: 'm1', from: 'x', to: 'y', chatId: 'c@c.us', body: 'hi', type: 'chat', + timestamp: 0, fromMe: false, isGroup: false, ...over }) as IncomingMessage; + +test('a failed history fetch is retried on the next inbound instead of being lost forever', async () => { + const links = new Map>(); + let historyCalls = 0; + const { deps, posts } = makeDeps({ + engine: { + canonicalChatId: async (_s: string, c: string) => c, + getChatHistory: async () => { + historyCalls++; + if (historyCalls === 1) throw new Error("capability 'engine.getChatHistory' timed out after 30000ms"); + return [ + { id: 'old', from: 'x', to: 'y', chatId: 'c@c.us', body: 'earlier', type: 'chat', + timestamp: 1, fromMe: false, isGroup: false }, + ]; + }, + }, + store: { + getByChat: async (_s: string, c: string) => links.get(c) ?? null, + link: async (_s: string, c: string, _i: string, l: Record) => void links.set(c, { ...l }), + patch: async (_s: string, c: string, p: Record) => + void links.set(c, { ...(links.get(c) ?? {}), ...p }), + }, + backfillLimit: 20, + }); + + await handleInbound(deps, 'sess', 'Engine', msgWith({ id: 'm1', body: 'first' })); + assert.equal(historyCalls, 1); + assert.equal(links.get('c@c.us')?.backfillAttempts, 1); + assert.equal(links.get('c@c.us')?.backfillDone, false, 'still eligible — a failed attempt is not an import'); + assert.ok(!posts.some(p => p.body === 'earlier'), 'nothing backfilled on the failed attempt'); + + await handleInbound(deps, 'sess', 'Engine', msgWith({ id: 'm2', body: 'second' })); + assert.equal(historyCalls, 2); + assert.equal(links.get('c@c.us')?.backfillDone, true); + assert.ok(posts.some(p => p.body === 'earlier'), 'history landed on the retry'); + + // The `!backfill.done` guard is the entire point of storing the marker: without it, a chat that already + // imported cleanly would still pay a getChatHistory call (and a full replay) on every later message. + await handleInbound(deps, 'sess', 'Engine', msgWith({ id: 'm3', body: 'third' })); + assert.equal(historyCalls, 2, 'an already-imported chat is never refetched'); +}); + +test('backfill stops after MAX_BACKFILL_ATTEMPTS and reports the chat as exhausted', async () => { + const links = new Map>(); + const exhausted: string[] = []; + let historyCalls = 0; + const { deps } = makeDeps({ + engine: { + canonicalChatId: async (_s: string, c: string) => c, + getChatHistory: async () => { + historyCalls++; + throw new Error('timed out'); + }, + }, + store: { + getByChat: async (_s: string, c: string) => links.get(c) ?? null, + link: async (_s: string, c: string, _i: string, l: Record) => void links.set(c, { ...l }), + patch: async (_s: string, c: string, p: Record) => + void links.set(c, { ...(links.get(c) ?? {}), ...p }), + }, + onBackfillExhausted: (chatId: string) => void exhausted.push(chatId), + backfillLimit: 20, + }); + + for (let i = 1; i <= 5; i++) { + await handleInbound(deps, 'sess', 'Engine', msgWith({ id: `m${i}`, body: `b${i}` })); + } + assert.equal(historyCalls, 3, 'stops attempting after the cap'); + assert.deepEqual(exhausted, ['c@c.us']); + assert.equal(links.get('c@c.us')?.backfillDone, false, 'exhaustion must never be recorded as done'); +}); + +test('reports the mapping document key to onBackfillExhausted, not the raw chatId, when the dual-lookup diverges', async () => { + // Mapping already lives under the canonical @c.us key (a prior @lid contact that migrated), but this + // inbound still arrives with the raw @lid chatId — the dual-lookup case resolveConversation exists for. + // `backfillDone: false` is what ensureConversation writes for a chat this version created; it is what + // makes the chat eligible for an import at all. + const links = new Map>([ + ['c@c.us', { conversationId: 55, contactId: 9, sourceId: 'src', backfillDone: false }], + ]); + const exhausted: string[] = []; + const { deps } = makeDeps({ + engine: { + canonicalChatId: async (_s: string, c: string) => (c === 'c@lid' ? 'c@c.us' : c), + getChatHistory: async () => { + throw new Error('timed out'); + }, + }, + store: { + getByChat: async (_s: string, c: string) => links.get(c) ?? null, + patch: async (_s: string, c: string, p: Record) => + void links.set(c, { ...(links.get(c) ?? {}), ...p }), + }, + onBackfillExhausted: (chatId: string) => void exhausted.push(chatId), + backfillLimit: 20, + }); + + for (let i = 1; i <= 3; i++) { + await handleInbound(deps, 'sess', 'Engine', { ...msgWith({ id: `m${i}` }), chatId: 'c@lid' }); + } + // The marker lives under 'c@c.us' (links.get above). An operator shown 'c@lid' would be pointed at a + // chat id that matches no stored state at all. + assert.deepEqual(exhausted, ['c@c.us'], 'reported id matches the document the marker is actually patched under'); +}); + +// ── Upgrade safety: "needs import" is a POSITIVE marker, never inferred from a missing field ────────── +// Every mapping written before this version carries no backfill fields at all. A trigger of `!done` +// reads that absence as "never imported" and replays the whole window into a conversation an earlier +// release already imported under the old `created`-derived scheme — a duplicate wall in every open +// chat, posted AHEAD of the message that is actually arriving. + +test('a mapping that predates the backfill marker is left alone — an upgrade never re-imports an existing chat', async () => { + const links = new Map>([ + // Exactly what a pre-0.7.0 release stored: no backfillDone, no backfillAttempts. + ['c@c.us', { conversationId: 55, contactId: 9, sourceId: 'src', name: 'Budi' }], + ]); + let historyCalls = 0; + const { deps, posts } = makeDeps({ + engine: { + canonicalChatId: async (_s: string, c: string) => c, + getChatHistory: async () => { + historyCalls++; + return [ + { id: 'a1', from: 'x', to: 'y', chatId: 'c@c.us', body: 'ANCIENT-1', type: 'chat', + timestamp: 1, fromMe: false, isGroup: false }, + { id: 'a2', from: 'x', to: 'y', chatId: 'c@c.us', body: 'ANCIENT-2', type: 'chat', + timestamp: 2, fromMe: false, isGroup: false }, + ]; + }, + }, + store: { + getByChat: async (_s: string, c: string) => links.get(c) ?? null, + patch: async (_s: string, c: string, p: Record) => + void links.set(c, { ...(links.get(c) ?? {}), ...p }), + }, + backfillLimit: 50, + }); + + await handleInbound(deps, 'sess', 'Engine', msgWith({ id: 'live', body: 'new message' })); + assert.equal(historyCalls, 0, 'an absent marker means a pre-existing chat, not an unimported one'); + assert.deepEqual(posts.map(p => p.body), ['new message'], 'only the live message is posted'); +}); + +test('a chat created by this version is stamped backfillDone:false and is imported on its first message', async () => { + const links = new Map>(); + const linked: Array> = []; + let historyCalls = 0; + const { deps, posts } = makeDeps({ + engine: { + canonicalChatId: async (_s: string, c: string) => c, + getChatHistory: async () => { + historyCalls++; + return [ + { id: 'old', from: 'x', to: 'y', chatId: 'c@c.us', body: 'earlier', type: 'chat', + timestamp: 1, fromMe: false, isGroup: false }, + ]; + }, + }, + store: { + getByChat: async (_s: string, c: string) => links.get(c) ?? null, + link: async (_s: string, c: string, _i: string, l: Record) => { + linked.push({ ...l }); + links.set(c, { ...l }); + }, + patch: async (_s: string, c: string, p: Record) => + void links.set(c, { ...(links.get(c) ?? {}), ...p }), + }, + backfillLimit: 20, + }); + + await handleInbound(deps, 'sess', 'Engine', msgWith({ id: 'm1', body: 'first' })); + // The stamp is the whole mechanism: without it on disk, the chat's SECOND message reads an absent + // marker and is misfiled as pre-existing, so a chat that failed its first import is never retried. + assert.equal(linked[0]?.backfillDone, false, 'the created mapping is positively marked as needing import'); + assert.equal(historyCalls, 1); + assert.deepEqual(posts.map(p => p.body), ['earlier', 'first'], 'history replays ahead of the live message'); + assert.equal(links.get('c@c.us')?.backfillDone, true); +}); + +// ── The import marker is bookkeeping; the message is the product ───────────────────────────────────── +// Both patches sit on the path to relayMessage. Unguarded, a rejected marker write (the host rejects +// every `set` once the plugin is at its 50 MiB quota) threw past the live relay, so a perfectly +// relayable message became a retry-queue entry — and the drain then re-ran the whole import behind it. + +test('a marker write that fails costs at most a redundant re-import, never the live message', async () => { + const queuedDone: string[] = []; + const doneMarker = makeDeps({ + engine: { canonicalChatId: async (_s: string, c: string) => c, getChatHistory: async () => [] }, + store: { + patch: async () => { throw new Error('storage quota exceeded'); }, + enqueueRetry: async (e: { msg: { id: string } }) => { queuedDone.push(e.msg.id); return null; }, + }, + backfillLimit: 20, + }); + await handleInbound(doneMarker.deps, 'sess', 'Engine', msgWith({ id: 'live1', body: 'live1' })); + assert.deepEqual(doneMarker.posts.map(p => p.body), ['live1'], 'the live message still relays'); + assert.deepEqual(queuedDone, [], 'and is not demoted into the retry queue'); + + // Same for the attempts counter, written on the failed-import branch. + const queuedAttempt: string[] = []; + const attemptMarker = makeDeps({ + engine: { + canonicalChatId: async (_s: string, c: string) => c, + getChatHistory: async () => { throw new Error('timed out'); }, + }, + store: { + patch: async () => { throw new Error('storage quota exceeded'); }, + enqueueRetry: async (e: { msg: { id: string } }) => { queuedAttempt.push(e.msg.id); return null; }, + }, + backfillLimit: 20, + }); + await handleInbound(attemptMarker.deps, 'sess', 'Engine', msgWith({ id: 'live2', body: 'live2' })); + assert.deepEqual(attemptMarker.posts.map(p => p.body), ['live2'], 'the live message still relays'); + assert.deepEqual(queuedAttempt, [], 'and is not demoted into the retry queue'); +}); diff --git a/chatwoot-adapter/inbound.ts b/chatwoot-adapter/inbound.ts index 510c0fe..12bfe8e 100644 --- a/chatwoot-adapter/inbound.ts +++ b/chatwoot-adapter/inbound.ts @@ -6,6 +6,11 @@ import { MAX_PENDING_RETRIES, slimForRetry } from './retry.ts'; export type { InboundDeps }; +// A chat's history import gets a bounded number of tries. Backfill runs inside the per-chat lock and +// BEFORE the live relay, so an unbounded retry would add a full 30 s capability timeout to every inbound +// message on a chat that can never be imported. +export const MAX_BACKFILL_ATTEMPTS = 3; + // The resolve + backfill + relay core, lock-free, that THROWS on failure. Shared by the live inbound // handler and the retry drain (retry.ts) so a retried message follows the exact same path. export async function relayInbound(deps: InboundDeps, sessionId: string, msg: IncomingMessage): Promise { @@ -18,12 +23,29 @@ export async function relayInbound(deps: InboundDeps, sessionId: string, msg: In } catch { /* session down / unresolvable — fall back to the raw id; dedup is best-effort */ } - const { conversationId, created } = await resolveConversation(deps, sessionId, msg, canonical); - // Lazy backfill: the first time this chat maps, replay its recent history (older messages, both - // directions, deduped) BEFORE posting this one — so the thread reads chronologically and this message's - // quote resolves against a just-posted source_id. This message is already markSeen, so backfill skips it. - if (created && deps.backfillLimit > 0) { - await backfillHistory(deps, sessionId, msg.chatId, conversationId); + const { conversationId, key, backfill } = await resolveConversation(deps, sessionId, msg, canonical); + // Lazy backfill: replay this chat's recent history (older messages, both directions, deduped) BEFORE + // posting this one, so the thread reads chronologically and this message's quote resolves against a + // just-posted source_id. This message is already markSeen, so backfill skips it. + // + // The trigger is the stored marker, NOT "the conversation was just created". The old derivation could + // only ever fire once: a failed fetch left the conversation in place, so every later message saw + // created=false and the chat never got its history. + // + // `=== false` and not `!backfill.done`: the marker must be POSITIVELY present. ensureConversation + // stamps `backfillDone: false` on every mapping this version creates, so absent means the mapping + // predates the marker — a chat an earlier release already handled, which must be left alone. The + // tradeoff is deliberate: a chat whose import failed before the upgrade is never retried, which is + // exactly what those installs do today; every chat from here on gets the durable retry. + const attempts = backfill.attempts ?? 0; + if (deps.backfillLimit > 0 && backfill.done === false && attempts < MAX_BACKFILL_ATTEMPTS) { + if (await backfillHistory(deps, sessionId, msg.chatId, conversationId)) { + await recordBackfill(deps, sessionId, key, { backfillDone: true }); + } else { + const next = attempts + 1; + await recordBackfill(deps, sessionId, key, { backfillAttempts: next }); + if (next >= MAX_BACKFILL_ATTEMPTS) deps.onBackfillExhausted(key); + } } await relayMessage(deps, sessionId, conversationId, msg, 'incoming'); } @@ -75,12 +97,31 @@ export async function handleInbound( }); } +// The import marker is bookkeeping; the message is the product. A rejected write — the host rejects +// EVERY `set` once the plugin is at its 50 MiB quota — must cost at most one redundant import on the +// next message. Unguarded it threw past relayMessage, so a perfectly relayable message became a +// retry-queue entry whose drain re-ran the whole 30 s import behind it. Mirrors refreshContactName. +async function recordBackfill( + deps: InboundDeps, + sessionId: string, + key: string, + patch: { backfillDone?: boolean; backfillAttempts?: number }, +): Promise { + try { + await deps.store.patch(sessionId, key, patch); + } catch (err) { + deps.log('backfill marker write failed', err); + } +} + +// Resolves the Chatwoot conversation for this chat and reports where its mapping document lives, so the +// caller patches the SAME document refreshContactName does, plus that document's backfill state. async function resolveConversation( deps: InboundDeps, sessionId: string, msg: IncomingMessage, canonicalChatId: string, -): Promise<{ conversationId: number; created: boolean }> { +): Promise<{ conversationId: number; key: string; backfill: { done?: boolean; attempts?: number } }> { // Dual lookup (re-read inside the lock): the raw chatId finds a mapping keyed by @lid, the canonical // chatId finds one keyed by @c.us (a contact that has since migrated to @lid, when the lid resolves) — // so a migrated contact's inbound lands in its EXISTING conversation instead of splitting a duplicate. @@ -93,16 +134,22 @@ async function resolveConversation( } if (existing) { await refreshContactName(deps, sessionId, msg, existing, foundKey); - return { conversationId: existing.conversationId, created: false }; + return { + conversationId: existing.conversationId, + key: foundKey, + backfill: { done: existing.backfillDone, attempts: existing.backfillAttempts }, + }; } const name = msg.isGroup ? `Group ${msg.chatId}` : msg.contact?.pushName || msg.contact?.name || msg.senderPhone || msg.chatId; const conversationId = await ensureConversation(deps, sessionId, msg.chatId, { - name, // Phone from the host-resolved sender (RESOLVE_LID_TO_PHONE), or the canonical chat id (warm lid→pn // cache / every plain @c.us chat); undefined when genuinely unknown so the contact still creates. + name, phone: resolvePhone(msg, canonicalChatId), }); - return { conversationId, created: true }; + // ensureConversation wrote the mapping under msg.chatId, stamped `backfillDone: false`. Mirroring that + // value here rather than reading the document back saves a storage round trip on every new chat. + return { conversationId, key: msg.chatId, backfill: { done: false } }; } diff --git a/chatwoot-adapter/index.test.ts b/chatwoot-adapter/index.test.ts index 91038f7..652c4c5 100644 --- a/chatwoot-adapter/index.test.ts +++ b/chatwoot-adapter/index.test.ts @@ -8,6 +8,7 @@ function fakeCtx(config: Record) { const hooks: string[] = []; const routes: string[] = []; const cbs: Record Promise<{ continue: boolean }>> = {}; + const priorities: Record = {}; let fetches = 0; const storageMap = new Map(); const ctx = { @@ -24,10 +25,14 @@ function fakeCtx(config: Record) { handover: { set: async () => ({}) }, engine: { canonicalChatId: async (_s: string, c: string) => c }, logger: { log: () => {}, debug: () => {}, warn: () => {}, error: () => {} }, - registerHook: (event: string, cb: (h: unknown) => Promise<{ continue: boolean }>) => { hooks.push(event); cbs[event] = cb; }, + registerHook: (event: string, cb: (h: unknown) => Promise<{ continue: boolean }>, priority?: number) => { + hooks.push(event); + cbs[event] = cb; + priorities[event] = priority; + }, registerWebhook: (route: string) => void routes.push(route), } as unknown as PluginContext; - return { ctx, hooks, routes, cbs, fetches: () => fetches, storageMap }; + return { ctx, hooks, routes, cbs, fetches: () => fetches, storageMap, priorities }; } const goodConfig = { baseUrl: 'https://chat.acme.com', apiToken: 'tok', accountId: 3, inboxId: 7 }; @@ -39,6 +44,14 @@ test('onEnable registers the message:received + message:sent hooks and the chatw assert.deepEqual(routes, ['chatwoot']); }); +// Observer band (PLUGIN-STANDARD.md "Co-installation"): must run before any responder, or a claiming +// responder (chat-flow, group-translate) silently ends the chain before the relay ever sees the message. +test('relays at observer priority on both hooks', async () => { + const { ctx, priorities } = fakeCtx(goodConfig); + await new ChatwootAdapter().onEnable(ctx); + assert.deepEqual(priorities, { 'message:received': 20, 'message:sent': 20 }); +}); + test('message:sent is registered even when relayOwnMessages=false (gate is per-event, not at enable)', async () => { const { ctx, hooks } = fakeCtx({ ...goodConfig, relayOwnMessages: false }); await new ChatwootAdapter().onEnable(ctx); @@ -103,3 +116,15 @@ test('onEnable rejects a non-https or credentialed baseUrl (fail fast, not per-m await assert.rejects(new ChatwootAdapter().onEnable(fakeCtx({ ...goodConfig, baseUrl: 'https://user:pw@chat.acme.com' }).ctx), /credential/); await assert.rejects(new ChatwootAdapter().onEnable(fakeCtx({ ...goodConfig, baseUrl: 'not a url' }).ctx), /valid URL/); }); + +test('healthCheck reports chats whose history import gave up', async () => { + const plugin = new ChatwootAdapter(); + await plugin.onEnable(fakeCtx(goodConfig).ctx); + // Driving this through the real path needs a hook registration plus three consecutive failing + // backfills, which inbound.test.ts already covers; this cast pins only the healthCheck wiring. + (plugin as unknown as { onBackfillExhausted: (c: string) => void }).onBackfillExhausted('c@c.us'); + const health = await plugin.healthCheck(); + assert.match(health.message ?? '', /1 chat\(s\) gave up on history import/); + // A gave-up history import doesn't touch the live relay, so it must not flip the health verdict. + assert.equal(health.healthy, true); +}); diff --git a/chatwoot-adapter/index.ts b/chatwoot-adapter/index.ts index 51d377a..b1859fb 100644 --- a/chatwoot-adapter/index.ts +++ b/chatwoot-adapter/index.ts @@ -2,12 +2,16 @@ import type { IPlugin, PluginContext, IncomingMessage, HookContext, HookResult, import { ChatwootClient } from './chatwoot-client.ts'; import { MappingStore, SEEN_TTL_MS, SEEN_PRUNE_INTERVAL_MS } from './mapping-store.ts'; import { KeyedAsyncLock } from './chat-lock.ts'; -import { handleInbound, relayInbound } from './inbound.ts'; +import { handleInbound, relayInbound, MAX_BACKFILL_ATTEMPTS } from './inbound.ts'; import { handleSent } from './sent.ts'; import { backfillAllChats } from './backfill.ts'; import { handleOutbound } from './outbound.ts'; import { drainRetries, RETRY_INTERVAL_MS, MAX_RETRY_ATTEMPTS, MAX_PENDING_RETRIES } from './retry.ts'; +// Observer band. Must run before any responder: a responder returning {continue:false} ends the chain, +// and at the default priority of 100 this plugin would simply stop seeing claimed messages. +const HOOK_PRIORITY = 20; + interface ChatwootFullConfig { baseUrl: string; apiToken: string; @@ -66,10 +70,17 @@ export default class ChatwootAdapter implements IPlugin { // rejecting a write because the plugin is at its storage quota. Counted separately from dead-lettering: // a dead letter was at least retried MAX_RETRY_ATTEMPTS times, this one never got a single attempt. private lostCount = 0; + // Chats whose history import burned MAX_BACKFILL_ATTEMPTS. In-memory like the other health counters: + // the durable state that actually stops the retries lives on each chat's mapping document. + private backfillExhausted = new Set(); private draining = false; private lastSeenPruneAt = 0; private seenPruning = false; + private onBackfillExhausted = (chatId: string): void => { + this.backfillExhausted.add(chatId); + }; + async onEnable(ctx: PluginContext): Promise { this.clearRetryTimer(); // idempotent re-enable: never leak a timer from a prior enable readConfig(ctx.config); // fail fast on the base config @@ -96,43 +107,52 @@ export default class ChatwootAdapter implements IPlugin { this.lostCount++; ctx.logger.error(`inbound message ${msgId} LOST: could not be relayed and could not be queued`, e); }, + onBackfillExhausted: this.onBackfillExhausted, }); - ctx.registerHook('message:received', async (h: HookContext): Promise => { - const sessionId = h.sessionId; - const msg = h.data as IncomingMessage; - if (sessionId && msg) { - const cfg = readConfig(ctx.config); - 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). - void handleInbound(deps, sessionId, h.source, msg).catch(e => ctx.logger.error('inbound hook failed', e)); - // Opt-in one-time bulk history sweep. Fired off the hook (outside handleInbound's per-chat lock), - // guarded internally so it runs once per session; a no-op after the first sweep completes. - if (cfg.backfillAllOnce && cfg.backfillLimit > 0) { - void backfillAllChats(deps, sessionId).catch(e => ctx.logger.error('bulk backfill failed', e)); + ctx.registerHook( + 'message:received', + async (h: HookContext): Promise => { + const sessionId = h.sessionId; + const msg = h.data as IncomingMessage; + if (sessionId && msg) { + const cfg = readConfig(ctx.config); + 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). + void handleInbound(deps, sessionId, h.source, msg).catch(e => ctx.logger.error('inbound hook failed', e)); + // Opt-in one-time bulk history sweep. Fired off the hook (outside handleInbound's per-chat lock), + // guarded internally so it runs once per session; a no-op after the first sweep completes. + if (cfg.backfillAllOnce && cfg.backfillLimit > 0) { + void backfillAllChats(deps, sessionId).catch(e => ctx.logger.error('bulk backfill failed', e)); + } } - } - return { continue: true }; - }); + return { continue: true }; // observer: never claims, see PLUGIN-STANDARD.md + }, + HOOK_PRIORITY, + ); // The account's OWN outbound sends (linked phone / WhatsApp app / OpenWA REST API) arrive on // message:sent, not message:received. Relay them as 'outgoing' so the Chatwoot thread mirrors the full // WhatsApp conversation (#615). The adapter's own Chatwoot-agent replies also surface here but are // echo-suppressed inside handleSent. Gated per-event so a live relayOwnMessages flip applies at once. - ctx.registerHook('message:sent', async (h: HookContext): Promise => { - const sessionId = h.sessionId; - const msg = h.data as IncomingMessage; - if (sessionId && msg) { - const cfg = readConfig(ctx.config); - if (cfg.relayOwnMessages) { - void handleSent(buildDeps(cfg, sessionId), sessionId, h.source, msg).catch(e => - ctx.logger.error('sent hook failed', e), - ); + ctx.registerHook( + 'message:sent', + async (h: HookContext): Promise => { + const sessionId = h.sessionId; + const msg = h.data as IncomingMessage; + if (sessionId && msg) { + const cfg = readConfig(ctx.config); + if (cfg.relayOwnMessages) { + void handleSent(buildDeps(cfg, sessionId), sessionId, h.source, msg).catch(e => + ctx.logger.error('sent hook failed', e), + ); + } } - } - return { continue: true }; - }); + return { continue: true }; // observer: never claims, see PLUGIN-STANDARD.md + }, + HOOK_PRIORITY, + ); ctx.registerWebhook('chatwoot', async (req: WebhookRequest) => handleOutbound( @@ -232,6 +252,12 @@ export default class ChatwootAdapter implements IPlugin { if (this.deadLetterCount > 0) parts.push(`${this.deadLetterCount} dead-lettered after ${MAX_RETRY_ATTEMPTS} attempts`); // Listed last but the most serious: these never reached the queue at all, so `pending` cannot show them. if (this.lostCount > 0) parts.push(`${this.lostCount} LOST (could not be queued — check the plugin storage quota)`); + // Not unhealthy on its own: the live relay is unaffected and the operator may simply have chats the + // engine cannot serve history for. It still has to be visible — the alternative is what this whole + // change exists to remove, a permanent gap nobody is told about. + if (this.backfillExhausted.size > 0) { + parts.push(`${this.backfillExhausted.size} chat(s) gave up on history import after ${MAX_BACKFILL_ATTEMPTS} attempts`); + } return { healthy: this.deadLetterCount === 0 && this.lostCount === 0 && !saturated, message: parts.join('; ') || undefined, diff --git a/chatwoot-adapter/manifest.json b/chatwoot-adapter/manifest.json index e2aeebf..a28386a 100644 --- a/chatwoot-adapter/manifest.json +++ b/chatwoot-adapter/manifest.json @@ -1,7 +1,7 @@ { "id": "chatwoot-adapter", "name": "Chatwoot Adapter", - "version": "0.6.0", + "version": "0.7.0", "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.", @@ -88,7 +88,9 @@ "type": "number", "title": "History backfill (messages per chat)", "default": 0, - "description": "When a chat first opens in Chatwoot, import this many recent messages (both directions, with media) so agents see prior context. 0 disables it. Clamped to 100 host-side. Requires OpenWA 0.8.5+." + "minimum": 0, + "maximum": 100, + "description": "When a chat first opens in Chatwoot, import this many recent messages (both directions) so agents see prior context. 0 disables it. Attachments are imported only at 25 or below — above that the import would exceed the host's 30-second budget and fail, so older media arrives as a placeholder line instead. Clamped to 100 host-side. Requires OpenWA 0.8.5+." }, "backfillAllOnce": { "type": "boolean", diff --git a/chatwoot-adapter/mapping-store.ts b/chatwoot-adapter/mapping-store.ts index 1acbc42..9d540b8 100644 --- a/chatwoot-adapter/mapping-store.ts +++ b/chatwoot-adapter/mapping-store.ts @@ -19,6 +19,16 @@ export interface ChatLink { // Last name synced to the Chatwoot contact. Lets inbound skip a redundant rename and detect when a real // pushName has arrived for a contact first seeded with a bare JID. Absent on pre-0.2.0 rows. name?: string; + // History-import state. Kept on this document rather than in its own key: the host re-measures the + // 50 MiB quota by stat-ing every key on every write, so key COUNT is a per-message cost. `done` and + // `attempts` stay separate on purpose — a retry budget that ran out must never be recorded as a + // completed import, which is precisely how the previous `created`-derived trigger lost history. + // + // Three states, not two. `false` is written at creation and means "eligible for import"; `true` means + // imported; ABSENT means the mapping predates the marker (pre-0.7.0) and must never be imported — that + // chat was already handled by the old scheme, and re-importing it duplicates its whole window. + backfillDone?: boolean; + backfillAttempts?: number; } // Single-document-per-chat mapping over ctx.storage, mirrored into the core ctx.mappings row so the diff --git a/chatwoot-adapter/relay.ts b/chatwoot-adapter/relay.ts index 8a8d7e9..a684dcb 100644 --- a/chatwoot-adapter/relay.ts +++ b/chatwoot-adapter/relay.ts @@ -21,6 +21,9 @@ export interface InboundDeps { // lost. The only way this failure reaches an operator: the retry queue can't count an entry it never // managed to store, so healthCheck would otherwise report green while dropping messages. onInboundLost: (msgId: string, err: unknown) => void; + // Called once when a chat's history import has burned MAX_BACKFILL_ATTEMPTS. Mirrors onInboundLost: + // the durable per-chat counter stops the retries, this makes the give-up visible on healthCheck. + onBackfillExhausted: (chatId: string) => void; } function senderLabel(msg: IncomingMessage): string { @@ -169,6 +172,11 @@ export async function ensureConversation( contactId: contact.id, sourceId: contact.sourceId, name: meta.name, + // "Not yet imported" is written down, never inferred from a missing field. Every mapping an earlier + // release wrote carries no backfill fields at all, so a trigger of `!backfillDone` would read them + // as unimported and replay each chat's whole window into a conversation that was already imported — + // duplicates ahead of the live message, in every open chat, on the first message after an upgrade. + backfillDone: false, }); return conversationId; } diff --git a/chatwoot-adapter/sent.test.ts b/chatwoot-adapter/sent.test.ts index 498e404..45b753b 100644 --- a/chatwoot-adapter/sent.test.ts +++ b/chatwoot-adapter/sent.test.ts @@ -47,7 +47,9 @@ function deps( lock: new KeyedAsyncLock(), client, store, engine, instanceId: 'inst', relayGroups: over.relayGroups ?? true, relayMedia: over.relayMedia ?? true, backfillLimit: 0, backfillAllOnce: false, log: () => {}, - onInboundLost: () => {}, // required on InboundDeps; the cast would hide its absence + // Both callbacks are required on InboundDeps; the cast would hide an omitted one. + onInboundLost: () => {}, + onBackfillExhausted: () => {}, } as unknown as InboundDeps; return { deps: d, counts: () => ({ contacts, convs }), posted, seen }; } diff --git a/faq-bot/CHANGELOG.md b/faq-bot/CHANGELOG.md index bca1fae..da9ab78 100644 --- a/faq-bot/CHANGELOG.md +++ b/faq-bot/CHANGELOG.md @@ -8,6 +8,19 @@ The version here always matches `manifest.json`'s `version`. ## [Unreleased] +## [0.2.0] — 2026-07-31 + +### Fixed + +- **A matched rule or fallback reply 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 a second time. It now claims a message whenever it actually sends a + reply — for a matched rule or the fallback — and never claims when nothing was delivered, so a failed + send or an unmatched message still leaves the next plugin free to answer. +- **This plugin now registers at an explicit responder priority**, after a command prefix and an in-flow + state machine, before a catch-all bot — instead of the registration-order default, which could put it + ahead of or behind another responder depending on enable order. + ## [0.1.8] — 2026-07-30 ### Fixed diff --git a/faq-bot/README.md b/faq-bot/README.md index ab62044..ed9091f 100644 --- a/faq-bot/README.md +++ b/faq-bot/README.md @@ -14,8 +14,8 @@ | Field | Value | | ----- | ----- | | **Identifier** | `faq-bot` | -| **Version** | 0.1.8 | -| **Released** | 2026-07-30 | +| **Version** | 0.2.0 | +| **Released** | 2026-07-31 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | diff --git a/faq-bot/index.test.ts b/faq-bot/index.test.ts index b904536..ea4c1c4 100644 --- a/faq-bot/index.test.ts +++ b/faq-bot/index.test.ts @@ -5,6 +5,99 @@ import { allowCooldown as allowFallback } from './cooldown.ts'; const rules = JSON.stringify([{ mode: 'contains', pattern: 'hi', reply: 'hello' }]); +// Minimal ctx builder shared by the two tests below. The file's regression test further down builds its +// own inline ctx (it needs a live `config` getter); this one only needs static overrides. +function makeCtx(overrides: { + config?: Record; + 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 ?? { rules }, + logger: { log() {}, debug() {}, warn() {}, error() {} }, + registerHook: overrides.registerHook ?? (() => {}), + messages: { + reply: overrides.reply ?? (async () => ({ messageId: 'x', timestamp: 0 })), + sendText: async () => ({ messageId: 'x', timestamp: 0 }), + }, + }; +} + +// Enables a fresh FaqBot with the given (rule-array) config, fires one message:received carrying `body`, +// and returns the {continue} result the host would see. `rules` is stringified here (not by the caller) +// so the test data reads as the plugin's rule objects, not the wire-format JSON string ctx.config expects. +// `onReply` receives every text the plugin actually sent, so a test can tell "claimed after replying" +// from "claimed without replying" — the two are indistinguishable from the {continue} value alone. +async function runHook( + config: { rules: Array<{ mode: string; pattern: string; reply: string }> } & Record, + body: string, + onReply?: (text: string) => void, +) { + const { rules: ruleList, ...rest } = config; + let handler: ((hook: unknown) => Promise<{ continue: boolean }>) | undefined; + const ctx = makeCtx({ + config: { ...rest, rules: JSON.stringify(ruleList) }, + registerHook: (_e, h) => { handler = h as (hook: unknown) => Promise<{ continue: boolean }>; }, + reply: async (_s, _c, _q, text) => { onReply?.(text); return { messageId: 'x', timestamp: 0 }; }, + }); + const { default: FaqBot } = await import('./index.ts'); + 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 }, + }); +} + +// 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 () => { + let priority: number | undefined; + const ctx = makeCtx({ registerHook: (_e, _h, p) => { priority = p; } }); + const { default: FaqBot } = await import('./index.ts'); + await new FaqBot().onEnable(ctx as never); + assert.equal(priority, 80); +}); + +test('claims the message when a rule answered it, passes it on when nothing matched', async () => { + const answered = await runHook({ rules: [{ mode: 'contains', pattern: 'hi', reply: 'hello' }] }, 'hi there'); + assert.equal(answered.continue, false, 'a rule replied — no other bot should answer too'); + + const unmatched = await runHook({ rules: [{ mode: 'contains', pattern: 'hi', reply: 'hello' }] }, 'unrelated'); + assert.equal(unmatched.continue, true, 'nothing was sent — the chain must continue'); +}); + +// The fallback is the path PLUGIN-STANDARD.md's "Known interactions" section publishes a guarantee +// about: it is what makes faq-bot able to take a message away from `after-hours`. Assert BOTH halves — +// a fallback that claimed without having sent anything would silence the chat with nothing to show for it. +test('an unmatched message claims when the fallback answered it', async () => { + const sent: string[] = []; + const result = await runHook( + { rules: [{ mode: 'contains', pattern: 'hi', reply: 'hello' }], fallbackReply: 'Maaf, belum paham.' }, + 'unrelated', // no rule matches, so the fallback is the only thing that can answer + text => sent.push(text), + ); + assert.deepEqual(sent, ['Maaf, belum paham.'], 'the fallback was actually delivered'); + assert.equal(result.continue, false, 'the fallback answered — no other bot should answer too'); +}); + +// 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 reply 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: { rules }, + registerHook: (_e, h) => { handler = h as (hook: unknown) => Promise<{ continue: boolean }>; }, + reply: async () => { throw new Error('blocked by plugin'); }, + }); + const { default: FaqBot } = await import('./index.ts'); + await new FaqBot().onEnable(ctx as never); + const result = await handler!({ + source: 'Engine', sessionId: 's1', timestamp: new Date(), + data: { id: 'm1', chatId: 'c@x', body: 'hi there', fromMe: false, isGroup: false }, + }); + assert.equal(result.continue, true, 'the send failed — a later plugin may still have an answer'); +}); + test('parseConfig requires rules', () => { assert.throws(() => parseConfig({}), /rules is required/); assert.throws(() => parseConfig({ rules: ' ' }), /rules is required/); diff --git a/faq-bot/index.ts b/faq-bot/index.ts index 6c0f714..025de0a 100644 --- a/faq-bot/index.ts +++ b/faq-bot/index.ts @@ -39,15 +39,20 @@ export function parseConfig(raw: Record): { }; } +// Responder band: keyword rules are more specific than a bot that answers everything, less specific than +// a command prefix. +const HOOK_PRIORITY = 80; + export default class FaqBot implements IPlugin { private readonly fallbackAt = new Map(); async onEnable(ctx: PluginContext): Promise { this.warnSkipped(ctx); // fail-fast + surface any invalid regex rules at enable - 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): Promise { @@ -65,10 +70,12 @@ export default class FaqBot implements IPlugin { } } - private async onMessage(ctx: PluginContext, hook: HookContext): Promise { - if (hook.source !== 'Engine' || !hook.sessionId) return; + // Returns true when this plugin answered, so the hook can claim the message and stop another bot from + // answering the same thing. Every early exit means "not mine". + private async onMessage(ctx: PluginContext, hook: HookContext): Promise { + if (hook.source !== 'Engine' || !hook.sessionId) return false; const m = (hook.data ?? {}) as Partial; - 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. @@ -77,27 +84,29 @@ export default class FaqBot implements IPlugin { cfg = parseConfig(ctx.config); } catch (e) { ctx.logger.warn(`faq-bot: skipping message, config invalid: ${e instanceof Error ? e.message : String(e)}`); - return; + return false; } - if (m.isGroup && !cfg.config.respondInGroups) return; + if (m.isGroup && !cfg.config.respondInGroups) return false; const sessionId = hook.sessionId; const rule = matchRule(cfg.rules, m.body); try { if (rule) { await ctx.messages.reply(sessionId, m.chatId, m.id, rule.reply); - return; + return true; } if (cfg.config.fallbackReply) { const key = `${sessionId}:${m.chatId}`; const cooldownMs = Math.max(0, cfg.config.fallbackCooldownSec) * 1000; if (allowCooldown(this.fallbackAt, key, Date.now(), cooldownMs)) { await ctx.messages.reply(sessionId, m.chatId, m.id, cfg.config.fallbackReply); + return true; } } } catch (err) { ctx.logger.error('faq-bot: reply failed', err); } + return false; // nothing delivered — a later plugin may still have an answer } } diff --git a/faq-bot/manifest.json b/faq-bot/manifest.json index 1e6340e..6873163 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.1.8", + "version": "0.2.0", "type": "extension", "main": "dist/index.js", "description": "Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules.", diff --git a/group-translate/CHANGELOG.md b/group-translate/CHANGELOG.md index 54a3713..006e1f0 100644 --- a/group-translate/CHANGELOG.md +++ b/group-translate/CHANGELOG.md @@ -8,6 +8,20 @@ The version here always matches `manifest.json`'s `version`. ## [Unreleased] +## [1.1.0] — 2026-07-31 + +### Fixed + +- **A message could go untranslated entirely if another auto-reply plugin was also installed.** This + plugin ran at the default hook priority — the same one an auto-reply plugin claiming its own messages + runs at by default — so whether this plugin ever got a chance to translate a given message depended on + which plugin happened to register first, which could change across a restart or re-enable. It now + registers in the transformer band, ahead of every responder, so it translates an eligible message + before any responder can answer it, instead of racing them for registration order. Note this only + affects when the translation runs: a translated message is still passed on afterward, so a co-installed + auto-reply plugin can still see and answer the original text. This plugin claims only its own `/tr` + admin commands, never a translated conversational message. + ## [1.0.7] — 2026-07-30 ### Fixed diff --git a/group-translate/README.md b/group-translate/README.md index c92006c..fe662b2 100644 --- a/group-translate/README.md +++ b/group-translate/README.md @@ -14,8 +14,8 @@ | Field | Value | | ----- | ----- | | **Identifier** | `group-translate` | -| **Version** | 1.0.7 | -| **Released** | 2026-07-30 | +| **Version** | 1.1.0 | +| **Released** | 2026-07-31 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | diff --git a/group-translate/index.test.ts b/group-translate/index.test.ts index b1e5b74..b1d4710 100644 --- a/group-translate/index.test.ts +++ b/group-translate/index.test.ts @@ -9,8 +9,8 @@ import type { } from "../types/openwa"; import { TranslationPlugin } from "./index.ts"; -function makeStorage() { - const m = new Map(); +function makeStorage(seed: Record = {}) { + const m = new Map(Object.entries(seed)); return { get: async (k: string) => (m.has(k) ? m.get(k) : null), set: async (k: string, v: unknown) => void m.set(k, v), @@ -19,11 +19,20 @@ function makeStorage() { }; } -function fakeContext(config: Record) { +function fakeContext( + config: Record, + over: { + net?: { fetch: (url: string, init?: unknown) => Promise }; + messages?: Record; + engine?: Record; + seed?: Record; + } = {}, +) { let hook: | ((ctx: HookContext) => Promise) | undefined; - const net = { + let priority: number | undefined; + const net = over.net ?? { fetch: async () => ({ ok: true, @@ -38,19 +47,21 @@ function fakeContext(config: Record) { manifest: { id: "group-translate" }, config, logger: { log() {}, debug() {}, warn() {}, error() {} }, - storage: makeStorage(), + storage: makeStorage(over.seed), registerHook: ( _event: string, handler: (c: HookContext) => Promise, + p?: number, ) => { hook = handler; + priority = p; }, - messages: {}, - engine: {}, + messages: over.messages ?? {}, + engine: over.engine ?? {}, net, hookManager: {}, } as unknown as PluginContext; - return { ctx, getHook: () => hook }; + return { ctx, getHook: () => hook, getPriority: () => priority }; } const engineCtx = ( @@ -75,6 +86,98 @@ const engineCtx = ( } as IncomingMessage, }); +// Transformer band (PLUGIN-STANDARD.md "Co-installation"): must run ahead of every responder, or a +// responder answers the untranslated original before this plugin's translation replaces it. +test("registers in the transformer band, ahead of every responder", async () => { + const { ctx, getPriority } = fakeContext({}); + const plugin = new TranslationPlugin(); + await plugin.onEnable(ctx); + assert.equal(getPriority(), 50); +}); + +// ── Claim wiring: the coordinator's `swallow` must reach the host as `continue` ────────────────────── +// The coordinator suites pin `{swallow: true/false}`, but nothing pinned that this plugin forwards it. +// The README, the CHANGELOG and PLUGIN-STANDARD.md all publish the resulting behavior — "claims only its +// own /tr admin commands, never a translated conversational message" — so a hook that hardcoded +// `continue: true` (or `false`) would make three public documents false with every test still green. + +const GROUP_KEY = "group:s1:group@g.us"; +const AUTHOR = "x@s.whatsapp.net"; + +test("claims a /tr admin command, so no responder answers a message addressed to this plugin", async () => { + const sent: string[] = []; + const { ctx, getHook } = fakeContext( + {}, + { + seed: { + [GROUP_KEY]: { + sessionId: "s1", + chatId: "group@g.us", + active: false, + participants: {}, + delegatedControllers: [], + announced: true, // already greeted, so the only send here is the command's own confirmation + }, + }, + messages: { sendText: async (_s: string, _c: string, t: string) => void sent.push(t) }, + engine: { + getGroupInfo: async () => ({ participants: [{ id: AUTHOR, isAdmin: true }] }), + }, + }, + ); + const plugin = new TranslationPlugin(); + await plugin.onEnable(ctx); + + const result = await getHook()!(engineCtx({ body: "/tr on" })); + assert.equal(sent.length, 1, "the command was handled (its confirmation was sent)"); + assert.equal(result.continue, false, "a control message addressed to this plugin is claimed"); +}); + +test("does NOT claim a translated conversational message — a co-installed responder still sees it", async () => { + const replies: string[] = []; + const { ctx, getHook } = fakeContext( + {}, + { + 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, + }, + }, + net: { + fetch: async (url: string) => + ({ + 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 result = await getHook()!(engineCtx({ body: "hello world" })); + assert.equal(replies.length, 1, "the message really was translated (not skipped into a trivial pass)"); + assert.match(replies[0], /halo dunia/); + assert.equal(result.continue, true, "conversational content is passed on, translated or not"); +}); + // 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 da7a34f..13d5710 100644 --- a/group-translate/index.ts +++ b/group-translate/index.ts @@ -23,6 +23,11 @@ import { LibreTranslateClient } from "./libretranslate.client"; import { PluginChatGateway } from "./plugin-chat.gateway"; import { PluginConfigStore } from "./plugin-config.store"; +// Transformer band. This plugin claims only its own /tr admin commands — a control message addressed to +// the plugin, not conversational content. A translated message is never claimed and still reaches any +// responder registered after it. +const HOOK_PRIORITY = 50; + function readString( cfg: Record, key: string, @@ -74,8 +79,10 @@ export class TranslationPlugin implements IPlugin { onEnable(context: PluginContext): Promise { this.coordinator = this.buildCoordinator(context); this.coordinatorSignature = this.configSignature(context.config); - context.registerHook("message:received", (ctx) => - this.onMessage(context, ctx as HookContext), + context.registerHook( + "message:received", + (ctx) => this.onMessage(context, ctx as HookContext), + HOOK_PRIORITY, ); context.logger.log("Translation plugin enabled", { action: "translation_enabled", diff --git a/group-translate/manifest.json b/group-translate/manifest.json index b6d643b..4cc83a8 100644 --- a/group-translate/manifest.json +++ b/group-translate/manifest.json @@ -1,7 +1,7 @@ { "id": "group-translate", "name": "Group Auto-Translation", - "version": "1.0.7", + "version": "1.1.0", "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.", diff --git a/gsheets-logger/CHANGELOG.md b/gsheets-logger/CHANGELOG.md index 1ccc1d7..c24d9df 100644 --- a/gsheets-logger/CHANGELOG.md +++ b/gsheets-logger/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this plugin adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The version here always matches `manifest.json`'s `version`. +## [0.3.2] — 2026-07-31 + +### Fixed + +- **Logged rows could go missing when another plugin was also installed.** This plugin registered at the + default hook priority, tied with (or behind) plugins that end the event chain for the message they + handle. It now registers early, at observer priority, so it always sees a message before anything else + decides that message is spoken for. + ## [0.3.1] — 2026-07-30 ### Fixed diff --git a/gsheets-logger/README.md b/gsheets-logger/README.md index 10c2fb3..f83aacf 100644 --- a/gsheets-logger/README.md +++ b/gsheets-logger/README.md @@ -14,8 +14,8 @@ | Field | Value | | ----- | ----- | | **Identifier** | `gsheets-logger` | -| **Version** | 0.3.1 | -| **Released** | 2026-07-30 | +| **Version** | 0.3.2 | +| **Released** | 2026-07-31 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | diff --git a/gsheets-logger/index.test.ts b/gsheets-logger/index.test.ts index d1a84db..e6cd237 100644 --- a/gsheets-logger/index.test.ts +++ b/gsheets-logger/index.test.ts @@ -120,3 +120,21 @@ test('onConfigChange drains the buffer to the old client before swapping', async assert.deepEqual(sentToOld, [['old-row']]); // buffered row went to the OLD client assert.equal(harness.buffer.length, 0); // buffer drained before the swap }); + +// Observer band (PLUGIN-STANDARD.md "Co-installation"): must run before any responder, or a claiming +// responder (chat-flow, group-translate) silently ends the chain before the logger ever sees the message. +test('logs at observer priority so a responder claim can never starve it', async () => { + const registered: Array<{ event: string; priority?: number }> = []; + const fakeCtx = { + config: { spreadsheetId: 'sid', serviceAccountJson: validSa }, + net: { fetch: async () => ({ ok: true, status: 200, body: '{}' }) }, + storage: { get: async () => null, set: async () => {} }, + logger: { log: () => {}, warn: () => {}, error: () => {} }, + registerHook: (event: string, _h: unknown, priority?: number) => void registered.push({ event, priority }), + }; + const logger = new GSheetsLogger(); + await logger.onEnable(fakeCtx as unknown as never); + await logger.onUnload(); // stop the flush interval started by onEnable + assert.equal(registered.length, 4); + assert.ok(registered.every(r => r.priority === 10), 'every logged event registers at 10'); +}); diff --git a/gsheets-logger/index.ts b/gsheets-logger/index.ts index 190541a..05d4a42 100644 --- a/gsheets-logger/index.ts +++ b/gsheets-logger/index.ts @@ -6,6 +6,10 @@ const LOGGED_EVENTS: HookEvent[] = ['message:received', 'message:sent', 'message const BUFFER_KEY = 'buffer'; const MAX_BUFFER = 5000; +// Observer band. Must run before any responder: a responder returning {continue:false} ends the chain, +// and at the default priority of 100 this plugin would simply stop seeing claimed messages. +const HOOK_PRIORITY = 10; + // Baked from manifest.json at build time by package.mjs (esbuild `define`). The sandbox does not pass // `manifest` into ctx, so this is how the plugin knows its own version at runtime. Falls back to a dev // marker when run un-bundled (e.g. the test runner). @@ -86,10 +90,14 @@ export default class GSheetsLogger implements IPlugin { if (Array.isArray(restored)) this.buffer = restored; for (const event of LOGGED_EVENTS) { - ctx.registerHook(event, async (hook: HookContext) => { - this.enqueue(hook); - return { continue: true }; - }); + ctx.registerHook( + event, + async (hook: HookContext) => { + this.enqueue(hook); + return { continue: true }; // observer: never claims, see PLUGIN-STANDARD.md + }, + HOOK_PRIORITY, + ); } this.startTimer(config.flushIntervalSec); ctx.logger.log(`gsheets-logger v${PLUGIN_VERSION} enabled → sheet ${config.spreadsheetId} (tab "${config.sheetTab}")`); diff --git a/gsheets-logger/manifest.json b/gsheets-logger/manifest.json index 16d0e0a..6749afb 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.1", + "version": "0.3.2", "type": "extension", "main": "dist/index.js", "description": "Logs WhatsApp message events to a Google Sheet via a service account.", diff --git a/http-action/CHANGELOG.md b/http-action/CHANGELOG.md index 6c134b3..d9e887b 100644 --- a/http-action/CHANGELOG.md +++ b/http-action/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to HTTP Action Bot are listed here. Versions follow [Semantic Versioning](https://semver.org/), and the top entry's version must match `manifest.json`. +## [0.2.0] — 2026-07-31 + +### Fixed + +- **A command could draw a reply from another auto-reply plugin too.** This plugin returns before its + action finishes running, so it never claimed the message — meaning any co-installed bot behind it in + the hook chain would also answer the same command. It now claims a message as soon as it matches one of + the configured commands, so a command addressed to this plugin only ever gets this plugin's answer. +- **This plugin now registers first among responders.** A command prefix is the most specific trigger a + responder can have, so it now runs ahead of keyword bots and flows, at an explicit priority instead of + the registration-order default. + ## [0.1.2] — 2026-07-30 ### Fixed diff --git a/http-action/README.md b/http-action/README.md index 9724b21..5ee3470 100644 --- a/http-action/README.md +++ b/http-action/README.md @@ -14,8 +14,8 @@ | Field | Value | | ----- | ----- | | **Identifier** | `http-action` | -| **Version** | 0.1.2 | -| **Released** | 2026-07-30 | +| **Version** | 0.2.0 | +| **Released** | 2026-07-31 | | **Status** | beta | | **Author** | Yudhi Armyndharis | | **License** | MIT | diff --git a/http-action/index.test.ts b/http-action/index.test.ts index cc1c22a..ba33f30 100644 --- a/http-action/index.test.ts +++ b/http-action/index.test.ts @@ -1,9 +1,9 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { handleMessage, type HandleDeps } from './index.ts'; +import HttpAction, { handleMessage, type HandleDeps } from './index.ts'; import { readConfig } from './config.ts'; import { hasSeen, type StorageLike } from './reliability.ts'; -import type { IncomingMessage } from '../types/openwa'; +import type { IncomingMessage, HookHandler } from '../types/openwa'; function cfgWith(over: Record = {}) { return readConfig({ @@ -35,6 +35,58 @@ const msg = (body: string, id = 'm1'): IncomingMessage => ({ body, type: 'text', timestamp: 0, fromMe: false, isGroup: false, }) as IncomingMessage; +// Minimal PluginContext-shaped ctx for the priority/claim tests below — the file's other tests exercise +// handleMessage directly via HandleDeps, so this is the only place onEnable itself needs to run. +function makeCtx(overrides: { + config?: Record; + registerHook?: (event: string, handler: HookHandler, priority?: number) => void; +} = {}) { + return { + config: overrides.config ?? { + baseUrl: 'https://api.example.com', + actions: JSON.stringify([{ + id: 'stock', match: { type: 'prefix', value: '/stock' }, + request: { method: 'GET', path: '/stock/{{args.0}}' }, + replyTemplate: 'Stock: {{response.status}}', + }]), + }, + logger: { log() {}, debug() {}, warn() {}, error() {} }, + storage: fakeStore(), + net: { fetch: async () => ({ ok: true, status: 200, statusText: 'OK', headers: {}, body: '{}' }) }, + conversations: { send: async () => {} }, + registerHook: overrides.registerHook ?? (() => {}), + }; +} + +// 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) { + 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), + }); +} + +// ── Co-installation: claim + priority (PLUGIN-STANDARD.md) ───────────────────────────────────────── + +test('registers first among responders', async () => { + let priority: number | undefined; + const ctx = makeCtx({ registerHook: (_e, _h, p) => { priority = p; } }); + await new HttpAction().onEnable(ctx as never); + assert.equal(priority, 70); +}); + +test('claims a message that matches a configured command, passes anything else on', async () => { + const matched = await runHook('/stock ABC'); + assert.equal(matched.continue, false, 'the command is addressed to this plugin'); + + const other = await runHook('good morning'); + assert.equal(other.continue, true, 'no command matched — let another bot answer'); +}); + interface Opts { body?: string; status?: number; ok?: boolean; reject?: boolean; now?: () => number } function makeDeps(o: Opts = {}) { diff --git a/http-action/index.ts b/http-action/index.ts index 57ed1a6..ca66837 100644 --- a/http-action/index.ts +++ b/http-action/index.ts @@ -13,6 +13,10 @@ const REPLY_MAX = 4000; const DEFAULT_NOT_FOUND = 'Not found.'; const DEFAULT_ERROR = 'Service is temporarily unavailable. Please try again later.'; +// Responder band, first: a command prefix is the most specific trigger any of these plugins has, so a +// message addressed to it should never also be answered by a keyword bot or a flow. +const HOOK_PRIORITY = 70; + /** Dependencies handleMessage needs, injected so the per-message logic tests without OpenWA. */ export interface HandleDeps { cfg: HttpActionConfig; @@ -173,8 +177,15 @@ export default class HttpActionPlugin implements IPlugin { } if (msg.isGroup && !liveCfg.respondInGroups) return { continue: true }; - // Off-dispatch (§1.2 #2): return {continue:true} synchronously and float handleMessage, so a slow - // or blocked upstream never stalls the WA hook (the ~5s host hook budget is sync-return only). + // Decide ownership SYNCHRONOUSLY, before floating the request. The work runs off-dispatch to stay + // inside the ~5 s hook budget, so the outcome is not knowable here — but whether the message is + // addressed to this plugin is, and that is what a claim means (see PLUGIN-STANDARD.md). + // handleMessage resolves the trigger with this same call (:75), so the claim and the reply can + // never disagree about which messages belong to this plugin. + const mine = matchAction(liveCfg.actions, msg.body) !== null; + + // Off-dispatch (§1.2 #2): return synchronously and float handleMessage, so a slow or blocked + // upstream never stalls the WA hook. void handleMessage( { cfg: liveCfg, @@ -188,8 +199,8 @@ export default class HttpActionPlugin implements IPlugin { sessionId, msg, ).catch((e) => ctx.logger.error(`${PLUGIN}: handler failed`, e)); - return { continue: true }; - }); + return { continue: !mine }; + }, HOOK_PRIORITY); 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 02376a2..5e8d716 100644 --- a/http-action/manifest.json +++ b/http-action/manifest.json @@ -1,7 +1,7 @@ { "id": "http-action", "name": "HTTP Action Bot", - "version": "0.1.2", + "version": "0.2.0", "type": "extension", "main": "dist/index.js", "description": "Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat.", diff --git a/plugins.json b/plugins.json index 3528e06..60b5f02 100644 --- a/plugins.json +++ b/plugins.json @@ -2,7 +2,7 @@ { "id": "after-hours", "name": "After-Hours Auto-Reply", - "version": "0.1.4", + "version": "0.2.0", "type": "extension", "status": "stable", "description": "Auto-replies with a configurable away/closing message to messages received outside business hours.", @@ -18,11 +18,11 @@ ], "minOpenWAVersion": "0.7.0", "testedOpenWAVersion": "0.8.1", - "releasedAt": "2026-07-30", + "releasedAt": "2026-07-31", "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.1.4/after-hours.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/after-hours-v0.2.0/after-hours.zip", "i18n": { "es": { "name": "Respuesta Automática Fuera de Horario", @@ -197,7 +197,7 @@ { "id": "chat-flow", "name": "Chat Flow", - "version": "1.0.8", + "version": "1.1.0", "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.", @@ -214,11 +214,11 @@ ], "minOpenWAVersion": "0.7.0", "testedOpenWAVersion": "0.8.1", - "releasedAt": "2026-07-30", + "releasedAt": "2026-07-31", "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.0.8/chat-flow.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chat-flow-v1.1.0/chat-flow.zip", "i18n": { "es": { "name": "Flujo de Chat", @@ -369,7 +369,7 @@ { "id": "chatwoot-adapter", "name": "Chatwoot Adapter", - "version": "0.6.0", + "version": "0.7.0", "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.", @@ -387,11 +387,11 @@ ], "minOpenWAVersion": "0.8.7", "testedOpenWAVersion": "0.10.5", - "releasedAt": "2026-07-30", + "releasedAt": "2026-07-31", "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.6.0/chatwoot-adapter.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chatwoot-adapter-v0.7.0/chatwoot-adapter.zip", "i18n": { "es": { "name": "Adaptador de Chatwoot", @@ -662,7 +662,7 @@ { "id": "faq-bot", "name": "FAQ / Auto-Reply Bot", - "version": "0.1.8", + "version": "0.2.0", "type": "extension", "status": "stable", "description": "Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules.", @@ -678,11 +678,11 @@ ], "minOpenWAVersion": "0.6.1", "testedOpenWAVersion": "0.8.1", - "releasedAt": "2026-07-30", + "releasedAt": "2026-07-31", "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.1.8/faq-bot.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/faq-bot-v0.2.0/faq-bot.zip", "i18n": { "es": { "name": "Bot de Preguntas Frecuentes / Respuesta Automática", @@ -833,7 +833,7 @@ { "id": "group-translate", "name": "Group Auto-Translation", - "version": "1.0.7", + "version": "1.1.0", "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.", @@ -849,11 +849,11 @@ ], "minOpenWAVersion": "0.7.0", "testedOpenWAVersion": "0.8.1", - "releasedAt": "2026-07-30", + "releasedAt": "2026-07-31", "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.0.7/group-translate.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/group-translate-v1.1.0/group-translate.zip", "i18n": { "es": { "name": "Traducción Automática de Grupos", @@ -1076,7 +1076,7 @@ { "id": "gsheets-logger", "name": "Google Sheets Logger", - "version": "0.3.1", + "version": "0.3.2", "type": "extension", "status": "stable", "description": "Logs WhatsApp message events to a Google Sheet via a service account.", @@ -1092,11 +1092,11 @@ ], "minOpenWAVersion": "0.7.0", "testedOpenWAVersion": "0.8.1", - "releasedAt": "2026-07-30", + "releasedAt": "2026-07-31", "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.1/gsheets-logger.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/gsheets-logger-v0.3.2/gsheets-logger.zip", "i18n": { "es": { "name": "Registrador en Google Sheets", @@ -1271,7 +1271,7 @@ { "id": "http-action", "name": "HTTP Action Bot", - "version": "0.1.2", + "version": "0.2.0", "type": "extension", "status": "beta", "description": "Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat.", @@ -1287,11 +1287,11 @@ ], "minOpenWAVersion": "0.8.0", "testedOpenWAVersion": "0.12.1", - "releasedAt": "2026-07-30", + "releasedAt": "2026-07-31", "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.1.2/http-action.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/http-action-v0.2.0/http-action.zip", "i18n": { "es": { "name": "Bot de acciones HTTP", @@ -1711,7 +1711,7 @@ { "id": "typebot-connector", "name": "Typebot Connector", - "version": "0.1.1", + "version": "0.2.0", "type": "extension", "status": "beta", "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.", @@ -1729,11 +1729,11 @@ ], "minOpenWAVersion": "0.8.2", "testedOpenWAVersion": "0.12.1", - "releasedAt": "2026-07-30", + "releasedAt": "2026-07-31", "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.1.1/typebot-connector.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/typebot-connector-v0.2.0/typebot-connector.zip", "i18n": { "es": { "name": "Conector de Typebot", @@ -1956,7 +1956,7 @@ { "id": "voice-transcription", "name": "Voice Note Transcription", - "version": "1.1.0", + "version": "1.2.0", "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.", @@ -1974,11 +1974,11 @@ ], "minOpenWAVersion": "0.7.0", "testedOpenWAVersion": "0.8.1", - "releasedAt": "2026-07-30", + "releasedAt": "2026-07-31", "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.1.0/voice-transcription.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/voice-transcription-v1.2.0/voice-transcription.zip", "i18n": { "es": { "name": "Transcripción de Notas de Voz", diff --git a/typebot-connector/CHANGELOG.md b/typebot-connector/CHANGELOG.md index 36d37d3..d602336 100644 --- a/typebot-connector/CHANGELOG.md +++ b/typebot-connector/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to the Typebot Connector plugin are documented here. The for ## [Unreleased] +## [0.2.0] — 2026-07-31 + +### Fixed + +- **A chat in scope for this bot could also draw a reply from another auto-reply plugin.** This plugin + returns before the flow turn finishes running, so it never claimed the message — meaning any + co-installed bot behind it in the hook chain would also answer. It now claims a chat as soon as it + determines the chat is in scope (the same check `handleTurn` re-applies before acting), so an outage on + the Typebot side produces silence for that message rather than a different plugin answering on this + bot's behalf. +- **This plugin now registers at an explicit responder priority**, after a command prefix and any + keyword-based bot, since it auto-starts a flow for every in-scope chat and should not pre-empt a more + specific trigger. + ## [0.1.1] — 2026-07-30 First release verified against a live server: a self-hosted Typebot v3.17.2 driven over real WhatsApp diff --git a/typebot-connector/README.md b/typebot-connector/README.md index c977c98..f2eb0af 100644 --- a/typebot-connector/README.md +++ b/typebot-connector/README.md @@ -14,8 +14,8 @@ | Field | Value | | ----- | ----- | | **Identifier** | `typebot-connector` | -| **Version** | 0.1.1 | -| **Released** | 2026-07-30 | +| **Version** | 0.2.0 | +| **Released** | 2026-07-31 | | **Status** | beta | | **Author** | Yudhi Armyndharis | | **License** | MIT | diff --git a/typebot-connector/index.test.ts b/typebot-connector/index.test.ts index e81d965..a68beeb 100644 --- a/typebot-connector/index.test.ts +++ b/typebot-connector/index.test.ts @@ -16,14 +16,14 @@ test('readConfig: defaults, normalization, and fail-fast', () => { }); test('onEnable registers a message:received hook that returns {continue:true}', async () => { - let registered: { event: string; handler: HookHandler } | undefined; + let registered: { event: string; handler: HookHandler; priority?: number } | undefined; const ctx = { config: { publicId: 'bot', apiHost: 'https://typebot.io' }, logger: { log() {}, debug() {}, warn() {}, error() {} }, storage: { get: async () => null, set: async () => {}, delete: async () => {}, list: async () => [] }, net: { fetch: async () => ({ ok: true, status: 200, headers: {}, body: '{}' }) }, conversations: { send: async () => {} }, - registerHook: (event: string, handler: HookHandler) => void (registered = { event, handler }), + registerHook: (event: string, handler: HookHandler, priority?: number) => void (registered = { event, handler, priority }), } as unknown as PluginContext; await new Plugin().onEnable(ctx); @@ -32,15 +32,15 @@ test('onEnable registers a message:received hook that returns {continue:true}', assert.deepEqual(result, { continue: true }); }); -test('message:received hook returns {continue:true} without awaiting a hanging Typebot turn', async () => { - let registered: { event: string; handler: HookHandler } | undefined; +test('message:received hook resolves synchronously (claiming an in-scope chat) without awaiting a hanging Typebot turn', async () => { + let registered: { event: string; handler: HookHandler; priority?: number } | undefined; const ctx = { config: { publicId: 'bot', apiHost: 'https://typebot.io' }, logger: { log() {}, debug() {}, warn() {}, error() {} }, storage: { get: async () => null, set: async () => {}, delete: async () => {}, list: async () => [] }, net: { fetch: () => new Promise(() => {}) }, conversations: { send: async () => {} }, - registerHook: (event: string, handler: HookHandler) => void (registered = { event, handler }), + registerHook: (event: string, handler: HookHandler, priority?: number) => void (registered = { event, handler, priority }), } as unknown as PluginContext; await new Plugin().onEnable(ctx); @@ -54,5 +54,61 @@ test('message:received hook returns {continue:true} without awaiting a hanging T timestamp: 0, fromMe: false, isGroup: false, }, } as unknown as HookContext; - assert.deepEqual(await registered!.handler(populated), { continue: true }); + // In scope (real Engine message, not our own, has a chat) — claimed even though net.fetch never + // resolves, proving the claim is decided before the floated handleTurn call, not after it. + assert.deepEqual(await registered!.handler(populated), { continue: false }); +}); + +// ── Co-installation: claim + priority (PLUGIN-STANDARD.md) ───────────────────────────────────────── + +// Minimal ctx builder for the tests below — the two tests above build their own inline ctx per case (a +// live vs. a hanging net.fetch); this one only needs a valid config + an overridable registerHook. +function makeCtx(overrides: { + config?: Record; + registerHook?: (event: string, handler: HookHandler, priority?: number) => void; +} = {}) { + return { + config: overrides.config ?? { publicId: 'bot', apiHost: 'https://typebot.io' }, + logger: { log() {}, debug() {}, warn() {}, error() {} }, + storage: { get: async () => null, set: async () => {}, delete: async () => {}, list: async () => [] }, + net: { fetch: async () => ({ ok: true, status: 200, headers: {}, body: '{}' }) }, + conversations: { send: async () => {} }, + registerHook: overrides.registerHook ?? (() => {}), + }; +} + +// Enables a fresh Plugin and fires one message:received from `source`, returning the synchronous +// {continue} result. The floated handleTurn call is out of scope — never awaited here. +async function runHook(opts: { source: string; isGroup: boolean }) { + let handler: HookHandler | undefined; + const ctx = makeCtx({ registerHook: (_e, h) => { handler = h; } }); + await new Plugin().onEnable(ctx as never); + return handler!({ + event: 'message:received', + source: opts.source, + sessionId: 's1', + timestamp: new Date(), + data: { + id: 'm', from: 'x@c.us', to: 'y', chatId: 'c@c.us', body: 'hi', type: 'chat', + timestamp: 0, fromMe: false, isGroup: opts.isGroup, + }, + }); +} + +test('registers at the typebot responder priority', async () => { + let registered: { event: string; handler: HookHandler; priority?: number } | undefined; + const ctx = makeCtx({ + registerHook: (event: string, handler: HookHandler, priority?: number) => + void (registered = { event, handler, priority }), + }); + await new Plugin().onEnable(ctx as never); + assert.equal(registered?.priority, 85); +}); + +test('claims an in-scope chat and passes an out-of-scope one on', async () => { + const inScopeResult = await runHook({ source: 'Engine', isGroup: false }); + assert.equal(inScopeResult.continue, false, 'this bot owns every chat it is in scope for'); + + const outOfScope = await runHook({ source: 'Webhook', isGroup: false }); + assert.equal(outOfScope.continue, true, 'not from the engine — not ours'); }); diff --git a/typebot-connector/index.ts b/typebot-connector/index.ts index c427862..427b1a8 100644 --- a/typebot-connector/index.ts +++ b/typebot-connector/index.ts @@ -4,6 +4,11 @@ import { KeyedAsyncLock } from './chat-lock.ts'; import { SessionStore } from './session-store.ts'; import { TypebotClient } from './typebot-client.ts'; import { handleTurn } from './turn.ts'; +import { inScope } from './filters.ts'; + +// Responder band, late: this plugin auto-starts a flow for every chat it is in scope for, so it is a +// catch-all brain and should not pre-empt a command or an in-flow menu. +const HOOK_PRIORITY = 85; // Read + validate config. Fail-fast on a bad apiHost so a misconfigured plugin never silently no-ops (the // host net allowlist only admits an https, credential-free host). @@ -79,16 +84,20 @@ export default class TypebotConnector implements IPlugin { .then(n => n && ctx.logger.log(`typebot-connector: pruned ${n} orphaned session row(s)`)) .catch(e => ctx.logger.error('typebot-connector: orphan prune failed', e)); } - // Off-dispatch: return {continue:true} immediately; a slow/failing Typebot call never blocks WA. + // Off-dispatch: return immediately; a slow or failing Typebot call never blocks WA. void handleTurn( { cfg, client, store, lock, conversations: ctx.conversations, now: () => Date.now(), log: (m, e) => ctx.logger.error(m, e) }, sessionId, h.source, msg, ).catch(e => ctx.logger.error('typebot turn failed', e)); + // Claim every chat this bot is in scope for. handleTurn re-checks the same predicate before doing + // anything, so the two can never disagree. A Typebot outage therefore produces silence rather + // than an unrelated plugin answering on this bot's behalf. + return { continue: !inScope(msg, h.source, cfg.respondInGroups) }; } return { continue: true }; - }); + }, HOOK_PRIORITY); ctx.logger.log('typebot-connector enabled'); } diff --git a/typebot-connector/manifest.json b/typebot-connector/manifest.json index e8805a8..7b69684 100644 --- a/typebot-connector/manifest.json +++ b/typebot-connector/manifest.json @@ -1,7 +1,7 @@ { "id": "typebot-connector", "name": "Typebot Connector", - "version": "0.1.1", + "version": "0.2.0", "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.", diff --git a/typebot-connector/upload-e2e.test.ts b/typebot-connector/upload-e2e.test.ts new file mode 100644 index 0000000..dfbb951 --- /dev/null +++ b/typebot-connector/upload-e2e.test.ts @@ -0,0 +1,179 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { + IncomingMessage, + PluginConversationsCapability, + ConversationSendEnvelope, + PluginStorage, + PluginNetRequestInit, + PluginNetResponse, +} from '../types/openwa'; +import type { TypebotConfig } from './typebot-types.ts'; +import { KeyedAsyncLock } from './chat-lock.ts'; +import { SessionStore } from './session-store.ts'; +import { TypebotClient } from './typebot-client.ts'; +import { handleTurn } from './turn.ts'; + +// The REAL client is wired into the REAL turn handler; only the network and the WhatsApp send are faked. +// typebot-client.test.ts covers uploadFile in isolation and turn.test.ts covers the failure branch — the +// gap this file closes is the success path across the seam between them. + +const cfg: TypebotConfig = { + apiHost: 'https://typebot.io', + publicId: 'bot', + respondInGroups: true, + sessionTimeoutMinutes: 30, + passContactVariables: true, +}; + +// A PNG magic number: 8 bytes that are NOT valid UTF-8, so any accidental string round-trip corrupts them. +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function fakeStorage(): PluginStorage { + const m = new Map(); + return { + get: async (k: string) => (m.has(k) ? (m.get(k) as T) : null), + set: async (k, v) => void m.set(k, v), + delete: async k => void m.delete(k), + list: async (p = '') => [...m.keys()].filter(k => k.startsWith(p)), + }; +} + +const ok = (body: unknown): PluginNetResponse => + ({ ok: true, status: 200, headers: {}, body: JSON.stringify(body) }) as PluginNetResponse; +const noBody = (status: number): PluginNetResponse => + ({ ok: true, status, headers: {}, body: '' }) as PluginNetResponse; + +function recorder(responses: PluginNetResponse[]) { + const calls: { url: string; init?: PluginNetRequestInit }[] = []; + let i = 0; + const fetchFn = async (url: string, init?: PluginNetRequestInit) => { + calls.push({ url, init }); + return responses[i++]; + }; + return { fetchFn, calls }; +} + +// Drive one turn: the bot is awaiting a file, the contact sends a PNG. +async function runFileTurn(responses: PluginNetResponse[]) { + const { fetchFn, calls } = recorder(responses); + const sent: ConversationSendEnvelope[] = []; + const conversations: PluginConversationsCapability = { send: async e => void sent.push(e) }; + const store = new SessionStore(fakeStorage()); + await store.set('sess:c@c.us', { + sessionId: 'S1', + awaiting: { kind: 'file', blockId: 'blk' }, + lastActivity: 1000, + }); + const msg = { + id: 'm', + from: 'c@c.us', + to: 'me', + chatId: 'c@c.us', + body: '', + type: 'image', + timestamp: 0, + fromMe: false, + isGroup: false, + media: { mimetype: 'image/png', filename: 'p.png', data: PNG.toString('base64') }, + } as IncomingMessage; + + await handleTurn( + { + cfg, + client: new TypebotClient(fetchFn, cfg), + store, + lock: new KeyedAsyncLock(), + conversations, + now: () => 1000, + log: () => {}, + }, + 'sess', + 'Engine', + msg, + ); + return { calls, sent, store }; +} + +test('file input, proxy branch: PUT raw bytes, then continueChat carries the returned fileUrl', async () => { + const { calls, sent, store } = await runFileTurn([ + ok({ presignedUrl: 'https://typebot.io/api/uploads/tok', formData: {}, fileUrl: 'https://cdn/p.png' }), + noBody(200), + ok({ + sessionId: 'S1', + messages: [{ id: 'm2', type: 'text', content: { type: 'markdown', markdown: 'Got it' } }], + input: { id: 'blk2', type: 'text input' }, + }), + ]); + + // fileSize must be the DECODED byte length. Base64 inflates by 4/3, so a mix-up is silently accepted by + // the type system and rejected by a real presigned URL whose policy pins content-length. + assert.equal(calls[0].url, 'https://typebot.io/api/v3/generate-upload-url'); + const gu = JSON.parse(calls[0].init!.body as string); + assert.equal(gu.fileSize, PNG.length); + assert.notEqual(gu.fileSize, PNG.toString('base64').length); + assert.equal(gu.sessionId, 'S1'); + assert.equal(gu.blockId, 'blk'); + assert.equal(gu.fileName, 'p.png'); + assert.equal(gu.fileType, 'image/png'); + + // The bytes reach the presigned URL intact. + assert.equal(calls[1].url, 'https://typebot.io/api/uploads/tok'); + assert.equal(calls[1].init!.method, 'PUT'); + assert.deepEqual(Buffer.from(calls[1].init!.body as Uint8Array), PNG); + + // continueChat carries fileUrl from generate-upload-url — NOT presignedUrl, which is single-use. + assert.equal(calls[2].url, 'https://typebot.io/api/v1/sessions/S1/continueChat'); + const cont = JSON.parse(calls[2].init!.body as string); + assert.deepEqual(cont.message, { type: 'text', text: '', attachedFileUrls: ['https://cdn/p.png'] }); + + // The next prompt reaches WhatsApp and the advanced state is persisted. + assert.deepEqual(sent.map(s => s.text), ['Got it']); + assert.equal((await store.get('sess:c@c.us'))?.awaiting.blockId, 'blk2'); +}); + +test('file input, S3 branch: multipart POST with every policy field before the file part', async () => { + const { calls, sent } = await runFileTurn([ + ok({ + presignedUrl: 'https://s3.example/bucket', + formData: { key: 'uploads/p.png', policy: 'POLICY', 'x-amz-signature': 'SIG' }, + fileUrl: 'https://cdn/s3.png', + }), + noBody(204), + ok({ + sessionId: 'S1', + messages: [{ id: 'm2', type: 'text', content: { type: 'markdown', markdown: 'Received' } }], + }), + ]); + + const post = calls[1]; + assert.equal(post.url, 'https://s3.example/bucket'); + assert.equal(post.init!.method, 'POST'); + + // latin1 keeps one byte per character, so string offsets are byte offsets. + const raw = Buffer.from(post.init!.body as Uint8Array); + const text = raw.toString('latin1'); + + // The boundary announced in the header must be the one delimiting the body. + const boundary = /boundary=(.+)$/.exec(post.init!.headers!['Content-Type'])![1]; + assert.ok(text.startsWith(`--${boundary}\r\n`)); + assert.ok(text.endsWith(`--${boundary}--\r\n`)); + + // S3 rejects a presigned POST whose `file` part does not come last. + const filePos = text.indexOf('name="file"'); + assert.ok(filePos !== -1); + for (const name of ['key', 'policy', 'x-amz-signature']) { + const pos = text.indexOf(`name="${name}"`); + assert.ok(pos !== -1, `missing policy field ${name}`); + assert.ok(pos < filePos, `policy field ${name} must precede the file part`); + } + + // The attachment's bytes survive assembly unchanged. + const marker = 'Content-Type: image/png\r\n\r\n'; + const start = text.indexOf(marker, filePos) + marker.length; + assert.deepEqual(raw.subarray(start, start + PNG.length), PNG); + + const cont = JSON.parse(calls[2].init!.body as string); + assert.deepEqual(cont.message, { type: 'text', text: '', attachedFileUrls: ['https://cdn/s3.png'] }); + assert.deepEqual(sent.map(s => s.text), ['Received']); +}); diff --git a/voice-transcription/CHANGELOG.md b/voice-transcription/CHANGELOG.md index 4fdc22e..bb010f8 100644 --- a/voice-transcription/CHANGELOG.md +++ b/voice-transcription/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to the Voice Note Transcription plugin are documented here. ## [Unreleased] +## [1.2.0] — 2026-07-31 + +### Fixed + +- **A voice note could go untranscribed if another auto-reply plugin was also installed.** This plugin + ran at the default hook priority — the same one an auto-reply plugin claiming its messages also runs + at by default — so whether this plugin ever saw the voice note at all depended on which plugin happened + to register first, which could change across a restart or re-enable. It now registers in the + transformer band, ahead of every responder, so it sees and transcribes the voice note before any + responder can answer it, instead of racing them for registration order. + ## [1.1.0] — 2026-07-30 ### Added diff --git a/voice-transcription/README.md b/voice-transcription/README.md index 9525c47..4ce804b 100644 --- a/voice-transcription/README.md +++ b/voice-transcription/README.md @@ -14,8 +14,8 @@ | Field | Value | | ----- | ----- | | **Identifier** | `voice-transcription` | -| **Version** | 1.1.0 | -| **Released** | 2026-07-30 | +| **Version** | 1.2.0 | +| **Released** | 2026-07-31 | | **Status** | beta | | **Author** | Yudhi Armyndharis | | **License** | MIT | diff --git a/voice-transcription/index.test.ts b/voice-transcription/index.test.ts index c82ba93..0a56168 100644 --- a/voice-transcription/index.test.ts +++ b/voice-transcription/index.test.ts @@ -45,6 +45,7 @@ function fakeContext(opts: { let hook: | ((ctx: HookContext) => Promise) | undefined; + let priority: number | undefined; const ctx = { pluginId: "voice-transcription", manifest: { id: "voice-transcription" }, @@ -58,15 +59,19 @@ function fakeContext(opts: { registerHook: ( event: string, handler: (c: HookContext) => Promise, + p?: number, ) => { - if (event === "message:received") hook = handler; + if (event === "message:received") { + hook = handler; + priority = p; + } }, messages: {}, engine: {}, net: opts.net, hookManager: {}, } as unknown as PluginContext; - return { ctx, getHook: () => hook }; + return { ctx, getHook: () => hook, getPriority: () => priority }; } const engineCtx = (data: IncomingMessage): HookContext => ({ @@ -101,6 +106,23 @@ test("the message:received hook returns {continue:true} without awaiting STT (no }); }); +// Transformer band (PLUGIN-STANDARD.md "Co-installation"): runs after the observers, before any +// responder. Must never claim — this plugin delivers a transcript out-of-band, so the contact's +// original message still needs whatever bot would otherwise answer it. +test("registers in the transformer band and never claims", async () => { + const net = { fetch: () => new Promise(() => {}) }; + const { ctx, getHook, getPriority } = fakeContext({ net }); + const plugin = new VoiceTranscriptionPlugin(); + await plugin.onEnable(ctx); + assert.equal(getPriority(), 40); + const result = await getHook()!(engineCtx(voiceMsg())); + assert.equal( + result.continue, + true, + "a transcriber must never take the message from a responder", + ); +}); + test("does not start transcription for non-Engine sources", async () => { let fetched = false; const net = { diff --git a/voice-transcription/index.ts b/voice-transcription/index.ts index eb163c8..2c8eeb9 100644 --- a/voice-transcription/index.ts +++ b/voice-transcription/index.ts @@ -23,6 +23,10 @@ import { ChatDeliveryMode, } from "./transcription.coordinator.ts"; +// Transformer band: runs after the observers, before any responder. This plugin delivers a transcript +// out-of-band and must never claim — the contact's message still needs whatever bot would answer it. +const HOOK_PRIORITY = 40; + function readString( cfg: Record, key: string, @@ -84,8 +88,10 @@ export class VoiceTranscriptionPlugin implements IPlugin { this.ctxRef = context; this.coordinator = this.build(context); this.coordinatorSignature = this.configSignature(context.config); - context.registerHook("message:received", (ctx) => - Promise.resolve(this.onMessage(ctx as HookContext)), + context.registerHook( + "message:received", + (ctx) => Promise.resolve(this.onMessage(ctx as HookContext)), + HOOK_PRIORITY, ); if ( !readOptionalString(context.config, "deliveryWebhookUrl") && diff --git a/voice-transcription/manifest.json b/voice-transcription/manifest.json index cd497e1..9c81621 100644 --- a/voice-transcription/manifest.json +++ b/voice-transcription/manifest.json @@ -1,7 +1,7 @@ { "id": "voice-transcription", "name": "Voice Note Transcription", - "version": "1.1.0", + "version": "1.2.0", "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 — so bots and AI can read and reply to audio. Off the message-delivery path; disabled until enabled.",