← back to Butlr

lib/twilio.js

357 lines

// Twilio Voice integration — dial, wait on hold, detect human, bridge to caller.
//
// PHASE 2 — DRY-RUN BY DEFAULT.
//
// Modes (env-controlled):
//   - TWILIO_DRY_RUN=1  → log what we would do, advance status, never spend credits (DEFAULT)
//   - TWILIO_DRY_RUN=0  → real outbound calls via Twilio
//
// Requires env when live:
//   TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER, PUBLIC_URL
//
// Flow (live mode, planned):
//   1. POST /v1/Accounts/{sid}/Calls.json
//        - To: business_phone
//        - From: TWILIO_PHONE_NUMBER
//        - Url: ${PUBLIC_URL}/twilio/twiml/hold/${call_id}
//        - StatusCallback: ${PUBLIC_URL}/twilio/status/${call_id}
//        - MachineDetection: 'DetectMessageEnd'  (separate IVR from human)
//        - AmdStatusCallback: ${PUBLIC_URL}/twilio/amd/${call_id}
//   2. Hold TwiML: <Play loop="0"> a silence file + <Gather> with speech_timeout to listen for human
//   3. On AmdStatusCallback with AnsweredBy=human → bridge:
//        - REST: Modify the call's TwiML to <Dial><Number>callback_phone</Number></Dial>
//        - Optional: <Say> a one-liner with the goal so the rep hears context
//   4. On call.completed → update status to 'done' (or 'failed' if duration < threshold)
//
// The worker polls data/calls.json for status='queued' rows and dispatches.
// 5-second tick. PM2 keeps it alive in prod.

const fs = require('fs');
const path = require('path');
const data = require('./data');

const DRY_RUN = process.env.TWILIO_DRY_RUN !== '0';  // default ON

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

// ── ALWAYS-ANNOUNCE PREAMBLE ─────────────────────────────────────────
// Steve's standing rule (2026-05-12): every outbound call MUST open with
// a two-party-consent announcement before any IVR navigation or recording.
// California Cal. Penal Code § 632 + 10 other two-party states. No exceptions.
// See ~/.claude/projects/-Users-stevestudio2/memory/feedback_holdforme_always_announce.md
function buildAnnouncement(call) {
  const onBehalfOf = call.callback_name || 'a Butlr customer';
  // BUTLR_INTRO_VARIANT=soft → drop the 'automated assistant' phrasing which is
  // triggering hangups at small-shop counter phones. Recording-disclosure is
  // PRESERVED (Cal. Penal Code § 632 + 2-party-consent rule).
  if ((process.env.BUTLR_INTRO_VARIANT || '').toLowerCase() === 'soft') {
    return [
      `Hi, this is Butler calling on behalf of ${onBehalfOf}, a real local client.`,
      `Heads up, this call is being recorded for Steve.`,
    ].join(' ');
  }
  return [
    `Hi, this is Butler calling on behalf of ${onBehalfOf}, a real local client.`,
    `Heads up, this call is being recorded for Steve.`,
  ].join(' ');
}
function announceTwiml(call) {
  const text = buildAnnouncement(call);
  // SOFT + CLONED — when running the soft intro AND a cached cloned-voice MP3 exists,
  // play the MP3 instead of Polly.Joanna so the call sounds human.
  // Recording disclosure is still present in the soft text (Cal. § 632 satisfied).
  if ((process.env.BUTLR_INTRO_VARIANT || '').toLowerCase() === 'soft'
      && process.env.BUTLR_INTRO_MP3_URL) {
    return `<Play>${process.env.BUTLR_INTRO_MP3_URL}</Play><Pause length="2"/>`;
  }
  return `<Say voice="Polly.Joanna">${text}</Say><Pause length="2"/>`;
}

// Dry-run state machine — simulates a real call advancing through stages.
// queued → dialing → on_hold → connected → done
// Each transition takes a few seconds so the UI shows live progression.
const DRY_RUN_STAGES = [
  { from: 'queued',    to: 'dialing',   delay_ms: 2000 },
  { from: 'dialing',   to: 'on_hold',   delay_ms: 4000 },
  { from: 'on_hold',   to: 'connected', delay_ms: 12000 }, // simulated 12s hold
  { from: 'connected', to: 'done',      delay_ms: 30000 }, // simulated 30s call
];

