create-twilio-agent
Version:
Create a new Twilio agent with a single command
690 lines (574 loc) • 22.1 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
async function generateRoutes(projectPath, config) {
const routesDir = path.join(projectPath, 'src', 'routes');
await fs.ensureDir(routesDir);
// Generate routeNames.ts
const routeNamesTemplate = `export const routeNames = {
call: 'call',
conversationRelay: 'conversation-relay',
liveAgent: 'live-agent',
sms: 'text',
outboundCall: 'outbound-call',
stats: 'stats',
activeNumbers: 'active-numbers',
outboundMessage: 'outbound-message',
liveNumbers: 'live-numbers',
} as const;
export type RouteNames = typeof routeNames[keyof typeof routeNames];
`;
await fs.writeFile(path.join(routesDir, 'routeNames.ts'), routeNamesTemplate);
// Generate call.ts
const callRouteTemplate = `import { Router } from 'express';
import { twiml } from 'twilio';
import axios from 'axios';
import { languages } from '../lib/config/languages';
import { log } from '../lib/utils/logger';
const router = Router();
router.get('/call', async (req: any, res: any) => {
await handleCallRequest(req, res);
});
router.post('/call', async (req: any, res: any) => {
await handleCallRequest(req, res);
});
async function handleCallRequest(req: any, res: any) {
const env = process.env.NODE_ENV;
const isProduction = env === 'production';
const {
From: fromNumber,
To: toNumber,
Direction: direction,
CallSid: callSid,
} = req.query as { From?: string; To?: string; Direction?: string; CallSid?: string };
// For outbound calls, use the "To" number as the caller number
let callerNumber: string;
if (direction && direction.includes('outbound')) {
callerNumber = toNumber || '';
console.log('Outbound call detected. Using "To" number as caller: ' + callerNumber);
} else {
callerNumber = fromNumber || '';
console.log('Inbound call detected. Using "From" number as caller: ' + callerNumber);
}
// Track the start of the call conversation
if (process.env.SEGMENT_WRITE_KEY) {
try {
await axios.post(
'https://api.segment.io/v1/track',
{
userId: callerNumber,
event: 'Conversation Started',
properties: {
type: 'voice',
phoneNumber: callerNumber,
direction: direction || 'inbound',
timestamp: new Date().toISOString(),
},
},
{
headers: {
'Content-Type': 'application/json',
Authorization: 'Basic ' + Buffer.from((process.env.SEGMENT_WRITE_KEY || '') + ':').toString('base64'),
},
}
);
} catch (trackError) {
log.error({
label: 'call',
message: 'Failed to track call conversation start',
data: trackError,
});
}
}
// action endpoint will be executed when action is dispatched to the ConversationRelay websocket
const baseActionUrl = isProduction
? 'https://' + process.env.LIVE_HOST_URL + '/live-agent'
: 'https://' + (process.env.NGROK_URL || req.get('host')) + '/live-agent';
const relayUrl = isProduction
? \`wss://\${process.env.LIVE_HOST_URL}/conversation-relay?callSid=\${callSid || 'unknown'}&from=\${fromNumber || 'unknown'}&to=\${toNumber || 'unknown'}&direction=\${direction || 'unknown'}\`
: \`wss://\${process.env.NGROK_URL || req.get('host')}/conversation-relay?callSid=\${callSid || 'unknown'}&from=\${fromNumber || 'unknown'}&to=\${toNumber || 'unknown'}&direction=\${direction || 'unknown'}\`;
console.log('🔧 Relay URL:', relayUrl);
console.log('🔧 NGROK_URL:', process.env.NGROK_URL);
console.log('🔧 LIVE_HOST_URL:', process.env.LIVE_HOST_URL);
const twilioTwiml = new twiml.VoiceResponse();
// Connect to ConversationRelay
const connect = twilioTwiml.connect({
action: \`\${baseActionUrl}?method=POST\`,
});
// Define comprehensive parameters for the ConversationRelay
const relayParams = {
url: relayUrl,
voice: 'g6xIsTj2HwM6VR4iXFCw', // Default ElevenLabs voice for English
transcriptionProvider: 'Deepgram', // Primary transcription provider
ttsProvider: 'ElevenLabs', // Text-to-Speech provider
speechModel: 'nova-2-general', // Speech model for transcription
dtmfDetection: true, // DTMF detection enabled
debug: 'true', // Debugging enabled for troubleshooting (string type)
intelligenceService: process.env.TWILIO_CONVERSATIONAL_INTELLIGENCE_SID, // TWILIO_CONVERSATIONAL_INTELLIGENCE_SID
};
const conversationRelay = connect.conversationRelay(relayParams);
console.log('✅ ConversationRelay created successfully');
// Configure supported languages for TwiML
try {
// Filter to working languages (those with proper Twilio configuration)
const workingLanguages = languages.filter(
(lang) =>
lang.value === 'en-US' ||
lang.value === 'de-DE' ||
lang.value === 'fr-FR' ||
lang.value === 'es-ES' ||
lang.value === 'pt-BR' ||
lang.value === 'ja-JP' ||
lang.value === 'hi-IN' ||
lang.value === 'nl-NL' ||
lang.value === 'it-IT' ||
lang.value === 'zh-CN'
);
// Configure each language individually
let configuredCount = 0;
let failedCount = 0;
workingLanguages.forEach((language, index) => {
try {
// Build language configuration with detailed settings
const languageConfig = {
code: language.value,
ttsProvider: relayParams.ttsProvider, // Use default from relayParams
transcriptionProvider: relayParams.transcriptionProvider, // Use default from relayParams
speechModel: relayParams.speechModel, // Use default from relayParams
voice: language.twilioConfig.voice || relayParams.voice,
};
// Add language to ConversationRelay
conversationRelay.language(languageConfig);
configuredCount++;
} catch (languageError) {
failedCount++;
console.error(\`❌ Failed to configure language \${language.value}:\`, languageError);
}
});
console.log(\`📊 Language configuration summary:\`);
console.log(\` - Successfully configured: \${configuredCount} languages\`);
console.log(\` - Failed: \${failedCount} languages\`);
console.log(\` - Total attempted: \${workingLanguages.length} languages\`);
if (configuredCount === 0) {
throw new Error('No languages were successfully configured');
}
} catch (error) {
console.error('❌ Critical error during language configuration:', error);
// Fallback to English-only configuration
console.log('🔄 Implementing fallback: English-only configuration...');
try {
const fallbackConfig = {
code: 'en-US',
ttsProvider: 'ElevenLabs',
voice: 'g6xIsTj2HwM6VR4iXFCw',
transcriptionProvider: 'Deepgram',
speechModel: 'nova-2-general',
};
console.log('🔄 Fallback language config:', fallbackConfig);
conversationRelay.language(fallbackConfig);
console.log('✅ Fallback English configuration applied successfully');
} catch (fallbackError) {
console.error('💥 CRITICAL: Even fallback configuration failed:', fallbackError);
// Continue anyway - let Twilio handle with defaults
}
}
// Send response
res.type('text/xml');
res.send(twilioTwiml.toString());
console.log('📤 TwiML response sent successfully');
}
export default router;
`;
await fs.writeFile(path.join(routesDir, 'call.ts'), callRouteTemplate);
// Generate conversationRelay.ts
const conversationRelayTemplate = `import ExpressWs from 'express-ws';
import WebSocket from 'ws';
import { LLMService } from '../llm';
import { getLocalTemplateData } from '../lib/utils/llm/getTemplateData';
import { log } from '../lib/utils/logger';
// Store active conversations
const activeConversations = new Map<string, {
ws: WebSocket;
llm: LLMService | null;
ttl: number;
targetWorker?: string;
}>();
// Store phone logs
const phoneLogs = new Map<string, any[]>();
// Store recent activity
const recentActivity = new Map<string, {
phoneNumber: string;
lastActivity: Date;
isActive: boolean;
}>();
// TTL cleanup interval - runs every 10 minutes
setInterval(() => {
const totalConnections = activeConversations.size;
console.log('Starting cleanup check - ' + totalConnections + ' active conversations');
let expiredCount = 0;
activeConversations.forEach((conversation, phoneNumber) => {
if (conversation.ttl < Date.now()) {
console.log('Closing expired conversation for ' + phoneNumber);
conversation.ws?.close();
activeConversations.delete(phoneNumber);
const existingActivity = recentActivity.get(phoneNumber);
if (existingActivity) {
existingActivity.isActive = false;
}
expiredCount++;
}
});
console.log('Cleanup complete - removed ' + expiredCount + ' expired conversations');
}, 10 * 60 * 1000);
export const setupConversationRelayRoute = (app: ExpressWs.Application) => {
app.ws('/conversation-relay', (ws) => {
let phoneNumber: string = 'unknown';
let llm: LLMService;
console.log('WebSocket connection established');
// Create a simple wrapper to mimic TypedWs.end() behavior
const wss = {
send: (data: any) => ws.send(JSON.stringify(data)),
end: (handoffData?: Record<string, any>) => {
// Send end message with handoff data (like ramp-agent's TypedWs)
ws.send(JSON.stringify({
type: 'end',
handoffData: JSON.stringify(handoffData ?? {})
}));
},
close: () => ws.close()
};
ws.on('message', async (message) => {
try {
const data = JSON.parse(message.toString());
log.info({
label: 'conversation',
phone: phoneNumber,
message: 'Received message',
data: data.type
});
// Handle different message types from Twilio ConversationRelay
if (data.type === 'setup') {
// Extract call parameters from setup message
const { from, to, direction, callSid } = data;
if (direction && direction.includes('outbound')) {
// For outbound calls, the customer is the 'to' number
phoneNumber = to;
console.log('Outbound call detected. Customer number: ' + phoneNumber + ', Twilio number: ' + from);
} else {
// For inbound calls, the customer is the 'from' number
phoneNumber = from;
console.log('Inbound call detected. Customer number: ' + phoneNumber + ', Twilio number: ' + to);
}
// Get template data and initialize LLM service
const templateData = await getLocalTemplateData();
llm = new LLMService(phoneNumber, templateData);
// Set call context with callSid (like ramp-agent does)
await llm.setCallContext(from, to, direction, callSid);
// Store the connection
activeConversations.set(phoneNumber, {
ws,
llm,
ttl: Date.now() + (30 * 60 * 1000), // 30 minutes TTL
targetWorker: process.env.TWILIO_FLEX_WORKER_SID
});
// Set up LLM event handlers
llm.on('text', (chunk: string, isFinal: boolean, fullText?: string) => {
// Send text token to Twilio ConversationRelay
wss.send({
type: 'text',
token: chunk,
last: isFinal
});
});
llm.on('handoff', (data: any) => {
// Get the stored conversation to access targetWorker
const conversation = activeConversations.get(phoneNumber);
const enhancedData = {
...data,
targetWorker: conversation?.targetWorker || process.env.TWILIO_FLEX_WORKER_SID || undefined,
};
console.log('🔄 Initiating live agent handoff:', enhancedData);
console.log('📞 Call status: Transferring to live agent');
// Use wss.end() like ramp-agent (triggers Connect verb)
wss.end(enhancedData);
});
llm.on('language', (data: any) => {
const languageMessage = {
type: 'language' as const,
ttsLanguage: data.ttsLanguage,
transcriptionLanguage: data.transcriptionLanguage,
};
console.log('Sending language message to Twilio:', languageMessage);
wss.send(languageMessage);
});
// Start the conversation
llm.isVoiceCall = true;
console.log('Starting conversation for ' + phoneNumber);
await llm.notifyInitialCallParams();
await llm.run();
} else if (data.type === 'message') {
// User speech message
if (!llm) {
console.log('LLM not initialized yet, ignoring message');
return;
}
llm.addMessage({
role: 'user',
content: data.content || data.message || ''
});
await llm.run();
} else if (data.type === 'interrupt') {
// Handle interruption
if (!llm) {
console.log('LLM not initialized yet, ignoring interrupt');
return;
}
console.log('Interrupting conversation for ' + phoneNumber);
// Optionally restart the LLM response
await llm.run();
} else if (data.type === 'dtmf') {
// Handle DTMF tones
if (!llm) {
console.log('LLM not initialized yet, ignoring DTMF');
return;
}
console.log('DTMF received: ' + data.digit);
llm.addMessage({
role: 'user',
content: \`DTMF: \${data.digit}\`
});
await llm.run();
} else if (data.type === 'prompt') {
// User speech prompt from Twilio ConversationRelay
if (!llm) {
console.log('LLM not initialized yet, ignoring prompt');
return;
}
console.log('Received voice prompt:', data.voicePrompt);
llm.addMessage({
role: 'user',
content: data.voicePrompt || ''
});
await llm.run();
} else if (data.type === 'info') {
// Handle info messages (heartbeat/status updates from Twilio)
console.log('📊 Info message from Twilio for ' + phoneNumber + ':', {
timestamp: new Date().toISOString(),
data: data
});
} else if (data.type === 'error') {
console.error('Error from Twilio:', data.description);
} else {
console.log('Unhandled message type:', data.type);
}
} catch (error) {
log.error({
label: 'conversation',
phone: phoneNumber,
message: 'Error processing message',
data: error
});
}
});
ws.on('close', () => {
console.log('WebSocket connection closed for ' + phoneNumber);
activeConversations.delete(phoneNumber);
const existingActivity = recentActivity.get(phoneNumber);
if (existingActivity) {
existingActivity.isActive = false;
}
});
ws.on('error', (error) => {
console.error('WebSocket error for ' + phoneNumber + ':', error);
activeConversations.delete(phoneNumber);
});
});
};
export { activeConversations, phoneLogs, recentActivity };
`;
await fs.writeFile(
path.join(routesDir, 'conversationRelay.ts'),
conversationRelayTemplate
);
// Generate other routes
const otherRoutes = {
sms: `import { Router } from 'express';
import twilio from 'twilio';
// Local imports
import { getLocalTemplateData } from '../lib/utils/llm/getTemplateData';
import { activeConversations } from './conversationRelay';
import { LLMService } from '../llm';
import { routeNames } from './routeNames';
import { trackMessage } from '../lib/utils/trackMessage';
const router = Router();
router.post(\`/\${routeNames.sms}\`, async (req: any, res: any) => {
try {
const callType =
req.body.To.includes('whatsapp:') || req.body.From.includes('whatsapp:')
? 'whatsapp'
: 'sms';
const { From: from, Body: body, To: to } = req.body;
// Validate required fields at the top
if (!from || !body) {
console.error('Missing required fields:', { from, body });
return res.status(400).send('Missing required fields');
}
console.log('Received SMS from ' + from + ': ' + body);
// Track inbound message
await trackMessage({
userId: from,
callType: 'sms',
phoneNumber: from,
label: 'sms',
direction: 'inbound',
event: 'Text Interaction',
});
// Check if there's an active conversation for this number
const conversation = activeConversations.get(from);
if (conversation && conversation.llm) {
const { llm } = conversation;
// Add message to conversation history
llm.addMessage({
role: 'user',
content: body,
});
// Process with LLM
await llm.run();
} else {
// Create new conversation for this number
const templateData = await getLocalTemplateData();
const llm = new LLMService(from, templateData);
// Reset voice call flag for SMS conversations
llm.isVoiceCall = false;
// Store the conversation
activeConversations.set(from, {
ws: null as any, // No WebSocket for SMS-only conversations
llm,
ttl: Date.now() + 60 * 60 * 1000, // TTL: current time + 1 hour
});
llm.addMessage({
role: 'system',
content: \`The customer's phone number is \${from}.
The agent's phone number is \${to}.
This is an \${callType} conversation.\`,
});
// Add user's message and start conversation
llm.addMessage({
role: 'user',
content: body,
});
await llm.run();
// Track the start of the text conversation (only for new conversations)
await trackMessage({
userId: from,
callType: 'sms',
phoneNumber: from,
label: 'sms',
direction: 'inbound',
event: 'Conversation Started',
});
}
// Send TwiML response
const twiml = new twilio.twiml.MessagingResponse();
res.type('text/xml');
return res.send(twiml.toString());
} catch (error: any) {
console.error('SMS error:', error);
return res.status(500).send('Error processing message');
}
});
export default router;`,
liveAgent: `import { Router } from 'express';
import { twiml } from 'twilio';
const router = Router();
router.post('/live-agent', async (req: any, res: any) => {
const { From: from, To: to, Direction: direction } = req.body;
const customerNumber = direction?.includes('outbound') ? to : from;
console.log('Received live agent request:', { from, to, direction, customerNumber });
const twilioTwiml = new twiml.VoiceResponse();
if (process.env.TWILIO_WORKFLOW_SID) {
const enqueue = twilioTwiml.enqueue({
workflowSid: process.env.TWILIO_WORKFLOW_SID
});
enqueue.task(JSON.stringify({
name: customerNumber,
handoffReason: 'Customer requested live agent',
reasonCode: 'live_agent_request',
conversationSummary: 'Customer transferred to live agent'
}));
} else {
twilioTwiml.say('Please hold while we transfer you to a live agent.');
twilioTwiml.hangup();
}
res.type('text/xml');
res.send(twilioTwiml.toString());
});
export default router;`,
outboundCall: `import { Router } from 'express';
const router = Router();
router.get('/outbound-call', async (req: any, res: any) => {
try {
res.json({ message: 'Outbound call endpoint' });
} catch (error: any) {
console.error('Outbound call error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;`,
stats: `import { Router } from 'express';
import { activeConversations, recentActivity } from './conversationRelay';
const router = Router();
router.get('/stats', async (req: any, res: any) => {
try {
const stats = {
activeConversations: activeConversations.size,
totalRecentActivity: recentActivity.size,
timestamp: new Date().toISOString()
};
res.json(stats);
} catch (error: any) {
console.error('Stats error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;`,
activeNumbers: `import { Router } from 'express';
const router = Router();
router.get('/active-numbers', async (req: any, res: any) => {
try {
res.json({ message: 'Active numbers endpoint' });
} catch (error: any) {
console.error('Active numbers error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;`,
outboundMessage: `import { Router } from 'express';
const router = Router();
router.get('/outbound-message', async (req: any, res: any) => {
try {
res.json({ message: 'Outbound message endpoint' });
} catch (error: any) {
console.error('Outbound message error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;`,
liveNumbers: `import { Router } from 'express';
const router = Router();
router.get('/live-numbers', async (req: any, res: any) => {
try {
res.json({ message: 'Live numbers endpoint' });
} catch (error: any) {
console.error('Live numbers error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;`,
};
// Generate other route files
for (const [routeName, template] of Object.entries(otherRoutes)) {
const fileName = routeName + '.ts';
await fs.writeFile(path.join(routesDir, fileName), template);
}
}
module.exports = { generateRoutes };