/** * Moderator bot service. * * Exports createModeratorService(config) returning { httpHandler(req, res), stop() }. * * Responsibilities: * - Discord Gateway WS with intents GUILD_MESSAGES (512) | MESSAGE_CONTENT (32768) * - On MESSAGE_CREATE dispatch: notify plugin API * - HTTP sub-handler for /health, /me, /send, /delete-message, /create-channel, /guilds, /channels/:guildId */ import { URL as NodeURL } from "node:url"; const DISCORD_API = "https://discord.com/api/v10"; const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"; const MAX_RECONNECT_DELAY_MS = 60_000; const INTENTS = 512 | 32768; // GUILD_MESSAGES | MESSAGE_CONTENT // ── Helpers ──────────────────────────────────────────────────────────────────── function sendJson(res, status, payload) { res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(payload)); } function readBody(req) { return new Promise((resolve, reject) => { let body = ""; req.on("data", (chunk) => { body += chunk; if (body.length > 1_000_000) { req.destroy(); reject(new Error("body too large")); } }); req.on("end", () => { try { resolve(body ? JSON.parse(body) : {}); } catch { reject(new Error("invalid_json")); } }); req.on("error", reject); }); } function getBotUserIdFromToken(token) { try { const segment = token.split(".")[0]; const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4); return Buffer.from(padded, "base64").toString("utf8"); } catch { return undefined; } } // ── Discord REST helpers ─────────────────────────────────────────────────────── async function discordGet(token, path) { const r = await fetch(`${DISCORD_API}${path}`, { headers: { Authorization: `Bot ${token}` }, }); if (!r.ok) { const text = await r.text().catch(() => ""); throw new Error(`Discord GET ${path} failed (${r.status}): ${text}`); } return r.json(); } async function discordPost(token, path, body) { const r = await fetch(`${DISCORD_API}${path}`, { method: "POST", headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); return { ok: r.ok, status: r.status, data: await r.json().catch(() => null) }; } async function discordDelete(token, path) { const r = await fetch(`${DISCORD_API}${path}`, { method: "DELETE", headers: { Authorization: `Bot ${token}` }, }); return { ok: r.ok, status: r.status }; } // ── Gateway connection ───────────────────────────────────────────────────────── function createGatewayConnection(token, onMessage, log) { let ws = null; let heartbeatTimer = null; let heartbeatAcked = true; let lastSequence = null; let sessionId = null; let resumeUrl = null; let reconnectTimer = null; let reconnectAttempts = 0; let destroyed = false; function sendPayload(data) { if (ws?.readyState === 1 /* OPEN */) { ws.send(JSON.stringify(data)); } } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); clearTimeout(heartbeatTimer); heartbeatTimer = null; } } function startHeartbeat(intervalMs) { stopHeartbeat(); heartbeatAcked = true; const jitter = Math.floor(Math.random() * intervalMs); const firstTimer = setTimeout(() => { if (destroyed) return; if (!heartbeatAcked) { ws?.close(4000, "missed heartbeat ack"); return; } heartbeatAcked = false; sendPayload({ op: 1, d: lastSequence }); heartbeatTimer = setInterval(() => { if (destroyed) return; if (!heartbeatAcked) { ws?.close(4000, "missed heartbeat ack"); return; } heartbeatAcked = false; sendPayload({ op: 1, d: lastSequence }); }, intervalMs); }, jitter); heartbeatTimer = firstTimer; } function cleanup() { stopHeartbeat(); if (ws) { ws.onopen = null; ws.onmessage = null; ws.onclose = null; ws.onerror = null; try { ws.close(1000); } catch { /* ignore */ } ws = null; } } function scheduleReconnect(resume) { if (destroyed) return; if (reconnectTimer) clearTimeout(reconnectTimer); reconnectAttempts++; const baseDelay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY_MS); const delay = baseDelay + Math.random() * 1000; log.info(`dirigent-moderator: reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`); reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(resume); }, delay); } function connect(isResume = false) { if (destroyed) return; cleanup(); const url = isResume && resumeUrl ? resumeUrl : GATEWAY_URL; try { ws = new WebSocket(url); } catch (err) { log.warn(`dirigent-moderator: ws constructor failed: ${String(err)}`); scheduleReconnect(false); return; } const currentWs = ws; ws.onopen = () => { if (currentWs !== ws || destroyed) return; reconnectAttempts = 0; if (isResume && sessionId) { sendPayload({ op: 6, d: { token, session_id: sessionId, seq: lastSequence }, }); } else { sendPayload({ op: 2, d: { token, intents: INTENTS, properties: { os: "linux", browser: "dirigent", device: "dirigent", }, }, }); } }; ws.onmessage = (evt) => { if (currentWs !== ws || destroyed) return; try { const msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data)); const { op, t, s, d } = msg; if (s != null) lastSequence = s; switch (op) { case 10: // Hello startHeartbeat(d.heartbeat_interval); break; case 11: // Heartbeat ACK heartbeatAcked = true; break; case 1: // Heartbeat request sendPayload({ op: 1, d: lastSequence }); break; case 0: // Dispatch if (t === "READY") { sessionId = d.session_id; resumeUrl = d.resume_gateway_url; log.info("dirigent-moderator: connected and ready"); } else if (t === "RESUMED") { log.info("dirigent-moderator: session resumed"); } else if (t === "MESSAGE_CREATE") { onMessage(d); } break; case 7: // Reconnect log.info("dirigent-moderator: reconnect requested by Discord"); cleanup(); scheduleReconnect(true); break; case 9: // Invalid Session log.warn(`dirigent-moderator: invalid session, resumable=${d}`); cleanup(); sessionId = d ? sessionId : null; setTimeout(() => { if (!destroyed) connect(!!d && !!sessionId); }, 3000 + Math.random() * 2000); break; } } catch { // ignore parse errors } }; ws.onclose = (evt) => { if (currentWs !== ws) return; stopHeartbeat(); if (destroyed) return; const code = evt.code; if (code === 4004) { log.warn("dirigent-moderator: token invalid (4004), stopping"); return; } if (code === 4010 || code === 4011 || code === 4013 || code === 4014) { log.warn(`dirigent-moderator: fatal close (${code}), re-identifying`); sessionId = null; scheduleReconnect(false); return; } log.info(`dirigent-moderator: disconnected (code=${code}), will reconnect`); const canResume = !!sessionId && code !== 4012; scheduleReconnect(canResume); }; ws.onerror = () => { // onclose will fire after this }; } // Start initial connection connect(false); return { stop() { destroyed = true; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } cleanup(); }, }; } // ── HTTP route handler ───────────────────────────────────────────────────────── function createHttpHandler(token, botUserId, log) { return async function httpHandler(req, res) { const url = req.url ?? "/"; // GET /health if (req.method === "GET" && url === "/health") { return sendJson(res, 200, { ok: true, botId: botUserId }); } // GET /me if (req.method === "GET" && url === "/me") { try { const data = await discordGet(token, "/users/@me"); return sendJson(res, 200, { id: data.id, username: data.username }); } catch (err) { return sendJson(res, 500, { ok: false, error: String(err) }); } } // GET /guilds if (req.method === "GET" && url === "/guilds") { try { const guilds = await discordGet(token, "/users/@me/guilds"); const ADMIN = 8n; const adminGuilds = guilds .filter((g) => (BigInt(g.permissions ?? "0") & ADMIN) === ADMIN) .map((g) => ({ id: g.id, name: g.name })); return sendJson(res, 200, { guilds: adminGuilds }); } catch (err) { return sendJson(res, 500, { ok: false, error: String(err) }); } } // GET /channels/:guildId const channelsMatch = url.match(/^\/channels\/(\d+)$/); if (req.method === "GET" && channelsMatch) { const guildId = channelsMatch[1]; try { const channels = await discordGet(token, `/guilds/${guildId}/channels`); return sendJson(res, 200, { channels: channels .filter((c) => c.type === 0) .map((c) => ({ id: c.id, name: c.name, type: c.type })), }); } catch (err) { return sendJson(res, 500, { ok: false, error: String(err) }); } } // POST /send if (req.method === "POST" && url === "/send") { let body; try { body = await readBody(req); } catch (err) { return sendJson(res, 400, { ok: false, error: String(err) }); } const { channelId, content } = body; if (!channelId || !content) { return sendJson(res, 400, { ok: false, error: "channelId and content required" }); } try { const result = await discordPost(token, `/channels/${channelId}/messages`, { content }); if (!result.ok) { return sendJson(res, result.status, { ok: false, error: `Discord API error ${result.status}` }); } return sendJson(res, 200, { ok: true, messageId: result.data?.id }); } catch (err) { return sendJson(res, 500, { ok: false, error: String(err) }); } } // POST /delete-message if (req.method === "POST" && url === "/delete-message") { let body; try { body = await readBody(req); } catch (err) { return sendJson(res, 400, { ok: false, error: String(err) }); } const { channelId, messageId } = body; if (!channelId || !messageId) { return sendJson(res, 400, { ok: false, error: "channelId and messageId required" }); } try { const result = await discordDelete(token, `/channels/${channelId}/messages/${messageId}`); return sendJson(res, 200, { ok: result.ok }); } catch (err) { return sendJson(res, 500, { ok: false, error: String(err) }); } } // POST /create-channel if (req.method === "POST" && url === "/create-channel") { let body; try { body = await readBody(req); } catch (err) { return sendJson(res, 400, { ok: false, error: String(err) }); } const { guildId, name, permissionOverwrites = [] } = body; if (!guildId || !name) { return sendJson(res, 400, { ok: false, error: "guildId and name required" }); } try { const result = await discordPost(token, `/guilds/${guildId}/channels`, { name, type: 0, permission_overwrites: permissionOverwrites, }); if (!result.ok) { return sendJson(res, result.status, { ok: false, error: `Discord API error ${result.status}` }); } return sendJson(res, 200, { ok: true, channelId: result.data?.id }); } catch (err) { return sendJson(res, 500, { ok: false, error: String(err) }); } } return sendJson(res, 404, { error: "not_found" }); }; } // ── Plugin notification ──────────────────────────────────────────────────────── function createNotifyPlugin(pluginApiUrl, pluginApiToken, log) { return function notifyPlugin(message) { const body = JSON.stringify({ channelId: message.channel_id, messageId: message.id, senderId: message.author?.id, guildId: message.guild_id, content: message.content, }); const headers = { "Content-Type": "application/json", }; if (pluginApiToken) { headers["Authorization"] = `Bearer ${pluginApiToken}`; } fetch(`${pluginApiUrl}/dirigent/api/moderator/message`, { method: "POST", headers, body, }).catch((err) => { log.warn(`dirigent-moderator: notify plugin failed: ${String(err)}`); }); }; } // ── Public API ───────────────────────────────────────────────────────────────── /** * Create the moderator service. * * @param {object} config * @param {string} config.token - Discord bot token * @param {string} config.pluginApiUrl - e.g. "http://127.0.0.1:18789" * @param {string} [config.pluginApiToken] - bearer token for plugin API * @param {string} [config.scheduleIdentifier] - e.g. "➡️" * @param {boolean} [config.debugMode] * @returns {{ httpHandler: Function, stop: Function }} */ export function createModeratorService(config) { const { token, pluginApiUrl, pluginApiToken = "", scheduleIdentifier = "➡️", debugMode = false } = config; const log = { info: (msg) => console.log(`[dirigent-moderator] ${msg}`), warn: (msg) => console.warn(`[dirigent-moderator] WARN ${msg}`), }; if (debugMode) { log.info(`debug mode enabled, scheduleIdentifier=${scheduleIdentifier}`); } // Decode bot user ID from token const botUserId = getBotUserIdFromToken(token); log.info(`bot user id decoded: ${botUserId ?? "(unknown)"}`); // Plugin notify callback (fire-and-forget) const notifyPlugin = createNotifyPlugin(pluginApiUrl, pluginApiToken, log); // Gateway connection const gateway = createGatewayConnection( token, (message) => { // Skip bot's own messages if (message.author?.id === botUserId) return; notifyPlugin(message); }, log, ); // HTTP handler (caller strips /moderator prefix) const httpHandler = createHttpHandler(token, botUserId, log); return { httpHandler, stop() { log.info("stopping moderator service"); gateway.stop(); }, }; }