diff --git a/CHANGELOG.md b/CHANGELOG.md index 29cd97f..67b617e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,3 +9,6 @@ - Added containerization (`Dockerfile`, `docker-compose.yml`) - Added helper scripts for smoke/dev lifecycle and rule validation - Added no-touch config rendering and integration docs +- Added discord-control-api with: + - `channel-private-create` (create private channel for allowlist) + - `member-list` (guild members list with pagination) diff --git a/Makefile b/Makefile index 87f994c..e24f0a7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: check check-rules test-api up down smoke render-config package-plugin +.PHONY: check check-rules test-api up down smoke render-config package-plugin discord-control-up check: cd plugin && npm run check @@ -23,3 +23,6 @@ render-config: package-plugin: node scripts/package-plugin.mjs + +discord-control-up: + cd discord-control-api && node server.mjs diff --git a/README.md b/README.md index 923ec36..68d36cf 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ The no-reply provider returns `NO_REPLY` for any input. - `plugin/` — OpenClaw plugin (before_model_resolve hook) - `no-reply-api/` — OpenAI-compatible minimal API that always returns `NO_REPLY` +- `discord-control-api/` — Discord 管理扩展 API(私密频道 + 成员列表) - `docs/` — rollout, integration, run-mode notes - `scripts/` — smoke/dev/helper checks - `Makefile` — common dev commands (`make check`, `make check-rules`, `make test-api`, `make up`) @@ -39,6 +40,8 @@ node scripts/render-openclaw-config.mjs See `docs/RUN_MODES.md` for Docker mode. +Discord 扩展能力见:`docs/DISCORD_CONTROL.md`。 + --- ## Development plan (incremental commits) diff --git a/discord-control-api/package.json b/discord-control-api/package.json new file mode 100644 index 0000000..adb396c --- /dev/null +++ b/discord-control-api/package.json @@ -0,0 +1,9 @@ +{ + "name": "whispergate-discord-control-api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs" + } +} diff --git a/discord-control-api/server.mjs b/discord-control-api/server.mjs new file mode 100644 index 0000000..4f82311 --- /dev/null +++ b/discord-control-api/server.mjs @@ -0,0 +1,179 @@ +import http from "node:http"; + +const port = Number(process.env.PORT || 8790); +const authToken = process.env.AUTH_TOKEN || ""; +const discordToken = process.env.DISCORD_BOT_TOKEN || ""; +const discordBase = "https://discord.com/api/v10"; + +const BIT_VIEW_CHANNEL = 1024n; +const BIT_SEND_MESSAGES = 2048n; +const BIT_READ_MESSAGE_HISTORY = 65536n; + +function sendJson(res, status, payload) { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(payload)); +} + +function isAuthorized(req) { + if (!authToken) return true; + const header = req.headers.authorization || ""; + return header === `Bearer ${authToken}`; +} + +function ensureDiscordToken() { + if (!discordToken) { + const e = new Error("missing DISCORD_BOT_TOKEN"); + e.status = 500; + throw e; + } +} + +async function discordRequest(path, init = {}) { + ensureDiscordToken(); + const headers = { + Authorization: `Bot ${discordToken}`, + "Content-Type": "application/json", + ...(init.headers || {}), + }; + + const r = await fetch(`${discordBase}${path}`, { ...init, headers }); + const text = await r.text(); + let data = text; + try { + data = text ? JSON.parse(text) : {}; + } catch {} + + if (!r.ok) { + const e = new Error(`discord_api_error ${r.status}`); + e.status = r.status; + e.details = data; + throw e; + } + return data; +} + +function toStringMask(v, fallback) { + if (v === undefined || v === null || v === "") return String(fallback); + if (typeof v === "string") return v; + if (typeof v === "number") return String(Math.floor(v)); + if (typeof v === "bigint") return String(v); + throw new Error("invalid mask"); +} + +function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = [], allowMask, denyEveryoneMask }) { + const allowDefault = BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY; + const denyDefault = BIT_VIEW_CHANNEL; + + const everyoneDeny = toStringMask(denyEveryoneMask, denyDefault); + const targetAllow = toStringMask(allowMask, allowDefault); + + const overwrites = [ + { + id: guildId, + type: 0, + allow: "0", + deny: everyoneDeny, + }, + ]; + + for (const roleId of allowedRoleIds) { + overwrites.push({ id: roleId, type: 0, allow: targetAllow, deny: "0" }); + } + for (const userId of allowedUserIds) { + overwrites.push({ id: userId, type: 1, allow: targetAllow, deny: "0" }); + } + + return overwrites; +} + +async function actionChannelPrivateCreate(body) { + const guildId = String(body.guildId || "").trim(); + const name = String(body.name || "").trim(); + if (!guildId) throw Object.assign(new Error("guildId is required"), { status: 400 }); + if (!name) throw Object.assign(new Error("name is required"), { status: 400 }); + + const payload = { + name, + type: Number.isInteger(body.type) ? body.type : 0, + parent_id: body.parentId || undefined, + topic: body.topic || undefined, + position: Number.isInteger(body.position) ? body.position : undefined, + nsfw: typeof body.nsfw === "boolean" ? body.nsfw : undefined, + permission_overwrites: buildPrivateOverwrites({ + guildId, + allowedUserIds: Array.isArray(body.allowedUserIds) ? body.allowedUserIds.map(String) : [], + allowedRoleIds: Array.isArray(body.allowedRoleIds) ? body.allowedRoleIds.map(String) : [], + allowMask: body.allowMask, + denyEveryoneMask: body.denyEveryoneMask, + }), + }; + + const channel = await discordRequest(`/guilds/${guildId}/channels`, { + method: "POST", + body: JSON.stringify(payload), + }); + + return { ok: true, action: "channel-private-create", channel }; +} + +async function actionMemberList(body) { + const guildId = String(body.guildId || "").trim(); + if (!guildId) throw Object.assign(new Error("guildId is required"), { status: 400 }); + + const limitRaw = Number(body.limit ?? 100); + const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100; + const after = body.after ? String(body.after) : undefined; + + const qs = new URLSearchParams(); + qs.set("limit", String(limit)); + if (after) qs.set("after", after); + + const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`); + return { ok: true, action: "member-list", guildId, count: Array.isArray(members) ? members.length : 0, members }; +} + +async function handleAction(body) { + const action = String(body.action || "").trim(); + if (!action) throw Object.assign(new Error("action is required"), { status: 400 }); + + if (action === "channel-private-create") return await actionChannelPrivateCreate(body); + if (action === "member-list") return await actionMemberList(body); + + throw Object.assign(new Error(`unsupported action: ${action}`), { status: 400 }); +} + +const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/health") { + return sendJson(res, 200, { ok: true, service: "discord-control-api" }); + } + + if (req.method !== "POST" || req.url !== "/v1/discord/action") { + return sendJson(res, 404, { error: "not_found" }); + } + + if (!isAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" }); + + let body = ""; + req.on("data", (chunk) => { + body += chunk; + if (body.length > 2_000_000) req.destroy(); + }); + + req.on("end", async () => { + try { + const parsed = body ? JSON.parse(body) : {}; + const result = await handleAction(parsed); + return sendJson(res, 200, result); + } catch (err) { + return sendJson(res, err?.status || 500, { + error: "request_failed", + message: String(err?.message || err), + details: err?.details, + }); + } + }); +}); + +server.listen(port, () => { + console.log(`[discord-control-api] listening on :${port}`); +}); diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md new file mode 100644 index 0000000..3d5f376 --- /dev/null +++ b/docs/DISCORD_CONTROL.md @@ -0,0 +1,85 @@ +# Discord Control API + +目标:补齐 OpenClaw 内置 message 工具当前未覆盖的两个能力: + +1. 创建指定名单可见的私人频道 +2. 查看 server 成员列表(分页) + +## Start + +```bash +cd discord-control-api +export DISCORD_BOT_TOKEN='xxx' +# optional +# export AUTH_TOKEN='strong-token' +node server.mjs +``` + +Health: + +```bash +curl -sS http://127.0.0.1:8790/health +``` + +## Unified action endpoint + +`POST /v1/discord/action` + +- Header: `Authorization: Bearer `(若配置) +- Body: `{ "action": "...", ... }` + +--- + +## Action: channel-private-create + +与 OpenClaw `channel-create` 参数保持风格一致,并增加私密覆盖参数。 + +### Request + +```json +{ + "action": "channel-private-create", + "guildId": "123", + "name": "private-room", + "type": 0, + "parentId": "456", + "topic": "secret", + "position": 3, + "nsfw": false, + "allowedUserIds": ["111", "222"], + "allowedRoleIds": ["333"], + "allowMask": "67648", + "denyEveryoneMask": "1024" +} +``` + +说明: +- 默认 deny `@everyone` 的 `VIEW_CHANNEL`。 +- 默认给 allowed targets 放行:`VIEW_CHANNEL + SEND_MESSAGES + READ_MESSAGE_HISTORY`。 +- `allowMask/denyEveryoneMask` 使用 Discord permission bit string。 + +--- + +## Action: member-list + +### Request + +```json +{ + "action": "member-list", + "guildId": "123", + "limit": 100, + "after": "0" +} +``` + +说明: +- `limit` 1~1000 +- `after` 用于分页(Discord snowflake) + +--- + +## Notes + +- 这不是 bot 自提权工具;bot 仍需由管理员授予足够权限。 +- 若无权限,Discord API 会返回 403 并原样透出错误信息。