diff --git a/voiceai-server.js b/voiceai-server.js index f03289d..cf7dfd9 100644 --- a/voiceai-server.js +++ b/voiceai-server.js @@ -1,82 +1,345 @@ const express = require('express'); const twilio = require('twilio'); const axios = require('axios'); +const fs = require('fs'); +const path = require('path'); + 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 = 'd5xU2Rwln0n15oHMmaTU'; +const PINK_PULSE_NUMBER = '+12134182600'; +const RYAN_CELL = '+18632382563'; +const JOSH_CELL = '+19547067488'; +const NOTIFY_EMAILS = ['ryan.salazar@thepinkpulse.com', 'josh.menghi@thepinkpulse.com']; + const AI_NODE = 'http://10.10.0.67:8765'; const AI_KEY = 'b1ca95570932bd14ae75426095f436912bb88e6b217712f0811e6501c68df465'; const AI_MODEL = 'deepseek-r1:latest'; + const conversations = {}; const pending = {}; -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. Under 40 words.'},'+12134182600':{name:'pinkpulse',greeting:'Hey gorgeous! You have reached The Pink Pulse. How can I help you today?',system:'You are the AI receptionist for The Pink Pulse, an LGBTQ+ publication. Be warm and fabulous. 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. 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. Under 40 words.'}}; +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 generateElevenLabsAudio(text, voiceId, cacheKey) { + if (audioCache[cacheKey]) return audioCache[cacheKey]; + try { + const response = await axios.post( + `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, + { text, model_id: 'eleven_turbo_v2', voice_settings: { stability: 0.5, similarity_boost: 0.8 } }, + { headers: { 'xi-api-key': ELEVENLABS_KEY, 'Content-Type': 'application/json' }, responseType: 'arraybuffer' } + ); + const filename = `/opt/voiceai/audio/${cacheKey}.mp3`; + fs.mkdirSync('/opt/voiceai/audio', { recursive: true }); + fs.writeFileSync(filename, response.data); + const url = `https://voice.onsethost.com/audio/${cacheKey}.mp3`; + audioCache[cacheKey] = url; + console.log('Generated audio:', url); + return url; + } catch (e) { + console.error('ElevenLabs error:', e.message); + return null; + } +} + async function askAI(prompt) { try { const r = await axios.post(AI_NODE+'/queue/add',{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){console.log('AI:',job.result.substring(0,80));return job.result.replace(/```json|```/g,'').trim();} + if(job&&job.status==='done'&&job.result) return job.result.replace(/```json|```/g,'').trim(); } } catch(e){console.error('AI error:',e.message);} return null; } -app.post('/voice',async(req,res)=>{ - const sid=req.body.CallSid,to=req.body.To; - const prop=PROPS[to]||PROPS['+12132977880']; - conversations[sid]={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,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.'; - console.log('Reply ready for',sid,':',pending[sid]); - }); - 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; - console.log('Check for',sid,'attempt',attempt,'ready:',pending[sid]!==null&&pending[sid]!==undefined); - 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]; - 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)); + +async function sendNotifications(callerNumber, transcript, department) { + const message = `New Pink Pulse voicemail from ${callerNumber}\nDepartment: ${department}\n\nTranscript:\n${transcript}`; + try { + await twilioClient.messages.create({ body: message, from: PINK_PULSE_NUMBER, to: RYAN_CELL }); + await twilioClient.messages.create({ body: message, from: PINK_PULSE_NUMBER, to: JOSH_CELL }); + console.log('SMS notifications sent'); + } catch(e) { console.error('SMS error:', e.message); } +} + +app.use('/audio', express.static('/opt/voiceai/audio')); + +// ============================================================ +// PINK PULSE AUTO-ATTENDANT +// ============================================================ + +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 = "Hey gorgeous! Thanks for calling The Pink Pulse, where your world gets gayed up! I'm Brian, your fabulous 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. Or stay on the line and I'll connect you with our team!"; + + let audioUrl = await generateElevenLabsAudio(greetingText, BRIAN_VOICE_ID, '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 { - t.say({voice:'Polly.Joanna-Neural'},'I apologize for the delay. Please call back shortly.'); + 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 & Sponsorships', '2': 'Editorial & Story Tips', '3': 'Events & 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 - attempting transfer to Ryan'); + const transferText = `Fabulous choice! Let me connect you with our team regarding ${dept}. One moment honey!`; + let audioUrl = await generateElevenLabsAudio(transferText, BRIAN_VOICE_ID, `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 - going to AI message taker'); + t.redirect({ method: 'POST' }, `https://voice.onsethost.com/pinkpulse/message?sid=${sid}&dept=${encodeURIComponent(dept)}&attempt=0`); + } + + 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 missedText = "Oh no honey, looks like our team is busy right now! But don't worry, I'll take a message and make sure they get back to you. What's your name?"; + let audioUrl = await generateElevenLabsAudio(missedText, BRIAN_VOICE_ID, 'pp_missed'); + const sess = conversations[sid] || { department: dept, history: [], callerNumber: '' }; + sess.history = []; + sess.history.push({ role: 'assistant', content: "What's your name?" }); + conversations[sid] = sess; + pending[sid] = null; + + 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' }, missedText); + } + + 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 = "Hey darling! Our team isn't available right now, but I'm Brian and I'll make sure your message gets to the right fabulous person! Can I start with your name?"; + let audioUrl = await generateElevenLabsAudio(messageText, BRIAN_VOICE_ID, '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 property. You are warm, sassy, inclusive and fun. You are collecting a message from a caller who selected: ${dept}. + +Your job is to get: 1) Their name 2) Their phone number 3) Their message/reason for calling + +Conversation so far: +${hist} + +If you have all 3 pieces of information, end your response with [COMPLETE]. +If not, ask for the next piece naturally and fabulously. +Keep responses under 50 words. No markdown.`; + + pending[sid] = null; + 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, isComplete, hist: hist + '\nBrian: ' + reply }; + console.log('Brian reply ready:', reply, '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] && pending[sid].reply) { + const { reply, isComplete, hist } = pending[sid]; + delete pending[sid]; + + let audioUrl = await generateElevenLabsAudio(reply, BRIAN_VOICE_ID, `pp_collect_${sid}_${attempt}`); + + if (isComplete) { + const sess = conversations[sid] || { callerNumber: '' }; + await sendNotifications(sess.callerNumber, hist, dept); + + const byeText = "Fabulous! I've got all your details and our team will be in touch soon. Thanks for calling The Pink Pulse gorgeous. Have a fierce day!"; + let byeUrl = await generateElevenLabsAudio(byeText, BRIAN_VOICE_ID, `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: 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' }, reply); + } + } else if (attempt < 20) { + 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()); + + 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)); \ No newline at end of file + +// ============================================================ +// OTHER PROPERTIES - AI ATTENDANT +// ============================================================ + +app.post('/voice', async (req, res) => { + const sid = req.body.CallSid, to = req.body.To; + if (to === PINK_PULSE_NUMBER) { + req.url = '/pinkpulse'; + return app._router.handle(req, res, () => {}); + } + const prop = PROPS[to] || PROPS['+12132977880']; + conversations[sid] = { 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, 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, 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)); \ No newline at end of file