import { v4 as uuidv4 } from 'uuid'; import { BotOrchestrator, OrchestratorCallbacks, OrchestratorOptions } from './bot/orchestrator'; import { BotSession, BotState, TranscriptEntry } from './types'; import { logger } from './utils/logger'; import { config } from './config'; /** * Manages all active bot sessions. * Each session connects to the Gateway independently via WebSocket. */ export class SessionManager { private _sessions: Map = new Map(); constructor() {} /** * Initialize the session manager. */ async initialize(): Promise { logger.info('Initializing SessionManager...'); logger.info(`Gateway WebSocket URL: ${config.gatewayWsUrl}`); // Sessions connect to Gateway individually when created } /** * Create a new bot session and join the meeting. * * @param sessionId - Unique session ID * @param meetingUrl - Teams meeting URL * @param botName - Display name for the bot * @param instanceId - Feature instance ID (for Gateway routing) * @param gatewayWsUrl - Full WebSocket URL to connect back to Gateway (supports multi-instance) * @param language - BCP-47 language code for captions spoken language (e.g. "de-DE") */ async createSession( sessionId: string, meetingUrl: string, botName?: string, instanceId?: string, gatewayWsUrl?: string, language?: string, botAccountEmail?: string, botAccountPassword?: string, transferMode?: string, ): Promise { if (this._sessions.has(sessionId)) { logger.warn(`Session ${sessionId} already exists`); return; } logger.info(`Creating session ${sessionId} for meeting: ${meetingUrl}`); logger.info(`Gateway WebSocket URL: ${gatewayWsUrl || 'not provided, using config'}`); if (botAccountEmail) { logger.info(`Authenticated join as: ${botAccountEmail}`); } const callbacks: OrchestratorCallbacks = { onStateChange: (state, message) => { this._handleStateChange(sessionId, state, message); }, onTranscript: (entry) => { this._handleTranscript(sessionId, entry); }, onError: (error) => { this._handleError(sessionId, error); }, }; // Options for Gateway connection // Use the gatewayWsUrl from the request if provided (supports multi-instance gateways) // Otherwise fall back to config (for local development) const options: OrchestratorOptions = { gatewayWsUrl: gatewayWsUrl || config.gatewayWsUrl, instanceId: instanceId || 'default', language: language, botAccountEmail: botAccountEmail, botAccountPassword: botAccountPassword, transferMode: transferMode, }; const orchestrator = new BotOrchestrator( sessionId, meetingUrl, botName || config.botName, callbacks, options ); this._sessions.set(sessionId, orchestrator); // Start the bot asynchronously orchestrator.start().catch((error) => { logger.error(`Session ${sessionId} failed to start:`, error); }); } /** * End a bot session and leave the meeting. * Robust: handles cases where the session was already cleaned up * (e.g. disconnected state removed it from the map). */ async endSession(sessionId: string): Promise { const orchestrator = this._sessions.get(sessionId); if (!orchestrator) { logger.warn(`Session ${sessionId} not found for endSession - may have already been cleaned up`); return; } logger.info(`Ending session ${sessionId}`); try { await orchestrator.stop(); } catch (error) { logger.error(`Error stopping session ${sessionId}:`, error); } finally { // Always remove from map after explicit end this._sessions.delete(sessionId); } } /** * Play audio in a session's meeting. */ async playAudio( sessionId: string, audioData: string, format: 'mp3' | 'wav' | 'pcm' ): Promise { const orchestrator = this._sessions.get(sessionId); if (!orchestrator) { logger.warn(`Session ${sessionId} not found for audio playback`); return; } await orchestrator.playAudio(audioData, format); } /** * Get the status of a session. */ getSessionStatus(sessionId: string): { state: string; error?: string } | null { const orchestrator = this._sessions.get(sessionId); if (!orchestrator) { return null; } return { state: orchestrator.state, }; } /** * Get all active session IDs. */ getActiveSessions(): string[] { return Array.from(this._sessions.keys()); } /** * Shutdown all sessions. */ async shutdown(): Promise { logger.info('Shutting down SessionManager...'); // End all sessions const sessionIds = Array.from(this._sessions.keys()); await Promise.all(sessionIds.map((id) => this.endSession(id))); logger.info('SessionManager shutdown complete'); } /** * Handle state changes from orchestrators. * * IMPORTANT: Do NOT delete from _sessions on 'disconnected' state. * The orchestrator may enter 'disconnected' due to a transient WebSocket * drop or browser crash. If we delete here, the Gateway's subsequent * 'leave' command won't find the session in endSession(). * Cleanup is done explicitly in endSession() or shutdown(). * Only auto-remove on terminal 'error' state after a delay so the * Gateway still has time to call endSession() first. */ private _handleStateChange(sessionId: string, state: BotState, message?: string): void { logger.info(`Session ${sessionId} state: ${state}${message ? ` - ${message}` : ''}`); if (state === 'error') { // Give Gateway a grace period to call endSession(), then auto-cleanup setTimeout(() => { if (this._sessions.has(sessionId)) { const orch = this._sessions.get(sessionId); if (orch && orch.state === 'error') { logger.info(`Auto-cleaning stale error session ${sessionId}`); this._sessions.delete(sessionId); } } }, 30000); // 30s grace period } // 'disconnected' state: do NOT delete - let endSession() handle it } /** * Handle transcripts from orchestrators. */ private _handleTranscript(sessionId: string, entry: TranscriptEntry): void { logger.debug(`Session ${sessionId} transcript: [${entry.speaker}] ${entry.text}`); // Transcripts are sent to Gateway by the orchestrator directly } /** * Handle errors from orchestrators. */ private _handleError(sessionId: string, error: Error): void { logger.error(`Session ${sessionId} error:`, error); // Errors are sent to Gateway by the orchestrator directly } }