WIP - twilio integration
This commit is contained in:
@@ -7,6 +7,7 @@ import { RbacModule } from './rbac/rbac.module';
|
||||
import { ObjectModule } from './object/object.module';
|
||||
import { AppBuilderModule } from './app-builder/app-builder.module';
|
||||
import { PageLayoutModule } from './page-layout/page-layout.module';
|
||||
import { VoiceModule } from './voice/voice.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -20,6 +21,7 @@ import { PageLayoutModule } from './page-layout/page-layout.module';
|
||||
ObjectModule,
|
||||
AppBuilderModule,
|
||||
PageLayoutModule,
|
||||
VoiceModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AppModule } from './app.module';
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter(),
|
||||
new FastifyAdapter({ logger: false }),
|
||||
);
|
||||
|
||||
// Global validation pipe
|
||||
|
||||
@@ -242,4 +242,26 @@ export class TenantDatabaseService {
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt integrations config JSON object
|
||||
* @param config - Plain object containing integration credentials
|
||||
* @returns Encrypted JSON string
|
||||
*/
|
||||
encryptIntegrationsConfig(config: any): string {
|
||||
if (!config) return null;
|
||||
const jsonString = JSON.stringify(config);
|
||||
return this.encryptPassword(jsonString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt integrations config JSON string
|
||||
* @param encryptedConfig - Encrypted JSON string
|
||||
* @returns Plain object with integration credentials
|
||||
*/
|
||||
decryptIntegrationsConfig(encryptedConfig: string): any {
|
||||
if (!encryptedConfig) return null;
|
||||
const decrypted = this.decryptPassword(encryptedConfig);
|
||||
return JSON.parse(decrypted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ export class TenantProvisioningService {
|
||||
* Seed default data for new tenant
|
||||
*/
|
||||
private async seedDefaultData(tenantId: string) {
|
||||
const tenantKnex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||
|
||||
try {
|
||||
// Create default roles
|
||||
|
||||
102
backend/src/tenant/tenant.controller.ts
Normal file
102
backend/src/tenant/tenant.controller.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { TenantDatabaseService } from './tenant-database.service';
|
||||
import { getCentralPrisma } from '../prisma/central-prisma.service';
|
||||
import { TenantId } from './tenant.decorator';
|
||||
|
||||
@Controller('tenant')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TenantController {
|
||||
constructor(private readonly tenantDbService: TenantDatabaseService) {}
|
||||
|
||||
/**
|
||||
* Get integrations configuration for the current tenant
|
||||
*/
|
||||
@Get('integrations')
|
||||
async getIntegrationsConfig(@TenantId() tenantId: string) {
|
||||
const centralPrisma = getCentralPrisma();
|
||||
|
||||
const tenant = await centralPrisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { integrationsConfig: true },
|
||||
});
|
||||
|
||||
if (!tenant || !tenant.integrationsConfig) {
|
||||
return { data: null };
|
||||
}
|
||||
|
||||
// Decrypt the config
|
||||
const config = this.tenantDbService.decryptIntegrationsConfig(
|
||||
tenant.integrationsConfig as any,
|
||||
);
|
||||
|
||||
// Return config with sensitive fields masked
|
||||
const maskedConfig = this.maskSensitiveFields(config);
|
||||
|
||||
return { data: maskedConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update integrations configuration for the current tenant
|
||||
*/
|
||||
@Put('integrations')
|
||||
async updateIntegrationsConfig(
|
||||
@TenantId() tenantId: string,
|
||||
@Body() body: { integrationsConfig: any },
|
||||
) {
|
||||
const { integrationsConfig } = body;
|
||||
|
||||
// Encrypt the config
|
||||
const encryptedConfig = this.tenantDbService.encryptIntegrationsConfig(
|
||||
integrationsConfig,
|
||||
);
|
||||
|
||||
// Update in database
|
||||
const centralPrisma = getCentralPrisma();
|
||||
await centralPrisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
integrationsConfig: encryptedConfig as any,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Integrations configuration updated successfully',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive fields for API responses
|
||||
*/
|
||||
private maskSensitiveFields(config: any): any {
|
||||
if (!config) return null;
|
||||
|
||||
const masked = { ...config };
|
||||
|
||||
// Mask Twilio credentials
|
||||
if (masked.twilio) {
|
||||
masked.twilio = {
|
||||
...masked.twilio,
|
||||
authToken: masked.twilio.authToken ? '••••••••' : '',
|
||||
};
|
||||
}
|
||||
|
||||
// Mask OpenAI credentials
|
||||
if (masked.openai) {
|
||||
masked.openai = {
|
||||
...masked.openai,
|
||||
apiKey: masked.openai.apiKey ? '••••••••' : '',
|
||||
};
|
||||
}
|
||||
|
||||
return masked;
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import { TenantDatabaseService } from './tenant-database.service';
|
||||
import { TenantProvisioningService } from './tenant-provisioning.service';
|
||||
import { TenantProvisioningController } from './tenant-provisioning.controller';
|
||||
import { CentralAdminController } from './central-admin.controller';
|
||||
import { TenantController } from './tenant.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [TenantProvisioningController, CentralAdminController],
|
||||
controllers: [TenantProvisioningController, CentralAdminController, TenantController],
|
||||
providers: [
|
||||
TenantDatabaseService,
|
||||
TenantProvisioningService,
|
||||
|
||||
25
backend/src/voice/dto/call-event.dto.ts
Normal file
25
backend/src/voice/dto/call-event.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface CallEventDto {
|
||||
callSid: string;
|
||||
direction: 'inbound' | 'outbound';
|
||||
fromNumber: string;
|
||||
toNumber: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface DtmfEventDto {
|
||||
callSid: string;
|
||||
digit: string;
|
||||
}
|
||||
|
||||
export interface TranscriptEventDto {
|
||||
callSid: string;
|
||||
transcript: string;
|
||||
isFinal: boolean;
|
||||
}
|
||||
|
||||
export interface AiSuggestionDto {
|
||||
callSid: string;
|
||||
suggestion: string;
|
||||
type: 'response' | 'action' | 'insight';
|
||||
data?: any;
|
||||
}
|
||||
10
backend/src/voice/dto/initiate-call.dto.ts
Normal file
10
backend/src/voice/dto/initiate-call.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString, IsNotEmpty, Matches } from 'class-validator';
|
||||
|
||||
export class InitiateCallDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^\+?[1-9]\d{1,14}$/, {
|
||||
message: 'Invalid phone number format (use E.164 format)',
|
||||
})
|
||||
toNumber: string;
|
||||
}
|
||||
19
backend/src/voice/interfaces/integration-config.interface.ts
Normal file
19
backend/src/voice/interfaces/integration-config.interface.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface TwilioConfig {
|
||||
accountSid: string;
|
||||
authToken: string;
|
||||
phoneNumber: string;
|
||||
apiKeySid?: string;
|
||||
apiKeySecret?: string;
|
||||
}
|
||||
|
||||
export interface OpenAIConfig {
|
||||
apiKey: string;
|
||||
assistantId?: string;
|
||||
model?: string;
|
||||
voice?: string;
|
||||
}
|
||||
|
||||
export interface IntegrationsConfig {
|
||||
twilio?: TwilioConfig;
|
||||
openai?: OpenAIConfig;
|
||||
}
|
||||
195
backend/src/voice/voice.controller.ts
Normal file
195
backend/src/voice/voice.controller.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
Logger,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { VoiceService } from './voice.service';
|
||||
import { VoiceGateway } from './voice.gateway';
|
||||
import { InitiateCallDto } from './dto/initiate-call.dto';
|
||||
import { TenantId } from '../tenant/tenant.decorator';
|
||||
|
||||
@Controller('voice')
|
||||
export class VoiceController {
|
||||
private readonly logger = new Logger(VoiceController.name);
|
||||
|
||||
constructor(
|
||||
private readonly voiceService: VoiceService,
|
||||
private readonly voiceGateway: VoiceGateway,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initiate outbound call via REST
|
||||
*/
|
||||
@Post('call')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async initiateCall(
|
||||
@Body() body: InitiateCallDto,
|
||||
@Req() req: any,
|
||||
@TenantId() tenantId: string,
|
||||
) {
|
||||
const userId = req.user?.userId || req.user?.sub;
|
||||
|
||||
const result = await this.voiceService.initiateCall({
|
||||
tenantId,
|
||||
userId,
|
||||
toNumber: body.toNumber,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get call history
|
||||
*/
|
||||
@Get('calls')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getCallHistory(
|
||||
@Req() req: any,
|
||||
@TenantId() tenantId: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
const userId = req.user?.userId || req.user?.sub;
|
||||
const calls = await this.voiceService.getCallHistory(
|
||||
tenantId,
|
||||
userId,
|
||||
limit ? parseInt(limit) : 50,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: calls,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TwiML for outbound calls
|
||||
*/
|
||||
@Post('twiml/outbound')
|
||||
async outboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
|
||||
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Start>
|
||||
<Stream url="wss://${req.headers.host}/api/voice/stream" />
|
||||
</Start>
|
||||
<Say>Connecting your call</Say>
|
||||
<Dial>
|
||||
<Number>${(req.body as any).To}</Number>
|
||||
</Dial>
|
||||
</Response>`;
|
||||
|
||||
res.type('text/xml').send(twiml);
|
||||
}
|
||||
|
||||
/**
|
||||
* TwiML for inbound calls
|
||||
*/
|
||||
@Post('twiml/inbound')
|
||||
async inboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
|
||||
const body = req.body as any;
|
||||
const callSid = body.CallSid;
|
||||
const fromNumber = body.From;
|
||||
const toNumber = body.To;
|
||||
|
||||
this.logger.log(`Incoming call: ${callSid} from ${fromNumber} to ${toNumber}`);
|
||||
|
||||
// TODO: Determine tenant from phone number mapping
|
||||
// TODO: Find available user to route call to
|
||||
// For now, return a simple TwiML response
|
||||
|
||||
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Start>
|
||||
<Stream url="wss://${req.headers.host}/api/voice/stream" />
|
||||
</Start>
|
||||
<Say>Please wait while we connect you to an agent</Say>
|
||||
<Dial>
|
||||
<Queue>support</Queue>
|
||||
</Dial>
|
||||
</Response>`;
|
||||
|
||||
res.type('text/xml').send(twiml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Twilio status webhook
|
||||
*/
|
||||
@Post('webhook/status')
|
||||
async statusWebhook(@Req() req: FastifyRequest) {
|
||||
const body = req.body as any;
|
||||
const callSid = body.CallSid;
|
||||
const status = body.CallStatus;
|
||||
const duration = body.CallDuration ? parseInt(body.CallDuration) : undefined;
|
||||
|
||||
this.logger.log(`Call status update: ${callSid} -> ${status}`);
|
||||
|
||||
// TODO: Extract tenant ID from call record
|
||||
// For now, we'll need to lookup the call to get tenant ID
|
||||
// This is a limitation - we should store tenantId in call metadata
|
||||
|
||||
try {
|
||||
// Update call status
|
||||
// await this.voiceService.updateCallStatus({
|
||||
// callSid,
|
||||
// tenantId: 'LOOKUP_NEEDED',
|
||||
// status,
|
||||
// duration,
|
||||
// });
|
||||
|
||||
// Notify user via WebSocket
|
||||
// await this.voiceGateway.notifyCallUpdate(userId, {
|
||||
// callSid,
|
||||
// status,
|
||||
// duration,
|
||||
// });
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to process status webhook', error);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Twilio recording webhook
|
||||
*/
|
||||
@Post('webhook/recording')
|
||||
async recordingWebhook(@Req() req: FastifyRequest) {
|
||||
const body = req.body as any;
|
||||
const callSid = body.CallSid;
|
||||
const recordingUrl = body.RecordingUrl;
|
||||
|
||||
this.logger.log(`Recording available for call ${callSid}: ${recordingUrl}`);
|
||||
|
||||
// TODO: Update call record with recording URL
|
||||
// TODO: Trigger transcription if needed
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket endpoint for Twilio Media Streams
|
||||
*/
|
||||
@Post('stream')
|
||||
async mediaStream(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
|
||||
// Twilio Media Streams use WebSocket protocol
|
||||
// This would need to be handled by the WebSocket server
|
||||
// In Fastify, we need to upgrade the connection
|
||||
|
||||
this.logger.log('Media stream connection requested');
|
||||
|
||||
// TODO: Implement WebSocket upgrade for media streams
|
||||
// This will handle bidirectional audio streaming between Twilio and OpenAI
|
||||
|
||||
res.send({ message: 'WebSocket upgrade required' });
|
||||
}
|
||||
}
|
||||
273
backend/src/voice/voice.gateway.ts
Normal file
273
backend/src/voice/voice.gateway.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
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,
|
||||
) {}
|
||||
|
||||
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);
|
||||
client.tenantId = payload.tenantId;
|
||||
client.userId = payload.sub;
|
||||
client.tenantSlug = payload.tenantSlug;
|
||||
|
||||
this.connectedUsers.set(client.userId, client);
|
||||
this.logger.log(
|
||||
`Client connected: ${client.id} (User: ${client.userId}, Tenant: ${client.tenantSlug})`,
|
||||
);
|
||||
|
||||
// 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})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (socket) {
|
||||
socket.emit('ai:suggestion', data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
backend/src/voice/voice.module.ts
Normal file
22
backend/src/voice/voice.module.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { VoiceGateway } from './voice.gateway';
|
||||
import { VoiceService } from './voice.service';
|
||||
import { VoiceController } from './voice.controller';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TenantModule,
|
||||
AuthModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'your-jwt-secret',
|
||||
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '24h' },
|
||||
}),
|
||||
],
|
||||
providers: [VoiceGateway, VoiceService],
|
||||
controllers: [VoiceController],
|
||||
exports: [VoiceService],
|
||||
})
|
||||
export class VoiceModule {}
|
||||
575
backend/src/voice/voice.service.ts
Normal file
575
backend/src/voice/voice.service.ts
Normal file
@@ -0,0 +1,575 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||
import { getCentralPrisma } from '../prisma/central-prisma.service';
|
||||
import { IntegrationsConfig, TwilioConfig, OpenAIConfig } from './interfaces/integration-config.interface';
|
||||
import * as Twilio from 'twilio';
|
||||
import { WebSocket } from 'ws';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
export class VoiceService {
|
||||
private readonly logger = new Logger(VoiceService.name);
|
||||
private twilioClients: Map<string, Twilio.Twilio> = new Map();
|
||||
private openaiConnections: Map<string, WebSocket> = new Map(); // callSid -> WebSocket
|
||||
private callStates: Map<string, any> = new Map(); // callSid -> call state
|
||||
|
||||
constructor(
|
||||
private readonly tenantDbService: TenantDatabaseService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get Twilio client for a tenant
|
||||
*/
|
||||
private async getTwilioClient(tenantId: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig }> {
|
||||
// Check cache first
|
||||
if (this.twilioClients.has(tenantId)) {
|
||||
const centralPrisma = getCentralPrisma();
|
||||
const tenant = await centralPrisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { integrationsConfig: true },
|
||||
});
|
||||
|
||||
const config = this.getIntegrationConfig(tenant?.integrationsConfig as any);
|
||||
return { client: this.twilioClients.get(tenantId), config: config.twilio };
|
||||
}
|
||||
|
||||
// Fetch tenant integrations config
|
||||
const centralPrisma = getCentralPrisma();
|
||||
const tenant = await centralPrisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { integrationsConfig: true },
|
||||
});
|
||||
|
||||
if (!tenant?.integrationsConfig) {
|
||||
throw new Error('Tenant integrations config not found');
|
||||
}
|
||||
|
||||
const config = this.getIntegrationConfig(tenant.integrationsConfig as any);
|
||||
|
||||
if (!config.twilio?.accountSid || !config.twilio?.authToken) {
|
||||
throw new Error('Twilio credentials not configured for tenant');
|
||||
}
|
||||
|
||||
const client = Twilio.default(config.twilio.accountSid, config.twilio.authToken);
|
||||
this.twilioClients.set(tenantId, client);
|
||||
|
||||
return { client, config: config.twilio };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt and parse integrations config
|
||||
*/
|
||||
private getIntegrationConfig(encryptedConfig: any): IntegrationsConfig {
|
||||
if (!encryptedConfig) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// If it's already decrypted (object), return it
|
||||
if (typeof encryptedConfig === 'object' && encryptedConfig.twilio) {
|
||||
return encryptedConfig;
|
||||
}
|
||||
|
||||
// If it's encrypted (string), decrypt it
|
||||
if (typeof encryptedConfig === 'string') {
|
||||
return this.tenantDbService.decryptIntegrationsConfig(encryptedConfig);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate outbound call
|
||||
*/
|
||||
async initiateCall(params: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
toNumber: string;
|
||||
}) {
|
||||
const { tenantId, userId, toNumber } = params;
|
||||
|
||||
try {
|
||||
const { client, config } = await this.getTwilioClient(tenantId);
|
||||
|
||||
// Create call record in database
|
||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||
const callId = uuidv4();
|
||||
|
||||
// Generate TwiML URL for call flow
|
||||
const twimlUrl = `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/voice/twiml/outbound`;
|
||||
|
||||
// Initiate call via Twilio
|
||||
const call = await client.calls.create({
|
||||
to: toNumber,
|
||||
from: config.phoneNumber,
|
||||
url: twimlUrl,
|
||||
statusCallback: `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/voice/webhook/status`,
|
||||
statusCallbackEvent: ['initiated', 'ringing', 'answered', 'completed'],
|
||||
statusCallbackMethod: 'POST',
|
||||
record: true,
|
||||
recordingStatusCallback: `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/voice/webhook/recording`,
|
||||
});
|
||||
|
||||
// Store call in database
|
||||
await tenantKnex('calls').insert({
|
||||
id: callId,
|
||||
call_sid: call.sid,
|
||||
direction: 'outbound',
|
||||
from_number: config.phoneNumber,
|
||||
to_number: toNumber,
|
||||
status: 'queued',
|
||||
user_id: userId,
|
||||
created_at: tenantKnex.fn.now(),
|
||||
updated_at: tenantKnex.fn.now(),
|
||||
});
|
||||
|
||||
// Store call state in memory
|
||||
this.callStates.set(call.sid, {
|
||||
callId,
|
||||
callSid: call.sid,
|
||||
tenantId,
|
||||
userId,
|
||||
direction: 'outbound',
|
||||
status: 'queued',
|
||||
});
|
||||
|
||||
this.logger.log(`Outbound call initiated: ${call.sid}`);
|
||||
|
||||
return {
|
||||
callId,
|
||||
callSid: call.sid,
|
||||
status: 'queued',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initiate call', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept incoming call
|
||||
*/
|
||||
async acceptCall(params: {
|
||||
callSid: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const { callSid, tenantId, userId } = params;
|
||||
|
||||
try {
|
||||
// Note: Twilio doesn't support updating call to 'in-progress' via API
|
||||
// Call status is managed by TwiML and call flow
|
||||
// We'll update our database status instead
|
||||
|
||||
// Update database
|
||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||
await tenantKnex('calls')
|
||||
.where({ call_sid: callSid })
|
||||
.update({
|
||||
status: 'in-progress',
|
||||
user_id: userId,
|
||||
started_at: tenantKnex.fn.now(),
|
||||
updated_at: tenantKnex.fn.now(),
|
||||
});
|
||||
|
||||
// Update state
|
||||
const state = this.callStates.get(callSid) || {};
|
||||
this.callStates.set(callSid, {
|
||||
...state,
|
||||
status: 'in-progress',
|
||||
userId,
|
||||
});
|
||||
|
||||
this.logger.log(`Call accepted: ${callSid} by user ${userId}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to accept call', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject incoming call
|
||||
*/
|
||||
async rejectCall(callSid: string, tenantId: string) {
|
||||
try {
|
||||
const { client } = await this.getTwilioClient(tenantId);
|
||||
|
||||
// End the call
|
||||
await client.calls(callSid).update({
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
// Update database
|
||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||
await tenantKnex('calls')
|
||||
.where({ call_sid: callSid })
|
||||
.update({
|
||||
status: 'canceled',
|
||||
updated_at: tenantKnex.fn.now(),
|
||||
});
|
||||
|
||||
// Clean up state
|
||||
this.callStates.delete(callSid);
|
||||
|
||||
this.logger.log(`Call rejected: ${callSid}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to reject call', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End active call
|
||||
*/
|
||||
async endCall(callSid: string, tenantId: string) {
|
||||
try {
|
||||
const { client } = await this.getTwilioClient(tenantId);
|
||||
|
||||
// End the call
|
||||
await client.calls(callSid).update({
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
// Clean up OpenAI connection if exists
|
||||
const openaiWs = this.openaiConnections.get(callSid);
|
||||
if (openaiWs) {
|
||||
openaiWs.close();
|
||||
this.openaiConnections.delete(callSid);
|
||||
}
|
||||
|
||||
// Update database
|
||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||
await tenantKnex('calls')
|
||||
.where({ call_sid: callSid })
|
||||
.update({
|
||||
status: 'completed',
|
||||
ended_at: tenantKnex.fn.now(),
|
||||
updated_at: tenantKnex.fn.now(),
|
||||
});
|
||||
|
||||
// Clean up state
|
||||
this.callStates.delete(callSid);
|
||||
|
||||
this.logger.log(`Call ended: ${callSid}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to end call', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send DTMF tones
|
||||
*/
|
||||
async sendDtmf(callSid: string, digit: string, tenantId: string) {
|
||||
try {
|
||||
const { client } = await this.getTwilioClient(tenantId);
|
||||
|
||||
// Twilio doesn't support sending DTMF directly via API
|
||||
// This would need to be handled via TwiML <Play> of DTMF tones
|
||||
this.logger.log(`DTMF requested for call ${callSid}: ${digit}`);
|
||||
|
||||
// TODO: Implement DTMF sending via TwiML update
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to send DTMF', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get call state
|
||||
*/
|
||||
async getCallState(callSid: string, tenantId: string) {
|
||||
// Try memory first
|
||||
if (this.callStates.has(callSid)) {
|
||||
return this.callStates.get(callSid);
|
||||
}
|
||||
|
||||
// Fallback to database
|
||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||
const call = await tenantKnex('calls')
|
||||
.where({ call_sid: callSid })
|
||||
.first();
|
||||
|
||||
return call || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update call status from webhook
|
||||
*/
|
||||
async updateCallStatus(params: {
|
||||
callSid: string;
|
||||
tenantId: string;
|
||||
status: string;
|
||||
duration?: number;
|
||||
recordingUrl?: string;
|
||||
}) {
|
||||
const { callSid, tenantId, status, duration, recordingUrl } = params;
|
||||
|
||||
try {
|
||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||
|
||||
const updateData: any = {
|
||||
status,
|
||||
updated_at: tenantKnex.fn.now(),
|
||||
};
|
||||
|
||||
if (duration !== undefined) {
|
||||
updateData.duration_seconds = duration;
|
||||
}
|
||||
|
||||
if (recordingUrl) {
|
||||
updateData.recording_url = recordingUrl;
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
updateData.ended_at = tenantKnex.fn.now();
|
||||
}
|
||||
|
||||
await tenantKnex('calls')
|
||||
.where({ call_sid: callSid })
|
||||
.update(updateData);
|
||||
|
||||
// Update state
|
||||
const state = this.callStates.get(callSid);
|
||||
if (state) {
|
||||
this.callStates.set(callSid, { ...state, status });
|
||||
}
|
||||
|
||||
this.logger.log(`Call status updated: ${callSid} -> ${status}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to update call status', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize OpenAI Realtime connection for call
|
||||
*/
|
||||
async initializeOpenAIRealtime(params: {
|
||||
callSid: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const { callSid, tenantId, userId } = params;
|
||||
|
||||
try {
|
||||
// Get OpenAI config
|
||||
const centralPrisma = getCentralPrisma();
|
||||
const tenant = await centralPrisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { integrationsConfig: true },
|
||||
});
|
||||
|
||||
const config = this.getIntegrationConfig(tenant?.integrationsConfig as any);
|
||||
|
||||
if (!config.openai?.apiKey) {
|
||||
this.logger.warn('OpenAI not configured for tenant, skipping AI features');
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to OpenAI Realtime API
|
||||
const ws = new WebSocket('wss://api.openai.com/v1/realtime', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.openai.apiKey}`,
|
||||
'OpenAI-Beta': 'realtime=v1',
|
||||
},
|
||||
});
|
||||
|
||||
ws.on('open', () => {
|
||||
this.logger.log(`OpenAI Realtime connected for call ${callSid}`);
|
||||
|
||||
// Initialize session
|
||||
ws.send(JSON.stringify({
|
||||
type: 'session.update',
|
||||
session: {
|
||||
model: config.openai.model || 'gpt-4o-realtime-preview',
|
||||
voice: config.openai.voice || 'alloy',
|
||||
instructions: 'You are a helpful AI assistant providing real-time support during phone calls. Provide concise, actionable suggestions to help the user.',
|
||||
turn_detection: {
|
||||
type: 'server_vad',
|
||||
},
|
||||
tools: this.getOpenAITools(),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
ws.on('message', (data: Buffer) => {
|
||||
this.handleOpenAIMessage(callSid, tenantId, userId, JSON.parse(data.toString()));
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
this.logger.error(`OpenAI WebSocket error for call ${callSid}`, error);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
this.logger.log(`OpenAI Realtime disconnected for call ${callSid}`);
|
||||
this.openaiConnections.delete(callSid);
|
||||
});
|
||||
|
||||
this.openaiConnections.set(callSid, ws);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize OpenAI Realtime', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OpenAI Realtime messages
|
||||
*/
|
||||
private async handleOpenAIMessage(
|
||||
callSid: string,
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
message: any,
|
||||
) {
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'conversation.item.created':
|
||||
if (message.item.type === 'message' && message.item.role === 'assistant') {
|
||||
// AI response generated
|
||||
this.logger.log(`AI response for call ${callSid}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'response.audio_transcript.delta':
|
||||
// Real-time transcript
|
||||
// TODO: Emit to gateway
|
||||
break;
|
||||
|
||||
case 'response.audio_transcript.done':
|
||||
// Final transcript
|
||||
const transcript = message.transcript;
|
||||
await this.updateCallTranscript(callSid, tenantId, transcript);
|
||||
break;
|
||||
|
||||
case 'response.function_call_arguments.done':
|
||||
// Tool call completed
|
||||
await this.handleToolCall(callSid, tenantId, userId, message);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Handle other message types
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to handle OpenAI message', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Define OpenAI tools for CRM actions
|
||||
*/
|
||||
private getOpenAITools(): any[] {
|
||||
return [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'search_contact',
|
||||
description: 'Search for a contact by name, email, or phone number',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query (name, email, or phone)',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'create_task',
|
||||
description: 'Create a follow-up task based on the call',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Task title',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Task description',
|
||||
},
|
||||
dueDate: {
|
||||
type: 'string',
|
||||
description: 'Due date (ISO format)',
|
||||
},
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'update_contact',
|
||||
description: 'Update contact information',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
contactId: {
|
||||
type: 'string',
|
||||
description: 'Contact ID',
|
||||
},
|
||||
fields: {
|
||||
type: 'object',
|
||||
description: 'Fields to update',
|
||||
},
|
||||
},
|
||||
required: ['contactId', 'fields'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tool calls from OpenAI
|
||||
*/
|
||||
private async handleToolCall(
|
||||
callSid: string,
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
message: any,
|
||||
) {
|
||||
// TODO: Implement actual tool execution
|
||||
// This would call the appropriate services based on the tool name
|
||||
// Respecting RBAC permissions for the user
|
||||
this.logger.log(`Tool call for call ${callSid}: ${message.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update call transcript
|
||||
*/
|
||||
private async updateCallTranscript(
|
||||
callSid: string,
|
||||
tenantId: string,
|
||||
transcript: string,
|
||||
) {
|
||||
try {
|
||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||
await tenantKnex('calls')
|
||||
.where({ call_sid: callSid })
|
||||
.update({
|
||||
ai_transcript: transcript,
|
||||
updated_at: tenantKnex.fn.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to update transcript', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get call history for user
|
||||
*/
|
||||
async getCallHistory(tenantId: string, userId: string, limit = 50) {
|
||||
try {
|
||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||
const calls = await tenantKnex('calls')
|
||||
.where({ user_id: userId })
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(limit);
|
||||
|
||||
return calls;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get call history', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user