← back to Butlr

routes/vapi-webhooks.js

177 lines

// Vapi webhook receiver — Vapi POSTs all call events here when the assistant's
// serverUrl is set to https://butlr.agentabrams.com/vapi/webhook.
//
// Events we care about:
//   - status-update      → maps to Butlr call.status (dialing/connected/done/failed)
//   - speech-update      → live transcript turn (rep + AI), pushed to agent-log
//   - tool-call          → AI invoked dtmf or endCall — log it
//   - end-of-call-report → final summary with recordingUrl, transcript, summary
//   - hang               → quick hangup signal
//
// Vapi metadata.butlr_call_id is the bridge back to our internal call row.

const express = require('express');
const fs = require('fs');
const path = require('path');
const data = require('../lib/data');
const vapiListen = require('../lib/vapi-listen');

// Owner user_id for autonomously-queued sub-calls. Spawned calls inherit the
// originating user's ownership so the listen page + recording proxy work.
const DEFAULT_OWNER_ID = process.env.BUTLR_DEFAULT_OWNER_ID || 'rcA9SnnO';

function handleToolCall(name, args, originatingCallMetadata) {
  if (name === 'placeOutboundCall') {
    // Hard guard: AI sometimes hallucinates toNumber (e.g. "Samantha" instead of digits).
    // Reject anything that isn't a real phone-number shape — 7-15 digits, optional leading +.
    const raw = String(args.toNumber || '').replace(/[^\d+]/g, '');
    const digitsOnly = raw.replace(/\D/g, '');
    if (digitsOnly.length < 7 || digitsOnly.length > 15) {
      console.warn(`[tool-call] placeOutboundCall REJECTED — bad toNumber=${JSON.stringify(args.toNumber)}`);
      return { ok: false, result: `I cannot place that call — the phone number I received was "${args.toNumber}" which isn't a valid phone number. Could you say the digits clearly, one at a time?` };
    }
    const phone = raw.startsWith('+') ? raw : (digitsOnly.length === 10 ? '+1' + digitsOnly : '+' + digitsOnly);
    const customerName = String(args.customerName || args.name || 'unknown').slice(0, 80);
    const goal = String(args.goal || 'Brief test call. Be polite. End politely when goodbye.').slice(0, 1000);
    const ownerId = (originatingCallMetadata && originatingCallMetadata.butlr_user_id) || DEFAULT_OWNER_ID;
    const body = {
      category: 'other',
      category_label: 'Spawned by Butlr placeOutboundCall',
      business_name: customerName,
      business_phone: phone,
      callback_phone: '+13107130489',
      callback_name: 'Steve Abrams',
      callback_email: 'steve@designerwallcoverings.com',
      goal,
      account_number: '', last4_ssn: '', billing_zip: '', date_of_birth: '', auth_password: '', best_time_window: '',
      max_hold_minutes: 5, max_spend_cents: 200,
      consent_recording: true, consent_terms: true,
      notify_sms: false, notify_email: false,
      notes: `Spawned by Butlr from call ${(originatingCallMetadata && originatingCallMetadata.butlr_call_id) || '?'}.`,
    };
    const r = data.addCall(body, ownerId);
    if (r.ok) {
      console.log(`[tool-call] placeOutboundCall queued → ${phone} (${customerName}) butlr_id=${r.call.id}`);
      return { ok: true, result: `Okay, I queued the call to ${customerName} at ${phone}. It will dial in a few seconds.` };
    }
    return { ok: false, result: `Sorry, I could not queue that call: ${JSON.stringify(r.errors)}` };
  }
  return null;
}

const router = express.Router();

const AGENT_LOG_DIR = path.join(__dirname, '..', 'data', 'agent-log');
fs.mkdirSync(AGENT_LOG_DIR, { recursive: true });

