345 lines
15 KiB
JavaScript
345 lines
15 KiB
JavaScript
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 = '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';
|
|
|
|
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 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_multilingual_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;
|
|
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 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 = "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 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 & 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 = "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 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());
|
|
});
|
|
|
|
// ============================================================
|
|
// 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)); |