WIP - fix twilio functionality now that we use BFF
This commit is contained in:
@@ -98,37 +98,75 @@ export class VoiceController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* TwiML for outbound calls from browser (Twilio Device)
|
* TwiML for outbound calls from browser (Twilio Device)
|
||||||
|
* Twilio sends application/x-www-form-urlencoded data
|
||||||
*/
|
*/
|
||||||
@Post('twiml/outbound')
|
@Post('twiml/outbound')
|
||||||
async outboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
|
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 to = body.To;
|
||||||
const from = body.From;
|
const from = body.From; // Format: "client:tenantId:userId"
|
||||||
const callSid = body.CallSid;
|
const callSid = body.CallSid;
|
||||||
|
|
||||||
this.logger.log(`=== TwiML OUTBOUND REQUEST RECEIVED ===`);
|
this.logger.log(`=== TwiML OUTBOUND REQUEST RECEIVED ===`);
|
||||||
this.logger.log(`CallSid: ${callSid}, Body From: ${from}, Body To: ${to}`);
|
this.logger.log(`CallSid: ${callSid}, From: ${from}, To: ${to}`);
|
||||||
this.logger.log(`Full body: ${JSON.stringify(body)}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract tenant domain from Host header
|
// Extract tenant ID from the client identity
|
||||||
const host = req.headers.host || '';
|
// Format: "client:tenantId:userId"
|
||||||
const tenantDomain = host.split('.')[0]; // e.g., "tenant1" from "tenant1.routebox.co"
|
let tenantId: string | null = null;
|
||||||
|
if (from && from.startsWith('client:')) {
|
||||||
this.logger.log(`Extracted tenant domain: ${tenantDomain}`);
|
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
|
// Look up tenant's Twilio phone number from config
|
||||||
let callerId = to; // Fallback (will cause error if not found)
|
let callerId: string | undefined;
|
||||||
try {
|
try {
|
||||||
// Get Twilio config to find the phone number
|
const { config } = await this.voiceService['getTwilioClient'](tenantId);
|
||||||
const { config } = await this.voiceService['getTwilioClient'](tenantDomain);
|
|
||||||
callerId = config.phoneNumber;
|
callerId = config.phoneNumber;
|
||||||
this.logger.log(`Retrieved Twilio phone number for tenant: ${callerId}`);
|
this.logger.log(`Retrieved Twilio phone number for tenant: ${callerId}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to get Twilio config: ${error.message}`);
|
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}`);
|
this.logger.log(`Using callerId: ${callerId}, dialNumber: ${dialNumber}`);
|
||||||
|
|
||||||
@@ -145,10 +183,9 @@ export class VoiceController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`=== ERROR GENERATING TWIML ===`);
|
this.logger.error(`=== ERROR GENERATING TWIML ===`);
|
||||||
this.logger.error(`Error: ${error.message}`);
|
this.logger.error(`Error: ${error.message}`);
|
||||||
this.logger.error(`Stack: ${error.stack}`);
|
|
||||||
const errorTwiml = `<?xml version="1.0" encoding="UTF-8"?>
|
const errorTwiml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Response>
|
<Response>
|
||||||
<Say>An error occurred while processing your call.</Say>
|
<Say>An error occurred while processing your call. ${error.message}</Say>
|
||||||
</Response>`;
|
</Response>`;
|
||||||
res.type('text/xml').send(errorTwiml);
|
res.type('text/xml').send(errorTwiml);
|
||||||
}
|
}
|
||||||
@@ -156,13 +193,33 @@ export class VoiceController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* TwiML for inbound calls
|
* TwiML for inbound calls
|
||||||
|
* Twilio sends application/x-www-form-urlencoded data
|
||||||
*/
|
*/
|
||||||
@Post('twiml/inbound')
|
@Post('twiml/inbound')
|
||||||
async inboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
|
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 callSid = body.CallSid;
|
||||||
const fromNumber = body.From;
|
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(`\n\n╔════════════════════════════════════════╗`);
|
||||||
this.logger.log(`║ === INBOUND CALL RECEIVED ===`);
|
this.logger.log(`║ === INBOUND CALL RECEIVED ===`);
|
||||||
@@ -170,19 +227,28 @@ export class VoiceController {
|
|||||||
this.logger.log(`CallSid: ${callSid}`);
|
this.logger.log(`CallSid: ${callSid}`);
|
||||||
this.logger.log(`From: ${fromNumber}`);
|
this.logger.log(`From: ${fromNumber}`);
|
||||||
this.logger.log(`To: ${toNumber}`);
|
this.logger.log(`To: ${toNumber}`);
|
||||||
this.logger.log(`Full body: ${JSON.stringify(body)}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract tenant domain from Host header
|
// Look up tenant by the Twilio phone number that was called
|
||||||
const host = req.headers.host || '';
|
const tenantInfo = await this.voiceService.findTenantByPhoneNumber(toNumber);
|
||||||
const tenantDomain = host.split('.')[0]; // e.g., "tenant1" from "tenant1.routebox.co"
|
|
||||||
|
|
||||||
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
|
// 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) {
|
if (connectedUsers.length > 0) {
|
||||||
this.logger.log(`Connected user IDs: ${connectedUsers.join(', ')}`);
|
this.logger.log(`Connected user IDs: ${connectedUsers.join(', ')}`);
|
||||||
}
|
}
|
||||||
@@ -198,20 +264,22 @@ export class VoiceController {
|
|||||||
return res.type('text/xml').send(twiml);
|
return res.type('text/xml').send(twiml);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build TwiML to dial all connected clients with Media Streams for AI
|
// Build TwiML to dial all connected clients
|
||||||
const clientElements = connectedUsers.map(userId => ` <Client>${userId}</Client>`).join('\n');
|
// 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`;
|
const streamUrl = `wss://${host}/api/voice/media-stream`;
|
||||||
|
|
||||||
this.logger.log(`Stream URL: ${streamUrl}`);
|
this.logger.log(`Stream URL: ${streamUrl}`);
|
||||||
this.logger.log(`Dialing ${connectedUsers.length} client(s)...`);
|
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
|
// Notify connected users about incoming call via Socket.IO
|
||||||
connectedUsers.forEach(userId => {
|
connectedUsers.forEach(userId => {
|
||||||
@@ -219,7 +287,7 @@ export class VoiceController {
|
|||||||
callSid,
|
callSid,
|
||||||
fromNumber,
|
fromNumber,
|
||||||
toNumber,
|
toNumber,
|
||||||
tenantDomain,
|
tenantId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -227,7 +295,7 @@ export class VoiceController {
|
|||||||
<Response>
|
<Response>
|
||||||
<Start>
|
<Start>
|
||||||
<Stream url="${streamUrl}">
|
<Stream url="${streamUrl}">
|
||||||
<Parameter name="tenantId" value="${tenantDomain}"/>
|
<Parameter name="tenantId" value="${tenantId}"/>
|
||||||
<Parameter name="userId" value="${connectedUsers[0]}"/>
|
<Parameter name="userId" value="${connectedUsers[0]}"/>
|
||||||
</Stream>
|
</Stream>
|
||||||
</Start>
|
</Start>
|
||||||
@@ -236,7 +304,7 @@ ${clientElements}
|
|||||||
</Dial>
|
</Dial>
|
||||||
</Response>`;
|
</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`);
|
this.logger.log(`Generated TwiML:\n${twiml}\n`);
|
||||||
res.type('text/xml').send(twiml);
|
res.type('text/xml').send(twiml);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -61,33 +61,41 @@ export class VoiceGateway
|
|||||||
const payload = await this.jwtService.verifyAsync(token);
|
const payload = await this.jwtService.verifyAsync(token);
|
||||||
|
|
||||||
// Extract domain from origin header (e.g., http://tenant1.routebox.co:3001)
|
// 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;
|
const origin = client.handshake.headers.origin || client.handshake.headers.referer;
|
||||||
let domain = 'localhost';
|
let subdomain = 'localhost';
|
||||||
|
|
||||||
if (origin) {
|
if (origin) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(origin);
|
const url = new URL(origin);
|
||||||
const hostname = url.hostname; // e.g., tenant1.routebox.co or localhost
|
const hostname = url.hostname;
|
||||||
|
subdomain = hostname.split('.')[0];
|
||||||
// Extract first part of subdomain as domain
|
|
||||||
// tenant1.routebox.co -> tenant1
|
|
||||||
// localhost -> localhost
|
|
||||||
domain = hostname.split('.')[0];
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`Failed to parse origin: ${origin}`);
|
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.userId = payload.sub;
|
||||||
client.tenantSlug = domain; // Same as subdomain
|
client.tenantSlug = subdomain;
|
||||||
|
|
||||||
this.connectedUsers.set(client.userId, client);
|
this.connectedUsers.set(client.userId, client);
|
||||||
this.logger.log(
|
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
|
// Send current call state if any active call
|
||||||
const activeCallSid = this.activeCallsByUser.get(client.userId);
|
const activeCallSid = this.activeCallsByUser.get(client.userId);
|
||||||
@@ -303,13 +311,14 @@ export class VoiceGateway
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get connected users for a tenant
|
* 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[] = [];
|
const userIds: string[] = [];
|
||||||
|
|
||||||
for (const [userId, socket] of this.connectedUsers.entries()) {
|
for (const [userId, socket] of this.connectedUsers.entries()) {
|
||||||
// If tenantDomain specified, filter by tenant
|
// If tenantId specified, filter by tenant
|
||||||
if (!tenantDomain || socket.tenantSlug === tenantDomain) {
|
if (!tenantId || socket.tenantId === tenantId) {
|
||||||
userIds.push(userId);
|
userIds.push(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,46 +31,46 @@ export class VoiceService {
|
|||||||
/**
|
/**
|
||||||
* Get Twilio client for a tenant
|
* 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
|
// Check cache first
|
||||||
if (this.twilioClients.has(tenantIdOrDomain)) {
|
if (this.twilioClients.has(tenantId)) {
|
||||||
const centralPrisma = getCentralPrisma();
|
const centralPrisma = getCentralPrisma();
|
||||||
|
|
||||||
// Look up tenant by domain
|
// Look up tenant by ID
|
||||||
const domainRecord = await centralPrisma.domain.findUnique({
|
const tenant = await centralPrisma.tenant.findUnique({
|
||||||
where: { domain: tenantIdOrDomain },
|
where: { id: tenantId },
|
||||||
include: { tenant: { select: { id: true, integrationsConfig: true } } },
|
select: { id: true, integrationsConfig: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const config = this.getIntegrationConfig(domainRecord?.tenant?.integrationsConfig as any);
|
const config = this.getIntegrationConfig(tenant?.integrationsConfig as any);
|
||||||
return {
|
return {
|
||||||
client: this.twilioClients.get(tenantIdOrDomain),
|
client: this.twilioClients.get(tenantId),
|
||||||
config: config.twilio,
|
config: config.twilio,
|
||||||
tenantId: domainRecord.tenant.id
|
tenantId: tenant.id
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch tenant integrations config
|
// Fetch tenant integrations config
|
||||||
const centralPrisma = getCentralPrisma();
|
const centralPrisma = getCentralPrisma();
|
||||||
|
|
||||||
this.logger.log(`Looking up domain: ${tenantIdOrDomain}`);
|
this.logger.log(`Looking up tenant: ${tenantId}`);
|
||||||
|
|
||||||
const domainRecord = await centralPrisma.domain.findUnique({
|
const tenant = await centralPrisma.tenant.findUnique({
|
||||||
where: { domain: tenantIdOrDomain },
|
where: { id: tenantId },
|
||||||
include: { tenant: { select: { id: true, integrationsConfig: true } } },
|
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) {
|
if (!tenant) {
|
||||||
throw new Error(`Domain ${tenantIdOrDomain} not found`);
|
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');
|
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}`);
|
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);
|
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 {};
|
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
|
* Generate Twilio access token for browser Voice SDK
|
||||||
*/
|
*/
|
||||||
async generateAccessToken(tenantDomain: string, userId: string): Promise<string> {
|
async generateAccessToken(tenantId: string, userId: string): Promise<string> {
|
||||||
const { config, tenantId } = await this.getTwilioClient(tenantDomain);
|
const { config, tenantId: resolvedTenantId } = await this.getTwilioClient(tenantId);
|
||||||
|
|
||||||
if (!config.accountSid || !config.apiKey || !config.apiSecret) {
|
if (!config.accountSid || !config.apiKey || !config.apiSecret) {
|
||||||
throw new Error('Twilio API credentials not configured. Please add API Key and Secret in Settings > Integrations');
|
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
|
// Create an access token
|
||||||
const token = new AccessToken(
|
const token = new AccessToken(
|
||||||
config.accountSid,
|
config.accountSid,
|
||||||
config.apiKey,
|
config.apiKey,
|
||||||
config.apiSecret,
|
config.apiSecret,
|
||||||
{ identity: userId, ttl: 3600 } // 1 hour expiry
|
{ identity, ttl: 3600 } // 1 hour expiry
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a Voice grant
|
// Create a Voice grant
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, shallowRef } from 'vue';
|
import { ref, computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
||||||
import { io, Socket } from 'socket.io-client';
|
import { io, Socket } from 'socket.io-client';
|
||||||
import { Device, Call as TwilioCall } from '@twilio/voice-sdk';
|
import { Device, Call as TwilioCall } from '@twilio/voice-sdk';
|
||||||
import { useAuth } from './useAuth';
|
import { useAuth } from './useAuth';
|
||||||
@@ -44,17 +44,6 @@ const volume = ref(100);
|
|||||||
export function useSoftphone() {
|
export function useSoftphone() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
// Get token and tenantId from localStorage
|
|
||||||
const getToken = () => {
|
|
||||||
if (typeof window === 'undefined') return null;
|
|
||||||
return localStorage.getItem('token');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTenantId = () => {
|
|
||||||
if (typeof window === 'undefined') return null;
|
|
||||||
return localStorage.getItem('tenantId');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const isInCall = computed(() => currentCall.value !== null);
|
const isInCall = computed(() => currentCall.value !== null);
|
||||||
const hasIncomingCall = computed(() => incomingCall.value !== null);
|
const hasIncomingCall = computed(() => incomingCall.value !== null);
|
||||||
@@ -93,6 +82,12 @@ export function useSoftphone() {
|
|||||||
* Initialize Twilio Device
|
* Initialize Twilio Device
|
||||||
*/
|
*/
|
||||||
const initializeTwilioDevice = async () => {
|
const initializeTwilioDevice = async () => {
|
||||||
|
// Prevent re-initialization if device already exists and is registered
|
||||||
|
if (twilioDevice.value) {
|
||||||
|
console.log('Twilio Device already exists, skipping initialization');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First, explicitly request microphone permission
|
// First, explicitly request microphone permission
|
||||||
const hasPermission = await requestMicrophonePermission();
|
const hasPermission = await requestMicrophonePermission();
|
||||||
@@ -107,12 +102,14 @@ export function useSoftphone() {
|
|||||||
// Log the token payload to see what identity is being used
|
// Log the token payload to see what identity is being used
|
||||||
try {
|
try {
|
||||||
const tokenPayload = JSON.parse(atob(token.split('.')[1]));
|
const tokenPayload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
console.log('📱 Twilio Device identity:', tokenPayload.grants?.identity || tokenPayload.sub);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Could not parse token payload');
|
console.log('Could not parse token payload');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('📱 Creating new Twilio Device...');
|
||||||
twilioDevice.value = new Device(token, {
|
twilioDevice.value = new Device(token, {
|
||||||
logLevel: 3,
|
logLevel: 1, // Reduce log level (1 = errors only, 3 = debug)
|
||||||
codecPreferences: ['opus', 'pcmu'],
|
codecPreferences: ['opus', 'pcmu'],
|
||||||
enableImprovedSignalingErrorPrecision: true,
|
enableImprovedSignalingErrorPrecision: true,
|
||||||
edge: 'ashburn',
|
edge: 'ashburn',
|
||||||
@@ -120,10 +117,12 @@ export function useSoftphone() {
|
|||||||
|
|
||||||
// Device events
|
// Device events
|
||||||
twilioDevice.value.on('registered', () => {
|
twilioDevice.value.on('registered', () => {
|
||||||
|
console.log('✅ Twilio Device registered successfully');
|
||||||
toast.success('Softphone ready');
|
toast.success('Softphone ready');
|
||||||
});
|
});
|
||||||
|
|
||||||
twilioDevice.value.on('unregistered', () => {
|
twilioDevice.value.on('unregistered', () => {
|
||||||
|
console.log('📱 Twilio Device unregistered');
|
||||||
});
|
});
|
||||||
|
|
||||||
twilioDevice.value.on('error', (error) => {
|
twilioDevice.value.on('error', (error) => {
|
||||||
@@ -132,6 +131,11 @@ export function useSoftphone() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
twilioDevice.value.on('incoming', (call: TwilioCall) => {
|
twilioDevice.value.on('incoming', (call: TwilioCall) => {
|
||||||
|
console.log('📞 Twilio Device incoming call event!', {
|
||||||
|
callSid: call.parameters.CallSid,
|
||||||
|
from: call.parameters.From,
|
||||||
|
to: call.parameters.To,
|
||||||
|
});
|
||||||
twilioCall.value = call;
|
twilioCall.value = call;
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
@@ -210,35 +214,54 @@ export function useSoftphone() {
|
|||||||
/**
|
/**
|
||||||
* Initialize WebSocket connection
|
* Initialize WebSocket connection
|
||||||
*/
|
*/
|
||||||
const connect = () => {
|
const connect = async () => {
|
||||||
const token = getToken();
|
// Guard against multiple connection attempts
|
||||||
|
if (socket.value) {
|
||||||
if (socket.value?.connected || !token) {
|
console.log('Softphone: Socket already exists, skipping connection');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use same pattern as useApi to preserve subdomain for multi-tenant
|
// Check if user is authenticated
|
||||||
const getBackendUrl = () => {
|
if (!auth.isAuthenticated.value) {
|
||||||
if (typeof window !== 'undefined') {
|
// Try to verify authentication first
|
||||||
const currentHost = window.location.hostname;
|
const isValid = await auth.checkAuth();
|
||||||
const protocol = window.location.protocol;
|
if (!isValid) {
|
||||||
return `${protocol}//${currentHost}`;
|
console.log('Softphone: User not authenticated, skipping connection');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return 'http://localhost:3000';
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Connect to /voice namespace with proper auth header
|
try {
|
||||||
socket.value = io(`${getBackendUrl()}/voice`, {
|
// Get WebSocket token from BFF (this retrieves the token from HTTP-only cookie server-side)
|
||||||
auth: {
|
const wsAuth = await $fetch('/api/auth/ws-token');
|
||||||
token: token,
|
|
||||||
},
|
if (!wsAuth.token) {
|
||||||
transports: ['websocket', 'polling'],
|
console.log('Softphone: No WebSocket token available');
|
||||||
reconnection: true,
|
return;
|
||||||
reconnectionDelay: 1000,
|
}
|
||||||
reconnectionDelayMax: 5000,
|
|
||||||
reconnectionAttempts: 5,
|
// Use same pattern as useApi to preserve subdomain for multi-tenant
|
||||||
query: {}, // Explicitly set empty query to prevent token leaking
|
const getBackendUrl = () => {
|
||||||
});
|
if (typeof window !== 'undefined') {
|
||||||
|
const currentHost = window.location.hostname;
|
||||||
|
const protocol = window.location.protocol;
|
||||||
|
return `${protocol}//${currentHost}`;
|
||||||
|
}
|
||||||
|
return 'http://localhost:3000';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect to /voice namespace with proper auth header
|
||||||
|
socket.value = io(`${getBackendUrl()}/voice`, {
|
||||||
|
auth: {
|
||||||
|
token: wsAuth.token,
|
||||||
|
},
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionDelayMax: 5000,
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
query: {}, // Explicitly set empty query to prevent token leaking
|
||||||
|
});
|
||||||
|
|
||||||
// Connection events
|
// Connection events
|
||||||
socket.value.on('connect', () => {
|
socket.value.on('connect', () => {
|
||||||
@@ -279,6 +302,10 @@ export function useSoftphone() {
|
|||||||
socket.value.on('ai:action', handleAiAction);
|
socket.value.on('ai:action', handleAiAction);
|
||||||
|
|
||||||
isInitialized.value = true;
|
isInitialized.value = true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Softphone: Failed to connect:', error);
|
||||||
|
toast.error('Failed to initialize voice service');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -569,14 +596,25 @@ export function useSoftphone() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-connect on mount if token is available
|
// Only set up auto-connect and watchers once (not for every component that uses this composable)
|
||||||
onMounted(() => {
|
// Use a module-level flag to track if watchers are already set up
|
||||||
if (getToken() && !isInitialized.value) {
|
if (process.client && !isInitialized.value && !socket.value) {
|
||||||
|
// Auto-connect if authenticated
|
||||||
|
if (auth.isAuthenticated.value) {
|
||||||
connect();
|
connect();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Watch for authentication changes to connect/disconnect
|
||||||
|
watch(() => auth.isAuthenticated.value, async (isAuth) => {
|
||||||
|
if (isAuth && !isInitialized.value && !socket.value) {
|
||||||
|
await connect();
|
||||||
|
} else if (!isAuth && isInitialized.value) {
|
||||||
|
disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount - only stop ringtone, don't disconnect shared socket
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopRingtone();
|
stopRingtone();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
const queryString = new URLSearchParams(query as Record<string, string>).toString()
|
const queryString = new URLSearchParams(query as Record<string, string>).toString()
|
||||||
const fullUrl = `${backendUrl}/api/${path}${queryString ? `?${queryString}` : ''}`
|
const fullUrl = `${backendUrl}/api/${path}${queryString ? `?${queryString}` : ''}`
|
||||||
|
|
||||||
|
console.log(`[BFF Proxy] ${method} ${fullUrl} (subdomain: ${subdomain}, hasToken: ${!!token})`)
|
||||||
|
|
||||||
// Build headers to forward
|
// Build headers to forward
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': getHeader(event, 'content-type') || 'application/json',
|
'Content-Type': getHeader(event, 'content-type') || 'application/json',
|
||||||
@@ -74,8 +76,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
errorMessage = errorData.message || errorMessage
|
errorMessage = errorData.message || errorMessage
|
||||||
} catch {
|
} catch {
|
||||||
// Response wasn't JSON
|
// Response wasn't JSON
|
||||||
|
try {
|
||||||
|
const text = await response.text()
|
||||||
|
console.error(`[BFF Proxy] Backend error (non-JSON): ${text}`)
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.error(`[BFF Proxy] Backend returned ${response.status}: ${errorMessage}`, errorData)
|
||||||
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: response.status,
|
statusCode: response.status,
|
||||||
statusMessage: errorMessage,
|
statusMessage: errorMessage,
|
||||||
|
|||||||
26
frontend/server/api/auth/ws-token.get.ts
Normal file
26
frontend/server/api/auth/ws-token.get.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineEventHandler, createError } from 'h3'
|
||||||
|
import { getSubdomainFromRequest } from '~/server/utils/tenant'
|
||||||
|
import { getSessionToken } from '~/server/utils/session'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a short-lived token for WebSocket authentication
|
||||||
|
* This is needed because socket.io cannot use HTTP-only cookies directly
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const subdomain = getSubdomainFromRequest(event)
|
||||||
|
const token = getSessionToken(event)
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Not authenticated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the token for WebSocket use
|
||||||
|
// The token is already validated by being in the HTTP-only cookie
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
subdomain,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user