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.
This commit is contained in:
zhi
2026-02-28 12:33:58 +00:00
parent fb50b62509
commit a6f2be44b7
3 changed files with 171 additions and 1 deletions

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; 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 { 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"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
@@ -367,6 +368,12 @@ export default {
const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig); const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig);
ensurePolicyStateLoaded(api, liveAtRegister); 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( api.registerTool(
{ {
name: "whispergate_tools", name: "whispergate_tools",

View File

@@ -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<typeof setInterval> | null = null;
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;
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<string, unknown>) {
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;
}
}

View File

@@ -8,7 +8,7 @@ const outDir = path.join(root, "dist", "whispergate");
fs.rmSync(outDir, { recursive: true, force: true }); fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: 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)); fs.copyFileSync(path.join(pluginDir, f), path.join(outDir, f));
} }