service-teams-browser-bot/src/sessionManager.ts
2026-05-12 19:16:07 +02:00

222 lines
6.8 KiB
TypeScript

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<string, BotOrchestrator> = new Map();
constructor() {}
/**
* Initialize the session manager.
*/
async initialize(): Promise<void> {
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,
debugMode?: boolean,
avatarMediaData?: string,
avatarMediaType?: string,
): Promise<void> {
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,
debugMode: debugMode,
avatarMediaData: avatarMediaData,
avatarMediaType: avatarMediaType,
};
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<void> {
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<void> {
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<void> {
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
}
}