← back to Butlr
lib/vapi-listen.js
72 lines
// Bridge: Vapi per-call listenUrl WebSocket → Butlr listen-bridge.
//
// When a Vapi call enters 'in-progress' status, the webhook payload includes
// call.monitor.listenUrl — a WebSocket that streams mixed-track raw audio for
// passive monitoring. Vapi default format: PCM 16-bit little-endian at 16 kHz
// mono. We open a WS to that URL, forward each binary frame to the existing
// listen-bridge broadcastPcm() so any browser already subscribed to
// /listen-ws/<butlrCallId> receives the audio identically to the Twilio path.
//
// Closes automatically when the call ends (call.ended webhook → detach).
const WebSocket = require('ws');
const listenBridge = require('./listen-bridge');
const ACTIVE = new Map(); // butlrCallId → { ws, listenUrl, attachedAt }
function attach(callId, listenUrl) {
if (!callId || !listenUrl) return { ok: false, error: 'missing_args' };
if (ACTIVE.has(callId)) return { ok: true, already_attached: true };
try {
const ws = new WebSocket(listenUrl);
ws.binaryType = 'arraybuffer';
ws.on('open', () => {
console.log(`[vapi-listen] WS open call=${callId}`);
});
ws.on('message', (data, isBinary) => {
if (!isBinary) return; // Vapi may send JSON control frames; we ignore
try {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
listenBridge.broadcastPcm(callId, buf);
} catch (e) {
// Drop silently — single bad frame shouldn't kill the bridge.
}
});
ws.on('close', (code, reason) => {
console.log(`[vapi-listen] WS closed call=${callId} code=${code} reason=${reason && reason.toString().slice(0, 80)}`);
ACTIVE.delete(callId);
});
ws.on('error', (e) => {
console.error(`[vapi-listen] WS error call=${callId}:`, e.message);
});
ACTIVE.set(callId, { ws, listenUrl, attachedAt: Date.now() });
return { ok: true };
} catch (e) {
console.error(`[vapi-listen] attach failed call=${callId}:`, e.message);
return { ok: false, error: e.message };
}
}
function detach(callId) {
const entry = ACTIVE.get(callId);
if (!entry) return { ok: true, was_attached: false };
try { entry.ws.close(); } catch {}
ACTIVE.delete(callId);
return { ok: true, was_attached: true };
}
function list() {
return Array.from(ACTIVE.entries()).map(([callId, e]) => ({
callId,
age_ms: Date.now() - e.attachedAt,
}));
}
module.exports = { attach, detach, list };