service-teams-browser-bot/src/server/gatewayClient.ts
ValueOn AG 043349f529 Initial commit: Browser-based Teams Meeting Bot
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 22:44:57 +01:00

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);
}
}