← 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 };