From 682d9a336ef282483b0c58109a26927861653ab3 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 00:28:34 +0000 Subject: [PATCH] feat(policy-file): move channel overrides to standalone channelPoliciesFile with hot reload --- docs/CONFIG.example.json | 8 +----- docs/INTEGRATION.md | 3 ++- docs/channel-policies.example.json | 12 +++++++++ plugin/README.md | 4 ++- plugin/index.ts | 35 +++++++++++++++++++++++-- plugin/openclaw.plugin.json | 15 +---------- plugin/rules.ts | 24 ++++++++--------- scripts/install-whispergate-openclaw.sh | 17 ++++++++++-- 8 files changed, 79 insertions(+), 39 deletions(-) create mode 100644 docs/channel-policies.example.json diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index c1f6109..afdaa17 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -13,13 +13,7 @@ "humanList": ["561921120408698910"], "agentList": [], "endSymbols": ["πŸ”š"], - "channelPolicies": { - "1476369680632647721": { - "listMode": "agent-list", - "agentList": ["1474088632750047324"], - "endSymbols": ["πŸ”š"] - } - }, + "channelPoliciesFile": "~/.openclaw/whispergate-channel-policies.json", "noReplyProvider": "whisper-gateway", "noReplyModel": "no-reply", "enableDiscordControlTool": true, diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 07d13ae..e0dcfde 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -54,7 +54,8 @@ Environment overrides: - `LIST_MODE` (`human-list` or `agent-list`) - `HUMAN_LIST_JSON` - `AGENT_LIST_JSON` -- `CHANNEL_POLICIES_JSON` +- `CHANNEL_POLICIES_FILE` (standalone channel policy file path) +- `CHANNEL_POLICIES_JSON` (only used to initialize file when missing) - `END_SYMBOLS_JSON` The script: diff --git a/docs/channel-policies.example.json b/docs/channel-policies.example.json new file mode 100644 index 0000000..6a950a5 --- /dev/null +++ b/docs/channel-policies.example.json @@ -0,0 +1,12 @@ +{ + "1476369680632647721": { + "listMode": "agent-list", + "agentList": ["1474088632750047324"], + "endSymbols": ["πŸ”š"] + }, + "another-channel-id": { + "listMode": "human-list", + "humanList": ["561921120408698910"], + "endSymbols": ["πŸ”š"] + } +} diff --git a/plugin/README.md b/plugin/README.md index 1abc1f9..63baa8e 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -29,7 +29,7 @@ Optional: - `listMode` (`human-list` | `agent-list`, default `human-list`) - `humanList` (default []) - `agentList` (default []) -- `channelPolicies` (per-channel overrides by channelId) +- `channelPoliciesFile` (per-channel overrides in a standalone JSON file) - `bypassUserIds` (deprecated alias of `humanList`) - `endSymbols` (default ["πŸ”š"]) - `enableDiscordControlTool` (default true) @@ -37,6 +37,8 @@ Optional: - `discordControlApiToken` - `discordControlCallerId` +Per-channel policy file example: `docs/channel-policies.example.json`. + ## 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 b3cffb7..1ff48f2 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,5 +1,6 @@ +import fs from "node:fs"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { evaluateDecision, type Decision, type WhisperGateConfig } from "./rules.js"; +import { evaluateDecision, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; @@ -13,6 +14,10 @@ const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; const END_MARKER_INSTRUCTION = "δ½ ηš„θΏ™ζ¬‘ε‘θ¨€εΏ…ι‘»δ»₯πŸ”šδ½œδΈΊη»“ε°Ύγ€‚"; +let channelPoliciesCache: Record = {}; +let channelPoliciesFilePath = ""; +let channelPoliciesMtimeMs = -1; + function normalizeChannel(ctx: Record): string { const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; for (const c of candidates) { @@ -65,6 +70,31 @@ function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig return fallback; } +function loadChannelPolicies(api: OpenClawPluginApi, config: WhisperGateConfig): Record { + const file = config.channelPoliciesFile; + if (!file) return {}; + + const resolved = api.resolvePath(file); + try { + const stat = fs.statSync(resolved); + const mtime = Number(stat.mtimeMs || 0); + + if (resolved === channelPoliciesFilePath && mtime === channelPoliciesMtimeMs) { + return channelPoliciesCache; + } + + const raw = fs.readFileSync(resolved, "utf8"); + const parsed = JSON.parse(raw) as Record; + channelPoliciesCache = parsed && typeof parsed === "object" ? parsed : {}; + channelPoliciesFilePath = resolved; + channelPoliciesMtimeMs = mtime; + return channelPoliciesCache; + } catch (err) { + api.logger.warn(`whispergate: failed loading channelPoliciesFile=${resolved}: ${String(err)}`); + return {}; + } +} + function pickDefined(input: Record) { const out: Record = {}; for (const [k, v] of Object.entries(input)) { @@ -181,7 +211,8 @@ export default { const channelId = typeof c.channelId === "string" ? c.channelId : undefined; const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig); - const decision = evaluateDecision({ config: live, channel, channelId, senderId, content }); + const channelPolicies = loadChannelPolicies(api, live); + const decision = evaluateDecision({ config: live, channel, channelId, channelPolicies, senderId, content }); sessionDecision.set(sessionKey, { decision, createdAt: Date.now() }); pruneDecisionMap(); api.logger.debug?.( diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 3779378..1de890b 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -13,20 +13,7 @@ "listMode": { "type": "string", "enum": ["human-list", "agent-list"], "default": "human-list" }, "humanList": { "type": "array", "items": { "type": "string" }, "default": [] }, "agentList": { "type": "array", "items": { "type": "string" }, "default": [] }, - "channelPolicies": { - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": false, - "properties": { - "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" } } - } - }, - "default": {} - }, + "channelPoliciesFile": { "type": "string", "default": "~/.openclaw/whispergate-channel-policies.json" }, "bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] }, "endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["πŸ”š"] }, "noReplyProvider": { "type": "string" }, diff --git a/plugin/rules.ts b/plugin/rules.ts index b4797f4..4ca53b5 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -4,15 +4,7 @@ export type WhisperGateConfig = { listMode?: "human-list" | "agent-list"; humanList?: string[]; agentList?: string[]; - channelPolicies?: Record< - string, - { - listMode?: "human-list" | "agent-list"; - humanList?: string[]; - agentList?: string[]; - endSymbols?: string[]; - } - >; + channelPoliciesFile?: string; // backward compatibility bypassUserIds?: string[]; endSymbols?: string[]; @@ -20,6 +12,13 @@ export type WhisperGateConfig = { noReplyModel: string; }; +export type ChannelPolicy = { + listMode?: "human-list" | "agent-list"; + humanList?: string[]; + agentList?: string[]; + endSymbols?: string[]; +}; + export type Decision = { shouldUseNoReply: boolean; reason: string; @@ -30,7 +29,7 @@ function getLastChar(input: string): string { return t.length ? t[t.length - 1] : ""; } -function resolvePolicy(config: WhisperGateConfig, channelId?: string) { +function resolvePolicy(config: WhisperGateConfig, channelId?: string, channelPolicies?: Record) { const globalMode = config.listMode || "human-list"; const globalHuman = config.humanList || config.bypassUserIds || []; const globalAgent = config.agentList || []; @@ -40,7 +39,7 @@ function resolvePolicy(config: WhisperGateConfig, channelId?: string) { return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd }; } - const cp = config.channelPolicies || {}; + const cp = channelPolicies || {}; const scoped = cp[channelId]; if (!scoped) { return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd }; @@ -58,6 +57,7 @@ export function evaluateDecision(params: { config: WhisperGateConfig; channel?: string; channelId?: string; + channelPolicies?: Record; senderId?: string; content?: string; }): Decision { @@ -72,7 +72,7 @@ export function evaluateDecision(params: { return { shouldUseNoReply: false, reason: "non_discord" }; } - const policy = resolvePolicy(config, params.channelId); + const policy = resolvePolicy(config, params.channelId, params.channelPolicies); const mode = policy.listMode; const humanList = policy.humanList; diff --git a/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh index 4686518..aa33acd 100755 --- a/scripts/install-whispergate-openclaw.sh +++ b/scripts/install-whispergate-openclaw.sh @@ -17,6 +17,7 @@ NO_REPLY_API_KEY="${NO_REPLY_API_KEY:-wg-local-test-token}" LIST_MODE="${LIST_MODE:-human-list}" HUMAN_LIST_JSON="${HUMAN_LIST_JSON:-[\"561921120408698910\",\"1474088632750047324\"]}" AGENT_LIST_JSON="${AGENT_LIST_JSON:-[]}" +CHANNEL_POLICIES_FILE="${CHANNEL_POLICIES_FILE:-$HOME/.openclaw/whispergate-channel-policies.json}" CHANNEL_POLICIES_JSON="${CHANNEL_POLICIES_JSON:-{}}" END_SYMBOLS_JSON="${END_SYMBOLS_JSON:-[\"πŸ”š\"]}" @@ -112,6 +113,18 @@ run_install() { cp -f "$OPENCLAW_CONFIG_PATH" "$BACKUP_PATH" echo "[whispergate] backup: $BACKUP_PATH" + # initialize standalone channel policies file if missing + CHANNEL_POLICIES_FILE_RESOLVED="$(python3 - <<'PY' +import os +print(os.path.expanduser(os.environ['CHANNEL_POLICIES_FILE'])) +PY +)" + if [[ ! -f "$CHANNEL_POLICIES_FILE_RESOLVED" ]]; then + mkdir -p "$(dirname "$CHANNEL_POLICIES_FILE_RESOLVED")" + printf '%s\n' "$CHANNEL_POLICIES_JSON" > "$CHANNEL_POLICIES_FILE_RESOLVED" + echo "[whispergate] initialized channel policies file: $CHANNEL_POLICIES_FILE_RESOLVED" + fi + local prev_paths_json prev_paths_json="$(PATH_PLUGINS_LOAD="$PATH_PLUGINS_LOAD" PATH_PLUGIN_ENTRY="$PATH_PLUGIN_ENTRY" PROVIDER_PATH="$PROVIDER_PATH" python3 - <<'PY' import json, os, subprocess @@ -139,7 +152,7 @@ PY current_plugins_json='{}' fi - new_plugins_json="$(CURRENT_PLUGINS_JSON="$current_plugins_json" PLUGIN_PATH="$PLUGIN_PATH" LIST_MODE="$LIST_MODE" HUMAN_LIST_JSON="$HUMAN_LIST_JSON" AGENT_LIST_JSON="$AGENT_LIST_JSON" CHANNEL_POLICIES_JSON="$CHANNEL_POLICIES_JSON" END_SYMBOLS_JSON="$END_SYMBOLS_JSON" NO_REPLY_PROVIDER_ID="$NO_REPLY_PROVIDER_ID" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' + new_plugins_json="$(CURRENT_PLUGINS_JSON="$current_plugins_json" PLUGIN_PATH="$PLUGIN_PATH" LIST_MODE="$LIST_MODE" HUMAN_LIST_JSON="$HUMAN_LIST_JSON" AGENT_LIST_JSON="$AGENT_LIST_JSON" CHANNEL_POLICIES_FILE="$CHANNEL_POLICIES_FILE" END_SYMBOLS_JSON="$END_SYMBOLS_JSON" NO_REPLY_PROVIDER_ID="$NO_REPLY_PROVIDER_ID" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' import json, os plugins=json.loads(os.environ['CURRENT_PLUGINS_JSON']) if not isinstance(plugins,dict): @@ -163,7 +176,7 @@ entries['whispergate']={ 'listMode': os.environ['LIST_MODE'], 'humanList': json.loads(os.environ['HUMAN_LIST_JSON']), 'agentList': json.loads(os.environ['AGENT_LIST_JSON']), - 'channelPolicies': json.loads(os.environ['CHANNEL_POLICIES_JSON']), + 'channelPoliciesFile': os.environ['CHANNEL_POLICIES_FILE'], 'endSymbols': json.loads(os.environ['END_SYMBOLS_JSON']), 'noReplyProvider': os.environ['NO_REPLY_PROVIDER_ID'], 'noReplyModel': os.environ['NO_REPLY_MODEL_ID'],