320 lines
9.2 KiB
TypeScript
320 lines
9.2 KiB
TypeScript
import {
|
|
WebSocketGateway,
|
|
WebSocketServer,
|
|
SubscribeMessage,
|
|
OnGatewayConnection,
|
|
OnGatewayDisconnect,
|
|
ConnectedSocket,
|
|
MessageBody,
|
|
} from '@nestjs/websockets';
|
|
import { Server, Socket } from 'socket.io';
|
|
import { Logger, UseGuards } from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { VoiceService } from './voice.service';
|
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
|
|
|
interface AuthenticatedSocket extends Socket {
|
|
tenantId?: string;
|
|
userId?: string;
|
|
tenantSlug?: string;
|
|
}
|
|
|
|
@WebSocketGateway({
|
|
namespace: 'voice',
|
|
cors: {
|
|
origin: true,
|
|
credentials: true,
|
|
},
|
|
})
|
|
export class VoiceGateway
|
|
implements OnGatewayConnection, OnGatewayDisconnect
|
|
{
|
|
@WebSocketServer()
|
|
server: Server;
|
|
|
|
private readonly logger = new Logger(VoiceGateway.name);
|
|
private connectedUsers: Map<string, AuthenticatedSocket> = new Map();
|
|
private activeCallsByUser: Map<string, string> = new Map(); // userId -> callSid
|
|
|
|
constructor(
|
|
private readonly jwtService: JwtService,
|
|
private readonly voiceService: VoiceService,
|
|
private readonly tenantDbService: TenantDatabaseService,
|
|
) {
|
|
// Set gateway reference in service to avoid circular dependency
|
|
this.voiceService.setGateway(this);
|
|
}
|
|
|
|
async handleConnection(client: AuthenticatedSocket) {
|
|
try {
|
|
// Extract token from handshake auth
|
|
const token =
|
|
client.handshake.auth.token || client.handshake.headers.authorization?.split(' ')[1];
|
|
|
|
if (!token) {
|
|
this.logger.warn('❌ Client connection rejected: No token provided');
|
|
client.disconnect();
|
|
return;
|
|
}
|
|
|
|
// Verify JWT token
|
|
const payload = await this.jwtService.verifyAsync(token);
|
|
|
|
// Extract domain from origin header (e.g., http://tenant1.routebox.co:3001)
|
|
// The domains table stores just the subdomain part (e.g., "tenant1")
|
|
const origin = client.handshake.headers.origin || client.handshake.headers.referer;
|
|
let domain = 'localhost';
|
|
|
|
if (origin) {
|
|
try {
|
|
const url = new URL(origin);
|
|
const hostname = url.hostname; // e.g., tenant1.routebox.co or localhost
|
|
|
|
// Extract first part of subdomain as domain
|
|
// tenant1.routebox.co -> tenant1
|
|
// localhost -> localhost
|
|
domain = hostname.split('.')[0];
|
|
} catch (error) {
|
|
this.logger.warn(`Failed to parse origin: ${origin}`);
|
|
}
|
|
}
|
|
|
|
client.tenantId = domain; // Store the subdomain as tenantId
|
|
client.userId = payload.sub;
|
|
client.tenantSlug = domain; // Same as subdomain
|
|
|
|
this.connectedUsers.set(client.userId, client);
|
|
this.logger.log(
|
|
`✓ Client connected: ${client.id} (User: ${client.userId}, Domain: ${domain})`,
|
|
);
|
|
this.logger.log(`Total connected users in ${domain}: ${this.getConnectedUsers(domain).length}`);
|
|
|
|
// Send current call state if any active call
|
|
const activeCallSid = this.activeCallsByUser.get(client.userId);
|
|
if (activeCallSid) {
|
|
const callState = await this.voiceService.getCallState(
|
|
activeCallSid,
|
|
client.tenantId,
|
|
);
|
|
client.emit('call:state', callState);
|
|
}
|
|
} catch (error) {
|
|
this.logger.error('❌ Authentication failed', error);
|
|
client.disconnect();
|
|
}
|
|
}
|
|
|
|
handleDisconnect(client: AuthenticatedSocket) {
|
|
if (client.userId) {
|
|
this.connectedUsers.delete(client.userId);
|
|
this.logger.log(`✓ Client disconnected: ${client.id} (User: ${client.userId})`);
|
|
this.logger.log(`Remaining connected users: ${this.connectedUsers.size}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initiate outbound call
|
|
*/
|
|
@SubscribeMessage('call:initiate')
|
|
async handleInitiateCall(
|
|
@ConnectedSocket() client: AuthenticatedSocket,
|
|
@MessageBody() data: { toNumber: string },
|
|
) {
|
|
try {
|
|
this.logger.log(`Initiating call from user ${client.userId} to ${data.toNumber}`);
|
|
|
|
const result = await this.voiceService.initiateCall({
|
|
tenantId: client.tenantId,
|
|
userId: client.userId,
|
|
toNumber: data.toNumber,
|
|
});
|
|
|
|
this.activeCallsByUser.set(client.userId, result.callSid);
|
|
|
|
client.emit('call:initiated', {
|
|
callSid: result.callSid,
|
|
toNumber: data.toNumber,
|
|
status: 'queued',
|
|
});
|
|
|
|
return { success: true, callSid: result.callSid };
|
|
} catch (error) {
|
|
this.logger.error('Failed to initiate call', error);
|
|
client.emit('call:error', {
|
|
message: error.message || 'Failed to initiate call',
|
|
});
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Accept incoming call
|
|
*/
|
|
@SubscribeMessage('call:accept')
|
|
async handleAcceptCall(
|
|
@ConnectedSocket() client: AuthenticatedSocket,
|
|
@MessageBody() data: { callSid: string },
|
|
) {
|
|
try {
|
|
this.logger.log(`User ${client.userId} accepting call ${data.callSid}`);
|
|
|
|
await this.voiceService.acceptCall({
|
|
callSid: data.callSid,
|
|
tenantId: client.tenantId,
|
|
userId: client.userId,
|
|
});
|
|
|
|
this.activeCallsByUser.set(client.userId, data.callSid);
|
|
|
|
client.emit('call:accepted', { callSid: data.callSid });
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
this.logger.error('Failed to accept call', error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reject incoming call
|
|
*/
|
|
@SubscribeMessage('call:reject')
|
|
async handleRejectCall(
|
|
@ConnectedSocket() client: AuthenticatedSocket,
|
|
@MessageBody() data: { callSid: string },
|
|
) {
|
|
try {
|
|
this.logger.log(`User ${client.userId} rejecting call ${data.callSid}`);
|
|
|
|
await this.voiceService.rejectCall(data.callSid, client.tenantId);
|
|
|
|
client.emit('call:rejected', { callSid: data.callSid });
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
this.logger.error('Failed to reject call', error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* End active call
|
|
*/
|
|
@SubscribeMessage('call:end')
|
|
async handleEndCall(
|
|
@ConnectedSocket() client: AuthenticatedSocket,
|
|
@MessageBody() data: { callSid: string },
|
|
) {
|
|
try {
|
|
this.logger.log(`User ${client.userId} ending call ${data.callSid}`);
|
|
|
|
await this.voiceService.endCall(data.callSid, client.tenantId);
|
|
|
|
this.activeCallsByUser.delete(client.userId);
|
|
|
|
client.emit('call:ended', { callSid: data.callSid });
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
this.logger.error('Failed to end call', error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send DTMF tones
|
|
*/
|
|
@SubscribeMessage('call:dtmf')
|
|
async handleDtmf(
|
|
@ConnectedSocket() client: AuthenticatedSocket,
|
|
@MessageBody() data: { callSid: string; digit: string },
|
|
) {
|
|
try {
|
|
await this.voiceService.sendDtmf(
|
|
data.callSid,
|
|
data.digit,
|
|
client.tenantId,
|
|
);
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
this.logger.error('Failed to send DTMF', error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit incoming call notification to specific user
|
|
*/
|
|
async notifyIncomingCall(userId: string, callData: any) {
|
|
const socket = this.connectedUsers.get(userId);
|
|
if (socket) {
|
|
socket.emit('call:incoming', callData);
|
|
this.logger.log(`Notified user ${userId} of incoming call`);
|
|
} else {
|
|
this.logger.warn(`User ${userId} not connected to receive call notification`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit call status update to user
|
|
*/
|
|
async notifyCallUpdate(userId: string, callData: any) {
|
|
const socket = this.connectedUsers.get(userId);
|
|
if (socket) {
|
|
socket.emit('call:update', callData);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit AI transcript to user
|
|
*/
|
|
async notifyAiTranscript(userId: string, data: { callSid: string; transcript: string; isFinal: boolean }) {
|
|
const socket = this.connectedUsers.get(userId);
|
|
if (socket) {
|
|
socket.emit('ai:transcript', data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit AI suggestion to user
|
|
*/
|
|
async notifyAiSuggestion(userId: string, data: any) {
|
|
const socket = this.connectedUsers.get(userId);
|
|
this.logger.log(`notifyAiSuggestion - userId: ${userId}, socket connected: ${!!socket}, total connected users: ${this.connectedUsers.size}`);
|
|
if (socket) {
|
|
this.logger.log(`Emitting ai:suggestion event with data:`, JSON.stringify(data));
|
|
socket.emit('ai:suggestion', data);
|
|
} else {
|
|
this.logger.warn(`No socket connection found for userId: ${userId}`);
|
|
this.logger.log(`Connected users: ${Array.from(this.connectedUsers.keys()).join(', ')}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit AI action result to user
|
|
*/
|
|
async notifyAiAction(userId: string, data: any) {
|
|
const socket = this.connectedUsers.get(userId);
|
|
if (socket) {
|
|
socket.emit('ai:action', data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get connected users for a tenant
|
|
*/
|
|
getConnectedUsers(tenantDomain?: string): string[] {
|
|
const userIds: string[] = [];
|
|
|
|
for (const [userId, socket] of this.connectedUsers.entries()) {
|
|
// If tenantDomain specified, filter by tenant
|
|
if (!tenantDomain || socket.tenantSlug === tenantDomain) {
|
|
userIds.push(userId);
|
|
}
|
|
}
|
|
|
|
return userIds;
|
|
}
|
|
}
|