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:
@@ -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",
|
||||||
|
|||||||
163
plugin/moderator-presence.ts
Normal file
163
plugin/moderator-presence.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user