import 'dotenv/config' import http from 'http' import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const PORT = process.env.PORT || 7860 const WEBHOOK_URL = process.env.WEBHOOK_URL || `http://localhost:${PORT}` const FROM_NUMBER = process.env.FROM_NUMBER || '' const USER_API_KEY = process.env.USER_API_KEY || '' const FLOW_API_KEY = process.env.FLOW_API_KEY || '' // Session-aware SSE subscribers: Map> const sessions = new Map() function getSession(id) { if (!sessions.has(id)) sessions.set(id, new Set()) return sessions.get(id) } function removeWriter(id, writer) { const s = sessions.get(id) if (!s) return s.delete(writer) if (s.size === 0) sessions.delete(id) } // Load call agent assets once at startup const CALL_BASE = process.env.CALL_BASE || path.resolve(__dirname, 'assets') let workflowTemplate, universityPrompt, realestatePrompt, coachingPrompt, medicalPrompt, hotelPrompt try { workflowTemplate = JSON.parse(fs.readFileSync(`${CALL_BASE}/jsons/restraunts/workflow.json`, 'utf8')) universityPrompt = fs.readFileSync(`${CALL_BASE}/prompts/college/nims_admission_eng.md`, 'utf8') realestatePrompt = fs.readFileSync(`${CALL_BASE}/prompts/realestate/apartment_sale_eng.md`, 'utf8') coachingPrompt = fs.readFileSync(`${CALL_BASE}/prompts/coaching/coaching_admission_eng.md`, 'utf8') medicalPrompt = fs.readFileSync(`${CALL_BASE}/prompts/medical/opd_reception_eng.md`, 'utf8') hotelPrompt = fs.readFileSync(`${CALL_BASE}/prompts/hotel/concierge_prearrival_eng.md`, 'utf8') console.log('call agent assets loaded') } catch (e) { console.warn('could not load call agent assets:', e.message) } // Each agent's prompt + greeting use a {{BRAND}} placeholder. The caller can // override the brand from the UI; otherwise defaultBrand is substituted in. const CALL_CONFIG = { university: { prompt: universityPrompt, defaultBrand: 'NIMS University', greeting: 'Hello! Thank you for calling {{BRAND}}, this is Sneha from the admissions team. May I know your name, and which course you are interested in?', }, realestate: { prompt: realestatePrompt, defaultBrand: 'Skyline Greens', greeting: 'Hello! Main Riya bol rahi hoon {{BRAND}} ki taraf se — aapne recently ek flat ke liye inquiry ki thi. Kya abhi thodi baat ho sakti hai?', }, coaching: { prompt: coachingPrompt, defaultBrand: 'Bansal Classes', greeting: 'Namaste! Main Aarohi baat kar rahi hoon {{BRAND}} se — aapne abhi coaching ke liye enquiry ki thi na? Kya do minute baat ho sakti hai?', }, medical: { prompt: medicalPrompt, defaultBrand: 'Sanjeevani Hospital', greeting: 'Namaste, {{BRAND}} me aapka swagat hai, main Meera baat kar rahi hoon. Aapki kya help kar sakti hoon?', }, hotel: { prompt: hotelPrompt, defaultBrand: 'The Grand Rambagh', greeting: 'Namaste! Main Kabir baat kar raha hoon {{BRAND}} se — aapki booking confirm hai, bas aapka welcome ready karne ke liye call kiya. Do minute baat ho sakti hai?', }, } // Static frontend assets const distPath = path.resolve(__dirname, 'dist') let indexHtml = null try { indexHtml = fs.readFileSync(path.join(distPath, 'index.html'), 'utf8') } catch {} const MIME = { '.html': 'text/html', '.js': 'application/javascript', '.mjs': 'application/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json', '.woff': 'font/woff', '.woff2': 'font/woff2', } const server = http.createServer(async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') res.setHeader('Access-Control-Allow-Headers', '*') if (req.method === 'OPTIONS') { res.writeHead(204) res.end() return } // Webhook receiver — VX backend POSTs here with session_id in payload if (req.method === 'POST' && req.url === '/') { let body = '' for await (const chunk of req) body += chunk try { const data = JSON.parse(body) const sessionId = data.session_id ?? data.responses?.find(r => r.session_id)?.session_id const clients = sessionId ? sessions.get(sessionId) : null console.log(`[webhook] resolved session=${sessionId} subscribers=${clients?.size ?? 0}`) console.log('[webhook] payload keys:', Object.keys(data), '| responses count:', data.responses?.length ?? 'none') if (data.responses?.length) { console.log('[webhook] response nodes:', data.responses.map(r => `node=${r.node} streaming=${r.streaming} hasChunk=${!!r.chunk} hasOut=${!!r.out}`).join(' | ')) } if (clients?.size) { for (const write of clients) write(data) } } catch (e) { console.error('[webhook] parse error:', e.message) console.error('[webhook] raw body snippet:', body.slice(0, 300)) } res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ok: true })) return } // SSE stream — browser subscribes by session if (req.method === 'GET' && req.url.startsWith('/events')) { const url = new URL(req.url, 'http://localhost') const sid = url.searchParams.get('session') if (!sid) { res.writeHead(400); res.end('missing session param'); return } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Accel-Buffering':'no', 'Connection': 'keep-alive', }) res.write(': connected\n\n') const write = data => res.write(`data: ${JSON.stringify(data)}\n\n`) const heartbeat = setInterval(() => res.write(': heartbeat\n\n'), 25000) getSession(sid).add(write) console.log(`[sse] client connected session=${sid} total=${sessions.get(sid)?.size}`) req.on('close', () => { removeWriter(sid, write) clearInterval(heartbeat) console.log(`[sse] client disconnected session=${sid}`) }) return } // Outbound call proxy — frontend POSTs here to initiate a call agent session if (req.method === 'POST' && req.url === '/call') { let body = '' for await (const chunk of req) body += chunk try { const { type, phone, session_id, voiceSettings, brand } = JSON.parse(body) const cfg = CALL_CONFIG[type] if (!cfg || !cfg.prompt) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: `unknown call type: ${type}` })) return } // Substitute the {{BRAND}} placeholder with the caller-supplied brand, // falling back to this agent's default. const brandName = (typeof brand === 'string' && brand.trim()) ? brand.trim() : cfg.defaultBrand const systemPrompt = cfg.prompt.split('{{BRAND}}').join(brandName) const greetingText = cfg.greeting.split('{{BRAND}}').join(brandName) // Deep-clone the workflow template and inject agent-specific fields const customs = JSON.parse(JSON.stringify(workflowTemplate)) customs.agent_id.workflow.nodes.llm.parameters.system_prompt = systemPrompt customs.agent_id.workflow.nodes.greeting.parameters.out_dict.speak = greetingText customs.agent_id['webhook-url'] = WEBHOOK_URL // Default STT / pre-fire / inactivity for all outbound calls customs['stt_id'] = { service: 'soniox', language: ['hi', 'en'] } customs['pre-fire'] = true customs['pre-fire-config'] = { min: 10, max: 5000, current: 40 } customs['inactivity'] = true customs['inactivity-metadata'] = { 'time-period': 1000, 'max-times': 3, 'inactivity-type': 'static', message: "Are you still there? No rush — take your time and answer whenever you're ready.", 'interruption-type': 'full', 'interruption-metadata': {}, } if (voiceSettings) { if (voiceSettings.tts_id) customs.tts_id = voiceSettings.tts_id if (voiceSettings.stt_id) customs.stt_id = voiceSettings.stt_id if (voiceSettings['grain-voice'] != null) customs['grain-voice'] = voiceSettings['grain-voice'] if (voiceSettings['grain-level'] != null) customs['grain-level'] = voiceSettings['grain-level'] if (voiceSettings['pre-fire'] != null) customs['pre-fire'] = voiceSettings['pre-fire'] if (voiceSettings['pre-fire-config']) customs['pre-fire-config'] = voiceSettings['pre-fire-config'] if (voiceSettings.inactivity != null) customs.inactivity = voiceSettings.inactivity if (voiceSettings['inactivity-metadata']) customs['inactivity-metadata'] = voiceSettings['inactivity-metadata'] } const payload = { 'from-number': FROM_NUMBER, 'to-number': phone, session_id, customs, } const upstream = await fetch('https://call.voxio.in/plivo/outbound', { method: 'POST', headers: { 'Content-Type': 'application/json', user_api_key: USER_API_KEY, flow_api_key: FLOW_API_KEY, }, body: JSON.stringify(payload), }) const result = await upstream.json() res.writeHead(upstream.status, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(result)) } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: e.message })) } return } // Static files + SPA fallback if (req.method === 'GET') { const urlPath = req.url.split('?')[0] const filePath = path.join(distPath, urlPath) try { const stat = fs.statSync(filePath) if (stat.isFile()) { const ext = path.extname(filePath).toLowerCase() const mime = MIME[ext] || 'application/octet-stream' res.writeHead(200, { 'Content-Type': mime }) fs.createReadStream(filePath).pipe(res) return } } catch {} if (indexHtml) { res.writeHead(200, { 'Content-Type': 'text/html' }) res.end(indexHtml) return } } res.writeHead(404) res.end() }) server.listen(PORT, '0.0.0.0', () => console.log(`webhook+sse server on :${PORT}`) )