← back to Butlr
routes/live.js
550 lines
// /live route — live call ticker with INLINE listen (no leaving the page).
// On host = live.agentabrams.com, also serves at /.
// AJAX-refreshes the card list every 3s; audio plays via Web Audio API + WS.
const express = require('express');
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const data = require('../lib/data');
const router = express.Router();
const REC_DIR = path.join(__dirname, '..', 'data', 'recordings');
const MIN_DONE_SEC = 5; // hide sub-5s completed calls (no-answer / instant hangups)
// Recording duration in seconds, cached on the call row after the first probe
// so we don't shell out to ffprobe on every /api/done refresh.
function recordingDurationSec(call) {
if (typeof call.recording_duration_sec === 'number') return call.recording_duration_sec;
const f = path.join(REC_DIR, call.id + '.mp3');
if (!fs.existsSync(f)) return 0;
try {
const out = execFileSync('ffprobe',
['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', f],
{ timeout: 4000 }).toString().trim();
const d = Math.round((parseFloat(out) || 0) * 10) / 10;
data.patchRow(call.id, { recording_duration_sec: d });
return d;
} catch { return 0; }
}
const LIVE_STATUSES = new Set(['queued', 'dialing', 'on_hold', 'connected', 'ringing']);
const LIVE_HOST = 'live.agentabrams.com';
// Max plausible age (since last activity) for each non-terminal status. A call
// that's been "dialing" for an hour is a zombie — its terminal Twilio callback
// (busy/no-answer/completed) never arrived, so the row never left the live set.
// Pre-pickup states resolve in well under a minute; an active call gets partial
// callbacks every few seconds, so a long-stale updated_at means it's dead.
const STALE_MS = {
queued: 3 * 60_000, dialing: 3 * 60_000, ringing: 3 * 60_000,
on_hold: 20 * 60_000, connected: 20 * 60_000,
};
function isStale(c) {
const ms = STALE_MS[c.status];
if (!ms) return false;
const last = new Date(c.updated_at || c.created_at).getTime();
if (!isFinite(last)) return false;
return (Date.now() - last) > ms;
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
}[c]));
}
function pickLive(calls) {
return (calls || []).filter((c) => LIVE_STATUSES.has(c.status) && !isStale(c))
.sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at));
}
// ── Stale-call reaper ─────────────────────────────────────────────────
// Belt to the display filter's suspenders: actually mark zombie rows
// terminal so they leave the dataset (and the cost ledger isn't waiting on
// a callback that will never come). Runs at module load + every 60s.
function reapStaleCalls() {
try {
const all = data.listCallsUnscoped();
let n = 0;
for (const c of all) {
if (LIVE_STATUSES.has(c.status) && isStale(c)) {
data.patchRow(c.id, {
status: 'failed',
notes: ((c.notes || '') + ' [stale-swept: stuck in ' + c.status + ', no terminal Twilio callback]').trim(),
stale_swept_at: new Date().toISOString(),
});
n++;
}
}
if (n) console.log('[live-reaper] swept ' + n + ' stale call(s) → failed');
} catch (e) { console.error('[live-reaper]', e.message); }
}
reapStaleCalls();
const _reaperTimer = setInterval(reapStaleCalls, 60_000);
if (_reaperTimer.unref) _reaperTimer.unref();
function fmtAgo(ts) {
try {
const ms = Date.now() - new Date(ts).getTime();
if (ms < 60_000) return Math.floor(ms / 1000) + 's ago';
if (ms < 3600_000) return Math.floor(ms / 60_000) + 'm ago';
return Math.floor(ms / 3600_000) + 'h ago';
} catch { return ts || ''; }
}
function cardHtml(c) {
const id = escapeHtml(c.id);
const phone = escapeHtml(c.business_phone || '');
const name = escapeHtml(c.business_name || c.callback_name || '(unknown)');
const status = escapeHtml(c.status);
const goal = escapeHtml((c.goal || '').slice(0, 180));
const ts = c.updated_at || c.created_at;
const ago = escapeHtml(fmtAgo(ts));
const sampleRate = c.vapi_call_id ? 16000 : 8000;
return `<article class="card status-${status}" data-id="${id}" data-sr="${sampleRate}">
<header>
<div class="biz">${name}</div>
<div class="meta">
<span class="pill">${status.toUpperCase()}</span>
<span>${phone}</span>
<span>·</span>
<span class="ago">${ago}</span>
</div>
</header>
${goal ? `<p class="goal">${goal}${(c.goal || '').length > 180 ? '…' : ''}</p>` : ''}
<div class="actions">
<button class="btn listen-btn" type="button" data-id="${id}" data-sr="${sampleRate}">🔊 Listen</button>
<span class="ws-state muted" data-id="${id}"></span>
<a class="btn ghost" href="/calls/${id}">Detail →</a>
</div>
</article>`;
}
function renderLive(calls) {
const live = pickLive(calls);
const cardsHtml = live.length
? live.map(cardHtml).join('\n')
: '<div class="empty"><p>No live calls right now.</p><p class="sub">When a Butlr call is queued, dialing, on hold, or connected, it will appear here.</p></div>';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="robots" content="noindex,nofollow" />
<title>Live calls · Butlr</title>
<style>
:root { --ink:#101828; --muted:#64748b; --bg:#0f172a; --card:#1e293b; --line:#334155; --green:#22c55e; --amber:#f59e0b; --blue:#3b82f6; --pink:#ec4899; }
* { box-sizing: border-box; }
body { font-family: 'Inter',system-ui,-apple-system,sans-serif; background: var(--bg); color: #e2e8f0; margin: 0; min-height: 100vh; }
header.top { padding: 24px 32px 16px; border-bottom: 1px solid var(--line); display:flex; align-items:center; gap:16px; flex-wrap:wrap; }
h1 { font-family: 'Playfair Display', Georgia, serif; font-weight: 500; margin: 0; font-size: 26px; color: #f8fafc; }
.dot { width: 10px; height: 10px; border-radius:50%; background: var(--green); animation: pulse 1.4s infinite; }
@keyframes pulse { 0%,100%{opacity:.4;transform:scale(1)} 50%{opacity:1;transform:scale(1.25)} }
.sub { color: var(--muted); font-size: 13px; }
main { max-width: 1100px; margin: 0 auto; padding: 24px 32px 80px; display: grid; grid-template-columns: repeat(auto-fill,minmax(340px,1fr)); gap: 14px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
.card.status-connected { border-left: 4px solid var(--green); }
.card.status-on_hold { border-left: 4px solid var(--amber); }
.card.status-dialing, .card.status-ringing { border-left: 4px solid var(--blue); }
.card.status-queued { border-left: 4px solid var(--pink); }
.biz { font-weight: 600; font-size: 15px; color: #f1f5f9; margin-bottom: 4px; }
.meta { display:flex; gap:6px; align-items:center; flex-wrap:wrap; font-size: 12px; color: #94a3b8; }
.pill { font-size: 10px; padding: 2px 8px; border-radius: 999px; background: var(--green); color: #022; font-weight: 700; letter-spacing: .5px; }
.card.status-on_hold .pill { background: var(--amber); }
.card.status-dialing .pill, .card.status-ringing .pill { background: var(--blue); color: #f8fafc; }
.card.status-queued .pill { background: var(--pink); color: #fff; }
.goal { color: #cbd5e1; font-size: 13px; line-height: 1.5; margin: 8px 0 10px; }
.actions { display:flex; gap:8px; align-items:center; }
.btn { display:inline-flex; align-items:center; gap:4px; padding: 6px 10px; background: var(--green); color: #022; text-decoration:none; border:0; border-radius: 6px; font-size: 12px; font-weight: 700; cursor: pointer; font-family: inherit; }
.btn.ghost { background:#334155; color:#f1f5f9; font-weight:500; }
.btn:hover { filter: brightness(1.08); }
.btn.listening { background: #ef4444; color: #fff; }
.ws-state { font-size: 11px; color: var(--muted); }
.ws-state.active { color: var(--green); }
.ws-state.err { color: #ef4444; }
.empty { grid-column: 1/-1; text-align:center; padding: 80px 20px; color: var(--muted); }
.empty p { margin: 0 0 8px; }
.count { color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
<header class="top">
<div class="dot"></div>
<h1>Live calls</h1>
<span class="count"><span id="liveCount">${live.length}</span> active</span>
<span class="sub" style="margin-left:auto">auto-refresh 3s · click 🔊 Listen to hear live (no page reload)</span>
</header>
<div id="audio-unlock-banner" onclick="unlockAudio()" style="position:sticky;top:0;z-index:50;background:#22c55e;color:#022;text-align:center;padding:10px;font-weight:700;cursor:pointer;font-size:14px;">🔊 CLICK HERE ONCE TO ENABLE SOUND — chime on every new call + live audio plays automatically as calls connect</div>
<div style="display:none">
</div>
<main id="grid">${cardsHtml}</main>
<section id="completed-section" style="max-width:1100px;margin:0 auto;padding:8px 32px 80px;">
<h2 style="font-family:'Playfair Display',Georgia,serif;font-weight:500;font-size:20px;color:#f8fafc;border-top:1px solid var(--line);padding-top:24px;margin:24px 0 4px;">Completed calls <span id="doneCount" style="font-size:13px;color:var(--muted);font-weight:400;"></span></h2>
<p class="sub" style="margin:0 0 16px;">recent successful calls · play the recording inline</p>
<div id="doneGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px;"></div>
</section>
<script>
(function(){
// Map of callId → { ws, audioCtx, btn, stateEl }
const sessions = new Map();
// Call IDs we've already announced with a chime. Seeded on load from the
// server-rendered cards so we DON'T beep for calls already on screen —
// only for genuinely new calls that arrive after the page is open.
const seenCallIds = new Set();
document.querySelectorAll('#grid .card[data-id]').forEach(function(el){
seenCallIds.add(el.dataset.id);
});
// Shared AudioContext — browsers require a user gesture to start audio.
// We unlock ONE context on the first click anywhere, then reuse it for every
// call so auto-play-on-connect actually produces sound.
let sharedCtx = null;
let audioUnlocked = false;
function ensureCtx(sampleRate) {
if (!sharedCtx) {
sharedCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (sharedCtx.state === 'suspended') sharedCtx.resume();
return sharedCtx;
}
function unlockAudio() {
if (audioUnlocked) return;
try {
ensureCtx();
// play a 1-sample silent buffer to fully unlock on iOS/Safari
const b = sharedCtx.createBuffer(1, 1, 22050);
const s = sharedCtx.createBufferSource();
s.buffer = b; s.connect(sharedCtx.destination); s.start(0);
audioUnlocked = true;
const banner = document.getElementById('audio-unlock-banner');
if (banner) { banner.style.display = 'none'; }
} catch (e) {}
}
document.addEventListener('click', unlockAudio, { once: false });
document.addEventListener('keydown', unlockAudio, { once: false });
// Re-surface the unlock banner (loudly) when live audio is arriving but the
// browser is still muted — the only thing standing between Steve and sound
// is one click. Throttled so it doesn't thrash on every frame.
let _bannerFlashTs = 0;
function flashUnlockBanner() {
const now = Date.now();
if (now - _bannerFlashTs < 1000) return;
_bannerFlashTs = now;
const banner = document.getElementById('audio-unlock-banner');
if (!banner) return;
banner.style.display = 'block';
banner.textContent = '🔇 LIVE AUDIO IS PLAYING BUT YOUR BROWSER IS MUTED — CLICK ANYWHERE TO HEAR IT';
banner.style.background = '#ef4444';
banner.style.color = '#fff';
banner.style.animation = 'pulse 1s infinite';
}
// Two-tone ascending chime (no audio asset needed) — fired whenever a NEW
// call appears in the grid so Steve hears "a call is being made" even before
// the called party picks up (the live stream is silent until they speak).
// Needs the shared AudioContext unlocked first (the green banner does that).
function playChime() {
if (!audioUnlocked) return;
try {
const ctx = ensureCtx();
const t0 = ctx.currentTime + 0.01;
[[880, t0, 0.12], [1318.5, t0 + 0.13, 0.18]].forEach(function(spec){
const freq = spec[0], start = spec[1], dur = spec[2];
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.25, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
osc.connect(gain); gain.connect(ctx.destination);
osc.start(start); osc.stop(start + dur + 0.02);
});
} catch (e) {}
}
function getScheme() { return location.protocol === 'https:' ? 'wss:' : 'ws:'; }
function startListen(callId, sampleRate, btn, stateEl) {
let audioCtx, ws, nextPlayTime, frameCount = 0;
try {
audioCtx = ensureCtx(sampleRate);
nextPlayTime = audioCtx.currentTime + 0.1;
} catch (e) {
stateEl.textContent = 'no audio support';
stateEl.className = 'ws-state err';
return;
}
const url = getScheme() + '//' + location.host + '/listen-ws/' + callId;
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
stateEl.textContent = 'connecting…';
stateEl.className = 'ws-state';
ws.onopen = () => {
stateEl.textContent = 'connected · waiting for audio';
stateEl.className = 'ws-state active';
btn.textContent = '■ Stop';
btn.classList.add('listening');
};
ws.onclose = () => {
stateEl.textContent = '';
btn.textContent = '🔊 Listen';
btn.classList.remove('listening');
sessions.delete(callId);
};
ws.onerror = () => {
stateEl.textContent = 'error';
stateEl.className = 'ws-state err';
};
ws.onmessage = (ev) => {
// Audio is arriving. If the context is suspended (autoplay policy) or the
// user never clicked to unlock, the buffers play SILENTLY — that's the
// "I heard nothing" bug. Force a resume every frame, and if still locked,
// surface the unlock banner loudly so one click turns sound on.
if (audioCtx.state === 'suspended') { try { audioCtx.resume(); } catch (e) {} }
if (!audioUnlocked || audioCtx.state === 'suspended') flashUnlockBanner();
const pcm16 = new Int16Array(ev.data);
const f32 = new Float32Array(pcm16.length);
for (let i = 0; i < pcm16.length; i++) f32[i] = pcm16[i] / 32768;
const buf = audioCtx.createBuffer(1, f32.length, sampleRate);
buf.copyToChannel(f32, 0);
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
const startAt = Math.max(nextPlayTime, audioCtx.currentTime + 0.02);
src.start(startAt);
nextPlayTime = startAt + buf.duration;
frameCount++;
if (frameCount === 1 || frameCount % 20 === 0) {
stateEl.textContent = '● LIVE · ' + frameCount + ' frames';
stateEl.className = 'ws-state active';
}
};
sessions.set(callId, { ws, audioCtx, btn, stateEl });
}
function stopListen(callId) {
const s = sessions.get(callId);
if (!s) return;
try { s.ws && s.ws.close(); } catch(e){}
/* keep shared ctx alive for reuse */
sessions.delete(callId);
s.btn.textContent = '🔊 Listen';
s.btn.classList.remove('listening');
s.stateEl.textContent = '';
s.stateEl.className = 'ws-state';
}
function wireButtons() {
document.querySelectorAll('.listen-btn').forEach((btn) => {
if (btn._wired) return;
btn._wired = true;
btn.addEventListener('click', () => {
const id = btn.dataset.id;
const sr = parseInt(btn.dataset.sr, 10) || 8000;
const stateEl = document.querySelector('.ws-state[data-id="' + id + '"]');
if (sessions.has(id)) stopListen(id);
else startListen(id, sr, btn, stateEl);
});
});
}
wireButtons();
// AJAX-refresh card grid every 3s WITHOUT killing active audio.
// We only re-render cards that aren't currently being listened to;
// listening cards stay in place so their <button> + WS stay alive.
async function refresh() {
try {
const r = await fetch('/api/live', { credentials: 'same-origin' });
const j = await r.json();
const calls = (j.calls || []);
const liveIds = new Set(calls.map(c => c.id));
document.getElementById('liveCount').textContent = calls.length;
// Chime for any call we haven't announced yet — a call was just made.
calls.forEach((c) => {
if (!seenCallIds.has(c.id)) {
seenCallIds.add(c.id);
playChime();
}
});
// Remove cards for calls that ended — unless we're listening to them (keep so user
// can finish hearing the wind-down before WS closes from the server side).
// Scope to #grid: the completed-calls history (#doneGrid) also uses .card,
// and an unscoped selector would delete those done cards every refresh.
document.querySelectorAll('#grid .card').forEach((el) => {
const id = el.dataset.id;
if (!liveIds.has(id) && !sessions.has(id)) el.remove();
});
// Re-render new + updated cards (skip ones we're listening to so the button keeps state).
const grid = document.getElementById('grid');
// Remove existing empty-state if any
grid.querySelectorAll('.empty').forEach(e => e.remove());
calls.forEach((c) => {
if (sessions.has(c.id)) {
// Just update the timestamp + status pill without replacing the card.
const el = grid.querySelector('.card[data-id="' + c.id + '"]');
if (el) {
const pill = el.querySelector('.pill');
if (pill) pill.textContent = c.status.toUpperCase();
el.className = 'card status-' + c.status;
}
return;
}
const existing = grid.querySelector('.card[data-id="' + c.id + '"]');
const html = (${cardHtml.toString()})(c);
if (existing) existing.outerHTML = html;
else grid.insertAdjacentHTML('beforeend', html);
// AUTO-LISTEN — open the WS the instant the call appears (any live
// status), not just on connect. The connected window can be short
// (voicemail + AMD hangup), so opening early guarantees we catch the
// audio the moment frames start. No frames flow until the stream
// begins, so connecting during dialing is harmless.
if (!sessions.has(c.id)) {
const btn = grid.querySelector('.card[data-id="' + c.id + '"] .listen-btn');
const stateEl = grid.querySelector('.card[data-id="' + c.id + '"] .ws-state');
const sr = parseInt(btn && btn.dataset.sr, 10) || 8000;
if (btn && stateEl) startListen(c.id, sr, btn, stateEl);
}
});
if (calls.length === 0 && sessions.size === 0) {
grid.innerHTML = '<div class="empty"><p>No live calls right now.</p><p class="sub">When a Butlr call is queued, dialing, on hold, or connected, it will appear here.</p></div>';
}
wireButtons();
} catch (e) { /* swallow */ }
}
setInterval(refresh, 3000);
// ── Completed-calls history ──────────────────────────────────────────
// Fetches the last N status=done calls and renders them with an inline
// <audio> player streaming from /api/calls/:id/recording (same-origin,
// session-cookie auth — token stays out of the DOM). Refreshes every 30s
// so a call that just finished slides into the history without a reload.
function doneEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){
return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c];
});
}
function doneAgo(ts) {
try {
var ms = Date.now() - new Date(ts).getTime();
if (ms < 60000) return Math.floor(ms/1000) + 's ago';
if (ms < 3600000) return Math.floor(ms/60000) + 'm ago';
if (ms < 86400000) return Math.floor(ms/3600000) + 'h ago';
return Math.floor(ms/86400000) + 'd ago';
} catch(e) { return ''; }
}
async function loadCompleted() {
try {
var r = await fetch('/api/done?limit=24', { credentials: 'same-origin' });
var j = await r.json();
var calls = (j.calls || []);
var countEl = document.getElementById('doneCount');
var grid = document.getElementById('doneGrid');
if (!grid) return;
if (countEl) countEl.textContent = calls.length ? '· ' + calls.length + ' shown' : '';
if (!calls.length) {
grid.innerHTML = '<div style="grid-column:1/-1;color:var(--muted);font-size:13px;padding:24px 0;">No completed calls yet.</div>';
return;
}
grid.innerHTML = calls.map(function(c){
var biz = doneEsc(c.business_name || 'Unknown');
var phone = doneEsc(c.business_phone || '');
var goal = doneEsc((c.goal || '').slice(0, 140));
var ago = doneAgo(c.updated_at || c.created_at);
var id = doneEsc(c.id);
var ds = Math.round(c.recording_duration_sec || 0);
var dur = ds > 0 ? Math.floor(ds / 60) + ':' + String(ds % 60).padStart(2, '0') : '';
return '<div class="card status-done" style="border-left:4px solid var(--muted);">' +
'<div class="biz">' + biz + '</div>' +
'<div class="meta">' +
'<span class="pill" style="background:#475569;color:#e2e8f0;">DONE</span>' +
(phone ? '<span>' + phone + '</span>' : '') +
(dur ? '<span style="color:var(--green);font-weight:600;">▶ ' + dur + '</span>' : '') +
'<span style="margin-left:auto;">' + ago + '</span>' +
'</div>' +
(goal ? '<div class="goal">' + goal + (c.goal && c.goal.length > 140 ? '\\u2026' : '') + '</div>' : '') +
'<audio controls preload="metadata" src="/api/calls/' + id + '/recording" ' +
'style="width:100%;height:34px;margin-top:6px;" ' +
'onerror="this.style.display=\\'none\\';this.insertAdjacentHTML(\\'afterend\\',\\'<span style="color:#64748b;font-size:11px">no recording archived</span>\\')"></audio>' +
'<div style="margin-top:6px;">' +
'<a href="https://butlr.agentabrams.com/calls/' + id + '" target="_blank" rel="noopener" ' +
'style="color:var(--muted);font-size:11px;text-decoration:none;">call detail \\u2192</a>' +
'</div>' +
'</div>';
}).join('');
} catch (e) { /* swallow */ }
}
loadCompleted();
setInterval(loadCompleted, 30000);
})();
</script>
</body>
</html>`;
}
router.get('/live', async (req, res) => {
try {
const calls = data.listCallsUnscoped();
res.type('html').send(renderLive(calls));
} catch (e) {
res.status(500).send('error: ' + e.message);
}
});
router.get('/api/done', async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit, 10) || 30, 100);
const all = data.listCallsUnscoped();
const done = all.filter((c) => c.status === 'done')
.sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at));
// Only surface real conversations — recording over MIN_DONE_SEC. Hides
// no-answers, instant hangups, and AMD-killed voicemails.
const calls = [];
for (const c of done) {
const dur = recordingDurationSec(c);
if (dur > MIN_DONE_SEC) { calls.push({ ...c, recording_duration_sec: dur }); }
if (calls.length >= limit) break;
}
res.json({ ok: true, calls });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
router.get('/api/live', async (req, res) => {
try {
const calls = data.listCallsUnscoped();
res.json({ ok: true, calls: pickLive(calls) });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Hostname-aware: live.agentabrams.com/ → /live page
router.get('/', async (req, res, next) => {
if ((req.hostname || '').toLowerCase() === LIVE_HOST) {
try {
const calls = data.listCallsUnscoped();
return res.type('html').send(renderLive(calls));
} catch (e) {
return res.status(500).send('error: ' + e.message);
}
}
next();
});
module.exports = router;