Spaces:
Sleeping
Sleeping
| 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<sessionId, Set<writer>> | |
| 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}`) | |
| ) | |