222 lines
6.8 KiB
TypeScript
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
|
|
}
|
|
}
|