From 37b29722311667d233daf85a56a68add2ebaf05f Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Sat, 3 Jan 2026 23:31:19 +0100 Subject: [PATCH] WIP - placing calls from softphone working --- backend/src/voice/voice.controller.ts | 104 ++++++++++++-------------- backend/src/voice/voice.service.ts | 47 +++++++++--- 2 files changed, 84 insertions(+), 67 deletions(-) diff --git a/backend/src/voice/voice.controller.ts b/backend/src/voice/voice.controller.ts index 1133779..09d0835 100644 --- a/backend/src/voice/voice.controller.ts +++ b/backend/src/voice/voice.controller.ts @@ -98,20 +98,55 @@ export class VoiceController { async outboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) { const body = req.body as any; const to = body.To; + const from = body.From; + const callSid = body.CallSid; - this.logger.log(`Outbound call to: ${to}`); + this.logger.log(`=== TwiML OUTBOUND REQUEST RECEIVED ===`); + this.logger.log(`CallSid: ${callSid}, Body From: ${from}, Body To: ${to}`); + this.logger.log(`Full body: ${JSON.stringify(body)}`); - // TwiML to dial the number and setup media stream for OpenAI - const twiml = ` + try { + // Extract tenant domain from Host header + const host = req.headers.host || ''; + const tenantDomain = host.split('.')[0]; // e.g., "tenant1" from "tenant1.routebox.co" + + this.logger.log(`Extracted tenant domain: ${tenantDomain}`); + + // Look up tenant's Twilio phone number from config + let callerId = to; // Fallback (will cause error if not found) + try { + // Get Twilio config to find the phone number + const { config } = await this.voiceService['getTwilioClient'](tenantDomain); + callerId = config.phoneNumber; + this.logger.log(`Retrieved Twilio phone number for tenant: ${callerId}`); + } catch (error: any) { + this.logger.error(`Failed to get Twilio config: ${error.message}`); + } + + const dialNumber = to; + + this.logger.log(`Using callerId: ${callerId}, dialNumber: ${dialNumber}`); + + // Return TwiML to DIAL the phone number with proper callerId + const twiml = ` - - - - Connecting your call - ${to} + + ${dialNumber} + `; - res.type('text/xml').send(twiml); + this.logger.log(`Returning TwiML with Dial verb - callerId: ${callerId}, to: ${dialNumber}`); + res.type('text/xml').send(twiml); + } catch (error: any) { + this.logger.error(`=== ERROR GENERATING TWIML ===`); + this.logger.error(`Error: ${error.message}`); + this.logger.error(`Stack: ${error.stack}`); + const errorTwiml = ` + + An error occurred while processing your call. +`; + res.type('text/xml').send(errorTwiml); + } } /** @@ -154,30 +189,8 @@ export class VoiceController { 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); - } + this.logger.log(`Call status webhook - CallSid: ${callSid}, Status: ${status}, Duration: ${duration}`); + this.logger.log(`Full status webhook body:`, JSON.stringify(body)); return { success: true }; } @@ -189,30 +202,11 @@ export class VoiceController { async recordingWebhook(@Req() req: FastifyRequest) { const body = req.body as any; const callSid = body.CallSid; - const recordingUrl = body.RecordingUrl; + const recordingSid = body.RecordingSid; + const recordingStatus = body.RecordingStatus; - this.logger.log(`Recording available for call ${callSid}: ${recordingUrl}`); - - // TODO: Update call record with recording URL - // TODO: Trigger transcription if needed + this.logger.log(`Recording webhook - CallSid: ${callSid}, RecordingSid: ${recordingSid}, Status: ${recordingStatus}`); 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' }); - } } diff --git a/backend/src/voice/voice.service.ts b/backend/src/voice/voice.service.ts index 17d9c47..60bc5da 100644 --- a/backend/src/voice/voice.service.ts +++ b/backend/src/voice/voice.service.ts @@ -137,37 +137,60 @@ export class VoiceService { const { tenantId: tenantDomain, userId, toNumber } = params; try { + this.logger.log(`=== INITIATING CALL ===`); + this.logger.log(`Domain: ${tenantDomain}, To: ${toNumber}, User: ${userId}`); + + // Validate phone number + if (!toNumber.match(/^\+?[1-9]\d{1,14}$/)) { + throw new Error(`Invalid phone number format: ${toNumber}. Use E.164 format (e.g., +1234567890)`); + } + const { client, config, tenantId } = await this.getTwilioClient(tenantDomain); + this.logger.log(`Twilio client obtained for tenant: ${tenantId}`); + + // Get from number + const fromNumber = config.phoneNumber; + if (!fromNumber) { + throw new Error('Twilio phone number not configured'); + } + this.logger.log(`From number: ${fromNumber}`); + + // Construct tenant-specific webhook URLs using HTTPS (for Traefik) + const backendUrl = `https://${tenantDomain}`; + const twimlUrl = `${backendUrl}/api/voice/twiml/outbound?phoneNumber=${encodeURIComponent(fromNumber)}&toNumber=${encodeURIComponent(toNumber)}`; + const statusUrl = `${backendUrl}/api/voice/webhook/status`; + + this.logger.log(`TwiML URL: ${twimlUrl}`); + this.logger.log(`Status URL: ${statusUrl}`); // 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 + this.logger.log(`Calling Twilio API...`); + + // For Device-to-Number calls, we need to use a TwiML App SID + // The Twilio SDK will handle the Device connection, and we return TwiML with Dial const call = await client.calls.create({ to: toNumber, - from: config.phoneNumber, + from: fromNumber, // Your Twilio phone number url: twimlUrl, - statusCallback: `${backendUrl}/api/voice/webhook/status`, + statusCallback: statusUrl, statusCallbackEvent: ['initiated', 'ringing', 'answered', 'completed'], statusCallbackMethod: 'POST', - record: true, - recordingStatusCallback: `${backendUrl}/api/voice/webhook/recording`, + record: false, + machineDetection: 'Enable', // Optional: detect answering machines }); + this.logger.log(`Call created successfully: ${call.sid}, Status: ${call.status}`); + // Store call in database await tenantKnex('calls').insert({ id: callId, call_sid: call.sid, direction: 'outbound', - from_number: config.phoneNumber, + from_number: fromNumber, to_number: toNumber, status: 'queued', user_id: userId,