← back to Butlr
routes/listen.js
161 lines
// Listen-in routes:
// GET /listen/:call_id — browser page that streams the live audio
// GET /api/calls/:call_id/transcript — transcript JSON (post-call)
// GET /api/calls/:call_id/recording — MP3 recording (post-call)
// GET /api/calls/:call_id/has-recording — JSON probe used by /calls list rows
// GET /api/calls/:call_id/has-transcript — JSON probe used by /calls list rows
const express = require('express');
const path = require('path');
const fs = require('fs');
const data = require('../lib/data');
const transcribe = require('../lib/transcribe');
const callOverrides = require('../lib/call-overrides');
const router = express.Router();
router.get('/listen/:call_id', (req, res) => {
const callId = req.params.call_id;
if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) {
return res.status(404).render('public/404', { title: 'Not found — Butlr' });
}
const call = data.getCall(callId, req.user && req.user.id, false);
if (!call) {
return res.status(404).render('public/404', { title: 'Not found — Butlr' });
}
res.render('public/listen', {
title: `Listen in — ${call.business_name} — Butlr`,
meta_desc: 'Live audio listen-in.',
call,
});
});
router.get('/api/calls/:call_id/transcript', (req, res) => {
const callId = req.params.call_id;
if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).json({ ok: false });
// Verify caller owns this call before serving the transcript.
const call = data.getCall(callId, req.user && req.user.id, false);
if (!call) return res.status(404).json({ ok: false });
const t = transcribe.readTranscript(callId);
if (!t) return res.status(404).json({ ok: false, error: 'no_transcript' });
res.json({ ok: true, transcript: t });
});
router.get('/api/calls/:call_id/recording', async (req, res) => {
const callId = req.params.call_id;
if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).end();
const call = data.getCall(callId, req.user && req.user.id, true);
if (!call) return res.status(404).end();
// Local Twilio-archive path
const fp = path.join(transcribe.RECORDINGS_DIR, `${callId}.mp3`);
if (fs.existsSync(fp)) {
res.type('audio/mpeg');
res.setHeader('Content-Disposition', `inline; filename="${callId}.mp3"`);
return fs.createReadStream(fp).pipe(res);
}
// Vapi-backed call — fetch recording URL from Vapi and stream it through
if (call.vapi_call_id) {
try {
const recordingUrl = await require('../lib/vapi').getRecording(call.vapi_call_id);
if (recordingUrl) {
const r = await fetch(recordingUrl);
if (r.ok) {
res.type(r.headers.get('content-type') || 'audio/mpeg');
res.setHeader('Content-Disposition', `inline; filename="${callId}.mp3"`);
const reader = r.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
}
return res.end();
}
}
} catch (e) { console.error('[recording] vapi fetch', e.message); }
}
return res.status(404).end();
});
// Cheap existence probes for /calls list inline chips. Return
// {ok, exists, size_bytes} without streaming so the list page can
// hide rows whose recording/transcript wasn't archived (older calls,
// dry-run, mid-call death, Whisper still running, etc).
router.get('/api/calls/:call_id/has-recording', (req, res) => {
const callId = req.params.call_id;
if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).json({ ok: false });
const call = data.getCall(callId, req.user && req.user.id, false);
if (!call) return res.status(404).json({ ok: false });
const fp = path.join(transcribe.RECORDINGS_DIR, `${callId}.mp3`);
if (!fs.existsSync(fp)) return res.json({ ok: true, exists: false });
res.json({ ok: true, exists: true, size_bytes: fs.statSync(fp).size });
});
router.get('/api/calls/:call_id/has-transcript', (req, res) => {
const callId = req.params.call_id;
if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).json({ ok: false });
const call = data.getCall(callId, req.user && req.user.id, false);
if (!call) return res.status(404).json({ ok: false });
const fp = path.join(transcribe.TRANSCRIPTS_DIR, `${callId}.json`);
if (!fs.existsSync(fp)) return res.json({ ok: true, exists: false });
res.json({ ok: true, exists: true, size_bytes: fs.statSync(fp).size });
});
// Live takeover — speak as the call owner on the next gather turn.
// The text gets synthesized through ElevenLabs (cloned voice if VOICE_ID
// is set to the user's IVC) and played on the call. Best UX is to type
// quickly while the rep is still talking; the speak lands within ~3-20s
// depending on where in the gather cycle we are.
router.post('/api/calls/:call_id/say', express.json(), (req, res) => {
const callId = req.params.call_id;
if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).json({ ok: false });
const call = data.getCall(callId, req.user && req.user.id, false);
if (!call) return res.status(404).json({ ok: false });
const text = String((req.body && req.body.text) || '').trim().slice(0, 500);
if (!text) return res.status(400).json({ ok: false, error: 'no_text' });
callOverrides.set(callId, { sayText: text });
console.log(`[takeover] say queued for ${callId}: "${text.slice(0, 80)}"`);
// If this call was placed via Vapi, push the say directly to the live call.
// Falls through silently if no vapi_call_id (Twilio-backend call).
if (call.vapi_call_id) {
try { require('../lib/vapi').sayOnCall(call.vapi_call_id, text); }
catch (e) { console.error('[takeover] vapi sayOnCall:', e.message); }
}
res.json({ ok: true });
});
// Live takeover — politely end the call on the next gather turn.
router.post('/api/calls/:call_id/hangup', (req, res) => {
const callId = req.params.call_id;
if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).json({ ok: false });
const call = data.getCall(callId, req.user && req.user.id, false);
if (!call) return res.status(404).json({ ok: false });
callOverrides.set(callId, { hangup: true });
console.log(`[takeover] hangup queued for ${callId}`);
if (call.vapi_call_id) {
try { require('../lib/vapi').endCall(call.vapi_call_id, 'owner ended call'); }
catch (e) { console.error('[takeover] vapi endCall:', e.message); }
}
res.json({ ok: true });
});
router.post('/api/calls/:call_id/pickup', (req, res) => {
const callId = req.params.call_id;
if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).json({ ok: false });
const call = data.getCall(callId, req.user && req.user.id, false);
if (!call) return res.status(404).json({ ok: false });
if (!call.vapi_call_id) return res.status(400).json({ ok: false, error: 'no_vapi_call_id (Twilio-direct)' });
const dest = (req.body && req.body.destination) || call.callback_phone;
if (!dest) return res.status(400).json({ ok: false, error: 'no_destination' });
console.log('[takeover] pickup transferring ' + callId + ' to ' + dest);
try {
require('../lib/vapi').transferCall(call.vapi_call_id, dest)
.then(r => { if (!r.ok) console.error('[takeover] transfer fail:', r); })
.catch(e => console.error('[takeover] transfer err:', e.message));
res.json({ ok: true, transferring_to: dest });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
module.exports = router;