Merge pull request 'fix: add default values for optional config fields' (#3) from feat/whispergate-mvp into main
Reviewed-on: orion/WhisperGate#3
This commit was merged in pull request #3.
This commit is contained in:
@@ -9,6 +9,10 @@
|
|||||||
- Added containerization (`Dockerfile`, `docker-compose.yml`)
|
- Added containerization (`Dockerfile`, `docker-compose.yml`)
|
||||||
- Added helper scripts for smoke/dev lifecycle and rule validation
|
- Added helper scripts for smoke/dev lifecycle and rule validation
|
||||||
- Added no-touch config rendering and integration docs
|
- 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:
|
- Added discord-control-api with:
|
||||||
- `channel-private-create` (create private channel for allowlist)
|
- `channel-private-create` (create private channel for allowlist)
|
||||||
- `channel-private-update` (update allowlist/overwrites for existing channel)
|
- `channel-private-update` (update allowlist/overwrites for existing channel)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"load": {
|
"load": {
|
||||||
"paths": ["/path/to/WhisperGate/plugin"]
|
"paths": ["/path/to/WhisperGate/dist/whispergate"]
|
||||||
},
|
},
|
||||||
"entries": {
|
"entries": {
|
||||||
"whispergate": {
|
"whispergate": {
|
||||||
@@ -9,20 +9,52 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"discordOnly": true,
|
"discordOnly": true,
|
||||||
"bypassUserIds": ["561921120408698910"],
|
"listMode": "human-list",
|
||||||
"endSymbols": ["。", "!", "?", ".", "!", "?"],
|
"humanList": ["561921120408698910"],
|
||||||
"noReplyProvider": "openai",
|
"agentList": [],
|
||||||
"noReplyModel": "whispergate-no-reply-v1"
|
"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": "<DISCORD_CONTROL_AUTH_TOKEN>",
|
||||||
|
"discordControlCallerId": "agent-main"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"providers": {
|
"providers": {
|
||||||
"openai": {
|
"whisper-gateway": {
|
||||||
"apiKey": "<AUTH_TOKEN_OR_PLACEHOLDER>",
|
"apiKey": "<NO_REPLY_API_TOKEN_OR_PLACEHOLDER>",
|
||||||
"baseURL": "http://127.0.0.1:8787/v1"
|
"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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力:
|
目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力:
|
||||||
|
|
||||||
|
> 现在可以通过 WhisperGate 插件内置的可选工具 `discord_control` 直接调用(无需手写 curl)。
|
||||||
|
> 注意:该工具是 optional,需要在 agent tools allowlist 中显式允许(例如允许 `whispergate` 或 `discord_control`)。
|
||||||
|
|
||||||
1. 创建指定名单可见的私人频道
|
1. 创建指定名单可见的私人频道
|
||||||
2. 查看 server 成员列表(分页)
|
2. 查看 server 成员列表(分页)
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,57 @@ The script prints JSON for:
|
|||||||
|
|
||||||
You can merge this snippet manually into your `openclaw.json`.
|
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
|
## Notes
|
||||||
|
|
||||||
- This repo does not run config mutation commands.
|
|
||||||
- Keep no-reply API bound to loopback/private network.
|
- Keep no-reply API bound to loopback/private network.
|
||||||
- If you use API auth, set `AUTH_TOKEN` and align provider apiKey usage.
|
- If you use API auth, set `AUTH_TOKEN` and align provider apiKey usage.
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ node scripts/package-plugin.mjs
|
|||||||
|
|
||||||
Output:
|
Output:
|
||||||
|
|
||||||
- `dist/plugin/index.ts`
|
- `dist/whispergate/index.ts`
|
||||||
- `dist/plugin/rules.ts`
|
- `dist/whispergate/rules.ts`
|
||||||
- `dist/plugin/openclaw.plugin.json`
|
- `dist/whispergate/openclaw.plugin.json`
|
||||||
- `dist/plugin/README.md`
|
- `dist/whispergate/README.md`
|
||||||
- `dist/plugin/package.json`
|
- `dist/whispergate/package.json`
|
||||||
|
|
||||||
## Use packaged plugin path
|
## Use packaged plugin path
|
||||||
|
|
||||||
Point OpenClaw `plugins.load.paths` to:
|
Point OpenClaw `plugins.load.paths` to:
|
||||||
|
|
||||||
`/absolute/path/to/WhisperGate/dist/plugin`
|
`/absolute/path/to/WhisperGate/dist/whispergate`
|
||||||
|
|
||||||
## Verify package completeness
|
## Verify package completeness
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@
|
|||||||
|
|
||||||
- Enable plugin with:
|
- Enable plugin with:
|
||||||
- `discordOnly=true`
|
- `discordOnly=true`
|
||||||
- narrow `bypassUserIds`
|
- `listMode=human-list` with narrow `humanList` (or `agent-list` with narrow `agentList`)
|
||||||
- strict `endSymbols`
|
- strict `endSymbols`
|
||||||
- Point no-reply provider/model to local API
|
- Point no-reply provider/model to local API
|
||||||
- Verify 4 rule paths in `docs/VERIFY.md`
|
- Verify 4 rule paths in `docs/VERIFY.md`
|
||||||
|
|
||||||
## Stage 2: Wider channel rollout
|
## 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
|
- Monitor false-silent turns
|
||||||
- Keep fallback model available
|
- Keep fallback model available
|
||||||
|
|
||||||
|
|||||||
130
docs/TEST_REPORT.md
Normal file
130
docs/TEST_REPORT.md
Normal file
@@ -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 联调。
|
||||||
12
docs/channel-policies.example.json
Normal file
12
docs/channel-policies.example.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"1476369680632647721": {
|
||||||
|
"listMode": "agent-list",
|
||||||
|
"agentList": ["1474088632750047324"],
|
||||||
|
"endSymbols": ["🔚"]
|
||||||
|
},
|
||||||
|
"another-channel-id": {
|
||||||
|
"listMode": "human-list",
|
||||||
|
"humanList": ["561921120408698910"],
|
||||||
|
"endSymbols": ["🔚"]
|
||||||
|
}
|
||||||
|
}
|
||||||
12
no-reply-api/package-lock.json
generated
Normal file
12
no-reply-api/package-lock.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,5 +26,45 @@ Required:
|
|||||||
Optional:
|
Optional:
|
||||||
- `enabled` (default true)
|
- `enabled` (default true)
|
||||||
- `discordOnly` (default true)
|
- `discordOnly` (default true)
|
||||||
- `bypassUserIds` (default [])
|
- `listMode` (`human-list` | `agent-list`, default `human-list`)
|
||||||
- `endSymbols` (default punctuation set)
|
- `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`
|
||||||
|
|||||||
474
plugin/index.ts
474
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 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 = {
|
type DecisionRecord = {
|
||||||
decision: Decision;
|
decision: Decision;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
needsRestore?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PolicyState = {
|
||||||
|
filePath: string;
|
||||||
|
channelPolicies: Record<string, ChannelPolicy>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DebugConfig = {
|
||||||
|
enableDebugLogs?: boolean;
|
||||||
|
debugLogChannelIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessionDecision = new Map<string, DecisionRecord>();
|
const sessionDecision = new Map<string, DecisionRecord>();
|
||||||
const MAX_SESSION_DECISIONS = 2000;
|
const MAX_SESSION_DECISIONS = 2000;
|
||||||
const DECISION_TTL_MS = 5 * 60 * 1000;
|
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, unknown>): string {
|
function normalizeChannel(ctx: Record<string, unknown>): string {
|
||||||
const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel];
|
const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel];
|
||||||
@@ -35,6 +58,46 @@ function normalizeSender(event: Record<string, unknown>, ctx: Record<string, unk
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractUntrustedConversationInfo(text: string): Record<string, unknown> | 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<string, unknown>) : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveDecisionInputFromPrompt(
|
||||||
|
prompt: string,
|
||||||
|
messageProvider?: string,
|
||||||
|
): {
|
||||||
|
channel: string;
|
||||||
|
channelId?: string;
|
||||||
|
senderId?: string;
|
||||||
|
content: string;
|
||||||
|
conv: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
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()) {
|
function pruneDecisionMap(now = Date.now()) {
|
||||||
for (const [k, v] of sessionDecision.entries()) {
|
for (const [k, v] of sessionDecision.entries()) {
|
||||||
if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k);
|
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<string, unknown>) || {};
|
||||||
|
const plugins = (root.plugins as Record<string, unknown>) || {};
|
||||||
|
const entries = (plugins.entries as Record<string, unknown>) || {};
|
||||||
|
const entry = (entries.whispergate as Record<string, unknown>) || {};
|
||||||
|
const cfg = (entry.config as Record<string, unknown>) || {};
|
||||||
|
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<string, ChannelPolicy>;
|
||||||
|
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<string, unknown>) {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>, event: Record<string, unknown>) {
|
||||||
|
const meta = ((ctx.metadata || event.metadata || {}) as Record<string, unknown>) || {};
|
||||||
|
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 {
|
export default {
|
||||||
id: "whispergate",
|
id: "whispergate",
|
||||||
name: "WhisperGate",
|
name: "WhisperGate",
|
||||||
register(api: OpenClawPluginApi) {
|
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<string, unknown>) {
|
||||||
|
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<string, string> = { "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<string, unknown>) 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 {
|
try {
|
||||||
const c = (ctx || {}) as Record<string, unknown>;
|
const c = (ctx || {}) as Record<string, unknown>;
|
||||||
const e = (event || {}) as Record<string, unknown>;
|
const e = (event || {}) as Record<string, unknown>;
|
||||||
const sessionKey = typeof c.sessionKey === "string" ? c.sessionKey : undefined;
|
const preChannelId = typeof c.channelId === "string" ? c.channelId : undefined;
|
||||||
if (!sessionKey) return;
|
const livePre = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||||
|
if (shouldDebugLog(livePre, preChannelId)) {
|
||||||
const senderId = normalizeSender(e, c);
|
api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
|
||||||
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) {
|
} catch (err) {
|
||||||
api.logger.warn(`whispergate: message hook failed: ${String(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;
|
const key = ctx.sessionKey;
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
|
|
||||||
const rec = sessionDecision.get(key);
|
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||||
if (!rec) return;
|
ensurePolicyStateLoaded(api, live);
|
||||||
if (Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
|
||||||
sessionDecision.delete(key);
|
const prompt = ((event as Record<string, unknown>).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<string, unknown>).sender_id ?? "")} ` +
|
||||||
|
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
|
||||||
|
`convChannelId=${String((derived.conv as Record<string, unknown>).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;
|
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<string, unknown>).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<string, unknown>).sender_id ?? "")} ` +
|
||||||
|
`convSender=${String((derived.conv as Record<string, unknown>).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(
|
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 {
|
return {
|
||||||
providerOverride: config.noReplyProvider,
|
providerOverride: live.noReplyProvider,
|
||||||
modelOverride: config.noReplyModel,
|
modelOverride: live.noReplyModel,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
api.on("before_prompt_build", async (_event, ctx) => {
|
api.on("before_prompt_build", async (event, ctx) => {
|
||||||
const key = ctx.sessionKey;
|
const key = ctx.sessionKey;
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
const rec = sessionDecision.get(key);
|
|
||||||
if (!rec) return;
|
|
||||||
|
|
||||||
if (Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||||
sessionDecision.delete(key);
|
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<string, unknown>).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<string, unknown>).sender_id ?? "")} ` +
|
||||||
|
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
|
||||||
|
`convChannelId=${String((derived.conv as Record<string, unknown>).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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// consume non-no-reply paths here to avoid stale carry-over
|
// Resolve end symbols from config/policy for dynamic instruction
|
||||||
sessionDecision.delete(key);
|
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||||
|
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider);
|
||||||
if (!shouldInjectEndMarker(rec.decision.reason)) return;
|
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}`);
|
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`);
|
||||||
return {
|
return { prependContext: instruction };
|
||||||
prependContext: END_MARKER_INSTRUCTION,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,10 +10,21 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"enabled": { "type": "boolean", "default": true },
|
"enabled": { "type": "boolean", "default": true },
|
||||||
"discordOnly": { "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": [] },
|
"bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||||
"endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["。", "!", "?", ".", "!", "?"] },
|
"endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] },
|
||||||
"noReplyProvider": { "type": "string" },
|
"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"]
|
"required": ["noReplyProvider", "noReplyModel"]
|
||||||
}
|
}
|
||||||
|
|||||||
103
plugin/rules.ts
103
plugin/rules.ts
@@ -1,47 +1,132 @@
|
|||||||
export type WhisperGateConfig = {
|
export type WhisperGateConfig = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
discordOnly?: boolean;
|
discordOnly?: boolean;
|
||||||
|
listMode?: "human-list" | "agent-list";
|
||||||
|
humanList?: string[];
|
||||||
|
agentList?: string[];
|
||||||
|
channelPoliciesFile?: string;
|
||||||
|
// backward compatibility
|
||||||
bypassUserIds?: string[];
|
bypassUserIds?: string[];
|
||||||
endSymbols?: string[];
|
endSymbols?: string[];
|
||||||
noReplyProvider: string;
|
noReplyProvider: string;
|
||||||
noReplyModel: string;
|
noReplyModel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChannelPolicy = {
|
||||||
|
listMode?: "human-list" | "agent-list";
|
||||||
|
humanList?: string[];
|
||||||
|
agentList?: string[];
|
||||||
|
endSymbols?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type Decision = {
|
export type Decision = {
|
||||||
shouldUseNoReply: boolean;
|
shouldUseNoReply: boolean;
|
||||||
|
shouldInjectEndMarkerPrompt: boolean;
|
||||||
reason: string;
|
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 {
|
function getLastChar(input: string): string {
|
||||||
const t = input.trim();
|
const t = stripTrailingMetadata(input).trim();
|
||||||
return t.length ? t[t.length - 1] : "";
|
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<string, ChannelPolicy>) {
|
||||||
|
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: {
|
export function evaluateDecision(params: {
|
||||||
config: WhisperGateConfig;
|
config: WhisperGateConfig;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
|
channelId?: string;
|
||||||
|
channelPolicies?: Record<string, ChannelPolicy>;
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
}): Decision {
|
}): Decision {
|
||||||
const { config } = params;
|
const { config } = params;
|
||||||
|
|
||||||
if (config.enabled === false) {
|
if (config.enabled === false) {
|
||||||
return { shouldUseNoReply: false, reason: "disabled" };
|
return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "disabled" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = (params.channel || "").toLowerCase();
|
const channel = (params.channel || "").toLowerCase();
|
||||||
if (config.discordOnly !== false && channel !== "discord") {
|
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)) {
|
// DM bypass: if no conversation info was found in the prompt (no senderId AND no channelId),
|
||||||
return { shouldUseNoReply: false, reason: "bypass_sender" };
|
// 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 || "");
|
const lastChar = getLastChar(params.content || "");
|
||||||
if (lastChar && (config.endSymbols || []).includes(lastChar)) {
|
const hasEnd = !!lastChar && policy.endSymbols.includes(lastChar);
|
||||||
return { shouldUseNoReply: false, reason: `end_symbol:${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" };
|
||||||
}
|
}
|
||||||
|
|||||||
224
scripts/install-whispergate-openclaw.mjs
Executable file
224
scripts/install-whispergate-openclaw.mjs
Executable file
@@ -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=<path> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
scripts/install-whispergate-openclaw.sh
Executable file
4
scripts/install-whispergate-openclaw.sh
Executable file
@@ -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" "$@"
|
||||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
|||||||
|
|
||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
const pluginDir = path.join(root, "plugin");
|
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.rmSync(outDir, { recursive: true, force: true });
|
||||||
fs.mkdirSync(outDir, { recursive: true });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user