/** * Minimal Discord Gateway connection to keep the moderator bot "online". * Uses Node.js built-in WebSocket (Node 22+). */ let ws: WebSocket | null = null; let heartbeatInterval: ReturnType | null = null; let lastSequence: number | null = null; let sessionId: string | null = null; let resumeUrl: string | null = null; let reconnectTimer: ReturnType | null = null; let destroyed = false; type Logger = { info: (msg: string) => void; warn: (msg: string) => void; }; const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"; function sendPayload(data: Record) { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); } } function startHeartbeat(intervalMs: number) { stopHeartbeat(); const jitter = Math.floor(Math.random() * intervalMs); setTimeout(() => { sendPayload({ op: 1, d: lastSequence }); heartbeatInterval = setInterval(() => { sendPayload({ op: 1, d: lastSequence }); }, intervalMs); }, jitter); } function stopHeartbeat() { if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } } function connect(token: string, logger: Logger, isResume = false) { if (destroyed) return; const url = isResume && resumeUrl ? resumeUrl : GATEWAY_URL; ws = new WebSocket(url); ws.onopen = () => { 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: "whispergate", device: "whispergate", }, presence: { status: "online", activities: [{ name: "Moderating", type: 3 }], }, }, }); } }; ws.onmessage = (evt: MessageEvent) => { try { const msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data)); const { op, t, s, d } = msg; if (s) lastSequence = s; switch (op) { case 10: // Hello startHeartbeat(d.heartbeat_interval); break; case 11: // Heartbeat ACK 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("whispergate: moderator bot connected and online"); } if (t === "RESUMED") { logger.info("whispergate: moderator bot resumed"); } break; case 7: // Reconnect logger.info("whispergate: moderator bot reconnect requested"); ws?.close(4000); break; case 9: // Invalid Session logger.warn(`whispergate: moderator bot invalid session, resumable=${d}`); if (d) { scheduleReconnect(token, logger, true); } else { sessionId = null; scheduleReconnect(token, logger, false); } break; } } catch { // ignore } }; ws.onclose = (evt: CloseEvent) => { stopHeartbeat(); if (destroyed) return; const code = evt.code; if (code === 4004) { logger.warn("whispergate: moderator bot token invalid, not reconnecting"); return; } logger.info(`whispergate: moderator bot disconnected (code=${code}), reconnecting...`); const canResume = code !== 4010 && code !== 4011 && code !== 4012 && code !== 4013 && code !== 4014; scheduleReconnect(token, logger, canResume && !!sessionId); }; ws.onerror = () => { // onclose will fire after this }; } function scheduleReconnect(token: string, logger: Logger, resume: boolean) { if (destroyed) return; if (reconnectTimer) clearTimeout(reconnectTimer); const delay = 2000 + Math.random() * 3000; reconnectTimer = setTimeout(() => connect(token, logger, resume), delay); } export function startModeratorPresence(token: string, logger: Logger): void { destroyed = false; connect(token, logger); } export function stopModeratorPresence(): void { destroyed = true; stopHeartbeat(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (ws) { ws.close(1000); ws = null; } }