import WebSocket from 'ws'; import { Logger } from 'winston'; import { GatewayToBot, BotToGateway, TranscriptMessage, StatusMessage, BotState } from '../types'; import { logger } from '../utils/logger'; export interface GatewayClientCallbacks { onJoinMeeting: (sessionId: string, meetingUrl: string, botName?: string) => void; onLeaveMeeting: (sessionId: string) => void; onPlayAudio: (sessionId: string, audioData: string, format: 'mp3' | 'wav' | 'pcm') => void; onDisconnect: () => void; } /** * WebSocket client that connects to the Gateway. * Receives commands (join, leave, play audio) and sends events (transcript, status). */ export class GatewayClient { private _wsUrl: string; private _ws: WebSocket | null = null; private _callbacks: GatewayClientCallbacks; private _logger: Logger; private _reconnectAttempts: number = 0; private _maxReconnectAttempts: number = 10; private _reconnectDelay: number = 1000; private _isConnecting: boolean = false; private _shouldReconnect: boolean = true; constructor(wsUrl: string, callbacks: GatewayClientCallbacks) { this._wsUrl = wsUrl; this._callbacks = callbacks; this._logger = logger.child({ component: 'GatewayClient' }); } /** * Connect to the Gateway WebSocket. */ async connect(): Promise { if (this._isConnecting || (this._ws && this._ws.readyState === WebSocket.OPEN)) { return; } this._isConnecting = true; this._shouldReconnect = true; return new Promise((resolve, reject) => { this._logger.info(`Connecting to Gateway: ${this._wsUrl}`); this._ws = new WebSocket(this._wsUrl); this._ws.on('open', () => { this._logger.info('Connected to Gateway'); this._isConnecting = false; this._reconnectAttempts = 0; resolve(); }); this._ws.on('message', (data) => { this._handleMessage(data.toString()); }); this._ws.on('close', (code, reason) => { this._logger.warn(`Gateway connection closed: ${code} - ${reason}`); this._isConnecting = false; this._ws = null; this._callbacks.onDisconnect(); if (this._shouldReconnect) { this._scheduleReconnect(); } }); this._ws.on('error', (error) => { this._logger.error('Gateway WebSocket error:', error); this._isConnecting = false; if (this._reconnectAttempts === 0) { reject(error); } }); }); } /** * Disconnect from the Gateway. */ disconnect(): void { this._shouldReconnect = false; if (this._ws) { this._ws.close(1000, 'Client disconnecting'); this._ws = null; } } /** * Send a transcript to the Gateway. */ sendTranscript( sessionId: string, speaker: string, text: string, isFinal: boolean = true ): void { const message: TranscriptMessage = { type: 'transcript', sessionId, transcript: { speaker, text, timestamp: new Date().toISOString(), isFinal, }, }; this._send(message); } /** * Send a status update to the Gateway. */ sendStatus( sessionId: string, status: StatusMessage['status'], message?: string ): void { const statusMessage: StatusMessage = { type: 'status', sessionId, status, message, }; this._send(statusMessage); } /** * Map BotState to StatusMessage status. */ mapStateToStatus(state: BotState): StatusMessage['status'] { switch (state) { case 'launching': case 'navigating': return 'connecting'; case 'in_lobby': return 'in_lobby'; case 'in_meeting': return 'joined'; case 'leaving': case 'disconnected': return 'left'; case 'error': return 'error'; default: return 'connecting'; } } /** * Handle incoming messages from the Gateway. */ private _handleMessage(data: string): void { try { const message = JSON.parse(data) as GatewayToBot; this._logger.debug('Received message:', { type: message.type }); switch (message.type) { case 'joinMeeting': this._callbacks.onJoinMeeting( message.sessionId, message.meetingUrl, message.botName ); break; case 'leaveMeeting': this._callbacks.onLeaveMeeting(message.sessionId); break; case 'playAudio': this._callbacks.onPlayAudio( message.sessionId, message.audio.data, message.audio.format ); break; default: this._logger.warn('Unknown message type:', (message as any).type); } } catch (error) { this._logger.error('Error parsing Gateway message:', error); } } /** * Send a message to the Gateway. */ private _send(message: BotToGateway): void { if (!this._ws || this._ws.readyState !== WebSocket.OPEN) { this._logger.warn('Cannot send message - not connected'); return; } try { this._ws.send(JSON.stringify(message)); } catch (error) { this._logger.error('Error sending message:', error); } } /** * Schedule a reconnection attempt. */ private _scheduleReconnect(): void { if (this._reconnectAttempts >= this._maxReconnectAttempts) { this._logger.error('Max reconnection attempts reached'); return; } this._reconnectAttempts++; const delay = this._reconnectDelay * Math.pow(2, this._reconnectAttempts - 1); this._logger.info(`Reconnecting in ${delay}ms (attempt ${this._reconnectAttempts}/${this._maxReconnectAttempts})`); setTimeout(() => { this.connect().catch((error) => { this._logger.error('Reconnection failed:', error); }); }, delay); } }