feat: moderator bot for turn handoff messages

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
This commit is contained in:
zhi
2026-02-28 11:39:11 +00:00
parent 476308d0df
commit 54ff78cffe
2 changed files with 84 additions and 9 deletions

View File

@@ -239,6 +239,61 @@ function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | u
return `你是 ${name}Discord 账号: ${accountId})。`; 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");
@@ -475,13 +530,22 @@ export default {
if (preChannelId) { if (preChannelId) {
ensureTurnOrder(api, preChannelId); ensureTurnOrder(api, preChannelId);
const from = typeof (e as Record<string, unknown>).from === "string" ? (e as Record<string, unknown>).from as string : ""; const from = typeof (e as Record<string, unknown>).from === "string" ? (e as Record<string, unknown>).from as string : "";
const humanList = livePre.humanList || livePre.bypassUserIds || [];
const isHuman = humanList.includes(from); // Ignore moderator bot messages — they don't affect turn state
// Resolve sender's accountId (for bot messages) const moderatorUserId = getModeratorUserId(livePre);
const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined; if (moderatorUserId && from === moderatorUserId) {
onNewMessage(preChannelId, senderAccountId, isHuman); if (shouldDebugLog(livePre, preChannelId)) {
if (shouldDebugLog(livePre, preChannelId)) { api.logger.info(`whispergate: ignoring moderator message in channel=${preChannelId}`);
api.logger.info(`whispergate: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`); }
// 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) { } catch (err) {
@@ -730,8 +794,17 @@ export default {
api.logger.info( api.logger.info(
`whispergate: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`, `whispergate: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`,
); );
// NOTE: if wasNoReply and nextSpeaker is set, the next agent needs // Moderator handoff: when current speaker NO_REPLY'd and there's a next speaker,
// a trigger message to start speaking. See TURN-WAKEUP-PROBLEM.md // 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) { } catch (err) {
api.logger.warn(`whispergate: message_sent hook failed: ${String(err)}`); api.logger.warn(`whispergate: message_sent hook failed: ${String(err)}`);

View File

@@ -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 = {