Skip to content

Commit cbea872

Browse files
author
mesut
committed
inbound calls
1 parent de529f5 commit cbea872

10 files changed

Lines changed: 420 additions & 35 deletions

File tree

apps/agent/src/expressive_agent.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,6 @@ async def tts_node(self, text: AsyncIterable[str], model_settings: ModelSettings
411411
params = {}
412412
if voice_instruction:
413413
params = self._map_instruction_to_params(voice_instruction)
414-
logger.info(f"Voice instruction: '{voice_instruction}' -> TTS params: {params}")
415414

416415
# Create inference.TTS with extra_kwargs for this utterance
417416
tts_kwargs = dict(self._tts_config)

apps/backend/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ LIVEKIT_API_SECRET=your_api_secret
1414
# Default: "aylin-agent" for production, "aylin-dev-agent" for development
1515
# AGENT_NAME=aylin-dev-agent
1616

17+
# LiveKit SIP Endpoint for inbound calls (WITHOUT sip: prefix)
18+
# Find this in your LiveKit Cloud project settings under "SIP URI"
19+
# Format: your-project-id.sip.livekit.cloud
20+
# Example: 3bfg1girhxh.sip.livekit.cloud
21+
LIVEKIT_SIP_ENDPOINT=3bfg1girhxh.sip.livekit.cloud
22+
1723
# Database Configuration
1824
DATABASE_URL=postgresql://aylin_user:aylin_password@localhost:5432/aylin
1925
DATABASE_HOST=localhost

apps/backend/.env.production

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,9 @@ AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;EndpointSuffix=co
3636

3737
AGENT_NAME=aylin-agent
3838

39+
40+
# LiveKit SIP Endpoint for inbound calls (WITHOUT sip: prefix)
41+
# Find this in your LiveKit Cloud project settings under "SIP URI"
42+
# Format: your-project-id.sip.livekit.cloud
43+
# Example: 3bfg1girhxh.sip.livekit.cloud
44+
LIVEKIT_SIP_ENDPOINT=3bfg1girhxh.sip.livekit.cloud
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE phone_numbers
2+
ADD COLUMN inbound_agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
3+
ADD COLUMN livekit_dispatch_rule_id VARCHAR(255),
4+
ADD COLUMN fallback_number VARCHAR(20);

apps/backend/src/routes/phone-numbers.ts

Lines changed: 142 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ router.use(authenticateToken);
1212
// Helper to separate sensitive configuration from response data
1313
const sanitizePhoneNumber = (pn: any) => {
1414
if (!pn) return null;
15-
const { configuration, provider, livekit_outbound_trunk_id, livekit_inbound_trunk_id, ...safeData } = pn;
15+
// Keep inbound_agent_id and fallback_number, but hide internal LiveKit IDs and config
16+
const { configuration, provider, livekit_outbound_trunk_id, livekit_inbound_trunk_id, livekit_dispatch_rule_id, ...safeData } = pn;
1617
return safeData;
1718
};
1819

@@ -327,6 +328,122 @@ router.put('/:id', async (req: Request, res: Response) => {
327328
}
328329
});
329330

