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
9 changes: 9 additions & 0 deletions after-hours/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ The version here always matches `manifest.json`'s `version`.

## [Unreleased]

### Fixed

- **Per-session config overrides are now honored at message time.** The hook previously read a config
snapshot cached at enable, so a per-session override (e.g. a different schedule or away message for one
WhatsApp number) set via the dashboard after enable was ignored. The hook now re-parses `ctx.config` on
each event, which the host resolves to the firing session's slice. An invalid config for a session is
logged and skipped instead of replying with a stale snapshot. The enable-time fail-fast validation is
retained so a bad base config still surfaces in the dashboard.

## [0.1.2] — 2026-06-23

### Changed
Expand Down
29 changes: 29 additions & 0 deletions after-hours/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,32 @@ test('allowReply eviction is recency-aware: re-touching a key protects it', () =
assert.equal(map.has('k-0'), true); // protected by recent touch
assert.equal(map.has('k-1'), false); // now the oldest, evicted
});

// Regression: the message hook must re-read ctx.config per event (not a snapshot cached at enable) so a
// per-session override resolved by the host for the firing session is honored. Mutating the config AFTER
// enable must be visible to the next hook fire. We prove it by corrupting the config post-enable and
// asserting the hook warns + skips (a cached snapshot would still hold the valid enable-time value).
test('onMessage re-reads ctx.config per event (per-session config is not cached at enable)', async () => {
// Use a schedule whose window never covers the current wall-clock minute, so any message is after-hours.
const alwaysClosed = JSON.stringify({ mon: '00:00-00:01', tue: '00:00-00:01', wed: '00:00-00:01', thu: '00:00-00:01', fri: '00:00-00:01', sat: '00:00-00:01', sun: '00:00-00:01' });
let liveConfig: Record<string, unknown> = { schedule: alwaysClosed, awayMessage: 'Closed', cooldownSec: 0 };
const warnings: string[] = [];
let registered = false;
let handler: (hook: any) => Promise<void> = async () => {}; // default no-op; overwritten on registerHook
const ctx: any = {
get config() { return liveConfig; }, // simulate the host's per-session getter
logger: { log() {}, debug() {}, warn: (m: string) => warnings.push(m), error() {} },
registerHook: (_e: string, h: any) => { handler = h; registered = true; },
messages: { reply: async () => ({ messageId: '', timestamp: 0 }), sendText: async () => ({ messageId: '', timestamp: 0 }) },
};
const { default: AfterHours } = await import('./index.ts');
const plugin = new AfterHours();
await plugin.onEnable(ctx);
assert.ok(registered, 'hook registered');

// Corrupt the config AFTER enable. A snapshot cached at enable would not see this; a per-event read does.
liveConfig = { schedule: 'NOT JSON', awayMessage: 'x' };
await handler({ source: 'Engine', sessionId: 's1', timestamp: new Date(),
data: { id: 'm1', chatId: 'c@x', body: 'hi', fromMe: false, isGroup: false } });
assert.ok(warnings.some(w => /config invalid/.test(w)), 'corrupted post-enable config was re-read and warned');
});
39 changes: 20 additions & 19 deletions after-hours/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,46 +57,47 @@ export function allowReply(map: Map<string, number>, key: string, nowMs: number,
}

