From f871ff8ac2611d8c724ffe08d2207f51207cd63e Mon Sep 17 00:00:00 2001 From: Affidev Date: Thu, 16 Jul 2026 12:54:28 +0700 Subject: [PATCH 1/3] fix(plugins): resolve per-session config caching issue - Refactored faq-bot, after-hours, and chat-flow to parse configuration dynamically inside message hooks. - Replaced global config caching with dynamic liveCfg resolution via ctx.config. - Implemented consistent error handling to gracefully skip and warn on invalid configs. --- after-hours/index.ts | 20 ++++++++++++++++---- chat-flow/index.ts | 16 ++++++++++++---- faq-bot/index.ts | 22 +++++++++++++++++----- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/after-hours/index.ts b/after-hours/index.ts index b012b72..c92e92f 100644 --- a/after-hours/index.ts +++ b/after-hours/index.ts @@ -85,16 +85,28 @@ export default class AfterHours implements IPlugin { if (hook.source !== 'Engine' || !hook.sessionId) return; const m = (hook.data ?? {}) as Partial; 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; + + let liveCfg; + let liveSchedule; + try { + const parsed = parseConfig(this.ctx!.config); + liveCfg = parsed.config; + liveSchedule = parsed.schedule; + } catch (e) { + this.ctx?.logger.warn(`after-hours: skipping message, config invalid: ${e instanceof Error ? e.message : String(e)}`); + return; + } + + if (m.isGroup && !liveCfg.respondInGroups) return; + if (!isAfterHours(new Date(), liveSchedule, liveCfg.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, liveCfg.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 this.ctx?.messages.reply(sessionId, m.chatId, m.id, liveCfg.awayMessage); } catch (err) { this.ctx?.logger.error('after-hours: reply failed', err); } diff --git a/chat-flow/index.ts b/chat-flow/index.ts index f51f0ca..4c48f3f 100644 --- a/chat-flow/index.ts +++ b/chat-flow/index.ts @@ -83,15 +83,23 @@ export default class ChatFlow implements IPlugin { } private async onMessage(ctx: PluginContext, hook: HookContext): Promise { - 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); diff --git a/faq-bot/index.ts b/faq-bot/index.ts index b0df92f..a6f9cdd 100644 --- a/faq-bot/index.ts +++ b/faq-bot/index.ts @@ -89,20 +89,32 @@ export default class FaqBot implements IPlugin { if (hook.source !== 'Engine' || !hook.sessionId) return; const m = (hook.data ?? {}) as Partial; if (m.fromMe || typeof m.body !== 'string' || !m.chatId || !m.id) return; - if (m.isGroup && !this.config.respondInGroups) return; + + let liveCfg; + let liveRules; + try { + const parsed = parseConfig(this.ctx!.config); + liveCfg = parsed.config; + liveRules = parsed.rules; + } catch (e) { + this.ctx?.logger.warn(`faq-bot: skipping message, config invalid: ${e instanceof Error ? e.message : String(e)}`); + return; + } + + if (m.isGroup && !liveCfg.respondInGroups) return; const sessionId = hook.sessionId; - const rule = matchRule(this.rules, m.body); + const rule = matchRule(liveRules, m.body); try { if (rule) { await this.ctx?.messages.reply(sessionId, m.chatId, m.id, rule.reply); return; } - if (this.config.fallbackReply) { + if (liveCfg.fallbackReply) { const key = `${sessionId}:${m.chatId}`; - const cooldownMs = Math.max(0, this.config.fallbackCooldownSec) * 1000; + const cooldownMs = Math.max(0, liveCfg.fallbackCooldownSec) * 1000; if (allowFallback(this.fallbackAt, key, Date.now(), cooldownMs)) { - await this.ctx?.messages.reply(sessionId, m.chatId, m.id, this.config.fallbackReply); + await this.ctx?.messages.reply(sessionId, m.chatId, m.id, liveCfg.fallbackReply); } } } catch (err) { From 5ddd7a250e9fbd038d946a37972de6e4c9a0576c Mon Sep 17 00:00:00 2001 From: Affidev <177397106+affidevcom@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:11:43 +0700 Subject: [PATCH 2/3] fix(plugins): resolve per-session config caching issue Co-authored-by: eabdalmufid <110360966+eabdalmufid@users.noreply.github.com> From 770f251830806aec3664a4b9e8c12fc5983e5512 Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Sat, 18 Jul 2026 19:27:33 +0700 Subject: [PATCH 3/3] fix(after-hours,faq-bot,chat-flow): clean up per-session config resolution Follow-up to the per-event config re-parse: drop the now-write-only cached fields and apply() methods, pass ctx as a parameter to the message hook for consistent null handling, and preserve the enable-time fail-fast validation (including faq-bot's invalid-regex skip warning). Add a regression test per plugin proving onMessage re-reads ctx.config per event, so a per-session override set via the dashboard after enable is honored. typecheck clean; 399/399 tests pass. --- after-hours/CHANGELOG.md | 9 ++++++++ after-hours/index.test.ts | 29 ++++++++++++++++++++++++++ after-hours/index.ts | 39 +++++++++++++---------------------- chat-flow/CHANGELOG.md | 8 ++++++++ chat-flow/index.test.ts | 28 +++++++++++++++++++++++++ chat-flow/index.ts | 5 ++--- faq-bot/CHANGELOG.md | 9 ++++++++ faq-bot/index.test.ts | 27 ++++++++++++++++++++++++ faq-bot/index.ts | 43 ++++++++++++++++----------------------- 9 files changed, 144 insertions(+), 53 deletions(-) diff --git a/after-hours/CHANGELOG.md b/after-hours/CHANGELOG.md index 293b505..f06ce3f 100644 --- a/after-hours/CHANGELOG.md +++ b/after-hours/CHANGELOG.md @@ -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 diff --git a/after-hours/index.test.ts b/after-hours/index.test.ts index f262b1a..fa7eb59 100644 --- a/after-hours/index.test.ts +++ b/after-hours/index.test.ts @@ -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 = { schedule: alwaysClosed, awayMessage: 'Closed', cooldownSec: 0 }; + const warnings: string[] = []; + let registered = false; + let handler: (hook: any) => Promise = 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'); +}); diff --git a/after-hours/index.ts b/after-hours/index.ts index c92e92f..e426c15 100644 --- a/after-hours/index.ts +++ b/after-hours/index.ts @@ -57,58 +57,47 @@ export function allowReply(map: Map, 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(); async onEnable(ctx: PluginContext): Promise { - 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): Promise { - 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 { + private async onMessage(ctx: PluginContext, hook: HookContext): Promise { if (hook.source !== 'Engine' || !hook.sessionId) return; const m = (hook.data ?? {}) as Partial; if (m.fromMe || typeof m.body !== 'string' || !m.chatId || !m.id) return; - let liveCfg; - let liveSchedule; + // 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 { - const parsed = parseConfig(this.ctx!.config); - liveCfg = parsed.config; - liveSchedule = parsed.schedule; + cfg = parseConfig(ctx.config); } catch (e) { - this.ctx?.logger.warn(`after-hours: skipping message, config invalid: ${e instanceof Error ? e.message : String(e)}`); + ctx.logger.warn(`after-hours: skipping message, config invalid: ${e instanceof Error ? e.message : String(e)}`); return; } - if (m.isGroup && !liveCfg.respondInGroups) return; - if (!isAfterHours(new Date(), liveSchedule, liveCfg.timezone)) 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, liveCfg.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, liveCfg.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); } } } diff --git a/chat-flow/CHANGELOG.md b/chat-flow/CHANGELOG.md index 9846308..6314a7c 100644 --- a/chat-flow/CHANGELOG.md +++ b/chat-flow/CHANGELOG.md @@ -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 diff --git a/chat-flow/index.test.ts b/chat-flow/index.test.ts index e6169ea..4fc159f 100644 --- a/chat-flow/index.test.ts +++ b/chat-flow/index.test.ts @@ -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 = { greeting: 'menu', options: [{ key: '1', text: 'A' }] }; + const warnings: string[] = []; + let registered = false; + let handler: (hook: any) => Promise = 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'); +}); diff --git a/chat-flow/index.ts b/chat-flow/index.ts index 4c48f3f..cfedb22 100644 --- a/chat-flow/index.ts +++ b/chat-flow/index.ts @@ -45,11 +45,10 @@ export function parseConfig(raw: Record): ChatFlowConfig { const SWEEP_INTERVAL_MS = 30 * 60 * 1000; export default class ChatFlow implements IPlugin { - private config: ChatFlowConfig | null = null; private sweepTimer: ReturnType | null = null; async onEnable(ctx: PluginContext): Promise { - 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), ); @@ -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): Promise { - this.config = parseConfig(ctx.config); + parseConfig(ctx.config); // re-validate on change (fail-fast feedback in the dashboard) } async onDisable(): Promise { diff --git a/faq-bot/CHANGELOG.md b/faq-bot/CHANGELOG.md index beb0aae..c7731c5 100644 --- a/faq-bot/CHANGELOG.md +++ b/faq-bot/CHANGELOG.md @@ -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 diff --git a/faq-bot/index.test.ts b/faq-bot/index.test.ts index 6103469..544f80f 100644 --- a/faq-bot/index.test.ts +++ b/faq-bot/index.test.ts @@ -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 = { rules: JSON.stringify([{ mode: 'contains', pattern: 'hi', reply: 'hello' }]) }; + const warnings: string[] = []; + let registered = false; + let handler: (hook: any) => Promise = 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'); +}); diff --git a/faq-bot/index.ts b/faq-bot/index.ts index a6f9cdd..79c74ef 100644 --- a/faq-bot/index.ts +++ b/faq-bot/index.ts @@ -58,67 +58,60 @@ export function allowFallback(map: Map, 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(); async onEnable(ctx: PluginContext): Promise { - 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 { - 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 { + private async onMessage(ctx: PluginContext, hook: HookContext): Promise { if (hook.source !== 'Engine' || !hook.sessionId) return; const m = (hook.data ?? {}) as Partial; if (m.fromMe || typeof m.body !== 'string' || !m.chatId || !m.id) return; - let liveCfg; - let liveRules; + // 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 { - const parsed = parseConfig(this.ctx!.config); - liveCfg = parsed.config; - liveRules = parsed.rules; + cfg = parseConfig(ctx.config); } catch (e) { - this.ctx?.logger.warn(`faq-bot: skipping message, config invalid: ${e instanceof Error ? e.message : String(e)}`); + ctx.logger.warn(`faq-bot: skipping message, config invalid: ${e instanceof Error ? e.message : String(e)}`); return; } - if (m.isGroup && !liveCfg.respondInGroups) return; + if (m.isGroup && !cfg.config.respondInGroups) return; const sessionId = hook.sessionId; - const rule = matchRule(liveRules, 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 (liveCfg.fallbackReply) { + if (cfg.config.fallbackReply) { const key = `${sessionId}:${m.chatId}`; - const cooldownMs = Math.max(0, liveCfg.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, liveCfg.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); } } }