WIP - fix twilio functionality now that we use BFF

This commit is contained in:
Francisco Gaona
2026-02-05 02:41:54 +01:00
parent 49a571215d
commit 9226442525
6 changed files with 307 additions and 116 deletions

View File

@@ -98,37 +98,75 @@ export class VoiceController {
/**
* TwiML for outbound calls from browser (Twilio Device)
* Twilio sends application/x-www-form-urlencoded data
*/
@Post('twiml/outbound')
async outboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
const body = req.body as any;
// Parse body - Twilio sends URL-encoded form data
let body = req.body as any;
// Handle case where body might be parsed as JSON key (URL-encoded string as key)
if (body && typeof body === 'object' && Object.keys(body).length === 1) {
const key = Object.keys(body)[0];
if (key.startsWith('{') || key.includes('=')) {
try {
// Try parsing as JSON if it looks like JSON
if (key.startsWith('{')) {
body = JSON.parse(key);
} else {
// Parse as URL-encoded
const params = new URLSearchParams(key);
body = Object.fromEntries(params.entries());
}
} catch (e) {
this.logger.warn(`Failed to re-parse body: ${e.message}`);
}
}
}
const to = body.To;
const from = body.From;
const from = body.From; // Format: "client:tenantId:userId"
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)}`);
this.logger.log(`CallSid: ${callSid}, From: ${from}, To: ${to}`);
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}`);
// Extract tenant ID from the client identity
// Format: "client:tenantId:userId"
let tenantId: string | null = null;
if (from && from.startsWith('client:')) {
const parts = from.replace('client:', '').split(':');
if (parts.length >= 2) {
tenantId = parts[0]; // First part is tenantId
this.logger.log(`Extracted tenantId from client identity: ${tenantId}`);
}
}
if (!tenantId) {
this.logger.error(`Could not extract tenant from From: ${from}`);
throw new Error('Could not determine tenant from call');
}
// Look up tenant's Twilio phone number from config
let callerId = to; // Fallback (will cause error if not found)
let callerId: string | undefined;
try {
// Get Twilio config to find the phone number
const { config } = await this.voiceService['getTwilioClient'](tenantDomain);
const { config } = await this.voiceService['getTwilioClient'](tenantId);
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}`);
throw error;
}
const dialNumber = to;
if (!callerId) {
throw new Error('No caller ID configured for tenant');
}
const dialNumber = to?.trim();
if (!dialNumber) {
throw new Error('No destination number provided');
}
this.logger.log(`Using callerId: ${callerId}, dialNumber: ${dialNumber}`);
@@ -145,10 +183,9 @@ export class VoiceController {
} 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>
<Say>An error occurred while processing your call. ${error.message}</Say>
</Response>`;
res.type('text/xml').send(errorTwiml);
}
@@ -156,13 +193,33 @@ export class VoiceController {
/**
* TwiML for inbound calls
* Twilio sends application/x-www-form-urlencoded data
*/
@Post('twiml/inbound')
async inboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
const body = req.body as any;
// Parse body - Twilio sends URL-encoded form data
let body = req.body as any;
// Handle case where body might be parsed incorrectly
if (body && typeof body === 'object' && Object.keys(body).length === 1) {
const key = Object.keys(body)[0];
if (key.startsWith('{') || key.includes('=')) {
try {
if (key.startsWith('{')) {
body = JSON.parse(key);
} else {
const params = new URLSearchParams(key);
body = Object.fromEntries(params.entries());
}
} catch (e) {
this.logger.warn(`Failed to re-parse body: ${e.message}`);
}
}
}
const callSid = body.CallSid;
const fromNumber = body.From;
const toNumber = body.To;
const toNumber = body.To; // This is the Twilio phone number that was called
this.logger.log(`\n\n╔════════════════════════════════════════╗`);
this.logger.log(`║ === INBOUND CALL RECEIVED ===`);
@@ -170,19 +227,28 @@ export class VoiceController {
this.logger.log(`CallSid: ${callSid}`);
this.logger.log(`From: ${fromNumber}`);
this.logger.log(`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"
// Look up tenant by the Twilio phone number that was called
const tenantInfo = await this.voiceService.findTenantByPhoneNumber(toNumber);
this.logger.log(`Extracted tenant domain: ${tenantDomain}`);
if (!tenantInfo) {
this.logger.error(`No tenant found for phone number: ${toNumber}`);
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>Sorry, this number is not configured. Please contact support.</Say>
<Hangup/>
</Response>`;
return res.type('text/xml').send(twiml);
}
const tenantId = tenantInfo.tenantId;
this.logger.log(`Found tenant: ${tenantId}`);
// Get all connected users for this tenant
const connectedUsers = this.voiceGateway.getConnectedUsers(tenantDomain);
const connectedUsers = this.voiceGateway.getConnectedUsers(tenantId);
this.logger.log(`Connected users for tenant ${tenantDomain}: ${connectedUsers.length}`);
this.logger.log(`Connected users for tenant ${tenantId}: ${connectedUsers.length}`);
if (connectedUsers.length > 0) {
this.logger.log(`Connected user IDs: ${connectedUsers.join(', ')}`);
}
@@ -198,20 +264,22 @@ export class VoiceController {
return res.type('text/xml').send(twiml);
}
// Build TwiML to dial all connected clients with Media Streams for AI
const clientElements = connectedUsers.map(userId => ` <Client>${userId}</Client>`).join('\n');
// Build TwiML to dial all connected clients
// Client identity format is now: tenantId:userId
const clientElements = connectedUsers.map(userId => ` <Client>${tenantId}:${userId}</Client>`).join('\n');
// Use wss:// for secure WebSocket (Traefik handles HTTPS)
// Log the client identities being dialed
this.logger.log(`Client identities being dialed:`);
connectedUsers.forEach(userId => {
this.logger.log(` - ${tenantId}:${userId}`);
});
// Use wss:// for secure WebSocket
const host = req.headers.host || 'backend.routebox.co';
const streamUrl = `wss://${host}/api/voice/media-stream`;
this.logger.log(`Stream URL: ${streamUrl}`);
this.logger.log(`Dialing ${connectedUsers.length} client(s)...`);
this.logger.log(`Client IDs to dial: ${connectedUsers.join(', ')}`);
// Verify we have client IDs in proper format
if (connectedUsers.length > 0) {
this.logger.log(`First Client ID format check: "${connectedUsers[0]}" (length: ${connectedUsers[0].length})`);
}
// Notify connected users about incoming call via Socket.IO
connectedUsers.forEach(userId => {
@@ -219,7 +287,7 @@ export class VoiceController {
callSid,
fromNumber,
toNumber,
tenantDomain,
tenantId,
});
});
@@ -227,7 +295,7 @@ export class VoiceController {
<Response>
<Start>
<Stream url="${streamUrl}">
<Parameter name="tenantId" value="${tenantDomain}"/>
<Parameter name="tenantId" value="${tenantId}"/>
<Parameter name="userId" value="${connectedUsers[0]}"/>
</Stream>
</Start>
@@ -236,7 +304,7 @@ ${clientElements}
</Dial>
</Response>`;
this.logger.log(`✓ Returning inbound TwiML with Media Streams - dialing ${connectedUsers.length} client(s)`);
this.logger.log(`✓ Returning inbound TwiML - dialing ${connectedUsers.length} client(s)`);
this.logger.log(`Generated TwiML:\n${twiml}\n`);
res.type('text/xml').send(twiml);
} catch (error: any) {

View File

@@ -61,33 +61,41 @@ export class VoiceGateway
const payload = await this.jwtService.verifyAsync(token);
// Extract domain from origin header (e.g., http://tenant1.routebox.co:3001)
// The domains table stores just the subdomain part (e.g., "tenant1")
const origin = client.handshake.headers.origin || client.handshake.headers.referer;
let domain = 'localhost';
let subdomain = 'localhost';
if (origin) {
try {
const url = new URL(origin);
const hostname = url.hostname; // e.g., tenant1.routebox.co or localhost
// Extract first part of subdomain as domain
// tenant1.routebox.co -> tenant1
// localhost -> localhost
domain = hostname.split('.')[0];
const hostname = url.hostname;
subdomain = hostname.split('.')[0];
} catch (error) {
this.logger.warn(`Failed to parse origin: ${origin}`);
}
}
client.tenantId = domain; // Store the subdomain as tenantId
// Resolve the actual tenantId (UUID) from the subdomain
let tenantId: string | null = null;
try {
const tenant = await this.tenantDbService.getTenantByDomain(subdomain);
if (tenant) {
tenantId = tenant.id;
this.logger.log(`Resolved tenant ${subdomain} -> ${tenantId}`);
}
} catch (error) {
this.logger.warn(`Failed to resolve tenant for subdomain ${subdomain}: ${error.message}`);
}
// Fall back to subdomain if tenant lookup fails
client.tenantId = tenantId || subdomain;
client.userId = payload.sub;
client.tenantSlug = domain; // Same as subdomain
client.tenantSlug = subdomain;
this.connectedUsers.set(client.userId, client);
this.logger.log(
`✓ Client connected: ${client.id} (User: ${client.userId}, Domain: ${domain})`,
`✓ Client connected: ${client.id} (User: ${client.userId}, TenantId: ${client.tenantId}, Subdomain: ${subdomain})`,
);
this.logger.log(`Total connected users in ${domain}: ${this.getConnectedUsers(domain).length}`);
this.logger.log(`Total connected users in tenant ${client.tenantId}: ${this.getConnectedUsers(client.tenantId).length}`);
// Send current call state if any active call
const activeCallSid = this.activeCallsByUser.get(client.userId);
@@ -303,13 +311,14 @@ export class VoiceGateway
/**
* Get connected users for a tenant
* @param tenantId - The tenant UUID to filter by
*/
getConnectedUsers(tenantDomain?: string): string[] {
getConnectedUsers(tenantId?: string): string[] {
const userIds: string[] = [];
for (const [userId, socket] of this.connectedUsers.entries()) {
// If tenantDomain specified, filter by tenant
if (!tenantDomain || socket.tenantSlug === tenantDomain) {
// If tenantId specified, filter by tenant
if (!tenantId || socket.tenantId === tenantId) {
userIds.push(userId);
}
}

View File

@@ -31,46 +31,46 @@ export class VoiceService {
/**
* Get Twilio client for a tenant
*/
private async getTwilioClient(tenantIdOrDomain: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig; tenantId: string }> {
private async getTwilioClient(tenantId: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig; tenantId: string }> {
// Check cache first
if (this.twilioClients.has(tenantIdOrDomain)) {
if (this.twilioClients.has(tenantId)) {
const centralPrisma = getCentralPrisma();
// Look up tenant by domain
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain: tenantIdOrDomain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
// Look up tenant by ID
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
select: { id: true, integrationsConfig: true },
});
const config = this.getIntegrationConfig(domainRecord?.tenant?.integrationsConfig as any);
const config = this.getIntegrationConfig(tenant?.integrationsConfig as any);
return {
client: this.twilioClients.get(tenantIdOrDomain),
client: this.twilioClients.get(tenantId),
config: config.twilio,
tenantId: domainRecord.tenant.id
tenantId: tenant.id
};
}
// Fetch tenant integrations config
const centralPrisma = getCentralPrisma();
this.logger.log(`Looking up domain: ${tenantIdOrDomain}`);
this.logger.log(`Looking up tenant: ${tenantId}`);
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain: tenantIdOrDomain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
select: { id: true, integrationsConfig: true },
});
this.logger.log(`Domain record found: ${!!domainRecord}, Tenant: ${!!domainRecord?.tenant}, Config: ${!!domainRecord?.tenant?.integrationsConfig}`);
this.logger.log(`Tenant found: ${!!tenant}, Config: ${!!tenant?.integrationsConfig}`);
if (!domainRecord?.tenant) {
throw new Error(`Domain ${tenantIdOrDomain} not found`);
if (!tenant) {
throw new Error(`Tenant ${tenantId} not found`);
}
if (!domainRecord.tenant.integrationsConfig) {
if (!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);
const config = this.getIntegrationConfig(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}`);
@@ -79,9 +79,9 @@ export class VoiceService {
}
const client = Twilio.default(config.twilio.accountSid, config.twilio.authToken);
this.twilioClients.set(tenantIdOrDomain, client);
this.twilioClients.set(tenantId, client);
return { client, config: config.twilio, tenantId: domainRecord.tenant.id };
return { client, config: config.twilio, tenantId: tenant.id };
}
/**
@@ -105,22 +105,64 @@ export class VoiceService {
return {};
}
/**
* Find tenant by their configured Twilio phone number
* Used for inbound call routing
*/
async findTenantByPhoneNumber(phoneNumber: string): Promise<{ tenantId: string; config: TwilioConfig } | null> {
const centralPrisma = getCentralPrisma();
// Normalize phone number (remove spaces, ensure + prefix for comparison)
const normalizedPhone = phoneNumber.replace(/\s+/g, '').replace(/^(\d)/, '+$1');
this.logger.log(`Looking up tenant by phone number: ${normalizedPhone}`);
// Get all tenants with integrations config
const tenants = await centralPrisma.tenant.findMany({
where: {
integrationsConfig: { not: null },
},
select: { id: true, integrationsConfig: true },
});
for (const tenant of tenants) {
const config = this.getIntegrationConfig(tenant.integrationsConfig as any);
if (config.twilio?.phoneNumber) {
const tenantPhone = config.twilio.phoneNumber.replace(/\s+/g, '').replace(/^(\d)/, '+$1');
if (tenantPhone === normalizedPhone) {
this.logger.log(`Found tenant ${tenant.id} for phone number ${normalizedPhone}`);
return { tenantId: tenant.id, config: config.twilio };
}
}
}
this.logger.warn(`No tenant found for phone number: ${normalizedPhone}`);
return null;
}
/**
* Generate Twilio access token for browser Voice SDK
*/
async generateAccessToken(tenantDomain: string, userId: string): Promise<string> {
const { config, tenantId } = await this.getTwilioClient(tenantDomain);
async generateAccessToken(tenantId: string, userId: string): Promise<string> {
const { config, tenantId: resolvedTenantId } = await this.getTwilioClient(tenantId);
if (!config.accountSid || !config.apiKey || !config.apiSecret) {
throw new Error('Twilio API credentials not configured. Please add API Key and Secret in Settings > Integrations');
}
// Include tenantId in the identity so we can extract it in TwiML webhooks
// Format: tenantId:userId
const identity = `${resolvedTenantId}:${userId}`;
this.logger.log(`Generating access token with identity: ${identity}`);
this.logger.log(` Input tenantId: ${tenantId}, Resolved tenantId: ${resolvedTenantId}, userId: ${userId}`);
// Create an access token
const token = new AccessToken(
config.accountSid,
config.apiKey,
config.apiSecret,
{ identity: userId, ttl: 3600 } // 1 hour expiry
{ identity, ttl: 3600 } // 1 hour expiry
);
// Create a Voice grant