From c912ceed79448c2999d0e8b9d833d0b2f5fdd6fa Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 22:19:29 +0000 Subject: [PATCH 01/32] docs(test): add phase test report with pending e2e checklist --- docs/TEST_REPORT.md | 130 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/TEST_REPORT.md 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 联调。 From 2f269c25b4b6d906809695c1cba98af907867c8f Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 23:04:14 +0000 Subject: [PATCH 02/32] feat(installer): add openclaw config-set installer with automatic rollback --- CHANGELOG.md | 1 + docs/INTEGRATION.md | 24 +++- scripts/install-whispergate-openclaw.sh | 140 ++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100755 scripts/install-whispergate-openclaw.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e9224..92aa2d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - 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`) - 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/INTEGRATION.md b/docs/INTEGRATION.md index 8bc5a18..3043de3 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -27,8 +27,30 @@ 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: + +```bash +./scripts/install-whispergate-openclaw.sh +``` + +Environment overrides: + +- `PLUGIN_PATH` +- `NO_REPLY_PROVIDER_ID` +- `NO_REPLY_MODEL_ID` +- `NO_REPLY_BASE_URL` +- `NO_REPLY_API_KEY` +- `BYPASS_USER_IDS_JSON` +- `END_SYMBOLS_JSON` + +The script: +- writes via `openclaw config set ... --json` +- creates config backup first +- restores backup automatically if any step fails + ## 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/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh new file mode 100755 index 0000000..9cb4946 --- /dev/null +++ b/scripts/install-whispergate-openclaw.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Install WhisperGate config into OpenClaw safely. +# - uses `openclaw config set ... --json` for writes +# - creates full config backup +# - rolls back automatically on failure + +OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-$HOME/.openclaw/openclaw.json}" +PLUGIN_PATH="${PLUGIN_PATH:-/root/.openclaw/workspace-operator/WhisperGate/dist/plugin}" +NO_REPLY_PROVIDER_ID="${NO_REPLY_PROVIDER_ID:-custom-127-0-0-1-8787}" +NO_REPLY_MODEL_ID="${NO_REPLY_MODEL_ID:-whispergate-no-reply-v1}" +NO_REPLY_BASE_URL="${NO_REPLY_BASE_URL:-http://127.0.0.1:8787/v1}" +NO_REPLY_API_KEY="${NO_REPLY_API_KEY:-wg-local-test-token}" +BYPASS_USER_IDS_JSON="${BYPASS_USER_IDS_JSON:-["561921120408698910","1474088632750047324"]}" +END_SYMBOLS_JSON="${END_SYMBOLS_JSON:-["🔚"]}" + +BACKUP_PATH="${OPENCLAW_CONFIG_PATH}.bak-whispergate-install-$(date +%Y%m%d%H%M%S)" +INSTALLED_OK=0 + +rollback() { + local ec=$? + if [[ $INSTALLED_OK -eq 1 ]]; then + return + fi + echo "[whispergate-install] ERROR (exit=$ec). Rolling back config from backup..." + if [[ -f "$BACKUP_PATH" ]]; then + cp -f "$BACKUP_PATH" "$OPENCLAW_CONFIG_PATH" + echo "[whispergate-install] rollback complete: $OPENCLAW_CONFIG_PATH restored" + else + echo "[whispergate-install] WARNING: backup file not found, cannot auto-rollback" + fi + exit "$ec" +} + +trap rollback ERR + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "[whispergate-install] missing command: $1" >&2 + exit 1 + } +} + +oc_set_json() { + local path="$1" + local json="$2" + openclaw config set "$path" "$json" --json >/dev/null +} + +echo "[whispergate-install] checking prerequisites..." +require_cmd openclaw +require_cmd python3 + +if [[ ! -f "$OPENCLAW_CONFIG_PATH" ]]; then + echo "[whispergate-install] config not found: $OPENCLAW_CONFIG_PATH" >&2 + exit 1 +fi + +cp -f "$OPENCLAW_CONFIG_PATH" "$BACKUP_PATH" +echo "[whispergate-install] backup created: $BACKUP_PATH" + +# 1) plugins.load.paths append plugin path (dedupe) +CURRENT_PATHS_JSON="[]" +if openclaw config get plugins.load.paths --json >/tmp/wg_paths.json 2>/dev/null; then + CURRENT_PATHS_JSON="$(cat /tmp/wg_paths.json)" +fi + +NEW_PATHS_JSON="$(python3 - <<'PY' +import json, os +cur=json.loads(os.environ['CURRENT_PATHS_JSON']) +pp=os.environ['PLUGIN_PATH'] +if not isinstance(cur,list): + cur=[] +if pp not in cur: + cur.append(pp) +print(json.dumps(cur, ensure_ascii=False)) +PY +)" + +oc_set_json "plugins.load.paths" "$NEW_PATHS_JSON" +echo "[whispergate-install] set plugins.load.paths" + +# 2) plugin entry +PLUGIN_ENTRY_JSON="$(python3 - <<'PY' +import json, os +entry={ + 'enabled': True, + 'config': { + 'enabled': True, + 'discordOnly': True, + 'bypassUserIds': json.loads(os.environ['BYPASS_USER_IDS_JSON']), + 'endSymbols': json.loads(os.environ['END_SYMBOLS_JSON']), + 'noReplyProvider': os.environ['NO_REPLY_PROVIDER_ID'], + 'noReplyModel': os.environ['NO_REPLY_MODEL_ID'], + } +} +print(json.dumps(entry, ensure_ascii=False)) +PY +)" + +oc_set_json "plugins.entries.whispergate" "$PLUGIN_ENTRY_JSON" +echo "[whispergate-install] set plugins.entries.whispergate" + +# 3) no-reply model provider (under models.providers) +PROVIDER_JSON="$(python3 - <<'PY' +import json, os +provider={ + 'baseUrl': os.environ['NO_REPLY_BASE_URL'], + 'apiKey': os.environ['NO_REPLY_API_KEY'], + 'api': 'openai-completions', + 'models': [{ + 'id': os.environ['NO_REPLY_MODEL_ID'], + 'name': 'whispergate-no-reply-v1 (Custom Provider)', + 'reasoning': False, + 'input': ['text'], + 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, + 'contextWindow': 4096, + 'maxTokens': 4096, + }] +} +print(json.dumps(provider, ensure_ascii=False)) +PY +)" + +PROVIDER_PATH="models.providers[\"${NO_REPLY_PROVIDER_ID}\"]" +oc_set_json "$PROVIDER_PATH" "$PROVIDER_JSON" +echo "[whispergate-install] set ${PROVIDER_PATH}" + +# 4) quick validation reads +openclaw config get plugins.entries.whispergate --json >/dev/null +openclaw config get "$PROVIDER_PATH" --json >/dev/null + +INSTALLED_OK=1 +trap - ERR + +echo "[whispergate-install] install completed successfully" +echo "[whispergate-install] next steps:" +echo " 1) start no-reply api (port 8787)" +echo " 2) restart gateway: openclaw gateway restart" From 9d752ca09059fd3595cc52d00edede1b7242dd88 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 23:08:02 +0000 Subject: [PATCH 03/32] feat(installer): add --install/--uninstall with recorded full rollback --- CHANGELOG.md | 3 + docs/INTEGRATION.md | 16 +- scripts/install-whispergate-openclaw.sh | 313 ++++++++++++++++++------ 3 files changed, 258 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92aa2d7..6150ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ - 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/INTEGRATION.md b/docs/INTEGRATION.md index 3043de3..339762a 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -32,7 +32,16 @@ You can merge this snippet manually into your `openclaw.json`. For production-like install with automatic rollback on error: ```bash -./scripts/install-whispergate-openclaw.sh +./scripts/install-whispergate-openclaw.sh --install +``` + +Uninstall (revert all recorded config changes): + +```bash +./scripts/install-whispergate-openclaw.sh --uninstall +# or specify a record explicitly +# RECORD_FILE=~/.openclaw/whispergate-install-records/whispergate-YYYYmmddHHMMSS.json \ +# ./scripts/install-whispergate-openclaw.sh --uninstall ``` Environment overrides: @@ -48,7 +57,10 @@ Environment overrides: The script: - writes via `openclaw config set ... --json` - creates config backup first -- restores backup automatically if any step fails +- restores backup automatically if any install step fails +- writes a change record for every install/uninstall: + - directory: `~/.openclaw/whispergate-install-records/` + - latest pointer: `~/.openclaw/whispergate-install-record-latest.json` ## Notes diff --git a/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh index 9cb4946..8970312 100755 --- a/scripts/install-whispergate-openclaw.sh +++ b/scripts/install-whispergate-openclaw.sh @@ -1,10 +1,12 @@ #!/usr/bin/env bash set -Eeuo pipefail -# Install WhisperGate config into OpenClaw safely. -# - uses `openclaw config set ... --json` for writes -# - creates full config backup -# - rolls back automatically on failure +# WhisperGate installer/uninstaller for OpenClaw +# Requirements: +# - all writes via `openclaw config set ... --json` +# - install supports rollback on failure +# - uninstall reverts ALL recorded changes +# - every install writes a change record OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-$HOME/.openclaw/openclaw.json}" PLUGIN_PATH="${PLUGIN_PATH:-/root/.openclaw/workspace-operator/WhisperGate/dist/plugin}" @@ -15,74 +17,141 @@ NO_REPLY_API_KEY="${NO_REPLY_API_KEY:-wg-local-test-token}" BYPASS_USER_IDS_JSON="${BYPASS_USER_IDS_JSON:-["561921120408698910","1474088632750047324"]}" END_SYMBOLS_JSON="${END_SYMBOLS_JSON:-["🔚"]}" -BACKUP_PATH="${OPENCLAW_CONFIG_PATH}.bak-whispergate-install-$(date +%Y%m%d%H%M%S)" +STATE_DIR="${STATE_DIR:-$HOME/.openclaw/whispergate-install-records}" +LATEST_RECORD_LINK="${LATEST_RECORD_LINK:-$HOME/.openclaw/whispergate-install-record-latest.json}" + +MODE="" +if [[ "${1:-}" == "--install" ]]; then + MODE="install" +elif [[ "${1:-}" == "--uninstall" ]]; then + MODE="uninstall" +else + echo "Usage: $0 --install | --uninstall" + exit 2 +fi + +TIMESTAMP="$(date +%Y%m%d%H%M%S)" +BACKUP_PATH="${OPENCLAW_CONFIG_PATH}.bak-whispergate-${MODE}-${TIMESTAMP}" +RECORD_PATH="${STATE_DIR}/whispergate-${TIMESTAMP}.json" + +PROVIDER_PATH="models.providers[\"${NO_REPLY_PROVIDER_ID}\"]" +PATH_PLUGINS_LOAD="plugins.load.paths" +PATH_PLUGIN_ENTRY="plugins.entries.whispergate" + INSTALLED_OK=0 -rollback() { - local ec=$? - if [[ $INSTALLED_OK -eq 1 ]]; then - return - fi - echo "[whispergate-install] ERROR (exit=$ec). Rolling back config from backup..." - if [[ -f "$BACKUP_PATH" ]]; then - cp -f "$BACKUP_PATH" "$OPENCLAW_CONFIG_PATH" - echo "[whispergate-install] rollback complete: $OPENCLAW_CONFIG_PATH restored" - else - echo "[whispergate-install] WARNING: backup file not found, cannot auto-rollback" - fi - exit "$ec" -} - -trap rollback ERR - require_cmd() { command -v "$1" >/dev/null 2>&1 || { - echo "[whispergate-install] missing command: $1" >&2 + echo "[whispergate] missing command: $1" >&2 exit 1 } } +oc_get_json_or_missing() { + local path="$1" + if openclaw config get "$path" --json >/tmp/wg_get.json 2>/dev/null; then + printf '{"exists":true,"value":%s}\n' "$(cat /tmp/wg_get.json)" + else + printf '{"exists":false}' + fi +} + oc_set_json() { local path="$1" local json="$2" openclaw config set "$path" "$json" --json >/dev/null } -echo "[whispergate-install] checking prerequisites..." -require_cmd openclaw -require_cmd python3 +oc_unset() { + local path="$1" + openclaw config unset "$path" >/dev/null +} -if [[ ! -f "$OPENCLAW_CONFIG_PATH" ]]; then - echo "[whispergate-install] config not found: $OPENCLAW_CONFIG_PATH" >&2 - exit 1 -fi - -cp -f "$OPENCLAW_CONFIG_PATH" "$BACKUP_PATH" -echo "[whispergate-install] backup created: $BACKUP_PATH" - -# 1) plugins.load.paths append plugin path (dedupe) -CURRENT_PATHS_JSON="[]" -if openclaw config get plugins.load.paths --json >/tmp/wg_paths.json 2>/dev/null; then - CURRENT_PATHS_JSON="$(cat /tmp/wg_paths.json)" -fi - -NEW_PATHS_JSON="$(python3 - <<'PY' +write_record() { + local mode="$1" + local prev_paths_json="$2" + local next_paths_json="$3" + mkdir -p "$STATE_DIR" + python3 - <<'PY' import json, os -cur=json.loads(os.environ['CURRENT_PATHS_JSON']) -pp=os.environ['PLUGIN_PATH'] -if not isinstance(cur,list): - cur=[] -if pp not in cur: - cur.append(pp) -print(json.dumps(cur, ensure_ascii=False)) +record={ + 'mode': os.environ['REC_MODE'], + 'timestamp': os.environ['TIMESTAMP'], + 'openclawConfigPath': os.environ['OPENCLAW_CONFIG_PATH'], + 'backupPath': os.environ['BACKUP_PATH'], + 'paths': json.loads(os.environ['PREV_PATHS_JSON']), + 'applied': json.loads(os.environ['NEXT_PATHS_JSON']), +} +with open(os.environ['RECORD_PATH'],'w',encoding='utf-8') as f: + json.dump(record,f,ensure_ascii=False,indent=2) +PY + cp -f "$RECORD_PATH" "$LATEST_RECORD_LINK" +} + +rollback_install() { + local ec=$? + if [[ $INSTALLED_OK -eq 1 ]]; then + return + fi + echo "[whispergate] install failed (exit=$ec), rolling back..." + if [[ -f "$BACKUP_PATH" ]]; then + cp -f "$BACKUP_PATH" "$OPENCLAW_CONFIG_PATH" + echo "[whispergate] rollback complete" + else + echo "[whispergate] WARNING: backup missing; rollback skipped" + fi + exit "$ec" +} + +run_install() { + trap rollback_install ERR + + cp -f "$OPENCLAW_CONFIG_PATH" "$BACKUP_PATH" + echo "[whispergate] backup: $BACKUP_PATH" + + local prev_paths_json + prev_paths_json="$(python3 - <<'PY' +import json, os, subprocess + +def get(path): + p=subprocess.run(['openclaw','config','get',path,'--json'],capture_output=True,text=True) + if p.returncode==0: + return {'exists':True,'value':json.loads(p.stdout)} + return {'exists':False} + +payload={ + os.environ['PATH_PLUGINS_LOAD']: get(os.environ['PATH_PLUGINS_LOAD']), + os.environ['PATH_PLUGIN_ENTRY']: get(os.environ['PATH_PLUGIN_ENTRY']), + os.environ['PROVIDER_PATH']: get(os.environ['PROVIDER_PATH']), +} +print(json.dumps(payload,ensure_ascii=False)) PY )" -oc_set_json "plugins.load.paths" "$NEW_PATHS_JSON" -echo "[whispergate-install] set plugins.load.paths" + # 1) plugins.load.paths append and dedupe + local current_paths_json new_paths_json + if openclaw config get "$PATH_PLUGINS_LOAD" --json >/tmp/wg_paths.json 2>/dev/null; then + current_paths_json="$(cat /tmp/wg_paths.json)" + else + current_paths_json="[]" + fi -# 2) plugin entry -PLUGIN_ENTRY_JSON="$(python3 - <<'PY' + new_paths_json="$(CURRENT_PATHS_JSON="$current_paths_json" PLUGIN_PATH="$PLUGIN_PATH" python3 - <<'PY' +import json, os +cur=json.loads(os.environ['CURRENT_PATHS_JSON']) +if not isinstance(cur,list): + cur=[] +pp=os.environ['PLUGIN_PATH'] +if pp not in cur: + cur.append(pp) +print(json.dumps(cur,ensure_ascii=False)) +PY +)" + oc_set_json "$PATH_PLUGINS_LOAD" "$new_paths_json" + + # 2) plugin entry + local plugin_entry_json + plugin_entry_json="$(BYPASS_USER_IDS_JSON="$BYPASS_USER_IDS_JSON" END_SYMBOLS_JSON="$END_SYMBOLS_JSON" NO_REPLY_PROVIDER_ID="$NO_REPLY_PROVIDER_ID" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' import json, os entry={ 'enabled': True, @@ -95,15 +164,14 @@ entry={ 'noReplyModel': os.environ['NO_REPLY_MODEL_ID'], } } -print(json.dumps(entry, ensure_ascii=False)) +print(json.dumps(entry,ensure_ascii=False)) PY )" + oc_set_json "$PATH_PLUGIN_ENTRY" "$plugin_entry_json" -oc_set_json "plugins.entries.whispergate" "$PLUGIN_ENTRY_JSON" -echo "[whispergate-install] set plugins.entries.whispergate" - -# 3) no-reply model provider (under models.providers) -PROVIDER_JSON="$(python3 - <<'PY' + # 3) provider + local provider_json + provider_json="$(NO_REPLY_BASE_URL="$NO_REPLY_BASE_URL" NO_REPLY_API_KEY="$NO_REPLY_API_KEY" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' import json, os provider={ 'baseUrl': os.environ['NO_REPLY_BASE_URL'], @@ -111,7 +179,7 @@ provider={ 'api': 'openai-completions', 'models': [{ 'id': os.environ['NO_REPLY_MODEL_ID'], - 'name': 'whispergate-no-reply-v1 (Custom Provider)', + 'name': f"{os.environ['NO_REPLY_MODEL_ID']} (Custom Provider)", 'reasoning': False, 'input': ['text'], 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, @@ -119,22 +187,123 @@ provider={ 'maxTokens': 4096, }] } -print(json.dumps(provider, ensure_ascii=False)) +print(json.dumps(provider,ensure_ascii=False)) +PY +)" + oc_set_json "$PROVIDER_PATH" "$provider_json" + + # validate writes + openclaw config get "$PATH_PLUGINS_LOAD" --json >/dev/null + openclaw config get "$PATH_PLUGIN_ENTRY" --json >/dev/null + openclaw config get "$PROVIDER_PATH" --json >/dev/null + + local next_paths_json + next_paths_json="$(python3 - <<'PY' +import json, os, subprocess + +def get(path): + p=subprocess.run(['openclaw','config','get',path,'--json'],capture_output=True,text=True) + if p.returncode==0: + return {'exists':True,'value':json.loads(p.stdout)} + return {'exists':False} + +payload={ + os.environ['PATH_PLUGINS_LOAD']: get(os.environ['PATH_PLUGINS_LOAD']), + os.environ['PATH_PLUGIN_ENTRY']: get(os.environ['PATH_PLUGIN_ENTRY']), + os.environ['PROVIDER_PATH']: get(os.environ['PROVIDER_PATH']), +} +print(json.dumps(payload,ensure_ascii=False)) PY )" -PROVIDER_PATH="models.providers[\"${NO_REPLY_PROVIDER_ID}\"]" -oc_set_json "$PROVIDER_PATH" "$PROVIDER_JSON" -echo "[whispergate-install] set ${PROVIDER_PATH}" + REC_MODE="install" PREV_PATHS_JSON="$prev_paths_json" NEXT_PATHS_JSON="$next_paths_json" TIMESTAMP="$TIMESTAMP" OPENCLAW_CONFIG_PATH="$OPENCLAW_CONFIG_PATH" BACKUP_PATH="$BACKUP_PATH" RECORD_PATH="$RECORD_PATH" write_record install "$prev_paths_json" "$next_paths_json" -# 4) quick validation reads -openclaw config get plugins.entries.whispergate --json >/dev/null -openclaw config get "$PROVIDER_PATH" --json >/dev/null + INSTALLED_OK=1 + trap - ERR + echo "[whispergate] install ok" + echo "[whispergate] record: $RECORD_PATH" +} -INSTALLED_OK=1 -trap - ERR +run_uninstall() { + local rec_file="" + if [[ -n "${RECORD_FILE:-}" ]]; then + rec_file="$RECORD_FILE" + elif [[ -f "$LATEST_RECORD_LINK" ]]; then + rec_file="$LATEST_RECORD_LINK" + else + echo "[whispergate] no record found. set RECORD_FILE= or install first." >&2 + exit 1 + fi -echo "[whispergate-install] install completed successfully" -echo "[whispergate-install] next steps:" -echo " 1) start no-reply api (port 8787)" -echo " 2) restart gateway: openclaw gateway restart" + if [[ ! -f "$rec_file" ]]; then + echo "[whispergate] record file not found: $rec_file" >&2 + exit 1 + fi + + cp -f "$OPENCLAW_CONFIG_PATH" "$BACKUP_PATH" + echo "[whispergate] backup before uninstall: $BACKUP_PATH" + + python3 - <<'PY' +import json, os, subprocess, sys +rec=json.load(open(os.environ['REC_FILE'],encoding='utf-8')) +paths=rec.get('paths',{}) + +for path, info in paths.items(): + exists=bool(info.get('exists')) + if exists: + val=json.dumps(info.get('value'),ensure_ascii=False) + p=subprocess.run(['openclaw','config','set',path,val,'--json'],capture_output=True,text=True) + if p.returncode!=0: + sys.stderr.write(p.stderr) + raise SystemExit(1) + else: + p=subprocess.run(['openclaw','config','unset',path],capture_output=True,text=True) + if p.returncode!=0: + # path may already be absent; tolerate common "not found" style failures + txt=(p.stderr or p.stdout or '').lower() + if 'not found' not in txt and 'missing' not in txt and 'does not exist' not in txt: + sys.stderr.write(p.stderr) + raise SystemExit(1) +print('ok') +PY + + local next_paths_json + next_paths_json="$(python3 - <<'PY' +import json, os, subprocess +paths=json.load(open(os.environ['REC_FILE'],encoding='utf-8')).get('paths',{}).keys() +def get(path): + p=subprocess.run(['openclaw','config','get',path,'--json'],capture_output=True,text=True) + if p.returncode==0: + return {'exists':True,'value':json.loads(p.stdout)} + return {'exists':False} +payload={k:get(k) for k in paths} +print(json.dumps(payload,ensure_ascii=False)) +PY +)" + + local prev_paths_json + prev_paths_json="$(python3 - <<'PY' +import json, os +print(json.dumps(json.load(open(os.environ['REC_FILE'],encoding='utf-8')).get('applied',{}),ensure_ascii=False)) +PY +)" + + REC_MODE="uninstall" PREV_PATHS_JSON="$prev_paths_json" NEXT_PATHS_JSON="$next_paths_json" TIMESTAMP="$TIMESTAMP" OPENCLAW_CONFIG_PATH="$OPENCLAW_CONFIG_PATH" BACKUP_PATH="$BACKUP_PATH" RECORD_PATH="$RECORD_PATH" write_record uninstall "$prev_paths_json" "$next_paths_json" + + echo "[whispergate] uninstall ok" + echo "[whispergate] record: $RECORD_PATH" +} + +main() { + require_cmd openclaw + require_cmd python3 + [[ -f "$OPENCLAW_CONFIG_PATH" ]] || { echo "[whispergate] config not found: $OPENCLAW_CONFIG_PATH"; exit 1; } + + if [[ "$MODE" == "install" ]]; then + run_install + else + REC_FILE="${RECORD_FILE:-$LATEST_RECORD_LINK}" run_uninstall + fi +} + +main From 6ff9858b18554856d3ef8a31d9278023b18ba5a2 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 23:18:07 +0000 Subject: [PATCH 04/32] fix(installer): atomic plugins write and valid json defaults for install flags --- scripts/install-whispergate-openclaw.sh | 54 ++++++++++++------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh index 8970312..573b325 100755 --- a/scripts/install-whispergate-openclaw.sh +++ b/scripts/install-whispergate-openclaw.sh @@ -14,8 +14,8 @@ NO_REPLY_PROVIDER_ID="${NO_REPLY_PROVIDER_ID:-custom-127-0-0-1-8787}" NO_REPLY_MODEL_ID="${NO_REPLY_MODEL_ID:-whispergate-no-reply-v1}" NO_REPLY_BASE_URL="${NO_REPLY_BASE_URL:-http://127.0.0.1:8787/v1}" NO_REPLY_API_KEY="${NO_REPLY_API_KEY:-wg-local-test-token}" -BYPASS_USER_IDS_JSON="${BYPASS_USER_IDS_JSON:-["561921120408698910","1474088632750047324"]}" -END_SYMBOLS_JSON="${END_SYMBOLS_JSON:-["🔚"]}" +BYPASS_USER_IDS_JSON="${BYPASS_USER_IDS_JSON:-[\"561921120408698910\",\"1474088632750047324\"]}" +END_SYMBOLS_JSON="${END_SYMBOLS_JSON:-[\"🔚\"]}" STATE_DIR="${STATE_DIR:-$HOME/.openclaw/whispergate-install-records}" LATEST_RECORD_LINK="${LATEST_RECORD_LINK:-$HOME/.openclaw/whispergate-install-record-latest.json}" @@ -110,7 +110,7 @@ run_install() { echo "[whispergate] backup: $BACKUP_PATH" local prev_paths_json - prev_paths_json="$(python3 - <<'PY' + prev_paths_json="$(PATH_PLUGINS_LOAD="$PATH_PLUGINS_LOAD" PATH_PLUGIN_ENTRY="$PATH_PLUGIN_ENTRY" PROVIDER_PATH="$PROVIDER_PATH" python3 - <<'PY' import json, os, subprocess def get(path): @@ -128,32 +128,31 @@ print(json.dumps(payload,ensure_ascii=False)) PY )" - # 1) plugins.load.paths append and dedupe - local current_paths_json new_paths_json - if openclaw config get "$PATH_PLUGINS_LOAD" --json >/tmp/wg_paths.json 2>/dev/null; then - current_paths_json="$(cat /tmp/wg_paths.json)" + # 1+2) set plugins object in one write (avoid transient schema failure) + local current_plugins_json new_plugins_json + if openclaw config get plugins --json >/tmp/wg_plugins.json 2>/dev/null; then + current_plugins_json="$(cat /tmp/wg_plugins.json)" else - current_paths_json="[]" + current_plugins_json='{}' fi - new_paths_json="$(CURRENT_PATHS_JSON="$current_paths_json" PLUGIN_PATH="$PLUGIN_PATH" python3 - <<'PY' + new_plugins_json="$(CURRENT_PLUGINS_JSON="$current_plugins_json" PLUGIN_PATH="$PLUGIN_PATH" BYPASS_USER_IDS_JSON="$BYPASS_USER_IDS_JSON" END_SYMBOLS_JSON="$END_SYMBOLS_JSON" NO_REPLY_PROVIDER_ID="$NO_REPLY_PROVIDER_ID" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' import json, os -cur=json.loads(os.environ['CURRENT_PATHS_JSON']) -if not isinstance(cur,list): - cur=[] -pp=os.environ['PLUGIN_PATH'] -if pp not in cur: - cur.append(pp) -print(json.dumps(cur,ensure_ascii=False)) -PY -)" - oc_set_json "$PATH_PLUGINS_LOAD" "$new_paths_json" +plugins=json.loads(os.environ['CURRENT_PLUGINS_JSON']) +if not isinstance(plugins,dict): + plugins={} - # 2) plugin entry - local plugin_entry_json - plugin_entry_json="$(BYPASS_USER_IDS_JSON="$BYPASS_USER_IDS_JSON" END_SYMBOLS_JSON="$END_SYMBOLS_JSON" NO_REPLY_PROVIDER_ID="$NO_REPLY_PROVIDER_ID" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' -import json, os -entry={ +load=plugins.setdefault('load',{}) +paths=load.get('paths') +if not isinstance(paths,list): + paths=[] +pp=os.environ['PLUGIN_PATH'] +if pp not in paths: + paths.append(pp) +load['paths']=paths + +entries=plugins.setdefault('entries',{}) +entries['whispergate']={ 'enabled': True, 'config': { 'enabled': True, @@ -164,10 +163,11 @@ entry={ 'noReplyModel': os.environ['NO_REPLY_MODEL_ID'], } } -print(json.dumps(entry,ensure_ascii=False)) + +print(json.dumps(plugins,ensure_ascii=False)) PY )" - oc_set_json "$PATH_PLUGIN_ENTRY" "$plugin_entry_json" + oc_set_json "plugins" "$new_plugins_json" # 3) provider local provider_json @@ -198,7 +198,7 @@ PY openclaw config get "$PROVIDER_PATH" --json >/dev/null local next_paths_json - next_paths_json="$(python3 - <<'PY' + next_paths_json="$(PATH_PLUGINS_LOAD="$PATH_PLUGINS_LOAD" PATH_PLUGIN_ENTRY="$PATH_PLUGIN_ENTRY" PROVIDER_PATH="$PROVIDER_PATH" python3 - <<'PY' import json, os, subprocess def get(path): From c119697f7fbe265dc8ec2775daa2bd1c258f8677 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 23:19:58 +0000 Subject: [PATCH 05/32] fix(installer): make uninstall atomic for plugins and pass rollback test --- scripts/install-whispergate-openclaw.sh | 51 ++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh index 573b325..5bf0c3d 100755 --- a/scripts/install-whispergate-openclaw.sh +++ b/scripts/install-whispergate-openclaw.sh @@ -245,25 +245,64 @@ run_uninstall() { python3 - <<'PY' import json, os, subprocess, sys + rec=json.load(open(os.environ['REC_FILE'],encoding='utf-8')) paths=rec.get('paths',{}) -for path, info in paths.items(): - exists=bool(info.get('exists')) - if exists: +PLUGINS_LOAD='plugins.load.paths' +PLUGINS_ENTRY='plugins.entries.whispergate' +PROVIDER_PATHS=[k for k in paths.keys() if k.startswith('models.providers[')] + +# 1) restore plugins atomically to avoid transient schema failures +pcur=subprocess.run(['openclaw','config','get','plugins','--json'],capture_output=True,text=True) +plugins={} +if pcur.returncode==0: + plugins=json.loads(pcur.stdout) +if not isinstance(plugins,dict): + plugins={} + +load=plugins.get('load') if isinstance(plugins.get('load'),dict) else {} +entries=plugins.get('entries') if isinstance(plugins.get('entries'),dict) else {} + +# restore plugins.load.paths from record +info=paths.get(PLUGINS_LOAD,{'exists':False}) +if info.get('exists'): + load['paths']=info.get('value') +else: + load.pop('paths',None) + +# restore plugins.entries.whispergate from record +info=paths.get(PLUGINS_ENTRY,{'exists':False}) +if info.get('exists'): + entries['whispergate']=info.get('value') +else: + entries.pop('whispergate',None) + +plugins['load']=load +plugins['entries']=entries + +pset=subprocess.run(['openclaw','config','set','plugins',json.dumps(plugins,ensure_ascii=False),'--json'],capture_output=True,text=True) +if pset.returncode!=0: + sys.stderr.write(pset.stderr or pset.stdout) + raise SystemExit(1) + +# 2) restore provider paths (usually custom no-reply provider) +for path in PROVIDER_PATHS: + info=paths.get(path,{'exists':False}) + if info.get('exists'): val=json.dumps(info.get('value'),ensure_ascii=False) p=subprocess.run(['openclaw','config','set',path,val,'--json'],capture_output=True,text=True) if p.returncode!=0: - sys.stderr.write(p.stderr) + sys.stderr.write(p.stderr or p.stdout) raise SystemExit(1) else: p=subprocess.run(['openclaw','config','unset',path],capture_output=True,text=True) if p.returncode!=0: - # path may already be absent; tolerate common "not found" style failures txt=(p.stderr or p.stdout or '').lower() if 'not found' not in txt and 'missing' not in txt and 'does not exist' not in txt: - sys.stderr.write(p.stderr) + sys.stderr.write(p.stderr or p.stdout) raise SystemExit(1) + print('ok') PY From f5ec6f9b15b629b320e58c9563f1f584680eafff Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 23:21:42 +0000 Subject: [PATCH 06/32] chore(installer): change default no-reply provider/model ids --- scripts/install-whispergate-openclaw.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh index 5bf0c3d..b96b4fb 100755 --- a/scripts/install-whispergate-openclaw.sh +++ b/scripts/install-whispergate-openclaw.sh @@ -10,8 +10,8 @@ set -Eeuo pipefail OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-$HOME/.openclaw/openclaw.json}" PLUGIN_PATH="${PLUGIN_PATH:-/root/.openclaw/workspace-operator/WhisperGate/dist/plugin}" -NO_REPLY_PROVIDER_ID="${NO_REPLY_PROVIDER_ID:-custom-127-0-0-1-8787}" -NO_REPLY_MODEL_ID="${NO_REPLY_MODEL_ID:-whispergate-no-reply-v1}" +NO_REPLY_PROVIDER_ID="${NO_REPLY_PROVIDER_ID:-whisper-gateway}" +NO_REPLY_MODEL_ID="${NO_REPLY_MODEL_ID:-no-reply}" NO_REPLY_BASE_URL="${NO_REPLY_BASE_URL:-http://127.0.0.1:8787/v1}" NO_REPLY_API_KEY="${NO_REPLY_API_KEY:-wg-local-test-token}" BYPASS_USER_IDS_JSON="${BYPASS_USER_IDS_JSON:-[\"561921120408698910\",\"1474088632750047324\"]}" From 7f1d6bb3f7e5cb8e973155497bb03e3305db84de Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 23:27:16 +0000 Subject: [PATCH 07/32] fix(plugin-path): align packaged dir with plugin id to remove mismatch warning --- docs/RELEASE.md | 12 ++++++------ scripts/install-whispergate-openclaw.sh | 2 +- scripts/package-plugin.mjs | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) 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/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh index b96b4fb..267b9fe 100755 --- a/scripts/install-whispergate-openclaw.sh +++ b/scripts/install-whispergate-openclaw.sh @@ -9,7 +9,7 @@ set -Eeuo pipefail # - every install writes a change record OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-$HOME/.openclaw/openclaw.json}" -PLUGIN_PATH="${PLUGIN_PATH:-/root/.openclaw/workspace-operator/WhisperGate/dist/plugin}" +PLUGIN_PATH="${PLUGIN_PATH:-/root/.openclaw/workspace-operator/WhisperGate/dist/whispergate}" NO_REPLY_PROVIDER_ID="${NO_REPLY_PROVIDER_ID:-whisper-gateway}" NO_REPLY_MODEL_ID="${NO_REPLY_MODEL_ID:-no-reply}" NO_REPLY_BASE_URL="${NO_REPLY_BASE_URL:-http://127.0.0.1:8787/v1}" 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 }); From 0f526346f48121fbc79f1d4f1ab6cf2a6b056267 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 23:37:15 +0000 Subject: [PATCH 08/32] feat(tool): register optional discord_control tool in whispergate plugin and align defaults --- docs/CONFIG.example.json | 40 +++++++++++++--- docs/DISCORD_CONTROL.md | 3 ++ plugin/README.md | 18 ++++++- plugin/index.ts | 96 ++++++++++++++++++++++++++++++++++++- plugin/openclaw.plugin.json | 8 +++- 5 files changed, 154 insertions(+), 11 deletions(-) diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index 4556397..ade6fec 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": { @@ -10,19 +10,45 @@ "enabled": true, "discordOnly": true, "bypassUserIds": ["561921120408698910"], - "endSymbols": ["。", "!", "?", ".", "!", "?"], - "noReplyProvider": "openai", - "noReplyModel": "whispergate-no-reply-v1" + "endSymbols": ["🔚"], + "noReplyProvider": "whisper-gateway", + "noReplyModel": "no-reply", + "enableDiscordControlTool": true, + "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": 4096, + "maxTokens": 64 + } + ] } } + }, + "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/plugin/README.md b/plugin/README.md index 2373162..94feb2a 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -27,4 +27,20 @@ Optional: - `enabled` (default true) - `discordOnly` (default true) - `bypassUserIds` (default []) -- `endSymbols` (default punctuation set) +- `endSymbols` (default ["🔚"]) +- `enableDiscordControlTool` (default true) +- `discordControlApiBaseUrl` (default `http://127.0.0.1:8790`) +- `discordControlApiToken` +- `discordControlCallerId` + +## Optional tool: `discord_control` + +This plugin now registers an optional tool named `discord_control`. +To use it, add tool allowlist entry for either: +- tool name: `discord_control` +- plugin id: `whispergate` + +Supported actions: +- `channel-private-create` +- `channel-private-update` +- `member-list` diff --git a/plugin/index.ts b/plugin/index.ts index 5310078..3bf28a8 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,6 +1,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { evaluateDecision, type Decision, type WhisperGateConfig } from "./rules.js"; +type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; + type DecisionRecord = { decision: Decision; createdAt: number; @@ -53,11 +55,103 @@ function shouldInjectEndMarker(reason: string): boolean { return reason === "bypass_sender" || reason.startsWith("end_symbol:"); } +function pickDefined(input: Record) { + const out: Record = {}; + for (const [k, v] of Object.entries(input)) { + if (v !== undefined) out[k] = v; + } + return out; +} + export default { id: "whispergate", name: "WhisperGate", register(api: OpenClawPluginApi) { - const config = (api.pluginConfig || {}) as WhisperGateConfig; + const config = (api.pluginConfig || {}) as WhisperGateConfig & { + enableDiscordControlTool?: boolean; + discordControlApiBaseUrl?: string; + discordControlApiToken?: string; + discordControlCallerId?: string; + }; + + if (config.enableDiscordControlTool !== false) { + api.registerTool( + { + name: "discord_control", + description: "Discord admin extension actions: private channel create/update and member list.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + action: { + type: "string", + enum: ["channel-private-create", "channel-private-update", "member-list"], + }, + 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" }, + }, + required: ["action", "guildId"], + }, + async execute(_id: string, params: Record) { + const action = String(params.action || "") as DiscordControlAction; + const baseUrl = (config.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, ""); + const body = pickDefined({ ...params, action }); + + const headers: Record = { "Content-Type": "application/json" }; + if (config.discordControlApiToken) headers.Authorization = `Bearer ${config.discordControlApiToken}`; + if (config.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = config.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: `discord_control failed (${r.status}): ${text}`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: "text", + text, + }, + ], + }; + }, + }, + { optional: true }, + ); + } api.registerHook("message:received", async (event, ctx) => { try { diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index bfba37e..b56dade 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -11,9 +11,13 @@ "enabled": { "type": "boolean", "default": true }, "discordOnly": { "type": "boolean", "default": true }, "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 }, + "discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" }, + "discordControlApiToken": { "type": "string" }, + "discordControlCallerId": { "type": "string" } }, "required": ["noReplyProvider", "noReplyModel"] } From 6d463a4572287b107d8df22d98bc324d8ecd93e9 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 00:18:05 +0000 Subject: [PATCH 09/32] feat(config): add hot-reload config + listMode (human-list/agent-list) --- docs/CONFIG.example.json | 4 ++- docs/INTEGRATION.md | 4 ++- docs/ROLLOUT.md | 4 +-- plugin/README.md | 5 +++- plugin/index.ts | 39 ++++++++++++++++++------- plugin/openclaw.plugin.json | 3 ++ plugin/rules.ts | 35 ++++++++++++++++++---- scripts/install-whispergate-openclaw.sh | 10 +++++-- 8 files changed, 79 insertions(+), 25 deletions(-) diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index ade6fec..0ed8f1e 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -9,7 +9,9 @@ "config": { "enabled": true, "discordOnly": true, - "bypassUserIds": ["561921120408698910"], + "listMode": "human-list", + "humanList": ["561921120408698910"], + "agentList": [], "endSymbols": ["🔚"], "noReplyProvider": "whisper-gateway", "noReplyModel": "no-reply", diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 339762a..f1c8c12 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -51,7 +51,9 @@ Environment overrides: - `NO_REPLY_MODEL_ID` - `NO_REPLY_BASE_URL` - `NO_REPLY_API_KEY` -- `BYPASS_USER_IDS_JSON` +- `LIST_MODE` (`human-list` or `agent-list`) +- `HUMAN_LIST_JSON` +- `AGENT_LIST_JSON` - `END_SYMBOLS_JSON` The script: 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/plugin/README.md b/plugin/README.md index 94feb2a..6e13cf2 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -26,7 +26,10 @@ Required: Optional: - `enabled` (default true) - `discordOnly` (default true) -- `bypassUserIds` (default []) +- `listMode` (`human-list` | `agent-list`, default `human-list`) +- `humanList` (default []) +- `agentList` (default []) +- `bypassUserIds` (deprecated alias of `humanList`) - `endSymbols` (default ["🔚"]) - `enableDiscordControlTool` (default true) - `discordControlApiBaseUrl` (default `http://127.0.0.1:8790`) diff --git a/plugin/index.ts b/plugin/index.ts index 3bf28a8..208c0be 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -52,7 +52,17 @@ function pruneDecisionMap(now = Date.now()) { } function shouldInjectEndMarker(reason: string): boolean { - return reason === "bypass_sender" || reason.startsWith("end_symbol:"); + return 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) return cfg as unknown as WhisperGateConfig; + return fallback; } function pickDefined(input: Record) { @@ -67,14 +77,14 @@ export default { id: "whispergate", name: "WhisperGate", register(api: OpenClawPluginApi) { - const config = (api.pluginConfig || {}) as WhisperGateConfig & { + const baseConfig = (api.pluginConfig || {}) as WhisperGateConfig & { enableDiscordControlTool?: boolean; discordControlApiBaseUrl?: string; discordControlApiToken?: string; discordControlCallerId?: string; }; - if (config.enableDiscordControlTool !== false) { + if (baseConfig.enableDiscordControlTool !== false) { api.registerTool( { name: "discord_control", @@ -113,12 +123,17 @@ export default { }, async execute(_id: string, params: Record) { const action = String(params.action || "") as DiscordControlAction; - const baseUrl = (config.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, ""); + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & { + discordControlApiBaseUrl?: string; + discordControlApiToken?: string; + discordControlCallerId?: string; + }; + const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, ""); const body = pickDefined({ ...params, action }); const headers: Record = { "Content-Type": "application/json" }; - if (config.discordControlApiToken) headers.Authorization = `Bearer ${config.discordControlApiToken}`; - if (config.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = config.discordControlCallerId; + 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", @@ -153,7 +168,7 @@ export default { ); } - api.registerHook("message:received", async (event, ctx) => { + api.on("message_received", async (event, ctx) => { try { const c = (ctx || {}) as Record; const e = (event || {}) as Record; @@ -164,7 +179,8 @@ export default { const content = typeof e.content === "string" ? e.content : ""; const channel = normalizeChannel(c); - const decision = evaluateDecision({ config, channel, senderId, content }); + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig); + const decision = evaluateDecision({ config: live, channel, senderId, content }); sessionDecision.set(sessionKey, { decision, createdAt: Date.now() }); pruneDecisionMap(); api.logger.debug?.( @@ -190,13 +206,14 @@ export default { // no-reply path is consumed here sessionDecision.delete(key); + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig); 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, }; }); diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index b56dade..968c0bf 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -10,6 +10,9 @@ "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": [] }, "bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] }, "endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] }, "noReplyProvider": { "type": "string" }, diff --git a/plugin/rules.ts b/plugin/rules.ts index 9fafcc2..fb00f3c 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -1,6 +1,10 @@ export type WhisperGateConfig = { enabled?: boolean; discordOnly?: boolean; + listMode?: "human-list" | "agent-list"; + humanList?: string[]; + agentList?: string[]; + // backward compatibility bypassUserIds?: string[]; endSymbols?: string[]; noReplyProvider: string; @@ -34,14 +38,33 @@ export function evaluateDecision(params: { return { shouldUseNoReply: false, reason: "non_discord" }; } - if (params.senderId && (config.bypassUserIds || []).includes(params.senderId)) { - return { shouldUseNoReply: false, reason: "bypass_sender" }; - } + const mode = config.listMode || "human-list"; + const humanList = config.humanList || config.bypassUserIds || []; + const agentList = config.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 && (config.endSymbols || []).includes(lastChar); + + if (mode === "human-list") { + if (inHumanList) { + return { shouldUseNoReply: false, reason: "human_list_sender" }; + } + if (hasEnd) { + return { shouldUseNoReply: false, reason: `end_symbol:${lastChar}` }; + } + return { shouldUseNoReply: true, 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, reason: "non_agent_list_sender" }; + } + if (hasEnd) { + return { shouldUseNoReply: false, reason: `end_symbol:${lastChar}` }; + } + return { shouldUseNoReply: true, reason: "agent_list_missing_end_symbol" }; } diff --git a/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh index 267b9fe..c91bcda 100755 --- a/scripts/install-whispergate-openclaw.sh +++ b/scripts/install-whispergate-openclaw.sh @@ -14,7 +14,9 @@ NO_REPLY_PROVIDER_ID="${NO_REPLY_PROVIDER_ID:-whisper-gateway}" NO_REPLY_MODEL_ID="${NO_REPLY_MODEL_ID:-no-reply}" NO_REPLY_BASE_URL="${NO_REPLY_BASE_URL:-http://127.0.0.1:8787/v1}" NO_REPLY_API_KEY="${NO_REPLY_API_KEY:-wg-local-test-token}" -BYPASS_USER_IDS_JSON="${BYPASS_USER_IDS_JSON:-[\"561921120408698910\",\"1474088632750047324\"]}" +LIST_MODE="${LIST_MODE:-human-list}" +HUMAN_LIST_JSON="${HUMAN_LIST_JSON:-[\"561921120408698910\",\"1474088632750047324\"]}" +AGENT_LIST_JSON="${AGENT_LIST_JSON:-[]}" END_SYMBOLS_JSON="${END_SYMBOLS_JSON:-[\"🔚\"]}" STATE_DIR="${STATE_DIR:-$HOME/.openclaw/whispergate-install-records}" @@ -136,7 +138,7 @@ PY current_plugins_json='{}' fi - new_plugins_json="$(CURRENT_PLUGINS_JSON="$current_plugins_json" PLUGIN_PATH="$PLUGIN_PATH" BYPASS_USER_IDS_JSON="$BYPASS_USER_IDS_JSON" END_SYMBOLS_JSON="$END_SYMBOLS_JSON" NO_REPLY_PROVIDER_ID="$NO_REPLY_PROVIDER_ID" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' + new_plugins_json="$(CURRENT_PLUGINS_JSON="$current_plugins_json" PLUGIN_PATH="$PLUGIN_PATH" LIST_MODE="$LIST_MODE" HUMAN_LIST_JSON="$HUMAN_LIST_JSON" AGENT_LIST_JSON="$AGENT_LIST_JSON" END_SYMBOLS_JSON="$END_SYMBOLS_JSON" NO_REPLY_PROVIDER_ID="$NO_REPLY_PROVIDER_ID" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' import json, os plugins=json.loads(os.environ['CURRENT_PLUGINS_JSON']) if not isinstance(plugins,dict): @@ -157,7 +159,9 @@ entries['whispergate']={ 'config': { 'enabled': True, 'discordOnly': True, - 'bypassUserIds': json.loads(os.environ['BYPASS_USER_IDS_JSON']), + 'listMode': os.environ['LIST_MODE'], + 'humanList': json.loads(os.environ['HUMAN_LIST_JSON']), + 'agentList': json.loads(os.environ['AGENT_LIST_JSON']), 'endSymbols': json.loads(os.environ['END_SYMBOLS_JSON']), 'noReplyProvider': os.environ['NO_REPLY_PROVIDER_ID'], 'noReplyModel': os.environ['NO_REPLY_MODEL_ID'], From d6f908b813ccdc40b667552966466c00703fb6b7 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 00:23:47 +0000 Subject: [PATCH 10/32] feat(policy): add per-channel channelPolicies with hot-reload list mode/lists --- docs/CONFIG.example.json | 7 ++++ docs/INTEGRATION.md | 1 + plugin/README.md | 1 + plugin/index.ts | 3 +- plugin/openclaw.plugin.json | 14 ++++++++ plugin/rules.ts | 44 ++++++++++++++++++++++--- scripts/install-whispergate-openclaw.sh | 4 ++- 7 files changed, 68 insertions(+), 6 deletions(-) diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index 0ed8f1e..c1f6109 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -13,6 +13,13 @@ "humanList": ["561921120408698910"], "agentList": [], "endSymbols": ["🔚"], + "channelPolicies": { + "1476369680632647721": { + "listMode": "agent-list", + "agentList": ["1474088632750047324"], + "endSymbols": ["🔚"] + } + }, "noReplyProvider": "whisper-gateway", "noReplyModel": "no-reply", "enableDiscordControlTool": true, diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index f1c8c12..07d13ae 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -54,6 +54,7 @@ Environment overrides: - `LIST_MODE` (`human-list` or `agent-list`) - `HUMAN_LIST_JSON` - `AGENT_LIST_JSON` +- `CHANNEL_POLICIES_JSON` - `END_SYMBOLS_JSON` The script: diff --git a/plugin/README.md b/plugin/README.md index 6e13cf2..1abc1f9 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -29,6 +29,7 @@ Optional: - `listMode` (`human-list` | `agent-list`, default `human-list`) - `humanList` (default []) - `agentList` (default []) +- `channelPolicies` (per-channel overrides by channelId) - `bypassUserIds` (deprecated alias of `humanList`) - `endSymbols` (default ["🔚"]) - `enableDiscordControlTool` (default true) diff --git a/plugin/index.ts b/plugin/index.ts index 208c0be..b3cffb7 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -178,9 +178,10 @@ export default { const senderId = normalizeSender(e, c); const content = typeof e.content === "string" ? e.content : ""; const channel = normalizeChannel(c); + const channelId = typeof c.channelId === "string" ? c.channelId : undefined; const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig); - const decision = evaluateDecision({ config: live, channel, senderId, content }); + const decision = evaluateDecision({ config: live, channel, channelId, senderId, content }); sessionDecision.set(sessionKey, { decision, createdAt: Date.now() }); pruneDecisionMap(); api.logger.debug?.( diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 968c0bf..3779378 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -13,6 +13,20 @@ "listMode": { "type": "string", "enum": ["human-list", "agent-list"], "default": "human-list" }, "humanList": { "type": "array", "items": { "type": "string" }, "default": [] }, "agentList": { "type": "array", "items": { "type": "string" }, "default": [] }, + "channelPolicies": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "listMode": { "type": "string", "enum": ["human-list", "agent-list"] }, + "humanList": { "type": "array", "items": { "type": "string" } }, + "agentList": { "type": "array", "items": { "type": "string" } }, + "endSymbols": { "type": "array", "items": { "type": "string" } } + } + }, + "default": {} + }, "bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] }, "endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] }, "noReplyProvider": { "type": "string" }, diff --git a/plugin/rules.ts b/plugin/rules.ts index fb00f3c..b4797f4 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -4,6 +4,15 @@ export type WhisperGateConfig = { listMode?: "human-list" | "agent-list"; humanList?: string[]; agentList?: string[]; + channelPolicies?: Record< + string, + { + listMode?: "human-list" | "agent-list"; + humanList?: string[]; + agentList?: string[]; + endSymbols?: string[]; + } + >; // backward compatibility bypassUserIds?: string[]; endSymbols?: string[]; @@ -21,9 +30,34 @@ function getLastChar(input: string): string { return t.length ? t[t.length - 1] : ""; } +function resolvePolicy(config: WhisperGateConfig, channelId?: string) { + 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 = config.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; senderId?: string; content?: string; }): Decision { @@ -38,16 +72,18 @@ export function evaluateDecision(params: { return { shouldUseNoReply: false, reason: "non_discord" }; } - const mode = config.listMode || "human-list"; - const humanList = config.humanList || config.bypassUserIds || []; - const agentList = config.agentList || []; + const policy = resolvePolicy(config, params.channelId); + + 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 hasEnd = !!lastChar && (config.endSymbols || []).includes(lastChar); + const hasEnd = !!lastChar && policy.endSymbols.includes(lastChar); if (mode === "human-list") { if (inHumanList) { diff --git a/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh index c91bcda..4686518 100755 --- a/scripts/install-whispergate-openclaw.sh +++ b/scripts/install-whispergate-openclaw.sh @@ -17,6 +17,7 @@ NO_REPLY_API_KEY="${NO_REPLY_API_KEY:-wg-local-test-token}" LIST_MODE="${LIST_MODE:-human-list}" HUMAN_LIST_JSON="${HUMAN_LIST_JSON:-[\"561921120408698910\",\"1474088632750047324\"]}" AGENT_LIST_JSON="${AGENT_LIST_JSON:-[]}" +CHANNEL_POLICIES_JSON="${CHANNEL_POLICIES_JSON:-{}}" END_SYMBOLS_JSON="${END_SYMBOLS_JSON:-[\"🔚\"]}" STATE_DIR="${STATE_DIR:-$HOME/.openclaw/whispergate-install-records}" @@ -138,7 +139,7 @@ PY current_plugins_json='{}' fi - new_plugins_json="$(CURRENT_PLUGINS_JSON="$current_plugins_json" PLUGIN_PATH="$PLUGIN_PATH" LIST_MODE="$LIST_MODE" HUMAN_LIST_JSON="$HUMAN_LIST_JSON" AGENT_LIST_JSON="$AGENT_LIST_JSON" END_SYMBOLS_JSON="$END_SYMBOLS_JSON" NO_REPLY_PROVIDER_ID="$NO_REPLY_PROVIDER_ID" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' + new_plugins_json="$(CURRENT_PLUGINS_JSON="$current_plugins_json" PLUGIN_PATH="$PLUGIN_PATH" LIST_MODE="$LIST_MODE" HUMAN_LIST_JSON="$HUMAN_LIST_JSON" AGENT_LIST_JSON="$AGENT_LIST_JSON" CHANNEL_POLICIES_JSON="$CHANNEL_POLICIES_JSON" END_SYMBOLS_JSON="$END_SYMBOLS_JSON" NO_REPLY_PROVIDER_ID="$NO_REPLY_PROVIDER_ID" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' import json, os plugins=json.loads(os.environ['CURRENT_PLUGINS_JSON']) if not isinstance(plugins,dict): @@ -162,6 +163,7 @@ entries['whispergate']={ 'listMode': os.environ['LIST_MODE'], 'humanList': json.loads(os.environ['HUMAN_LIST_JSON']), 'agentList': json.loads(os.environ['AGENT_LIST_JSON']), + 'channelPolicies': json.loads(os.environ['CHANNEL_POLICIES_JSON']), 'endSymbols': json.loads(os.environ['END_SYMBOLS_JSON']), 'noReplyProvider': os.environ['NO_REPLY_PROVIDER_ID'], 'noReplyModel': os.environ['NO_REPLY_MODEL_ID'], From 682d9a336ef282483b0c58109a26927861653ab3 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 00:28:34 +0000 Subject: [PATCH 11/32] feat(policy-file): move channel overrides to standalone channelPoliciesFile with hot reload --- docs/CONFIG.example.json | 8 +----- docs/INTEGRATION.md | 3 ++- docs/channel-policies.example.json | 12 +++++++++ plugin/README.md | 4 ++- plugin/index.ts | 35 +++++++++++++++++++++++-- plugin/openclaw.plugin.json | 15 +---------- plugin/rules.ts | 24 ++++++++--------- scripts/install-whispergate-openclaw.sh | 17 ++++++++++-- 8 files changed, 79 insertions(+), 39 deletions(-) create mode 100644 docs/channel-policies.example.json diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index c1f6109..afdaa17 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -13,13 +13,7 @@ "humanList": ["561921120408698910"], "agentList": [], "endSymbols": ["🔚"], - "channelPolicies": { - "1476369680632647721": { - "listMode": "agent-list", - "agentList": ["1474088632750047324"], - "endSymbols": ["🔚"] - } - }, + "channelPoliciesFile": "~/.openclaw/whispergate-channel-policies.json", "noReplyProvider": "whisper-gateway", "noReplyModel": "no-reply", "enableDiscordControlTool": true, diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 07d13ae..e0dcfde 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -54,7 +54,8 @@ Environment overrides: - `LIST_MODE` (`human-list` or `agent-list`) - `HUMAN_LIST_JSON` - `AGENT_LIST_JSON` -- `CHANNEL_POLICIES_JSON` +- `CHANNEL_POLICIES_FILE` (standalone channel policy file path) +- `CHANNEL_POLICIES_JSON` (only used to initialize file when missing) - `END_SYMBOLS_JSON` The script: diff --git a/docs/channel-policies.example.json b/docs/channel-policies.example.json new file mode 100644 index 0000000..6a950a5 --- /dev/null +++ b/docs/channel-policies.example.json @@ -0,0 +1,12 @@ +{ + "1476369680632647721": { + "listMode": "agent-list", + "agentList": ["1474088632750047324"], + "endSymbols": ["🔚"] + }, + "another-channel-id": { + "listMode": "human-list", + "humanList": ["561921120408698910"], + "endSymbols": ["🔚"] + } +} diff --git a/plugin/README.md b/plugin/README.md index 1abc1f9..63baa8e 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -29,7 +29,7 @@ Optional: - `listMode` (`human-list` | `agent-list`, default `human-list`) - `humanList` (default []) - `agentList` (default []) -- `channelPolicies` (per-channel overrides by channelId) +- `channelPoliciesFile` (per-channel overrides in a standalone JSON file) - `bypassUserIds` (deprecated alias of `humanList`) - `endSymbols` (default ["🔚"]) - `enableDiscordControlTool` (default true) @@ -37,6 +37,8 @@ Optional: - `discordControlApiToken` - `discordControlCallerId` +Per-channel policy file example: `docs/channel-policies.example.json`. + ## Optional tool: `discord_control` This plugin now registers an optional tool named `discord_control`. diff --git a/plugin/index.ts b/plugin/index.ts index b3cffb7..1ff48f2 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,5 +1,6 @@ +import fs from "node:fs"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { evaluateDecision, type Decision, type WhisperGateConfig } from "./rules.js"; +import { evaluateDecision, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; @@ -13,6 +14,10 @@ const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; const END_MARKER_INSTRUCTION = "你的这次发言必须以🔚作为结尾。"; +let channelPoliciesCache: Record = {}; +let channelPoliciesFilePath = ""; +let channelPoliciesMtimeMs = -1; + function normalizeChannel(ctx: Record): string { const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; for (const c of candidates) { @@ -65,6 +70,31 @@ function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig return fallback; } +function loadChannelPolicies(api: OpenClawPluginApi, config: WhisperGateConfig): Record { + const file = config.channelPoliciesFile; + if (!file) return {}; + + const resolved = api.resolvePath(file); + try { + const stat = fs.statSync(resolved); + const mtime = Number(stat.mtimeMs || 0); + + if (resolved === channelPoliciesFilePath && mtime === channelPoliciesMtimeMs) { + return channelPoliciesCache; + } + + const raw = fs.readFileSync(resolved, "utf8"); + const parsed = JSON.parse(raw) as Record; + channelPoliciesCache = parsed && typeof parsed === "object" ? parsed : {}; + channelPoliciesFilePath = resolved; + channelPoliciesMtimeMs = mtime; + return channelPoliciesCache; + } catch (err) { + api.logger.warn(`whispergate: failed loading channelPoliciesFile=${resolved}: ${String(err)}`); + return {}; + } +} + function pickDefined(input: Record) { const out: Record = {}; for (const [k, v] of Object.entries(input)) { @@ -181,7 +211,8 @@ export default { const channelId = typeof c.channelId === "string" ? c.channelId : undefined; const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig); - const decision = evaluateDecision({ config: live, channel, channelId, senderId, content }); + const channelPolicies = loadChannelPolicies(api, live); + const decision = evaluateDecision({ config: live, channel, channelId, channelPolicies, senderId, content }); sessionDecision.set(sessionKey, { decision, createdAt: Date.now() }); pruneDecisionMap(); api.logger.debug?.( diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 3779378..1de890b 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -13,20 +13,7 @@ "listMode": { "type": "string", "enum": ["human-list", "agent-list"], "default": "human-list" }, "humanList": { "type": "array", "items": { "type": "string" }, "default": [] }, "agentList": { "type": "array", "items": { "type": "string" }, "default": [] }, - "channelPolicies": { - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": false, - "properties": { - "listMode": { "type": "string", "enum": ["human-list", "agent-list"] }, - "humanList": { "type": "array", "items": { "type": "string" } }, - "agentList": { "type": "array", "items": { "type": "string" } }, - "endSymbols": { "type": "array", "items": { "type": "string" } } - } - }, - "default": {} - }, + "channelPoliciesFile": { "type": "string", "default": "~/.openclaw/whispergate-channel-policies.json" }, "bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] }, "endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["🔚"] }, "noReplyProvider": { "type": "string" }, diff --git a/plugin/rules.ts b/plugin/rules.ts index b4797f4..4ca53b5 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -4,15 +4,7 @@ export type WhisperGateConfig = { listMode?: "human-list" | "agent-list"; humanList?: string[]; agentList?: string[]; - channelPolicies?: Record< - string, - { - listMode?: "human-list" | "agent-list"; - humanList?: string[]; - agentList?: string[]; - endSymbols?: string[]; - } - >; + channelPoliciesFile?: string; // backward compatibility bypassUserIds?: string[]; endSymbols?: string[]; @@ -20,6 +12,13 @@ export type WhisperGateConfig = { noReplyModel: string; }; +export type ChannelPolicy = { + listMode?: "human-list" | "agent-list"; + humanList?: string[]; + agentList?: string[]; + endSymbols?: string[]; +}; + export type Decision = { shouldUseNoReply: boolean; reason: string; @@ -30,7 +29,7 @@ function getLastChar(input: string): string { return t.length ? t[t.length - 1] : ""; } -function resolvePolicy(config: WhisperGateConfig, channelId?: string) { +function resolvePolicy(config: WhisperGateConfig, channelId?: string, channelPolicies?: Record) { const globalMode = config.listMode || "human-list"; const globalHuman = config.humanList || config.bypassUserIds || []; const globalAgent = config.agentList || []; @@ -40,7 +39,7 @@ function resolvePolicy(config: WhisperGateConfig, channelId?: string) { return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd }; } - const cp = config.channelPolicies || {}; + const cp = channelPolicies || {}; const scoped = cp[channelId]; if (!scoped) { return { listMode: globalMode, humanList: globalHuman, agentList: globalAgent, endSymbols: globalEnd }; @@ -58,6 +57,7 @@ export function evaluateDecision(params: { config: WhisperGateConfig; channel?: string; channelId?: string; + channelPolicies?: Record; senderId?: string; content?: string; }): Decision { @@ -72,7 +72,7 @@ export function evaluateDecision(params: { return { shouldUseNoReply: false, reason: "non_discord" }; } - const policy = resolvePolicy(config, params.channelId); + const policy = resolvePolicy(config, params.channelId, params.channelPolicies); const mode = policy.listMode; const humanList = policy.humanList; diff --git a/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh index 4686518..aa33acd 100755 --- a/scripts/install-whispergate-openclaw.sh +++ b/scripts/install-whispergate-openclaw.sh @@ -17,6 +17,7 @@ NO_REPLY_API_KEY="${NO_REPLY_API_KEY:-wg-local-test-token}" LIST_MODE="${LIST_MODE:-human-list}" HUMAN_LIST_JSON="${HUMAN_LIST_JSON:-[\"561921120408698910\",\"1474088632750047324\"]}" AGENT_LIST_JSON="${AGENT_LIST_JSON:-[]}" +CHANNEL_POLICIES_FILE="${CHANNEL_POLICIES_FILE:-$HOME/.openclaw/whispergate-channel-policies.json}" CHANNEL_POLICIES_JSON="${CHANNEL_POLICIES_JSON:-{}}" END_SYMBOLS_JSON="${END_SYMBOLS_JSON:-[\"🔚\"]}" @@ -112,6 +113,18 @@ run_install() { cp -f "$OPENCLAW_CONFIG_PATH" "$BACKUP_PATH" echo "[whispergate] backup: $BACKUP_PATH" + # initialize standalone channel policies file if missing + CHANNEL_POLICIES_FILE_RESOLVED="$(python3 - <<'PY' +import os +print(os.path.expanduser(os.environ['CHANNEL_POLICIES_FILE'])) +PY +)" + if [[ ! -f "$CHANNEL_POLICIES_FILE_RESOLVED" ]]; then + mkdir -p "$(dirname "$CHANNEL_POLICIES_FILE_RESOLVED")" + printf '%s\n' "$CHANNEL_POLICIES_JSON" > "$CHANNEL_POLICIES_FILE_RESOLVED" + echo "[whispergate] initialized channel policies file: $CHANNEL_POLICIES_FILE_RESOLVED" + fi + local prev_paths_json prev_paths_json="$(PATH_PLUGINS_LOAD="$PATH_PLUGINS_LOAD" PATH_PLUGIN_ENTRY="$PATH_PLUGIN_ENTRY" PROVIDER_PATH="$PROVIDER_PATH" python3 - <<'PY' import json, os, subprocess @@ -139,7 +152,7 @@ PY current_plugins_json='{}' fi - new_plugins_json="$(CURRENT_PLUGINS_JSON="$current_plugins_json" PLUGIN_PATH="$PLUGIN_PATH" LIST_MODE="$LIST_MODE" HUMAN_LIST_JSON="$HUMAN_LIST_JSON" AGENT_LIST_JSON="$AGENT_LIST_JSON" CHANNEL_POLICIES_JSON="$CHANNEL_POLICIES_JSON" END_SYMBOLS_JSON="$END_SYMBOLS_JSON" NO_REPLY_PROVIDER_ID="$NO_REPLY_PROVIDER_ID" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' + new_plugins_json="$(CURRENT_PLUGINS_JSON="$current_plugins_json" PLUGIN_PATH="$PLUGIN_PATH" LIST_MODE="$LIST_MODE" HUMAN_LIST_JSON="$HUMAN_LIST_JSON" AGENT_LIST_JSON="$AGENT_LIST_JSON" CHANNEL_POLICIES_FILE="$CHANNEL_POLICIES_FILE" END_SYMBOLS_JSON="$END_SYMBOLS_JSON" NO_REPLY_PROVIDER_ID="$NO_REPLY_PROVIDER_ID" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' import json, os plugins=json.loads(os.environ['CURRENT_PLUGINS_JSON']) if not isinstance(plugins,dict): @@ -163,7 +176,7 @@ entries['whispergate']={ 'listMode': os.environ['LIST_MODE'], 'humanList': json.loads(os.environ['HUMAN_LIST_JSON']), 'agentList': json.loads(os.environ['AGENT_LIST_JSON']), - 'channelPolicies': json.loads(os.environ['CHANNEL_POLICIES_JSON']), + 'channelPoliciesFile': os.environ['CHANNEL_POLICIES_FILE'], 'endSymbols': json.loads(os.environ['END_SYMBOLS_JSON']), 'noReplyProvider': os.environ['NO_REPLY_PROVIDER_ID'], 'noReplyModel': os.environ['NO_REPLY_MODEL_ID'], From e5999743feaf9c5d1f49353502842dae158400aa Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 00:35:17 +0000 Subject: [PATCH 12/32] feat(policy-runtime): in-memory policy state with whispergate_policy tool and atomic persist --- docs/CONFIG.example.json | 1 + docs/INTEGRATION.md | 6 ++ plugin/README.md | 7 ++ plugin/index.ts | 174 +++++++++++++++++++++++++++--------- plugin/openclaw.plugin.json | 1 + 5 files changed, 148 insertions(+), 41 deletions(-) diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index afdaa17..173fcfd 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -17,6 +17,7 @@ "noReplyProvider": "whisper-gateway", "noReplyModel": "no-reply", "enableDiscordControlTool": true, + "enableWhispergatePolicyTool": true, "discordControlApiBaseUrl": "http://127.0.0.1:8790", "discordControlApiToken": "", "discordControlCallerId": "agent-main" diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index e0dcfde..a428d7c 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -66,6 +66,12 @@ The script: - directory: `~/.openclaw/whispergate-install-records/` - latest pointer: `~/.openclaw/whispergate-install-record-latest.json` +Policy state semantics: +- channel policy file is loaded once into memory on startup +- runtime decisions use in-memory state +- use `whispergate_policy` tool to update state (memory first, then file persist) +- manual file edits do not auto-apply until next restart + ## Notes - Keep no-reply API bound to loopback/private network. diff --git a/plugin/README.md b/plugin/README.md index 63baa8e..c0998eb 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -30,6 +30,7 @@ Optional: - `humanList` (default []) - `agentList` (default []) - `channelPoliciesFile` (per-channel overrides in a standalone JSON file) +- `enableWhispergatePolicyTool` (default true) - `bypassUserIds` (deprecated alias of `humanList`) - `endSymbols` (default ["🔚"]) - `enableDiscordControlTool` (default true) @@ -39,6 +40,12 @@ Optional: Per-channel policy file example: `docs/channel-policies.example.json`. +Policy file behavior: +- loaded once on startup into memory +- runtime decisions read memory state only +- direct file edits do NOT affect memory state +- `whispergate_policy` tool updates memory first, then persists to file (atomic write) + ## Optional tool: `discord_control` This plugin now registers an optional tool named `discord_control`. diff --git a/plugin/index.ts b/plugin/index.ts index 1ff48f2..057caec 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { evaluateDecision, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; @@ -9,14 +10,20 @@ type DecisionRecord = { createdAt: number; }; +type PolicyState = { + filePath: string; + channelPolicies: Record; +}; + const sessionDecision = new Map(); const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; const END_MARKER_INSTRUCTION = "你的这次发言必须以🔚作为结尾。"; -let channelPoliciesCache: Record = {}; -let channelPoliciesFilePath = ""; -let channelPoliciesMtimeMs = -1; +const policyState: PolicyState = { + filePath: "", + channelPolicies: {}, +}; function normalizeChannel(ctx: Record): string { const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; @@ -70,31 +77,43 @@ function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig return fallback; } -function loadChannelPolicies(api: OpenClawPluginApi, config: WhisperGateConfig): Record { - const file = config.channelPoliciesFile; - if (!file) return {}; +function resolvePoliciesPath(api: OpenClawPluginApi, config: WhisperGateConfig): string { + return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/whispergate-channel-policies.json"); +} + +function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConfig) { + if (policyState.filePath) return; + const filePath = resolvePoliciesPath(api, config); + policyState.filePath = filePath; - const resolved = api.resolvePath(file); try { - const stat = fs.statSync(resolved); - const mtime = Number(stat.mtimeMs || 0); - - if (resolved === channelPoliciesFilePath && mtime === channelPoliciesMtimeMs) { - return channelPoliciesCache; + if (!fs.existsSync(filePath)) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "{}\n", "utf8"); + policyState.channelPolicies = {}; + return; } - const raw = fs.readFileSync(resolved, "utf8"); + const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as Record; - channelPoliciesCache = parsed && typeof parsed === "object" ? parsed : {}; - channelPoliciesFilePath = resolved; - channelPoliciesMtimeMs = mtime; - return channelPoliciesCache; + policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {}; } catch (err) { - api.logger.warn(`whispergate: failed loading channelPoliciesFile=${resolved}: ${String(err)}`); - return {}; + api.logger.warn(`whispergate: failed init policy file ${filePath}: ${String(err)}`); + policyState.channelPolicies = {}; } } +function persistPolicies(api: OpenClawPluginApi): void { + const filePath = policyState.filePath; + if (!filePath) throw new Error("policy file path not initialized"); + const before = JSON.stringify(policyState.channelPolicies, null, 2) + "\n"; + const tmp = `${filePath}.tmp`; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(tmp, before, "utf8"); + fs.renameSync(tmp, filePath); + api.logger.info(`whispergate: policy file persisted: ${filePath}`); +} + function pickDefined(input: Record) { const out: Record = {}; for (const [k, v] of Object.entries(input)) { @@ -112,8 +131,12 @@ export default { discordControlApiBaseUrl?: string; discordControlApiToken?: string; discordControlCallerId?: string; + enableWhispergatePolicyTool?: boolean; }; + const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig); + ensurePolicyStateLoaded(api, liveAtRegister); + if (baseConfig.enableDiscordControlTool !== false) { api.registerTool( { @@ -174,24 +197,91 @@ export default { if (!r.ok) { return { - content: [ - { - type: "text", - text: `discord_control failed (${r.status}): ${text}`, - }, - ], + content: [{ type: "text", text: `discord_control failed (${r.status}): ${text}` }], isError: true, }; } - return { - content: [ - { - type: "text", - text, - }, - ], - }; + return { content: [{ type: "text", text }] }; + }, + }, + { optional: true }, + ); + } + + if (baseConfig.enableWhispergatePolicyTool !== false) { + api.registerTool( + { + name: "whispergate_policy", + description: "Manage WhisperGate in-memory channel policies and persist to file.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + action: { + type: "string", + enum: ["get", "set-channel", "delete-channel"], + }, + channelId: { type: "string" }, + listMode: { type: "string", enum: ["human-list", "agent-list"] }, + humanList: { type: "array", items: { type: "string" } }, + agentList: { type: "array", items: { type: "string" } }, + endSymbols: { type: "array", items: { type: "string" } }, + }, + required: ["action"], + }, + async execute(_id: string, params: Record) { + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig); + ensurePolicyStateLoaded(api, live); + const action = String(params.action || ""); + + if (action === "get") { + return { + content: [ + { + type: "text", + text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2), + }, + ], + }; + } + + if (action === "set-channel") { + const channelId = String(params.channelId || "").trim(); + if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; + + const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); + try { + const next: ChannelPolicy = { + listMode: (params.listMode as "human-list" | "agent-list" | undefined) || undefined, + humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined, + agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined, + endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined, + }; + policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record) as ChannelPolicy; + persistPolicies(api); + return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, policy: policyState.channelPolicies[channelId] }) }] }; + } catch (err) { + policyState.channelPolicies = prev; + return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; + } + } + + if (action === "delete-channel") { + const channelId = String(params.channelId || "").trim(); + if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; + const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); + try { + delete policyState.channelPolicies[channelId]; + persistPolicies(api); + return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] }; + } catch (err) { + policyState.channelPolicies = prev; + return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; + } + } + + return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; }, }, { optional: true }, @@ -211,8 +301,15 @@ export default { const channelId = typeof c.channelId === "string" ? c.channelId : undefined; const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig); - const channelPolicies = loadChannelPolicies(api, live); - const decision = evaluateDecision({ config: live, channel, channelId, channelPolicies, senderId, content }); + ensurePolicyStateLoaded(api, live); + const decision = evaluateDecision({ + config: live, + channel, + channelId, + channelPolicies: policyState.channelPolicies, + senderId, + content, + }); sessionDecision.set(sessionKey, { decision, createdAt: Date.now() }); pruneDecisionMap(); api.logger.debug?.( @@ -236,7 +333,6 @@ export default { if (!rec.decision.shouldUseNoReply) return; - // no-reply path is consumed here sessionDecision.delete(key); const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig); api.logger.info( @@ -260,15 +356,11 @@ export default { return; } - // consume non-no-reply paths here to avoid stale carry-over sessionDecision.delete(key); - if (!shouldInjectEndMarker(rec.decision.reason)) return; api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`); - return { - prependContext: END_MARKER_INSTRUCTION, - }; + return { prependContext: END_MARKER_INSTRUCTION }; }); }, }; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 1de890b..2872ff9 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -19,6 +19,7 @@ "noReplyProvider": { "type": "string" }, "noReplyModel": { "type": "string" }, "enableDiscordControlTool": { "type": "boolean", "default": true }, + "enableWhispergatePolicyTool": { "type": "boolean", "default": true }, "discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" }, "discordControlApiToken": { "type": "string" }, "discordControlCallerId": { "type": "string" } From 714168e4bf772551fb5998be936122acdd706134 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 00:38:32 +0000 Subject: [PATCH 13/32] fix(installer): pass CHANNEL_POLICIES_FILE env when resolving policy path --- scripts/install-whispergate-openclaw.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install-whispergate-openclaw.sh b/scripts/install-whispergate-openclaw.sh index aa33acd..fe7f6a4 100755 --- a/scripts/install-whispergate-openclaw.sh +++ b/scripts/install-whispergate-openclaw.sh @@ -114,7 +114,7 @@ run_install() { echo "[whispergate] backup: $BACKUP_PATH" # initialize standalone channel policies file if missing - CHANNEL_POLICIES_FILE_RESOLVED="$(python3 - <<'PY' + CHANNEL_POLICIES_FILE_RESOLVED="$(CHANNEL_POLICIES_FILE="$CHANNEL_POLICIES_FILE" python3 - <<'PY' import os print(os.path.expanduser(os.environ['CHANNEL_POLICIES_FILE'])) PY From 46e56c6760a2be035670256cb4e6eb4b3ead3f0c Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 00:41:56 +0000 Subject: [PATCH 14/32] refactor(installer): replace bash installer logic with node-only implementation --- docs/INTEGRATION.md | 8 +- scripts/install-whispergate-openclaw.mjs | 202 +++++++++++++ scripts/install-whispergate-openclaw.sh | 369 +---------------------- 3 files changed, 211 insertions(+), 368 deletions(-) create mode 100755 scripts/install-whispergate-openclaw.mjs diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index a428d7c..a79c405 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -29,19 +29,23 @@ You can merge this snippet manually into your `openclaw.json`. ## Installer script (with rollback) -For production-like install with automatic rollback on error: +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 \ -# ./scripts/install-whispergate-openclaw.sh --uninstall +# node ./scripts/install-whispergate-openclaw.mjs --uninstall ``` Environment overrides: diff --git a/scripts/install-whispergate-openclaw.mjs b/scripts/install-whispergate-openclaw.mjs new file mode 100755 index 0000000..ec0da4d --- /dev/null +++ b/scripts/install-whispergate-openclaw.mjs @@ -0,0 +1,202 @@ +#!/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 PLUGIN_PATH = env.PLUGIN_PATH || "/root/.openclaw/workspace-operator/WhisperGate/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 PROVIDER_PATH = `models.providers["${NO_REPLY_PROVIDER_ID}"]`; + +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")); +} + +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), + [PROVIDER_PATH]: getJson(PROVIDER_PATH), + }; + + 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); + + setJson(PROVIDER_PATH, { + 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: 4096, + maxTokens: 4096, + }, + ], + }); + + const after = { + [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), + [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), + [PROVIDER_PATH]: getJson(PROVIDER_PATH), + }; + writeRecord("install", before, after); + console.log("[whispergate] install ok"); + console.log(`[whispergate] record: ${RECORD_PATH}`); + } 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 || (fs.existsSync(LATEST_RECORD_LINK) ? LATEST_RECORD_LINK : ""); + if (!recFile || !fs.existsSync(recFile)) { + console.error("[whispergate] no record found. set RECORD_FILE= or install first."); + 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); + + for (const k of Object.keys(target)) { + if (!k.startsWith("models.providers[")) continue; + if (target[k]?.exists) setJson(k, target[k].value); + else unsetPath(k); + } + + const after = { + [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), + [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), + [PROVIDER_PATH]: getJson(PROVIDER_PATH), + }; + 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 index fe7f6a4..8ba5a65 100755 --- a/scripts/install-whispergate-openclaw.sh +++ b/scripts/install-whispergate-openclaw.sh @@ -1,367 +1,4 @@ #!/usr/bin/env bash -set -Eeuo pipefail - -# WhisperGate installer/uninstaller for OpenClaw -# Requirements: -# - all writes via `openclaw config set ... --json` -# - install supports rollback on failure -# - uninstall reverts ALL recorded changes -# - every install writes a change record - -OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-$HOME/.openclaw/openclaw.json}" -PLUGIN_PATH="${PLUGIN_PATH:-/root/.openclaw/workspace-operator/WhisperGate/dist/whispergate}" -NO_REPLY_PROVIDER_ID="${NO_REPLY_PROVIDER_ID:-whisper-gateway}" -NO_REPLY_MODEL_ID="${NO_REPLY_MODEL_ID:-no-reply}" -NO_REPLY_BASE_URL="${NO_REPLY_BASE_URL:-http://127.0.0.1:8787/v1}" -NO_REPLY_API_KEY="${NO_REPLY_API_KEY:-wg-local-test-token}" -LIST_MODE="${LIST_MODE:-human-list}" -HUMAN_LIST_JSON="${HUMAN_LIST_JSON:-[\"561921120408698910\",\"1474088632750047324\"]}" -AGENT_LIST_JSON="${AGENT_LIST_JSON:-[]}" -CHANNEL_POLICIES_FILE="${CHANNEL_POLICIES_FILE:-$HOME/.openclaw/whispergate-channel-policies.json}" -CHANNEL_POLICIES_JSON="${CHANNEL_POLICIES_JSON:-{}}" -END_SYMBOLS_JSON="${END_SYMBOLS_JSON:-[\"🔚\"]}" - -STATE_DIR="${STATE_DIR:-$HOME/.openclaw/whispergate-install-records}" -LATEST_RECORD_LINK="${LATEST_RECORD_LINK:-$HOME/.openclaw/whispergate-install-record-latest.json}" - -MODE="" -if [[ "${1:-}" == "--install" ]]; then - MODE="install" -elif [[ "${1:-}" == "--uninstall" ]]; then - MODE="uninstall" -else - echo "Usage: $0 --install | --uninstall" - exit 2 -fi - -TIMESTAMP="$(date +%Y%m%d%H%M%S)" -BACKUP_PATH="${OPENCLAW_CONFIG_PATH}.bak-whispergate-${MODE}-${TIMESTAMP}" -RECORD_PATH="${STATE_DIR}/whispergate-${TIMESTAMP}.json" - -PROVIDER_PATH="models.providers[\"${NO_REPLY_PROVIDER_ID}\"]" -PATH_PLUGINS_LOAD="plugins.load.paths" -PATH_PLUGIN_ENTRY="plugins.entries.whispergate" - -INSTALLED_OK=0 - -require_cmd() { - command -v "$1" >/dev/null 2>&1 || { - echo "[whispergate] missing command: $1" >&2 - exit 1 - } -} - -oc_get_json_or_missing() { - local path="$1" - if openclaw config get "$path" --json >/tmp/wg_get.json 2>/dev/null; then - printf '{"exists":true,"value":%s}\n' "$(cat /tmp/wg_get.json)" - else - printf '{"exists":false}' - fi -} - -oc_set_json() { - local path="$1" - local json="$2" - openclaw config set "$path" "$json" --json >/dev/null -} - -oc_unset() { - local path="$1" - openclaw config unset "$path" >/dev/null -} - -write_record() { - local mode="$1" - local prev_paths_json="$2" - local next_paths_json="$3" - mkdir -p "$STATE_DIR" - python3 - <<'PY' -import json, os -record={ - 'mode': os.environ['REC_MODE'], - 'timestamp': os.environ['TIMESTAMP'], - 'openclawConfigPath': os.environ['OPENCLAW_CONFIG_PATH'], - 'backupPath': os.environ['BACKUP_PATH'], - 'paths': json.loads(os.environ['PREV_PATHS_JSON']), - 'applied': json.loads(os.environ['NEXT_PATHS_JSON']), -} -with open(os.environ['RECORD_PATH'],'w',encoding='utf-8') as f: - json.dump(record,f,ensure_ascii=False,indent=2) -PY - cp -f "$RECORD_PATH" "$LATEST_RECORD_LINK" -} - -rollback_install() { - local ec=$? - if [[ $INSTALLED_OK -eq 1 ]]; then - return - fi - echo "[whispergate] install failed (exit=$ec), rolling back..." - if [[ -f "$BACKUP_PATH" ]]; then - cp -f "$BACKUP_PATH" "$OPENCLAW_CONFIG_PATH" - echo "[whispergate] rollback complete" - else - echo "[whispergate] WARNING: backup missing; rollback skipped" - fi - exit "$ec" -} - -run_install() { - trap rollback_install ERR - - cp -f "$OPENCLAW_CONFIG_PATH" "$BACKUP_PATH" - echo "[whispergate] backup: $BACKUP_PATH" - - # initialize standalone channel policies file if missing - CHANNEL_POLICIES_FILE_RESOLVED="$(CHANNEL_POLICIES_FILE="$CHANNEL_POLICIES_FILE" python3 - <<'PY' -import os -print(os.path.expanduser(os.environ['CHANNEL_POLICIES_FILE'])) -PY -)" - if [[ ! -f "$CHANNEL_POLICIES_FILE_RESOLVED" ]]; then - mkdir -p "$(dirname "$CHANNEL_POLICIES_FILE_RESOLVED")" - printf '%s\n' "$CHANNEL_POLICIES_JSON" > "$CHANNEL_POLICIES_FILE_RESOLVED" - echo "[whispergate] initialized channel policies file: $CHANNEL_POLICIES_FILE_RESOLVED" - fi - - local prev_paths_json - prev_paths_json="$(PATH_PLUGINS_LOAD="$PATH_PLUGINS_LOAD" PATH_PLUGIN_ENTRY="$PATH_PLUGIN_ENTRY" PROVIDER_PATH="$PROVIDER_PATH" python3 - <<'PY' -import json, os, subprocess - -def get(path): - p=subprocess.run(['openclaw','config','get',path,'--json'],capture_output=True,text=True) - if p.returncode==0: - return {'exists':True,'value':json.loads(p.stdout)} - return {'exists':False} - -payload={ - os.environ['PATH_PLUGINS_LOAD']: get(os.environ['PATH_PLUGINS_LOAD']), - os.environ['PATH_PLUGIN_ENTRY']: get(os.environ['PATH_PLUGIN_ENTRY']), - os.environ['PROVIDER_PATH']: get(os.environ['PROVIDER_PATH']), -} -print(json.dumps(payload,ensure_ascii=False)) -PY -)" - - # 1+2) set plugins object in one write (avoid transient schema failure) - local current_plugins_json new_plugins_json - if openclaw config get plugins --json >/tmp/wg_plugins.json 2>/dev/null; then - current_plugins_json="$(cat /tmp/wg_plugins.json)" - else - current_plugins_json='{}' - fi - - new_plugins_json="$(CURRENT_PLUGINS_JSON="$current_plugins_json" PLUGIN_PATH="$PLUGIN_PATH" LIST_MODE="$LIST_MODE" HUMAN_LIST_JSON="$HUMAN_LIST_JSON" AGENT_LIST_JSON="$AGENT_LIST_JSON" CHANNEL_POLICIES_FILE="$CHANNEL_POLICIES_FILE" END_SYMBOLS_JSON="$END_SYMBOLS_JSON" NO_REPLY_PROVIDER_ID="$NO_REPLY_PROVIDER_ID" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' -import json, os -plugins=json.loads(os.environ['CURRENT_PLUGINS_JSON']) -if not isinstance(plugins,dict): - plugins={} - -load=plugins.setdefault('load',{}) -paths=load.get('paths') -if not isinstance(paths,list): - paths=[] -pp=os.environ['PLUGIN_PATH'] -if pp not in paths: - paths.append(pp) -load['paths']=paths - -entries=plugins.setdefault('entries',{}) -entries['whispergate']={ - 'enabled': True, - 'config': { - 'enabled': True, - 'discordOnly': True, - 'listMode': os.environ['LIST_MODE'], - 'humanList': json.loads(os.environ['HUMAN_LIST_JSON']), - 'agentList': json.loads(os.environ['AGENT_LIST_JSON']), - 'channelPoliciesFile': os.environ['CHANNEL_POLICIES_FILE'], - 'endSymbols': json.loads(os.environ['END_SYMBOLS_JSON']), - 'noReplyProvider': os.environ['NO_REPLY_PROVIDER_ID'], - 'noReplyModel': os.environ['NO_REPLY_MODEL_ID'], - } -} - -print(json.dumps(plugins,ensure_ascii=False)) -PY -)" - oc_set_json "plugins" "$new_plugins_json" - - # 3) provider - local provider_json - provider_json="$(NO_REPLY_BASE_URL="$NO_REPLY_BASE_URL" NO_REPLY_API_KEY="$NO_REPLY_API_KEY" NO_REPLY_MODEL_ID="$NO_REPLY_MODEL_ID" python3 - <<'PY' -import json, os -provider={ - 'baseUrl': os.environ['NO_REPLY_BASE_URL'], - 'apiKey': os.environ['NO_REPLY_API_KEY'], - 'api': 'openai-completions', - 'models': [{ - 'id': os.environ['NO_REPLY_MODEL_ID'], - 'name': f"{os.environ['NO_REPLY_MODEL_ID']} (Custom Provider)", - 'reasoning': False, - 'input': ['text'], - 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, - 'contextWindow': 4096, - 'maxTokens': 4096, - }] -} -print(json.dumps(provider,ensure_ascii=False)) -PY -)" - oc_set_json "$PROVIDER_PATH" "$provider_json" - - # validate writes - openclaw config get "$PATH_PLUGINS_LOAD" --json >/dev/null - openclaw config get "$PATH_PLUGIN_ENTRY" --json >/dev/null - openclaw config get "$PROVIDER_PATH" --json >/dev/null - - local next_paths_json - next_paths_json="$(PATH_PLUGINS_LOAD="$PATH_PLUGINS_LOAD" PATH_PLUGIN_ENTRY="$PATH_PLUGIN_ENTRY" PROVIDER_PATH="$PROVIDER_PATH" python3 - <<'PY' -import json, os, subprocess - -def get(path): - p=subprocess.run(['openclaw','config','get',path,'--json'],capture_output=True,text=True) - if p.returncode==0: - return {'exists':True,'value':json.loads(p.stdout)} - return {'exists':False} - -payload={ - os.environ['PATH_PLUGINS_LOAD']: get(os.environ['PATH_PLUGINS_LOAD']), - os.environ['PATH_PLUGIN_ENTRY']: get(os.environ['PATH_PLUGIN_ENTRY']), - os.environ['PROVIDER_PATH']: get(os.environ['PROVIDER_PATH']), -} -print(json.dumps(payload,ensure_ascii=False)) -PY -)" - - REC_MODE="install" PREV_PATHS_JSON="$prev_paths_json" NEXT_PATHS_JSON="$next_paths_json" TIMESTAMP="$TIMESTAMP" OPENCLAW_CONFIG_PATH="$OPENCLAW_CONFIG_PATH" BACKUP_PATH="$BACKUP_PATH" RECORD_PATH="$RECORD_PATH" write_record install "$prev_paths_json" "$next_paths_json" - - INSTALLED_OK=1 - trap - ERR - echo "[whispergate] install ok" - echo "[whispergate] record: $RECORD_PATH" -} - -run_uninstall() { - local rec_file="" - if [[ -n "${RECORD_FILE:-}" ]]; then - rec_file="$RECORD_FILE" - elif [[ -f "$LATEST_RECORD_LINK" ]]; then - rec_file="$LATEST_RECORD_LINK" - else - echo "[whispergate] no record found. set RECORD_FILE= or install first." >&2 - exit 1 - fi - - if [[ ! -f "$rec_file" ]]; then - echo "[whispergate] record file not found: $rec_file" >&2 - exit 1 - fi - - cp -f "$OPENCLAW_CONFIG_PATH" "$BACKUP_PATH" - echo "[whispergate] backup before uninstall: $BACKUP_PATH" - - python3 - <<'PY' -import json, os, subprocess, sys - -rec=json.load(open(os.environ['REC_FILE'],encoding='utf-8')) -paths=rec.get('paths',{}) - -PLUGINS_LOAD='plugins.load.paths' -PLUGINS_ENTRY='plugins.entries.whispergate' -PROVIDER_PATHS=[k for k in paths.keys() if k.startswith('models.providers[')] - -# 1) restore plugins atomically to avoid transient schema failures -pcur=subprocess.run(['openclaw','config','get','plugins','--json'],capture_output=True,text=True) -plugins={} -if pcur.returncode==0: - plugins=json.loads(pcur.stdout) -if not isinstance(plugins,dict): - plugins={} - -load=plugins.get('load') if isinstance(plugins.get('load'),dict) else {} -entries=plugins.get('entries') if isinstance(plugins.get('entries'),dict) else {} - -# restore plugins.load.paths from record -info=paths.get(PLUGINS_LOAD,{'exists':False}) -if info.get('exists'): - load['paths']=info.get('value') -else: - load.pop('paths',None) - -# restore plugins.entries.whispergate from record -info=paths.get(PLUGINS_ENTRY,{'exists':False}) -if info.get('exists'): - entries['whispergate']=info.get('value') -else: - entries.pop('whispergate',None) - -plugins['load']=load -plugins['entries']=entries - -pset=subprocess.run(['openclaw','config','set','plugins',json.dumps(plugins,ensure_ascii=False),'--json'],capture_output=True,text=True) -if pset.returncode!=0: - sys.stderr.write(pset.stderr or pset.stdout) - raise SystemExit(1) - -# 2) restore provider paths (usually custom no-reply provider) -for path in PROVIDER_PATHS: - info=paths.get(path,{'exists':False}) - if info.get('exists'): - val=json.dumps(info.get('value'),ensure_ascii=False) - p=subprocess.run(['openclaw','config','set',path,val,'--json'],capture_output=True,text=True) - if p.returncode!=0: - sys.stderr.write(p.stderr or p.stdout) - raise SystemExit(1) - else: - p=subprocess.run(['openclaw','config','unset',path],capture_output=True,text=True) - if p.returncode!=0: - txt=(p.stderr or p.stdout or '').lower() - if 'not found' not in txt and 'missing' not in txt and 'does not exist' not in txt: - sys.stderr.write(p.stderr or p.stdout) - raise SystemExit(1) - -print('ok') -PY - - local next_paths_json - next_paths_json="$(python3 - <<'PY' -import json, os, subprocess -paths=json.load(open(os.environ['REC_FILE'],encoding='utf-8')).get('paths',{}).keys() -def get(path): - p=subprocess.run(['openclaw','config','get',path,'--json'],capture_output=True,text=True) - if p.returncode==0: - return {'exists':True,'value':json.loads(p.stdout)} - return {'exists':False} -payload={k:get(k) for k in paths} -print(json.dumps(payload,ensure_ascii=False)) -PY -)" - - local prev_paths_json - prev_paths_json="$(python3 - <<'PY' -import json, os -print(json.dumps(json.load(open(os.environ['REC_FILE'],encoding='utf-8')).get('applied',{}),ensure_ascii=False)) -PY -)" - - REC_MODE="uninstall" PREV_PATHS_JSON="$prev_paths_json" NEXT_PATHS_JSON="$next_paths_json" TIMESTAMP="$TIMESTAMP" OPENCLAW_CONFIG_PATH="$OPENCLAW_CONFIG_PATH" BACKUP_PATH="$BACKUP_PATH" RECORD_PATH="$RECORD_PATH" write_record uninstall "$prev_paths_json" "$next_paths_json" - - echo "[whispergate] uninstall ok" - echo "[whispergate] record: $RECORD_PATH" -} - -main() { - require_cmd openclaw - require_cmd python3 - [[ -f "$OPENCLAW_CONFIG_PATH" ]] || { echo "[whispergate] config not found: $OPENCLAW_CONFIG_PATH"; exit 1; } - - if [[ "$MODE" == "install" ]]; then - run_install - else - REC_FILE="${RECORD_FILE:-$LATEST_RECORD_LINK}" run_uninstall - fi -} - -main +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec node "$SCRIPT_DIR/install-whispergate-openclaw.mjs" "$@" From ca967791593a69e4e8f859fc5269aef668a840c9 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 01:31:31 +0000 Subject: [PATCH 15/32] refactor(tooling): merge discord_control and whispergate_policy into whispergateway_tools --- plugin/README.md | 18 ++-- plugin/index.ts | 224 +++++++++++++++++++++-------------------------- 2 files changed, 112 insertions(+), 130 deletions(-) diff --git a/plugin/README.md b/plugin/README.md index c0998eb..38f4f2c 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -31,6 +31,11 @@ Optional: - `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) @@ -44,16 +49,15 @@ Policy file behavior: - loaded once on startup into memory - runtime decisions read memory state only - direct file edits do NOT affect memory state -- `whispergate_policy` tool updates memory first, then persists to file (atomic write) +- `whispergateway_tools` policy actions update memory first, then persist to file (atomic write) -## Optional tool: `discord_control` +## Optional tool: `whispergateway_tools` -This plugin now registers an optional tool named `discord_control`. +This plugin registers one unified optional tool: `whispergateway_tools`. To use it, add tool allowlist entry for either: -- tool name: `discord_control` +- tool name: `whispergateway_tools` - plugin id: `whispergate` Supported actions: -- `channel-private-create` -- `channel-private-update` -- `member-list` +- Discord: `channel-private-create`, `channel-private-update`, `member-list` +- Policy: `policy-get`, `policy-set-channel`, `policy-delete-channel` diff --git a/plugin/index.ts b/plugin/index.ts index 057caec..e36bb0e 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -137,53 +137,65 @@ export default { const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig); ensurePolicyStateLoaded(api, liveAtRegister); - if (baseConfig.enableDiscordControlTool !== false) { - api.registerTool( - { - name: "discord_control", - description: "Discord admin extension actions: private channel create/update and member list.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - action: { - type: "string", - enum: ["channel-private-create", "channel-private-update", "member-list"], - }, - 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" }, + 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"], }, - required: ["action", "guildId"], + 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" } }, }, - async execute(_id: string, params: Record) { - const action = String(params.action || "") as DiscordControlAction; - const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & { - discordControlApiBaseUrl?: string; - discordControlApiToken?: string; - discordControlCallerId?: string; - }; - const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, ""); - const body = pickDefined({ ...params, action }); + 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; @@ -194,99 +206,65 @@ export default { body: JSON.stringify(body), }); const text = await r.text(); - if (!r.ok) { return { - content: [{ type: "text", text: `discord_control failed (${r.status}): ${text}` }], + content: [{ type: "text", text: `whispergateway_tools discord failed (${r.status}): ${text}` }], isError: true, }; } - return { content: [{ type: "text", text }] }; - }, - }, - { optional: true }, - ); - } + } - if (baseConfig.enableWhispergatePolicyTool !== false) { - api.registerTool( - { - name: "whispergate_policy", - description: "Manage WhisperGate in-memory channel policies and persist to file.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - action: { - type: "string", - enum: ["get", "set-channel", "delete-channel"], - }, - channelId: { type: "string" }, - listMode: { type: "string", enum: ["human-list", "agent-list"] }, - humanList: { type: "array", items: { type: "string" } }, - agentList: { type: "array", items: { type: "string" } }, - endSymbols: { type: "array", items: { type: "string" } }, - }, - required: ["action"], - }, - async execute(_id: string, params: Record) { - const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig); - ensurePolicyStateLoaded(api, live); - const action = String(params.action || ""); + if (live.enableWhispergatePolicyTool === false) { + return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; + } - if (action === "get") { - return { - content: [ - { - type: "text", - text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2), - }, - ], + 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 === "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 }; } + } - if (action === "delete-channel") { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); - try { - delete policyState.channelPolicies[channelId]; - persistPolicies(api); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] }; - } catch (err) { - policyState.channelPolicies = prev; - return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; - } - } - - return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; - }, + return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; }, - { optional: true }, - ); - } + }, + { optional: true }, + ); api.on("message_received", async (event, ctx) => { try { From 46ea43b3fe52b67d1336873adc3fd8d848f24880 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 01:47:27 +0000 Subject: [PATCH 16/32] fix(rules): inject end-marker prompt for every non-no-reply discord turn --- plugin/index.ts | 5 +---- plugin/rules.ts | 17 +++++++++-------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index e36bb0e..8f6307d 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -63,9 +63,6 @@ function pruneDecisionMap(now = Date.now()) { } } -function shouldInjectEndMarker(reason: string): boolean { - return reason.startsWith("end_symbol:"); -} function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig): WhisperGateConfig { const root = (api.config as Record) || {}; @@ -335,7 +332,7 @@ export default { } sessionDecision.delete(key); - if (!shouldInjectEndMarker(rec.decision.reason)) return; + if (!rec.decision.shouldInjectEndMarkerPrompt) return; api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`); return { prependContext: END_MARKER_INSTRUCTION }; diff --git a/plugin/rules.ts b/plugin/rules.ts index 4ca53b5..42b50d1 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -21,6 +21,7 @@ export type ChannelPolicy = { export type Decision = { shouldUseNoReply: boolean; + shouldInjectEndMarkerPrompt: boolean; reason: string; }; @@ -64,12 +65,12 @@ export function evaluateDecision(params: { 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" }; } const policy = resolvePolicy(config, params.channelId, params.channelPolicies); @@ -87,20 +88,20 @@ export function evaluateDecision(params: { if (mode === "human-list") { if (inHumanList) { - return { shouldUseNoReply: false, reason: "human_list_sender" }; + return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "human_list_sender" }; } if (hasEnd) { - return { shouldUseNoReply: false, reason: `end_symbol:${lastChar}` }; + return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: `end_symbol:${lastChar}` }; } - return { shouldUseNoReply: true, reason: "rule_match_no_end_symbol" }; + return { shouldUseNoReply: true, shouldInjectEndMarkerPrompt: false, reason: "rule_match_no_end_symbol" }; } // agent-list mode: listed senders require end symbol; others bypass requirement. if (!inAgentList) { - return { shouldUseNoReply: false, reason: "non_agent_list_sender" }; + return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "non_agent_list_sender" }; } if (hasEnd) { - return { shouldUseNoReply: false, reason: `end_symbol:${lastChar}` }; + return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: `end_symbol:${lastChar}` }; } - return { shouldUseNoReply: true, reason: "agent_list_missing_end_symbol" }; + return { shouldUseNoReply: true, shouldInjectEndMarkerPrompt: false, reason: "agent_list_missing_end_symbol" }; } From 51149dd6a03d39af18f1266f8063d60c83369918 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 01:58:01 +0000 Subject: [PATCH 17/32] feat(debug): add whispergate hook diagnostics with channel-scoped debug logs --- docs/CONFIG.example.json | 2 ++ plugin/README.md | 7 ++++ plugin/index.ts | 72 +++++++++++++++++++++++++++++++++++-- plugin/openclaw.plugin.json | 4 ++- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index 173fcfd..0c887fb 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -18,6 +18,8 @@ "noReplyModel": "no-reply", "enableDiscordControlTool": true, "enableWhispergatePolicyTool": true, + "enableDebugLogs": false, + "debugLogChannelIds": [], "discordControlApiBaseUrl": "http://127.0.0.1:8790", "discordControlApiToken": "", "discordControlCallerId": "agent-main" diff --git a/plugin/README.md b/plugin/README.md index 38f4f2c..db01bbf 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -42,6 +42,8 @@ Unified optional tool: - `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`. @@ -61,3 +63,8 @@ To use it, add tool allowlist entry for either: 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 8f6307d..bb7c30e 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -15,6 +15,11 @@ type PolicyState = { channelPolicies: Record; }; +type DebugConfig = { + enableDebugLogs?: boolean; + debugLogChannelIds?: string[]; +}; + const sessionDecision = new Map(); const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; @@ -119,6 +124,39 @@ function pickDefined(input: Record) { return out; } +function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean { + if (!cfg.enableDebugLogs) return false; + const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : []; + if (allow.length === 0) return true; + if (!channelId) return false; + return allow.includes(channelId); +} + +function debugCtxSummary(ctx: Record, event: Record) { + const meta = ((ctx.metadata || event.metadata || {}) as Record) || {}; + return { + sessionKey: typeof ctx.sessionKey === "string" ? ctx.sessionKey : undefined, + commandSource: typeof ctx.commandSource === "string" ? ctx.commandSource : undefined, + messageProvider: typeof ctx.messageProvider === "string" ? ctx.messageProvider : undefined, + channel: typeof ctx.channel === "string" ? ctx.channel : undefined, + channelId: typeof ctx.channelId === "string" ? ctx.channelId : undefined, + senderId: typeof ctx.senderId === "string" ? ctx.senderId : undefined, + from: typeof ctx.from === "string" ? ctx.from : undefined, + metaSenderId: + typeof meta.senderId === "string" + ? meta.senderId + : typeof meta.sender_id === "string" + ? meta.sender_id + : undefined, + metaUserId: + typeof meta.userId === "string" + ? meta.userId + : typeof meta.user_id === "string" + ? meta.user_id + : undefined, + }; +} + export default { id: "whispergate", name: "WhisperGate", @@ -290,6 +328,14 @@ export default { api.logger.debug?.( `whispergate: session=${sessionKey} sender=${senderId ?? "unknown"} channel=${channel || "unknown"} decision=${decision.reason}`, ); + + if (shouldDebugLog(live as DebugConfig, channelId)) { + const summary = debugCtxSummary(c, e); + api.logger.info( + `whispergate: debug message_received session=${sessionKey} decision=${decision.reason} ` + + `shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt} ctx=${JSON.stringify(summary)}`, + ); + } } catch (err) { api.logger.warn(`whispergate: message hook failed: ${String(err)}`); } @@ -300,7 +346,13 @@ export default { if (!key) return; const rec = sessionDecision.get(key); - if (!rec) return; + if (!rec) { + const liveMiss = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + if (shouldDebugLog(liveMiss, ctx.channelId)) { + api.logger.info(`whispergate: debug before_model_resolve session=${key} decision=missing`); + } + return; + } if (Date.now() - rec.createdAt > DECISION_TTL_MS) { sessionDecision.delete(key); return; @@ -324,7 +376,13 @@ export default { const key = ctx.sessionKey; if (!key) return; const rec = sessionDecision.get(key); - if (!rec) return; + if (!rec) { + const liveMiss = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + if (shouldDebugLog(liveMiss, ctx.channelId)) { + api.logger.info(`whispergate: debug before_prompt_build session=${key} decision=missing`); + } + return; + } if (Date.now() - rec.createdAt > DECISION_TTL_MS) { sessionDecision.delete(key); @@ -332,7 +390,15 @@ export default { } sessionDecision.delete(key); - if (!rec.decision.shouldInjectEndMarkerPrompt) return; + if (!rec.decision.shouldInjectEndMarkerPrompt) { + const liveSkip = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + if (shouldDebugLog(liveSkip, ctx.channelId)) { + api.logger.info( + `whispergate: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`, + ); + } + return; + } api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`); return { prependContext: END_MARKER_INSTRUCTION }; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 2872ff9..d10f3e5 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -22,7 +22,9 @@ "enableWhispergatePolicyTool": { "type": "boolean", "default": true }, "discordControlApiBaseUrl": { "type": "string", "default": "http://127.0.0.1:8790" }, "discordControlApiToken": { "type": "string" }, - "discordControlCallerId": { "type": "string" } + "discordControlCallerId": { "type": "string" }, + "enableDebugLogs": { "type": "boolean", "default": false }, + "debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] } }, "required": ["noReplyProvider", "noReplyModel"] } From 8eade446b3121feebfa580197eba797fc846e5b4 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 02:33:15 +0000 Subject: [PATCH 18/32] fix(hooks): move first-pass decision to before_model_resolve and keep message_received for debug only --- plugin/index.ts | 148 ++++++++++++++++++++++++++++++------------------ 1 file changed, 93 insertions(+), 55 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index bb7c30e..c77f9a5 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -54,6 +54,47 @@ 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 deriveDecisionInputFromAgentCtx( + ctx: Record, +): { channel: string; channelId?: string; senderId?: string; content: string } { + const channel = normalizeChannel(ctx); + const content = typeof ctx.input === "string" ? ctx.input : ""; + const conv = extractUntrustedConversationInfo(content) || {}; + const channelIdRaw = + (typeof ctx.channelId === "string" && ctx.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 senderIdRaw = + (typeof ctx.senderId === "string" && ctx.senderId) || + (typeof conv.sender_id === "string" && conv.sender_id) || + (typeof conv.sender === "string" && conv.sender) || + undefined; + + return { + channel, + channelId: channelIdRaw, + senderId: senderIdRaw, + content, + }; +} + function pruneDecisionMap(now = Date.now()) { for (const [k, v] of sessionDecision.entries()) { if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k); @@ -305,36 +346,10 @@ export default { 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 channelId = typeof c.channelId === "string" ? c.channelId : undefined; - - const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig); - ensurePolicyStateLoaded(api, live); - const decision = evaluateDecision({ - config: live, - channel, - channelId, - channelPolicies: policyState.channelPolicies, - senderId, - content, - }); - sessionDecision.set(sessionKey, { decision, createdAt: Date.now() }); - pruneDecisionMap(); - api.logger.debug?.( - `whispergate: session=${sessionKey} sender=${senderId ?? "unknown"} channel=${channel || "unknown"} decision=${decision.reason}`, - ); - - if (shouldDebugLog(live as DebugConfig, channelId)) { - const summary = debugCtxSummary(c, e); - api.logger.info( - `whispergate: debug message_received session=${sessionKey} decision=${decision.reason} ` + - `shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt} ctx=${JSON.stringify(summary)}`, - ); + 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)}`); @@ -345,23 +360,35 @@ export default { const key = ctx.sessionKey; if (!key) return; - const rec = sessionDecision.get(key); - if (!rec) { - const liveMiss = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; - if (shouldDebugLog(liveMiss, ctx.channelId)) { - api.logger.info(`whispergate: debug before_model_resolve session=${key} decision=missing`); + 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 c = (ctx || {}) as Record; + const derived = deriveDecisionInputFromAgentCtx(c); + 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 ?? ctx.channelId)) { + api.logger.info( + `whispergate: debug before_model_resolve recompute session=${key} decision=${decision.reason} ` + + `shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, + ); } - return; - } - if (Date.now() - rec.createdAt > DECISION_TTL_MS) { - sessionDecision.delete(key); - return; } if (!rec.decision.shouldUseNoReply) return; - sessionDecision.delete(key); - const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig); api.logger.info( `whispergate: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`, ); @@ -375,24 +402,35 @@ export default { api.on("before_prompt_build", async (_event, ctx) => { const key = ctx.sessionKey; if (!key) return; - const rec = sessionDecision.get(key); - if (!rec) { - const liveMiss = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; - if (shouldDebugLog(liveMiss, ctx.channelId)) { - api.logger.info(`whispergate: debug before_prompt_build session=${key} decision=missing`); - } - return; - } - if (Date.now() - rec.createdAt > DECISION_TTL_MS) { - sessionDecision.delete(key); - return; + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + ensurePolicyStateLoaded(api, live); + + let rec = sessionDecision.get(key); + if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { + if (rec) sessionDecision.delete(key); + const c = (ctx || {}) as Record; + const derived = deriveDecisionInputFromAgentCtx(c); + 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 ?? ctx.channelId)) { + api.logger.info( + `whispergate: debug before_prompt_build recompute session=${key} decision=${decision.reason} ` + + `shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, + ); + } } sessionDecision.delete(key); if (!rec.decision.shouldInjectEndMarkerPrompt) { - const liveSkip = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; - if (shouldDebugLog(liveSkip, ctx.channelId)) { + if (shouldDebugLog(live, ctx.channelId)) { api.logger.info( `whispergate: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`, ); From 1746fb33ad3191447bc092d027e657abc26d7a6c Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 06:04:17 +0000 Subject: [PATCH 19/32] fix(installer): uninstall now selects latest install record instead of latest pointer --- scripts/install-whispergate-openclaw.mjs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/install-whispergate-openclaw.mjs b/scripts/install-whispergate-openclaw.mjs index ec0da4d..111ad8f 100755 --- a/scripts/install-whispergate-openclaw.mjs +++ b/scripts/install-whispergate-openclaw.mjs @@ -77,6 +77,25 @@ 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); @@ -153,9 +172,9 @@ if (mode === "install") { process.exit(1); } } else { - const recFile = env.RECORD_FILE || (fs.existsSync(LATEST_RECORD_LINK) ? LATEST_RECORD_LINK : ""); + const recFile = env.RECORD_FILE || findLatestInstallRecord(); if (!recFile || !fs.existsSync(recFile)) { - console.error("[whispergate] no record found. set RECORD_FILE= or install first."); + console.error("[whispergate] no install record found. set RECORD_FILE= to an install record."); process.exit(1); } From 875cd66d34228a38edd14414196830e275873bd9 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 06:14:06 +0000 Subject: [PATCH 20/32] feat(installer): restart gateway and validate custom no-reply model visibility after install --- docs/INTEGRATION.md | 1 + scripts/install-whispergate-openclaw.mjs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index a79c405..9e3d239 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -66,6 +66,7 @@ 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` diff --git a/scripts/install-whispergate-openclaw.mjs b/scripts/install-whispergate-openclaw.mjs index 111ad8f..6e479b7 100755 --- a/scripts/install-whispergate-openclaw.mjs +++ b/scripts/install-whispergate-openclaw.mjs @@ -45,6 +45,19 @@ function runOpenclaw(args, { allowFail = false } = {}) { } } +function validateNoReplyModelAvailable() { + const modelRef = `${NO_REPLY_PROVIDER_ID}/${NO_REPLY_MODEL_ID}`; + const list = runOpenclaw(["models", "list"], { allowFail: true }) || ""; + if (!list.includes(modelRef)) { + throw new Error(`post-install validation failed: model not listed: ${modelRef}`); + } + + const status = runOpenclaw(["models", "status", "--json"], { allowFail: true }) || ""; + if (!status.includes(NO_REPLY_PROVIDER_ID)) { + throw new Error(`post-install validation failed: provider not visible in models status: ${NO_REPLY_PROVIDER_ID}`); + } +} + function getJson(pathKey) { const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true }); if (out == null || out === "") return { exists: false }; @@ -158,6 +171,9 @@ if (mode === "install") { ], }); + runOpenclaw(["gateway", "restart"]); + validateNoReplyModelAvailable(); + const after = { [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), From 8d34bf257bab3a862acf09ec8987370c15e6767a Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 06:26:52 +0000 Subject: [PATCH 21/32] fix(installer): write providers map by object key to avoid quoted provider id and restore providers atomically on uninstall --- scripts/install-whispergate-openclaw.mjs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/scripts/install-whispergate-openclaw.mjs b/scripts/install-whispergate-openclaw.mjs index 6e479b7..058d2f1 100755 --- a/scripts/install-whispergate-openclaw.mjs +++ b/scripts/install-whispergate-openclaw.mjs @@ -34,7 +34,7 @@ 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 PROVIDER_PATH = `models.providers["${NO_REPLY_PROVIDER_ID}"]`; +const PATH_PROVIDERS = "models.providers"; function runOpenclaw(args, { allowFail = false } = {}) { try { @@ -127,7 +127,7 @@ if (mode === "install") { const before = { [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), - [PROVIDER_PATH]: getJson(PROVIDER_PATH), + [PATH_PROVIDERS]: getJson(PATH_PROVIDERS), }; try { @@ -154,7 +154,9 @@ if (mode === "install") { }; setJson("plugins", plugins); - setJson(PROVIDER_PATH, { + 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", @@ -169,7 +171,8 @@ if (mode === "install") { maxTokens: 4096, }, ], - }); + }; + setJson(PATH_PROVIDERS, providers); runOpenclaw(["gateway", "restart"]); validateNoReplyModelAvailable(); @@ -177,7 +180,7 @@ if (mode === "install") { const after = { [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), - [PROVIDER_PATH]: getJson(PROVIDER_PATH), + [PATH_PROVIDERS]: getJson(PATH_PROVIDERS), }; writeRecord("install", before, after); console.log("[whispergate] install ok"); @@ -215,16 +218,13 @@ if (mode === "install") { setJson("plugins", plugins); - for (const k of Object.keys(target)) { - if (!k.startsWith("models.providers[")) continue; - if (target[k]?.exists) setJson(k, target[k].value); - else unsetPath(k); - } + 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), - [PROVIDER_PATH]: getJson(PROVIDER_PATH), + [PATH_PROVIDERS]: getJson(PATH_PROVIDERS), }; writeRecord("uninstall", before, after); console.log("[whispergate] uninstall ok"); From 15975e3970d8f3ec741f633c6467ed78c99a2b56 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 06:30:56 +0000 Subject: [PATCH 22/32] fix(installer): raise no-reply model contextWindow/maxTokens to satisfy OpenClaw minimums --- scripts/install-whispergate-openclaw.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/install-whispergate-openclaw.mjs b/scripts/install-whispergate-openclaw.mjs index 058d2f1..7cc3e7b 100755 --- a/scripts/install-whispergate-openclaw.mjs +++ b/scripts/install-whispergate-openclaw.mjs @@ -167,8 +167,8 @@ if (mode === "install") { reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 4096, - maxTokens: 4096, + contextWindow: 200000, + maxTokens: 8192, }, ], }; From fd6c4dd3a2b0f58c2c416f9b7f30d5271cc15531 Mon Sep 17 00:00:00 2001 From: zhi Date: Thu, 26 Feb 2026 08:45:37 +0000 Subject: [PATCH 23/32] fix: remove gateway restart from installer, let user restart manually MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: installer called 'openclaw gateway restart' (async via systemd) then immediately validated model visibility — race condition caused validation to fail and rollback the correct config. Fix: remove restart + validation from script entirely. Script only writes config. User restarts gateway manually after install completes. Also fix CONFIG.example.json: contextWindow 4096->200000, maxTokens 64->8192 (OpenClaw requires minimum 16000 contextWindow). --- docs/CONFIG.example.json | 4 ++-- no-reply-api/package-lock.json | 12 ++++++++++++ scripts/install-whispergate-openclaw.mjs | 18 ++---------------- 3 files changed, 16 insertions(+), 18 deletions(-) create mode 100644 no-reply-api/package-lock.json diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index 0c887fb..d14f120 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -40,8 +40,8 @@ "reasoning": false, "input": ["text"], "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 4096, - "maxTokens": 64 + "contextWindow": 200000, + "maxTokens": 8192 } ] } 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/scripts/install-whispergate-openclaw.mjs b/scripts/install-whispergate-openclaw.mjs index 7cc3e7b..73d7ad9 100755 --- a/scripts/install-whispergate-openclaw.mjs +++ b/scripts/install-whispergate-openclaw.mjs @@ -45,18 +45,6 @@ function runOpenclaw(args, { allowFail = false } = {}) { } } -function validateNoReplyModelAvailable() { - const modelRef = `${NO_REPLY_PROVIDER_ID}/${NO_REPLY_MODEL_ID}`; - const list = runOpenclaw(["models", "list"], { allowFail: true }) || ""; - if (!list.includes(modelRef)) { - throw new Error(`post-install validation failed: model not listed: ${modelRef}`); - } - - const status = runOpenclaw(["models", "status", "--json"], { allowFail: true }) || ""; - if (!status.includes(NO_REPLY_PROVIDER_ID)) { - throw new Error(`post-install validation failed: provider not visible in models status: ${NO_REPLY_PROVIDER_ID}`); - } -} function getJson(pathKey) { const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true }); @@ -174,17 +162,15 @@ if (mode === "install") { }; setJson(PATH_PROVIDERS, providers); - runOpenclaw(["gateway", "restart"]); - validateNoReplyModelAvailable(); - 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"); + 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)}`); From f33dc13af44552fc752b9d5de74a9412fdf69186 Mon Sep 17 00:00:00 2001 From: zhi Date: Thu, 26 Feb 2026 08:56:24 +0000 Subject: [PATCH 24/32] fix: extract senderId from event.prompt instead of ctx in hooks Root cause: PluginHookAgentContext in before_model_resolve only has agentId, sessionKey, sessionId, workspaceDir, messageProvider. senderId, channelId, input are NOT available in this hook phase. The plugin was reading ctx.senderId (undefined) -> inHumanList=false for ALL Discord sessions -> shouldUseNoReply=true -> all silenced. Fix: use event.prompt which contains the full user message including the 'Conversation info (untrusted metadata)' JSON block, and extract sender_id from there. Same fix applied to before_prompt_build. --- plugin/index.ts | 64 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index c77f9a5..7f8ce75 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -356,32 +356,47 @@ export default { } }); - api.on("before_model_resolve", async (_event, ctx) => { + api.on("before_model_resolve", async (event, ctx) => { const key = ctx.sessionKey; if (!key) return; const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; ensurePolicyStateLoaded(api, live); + // In before_model_resolve, ctx only has: agentId, sessionKey, sessionId, workspaceDir, messageProvider. + // senderId/channelId/input are NOT available. Use event.prompt (user message incl. untrusted metadata). + const channel = (ctx.messageProvider || "").toLowerCase(); + if (live.discordOnly !== false && channel !== "discord") return; + + const prompt = ((event as Record).prompt as string) || ""; + const conv = extractUntrustedConversationInfo(prompt) || {}; + const senderId = + (typeof conv.sender_id === "string" && conv.sender_id) || + (typeof conv.sender === "string" && conv.sender) || + undefined; + const channelId = + (typeof conv.channel_id === "string" && conv.channel_id) || + (typeof (conv as Record).conversation_label === "string" + ? undefined + : undefined); + let rec = sessionDecision.get(key); if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { if (rec) sessionDecision.delete(key); - const c = (ctx || {}) as Record; - const derived = deriveDecisionInputFromAgentCtx(c); const decision = evaluateDecision({ config: live, - channel: derived.channel, - channelId: derived.channelId, + channel, + channelId, channelPolicies: policyState.channelPolicies, - senderId: derived.senderId, - content: derived.content, + senderId, + content: prompt, }); rec = { decision, createdAt: Date.now() }; sessionDecision.set(key, rec); pruneDecisionMap(); - if (shouldDebugLog(live, derived.channelId ?? ctx.channelId)) { + if (shouldDebugLog(live, channelId)) { api.logger.info( - `whispergate: debug before_model_resolve recompute session=${key} decision=${decision.reason} ` + + `whispergate: debug before_model_resolve recompute session=${key} senderId=${senderId} decision=${decision.reason} ` + `shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, ); } @@ -399,7 +414,7 @@ export default { }; }); - api.on("before_prompt_build", async (_event, ctx) => { + api.on("before_prompt_build", async (event, ctx) => { const key = ctx.sessionKey; if (!key) return; @@ -409,20 +424,31 @@ export default { let rec = sessionDecision.get(key); if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { if (rec) sessionDecision.delete(key); - const c = (ctx || {}) as Record; - const derived = deriveDecisionInputFromAgentCtx(c); + + // before_prompt_build has event.prompt and event.messages available. + const channel = (ctx.messageProvider || "").toLowerCase(); + const prompt = ((event as Record).prompt as string) || ""; + const conv = extractUntrustedConversationInfo(prompt) || {}; + const senderId = + (typeof conv.sender_id === "string" && conv.sender_id) || + (typeof conv.sender === "string" && conv.sender) || + undefined; + const channelId = + (typeof conv.channel_id === "string" && conv.channel_id) || + undefined; + const decision = evaluateDecision({ config: live, - channel: derived.channel, - channelId: derived.channelId, + channel, + channelId, channelPolicies: policyState.channelPolicies, - senderId: derived.senderId, - content: derived.content, + senderId, + content: prompt, }); rec = { decision, createdAt: Date.now() }; - if (shouldDebugLog(live, derived.channelId ?? ctx.channelId)) { + if (shouldDebugLog(live, channelId)) { api.logger.info( - `whispergate: debug before_prompt_build recompute session=${key} decision=${decision.reason} ` + + `whispergate: debug before_prompt_build recompute session=${key} senderId=${senderId} decision=${decision.reason} ` + `shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, ); } @@ -430,7 +456,7 @@ export default { sessionDecision.delete(key); if (!rec.decision.shouldInjectEndMarkerPrompt) { - if (shouldDebugLog(live, ctx.channelId)) { + if (shouldDebugLog(live, undefined)) { api.logger.info( `whispergate: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`, ); From 211a94233f1ff7af7569d8889654595454f9af67 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 11:47:23 +0000 Subject: [PATCH 25/32] chore(debug): log parsed sender/channel fields in hook decision recompute --- plugin/index.ts | 95 +++++++++++++++++++++---------------------------- 1 file changed, 40 insertions(+), 55 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 7f8ce75..2c2f727 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -69,30 +69,29 @@ function extractUntrustedConversationInfo(text: string): Record } } -function deriveDecisionInputFromAgentCtx( - ctx: Record, -): { channel: string; channelId?: string; senderId?: string; content: string } { - const channel = normalizeChannel(ctx); - const content = typeof ctx.input === "string" ? ctx.input : ""; - const conv = extractUntrustedConversationInfo(content) || {}; - const channelIdRaw = - (typeof ctx.channelId === "string" && ctx.channelId) || +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 senderIdRaw = - (typeof ctx.senderId === "string" && ctx.senderId) || + const senderId = (typeof conv.sender_id === "string" && conv.sender_id) || (typeof conv.sender === "string" && conv.sender) || undefined; - return { - channel, - channelId: channelIdRaw, - senderId: senderIdRaw, - content, - }; + return { channel, channelId, senderId, content: prompt, conv }; } function pruneDecisionMap(now = Date.now()) { @@ -363,41 +362,32 @@ export default { const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; ensurePolicyStateLoaded(api, live); - // In before_model_resolve, ctx only has: agentId, sessionKey, sessionId, workspaceDir, messageProvider. - // senderId/channelId/input are NOT available. Use event.prompt (user message incl. untrusted metadata). - const channel = (ctx.messageProvider || "").toLowerCase(); - if (live.discordOnly !== false && channel !== "discord") return; - const prompt = ((event as Record).prompt as string) || ""; - const conv = extractUntrustedConversationInfo(prompt) || {}; - const senderId = - (typeof conv.sender_id === "string" && conv.sender_id) || - (typeof conv.sender === "string" && conv.sender) || - undefined; - const channelId = - (typeof conv.channel_id === "string" && conv.channel_id) || - (typeof (conv as Record).conversation_label === "string" - ? undefined - : undefined); + const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); + if (live.discordOnly !== false && derived.channel !== "discord") return; let rec = sessionDecision.get(key); if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { if (rec) sessionDecision.delete(key); const decision = evaluateDecision({ config: live, - channel, - channelId, + channel: derived.channel, + channelId: derived.channelId, channelPolicies: policyState.channelPolicies, - senderId, - content: prompt, + senderId: derived.senderId, + content: derived.content, }); rec = { decision, createdAt: Date.now() }; sessionDecision.set(key, rec); pruneDecisionMap(); - if (shouldDebugLog(live, channelId)) { + if (shouldDebugLog(live, derived.channelId)) { api.logger.info( - `whispergate: debug before_model_resolve recompute session=${key} senderId=${senderId} decision=${decision.reason} ` + - `shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, + `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}`, ); } } @@ -425,31 +415,26 @@ export default { if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { if (rec) sessionDecision.delete(key); - // before_prompt_build has event.prompt and event.messages available. - const channel = (ctx.messageProvider || "").toLowerCase(); const prompt = ((event as Record).prompt as string) || ""; - const conv = extractUntrustedConversationInfo(prompt) || {}; - const senderId = - (typeof conv.sender_id === "string" && conv.sender_id) || - (typeof conv.sender === "string" && conv.sender) || - undefined; - const channelId = - (typeof conv.channel_id === "string" && conv.channel_id) || - undefined; + const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); const decision = evaluateDecision({ config: live, - channel, - channelId, + channel: derived.channel, + channelId: derived.channelId, channelPolicies: policyState.channelPolicies, - senderId, - content: prompt, + senderId: derived.senderId, + content: derived.content, }); rec = { decision, createdAt: Date.now() }; - if (shouldDebugLog(live, channelId)) { + if (shouldDebugLog(live, derived.channelId)) { api.logger.info( - `whispergate: debug before_prompt_build recompute session=${key} senderId=${senderId} decision=${decision.reason} ` + - `shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, + `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}`, ); } } From 4622173787ce70a26ae807d94cb23ecd19f50586 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 13:39:43 +0000 Subject: [PATCH 26/32] fix: restore model after no-reply executes (needsRestore flag) --- plugin/index.ts | 46 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 2c2f727..a1d5bc8 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -8,6 +8,7 @@ type DiscordControlAction = "channel-private-create" | "channel-private-update" type DecisionRecord = { decision: Decision; createdAt: number; + needsRestore?: boolean; }; type PolicyState = { @@ -168,7 +169,7 @@ function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean { if (!cfg.enableDebugLogs) return false; const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : []; if (allow.length === 0) return true; - if (!channelId) return false; + if (!channelId) return true; // 允许打印,方便排查 channelId 为空的场景 return allow.includes(channelId); } @@ -363,8 +364,18 @@ export default { 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); - if (live.discordOnly !== false && derived.channel !== "discord") return; + // 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) { @@ -392,7 +403,36 @@ export default { } } - if (!rec.decision.shouldUseNoReply) return; + if (!rec.decision.shouldUseNoReply) { + // 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型 + if (rec.needsRestore) { + sessionDecision.delete(key); + return { + providerOverride: undefined, + modelOverride: undefined, + }; + } + 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}`, + ); + } api.logger.info( `whispergate: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`, From a4836097e4baac34cf56e06d5ebb524f93d8ceea Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 26 Feb 2026 22:16:23 +0000 Subject: [PATCH 27/32] fix: add default values for optional config fields - Add default values for enableDiscordControlTool, enableWhispergatePolicyTool, discordControlApiBaseUrl, enableDebugLogs, debugLogChannelIds - Merge defaults in both baseConfig and getLivePluginConfig - Fixes issue where whispergateway_tools tool was not exposed due to missing config fields in openclaw.json --- plugin/index.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index a1d5bc8..0561df2 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -116,7 +116,17 @@ function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig const entries = (plugins.entries as Record) || {}; const entry = (entries.whispergate as Record) || {}; const cfg = (entry.config as Record) || {}; - if (Object.keys(cfg).length > 0) return cfg as unknown as WhisperGateConfig; + 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; } @@ -202,12 +212,18 @@ export default { id: "whispergate", name: "WhisperGate", register(api: OpenClawPluginApi) { - const baseConfig = (api.pluginConfig || {}) as WhisperGateConfig & { - enableDiscordControlTool?: boolean; - discordControlApiBaseUrl?: string; + // 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; + enableWhispergatePolicyTool: boolean; }; const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig); From f23d9049a79d275a08acdbab649d7813fe01086e Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 27 Feb 2026 14:14:39 +0000 Subject: [PATCH 28/32] fix: bypass DM sessions without metadata and make tool globally visible 1. DM bypass: when neither senderId nor channelId can be extracted from the prompt (DM sessions lack untrusted conversation info), skip the no-reply gate and allow the message through with end-marker injection. 2. Tool visibility: change whispergateway_tools registration from optional=true to optional=false so all agents can see the tool without needing explicit tools.allow entries. --- plugin/index.ts | 2 +- plugin/rules.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/plugin/index.ts b/plugin/index.ts index 0561df2..540a538 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -355,7 +355,7 @@ export default { return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; }, }, - { optional: true }, + { optional: false }, ); api.on("message_received", async (event, ctx) => { diff --git a/plugin/rules.ts b/plugin/rules.ts index 42b50d1..ccaacd8 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -73,6 +73,12 @@ export function evaluateDecision(params: { return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "non_discord" }; } + // 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; From f74b3978e77fb30cb588958897d1ab7c450f7f2d Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 27 Feb 2026 14:25:12 +0000 Subject: [PATCH 29/32] fix(installer): resolve plugin path relative to repo instead of hardcoded operator path PLUGIN_PATH defaulted to /root/.openclaw/workspace-operator/... regardless of which workspace the installer was run from. Now resolves relative to the script location (../dist/whispergate). --- scripts/install-whispergate-openclaw.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/install-whispergate-openclaw.mjs b/scripts/install-whispergate-openclaw.mjs index 73d7ad9..be64876 100755 --- a/scripts/install-whispergate-openclaw.mjs +++ b/scripts/install-whispergate-openclaw.mjs @@ -13,7 +13,8 @@ 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 PLUGIN_PATH = env.PLUGIN_PATH || "/root/.openclaw/workspace-operator/WhisperGate/dist/whispergate"; +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"; From 75d659787c9eda31635ac2aafd0a61f79d2f03c4 Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 27 Feb 2026 14:37:59 +0000 Subject: [PATCH 30/32] fix(rules): strip trailing metadata blocks before checking end symbol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getLastChar was checking the last character of the full event.prompt, which includes Conversation/Sender metadata blocks appended by OpenClaw after the actual message. This meant end symbols like 🔚 at the end of the message body were invisible — the last char was always backtick or whitespace from the metadata JSON block. Fix: strip trailing '(untrusted metadata)' blocks before extracting the last character. This only affects non-humanList senders (humanList senders bypass end symbol check via human_list_sender reason). --- plugin/rules.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/plugin/rules.ts b/plugin/rules.ts index ccaacd8..f886f56 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -25,8 +25,24 @@ export type Decision = { 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(); + const t = stripTrailingMetadata(input).trim(); return t.length ? t[t.length - 1] : ""; } From 3749de981f90fa80850cdeb57b430bcca5653f4c Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 27 Feb 2026 15:16:06 +0000 Subject: [PATCH 31/32] fix: use configured endSymbols in injected prompt and exempt gateway keywords - buildEndMarkerInstruction() replaces hardcoded END_MARKER_INSTRUCTION, dynamically using the resolved policy's endSymbols - Instruction now explicitly exempts gateway keywords (NO_REPLY, HEARTBEAT_OK) from requiring end symbols - Export resolvePolicy from rules.ts for reuse in before_prompt_build hook --- plugin/index.ts | 15 ++++++++++++--- plugin/rules.ts | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 540a538..20ac75e 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { evaluateDecision, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; +import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; @@ -24,7 +24,10 @@ type DebugConfig = { 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: "", @@ -505,8 +508,14 @@ export default { 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/rules.ts b/plugin/rules.ts index f886f56..1a69b44 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -46,7 +46,7 @@ function getLastChar(input: string): string { return t.length ? t[t.length - 1] : ""; } -function resolvePolicy(config: WhisperGateConfig, channelId?: string, channelPolicies?: Record) { +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 || []; From 75f358001b28418626af86b098f8ad43e3b3db8a Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 27 Feb 2026 15:20:05 +0000 Subject: [PATCH 32/32] fix(rules): handle multi-byte emoji in getLastChar via Array.from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getLastChar used t[t.length-1] which only gets the trailing surrogate of emoji like 🔚 (U+1F51A, a surrogate pair in UTF-16). This meant end symbol matching ALWAYS failed for emoji symbols, causing every non-humanList message to hit rule_match_no_end_symbol -> no-reply. Fix: use Array.from(t) to correctly split by Unicode code points. --- plugin/rules.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugin/rules.ts b/plugin/rules.ts index 1a69b44..ca00593 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -43,7 +43,10 @@ function stripTrailingMetadata(input: string): string { function getLastChar(input: string): string { 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) {