function pickNextStage(currentStatus) {
  return DRY_RUN_STAGES.find(s => s.from === currentStatus);
}

// ── SMS notification ────────────────────────────────────────────────
// When a call reaches a notify-worthy stage (on_hold reached, agent picked up),
// optionally send an SMS to callback_phone via Twilio. Dry-run logs.
// ── AUTH CREDENTIAL RESOLUTION ───────────────────────────────────────
// Prefer API Key (SK.../<secret>) over master Auth Token. Both flavors
// use HTTP basic auth, just with different username/password mapping:
//   API Key:    user=TWILIO_API_KEY_SID    pass=TWILIO_API_KEY_SECRET
//   Auth Token: user=TWILIO_ACCOUNT_SID    pass=TWILIO_AUTH_TOKEN
// The Account SID still goes in the URL path either way to identify
// which account to operate on.
function twilioAuth() {
  const accountSid = process.env.TWILIO_ACCOUNT_SID;
  const keySid     = process.env.TWILIO_API_KEY_SID;
  const keySecret  = process.env.TWILIO_API_KEY_SECRET;
  const authToken  = process.env.TWILIO_AUTH_TOKEN;
  if (!accountSid) return { ok: false, error: 'no_account_sid' };
  if (keySid && keySecret) {
    return { ok: true, accountSid, user: keySid, pass: keySecret, mode: 'api_key' };
  }
  if (authToken) {
    return { ok: true, accountSid, user: accountSid, pass: authToken, mode: 'auth_token' };
  }
  return { ok: false, error: 'no_credential', accountSid };
}
function twilioAuthHeader(auth) {
  return 'Basic ' + Buffer.from(`${auth.user}:${auth.pass}`).toString('base64');
}

