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