From 4bec5982a59122b38726f5ab4a762bb74d92ab1c Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 21:57:39 +0000 Subject: [PATCH] feat(discord-control): align auth with token/allowlist/action-gate and add dryRun --- discord-control-api/server.mjs | 104 ++++++++++++++++++++++++++------- docs/DISCORD_CONTROL.md | 24 ++++++-- 2 files changed, 104 insertions(+), 24 deletions(-) diff --git a/discord-control-api/server.mjs b/discord-control-api/server.mjs index 4f82311..92ccc99 100644 --- a/discord-control-api/server.mjs +++ b/discord-control-api/server.mjs @@ -2,9 +2,29 @@ import http from "node:http"; const port = Number(process.env.PORT || 8790); const authToken = process.env.AUTH_TOKEN || ""; +const requireAuthToken = String(process.env.REQUIRE_AUTH_TOKEN || "false").toLowerCase() === "true"; const discordToken = process.env.DISCORD_BOT_TOKEN || ""; const discordBase = "https://discord.com/api/v10"; +const enabledActions = { + channelPrivateCreate: String(process.env.ENABLE_CHANNEL_PRIVATE_CREATE || "true").toLowerCase() !== "false", + memberList: String(process.env.ENABLE_MEMBER_LIST || "true").toLowerCase() !== "false", +}; + +const allowedGuildIds = new Set( + String(process.env.ALLOWED_GUILD_IDS || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), +); + +const allowedCallerIds = new Set( + String(process.env.ALLOWED_CALLER_IDS || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), +); + const BIT_VIEW_CHANNEL = 1024n; const BIT_SEND_MESSAGES = 2048n; const BIT_READ_MESSAGE_HISTORY = 65536n; @@ -14,17 +34,52 @@ function sendJson(res, status, payload) { res.end(JSON.stringify(payload)); } -function isAuthorized(req) { - if (!authToken) return true; +function fail(status, code, message, details) { + return { status, code, message, details }; +} + +function readCallerId(req) { + const v = req.headers["x-openclaw-caller-id"] || req.headers["x-caller-id"]; + return typeof v === "string" ? v.trim() : ""; +} + +function ensureControlAuth(req) { + if (requireAuthToken && !authToken) { + throw fail(500, "auth_misconfigured", "REQUIRE_AUTH_TOKEN=true but AUTH_TOKEN is empty"); + } + if (!authToken) return; const header = req.headers.authorization || ""; - return header === `Bearer ${authToken}`; + if (header !== `Bearer ${authToken}`) { + throw fail(401, "unauthorized", "invalid or missing bearer token"); + } + + if (allowedCallerIds.size > 0) { + const callerId = readCallerId(req); + if (!callerId || !allowedCallerIds.has(callerId)) { + throw fail(403, "caller_forbidden", "caller is not in ALLOWED_CALLER_IDS", { callerId: callerId || null }); + } + } } function ensureDiscordToken() { if (!discordToken) { - const e = new Error("missing DISCORD_BOT_TOKEN"); - e.status = 500; - throw e; + throw fail(500, "discord_token_missing", "missing DISCORD_BOT_TOKEN"); + } +} + +function ensureGuildAllowed(guildId) { + if (allowedGuildIds.size === 0) return; + if (!allowedGuildIds.has(guildId)) { + throw fail(403, "guild_forbidden", "guild is not in ALLOWED_GUILD_IDS", { guildId }); + } +} + +function ensureActionEnabled(action) { + if (action === "channel-private-create" && !enabledActions.channelPrivateCreate) { + throw fail(403, "action_disabled", "channel-private-create is disabled"); + } + if (action === "member-list" && !enabledActions.memberList) { + throw fail(403, "action_disabled", "member-list is disabled"); } } @@ -44,10 +99,7 @@ async function discordRequest(path, init = {}) { } catch {} if (!r.ok) { - const e = new Error(`discord_api_error ${r.status}`); - e.status = r.status; - e.details = data; - throw e; + throw fail(r.status, "discord_api_error", `discord api returned ${r.status}`, data); } return data; } @@ -57,7 +109,7 @@ function toStringMask(v, fallback) { if (typeof v === "string") return v; if (typeof v === "number") return String(Math.floor(v)); if (typeof v === "bigint") return String(v); - throw new Error("invalid mask"); + throw fail(400, "invalid_mask", "invalid permission bit mask"); } function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = [], allowMask, denyEveryoneMask }) { @@ -89,8 +141,9 @@ function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = async function actionChannelPrivateCreate(body) { const guildId = String(body.guildId || "").trim(); const name = String(body.name || "").trim(); - if (!guildId) throw Object.assign(new Error("guildId is required"), { status: 400 }); - if (!name) throw Object.assign(new Error("name is required"), { status: 400 }); + if (!guildId) throw fail(400, "bad_request", "guildId is required"); + if (!name) throw fail(400, "bad_request", "name is required"); + ensureGuildAllowed(guildId); const payload = { name, @@ -108,6 +161,10 @@ async function actionChannelPrivateCreate(body) { }), }; + if (body.dryRun === true) { + return { ok: true, action: "channel-private-create", dryRun: true, payload }; + } + const channel = await discordRequest(`/guilds/${guildId}/channels`, { method: "POST", body: JSON.stringify(payload), @@ -118,7 +175,8 @@ async function actionChannelPrivateCreate(body) { async function actionMemberList(body) { const guildId = String(body.guildId || "").trim(); - if (!guildId) throw Object.assign(new Error("guildId is required"), { status: 400 }); + if (!guildId) throw fail(400, "bad_request", "guildId is required"); + ensureGuildAllowed(guildId); const limitRaw = Number(body.limit ?? 100); const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100; @@ -134,25 +192,30 @@ async function actionMemberList(body) { async function handleAction(body) { const action = String(body.action || "").trim(); - if (!action) throw Object.assign(new Error("action is required"), { status: 400 }); + if (!action) throw fail(400, "bad_request", "action is required"); + ensureActionEnabled(action); if (action === "channel-private-create") return await actionChannelPrivateCreate(body); if (action === "member-list") return await actionMemberList(body); - throw Object.assign(new Error(`unsupported action: ${action}`), { status: 400 }); + throw fail(400, "unsupported_action", `unsupported action: ${action}`); } const server = http.createServer((req, res) => { if (req.method === "GET" && req.url === "/health") { - return sendJson(res, 200, { ok: true, service: "discord-control-api" }); + return sendJson(res, 200, { + ok: true, + service: "discord-control-api", + authRequired: !!authToken || requireAuthToken, + actionGates: enabledActions, + guildAllowlistEnabled: allowedGuildIds.size > 0, + }); } if (req.method !== "POST" || req.url !== "/v1/discord/action") { return sendJson(res, 404, { error: "not_found" }); } - if (!isAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" }); - let body = ""; req.on("data", (chunk) => { body += chunk; @@ -161,12 +224,13 @@ const server = http.createServer((req, res) => { req.on("end", async () => { try { + ensureControlAuth(req); const parsed = body ? JSON.parse(body) : {}; const result = await handleAction(parsed); return sendJson(res, 200, result); } catch (err) { return sendJson(res, err?.status || 500, { - error: "request_failed", + error: err?.code || "request_failed", message: String(err?.message || err), details: err?.details, }); diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md index 3d5f376..0a506ca 100644 --- a/docs/DISCORD_CONTROL.md +++ b/docs/DISCORD_CONTROL.md @@ -10,8 +10,16 @@ ```bash cd discord-control-api export DISCORD_BOT_TOKEN='xxx' -# optional -# export AUTH_TOKEN='strong-token' +# 建议启用 +export AUTH_TOKEN='strong-token' +# optional hard requirement +# export REQUIRE_AUTH_TOKEN=true +# optional action gates +# export ENABLE_CHANNEL_PRIVATE_CREATE=true +# export ENABLE_MEMBER_LIST=true +# optional allowlist +# export ALLOWED_GUILD_IDS='123,456' +# export ALLOWED_CALLER_IDS='agent-main,agent-admin' node server.mjs ``` @@ -26,6 +34,7 @@ curl -sS http://127.0.0.1:8790/health `POST /v1/discord/action` - Header: `Authorization: Bearer `(若配置) +- Header: `X-OpenClaw-Caller-Id: `(若配置了 `ALLOWED_CALLER_IDS`) - Body: `{ "action": "...", ... }` --- @@ -49,7 +58,8 @@ curl -sS http://127.0.0.1:8790/health "allowedUserIds": ["111", "222"], "allowedRoleIds": ["333"], "allowMask": "67648", - "denyEveryoneMask": "1024" + "denyEveryoneMask": "1024", + "dryRun": false } ``` @@ -81,5 +91,11 @@ curl -sS http://127.0.0.1:8790/health ## Notes +鉴权与内置风格对齐(简化版): +- 控制面 token:`AUTH_TOKEN` / `REQUIRE_AUTH_TOKEN` +- 调用者 allowlist:`ALLOWED_CALLER_IDS`(配合 `X-OpenClaw-Caller-Id`) +- action gate:`ENABLE_CHANNEL_PRIVATE_CREATE` / `ENABLE_MEMBER_LIST` +- guild allowlist:`ALLOWED_GUILD_IDS` + - 这不是 bot 自提权工具;bot 仍需由管理员授予足够权限。 -- 若无权限,Discord API 会返回 403 并原样透出错误信息。 +- 若无权限,Discord API 会返回 403 并透传错误细节。