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}`); });