export default class AfterHours implements IPlugin {
private schedule: Schedule = {};
private config: AfterHoursConfig = { timezone: 'UTC', awayMessage: '', cooldownSec: 3600, respondInGroups: false };
private ctx: PluginContext | null = null;
private readonly repliedAt = new Map<string, number>();

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

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

private apply(ctx: PluginContext): void {
this.ctx = ctx;
const { config, schedule } = parseConfig(ctx.config);
this.schedule = schedule;
this.config = config;
}

private async onMessage(hook: HookContext): Promise<void> {
private async onMessage(ctx: PluginContext, hook: HookContext): Promise<void> {
if (hook.source !== 'Engine' || !hook.sessionId) return;
const m = (hook.data ?? {}) as Partial<IncomingMessage>;
if (m.fromMe || typeof m.body !== 'string' || !m.chatId || !m.id) return;
if (m.isGroup && !this.config.respondInGroups) return;
if (!isAfterHours(new Date(), this.schedule, this.config.timezone)) return;

// 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.
let cfg: { config: AfterHoursConfig; schedule: Schedule };
try {
cfg = parseConfig(ctx.config);
} catch (e) {
ctx.logger.warn(`after-hours: skipping message, config invalid: ${e instanceof Error ? e.message : String(e)}`);
return;
}

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

const sessionId = hook.sessionId;
const key = `${sessionId}:${m.chatId}`;
const cooldownMs = Math.max(0, this.config.cooldownSec) * 1000;
const cooldownMs = Math.max(0, cfg.config.cooldownSec) * 1000;
if (!allowReply(this.repliedAt, key, Date.now(), cooldownMs)) return;

try {
await this.ctx?.messages.reply(sessionId, m.chatId, m.id, this.config.awayMessage);
await ctx.messages.reply(sessionId, m.chatId, m.id, cfg.config.awayMessage);
} catch (err) {
this.ctx?.logger.error('after-hours: reply failed', err);
ctx.logger.error('after-hours: reply failed', err);
}
}
}
8 changes: 8 additions & 0 deletions chat-flow/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ The version here always matches `manifest.json`'s `version`.

## [Unreleased]

### Fixed

- **Per-session config overrides are now honored at message time.** The hook previously read a config
snapshot cached at enable, so a per-session override (e.g. a different menu tree for one WhatsApp number)
set via the dashboard after enable was ignored. The hook now re-parses `ctx.config` on each event, which
the host resolves to the firing session's slice. An invalid config for a session is logged and skipped
instead of driving the flow with a stale snapshot. The enable-time fail-fast validation is retained.

## [1.0.5] — 2026-07-02

### Fixed
Expand Down
28 changes: 28 additions & 0 deletions chat-flow/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,31 @@ test('toFlowNodes accepts keys that collide with Object.prototype names, but rej
// __proto__ would set the prototype rather than an own key — reject it outright
assert.throws(() => toFlowNodes([{ key: '__proto__', text: 'A' }]), /not allowed/);
});

