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