← back to Butlr

lib/vapi.js

224 lines

// Vapi outbound dialer — alternative backend to lib/twilio.js.
//
// Flow:
//   1) Butlr's wizard adds a call row via data.addCall(body, userId)
//   2) Worker tick (in lib/twilio.js or this module) picks up queued rows
//   3) dialCall(row) POSTs to https://api.vapi.ai/call/phone with assistantId
//      + phoneNumberId + customer.number + assistantOverrides.firstMessage +
//      assistantOverrides.model.messages (system) injecting the per-call goal
//   4) Vapi handles the actual outbound dial, speech-to-speech, IVR navigation,
//      and emits events to the serverUrl configured on the assistant
//      (currently https://butlr.agentabrams.com/vapi/webhook)
//
// All env vars come from the secrets-manager fan-out:
//   VAPI_API_KEY            — server-side (Bearer)
//   VAPI_ASSISTANT_ID       — the Butlr v1 assistant
//   VAPI_PHONE_NUMBER_ID    — Steve's Twilio +1-606-713-0489 imported to Vapi
//   VAPI_VOICE_ID           — Steve's ElevenLabs IVC clone (Xa9qV4wNbvSkdUWsYLzq)

const VAPI_BASE = 'https://api.vapi.ai';

function log(...args) { console.log('[vapi]', new Date().toISOString(), ...args); }

