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:
@@ -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