fix: channelId extraction, sender identification, and per-channel turn order #9
1105
dist/whispergate/index.ts
vendored
Normal file
1105
dist/whispergate/index.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
108
docs/TURN-WAKEUP-PROBLEM.md
Normal file
108
docs/TURN-WAKEUP-PROBLEM.md
Normal file
@@ -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?
|
||||||
618
plugin/index.ts
618
plugin/index.ts
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js";
|
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js";
|
||||||
|
import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js";
|
||||||
|
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
|
||||||
|
|
||||||
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
|
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
|
||||||
|
|
||||||
@@ -22,11 +24,19 @@ type DebugConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sessionDecision = new Map<string, DecisionRecord>();
|
const sessionDecision = new Map<string, DecisionRecord>();
|
||||||
|
const sessionAllowed = new Map<string, boolean>(); // Track if session was allowed to speak (true) or forced no-reply (false)
|
||||||
|
const sessionInjected = new Set<string>(); // Track which sessions have already injected the end marker
|
||||||
|
const sessionChannelId = new Map<string, string>(); // Track sessionKey -> channelId mapping
|
||||||
|
const sessionAccountId = new Map<string, string>(); // Track sessionKey -> accountId mapping
|
||||||
const MAX_SESSION_DECISIONS = 2000;
|
const MAX_SESSION_DECISIONS = 2000;
|
||||||
const DECISION_TTL_MS = 5 * 60 * 1000;
|
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("") : "🔚";
|
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 = {
|
const policyState: PolicyState = {
|
||||||
@@ -42,6 +52,37 @@ function normalizeChannel(ctx: Record<string, unknown>): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the actual Discord channel ID from a conversationId or "to" field.
|
||||||
|
* OpenClaw uses format "channel:<snowflake>" for Discord conversations.
|
||||||
|
* Also tries event.to and event.metadata.to as fallbacks.
|
||||||
|
*/
|
||||||
|
function extractDiscordChannelId(ctx: Record<string, unknown>, event?: Record<string, unknown>): string | undefined {
|
||||||
|
const candidates: unknown[] = [
|
||||||
|
ctx.conversationId,
|
||||||
|
event?.to,
|
||||||
|
(event?.metadata as Record<string, unknown>)?.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<string, unknown>, ctx: Record<string, unknown>): string | undefined {
|
function normalizeSender(event: Record<string, unknown>, ctx: Record<string, unknown>): string | undefined {
|
||||||
const direct = [ctx.senderId, ctx.from, event.from];
|
const direct = [ctx.senderId, ctx.from, event.from];
|
||||||
for (const v of direct) {
|
for (const v of direct) {
|
||||||
@@ -76,6 +117,7 @@ function extractUntrustedConversationInfo(text: string): Record<string, unknown>
|
|||||||
function deriveDecisionInputFromPrompt(
|
function deriveDecisionInputFromPrompt(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
messageProvider?: string,
|
messageProvider?: string,
|
||||||
|
channelIdFromCtx?: string,
|
||||||
): {
|
): {
|
||||||
channel: string;
|
channel: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
@@ -85,11 +127,25 @@ function deriveDecisionInputFromPrompt(
|
|||||||
} {
|
} {
|
||||||
const conv = extractUntrustedConversationInfo(prompt) || {};
|
const conv = extractUntrustedConversationInfo(prompt) || {};
|
||||||
const channel = (messageProvider || "").toLowerCase();
|
const channel = (messageProvider || "").toLowerCase();
|
||||||
const channelId =
|
|
||||||
(typeof conv.channel_id === "string" && conv.channel_id) ||
|
// Priority: ctx.channelId > conv.chat_id > conversation_label > conv.channel_id
|
||||||
(typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")
|
let channelId = channelIdFromCtx;
|
||||||
? conv.chat_id.slice("channel:".length)
|
if (!channelId) {
|
||||||
: 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 =
|
const senderId =
|
||||||
(typeof conv.sender_id === "string" && conv.sender_id) ||
|
(typeof conv.sender_id === "string" && conv.sender_id) ||
|
||||||
(typeof conv.sender === "string" && conv.sender) ||
|
(typeof conv.sender === "string" && conv.sender) ||
|
||||||
@@ -159,6 +215,168 @@ function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve agentId → Discord accountId from config bindings */
|
||||||
|
function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined {
|
||||||
|
const root = (api.config as Record<string, unknown>) || {};
|
||||||
|
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||||
|
if (!Array.isArray(bindings)) return undefined;
|
||||||
|
for (const b of bindings) {
|
||||||
|
if (b.agentId === agentId) {
|
||||||
|
const match = b.match as Record<string, unknown> | 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<string, unknown>) || {};
|
||||||
|
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||||
|
if (!Array.isArray(bindings)) return [];
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const b of bindings) {
|
||||||
|
const match = b.match as Record<string, unknown> | 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<string, Set<string>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>) || {};
|
||||||
|
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||||
|
const agents = ((root.agents as Record<string, unknown>)?.list as Array<Record<string, unknown>>) || [];
|
||||||
|
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<string, unknown> | 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<string, unknown>) => 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<string, unknown>) || {};
|
||||||
|
const channels = (root.channels as Record<string, unknown>) || {};
|
||||||
|
const discord = (channels.discord as Record<string, unknown>) || {};
|
||||||
|
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
||||||
|
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<boolean> {
|
||||||
|
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 {
|
function persistPolicies(api: OpenClawPluginApi): void {
|
||||||
const filePath = policyState.filePath;
|
const filePath = policyState.filePath;
|
||||||
if (!filePath) throw new Error("policy file path not initialized");
|
if (!filePath) throw new Error("policy file path not initialized");
|
||||||
@@ -232,9 +450,15 @@ export default {
|
|||||||
const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig);
|
const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig);
|
||||||
ensurePolicyStateLoaded(api, liveAtRegister);
|
ensurePolicyStateLoaded(api, liveAtRegister);
|
||||||
|
|
||||||
|
// Start moderator bot presence (keep it "online" on Discord)
|
||||||
|
if (liveAtRegister.moderatorBotToken) {
|
||||||
|
startModeratorPresence(liveAtRegister.moderatorBotToken, api.logger);
|
||||||
|
api.logger.info("whispergate: moderator bot presence starting");
|
||||||
|
}
|
||||||
|
|
||||||
api.registerTool(
|
api.registerTool(
|
||||||
{
|
{
|
||||||
name: "whispergateway_tools",
|
name: "whispergate_tools",
|
||||||
description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.",
|
description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -242,7 +466,7 @@ export default {
|
|||||||
properties: {
|
properties: {
|
||||||
action: {
|
action: {
|
||||||
type: "string",
|
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" },
|
guildId: { type: "string" },
|
||||||
name: { type: "string" },
|
name: { type: "string" },
|
||||||
@@ -303,7 +527,7 @@ export default {
|
|||||||
const text = await r.text();
|
const text = await r.text();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
return {
|
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,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -355,6 +579,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 };
|
return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -365,11 +609,51 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const c = (ctx || {}) as Record<string, unknown>;
|
const c = (ctx || {}) as Record<string, unknown>;
|
||||||
const e = (event || {}) as Record<string, unknown>;
|
const e = (event || {}) as Record<string, unknown>;
|
||||||
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;
|
const livePre = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||||
if (shouldDebugLog(livePre, preChannelId)) {
|
if (shouldDebugLog(livePre, preChannelId)) {
|
||||||
api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
|
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<string, unknown>).metadata as Record<string, unknown> | undefined;
|
||||||
|
const from = (typeof metadata?.senderId === "string" && metadata.senderId)
|
||||||
|
|| (typeof (e as Record<string, unknown>).from === "string" ? (e as Record<string, unknown>).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) {
|
} catch (err) {
|
||||||
api.logger.warn(`whispergate: message hook failed: ${String(err)}`);
|
api.logger.warn(`whispergate: message hook failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
@@ -391,11 +675,25 @@ export default {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider);
|
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId);
|
||||||
|
// Fallback: extract channelId from sessionKey (format "agent:<id>:discord:channel:<channelId>")
|
||||||
|
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
|
// Only proceed if: discord channel AND prompt contains untrusted metadata
|
||||||
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
|
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
|
||||||
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
|
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);
|
let rec = sessionDecision.get(key);
|
||||||
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
||||||
if (rec) sessionDecision.delete(key);
|
if (rec) sessionDecision.delete(key);
|
||||||
@@ -422,6 +720,29 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (!rec.decision.shouldUseNoReply) {
|
||||||
// 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型
|
// 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型
|
||||||
if (rec.needsRestore) {
|
if (rec.needsRestore) {
|
||||||
@@ -475,7 +796,7 @@ export default {
|
|||||||
if (rec) sessionDecision.delete(key);
|
if (rec) sessionDecision.delete(key);
|
||||||
|
|
||||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||||
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider);
|
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId);
|
||||||
|
|
||||||
const decision = evaluateDecision({
|
const decision = evaluateDecision({
|
||||||
config: live,
|
config: live,
|
||||||
@@ -499,6 +820,17 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionDecision.delete(key);
|
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 (!rec.decision.shouldInjectEndMarkerPrompt) {
|
||||||
if (shouldDebugLog(live, undefined)) {
|
if (shouldDebugLog(live, undefined)) {
|
||||||
api.logger.info(
|
api.logger.info(
|
||||||
@@ -510,12 +842,264 @@ export default {
|
|||||||
|
|
||||||
// Resolve end symbols from config/policy for dynamic instruction
|
// Resolve end symbols from config/policy for dynamic instruction
|
||||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
const prompt = ((event as Record<string, unknown>).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 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}`);
|
// Inject agent identity for group chats
|
||||||
return { prependContext: instruction };
|
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<string, unknown>).message as Record<string, unknown> | 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<string, unknown>).text === "string") {
|
||||||
|
content += (part as Record<string, unknown>).text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to event.content
|
||||||
|
if (!content) {
|
||||||
|
content = ((event as Record<string, unknown>).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<string, unknown>;
|
||||||
|
const e = (event || {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
// 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)}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
257
plugin/moderator-presence.ts
Normal file
257
plugin/moderator-presence.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* 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<typeof setInterval> | null = null;
|
||||||
|
let heartbeatAcked = true;
|
||||||
|
let lastSequence: number | null = null;
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
let resumeUrl: string | null = null;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let destroyed = false;
|
||||||
|
let started = false; // singleton guard
|
||||||
|
|
||||||
|
type Logger = {
|
||||||
|
info: (msg: string) => void;
|
||||||
|
warn: (msg: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, unknown>) {
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHeartbeat(intervalMs: number) {
|
||||||
|
stopHeartbeat();
|
||||||
|
heartbeatAcked = true;
|
||||||
|
|
||||||
|
// First heartbeat after jitter
|
||||||
|
const jitter = Math.floor(Math.random() * intervalMs);
|
||||||
|
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<typeof setInterval>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHeartbeat() {
|
||||||
|
if (heartbeatInterval) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
clearTimeout(heartbeatInterval as unknown as ReturnType<typeof setTimeout>);
|
||||||
|
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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
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) => {
|
||||||
|
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 != 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 });
|
||||||
|
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 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}`);
|
||||||
|
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 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 (4004), stopping");
|
||||||
|
started = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 = () => {
|
||||||
|
// onclose will fire after this
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect(token: string, logger: Logger, resume: boolean) {
|
||||||
|
if (destroyed) return;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
started = false;
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@
|
|||||||
"discordControlApiToken": { "type": "string" },
|
"discordControlApiToken": { "type": "string" },
|
||||||
"discordControlCallerId": { "type": "string" },
|
"discordControlCallerId": { "type": "string" },
|
||||||
"enableDebugLogs": { "type": "boolean", "default": false },
|
"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"]
|
"required": ["noReplyProvider", "noReplyModel"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export type WhisperGateConfig = {
|
|||||||
endSymbols?: string[];
|
endSymbols?: string[];
|
||||||
noReplyProvider: string;
|
noReplyProvider: string;
|
||||||
noReplyModel: string;
|
noReplyModel: string;
|
||||||
|
/** Discord bot token for the moderator bot (used for turn handoff messages) */
|
||||||
|
moderatorBotToken?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelPolicy = {
|
export type ChannelPolicy = {
|
||||||
|
|||||||
233
plugin/turn-manager.ts
Normal file
233
plugin/turn-manager.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* Turn-based speaking manager for group channels.
|
||||||
|
*
|
||||||
|
* 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 ChannelTurnState = {
|
||||||
|
/** 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<string>;
|
||||||
|
/** Timestamp of last state change */
|
||||||
|
lastChangedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const channelTurns = new Map<string, ChannelTurnState>();
|
||||||
|
|
||||||
|
/** Turn timeout: if the current speaker hasn't responded, auto-advance */
|
||||||
|
const TURN_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
function shuffleArray<T>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.currentSpeaker !== null) {
|
||||||
|
// Already active, no change needed from incoming message
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
return advanceTurn(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance to next speaker in order.
|
||||||
|
*/
|
||||||
|
export function advanceTurn(channelId: string): string | null {
|
||||||
|
const state = channelTurns.get(channelId);
|
||||||
|
if (!state || state.turnOrder.length === 0) return null;
|
||||||
|
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force reset: go dormant.
|
||||||
|
*/
|
||||||
|
export function resetTurn(channelId: string): void {
|
||||||
|
const state = channelTurns.get(channelId);
|
||||||
|
if (state) {
|
||||||
|
state.currentSpeaker = null;
|
||||||
|
state.noRepliedThisCycle = new Set();
|
||||||
|
state.lastChangedAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get debug info.
|
||||||
|
*/
|
||||||
|
export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
|
||||||
|
const state = channelTurns.get(channelId);
|
||||||
|
if (!state) return { channelId, hasTurnState: false };
|
||||||
|
return {
|
||||||
|
channelId,
|
||||||
|
hasTurnState: true,
|
||||||
|
turnOrder: state.turnOrder,
|
||||||
|
currentSpeaker: state.currentSpeaker,
|
||||||
|
noRepliedThisCycle: [...state.noRepliedThisCycle],
|
||||||
|
lastChangedAt: state.lastChangedAt,
|
||||||
|
dormant: state.currentSpeaker === null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ const outDir = path.join(root, "dist", "whispergate");
|
|||||||
fs.rmSync(outDir, { recursive: true, force: true });
|
fs.rmSync(outDir, { recursive: true, force: true });
|
||||||
fs.mkdirSync(outDir, { recursive: true });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
for (const f of ["index.ts", "rules.ts", "openclaw.plugin.json", "README.md", "package.json"]) {
|
for (const f of ["index.ts", "rules.ts", "turn-manager.ts", "moderator-presence.ts", "openclaw.plugin.json", "README.md", "package.json"]) {
|
||||||
fs.copyFileSync(path.join(pluginDir, f), path.join(outDir, f));
|
fs.copyFileSync(path.join(pluginDir, f), path.join(outDir, f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user