238 lines
5.8 KiB
TypeScript
238 lines
5.8 KiB
TypeScript
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<void> {
|
|
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);
|
|
}
|
|
}
|