WhisperGate MVP: no-reply API + plugin rule gate #1
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
16
CHANGELOG.md
Normal file
16
CHANGELOG.md
Normal file
@@ -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
|
||||||
31
Makefile
Normal file
31
Makefile
Normal file
@@ -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
|
||||||
57
README.md
57
README.md
@@ -2,6 +2,59 @@
|
|||||||
|
|
||||||
Rule-based no-reply gate for OpenClaw.
|
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
|
||||||
|
|||||||
9
discord-control-api/package.json
Normal file
9
discord-control-api/package.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "whispergate-discord-control-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.mjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
388
discord-control-api/server.mjs
Normal file
388
discord-control-api/server.mjs
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -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
|
||||||
28
docs/CONFIG.example.json
Normal file
28
docs/CONFIG.example.json
Normal file
@@ -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": "<AUTH_TOKEN_OR_PLACEHOLDER>",
|
||||||
|
"baseURL": "http://127.0.0.1:8787/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
147
docs/DISCORD_CONTROL.md
Normal file
147
docs/DISCORD_CONTROL.md
Normal file
@@ -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 <AUTH_TOKEN>`(若配置)
|
||||||
|
- Header: `X-OpenClaw-Caller-Id: <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 并透传错误细节。
|
||||||
68
docs/EXAMPLES.discord-control.json
Normal file
68
docs/EXAMPLES.discord-control.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
25
docs/IMPLEMENTATION.md
Normal file
25
docs/IMPLEMENTATION.md
Normal file
@@ -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.
|
||||||
34
docs/INTEGRATION.md
Normal file
34
docs/INTEGRATION.md
Normal file
@@ -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.
|
||||||
55
docs/PR_SUMMARY.md
Normal file
55
docs/PR_SUMMARY.md
Normal file
@@ -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
|
||||||
27
docs/RELEASE.md
Normal file
27
docs/RELEASE.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
33
docs/ROLLOUT.md
Normal file
33
docs/ROLLOUT.md
Normal file
@@ -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
|
||||||
35
docs/RUN_MODES.md
Normal file
35
docs/RUN_MODES.md
Normal file
@@ -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`.
|
||||||
40
docs/VERIFY.md
Normal file
40
docs/VERIFY.md
Normal file
@@ -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
|
||||||
54
docs/rule-cases.json
Normal file
54
docs/rule-cases.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2
no-reply-api/.dockerignore
Normal file
2
no-reply-api/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
7
no-reply-api/Dockerfile
Normal file
7
no-reply-api/Dockerfile
Normal file
@@ -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"]
|
||||||
9
no-reply-api/package.json
Normal file
9
no-reply-api/package.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "whispergate-no-reply-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.mjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
112
no-reply-api/server.mjs
Normal file
112
no-reply-api/server.mjs
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
30
plugin/README.md
Normal file
30
plugin/README.md
Normal file
@@ -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)
|
||||||
131
plugin/index.ts
Normal file
131
plugin/index.ts
Normal file
@@ -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<string, DecisionRecord>();
|
||||||
|
const MAX_SESSION_DECISIONS = 2000;
|
||||||
|
const DECISION_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const END_MARKER_INSTRUCTION = "你的这次发言必须以🔚作为结尾。";
|
||||||
|
|
||||||
|
function normalizeChannel(ctx: Record<string, unknown>): 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<string, unknown>, ctx: Record<string, unknown>): 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<string, unknown> | 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<string, unknown>;
|
||||||
|
const e = (event || {}) as Record<string, unknown>;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
20
plugin/openclaw.plugin.json
Normal file
20
plugin/openclaw.plugin.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
plugin/package.json
Normal file
11
plugin/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
plugin/rules.ts
Normal file
47
plugin/rules.ts
Normal file
@@ -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" };
|
||||||
|
}
|
||||||
29
scripts/check-plugin-files.mjs
Normal file
29
scripts/check-plugin-files.mjs
Normal file
@@ -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');
|
||||||
6
scripts/dev-down.sh
Executable file
6
scripts/dev-down.sh
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
docker compose down
|
||||||
13
scripts/dev-up.sh
Executable file
13
scripts/dev-up.sh
Executable file
@@ -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"
|
||||||
15
scripts/package-plugin.mjs
Normal file
15
scripts/package-plugin.mjs
Normal file
@@ -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}`);
|
||||||
25
scripts/render-openclaw-config.mjs
Normal file
25
scripts/render-openclaw-config.mjs
Normal file
@@ -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));
|
||||||
52
scripts/smoke-discord-control.sh
Executable file
52
scripts/smoke-discord-control.sh
Executable file
@@ -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"
|
||||||
32
scripts/smoke-no-reply-api.sh
Executable file
32
scripts/smoke-no-reply-api.sh
Executable file
@@ -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"
|
||||||
82
scripts/test-no-reply-api.mjs
Normal file
82
scripts/test-no-reply-api.mjs
Normal file
@@ -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);
|
||||||
|
});
|
||||||
64
scripts/validate-rules.mjs
Normal file
64
scripts/validate-rules.mjs
Normal file
@@ -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");
|
||||||
Reference in New Issue
Block a user