feat: improve turn order initialization and member management

1. Turn order now activates via moderator on first creation (discovery-based),
   ensuring no agent consumes messages before the turn list is ready.
   All agents are blocked (dormant) until moderator sends handoff to first speaker.

2. channel-private-create: pre-populates turn order from allowedUserIds,
   filtering to only bot accounts (excluding humans and moderator).
   Immediately activates first speaker via moderator handoff.

3. channel-private-update: updates turn order when members are added/removed.
   If current speaker is removed, activates next available speaker.

4. Any member (human or bot) can now trigger turn activation when dormant,
   not just humans. Human messages still reset the cycle.

5. Added resolveAccountIdByUserId helper to map Discord user IDs back to
   account names from bot tokens.

6. turn-manager: added initTurnOrderPrePopulated, updateTurnMembers,
   activateFirstSpeaker, hasTurnState exports.
This commit is contained in:
zhi
2026-03-02 00:55:55 +00:00
parent aeecb534b7
commit 8e3f8e7c4e
3 changed files with 437 additions and 35 deletions

View File

@@ -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, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js";
import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, initTurnOrderPrePopulated, updateTurnMembers, activateFirstSpeaker, hasTurnState, getTurnDebugInfo } from "./turn-manager.js";
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
@@ -283,12 +283,40 @@ function getChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): str
/**
* Ensure turn order is initialized for a channel.
* Uses only bot accounts that have been seen in this channel.
* If a new turn state is created, activates the first speaker via moderator handoff.
*
* @returns true if a new turn state was created and first speaker activated
*/
function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void {
const botAccounts = getChannelBotAccountIds(api, channelId);
if (botAccounts.length > 0) {
initTurnOrder(channelId, botAccounts);
function ensureTurnOrder(api: OpenClawPluginApi, channelId: string, config?: WhisperGateConfig): boolean {
// Skip if already pre-populated (e.g. from channel-private-create)
if (hasTurnState(channelId)) {
const botAccounts = getChannelBotAccountIds(api, channelId);
if (botAccounts.length > 0) {
initTurnOrder(channelId, botAccounts);
}
return false;
}
const botAccounts = getChannelBotAccountIds(api, channelId);
if (botAccounts.length === 0) return false;
const isNew = initTurnOrder(channelId, botAccounts);
if (isNew) {
// New turn state created via discovery — activate first speaker via moderator
const firstSpeaker = activateFirstSpeaker(channelId);
if (firstSpeaker && config?.moderatorBotToken) {
const firstUserId = resolveDiscordUserId(api, firstSpeaker);
if (firstUserId) {
const handoffMsg = `轮到(<@${firstUserId}>如果没有想说的请直接回复NO_REPLY`;
void sendModeratorMessage(config.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => {
api.logger.warn(`whispergate: ensureTurnOrder handoff failed: ${String(err)}`);
});
}
}
api.logger.info(`whispergate: new turn order created for channel=${channelId}, firstSpeaker=${firstSpeaker}`);
return true;
}
return false;
}
/**
@@ -347,6 +375,20 @@ function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string
return userIdFromToken(acct.token);
}
/** Resolve Discord user ID → accountId by checking all account tokens */
function resolveAccountIdByUserId(api: OpenClawPluginApi, userId: 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>>) || {};
for (const [accountId, acct] of Object.entries(accounts)) {
if (!acct?.token || typeof acct.token !== "string") continue;
const extractedUserId = userIdFromToken(acct.token);
if (extractedUserId === userId) return accountId;
}
return undefined;
}
/** Get the moderator bot's Discord user ID from its token */
function getModeratorUserId(config: WhisperGateConfig): string | undefined {
if (!config.moderatorBotToken) return undefined;
@@ -531,6 +573,100 @@ export default {
isError: true,
};
}
// Post-processing: initialize turn order for newly created/updated channels
if (action === "channel-private-create") {
try {
const result = JSON.parse(text);
const newChannelId = result?.channel?.id || result?.channelId;
const allowedUserIds = Array.isArray(params.allowedUserIds) ? (params.allowedUserIds as string[]) : [];
if (newChannelId && allowedUserIds.length > 0) {
// Filter allowedUserIds to only include bot accounts (exclude humans and moderator)
const allBotAccounts = getAllBotAccountIds(api);
const moderatorUserId = getModeratorUserId(live);
const botAccountIdsInChannel: string[] = [];
for (const userId of allowedUserIds) {
// userId is a Discord user ID — resolve which accountId it maps to
const accountId = resolveAccountIdByUserId(api, userId);
if (accountId && allBotAccounts.includes(accountId)) {
// Exclude moderator bot
if (moderatorUserId && userId === moderatorUserId) continue;
botAccountIdsInChannel.push(accountId);
recordChannelAccount(newChannelId, accountId);
}
}
if (botAccountIdsInChannel.length > 0) {
const firstSpeaker = initTurnOrderPrePopulated(newChannelId, botAccountIdsInChannel);
api.logger.info(
`whispergate: pre-populated turn order for new channel=${newChannelId} ` +
`bots=${JSON.stringify(botAccountIdsInChannel)} firstSpeaker=${firstSpeaker}`,
);
// Send moderator handoff to activate first speaker
if (firstSpeaker && live.moderatorBotToken) {
const firstUserId = resolveDiscordUserId(api, firstSpeaker);
if (firstUserId) {
const handoffMsg = `轮到(<@${firstUserId}>如果没有想说的请直接回复NO_REPLY`;
void sendModeratorMessage(live.moderatorBotToken, newChannelId, handoffMsg, api.logger);
}
}
}
}
} catch (err) {
api.logger.warn(`whispergate: channel-private-create post-processing failed: ${String(err)}`);
}
}
if (action === "channel-private-update") {
try {
const channelId = String(params.channelId || "").trim();
if (channelId) {
const addUserIds = Array.isArray(params.addUserIds) ? (params.addUserIds as string[]) : [];
const removeTargetIds = Array.isArray(params.removeTargetIds) ? (params.removeTargetIds as string[]) : [];
const allBotAccounts = getAllBotAccountIds(api);
const moderatorUserId = getModeratorUserId(live);
// Resolve added bot accountIds
const addAccountIds: string[] = [];
for (const userId of addUserIds) {
const accountId = resolveAccountIdByUserId(api, userId);
if (accountId && allBotAccounts.includes(accountId)) {
if (moderatorUserId && userId === moderatorUserId) continue;
addAccountIds.push(accountId);
recordChannelAccount(channelId, accountId);
}
}
// Resolve removed bot accountIds
const removeAccountIds: string[] = [];
for (const userId of removeTargetIds) {
const accountId = resolveAccountIdByUserId(api, userId);
if (accountId && allBotAccounts.includes(accountId)) {
removeAccountIds.push(accountId);
}
}
if (addAccountIds.length > 0 || removeAccountIds.length > 0) {
const result = updateTurnMembers(channelId, addAccountIds, removeAccountIds);
api.logger.info(
`whispergate: updated turn members for channel=${channelId} ` +
`added=${JSON.stringify(addAccountIds)} removed=${JSON.stringify(removeAccountIds)} ` +
`turnOrder=${JSON.stringify(result.turnOrder)} currentSpeaker=${result.currentSpeaker}`,
);
// If current speaker changed due to removal, send handoff
if (result.needsHandoff && result.currentSpeaker && live.moderatorBotToken) {
const nextUserId = resolveDiscordUserId(api, result.currentSpeaker);
if (nextUserId) {
const handoffMsg = `轮到(<@${nextUserId}>如果没有想说的请直接回复NO_REPLY`;
void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger);
}
}
}
}
} catch (err) {
api.logger.warn(`whispergate: channel-private-update post-processing failed: ${String(err)}`);
}
}
return { content: [{ type: "text", text }] };
}
@@ -619,7 +755,7 @@ export default {
// Turn management on message received
if (preChannelId) {
ensureTurnOrder(api, preChannelId);
ensureTurnOrder(api, preChannelId, livePre);
// 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;
@@ -643,7 +779,7 @@ export default {
const isNew = recordChannelAccount(preChannelId, senderAccountId);
if (isNew) {
// Re-initialize turn order with updated channel membership
ensureTurnOrder(api, preChannelId);
ensureTurnOrder(api, preChannelId, livePre);
api.logger.info(`whispergate: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`);
}
}
@@ -723,7 +859,7 @@ 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);
ensureTurnOrder(api, derived.channelId, live);
const accountId = resolveAccountId(api, ctx.agentId || "");
if (accountId) {
const turnCheck = checkTurn(derived.channelId, accountId);
@@ -995,7 +1131,7 @@ export default {
}
// Allowed to speak (current speaker) but chose NO_REPLY - advance turn
ensureTurnOrder(api, channelId);
ensureTurnOrder(api, channelId, live);
const nextSpeaker = onSpeakerDone(channelId, accountId, true);
sessionAllowed.delete(key);