From f3662457bc25cf1411cfc6b82d1433a996f49adb Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:36:16 +0000 Subject: [PATCH 01/27] docs: add WhisperGate architecture and implementation plan --- README.md | 28 ++++++++++++++++++++++++++-- docs/IMPLEMENTATION.md | 21 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 docs/IMPLEMENTATION.md diff --git a/README.md b/README.md index e527f09..926497f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,30 @@ Rule-based no-reply gate for OpenClaw. -## Status +## What it does -Initial scaffold. +WhisperGate adds a deterministic gate **before model selection**: + +1. If message is not from Discord → skip gate +2. If sender is in bypass user list → skip gate +3. If message ends with configured end-symbol → skip gate +4. Otherwise switch this turn to a no-reply model/provider + +The no-reply provider returns `NO_REPLY` for any input. + +--- + +## Repo layout + +- `plugin/` — OpenClaw plugin (before_model_resolve hook) +- `no-reply-api/` — OpenAI-compatible minimal API that always returns `NO_REPLY` +- `docs/` — rollout and configuration notes + +--- + +## Development plan (incremental commits) + +- [x] Task 1: project docs + structure +- [ ] Task 2: no-reply API MVP +- [ ] Task 3: plugin MVP with rule chain +- [ ] Task 4: sample config + quick verification scripts diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md new file mode 100644 index 0000000..8bd0912 --- /dev/null +++ b/docs/IMPLEMENTATION.md @@ -0,0 +1,21 @@ +# WhisperGate Implementation Notes + +## Decision path + +WhisperGate evaluates in strict order: + +1. channel check (discord-only) +2. bypass sender check +3. message ending symbol check +4. fallback to no-reply model override + +## Why before_model_resolve + +- deterministic +- no LLM dependency +- low overhead +- uses built-in override path (`providerOverride` + `modelOverride`) + +## Known limitation + +This does not fully skip OpenClaw prompt assembly. It reduces provider-side LLM usage by routing no-reply turns to a deterministic API. -- 2.49.1 From 1140a928f39162d5c9b05f11ba8715f9d58a9106 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:36:32 +0000 Subject: [PATCH 02/27] feat(api): add no-reply OpenAI-compatible API service --- no-reply-api/package.json | 9 +++++ no-reply-api/server.mjs | 82 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 no-reply-api/package.json create mode 100644 no-reply-api/server.mjs diff --git a/no-reply-api/package.json b/no-reply-api/package.json new file mode 100644 index 0000000..0798fd9 --- /dev/null +++ b/no-reply-api/package.json @@ -0,0 +1,9 @@ +{ + "name": "whispergate-no-reply-api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs" + } +} diff --git a/no-reply-api/server.mjs b/no-reply-api/server.mjs new file mode 100644 index 0000000..4e999fc --- /dev/null +++ b/no-reply-api/server.mjs @@ -0,0 +1,82 @@ +import http from "node:http"; + +const port = Number(process.env.PORT || 8787); +const modelName = process.env.NO_REPLY_MODEL || "whispergate-no-reply-v1"; + +function sendJson(res, status, payload) { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(payload)); +} + +function noReplyChatCompletion(reqBody) { + return { + id: `chatcmpl_whispergate_${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: reqBody?.model || modelName, + choices: [ + { + index: 0, + message: { role: "assistant", content: "NO_REPLY" }, + finish_reason: "stop" + } + ], + usage: { prompt_tokens: 0, completion_tokens: 1, total_tokens: 1 } + }; +} + +function noReplyResponses(reqBody) { + return { + id: `resp_whispergate_${Date.now()}`, + object: "response", + created_at: Math.floor(Date.now() / 1000), + model: reqBody?.model || modelName, + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "NO_REPLY" }] + } + ], + usage: { input_tokens: 0, output_tokens: 1, total_tokens: 1 } + }; +} + +const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/health") { + return sendJson(res, 200, { ok: true, service: "whispergate-no-reply-api" }); + } + + if (req.method !== "POST") { + return sendJson(res, 404, { error: "not_found" }); + } + + let body = ""; + req.on("data", (chunk) => { + body += chunk; + if (body.length > 1_000_000) req.destroy(); + }); + + req.on("end", () => { + let parsed = {}; + try { + parsed = body ? JSON.parse(body) : {}; + } catch { + return sendJson(res, 400, { error: "invalid_json" }); + } + + if (req.url === "/v1/chat/completions") { + return sendJson(res, 200, noReplyChatCompletion(parsed)); + } + + if (req.url === "/v1/responses") { + return sendJson(res, 200, noReplyResponses(parsed)); + } + + return sendJson(res, 404, { error: "not_found" }); + }); +}); + +server.listen(port, () => { + console.log(`[whispergate-no-reply-api] listening on :${port}`); +}); -- 2.49.1 From 7728892d1546fe10a780e6ed563a4ce7cf835717 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:36:59 +0000 Subject: [PATCH 03/27] feat(plugin): add WhisperGate rule engine and model override hook --- plugin/index.ts | 105 ++++++++++++++++++++++++++++++++++++ plugin/openclaw.plugin.json | 20 +++++++ 2 files changed, 125 insertions(+) create mode 100644 plugin/index.ts create mode 100644 plugin/openclaw.plugin.json diff --git a/plugin/index.ts b/plugin/index.ts new file mode 100644 index 0000000..7c98502 --- /dev/null +++ b/plugin/index.ts @@ -0,0 +1,105 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; + +type Config = { + enabled?: boolean; + discordOnly?: boolean; + bypassUserIds?: string[]; + endSymbols?: string[]; + noReplyProvider: string; + noReplyModel: string; +}; + +type Decision = { + shouldUseNoReply: boolean; + reason: string; +}; + +const sessionDecision = new Map(); + +function getLastChar(input: string): string { + const t = input.trim(); + return t.length ? t[t.length - 1] : ""; +} + +function shouldUseNoReply(params: { + config: Config; + channel?: string; + senderId?: string; + content?: string; +}): Decision { + const { config } = params; + + if (config.enabled === false) { + return { shouldUseNoReply: false, reason: "disabled" }; + } + + const channel = (params.channel || "").toLowerCase(); + if (config.discordOnly !== false && channel !== "discord") { + return { shouldUseNoReply: false, reason: "non_discord" }; + } + + if (params.senderId && (config.bypassUserIds || []).includes(params.senderId)) { + return { shouldUseNoReply: false, reason: "bypass_sender" }; + } + + const lastChar = getLastChar(params.content || ""); + if (lastChar && (config.endSymbols || []).includes(lastChar)) { + return { shouldUseNoReply: false, reason: `end_symbol:${lastChar}` }; + } + + return { shouldUseNoReply: true, reason: "rule_match_no_end_symbol" }; +} + +export default { + id: "whispergate", + name: "WhisperGate", + register(api: OpenClawPluginApi) { + const config = (api.pluginConfig || {}) as Config; + + api.registerHook("message:received", async (event, ctx) => { + try { + const c = (ctx || {}) as Record; + const e = (event || {}) as Record; + const sessionKey = typeof c.sessionKey === "string" ? c.sessionKey : undefined; + if (!sessionKey) return; + + const senderId = + typeof c.senderId === "string" + ? c.senderId + : typeof e.from === "string" + ? e.from + : undefined; + + const content = typeof e.content === "string" ? e.content : ""; + const channel = + typeof c.commandSource === "string" + ? c.commandSource + : typeof c.channelId === "string" + ? c.channelId + : ""; + + const decision = shouldUseNoReply({ config, channel, senderId, content }); + sessionDecision.set(sessionKey, decision); + api.logger.debug?.(`whispergate: session=${sessionKey} decision=${decision.reason}`); + } catch (err) { + api.logger.warn(`whispergate: message hook failed: ${String(err)}`); + } + }); + + api.on("before_model_resolve", async (_event, ctx) => { + const key = ctx.sessionKey; + if (!key) return; + const decision = sessionDecision.get(key); + if (!decision?.shouldUseNoReply) return; + + api.logger.info( + `whispergate: override model for session=${key}, provider=${config.noReplyProvider}, model=${config.noReplyModel}, reason=${decision.reason}`, + ); + + return { + providerOverride: config.noReplyProvider, + modelOverride: config.noReplyModel, + }; + }); + }, +}; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json new file mode 100644 index 0000000..bfba37e --- /dev/null +++ b/plugin/openclaw.plugin.json @@ -0,0 +1,20 @@ +{ + "id": "whispergate", + "name": "WhisperGate", + "version": "0.1.0", + "description": "Rule-based no-reply gate with provider/model override", + "entry": "./index.ts", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": true }, + "discordOnly": { "type": "boolean", "default": true }, + "bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] }, + "endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["。", "!", "?", ".", "!", "?"] }, + "noReplyProvider": { "type": "string" }, + "noReplyModel": { "type": "string" } + }, + "required": ["noReplyProvider", "noReplyModel"] + } +} -- 2.49.1 From e020f1f0260b1aaa9f5731b4067eeb1ccb7ef50e Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:37:12 +0000 Subject: [PATCH 04/27] docs: add sample config and verification guide --- docs/CONFIG.example.json | 28 ++++++++++++++++++++++++++++ docs/VERIFY.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 docs/CONFIG.example.json create mode 100644 docs/VERIFY.md diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json new file mode 100644 index 0000000..0eb1a80 --- /dev/null +++ b/docs/CONFIG.example.json @@ -0,0 +1,28 @@ +{ + "plugins": { + "load": { + "paths": ["/path/to/WhisperGate/plugin"] + }, + "entries": { + "whispergate": { + "enabled": true, + "config": { + "enabled": true, + "discordOnly": true, + "bypassUserIds": ["561921120408698910"], + "endSymbols": ["。", "!", "?", ".", "!", "?"], + "noReplyProvider": "openai", + "noReplyModel": "whispergate-no-reply-v1" + } + } + } + }, + "models": { + "providers": { + "openai": { + "apiKey": "sk-xxxx", + "baseURL": "http://127.0.0.1:8787/v1" + } + } + } +} diff --git a/docs/VERIFY.md b/docs/VERIFY.md new file mode 100644 index 0000000..6375c48 --- /dev/null +++ b/docs/VERIFY.md @@ -0,0 +1,30 @@ +# WhisperGate Quick Verification + +## 1) Start no-reply API + +```bash +cd no-reply-api +npm start +``` + +## 2) Validate API behavior + +```bash +curl -sS http://127.0.0.1:8787/health +curl -sS -X POST http://127.0.0.1:8787/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{"model":"whispergate-no-reply-v1","messages":[{"role":"user","content":"hi"}]}' +``` + +Expected assistant text: `NO_REPLY` + +## 3) Enable plugin + +Set OpenClaw plugin path to `plugin/` and apply `docs/CONFIG.example.json` values. + +## 4) Discord logic check + +- non-discord session -> normal model +- discord + bypass user -> normal model +- discord + non-bypass + ending punctuation -> normal model +- discord + non-bypass + no ending punctuation -> no-reply model override -- 2.49.1 From 83b9d517ec23db1e470eb855361f92ba8f31e508 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:39:47 +0000 Subject: [PATCH 05/27] feat(plugin): extract rule engine and harden channel/session handling --- plugin/index.ts | 70 ++++++++++++++----------------------------------- plugin/rules.ts | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 51 deletions(-) create mode 100644 plugin/rules.ts diff --git a/plugin/index.ts b/plugin/index.ts index 7c98502..006b733 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,60 +1,32 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; - -type Config = { - enabled?: boolean; - discordOnly?: boolean; - bypassUserIds?: string[]; - endSymbols?: string[]; - noReplyProvider: string; - noReplyModel: string; -}; - -type Decision = { - shouldUseNoReply: boolean; - reason: string; -}; +import { evaluateDecision, type Decision, type WhisperGateConfig } from "./rules.js"; const sessionDecision = new Map(); +const MAX_SESSION_DECISIONS = 2000; -function getLastChar(input: string): string { - const t = input.trim(); - return t.length ? t[t.length - 1] : ""; +function normalizeChannel(ctx: Record): string { + const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; + for (const c of candidates) { + if (typeof c === "string" && c.trim()) return c.trim().toLowerCase(); + } + return ""; } -function shouldUseNoReply(params: { - config: Config; - channel?: string; - senderId?: string; - content?: string; -}): Decision { - const { config } = params; - - if (config.enabled === false) { - return { shouldUseNoReply: false, reason: "disabled" }; +function pruneDecisionMap() { + if (sessionDecision.size <= MAX_SESSION_DECISIONS) return; + const keys = sessionDecision.keys(); + while (sessionDecision.size > MAX_SESSION_DECISIONS) { + const k = keys.next(); + if (k.done) break; + sessionDecision.delete(k.value); } - - const channel = (params.channel || "").toLowerCase(); - if (config.discordOnly !== false && channel !== "discord") { - return { shouldUseNoReply: false, reason: "non_discord" }; - } - - if (params.senderId && (config.bypassUserIds || []).includes(params.senderId)) { - return { shouldUseNoReply: false, reason: "bypass_sender" }; - } - - const lastChar = getLastChar(params.content || ""); - if (lastChar && (config.endSymbols || []).includes(lastChar)) { - return { shouldUseNoReply: false, reason: `end_symbol:${lastChar}` }; - } - - return { shouldUseNoReply: true, reason: "rule_match_no_end_symbol" }; } export default { id: "whispergate", name: "WhisperGate", register(api: OpenClawPluginApi) { - const config = (api.pluginConfig || {}) as Config; + const config = (api.pluginConfig || {}) as WhisperGateConfig; api.registerHook("message:received", async (event, ctx) => { try { @@ -71,15 +43,11 @@ export default { : undefined; const content = typeof e.content === "string" ? e.content : ""; - const channel = - typeof c.commandSource === "string" - ? c.commandSource - : typeof c.channelId === "string" - ? c.channelId - : ""; + const channel = normalizeChannel(c); - const decision = shouldUseNoReply({ config, channel, senderId, content }); + const decision = evaluateDecision({ config, channel, senderId, content }); sessionDecision.set(sessionKey, decision); + pruneDecisionMap(); api.logger.debug?.(`whispergate: session=${sessionKey} decision=${decision.reason}`); } catch (err) { api.logger.warn(`whispergate: message hook failed: ${String(err)}`); diff --git a/plugin/rules.ts b/plugin/rules.ts new file mode 100644 index 0000000..9fafcc2 --- /dev/null +++ b/plugin/rules.ts @@ -0,0 +1,47 @@ +export type WhisperGateConfig = { + enabled?: boolean; + discordOnly?: boolean; + bypassUserIds?: string[]; + endSymbols?: string[]; + noReplyProvider: string; + noReplyModel: string; +}; + +export type Decision = { + shouldUseNoReply: boolean; + reason: string; +}; + +function getLastChar(input: string): string { + const t = input.trim(); + return t.length ? t[t.length - 1] : ""; +} + +export function evaluateDecision(params: { + config: WhisperGateConfig; + channel?: string; + senderId?: string; + content?: string; +}): Decision { + const { config } = params; + + if (config.enabled === false) { + return { shouldUseNoReply: false, reason: "disabled" }; + } + + const channel = (params.channel || "").toLowerCase(); + if (config.discordOnly !== false && channel !== "discord") { + return { shouldUseNoReply: false, reason: "non_discord" }; + } + + if (params.senderId && (config.bypassUserIds || []).includes(params.senderId)) { + return { shouldUseNoReply: false, reason: "bypass_sender" }; + } + + const lastChar = getLastChar(params.content || ""); + if (lastChar && (config.endSymbols || []).includes(lastChar)) { + return { shouldUseNoReply: false, reason: `end_symbol:${lastChar}` }; + } + + return { shouldUseNoReply: true, reason: "rule_match_no_end_symbol" }; +} -- 2.49.1 From f4fb495ab250c9e96d7856218e0b598de4d9d136 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:39:51 +0000 Subject: [PATCH 06/27] chore(api): add docker compose and container image for no-reply service --- .gitignore | 4 ++++ docker-compose.yml | 11 +++++++++++ no-reply-api/.dockerignore | 2 ++ no-reply-api/Dockerfile | 7 +++++++ 4 files changed, 24 insertions(+) create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 no-reply-api/.dockerignore create mode 100644 no-reply-api/Dockerfile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1625333 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +.DS_Store +*.log diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..af60335 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + whispergate-no-reply-api: + build: + context: ./no-reply-api + container_name: whispergate-no-reply-api + ports: + - "8787:8787" + environment: + - PORT=8787 + - NO_REPLY_MODEL=whispergate-no-reply-v1 + restart: unless-stopped diff --git a/no-reply-api/.dockerignore b/no-reply-api/.dockerignore new file mode 100644 index 0000000..93f1361 --- /dev/null +++ b/no-reply-api/.dockerignore @@ -0,0 +1,2 @@ +node_modules +npm-debug.log diff --git a/no-reply-api/Dockerfile b/no-reply-api/Dockerfile new file mode 100644 index 0000000..b5c7e50 --- /dev/null +++ b/no-reply-api/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json ./ +COPY server.mjs ./ +EXPOSE 8787 +ENV PORT=8787 +CMD ["node", "server.mjs"] -- 2.49.1 From 99f96a95496e2337821b0d5775ed2401239ab94f Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:40:00 +0000 Subject: [PATCH 07/27] docs(plugin): add usage and hook flow notes --- plugin/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 plugin/README.md diff --git a/plugin/README.md b/plugin/README.md new file mode 100644 index 0000000..c7388f3 --- /dev/null +++ b/plugin/README.md @@ -0,0 +1,27 @@ +# WhisperGate Plugin + +## Hook strategy + +- `message:received` caches a per-session decision from deterministic rules. +- `before_model_resolve` applies `providerOverride + modelOverride` when decision says no-reply. + +## Rules (in order) + +1. non-discord -> skip +2. bypass sender -> skip +3. end symbol matched -> skip +4. else -> no-reply override + +## Config + +See `docs/CONFIG.example.json`. + +Required: +- `noReplyProvider` +- `noReplyModel` + +Optional: +- `enabled` (default true) +- `discordOnly` (default true) +- `bypassUserIds` (default []) +- `endSymbols` (default punctuation set) -- 2.49.1 From f09972c083f65c10572e41f48ee65d493282a6d4 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:43:11 +0000 Subject: [PATCH 08/27] feat(plugin): add sender normalization, TTL, and one-shot decisions --- plugin/index.ts | 59 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 006b733..e5a00c0 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,8 +1,14 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { evaluateDecision, type Decision, type WhisperGateConfig } from "./rules.js"; -const sessionDecision = new Map(); +type DecisionRecord = { + decision: Decision; + createdAt: number; +}; + +const sessionDecision = new Map(); const MAX_SESSION_DECISIONS = 2000; +const DECISION_TTL_MS = 5 * 60 * 1000; function normalizeChannel(ctx: Record): string { const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; @@ -12,7 +18,27 @@ function normalizeChannel(ctx: Record): string { return ""; } -function pruneDecisionMap() { +function normalizeSender(event: Record, ctx: Record): string | undefined { + const direct = [ctx.senderId, ctx.from, event.from]; + for (const v of direct) { + if (typeof v === "string" && v.trim()) return v.trim(); + } + + const meta = (event.metadata || ctx.metadata) as Record | undefined; + if (!meta) return undefined; + const metaCandidates = [meta.senderId, meta.sender_id, meta.userId, meta.user_id]; + for (const v of metaCandidates) { + if (typeof v === "string" && v.trim()) return v.trim(); + } + + return undefined; +} + +function pruneDecisionMap(now = Date.now()) { + for (const [k, v] of sessionDecision.entries()) { + if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k); + } + if (sessionDecision.size <= MAX_SESSION_DECISIONS) return; const keys = sessionDecision.keys(); while (sessionDecision.size > MAX_SESSION_DECISIONS) { @@ -35,20 +61,16 @@ export default { const sessionKey = typeof c.sessionKey === "string" ? c.sessionKey : undefined; if (!sessionKey) return; - const senderId = - typeof c.senderId === "string" - ? c.senderId - : typeof e.from === "string" - ? e.from - : undefined; - + const senderId = normalizeSender(e, c); const content = typeof e.content === "string" ? e.content : ""; const channel = normalizeChannel(c); const decision = evaluateDecision({ config, channel, senderId, content }); - sessionDecision.set(sessionKey, decision); + sessionDecision.set(sessionKey, { decision, createdAt: Date.now() }); pruneDecisionMap(); - api.logger.debug?.(`whispergate: session=${sessionKey} decision=${decision.reason}`); + api.logger.debug?.( + `whispergate: session=${sessionKey} sender=${senderId ?? "unknown"} channel=${channel || "unknown"} decision=${decision.reason}`, + ); } catch (err) { api.logger.warn(`whispergate: message hook failed: ${String(err)}`); } @@ -57,11 +79,20 @@ export default { api.on("before_model_resolve", async (_event, ctx) => { const key = ctx.sessionKey; if (!key) return; - const decision = sessionDecision.get(key); - if (!decision?.shouldUseNoReply) return; + + const rec = sessionDecision.get(key); + if (!rec) return; + if (Date.now() - rec.createdAt > DECISION_TTL_MS) { + sessionDecision.delete(key); + return; + } + + // one-shot decision per inbound turn + sessionDecision.delete(key); + if (!rec.decision.shouldUseNoReply) return; api.logger.info( - `whispergate: override model for session=${key}, provider=${config.noReplyProvider}, model=${config.noReplyModel}, reason=${decision.reason}`, + `whispergate: override model for session=${key}, provider=${config.noReplyProvider}, model=${config.noReplyModel}, reason=${rec.decision.reason}`, ); return { -- 2.49.1 From 32af5fde6d453861e5fd396608cb09674877881b Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:43:19 +0000 Subject: [PATCH 09/27] feat(api): add bearer auth option and /v1/models endpoint --- no-reply-api/server.mjs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/no-reply-api/server.mjs b/no-reply-api/server.mjs index 4e999fc..f56a1dd 100644 --- a/no-reply-api/server.mjs +++ b/no-reply-api/server.mjs @@ -2,12 +2,19 @@ import http from "node:http"; const port = Number(process.env.PORT || 8787); const modelName = process.env.NO_REPLY_MODEL || "whispergate-no-reply-v1"; +const authToken = process.env.AUTH_TOKEN || ""; function sendJson(res, status, payload) { res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(payload)); } +function isAuthorized(req) { + if (!authToken) return true; + const header = req.headers.authorization || ""; + return header === `Bearer ${authToken}`; +} + function noReplyChatCompletion(reqBody) { return { id: `chatcmpl_whispergate_${Date.now()}`, @@ -42,15 +49,38 @@ function noReplyResponses(reqBody) { }; } +function listModels() { + return { + object: "list", + data: [ + { + id: modelName, + object: "model", + created: Math.floor(Date.now() / 1000), + owned_by: "whispergate" + } + ] + }; +} + const server = http.createServer((req, res) => { if (req.method === "GET" && req.url === "/health") { - return sendJson(res, 200, { ok: true, service: "whispergate-no-reply-api" }); + return sendJson(res, 200, { ok: true, service: "whispergate-no-reply-api", model: modelName }); + } + + if (req.method === "GET" && req.url === "/v1/models") { + if (!isAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" }); + return sendJson(res, 200, listModels()); } if (req.method !== "POST") { return sendJson(res, 404, { error: "not_found" }); } + if (!isAuthorized(req)) { + return sendJson(res, 401, { error: "unauthorized" }); + } + let body = ""; req.on("data", (chunk) => { body += chunk; -- 2.49.1 From d1f4252f379c9dc1e45176ff630068f4985167d3 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:43:23 +0000 Subject: [PATCH 10/27] docs: add smoke script and auth-aware verification steps --- docs/CONFIG.example.json | 2 +- docs/VERIFY.md | 10 ++++++++++ scripts/smoke-no-reply-api.sh | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100755 scripts/smoke-no-reply-api.sh diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index 0eb1a80..4556397 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -20,7 +20,7 @@ "models": { "providers": { "openai": { - "apiKey": "sk-xxxx", + "apiKey": "", "baseURL": "http://127.0.0.1:8787/v1" } } diff --git a/docs/VERIFY.md b/docs/VERIFY.md index 6375c48..52ea5d3 100644 --- a/docs/VERIFY.md +++ b/docs/VERIFY.md @@ -4,6 +4,8 @@ ```bash cd no-reply-api +# optional: enforce bearer token checks +# export AUTH_TOKEN='replace-with-strong-token' npm start ``` @@ -16,6 +18,14 @@ curl -sS -X POST http://127.0.0.1:8787/v1/chat/completions \ -d '{"model":"whispergate-no-reply-v1","messages":[{"role":"user","content":"hi"}]}' ``` +Or run bundled smoke check: + +```bash +./scripts/smoke-no-reply-api.sh +# with auth: +# AUTH_TOKEN='replace-with-strong-token' ./scripts/smoke-no-reply-api.sh +``` + Expected assistant text: `NO_REPLY` ## 3) Enable plugin diff --git a/scripts/smoke-no-reply-api.sh b/scripts/smoke-no-reply-api.sh new file mode 100755 index 0000000..87a5ac9 --- /dev/null +++ b/scripts/smoke-no-reply-api.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://127.0.0.1:8787}" +AUTH_TOKEN="${AUTH_TOKEN:-}" + +AUTH_HEADER=() +if [[ -n "$AUTH_TOKEN" ]]; then + AUTH_HEADER=(-H "Authorization: Bearer ${AUTH_TOKEN}") +fi + +echo "[1] health" +curl -sS "${BASE_URL}/health" | sed -n '1,3p' + +echo "[2] models" +curl -sS "${BASE_URL}/v1/models" "${AUTH_HEADER[@]}" | sed -n '1,8p' + +echo "[3] chat/completions" +curl -sS -X POST "${BASE_URL}/v1/chat/completions" \ + -H 'Content-Type: application/json' \ + "${AUTH_HEADER[@]}" \ + -d '{"model":"whispergate-no-reply-v1","messages":[{"role":"user","content":"hello"}]}' \ + | sed -n '1,20p' + +echo "[4] responses" +curl -sS -X POST "${BASE_URL}/v1/responses" \ + -H 'Content-Type: application/json' \ + "${AUTH_HEADER[@]}" \ + -d '{"model":"whispergate-no-reply-v1","input":"hello"}' \ + | sed -n '1,20p' + +echo "smoke ok" -- 2.49.1 From 0e98f0877b23991e007e2892e6703ff16cf9ace7 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:44:33 +0000 Subject: [PATCH 11/27] chore(plugin): add package metadata and plugin file checker --- plugin/package.json | 10 ++++++++++ scripts/check-plugin-files.mjs | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 plugin/package.json create mode 100644 scripts/check-plugin-files.mjs diff --git a/plugin/package.json b/plugin/package.json new file mode 100644 index 0000000..9095670 --- /dev/null +++ b/plugin/package.json @@ -0,0 +1,10 @@ +{ + "name": "whispergate-plugin", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "WhisperGate OpenClaw plugin", + "scripts": { + "check": "node ../scripts/check-plugin-files.mjs" + } +} diff --git a/scripts/check-plugin-files.mjs b/scripts/check-plugin-files.mjs new file mode 100644 index 0000000..9f48f57 --- /dev/null +++ b/scripts/check-plugin-files.mjs @@ -0,0 +1,29 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(process.cwd(), '..'); +const pluginDir = path.join(root, 'plugin'); +const required = ['index.ts', 'rules.ts', 'openclaw.plugin.json', 'README.md', 'package.json']; + +let ok = true; +for (const f of required) { + const p = path.join(pluginDir, f); + if (!fs.existsSync(p)) { + ok = false; + console.error(`missing: ${p}`); + } +} + +const manifestPath = path.join(pluginDir, 'openclaw.plugin.json'); +if (fs.existsSync(manifestPath)) { + const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + for (const k of ['id', 'entry', 'configSchema']) { + if (!(k in m)) { + ok = false; + console.error(`manifest missing key: ${k}`); + } + } +} + +if (!ok) process.exit(1); +console.log('plugin file check: ok'); -- 2.49.1 From f76d952b593ae55078e5a9d4e245806df9faa499 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:45:06 +0000 Subject: [PATCH 12/27] chore(dev): add docker compose up/down helper scripts --- scripts/dev-down.sh | 6 ++++++ scripts/dev-up.sh | 13 +++++++++++++ 2 files changed, 19 insertions(+) create mode 100755 scripts/dev-down.sh create mode 100755 scripts/dev-up.sh diff --git a/scripts/dev-down.sh b/scripts/dev-down.sh new file mode 100755 index 0000000..d5f449f --- /dev/null +++ b/scripts/dev-down.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +docker compose down diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh new file mode 100755 index 0000000..eda0d39 --- /dev/null +++ b/scripts/dev-up.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +echo "[whispergate] building/starting no-reply API container" +docker compose up -d --build whispergate-no-reply-api + +echo "[whispergate] health check" +curl -sS http://127.0.0.1:8787/health + +echo "[whispergate] done" -- 2.49.1 From 2379d0f52177cbd6ba316fa90736620ad70e0a36 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:45:18 +0000 Subject: [PATCH 13/27] docs: add staged rollout and rollback checklist --- docs/ROLLOUT.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/ROLLOUT.md diff --git a/docs/ROLLOUT.md b/docs/ROLLOUT.md new file mode 100644 index 0000000..533d71f --- /dev/null +++ b/docs/ROLLOUT.md @@ -0,0 +1,33 @@ +# WhisperGate Rollout Checklist + +## Stage 0: Local sanity + +- Start API: `./scripts/dev-up.sh` +- Smoke API: `./scripts/smoke-no-reply-api.sh` +- Check plugin files: `cd plugin && npm run check` + +## Stage 1: Canary (single Discord session) + +- Enable plugin with: + - `discordOnly=true` + - narrow `bypassUserIds` + - 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 +- Monitor false-silent turns +- Keep fallback model available + +## Stage 3: Production hardening + +- Set `AUTH_TOKEN` for no-reply API +- Run behind private network / loopback +- Add service supervisor (systemd or compose restart policy) + +## Rollback + +- Disable plugin entry `whispergate.enabled=false` OR remove plugin path +- Keep API service running; it is inert when plugin disabled -- 2.49.1 From 798c402ab0324cc2c3f6a3d6168424b3d9794486 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:45:45 +0000 Subject: [PATCH 14/27] docs: update progress tracker and repository map --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 926497f..fa04acf 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,21 @@ The no-reply provider returns `NO_REPLY` for any input. - `plugin/` — OpenClaw plugin (before_model_resolve hook) - `no-reply-api/` — OpenAI-compatible minimal API that always returns `NO_REPLY` - `docs/` — rollout and configuration notes +- `scripts/` — smoke/dev helper scripts --- ## Development plan (incremental commits) - [x] Task 1: project docs + structure -- [ ] Task 2: no-reply API MVP -- [ ] Task 3: plugin MVP with rule chain -- [ ] Task 4: sample config + quick verification scripts +- [x] Task 2: no-reply API MVP +- [x] Task 3: plugin MVP with rule chain +- [x] Task 4: sample config + quick verification scripts +- [x] Task 5: plugin rule extraction + hardening +- [x] Task 6: containerization + compose +- [x] Task 7: plugin usage notes +- [x] Task 8: sender normalization + TTL + one-shot decision +- [x] Task 9: auth-aware no-reply API +- [x] Task 10: smoke test helpers +- [x] Task 11: plugin structure checker +- [x] Task 12: rollout checklist -- 2.49.1 From 32405fa3e285c22ffc061150ad7d32a48f198203 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 11:04:01 +0000 Subject: [PATCH 15/27] =?UTF-8?q?feat(plugin):=20inject=20=F0=9F=94=9A=20e?= =?UTF-8?q?nding=20instruction=20for=20bypass/end-symbol=20discord=20turns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/IMPLEMENTATION.md | 4 ++++ plugin/README.md | 3 +++ plugin/index.ts | 31 +++++++++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 8bd0912..96e748d 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -9,6 +9,10 @@ WhisperGate evaluates in strict order: 3. message ending symbol check 4. fallback to no-reply model override +Additional prompt behavior: +- when decision is `bypass_sender` or `end_symbol:*`, plugin prepends: + - `你的这次发言必须以🔚作为结尾。` + ## Why before_model_resolve - deterministic diff --git a/plugin/README.md b/plugin/README.md index c7388f3..2373162 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -4,6 +4,9 @@ - `message:received` caches a per-session decision from deterministic rules. - `before_model_resolve` applies `providerOverride + modelOverride` when decision says no-reply. +- `before_prompt_build` prepends instruction `你的这次发言必须以🔚作为结尾。` when decision is: + - `bypass_sender` + - `end_symbol:*` ## Rules (in order) diff --git a/plugin/index.ts b/plugin/index.ts index e5a00c0..5310078 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -9,6 +9,7 @@ type DecisionRecord = { const sessionDecision = new Map(); const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; +const END_MARKER_INSTRUCTION = "你的这次发言必须以🔚作为结尾。"; function normalizeChannel(ctx: Record): string { const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; @@ -48,6 +49,10 @@ function pruneDecisionMap(now = Date.now()) { } } +function shouldInjectEndMarker(reason: string): boolean { + return reason === "bypass_sender" || reason.startsWith("end_symbol:"); +} + export default { id: "whispergate", name: "WhisperGate", @@ -87,10 +92,10 @@ export default { return; } - // one-shot decision per inbound turn - sessionDecision.delete(key); if (!rec.decision.shouldUseNoReply) return; + // no-reply path is consumed here + sessionDecision.delete(key); api.logger.info( `whispergate: override model for session=${key}, provider=${config.noReplyProvider}, model=${config.noReplyModel}, reason=${rec.decision.reason}`, ); @@ -100,5 +105,27 @@ export default { modelOverride: config.noReplyModel, }; }); + + api.on("before_prompt_build", async (_event, ctx) => { + const key = ctx.sessionKey; + if (!key) return; + const rec = sessionDecision.get(key); + if (!rec) return; + + if (Date.now() - rec.createdAt > DECISION_TTL_MS) { + sessionDecision.delete(key); + 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, + }; + }); }, }; -- 2.49.1 From d2fc8c89dd9473bf1549af4a7d3d64a3f47ebcc4 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 12:53:45 +0000 Subject: [PATCH 16/27] =?UTF-8?q?test:=20add=20rule-case=20validator=20for?= =?UTF-8?q?=20no-reply=20and=20=F0=9F=94=9A=20injection=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rule-cases.json | 54 ++++++++++++++++++++++++++++++++ plugin/package.json | 3 +- scripts/validate-rules.mjs | 64 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 docs/rule-cases.json create mode 100644 scripts/validate-rules.mjs diff --git a/docs/rule-cases.json b/docs/rule-cases.json new file mode 100644 index 0000000..500969f --- /dev/null +++ b/docs/rule-cases.json @@ -0,0 +1,54 @@ +{ + "config": { + "enabled": true, + "discordOnly": true, + "bypassUserIds": ["561921120408698910"], + "endSymbols": ["。", "!", "?", ".", "!", "?"] + }, + "cases": [ + { + "name": "non-discord skips gate", + "channel": "telegram", + "senderId": "u1", + "content": "hello", + "expect": { + "shouldUseNoReply": false, + "reason": "non_discord", + "injectEndMarker": false + } + }, + { + "name": "bypass sender injects end marker", + "channel": "discord", + "senderId": "561921120408698910", + "content": "hello", + "expect": { + "shouldUseNoReply": false, + "reason": "bypass_sender", + "injectEndMarker": true + } + }, + { + "name": "ending punctuation injects end marker", + "channel": "discord", + "senderId": "u2", + "content": "你好!", + "expect": { + "shouldUseNoReply": false, + "reason": "end_symbol:!", + "injectEndMarker": true + } + }, + { + "name": "no ending punctuation triggers no-reply override", + "channel": "discord", + "senderId": "u2", + "content": "继续", + "expect": { + "shouldUseNoReply": true, + "reason": "rule_match_no_end_symbol", + "injectEndMarker": false + } + } + ] +} diff --git a/plugin/package.json b/plugin/package.json index 9095670..35b00ba 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -5,6 +5,7 @@ "type": "module", "description": "WhisperGate OpenClaw plugin", "scripts": { - "check": "node ../scripts/check-plugin-files.mjs" + "check": "node ../scripts/check-plugin-files.mjs", + "check:rules": "node ../scripts/validate-rules.mjs" } } diff --git a/scripts/validate-rules.mjs b/scripts/validate-rules.mjs new file mode 100644 index 0000000..4c74bc6 --- /dev/null +++ b/scripts/validate-rules.mjs @@ -0,0 +1,64 @@ +import fs from "node:fs"; +import path from "node:path"; + +function getLastChar(input) { + const t = (input || "").trim(); + return t.length ? t[t.length - 1] : ""; +} + +function evaluateDecision({ config, channel, senderId, content }) { + if (config.enabled === false) { + return { shouldUseNoReply: false, reason: "disabled" }; + } + + const ch = (channel || "").toLowerCase(); + if (config.discordOnly !== false && ch !== "discord") { + return { shouldUseNoReply: false, reason: "non_discord" }; + } + + if (senderId && (config.bypassUserIds || []).includes(senderId)) { + return { shouldUseNoReply: false, reason: "bypass_sender" }; + } + + const last = getLastChar(content || ""); + if (last && (config.endSymbols || []).includes(last)) { + return { shouldUseNoReply: false, reason: `end_symbol:${last}` }; + } + + return { shouldUseNoReply: true, reason: "rule_match_no_end_symbol" }; +} + +function shouldInjectEndMarker(reason) { + return reason === "bypass_sender" || String(reason).startsWith("end_symbol:"); +} + +const fixturePath = path.join(process.cwd(), "docs", "rule-cases.json"); +const payload = JSON.parse(fs.readFileSync(fixturePath, "utf8")); + +let ok = true; +for (const c of payload.cases || []) { + const d = evaluateDecision({ + config: payload.config, + channel: c.channel, + senderId: c.senderId, + content: c.content, + }); + + const inject = shouldInjectEndMarker(d.reason); + const pass = + d.shouldUseNoReply === c.expect.shouldUseNoReply && + d.reason === c.expect.reason && + inject === c.expect.injectEndMarker; + + if (!pass) { + ok = false; + console.error(`FAIL ${c.name}`); + console.error(` got: ${JSON.stringify({ ...d, injectEndMarker: inject })}`); + console.error(` expect: ${JSON.stringify(c.expect)}`); + } else { + console.log(`OK ${c.name}`); + } +} + +if (!ok) process.exit(1); +console.log("all rule cases passed"); -- 2.49.1 From 16fdb6600ac97fb5fd228ae0464aeb2e04f21ea4 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 12:54:05 +0000 Subject: [PATCH 17/27] chore(dev): add Makefile shortcuts for check/smoke lifecycle --- Makefile | 16 ++++++++++++++++ README.md | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..437f721 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: check check-rules up down smoke + +check: + cd plugin && npm run check + +check-rules: + node scripts/validate-rules.mjs + +up: + ./scripts/dev-up.sh + +down: + ./scripts/dev-down.sh + +smoke: + ./scripts/smoke-no-reply-api.sh diff --git a/README.md b/README.md index fa04acf..0776e83 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ The no-reply provider returns `NO_REPLY` for any input. - `plugin/` — OpenClaw plugin (before_model_resolve hook) - `no-reply-api/` — OpenAI-compatible minimal API that always returns `NO_REPLY` - `docs/` — rollout and configuration notes -- `scripts/` — smoke/dev helper scripts +- `scripts/` — smoke/dev/helper checks +- `Makefile` — common dev commands (`make check`, `make check-rules`, `make up`) --- -- 2.49.1 From b99ec7c281d97512836a07da9cc5dbdb73614ece Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 13:45:57 +0000 Subject: [PATCH 18/27] test(api): add isolated no-reply API integration test script --- scripts/test-no-reply-api.mjs | 82 +++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 scripts/test-no-reply-api.mjs diff --git a/scripts/test-no-reply-api.mjs b/scripts/test-no-reply-api.mjs new file mode 100644 index 0000000..971bf1f --- /dev/null +++ b/scripts/test-no-reply-api.mjs @@ -0,0 +1,82 @@ +import { spawn } from "node:child_process"; + +const BASE = "http://127.0.0.1:18787"; + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function waitForHealth(retries = 30) { + for (let i = 0; i < retries; i++) { + try { + const r = await fetch(`${BASE}/health`); + if (r.ok) return true; + } catch {} + await sleep(200); + } + return false; +} + +function assert(cond, msg) { + if (!cond) throw new Error(msg); +} + +async function run() { + const token = "test-token"; + const child = spawn("node", ["no-reply-api/server.mjs"], { + cwd: process.cwd(), + env: { ...process.env, PORT: "18787", AUTH_TOKEN: token, NO_REPLY_MODEL: "wg-test-model" }, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout.on("data", () => {}); + child.stderr.on("data", () => {}); + + try { + const ok = await waitForHealth(); + assert(ok, "health check failed"); + + const unauth = await fetch(`${BASE}/v1/models`); + assert(unauth.status === 401, `expected 401, got ${unauth.status}`); + + const models = await fetch(`${BASE}/v1/models`, { + headers: { Authorization: `Bearer ${token}` }, + }); + assert(models.ok, "authorized /v1/models failed"); + const modelsJson = await models.json(); + assert(modelsJson?.data?.[0]?.id === "wg-test-model", "model id mismatch"); + + const cc = await fetch(`${BASE}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ model: "wg-test-model", messages: [{ role: "user", content: "hi" }] }), + }); + assert(cc.ok, "chat completions failed"); + const ccJson = await cc.json(); + assert(ccJson?.choices?.[0]?.message?.content === "NO_REPLY", "chat completion not NO_REPLY"); + + const rsp = await fetch(`${BASE}/v1/responses`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ model: "wg-test-model", input: "hi" }), + }); + assert(rsp.ok, "responses failed"); + const rspJson = await rsp.json(); + assert(rspJson?.output?.[0]?.content?.[0]?.text === "NO_REPLY", "responses not NO_REPLY"); + + console.log("test-no-reply-api: ok"); + } finally { + child.kill("SIGTERM"); + } +} + +run().catch((err) => { + console.error(`test-no-reply-api: fail: ${err.message}`); + process.exit(1); +}); -- 2.49.1 From 1acaebf73ffdbe25d7886ba1dd3cf06eb0ee4b0a Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 13:47:00 +0000 Subject: [PATCH 19/27] feat(dev): add no-touch OpenClaw config renderer and integration guide --- Makefile | 8 ++++++- docs/INTEGRATION.md | 34 ++++++++++++++++++++++++++++++ scripts/render-openclaw-config.mjs | 25 ++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 docs/INTEGRATION.md create mode 100644 scripts/render-openclaw-config.mjs diff --git a/Makefile b/Makefile index 437f721..de263ab 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: check check-rules up down smoke +.PHONY: check check-rules test-api up down smoke render-config check: cd plugin && npm run check @@ -6,6 +6,9 @@ check: check-rules: node scripts/validate-rules.mjs +test-api: + node scripts/test-no-reply-api.mjs + up: ./scripts/dev-up.sh @@ -14,3 +17,6 @@ down: smoke: ./scripts/smoke-no-reply-api.sh + +render-config: + node scripts/render-openclaw-config.mjs diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md new file mode 100644 index 0000000..8bc5a18 --- /dev/null +++ b/docs/INTEGRATION.md @@ -0,0 +1,34 @@ +# WhisperGate Integration (No-touch Template) + +This guide **does not** change your current OpenClaw config automatically. +It only generates a JSON snippet you can review. + +## Generate config snippet + +```bash +node scripts/render-openclaw-config.mjs \ + /absolute/path/to/WhisperGate/plugin \ + openai \ + whispergate-no-reply-v1 \ + 561921120408698910 +``` + +Arguments: +1. plugin path +2. provider alias +3. model name +4. bypass user ids (comma-separated, optional) + +## Output + +The script prints JSON for: +- `plugins.load.paths` +- `plugins.entries.whispergate.config` + +You can merge this snippet manually into your `openclaw.json`. + +## 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/render-openclaw-config.mjs b/scripts/render-openclaw-config.mjs new file mode 100644 index 0000000..9a50eb5 --- /dev/null +++ b/scripts/render-openclaw-config.mjs @@ -0,0 +1,25 @@ +const pluginPath = process.argv[2] || "/opt/WhisperGate/plugin"; +const provider = process.argv[3] || "openai"; +const model = process.argv[4] || "whispergate-no-reply-v1"; +const bypass = (process.argv[5] || "").split(",").filter(Boolean); + +const payload = { + plugins: { + load: { paths: [pluginPath] }, + entries: { + whispergate: { + enabled: true, + config: { + enabled: true, + discordOnly: true, + bypassUserIds: bypass, + endSymbols: ["。", "!", "?", ".", "!", "?"], + noReplyProvider: provider, + noReplyModel: model, + }, + }, + }, + }, +}; + +console.log(JSON.stringify(payload, null, 2)); -- 2.49.1 From 0a224983fd086272ed59ab21ccc0ce83bbec6af2 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 13:47:19 +0000 Subject: [PATCH 20/27] docs: add changelog and expose test-api command in readme --- CHANGELOG.md | 11 +++++++++++ README.md | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..29cd97f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 0.1.0-mvp + +- Added no-reply API service (`/v1/chat/completions`, `/v1/responses`, `/v1/models`) +- Added optional bearer auth (`AUTH_TOKEN`) +- Added WhisperGate plugin with deterministic rule gate +- Added discord-specific 🔚 prompt injection for bypass/end-symbol paths +- Added containerization (`Dockerfile`, `docker-compose.yml`) +- Added helper scripts for smoke/dev lifecycle and rule validation +- Added no-touch config rendering and integration docs diff --git a/README.md b/README.md index 0776e83..bd8e78f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ The no-reply provider returns `NO_REPLY` for any input. - `no-reply-api/` — OpenAI-compatible minimal API that always returns `NO_REPLY` - `docs/` — rollout and configuration notes - `scripts/` — smoke/dev/helper checks -- `Makefile` — common dev commands (`make check`, `make check-rules`, `make up`) +- `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make up`) +- `CHANGELOG.md` — milestone summary --- -- 2.49.1 From 6792249a0b6912e38be635dd111acaed46afcc6f Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 14:08:15 +0000 Subject: [PATCH 21/27] docs: add no-docker-first run modes and quick start --- README.md | 17 ++++++++++++++++- docs/RUN_MODES.md | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 docs/RUN_MODES.md diff --git a/README.md b/README.md index bd8e78f..923ec36 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,26 @@ The no-reply provider returns `NO_REPLY` for any input. - `plugin/` — OpenClaw plugin (before_model_resolve hook) - `no-reply-api/` — OpenAI-compatible minimal API that always returns `NO_REPLY` -- `docs/` — rollout and configuration notes +- `docs/` — rollout, integration, run-mode notes - `scripts/` — smoke/dev/helper checks - `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make up`) - `CHANGELOG.md` — milestone summary +## Quick start (no Docker) + +```bash +cd no-reply-api +node server.mjs +``` + +Then render config snippet: + +```bash +node scripts/render-openclaw-config.mjs +``` + +See `docs/RUN_MODES.md` for Docker mode. + --- ## Development plan (incremental commits) diff --git a/docs/RUN_MODES.md b/docs/RUN_MODES.md new file mode 100644 index 0000000..57957e9 --- /dev/null +++ b/docs/RUN_MODES.md @@ -0,0 +1,35 @@ +# Run Modes + +WhisperGate has two runtime components: + +1. `plugin/` (OpenClaw plugin) +2. `no-reply-api/` (deterministic NO_REPLY service) + +Docker is optional. + +## Mode A (recommended): No Docker + +```bash +cd no-reply-api +node server.mjs +``` + +Then configure OpenClaw provider `baseURL` to `http://127.0.0.1:8787/v1`. + +## Mode B: Docker + +```bash +./scripts/dev-up.sh +# or: docker compose up -d --build whispergate-no-reply-api +``` + +Stop: + +```bash +./scripts/dev-down.sh +``` + +## Security notes + +- Bind API to loopback/private network. +- If exposed beyond localhost, set `AUTH_TOKEN`. -- 2.49.1 From 0c6d8bb2c50620968b164ac5e2056032dbffe83e Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 14:09:14 +0000 Subject: [PATCH 22/27] feat(release): add plugin packaging script and release notes --- .gitignore | 1 + Makefile | 5 ++++- docs/RELEASE.md | 27 +++++++++++++++++++++++++++ scripts/package-plugin.mjs | 15 +++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 docs/RELEASE.md create mode 100644 scripts/package-plugin.mjs diff --git a/.gitignore b/.gitignore index 1625333..fd7d79d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +dist/ .env .DS_Store *.log diff --git a/Makefile b/Makefile index de263ab..87f994c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: check check-rules test-api up down smoke render-config +.PHONY: check check-rules test-api up down smoke render-config package-plugin check: cd plugin && npm run check @@ -20,3 +20,6 @@ smoke: render-config: node scripts/render-openclaw-config.mjs + +package-plugin: + node scripts/package-plugin.mjs diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..6c98b8c --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,27 @@ +# Release / Packaging + +## Package plugin files + +```bash +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` + +## Use packaged plugin path + +Point OpenClaw `plugins.load.paths` to: + +`/absolute/path/to/WhisperGate/dist/plugin` + +## Verify package completeness + +```bash +cd plugin && npm run check +``` diff --git a/scripts/package-plugin.mjs b/scripts/package-plugin.mjs new file mode 100644 index 0000000..46e6326 --- /dev/null +++ b/scripts/package-plugin.mjs @@ -0,0 +1,15 @@ +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const pluginDir = path.join(root, "plugin"); +const outDir = path.join(root, "dist", "plugin"); + +fs.rmSync(outDir, { recursive: true, force: true }); +fs.mkdirSync(outDir, { recursive: true }); + +for (const f of ["index.ts", "rules.ts", "openclaw.plugin.json", "README.md", "package.json"]) { + fs.copyFileSync(path.join(pluginDir, f), path.join(outDir, f)); +} + +console.log(`packaged plugin to ${outDir}`); -- 2.49.1 From 867285309b6b775ed16f544dc67f666001098ebc Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 16:09:14 +0000 Subject: [PATCH 23/27] feat(discord-control): add private-channel and member-list admin API --- CHANGELOG.md | 3 + Makefile | 5 +- README.md | 3 + discord-control-api/package.json | 9 ++ discord-control-api/server.mjs | 179 +++++++++++++++++++++++++++++++ docs/DISCORD_CONTROL.md | 85 +++++++++++++++ 6 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 discord-control-api/package.json create mode 100644 discord-control-api/server.mjs create mode 100644 docs/DISCORD_CONTROL.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 29cd97f..67b617e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,3 +9,6 @@ - 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 discord-control-api with: + - `channel-private-create` (create private channel for allowlist) + - `member-list` (guild members list with pagination) diff --git a/Makefile b/Makefile index 87f994c..e24f0a7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: check check-rules test-api up down smoke render-config package-plugin +.PHONY: check check-rules test-api up down smoke render-config package-plugin discord-control-up check: cd plugin && npm run check @@ -23,3 +23,6 @@ render-config: package-plugin: node scripts/package-plugin.mjs + +discord-control-up: + cd discord-control-api && node server.mjs diff --git a/README.md b/README.md index 923ec36..68d36cf 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ The no-reply provider returns `NO_REPLY` for any input. - `plugin/` — OpenClaw plugin (before_model_resolve hook) - `no-reply-api/` — OpenAI-compatible minimal API that always returns `NO_REPLY` +- `discord-control-api/` — Discord 管理扩展 API(私密频道 + 成员列表) - `docs/` — rollout, integration, run-mode notes - `scripts/` — smoke/dev/helper checks - `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make up`) @@ -39,6 +40,8 @@ node scripts/render-openclaw-config.mjs See `docs/RUN_MODES.md` for Docker mode. +Discord 扩展能力见:`docs/DISCORD_CONTROL.md`。 + --- ## Development plan (incremental commits) diff --git a/discord-control-api/package.json b/discord-control-api/package.json new file mode 100644 index 0000000..adb396c --- /dev/null +++ b/discord-control-api/package.json @@ -0,0 +1,9 @@ +{ + "name": "whispergate-discord-control-api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs" + } +} diff --git a/discord-control-api/server.mjs b/discord-control-api/server.mjs new file mode 100644 index 0000000..4f82311 --- /dev/null +++ b/discord-control-api/server.mjs @@ -0,0 +1,179 @@ +import http from "node:http"; + +const port = Number(process.env.PORT || 8790); +const authToken = process.env.AUTH_TOKEN || ""; +const discordToken = process.env.DISCORD_BOT_TOKEN || ""; +const discordBase = "https://discord.com/api/v10"; + +const BIT_VIEW_CHANNEL = 1024n; +const BIT_SEND_MESSAGES = 2048n; +const BIT_READ_MESSAGE_HISTORY = 65536n; + +function sendJson(res, status, payload) { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(payload)); +} + +function isAuthorized(req) { + if (!authToken) return true; + const header = req.headers.authorization || ""; + return header === `Bearer ${authToken}`; +} + +function ensureDiscordToken() { + if (!discordToken) { + const e = new Error("missing DISCORD_BOT_TOKEN"); + e.status = 500; + throw e; + } +} + +async function discordRequest(path, init = {}) { + ensureDiscordToken(); + const headers = { + Authorization: `Bot ${discordToken}`, + "Content-Type": "application/json", + ...(init.headers || {}), + }; + + const r = await fetch(`${discordBase}${path}`, { ...init, headers }); + const text = await r.text(); + let data = text; + try { + data = text ? JSON.parse(text) : {}; + } catch {} + + if (!r.ok) { + const e = new Error(`discord_api_error ${r.status}`); + e.status = r.status; + e.details = data; + throw e; + } + return data; +} + +function toStringMask(v, fallback) { + if (v === undefined || v === null || v === "") return String(fallback); + if (typeof v === "string") return v; + if (typeof v === "number") return String(Math.floor(v)); + if (typeof v === "bigint") return String(v); + throw new Error("invalid mask"); +} + +function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = [], allowMask, denyEveryoneMask }) { + const allowDefault = BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY; + const denyDefault = BIT_VIEW_CHANNEL; + + const everyoneDeny = toStringMask(denyEveryoneMask, denyDefault); + const targetAllow = toStringMask(allowMask, allowDefault); + + const overwrites = [ + { + id: guildId, + type: 0, + allow: "0", + deny: everyoneDeny, + }, + ]; + + for (const roleId of allowedRoleIds) { + overwrites.push({ id: roleId, type: 0, allow: targetAllow, deny: "0" }); + } + for (const userId of allowedUserIds) { + overwrites.push({ id: userId, type: 1, allow: targetAllow, deny: "0" }); + } + + return overwrites; +} + +async function actionChannelPrivateCreate(body) { + const guildId = String(body.guildId || "").trim(); + const name = String(body.name || "").trim(); + if (!guildId) throw Object.assign(new Error("guildId is required"), { status: 400 }); + if (!name) throw Object.assign(new Error("name is required"), { status: 400 }); + + const payload = { + name, + type: Number.isInteger(body.type) ? body.type : 0, + parent_id: body.parentId || undefined, + topic: body.topic || undefined, + position: Number.isInteger(body.position) ? body.position : undefined, + nsfw: typeof body.nsfw === "boolean" ? body.nsfw : undefined, + permission_overwrites: buildPrivateOverwrites({ + guildId, + allowedUserIds: Array.isArray(body.allowedUserIds) ? body.allowedUserIds.map(String) : [], + allowedRoleIds: Array.isArray(body.allowedRoleIds) ? body.allowedRoleIds.map(String) : [], + allowMask: body.allowMask, + denyEveryoneMask: body.denyEveryoneMask, + }), + }; + + const channel = await discordRequest(`/guilds/${guildId}/channels`, { + method: "POST", + body: JSON.stringify(payload), + }); + + return { ok: true, action: "channel-private-create", channel }; +} + +async function actionMemberList(body) { + const guildId = String(body.guildId || "").trim(); + if (!guildId) throw Object.assign(new Error("guildId is required"), { status: 400 }); + + const limitRaw = Number(body.limit ?? 100); + const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100; + const after = body.after ? String(body.after) : undefined; + + const qs = new URLSearchParams(); + qs.set("limit", String(limit)); + if (after) qs.set("after", after); + + const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`); + return { ok: true, action: "member-list", guildId, count: Array.isArray(members) ? members.length : 0, members }; +} + +async function handleAction(body) { + const action = String(body.action || "").trim(); + if (!action) throw Object.assign(new Error("action is required"), { status: 400 }); + + if (action === "channel-private-create") return await actionChannelPrivateCreate(body); + if (action === "member-list") return await actionMemberList(body); + + throw Object.assign(new Error(`unsupported action: ${action}`), { status: 400 }); +} + +const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/health") { + return sendJson(res, 200, { ok: true, service: "discord-control-api" }); + } + + if (req.method !== "POST" || req.url !== "/v1/discord/action") { + return sendJson(res, 404, { error: "not_found" }); + } + + if (!isAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" }); + + let body = ""; + req.on("data", (chunk) => { + body += chunk; + if (body.length > 2_000_000) req.destroy(); + }); + + req.on("end", async () => { + try { + const parsed = body ? JSON.parse(body) : {}; + const result = await handleAction(parsed); + return sendJson(res, 200, result); + } catch (err) { + return sendJson(res, err?.status || 500, { + error: "request_failed", + message: String(err?.message || err), + details: err?.details, + }); + } + }); +}); + +server.listen(port, () => { + console.log(`[discord-control-api] listening on :${port}`); +}); diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md new file mode 100644 index 0000000..3d5f376 --- /dev/null +++ b/docs/DISCORD_CONTROL.md @@ -0,0 +1,85 @@ +# Discord Control API + +目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力: + +1. 创建指定名单可见的私人频道 +2. 查看 server 成员列表(分页) + +## Start + +```bash +cd discord-control-api +export DISCORD_BOT_TOKEN='xxx' +# optional +# export AUTH_TOKEN='strong-token' +node server.mjs +``` + +Health: + +```bash +curl -sS http://127.0.0.1:8790/health +``` + +## Unified action endpoint + +`POST /v1/discord/action` + +- Header: `Authorization: Bearer `(若配置) +- Body: `{ "action": "...", ... }` + +--- + +## Action: channel-private-create + +与 OpenClaw `channel-create` 参数保持风格一致,并增加私密覆盖参数。 + +### Request + +```json +{ + "action": "channel-private-create", + "guildId": "123", + "name": "private-room", + "type": 0, + "parentId": "456", + "topic": "secret", + "position": 3, + "nsfw": false, + "allowedUserIds": ["111", "222"], + "allowedRoleIds": ["333"], + "allowMask": "67648", + "denyEveryoneMask": "1024" +} +``` + +说明: +- 默认 deny `@everyone` 的 `VIEW_CHANNEL`。 +- 默认给 allowed targets 放行:`VIEW_CHANNEL + SEND_MESSAGES + READ_MESSAGE_HISTORY`。 +- `allowMask/denyEveryoneMask` 使用 Discord permission bit string。 + +--- + +## Action: member-list + +### Request + +```json +{ + "action": "member-list", + "guildId": "123", + "limit": 100, + "after": "0" +} +``` + +说明: +- `limit` 1~1000 +- `after` 用于分页(Discord snowflake) + +--- + +## Notes + +- 这不是 bot 自提权工具;bot 仍需由管理员授予足够权限。 +- 若无权限,Discord API 会返回 403 并原样透出错误信息。 -- 2.49.1 From 4bec5982a59122b38726f5ab4a762bb74d92ab1c Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 21:57:39 +0000 Subject: [PATCH 24/27] feat(discord-control): align auth with token/allowlist/action-gate and add dryRun --- discord-control-api/server.mjs | 104 ++++++++++++++++++++++++++------- docs/DISCORD_CONTROL.md | 24 ++++++-- 2 files changed, 104 insertions(+), 24 deletions(-) diff --git a/discord-control-api/server.mjs b/discord-control-api/server.mjs index 4f82311..92ccc99 100644 --- a/discord-control-api/server.mjs +++ b/discord-control-api/server.mjs @@ -2,9 +2,29 @@ import http from "node:http"; const port = Number(process.env.PORT || 8790); const authToken = process.env.AUTH_TOKEN || ""; +const requireAuthToken = String(process.env.REQUIRE_AUTH_TOKEN || "false").toLowerCase() === "true"; const discordToken = process.env.DISCORD_BOT_TOKEN || ""; const discordBase = "https://discord.com/api/v10"; +const enabledActions = { + channelPrivateCreate: String(process.env.ENABLE_CHANNEL_PRIVATE_CREATE || "true").toLowerCase() !== "false", + memberList: String(process.env.ENABLE_MEMBER_LIST || "true").toLowerCase() !== "false", +}; + +const allowedGuildIds = new Set( + String(process.env.ALLOWED_GUILD_IDS || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), +); + +const allowedCallerIds = new Set( + String(process.env.ALLOWED_CALLER_IDS || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), +); + const BIT_VIEW_CHANNEL = 1024n; const BIT_SEND_MESSAGES = 2048n; const BIT_READ_MESSAGE_HISTORY = 65536n; @@ -14,17 +34,52 @@ function sendJson(res, status, payload) { res.end(JSON.stringify(payload)); } -function isAuthorized(req) { - if (!authToken) return true; +function fail(status, code, message, details) { + return { status, code, message, details }; +} + +function readCallerId(req) { + const v = req.headers["x-openclaw-caller-id"] || req.headers["x-caller-id"]; + return typeof v === "string" ? v.trim() : ""; +} + +function ensureControlAuth(req) { + if (requireAuthToken && !authToken) { + throw fail(500, "auth_misconfigured", "REQUIRE_AUTH_TOKEN=true but AUTH_TOKEN is empty"); + } + if (!authToken) return; const header = req.headers.authorization || ""; - return header === `Bearer ${authToken}`; + if (header !== `Bearer ${authToken}`) { + throw fail(401, "unauthorized", "invalid or missing bearer token"); + } + + if (allowedCallerIds.size > 0) { + const callerId = readCallerId(req); + if (!callerId || !allowedCallerIds.has(callerId)) { + throw fail(403, "caller_forbidden", "caller is not in ALLOWED_CALLER_IDS", { callerId: callerId || null }); + } + } } function ensureDiscordToken() { if (!discordToken) { - const e = new Error("missing DISCORD_BOT_TOKEN"); - e.status = 500; - throw e; + throw fail(500, "discord_token_missing", "missing DISCORD_BOT_TOKEN"); + } +} + +function ensureGuildAllowed(guildId) { + if (allowedGuildIds.size === 0) return; + if (!allowedGuildIds.has(guildId)) { + throw fail(403, "guild_forbidden", "guild is not in ALLOWED_GUILD_IDS", { guildId }); + } +} + +function ensureActionEnabled(action) { + if (action === "channel-private-create" && !enabledActions.channelPrivateCreate) { + throw fail(403, "action_disabled", "channel-private-create is disabled"); + } + if (action === "member-list" && !enabledActions.memberList) { + throw fail(403, "action_disabled", "member-list is disabled"); } } @@ -44,10 +99,7 @@ async function discordRequest(path, init = {}) { } catch {} if (!r.ok) { - const e = new Error(`discord_api_error ${r.status}`); - e.status = r.status; - e.details = data; - throw e; + throw fail(r.status, "discord_api_error", `discord api returned ${r.status}`, data); } return data; } @@ -57,7 +109,7 @@ function toStringMask(v, fallback) { if (typeof v === "string") return v; if (typeof v === "number") return String(Math.floor(v)); if (typeof v === "bigint") return String(v); - throw new Error("invalid mask"); + throw fail(400, "invalid_mask", "invalid permission bit mask"); } function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = [], allowMask, denyEveryoneMask }) { @@ -89,8 +141,9 @@ function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = async function actionChannelPrivateCreate(body) { const guildId = String(body.guildId || "").trim(); const name = String(body.name || "").trim(); - if (!guildId) throw Object.assign(new Error("guildId is required"), { status: 400 }); - if (!name) throw Object.assign(new Error("name is required"), { status: 400 }); + if (!guildId) throw fail(400, "bad_request", "guildId is required"); + if (!name) throw fail(400, "bad_request", "name is required"); + ensureGuildAllowed(guildId); const payload = { name, @@ -108,6 +161,10 @@ async function actionChannelPrivateCreate(body) { }), }; + if (body.dryRun === true) { + return { ok: true, action: "channel-private-create", dryRun: true, payload }; + } + const channel = await discordRequest(`/guilds/${guildId}/channels`, { method: "POST", body: JSON.stringify(payload), @@ -118,7 +175,8 @@ async function actionChannelPrivateCreate(body) { async function actionMemberList(body) { const guildId = String(body.guildId || "").trim(); - if (!guildId) throw Object.assign(new Error("guildId is required"), { status: 400 }); + if (!guildId) throw fail(400, "bad_request", "guildId is required"); + ensureGuildAllowed(guildId); const limitRaw = Number(body.limit ?? 100); const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100; @@ -134,25 +192,30 @@ async function actionMemberList(body) { async function handleAction(body) { const action = String(body.action || "").trim(); - if (!action) throw Object.assign(new Error("action is required"), { status: 400 }); + if (!action) throw fail(400, "bad_request", "action is required"); + ensureActionEnabled(action); if (action === "channel-private-create") return await actionChannelPrivateCreate(body); if (action === "member-list") return await actionMemberList(body); - throw Object.assign(new Error(`unsupported action: ${action}`), { status: 400 }); + throw fail(400, "unsupported_action", `unsupported action: ${action}`); } const server = http.createServer((req, res) => { if (req.method === "GET" && req.url === "/health") { - return sendJson(res, 200, { ok: true, service: "discord-control-api" }); + return sendJson(res, 200, { + ok: true, + service: "discord-control-api", + authRequired: !!authToken || requireAuthToken, + actionGates: enabledActions, + guildAllowlistEnabled: allowedGuildIds.size > 0, + }); } if (req.method !== "POST" || req.url !== "/v1/discord/action") { return sendJson(res, 404, { error: "not_found" }); } - if (!isAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" }); - let body = ""; req.on("data", (chunk) => { body += chunk; @@ -161,12 +224,13 @@ const server = http.createServer((req, res) => { req.on("end", async () => { try { + ensureControlAuth(req); const parsed = body ? JSON.parse(body) : {}; const result = await handleAction(parsed); return sendJson(res, 200, result); } catch (err) { return sendJson(res, err?.status || 500, { - error: "request_failed", + error: err?.code || "request_failed", message: String(err?.message || err), details: err?.details, }); diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md index 3d5f376..0a506ca 100644 --- a/docs/DISCORD_CONTROL.md +++ b/docs/DISCORD_CONTROL.md @@ -10,8 +10,16 @@ ```bash cd discord-control-api export DISCORD_BOT_TOKEN='xxx' -# optional -# export AUTH_TOKEN='strong-token' +# 建议启用 +export AUTH_TOKEN='strong-token' +# optional hard requirement +# export REQUIRE_AUTH_TOKEN=true +# optional action gates +# export ENABLE_CHANNEL_PRIVATE_CREATE=true +# export ENABLE_MEMBER_LIST=true +# optional allowlist +# export ALLOWED_GUILD_IDS='123,456' +# export ALLOWED_CALLER_IDS='agent-main,agent-admin' node server.mjs ``` @@ -26,6 +34,7 @@ curl -sS http://127.0.0.1:8790/health `POST /v1/discord/action` - Header: `Authorization: Bearer `(若配置) +- Header: `X-OpenClaw-Caller-Id: `(若配置了 `ALLOWED_CALLER_IDS`) - Body: `{ "action": "...", ... }` --- @@ -49,7 +58,8 @@ curl -sS http://127.0.0.1:8790/health "allowedUserIds": ["111", "222"], "allowedRoleIds": ["333"], "allowMask": "67648", - "denyEveryoneMask": "1024" + "denyEveryoneMask": "1024", + "dryRun": false } ``` @@ -81,5 +91,11 @@ curl -sS http://127.0.0.1:8790/health ## Notes +鉴权与内置风格对齐(简化版): +- 控制面 token:`AUTH_TOKEN` / `REQUIRE_AUTH_TOKEN` +- 调用者 allowlist:`ALLOWED_CALLER_IDS`(配合 `X-OpenClaw-Caller-Id`) +- action gate:`ENABLE_CHANNEL_PRIVATE_CREATE` / `ENABLE_MEMBER_LIST` +- guild allowlist:`ALLOWED_GUILD_IDS` + - 这不是 bot 自提权工具;bot 仍需由管理员授予足够权限。 -- 若无权限,Discord API 会返回 403 并原样透出错误信息。 +- 若无权限,Discord API 会返回 403 并透传错误细节。 -- 2.49.1 From f20728b02deb1efd77a864ab5d7fccfbbe9c77da Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 21:59:51 +0000 Subject: [PATCH 25/27] feat(discord-control): add channel-private-update and member-list field projection --- CHANGELOG.md | 3 +- discord-control-api/server.mjs | 106 ++++++++++++++++++++++++++++++++- docs/DISCORD_CONTROL.md | 32 +++++++++- 3 files changed, 138 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67b617e..92b3321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,4 +11,5 @@ - Added no-touch config rendering and integration docs - Added discord-control-api with: - `channel-private-create` (create private channel for allowlist) - - `member-list` (guild members list with pagination) + - `channel-private-update` (update allowlist/overwrites for existing channel) + - `member-list` (guild members list with pagination + optional field projection) diff --git a/discord-control-api/server.mjs b/discord-control-api/server.mjs index 92ccc99..8bd582b 100644 --- a/discord-control-api/server.mjs +++ b/discord-control-api/server.mjs @@ -8,6 +8,7 @@ const discordBase = "https://discord.com/api/v10"; const enabledActions = { channelPrivateCreate: String(process.env.ENABLE_CHANNEL_PRIVATE_CREATE || "true").toLowerCase() !== "false", + channelPrivateUpdate: String(process.env.ENABLE_CHANNEL_PRIVATE_UPDATE || "true").toLowerCase() !== "false", memberList: String(process.env.ENABLE_MEMBER_LIST || "true").toLowerCase() !== "false", }; @@ -78,6 +79,9 @@ function ensureActionEnabled(action) { if (action === "channel-private-create" && !enabledActions.channelPrivateCreate) { throw fail(403, "action_disabled", "channel-private-create is disabled"); } + if (action === "channel-private-update" && !enabledActions.channelPrivateUpdate) { + throw fail(403, "action_disabled", "channel-private-update is disabled"); + } if (action === "member-list" && !enabledActions.memberList) { throw fail(403, "action_disabled", "member-list is disabled"); } @@ -138,6 +142,34 @@ function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = return overwrites; } +function parseFieldList(input) { + if (Array.isArray(input)) return input.map((x) => String(x).trim()).filter(Boolean); + if (typeof input === "string") return input.split(",").map((x) => x.trim()).filter(Boolean); + return []; +} + +function pick(obj, keys) { + if (!obj || typeof obj !== "object") return obj; + const out = {}; + for (const k of keys) { + if (k in obj) out[k] = obj[k]; + } + return out; +} + +function projectMember(member, fields) { + if (!fields.length) return member; + const base = pick(member, fields); + if (fields.some((f) => f.startsWith("user.")) && member?.user) { + const userFields = fields + .filter((f) => f.startsWith("user.")) + .map((f) => f.slice(5)) + .filter(Boolean); + base.user = pick(member.user, userFields); + } + return base; +} + async function actionChannelPrivateCreate(body) { const guildId = String(body.guildId || "").trim(); const name = String(body.name || "").trim(); @@ -173,6 +205,67 @@ async function actionChannelPrivateCreate(body) { return { ok: true, action: "channel-private-create", channel }; } +async function actionChannelPrivateUpdate(body) { + const guildId = String(body.guildId || "").trim(); + const channelId = String(body.channelId || "").trim(); + if (!guildId) throw fail(400, "bad_request", "guildId is required"); + if (!channelId) throw fail(400, "bad_request", "channelId is required"); + ensureGuildAllowed(guildId); + + const allowMask = toStringMask(body.allowMask, BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY); + const denyMask = toStringMask(body.denyMask, 0n); + const mode = String(body.mode || "merge").trim(); + + const addUserIds = Array.isArray(body.addUserIds) ? body.addUserIds.map(String) : []; + const addRoleIds = Array.isArray(body.addRoleIds) ? body.addRoleIds.map(String) : []; + const removeTargetIds = Array.isArray(body.removeTargetIds) ? body.removeTargetIds.map(String) : []; + + const existing = await discordRequest(`/channels/${channelId}`); + const existingOverwrites = Array.isArray(existing.permission_overwrites) ? existing.permission_overwrites : []; + + let next = []; + if (mode === "replace") { + // keep @everyone deny if present, otherwise set one + const everyone = existingOverwrites.find((o) => String(o.id) === guildId && Number(o.type) === 0) || { + id: guildId, + type: 0, + allow: "0", + deny: String(BIT_VIEW_CHANNEL), + }; + next.push({ id: String(everyone.id), type: 0, allow: String(everyone.allow || "0"), deny: String(everyone.deny || BIT_VIEW_CHANNEL) }); + } else { + next = existingOverwrites.map((o) => ({ id: String(o.id), type: Number(o.type) === 1 ? 1 : 0, allow: String(o.allow || "0"), deny: String(o.deny || "0") })); + } + + const removeSet = new Set(removeTargetIds); + if (removeSet.size > 0) { + next = next.filter((o) => !removeSet.has(String(o.id))); + } + + const upsert = (id, type) => { + const idx = next.findIndex((o) => String(o.id) === String(id)); + const row = { id: String(id), type, allow: allowMask, deny: denyMask }; + if (idx >= 0) next[idx] = row; + else next.push(row); + }; + + for (const userId of addUserIds) upsert(userId, 1); + for (const roleId of addRoleIds) upsert(roleId, 0); + + const payload = { permission_overwrites: next }; + + if (body.dryRun === true) { + return { ok: true, action: "channel-private-update", dryRun: true, payload, mode }; + } + + const channel = await discordRequest(`/channels/${channelId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); + + return { ok: true, action: "channel-private-update", mode, channel }; +} + async function actionMemberList(body) { const guildId = String(body.guildId || "").trim(); if (!guildId) throw fail(400, "bad_request", "guildId is required"); @@ -181,13 +274,23 @@ async function actionMemberList(body) { const limitRaw = Number(body.limit ?? 100); const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100; const after = body.after ? String(body.after) : undefined; + const fields = parseFieldList(body.fields); const qs = new URLSearchParams(); qs.set("limit", String(limit)); if (after) qs.set("after", after); const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`); - return { ok: true, action: "member-list", guildId, count: Array.isArray(members) ? members.length : 0, members }; + const projected = Array.isArray(members) ? members.map((m) => projectMember(m, fields)) : members; + + return { + ok: true, + action: "member-list", + guildId, + count: Array.isArray(projected) ? projected.length : 0, + fields: fields.length ? fields : undefined, + members: projected, + }; } async function handleAction(body) { @@ -196,6 +299,7 @@ async function handleAction(body) { ensureActionEnabled(action); if (action === "channel-private-create") return await actionChannelPrivateCreate(body); + if (action === "channel-private-update") return await actionChannelPrivateUpdate(body); if (action === "member-list") return await actionMemberList(body); throw fail(400, "unsupported_action", `unsupported action: ${action}`); diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md index 0a506ca..732926e 100644 --- a/docs/DISCORD_CONTROL.md +++ b/docs/DISCORD_CONTROL.md @@ -16,6 +16,7 @@ export AUTH_TOKEN='strong-token' # export REQUIRE_AUTH_TOKEN=true # optional action gates # export ENABLE_CHANNEL_PRIVATE_CREATE=true +# export ENABLE_CHANNEL_PRIVATE_UPDATE=true # export ENABLE_MEMBER_LIST=true # optional allowlist # export ALLOWED_GUILD_IDS='123,456' @@ -70,6 +71,33 @@ curl -sS http://127.0.0.1:8790/health --- +## Action: channel-private-update + +对现有频道的白名单/覆盖权限做增删改。 + +### Request + +```json +{ + "action": "channel-private-update", + "guildId": "123", + "channelId": "789", + "mode": "merge", + "addUserIds": ["111"], + "addRoleIds": ["333"], + "removeTargetIds": ["222"], + "allowMask": "67648", + "denyMask": "0", + "dryRun": false +} +``` + +说明: +- `mode=merge`:在现有覆盖基础上增删 +- `mode=replace`:重建覆盖(保留/补上 @everyone deny) + +--- + ## Action: member-list ### Request @@ -79,13 +107,15 @@ curl -sS http://127.0.0.1:8790/health "action": "member-list", "guildId": "123", "limit": 100, - "after": "0" + "after": "0", + "fields": ["user.id", "user.username", "nick", "roles", "joined_at"] } ``` 说明: - `limit` 1~1000 - `after` 用于分页(Discord snowflake) +- `fields` 可选:字段裁剪,减小返回体;可用 `user.xxx` 选子字段 --- -- 2.49.1 From 8097ab748400534a09c0d0aebc9dc2ff9560b668 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 22:02:00 +0000 Subject: [PATCH 26/27] docs(discord-control): add runnable examples and smoke script --- Makefile | 5 ++- README.md | 2 +- docs/DISCORD_CONTROL.md | 12 ++++++ docs/EXAMPLES.discord-control.json | 68 ++++++++++++++++++++++++++++++ scripts/smoke-discord-control.sh | 52 +++++++++++++++++++++++ 5 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 docs/EXAMPLES.discord-control.json create mode 100755 scripts/smoke-discord-control.sh diff --git a/Makefile b/Makefile index e24f0a7..5f3893c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: check check-rules test-api up down smoke render-config package-plugin discord-control-up +.PHONY: check check-rules test-api up down smoke render-config package-plugin discord-control-up smoke-discord-control check: cd plugin && npm run check @@ -26,3 +26,6 @@ package-plugin: discord-control-up: cd discord-control-api && node server.mjs + +smoke-discord-control: + ./scripts/smoke-discord-control.sh diff --git a/README.md b/README.md index 68d36cf..687c3fe 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The no-reply provider returns `NO_REPLY` for any input. - `discord-control-api/` — Discord 管理扩展 API(私密频道 + 成员列表) - `docs/` — rollout, integration, run-mode notes - `scripts/` — smoke/dev/helper checks -- `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make up`) +- `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make smoke-discord-control`, `make up`) - `CHANGELOG.md` — milestone summary ## Quick start (no Docker) diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md index 732926e..aba5993 100644 --- a/docs/DISCORD_CONTROL.md +++ b/docs/DISCORD_CONTROL.md @@ -119,6 +119,18 @@ curl -sS http://127.0.0.1:8790/health --- +## Curl examples + +示例文件:`docs/EXAMPLES.discord-control.json` + +快速检查脚本: + +```bash +./scripts/smoke-discord-control.sh +# with env +# AUTH_TOKEN=xxx CALLER_ID=agent-main GUILD_ID=123 CHANNEL_ID=456 ./scripts/smoke-discord-control.sh +``` + ## Notes 鉴权与内置风格对齐(简化版): diff --git a/docs/EXAMPLES.discord-control.json b/docs/EXAMPLES.discord-control.json new file mode 100644 index 0000000..1943979 --- /dev/null +++ b/docs/EXAMPLES.discord-control.json @@ -0,0 +1,68 @@ +{ + "channel-private-create": { + "action": "channel-private-create", + "guildId": "123456789012345678", + "name": "ops-private", + "type": 0, + "parentId": "234567890123456789", + "topic": "ops only", + "position": 3, + "nsfw": false, + "allowedUserIds": [ + "345678901234567890", + "456789012345678901" + ], + "allowedRoleIds": [ + "567890123456789012" + ], + "allowMask": "67648", + "denyEveryoneMask": "1024", + "dryRun": true + }, + "channel-private-update-merge": { + "action": "channel-private-update", + "guildId": "123456789012345678", + "channelId": "678901234567890123", + "mode": "merge", + "addUserIds": [ + "345678901234567890" + ], + "addRoleIds": [ + "567890123456789012" + ], + "removeTargetIds": [ + "456789012345678901" + ], + "allowMask": "67648", + "denyMask": "0", + "dryRun": true + }, + "channel-private-update-replace": { + "action": "channel-private-update", + "guildId": "123456789012345678", + "channelId": "678901234567890123", + "mode": "replace", + "addUserIds": [ + "345678901234567890" + ], + "addRoleIds": [ + "567890123456789012" + ], + "allowMask": "67648", + "denyMask": "0", + "dryRun": true + }, + "member-list": { + "action": "member-list", + "guildId": "123456789012345678", + "limit": 100, + "after": "0", + "fields": [ + "user.id", + "user.username", + "nick", + "roles", + "joined_at" + ] + } +} diff --git a/scripts/smoke-discord-control.sh b/scripts/smoke-discord-control.sh new file mode 100755 index 0000000..613be5c --- /dev/null +++ b/scripts/smoke-discord-control.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://127.0.0.1:8790}" +AUTH_TOKEN="${AUTH_TOKEN:-}" +CALLER_ID="${CALLER_ID:-}" + +AUTH_HEADER=() +if [[ -n "$AUTH_TOKEN" ]]; then + AUTH_HEADER=(-H "Authorization: Bearer ${AUTH_TOKEN}") +fi + +CALLER_HEADER=() +if [[ -n "$CALLER_ID" ]]; then + CALLER_HEADER=(-H "X-OpenClaw-Caller-Id: ${CALLER_ID}") +fi + +echo "[1] health" +curl -sS "${BASE_URL}/health" | sed -n '1,20p' + +if [[ -z "${GUILD_ID:-}" ]]; then + echo "skip action checks: set GUILD_ID (and optional CHANNEL_ID) to run dryRun actions" + exit 0 +fi + +echo "[2] dry-run private create" +curl -sS -X POST "${BASE_URL}/v1/discord/action" \ + -H 'Content-Type: application/json' \ + "${AUTH_HEADER[@]}" \ + "${CALLER_HEADER[@]}" \ + -d "{\"action\":\"channel-private-create\",\"guildId\":\"${GUILD_ID}\",\"name\":\"wg-dryrun\",\"dryRun\":true}" \ + | sed -n '1,80p' + +if [[ -n "${CHANNEL_ID:-}" ]]; then + echo "[3] dry-run private update" + curl -sS -X POST "${BASE_URL}/v1/discord/action" \ + -H 'Content-Type: application/json' \ + "${AUTH_HEADER[@]}" \ + "${CALLER_HEADER[@]}" \ + -d "{\"action\":\"channel-private-update\",\"guildId\":\"${GUILD_ID}\",\"channelId\":\"${CHANNEL_ID}\",\"mode\":\"merge\",\"dryRun\":true}" \ + | sed -n '1,100p' +fi + +echo "[4] member-list (limit=1)" +curl -sS -X POST "${BASE_URL}/v1/discord/action" \ + -H 'Content-Type: application/json' \ + "${AUTH_HEADER[@]}" \ + "${CALLER_HEADER[@]}" \ + -d "{\"action\":\"member-list\",\"guildId\":\"${GUILD_ID}\",\"limit\":1,\"fields\":[\"user.id\",\"user.username\"]}" \ + | sed -n '1,120p' + +echo "smoke-discord-control: done" -- 2.49.1 From a2f88cfe0f82971b8434feeea6dcf3130d4d55f9 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 22:05:22 +0000 Subject: [PATCH 27/27] chore(security): add guardrails and PR merge summary docs --- CHANGELOG.md | 1 + discord-control-api/server.mjs | 53 ++++++++++++++++++++++++++++---- docs/DISCORD_CONTROL.md | 4 +++ docs/PR_SUMMARY.md | 55 ++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 docs/PR_SUMMARY.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 92b3321..16e9224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,4 @@ - `channel-private-create` (create private channel for allowlist) - `channel-private-update` (update allowlist/overwrites for existing channel) - `member-list` (guild members list with pagination + optional field projection) + - guardrails: action mode validation, id-list limits, response-size limit diff --git a/discord-control-api/server.mjs b/discord-control-api/server.mjs index 8bd582b..27a21db 100644 --- a/discord-control-api/server.mjs +++ b/discord-control-api/server.mjs @@ -30,6 +30,10 @@ const BIT_VIEW_CHANNEL = 1024n; const BIT_SEND_MESSAGES = 2048n; const BIT_READ_MESSAGE_HISTORY = 65536n; +const MAX_MEMBER_FIELDS = Math.max(1, Number(process.env.MAX_MEMBER_FIELDS || 20)); +const MAX_MEMBER_RESPONSE_BYTES = Math.max(2048, Number(process.env.MAX_MEMBER_RESPONSE_BYTES || 500000)); +const MAX_PRIVATE_MUTATION_TARGETS = Math.max(1, Number(process.env.MAX_PRIVATE_MUTATION_TARGETS || 200)); + function sendJson(res, status, payload) { res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(payload)); @@ -148,6 +152,18 @@ function parseFieldList(input) { return []; } +function normalizeIdList(value, label) { + const arr = Array.isArray(value) ? value.map(String).map((v) => v.trim()).filter(Boolean) : []; + if (arr.length > MAX_PRIVATE_MUTATION_TARGETS) { + throw fail(400, "bad_request", `${label} exceeds MAX_PRIVATE_MUTATION_TARGETS`, { + label, + limit: MAX_PRIVATE_MUTATION_TARGETS, + size: arr.length, + }); + } + return arr; +} + function pick(obj, keys) { if (!obj || typeof obj !== "object") return obj; const out = {}; @@ -186,8 +202,8 @@ async function actionChannelPrivateCreate(body) { nsfw: typeof body.nsfw === "boolean" ? body.nsfw : undefined, permission_overwrites: buildPrivateOverwrites({ guildId, - allowedUserIds: Array.isArray(body.allowedUserIds) ? body.allowedUserIds.map(String) : [], - allowedRoleIds: Array.isArray(body.allowedRoleIds) ? body.allowedRoleIds.map(String) : [], + allowedUserIds: normalizeIdList(body.allowedUserIds, "allowedUserIds"), + allowedRoleIds: normalizeIdList(body.allowedRoleIds, "allowedRoleIds"), allowMask: body.allowMask, denyEveryoneMask: body.denyEveryoneMask, }), @@ -215,10 +231,13 @@ async function actionChannelPrivateUpdate(body) { const allowMask = toStringMask(body.allowMask, BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY); const denyMask = toStringMask(body.denyMask, 0n); const mode = String(body.mode || "merge").trim(); + if (mode !== "merge" && mode !== "replace") { + throw fail(400, "bad_request", "mode must be merge or replace", { mode }); + } - const addUserIds = Array.isArray(body.addUserIds) ? body.addUserIds.map(String) : []; - const addRoleIds = Array.isArray(body.addRoleIds) ? body.addRoleIds.map(String) : []; - const removeTargetIds = Array.isArray(body.removeTargetIds) ? body.removeTargetIds.map(String) : []; + const addUserIds = normalizeIdList(body.addUserIds, "addUserIds"); + const addRoleIds = normalizeIdList(body.addRoleIds, "addRoleIds"); + const removeTargetIds = normalizeIdList(body.removeTargetIds, "removeTargetIds"); const existing = await discordRequest(`/channels/${channelId}`); const existingOverwrites = Array.isArray(existing.permission_overwrites) ? existing.permission_overwrites : []; @@ -275,6 +294,12 @@ async function actionMemberList(body) { const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100; const after = body.after ? String(body.after) : undefined; const fields = parseFieldList(body.fields); + if (fields.length > MAX_MEMBER_FIELDS) { + throw fail(400, "bad_request", "fields exceeds MAX_MEMBER_FIELDS", { + limit: MAX_MEMBER_FIELDS, + size: fields.length, + }); + } const qs = new URLSearchParams(); qs.set("limit", String(limit)); @@ -283,7 +308,7 @@ async function actionMemberList(body) { const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`); const projected = Array.isArray(members) ? members.map((m) => projectMember(m, fields)) : members; - return { + const response = { ok: true, action: "member-list", guildId, @@ -291,6 +316,17 @@ async function actionMemberList(body) { fields: fields.length ? fields : undefined, members: projected, }; + + const bytes = Buffer.byteLength(JSON.stringify(response), "utf8"); + if (bytes > MAX_MEMBER_RESPONSE_BYTES) { + throw fail(413, "response_too_large", "member-list response exceeds MAX_MEMBER_RESPONSE_BYTES", { + bytes, + limit: MAX_MEMBER_RESPONSE_BYTES, + hint: "reduce limit or set fields projection", + }); + } + + return response; } async function handleAction(body) { @@ -313,6 +349,11 @@ const server = http.createServer((req, res) => { authRequired: !!authToken || requireAuthToken, actionGates: enabledActions, guildAllowlistEnabled: allowedGuildIds.size > 0, + limits: { + maxMemberFields: MAX_MEMBER_FIELDS, + maxMemberResponseBytes: MAX_MEMBER_RESPONSE_BYTES, + maxPrivateMutationTargets: MAX_PRIVATE_MUTATION_TARGETS, + }, }); } diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md index aba5993..58daa3a 100644 --- a/docs/DISCORD_CONTROL.md +++ b/docs/DISCORD_CONTROL.md @@ -21,6 +21,10 @@ export AUTH_TOKEN='strong-token' # optional allowlist # export ALLOWED_GUILD_IDS='123,456' # export ALLOWED_CALLER_IDS='agent-main,agent-admin' +# optional limits +# export MAX_MEMBER_FIELDS=20 +# export MAX_MEMBER_RESPONSE_BYTES=500000 +# export MAX_PRIVATE_MUTATION_TARGETS=200 node server.mjs ``` diff --git a/docs/PR_SUMMARY.md b/docs/PR_SUMMARY.md new file mode 100644 index 0000000..380e0a8 --- /dev/null +++ b/docs/PR_SUMMARY.md @@ -0,0 +1,55 @@ +# PR Summary (WhisperGate + Discord Control) + +## Scope + +This PR delivers two tracks: + +1. WhisperGate deterministic no-reply gate for Discord sessions +2. Discord control extension API for private-channel/member-list gaps + +## Delivered Features + +### WhisperGate + +- Deterministic rule chain: + 1) non-discord => skip + 2) bypass sender => skip + 3) ending symbol matched => skip + 4) else => no-reply provider/model override +- `NO_REPLY` backend API (`/v1/chat/completions`, `/v1/responses`, `/v1/models`) +- Optional API bearer auth (`AUTH_TOKEN`) +- Prompt prepend on bypass/end-symbol paths: + - `你的这次发言必须以🔚作为结尾。` +- Rule validation script and fixtures + +### Discord Control API + +- `channel-private-create` +- `channel-private-update` (`merge`/`replace`) +- `member-list` with optional field projection +- Action gate + guild allowlist + caller allowlist + bearer auth +- Dry-run support for channel private actions + +## Runtime Mode + +- No-Docker-first +- Run directly with Node.js + +## Security Defaults (recommended) + +- Set `AUTH_TOKEN` +- Set `REQUIRE_AUTH_TOKEN=true` +- Use `ALLOWED_GUILD_IDS` +- Use `ALLOWED_CALLER_IDS` +- Keep Discord bot token in env only (`DISCORD_BOT_TOKEN`) + +## Known Limits + +- This repo cannot elevate bot privileges; Discord admin permissions still govern all actions. +- `member-list` depends on Discord API permission/intents availability. + +## Rollback + +- Disable plugin entry or remove plugin path from OpenClaw config +- Stop `discord-control-api` process +- Keep no-reply API stopped if not needed -- 2.49.1