Files
FrenoCorp/server/alerts/alert-server.ts

416 lines
12 KiB
TypeScript

/**
* WebSocket Alert Server
* Real-time alert broadcasting for call analysis events and anomalies
* Connects to CallAnalysisEngine and pushes alerts to subscribed clients
*/
import { WebSocketServer, WebSocket } from 'ws';
import { CallAnalysisEngine, CallEvent, Anomaly, SentimentAnalysis, AnalysisResult } from '../../src/lib/inference/call-analysis-engine';
import { jwtVerify, SignJWT } from 'jose';
export type AlertType =
| 'anomaly'
| 'call_event'
| 'quality_degraded'
| 'sentiment_shift'
| 'call_summary'
| 'connection'
| 'disconnection';
export type AlertSeverity = 'info' | 'low' | 'medium' | 'high' | 'critical';
export interface AlertPayload {
id: string;
type: AlertType;
severity: AlertSeverity;
timestamp: number;
callId?: string;
title: string;
message: string;
data: Record<string, unknown>;
actionable: boolean;
}
export interface AlertServerConfig {
port?: number;
enableAuth?: boolean;
jwtSecret?: string;
allowedOrigins?: string[];
alertCooldownMs?: number;
maxSubscribers?: number;
enableCallCorrelation?: boolean;
}
export interface SubscriberSession {
ws: WebSocket;
userId?: string;
callIds: Set<string>;
lastAlertTime: Map<string, number>;
subscribedAt: number;
}
const DEFAULT_CONFIG: Required<AlertServerConfig> = {
port: 8088,
enableAuth: true,
jwtSecret: process.env.ALERT_SERVER_JWT_SECRET || '',
allowedOrigins: ['http://localhost:3000'],
alertCooldownMs: 5000,
maxSubscribers: 100,
enableCallCorrelation: true,
};
/**
* JWT verification helper
*/
async function verifyJWT(token: string, secret: string): Promise<any | null> {
try {
const decoded = await jwtVerify(token, new TextEncoder().encode(secret), {
algorithms: ['HS256'],
});
return decoded;
} catch (error) {
console.error('[AlertServer] JWT verification failed:', (error as Error).message);
return null;
}
}
export class AlertServer {
private wss: WebSocketServer | null = null;
private config: Required<AlertServerConfig>;
private subscribers: Map<string, SubscriberSession> = new Map();
private analysisEngines: Map<string, CallAnalysisEngine> = new Map();
private alertHistory: AlertPayload[] = [];
private maxAlertHistory: number = 500;
private isRunning: boolean = false;
constructor(config: AlertServerConfig = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
async start(): Promise<void> {
this.wss = new WebSocketServer({
port: this.config.port,
maxPayload: 1024 * 1024,
});
this.wss.on('connection', (ws: WebSocket, req) => {
this.handleConnection(ws, req);
});
this.wss.on('error', (error: Error) => {
console.error(`[AlertServer] WebSocket error: ${error.message}`);
});
this.isRunning = true;
console.log(`[AlertServer] Listening on port ${this.config.port}`);
}
private handleConnection(ws: WebSocket, req: import('http').IncomingMessage): void {
const url = new URL(req.url || '', `http://${req.headers.host}`);
const sessionId = url.searchParams.get('sessionId') || `sub-${Date.now()}-${Math.random().toString(36).slice(2)}`;
let userId = url.searchParams.get('userId') || undefined;
const callId = url.searchParams.get('callId') || undefined;
// Origin validation
const origin = req.headers.origin;
if (origin && !this.config.allowedOrigins.includes(origin)) {
ws.close(1008, 'Origin not allowed');
return;
}
// JWT Authentication (if enabled)
if (this.config.enableAuth && this.config.jwtSecret) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
ws.close(4001, 'Missing or invalid JWT token');
return;
}
const token = authHeader.substring(7);
const decoded = verifyJWT(token, this.config.jwtSecret);
if (!decoded) {
ws.close(4002, 'Invalid or expired JWT token');
return;
}
// Extract user ID from token if present
userId = (decoded as any).sub || userId;
}
if (this.subscribers.size >= this.config.maxSubscribers) {
ws.close(1013, 'Too many subscribers');
return;
}
const session: SubscriberSession = {
ws,
userId,
callIds: callId ? new Set([callId]) : new Set(),
lastAlertTime: new Map(),
subscribedAt: Date.now(),
};
this.subscribers.set(sessionId, session);
ws.send(JSON.stringify({
type: 'connected',
payload: { sessionId, userId, timestamp: Date.now() },
}));
ws.on('message', (data: Buffer | ArrayBuffer) => {
this.handleMessage(sessionId, data);
});
ws.on('close', () => {
this.subscribers.delete(sessionId);
console.log(`[AlertServer] Subscriber disconnected: ${sessionId}`);
});
ws.on('error', (error: Error) => {
console.error(`[AlertServer] Subscriber error (${sessionId}): ${error.message}`);
});
console.log(`[AlertServer] Subscriber connected: ${sessionId}${callId ? ` (call: ${callId})` : ''}`);
}
private handleMessage(sessionId: string, data: Buffer | ArrayBuffer): void {
try {
const message = JSON.parse(data.toString());
const session = this.subscribers.get(sessionId);
if (!session) return;
switch (message.type) {
case 'subscribe':
if (message.callId) {
session.callIds.add(message.callId);
}
break;
case 'unsubscribe':
if (message.callId) {
session.callIds.delete(message.callId);
}
break;
case 'ping':
session.ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
break;
}
} catch (error) {
console.error(`[AlertServer] Message parse error: ${(error as Error).message}`);
}
}
bindAnalysisEngine(callId: string, engine: CallAnalysisEngine): void {
this.analysisEngines.set(callId, engine);
engine.on('result', (result: AnalysisResult) => {
this.processAnalysisResult(callId, result);
});
engine.on('events', (events: CallEvent[]) => {
events.forEach(event => {
this.sendAlert({
type: 'call_event',
severity: event.severity as AlertSeverity,
callId,
title: this.formatEventType(event.type),
message: this.formatEventMessage(event),
data: { event, timestamp: event.timestamp },
actionable: event.severity === 'high',
});
});
});
engine.on('anomalies', (anomalies: Anomaly[]) => {
anomalies.forEach(anomaly => {
this.sendAlert({
type: 'anomaly',
severity: anomaly.severity as AlertSeverity,
callId,
title: this.formatAnomalyType(anomaly.type),
message: anomaly.description,
data: {
anomaly,
confidence: anomaly.confidence,
recommendation: anomaly.recommendation,
},
actionable: anomaly.severity === 'high' || anomaly.severity === 'critical',
});
});
});
console.log(`[AlertServer] Bound analysis engine for call: ${callId}`);
}
private processAnalysisResult(callId: string, result: AnalysisResult): void {
if (result.callQuality.mosScore < 3.0) {
this.sendAlert({
type: 'quality_degraded',
severity: result.callQuality.mosScore < 2.5 ? 'high' : 'medium',
callId,
title: 'Call Quality Degraded',
message: `MOS score: ${result.callQuality.mosScore.toFixed(1)} (threshold: 3.0)`,
data: result.callQuality as unknown as Record<string, unknown>,
actionable: true,
});
}
if (result.sentiment.sentiment === 'negative' && result.sentiment.confidence > 0.7) {
this.sendAlert({
type: 'sentiment_shift',
severity: 'medium',
callId,
title: 'Negative Sentiment Detected',
message: `Confidence: ${(result.sentiment.confidence * 100).toFixed(0)}%`,
data: result.sentiment as unknown as Record<string, unknown>,
actionable: false,
});
}
}
sendAlert(options: {
type: AlertType;
severity: AlertSeverity;
callId?: string;
title: string;
message: string;
data: Record<string, unknown>;
actionable: boolean;
}): void {
const cooldownKey = `${options.callId}:${options.type}`;
const now = Date.now();
const sessionKeys = Array.from(this.subscribers.keys());
for (const key of sessionKeys) {
const session = this.subscribers.get(key);
if (!session) continue;
const lastTime = session.lastAlertTime.get(cooldownKey) || 0;
if (now - lastTime < this.config.alertCooldownMs) continue;
if (options.callId && session.callIds.size > 0 && !session.callIds.has(options.callId)) continue;
const alert: AlertPayload = {
id: `alert-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: options.type,
severity: options.severity,
timestamp: now,
callId: options.callId,
title: options.title,
message: options.message,
data: options.data,
actionable: options.actionable,
};
this.alertHistory.push(alert);
if (this.alertHistory.length > this.maxAlertHistory) {
this.alertHistory.shift();
}
if (session.ws.readyState === WebSocket.OPEN) {
session.ws.send(JSON.stringify(alert));
}
session.lastAlertTime.set(cooldownKey, now);
}
}
broadcastCallSummary(callId: string, summary: string): void {
this.sendAlert({
type: 'call_summary',
severity: 'info',
callId,
title: 'Call Analysis Summary',
message: summary,
data: { summary },
actionable: false,
});
}
getAlertHistory(limit: number = 50, callId?: string): AlertPayload[] {
let history = this.alertHistory;
if (callId) {
history = history.filter(a => a.callId === callId);
}
return history.slice(-limit);
}
getSubscriberCount(): number {
return this.subscribers.size;
}
getActiveCalls(): string[] {
return Array.from(this.analysisEngines.keys());
}
getEngine(callId: string): CallAnalysisEngine | undefined {
return this.analysisEngines.get(callId);
}
async stop(): Promise<void> {
this.isRunning = false;
this.subscribers.forEach((session) => {
if (session.ws.readyState === WebSocket.OPEN) {
session.ws.send(JSON.stringify({
type: 'server_shutdown',
payload: { timestamp: Date.now() },
}));
session.ws.close(1001, 'Server shutting down');
}
});
this.analysisEngines.forEach((engine) => {
engine.destroy();
});
if (this.wss) {
await new Promise<void>((resolve) => {
this.wss!.close(() => resolve());
});
this.wss = null;
}
console.log('[AlertServer] Stopped');
}
private formatEventType(type: string): string {
const labels: Record<string, string> = {
interrupt: 'Speaker Interrupt',
overlap: 'Speech Overlap',
long_pause: 'Long Pause',
volume_spike: 'Volume Spike',
silence: 'Silence Detected',
speaker_change: 'Speaker Change',
};
return labels[type] || type;
}
private formatEventMessage(event: CallEvent): string {
const messages: Record<string, string> = {
interrupt: `Interrupt detected (${event.duration}ms)`,
overlap: `Speech overlap detected (${event.duration}ms)`,
long_pause: `Pause duration: ${(event.duration / 1000).toFixed(1)}s`,
volume_spike: `Volume spike: ${(event.metadata.level as number)?.toFixed(2) || 'unknown'}`,
silence: `Silence detected for ${(event.duration * 1000).toFixed(0)}ms`,
speaker_change: 'Speaker change detected',
};
return messages[event.type] || 'Event detected';
}
private formatAnomalyType(type: string): string {
const labels: Record<string, string> = {
background_noise: 'Background Noise',
echo: 'Echo Detected',
distortion: 'Audio Distortion',
dropouts: 'Audio Dropout',
excessive_silence: 'Excessive Silence',
volume_inconsistency: 'Volume Inconsistency',
};
return labels[type] || type;
}
}
export default AlertServer;