From 1d8881577d4742518aac4e2241b1215b85bed5da Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 27 Feb 2026 16:05:39 +0000 Subject: [PATCH 01/16] feat: turn-based speaking + slash commands + enhanced prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Rename tool: whispergateway_tools → whispergate_tools 2. Turn-based speaking mechanism: - New turn-manager.ts maintains per-channel turn state - ChannelPolicy新增turnOrder字段配置发言顺序 - before_model_resolve hook检查当前agent是否为发言人 - 非当前发言人直接切换到no-reply模型 - message_sent hook检测结束符或NO_REPLY时推进turn - message_received检测到human消息时重置turn 3. 注入提示词增强: - buildEndMarkerInstruction增加isGroupChat参数 - 群聊时追加规则:与自己无关时主动回复NO_REPLY 4. Slash command支持: - /whispergate status - 查看频道策略 - /whispergate turn-status - 查看轮流状态 - /whispergate turn-advance - 手动推进轮流 - /whispergate turn-reset - 重置轮流顺序 --- plugin/index.ts | 191 +++++++++++++++++++++++++++++++++++++++-- plugin/rules.ts | 2 + plugin/turn-manager.ts | 136 +++++++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 plugin/turn-manager.ts diff --git a/plugin/index.ts b/plugin/index.ts index 20ac75e..53e0adb 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -2,6 +2,7 @@ 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, setTurnPolicy, getTurnDebugInfo, type TurnPolicy } from "./turn-manager.js"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; @@ -24,9 +25,13 @@ type DebugConfig = { const sessionDecision = new Map(); const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; -function buildEndMarkerInstruction(endSymbols: string[]): string { +function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string { const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚"; - return `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`; + let instruction = `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`; + if (isGroupChat) { + instruction += `\n\n群聊发言规则:如果这条消息与你无关、不需要你回应、或你没有有价值的补充,请主动回复 NO_REPLY。不要为了说话而说话。`; + } + return instruction; } const policyState: PolicyState = { @@ -153,12 +158,40 @@ function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConf const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as Record; policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {}; + syncTurnPolicies(); } 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; +} + +/** Sync turn policies from channel policies into the turn manager */ +function syncTurnPolicies(): void { + for (const [channelId, policy] of Object.entries(policyState.channelPolicies)) { + if (policy.turnOrder?.length) { + setTurnPolicy(channelId, { turnOrder: policy.turnOrder }); + } else { + setTurnPolicy(channelId, undefined); + } + } +} + function persistPolicies(api: OpenClawPluginApi): void { const filePath = policyState.filePath; if (!filePath) throw new Error("policy file path not initialized"); @@ -167,6 +200,7 @@ function persistPolicies(api: OpenClawPluginApi): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(tmp, before, "utf8"); fs.renameSync(tmp, filePath); + syncTurnPolicies(); api.logger.info(`whispergate: policy file persisted: ${filePath}`); } @@ -234,7 +268,7 @@ export default { api.registerTool( { - name: "whispergateway_tools", + name: "whispergate_tools", description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.", parameters: { type: "object", @@ -242,7 +276,7 @@ export default { properties: { action: { type: "string", - enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel"], + 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" }, @@ -269,6 +303,7 @@ export default { humanList: { type: "array", items: { type: "string" } }, agentList: { type: "array", items: { type: "string" } }, endSymbols: { type: "array", items: { type: "string" } }, + turnOrder: { type: "array", items: { type: "string" } }, }, required: ["action"], }, @@ -303,7 +338,7 @@ export default { const text = await r.text(); if (!r.ok) { return { - content: [{ type: "text", text: `whispergateway_tools discord failed (${r.status}): ${text}` }], + content: [{ type: "text", text: `whispergate_tools discord failed (${r.status}): ${text}` }], isError: true, }; } @@ -331,6 +366,7 @@ export default { 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, + turnOrder: Array.isArray(params.turnOrder) ? (params.turnOrder as string[]) : undefined, }; policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record) as ChannelPolicy; persistPolicies(api); @@ -355,6 +391,26 @@ export default { } } + 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 }; }, }, @@ -370,6 +426,16 @@ export default { if (shouldDebugLog(livePre, preChannelId)) { api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); } + + // Reset turn when human sends a message in a turn-managed channel + if (preChannelId) { + const from = typeof (e as Record).from === "string" ? (e as Record).from as string : ""; + const humanList = livePre.humanList || livePre.bypassUserIds || []; + if (humanList.includes(from)) { + resetTurn(preChannelId); + api.logger.info(`whispergate: turn reset by human message in channel=${preChannelId} from=${from}`); + } + } } catch (err) { api.logger.warn(`whispergate: message hook failed: ${String(err)}`); } @@ -422,6 +488,23 @@ export default { } } + // Turn-based check: if channel has turn order, only current speaker can respond + if (!rec.decision.shouldUseNoReply && derived.channelId) { + const accountId = resolveAccountId(api, ctx.agentId || ""); + if (accountId) { + const turnCheck = checkTurn(derived.channelId, accountId); + if (!turnCheck.isSpeaker) { + api.logger.info( + `whispergate: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`, + ); + return { + providerOverride: live.noReplyProvider, + modelOverride: live.noReplyModel, + }; + } + } + } + if (!rec.decision.shouldUseNoReply) { // 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型 if (rec.needsRestore) { @@ -512,10 +595,104 @@ export default { const prompt = ((event as Record).prompt as string) || ""; const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies); - const instruction = buildEndMarkerInstruction(policy.endSymbols); + const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true"; + const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat); - api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`); + api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); return { prependContext: 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 }; + }, + }); + + // Turn advance: when an agent sends a message, check if it signals end of turn + api.on("message_sent", async (event, ctx) => { + try { + const channelId = ctx.channelId; + const accountId = ctx.accountId; + const content = event.content || ""; + + if (!channelId || !accountId) return; + + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + ensurePolicyStateLoaded(api, live); + const policy = resolvePolicy(live, channelId, policyState.channelPolicies); + + // Check if this message ends the turn: + // 1. Content is empty (no-reply was used) + // 2. Content ends with an end symbol + // 3. Content is a gateway keyword like NO_REPLY + 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); + + if (isEmpty || isNoReply || hasEndSymbol) { + const nextSpeaker = advanceTurn(channelId); + if (nextSpeaker) { + api.logger.info( + `whispergate: turn advanced in channel=${channelId} from=${accountId} to=${nextSpeaker} ` + + `trigger=${isEmpty ? "empty" : isNoReply ? "no_reply" : "end_symbol"}`, + ); + + // Wake the next speaker by sending a nudge message to the channel + // The next agent will pick it up as a new message_received + // We use a zero-width space message to avoid visible noise + // Actually, we need the next agent's session to receive a trigger. + // The simplest approach: the turn manager just advances state. + // The next message in the channel (from any source) will allow + // the next speaker to respond. If the current speaker said NO_REPLY + // (empty content), the original message is still pending for the + // next speaker. + } + } + } catch (err) { + api.logger.warn(`whispergate: message_sent hook failed: ${String(err)}`); + } + }); }, }; diff --git a/plugin/rules.ts b/plugin/rules.ts index ca00593..4152a80 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -17,6 +17,8 @@ export type ChannelPolicy = { humanList?: string[]; agentList?: string[]; endSymbols?: string[]; + /** Ordered list of Discord account IDs for turn-based speaking */ + turnOrder?: string[]; }; export type Decision = { diff --git a/plugin/turn-manager.ts b/plugin/turn-manager.ts new file mode 100644 index 0000000..7bb8403 --- /dev/null +++ b/plugin/turn-manager.ts @@ -0,0 +1,136 @@ +/** + * Turn-based speaking manager for group channels. + * + * Maintains per-channel turn order so that only one agent speaks at a time. + * When the current speaker finishes (end symbol or NO_REPLY), the turn advances + * to the next agent in the rotation. + */ + +export type TurnPolicy = { + /** Ordered list of Discord account IDs (bot user IDs) that participate in turn rotation */ + turnOrder: string[]; +}; + +export type ChannelTurnState = { + /** Index into turnOrder for the current speaker */ + currentIndex: number; + /** Timestamp of last turn advance */ + lastAdvancedAt: number; +}; + +/** In-memory turn state per channel */ +const channelTurns = new Map(); + +/** Turn policies per channel (loaded from channel policies) */ +const turnPolicies = new Map(); + +/** Turn timeout: if the current speaker hasn't responded in this time, auto-advance */ +const TURN_TIMEOUT_MS = 30_000; + +export function setTurnPolicy(channelId: string, policy: TurnPolicy | undefined): void { + if (!policy || !policy.turnOrder?.length) { + turnPolicies.delete(channelId); + channelTurns.delete(channelId); + return; + } + turnPolicies.set(channelId, policy); + // Initialize turn state if not exists + if (!channelTurns.has(channelId)) { + channelTurns.set(channelId, { currentIndex: 0, lastAdvancedAt: Date.now() }); + } +} + +export function getTurnPolicy(channelId: string): TurnPolicy | undefined { + return turnPolicies.get(channelId); +} + +export function getTurnState(channelId: string): ChannelTurnState | undefined { + return channelTurns.get(channelId); +} + +/** + * Check if the given accountId is the current speaker for this channel. + * Returns: { isSpeaker: true } or { isSpeaker: false, reason: string } + */ +export function checkTurn(channelId: string, accountId: string): { isSpeaker: boolean; currentSpeaker?: string; reason: string } { + const policy = turnPolicies.get(channelId); + if (!policy) { + return { isSpeaker: true, reason: "no_turn_policy" }; + } + + const order = policy.turnOrder; + if (!order.includes(accountId)) { + // Not in turn order — could be human or unmanaged agent, allow through + return { isSpeaker: true, reason: "not_in_turn_order" }; + } + + let state = channelTurns.get(channelId); + if (!state) { + state = { currentIndex: 0, lastAdvancedAt: Date.now() }; + channelTurns.set(channelId, state); + } + + // Auto-advance if turn has timed out + const now = Date.now(); + if (now - state.lastAdvancedAt > TURN_TIMEOUT_MS) { + advanceTurn(channelId); + state = channelTurns.get(channelId)!; + } + + const currentSpeaker = order[state.currentIndex]; + if (accountId === currentSpeaker) { + return { isSpeaker: true, currentSpeaker, reason: "is_current_speaker" }; + } + + return { isSpeaker: false, currentSpeaker, reason: "not_current_speaker" }; +} + +/** + * Advance turn to the next agent in rotation. + * Returns the new current speaker's accountId. + */ +export function advanceTurn(channelId: string): string | undefined { + const policy = turnPolicies.get(channelId); + if (!policy) return undefined; + + const order = policy.turnOrder; + let state = channelTurns.get(channelId); + if (!state) { + state = { currentIndex: 0, lastAdvancedAt: Date.now() }; + channelTurns.set(channelId, state); + return order[0]; + } + + state.currentIndex = (state.currentIndex + 1) % order.length; + state.lastAdvancedAt = Date.now(); + return order[state.currentIndex]; +} + +/** + * Reset turn to the first agent (e.g., when a human sends a message). + */ +export function resetTurn(channelId: string): void { + const state = channelTurns.get(channelId); + if (state) { + state.currentIndex = 0; + state.lastAdvancedAt = Date.now(); + } +} + +/** + * Get debug info for a channel's turn state. + */ +export function getTurnDebugInfo(channelId: string): Record { + const policy = turnPolicies.get(channelId); + const state = channelTurns.get(channelId); + if (!policy) return { channelId, hasTurnPolicy: false }; + return { + channelId, + hasTurnPolicy: true, + turnOrder: policy.turnOrder, + currentIndex: state?.currentIndex ?? 0, + currentSpeaker: policy.turnOrder[state?.currentIndex ?? 0], + lastAdvancedAt: state?.lastAdvancedAt, + timeSinceAdvanceMs: state ? Date.now() - state.lastAdvancedAt : null, + }; +} -- 2.49.1 From 476308d0dffc8e8d8d267adeeac096a4e0abfccc Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 27 Feb 2026 23:27:36 +0000 Subject: [PATCH 02/16] refactor: auto-managed turn order + dormant state + identity injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn system redesign: - Turn order auto-populated from config bindings (all bot accounts) - No manual turnOrder config needed - Humans (humanList) excluded from turn order automatically - Dormant state: when all agents NO_REPLY in a cycle, currentSpeaker=null - Reactivation: any new message wakes the system - Human message → start from first in order - Bot not in order → start from first - Bot in order → next after sender - Skip already-NO_REPLY'd agents when advancing Identity injection: - Group chat prompts now include agent identity - Format: '你是 {name}(Discord 账号: {accountId})' Other: - Remove turnOrder from ChannelPolicy (no longer configurable) - Add TURN-WAKEUP-PROBLEM.md documenting the NO_REPLY wake-up challenge - Update message_received to call onNewMessage with proper human detection - Update message_sent to call onSpeakerDone with NO_REPLY tracking --- docs/TURN-WAKEUP-PROBLEM.md | 108 ++++++++++++++ plugin/index.ts | 123 ++++++++++------ plugin/rules.ts | 2 - plugin/turn-manager.ts | 275 ++++++++++++++++++++++++------------ 4 files changed, 377 insertions(+), 131 deletions(-) create mode 100644 docs/TURN-WAKEUP-PROBLEM.md diff --git a/docs/TURN-WAKEUP-PROBLEM.md b/docs/TURN-WAKEUP-PROBLEM.md new file mode 100644 index 0000000..f56b59f --- /dev/null +++ b/docs/TURN-WAKEUP-PROBLEM.md @@ -0,0 +1,108 @@ +# Turn-Based Speaking: Wakeup Problem + +## Context + +WhisperGate implements turn-based speaking for Discord group channels where multiple AI agents coexist. Only one agent (the "current speaker") is allowed to respond at a time. Others are silenced via a no-reply model override. + +## The Problem + +When the current speaker responds with **NO_REPLY** (decides the message is not relevant to them), the turn advances to the next agent. However, **the next agent has no trigger to start speaking**. + +### Why This Happens + +1. A message arrives in the Discord channel +2. OpenClaw routes it to **all** agent sessions in that channel simultaneously +3. The WhisperGate plugin intercepts at `before_model_resolve`: + - Current speaker → allowed to process + - Everyone else → forced to no-reply model (message is "consumed" silently) +4. Current speaker processes the message and returns NO_REPLY +5. `message_sent` hook detects NO_REPLY → turn advances to next agent +6. **But the next agent already "consumed" the message in step 3** — their session processed it (as no-reply) and moved on +7. No new message exists to trigger the next agent + +### The Result + +After a NO_REPLY, the next speaker sits idle until a **new** message arrives in the channel (from a human or another source). The original message that should have been passed to the next speaker is lost. + +## When This Matters + +- **Single-round conversation**: Human asks a question → Agent A says NO_REPLY → Agent B should answer but can't +- **Chain conversations**: Agent A defers → Agent B defers → Agent C should speak but never gets triggered + +## When This Doesn't Matter + +- **End-symbol responses**: When an agent actually speaks (ends with 🔚), the turn advances and the next agent will respond to the **next** message. This is fine. +- **Human-driven channels**: If humans keep sending messages, the dormant state resolves quickly. + +## Possible Solutions + +### 1. Synthetic Trigger Message (Plugin-Side) + +After detecting NO_REPLY and advancing the turn, the plugin sends a **synthetic message** to the channel that triggers the next agent. + +**Challenges:** +- The plugin SDK (`message_sent` hook) doesn't have an API to inject messages into agent sessions +- Sending a real Discord message (even invisible like zero-width space) creates noise and may confuse other agents +- The synthetic message wouldn't contain the original user's context + +### 2. Deferred Evaluation (Don't Block in before_model_resolve) + +Instead of blocking non-speakers at `before_model_resolve`, let all agents receive the message but inject a "you are not the current speaker, reply NO_REPLY" instruction. The current speaker gets a normal prompt. + +After the current speaker responds with NO_REPLY, the plugin would need to **re-trigger** the next agent's session with the same message. + +**Challenges:** +- All agents still consume tokens for the NO_REPLY evaluation +- Re-triggering a session with an already-processed message requires OpenClaw internal APIs + +### 3. Queue + Replay (Plugin-Side State) + +The plugin stores the original message when it arrives. After NO_REPLY, it replays the message by injecting it into the next speaker's session. + +**Challenges:** +- Requires access to session injection API (not available in current plugin SDK) +- Managing the message queue adds complexity + +### 4. Gateway-Level Support (OpenClaw Core Change) + +Add a plugin hook return value like `{ defer: true }` in `before_model_resolve` that tells OpenClaw: "don't process this message yet, but keep it pending." When the turn advances, the plugin could call `api.retrigger(sessionKey)` to replay the pending message. + +**Challenges:** +- Requires changes to OpenClaw core, not just the plugin +- Needs design discussion with the OpenClaw team + +### 5. Bot-to-Bot Handoff via Discord Message + +When current speaker NO_REPLYs, have **that bot** send a brief handoff message in the channel: e.g., "(轮到下一位)" or a reaction. This real Discord message triggers all agents, and the turn manager ensures only the next speaker responds. + +**Challenges:** +- Adds visible noise to the channel (could use a convention like a specific emoji reaction) +- The no-reply'd bot can't send messages (it was silenced) +- Could use the discord-control-api to send as a different bot + +### 6. Timer-Based Retry (Pragmatic) + +After advancing the turn, set a short timer (e.g., 2-3 seconds). If no new message has arrived, send a minimal trigger. This could be an internal "nudge" if the SDK supports it. + +**Challenges:** +- Timing is fragile +- Still needs a mechanism to trigger the next agent + +## Recommendation + +**Solution 5 (Bot-to-Bot Handoff)** is the most pragmatic with current constraints. The implementation would be: + +1. In the `message_sent` hook, after detecting NO_REPLY and advancing the turn: +2. Use the discord-control-api to send a short message (e.g., `[轮转]` or a specific emoji) from the **next speaker's bot account** in the channel +3. This real Discord message triggers OpenClaw to route it to all agents +4. The turn manager allows only the (now-current) next speaker to respond +5. The next speaker sees the original conversation context in their session history and responds appropriately + +**Downside:** Adds a visible "[轮转]" message. Could be mitigated by immediately deleting it after delivery, or using a reaction instead of a message. + +## Open Questions + +1. Does the OpenClaw plugin SDK support injecting messages into sessions? +2. Can plugins access the Discord client to send messages directly? +3. Would an OpenClaw core `defer`/`retrigger` mechanism be feasible? +4. Is visible channel noise acceptable for the handoff message? diff --git a/plugin/index.ts b/plugin/index.ts index 53e0adb..b969599 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -2,7 +2,7 @@ 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, setTurnPolicy, getTurnDebugInfo, type TurnPolicy } from "./turn-manager.js"; +import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; @@ -158,7 +158,6 @@ function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConf const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as Record; policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {}; - syncTurnPolicies(); } catch (err) { api.logger.warn(`whispergate: failed init policy file ${filePath}: ${String(err)}`); policyState.channelPolicies = {}; @@ -181,15 +180,63 @@ function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | und return undefined; } -/** Sync turn policies from channel policies into the turn manager */ -function syncTurnPolicies(): void { - for (const [channelId, policy] of Object.entries(policyState.channelPolicies)) { - if (policy.turnOrder?.length) { - setTurnPolicy(channelId, { turnOrder: policy.turnOrder }); - } else { - setTurnPolicy(channelId, undefined); +/** + * Get all Discord bot accountIds from config bindings (excluding humanList-bound agents). + */ +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; +} + +/** + * Ensure turn order is initialized for a channel. + * Uses all bot accounts from bindings as the turn order. + */ +function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void { + const botAccounts = getAllBotAccountIds(api); + 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})。`; } function persistPolicies(api: OpenClawPluginApi): void { @@ -200,7 +247,6 @@ function persistPolicies(api: OpenClawPluginApi): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(tmp, before, "utf8"); fs.renameSync(tmp, filePath); - syncTurnPolicies(); api.logger.info(`whispergate: policy file persisted: ${filePath}`); } @@ -303,7 +349,6 @@ export default { humanList: { type: "array", items: { type: "string" } }, agentList: { type: "array", items: { type: "string" } }, endSymbols: { type: "array", items: { type: "string" } }, - turnOrder: { type: "array", items: { type: "string" } }, }, required: ["action"], }, @@ -366,7 +411,6 @@ export default { 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, - turnOrder: Array.isArray(params.turnOrder) ? (params.turnOrder as string[]) : undefined, }; policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record) as ChannelPolicy; persistPolicies(api); @@ -427,13 +471,17 @@ export default { api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); } - // Reset turn when human sends a message in a turn-managed channel + // Turn management on message received if (preChannelId) { + ensureTurnOrder(api, preChannelId); const from = typeof (e as Record).from === "string" ? (e as Record).from as string : ""; const humanList = livePre.humanList || livePre.bypassUserIds || []; - if (humanList.includes(from)) { - resetTurn(preChannelId); - api.logger.info(`whispergate: turn reset by human message in channel=${preChannelId} from=${from}`); + const isHuman = humanList.includes(from); + // Resolve sender's accountId (for bot messages) + const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined; + 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) { @@ -490,10 +538,11 @@ export default { // Turn-based check: if channel has turn order, only current speaker can respond if (!rec.decision.shouldUseNoReply && derived.channelId) { + ensureTurnOrder(api, derived.channelId); const accountId = resolveAccountId(api, ctx.agentId || ""); if (accountId) { const turnCheck = checkTurn(derived.channelId, accountId); - if (!turnCheck.isSpeaker) { + if (!turnCheck.allowed) { api.logger.info( `whispergate: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`, ); @@ -598,8 +647,15 @@ export default { 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"; + } + api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); - return { prependContext: instruction }; + return { prependContext: identity + instruction }; }); // Register slash commands for Discord @@ -661,34 +717,21 @@ export default { ensurePolicyStateLoaded(api, live); const policy = resolvePolicy(live, channelId, policyState.channelPolicies); - // Check if this message ends the turn: - // 1. Content is empty (no-reply was used) - // 2. Content ends with an end symbol - // 3. Content is a gateway keyword like NO_REPLY 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; - if (isEmpty || isNoReply || hasEndSymbol) { - const nextSpeaker = advanceTurn(channelId); - if (nextSpeaker) { - api.logger.info( - `whispergate: turn advanced in channel=${channelId} from=${accountId} to=${nextSpeaker} ` + - `trigger=${isEmpty ? "empty" : isNoReply ? "no_reply" : "end_symbol"}`, - ); - - // Wake the next speaker by sending a nudge message to the channel - // The next agent will pick it up as a new message_received - // We use a zero-width space message to avoid visible noise - // Actually, we need the next agent's session to receive a trigger. - // The simplest approach: the turn manager just advances state. - // The next message in the channel (from any source) will allow - // the next speaker to respond. If the current speaker said NO_REPLY - // (empty content), the original message is still pending for the - // next speaker. - } + 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}`, + ); + // NOTE: if wasNoReply and nextSpeaker is set, the next agent needs + // a trigger message to start speaking. See TURN-WAKEUP-PROBLEM.md } } catch (err) { api.logger.warn(`whispergate: message_sent hook failed: ${String(err)}`); diff --git a/plugin/rules.ts b/plugin/rules.ts index 4152a80..ca00593 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -17,8 +17,6 @@ export type ChannelPolicy = { humanList?: string[]; agentList?: string[]; endSymbols?: string[]; - /** Ordered list of Discord account IDs for turn-based speaking */ - turnOrder?: string[]; }; export type Decision = { diff --git a/plugin/turn-manager.ts b/plugin/turn-manager.ts index 7bb8403..033699e 100644 --- a/plugin/turn-manager.ts +++ b/plugin/turn-manager.ts @@ -1,136 +1,233 @@ /** * Turn-based speaking manager for group channels. * - * Maintains per-channel turn order so that only one agent speaks at a time. - * When the current speaker finishes (end symbol or NO_REPLY), the turn advances - * to the next agent in the rotation. + * Rules: + * - Humans (humanList) are never in the turn order + * - Turn order is auto-populated from channel/server members minus humans + * - currentSpeaker can be null (dormant state) + * - When ALL agents in a cycle have NO_REPLY'd, state goes dormant (null) + * - Dormant → any new message reactivates: + * - If sender is NOT in turn order → current = first in list + * - If sender IS in turn order → current = next after sender */ -export type TurnPolicy = { - /** Ordered list of Discord account IDs (bot user IDs) that participate in turn rotation */ - turnOrder: string[]; -}; - export type ChannelTurnState = { - /** Index into turnOrder for the current speaker */ - currentIndex: number; - /** Timestamp of last turn advance */ - lastAdvancedAt: number; + /** Ordered accountIds for this channel (auto-populated, shuffled) */ + turnOrder: string[]; + /** Current speaker accountId, or null if dormant */ + currentSpeaker: string | null; + /** Set of accountIds that have NO_REPLY'd this cycle */ + noRepliedThisCycle: Set; + /** Timestamp of last state change */ + lastChangedAt: number; }; -/** In-memory turn state per channel */ const channelTurns = new Map(); -/** Turn policies per channel (loaded from channel policies) */ -const turnPolicies = new Map(); +/** Turn timeout: if the current speaker hasn't responded, auto-advance */ +const TURN_TIMEOUT_MS = 60_000; -/** Turn timeout: if the current speaker hasn't responded in this time, auto-advance */ -const TURN_TIMEOUT_MS = 30_000; +// --- helpers --- -export function setTurnPolicy(channelId: string, policy: TurnPolicy | undefined): void { - if (!policy || !policy.turnOrder?.length) { - turnPolicies.delete(channelId); - channelTurns.delete(channelId); +function shuffleArray(arr: T[]): T[] { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +// --- public API --- + +/** + * Initialize or update the turn order for a channel. + * Called with the list of bot accountIds (already filtered, humans excluded). + */ +export function initTurnOrder(channelId: string, botAccountIds: string[]): void { + const existing = channelTurns.get(channelId); + if (existing) { + // Check if membership changed + const oldSet = new Set(existing.turnOrder); + const newSet = new Set(botAccountIds); + const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id)); + if (same) return; // no change + } + + channelTurns.set(channelId, { + turnOrder: shuffleArray(botAccountIds), + currentSpeaker: null, // start dormant + noRepliedThisCycle: new Set(), + lastChangedAt: Date.now(), + }); +} + +/** + * Check if the given accountId is allowed to speak. + */ +export function checkTurn(channelId: string, accountId: string): { + allowed: boolean; + currentSpeaker: string | null; + reason: string; +} { + const state = channelTurns.get(channelId); + if (!state || state.turnOrder.length === 0) { + return { allowed: true, currentSpeaker: null, reason: "no_turn_state" }; + } + + // Not in turn order (human or unknown) → always allowed + if (!state.turnOrder.includes(accountId)) { + return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" }; + } + + // Dormant → not allowed (will be activated by onNewMessage) + if (state.currentSpeaker === null) { + return { allowed: false, currentSpeaker: null, reason: "dormant" }; + } + + // Check timeout → auto-advance + if (Date.now() - state.lastChangedAt > TURN_TIMEOUT_MS) { + advanceTurn(channelId); + // Re-check after advance + const updated = channelTurns.get(channelId)!; + if (updated.currentSpeaker === accountId) { + return { allowed: true, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_self" }; + } + return { allowed: false, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_other" }; + } + + if (accountId === state.currentSpeaker) { + return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "is_current_speaker" }; + } + + return { allowed: false, currentSpeaker: state.currentSpeaker, reason: "not_current_speaker" }; +} + +/** + * Called when a new message arrives in the channel. + * Handles reactivation from dormant state and human-triggered resets. + * + * @param senderAccountId - the accountId of the message sender (could be human/bot/unknown) + * @param isHuman - whether the sender is in the humanList + */ +export function onNewMessage(channelId: string, senderAccountId: string | undefined, isHuman: boolean): void { + const state = channelTurns.get(channelId); + if (!state || state.turnOrder.length === 0) return; + + if (isHuman) { + // Human message: activate, start from first in order + state.currentSpeaker = state.turnOrder[0]; + state.noRepliedThisCycle = new Set(); + state.lastChangedAt = Date.now(); return; } - turnPolicies.set(channelId, policy); - // Initialize turn state if not exists - if (!channelTurns.has(channelId)) { - channelTurns.set(channelId, { currentIndex: 0, lastAdvancedAt: Date.now() }); + + if (state.currentSpeaker !== null) { + // Already active, no change needed from incoming message + return; } -} -export function getTurnPolicy(channelId: string): TurnPolicy | undefined { - return turnPolicies.get(channelId); -} - -export function getTurnState(channelId: string): ChannelTurnState | undefined { - return channelTurns.get(channelId); + // Dormant state + non-human message → reactivate + if (senderAccountId && state.turnOrder.includes(senderAccountId)) { + // Sender is in turn order → next after sender + const idx = state.turnOrder.indexOf(senderAccountId); + const nextIdx = (idx + 1) % state.turnOrder.length; + state.currentSpeaker = state.turnOrder[nextIdx]; + } else { + // Sender not in turn order → start from first + state.currentSpeaker = state.turnOrder[0]; + } + state.noRepliedThisCycle = new Set(); + state.lastChangedAt = Date.now(); } /** - * Check if the given accountId is the current speaker for this channel. - * Returns: { isSpeaker: true } or { isSpeaker: false, reason: string } + * Called when the current speaker finishes (end symbol detected) or says NO_REPLY. + * @param wasNoReply - true if the speaker said NO_REPLY (empty/silent) + * @returns the new currentSpeaker (or null if dormant) */ -export function checkTurn(channelId: string, accountId: string): { isSpeaker: boolean; currentSpeaker?: string; reason: string } { - const policy = turnPolicies.get(channelId); - if (!policy) { - return { isSpeaker: true, reason: "no_turn_policy" }; +export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: boolean): string | null { + const state = channelTurns.get(channelId); + if (!state) return null; + if (state.currentSpeaker !== accountId) return state.currentSpeaker; // not current speaker, ignore + + if (wasNoReply) { + state.noRepliedThisCycle.add(accountId); + + // Check if ALL agents have NO_REPLY'd this cycle + const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id)); + if (allNoReplied) { + // Go dormant + state.currentSpeaker = null; + state.noRepliedThisCycle = new Set(); + state.lastChangedAt = Date.now(); + return null; + } + } else { + // Successful speech resets the cycle counter + state.noRepliedThisCycle = new Set(); } - const order = policy.turnOrder; - if (!order.includes(accountId)) { - // Not in turn order — could be human or unmanaged agent, allow through - return { isSpeaker: true, reason: "not_in_turn_order" }; - } - - let state = channelTurns.get(channelId); - if (!state) { - state = { currentIndex: 0, lastAdvancedAt: Date.now() }; - channelTurns.set(channelId, state); - } - - // Auto-advance if turn has timed out - const now = Date.now(); - if (now - state.lastAdvancedAt > TURN_TIMEOUT_MS) { - advanceTurn(channelId); - state = channelTurns.get(channelId)!; - } - - const currentSpeaker = order[state.currentIndex]; - if (accountId === currentSpeaker) { - return { isSpeaker: true, currentSpeaker, reason: "is_current_speaker" }; - } - - return { isSpeaker: false, currentSpeaker, reason: "not_current_speaker" }; + return advanceTurn(channelId); } /** - * Advance turn to the next agent in rotation. - * Returns the new current speaker's accountId. + * Advance to next speaker in order. */ -export function advanceTurn(channelId: string): string | undefined { - const policy = turnPolicies.get(channelId); - if (!policy) return undefined; +export function advanceTurn(channelId: string): string | null { + const state = channelTurns.get(channelId); + if (!state || state.turnOrder.length === 0) return null; - const order = policy.turnOrder; - let state = channelTurns.get(channelId); - if (!state) { - state = { currentIndex: 0, lastAdvancedAt: Date.now() }; - channelTurns.set(channelId, state); - return order[0]; + if (state.currentSpeaker === null) return null; + + const idx = state.turnOrder.indexOf(state.currentSpeaker); + const nextIdx = (idx + 1) % state.turnOrder.length; + + // Skip agents that already NO_REPLY'd this cycle + let attempts = 0; + let candidateIdx = nextIdx; + while (state.noRepliedThisCycle.has(state.turnOrder[candidateIdx]) && attempts < state.turnOrder.length) { + candidateIdx = (candidateIdx + 1) % state.turnOrder.length; + attempts++; } - state.currentIndex = (state.currentIndex + 1) % order.length; - state.lastAdvancedAt = Date.now(); - return order[state.currentIndex]; + if (attempts >= state.turnOrder.length) { + // All have NO_REPLY'd + state.currentSpeaker = null; + state.lastChangedAt = Date.now(); + return null; + } + + state.currentSpeaker = state.turnOrder[candidateIdx]; + state.lastChangedAt = Date.now(); + return state.currentSpeaker; } /** - * Reset turn to the first agent (e.g., when a human sends a message). + * Force reset: go dormant. */ export function resetTurn(channelId: string): void { const state = channelTurns.get(channelId); if (state) { - state.currentIndex = 0; - state.lastAdvancedAt = Date.now(); + state.currentSpeaker = null; + state.noRepliedThisCycle = new Set(); + state.lastChangedAt = Date.now(); } } /** - * Get debug info for a channel's turn state. + * Get debug info. */ export function getTurnDebugInfo(channelId: string): Record { - const policy = turnPolicies.get(channelId); const state = channelTurns.get(channelId); - if (!policy) return { channelId, hasTurnPolicy: false }; + if (!state) return { channelId, hasTurnState: false }; return { channelId, - hasTurnPolicy: true, - turnOrder: policy.turnOrder, - currentIndex: state?.currentIndex ?? 0, - currentSpeaker: policy.turnOrder[state?.currentIndex ?? 0], - lastAdvancedAt: state?.lastAdvancedAt, - timeSinceAdvanceMs: state ? Date.now() - state.lastAdvancedAt : null, + hasTurnState: true, + turnOrder: state.turnOrder, + currentSpeaker: state.currentSpeaker, + noRepliedThisCycle: [...state.noRepliedThisCycle], + lastChangedAt: state.lastChangedAt, + dormant: state.currentSpeaker === null, }; } -- 2.49.1 From 54ff78cffe98cf4f5d4ca8facef2d8939463f82a Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 11:39:11 +0000 Subject: [PATCH 03/16] feat: moderator bot for turn handoff messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dedicated moderator Discord bot that sends handoff messages when the current speaker says NO_REPLY. This solves the wakeup problem. Flow: 1. Agent A is current speaker, receives message 2. Agent A responds with NO_REPLY 3. Plugin detects NO_REPLY in message_sent hook, advances turn to Agent B 4. Plugin sends via moderator bot: '轮到(@AgentB)了,如果没有想说的请直接回复NO_REPLY' 5. This real Discord message triggers Agent B's session 6. Turn manager allows Agent B to respond Implementation: - moderatorBotToken config field for the moderator bot's Discord token - userIdFromToken() extracts Discord user ID from bot token (base64) - resolveDiscordUserId() maps accountId → Discord user ID via account tokens - sendModeratorMessage() calls Discord REST API directly - message_received ignores moderator bot messages (transparent to turn state) - Moderator bot is NOT in the turn order --- plugin/index.ts | 91 ++++++++++++++++++++++++++++++++++++++++++++----- plugin/rules.ts | 2 ++ 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index b969599..3c18b15 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -239,6 +239,61 @@ function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | u 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"); @@ -475,13 +530,22 @@ export default { if (preChannelId) { ensureTurnOrder(api, preChannelId); const from = typeof (e as Record).from === "string" ? (e as Record).from as string : ""; - const humanList = livePre.humanList || livePre.bypassUserIds || []; - const isHuman = humanList.includes(from); - // Resolve sender's accountId (for bot messages) - const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined; - onNewMessage(preChannelId, senderAccountId, isHuman); - if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`whispergate: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`); + + // 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; + 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) { @@ -730,8 +794,17 @@ export default { api.logger.info( `whispergate: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`, ); - // NOTE: if wasNoReply and nextSpeaker is set, the next agent needs - // a trigger message to start speaking. See TURN-WAKEUP-PROBLEM.md + // 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)}`); diff --git a/plugin/rules.ts b/plugin/rules.ts index ca00593..9abcda9 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -10,6 +10,8 @@ export type WhisperGateConfig = { endSymbols?: string[]; noReplyProvider: string; noReplyModel: string; + /** Discord bot token for the moderator bot (used for turn handoff messages) */ + moderatorBotToken?: string; }; export type ChannelPolicy = { -- 2.49.1 From 8c8757e9a78b38ea5282aab7bf4c8e206827dbb7 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 12:11:04 +0000 Subject: [PATCH 04/16] fix: add moderatorBotToken to plugin configSchema The plugin's openclaw.plugin.json has additionalProperties:false, so any config field not in the schema causes gateway startup failure. --- plugin/openclaw.plugin.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index d10f3e5..1cb7fab 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -24,7 +24,8 @@ "discordControlApiToken": { "type": "string" }, "discordControlCallerId": { "type": "string" }, "enableDebugLogs": { "type": "boolean", "default": false }, - "debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] } + "debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] }, + "moderatorBotToken": { "type": "string" } }, "required": ["noReplyProvider", "noReplyModel"] } -- 2.49.1 From fb50b625093cfa6cf8554ad27907161a073cc318 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 12:27:58 +0000 Subject: [PATCH 05/16] fix: include turn-manager.ts in package-plugin.mjs The packaging script didn't copy turn-manager.ts to dist, causing 'Cannot find module ./turn-manager.js' at gateway startup. --- scripts/package-plugin.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/package-plugin.mjs b/scripts/package-plugin.mjs index 9e65873..462abc4 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", "openclaw.plugin.json", "README.md", "package.json"]) { +for (const f of ["index.ts", "rules.ts", "turn-manager.ts", "openclaw.plugin.json", "README.md", "package.json"]) { fs.copyFileSync(path.join(pluginDir, f), path.join(outDir, f)); } -- 2.49.1 From a6f2be44b715d0ed32824cb53db7f32c7a5e2ded Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 12:33:58 +0000 Subject: [PATCH 06/16] 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)); } -- 2.49.1 From 385990ab904777c67b6d856002a2640aee5e28f2 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 12:41:46 +0000 Subject: [PATCH 07/16] fix: turn check runs independently of evaluateDecision Turn order should be enforced for ALL messages, not just non-human ones. Previously, human messages bypassed turn check because they go through human_list_sender path with shouldUseNoReply=false. Now turn check always runs when channel has turn state. --- plugin/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 2a0f2e8..2dd9411 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -607,8 +607,9 @@ export default { } } - // Turn-based check: if channel has turn order, only current speaker can respond - if (!rec.decision.shouldUseNoReply && derived.channelId) { + // 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) { -- 2.49.1 From 86fdc63802cb8df0213ef31f141601a031ef925a Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 18:49:17 +0000 Subject: [PATCH 08/16] fix: moderator presence reconnect loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes: 1. Multiple plugin subsystems each called startModeratorPresence, creating competing WebSocket connections to the same bot token. Discord only allows one connection per bot → 4008 rate limit → infinite reconnect loop (1000+ connects → token reset by Discord) 2. Invalid session (op 9) handler called scheduleReconnect, but the new connection would also get kicked → cascading reconnects Fixes: - Singleton guard: startModeratorPresence is a no-op if already started - cleanup() nullifies old ws handlers before creating new connection - Stale ws check: all callbacks verify they belong to current ws - Exponential backoff with cap (max 60s) instead of fixed 2-5s delay - heartbeat ACK tracking: detect zombie connections - Non-recoverable codes (4004) properly stop all reconnection --- plugin/moderator-presence.ts | 142 +++++++++++++++++++++++++++++------ 1 file changed, 118 insertions(+), 24 deletions(-) diff --git a/plugin/moderator-presence.ts b/plugin/moderator-presence.ts index 5f4e1e9..dfe311e 100644 --- a/plugin/moderator-presence.ts +++ b/plugin/moderator-presence.ts @@ -1,15 +1,20 @@ /** * 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 | null = null; +let heartbeatAcked = true; let lastSequence: number | null = null; let sessionId: string | null = null; let resumeUrl: string | null = null; let reconnectTimer: ReturnType | null = null; let destroyed = false; +let started = false; // singleton guard type Logger = { info: (msg: string) => void; @@ -17,6 +22,8 @@ type Logger = { }; 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) { if (ws?.readyState === WebSocket.OPEN) { @@ -26,29 +33,79 @@ function sendPayload(data: Record) { function startHeartbeat(intervalMs: number) { stopHeartbeat(); + heartbeatAcked = true; + + // First heartbeat after jitter const jitter = Math.floor(Math.random() * intervalMs); - setTimeout(() => { + 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; } function stopHeartbeat() { if (heartbeatInterval) { clearInterval(heartbeatInterval); + clearTimeout(heartbeatInterval as unknown as ReturnType); 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; - ws = new WebSocket(url); + + try { + ws = new WebSocket(url); + } catch (err) { + logger.warn(`whispergate: 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, @@ -75,17 +132,20 @@ function connect(token: string, logger: Logger, isResume = false) { }; 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) lastSequence = s; + 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 }); @@ -100,36 +160,49 @@ function connect(token: string, logger: Logger, isResume = false) { logger.info("whispergate: moderator bot resumed"); } break; - case 7: // Reconnect - logger.info("whispergate: moderator bot reconnect requested"); - ws?.close(4000); + case 7: // Reconnect request + logger.info("whispergate: moderator bot reconnect requested by Discord"); + cleanup(); + scheduleReconnect(token, logger, true); 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); - } + 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 + // 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("whispergate: moderator bot token invalid, not reconnecting"); + logger.warn("whispergate: moderator bot token invalid (4004), stopping"); + started = false; 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); + if (code === 4010 || code === 4011 || code === 4013 || code === 4014) { + logger.warn(`whispergate: moderator bot fatal close (${code}), re-identifying`); + sessionId = null; + scheduleReconnect(token, logger, false); + return; + } + + logger.info(`whispergate: moderator bot disconnected (code=${code}), will reconnect`); + const canResume = !!sessionId && code !== 4012; + scheduleReconnect(token, logger, canResume); }; ws.onerror = () => { @@ -140,24 +213,45 @@ function connect(token: string, logger: Logger, isResume = false) { 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); + + // 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(`whispergate: 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("whispergate: 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; - stopHeartbeat(); + started = false; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } - if (ws) { - ws.close(1000); - ws = null; - } + cleanup(); } -- 2.49.1 From 81758b9a54b8aa2e094a6a58cded5a2ae51ad59e Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 19:24:50 +0000 Subject: [PATCH 09/16] fix: use systemPrompt instead of prependContext for end marker instruction - Change before_prompt_build hook to return systemPrompt instead of prependContext - This ensures the end marker instruction is injected once per session as a system prompt, not repeatedly prepended to each user message - Add moderatorBotToken to CONFIG.example.json for documentation --- docs/CONFIG.example.json | 3 ++- plugin/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index d14f120..a18ed96 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -22,7 +22,8 @@ "debugLogChannelIds": [], "discordControlApiBaseUrl": "http://127.0.0.1:8790", "discordControlApiToken": "", - "discordControlCallerId": "agent-main" + "discordControlCallerId": "agent-main", + "moderatorBotToken": "" } } } diff --git a/plugin/index.ts b/plugin/index.ts index 2dd9411..0959331 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -726,8 +726,8 @@ export default { if (idStr) identity = idStr + "\n\n"; } - api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); - return { prependContext: identity + instruction }; + api.logger.info(`whispergate: set system prompt for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); + return { systemPrompt: identity + instruction }; }); // Register slash commands for Discord -- 2.49.1 From f03d32e15f13abecec4495535eec078367f91596 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 19:27:39 +0000 Subject: [PATCH 10/16] fix: add turn check debug logs + one-time prompt injection 1. Turn check improvements: - Add debug logs for ctx.agentId, resolved accountId, turnOrder length - Fallback to ctx.accountId if resolveAccountId fails - Add resolveDiscordUserId debug logs for handoff troubleshooting 2. One-time prompt injection: - Add sessionInjected Set to track injected sessions - Use prependContext (not systemPrompt) but only inject once per session - Skip subsequent injections with debug log --- plugin/index.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 0959331..8bbb6fb 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -24,6 +24,7 @@ type DebugConfig = { }; const sessionDecision = new Map(); +const sessionInjected = new Set(); // Track which sessions have already injected the end marker const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string { @@ -261,8 +262,20 @@ function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string 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); + + if (!acct?.token || typeof acct.token !== "string") { + api.logger.warn(`whispergate: resolveDiscordUserId failed for accountId=${accountId}: no token found in config`); + return undefined; + } + + const userId = userIdFromToken(acct.token); + if (!userId) { + api.logger.warn(`whispergate: resolveDiscordUserId failed for accountId=${accountId}: could not parse userId from token`); + return undefined; + } + + api.logger.info(`whispergate: resolveDiscordUserId success accountId=${accountId} userId=${userId}`); + return userId; } /** Get the moderator bot's Discord user ID from its token */ @@ -611,7 +624,27 @@ export default { // 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 || ""); + + // Try resolveAccountId first, fall back to ctx.accountId if not found + let accountId = resolveAccountId(api, ctx.agentId || ""); + + // Debug log for turn check + if (shouldDebugLog(live, derived.channelId)) { + const turnDebug = getTurnDebugInfo(derived.channelId); + api.logger.info( + `whispergate: turn check preflight agentId=${ctx.agentId ?? "undefined"} ` + + `resolvedAccountId=${accountId ?? "undefined"} ` + + `ctxAccountId=${ctx.accountId ?? "undefined"} ` + + `turnOrderLen=${turnDebug.turnOrder?.length ?? 0} ` + + `currentSpeaker=${turnDebug.currentSpeaker ?? "null"}`, + ); + } + + // Fallback to ctx.accountId if resolveAccountId failed + if (!accountId && ctx.accountId) { + accountId = String(ctx.accountId); + } + if (accountId) { const turnCheck = checkTurn(derived.channelId, accountId); if (!turnCheck.allowed) { @@ -703,6 +736,17 @@ export default { } 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( @@ -726,8 +770,11 @@ export default { if (idStr) identity = idStr + "\n\n"; } - api.logger.info(`whispergate: set system prompt for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); - return { systemPrompt: identity + instruction }; + // Mark session as injected (one-time injection) + sessionInjected.add(key); + + api.logger.info(`whispergate: one-time inject end marker for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); + return { prependContext: identity + instruction }; }); // Register slash commands for Discord -- 2.49.1 From 80439b0912b8136eb930d754cd055f89f414a2e4 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 19:34:37 +0000 Subject: [PATCH 11/16] Revert "Merge pull request 'fix: use systemPrompt instead of prependContext for end marker instruction' (#5) from fix/moderator-and-system-prompt into feat/turn-based-speaking" This reverts commit 6a81f75fd021140a222c665ac0ed4b2610c69deb, reversing changes made to 86fdc63802cb8df0213ef31f141601a031ef925a. --- docs/CONFIG.example.json | 3 +-- plugin/index.ts | 55 +++------------------------------------- 2 files changed, 5 insertions(+), 53 deletions(-) diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index a18ed96..d14f120 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -22,8 +22,7 @@ "debugLogChannelIds": [], "discordControlApiBaseUrl": "http://127.0.0.1:8790", "discordControlApiToken": "", - "discordControlCallerId": "agent-main", - "moderatorBotToken": "" + "discordControlCallerId": "agent-main" } } } diff --git a/plugin/index.ts b/plugin/index.ts index 8bbb6fb..2dd9411 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -24,7 +24,6 @@ type DebugConfig = { }; const sessionDecision = new Map(); -const sessionInjected = new Set(); // Track which sessions have already injected the end marker const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string { @@ -262,20 +261,8 @@ function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string const discord = (channels.discord as Record) || {}; const accounts = (discord.accounts as Record>) || {}; const acct = accounts[accountId]; - - if (!acct?.token || typeof acct.token !== "string") { - api.logger.warn(`whispergate: resolveDiscordUserId failed for accountId=${accountId}: no token found in config`); - return undefined; - } - - const userId = userIdFromToken(acct.token); - if (!userId) { - api.logger.warn(`whispergate: resolveDiscordUserId failed for accountId=${accountId}: could not parse userId from token`); - return undefined; - } - - api.logger.info(`whispergate: resolveDiscordUserId success accountId=${accountId} userId=${userId}`); - return userId; + if (!acct?.token || typeof acct.token !== "string") return undefined; + return userIdFromToken(acct.token); } /** Get the moderator bot's Discord user ID from its token */ @@ -624,27 +611,7 @@ export default { // This ensures only the current speaker can respond even for human messages. if (derived.channelId) { ensureTurnOrder(api, derived.channelId); - - // Try resolveAccountId first, fall back to ctx.accountId if not found - let accountId = resolveAccountId(api, ctx.agentId || ""); - - // Debug log for turn check - if (shouldDebugLog(live, derived.channelId)) { - const turnDebug = getTurnDebugInfo(derived.channelId); - api.logger.info( - `whispergate: turn check preflight agentId=${ctx.agentId ?? "undefined"} ` + - `resolvedAccountId=${accountId ?? "undefined"} ` + - `ctxAccountId=${ctx.accountId ?? "undefined"} ` + - `turnOrderLen=${turnDebug.turnOrder?.length ?? 0} ` + - `currentSpeaker=${turnDebug.currentSpeaker ?? "null"}`, - ); - } - - // Fallback to ctx.accountId if resolveAccountId failed - if (!accountId && ctx.accountId) { - accountId = String(ctx.accountId); - } - + const accountId = resolveAccountId(api, ctx.agentId || ""); if (accountId) { const turnCheck = checkTurn(derived.channelId, accountId); if (!turnCheck.allowed) { @@ -736,17 +703,6 @@ export default { } 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( @@ -770,10 +726,7 @@ export default { if (idStr) identity = idStr + "\n\n"; } - // Mark session as injected (one-time injection) - sessionInjected.add(key); - - api.logger.info(`whispergate: one-time inject end marker for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); + api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); return { prependContext: identity + instruction }; }); -- 2.49.1 From 692111eeda7c0b4d4a788a7fd45b02e1e15bc030 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 21:44:53 +0000 Subject: [PATCH 12/16] fix: implement turn gate and handoff improvements 1. Fix channelId priority: use ctx.channelId > conv.chat_id > conv.channel_id 2. Add sessionAllowed state: track if session was allowed (true) or forced no-reply (false) 3. Add sessionInjected Set: implement one-time prependContext injection 4. Add before_message_write hook: detect NO_REPLY and handle turn advancement - forced no-reply: don't advance turn - allowed + NO_REPLY: advance turn + handoff - dormant state: don't trigger handoff 5. message_sent: continues to handle real messages with end symbols --- plugin/index.ts | 114 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 8 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 2dd9411..1de1e3a 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -24,6 +24,8 @@ type DebugConfig = { }; 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 MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string { @@ -82,6 +84,7 @@ function extractUntrustedConversationInfo(text: string): Record function deriveDecisionInputFromPrompt( prompt: string, messageProvider?: string, + channelIdFromCtx?: string, ): { channel: string; channelId?: string; @@ -91,11 +94,17 @@ function deriveDecisionInputFromPrompt( } { const conv = extractUntrustedConversationInfo(prompt) || {}; const channel = (messageProvider || "").toLowerCase(); - const channelId = - (typeof conv.channel_id === "string" && conv.channel_id) || - (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:") - ? conv.chat_id.slice("channel:".length) - : undefined); + + // Priority: ctx.channelId > conv.chat_id > conv.channel_id + let channelId = channelIdFromCtx; + if (!channelId) { + channelId = + (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:") + ? conv.chat_id.slice("channel:".length) + : typeof conv.channel_id === "string" && conv.channel_id) || + undefined; + } + const senderId = (typeof conv.sender_id === "string" && conv.sender_id) || (typeof conv.sender === "string" && conv.sender) || @@ -576,7 +585,7 @@ export default { ); } - const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); + const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); // 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; @@ -615,6 +624,8 @@ export default { 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}`, ); @@ -623,6 +634,8 @@ export default { modelOverride: live.noReplyModel, }; } + // Allowed to speak - record this session as allowed + sessionAllowed.set(key, true); } } @@ -679,7 +692,7 @@ export default { if (rec) sessionDecision.delete(key); const prompt = ((event as Record).prompt as string) || ""; - const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); + const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); const decision = evaluateDecision({ config: live, @@ -703,6 +716,17 @@ export default { } 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( @@ -714,7 +738,7 @@ export default { // Resolve end symbols from config/policy for dynamic instruction const prompt = ((event as Record).prompt as string) || ""; - const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); + 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); @@ -726,6 +750,9 @@ export default { 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 }; }); @@ -776,6 +803,77 @@ export default { }, }); + // Handle NO_REPLY detection before message write + // This is where we detect if agent output is NO_REPLY and handle turn advancement + api.on("before_message_write", async (event, ctx) => { + try { + const key = ctx.sessionKey; + const channelId = ctx.channelId as string | undefined; + const accountId = ctx.accountId as string | undefined; + + if (!key || !channelId || !accountId) return; + + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + + // Get the agent's output content + const content = (event.content as string) || ""; + const trimmed = content.trim(); + const isNoReply = /^NO_REPLY$/i.test(trimmed); + + if (!isNoReply) return; + + // Check if this session was forced no-reply or allowed to speak + const wasAllowed = sessionAllowed.get(key); + if (wasAllowed === undefined) return; // No record, skip + + if (wasAllowed === false) { + // Forced no-reply - do not advance turn + sessionAllowed.delete(key); + if (shouldDebugLog(live, channelId)) { + 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); + const nextSpeaker = onSpeakerDone(channelId, accountId, true); + + sessionAllowed.delete(key); + + if (shouldDebugLog(live, channelId)) { + 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 + if (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: 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 { -- 2.49.1 From 9e754d32cfd4310d297ddd8aa9811da402e5f344 Mon Sep 17 00:00:00 2001 From: zhi Date: Sun, 1 Mar 2026 04:59:45 +0000 Subject: [PATCH 13/16] fix: make before_message_write synchronous - Remove async/await from before_message_write hook - Use fire-and-forget for sendModeratorMessage (void ... .catch()) - OpenClaw's before_message_write is synchronous, not async --- plugin/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 1de1e3a..ddbb311 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -805,7 +805,8 @@ export default { // Handle NO_REPLY detection before message write // This is where we detect if agent output is NO_REPLY and handle turn advancement - api.on("before_message_write", async (event, ctx) => { + // NOTE: This hook is synchronous, do not use async/await + api.on("before_message_write", (event, ctx) => { try { const key = ctx.sessionKey; const channelId = ctx.channelId as string | undefined; @@ -859,12 +860,14 @@ export default { return; } - // Trigger moderator handoff message + // 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`; - sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger); + 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}`); } -- 2.49.1 From 435a7712b8135c17fae94e39797a762947625930 Mon Sep 17 00:00:00 2001 From: zhi Date: Sun, 1 Mar 2026 09:13:03 +0000 Subject: [PATCH 14/16] fix: add sessionChannelId mapping for message_sent - Add sessionChannelId Map to track sessionKey -> channelId - Save channelId in before_model_resolve when we have derived.channelId - Fix message_sent to use sessionChannelId fallback when ctx.channelId is undefined - Add debug logging to message_sent --- plugin/index.ts | 92 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 19 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index ddbb311..3fcbc8b 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -26,6 +26,7 @@ type DebugConfig = { 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 MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string { @@ -636,6 +637,10 @@ export default { } // Allowed to speak - record this session as allowed sessionAllowed.set(key, true); + // Also save channelId for this session + if (derived.channelId) { + sessionChannelId.set(key, derived.channelId); + } } } @@ -808,33 +813,74 @@ export default { // NOTE: This hook is synchronous, do not use async/await api.on("before_message_write", (event, ctx) => { try { - const key = ctx.sessionKey; - const channelId = ctx.channelId as string | undefined; - const accountId = ctx.accountId as string | undefined; + // 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 ?? {}))}`, + ); + + // Try multiple sources for channelId and accountId + const deliveryContext = (ctx as Record)?.deliveryContext as Record | undefined; + let key = ctx.sessionKey; + let channelId = ctx.channelId as string | undefined; + let accountId = ctx.accountId as string | undefined; + let content = (event.content as string) || ""; + + // Fallback: get channelId from deliveryContext.to + if (!channelId && deliveryContext?.to) { + const toStr = String(deliveryContext.to); + channelId = toStr.startsWith("channel:") ? toStr.replace("channel:", "") : toStr; + } + + // Fallback: get accountId from deliveryContext.accountId + if (!accountId && deliveryContext?.accountId) { + accountId = String(deliveryContext.accountId); + } + + // Fallback: get content from event.message.content + if (!content && (event as Record).message) { + const msg = (event as Record).message as Record; + content = String(msg.content ?? ""); + } + + // 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; const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; - // Get the agent's output content - const content = (event.content as string) || ""; const trimmed = content.trim(); const isNoReply = /^NO_REPLY$/i.test(trimmed); - if (!isNoReply) return; + // 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])}`, + ); + + if (!isNoReply) { + api.logger.info( + `whispergate: before_message_write content is not NO_REPLY, skipping channel=${channelId}`, + ); + return; + } // Check if this session was forced no-reply or allowed to speak const wasAllowed = sessionAllowed.get(key); + 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); - if (shouldDebugLog(live, channelId)) { - api.logger.info( - `whispergate: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`, - ); - } + api.logger.info( + `whispergate: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`, + ); return; } @@ -844,11 +890,9 @@ export default { sessionAllowed.delete(key); - if (shouldDebugLog(live, channelId)) { - api.logger.info( - `whispergate: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, - ); - } + 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) { @@ -880,9 +924,19 @@ export default { // Turn advance: when an agent sends a message, check if it signals end of turn api.on("message_sent", async (event, ctx) => { try { - const channelId = ctx.channelId; - const accountId = ctx.accountId; - const content = event.content || ""; + const key = ctx.sessionKey; + // Try ctx.channelId first, fallback to sessionChannelId mapping + let channelId = ctx.channelId as string | undefined; + if (!channelId && key) { + channelId = sessionChannelId.get(key); + } + const accountId = ctx.accountId as string | undefined; + const content = (event.content as string) || ""; + + // Debug log + api.logger.info( + `whispergate: DEBUG message_sent session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`, + ); if (!channelId || !accountId) return; -- 2.49.1 From a4bc9990dbd88a1103dc2188a347c0a463e76ecb Mon Sep 17 00:00:00 2001 From: zhi Date: Sun, 1 Mar 2026 09:16:55 +0000 Subject: [PATCH 15/16] fix: add sessionAccountId mapping and use in before_message_write - Add sessionAccountId Map to track sessionKey -> accountId - Save accountId in before_model_resolve when resolving accountId - Use sessionChannelId/sessionAccountId fallback in before_message_write --- plugin/index.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/plugin/index.ts b/plugin/index.ts index 3fcbc8b..f862ff4 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -27,6 +27,7 @@ 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 MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string { @@ -637,10 +638,13 @@ export default { } // Allowed to speak - record this session as allowed sessionAllowed.set(key, true); - // Also save channelId for this session + // Also save channelId and accountId for this session if (derived.channelId) { sessionChannelId.set(key, derived.channelId); } + if (accountId) { + sessionAccountId.set(key, accountId); + } } } @@ -836,6 +840,14 @@ export default { accountId = String(deliveryContext.accountId); } + // Fallback: get from session mapping + if (!channelId && key) { + channelId = sessionChannelId.get(key); + } + if (!accountId && key) { + accountId = sessionAccountId.get(key); + } + // Fallback: get content from event.message.content if (!content && (event as Record).message) { const msg = (event as Record).message as Record; -- 2.49.1 From d90083317bd992497d0d7c050de35dcccbd29ff6 Mon Sep 17 00:00:00 2001 From: zhi Date: Sun, 1 Mar 2026 11:08:41 +0000 Subject: [PATCH 16/16] fix: channelId extraction, sender identification, and per-channel turn order - Fix channelId extraction: ctx.channelId is platform name ('discord'), not the Discord channel snowflake. Now extracts from conversation_label field ('channel id:123456') and sessionKey fallback (':channel:123456'). - Fix extractDiscordChannelId: support 'discord:channel:xxx' format in addition to 'channel:xxx' for conversationId/event.to fields. - Fix sender identification in message_received: event.from returns channel target, not sender ID. Now uses event.metadata.senderId for humanList matching so human messages correctly reset turn order. - Fix per-channel turn order: was using all server-wide bot accounts from bindings, causing deadlock when turn landed on bots not in the channel. Now dynamically tracks which bot accounts are seen per channel via message_received and only includes those in turn order. - Always save sessionChannelId/sessionAccountId mappings in before_model_resolve regardless of turn check result, so downstream hooks can use them. - Add comprehensive debug logging to message_sent hook. --- dist/whispergate/index.ts | 1105 +++++++++++++++++++++++++++++++++++++ plugin/index.ts | 208 +++++-- 2 files changed, 1267 insertions(+), 46 deletions(-) create mode 100644 dist/whispergate/index.ts diff --git a/dist/whispergate/index.ts b/dist/whispergate/index.ts new file mode 100644 index 0000000..61c61ad --- /dev/null +++ b/dist/whispergate/index.ts @@ -0,0 +1,1105 @@ +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 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) + let content = ""; + const msg = (event as Record).message as Record | undefined; + if (msg) { + // 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; + + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + + const trimmed = content.trim(); + const isNoReply = /^NO_REPLY$/i.test(trimmed); + + // 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])}`, + ); + + if (!isNoReply) { + api.logger.info( + `whispergate: before_message_write content is not NO_REPLY, skipping channel=${channelId}`, + ); + return; + } + + // Check if this session was forced no-reply or allowed to speak + const wasAllowed = sessionAllowed.get(key); + 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); + const nextSpeaker = onSpeakerDone(channelId, accountId, true); + + sessionAllowed.delete(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}`); + } + } + } 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; + + 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)}`); + } + }); + }, +}; diff --git a/plugin/index.ts b/plugin/index.ts index f862ff4..61c61ad 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -52,6 +52,37 @@ function normalizeChannel(ctx: Record): string { 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) { @@ -97,14 +128,22 @@ function deriveDecisionInputFromPrompt( const conv = extractUntrustedConversationInfo(prompt) || {}; const channel = (messageProvider || "").toLowerCase(); - // Priority: ctx.channelId > conv.chat_id > conv.channel_id + // Priority: ctx.channelId > conv.chat_id > conversation_label > conv.channel_id let channelId = channelIdFromCtx; if (!channelId) { - channelId = - (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:") - ? conv.chat_id.slice("channel:".length) - : typeof conv.channel_id === "string" && conv.channel_id) || - undefined; + // 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 = @@ -193,7 +232,7 @@ function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | und } /** - * Get all Discord bot accountIds from config bindings (excluding humanList-bound agents). + * Get all Discord bot accountIds from config bindings. */ function getAllBotAccountIds(api: OpenClawPluginApi): string[] { const root = (api.config as Record) || {}; @@ -209,12 +248,44 @@ function getAllBotAccountIds(api: OpenClawPluginApi): string[] { 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 all bot accounts from bindings as the turn order. + * Uses only bot accounts that have been seen in this channel. */ function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void { - const botAccounts = getAllBotAccountIds(api); + const botAccounts = getChannelBotAccountIds(api, channelId); if (botAccounts.length > 0) { initTurnOrder(channelId, botAccounts); } @@ -538,7 +609,9 @@ export default { try { const c = (ctx || {}) as Record; const e = (event || {}) as Record; - const preChannelId = typeof c.channelId === "string" ? c.channelId : undefined; + // 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))}`); @@ -547,7 +620,11 @@ export default { // Turn management on message received if (preChannelId) { ensureTurnOrder(api, preChannelId); - const from = typeof (e as Record).from === "string" ? (e as Record).from as string : ""; + // 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); @@ -560,6 +637,17 @@ export default { 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"}`); @@ -588,10 +676,24 @@ export default { } 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); @@ -638,13 +740,6 @@ export default { } // Allowed to speak - record this session as allowed sessionAllowed.set(key, true); - // Also save channelId and accountId for this session - if (derived.channelId) { - sessionChannelId.set(key, derived.channelId); - } - if (accountId) { - sessionAccountId.set(key, accountId); - } } } @@ -822,36 +917,39 @@ export default { `whispergate: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`, ); - // Try multiple sources for channelId and accountId - const deliveryContext = (ctx as Record)?.deliveryContext as Record | undefined; + // 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 = ctx.channelId as string | undefined; - let accountId = ctx.accountId as string | undefined; - let content = (event.content as string) || ""; + let channelId: string | undefined; + let accountId: string | undefined; - // Fallback: get channelId from deliveryContext.to - if (!channelId && deliveryContext?.to) { - const toStr = String(deliveryContext.to); - channelId = toStr.startsWith("channel:") ? toStr.replace("channel:", "") : toStr; - } - - // Fallback: get accountId from deliveryContext.accountId - if (!accountId && deliveryContext?.accountId) { - accountId = String(deliveryContext.accountId); - } - - // Fallback: get from session mapping - if (!channelId && key) { + // Get from session mapping (set in before_model_resolve) + if (key) { channelId = sessionChannelId.get(key); - } - if (!accountId && key) { accountId = sessionAccountId.get(key); } - // Fallback: get content from event.message.content - if (!content && (event as Record).message) { - const msg = (event as Record).message as Record; - content = String(msg.content ?? ""); + // Extract content from event.message (AgentMessage) + let content = ""; + const msg = (event as Record).message as Record | undefined; + if (msg) { + // 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 @@ -937,17 +1035,35 @@ export default { api.on("message_sent", async (event, ctx) => { try { const key = ctx.sessionKey; - // Try ctx.channelId first, fallback to sessionChannelId mapping - let channelId = ctx.channelId as string | undefined; + 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); } - const accountId = ctx.accountId as string | undefined; + // 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 session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`, + `whispergate: DEBUG message_sent RESOLVED session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`, ); if (!channelId || !accountId) return; -- 2.49.1