From 83b9d517ec23db1e470eb855361f92ba8f31e508 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:39:47 +0000 Subject: [PATCH] feat(plugin): extract rule engine and harden channel/session handling --- plugin/index.ts | 70 ++++++++++++++----------------------------------- plugin/rules.ts | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 51 deletions(-) create mode 100644 plugin/rules.ts diff --git a/plugin/index.ts b/plugin/index.ts index 7c98502..006b733 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,60 +1,32 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; - -type Config = { - enabled?: boolean; - discordOnly?: boolean; - bypassUserIds?: string[]; - endSymbols?: string[]; - noReplyProvider: string; - noReplyModel: string; -}; - -type Decision = { - shouldUseNoReply: boolean; - reason: string; -}; +import { evaluateDecision, type Decision, type WhisperGateConfig } from "./rules.js"; const sessionDecision = new Map(); +const MAX_SESSION_DECISIONS = 2000; -function getLastChar(input: string): string { - const t = input.trim(); - return t.length ? t[t.length - 1] : ""; +function normalizeChannel(ctx: Record): string { + const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; + for (const c of candidates) { + if (typeof c === "string" && c.trim()) return c.trim().toLowerCase(); + } + return ""; } -function shouldUseNoReply(params: { - config: Config; - channel?: string; - senderId?: string; - content?: string; -}): Decision { - const { config } = params; - - if (config.enabled === false) { - return { shouldUseNoReply: false, reason: "disabled" }; +function pruneDecisionMap() { + if (sessionDecision.size <= MAX_SESSION_DECISIONS) return; + const keys = sessionDecision.keys(); + while (sessionDecision.size > MAX_SESSION_DECISIONS) { + const k = keys.next(); + if (k.done) break; + sessionDecision.delete(k.value); } - - const channel = (params.channel || "").toLowerCase(); - if (config.discordOnly !== false && channel !== "discord") { - return { shouldUseNoReply: false, reason: "non_discord" }; - } - - if (params.senderId && (config.bypassUserIds || []).includes(params.senderId)) { - return { shouldUseNoReply: false, reason: "bypass_sender" }; - } - - const lastChar = getLastChar(params.content || ""); - if (lastChar && (config.endSymbols || []).includes(lastChar)) { - return { shouldUseNoReply: false, reason: `end_symbol:${lastChar}` }; - } - - return { shouldUseNoReply: true, reason: "rule_match_no_end_symbol" }; } export default { id: "whispergate", name: "WhisperGate", register(api: OpenClawPluginApi) { - const config = (api.pluginConfig || {}) as Config; + const config = (api.pluginConfig || {}) as WhisperGateConfig; api.registerHook("message:received", async (event, ctx) => { try { @@ -71,15 +43,11 @@ export default { : undefined; const content = typeof e.content === "string" ? e.content : ""; - const channel = - typeof c.commandSource === "string" - ? c.commandSource - : typeof c.channelId === "string" - ? c.channelId - : ""; + const channel = normalizeChannel(c); - const decision = shouldUseNoReply({ config, channel, senderId, content }); + const decision = evaluateDecision({ config, channel, senderId, content }); sessionDecision.set(sessionKey, decision); + pruneDecisionMap(); api.logger.debug?.(`whispergate: session=${sessionKey} decision=${decision.reason}`); } catch (err) { api.logger.warn(`whispergate: message hook failed: ${String(err)}`); diff --git a/plugin/rules.ts b/plugin/rules.ts new file mode 100644 index 0000000..9fafcc2 --- /dev/null +++ b/plugin/rules.ts @@ -0,0 +1,47 @@ +export type WhisperGateConfig = { + enabled?: boolean; + discordOnly?: boolean; + bypassUserIds?: string[]; + endSymbols?: string[]; + noReplyProvider: string; + noReplyModel: string; +}; + +export type Decision = { + shouldUseNoReply: boolean; + reason: string; +}; + +function getLastChar(input: string): string { + const t = input.trim(); + return t.length ? t[t.length - 1] : ""; +} + +export function evaluateDecision(params: { + config: WhisperGateConfig; + channel?: string; + senderId?: string; + content?: string; +}): Decision { + const { config } = params; + + if (config.enabled === false) { + return { shouldUseNoReply: false, reason: "disabled" }; + } + + const channel = (params.channel || "").toLowerCase(); + if (config.discordOnly !== false && channel !== "discord") { + return { shouldUseNoReply: false, reason: "non_discord" }; + } + + if (params.senderId && (config.bypassUserIds || []).includes(params.senderId)) { + return { shouldUseNoReply: false, reason: "bypass_sender" }; + } + + const lastChar = getLastChar(params.content || ""); + if (lastChar && (config.endSymbols || []).includes(lastChar)) { + return { shouldUseNoReply: false, reason: `end_symbol:${lastChar}` }; + } + + return { shouldUseNoReply: true, reason: "rule_match_no_end_symbol" }; +}