From a6f2be44b715d0ed32824cb53db7f32c7a5e2ded Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 12:33:58 +0000 Subject: [PATCH] feat: moderator bot presence via Discord Gateway Use Node.js built-in WebSocket to maintain a minimal Discord Gateway connection for the moderator bot, keeping it 'online' with a 'Watching Moderating' status. Handles heartbeat, reconnect, and resume. Also fix package-plugin.mjs to include moderator-presence.ts in dist. --- plugin/index.ts | 7 ++ plugin/moderator-presence.ts | 163 +++++++++++++++++++++++++++++++++++ scripts/package-plugin.mjs | 2 +- 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 plugin/moderator-presence.ts diff --git a/plugin/index.ts b/plugin/index.ts index 3c18b15..2a0f2e8 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js"; +import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; @@ -367,6 +368,12 @@ export default { const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig); ensurePolicyStateLoaded(api, liveAtRegister); + // Start moderator bot presence (keep it "online" on Discord) + if (liveAtRegister.moderatorBotToken) { + startModeratorPresence(liveAtRegister.moderatorBotToken, api.logger); + api.logger.info("whispergate: moderator bot presence starting"); + } + api.registerTool( { name: "whispergate_tools", diff --git a/plugin/moderator-presence.ts b/plugin/moderator-presence.ts new file mode 100644 index 0000000..5f4e1e9 --- /dev/null +++ b/plugin/moderator-presence.ts @@ -0,0 +1,163 @@ +/** + * 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; + } +} diff --git a/scripts/package-plugin.mjs b/scripts/package-plugin.mjs index 462abc4..29be6d3 100644 --- a/scripts/package-plugin.mjs +++ b/scripts/package-plugin.mjs @@ -8,7 +8,7 @@ const outDir = path.join(root, "dist", "whispergate"); fs.rmSync(outDir, { recursive: true, force: true }); fs.mkdirSync(outDir, { recursive: true }); -for (const f of ["index.ts", "rules.ts", "turn-manager.ts", "openclaw.plugin.json", "README.md", "package.json"]) { +for (const f of ["index.ts", "rules.ts", "turn-manager.ts", "moderator-presence.ts", "openclaw.plugin.json", "README.md", "package.json"]) { fs.copyFileSync(path.join(pluginDir, f), path.join(outDir, f)); }