244 lines
7.2 KiB
TypeScript
244 lines
7.2 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate Twilio access token for browser client
|
|
*/
|
|
@Get('token')
|
|
@UseGuards(JwtAuthGuard)
|
|
async getAccessToken(
|
|
@Req() req: any,
|
|
@TenantId() tenantId: string,
|
|
) {
|
|
const userId = req.user?.userId || req.user?.sub;
|
|
|
|
const token = await this.voiceService.generateAccessToken(tenantId, userId);
|
|
|
|
return {
|
|
success: true,
|
|
data: { token },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 from browser (Twilio Device)
|
|
*/
|
|
@Post('twiml/outbound')
|
|
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(`=== TwiML OUTBOUND REQUEST RECEIVED ===`);
|
|
this.logger.log(`CallSid: ${callSid}, Body From: ${from}, Body To: ${to}`);
|
|
this.logger.log(`Full body: ${JSON.stringify(body)}`);
|
|
|
|
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"?>
|
|
<Response>
|
|
<Dial callerId="${callerId}">
|
|
<Number>${dialNumber}</Number>
|
|
</Dial>
|
|
</Response>`;
|
|
|
|
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Response>
|
|
<Say>An error occurred while processing your call.</Say>
|
|
</Response>`;
|
|
res.type('text/xml').send(errorTwiml);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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(`=== INBOUND CALL RECEIVED ===`);
|
|
this.logger.log(`CallSid: ${callSid}, From: ${fromNumber}, To: ${toNumber}`);
|
|
this.logger.log(`Full body: ${JSON.stringify(body)}`);
|
|
|
|
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}`);
|
|
|
|
// Get all connected users for this tenant
|
|
const connectedUsers = this.voiceGateway.getConnectedUsers(tenantDomain);
|
|
|
|
this.logger.log(`Connected users for tenant ${tenantDomain}: ${connectedUsers.length}`);
|
|
|
|
if (connectedUsers.length === 0) {
|
|
// No users online - send to voicemail or play message
|
|
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Response>
|
|
<Say>Sorry, no agents are currently available. Please try again later.</Say>
|
|
<Hangup/>
|
|
</Response>`;
|
|
this.logger.log(`No users online - returning unavailable message`);
|
|
return res.type('text/xml').send(twiml);
|
|
}
|
|
|
|
// Build TwiML to dial all connected clients (first to answer gets the call)
|
|
const clientElements = connectedUsers.map(userId => ` <Client>${userId}</Client>`).join('\n');
|
|
|
|
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Response>
|
|
<Say>Connecting your call</Say>
|
|
<Dial timeout="30" action="${host}/api/voice/webhook/dial-status">
|
|
${clientElements}
|
|
</Dial>
|
|
</Response>`;
|
|
|
|
this.logger.log(`Returning inbound TwiML - dialing ${connectedUsers.length} client(s)`);
|
|
res.type('text/xml').send(twiml);
|
|
} catch (error: any) {
|
|
this.logger.error(`Error generating inbound TwiML: ${error.message}`);
|
|
const errorTwiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Response>
|
|
<Say>Sorry, we are unable to connect your call at this time.</Say>
|
|
<Hangup/>
|
|
</Response>`;
|
|
res.type('text/xml').send(errorTwiml);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 webhook - CallSid: ${callSid}, Status: ${status}, Duration: ${duration}`);
|
|
this.logger.log(`Full status webhook body:`, JSON.stringify(body));
|
|
|
|
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 recordingSid = body.RecordingSid;
|
|
const recordingStatus = body.RecordingStatus;
|
|
|
|
this.logger.log(`Recording webhook - CallSid: ${callSid}, RecordingSid: ${recordingSid}, Status: ${recordingStatus}`);
|
|
|
|
return { success: true };
|
|
}
|
|
}
|