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 path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; 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"; import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; 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. * Ensure turn order is initialized for a channel.
* Uses only bot accounts that have been seen in this 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 { function ensureTurnOrder(api: OpenClawPluginApi, channelId: string, config?: WhisperGateConfig): boolean {
const botAccounts = getChannelBotAccountIds(api, channelId); // Skip if already pre-populated (e.g. from channel-private-create)
if (botAccounts.length > 0) { if (hasTurnState(channelId)) {
initTurnOrder(channelId, botAccounts); 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); 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 */ /** Get the moderator bot's Discord user ID from its token */
function getModeratorUserId(config: WhisperGateConfig): string | undefined { function getModeratorUserId(config: WhisperGateConfig): string | undefined {
if (!config.moderatorBotToken) return undefined; if (!config.moderatorBotToken) return undefined;
@@ -531,6 +573,100 @@ export default {
isError: true, 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 }] }; return { content: [{ type: "text", text }] };
} }
@@ -619,7 +755,7 @@ export default {
// Turn management on message received // Turn management on message received
if (preChannelId) { if (preChannelId) {
ensureTurnOrder(api, preChannelId); ensureTurnOrder(api, preChannelId, livePre);
// event.from is often the channel target (e.g. "discord:channel:xxx"), NOT the sender. // event.from is often the channel target (e.g. "discord:channel:xxx"), NOT the sender.
// The actual sender ID is in event.metadata.senderId. // The actual sender ID is in event.metadata.senderId.
const metadata = (e as Record<string, unknown>).metadata as Record<string, unknown> | undefined; const metadata = (e as Record<string, unknown>).metadata as Record<string, unknown> | undefined;
@@ -643,7 +779,7 @@ export default {
const isNew = recordChannelAccount(preChannelId, senderAccountId); const isNew = recordChannelAccount(preChannelId, senderAccountId);
if (isNew) { if (isNew) {
// Re-initialize turn order with updated channel membership // 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`); 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. // Turn-based check: ALWAYS check turn order regardless of evaluateDecision result.
// This ensures only the current speaker can respond even for human messages. // This ensures only the current speaker can respond even for human messages.
if (derived.channelId) { if (derived.channelId) {
ensureTurnOrder(api, derived.channelId); ensureTurnOrder(api, derived.channelId, live);
const accountId = resolveAccountId(api, ctx.agentId || ""); const accountId = resolveAccountId(api, ctx.agentId || "");
if (accountId) { if (accountId) {
const turnCheck = checkTurn(derived.channelId, accountId); const turnCheck = checkTurn(derived.channelId, accountId);
@@ -995,7 +1131,7 @@ export default {
} }
// Allowed to speak (current speaker) but chose NO_REPLY - advance turn // Allowed to speak (current speaker) but chose NO_REPLY - advance turn
ensureTurnOrder(api, channelId); ensureTurnOrder(api, channelId, live);
const nextSpeaker = onSpeakerDone(channelId, accountId, true); const nextSpeaker = onSpeakerDone(channelId, accountId, true);
sessionAllowed.delete(key); sessionAllowed.delete(key);

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; 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"; import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; 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. * Ensure turn order is initialized for a channel.
* Uses only bot accounts that have been seen in this 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 { function ensureTurnOrder(api: OpenClawPluginApi, channelId: string, config?: WhisperGateConfig): boolean {
const botAccounts = getChannelBotAccountIds(api, channelId); // Skip if already pre-populated (e.g. from channel-private-create)
if (botAccounts.length > 0) { if (hasTurnState(channelId)) {
initTurnOrder(channelId, botAccounts); 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); 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 */ /** Get the moderator bot's Discord user ID from its token */
function getModeratorUserId(config: WhisperGateConfig): string | undefined { function getModeratorUserId(config: WhisperGateConfig): string | undefined {
if (!config.moderatorBotToken) return undefined; if (!config.moderatorBotToken) return undefined;
@@ -531,6 +573,100 @@ export default {
isError: true, 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 }] }; return { content: [{ type: "text", text }] };
} }
@@ -619,7 +755,7 @@ export default {
// Turn management on message received // Turn management on message received
if (preChannelId) { if (preChannelId) {
ensureTurnOrder(api, preChannelId); ensureTurnOrder(api, preChannelId, livePre);
// event.from is often the channel target (e.g. "discord:channel:xxx"), NOT the sender. // event.from is often the channel target (e.g. "discord:channel:xxx"), NOT the sender.
// The actual sender ID is in event.metadata.senderId. // The actual sender ID is in event.metadata.senderId.
const metadata = (e as Record<string, unknown>).metadata as Record<string, unknown> | undefined; const metadata = (e as Record<string, unknown>).metadata as Record<string, unknown> | undefined;
@@ -643,7 +779,7 @@ export default {
const isNew = recordChannelAccount(preChannelId, senderAccountId); const isNew = recordChannelAccount(preChannelId, senderAccountId);
if (isNew) { if (isNew) {
// Re-initialize turn order with updated channel membership // 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`); 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. // Turn-based check: ALWAYS check turn order regardless of evaluateDecision result.
// This ensures only the current speaker can respond even for human messages. // This ensures only the current speaker can respond even for human messages.
if (derived.channelId) { if (derived.channelId) {
ensureTurnOrder(api, derived.channelId); ensureTurnOrder(api, derived.channelId, live);
const accountId = resolveAccountId(api, ctx.agentId || ""); const accountId = resolveAccountId(api, ctx.agentId || "");
if (accountId) { if (accountId) {
const turnCheck = checkTurn(derived.channelId, accountId); const turnCheck = checkTurn(derived.channelId, accountId);
@@ -995,7 +1131,7 @@ export default {
} }
// Allowed to speak (current speaker) but chose NO_REPLY - advance turn // Allowed to speak (current speaker) but chose NO_REPLY - advance turn
ensureTurnOrder(api, channelId); ensureTurnOrder(api, channelId, live);
const nextSpeaker = onSpeakerDone(channelId, accountId, true); const nextSpeaker = onSpeakerDone(channelId, accountId, true);
sessionAllowed.delete(key); sessionAllowed.delete(key);

View File

@@ -2,13 +2,17 @@
* Turn-based speaking manager for group channels. * Turn-based speaking manager for group channels.
* *
* Rules: * Rules:
* - Humans (humanList) are never in the turn order * - Humans (humanList) and the moderator bot are never in the turn order
* - Turn order is auto-populated from channel/server members minus humans * - Turn order is auto-populated from channel/server members minus humans/moderator
* - currentSpeaker can be null (dormant state) * - currentSpeaker can be null (dormant state)
* - When ALL agents in a cycle have NO_REPLY'd, state goes dormant (null) * - When ALL agents in a cycle have NO_REPLY'd, state goes dormant (null)
* - Dormant → any new message reactivates: * - Dormant → any new message reactivates:
* - If sender is NOT in turn order → current = first in list * - If sender is NOT in turn order → current = first in list
* - If sender IS in turn order → current = next after sender * - If sender IS in turn order → current = next after sender
* - When turn order is first created via discovery, all agents are forced no-reply
* until moderator activates the first speaker
* - When turn order is pre-populated (e.g. channel-private-create), the first
* speaker is immediately activated via moderator handoff
*/ */
export type ChannelTurnState = { export type ChannelTurnState = {
@@ -20,6 +24,12 @@ export type ChannelTurnState = {
noRepliedThisCycle: Set<string>; noRepliedThisCycle: Set<string>;
/** Timestamp of last state change */ /** Timestamp of last state change */
lastChangedAt: number; lastChangedAt: number;
/**
* Whether this turn state was pre-populated (e.g. from channel-private-create params).
* Pre-populated states have a complete member list and can be activated immediately.
* Discovery-based states may be incomplete and need stabilization.
*/
prePopulated: boolean;
}; };
const channelTurns = new Map<string, ChannelTurnState>(); const channelTurns = new Map<string, ChannelTurnState>();
@@ -40,18 +50,42 @@ function shuffleArray<T>(arr: T[]): T[] {
// --- public API --- // --- public API ---
/**
* Check if a channel already has turn state initialized.
*/
export function hasTurnState(channelId: string): boolean {
return channelTurns.has(channelId);
}
/** /**
* Initialize or update the turn order for a channel. * Initialize or update the turn order for a channel.
* Called with the list of bot accountIds (already filtered, humans excluded). * Called with the list of bot accountIds (already filtered, humans excluded).
* Starts in dormant state — activation happens via onNewMessage or activateFirstSpeaker.
*
* @returns true if a NEW turn state was created (callers should trigger moderator activation)
*/ */
export function initTurnOrder(channelId: string, botAccountIds: string[]): void { export function initTurnOrder(channelId: string, botAccountIds: string[]): boolean {
const existing = channelTurns.get(channelId); const existing = channelTurns.get(channelId);
if (existing) { if (existing) {
// Check if membership changed // Check if membership changed
const oldSet = new Set(existing.turnOrder); const oldSet = new Set(existing.turnOrder);
const newSet = new Set(botAccountIds); const newSet = new Set(botAccountIds);
const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id)); const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id));
if (same) return; // no change if (same) return false; // no change
// Membership changed — update turn order, preserve current state where possible
const newOrder = shuffleArray(botAccountIds);
existing.turnOrder = newOrder;
// If current speaker was removed, go dormant
if (existing.currentSpeaker && !newSet.has(existing.currentSpeaker)) {
existing.currentSpeaker = newOrder.length > 0 ? newOrder[0] : null;
}
// Clean up noRepliedThisCycle
for (const id of existing.noRepliedThisCycle) {
if (!newSet.has(id)) existing.noRepliedThisCycle.delete(id);
}
existing.lastChangedAt = Date.now();
return false;
} }
channelTurns.set(channelId, { channelTurns.set(channelId, {
@@ -59,7 +93,104 @@ export function initTurnOrder(channelId: string, botAccountIds: string[]): void
currentSpeaker: null, // start dormant currentSpeaker: null, // start dormant
noRepliedThisCycle: new Set(), noRepliedThisCycle: new Set(),
lastChangedAt: Date.now(), lastChangedAt: Date.now(),
prePopulated: false,
}); });
return true;
}
/**
* Initialize turn order from a known complete member list (e.g. channel-private-create).
* Immediately activates the first speaker.
*
* @returns the first speaker accountId, or null if no bots
*/
export function initTurnOrderPrePopulated(channelId: string, botAccountIds: string[]): string | null {
if (botAccountIds.length === 0) return null;
const order = shuffleArray(botAccountIds);
channelTurns.set(channelId, {
turnOrder: order,
currentSpeaker: order[0],
noRepliedThisCycle: new Set(),
lastChangedAt: Date.now(),
prePopulated: true,
});
return order[0];
}
/**
* Update turn order when members are added or removed (e.g. channel-private-update).
* Preserves current speaker if still present, otherwise activates first in new order.
*
* @returns { turnOrder, currentSpeaker, needsHandoff } — needsHandoff is true if
* currentSpeaker changed and moderator should send a handoff message
*/
export function updateTurnMembers(
channelId: string,
addAccountIds: string[],
removeAccountIds: string[],
): { turnOrder: string[]; currentSpeaker: string | null; needsHandoff: boolean } {
const state = channelTurns.get(channelId);
const removeSet = new Set(removeAccountIds);
if (!state) {
// No existing state — create fresh with the added members
const order = shuffleArray(addAccountIds.filter(id => !removeSet.has(id)));
if (order.length === 0) return { turnOrder: [], currentSpeaker: null, needsHandoff: false };
channelTurns.set(channelId, {
turnOrder: order,
currentSpeaker: order[0],
noRepliedThisCycle: new Set(),
lastChangedAt: Date.now(),
prePopulated: true,
});
return { turnOrder: order, currentSpeaker: order[0], needsHandoff: true };
}
const oldSpeaker = state.currentSpeaker;
// Remove members
if (removeAccountIds.length > 0) {
state.turnOrder = state.turnOrder.filter(id => !removeSet.has(id));
for (const id of removeAccountIds) {
state.noRepliedThisCycle.delete(id);
}
}
// Add new members (append, don't shuffle existing order to preserve continuity)
for (const id of addAccountIds) {
if (!removeSet.has(id) && !state.turnOrder.includes(id)) {
state.turnOrder.push(id);
}
}
// Handle current speaker removal
let needsHandoff = false;
if (oldSpeaker && !state.turnOrder.includes(oldSpeaker)) {
// Current speaker was removed — activate first available
state.currentSpeaker = state.turnOrder.length > 0 ? state.turnOrder[0] : null;
needsHandoff = state.currentSpeaker !== null;
}
state.lastChangedAt = Date.now();
return { turnOrder: state.turnOrder, currentSpeaker: state.currentSpeaker, needsHandoff };
}
/**
* Activate the first speaker in the turn order.
* Used after turn order creation to start the cycle.
*
* @returns the activated speaker accountId, or null if no bots / already active
*/
export function activateFirstSpeaker(channelId: string): string | null {
const state = channelTurns.get(channelId);
if (!state || state.turnOrder.length === 0) return null;
if (state.currentSpeaker !== null) return null; // already active
state.currentSpeaker = state.turnOrder[0];
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return state.currentSpeaker;
} }
/** /**
@@ -80,7 +211,7 @@ export function checkTurn(channelId: string, accountId: string): {
return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" }; return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" };
} }
// Dormant → not allowed (will be activated by onNewMessage) // Dormant → not allowed (will be activated by onNewMessage or moderator)
if (state.currentSpeaker === null) { if (state.currentSpeaker === null) {
return { allowed: false, currentSpeaker: null, reason: "dormant" }; return { allowed: false, currentSpeaker: null, reason: "dormant" };
} }
@@ -105,7 +236,8 @@ export function checkTurn(channelId: string, accountId: string): {
/** /**
* Called when a new message arrives in the channel. * Called when a new message arrives in the channel.
* Handles reactivation from dormant state and human-triggered resets. * Handles reactivation from dormant state.
* Any member (human or bot) can trigger activation when dormant.
* *
* @param senderAccountId - the accountId of the message sender (could be human/bot/unknown) * @param senderAccountId - the accountId of the message sender (could be human/bot/unknown)
* @param isHuman - whether the sender is in the humanList * @param isHuman - whether the sender is in the humanList
@@ -114,27 +246,24 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi
const state = channelTurns.get(channelId); const state = channelTurns.get(channelId);
if (!state || state.turnOrder.length === 0) return; if (!state || state.turnOrder.length === 0) return;
if (isHuman) {
// Human message: activate, start from first in order
state.currentSpeaker = state.turnOrder[0];
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return;
}
if (state.currentSpeaker !== null) { if (state.currentSpeaker !== null) {
// Already active, no change needed from incoming message // Already active — human messages reset the cycle, bot messages do nothing
if (isHuman) {
state.currentSpeaker = state.turnOrder[0];
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
}
return; return;
} }
// Dormant state + non-human message → reactivate // Dormant state → reactivate (any member can trigger)
if (senderAccountId && state.turnOrder.includes(senderAccountId)) { if (senderAccountId && state.turnOrder.includes(senderAccountId)) {
// Sender is in turn order → next after sender // Sender is in turn order → next after sender
const idx = state.turnOrder.indexOf(senderAccountId); const idx = state.turnOrder.indexOf(senderAccountId);
const nextIdx = (idx + 1) % state.turnOrder.length; const nextIdx = (idx + 1) % state.turnOrder.length;
state.currentSpeaker = state.turnOrder[nextIdx]; state.currentSpeaker = state.turnOrder[nextIdx];
} else { } else {
// Sender not in turn order → start from first // Sender not in turn order (human or unknown) → start from first
state.currentSpeaker = state.turnOrder[0]; state.currentSpeaker = state.turnOrder[0];
} }
state.noRepliedThisCycle = new Set(); state.noRepliedThisCycle = new Set();
@@ -229,5 +358,6 @@ export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
noRepliedThisCycle: [...state.noRepliedThisCycle], noRepliedThisCycle: [...state.noRepliedThisCycle],
lastChangedAt: state.lastChangedAt, lastChangedAt: state.lastChangedAt,
dormant: state.currentSpeaker === null, dormant: state.currentSpeaker === null,
prePopulated: state.prePopulated,
}; };
} }