feat(discord-control): align auth with token/allowlist/action-gate and add dryRun
This commit is contained in:
@@ -2,9 +2,29 @@ import http from "node:http";
|
|||||||
|
|
||||||
const port = Number(process.env.PORT || 8790);
|
const port = Number(process.env.PORT || 8790);
|
||||||
const authToken = process.env.AUTH_TOKEN || "";
|
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 discordToken = process.env.DISCORD_BOT_TOKEN || "";
|
||||||
const discordBase = "https://discord.com/api/v10";
|
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_VIEW_CHANNEL = 1024n;
|
||||||
const BIT_SEND_MESSAGES = 2048n;
|
const BIT_SEND_MESSAGES = 2048n;
|
||||||
const BIT_READ_MESSAGE_HISTORY = 65536n;
|
const BIT_READ_MESSAGE_HISTORY = 65536n;
|
||||||
@@ -14,17 +34,52 @@ function sendJson(res, status, payload) {
|
|||||||
res.end(JSON.stringify(payload));
|
res.end(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAuthorized(req) {
|
function fail(status, code, message, details) {
|
||||||
if (!authToken) return true;
|
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 || "";
|
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() {
|
function ensureDiscordToken() {
|
||||||
if (!discordToken) {
|
if (!discordToken) {
|
||||||
const e = new Error("missing DISCORD_BOT_TOKEN");
|
throw fail(500, "discord_token_missing", "missing DISCORD_BOT_TOKEN");
|
||||||
e.status = 500;
|
}
|
||||||
throw e;
|
}
|
||||||
|
|
||||||
|
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 {}
|
} catch {}
|
||||||
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const e = new Error(`discord_api_error ${r.status}`);
|
throw fail(r.status, "discord_api_error", `discord api returned ${r.status}`, data);
|
||||||
e.status = r.status;
|
|
||||||
e.details = data;
|
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -57,7 +109,7 @@ function toStringMask(v, fallback) {
|
|||||||
if (typeof v === "string") return v;
|
if (typeof v === "string") return v;
|
||||||
if (typeof v === "number") return String(Math.floor(v));
|
if (typeof v === "number") return String(Math.floor(v));
|
||||||
if (typeof v === "bigint") return String(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 }) {
|
function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds = [], allowMask, denyEveryoneMask }) {
|
||||||
@@ -89,8 +141,9 @@ function buildPrivateOverwrites({ guildId, allowedUserIds = [], allowedRoleIds =
|
|||||||
async function actionChannelPrivateCreate(body) {
|
async function actionChannelPrivateCreate(body) {
|
||||||
const guildId = String(body.guildId || "").trim();
|
const guildId = String(body.guildId || "").trim();
|
||||||
const name = String(body.name || "").trim();
|
const name = String(body.name || "").trim();
|
||||||
if (!guildId) throw Object.assign(new Error("guildId is required"), { status: 400 });
|
if (!guildId) throw fail(400, "bad_request", "guildId is required");
|
||||||
if (!name) throw Object.assign(new Error("name is required"), { status: 400 });
|
if (!name) throw fail(400, "bad_request", "name is required");
|
||||||
|
ensureGuildAllowed(guildId);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name,
|
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`, {
|
const channel = await discordRequest(`/guilds/${guildId}/channels`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -118,7 +175,8 @@ async function actionChannelPrivateCreate(body) {
|
|||||||
|
|
||||||
async function actionMemberList(body) {
|
async function actionMemberList(body) {
|
||||||
const guildId = String(body.guildId || "").trim();
|
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 limitRaw = Number(body.limit ?? 100);
|
||||||
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 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) {
|
async function handleAction(body) {
|
||||||
const action = String(body.action || "").trim();
|
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 === "channel-private-create") return await actionChannelPrivateCreate(body);
|
||||||
if (action === "member-list") return await actionMemberList(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) => {
|
const server = http.createServer((req, res) => {
|
||||||
if (req.method === "GET" && req.url === "/health") {
|
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") {
|
if (req.method !== "POST" || req.url !== "/v1/discord/action") {
|
||||||
return sendJson(res, 404, { error: "not_found" });
|
return sendJson(res, 404, { error: "not_found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" });
|
|
||||||
|
|
||||||
let body = "";
|
let body = "";
|
||||||
req.on("data", (chunk) => {
|
req.on("data", (chunk) => {
|
||||||
body += chunk;
|
body += chunk;
|
||||||
@@ -161,12 +224,13 @@ const server = http.createServer((req, res) => {
|
|||||||
|
|
||||||
req.on("end", async () => {
|
req.on("end", async () => {
|
||||||
try {
|
try {
|
||||||
|
ensureControlAuth(req);
|
||||||
const parsed = body ? JSON.parse(body) : {};
|
const parsed = body ? JSON.parse(body) : {};
|
||||||
const result = await handleAction(parsed);
|
const result = await handleAction(parsed);
|
||||||
return sendJson(res, 200, result);
|
return sendJson(res, 200, result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendJson(res, err?.status || 500, {
|
return sendJson(res, err?.status || 500, {
|
||||||
error: "request_failed",
|
error: err?.code || "request_failed",
|
||||||
message: String(err?.message || err),
|
message: String(err?.message || err),
|
||||||
details: err?.details,
|
details: err?.details,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,16 @@
|
|||||||
```bash
|
```bash
|
||||||
cd discord-control-api
|
cd discord-control-api
|
||||||
export DISCORD_BOT_TOKEN='xxx'
|
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
|
node server.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -26,6 +34,7 @@ curl -sS http://127.0.0.1:8790/health
|
|||||||
`POST /v1/discord/action`
|
`POST /v1/discord/action`
|
||||||
|
|
||||||
- Header: `Authorization: Bearer <AUTH_TOKEN>`(若配置)
|
- Header: `Authorization: Bearer <AUTH_TOKEN>`(若配置)
|
||||||
|
- Header: `X-OpenClaw-Caller-Id: <id>`(若配置了 `ALLOWED_CALLER_IDS`)
|
||||||
- Body: `{ "action": "...", ... }`
|
- Body: `{ "action": "...", ... }`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -49,7 +58,8 @@ curl -sS http://127.0.0.1:8790/health
|
|||||||
"allowedUserIds": ["111", "222"],
|
"allowedUserIds": ["111", "222"],
|
||||||
"allowedRoleIds": ["333"],
|
"allowedRoleIds": ["333"],
|
||||||
"allowMask": "67648",
|
"allowMask": "67648",
|
||||||
"denyEveryoneMask": "1024"
|
"denyEveryoneMask": "1024",
|
||||||
|
"dryRun": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -81,5 +91,11 @@ curl -sS http://127.0.0.1:8790/health
|
|||||||
|
|
||||||
## Notes
|
## 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 仍需由管理员授予足够权限。
|
- 这不是 bot 自提权工具;bot 仍需由管理员授予足够权限。
|
||||||
- 若无权限,Discord API 会返回 403 并原样透出错误信息。
|
- 若无权限,Discord API 会返回 403 并透传错误细节。
|
||||||
|
|||||||
Reference in New Issue
Block a user