diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e9224..6150ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ - Added containerization (`Dockerfile`, `docker-compose.yml`) - Added helper scripts for smoke/dev lifecycle and rule validation - Added no-touch config rendering and integration docs +- Added installer script with rollback (`scripts/install-whispergate-openclaw.sh`) + - supports `--install` / `--uninstall` + - uninstall restores all recorded changes + - writes install/uninstall records under `~/.openclaw/whispergate-install-records/` - Added discord-control-api with: - `channel-private-create` (create private channel for allowlist) - `channel-private-update` (update allowlist/overwrites for existing channel) diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index 4556397..d14f120 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -1,7 +1,7 @@ { "plugins": { "load": { - "paths": ["/path/to/WhisperGate/plugin"] + "paths": ["/path/to/WhisperGate/dist/whispergate"] }, "entries": { "whispergate": { @@ -9,20 +9,52 @@ "config": { "enabled": true, "discordOnly": true, - "bypassUserIds": ["561921120408698910"], - "endSymbols": ["。", "!", "?", ".", "!", "?"], - "noReplyProvider": "openai", - "noReplyModel": "whispergate-no-reply-v1" + "listMode": "human-list", + "humanList": ["561921120408698910"], + "agentList": [], + "endSymbols": ["🔚"], + "channelPoliciesFile": "~/.openclaw/whispergate-channel-policies.json", + "noReplyProvider": "whisper-gateway", + "noReplyModel": "no-reply", + "enableDiscordControlTool": true, + "enableWhispergatePolicyTool": true, + "enableDebugLogs": false, + "debugLogChannelIds": [], + "discordControlApiBaseUrl": "http://127.0.0.1:8790", + "discordControlApiToken": "", + "discordControlCallerId": "agent-main" } } } }, "models": { "providers": { - "openai": { - "apiKey": "", - "baseURL": "http://127.0.0.1:8787/v1" + "whisper-gateway": { + "apiKey": "", + "baseUrl": "http://127.0.0.1:8787/v1", + "api": "openai-completions", + "models": [ + { + "id": "no-reply", + "name": "No Reply", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 200000, + "maxTokens": 8192 + } + ] } } + }, + "agents": { + "list": [ + { + "id": "main", + "tools": { + "allow": ["whispergate"] + } + } + ] } } diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md index 58daa3a..0df2780 100644 --- a/docs/DISCORD_CONTROL.md +++ b/docs/DISCORD_CONTROL.md @@ -2,6 +2,9 @@ 目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力: +> 现在可以通过 WhisperGate 插件内置的可选工具 `discord_control` 直接调用(无需手写 curl)。 +> 注意:该工具是 optional,需要在 agent tools allowlist 中显式允许(例如允许 `whispergate` 或 `discord_control`)。 + 1. 创建指定名单可见的私人频道 2. 查看 server 成员列表(分页) diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 8bc5a18..9e3d239 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -27,8 +27,57 @@ The script prints JSON for: You can merge this snippet manually into your `openclaw.json`. +## Installer script (with rollback) + +For production-like install with automatic rollback on error (Node-only installer): + +```bash +node ./scripts/install-whispergate-openclaw.mjs --install +# or wrapper +./scripts/install-whispergate-openclaw.sh --install +``` + +Uninstall (revert all recorded config changes): + +```bash +node ./scripts/install-whispergate-openclaw.mjs --uninstall +# or wrapper +./scripts/install-whispergate-openclaw.sh --uninstall +# or specify a record explicitly +# RECORD_FILE=~/.openclaw/whispergate-install-records/whispergate-YYYYmmddHHMMSS.json \ +# node ./scripts/install-whispergate-openclaw.mjs --uninstall +``` + +Environment overrides: + +- `PLUGIN_PATH` +- `NO_REPLY_PROVIDER_ID` +- `NO_REPLY_MODEL_ID` +- `NO_REPLY_BASE_URL` +- `NO_REPLY_API_KEY` +- `LIST_MODE` (`human-list` or `agent-list`) +- `HUMAN_LIST_JSON` +- `AGENT_LIST_JSON` +- `CHANNEL_POLICIES_FILE` (standalone channel policy file path) +- `CHANNEL_POLICIES_JSON` (only used to initialize file when missing) +- `END_SYMBOLS_JSON` + +The script: +- writes via `openclaw config set ... --json` +- creates config backup first +- restores backup automatically if any install step fails +- restarts gateway during install, then validates `whisper-gateway/no-reply` is visible via `openclaw models list/status` +- writes a change record for every install/uninstall: + - 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 -- This repo does not run config mutation commands. - Keep no-reply API bound to loopback/private network. - If you use API auth, set `AUTH_TOKEN` and align provider apiKey usage. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 6c98b8c..8d646d1 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -8,17 +8,17 @@ node scripts/package-plugin.mjs Output: -- `dist/plugin/index.ts` -- `dist/plugin/rules.ts` -- `dist/plugin/openclaw.plugin.json` -- `dist/plugin/README.md` -- `dist/plugin/package.json` +- `dist/whispergate/index.ts` +- `dist/whispergate/rules.ts` +- `dist/whispergate/openclaw.plugin.json` +- `dist/whispergate/README.md` +- `dist/whispergate/package.json` ## Use packaged plugin path Point OpenClaw `plugins.load.paths` to: -`/absolute/path/to/WhisperGate/dist/plugin` +`/absolute/path/to/WhisperGate/dist/whispergate` ## Verify package completeness diff --git a/docs/ROLLOUT.md b/docs/ROLLOUT.md index 533d71f..48a60ee 100644 --- a/docs/ROLLOUT.md +++ b/docs/ROLLOUT.md @@ -10,14 +10,14 @@ - Enable plugin with: - `discordOnly=true` - - narrow `bypassUserIds` + - `listMode=human-list` with narrow `humanList` (or `agent-list` with narrow `agentList`) - strict `endSymbols` - Point no-reply provider/model to local API - Verify 4 rule paths in `docs/VERIFY.md` ## Stage 2: Wider channel rollout -- Expand `bypassUserIds` and symbol list based on canary outcomes +- Expand `humanList`/`agentList` and symbol list based on canary outcomes - Monitor false-silent turns - Keep fallback model available diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md new file mode 100644 index 0000000..ad8aa43 --- /dev/null +++ b/docs/TEST_REPORT.md @@ -0,0 +1,130 @@ +# WhisperGate 测试记录报告(阶段性) + +日期:2026-02-25 + +## 一、测试范围 + +本轮覆盖: + +1. WhisperGate 基础静态与脚本测试 +2. no-reply-api 隔离集成测试 +3. discord-control-api 功能测试(dryRun + 实操) + +未覆盖: + +- WhisperGate 插件真实挂载 OpenClaw 后的端到端(E2E) + +--- + +## 二、测试环境 + +- 代码仓库:`/root/.openclaw/workspace-operator/WhisperGate` +- OpenClaw 配置来源:本机已有配置(读取 Discord token) +- Discord guild(server)ID:`1368531017534537779` +- allowlist user IDs: + - `561921120408698910` + - `1474088632750047324` + +--- + +## 三、已执行测试与结果 + +### A. WhisperGate 基础测试 + +命令: + +```bash +make check check-rules test-api +``` + +结果: + +- `make check` ✅ + - 输出:`plugin file check: ok` +- `make check-rules` ✅ + - 4 条规则用例全部通过 +- `make test-api` ✅ + - 输出:`test-no-reply-api: ok` + +结论: +- 插件文件结构完整 +- 规则决策逻辑正确 +- no-reply API 基础行为正常 + +--- + +### B. discord-control-api dryRun + 实操测试 + +执行内容与结果: + +1) `channel-private-create`(dryRun)✅ +2) `channel-private-create`(真实创建)✅ +- 生成频道 ID:`1476341726108192919` +3) `channel-private-update`(dryRun)✅ +4) `member-list`(真实查询)✅ +- `limit=2`,字段裁剪 `user.id,user.username` +- 返回样例用户:`561921120408698910 / hangman0414` +5) `channel-private-update`(真实更新)✅ + +清理: + +- 直接 Discord REST 删除频道时遇到:`HTTP 403 / code 1010` +- 改用 OpenClaw 内置 `channel-delete` 删除成功 ✅ + - 删除频道:`1476341726108192919` + +结论: +- 两个新增能力已完成核心实测: + - 私密频道创建/更新 + - 成员列表查询 +- 功能在当前环境可用 + +--- + +## 四、问题与处理 + +问题: +- 直接调用 Discord REST 删除临时频道出现 `403 / code 1010` + +处理: +- 使用 OpenClaw 内置工具 `channel-delete` 成功清理 + +说明: +- 不影响本次新增功能有效性 + +--- + +## 五、待测项(下一阶段) + +### 1) WhisperGate 插件 E2E(需临时接入 OpenClaw 配置) + +目标:验证插件真实挂载后的完整链路。 + +待测场景: + +- 场景 1:非 Discord 消息 -> 不触发 no-reply +- 场景 2:Discord + 白名单发送者 -> 注入 `🔚` 指令 +- 场景 3:Discord + 结束符消息 -> 注入 `🔚` 指令 +- 场景 4:Discord + 非结束符且非白名单 -> 走 no-reply override + +验收要点: +- `before_model_resolve` 命中时 provider/model 确实被覆盖 +- no-reply provider 返回 `NO_REPLY` +- 决策 TTL/one-shot 不串轮 + +### 2) 回归测试 + +- discord-control-api 引入后,不影响 WhisperGate 原有流程 +- 规则校验脚本在最新代码继续稳定通过 + +### 3) 运行与安全校验 + +- `AUTH_TOKEN` + `REQUIRE_AUTH_TOKEN=true` 场景下鉴权验证 +- `ALLOWED_GUILD_IDS` / `ALLOWED_CALLER_IDS` 拒绝路径验证 +- 大响应保护(`MAX_MEMBER_RESPONSE_BYTES`)触发与提示验证 + +--- + +## 六、当前结论 + +- 新增 Discord 管控能力(私密频道 + 成员列表)已完成核心测试,可用。 +- 项目剩余主要测试工作集中在 WhisperGate 插件与 OpenClaw 的真实 E2E 联调。 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/no-reply-api/package-lock.json b/no-reply-api/package-lock.json new file mode 100644 index 0000000..3e29cb5 --- /dev/null +++ b/no-reply-api/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "whispergate-no-reply-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "whispergate-no-reply-api", + "version": "0.1.0" + } + } +} diff --git a/plugin/README.md b/plugin/README.md index 2373162..db01bbf 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -26,5 +26,45 @@ Required: Optional: - `enabled` (default true) - `discordOnly` (default true) -- `bypassUserIds` (default []) -- `endSymbols` (default punctuation set) +- `listMode` (`human-list` | `agent-list`, default `human-list`) +- `humanList` (default []) +- `agentList` (default []) +- `channelPoliciesFile` (per-channel overrides in a standalone JSON file) +- `enableWhispergatePolicyTool` (default true) + +Unified optional tool: +- `whispergateway_tools` + - Discord actions: `channel-private-create`, `channel-private-update`, `member-list` + - Policy actions: `policy-get`, `policy-set-channel`, `policy-delete-channel` +- `bypassUserIds` (deprecated alias of `humanList`) +- `endSymbols` (default ["🔚"]) +- `enableDiscordControlTool` (default true) +- `discordControlApiBaseUrl` (default `http://127.0.0.1:8790`) +- `discordControlApiToken` +- `discordControlCallerId` +- `enableDebugLogs` (default false) +- `debugLogChannelIds` (default [], empty = all channels when debug enabled) + +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 +- `whispergateway_tools` policy actions update memory first, then persist to file (atomic write) + +## Optional tool: `whispergateway_tools` + +This plugin registers one unified optional tool: `whispergateway_tools`. +To use it, add tool allowlist entry for either: +- tool name: `whispergateway_tools` +- plugin id: `whispergate` + +Supported actions: +- Discord: `channel-private-create`, `channel-private-update`, `member-list` +- Policy: `policy-get`, `policy-set-channel`, `policy-delete-channel` + +Debug logging: +- set `enableDebugLogs: true` to emit detailed hook diagnostics +- optionally set `debugLogChannelIds` to only log selected channel IDs +- logs include key ctx fields + decision status at `message_received`, `before_model_resolve`, `before_prompt_build` diff --git a/plugin/index.ts b/plugin/index.ts index 5310078..20ac75e 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,15 +1,38 @@ +import fs from "node:fs"; +import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { evaluateDecision, type Decision, type WhisperGateConfig } from "./rules.js"; +import { evaluateDecision, resolvePolicy, 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; + needsRestore?: boolean; +}; + +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 = "你的这次发言必须以🔚作为结尾。"; +function buildEndMarkerInstruction(endSymbols: string[]): string { + const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚"; + return `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`; +} + +const policyState: PolicyState = { + filePath: "", + channelPolicies: {}, +}; function normalizeChannel(ctx: Record): string { const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; @@ -35,6 +58,46 @@ function normalizeSender(event: Record, ctx: 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); @@ -49,83 +112,410 @@ function pruneDecisionMap(now = Date.now()) { } } -function shouldInjectEndMarker(reason: string): boolean { - return reason === "bypass_sender" || reason.startsWith("end_symbol:"); + +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) { + // Merge with defaults to ensure optional fields have values + return { + enableDiscordControlTool: true, + enableWhispergatePolicyTool: true, + discordControlApiBaseUrl: "http://127.0.0.1:8790", + enableDebugLogs: false, + debugLogChannelIds: [], + ...cfg, + } 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 true; // 允许打印,方便排查 channelId 为空的场景 + 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 config = (api.pluginConfig || {}) as WhisperGateConfig; + // Merge pluginConfig with defaults (in case config is missing from openclaw.json) + const baseConfig = { + enableDiscordControlTool: true, + enableWhispergatePolicyTool: true, + discordControlApiBaseUrl: "http://127.0.0.1:8790", + ...(api.pluginConfig || {}), + } as WhisperGateConfig & { + enableDiscordControlTool: boolean; + discordControlApiBaseUrl: string; + discordControlApiToken?: string; + discordControlCallerId?: string; + enableWhispergatePolicyTool: boolean; + }; - api.registerHook("message:received", async (event, ctx) => { + 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: false }, + ); + + api.on("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}`, - ); + 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) => { + 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); + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + ensurePolicyStateLoaded(api, live); + + const prompt = ((event as Record).prompt as string) || ""; + + if (live.enableDebugLogs) { + api.logger.info( + `whispergate: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` + + `promptPreview=${prompt.slice(0, 300)}`, + ); + } + + const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); + // Only proceed if: discord channel AND prompt contains untrusted metadata + const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); + if (live.discordOnly !== false && (!hasConvMarker || 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) { + // 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型 + if (rec.needsRestore) { + sessionDecision.delete(key); + return { + providerOverride: undefined, + modelOverride: undefined, + }; + } return; } - if (!rec.decision.shouldUseNoReply) return; + // 标记这次执行了 no-reply,下次需要恢复模型 + rec.needsRestore = true; + sessionDecision.set(key, rec); + + // 无论是否有缓存,只要 debug flag 开启就打印决策详情 + if (live.enableDebugLogs) { + const prompt = ((event as Record).prompt as string) || ""; + const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); + api.logger.info( + `whispergate: DEBUG_NO_REPLY_TRIGGER 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 ?? "")} ` + + `decision=${rec.decision.reason} ` + + `shouldNoReply=${rec.decision.shouldUseNoReply} shouldInject=${rec.decision.shouldInjectEndMarkerPrompt} ` + + `hasConvMarker=${hasConvMarker} promptLen=${prompt.length}`, + ); + } - // no-reply path is consumed here - sessionDecision.delete(key); api.logger.info( - `whispergate: override model for session=${key}, provider=${config.noReplyProvider}, model=${config.noReplyModel}, reason=${rec.decision.reason}`, + `whispergate: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`, ); return { - providerOverride: config.noReplyProvider, - modelOverride: config.noReplyModel, + providerOverride: live.noReplyProvider, + modelOverride: live.noReplyModel, }; }); - api.on("before_prompt_build", async (_event, ctx) => { + api.on("before_prompt_build", 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); + 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; } - // consume non-no-reply paths here to avoid stale carry-over - sessionDecision.delete(key); - - if (!shouldInjectEndMarker(rec.decision.reason)) return; + // Resolve end symbols from config/policy for dynamic instruction + const prompt = ((event as Record).prompt as string) || ""; + const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); + const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies); + const instruction = buildEndMarkerInstruction(policy.endSymbols); api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`); - return { - prependContext: END_MARKER_INSTRUCTION, - }; + return { prependContext: instruction }; }); }, }; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index bfba37e..d10f3e5 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -10,10 +10,21 @@ "properties": { "enabled": { "type": "boolean", "default": true }, "discordOnly": { "type": "boolean", "default": true }, + "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": [] }, + "channelPoliciesFile": { "type": "string", "default": "~/.openclaw/whispergate-channel-policies.json" }, "bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] }, - "endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["。", "!", "?", ".", "!", "?"] }, + "endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] }, "noReplyProvider": { "type": "string" }, - "noReplyModel": { "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" }, + "enableDebugLogs": { "type": "boolean", "default": false }, + "debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] } }, "required": ["noReplyProvider", "noReplyModel"] } diff --git a/plugin/rules.ts b/plugin/rules.ts index 9fafcc2..ca00593 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -1,47 +1,132 @@ export type WhisperGateConfig = { enabled?: boolean; discordOnly?: boolean; + listMode?: "human-list" | "agent-list"; + humanList?: string[]; + agentList?: string[]; + channelPoliciesFile?: string; + // backward compatibility bypassUserIds?: string[]; endSymbols?: string[]; noReplyProvider: string; noReplyModel: string; }; +export type ChannelPolicy = { + listMode?: "human-list" | "agent-list"; + humanList?: string[]; + agentList?: string[]; + endSymbols?: string[]; +}; + export type Decision = { shouldUseNoReply: boolean; + shouldInjectEndMarkerPrompt: boolean; reason: string; }; +/** + * Strip trailing OpenClaw metadata blocks from the prompt to get the actual message content. + * The prompt format is: [prepend instruction]\n\n[message]\n\nConversation info (untrusted metadata):\n```json\n{...}\n```\n\nSender (untrusted metadata):\n```json\n{...}\n``` + */ +function stripTrailingMetadata(input: string): string { + // Remove all trailing "XXX (untrusted metadata):\n```json\n...\n```" blocks + let text = input; + // eslint-disable-next-line no-constant-condition + while (true) { + const m = text.match(/\n*[A-Z][^\n]*\(untrusted metadata\):\s*```json\s*[\s\S]*?```\s*$/); + if (!m) break; + text = text.slice(0, text.length - m[0].length); + } + return text; +} + function getLastChar(input: string): string { - const t = input.trim(); - return t.length ? t[t.length - 1] : ""; + const t = stripTrailingMetadata(input).trim(); + if (!t.length) return ""; + // Use Array.from to handle multi-byte characters (emoji, surrogate pairs) + const chars = Array.from(t); + return chars[chars.length - 1] || ""; +} + +export function resolvePolicy(config: WhisperGateConfig, channelId?: string, channelPolicies?: Record) { + const globalMode = config.listMode || "human-list"; + const globalHuman = config.humanList || config.bypassUserIds || []; + const globalAgent = config.agentList || []; + const globalEnd = config.endSymbols || ["🔚"]; + + if (!channelId) { + return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd }; + } + + const cp = channelPolicies || {}; + const scoped = cp[channelId]; + if (!scoped) { + return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd }; + } + + return { + listMode: scoped.listMode || globalMode, + humanList: scoped.humanList || globalHuman, + agentList: scoped.agentList || globalAgent, + endSymbols: scoped.endSymbols || globalEnd, + }; } export function evaluateDecision(params: { config: WhisperGateConfig; channel?: string; + channelId?: string; + channelPolicies?: Record; senderId?: string; content?: string; }): Decision { const { config } = params; if (config.enabled === false) { - return { shouldUseNoReply: false, reason: "disabled" }; + return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "disabled" }; } const channel = (params.channel || "").toLowerCase(); if (config.discordOnly !== false && channel !== "discord") { - return { shouldUseNoReply: false, reason: "non_discord" }; + return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "non_discord" }; } - if (params.senderId && (config.bypassUserIds || []).includes(params.senderId)) { - return { shouldUseNoReply: false, reason: "bypass_sender" }; + // DM bypass: if no conversation info was found in the prompt (no senderId AND no channelId), + // this is a DM session where untrusted metadata is not injected. Always allow through. + if (!params.senderId && !params.channelId) { + return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "dm_no_metadata_bypass" }; } + const policy = resolvePolicy(config, params.channelId, params.channelPolicies); + + const mode = policy.listMode; + const humanList = policy.humanList; + const agentList = policy.agentList; + + const senderId = params.senderId || ""; + const inHumanList = !!senderId && humanList.includes(senderId); + const inAgentList = !!senderId && agentList.includes(senderId); + const lastChar = getLastChar(params.content || ""); - if (lastChar && (config.endSymbols || []).includes(lastChar)) { - return { shouldUseNoReply: false, reason: `end_symbol:${lastChar}` }; + const hasEnd = !!lastChar && policy.endSymbols.includes(lastChar); + + if (mode === "human-list") { + if (inHumanList) { + return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "human_list_sender" }; + } + if (hasEnd) { + return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: `end_symbol:${lastChar}` }; + } + return { shouldUseNoReply: true, shouldInjectEndMarkerPrompt: false, reason: "rule_match_no_end_symbol" }; } - return { shouldUseNoReply: true, reason: "rule_match_no_end_symbol" }; + // agent-list mode: listed senders require end symbol; others bypass requirement. + if (!inAgentList) { + return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "non_agent_list_sender" }; + } + if (hasEnd) { + return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: `end_symbol:${lastChar}` }; + } + return { shouldUseNoReply: true, shouldInjectEndMarkerPrompt: false, reason: "agent_list_missing_end_symbol" }; } diff --git a/scripts/install-whispergate-openclaw.mjs b/scripts/install-whispergate-openclaw.mjs new file mode 100755 index 0000000..be64876 --- /dev/null +++ b/scripts/install-whispergate-openclaw.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { execFileSync } from "node:child_process"; + +const modeArg = process.argv[2]; +if (modeArg !== "--install" && modeArg !== "--uninstall") { + console.error("Usage: install-whispergate-openclaw.mjs --install | --uninstall"); + process.exit(2); +} +const mode = modeArg === "--install" ? "install" : "uninstall"; + +const env = process.env; +const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json"); +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const PLUGIN_PATH = env.PLUGIN_PATH || path.resolve(__dirname, "..", "dist", "whispergate"); +const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "whisper-gateway"; +const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply"; +const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1"; +const NO_REPLY_API_KEY = env.NO_REPLY_API_KEY || "wg-local-test-token"; +const LIST_MODE = env.LIST_MODE || "human-list"; +const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]'; +const AGENT_LIST_JSON = env.AGENT_LIST_JSON || "[]"; +const CHANNEL_POLICIES_FILE = (env.CHANNEL_POLICIES_FILE || "~/.openclaw/whispergate-channel-policies.json").replace(/^~(?=$|\/)/, os.homedir()); +const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}"; +const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]'; + +const STATE_DIR = (env.STATE_DIR || "~/.openclaw/whispergate-install-records").replace(/^~(?=$|\/)/, os.homedir()); +const LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/whispergate-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir()); + +const ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14); +const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-whispergate-${mode}-${ts}`; +const RECORD_PATH = path.join(STATE_DIR, `whispergate-${ts}.json`); + +const PATH_PLUGINS_LOAD = "plugins.load.paths"; +const PATH_PLUGIN_ENTRY = "plugins.entries.whispergate"; +const PATH_PROVIDERS = "models.providers"; + +function runOpenclaw(args, { allowFail = false } = {}) { + try { + return execFileSync("openclaw", args, { encoding: "utf8" }).trim(); + } catch (e) { + if (allowFail) return null; + throw e; + } +} + + +function getJson(pathKey) { + const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true }); + if (out == null || out === "") return { exists: false }; + return { exists: true, value: JSON.parse(out) }; +} + +function setJson(pathKey, value) { + runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); +} + +function unsetPath(pathKey) { + runOpenclaw(["config", "unset", pathKey], { allowFail: true }); +} + +function writeRecord(modeName, before, after) { + fs.mkdirSync(STATE_DIR, { recursive: true }); + const rec = { + mode: modeName, + timestamp: ts, + openclawConfigPath: OPENCLAW_CONFIG_PATH, + backupPath: BACKUP_PATH, + paths: before, + applied: after, + }; + fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2)); + fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK); +} + +function readRecord(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function findLatestInstallRecord() { + if (!fs.existsSync(STATE_DIR)) return ""; + const files = fs + .readdirSync(STATE_DIR) + .filter((f) => /^whispergate-\d+\.json$/.test(f)) + .sort() + .reverse(); + for (const f of files) { + const p = path.join(STATE_DIR, f); + try { + const rec = readRecord(p); + if (rec?.mode === "install") return p; + } catch { + // ignore broken records + } + } + return ""; +} + +if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) { + console.error(`[whispergate] config not found: ${OPENCLAW_CONFIG_PATH}`); + process.exit(1); +} + +if (mode === "install") { + fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); + console.log(`[whispergate] backup: ${BACKUP_PATH}`); + + if (!fs.existsSync(CHANNEL_POLICIES_FILE)) { + fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true }); + fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`); + console.log(`[whispergate] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`); + } + + const before = { + [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), + [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), + [PATH_PROVIDERS]: getJson(PATH_PROVIDERS), + }; + + try { + const pluginsNow = getJson("plugins").value || {}; + const plugins = typeof pluginsNow === "object" ? pluginsNow : {}; + plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {}; + const paths = Array.isArray(plugins.load.paths) ? plugins.load.paths : []; + if (!paths.includes(PLUGIN_PATH)) paths.push(PLUGIN_PATH); + plugins.load.paths = paths; + plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {}; + plugins.entries.whispergate = { + enabled: true, + config: { + enabled: true, + discordOnly: true, + listMode: LIST_MODE, + humanList: JSON.parse(HUMAN_LIST_JSON), + agentList: JSON.parse(AGENT_LIST_JSON), + channelPoliciesFile: CHANNEL_POLICIES_FILE, + endSymbols: JSON.parse(END_SYMBOLS_JSON), + noReplyProvider: NO_REPLY_PROVIDER_ID, + noReplyModel: NO_REPLY_MODEL_ID, + }, + }; + setJson("plugins", plugins); + + const providersNow = getJson(PATH_PROVIDERS).value || {}; + const providers = typeof providersNow === "object" ? providersNow : {}; + providers[NO_REPLY_PROVIDER_ID] = { + baseUrl: NO_REPLY_BASE_URL, + apiKey: NO_REPLY_API_KEY, + api: "openai-completions", + models: [ + { + id: NO_REPLY_MODEL_ID, + name: `${NO_REPLY_MODEL_ID} (Custom Provider)`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + }, + ], + }; + setJson(PATH_PROVIDERS, providers); + + const after = { + [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), + [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), + [PATH_PROVIDERS]: getJson(PATH_PROVIDERS), + }; + writeRecord("install", before, after); + console.log("[whispergate] install ok (config written)"); + console.log(`[whispergate] record: ${RECORD_PATH}`); + console.log("[whispergate] >>> restart gateway to apply: openclaw gateway restart"); + } catch (e) { + fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); + console.error(`[whispergate] install failed; rollback complete: ${String(e)}`); + process.exit(1); + } +} else { + const recFile = env.RECORD_FILE || findLatestInstallRecord(); + if (!recFile || !fs.existsSync(recFile)) { + console.error("[whispergate] no install record found. set RECORD_FILE= to an install record."); + process.exit(1); + } + + fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); + console.log(`[whispergate] backup before uninstall: ${BACKUP_PATH}`); + + const rec = readRecord(recFile); + const before = rec.applied || {}; + const target = rec.paths || {}; + + try { + const pluginsNow = getJson("plugins").value || {}; + const plugins = typeof pluginsNow === "object" ? pluginsNow : {}; + plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {}; + plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {}; + + if (target[PATH_PLUGINS_LOAD]?.exists) plugins.load.paths = target[PATH_PLUGINS_LOAD].value; + else delete plugins.load.paths; + + if (target[PATH_PLUGIN_ENTRY]?.exists) plugins.entries.whispergate = target[PATH_PLUGIN_ENTRY].value; + else delete plugins.entries.whispergate; + + setJson("plugins", plugins); + + if (target[PATH_PROVIDERS]?.exists) setJson(PATH_PROVIDERS, target[PATH_PROVIDERS].value); + else unsetPath(PATH_PROVIDERS); + + const after = { + [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), + [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), + [PATH_PROVIDERS]: getJson(PATH_PROVIDERS), + }; + writeRecord("uninstall", before, after); + console.log("[whispergate] uninstall ok"); + console.log(`[whispergate] record: ${RECORD_PATH}`); + } catch (e) { + fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); + console.error(`[whispergate] uninstall failed; rollback complete: ${String(e)}`); + process.exit(1); + } +} diff --git a/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh new file mode 100755 index 0000000..8ba5a65 --- /dev/null +++ b/scripts/install-whispergate-openclaw.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec node "$SCRIPT_DIR/install-whispergate-openclaw.mjs" "$@" diff --git a/scripts/package-plugin.mjs b/scripts/package-plugin.mjs index 46e6326..9e65873 100644 --- a/scripts/package-plugin.mjs +++ b/scripts/package-plugin.mjs @@ -3,7 +3,7 @@ import path from "node:path"; const root = process.cwd(); const pluginDir = path.join(root, "plugin"); -const outDir = path.join(root, "dist", "plugin"); +const outDir = path.join(root, "dist", "whispergate"); fs.rmSync(outDir, { recursive: true, force: true }); fs.mkdirSync(outDir, { recursive: true });