async function sendSms({ to, body }) {
  if (!to || !body) return { ok: false, error: 'missing to/body' };
  if (DRY_RUN) {
    log(`SMS dry-run  to=${to}  body="${body.slice(0, 60)}…"`);
    return { ok: true, dry_run: true };
  }
  // ── DNC pre-flight for SMS (Steve's standing rule, 2026-05-13) ─────
  // Same gate as outbound calls — fail closed if snapshot missing.
  try {
    const dnc = require('./dnc-check');
    const result = dnc.checkPhone(to, 'sms');
    if (!result.allowed) {
      log(`DNC BLOCK sms  to=…${String(to).slice(-4)}  reason=${result.reason}  source=${result.source}`);
      return { ok: false, error: 'dnc_block', reason: result.reason, source: result.source };
    }
  } catch (e) {
    log(`DNC GATE ERROR sms — refusing to send: ${e.message}`);
    return { ok: false, error: 'dnc_gate_error', detail: e.message };
  }
  const auth = twilioAuth();
  const fromNum = process.env.TWILIO_PHONE_NUMBER;
  if (!auth.ok || !fromNum) {
    log('LIVE SMS skipped — missing creds:', auth.error || 'no_phone_number');
    return { ok: false, error: 'twilio_creds_missing' };
  }
  try {
    const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${auth.accountSid}/Messages.json`, {
      method: 'POST',
      headers: {
        'Authorization': twilioAuthHeader(auth),
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({ To: to, From: fromNum, Body: body }),
    });
    const json = await resp.json().catch(() => ({}));
    if (!resp.ok) {
      log('LIVE SMS failed:', resp.status, json && json.message);
      return { ok: false, error: 'sms_failed', status: resp.status, detail: json };
    }
    // Cost ledger — 1 outbound SMS (US rate $0.0083). Per Steve's standing rule.
    logCost('twilio_sms_us', 1, 'sms', `to=${String(to).slice(-4)}`);
    return { ok: true, sid: json.sid, mode: auth.mode };
  } catch (e) {
    log('LIVE SMS exception:', e.message);
    return { ok: false, error: 'sms_exception', detail: e.message };
  }
}

// Append-only cost-ledger emit. Best-effort — never blocks the call path.
function logCost(api, qty, unit, note) {
  try {
    const { spawn } = require('child_process');
    const homeDir = process.env.HOME || '/root';
    const child = spawn('node', [
      `${homeDir}/.claude/skills/cost-tracker/scripts/log.js`,
      '--api', api,
      '--units', `${qty}:${unit}`,
      '--app', 'butlr',
      '--note', note || '',
    ], { detached: true, stdio: 'ignore' });
    child.unref();
  } catch (e) { log('cost-log spawn:', e.message); }
}

// Send the right SMS for each status transition (only when notify_sms is on).
async function notifyOnStageChange(call, fromStatus, toStatus) {
  if (!call.notify_sms || !call.callback_phone) return;
  let body = null;
  if (toStatus === 'on_hold')   body = `Butlr: we're now on hold with ${call.business_name}. We'll text again when an agent picks up.`;
  if (toStatus === 'connected') body = `Butlr: an agent at ${call.business_name} just picked up. Calling you now — pick up to be bridged.`;
  if (toStatus === 'done')      body = `Butlr: your call to ${call.business_name} is complete. Open the app for the summary.`;
  if (toStatus === 'failed')    body = `Butlr: your call to ${call.business_name} couldn't complete. Open the app for details.`;
  if (body) await sendSms({ to: call.callback_phone, body });
}

async function dialCall(call) {
  if (DRY_RUN) {
    log(`DRY-RUN  call=${call.id} business="${call.business_name}" phone=${call.business_phone}`);
    log(`         goal="${call.goal.slice(0, 80)}…"`);
    log(`         callback=${call.callback_phone} (${call.callback_name})`);
    log(`[ANNOUNCE] preamble would play here:`);
    log(`           "${buildAnnouncement(call)}"`);
    data.updateStatus(call.id, 'dialing');
    return { ok: true, dry_run: true };
  }
  // LIVE — actually dial via Twilio REST API. TwiML lives at
  // /twilio/twiml/:call_id and always opens with the consent announcement.
  // Auth: prefer API Key (SK.../secret), fall back to Auth Token.
  const auth = twilioAuth();
  const fromNum    = process.env.TWILIO_PHONE_NUMBER;
  const publicUrl  = process.env.PUBLIC_URL;
  if (!auth.ok || !fromNum || !publicUrl) {
    log('LIVE mode requested but Twilio creds missing:', auth.error || 'no_phone_number_or_public_url');
    return { ok: false, error: 'twilio_creds_missing', detail: auth.error };
  }
  // Twilio requires StatusCallbackEvent to appear as a REPEATED param
  // (StatusCallbackEvent=initiated&StatusCallbackEvent=ringing&…), NOT
  // a single space-separated string. Passing a string makes Twilio reject
  // ALL events except the implicit `completed`, which is why our
  // /twilio/status/:id route never fires for ringing/answered/etc. Fix:
  // use URLSearchParams.append() per value.
  const body = new URLSearchParams({
    To: call.business_phone,
    From: fromNum,
    Url: `${publicUrl}/twilio/twiml/${call.id}`,
    StatusCallback: `${publicUrl}/twilio/status/${call.id}`,
    StatusCallbackMethod: 'POST',
    MachineDetection: 'DetectMessageEnd',
    AsyncAmd: 'true',
    AsyncAmdStatusCallback: `${publicUrl}/twilio/amd/${call.id}`,
    AsyncAmdStatusCallbackMethod: 'POST',
  });
  for (const evt of ['initiated', 'ringing', 'answered', 'completed']) {
    body.append('StatusCallbackEvent', evt);
  }
  try {
    const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${auth.accountSid}/Calls.json`, {
      method: 'POST',
      headers: {
        'Authorization': twilioAuthHeader(auth),
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body,
    });
    const json = await resp.json().catch(() => ({}));
    if (!resp.ok) {
      log('LIVE dial failed:', resp.status, json && json.message);
      return { ok: false, error: 'twilio_dial_failed', status: resp.status, detail: json };
    }
    data.updateStatus(call.id, 'dialing');
    if (data.setTwilioSid) data.setTwilioSid(call.id, json.sid);
    log(`LIVE dial placed · sid=${json.sid} · to=${call.business_phone} · auth=${auth.mode}`);
    return { ok: true, sid: json.sid };
  } catch (e) {
    log('LIVE dial exception:', e.message);
    return { ok: false, error: 'twilio_exception', detail: e.message };
  }
}

async function advanceDryRun(call) {
  const stage = pickNextStage(call.status);
  if (!stage) return false;
  const updatedAt = call.updated_at ? new Date(call.updated_at).getTime() : 0;
  const age = Date.now() - updatedAt;
  if (age < stage.delay_ms) return false;
  log(`DRY-RUN  call=${call.id} ${stage.from} → ${stage.to}`);
  data.updateStatus(call.id, stage.to);
  // Fire SMS notification on key transitions
  await notifyOnStageChange(call, stage.from, stage.to);
  return true;
}

let _tickRunning = false;
async function tick() {
  if (_tickRunning) return;
  _tickRunning = true;
  try {
    const all = data.listCallsUnscoped();  // background worker sees all users' queued calls
    for (const c of all) {
      // Get unmasked row for actual work
      const full = data.getCallUnscoped(c.id, true);
      if (!full) continue;

      if (full.status === 'queued') {
        // ── DNC pre-flight (Steve's standing rule, 2026-05-13) ──────────
        // Check BOTH business_phone (who we're dialing) and callback_phone
        // (where our SMS notifications would go if notify_sms is on). If
        // either is suppressed, the call is cancelled and dnc_flagged on
        // the row — never dispatched to Twilio or Vapi.
        try {
          const dnc = require('./dnc-check');
          const checks = [
            ['business_phone', dnc.checkPhone(full.business_phone, 'call')],
            ['callback_phone', dnc.checkPhone(full.callback_phone, 'sms')],
          ];
          const blocker = checks.find(([, r]) => !r.allowed);
          if (blocker) {
            const [field, result] = blocker;
            log(`DNC BLOCK call=${full.id} ${field}=…${String(full[field]||'').slice(-4)} reason=${result.reason} source=${result.source}`);
            data.updateStatus(full.id, 'cancelled');
            if (data.patchRow) data.patchRow(full.id, {
              dnc_flagged: true,
              dnc_reason: result.reason,
              dnc_source: result.source,
              dnc_field: field,
              dnc_blocked_at: new Date().toISOString(),
            });
            continue;
          }
        } catch (e) {
          // Fail closed — if the DNC snapshot is missing, do NOT dial.
          // Snapshot is required for live mode. Steve must obtain the SAN
          // subscription (https://telemarketing.donotcall.gov/) and place
          // the file at $DNC_FILE before any live dial can succeed.
          log(`DNC GATE ERROR call=${full.id} — refusing to dial: ${e.message}`);
          data.updateStatus(full.id, 'cancelled');
          if (data.patchRow) data.patchRow(full.id, {
            dnc_flagged: true,
            dnc_reason: 'gate_error',
            dnc_source: e.code || 'unknown',
            dnc_blocked_at: new Date().toISOString(),
          });
          continue;
        }

        // Backend dispatch — Vapi is the production conversational stack;
        // Twilio remains as the fallback for legacy Twilio-only flows.
        const backend = (process.env.BUTLR_BACKEND || 'vapi').toLowerCase();
        if (backend === 'vapi') {
          try {
            const vapi = require('./vapi');
            const r = await vapi.dialCall(full);
            if (r.ok) {
              data.updateStatus(full.id, 'dialing');
              if (data.patchRow) data.patchRow(full.id, { vapi_call_id: r.vapi_call_id });
            } else {
              log('vapi dial failed, falling back to Twilio:', r.error, r.detail);
              await dialCall(full);
            }
          } catch (e) {
            log('vapi dispatch exception, falling back to Twilio:', e.message);
            await dialCall(full);
          }
        } else {
          await dialCall(full);
        }
      } else if (DRY_RUN && ['dialing','on_hold','connected'].includes(full.status)) {
        await advanceDryRun(full);
      }
    }
  } catch (e) {
    log('tick error:', e.message);
  } finally {
    _tickRunning = false;
  }
}

function start({ intervalMs = 5000 } = {}) {
  log(`worker starting · DRY_RUN=${DRY_RUN} · interval=${intervalMs}ms`);
  setInterval(tick, intervalMs);
  // also tick once immediately
  setTimeout(tick, 500);
}

module.exports = { start, tick, dialCall, sendSms, logCost, DRY_RUN, buildAnnouncement, announceTwiml };