WIP - placing calls from softphone working
This commit is contained in:
@@ -98,20 +98,55 @@ export class VoiceController {
|
|||||||
async outboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
|
async outboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
|
||||||
const body = req.body as any;
|
const body = req.body as any;
|
||||||
const to = body.To;
|
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
|
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Response>
|
<Response>
|
||||||
<Start>
|
<Dial callerId="${callerId}">
|
||||||
<Stream url="wss://${req.headers.host}/api/voice/stream" />
|
<Number>${dialNumber}</Number>
|
||||||
</Start>
|
</Dial>
|
||||||
<Say>Connecting your call</Say>
|
|
||||||
<Dial>${to}</Dial>
|
|
||||||
</Response>`;
|
</Response>`;
|
||||||
|
|
||||||
|
this.logger.log(`Returning TwiML with Dial verb - callerId: ${callerId}, to: ${dialNumber}`);
|
||||||
res.type('text/xml').send(twiml);
|
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Response>
|
||||||
|
<Say>An error occurred while processing your call.</Say>
|
||||||
|
</Response>`;
|
||||||
|
res.type('text/xml').send(errorTwiml);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,30 +189,8 @@ export class VoiceController {
|
|||||||
const status = body.CallStatus;
|
const status = body.CallStatus;
|
||||||
const duration = body.CallDuration ? parseInt(body.CallDuration) : undefined;
|
const duration = body.CallDuration ? parseInt(body.CallDuration) : undefined;
|
||||||
|
|
||||||
this.logger.log(`Call status update: ${callSid} -> ${status}`);
|
this.logger.log(`Call status webhook - CallSid: ${callSid}, Status: ${status}, Duration: ${duration}`);
|
||||||
|
this.logger.log(`Full status webhook body:`, JSON.stringify(body));
|
||||||
// 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 };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -189,30 +202,11 @@ export class VoiceController {
|
|||||||
async recordingWebhook(@Req() req: FastifyRequest) {
|
async recordingWebhook(@Req() req: FastifyRequest) {
|
||||||
const body = req.body as any;
|
const body = req.body as any;
|
||||||
const callSid = body.CallSid;
|
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}`);
|
this.logger.log(`Recording webhook - CallSid: ${callSid}, RecordingSid: ${recordingSid}, Status: ${recordingStatus}`);
|
||||||
|
|
||||||
// TODO: Update call record with recording URL
|
|
||||||
// TODO: Trigger transcription if needed
|
|
||||||
|
|
||||||
return { success: true };
|
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' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,37 +137,60 @@ export class VoiceService {
|
|||||||
const { tenantId: tenantDomain, userId, toNumber } = params;
|
const { tenantId: tenantDomain, userId, toNumber } = params;
|
||||||
|
|
||||||
try {
|
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);
|
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
|
// Create call record in database
|
||||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||||
const callId = uuidv4();
|
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
|
// 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({
|
const call = await client.calls.create({
|
||||||
to: toNumber,
|
to: toNumber,
|
||||||
from: config.phoneNumber,
|
from: fromNumber, // Your Twilio phone number
|
||||||
url: twimlUrl,
|
url: twimlUrl,
|
||||||
statusCallback: `${backendUrl}/api/voice/webhook/status`,
|
statusCallback: statusUrl,
|
||||||
statusCallbackEvent: ['initiated', 'ringing', 'answered', 'completed'],
|
statusCallbackEvent: ['initiated', 'ringing', 'answered', 'completed'],
|
||||||
statusCallbackMethod: 'POST',
|
statusCallbackMethod: 'POST',
|
||||||
record: true,
|
record: false,
|
||||||
recordingStatusCallback: `${backendUrl}/api/voice/webhook/recording`,
|
machineDetection: 'Enable', // Optional: detect answering machines
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Call created successfully: ${call.sid}, Status: ${call.status}`);
|
||||||
|
|
||||||
// Store call in database
|
// Store call in database
|
||||||
await tenantKnex('calls').insert({
|
await tenantKnex('calls').insert({
|
||||||
id: callId,
|
id: callId,
|
||||||
call_sid: call.sid,
|
call_sid: call.sid,
|
||||||
direction: 'outbound',
|
direction: 'outbound',
|
||||||
from_number: config.phoneNumber,
|
from_number: fromNumber,
|
||||||
to_number: toNumber,
|
to_number: toNumber,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
|||||||
Reference in New Issue
Block a user