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 = new Map(); private openaiConnections: Map = new Map(); // callSid -> WebSocket private callStates: Map = new Map(); // callSid -> call state constructor( private readonly tenantDbService: TenantDatabaseService, ) {} /** * Get Twilio client for a tenant */ private async getTwilioClient(tenantIdOrDomain: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig; tenantId: string }> { // Check cache first if (this.twilioClients.has(tenantIdOrDomain)) { const centralPrisma = getCentralPrisma(); // Look up tenant by domain const domainRecord = await centralPrisma.domain.findUnique({ where: { domain: tenantIdOrDomain }, include: { tenant: { select: { id: true, integrationsConfig: true } } }, }); const config = this.getIntegrationConfig(domainRecord?.tenant?.integrationsConfig as any); return { client: this.twilioClients.get(tenantIdOrDomain), config: config.twilio, tenantId: domainRecord.tenant.id }; } // Fetch tenant integrations config const centralPrisma = getCentralPrisma(); this.logger.log(`Looking up domain: ${tenantIdOrDomain}`); const domainRecord = await centralPrisma.domain.findUnique({ where: { domain: tenantIdOrDomain }, include: { tenant: { select: { id: true, integrationsConfig: true } } }, }); this.logger.log(`Domain record found: ${!!domainRecord}, Tenant: ${!!domainRecord?.tenant}, Config: ${!!domainRecord?.tenant?.integrationsConfig}`); if (!domainRecord?.tenant) { throw new Error(`Domain ${tenantIdOrDomain} not found`); } if (!domainRecord.tenant.integrationsConfig) { throw new Error('Tenant integrations config not found. Please configure Twilio credentials in Settings > Integrations'); } const config = this.getIntegrationConfig(domainRecord.tenant.integrationsConfig as any); this.logger.log(`Config decrypted: ${!!config.twilio}, AccountSid: ${config.twilio?.accountSid?.substring(0, 10)}..., AuthToken: ${config.twilio?.authToken?.substring(0, 10)}..., Phone: ${config.twilio?.phoneNumber}`); 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(tenantIdOrDomain, client); return { client, config: config.twilio, tenantId: domainRecord.tenant.id }; } /** * 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: tenantDomain, userId, toNumber } = params; try { const { client, config, tenantId } = await this.getTwilioClient(tenantDomain); // Create call record in database const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId); const callId = uuidv4(); // Construct tenant-specific webhook URLs // The tenantDomain is the subdomain (e.g., "tenant1") const backendPort = process.env.PORT || '3000'; const backendUrl = `http://${tenantDomain}.routebox.co:${backendPort}`; const twimlUrl = `${backendUrl}/api/voice/twiml/outbound`; // Initiate call via Twilio const call = await client.calls.create({ to: toNumber, from: config.phoneNumber, url: twimlUrl, statusCallback: `${backendUrl}/api/voice/webhook/status`, statusCallbackEvent: ['initiated', 'ringing', 'answered', 'completed'], statusCallbackMethod: 'POST', record: true, recordingStatusCallback: `${backendUrl}/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 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; } } }