331+
// Configure inbound settings (assign agent, create inbound trunk + dispatch rule)
332+
router.put('/:id/inbound', async (req: Request, res: Response) => {
333+
const { id } = req.params;
334+
const { agentId, fallbackNumber } = req.body;
335+
const userId = (req as any).user.id;
336+
337+
try {
338+
// Fetch the phone number
339+
const phoneResult = await pool.query(
340+
'SELECT * FROM phone_numbers WHERE id = $1 AND user_id = $2',
341+
[id, userId]
342+
);
343+
344+
if (phoneResult.rows.length === 0) {
345+
return res.status(404).json({ error: 'Phone number not found' });
346+
}
347+
348+
const phoneNumber = phoneResult.rows[0];
349+
const livekitService = new LiveKitSipConfigService();
350+
351+
// Determine agent name from environment
352+
const isProduction = process.env.NODE_ENV === 'production';
353+
const agentName = process.env.AGENT_NAME || (isProduction ? 'aylin-agent' : 'aylin-dev-agent');
354+
355+
// If agentId is null/empty, remove inbound settings
356+
if (!agentId) {
357+
// Clean up existing LiveKit resources
358+
if (phoneNumber.livekit_dispatch_rule_id) {
359+
await livekitService.deleteDispatchRule(phoneNumber.livekit_dispatch_rule_id);
360+
}
361+
if (phoneNumber.livekit_inbound_trunk_id) {
362+
await livekitService.deleteTrunk(phoneNumber.livekit_inbound_trunk_id);
363+
}
364+
365+
const result = await pool.query(
366+
`UPDATE phone_numbers
367+
SET inbound_agent_id = NULL,
368+
livekit_inbound_trunk_id = NULL,
369+
livekit_dispatch_rule_id = NULL,
370+
fallback_number = NULL
371+
WHERE id = $1 AND user_id = $2
372+
RETURNING *`,
373+
[id, userId]
374+
);
375+
376+
console.log(`Inbound settings removed for phone number ${phoneNumber.phone_number}`);
377+
return res.json(sanitizePhoneNumber(result.rows[0]));
378+
}
379+
380+
// Verify agent exists
381+
const agentResult = await pool.query('SELECT id FROM agents WHERE id = $1', [agentId]);
382+
if (agentResult.rows.length === 0) {
383+
return res.status(404).json({ error: 'Agent not found' });
384+
}
385+
386+
// Clean up old inbound resources if they exist
387+
if (phoneNumber.livekit_dispatch_rule_id) {
388+
await livekitService.deleteDispatchRule(phoneNumber.livekit_dispatch_rule_id);
389+
}
390+
if (phoneNumber.livekit_inbound_trunk_id) {
391+
await livekitService.deleteTrunk(phoneNumber.livekit_inbound_trunk_id);
392+
}
393+
394+
// Create new inbound trunk
395+
console.log(`Creating inbound trunk for ${phoneNumber.phone_number}...`);
396+
const inboundTrunk = await livekitService.createInboundTrunk({
397+
phoneNumber: phoneNumber.phone_number,
398+
name: `Inbound-${phoneNumber.phone_number.replace(/[^\w]/g, '')}`,
399+
});
400+
401+
// Create dispatch rule linking to the agent
402+
console.log(`Creating dispatch rule for agent ${agentId}...`);
403+
const dispatchRule = await livekitService.createInboundDispatchRule({
404+
inboundTrunkId: inboundTrunk.inboundTrunkId,
405+
agentName,
406+
agentId,
407+
});
408+
409+
// Update database
410+
const result = await pool.query(
411+
`UPDATE phone_numbers
412+
SET inbound_agent_id = $1,
413+
livekit_inbound_trunk_id = $2,
414+
livekit_dispatch_rule_id = $3,
415+
fallback_number = $4
416+
WHERE id = $5 AND user_id = $6
417+
RETURNING *`,
418+
[agentId, inboundTrunk.inboundTrunkId, dispatchRule.dispatchRuleId, fallbackNumber || null, id, userId]
419+
);
420+
421+
console.log(`Inbound settings configured for ${phoneNumber.phone_number}`);
422+
console.log(` Inbound Trunk: ${inboundTrunk.inboundTrunkId}`);
423+
console.log(` Dispatch Rule: ${dispatchRule.dispatchRuleId}`);
424+
console.log(` Agent: ${agentId}`);
425+
426+
const updatedPhone = result.rows[0];
427+
console.log('Database result:', {
428+
inbound_agent_id: updatedPhone.inbound_agent_id,
429+
livekit_inbound_trunk_id: updatedPhone.livekit_inbound_trunk_id,
430+
livekit_dispatch_rule_id: updatedPhone.livekit_dispatch_rule_id,
431+
fallback_number: updatedPhone.fallback_number,
432+
});
433+
434+
const sanitized = sanitizePhoneNumber(updatedPhone);
435+
console.log('Sanitized response:', {
436+
inbound_agent_id: sanitized.inbound_agent_id,
437+
fallback_number: sanitized.fallback_number,
438+
});
439+
440+
res.json(sanitized);
441+
} catch (error: any) {
442+
console.error('Error configuring inbound settings:', error);
443+
res.status(500).json({ error: 'Failed to configure inbound settings', message: error.message });
444+
}
445+
});
446+
330447
router.delete('/:id', async (req: Request, res: Response) => {
331448
const { id } = req.params;
332449
const userId = (req as any).user.id;
@@ -346,23 +463,32 @@ router.delete('/:id', async (req: Request, res: Response) => {
346463
const config = phoneNumber.configuration;
347464

348465
// Cleanup external resources if they exist
349-
if (config) {
350-
try {
351-
// 1. Delete LiveKit trunk
352-
if (phoneNumber.livekit_outbound_trunk_id) {
353-
const livekitService = new LiveKitSipConfigService();
354-
await livekitService.deleteTrunk(phoneNumber.livekit_outbound_trunk_id);
355-
}
466+
try {
467+
const livekitService = new LiveKitSipConfigService();
356468

357-
// 2. Delete Twilio trunk
358-
if (config.twilioTrunkSid && config.accountSid && config.authToken) {
359-
const twilioService = new TwilioSipTrunkService(config.accountSid, config.authToken);
360-
await twilioService.deleteSipTrunk(config.twilioTrunkSid);
361-
}
362-
} catch (cleanupError) {
363-
console.error('Error cleaning up external resources:', cleanupError);
364-
// Continue with deletion even if cleanup fails
469+
// 1. Delete inbound dispatch rule
470+
if (phoneNumber.livekit_dispatch_rule_id) {
471+
await livekitService.deleteDispatchRule(phoneNumber.livekit_dispatch_rule_id);
472+
}
473+
474+
// 2. Delete inbound trunk
475+
if (phoneNumber.livekit_inbound_trunk_id) {
476+
await livekitService.deleteTrunk(phoneNumber.livekit_inbound_trunk_id);
477+
}
478+
479+
// 3. Delete outbound trunk
480+
if (phoneNumber.livekit_outbound_trunk_id) {
481+
await livekitService.deleteTrunk(phoneNumber.livekit_outbound_trunk_id);
482+
}
483+
484+
// 4. Delete Twilio trunk
485+
if (config && config.twilioTrunkSid && config.accountSid && config.authToken) {
486+
const twilioService = new TwilioSipTrunkService(config.accountSid, config.authToken);
487+
await twilioService.deleteSipTrunk(config.twilioTrunkSid);
365488
}
489+
} catch (cleanupError) {
490+
console.error('Error cleaning up external resources:', cleanupError);
491+
// Continue with deletion even if cleanup fails
366492
}
367493

368494
// Delete from database

apps/backend/src/services/livekitSipConfigService.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { SipClient } from 'livekit-server-sdk';
2+
import { RoomConfiguration, RoomAgentDispatch } from '@livekit/protocol';
23
import { ElasticTrunkResult } from './twilioSipTrunkService';
34

45
interface CreateTwilioTrunkParams {
@@ -110,6 +111,81 @@ export class LiveKitSipConfigService {
110111
}
111112
}
112113

114+
async createInboundTrunk(params: {
115+
phoneNumber: string;
116+
name?: string;
117+
}) {
118+
const { phoneNumber, name } = params;
119+
const safePhoneNumber = phoneNumber.replace(/[^\w]/g, '');
120+
121+
try {
122+
console.log(`Creating LiveKit SIP Inbound Trunk for ${phoneNumber}`);
123+
124+
const trunk = await this.sipClient.createSipInboundTrunk(
125+
name || `Inbound-${safePhoneNumber}`,
126+
[phoneNumber],
127+
{
128+
krispEnabled: true,
129+
}
130+
);
131+
132+
console.log(`LiveKit Inbound Trunk created: ${trunk.sipTrunkId}`);
133+
return { inboundTrunkId: trunk.sipTrunkId };
134+
} catch (error) {
135+
console.error('Error creating LiveKit SIP Inbound Trunk:', error);
136+
throw error;
137+
}
138+
}
139+
140+
async createInboundDispatchRule(params: {
141+
inboundTrunkId: string;
142+
agentName: string;
143+
agentId: string;
144+
}) {
145+
const { inboundTrunkId, agentName, agentId } = params;
146+
147+
try {
148+
console.log(`Creating SIP dispatch rule for trunk ${inboundTrunkId}, agent ${agentId}`);
149+
150+
const dispatchRule = await this.sipClient.createSipDispatchRule(
151+
{
152+
type: 'individual',
153+
roomPrefix: 'inbound-',
154+
},
155+
{
156+
name: `Inbound-dispatch-${agentId}`,
157+
trunkIds: [inboundTrunkId],
158+
roomConfig: new RoomConfiguration({
159+
agents: [
160+
new RoomAgentDispatch({
161+
agentName: agentName,
162+
metadata: JSON.stringify({
163+
agentId,
164+
conversationType: 'inbound',
165+
}),
166+
}),
167+
],
168+
}),
169+
}
170+
);
171+
172+
console.log(`SIP dispatch rule created: ${dispatchRule.sipDispatchRuleId}`);
173+
return { dispatchRuleId: dispatchRule.sipDispatchRuleId };
174+
} catch (error) {
175+
console.error('Error creating SIP dispatch rule:', error);
176+
throw error;
177+
}
178+
}
179+
180+
async deleteDispatchRule(dispatchRuleId: string) {
181+
try {
182+
await this.sipClient.deleteSipDispatchRule(dispatchRuleId);
183+
console.log(`SIP dispatch rule deleted: ${dispatchRuleId}`);
184+
} catch (error) {
185+
console.error('Error deleting SIP dispatch rule:', error);
186+
}
187+
}
188+
113189
async createSipOutboundCall(params: {
114190
trunkId: string;
115191
roomName: string;

apps/backend/src/services/twilioSipTrunkService.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,15 @@ export class TwilioSipTrunkService {
5656

5757
const phoneNumberResource = phoneNumbers[0];
5858

59-
// Check if already assigned to a trunk
59+
// Update phone number to route voice calls via SIP trunk
60+
console.log(`📞 Configuring phone number to use SIP trunk for voice...`);
61+
await client.incomingPhoneNumbers(phoneNumberResource.sid).update({
62+
trunkSid: trunk.sid,
63+
voiceUrl: '', // Clear voice URL to use trunk routing
64+
});
65+
console.log(`✅ Phone number configured to route calls via trunk`);
66+
67+
// Verify phone number is assigned to trunk
6068
const existingPhoneNumbers = await client.trunking.v1
6169
.trunks(trunk.sid)
6270
.phoneNumbers.list();
@@ -65,15 +73,11 @@ export class TwilioSipTrunkService {
6573
(pn: any) => pn.sid === phoneNumberResource.sid
6674
);
6775

68-
if (!alreadyAssigned) {
69-
await client.trunking.v1
70-
.trunks(trunk.sid)
71-
.phoneNumbers.create({
72-
phoneNumberSid: phoneNumberResource.sid,
73-
});
76+
if (alreadyAssigned) {
7477
console.log(`✅ Phone number assigned to trunk`);
7578
} else {
76-
console.log(`✅ Phone number already assigned to trunk`);
79+
// This should not happen if the update above succeeded
80+
console.warn(`⚠️ Phone number not found in trunk after assignment`);
7781
}
7882

7983
// Step 2: Create SIP Credentials for this trunk
@@ -108,7 +112,32 @@ export class TwilioSipTrunkService {
108112

109113
console.log(`✅ Credential list associated with trunk`);
110114

115+
// Step 3: Configure Origination (inbound calls from Twilio to LiveKit)
116+
console.log(`\n📞 Configuring Origination URL for inbound calls...`);
117+
118+
// Get LiveKit SIP endpoint from environment
119+
const livekitSipEndpoint = process.env.LIVEKIT_SIP_ENDPOINT;
120+
const originationUri = `sip:${livekitSipEndpoint}`;
121+
122+
try {
123+
// Create Origination URL
124+
await client.trunking.v1
125+
.trunks(trunk.sid)
126+
.originationUrls.create({
127+
friendlyName: 'LiveKit Inbound',
128+
sipUrl: originationUri,
129+
priority: 10,
130+
weight: 10,
131+
enabled: true,
132+
});
133+
console.log(`✅ Origination URL configured: ${originationUri}`);
134+
} catch (originationError: any) {
135+
console.error(`⚠️ Failed to configure Origination:`, originationError.message);
136+
// Continue anyway - trunk is created, user can configure manually
137+
}
138+
111139
console.log(`\n✅ Twilio Elastic SIP Trunk fully configured!`);
140+
console.log(` ⚡ Outbound calls: ${domainName}`);
112141

113142
return {
114143
trunkSid: trunk.sid,

apps/frontend/src/api/client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ export const phoneNumbersApi = {
7878
return response.data;
7979
},
8080

81+
updateInbound: async (id: string, data: { agentId: string | null; fallbackNumber?: string }) => {
82+
const response = await client.put(`/phone-numbers/${id}/inbound`, data);
83+
return response.data;
84+
},
85+
8186
delete: async (id: string) => {
8287
await client.delete(`/phone-numbers/${id}`);
8388
}

0 commit comments

Comments
 (0)