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; 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 === "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; } 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: Array.isArray(body.allowedUserIds) ? body.allowedUserIds.map(String) : [], allowedRoleIds: Array.isArray(body.allowedRoleIds) ? body.allowedRoleIds.map(String) : [], 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 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 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 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 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, }); } 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}`); });