import fs from "node:fs"; 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"; type DecisionRecord = { decision: Decision; createdAt: number; needsRestore?: boolean; }; type PolicyState = { filePath: string; channelPolicies: Record; }; type DebugConfig = { enableDebugLogs?: boolean; debugLogChannelIds?: string[]; }; const sessionDecision = new Map(); const sessionAllowed = new Map(); // Track if session was allowed to speak (true) or forced no-reply (false) const sessionInjected = new Set(); // Track which sessions have already injected the end marker const sessionChannelId = new Map(); // Track sessionKey -> channelId mapping const sessionAccountId = new Map(); // Track sessionKey -> accountId mapping const sessionTurnHandled = new Set(); // Track sessions where turn was already advanced in before_message_write const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string { const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚"; let instruction = `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`; if (isGroupChat) { instruction += `\n\n群聊发言规则:如果这条消息与你无关、不需要你回应、或你没有有价值的补充,请主动回复 NO_REPLY。不要为了说话而说话。`; } return instruction; } const policyState: PolicyState = { filePath: "", channelPolicies: {}, }; function normalizeChannel(ctx: Record): string { const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel]; for (const c of candidates) { if (typeof c === "string" && c.trim()) return c.trim().toLowerCase(); } return ""; } /** * Extract the actual Discord channel ID from a conversationId or "to" field. * OpenClaw uses format "channel:" for Discord conversations. * Also tries event.to and event.metadata.to as fallbacks. */ function extractDiscordChannelId(ctx: Record, event?: Record): string | undefined { const candidates: unknown[] = [ ctx.conversationId, event?.to, (event?.metadata as Record)?.to, ]; for (const c of candidates) { if (typeof c === "string" && c.trim()) { const s = c.trim(); // Handle "channel:123456" format if (s.startsWith("channel:")) { const id = s.slice("channel:".length); if (/^\d+$/.test(id)) return id; } // Handle "discord:channel:123456" format if (s.startsWith("discord:channel:")) { const id = s.slice("discord:channel:".length); if (/^\d+$/.test(id)) return id; } // If it's a raw snowflake (all digits), use directly if (/^\d{15,}$/.test(s)) return s; } } return undefined; } function normalizeSender(event: Record, ctx: Record): string | undefined { const direct = [ctx.senderId, ctx.from, event.from]; for (const v of direct) { if (typeof v === "string" && v.trim()) return v.trim(); } const meta = (event.metadata || ctx.metadata) as Record | undefined; if (!meta) return undefined; const metaCandidates = [meta.senderId, meta.sender_id, meta.userId, meta.user_id]; for (const v of metaCandidates) { if (typeof v === "string" && v.trim()) return v.trim(); } return undefined; } function extractUntrustedConversationInfo(text: string): Record | undefined { const marker = "Conversation info (untrusted metadata):"; const idx = text.indexOf(marker); if (idx < 0) return undefined; const tail = text.slice(idx + marker.length); const m = tail.match(/```json\s*([\s\S]*?)\s*```/i); if (!m) return undefined; try { const parsed = JSON.parse(m[1]); return parsed && typeof parsed === "object" ? (parsed as Record) : undefined; } catch { return undefined; } } function deriveDecisionInputFromPrompt( prompt: string, messageProvider?: string, channelIdFromCtx?: string, ): { channel: string; channelId?: string; senderId?: string; content: string; conv: Record; } { const conv = extractUntrustedConversationInfo(prompt) || {}; const channel = (messageProvider || "").toLowerCase(); // Priority: ctx.channelId > conv.chat_id > conversation_label > conv.channel_id let channelId = channelIdFromCtx; if (!channelId) { // Try chat_id field (format "channel:123456") if (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")) { channelId = conv.chat_id.slice("channel:".length); } // Try conversation_label (format "Guild #name channel id:123456") if (!channelId && typeof conv.conversation_label === "string") { const labelMatch = conv.conversation_label.match(/channel id:(\d+)/); if (labelMatch) channelId = labelMatch[1]; } // Try channel_id field directly if (!channelId && typeof conv.channel_id === "string" && conv.channel_id) { channelId = conv.channel_id; } } const senderId = (typeof conv.sender_id === "string" && conv.sender_id) || (typeof conv.sender === "string" && conv.sender) || undefined; return { channel, channelId, senderId, content: prompt, conv }; } function pruneDecisionMap(now = Date.now()) { for (const [k, v] of sessionDecision.entries()) { if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k); } if (sessionDecision.size <= MAX_SESSION_DECISIONS) return; const keys = sessionDecision.keys(); while (sessionDecision.size > MAX_SESSION_DECISIONS) { const k = keys.next(); if (k.done) break; sessionDecision.delete(k.value); } } function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig): WhisperGateConfig { const root = (api.config as Record) || {}; const plugins = (root.plugins as Record) || {}; const entries = (plugins.entries as Record) || {}; const entry = (entries.whispergate as Record) || {}; const cfg = (entry.config as Record) || {}; if (Object.keys(cfg).length > 0) { // Merge with defaults to ensure optional fields have values return { enableDiscordControlTool: true, enableWhispergatePolicyTool: true, discordControlApiBaseUrl: "http://127.0.0.1:8790", enableDebugLogs: false, debugLogChannelIds: [], ...cfg, } as WhisperGateConfig; } return fallback; } function resolvePoliciesPath(api: OpenClawPluginApi, config: WhisperGateConfig): string { return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/whispergate-channel-policies.json"); } function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConfig) { if (policyState.filePath) return; const filePath = resolvePoliciesPath(api, config); policyState.filePath = filePath; try { if (!fs.existsSync(filePath)) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, "{}\n", "utf8"); policyState.channelPolicies = {}; return; } const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as Record; policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {}; } catch (err) { api.logger.warn(`whispergate: failed init policy file ${filePath}: ${String(err)}`); policyState.channelPolicies = {}; } } /** Resolve agentId → Discord accountId from config bindings */ function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined { const root = (api.config as Record) || {}; const bindings = root.bindings as Array> | undefined; if (!Array.isArray(bindings)) return undefined; for (const b of bindings) { if (b.agentId === agentId) { const match = b.match as Record | undefined; if (match?.channel === "discord" && typeof match.accountId === "string") { return match.accountId; } } } return undefined; } /** * Get all Discord bot accountIds from config bindings. */ function getAllBotAccountIds(api: OpenClawPluginApi): string[] { const root = (api.config as Record) || {}; const bindings = root.bindings as Array> | undefined; if (!Array.isArray(bindings)) return []; const ids: string[] = []; for (const b of bindings) { const match = b.match as Record | undefined; if (match?.channel === "discord" && typeof match.accountId === "string") { ids.push(match.accountId); } } return ids; } /** * Track which bot accountIds have been seen in each channel via message_received. * Key: channelId, Value: Set of accountIds seen. */ const channelSeenAccounts = new Map>(); /** * Record a bot accountId seen in a channel. * Returns true if this is a new account for this channel (turn order should be updated). */ function recordChannelAccount(channelId: string, accountId: string): boolean { let seen = channelSeenAccounts.get(channelId); if (!seen) { seen = new Set(); channelSeenAccounts.set(channelId, seen); } if (seen.has(accountId)) return false; seen.add(accountId); return true; } /** * Get the list of bot accountIds seen in a channel. * Only returns accounts that are also in the global bindings (actual bots). */ function getChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): string[] { const allBots = new Set(getAllBotAccountIds(api)); const seen = channelSeenAccounts.get(channelId); if (!seen) return []; return [...seen].filter(id => allBots.has(id)); } /** * Ensure turn order is initialized for a channel. * Uses only bot accounts that have been seen in this channel. */ function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void { const botAccounts = getChannelBotAccountIds(api, channelId); if (botAccounts.length > 0) { initTurnOrder(channelId, botAccounts); } } /** * Build agent identity string for injection into group chat prompts. */ function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined { const root = (api.config as Record) || {}; const bindings = root.bindings as Array> | undefined; const agents = ((root.agents as Record)?.list as Array>) || []; if (!Array.isArray(bindings)) return undefined; // Find accountId for this agent let accountId: string | undefined; for (const b of bindings) { if (b.agentId === agentId) { const match = b.match as Record | undefined; if (match?.channel === "discord" && typeof match.accountId === "string") { accountId = match.accountId; break; } } } if (!accountId) return undefined; // Find agent name const agent = agents.find((a: Record) => a.id === agentId); const name = (agent?.name as string) || agentId; // Find Discord bot user ID from account token (not available directly) // We'll use accountId as the identifier return `你是 ${name}(Discord 账号: ${accountId})。`; } // --- Moderator bot helpers --- /** Extract Discord user ID from a bot token (base64-encoded in first segment) */ function userIdFromToken(token: string): string | undefined { try { const segment = token.split(".")[0]; // Add padding const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4); return Buffer.from(padded, "base64").toString("utf8"); } catch { return undefined; } } /** Resolve accountId → Discord user ID by reading the account's bot token from config */ function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string | undefined { const root = (api.config as Record) || {}; const channels = (root.channels as Record) || {}; const discord = (channels.discord as Record) || {}; const accounts = (discord.accounts as Record>) || {}; const acct = accounts[accountId]; if (!acct?.token || typeof acct.token !== "string") return undefined; return userIdFromToken(acct.token); } /** Get the moderator bot's Discord user ID from its token */ function getModeratorUserId(config: WhisperGateConfig): string | undefined { if (!config.moderatorBotToken) return undefined; return userIdFromToken(config.moderatorBotToken); } /** Send a message as the moderator bot via Discord REST API */ async function sendModeratorMessage(token: string, channelId: string, content: string, logger: { info: (msg: string) => void; warn: (msg: string) => void }): Promise { try { const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { method: "POST", headers: { "Authorization": `Bot ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ content }), }); if (!r.ok) { const text = await r.text(); logger.warn(`whispergate: moderator send failed (${r.status}): ${text}`); return false; } logger.info(`whispergate: moderator message sent to channel=${channelId}`); return true; } catch (err) { logger.warn(`whispergate: moderator send error: ${String(err)}`); return false; } } function persistPolicies(api: OpenClawPluginApi): void { const filePath = policyState.filePath; if (!filePath) throw new Error("policy file path not initialized"); const before = JSON.stringify(policyState.channelPolicies, null, 2) + "\n"; const tmp = `${filePath}.tmp`; fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(tmp, before, "utf8"); fs.renameSync(tmp, filePath); api.logger.info(`whispergate: policy file persisted: ${filePath}`); } function pickDefined(input: Record) { const out: Record = {}; for (const [k, v] of Object.entries(input)) { if (v !== undefined) out[k] = v; } return out; } function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean { if (!cfg.enableDebugLogs) return false; const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : []; if (allow.length === 0) return true; if (!channelId) return true; // 允许打印,方便排查 channelId 为空的场景 return allow.includes(channelId); } function debugCtxSummary(ctx: Record, event: Record) { const meta = ((ctx.metadata || event.metadata || {}) as Record) || {}; return { sessionKey: typeof ctx.sessionKey === "string" ? ctx.sessionKey : undefined, commandSource: typeof ctx.commandSource === "string" ? ctx.commandSource : undefined, messageProvider: typeof ctx.messageProvider === "string" ? ctx.messageProvider : undefined, channel: typeof ctx.channel === "string" ? ctx.channel : undefined, channelId: typeof ctx.channelId === "string" ? ctx.channelId : undefined, senderId: typeof ctx.senderId === "string" ? ctx.senderId : undefined, from: typeof ctx.from === "string" ? ctx.from : undefined, metaSenderId: typeof meta.senderId === "string" ? meta.senderId : typeof meta.sender_id === "string" ? meta.sender_id : undefined, metaUserId: typeof meta.userId === "string" ? meta.userId : typeof meta.user_id === "string" ? meta.user_id : undefined, }; } export default { id: "whispergate", name: "WhisperGate", register(api: OpenClawPluginApi) { // Merge pluginConfig with defaults (in case config is missing from openclaw.json) const baseConfig = { enableDiscordControlTool: true, enableWhispergatePolicyTool: true, discordControlApiBaseUrl: "http://127.0.0.1:8790", ...(api.pluginConfig || {}), } as WhisperGateConfig & { enableDiscordControlTool: boolean; discordControlApiBaseUrl: string; discordControlApiToken?: string; discordControlCallerId?: string; enableWhispergatePolicyTool: boolean; }; 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", description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.", parameters: { type: "object", additionalProperties: false, properties: { action: { type: "string", enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel", "turn-status", "turn-advance", "turn-reset"], }, guildId: { type: "string" }, name: { type: "string" }, type: { type: "number" }, parentId: { type: "string" }, topic: { type: "string" }, position: { type: "number" }, nsfw: { type: "boolean" }, allowedUserIds: { type: "array", items: { type: "string" } }, allowedRoleIds: { type: "array", items: { type: "string" } }, allowMask: { type: "string" }, denyEveryoneMask: { type: "string" }, channelId: { type: "string" }, mode: { type: "string", enum: ["merge", "replace"] }, addUserIds: { type: "array", items: { type: "string" } }, addRoleIds: { type: "array", items: { type: "string" } }, removeTargetIds: { type: "array", items: { type: "string" } }, denyMask: { type: "string" }, limit: { type: "number" }, after: { type: "string" }, fields: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] }, dryRun: { type: "boolean" }, listMode: { type: "string", enum: ["human-list", "agent-list"] }, humanList: { type: "array", items: { type: "string" } }, agentList: { type: "array", items: { type: "string" } }, endSymbols: { type: "array", items: { type: "string" } }, }, required: ["action"], }, async execute(_id: string, params: Record) { const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & { discordControlApiBaseUrl?: string; discordControlApiToken?: string; discordControlCallerId?: string; enableDiscordControlTool?: boolean; enableWhispergatePolicyTool?: boolean; }; ensurePolicyStateLoaded(api, live); const action = String(params.action || ""); const discordActions = new Set(["channel-private-create", "channel-private-update", "member-list"]); if (discordActions.has(action)) { if (live.enableDiscordControlTool === false) { return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true }; } const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, ""); const body = pickDefined({ ...params, action: action as DiscordControlAction }); const headers: Record = { "Content-Type": "application/json" }; if (live.discordControlApiToken) headers.Authorization = `Bearer ${live.discordControlApiToken}`; if (live.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = live.discordControlCallerId; const r = await fetch(`${baseUrl}/v1/discord/action`, { method: "POST", headers, body: JSON.stringify(body), }); const text = await r.text(); if (!r.ok) { return { content: [{ type: "text", text: `whispergate_tools discord failed (${r.status}): ${text}` }], isError: true, }; } return { content: [{ type: "text", text }] }; } if (live.enableWhispergatePolicyTool === false) { return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; } if (action === "policy-get") { return { content: [{ type: "text", text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2) }], }; } if (action === "policy-set-channel") { const channelId = String(params.channelId || "").trim(); if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); try { const next: ChannelPolicy = { listMode: (params.listMode as "human-list" | "agent-list" | undefined) || undefined, humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined, agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined, endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined, }; policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record) as ChannelPolicy; persistPolicies(api); return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, policy: policyState.channelPolicies[channelId] }) }] }; } catch (err) { policyState.channelPolicies = prev; return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; } } if (action === "policy-delete-channel") { const channelId = String(params.channelId || "").trim(); if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); try { delete policyState.channelPolicies[channelId]; persistPolicies(api); return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] }; } catch (err) { policyState.channelPolicies = prev; return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; } } if (action === "turn-status") { const channelId = String(params.channelId || "").trim(); if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; return { content: [{ type: "text", text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }] }; } if (action === "turn-advance") { const channelId = String(params.channelId || "").trim(); if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; const next = advanceTurn(channelId); return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, nextSpeaker: next, ...getTurnDebugInfo(channelId) }) }] }; } if (action === "turn-reset") { const channelId = String(params.channelId || "").trim(); if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; resetTurn(channelId); return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, ...getTurnDebugInfo(channelId) }) }] }; } return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; }, }, { optional: false }, ); api.on("message_received", async (event, ctx) => { try { const c = (ctx || {}) as Record; const e = (event || {}) as Record; // ctx.channelId is the platform name (e.g. "discord"), NOT the Discord channel snowflake. // Extract the real Discord channel ID from conversationId or event.to. const preChannelId = extractDiscordChannelId(c, e); const livePre = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; if (shouldDebugLog(livePre, preChannelId)) { api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); } // Turn management on message received if (preChannelId) { ensureTurnOrder(api, preChannelId); // event.from is often the channel target (e.g. "discord:channel:xxx"), NOT the sender. // The actual sender ID is in event.metadata.senderId. const metadata = (e as Record).metadata as Record | undefined; const from = (typeof metadata?.senderId === "string" && metadata.senderId) || (typeof (e as Record).from === "string" ? (e as Record).from as string : ""); // Ignore moderator bot messages — they don't affect turn state const moderatorUserId = getModeratorUserId(livePre); if (moderatorUserId && from === moderatorUserId) { if (shouldDebugLog(livePre, preChannelId)) { api.logger.info(`whispergate: ignoring moderator message in channel=${preChannelId}`); } // Don't call onNewMessage — moderator messages are transparent to turn logic } else { const humanList = livePre.humanList || livePre.bypassUserIds || []; const isHuman = humanList.includes(from); const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined; // Track which bot accounts are present in this channel if (senderAccountId && senderAccountId !== "default") { const isNew = recordChannelAccount(preChannelId, senderAccountId); if (isNew) { // Re-initialize turn order with updated channel membership ensureTurnOrder(api, preChannelId); api.logger.info(`whispergate: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`); } } onNewMessage(preChannelId, senderAccountId, isHuman); if (shouldDebugLog(livePre, preChannelId)) { api.logger.info(`whispergate: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`); } } } } catch (err) { api.logger.warn(`whispergate: message hook failed: ${String(err)}`); } }); api.on("before_model_resolve", async (event, ctx) => { const key = ctx.sessionKey; if (!key) return; const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; ensurePolicyStateLoaded(api, live); const prompt = ((event as Record).prompt as string) || ""; if (live.enableDebugLogs) { api.logger.info( `whispergate: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` + `promptPreview=${prompt.slice(0, 300)}`, ); } const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); // Fallback: extract channelId from sessionKey (format "agent::discord:channel:") if (!derived.channelId && key) { const skMatch = key.match(/:channel:(\d+)$/); if (skMatch) derived.channelId = skMatch[1]; } // Only proceed if: discord channel AND prompt contains untrusted metadata const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return; // Always save channelId and accountId mappings for use in later hooks if (derived.channelId) { sessionChannelId.set(key, derived.channelId); } const resolvedAccountId = resolveAccountId(api, ctx.agentId || ""); if (resolvedAccountId) { sessionAccountId.set(key, resolvedAccountId); } let rec = sessionDecision.get(key); if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { if (rec) sessionDecision.delete(key); const decision = evaluateDecision({ config: live, channel: derived.channel, channelId: derived.channelId, channelPolicies: policyState.channelPolicies, senderId: derived.senderId, content: derived.content, }); rec = { decision, createdAt: Date.now() }; sessionDecision.set(key, rec); pruneDecisionMap(); if (shouldDebugLog(live, derived.channelId)) { api.logger.info( `whispergate: debug before_model_resolve recompute session=${key} ` + `channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` + `convSenderId=${String((derived.conv as Record).sender_id ?? "")} ` + `convSender=${String((derived.conv as Record).sender ?? "")} ` + `convChannelId=${String((derived.conv as Record).channel_id ?? "")} ` + `decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, ); } } // Turn-based check: ALWAYS check turn order regardless of evaluateDecision result. // This ensures only the current speaker can respond even for human messages. if (derived.channelId) { ensureTurnOrder(api, derived.channelId); const accountId = resolveAccountId(api, ctx.agentId || ""); if (accountId) { const turnCheck = checkTurn(derived.channelId, accountId); if (!turnCheck.allowed) { // Forced no-reply - record this session as not allowed to speak sessionAllowed.set(key, false); api.logger.info( `whispergate: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`, ); return { providerOverride: live.noReplyProvider, modelOverride: live.noReplyModel, }; } // Allowed to speak - record this session as allowed sessionAllowed.set(key, true); } } if (!rec.decision.shouldUseNoReply) { // 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型 if (rec.needsRestore) { sessionDecision.delete(key); return { providerOverride: undefined, modelOverride: undefined, }; } return; } // 标记这次执行了 no-reply,下次需要恢复模型 rec.needsRestore = true; sessionDecision.set(key, rec); // 无论是否有缓存,只要 debug flag 开启就打印决策详情 if (live.enableDebugLogs) { const prompt = ((event as Record).prompt as string) || ""; const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); api.logger.info( `whispergate: DEBUG_NO_REPLY_TRIGGER session=${key} ` + `channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` + `convSenderId=${String((derived.conv as Record).sender_id ?? "")} ` + `convSender=${String((derived.conv as Record).sender ?? "")} ` + `decision=${rec.decision.reason} ` + `shouldNoReply=${rec.decision.shouldUseNoReply} shouldInject=${rec.decision.shouldInjectEndMarkerPrompt} ` + `hasConvMarker=${hasConvMarker} promptLen=${prompt.length}`, ); } api.logger.info( `whispergate: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`, ); return { providerOverride: live.noReplyProvider, modelOverride: live.noReplyModel, }; }); api.on("before_prompt_build", async (event, ctx) => { const key = ctx.sessionKey; if (!key) return; const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; ensurePolicyStateLoaded(api, live); let rec = sessionDecision.get(key); if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { if (rec) sessionDecision.delete(key); const prompt = ((event as Record).prompt as string) || ""; const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); const decision = evaluateDecision({ config: live, channel: derived.channel, channelId: derived.channelId, channelPolicies: policyState.channelPolicies, senderId: derived.senderId, content: derived.content, }); rec = { decision, createdAt: Date.now() }; if (shouldDebugLog(live, derived.channelId)) { api.logger.info( `whispergate: debug before_prompt_build recompute session=${key} ` + `channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` + `convSenderId=${String((derived.conv as Record).sender_id ?? "")} ` + `convSender=${String((derived.conv as Record).sender ?? "")} ` + `convChannelId=${String((derived.conv as Record).channel_id ?? "")} ` + `decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, ); } } sessionDecision.delete(key); // Only inject once per session (one-time injection) if (sessionInjected.has(key)) { if (shouldDebugLog(live, undefined)) { api.logger.info( `whispergate: debug before_prompt_build session=${key} inject skipped (already injected)`, ); } return; } if (!rec.decision.shouldInjectEndMarkerPrompt) { if (shouldDebugLog(live, undefined)) { api.logger.info( `whispergate: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`, ); } return; } // Resolve end symbols from config/policy for dynamic instruction const prompt = ((event as Record).prompt as string) || ""; const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies); const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true"; const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat); // Inject agent identity for group chats let identity = ""; if (isGroupChat && ctx.agentId) { const idStr = buildAgentIdentity(api, ctx.agentId); if (idStr) identity = idStr + "\n\n"; } // Mark session as injected (one-time injection) sessionInjected.add(key); api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); return { prependContext: identity + instruction }; }); // Register slash commands for Discord api.registerCommand({ name: "whispergate", description: "WhisperGate 频道策略管理", acceptsArgs: true, handler: async (cmdCtx) => { const args = cmdCtx.args || ""; const parts = args.trim().split(/\s+/); const subCmd = parts[0] || "help"; if (subCmd === "help") { return { text: `WhisperGate 命令:\n` + `/whispergate status - 显示当前频道状态\n` + `/whispergate turn-status - 显示轮流发言状态\n` + `/whispergate turn-advance - 手动推进轮流\n` + `/whispergate turn-reset - 重置轮流顺序` }; } if (subCmd === "status") { return { text: JSON.stringify({ policies: policyState.channelPolicies }, null, 2) }; } if (subCmd === "turn-status") { const channelId = cmdCtx.channelId; if (!channelId) return { text: "无法获取频道ID", isError: true }; return { text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }; } if (subCmd === "turn-advance") { const channelId = cmdCtx.channelId; if (!channelId) return { text: "无法获取频道ID", isError: true }; const next = advanceTurn(channelId); return { text: JSON.stringify({ ok: true, nextSpeaker: next }) }; } if (subCmd === "turn-reset") { const channelId = cmdCtx.channelId; if (!channelId) return { text: "无法获取频道ID", isError: true }; resetTurn(channelId); return { text: JSON.stringify({ ok: true }) }; } return { text: `未知子命令: ${subCmd}`, isError: true }; }, }); // Handle NO_REPLY detection before message write // This is where we detect if agent output is NO_REPLY and handle turn advancement // NOTE: This hook is synchronous, do not use async/await api.on("before_message_write", (event, ctx) => { try { // Debug: print all available keys in event and ctx api.logger.info( `whispergate: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`, ); // before_message_write ctx only has { agentId, sessionKey }. // Use session mappings populated during before_model_resolve for channelId/accountId. // Content comes from event.message (AgentMessage). let key = ctx.sessionKey; let channelId: string | undefined; let accountId: string | undefined; // Get from session mapping (set in before_model_resolve) if (key) { channelId = sessionChannelId.get(key); accountId = sessionAccountId.get(key); } // Extract content from event.message (AgentMessage) // Only process assistant messages — before_message_write fires for both // user (incoming) and assistant (outgoing) messages. Incoming messages may // contain end symbols from OTHER agents, which would incorrectly advance the turn. let content = ""; const msg = (event as Record).message as Record | undefined; if (msg) { const role = msg.role as string | undefined; if (role && role !== "assistant") return; // AgentMessage may have content as string or nested if (typeof msg.content === "string") { content = msg.content; } else if (Array.isArray(msg.content)) { // content might be an array of parts (Anthropic format) for (const part of msg.content) { if (typeof part === "string") content += part; else if (part && typeof part === "object" && typeof (part as Record).text === "string") { content += (part as Record).text; } } } } // Fallback to event.content if (!content) { content = ((event as Record).content as string) || ""; } // Always log for debugging - show all available info api.logger.info( `whispergate: DEBUG before_message_write session=${key ?? "undefined"} channel=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} contentType=${typeof content} content=${String(content).slice(0, 200)}`, ); if (!key || !channelId || !accountId) return; // Only the current speaker should advance the turn. // Other agents also trigger before_message_write (for incoming messages or forced no-reply), // but they must not affect turn state. const currentTurn = getTurnDebugInfo(channelId); if (currentTurn.currentSpeaker !== accountId) { api.logger.info( `whispergate: before_message_write skipping non-current-speaker session=${key} accountId=${accountId} currentSpeaker=${currentTurn.currentSpeaker}`, ); return; } const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; ensurePolicyStateLoaded(api, live); const policy = resolvePolicy(live, channelId, policyState.channelPolicies); const trimmed = content.trim(); const isEmpty = trimmed.length === 0; const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); const wasNoReply = isEmpty || isNoReply; // Log turn state for debugging const turnDebug = getTurnDebugInfo(channelId); api.logger.info( `whispergate: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`, ); // Check if this session was forced no-reply or allowed to speak const wasAllowed = sessionAllowed.get(key); if (wasNoReply) { api.logger.info( `whispergate: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`, ); if (wasAllowed === undefined) return; // No record, skip if (wasAllowed === false) { // Forced no-reply - do not advance turn sessionAllowed.delete(key); api.logger.info( `whispergate: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`, ); return; } // Allowed to speak (current speaker) but chose NO_REPLY - advance turn ensureTurnOrder(api, channelId, live); const nextSpeaker = onSpeakerDone(channelId, accountId, true); sessionAllowed.delete(key); sessionTurnHandled.add(key); api.logger.info( `whispergate: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, ); // If all agents NO_REPLY'd (dormant), don't trigger handoff if (!nextSpeaker) { if (shouldDebugLog(live, channelId)) { api.logger.info( `whispergate: before_message_write all agents no-reply, going dormant - no handoff`, ); } return; } // Trigger moderator handoff message (fire-and-forget, don't await) if (live.moderatorBotToken) { const nextUserId = resolveDiscordUserId(api, nextSpeaker); if (nextUserId) { const handoffMsg = `轮到(<@${nextUserId}>)了,如果没有想说的请直接回复NO_REPLY`; void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => { api.logger.warn(`whispergate: before_message_write handoff failed: ${String(err)}`); }); } else { api.logger.warn(`whispergate: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); } } } else if (hasEndSymbol) { // End symbol detected — advance turn NOW (before message is broadcast to other agents) // This prevents the race condition where other agents receive the message // before message_sent fires and advances the turn. ensureTurnOrder(api, channelId, live); const nextSpeaker = onSpeakerDone(channelId, accountId, false); sessionAllowed.delete(key); sessionTurnHandled.add(key); api.logger.info( `whispergate: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, ); } else { api.logger.info( `whispergate: before_message_write no turn action needed session=${key} channel=${channelId}`, ); return; } } catch (err) { api.logger.warn(`whispergate: before_message_write hook failed: ${String(err)}`); } }); // Turn advance: when an agent sends a message, check if it signals end of turn api.on("message_sent", async (event, ctx) => { try { const key = ctx.sessionKey; const c = (ctx || {}) as Record; const e = (event || {}) as Record; // Always log raw context first for debugging api.logger.info( `whispergate: DEBUG message_sent RAW ctxKeys=${JSON.stringify(Object.keys(c))} eventKeys=${JSON.stringify(Object.keys(e))} ` + `ctx.channelId=${String(c.channelId ?? "undefined")} ctx.conversationId=${String(c.conversationId ?? "undefined")} ` + `ctx.accountId=${String(c.accountId ?? "undefined")} event.to=${String(e.to ?? "undefined")} ` + `session=${key ?? "undefined"}`, ); // ctx.channelId is the platform name (e.g. "discord"), NOT the Discord channel snowflake. // Extract real Discord channel ID from conversationId or event.to. let channelId = extractDiscordChannelId(c, e); // Fallback: sessionKey mapping if (!channelId && key) { channelId = sessionChannelId.get(key); } // Fallback: parse from sessionKey if (!channelId && key) { const skMatch = key.match(/:channel:(\d+)$/); if (skMatch) channelId = skMatch[1]; } const accountId = (ctx.accountId as string | undefined) || (key ? sessionAccountId.get(key) : undefined); const content = (event.content as string) || ""; // Debug log api.logger.info( `whispergate: DEBUG message_sent RESOLVED session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`, ); if (!channelId || !accountId) return; const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; ensurePolicyStateLoaded(api, live); const policy = resolvePolicy(live, channelId, policyState.channelPolicies); const trimmed = content.trim(); const isEmpty = trimmed.length === 0; const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); const wasNoReply = isEmpty || isNoReply; // Skip if turn was already advanced in before_message_write if (key && sessionTurnHandled.has(key)) { sessionTurnHandled.delete(key); api.logger.info( `whispergate: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`, ); return; } if (wasNoReply || hasEndSymbol) { const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply); const trigger = wasNoReply ? (isEmpty ? "empty" : "no_reply_keyword") : "end_symbol"; api.logger.info( `whispergate: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`, ); // Moderator handoff: when current speaker NO_REPLY'd and there's a next speaker, // send a handoff message via the moderator bot to trigger the next agent if (wasNoReply && nextSpeaker && live.moderatorBotToken) { const nextUserId = resolveDiscordUserId(api, nextSpeaker); if (nextUserId) { const handoffMsg = `轮到(<@${nextUserId}>)了,如果没有想说的请直接回复NO_REPLY`; sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger); } else { api.logger.warn(`whispergate: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); } } } } catch (err) { api.logger.warn(`whispergate: message_sent hook failed: ${String(err)}`); } }); }, };