diff --git a/voiceai-server.js b/voiceai-server.js index 99e5c76..ec2e41d 100644 --- a/voiceai-server.js +++ b/voiceai-server.js @@ -2,7 +2,6 @@ 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 })); @@ -16,8 +15,6 @@ const BRIAN_VOICE_ID = 'Afeo3giYvnyOZXdF5ct9'; 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'; @@ -27,89 +24,105 @@ 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.'} + '+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 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) { +async function generateAudio(text, 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_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' } - ); - const filename = `/opt/voiceai/audio/${cacheKey}.mp3`; + 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(filename, response.data); - const url = `https://voice.onsethost.com/audio/${cacheKey}.mp3`; + fs.writeFileSync('/opt/voiceai/audio/' + cacheKey + '.mp3', response.data); + const url = 'https://voice.onsethost.com/audio/' + cacheKey + '.mp3'; audioCache[cacheKey] = url; - console.log('Generated audio:', url); + console.log('Audio generated:', url); return url; } catch (e) { - console.error('ElevenLabs error:', e.message); + 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,model:AI_MODEL,site:'voiceai',job_type:'text'},{headers:{'x-api-key':AI_KEY},timeout:5000}); + 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; - 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(); + 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);} + } catch (e) { + console.error('AI error:', e.message); + } return null; } -async function sendNotifications(callerNumber, transcript, department) { - const message = `New Pink Pulse voicemail from ${callerNumber}\nDepartment: ${department}\n\nTranscript:\n${transcript}`; +async function sendSMS(to, message) { 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); } + 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')); -// ============================================================ -// 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 = "Thanks for calling The Pink Pulse, covering over 138 gay communities worldwide! 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 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."; + 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()); @@ -119,29 +132,25 @@ 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 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 - 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}`); - + 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 }); + 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`); + 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'); @@ -153,26 +162,16 @@ app.post('/pinkpulse/transfer', async (req, res) => { 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'); + console.log('Transfer failed - going to AI message taker'); 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); + t.redirect({ method: 'POST' }, 'https://voice.onsethost.com/pinkpulse/message?sid=' + sid + '&dept=' + encodeURIComponent(dept)); } - res.type('text/xml'); res.send(t.toString()); }); @@ -184,14 +183,13 @@ app.post('/pinkpulse/message', async (req, res) => { sess.history = []; conversations[sid] = sess; - const messageText = "Unfortunately, our team isn't available right this moment, but I'll make sure your message gets to someone within minutes! Can I start with your name?"; - let audioUrl = await generateElevenLabsAudio(messageText, BRIAN_VOICE_ID, 'pp_afterhours'); + const messageText = "Hey darling! Our team isn't available right now, but I'm Brian and I'll personally make sure your message gets to the right fabulous 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' }); + 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()); }); @@ -201,35 +199,25 @@ app.post('/pinkpulse/collect', async (req, res) => { 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}. + 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.'; -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; + pending[sid] = undefined; askAI(prompt).then(async reply => { - if (!reply) reply = "Could you repeat that for me darling?"; + 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); + 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`); + 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()); }); @@ -240,33 +228,33 @@ app.post('/pinkpulse/collect-check', async (req, res) => { 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]; + 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); - let audioUrl = await generateElevenLabsAudio(reply, BRIAN_VOICE_ID, `pp_collect_${sid}_${attempt}`); - - if (isComplete) { + if (data.isComplete) { const sess = conversations[sid] || { callerNumber: '' }; - await sendNotifications(sess.callerNumber, hist, dept); + 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'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}`); + 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: reply }); + 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' }); + 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 g.say({ voice: 'Polly.Joanna-Neural' }, data.reply); } - } else if (attempt < 20) { + } 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}`); + 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(); @@ -276,18 +264,11 @@ app.post('/pinkpulse/collect-check', async (req, res) => { res.send(t.toString()); }); -// ============================================================ -// 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 sid = req.body.CallSid; + const to = req.body.To; const prop = PROPS[to] || PROPS['+12132977880']; - conversations[sid] = { prop, history: [] }; + 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' }); @@ -298,7 +279,8 @@ app.post('/voice', async (req, res) => { }); app.post('/respond', async (req, res) => { - const sid = req.body.CallSid, speech = req.body.SpeechResult || ''; + 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 }); @@ -317,7 +299,8 @@ app.post('/respond', async (req, res) => { }); app.post('/check', async (req, res) => { - const sid = req.query.sid, attempt = parseInt(req.query.attempt) || 0; + 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];