function authHeader() {
  const key = process.env.VAPI_API_KEY;
  if (!key) throw new Error('VAPI_API_KEY missing');
  return { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' };
}

// Build the per-call goal override that Vapi appends on top of the assistant's
// baked-in system prompt. Keeps the assistant generic; each call gets a goal.
function goalOverride(call) {
  const callbackName = call.callback_name || 'a customer';
  const callbackPhone = call.callback_phone || 'unknown';
  const acctLast4 = (call.account_number || '').slice(-4);
  return {
    firstMessage: `Hi, this is an automated assistant calling on behalf of ${callbackName}. This call may be recorded for quality.`,
    model: {
      // Provider/model REQUIRED by Vapi assistantOverrides — even though we just
      // want to append a system message, the override replaces the whole model
      // block. So we re-state the assistant's defaults here.
      provider: 'openai',
      model: 'gpt-4o',  // upgraded from gpt-4o-mini 2026-05-21 — mini ignores anti-endCall rules
      temperature: 0.3,
      messages: [
        {
          role: 'system',
          content: [
            `GOAL FOR THIS CALL: ${call.goal}`,
            ``,
            `Calling on behalf of: ${callbackName}`,
            `Callback phone (if rep asks): ${callbackPhone}`,
            `Account last-4 (if rep asks AND only the last 4): ${acctLast4 || 'not provided'}`,
            ``,
            `═══════════════════ HARD RULES ═══════════════════`,
            ``,
            `1. AD / PROMO / MARKETING AUDIO → PRESS 0 IMMEDIATELY via the dtmf tool.`,
            `   These are NEVER an answer to the goal — they're commercials. The MOMENT you recognize`,
            `   the audio is promotional, call the dtmf tool with digit "0" to escape to a human.`,
            `   Examples of promo audio to press 0 on instantly:`,
            `   - "try our new ___", "zest sauce", "loaded tots", "choose 2 for $6.99"`,
            `   - "place your order online at ___.com"`,
            `   - "have you tried our ___", "thanks for choosing ___" + a sales pitch`,
            `   - any marketing-voice narration that isn't a direct question to you`,
            `   Do NOT wait. Do NOT endCall. Do NOT stay silent. Press 0 fast.`,
            ``,
            `2. HOLD = STAY ON THE LINE. Period. No exceptions.`,
            `   ANY of these phrases means HOLD, not goodbye:`,
            `   - "Can you hold please?" / "Could you hold?" / "Hold on a moment" / "One sec"`,
            `   - "Please hold" / "Thank you for holding"`,
            `   - "We are with another customer" / "All reps are busy" / "Helping other customers"`,
            `   - Hold music, silence, beeps, ringing-back sounds`,
            `   - "Thank you for calling [business]" (this is the greeting, NOT the answer to your goal)`,
            `   When you hear any of the above, say simply "Sure, I'll hold" (or nothing) and then`,
            `   WAIT SILENTLY for up to 3 minutes. DO NOT call endCall. DO NOT say "Got it. Goodbye".`,
            `   The call is NOT over just because someone asked you to hold — they are about to come back.`,
            ``,
            `3. ORDER-BOTS that ask "is this pickup or delivery?" or "are you placing an order?":`,
            `   DO NOT repeat the goal question over and over — that's a useless loop. Instead say`,
            `   ONCE, clearly: "Neither. I just have a quick question — can I speak to a person please?"`,
            `   Then go silent and wait. If the bot loops again, press 0 via the dtmf tool to escape`,
            `   to a human agent.`,
            ``,
            `4. IVR menus ("press 1 for English", "press 2 for hours", etc.): use the dtmf tool to`,
            `   press the digit that gets you closest to a human or to the answer. If the menu`,
            `   explicitly offers store hours, press THAT. Otherwise press 0 to reach an operator.`,
            ``,
            `5. endCall CRITERIA — only end the call when EITHER:`,
            `   (a) A real human gave an EXPLICIT on-topic answer to the goal (e.g. "we close at 11pm",`,
            `       "we're open until midnight tonight"). Repeat the answer back briefly to confirm,`,
            `       thank them, then endCall with the answer in the reason field.`,
            `   (b) You've waited silently for 3+ minutes with no human and only hold/promo audio.`,
            `       endCall with reason "no human reached after 3 minutes hold".`,
            `   NEVER endCall just because audio is playing. Audio ≠ answer.`,
            ``,
            `6. NEVER say "Got it. Goodbye" unless a real human just told you the actual answer.`,
            ``,
            `7. MISSING INFO that the rep demands (account number, SSN, DOB, full card #, password, member ID):`,
            `   Do NOT get stuck saying "I don't have that handy." Instead — pick whichever is better`,
            `   given the situation:`,
            `   (a) Offer a best-guess placeholder (e.g. last-4 "1234" or a plausible account # format).`,
            `       The rep will either accept it or ask for the real one — either way the call moves.`,
            `   (b) Say: "I don't have that on me right now — Steve will call back with that info`,
            `       within the hour. Anything else you'd need from him when he calls?"`,
            `       Then thank the rep and endCall with reason: "need-callback: rep required <field-name>".`,
            `   Pick (a) when the question is recoverable and (b) when it would block all progress.`,
            `   Either is acceptable — DO NOT just keep apologizing for missing info.`,
            ``,
            `═════════════════════════════════════════════════`,
          ].join('\n'),
        },
      ],
      tools: [
        { type: 'dtmf' },
        { type: 'endCall' },
      ],
    },
  };
}

// Place an outbound call via Vapi. Returns { ok, vapi_call_id, customer_number }
// or { ok: false, error, status, detail }.
async function dialCall(call) {
  const assistantId = process.env.VAPI_ASSISTANT_ID;
  const phoneNumberId = process.env.VAPI_PHONE_NUMBER_ID;
  if (!assistantId || !phoneNumberId) {
    return { ok: false, error: 'missing_assistant_or_phone_id' };
  }
  if (!call.business_phone) return { ok: false, error: 'no_business_phone' };

  const body = {
    assistantId,
    phoneNumberId,
    customer: { number: call.business_phone, name: call.business_name || '' },
    assistantOverrides: goalOverride(call),
    metadata: {
      butlr_call_id: call.id,
      butlr_business_name: call.business_name,
      butlr_user_id: call.user_id || null,
    },
  };

  try {
    const r = await fetch(VAPI_BASE + '/call/phone', {
      method: 'POST',
      headers: authHeader(),
      body: JSON.stringify(body),
    });
    if (!r.ok) {
      const text = await r.text().catch(() => '');
      log('LIVE dial FAIL', { call_id: call.id, status: r.status, body: text.slice(0, 300) });
      return { ok: false, error: 'vapi_http_' + r.status, detail: text.slice(0, 300) };
    }
    const j = await r.json();
    log('LIVE dial placed', { call_id: call.id, vapi_id: j.id, to: call.business_phone });
    return { ok: true, vapi_call_id: j.id, customer_number: call.business_phone };
  } catch (e) {
    log('LIVE dial exception', { call_id: call.id, error: e.message });
    return { ok: false, error: 'vapi_exception', detail: e.message };
  }
}

// Live-takeover — push a one-shot say into an active Vapi call. Uses Vapi's
// POST /call/{id}/control endpoint (control verb: 'say').
async function sayOnCall(vapiCallId, text) {
  if (!vapiCallId || !text) return { ok: false, error: 'missing_args' };
  try {
    const r = await fetch(VAPI_BASE + '/call/' + vapiCallId + '/control', {
      method: 'POST',
      headers: authHeader(),
      body: JSON.stringify({ type: 'say', message: String(text).slice(0, 500), endCallAfterSpoken: false }),
    });
    return { ok: r.ok, status: r.status };
  } catch (e) {
    return { ok: false, error: 'control_exception', detail: e.message };
  }
}

// Live-takeover hangup — end the active Vapi call now.
async function endCall(vapiCallId, reason = 'owner ended call from listen page') {
  if (!vapiCallId) return { ok: false, error: 'missing_call_id' };
  try {
    const r = await fetch(VAPI_BASE + '/call/' + vapiCallId + '/control', {
      method: 'POST',
      headers: authHeader(),
      body: JSON.stringify({ type: 'end-call', reason }),
    });
    return { ok: r.ok, status: r.status };
  } catch (e) {
    return { ok: false, error: 'control_exception', detail: e.message };
  }
}

// Fetch the recording URL for a completed Vapi call. Used by listen page
// archive-section when the call.status flips to done/failed and Vapi has
// finished post-call processing (~30s after hangup).
async function getRecording(vapiCallId) {
  if (!vapiCallId) return null;
  try {
    const r = await fetch(VAPI_BASE + '/call/' + vapiCallId, { headers: authHeader() });
    if (!r.ok) return null;
    const j = await r.json();
    return j.recordingUrl || (j.artifact && j.artifact.recordingUrl) || null;
  } catch { return null; }
}

async function transferCall(vapiCallId, destinationNumber) {
  if (!vapiCallId || !destinationNumber) return { ok: false, error: 'missing_args' };
  try {
    const r = await fetch(VAPI_BASE + '/call/' + vapiCallId + '/control', {
      method: 'POST',
      headers: authHeader(),
      body: JSON.stringify({
        type: 'transfer',
        destination: { type: 'number', number: destinationNumber },
      }),
    });
    return { ok: r.ok, status: r.status };
  } catch (e) {
    return { ok: false, error: 'control_exception', detail: e.message };
  }
}

module.exports = { dialCall, sayOnCall, endCall, transferCall, getRecording };