diff --git a/dist/whispergate/index.ts b/dist/whispergate/index.ts index 61c61ad..1312e2a 100644 --- a/dist/whispergate/index.ts +++ b/dist/whispergate/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) || {}; + const channels = (root.channels as Record) || {}; + const discord = (channels.discord as Record) || {}; + const accounts = (discord.accounts as Record>) || {}; + 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).metadata as Record | 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); diff --git a/plugin/index.ts b/plugin/index.ts index 61c61ad..1312e2a 100644 --- a/plugin/index.ts +++ b/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) || {}; + const channels = (root.channels as Record) || {}; + const discord = (channels.discord as Record) || {}; + const accounts = (discord.accounts as Record>) || {}; + 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).metadata as Record | 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); diff --git a/plugin/turn-manager.ts b/plugin/turn-manager.ts index 033699e..8d8cab1 100644 --- a/plugin/turn-manager.ts +++ b/plugin/turn-manager.ts @@ -2,13 +2,17 @@ * Turn-based speaking manager for group channels. * * Rules: - * - Humans (humanList) are never in the turn order - * - Turn order is auto-populated from channel/server members minus humans + * - Humans (humanList) and the moderator bot are never in the turn order + * - Turn order is auto-populated from channel/server members minus humans/moderator * - currentSpeaker can be null (dormant state) * - When ALL agents in a cycle have NO_REPLY'd, state goes dormant (null) * - Dormant → any new message reactivates: * - If sender is NOT in turn order → current = first in list * - 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 = { @@ -20,6 +24,12 @@ export type ChannelTurnState = { noRepliedThisCycle: Set; /** Timestamp of last state change */ 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(); @@ -40,18 +50,42 @@ function shuffleArray(arr: T[]): T[] { // --- 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. * 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); if (existing) { // Check if membership changed const oldSet = new Set(existing.turnOrder); const newSet = new Set(botAccountIds); 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, { @@ -59,7 +93,104 @@ export function initTurnOrder(channelId: string, botAccountIds: string[]): void currentSpeaker: null, // start dormant noRepliedThisCycle: new Set(), 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" }; } - // Dormant → not allowed (will be activated by onNewMessage) + // Dormant → not allowed (will be activated by onNewMessage or moderator) if (state.currentSpeaker === null) { 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. - * 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 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); 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) { - // 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; } - // Dormant state + non-human message → reactivate + // Dormant state → reactivate (any member can trigger) if (senderAccountId && state.turnOrder.includes(senderAccountId)) { // Sender is in turn order → next after sender const idx = state.turnOrder.indexOf(senderAccountId); const nextIdx = (idx + 1) % state.turnOrder.length; state.currentSpeaker = state.turnOrder[nextIdx]; } 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.noRepliedThisCycle = new Set(); @@ -229,5 +358,6 @@ export function getTurnDebugInfo(channelId: string): Record { noRepliedThisCycle: [...state.noRepliedThisCycle], lastChangedAt: state.lastChangedAt, dormant: state.currentSpeaker === null, + prePopulated: state.prePopulated, }; }