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
dist/whispergate/index.ts
vendored
154
dist/whispergate/index.ts
vendored
@@ -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);
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<string>;
|
||||
/** 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<string, ChannelTurnState>();
|
||||
@@ -40,18 +50,42 @@ function shuffleArray<T>(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<string, unknown> {
|
||||
noRepliedThisCycle: [...state.noRepliedThisCycle],
|
||||
lastChangedAt: state.lastChangedAt,
|
||||
dormant: state.currentSpeaker === null,
|
||||
prePopulated: state.prePopulated,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user