diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd7d79d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +.DS_Store +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..16e9224 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# 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 +- Added discord-control-api with: + - `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/Makefile b/Makefile new file mode 100644 index 0000000..5f3893c --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.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 + +check-rules: + node scripts/validate-rules.mjs + +test-api: + node scripts/test-no-reply-api.mjs + +up: + ./scripts/dev-up.sh + +down: + ./scripts/dev-down.sh + +smoke: + ./scripts/smoke-no-reply-api.sh + +render-config: + node scripts/render-openclaw-config.mjs + +package-plugin: + node scripts/package-plugin.mjs + +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 e527f09..687c3fe 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,59 @@ 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` +- `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 smoke-discord-control`, `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. + +Discord 扩展能力见:`docs/DISCORD_CONTROL.md`。 + +--- + +## Development plan (incremental commits) + +- [x] Task 1: project docs + structure +- [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 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..27a21db --- /dev/null +++ b/discord-control-api/server.mjs @@ -0,0 +1,388 @@ +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", + channelPrivateUpdate: String(process.env.ENABLE_CHANNEL_PRIVATE_UPDATE || "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; + +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)); +} + +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 || ""; + 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) { + 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 === "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"); + } +} + +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) { + throw fail(r.status, "discord_api_error", `discord api returned ${r.status}`, data); + } + 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 fail(400, "invalid_mask", "invalid permission bit 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; +} + +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 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 = {}; + 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(); + 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, + 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: normalizeIdList(body.allowedUserIds, "allowedUserIds"), + allowedRoleIds: normalizeIdList(body.allowedRoleIds, "allowedRoleIds"), + allowMask: body.allowMask, + denyEveryoneMask: body.denyEveryoneMask, + }), + }; + + 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), + }); + + 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(); + if (mode !== "merge" && mode !== "replace") { + throw fail(400, "bad_request", "mode must be merge or replace", { mode }); + } + + 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 : []; + + 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"); + ensureGuildAllowed(guildId); + + 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); + 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)); + if (after) qs.set("after", after); + + const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`); + const projected = Array.isArray(members) ? members.map((m) => projectMember(m, fields)) : members; + + const response = { + ok: true, + action: "member-list", + guildId, + count: Array.isArray(projected) ? projected.length : 0, + 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) { + const action = String(body.action || "").trim(); + if (!action) throw fail(400, "bad_request", "action is required"); + 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}`); +} + +const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/health") { + return sendJson(res, 200, { + ok: true, + service: "discord-control-api", + authRequired: !!authToken || requireAuthToken, + actionGates: enabledActions, + guildAllowlistEnabled: allowedGuildIds.size > 0, + limits: { + maxMemberFields: MAX_MEMBER_FIELDS, + maxMemberResponseBytes: MAX_MEMBER_RESPONSE_BYTES, + maxPrivateMutationTargets: MAX_PRIVATE_MUTATION_TARGETS, + }, + }); + } + + if (req.method !== "POST" || req.url !== "/v1/discord/action") { + return sendJson(res, 404, { error: "not_found" }); + } + + let body = ""; + req.on("data", (chunk) => { + body += chunk; + if (body.length > 2_000_000) req.destroy(); + }); + + 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: err?.code || "request_failed", + message: String(err?.message || err), + details: err?.details, + }); + } + }); +}); + +server.listen(port, () => { + console.log(`[discord-control-api] listening on :${port}`); +}); 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/docs/CONFIG.example.json b/docs/CONFIG.example.json new file mode 100644 index 0000000..4556397 --- /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": "", + "baseURL": "http://127.0.0.1:8787/v1" + } + } + } +} diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md new file mode 100644 index 0000000..58daa3a --- /dev/null +++ b/docs/DISCORD_CONTROL.md @@ -0,0 +1,147 @@ +# Discord Control API + +目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力: + +1. 创建指定名单可见的私人频道 +2. 查看 server 成员列表(分页) + +## Start + +```bash +cd discord-control-api +export DISCORD_BOT_TOKEN='xxx' +# 建议启用 +export AUTH_TOKEN='strong-token' +# optional hard requirement +# 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' +# 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 +``` + +Health: + +```bash +curl -sS http://127.0.0.1:8790/health +``` + +## Unified action endpoint + +`POST /v1/discord/action` + +- Header: `Authorization: Bearer `(若配置) +- Header: `X-OpenClaw-Caller-Id: `(若配置了 `ALLOWED_CALLER_IDS`) +- 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", + "dryRun": false +} +``` + +说明: +- 默认 deny `@everyone` 的 `VIEW_CHANNEL`。 +- 默认给 allowed targets 放行:`VIEW_CHANNEL + SEND_MESSAGES + READ_MESSAGE_HISTORY`。 +- `allowMask/denyEveryoneMask` 使用 Discord permission bit string。 + +--- + +## 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 + +```json +{ + "action": "member-list", + "guildId": "123", + "limit": 100, + "after": "0", + "fields": ["user.id", "user.username", "nick", "roles", "joined_at"] +} +``` + +说明: +- `limit` 1~1000 +- `after` 用于分页(Discord snowflake) +- `fields` 可选:字段裁剪,减小返回体;可用 `user.xxx` 选子字段 + +--- + +## 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 + +鉴权与内置风格对齐(简化版): +- 控制面 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 并透传错误细节。 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/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md new file mode 100644 index 0000000..96e748d --- /dev/null +++ b/docs/IMPLEMENTATION.md @@ -0,0 +1,25 @@ +# 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 + +Additional prompt behavior: +- when decision is `bypass_sender` or `end_symbol:*`, plugin prepends: + - `你的这次发言必须以🔚作为结尾。` + +## 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. 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/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 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/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 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`. diff --git a/docs/VERIFY.md b/docs/VERIFY.md new file mode 100644 index 0000000..52ea5d3 --- /dev/null +++ b/docs/VERIFY.md @@ -0,0 +1,40 @@ +# WhisperGate Quick Verification + +## 1) Start no-reply API + +```bash +cd no-reply-api +# optional: enforce bearer token checks +# export AUTH_TOKEN='replace-with-strong-token' +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"}]}' +``` + +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 + +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 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/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"] 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..f56a1dd --- /dev/null +++ b/no-reply-api/server.mjs @@ -0,0 +1,112 @@ +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()}`, + 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 } + }; +} + +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", 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; + 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}`); +}); diff --git a/plugin/README.md b/plugin/README.md new file mode 100644 index 0000000..2373162 --- /dev/null +++ b/plugin/README.md @@ -0,0 +1,30 @@ +# 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. +- `before_prompt_build` prepends instruction `你的这次发言必须以🔚作为结尾。` when decision is: + - `bypass_sender` + - `end_symbol:*` + +## 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) diff --git a/plugin/index.ts b/plugin/index.ts new file mode 100644 index 0000000..5310078 --- /dev/null +++ b/plugin/index.ts @@ -0,0 +1,131 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { evaluateDecision, type Decision, type WhisperGateConfig } from "./rules.js"; + +type DecisionRecord = { + decision: Decision; + createdAt: number; +}; + +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]; + for (const c of candidates) { + if (typeof c === "string" && c.trim()) return c.trim().toLowerCase(); + } + return ""; +} + +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) { + const k = keys.next(); + if (k.done) break; + sessionDecision.delete(k.value); + } +} + +function shouldInjectEndMarker(reason: string): boolean { + return reason === "bypass_sender" || reason.startsWith("end_symbol:"); +} + +export default { + id: "whispergate", + name: "WhisperGate", + register(api: OpenClawPluginApi) { + const config = (api.pluginConfig || {}) as WhisperGateConfig; + + 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 = normalizeSender(e, c); + const content = typeof e.content === "string" ? e.content : ""; + const channel = normalizeChannel(c); + + const decision = evaluateDecision({ config, channel, senderId, content }); + sessionDecision.set(sessionKey, { decision, createdAt: Date.now() }); + pruneDecisionMap(); + api.logger.debug?.( + `whispergate: session=${sessionKey} sender=${senderId ?? "unknown"} channel=${channel || "unknown"} decision=${decision.reason}`, + ); + } 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 rec = sessionDecision.get(key); + if (!rec) return; + if (Date.now() - rec.createdAt > DECISION_TTL_MS) { + sessionDecision.delete(key); + return; + } + + 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}`, + ); + + return { + providerOverride: config.noReplyProvider, + 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, + }; + }); + }, +}; 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"] + } +} diff --git a/plugin/package.json b/plugin/package.json new file mode 100644 index 0000000..35b00ba --- /dev/null +++ b/plugin/package.json @@ -0,0 +1,11 @@ +{ + "name": "whispergate-plugin", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "WhisperGate OpenClaw plugin", + "scripts": { + "check": "node ../scripts/check-plugin-files.mjs", + "check:rules": "node ../scripts/validate-rules.mjs" + } +} 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" }; +} 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'); 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" 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}`); 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)); 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" 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" 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); +}); 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");