const express = require('express'); const twilio = require('twilio'); const axios = require('axios'); const fs = require('fs'); const app = express(); app.use(express.urlencoded({ extended: true })); app.use(express.json()); const PORT = process.env.PORT || 3000; const TWILIO_SID = 'ACa8da3932243665014dd53866dd93420c'; const TWILIO_AUTH = '506838b9c5355b07bb2caa278aecc7ae'; const ELEVENLABS_KEY = 'sk_ad5d7ff77dcd365ed6c492f50cd1f71b97c81fb4e8b19ef0'; const BRIAN_VOICE_ID = 'Afeo3giYvnyOZXdF5ct9'; const PINK_PULSE_NUMBER = '+12134182600'; const RYAN_CELL = '+18632382563'; const JOSH_CELL = '+19547067488'; const AI_NODE = 'http://10.10.0.67:8765'; const AI_KEY = 'b1ca95570932bd14ae75426095f436912bb88e6b217712f0811e6501c68df465'; const AI_MODEL = 'deepseek-r1:latest'; const conversations = {}; const pending = {}; const audioCache = {}; const PROPS = { '+12133547300': { name: 'broadcastbeat', greeting: 'Thank you for calling Broadcast Beat. How can I help you today?', system: 'You are the AI receptionist for Broadcast Beat. Be professional. Keep responses under 40 words.' }, '+12136529233': { name: 'avbeat', greeting: 'Thank you for calling AV Beat. How can I help you today?', system: 'You are the AI receptionist for AV Beat. Be professional. Keep responses under 40 words.' }, '+12132977880': { name: 'onsethost', greeting: 'Thank you for calling OnSetHost. How can I help you today?', system: 'You are the AI receptionist for OnSetHost, web hosting for production professionals. Keep responses under 40 words.' } }; const twilioClient = twilio(TWILIO_SID, TWILIO_AUTH); function isBusinessHours() { const now = new Date(); const et = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' })); const day = et.getDay(); const hour = et.getHours(); return day >= 1 && day <= 5 && hour >= 9 && hour < 17; } async function generateAudio(text, cacheKey) { if (audioCache[cacheKey]) return audioCache[cacheKey]; try { const response = await axios({ method: 'post', url: 'https://api.elevenlabs.io/v1/text-to-speech/' + BRIAN_VOICE_ID, data: { text: text, model_id: 'eleven_flash_v2_5', voice_settings: { stability: 0.3, similarity_boost: 0.75, style: 0.5, use_speaker_boost: true } }, headers: { 'xi-api-key': ELEVENLABS_KEY, 'Content-Type': 'application/json' }, responseType: 'arraybuffer' }); fs.mkdirSync('/opt/voiceai/audio', { recursive: true }); fs.writeFileSync('/opt/voiceai/audio/' + cacheKey + '.mp3', response.data); const url = 'https://voice.onsethost.com/audio/' + cacheKey + '.mp3'; audioCache[cacheKey] = url; console.log('Audio generated:', url); return url; } catch (e) { console.error('ElevenLabs error:', e.message, e.response && e.response.data ? Buffer.from(e.response.data).toString() : ''); return null; } } async function askAI(prompt) { try { const r = await axios.post(AI_NODE + '/queue/add', { prompt: prompt, model: AI_MODEL, site: 'voiceai', job_type: 'text' }, { headers: { 'x-api-key': AI_KEY }, timeout: 5000 }); const jobId = r.data.job_id; console.log('Job submitted:', jobId); for (let i = 0; i < 120; i++) { await new Promise(r => setTimeout(r, 500)); const j = await axios.get(AI_NODE + '/jobs', { headers: { 'x-api-key': AI_KEY }, timeout: 3000 }); const job = (j.data.jobs || []).find(x => x.job_id === jobId); if (job && job.status === 'done' && job.result) { return job.result.replace(/```json|```/g, '').trim(); } } } catch (e) { console.error('AI error:', e.message); } return null; } async function sendSMS(to, message) { try { await twilioClient.messages.create({ body: message, from: PINK_PULSE_NUMBER, to: to }); console.log('SMS sent to', to); } catch (e) { console.error('SMS error:', e.message); } } app.use('/audio', express.static('/opt/voiceai/audio')); app.post('/pinkpulse', async (req, res) => { const sid = req.body.CallSid; conversations[sid] = { department: null, history: [], callerNumber: req.body.From }; console.log('Pink Pulse call from', req.body.From, sid); const greetingText = "Thanks for calling The Pink Pulse, covering Entertainment news and information for over 138 gay communities worldwide! I'm Brian, your AI assistant. Press 1 for Advertising and Sponsorships. Press 2 for Editorial and Story Tips. Press 3 for Events and Partnerships. Press 4 for General Inquiries."; const audioUrl = await generateAudio(greetingText, 'pp_greeting'); const t = new twilio.twiml.VoiceResponse(); const g = t.gather({ numDigits: 1, action: 'https://voice.onsethost.com/pinkpulse/menu', method: 'POST', timeout: 10 }); if (audioUrl) { g.play(audioUrl); } else { g.say({ voice: 'Polly.Joanna-Neural' }, greetingText); } t.redirect({ method: 'POST' }, 'https://voice.onsethost.com/pinkpulse/menu?Digits=4'); res.type('text/xml'); res.send(t.toString()); }); app.post('/pinkpulse/menu', async (req, res) => { const sid = req.body.CallSid; const digit = req.body.Digits || '4'; const sess = conversations[sid] || { department: null, history: [], callerNumber: req.body.From }; const departments = { '1': 'Advertising and Sponsorships', '2': 'Editorial and Story Tips', '3': 'Events and Partnerships', '4': 'General Inquiries' }; const dept = departments[digit] || 'General Inquiries'; sess.department = dept; conversations[sid] = sess; console.log('Menu selection:', digit, dept); const t = new twilio.twiml.VoiceResponse(); if (isBusinessHours()) { console.log('Business hours - transferring to Ryan'); const transferText = 'Fabulous choice honey! Let me connect you with our team right now. One moment!'; const audioUrl = await generateAudio(transferText, 'pp_transfer_' + digit); if (audioUrl) t.play(audioUrl); else t.say({ voice: 'Polly.Joanna-Neural' }, transferText); const dial = t.dial({ action: 'https://voice.onsethost.com/pinkpulse/transfer?sid=' + sid + '&dept=' + encodeURIComponent(dept), method: 'POST', timeout: 20 }); dial.number(RYAN_CELL); } else { console.log('After hours - AI message taker'); t.redirect({ method: 'POST' }, 'https://voice.onsethost.com/pinkpulse/message?sid=' + sid + '&dept=' + encodeURIComponent(dept)); } res.type('text/xml'); res.send(t.toString()); }); app.post('/pinkpulse/transfer', async (req, res) => { const sid = req.query.sid; const dept = req.query.dept || 'General Inquiries'; const dialStatus = req.body.DialCallStatus; console.log('Transfer status:', dialStatus); const t = new twilio.twiml.VoiceResponse(); if (dialStatus === 'completed') { t.hangup(); } else { console.log('Transfer failed - going to AI message taker'); const sess = conversations[sid] || { department: dept, history: [], callerNumber: '' }; sess.history = []; conversations[sid] = sess; t.redirect({ method: 'POST' }, 'https://voice.onsethost.com/pinkpulse/message?sid=' + sid + '&dept=' + encodeURIComponent(dept)); } res.type('text/xml'); res.send(t.toString()); }); app.post('/pinkpulse/message', async (req, res) => { const sid = req.query.sid; const dept = req.query.dept || 'General Inquiries'; const sess = conversations[sid] || { department: dept, history: [], callerNumber: req.body.From || '' }; sess.history = []; conversations[sid] = sess; const messageText = "My apologies, but our team isn't available right his moment, but I'll personally make sure your message gets to the right person. Can I start with your name?"; const audioUrl = await generateAudio(messageText, 'pp_afterhours'); const t = new twilio.twiml.VoiceResponse(); const g = t.gather({ input: 'speech', action: 'https://voice.onsethost.com/pinkpulse/collect?sid=' + sid + '&dept=' + encodeURIComponent(dept), method: 'POST', speechTimeout: 'auto' }); if (audioUrl) g.play(audioUrl); else g.say({ voice: 'Polly.Joanna-Neural' }, messageText); res.type('text/xml'); res.send(t.toString()); }); app.post('/pinkpulse/collect', async (req, res) => { const sid = req.query.sid; const dept = req.query.dept || 'General Inquiries'; const speech = req.body.SpeechResult || ''; const sess = conversations[sid] || { department: dept, history: [], callerNumber: req.body.From || '' }; console.log('Collect speech:', speech); sess.history.push({ role: 'user', content: speech }); conversations[sid] = sess; const hist = sess.history.map(m => (m.role === 'user' ? 'Caller' : 'Brian') + ': ' + m.content).join('\n'); const prompt = 'You are Brian, the fabulous AI assistant for The Pink Pulse, an LGBTQ+ entertainment and nightlife media publication. You are warm, sassy, inclusive and fun. You are collecting a message from a caller about: ' + dept + '.\n\nYour job is to collect: 1) Their name 2) Their phone number 3) Their message or reason for calling.\n\nConversation so far:\n' + hist + '\n\nIf you have collected all 3 pieces of information, end your response with exactly [COMPLETE]. If not, ask for the next missing piece naturally and fabulously. Keep responses under 50 words. No markdown or special characters.'; pending[sid] = undefined; askAI(prompt).then(async reply => { if (!reply) reply = 'Could you repeat that for me darling?'; const isComplete = reply.includes('[COMPLETE]'); reply = reply.replace('[COMPLETE]', '').trim(); pending[sid] = { reply: reply, isComplete: isComplete, hist: hist + '\nBrian: ' + reply }; console.log('Brian reply ready:', reply.substring(0, 80), 'complete:', isComplete); }); const t = new twilio.twiml.VoiceResponse(); t.pause({ length: 1 }); t.redirect({ method: 'POST' }, 'https://voice.onsethost.com/pinkpulse/collect-check?sid=' + sid + '&dept=' + encodeURIComponent(dept) + '&attempt=0'); res.type('text/xml'); res.send(t.toString()); }); app.post('/pinkpulse/collect-check', async (req, res) => { const sid = req.query.sid; const dept = req.query.dept || 'General Inquiries'; const attempt = parseInt(req.query.attempt) || 0; const t = new twilio.twiml.VoiceResponse(); if (pending[sid] !== undefined && pending[sid] !== null && typeof pending[sid] === 'object') { const data = pending[sid]; delete pending[sid]; const audioUrl = await generateAudio(data.reply, 'pp_collect_' + sid + '_' + attempt); if (data.isComplete) { const sess = conversations[sid] || { callerNumber: '' }; const notification = 'New Pink Pulse message from ' + sess.callerNumber + '\nDepartment: ' + dept + '\n\nTranscript:\n' + data.hist; await sendSMS(RYAN_CELL, notification); await sendSMS(JOSH_CELL, notification); const byeText = 'Fabulous! I have got everything I need. Our team will be in touch soon. Thanks for calling The Pink Pulse gorgeous. Have a fierce day!'; const byeUrl = await generateAudio(byeText, 'pp_bye_' + sid); if (byeUrl) t.play(byeUrl); else t.say({ voice: 'Polly.Joanna-Neural' }, byeText); t.hangup(); } else { const sess = conversations[sid] || { department: dept, history: [], callerNumber: '' }; sess.history.push({ role: 'assistant', content: data.reply }); conversations[sid] = sess; const g = t.gather({ input: 'speech', action: 'https://voice.onsethost.com/pinkpulse/collect?sid=' + sid + '&dept=' + encodeURIComponent(dept), method: 'POST', speechTimeout: 'auto' }); if (audioUrl) g.play(audioUrl); else g.say({ voice: 'Polly.Joanna-Neural' }, data.reply); } } else if (attempt < 30) { t.pause({ length: 1 }); t.redirect({ method: 'POST' }, 'https://voice.onsethost.com/pinkpulse/collect-check?sid=' + sid + '&dept=' + encodeURIComponent(dept) + '&attempt=' + (attempt + 1)); } else { t.say({ voice: 'Polly.Joanna-Neural' }, 'Thank you for calling The Pink Pulse. Goodbye!'); t.hangup(); } res.type('text/xml'); res.send(t.toString()); }); app.post('/voice', async (req, res) => { const sid = req.body.CallSid; const to = req.body.To; const prop = PROPS[to] || PROPS['+12132977880']; conversations[sid] = { prop: prop, history: [] }; console.log('Call to', to, sid); const t = new twilio.twiml.VoiceResponse(); const g = t.gather({ input: 'speech', action: 'https://voice.onsethost.com/respond', method: 'POST', speechTimeout: 'auto', language: 'en-US' }); g.say({ voice: 'Polly.Joanna-Neural' }, prop.greeting); t.hangup(); res.type('text/xml'); res.send(t.toString()); }); app.post('/respond', async (req, res) => { const sid = req.body.CallSid; const speech = req.body.SpeechResult || ''; const sess = conversations[sid] || { prop: PROPS['+12132977880'], history: [] }; console.log('Speech:', speech); sess.history.push({ role: 'user', content: speech }); conversations[sid] = sess; const hist = sess.history.map(m => (m.role === 'user' ? 'Caller' : 'Assistant') + ': ' + m.content).join('\n'); const prompt = sess.prop.system + '\n\nConversation:\n' + hist + '\n\nRespond as AI receptionist. Under 40 words. No markdown.'; pending[sid] = null; askAI(prompt).then(reply => { pending[sid] = reply || 'I apologize for the difficulty. Please try again shortly.'; }); const t = new twilio.twiml.VoiceResponse(); t.pause({ length: 1 }); t.redirect({ method: 'POST' }, 'https://voice.onsethost.com/check?sid=' + sid + '&attempt=0'); res.type('text/xml'); res.send(t.toString()); }); app.post('/check', async (req, res) => { const sid = req.query.sid; const attempt = parseInt(req.query.attempt) || 0; const t = new twilio.twiml.VoiceResponse(); if (pending[sid]) { const reply = pending[sid]; const sess = conversations[sid] || { prop: PROPS['+12132977880'], history: [] }; sess.history.push({ role: 'assistant', content: reply }); conversations[sid] = sess; delete pending[sid]; console.log('Reply:', reply); const g = t.gather({ input: 'speech', action: 'https://voice.onsethost.com/respond', method: 'POST', speechTimeout: 'auto', language: 'en-US' }); g.say({ voice: 'Polly.Joanna-Neural' }, reply); t.say({ voice: 'Polly.Joanna-Neural' }, 'Thank you for calling. Goodbye!'); t.hangup(); } else if (attempt < 30) { t.pause({ length: 1 }); t.redirect({ method: 'POST' }, 'https://voice.onsethost.com/check?sid=' + sid + '&attempt=' + (attempt + 1)); } else { t.say({ voice: 'Polly.Joanna-Neural' }, 'I apologize for the delay. Please call back shortly.'); t.hangup(); } res.type('text/xml'); res.send(t.toString()); }); app.get('/health', (req, res) => res.json({ status: 'online', service: 'VoiceAI' })); app.listen(PORT, '0.0.0.0', () => console.log('VoiceAI running on port ' + PORT));