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