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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions after-hours/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ The version here always matches `manifest.json`'s `version`.

### Fixed

- **Overnight and all-day windows are accepted.** `22:00-06:00` and `00:00-00:00` are ordinary business
hours, but the parser rejected any window whose open was not before its close — which made the whole
schedule unparseable rather than just that day. The comparison understands a wrapped window now, so an
overnight one is open late and early and closed in between.
- **The send-retry backoff map is now bounded.** An entry is added when a send fails and removed on the
next delivery, so a chat that never messages again left one behind. Every other piece of state in this
plugin was already capped; this was the exception.
Expand Down
14 changes: 13 additions & 1 deletion after-hours/schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ test('parseSchedule rejects bad input', () => {
assert.throws(() => parseSchedule('[]'), /object/i);
assert.throws(() => parseSchedule(JSON.stringify({ xyz: '09:00-17:00' })), /unknown day/i);
assert.throws(() => parseSchedule(JSON.stringify({ mon: '9:00-17:00' })), /HH:MM/i);
assert.throws(() => parseSchedule(JSON.stringify({ mon: '17:00-09:00' })), /before close/i);
assert.throws(() => parseSchedule(JSON.stringify({ mon: null, sun: null })), /no open days/i);
assert.throws(() => parseSchedule(JSON.stringify({ mon: '09:00-17:00-junk' })), /HH:MM/i);
});
Expand Down Expand Up @@ -52,3 +51,16 @@ test('isAfterHours: closed day and the local-midnight edge', () => {
// (also exercises the hour '24' → %24 normalization).
assert.equal(isAfterHours(new Date('2026-06-21T17:00:00Z'), sched, 'Asia/Jakarta'), true);
});

test('an overnight window and an all-day window are accepted and honoured', () => {
// "22:00-06:00" and "00:00-00:00" are ordinary business hours. The parser rejected both, which made
// the whole schedule unparseable rather than just that day, and the comparison only understood a
// window that opens and closes on the same date.
const sch = parseSchedule(JSON.stringify({ mon: '22:00-06:00', wed: '00:00-00:00' }));
const at = (d: number, h: number, m: number) => isAfterHours(new Date(Date.UTC(2026, 7, d, h, m)), sch, 'UTC');
assert.equal(at(10, 23, 20), false, 'late evening is inside an overnight window');
assert.equal(at(10, 5, 0), false, 'early morning is inside an overnight window');
assert.equal(at(10, 10, 0), true, 'mid-morning is outside it');
assert.equal(at(12, 3, 0), false, '00:00-00:00 is open all day');
assert.throws(() => parseSchedule(JSON.stringify({ mon: '09:00-09:00' })), /use 00:00-00:00/);
});
14 changes: 12 additions & 2 deletions after-hours/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ export function parseSchedule(json: string): Schedule {
if (openMin === null || closeMin === null) {
throw new Error(`schedule: ${day} window "${value}" is not "HH:MM-HH:MM"`);
}
if (openMin >= closeMin) throw new Error(`schedule: ${day} open must be before close ("${value}")`);
// An overnight window ("22:00-06:00") and a 24-hour one ("00:00-00:00") are both ordinary business
// hours; rejecting them made the whole schedule unparseable rather than just that day. They are
// stored as-is and interpreted by the comparison, which already handles a wrapped window.
if (openMin === closeMin && value.trim() !== '00:00-00:00') {
throw new Error(`schedule: ${day} open and close are the same ("${value}") — use 00:00-00:00 for all day`);
}
schedule[day] = { openMin, closeMin };
}

Expand Down Expand Up @@ -74,5 +79,10 @@ export function isAfterHours(date: Date, schedule: Schedule, timezone: string):
const minutes = (Number(get('hour')) % 24) * 60 + Number(get('minute'));
const window = day ? schedule[day] : undefined;
if (!window) return true; // closed day (or an unmapped weekday — treat as closed)
return minutes < window.openMin || minutes >= window.closeMin;
// "00:00-00:00" means open all day.
if (window.openMin === window.closeMin) return false;
// A normal window opens and closes on the same day; a wrapped one ("22:00-06:00") opens in the
// evening and closes the next morning, so "inside" is late OR early rather than between.
if (window.openMin < window.closeMin) return minutes < window.openMin || minutes >= window.closeMin;
return minutes < window.openMin && minutes >= window.closeMin;
}
2 changes: 1 addition & 1 deletion chatwoot-adapter/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"status": "stable",
"minOpenWAVersion": "0.8.7",
"testedOpenWAVersion": "0.14.0",
"sdkVersion": "1",
"sdkVersion": 1,
"permissions": ["net:fetch", "conversation:send", "webhook:ingress", "engine:read",
"storage:use"],
"net": { "allow": [], "allowConfigHosts": ["baseUrl"] },
Expand Down
9 changes: 7 additions & 2 deletions chatwoot-adapter/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ export interface OutboundDeps {
}

