diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index afdaa17..173fcfd 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -17,6 +17,7 @@ "noReplyProvider": "whisper-gateway", "noReplyModel": "no-reply", "enableDiscordControlTool": true, + "enableWhispergatePolicyTool": true, "discordControlApiBaseUrl": "http://127.0.0.1:8790", "discordControlApiToken": "", "discordControlCallerId": "agent-main" diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index e0dcfde..a428d7c 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -66,6 +66,12 @@ The script: - directory: `~/.openclaw/whispergate-install-records/` - latest pointer: `~/.openclaw/whispergate-install-record-latest.json` +Policy state semantics: +- channel policy file is loaded once into memory on startup +- runtime decisions use in-memory state +- use `whispergate_policy` tool to update state (memory first, then file persist) +- manual file edits do not auto-apply until next restart + ## Notes - Keep no-reply API bound to loopback/private network. diff --git a/plugin/README.md b/plugin/README.md index 63baa8e..c0998eb 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -30,6 +30,7 @@ Optional: - `humanList` (default []) - `agentList` (default []) - `channelPoliciesFile` (per-channel overrides in a standalone JSON file) +- `enableWhispergatePolicyTool` (default true) - `bypassUserIds` (deprecated alias of `humanList`) - `endSymbols` (default ["🔚"]) - `enableDiscordControlTool` (default true) @@ -39,6 +40,12 @@ Optional: Per-channel policy file example: `docs/channel-policies.example.json`. +Policy file behavior: +- loaded once on startup into memory +- runtime decisions read memory state only +- direct file edits do NOT affect memory state +- `whispergate_policy` tool updates memory first, then persists to file (atomic write) + ## Optional tool: `discord_control` This plugin now registers an optional tool named `discord_control`. diff --git a/plugin/index.ts b/plugin/index.ts index 1ff48f2..057caec 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,4 +1,5 @@ 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"; @@ -9,14 +10,20 @@ type DecisionRecord = { createdAt: number; }; +type PolicyState = { + filePath: string; + channelPolicies: Record; +}; + const sessionDecision = new Map(); const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; const END_MARKER_INSTRUCTION = "你的这次发言必须以🔚作为结尾。"; -let channelPoliciesCache: Record = {}; -let channelPoliciesFilePath = ""; -let channelPoliciesMtimeMs = -1; +const policyState: PolicyState = { + filePath: "", + channelPolicies: {}, +}; function normalizeChannel(ctx: Record): string { const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; @@ -70,31 +77,43 @@ function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig return fallback; } -function loadChannelPolicies(api: OpenClawPluginApi, config: WhisperGateConfig): Record { - const file = config.channelPoliciesFile; - if (!file) return {}; +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; - const resolved = api.resolvePath(file); try { - const stat = fs.statSync(resolved); - const mtime = Number(stat.mtimeMs || 0); - - if (resolved === channelPoliciesFilePath && mtime === channelPoliciesMtimeMs) { - return channelPoliciesCache; + if (!fs.existsSync(filePath)) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "{}\n", "utf8"); + policyState.channelPolicies = {}; + return; } - const raw = fs.readFileSync(resolved, "utf8"); + const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as Record; - channelPoliciesCache = parsed && typeof parsed === "object" ? parsed : {}; - channelPoliciesFilePath = resolved; - channelPoliciesMtimeMs = mtime; - return channelPoliciesCache; + policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {}; } catch (err) { - api.logger.warn(`whispergate: failed loading channelPoliciesFile=${resolved}: ${String(err)}`); - return {}; + 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)) { @@ -112,8 +131,12 @@ export default { discordControlApiBaseUrl?: string; discordControlApiToken?: string; discordControlCallerId?: string; + enableWhispergatePolicyTool?: boolean; }; + const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig); + ensurePolicyStateLoaded(api, liveAtRegister); + if (baseConfig.enableDiscordControlTool !== false) { api.registerTool( { @@ -174,24 +197,91 @@ export default { if (!r.ok) { return { - content: [ - { - type: "text", - text: `discord_control failed (${r.status}): ${text}`, - }, - ], + content: [{ type: "text", text: `discord_control failed (${r.status}): ${text}` }], isError: true, }; } - return { - content: [ - { - type: "text", - text, - }, - ], - }; + return { content: [{ type: "text", text }] }; + }, + }, + { optional: true }, + ); + } + + if (baseConfig.enableWhispergatePolicyTool !== false) { + api.registerTool( + { + name: "whispergate_policy", + description: "Manage WhisperGate in-memory channel policies and persist to file.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + action: { + type: "string", + enum: ["get", "set-channel", "delete-channel"], + }, + channelId: { type: "string" }, + 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); + ensurePolicyStateLoaded(api, live); + const action = String(params.action || ""); + + if (action === "get") { + return { + content: [ + { + type: "text", + text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2), + }, + ], + }; + } + + if (action === "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 === "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 }, @@ -211,8 +301,15 @@ export default { const channelId = typeof c.channelId === "string" ? c.channelId : undefined; const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig); - const channelPolicies = loadChannelPolicies(api, live); - const decision = evaluateDecision({ config: live, channel, channelId, channelPolicies, senderId, content }); + ensurePolicyStateLoaded(api, live); + const decision = evaluateDecision({ + config: live, + channel, + channelId, + channelPolicies: policyState.channelPolicies, + senderId, + content, + }); sessionDecision.set(sessionKey, { decision, createdAt: Date.now() }); pruneDecisionMap(); api.logger.debug?.( @@ -236,7 +333,6 @@ export default { if (!rec.decision.shouldUseNoReply) return; - // no-reply path is consumed here sessionDecision.delete(key); const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig); api.logger.info( @@ -260,15 +356,11 @@ export default { return; } - // consume non-no-reply paths here to avoid stale carry-over sessionDecision.delete(key); - if (!shouldInjectEndMarker(rec.decision.reason)) return; api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`); - return { - prependContext: END_MARKER_INSTRUCTION, - }; + return { prependContext: END_MARKER_INSTRUCTION }; }); }, }; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 1de890b..2872ff9 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -19,6 +19,7 @@ "noReplyProvider": { "type": "string" }, "noReplyModel": { "type": "string" }, "enableDiscordControlTool": { "type": "boolean", "default": true }, + "enableWhispergatePolicyTool": { "type": "boolean", "default": true }, "discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" }, "discordControlApiToken": { "type": "string" }, "discordControlCallerId": { "type": "string" }