// Regression: the message hook must re-read ctx.config per event (not a snapshot cached at enable) so a
// per-session override resolved by the host for the firing session is honored. We prove it by corrupting
// the config post-enable and asserting the hook warns + skips (a cached snapshot would drive the flow with
// the stale enable-time menu).
test('onMessage re-reads ctx.config per event (per-session config is not cached at enable)', async () => {
let liveConfig: Record<string, unknown> = { greeting: 'menu', options: [{ key: '1', text: 'A' }] };
const warnings: string[] = [];
let registered = false;
let handler: (hook: any) => Promise<unknown> = async () => {}; // default no-op; overwritten on registerHook
const ctx: any = {
get config() { return liveConfig; }, // simulate the host's per-session getter
logger: { log() {}, debug() {}, warn: (m: string) => warnings.push(m), error() {} },
registerHook: (_e: string, h: any) => { handler = h; registered = true; },
storage: { get: async () => null, set: async () => {}, delete: async () => {}, list: async () => [] },
messages: { reply: async () => ({ messageId: '', timestamp: 0 }), sendText: async () => ({ messageId: '', timestamp: 0 }) },
};
const { default: ChatFlow } = await import('./index.ts');
const plugin = new ChatFlow();
await plugin.onEnable(ctx);
assert.ok(registered, 'hook registered');

// Corrupt the config AFTER enable. A snapshot cached at enable would not see this; a per-event read does.
liveConfig = { greeting: '', options: [] };
await handler({ source: 'Engine', sessionId: 's1', timestamp: new Date(),
data: { id: 'm1', chatId: 'c@x', body: 'hi', fromMe: false, isGroup: false } });
assert.ok(warnings.some(w => /config invalid/.test(w)), 'corrupted post-enable config was re-read and warned');
});
21 changes: 14 additions & 7 deletions chat-flow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,10 @@ export function parseConfig(raw: Record<string, unknown>): ChatFlowConfig {
const SWEEP_INTERVAL_MS = 30 * 60 * 1000;

export default class ChatFlow implements IPlugin {
private config: ChatFlowConfig | null = null;
private sweepTimer: ReturnType<typeof setInterval> | null = null;

async onEnable(ctx: PluginContext): Promise<void> {
this.config = parseConfig(ctx.config);
parseConfig(ctx.config); // fail-fast: surface invalid config at enable, not per-message
ctx.registerHook('message:received', hook =>
this.onMessage(ctx, hook as HookContext<IncomingMessage>),
);
Expand All @@ -64,7 +63,7 @@ export default class ChatFlow implements IPlugin {
// The platform passes the new config as the 2nd arg, but for a sessionScoped plugin ctx.config is already
// the resolved per-session slice — read that (re-parsing _newConfig would lose the per-session merge).
async onConfigChange(ctx: PluginContext, _newConfig: Record<string, unknown>): Promise<void> {
this.config = parseConfig(ctx.config);
parseConfig(ctx.config); // re-validate on change (fail-fast feedback in the dashboard)
}

async onDisable(): Promise<void> {
Expand All @@ -83,15 +82,23 @@ export default class ChatFlow implements IPlugin {
}

private async onMessage(ctx: PluginContext, hook: HookContext<IncomingMessage>): Promise<HookResult> {
const cfg = this.config;
if (!cfg || hook.source !== 'Engine' || !hook.sessionId) return { continue: true };
if (hook.source !== 'Engine' || !hook.sessionId) return { continue: true };
const m = hook.data;
if (m.fromMe || typeof m.body !== 'string' || !m.chatId || !m.id) return { continue: true };
if (m.isGroup && !cfg.respondInGroups) return { continue: true };

let liveCfg;
try {
liveCfg = parseConfig(ctx.config);
} catch (e) {
ctx.logger.warn(`chat-flow: skipping message, config invalid: ${e instanceof Error ? e.message : String(e)}`);
return { continue: true };
}

if (m.isGroup && !liveCfg.respondInGroups) return { continue: true };
try {
// In a group, scope flow state to the sender so members don't clobber each other's menu position.
const actor = m.isGroup ? m.author : undefined;
const handled = await FlowEngine.processMessage(ctx, cfg.flow, hook.sessionId, m.chatId, m.body, m.id, actor);
const handled = await FlowEngine.processMessage(ctx, liveCfg.flow, hook.sessionId, m.chatId, m.body, m.id, actor);
return { continue: !handled };
} catch (err) {
ctx.logger.error('chat-flow: flow processing failed', err);
Expand Down
9 changes: 9 additions & 0 deletions faq-bot/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ The version here always matches `manifest.json`'s `version`.

## [Unreleased]

### Fixed

- **Per-session config overrides are now honored at message time.** The hook previously read a config
snapshot cached at enable, so a per-session override (e.g. a different rule set or fallback reply for one
WhatsApp number) set via the dashboard after enable was ignored. The hook now re-parses `ctx.config` on
each event, which the host resolves to the firing session's slice. An invalid config for a session is
logged and skipped instead of replying with a stale snapshot. The enable-time fail-fast validation and the
invalid-regex skip warning are retained.

## [0.1.6] — 2026-07-02

### Fixed
Expand Down
27 changes: 27 additions & 0 deletions faq-bot/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,30 @@ test('allowFallback eviction is recency-aware: re-touching a key protects it fro
assert.equal(map.has('chat-0'), true); // protected by recent touch
assert.equal(map.has('chat-1'), false); // now the oldest, evicted
});

// Regression: the message hook must re-read ctx.config per event (not a snapshot cached at enable) so a
// per-session override resolved by the host for the firing session is honored. We prove it by corrupting
// the config post-enable and asserting the hook warns + skips (a cached snapshot would hold the valid
// enable-time value and try to match rules that no longer exist).
test('onMessage re-reads ctx.config per event (per-session config is not cached at enable)', async () => {
let liveConfig: Record<string, unknown> = { rules: JSON.stringify([{ mode: 'contains', pattern: 'hi', reply: 'hello' }]) };
const warnings: string[] = [];
let registered = false;
let handler: (hook: any) => Promise<void> = async () => {}; // default no-op; overwritten on registerHook
const ctx: any = {
get config() { return liveConfig; }, // simulate the host's per-session getter
logger: { log() {}, debug() {}, warn: (m: string) => warnings.push(m), error() {} },
registerHook: (_e: string, h: any) => { handler = h; registered = true; },
messages: { reply: async () => ({ messageId: '', timestamp: 0 }), sendText: async () => ({ messageId: '', timestamp: 0 }) },
};
const { default: FaqBot } = await import('./index.ts');
const plugin = new FaqBot();
await plugin.onEnable(ctx);
assert.ok(registered, 'hook registered');

// Corrupt the config AFTER enable. A snapshot cached at enable would not see this; a per-event read does.
liveConfig = { rules: 'NOT JSON' };
await handler({ source: 'Engine', sessionId: 's1', timestamp: new Date(),
data: { id: 'm1', chatId: 'c@x', body: 'hi', fromMe: false, isGroup: false } });
assert.ok(warnings.some(w => /config invalid/.test(w)), 'corrupted post-enable config was re-read and warned');
});
43 changes: 24 additions & 19 deletions faq-bot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,55 +58,60 @@ export function allowFallback(map: Map<string, number>, key: string, nowMs: numb
}

export default class FaqBot implements IPlugin {
private rules: CompiledRule[] = [];
private config: FaqConfig = { fallbackReply: '', fallbackCooldownSec: 600, respondInGroups: false };
private ctx: PluginContext | null = null;
private readonly fallbackAt = new Map<string, number>();

async onEnable(ctx: PluginContext): Promise<void> {
this.apply(ctx);
this.warnSkipped(ctx); // fail-fast + surface any invalid regex rules at enable
ctx.registerHook('message:received', async (hook: HookContext) => {
await this.onMessage(hook);
await this.onMessage(ctx, hook);
return { continue: true };
});
}

async onConfigChange(ctx: PluginContext): Promise<void> {
this.apply(ctx);
this.warnSkipped(ctx); // re-validate on change (fail-fast feedback + fresh skipped warning)
}

private apply(ctx: PluginContext): void {
this.ctx = ctx;
const { config, rules, skipped } = parseConfig(ctx.config);
this.rules = rules;
this.config = config;
private warnSkipped(ctx: PluginContext): void {
const { skipped } = parseConfig(ctx.config);
if (skipped.length) {
ctx.logger.warn(`faq-bot: skipped ${skipped.length} rule(s) with an invalid regex: ${skipped.join(', ')}`);
}
}

private async onMessage(hook: HookContext): Promise<void> {
private async onMessage(ctx: PluginContext, hook: HookContext): Promise<void> {
if (hook.source !== 'Engine' || !hook.sessionId) return;
const m = (hook.data ?? {}) as Partial<IncomingMessage>;
if (m.fromMe || typeof m.body !== 'string' || !m.chatId || !m.id) return;
if (m.isGroup && !this.config.respondInGroups) return;

// 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.
let cfg: { config: FaqConfig; rules: CompiledRule[] };
try {
cfg = parseConfig(ctx.config);
} catch (e) {
ctx.logger.warn(`faq-bot: skipping message, config invalid: ${e instanceof Error ? e.message : String(e)}`);
return;
}

if (m.isGroup && !cfg.config.respondInGroups) return;

const sessionId = hook.sessionId;
const rule = matchRule(this.rules, m.body);
const rule = matchRule(cfg.rules, m.body);
try {
if (rule) {
await this.ctx?.messages.reply(sessionId, m.chatId, m.id, rule.reply);
await ctx.messages.reply(sessionId, m.chatId, m.id, rule.reply);
return;
}
if (this.config.fallbackReply) {
if (cfg.config.fallbackReply) {
const key = `${sessionId}:${m.chatId}`;
const cooldownMs = Math.max(0, this.config.fallbackCooldownSec) * 1000;
const cooldownMs = Math.max(0, cfg.config.fallbackCooldownSec) * 1000;
if (allowFallback(this.fallbackAt, key, Date.now(), cooldownMs)) {
await this.ctx?.messages.reply(sessionId, m.chatId, m.id, this.config.fallbackReply);
await ctx.messages.reply(sessionId, m.chatId, m.id, cfg.config.fallbackReply);
}
}
} catch (err) {
this.ctx?.logger.error('faq-bot: reply failed', err);
ctx.logger.error('faq-bot: reply failed', err);
}
}
}
Loading