Files
Dirigent/discord-control-api/server.mjs

244 lines
7.7 KiB
JavaScript

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