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:
@@ -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<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 {
|
||||
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<string, unknown>).from === "string" ? (e as Record<string, unknown>).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)}`);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user