// Chatwoot → WhatsApp. `conversation_updated` drives handover; a relayable `message_created` is sent to
// WhatsApp under the per-chat lock (deduped on the Chatwoot message id). Throwing surfaces to the ingress
// pipeline for retry/DLReturn; a parse error is a 400 (never retried).
// WhatsApp under the per-chat lock (deduped on the Chatwoot message id).
//
// The returned status is NOT what the provider sees: the host computes that from
// `manifest.ingress[].response.ack` and reads only whether this handler resolved or threw
// (types/openwa.d.ts:296). What the number does control is retry — resolving means "accepted, do not
// retry", throwing means "retry". The values below are kept because they read as intent at each exit,
// not because a 400 reaches Chatwoot.
export async function handleOutbound(deps: OutboundDeps, req: WebhookRequest): Promise<{ status: number }> {
let evt: ChatwootWebhookMessage;
try {
Expand Down
3 changes: 3 additions & 0 deletions gsheets-logger/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ The version here always matches `manifest.json`'s `version`.

### Fixed

- **healthCheck now reports unhealthy while a flush is failing.** The plugin was green whenever a client
existed, so a spreadsheet that had stopped accepting appends looked fine while rows piled up. The last
flush error is surfaced alongside the buffered-row count.
- **Undelivered rows no longer follow a spreadsheet rotation.** `onConfigChange` drains before swapping
the client, but a drain that cannot reach Sheets leaves every row in place — and those rows carry
message content captured under the previous spreadsheet and credentials. They are now parked under
Expand Down
11 changes: 10 additions & 1 deletion gsheets-logger/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ export default class GSheetsLogger implements IPlugin {
private flushingPromise: Promise<void> | null = null;
private ctx: PluginContext | null = null;
private batchSize = 20;
// Last flush failure, surfaced on healthCheck. Green while every append fails is the one state an
// operator must not see: the rows are piling up and nothing says so.
private lastFlushError: string | null = null;

async onEnable(ctx: PluginContext): Promise<void> {
this.ctx = ctx;
Expand Down Expand Up @@ -190,7 +193,11 @@ export default class GSheetsLogger implements IPlugin {
}

async healthCheck(): Promise<{ healthy: boolean; message?: string }> {
return { healthy: this.client !== null, message: `v${PLUGIN_VERSION} — ${this.buffer.length} rows buffered` };
const parts = [`v${PLUGIN_VERSION}`, `${this.buffer.length} rows buffered`];
if (this.lastFlushError) parts.push(`last flush failed: ${this.lastFlushError.slice(0, 200)}`);
// Unhealthy while a flush is failing: the plugin is running, but nothing it was installed to do is
// reaching the sheet, and a green tile is what let that go unnoticed.
return { healthy: this.client !== null && this.lastFlushError === null, message: parts.join(' — ') };
}

private startTimer(intervalSec: number): void {
Expand Down Expand Up @@ -226,8 +233,10 @@ export default class GSheetsLogger implements IPlugin {
this.flushingPromise = (async () => {
try {
await flushBuffer(this.buffer, (rows) => client.appendRows(rows));
this.lastFlushError = null;
await this.ctx?.storage.set(BUFFER_KEY, this.buffer);
} catch (err) {
this.lastFlushError = err instanceof Error ? err.message : String(err);
this.ctx?.logger.error('gsheets-logger: flush failed, will retry next tick', err);
} finally {
this.flushingPromise = null;
Expand Down
2 changes: 1 addition & 1 deletion http-action/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"status": "stable",
"testedOpenWAVersion": "0.14.0",
"minOpenWAVersion": "0.8.0",
"sdkVersion": "1",
"sdkVersion": 1,
"provides": [
"api-automation",
"rest-connector",
Expand Down
14 changes: 14 additions & 0 deletions scripts/catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'it', 'ar', 'he', 'te', 'zh-CN', 'z
// author; the ARTIFACT must come from a repo this project publishes.
const RELEASE_OWNERS = new Set(['rmyndharis']);

// Both taken verbatim from PLUGIN-STANDARD.md — the shape at its manifest example and the reserved
// list at its "Reserved ids" line. Neither was checked anywhere, so a plugin could take an id the
// standard forbids and the catalogue would publish it.
const ID_SHAPE = /^[a-z0-9][a-z0-9._-]*$/i;
const RESERVED_IDS = new Set(['whatsapp-web.js', 'baileys', 'auto-reply', 'translation']);

const HOST_PERMISSIONS = new Set([
'messages:send',
'engine:read',
Expand All @@ -51,6 +57,14 @@ function validateManifest(id, manifest) {
// folder, the zip name, the release tag. `manifest.id` is what the host installs under and what the
// catalogue publishes. If the two disagree, the download URL points at one plugin and the installed
// plugin calls itself another.
// PLUGIN-STANDARD states an id shape and a reserved list; nothing checked either, so both could be
// violated by a plugin that then took a path or a catalogue slot it should not have.
if (!ID_SHAPE.test(manifest.id ?? '')) {
throw new Error(`${id}: manifest.id "${manifest.id}" does not match ${ID_SHAPE} (PLUGIN-STANDARD.md)`);
}
if (RESERVED_IDS.has(manifest.id)) {
throw new Error(`${id}: "${manifest.id}" is a reserved id`);
}
if (manifest.id !== id) {
throw new Error(`${id}: manifest.id is "${manifest.id}" — it must match the directory name`);
}
Expand Down
3 changes: 3 additions & 0 deletions scripts/catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ test('every stable plugin ships a full i18n block', () => {
// a plugin whose name and description translate while all of its config-field titles stay English —
// voice-transcription's `es` did exactly that, and being `beta` also put it outside the stable-only
// test above.
// Scope note: this compares config-field KEYS, which carry `title`. No plugin translates a field's
// `description`, so requiring it here would fail every locale in the catalogue at once. What is
// guaranteed is that a translated locale covers every field's title; the descriptions stay English.
test('a locale that translates a plugin translates all of its config fields', () => {
const root = new URL('../', import.meta.url);
const dirs = readdirSync(root, { withFileTypes: true })
Expand Down
2 changes: 1 addition & 1 deletion supabase-otp-hook/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"status": "beta",
"minOpenWAVersion": "0.8.16",
"testedOpenWAVersion": "0.14.0",
"sdkVersion": "1",
"sdkVersion": 1,
"permissions": ["webhook:ingress", "messages:send"],
"sessionScoped": true,
"sessions": ["*"],
Expand Down
2 changes: 1 addition & 1 deletion typebot-connector/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"status": "stable",
"minOpenWAVersion": "0.8.2",
"testedOpenWAVersion": "0.14.0",
"sdkVersion": "1",
"sdkVersion": 1,
"permissions": ["net:fetch", "conversation:send",
"storage:use"],
"net": { "allow": [], "allowConfigHosts": ["apiHost", "mediaHost"] },
Expand Down
9 changes: 6 additions & 3 deletions types/openwa.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Vendored OpenWA plugin contract. There is no published @openwa SDK package; keep this in sync
// with the OpenWA version you target. All imports of this module must be `import type`.
//
// Last aligned against OpenWA core v0.14.5 (tag), verified field-by-field against
// Last aligned against OpenWA core v0.14.5 (tag). Where a comment below mentions later host
// behaviour, it is describing a change this file has NOT been re-verified against — treat it as a
// note about the host's direction, not as something checked here., verified field-by-field against
// src/core/plugins/plugin.interfaces.ts, src/core/hooks/hook.interfaces.ts, plugin-net.ts,
// sandbox/{worker-bootstrap,worker-capability,worker-hooks,worker-webhooks}.ts and
// src/engine/interfaces/whatsapp-engine.interface.ts. Where this file narrows the host on purpose it
Expand Down Expand Up @@ -144,7 +146,7 @@ export interface PluginEngineReadCapability {
}

// ── v0.7: host-proxied, SSRF-guarded outbound HTTP ──────────────────────────────────────────────
// Gated by the "net:fetch" permission + manifest `net.allow` (host:port allowlist; deny by default).
// Gated by the "net:fetch" permission + manifest `net.allow` (host allowlist, port optional; deny by default).
// Use this for ALL outbound HTTP — the raw worker `fetch` is unguarded and discouraged.
export interface PluginNetRequestInit {
method?: string;
Expand Down Expand Up @@ -186,7 +188,8 @@ export interface PluginManifest {
hooks?: HookEvent[];
/** v0.7: per-session activation (default true). The platform owns which sessions a plugin runs for. */
sessionScoped?: boolean;
/** v0.7: outbound HTTP host allowlist for ctx.net.fetch — "host:port" entries; deny by default.
/** v0.7: outbound HTTP host allowlist for ctx.net.fetch — "host" or "host:port"; deny by default.
* The catalogue uses both forms: a bare host matches any port, which is what most entries rely on.
* v1: `allowConfigHosts` additionally admits the host of each named config key (e.g. "baseUrl"). */
net?: { allow: string[]; allowConfigHosts?: string[] };
/** v0.7: a sandboxed-iframe config editor served by the host. */
Expand Down
Loading