function appendAgentLog(callId, turn) {
  if (!callId) return;
  const fp = path.join(AGENT_LOG_DIR, `${callId}.json`);
  let arr = [];
  try { arr = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch {}
  arr.push({ ...turn, at: new Date().toISOString() });
  try { fs.writeFileSync(fp, JSON.stringify(arr, null, 0)); } catch (e) { console.error('[vapi-wh] log write', e.message); }
}

router.post('/webhook', express.json({ limit: '256kb' }), (req, res) => {
  // Vapi wraps each event in {message: {...}}. We just unpack.
  const msg = (req.body && req.body.message) || req.body || {};
  const type = msg.type;
  const callObj = msg.call || {};
  const vapiCallId = callObj.id;
  const butlrCallId = (callObj.metadata && callObj.metadata.butlr_call_id) || null;

  if (type === 'status-update') {
    const s = msg.status || msg.callStatus;
    let internal = null;
    if (s === 'queued' || s === 'ringing') internal = 'dialing';
    else if (s === 'in-progress') internal = 'connected';
    else if (s === 'ended' || s === 'completed') internal = 'done';
    else if (['busy', 'no-answer', 'failed'].includes(s)) internal = 'failed';
    if (butlrCallId && internal) {
      try { data.updateStatus(butlrCallId, internal); } catch (e) { console.error('[vapi-wh] updateStatus', e.message); }
    }
    console.log(`[vapi-wh] status=${s} → ${internal || 'noop'} call=${butlrCallId} vapi=${vapiCallId}`);

    // On call connect, open Vapi's listen-WS bridge so the browser's /listen-ws
    // for this call_id starts receiving 16 kHz PCM audio frames. On call end,
    // tear it down. The listenUrl comes from Vapi's webhook payload.
    if (s === 'in-progress' && butlrCallId) {
      const listenUrl = (callObj.monitor && callObj.monitor.listenUrl) || msg.listenUrl;
      if (listenUrl) {
        const r = vapiListen.attach(butlrCallId, listenUrl);
        console.log(`[vapi-wh] listen bridge attach call=${butlrCallId}: ${JSON.stringify(r)}`);
      }
    } else if ((s === 'ended' || internal === 'done' || internal === 'failed') && butlrCallId) {
      vapiListen.detach(butlrCallId);
    }
  }

  else if (type === 'transcript' || type === 'speech-update') {
    const role = msg.role || msg.transcriptType || 'unknown';
    const text = msg.transcript || msg.transcriptDelta || '';
    const isFinal = msg.transcriptType === 'final' || msg.isFinal;
    if (text && isFinal && butlrCallId) {
      appendAgentLog(butlrCallId, { kind: role === 'assistant' ? 'said' : 'heard', text, role });
      console.log(`[vapi-wh] transcript role=${role} call=${butlrCallId}: "${text.slice(0, 80)}"`);
    }
  }

  else if (type === 'tool-calls' || type === 'function-call') {
    const results = [];
    const toolCalls = msg.toolCallList || (msg.functionCall ? [{ id: 'fc-' + Date.now(), function: msg.functionCall }] : []);
    for (const t of toolCalls) {
      const tname = t.function && t.function.name;
      let targs = t.function && t.function.arguments;
      if (typeof targs === 'string') { try { targs = JSON.parse(targs); } catch { targs = {}; } }
      targs = targs || {};
      console.log(`[vapi-wh] tool-call call=${butlrCallId} ${tname}(${JSON.stringify(targs)})`);
      if (butlrCallId) appendAgentLog(butlrCallId, { kind: 'tool', tool: tname, args: targs });

      // Route custom tools (Vapi handles dtmf/endCall internally — we only see them as logs)
      const handled = handleToolCall(tname, targs, callObj.metadata);
      if (handled) results.push({ toolCallId: t.id, result: handled.result });
    }
    if (results.length) return res.json({ results });
  }

  else if (type === 'end-of-call-report') {
    const recordingUrl = msg.recordingUrl || (msg.artifact && msg.artifact.recordingUrl);
    const summary = msg.summary || '';
    const transcript = msg.transcript || '';
    const durSec = Number(msg.durationSeconds || msg.duration || 0);
    if (butlrCallId) {
      try {
        if (data.setRecordingMeta && recordingUrl) data.setRecordingMeta(butlrCallId, { recording_url: recordingUrl, recording_sid: null });
        if (data.setTranscriptMeta && transcript) data.setTranscriptMeta(butlrCallId, { transcript_path: `vapi:${vapiCallId}` });
        appendAgentLog(butlrCallId, { kind: 'end-of-call', summary, recordingUrl: recordingUrl || null, duration_seconds: durSec });
      } catch (e) { console.error('[vapi-wh] eoc save', e.message); }
    }
    // ── cost ledger ─────────────────────────────────────────────────────
    // Append the per-call Vapi voice-min usage to ~/.claude/cost-ledger.jsonl
    // via the cost-tracker skill (Steve's standing rule). Best-effort —
    // log failures don't affect call data.
    if (durSec > 0) {
      const minutes = (durSec / 60).toFixed(3);
      const { spawn } = require('child_process');
      const homeDir = process.env.HOME || '/root';
      const logScript = `${homeDir}/.claude/skills/cost-tracker/scripts/log.js`;
      try {
        const child = spawn('node', [
          logScript,
          '--api', 'vapi_voice',
          '--units', `${minutes}:voice_min`,
          '--app', 'butlr',
          '--note', `call=${butlrCallId || vapiCallId} dur=${durSec}s`,
        ], { detached: true, stdio: 'ignore' });
        child.unref();
      } catch (e) { console.error('[vapi-wh] cost-log spawn:', e.message); }
    }
    console.log(`[vapi-wh] end-of-call call=${butlrCallId} duration=${durSec}s rec=${recordingUrl ? 'yes' : 'no'}`);
  }

  res.json({ ok: true });
});

module.exports = router;