Files
Dirigent/plugin/moderator-presence.ts
zhi af33d747d9 feat: complete Dirigent rename + all TASKLIST items
- Task 1: Identity prompt now includes Discord userId
- Task 2: Added configurable schedulingIdentifier (default: ➡️)
- Task 3: Moderator handoff uses <@userId>+identifier instead of semantic messages
- Task 4: All prompts/comments/help text converted to English
- Task 5: Full project rename WhisperGate → Dirigent across all files

Breaking: config key changed from plugins.entries.whispergate to plugins.entries.dirigent
Breaking: channel policies file renamed to dirigent-channel-policies.json
Breaking: tool name changed from whispergate_tools to dirigent_tools
2026-03-03 10:10:27 +00:00

258 lines
7.0 KiB
TypeScript

/**
* 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<typeof setInterval> | null = null;
let heartbeatAcked = true;
let lastSequence: number | null = null;
let sessionId: string | null = null;
let resumeUrl: string | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | 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<string, unknown>) {
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<typeof setInterval>;
}
function stopHeartbeat() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
clearTimeout(heartbeatInterval as unknown as ReturnType<typeof setTimeout>);
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();
}