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:
154
plugin/index.ts
154
plugin/index.ts
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user