From f09972c083f65c10572e41f48ee65d493282a6d4 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:43:11 +0000 Subject: [PATCH] feat(plugin): add sender normalization, TTL, and one-shot decisions --- plugin/index.ts | 59 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 006b733..e5a00c0 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,8 +1,14 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { evaluateDecision, type Decision, type WhisperGateConfig } from "./rules.js"; -const sessionDecision = new Map(); +type DecisionRecord = { + decision: Decision; + createdAt: number; +}; + +const sessionDecision = new Map(); const MAX_SESSION_DECISIONS = 2000; +const DECISION_TTL_MS = 5 * 60 * 1000; function normalizeChannel(ctx: Record): string { const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; @@ -12,7 +18,27 @@ function normalizeChannel(ctx: Record): string { return ""; } -function pruneDecisionMap() { +function normalizeSender(event: Record, ctx: Record): string | undefined { + const direct = [ctx.senderId, ctx.from, event.from]; + for (const v of direct) { + if (typeof v === "string" && v.trim()) return v.trim(); + } + + const meta = (event.metadata || ctx.metadata) as Record | undefined; + if (!meta) return undefined; + const metaCandidates = [meta.senderId, meta.sender_id, meta.userId, meta.user_id]; + for (const v of metaCandidates) { + if (typeof v === "string" && v.trim()) return v.trim(); + } + + return undefined; +} + +function pruneDecisionMap(now = Date.now()) { + for (const [k, v] of sessionDecision.entries()) { + if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k); + } + if (sessionDecision.size <= MAX_SESSION_DECISIONS) return; const keys = sessionDecision.keys(); while (sessionDecision.size > MAX_SESSION_DECISIONS) { @@ -35,20 +61,16 @@ export default { const sessionKey = typeof c.sessionKey === "string" ? c.sessionKey : undefined; if (!sessionKey) return; - const senderId = - typeof c.senderId === "string" - ? c.senderId - : typeof e.from === "string" - ? e.from - : undefined; - + const senderId = normalizeSender(e, c); const content = typeof e.content === "string" ? e.content : ""; const channel = normalizeChannel(c); const decision = evaluateDecision({ config, channel, senderId, content }); - sessionDecision.set(sessionKey, decision); + sessionDecision.set(sessionKey, { decision, createdAt: Date.now() }); pruneDecisionMap(); - api.logger.debug?.(`whispergate: session=${sessionKey} decision=${decision.reason}`); + api.logger.debug?.( + `whispergate: session=${sessionKey} sender=${senderId ?? "unknown"} channel=${channel || "unknown"} decision=${decision.reason}`, + ); } catch (err) { api.logger.warn(`whispergate: message hook failed: ${String(err)}`); } @@ -57,11 +79,20 @@ export default { api.on("before_model_resolve", async (_event, ctx) => { const key = ctx.sessionKey; if (!key) return; - const decision = sessionDecision.get(key); - if (!decision?.shouldUseNoReply) return; + + const rec = sessionDecision.get(key); + if (!rec) return; + if (Date.now() - rec.createdAt > DECISION_TTL_MS) { + sessionDecision.delete(key); + return; + } + + // one-shot decision per inbound turn + sessionDecision.delete(key); + if (!rec.decision.shouldUseNoReply) return; api.logger.info( - `whispergate: override model for session=${key}, provider=${config.noReplyProvider}, model=${config.noReplyModel}, reason=${decision.reason}`, + `whispergate: override model for session=${key}, provider=${config.noReplyProvider}, model=${config.noReplyModel}, reason=${rec.decision.reason}`, ); return {