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