/** * Minimal Discord Gateway connection to keep the moderator bot "online". * Uses Node.js built-in WebSocket (Node 22+). * * IMPORTANT: Only ONE instance should exist per bot token. * Uses a singleton guard to prevent multiple connections. */ let ws: WebSocket | null = null; let heartbeatInterval: ReturnType | null = null; let heartbeatAcked = true; let lastSequence: number | null = null; let sessionId: string | null = null; let resumeUrl: string | null = null; let reconnectTimer: ReturnType | null = null; let destroyed = false; let started = false; // singleton guard type Logger = { info: (msg: string) => void; warn: (msg: string) => void; }; const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"; const MAX_RECONNECT_DELAY_MS = 60_000; let reconnectAttempts = 0; function sendPayload(data: Record) { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); } } function startHeartbeat(intervalMs: number) { stopHeartbeat(); heartbeatAcked = true; // First heartbeat after jitter const jitter = Math.floor(Math.random() * intervalMs); const firstTimer = setTimeout(() => { if (destroyed) return; if (!heartbeatAcked) { // Missed ACK — zombie connection, close and reconnect ws?.close(4000, "missed heartbeat ack"); return; } heartbeatAcked = false; sendPayload({ op: 1, d: lastSequence }); heartbeatInterval = setInterval(() => { if (destroyed) return; if (!heartbeatAcked) { ws?.close(4000, "missed heartbeat ack"); return; } heartbeatAcked = false; sendPayload({ op: 1, d: lastSequence }); }, intervalMs); }, jitter); // Store the first timer so we can clear it heartbeatInterval = firstTimer as unknown as ReturnType; } function stopHeartbeat() { if (heartbeatInterval) { clearInterval(heartbeatInterval); clearTimeout(heartbeatInterval as unknown as ReturnType); heartbeatInterval = null; } } function cleanup() { stopHeartbeat(); if (ws) { // Remove all handlers to avoid ghost callbacks ws.onopen = null; ws.onmessage = null; ws.onclose = null; ws.onerror = null; try { ws.close(1000); } catch { /* ignore */ } ws = null; } } function connect(token: string, logger: Logger, isResume = false) { if (destroyed) return; // Clean up any existing connection first cleanup(); const url = isResume && resumeUrl ? resumeUrl : GATEWAY_URL; try { ws = new WebSocket(url); } catch (err) { logger.warn(`dirigent: moderator ws constructor failed: ${String(err)}`); scheduleReconnect(token, logger, false); return; } const currentWs = ws; // capture for closure ws.onopen = () => { if (currentWs !== ws || destroyed) return; // stale reconnectAttempts = 0; // reset on successful open if (isResume && sessionId) { sendPayload({ op: 6, d: { token, session_id: sessionId, seq: lastSequence }, }); } else { sendPayload({ op: 2, d: { token, intents: 0, properties: { os: "linux", browser: "dirigent", device: "dirigent", }, presence: { status: "online", activities: [{ name: "Moderating", type: 3 }], }, }, }); } }; ws.onmessage = (evt: MessageEvent) => { 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; logger.info("dirigent: moderator bot connected and online"); } if (t === "RESUMED") { logger.info("dirigent: moderator bot resumed"); } break; case 7: // Reconnect request logger.info("dirigent: moderator bot reconnect requested by Discord"); cleanup(); scheduleReconnect(token, logger, true); break; case 9: // Invalid Session logger.warn(`dirigent: moderator bot invalid session, resumable=${d}`); cleanup(); sessionId = d ? sessionId : null; // Wait longer before re-identifying setTimeout(() => { if (!destroyed) connect(token, logger, !!d && !!sessionId); }, 3000 + Math.random() * 2000); break; } } catch { // ignore parse errors } }; ws.onclose = (evt: CloseEvent) => { if (currentWs !== ws) return; // stale ws stopHeartbeat(); if (destroyed) return; const code = evt.code; // Non-recoverable codes — stop reconnecting if (code === 4004) { logger.warn("dirigent: moderator bot token invalid (4004), stopping"); started = false; return; } if (code === 4010 || code === 4011 || code === 4013 || code === 4014) { logger.warn(`dirigent: moderator bot fatal close (${code}), re-identifying`); sessionId = null; scheduleReconnect(token, logger, false); return; } logger.info(`dirigent: moderator bot disconnected (code=${code}), will reconnect`); const canResume = !!sessionId && code !== 4012; scheduleReconnect(token, logger, canResume); }; ws.onerror = () => { // onclose will fire after this }; } function scheduleReconnect(token: string, logger: Logger, resume: boolean) { if (destroyed) return; if (reconnectTimer) clearTimeout(reconnectTimer); // Exponential backoff with cap reconnectAttempts++; const baseDelay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY_MS); const jitter = Math.random() * 1000; const delay = baseDelay + jitter; logger.info(`dirigent: moderator reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`); reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(token, logger, resume); }, delay); } /** * Start the moderator bot's Discord Gateway connection. * Singleton: calling multiple times with the same token is safe (no-op). */ export function startModeratorPresence(token: string, logger: Logger): void { if (started) { logger.info("dirigent: moderator presence already started, skipping"); return; } started = true; destroyed = false; reconnectAttempts = 0; connect(token, logger); } /** * Disconnect the moderator bot. */ export function stopModeratorPresence(): void { destroyed = true; started = false; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } cleanup(); }