import fs from "node:fs"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { evaluateDecision, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; type DecisionRecord = { decision: Decision; createdAt: number; }; type PolicyState = { filePath: string; channelPolicies: Record; }; type DebugConfig = { enableDebugLogs?: boolean; debugLogChannelIds?: string[]; }; const sessionDecision = new Map(); const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; const END_MARKER_INSTRUCTION = "你的这次发言必须以🔚作为结尾。"; const policyState: PolicyState = { filePath: "", channelPolicies: {}, }; 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 extractUntrustedConversationInfo(text: string): Record | undefined { const marker = "Conversation info (untrusted metadata):"; const idx = text.indexOf(marker); if (idx < 0) return undefined; const tail = text.slice(idx + marker.length); const m = tail.match(/```json\s*([\s\S]*?)\s*```/i); if (!m) return undefined; try { const parsed = JSON.parse(m[1]); return parsed && typeof parsed === "object" ? (parsed as Record) : undefined; } catch { return undefined; } } function deriveDecisionInputFromPrompt( prompt: string, messageProvider?: string, ): { channel: string; channelId?: string; senderId?: string; content: string; conv: Record; } { const conv = extractUntrustedConversationInfo(prompt) || {}; const channel = (messageProvider || "").toLowerCase(); const channelId = (typeof conv.channel_id === "string" && conv.channel_id) || (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:") ? conv.chat_id.slice("channel:".length) : undefined); const senderId = (typeof conv.sender_id === "string" && conv.sender_id) || (typeof conv.sender === "string" && conv.sender) || undefined; return { channel, channelId, senderId, content: prompt, conv }; } 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); } } function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig): WhisperGateConfig { const root = (api.config as Record) || {}; const plugins = (root.plugins as Record) || {}; const entries = (plugins.entries as Record) || {}; const entry = (entries.whispergate as Record) || {}; const cfg = (entry.config as Record) || {}; if (Object.keys(cfg).length > 0) return cfg as unknown as WhisperGateConfig; return fallback; } function resolvePoliciesPath(api: OpenClawPluginApi, config: WhisperGateConfig): string { return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/whispergate-channel-policies.json"); } function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConfig) { if (policyState.filePath) return; const filePath = resolvePoliciesPath(api, config); policyState.filePath = filePath; try { if (!fs.existsSync(filePath)) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, "{}\n", "utf8"); policyState.channelPolicies = {}; return; } const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as Record; policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {}; } catch (err) { api.logger.warn(`whispergate: failed init policy file ${filePath}: ${String(err)}`); policyState.channelPolicies = {}; } } function persistPolicies(api: OpenClawPluginApi): void { const filePath = policyState.filePath; if (!filePath) throw new Error("policy file path not initialized"); const before = JSON.stringify(policyState.channelPolicies, null, 2) + "\n"; const tmp = `${filePath}.tmp`; fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(tmp, before, "utf8"); fs.renameSync(tmp, filePath); api.logger.info(`whispergate: policy file persisted: ${filePath}`); } function pickDefined(input: Record) { const out: Record = {}; for (const [k, v] of Object.entries(input)) { if (v !== undefined) out[k] = v; } return out; } function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean { if (!cfg.enableDebugLogs) return false; const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : []; if (allow.length === 0) return true; if (!channelId) return false; return allow.includes(channelId); } function debugCtxSummary(ctx: Record, event: Record) { const meta = ((ctx.metadata || event.metadata || {}) as Record) || {}; return { sessionKey: typeof ctx.sessionKey === "string" ? ctx.sessionKey : undefined, commandSource: typeof ctx.commandSource === "string" ? ctx.commandSource : undefined, messageProvider: typeof ctx.messageProvider === "string" ? ctx.messageProvider : undefined, channel: typeof ctx.channel === "string" ? ctx.channel : undefined, channelId: typeof ctx.channelId === "string" ? ctx.channelId : undefined, senderId: typeof ctx.senderId === "string" ? ctx.senderId : undefined, from: typeof ctx.from === "string" ? ctx.from : undefined, metaSenderId: typeof meta.senderId === "string" ? meta.senderId : typeof meta.sender_id === "string" ? meta.sender_id : undefined, metaUserId: typeof meta.userId === "string" ? meta.userId : typeof meta.user_id === "string" ? meta.user_id : undefined, }; } export default { id: "whispergate", name: "WhisperGate", register(api: OpenClawPluginApi) { const baseConfig = (api.pluginConfig || {}) as WhisperGateConfig & { enableDiscordControlTool?: boolean; discordControlApiBaseUrl?: string; discordControlApiToken?: string; discordControlCallerId?: string; enableWhispergatePolicyTool?: boolean; }; const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig); ensurePolicyStateLoaded(api, liveAtRegister); api.registerTool( { name: "whispergateway_tools", description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.", parameters: { type: "object", additionalProperties: false, properties: { action: { type: "string", enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel"], }, guildId: { type: "string" }, name: { type: "string" }, type: { type: "number" }, parentId: { type: "string" }, topic: { type: "string" }, position: { type: "number" }, nsfw: { type: "boolean" }, allowedUserIds: { type: "array", items: { type: "string" } }, allowedRoleIds: { type: "array", items: { type: "string" } }, allowMask: { type: "string" }, denyEveryoneMask: { type: "string" }, channelId: { type: "string" }, mode: { type: "string", enum: ["merge", "replace"] }, addUserIds: { type: "array", items: { type: "string" } }, addRoleIds: { type: "array", items: { type: "string" } }, removeTargetIds: { type: "array", items: { type: "string" } }, denyMask: { type: "string" }, limit: { type: "number" }, after: { type: "string" }, fields: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] }, dryRun: { type: "boolean" }, listMode: { type: "string", enum: ["human-list", "agent-list"] }, humanList: { type: "array", items: { type: "string" } }, agentList: { type: "array", items: { type: "string" } }, endSymbols: { type: "array", items: { type: "string" } }, }, required: ["action"], }, async execute(_id: string, params: Record) { const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & { discordControlApiBaseUrl?: string; discordControlApiToken?: string; discordControlCallerId?: string; enableDiscordControlTool?: boolean; enableWhispergatePolicyTool?: boolean; }; ensurePolicyStateLoaded(api, live); const action = String(params.action || ""); const discordActions = new Set(["channel-private-create", "channel-private-update", "member-list"]); if (discordActions.has(action)) { if (live.enableDiscordControlTool === false) { return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true }; } const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, ""); const body = pickDefined({ ...params, action: action as DiscordControlAction }); const headers: Record = { "Content-Type": "application/json" }; if (live.discordControlApiToken) headers.Authorization = `Bearer ${live.discordControlApiToken}`; if (live.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = live.discordControlCallerId; const r = await fetch(`${baseUrl}/v1/discord/action`, { method: "POST", headers, body: JSON.stringify(body), }); const text = await r.text(); if (!r.ok) { return { content: [{ type: "text", text: `whispergateway_tools discord failed (${r.status}): ${text}` }], isError: true, }; } return { content: [{ type: "text", text }] }; } if (live.enableWhispergatePolicyTool === false) { return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; } if (action === "policy-get") { return { content: [{ type: "text", text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2) }], }; } if (action === "policy-set-channel") { const channelId = String(params.channelId || "").trim(); if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); try { const next: ChannelPolicy = { listMode: (params.listMode as "human-list" | "agent-list" | undefined) || undefined, humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined, agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined, endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined, }; policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record) as ChannelPolicy; persistPolicies(api); return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, policy: policyState.channelPolicies[channelId] }) }] }; } catch (err) { policyState.channelPolicies = prev; return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; } } if (action === "policy-delete-channel") { const channelId = String(params.channelId || "").trim(); if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); try { delete policyState.channelPolicies[channelId]; persistPolicies(api); return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] }; } catch (err) { policyState.channelPolicies = prev; return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; } } return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; }, }, { optional: true }, ); api.on("message_received", async (event, ctx) => { try { const c = (ctx || {}) as Record; const e = (event || {}) as Record; const preChannelId = typeof c.channelId === "string" ? c.channelId : undefined; const livePre = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; if (shouldDebugLog(livePre, preChannelId)) { api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); } } 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 live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; ensurePolicyStateLoaded(api, live); const prompt = ((event as Record).prompt as string) || ""; const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); if (live.discordOnly !== false && derived.channel !== "discord") return; let rec = sessionDecision.get(key); if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { if (rec) sessionDecision.delete(key); const decision = evaluateDecision({ config: live, channel: derived.channel, channelId: derived.channelId, channelPolicies: policyState.channelPolicies, senderId: derived.senderId, content: derived.content, }); rec = { decision, createdAt: Date.now() }; sessionDecision.set(key, rec); pruneDecisionMap(); if (shouldDebugLog(live, derived.channelId)) { api.logger.info( `whispergate: debug before_model_resolve recompute session=${key} ` + `channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` + `convSenderId=${String((derived.conv as Record).sender_id ?? "")} ` + `convSender=${String((derived.conv as Record).sender ?? "")} ` + `convChannelId=${String((derived.conv as Record).channel_id ?? "")} ` + `decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, ); } } if (!rec.decision.shouldUseNoReply) return; api.logger.info( `whispergate: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`, ); return { providerOverride: live.noReplyProvider, modelOverride: live.noReplyModel, }; }); api.on("before_prompt_build", async (event, ctx) => { const key = ctx.sessionKey; if (!key) return; const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; ensurePolicyStateLoaded(api, live); let rec = sessionDecision.get(key); if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { if (rec) sessionDecision.delete(key); const prompt = ((event as Record).prompt as string) || ""; const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); const decision = evaluateDecision({ config: live, channel: derived.channel, channelId: derived.channelId, channelPolicies: policyState.channelPolicies, senderId: derived.senderId, content: derived.content, }); rec = { decision, createdAt: Date.now() }; if (shouldDebugLog(live, derived.channelId)) { api.logger.info( `whispergate: debug before_prompt_build recompute session=${key} ` + `channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` + `convSenderId=${String((derived.conv as Record).sender_id ?? "")} ` + `convSender=${String((derived.conv as Record).sender ?? "")} ` + `convChannelId=${String((derived.conv as Record).channel_id ?? "")} ` + `decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, ); } } sessionDecision.delete(key); if (!rec.decision.shouldInjectEndMarkerPrompt) { if (shouldDebugLog(live, undefined)) { api.logger.info( `whispergate: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`, ); } return; } api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`); return { prependContext: END_MARKER_INSTRUCTION }; }); }, };