import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { evaluateDecision, type Decision, type WhisperGateConfig } from "./rules.js"; 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]; for (const c of candidates) { if (typeof c === "string" && c.trim()) return c.trim().toLowerCase(); } return ""; } 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) { const k = keys.next(); if (k.done) break; sessionDecision.delete(k.value); } } export default { id: "whispergate", name: "WhisperGate", register(api: OpenClawPluginApi) { const config = (api.pluginConfig || {}) as WhisperGateConfig; api.registerHook("message:received", async (event, ctx) => { try { const c = (ctx || {}) as Record; const e = (event || {}) as Record; const sessionKey = typeof c.sessionKey === "string" ? c.sessionKey : undefined; if (!sessionKey) return; 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, createdAt: Date.now() }); pruneDecisionMap(); 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)}`); } }); api.on("before_model_resolve", async (_event, ctx) => { const key = ctx.sessionKey; if (!key) 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=${rec.decision.reason}`, ); return { providerOverride: config.noReplyProvider, modelOverride: config.noReplyModel, }; }); }, };