← back to Butlr

routes/twilio-webhooks.js

856 lines

// Twilio webhook routes:
//   GET  /twilio/twiml/:call_id            — the call's TwiML script
//   POST /twilio/status/:call_id           — status callback (in-progress, completed)
//   POST /twilio/recording/:call_id        — recording-complete callback (kicks Whisper)
//   POST /twilio/amd/:call_id              — answering machine detection
//   GET  /twilio/audio/announce/:hash.mp3  — pre-rendered ElevenLabs announce file
//
// The TwiML script ALWAYS:
//   1. <Start><Stream url="wss://.../stream/<call_id>"/></Start>  — start listen-in
//   2. <Play>EL-announce.mp3</Play>  (or <Say> fallback)            — two-party-consent preamble
//   3. <Pause length="2"/>                                          — beat for objection
//   4. <Record recordingStatusCallback=…/>                          — record entire call
//      OR  <Dial record="record-from-answer-dual"/>                 — when bridging
//   5. <Pause length="6000"/>                                       — keep call open to hold
//
// Recording consent is in the announce; we never start recording without it.

const express = require('express');
const path = require('path');
const fs = require('fs');
const { buildAnnouncement } = require('../lib/twilio');
const elevenlabs = require('../lib/elevenlabs');
const data = require('../lib/data');
const transcribe = require('../lib/transcribe');
const callOverrides = require('../lib/call-overrides');

// IVR DTMF lookup — given a call's business phone, return the configured
// digit sequence (delay → press) for navigating to a human. Pulled from
// data/businesses.json's `ivr_dtmf` field per business.
function ivrDtmfForPhone(phone) {
  try {
    const all = require('../data/businesses.json');
    const normalize = s => String(s||'').replace(/\D/g,'');
    const wanted = normalize(phone);
    // Compare with leading-1 stripped both ways
    const match = all.find(b => {
      const bn = normalize(b.phone);
      return bn === wanted || ('1'+bn) === wanted || bn === ('1'+wanted);
    });
    return match && Array.isArray(match.ivr_dtmf) ? match.ivr_dtmf : [];
  } catch { return []; }
}

const router = express.Router();

function publicUrl(req) {
  return process.env.PUBLIC_URL || `${req.protocol}://${req.get('host')}`;
}
function wsUrl(req) {
  const pub = publicUrl(req);
  return pub.replace(/^http/, 'ws');
}

// ── In-memory conversation history per call_id ──────────────────────
// Keeps the back-and-forth between Wells Fargo IVR/rep + Butlr's AI agent.
// Cleared when the call completes.
const CONV = new Map();
function turnsFor(callId) {
  if (!CONV.has(callId)) CONV.set(callId, []);
  return CONV.get(callId);
}

// ── Stream lifecycle (corrected 2026-05-13) ─────────────────────────
// Per Twilio Media Streams docs: <Start><Stream/></Start> persists for
// the lifetime of the TwiML execution context. A <Redirect> ENDS that
// context — Twilio fetches a new TwiML document and the prior stream is
// torn down. `name=` attribute dedupes within a single document only;
// it does NOT survive Redirect boundaries.
//
// Correct fix (Option B from the debugger subagent review): the initial
// /twiml/:call_id renders <Start><Stream> ONCE; the /gather handler
// returns a self-loop inline <Gather> (no <Redirect>) so the response
// chain stays in one execution context for the whole call. The Stream
// stays alive until call hangs up.
function streamStartXml(callId, wsBase) {
  // track="both_tracks" → Steve's listen page hears BOTH sides (the IVR/rep
  // AND Butlr's TTS responses). Earlier "inbound_track only" was wrong: AI
  // and rep speak in alternation, not overlap, so there's no echo to worry
  // about. The cost is the listener also hears the consent announce; that's
  // fine — it's a feature (Steve gets to verify the announce played).
  return `<Start><Stream url="${wsBase}/stream/${callId}" track="both_tracks"/></Start>`;
}

// ── /twilio/twiml/:call_id  (initial TwiML when Twilio dials a business)
async function twimlHandler(req, res) {
  const callId = req.params.call_id;
  if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).type('text/xml').send('<Response><Hangup/></Response>');

  const call = data.getCallUnscoped(callId, true);
  if (!call) return res.status(404).type('text/xml').send('<Response><Hangup/></Response>');

  const text = buildAnnouncement(call);
  const pub = publicUrl(req);
  const ws = wsUrl(req);

  // Pre-warm ElevenLabs announce; fall back to <Say> if EL unavailable.
  let announceXml;
  try {
    const synth = await elevenlabs.synthesize(text);
    if (synth.ok) {
      const fname = path.basename(synth.path);
      announceXml = `<Play>${pub}/twilio/audio/announce/${fname}</Play>`;
    } else {
      announceXml = `<Say voice="Polly.Joanna">${escapeXml(text)}</Say>`;
    }
  } catch {
    announceXml = `<Say voice="Polly.Joanna">${escapeXml(text)}</Say>`;
  }

  // Reset conversation history on each fresh call
  CONV.set(callId, []);

  // AI-agent loop: announce consent + start Gather (speech or DTMF) which
  // POSTs the transcribed input to /twilio/gather/:call_id. Our handler
  // calls Ollama with the goal + conversation history, returns a new TwiML
  // chunk with the AI's verbal response + a new Gather for the next turn.
  // Steve's phone never rings — Butlr conducts the entire call autonomously.
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  ${streamStartXml(callId, ws)}
  ${announceXml}
  <Pause length="1"/>
  <Gather input="speech dtmf"
          action="${pub}/twilio/gather/${callId}"
          partialResultCallback="${pub}/twilio/partial/${callId}"
          method="POST"
          speechTimeout="1"
          timeout="10"
          actionOnEmptyResult="true"
          speechModel="phone_call"
          hints="press 0,representative,agent,operator,customer service,billing,new account,credit limit,for a representative">
    <Pause length="10"/>
  </Gather>
  <!-- actionOnEmptyResult=true means the Gather POSTs to /gather even on
       empty input; /gather self-loops with inline Gather, so we never
       return here. No Redirect verb here on purpose - it would tear down
       the Stream. -->
</Response>`;
  res.type('text/xml').send(xml);
}
router.get('/twiml/:call_id', twimlHandler);
router.post('/twiml/:call_id', twimlHandler);

// ── POST /twilio/gather/:call_id ──────────────────────────────────────
// Twilio POSTs here after each Gather completes. Body contains
// SpeechResult (transcript) or Digits. We feed this + call goal +
// conversation history to Ollama (qwen3:14b), get back the AI's
// next move, return TwiML that either:
//   - speaks the AI's response and gathers again (continue conversation)
//   - plays DTMF digits (navigate IVR)
//   - hangs up (goal complete or escalation needed)
router.post('/gather/:call_id', express.urlencoded({ extended: true }), async (req, res) => {
  const callId = req.params.call_id;
  if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).type('text/xml').send('<Response><Hangup/></Response>');
  const call = data.getCallUnscoped(callId, true);
  if (!call) return res.status(404).type('text/xml').send('<Response><Hangup/></Response>');

  const pub = publicUrl(req);
  const speechRaw = String(req.body.SpeechResult || '').trim();
  const digitsRaw = String(req.body.Digits || '').trim();
  const incoming = speechRaw || (digitsRaw ? `[user pressed digits: ${digitsRaw}]` : '');

  const turns = turnsFor(callId);
  if (incoming) turns.push({ role: 'user', content: incoming });
  console.log(`[ai-agent] call=${callId} heard: "${incoming.slice(0,80)}"  turn=${turns.length}`);

  // ── Voicemail-IVR guard (Steve's HARD RULE — 2026-05-26) ────────────────
  // Twilio AMD false-positives chatty voicemail systems as AnsweredBy=human /
  // unknown, so the machine-hangup in /twilio/amd never fires and the AI
  // agent then happily navigates the voicemail menu (call Tjz8cfFd burned
  // turns pressing 0/1 inside Civil Coffee's voicemail tree). This is a
  // deterministic belt-and-suspenders bail: if what we just heard matches
  // voicemail-tree language, hang up IMMEDIATELY — no LLM round-trip.
  // Voicemail = wasted money. See feedback_butlr_amd_auto_hangup.md.
  if (looksLikeVoicemail(incoming)) {
    console.log(`[ai-agent] call=${callId} VOICEMAIL-GUARD hangup — heard: "${incoming.slice(0,80)}"`);
    try { data.patchRow && data.patchRow(callId, { notes: ((call.notes || '') + ' [auto-hangup: voicemail-IVR language guard]') }); } catch (e) {}
    data.updateStatus(callId, 'done');
    return res.type('text/xml').send('<?xml version="1.0" encoding="UTF-8"?>\n<Response><Hangup/></Response>');
  }

  // ── Live takeover check — skip LLM entirely if owner queued an action ────
  // Owner can POST to /api/calls/:id/say or /hangup from the listen page;
  // we consume that here and short-circuit the LLM round-trip.
  const override = callOverrides.consume(callId);
  if (override.hangup) {
    console.log(`[takeover] call=${callId} hangup`);
    const goodbye = await playOrSay('Thank you. Goodbye.', pub);
    data.updateStatus(callId, 'done');
    return res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<Response>${goodbye}<Hangup/></Response>`);
  }
  if (override.sayText) {
    console.log(`[takeover] call=${callId} say: "${override.sayText.slice(0,80)}"`);
    turns.push({ role: 'assistant', content: `[OWNER SPOKE]: ${override.sayText}` });
    const speak = await playOrSay(override.sayText, pub);
    const continueGather = `<Gather input="speech dtmf"
              action="${pub}/twilio/gather/${callId}"
              partialResultCallback="${pub}/twilio/partial/${callId}"
              method="POST"
              speechTimeout="1"
              timeout="10"
              actionOnEmptyResult="true"
              speechModel="phone_call">
        <Pause length="10"/>
      </Gather>`;
    return res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<Response>${speak}${continueGather}</Response>`);
  }

  // Build the LLM prompt
  // Action-first design: model must emit a JSON {action:...} object whenever
  // the input contains anything that resembles an IVR menu, hold music,
  // verification ask, or goal-complete signal. Prose is reserved for when
  // a human rep is actually on the line. The 2026-05-12 MacEnthusiasts test
  // call exposed that qwen2.5:7b would deflect with "One moment please…"
  // instead of pressing a digit; this prompt corrects that with worked
  // examples for every action type.
  const goalShort = call.goal.length > 80 ? call.goal.slice(0, 80) + '…' : call.goal;
  const customerName = call.callback_name || 'a customer';
  const systemPrompt = `You are Butlr, an automated phone assistant calling ${call.business_name} for ${customerName}.

GOAL: ${call.goal}

═══ DECISION TABLE — pick ONE action per turn ═══

You will hear text from the other end of the line. Identify what KIND of input it is, then emit the matching response. JSON actions MUST be on their own line with no other text.

1. IVR MENU heard (any list of "press N for X" / "for X press N" / "say or press N") → emit JSON.
   CRITICAL: ONLY press a digit that was EXPLICITLY mentioned in the menu. NEVER invent a digit.
   If the menu has options but none directly serves the goal, press 0 (operator).
   The digit you output MUST literally appear in the input you just heard.
   Examples:
     Input:  "For sales, press 2. For service, press 3. For store hours, press 5."
     Output: {"action":"press","digit":"5"}
     Input:  "Para español, oprima 9. For English, press 1."
     Output: {"action":"press","digit":"1"}
     Input:  "For roadside, press 1. For membership, press 3."   (goal: check membership)
     Output: {"action":"press","digit":"3"}

2. YES/NO QUESTION from IVR ("are you calling from X? Say yes or no") → speak the word, NOT JSON.
     Input:  "Are you calling from Kentucky? Say yes or no."
     Output: No
     Input:  "Is this a roadside emergency? Yes or no?"
     Output: No

0. FIRST TURN (no prior "assistant" message in this conversation) → GREET + STATE GOAL.
   This is the only time you proactively speak without being prompted. The callee may say
   nothing, say "hello", or ask "can you hear me?" — IGNORE the wait rule on the first turn.
     If turns has no prior assistant content:
       Output: Hi, this is Butlr calling on behalf of ${customerName}. ${goalShort}
   On the SECOND turn onward, follow cases 1-8 below.

3. HOLD MUSIC / "please continue holding" / silence / empty input → wait:
     Input:  "Please continue to hold. Your call is important to us."
     Output: {"action":"wait"}
     Input:  ""   (empty — they didn't speak, or our speech got dropped)
     Output: {"action":"wait"}

4. VERIFICATION request only the customer can answer (full SSN, password, full DOB) → escalate:
     Input:  "I'll need your full social security number and date of birth."
     Output: {"action":"escalate","reason":"rep asked for full SSN + DOB"}

5. GOAL ACHIEVED — ONLY when the rep EXPLICITLY answered the goal question.
   "Goal achieved" means the rep gave you the actual answer, not just greeted you.
   NEVER hang up on empty input. NEVER hang up immediately after your own introduction.
     Goal "verify membership" — achieved means: "Yes that membership is active" or "No it expired".
     Goal "get store hours"  — achieved means: "We're open 10 to 6 Monday-Saturday".
     Examples:
       Input:  "Yes, membership is active and renews March 2027."
       Output: {"action":"hangup","reason":"confirmed active through 2027"}
       Input:  "We're open 10 to 6 Monday through Saturday."
       Output: {"action":"hangup","reason":"got store hours M-Sat 10-6"}

6. HUMAN REP greets you (after pressing through IVR) → speak ONE short sentence using THE ACTUAL NAME they JUST SAID.
   Extract the rep's name from the input. Common patterns: "this is NAME", "my name is NAME", "I'm NAME".
   If no name was said, say "Hi" with no name. DO NOT memorize names from examples below.
   Examples (notice the name CHANGES — extract from each input, never copy from another):
     Input:  "Thanks for calling MacEnthusiasts, this is Sarah, how can I help?"
     Output: Hi Sarah, I'm calling for ${customerName} — quick question about ${goalShort}
     Input:  "Hi this is Carol, are you safe and where are you located?"
     Output: Hi Carol, I'm calling for ${customerName} — quick question about ${goalShort}
     Input:  "Triple A this is Marcus how can I help?"
     Output: Hi Marcus, I'm calling for ${customerName} — quick question about ${goalShort}
     Input:  "Customer service, how can I help?"   (no name given)
     Output: Hi, I'm calling for ${customerName} — quick question about ${goalShort}

7. REP FOLLOW-UP after your intro (clarifying question, asks for lookup info) → answer concisely.
     Input:  "Sure, what's the phone or membership number on the account?"
     Output: The phone on file is ${call.callback_phone || 'unknown'}.
     Input:  "What's the name on the account?"
     Output: It's ${customerName}.

8. INPUT-ASK from an automated system ("enter your account number", "say your member number", "enter your 10-digit phone number on your keypad") AND we don't have that info in the data above → SPEAK a transfer request, NEVER press a random digit:
     Input:  "Please enter the 10-digit phone number on the account."
     Output: I'm sorry, I don't have that handy. Could you transfer me to a representative please?
     Input:  "Say or enter your account number."
     Output: I don't have the account number. Could I speak with a representative please?

═══ HARD RULES ═══
- NEVER greet a rep by a name they didn't just say. If no name was given, just "Hi".
- NEVER press a digit that wasn't explicitly offered in the menu.
- NEVER press # / * / 0 just to get past an input-ask. SPEAK a transfer request instead.
- NEVER claim "goal achieved" without an explicit answer from the rep.
- NEVER reply with "One moment please" or "I'm checking on that".
- If asked to enter / say / provide info that isn't listed in your data above, SPEAK case 8 — do NOT press anything.
- Empty input → wait. Don't hangup. Don't speak filler. EXCEPTION — on the FIRST turn (no prior assistant message), GREET per Case 0 even on empty input.
- If the IVR repeats itself, press 0 on the SECOND repeat.
- Keep ANY prose reply under 20 words.
- Account info if asked: last4 ${(call.account_number||'').slice(-4) || 'n/a'}, SSN ${call.last4_ssn || 'n/a'}, ZIP ${call.billing_zip || 'n/a'}, DOB ${call.date_of_birth || 'n/a'}.

You will now hear what ${call.business_name} just said. Decide ACTION first, then format.`;

  // ── Deterministic first-turn opener (2026-05-21) ──────────────────
  // The system prompt is wired for "wait until rep talks" so on the FIRST
  // turn the LLM tends to reply with {action:"wait"} — leaving the callee
  // in silence. For ai_agent calls we ALWAYS want to introduce + state goal
  // on turn 1 regardless of what Twilio Gather transcribed. Short-circuit
  // the LLM here.
  const priorAssistant = turns.some(t => t.role === 'assistant');
  const isSelfTest = call.business_phone && call.callback_phone && call.business_phone.replace(/\D/g, '') === call.callback_phone.replace(/\D/g, '');
  if (!priorAssistant && call.mode !== 'hold' && call.mode !== 'bridge' && isSelfTest) {
    const opener = `Hi, this is Butlr calling on behalf of ${customerName}. ${goalShort}`;
    console.log(`[ai-agent] call=${callId} first-turn opener: "${opener.slice(0,120)}"`);
    turns.push({ role: 'assistant', content: opener });
    const speakTwiml = await playOrSay(opener, pub);
    const gather = `<Gather input="speech dtmf" action="${pub}/twilio/gather/${callId}" partialResultCallback="${pub}/twilio/partial/${callId}" method="POST" speechTimeout="1" timeout="10" actionOnEmptyResult="true" speechModel="phone_call"><Pause length="10"/></Gather>`;
    res.type('text/xml').send('<?xml version="1.0" encoding="UTF-8"?><Response>' + speakTwiml + gather + '</Response>');
    return;
  }

  // ── LLM call with hard 4s timeout ────────────────────────────────────
  // Model: hermes3:8b — warm response ~0.3s on M2 Max vs qwen3:14b ~6.9s.
  // Phone-call latency budget is sub-3s; anything slower and the human
  // hears silence and hangs up.
  // OLLAMA_HOST override supported for non-default endpoints.
  let aiText = '';
  let aiAction = null;
  const OLLAMA_URL = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
  const MODEL = process.env.BUTLR_LLM_MODEL || 'hermes3:8b';
  const ac = new AbortController();
  const timeoutMs = parseInt(process.env.BUTLR_LLM_TIMEOUT_MS || '4000', 10);
  const timeoutId = setTimeout(() => ac.abort(), timeoutMs);
  try {
    const messages = [{ role: 'system', content: systemPrompt }, ...turns.slice(-12)];
    const backend = (process.env.BUTLR_LLM_BACKEND || 'ollama').toLowerCase();
    let r;
    if (backend === 'openai') {
      const OPENAI_MODEL = process.env.BUTLR_LLM_MODEL_OPENAI || 'gpt-4o-mini';
      r = await fetch('https://api.openai.com/v1/chat/completions', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
        },
        body: JSON.stringify({ model: OPENAI_MODEL, messages, temperature: 0.3, max_tokens: 200 }),
        signal: ac.signal,
      });
      if (r && r.ok) {
        const j = await r.json();
        aiText = ((j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || '').trim();
      } else {
        console.error('[ai-agent] OpenAI http error:', r && r.status);
      }
    } else {
      r = await fetch(`${OLLAMA_URL}/api/chat`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ model: MODEL, stream: false, messages, options: { temperature: 0.3, num_predict: 120 }, keep_alive: '15m' }),
        signal: ac.signal,
      });
      if (r && r.ok) {
        const j = await r.json();
        aiText = (j.message && j.message.content || '').replace(/<think>[\s\S]*?<\/think>/g, '').trim();
      } else {
        console.error('[ai-agent] Ollama http error:', r && r.status);
      }
    }
    // Try to parse as JSON action (be forgiving with whitespace, single quotes)
    const m = aiText.match(/\{\s*"?action"?\s*:\s*"[^"]+"[\s\S]*?\}/);
    if (m) try { aiAction = JSON.parse(m[0].replace(/'/g, '"')); } catch {}
  } catch (e) {
    if (e.name === 'AbortError') {
      console.error(`[ai-agent] LLM timed out after ${timeoutMs}ms — falling back`);
      aiText = "One moment please, I'm checking on that.";
    } else {
      console.error('[ai-agent] LLM error:', e.message);
    }
  } finally {
    clearTimeout(timeoutId);
  }

  if (!aiText) aiText = 'Sorry, could you repeat that?';
  turns.push({ role: 'assistant', content: aiText });
  console.log(`[ai-agent] call=${callId} reply: "${aiText.slice(0,100)}"  action=${aiAction ? aiAction.action : '(speak)'}`);

  // Build TwiML response based on action — all spoken text goes through
  // ElevenLabs (cloned voice) per feedback_always_elevenlabs_voice.md.
  let actionTwiml = '';
  if (aiAction && aiAction.action === 'press') {
    actionTwiml = `<Play digits="${String(aiAction.digit||'0').replace(/[^0-9*#]/g,'')}"/>`;
  } else if (aiAction && aiAction.action === 'hangup') {
    actionTwiml = (await playOrSay('Thank you for your time. Goodbye.', pub)) + '<Hangup/>';
    data.updateStatus(callId, 'done');
  } else if (aiAction && aiAction.action === 'escalate') {
    actionTwiml = (await playOrSay("I'll have my customer call you back directly with that information. Thank you. Goodbye.", pub)) + '<Hangup/>';
    data.updateStatus(callId, 'done');
  } else if (aiAction && aiAction.action === 'wait') {
    actionTwiml = `<Pause length="15"/>`;
  } else if (aiAction && (aiAction.action === 'transfer' || aiAction.action === 'representative' || aiAction.action === 'rep')) {
    // Model invented a non-canonical action (we only spec press/wait/hangup/escalate),
    // but the intent is clearly "ask to be transferred". Don't speak the raw JSON;
    // map to a polite transfer request.
    actionTwiml = await playOrSay('Could you transfer me to a representative please?', pub);
  } else {
    // Normal speech response — strip any JSON-looking suffix.
    // If the strip leaves an empty string (AI emitted ONLY JSON for a non-canonical
    // action), DO NOT fall back to speaking the raw JSON — speak a safe transfer
    // request instead. Previously this bug had the rep hearing literal "action
    // transfer no card info needed" on Capital One call SZQwbOM_.
    const cleanSpeech = aiText.replace(/\{[^{}]*"action"[^{}]*\}/g, '').trim();
    const safeText = cleanSpeech || 'Could you transfer me to a representative please?';
    actionTwiml = await playOrSay(safeText, pub);
  }

  // Continue the conversation with another inline Gather (unless we hung up).
  // CRITICAL: do NOT use <Redirect> here — Redirect ends the TwiML execution
  // context and kills the <Stream>. Inline Gather loops within the same
  // execution context. Twilio loops Gather → /gather → next response → Gather
  // and the stream persists across all of it.
  const continueTwiml = (aiAction && (aiAction.action === 'hangup' || aiAction.action === 'escalate'))
    ? ''
    : `<Gather input="speech dtmf"
              action="${pub}/twilio/gather/${callId}"
              partialResultCallback="${pub}/twilio/partial/${callId}"
              method="POST"
              speechTimeout="1"
              timeout="10"
              actionOnEmptyResult="true"
              speechModel="phone_call">
        <Pause length="10"/>
      </Gather>`;

  res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  ${actionTwiml}
  ${continueTwiml}
</Response>`);
});

// ── POST /twilio/partial/:call_id ─────────────────────────────────────
// Twilio fires this for each interim transcription chunk DURING a Gather
// (every ~500ms). We log it for visibility and let the AI act on partial
// audio so we don't wait for a full IVR readout. Fast-path: if the partial
// already contains a goal-satisfying phrase, call Twilio's call.update API
// to inject a Hangup TwiML and end the call instantly.
const PARTIAL_DECIDED = new Set(); // callId already hangup-triggered
router.post('/partial/:call_id', express.urlencoded({ extended: true }), async (req, res) => {
  res.type('text/xml').send('<Response/>'); // ack fast — no TwiML reply expected
  const callId = req.params.call_id;
  if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return;
  if (PARTIAL_DECIDED.has(callId)) return;
  const partial = String(req.body.UnstableSpeechResult || '').trim();
  if (!partial || partial.length < 30) return;
  const call = data.getCallUnscoped(callId, true);
  if (!call) return;

  // ── Voicemail fast-bail (Steve's HARD RULE — 2026-05-26) ────────────────
  // If the interim transcript already reads like a voicemail tree, hang up
  // NOW via call.update — earliest possible exit, before the gather even
  // completes. SILENT hangup (no goodbye) so we never leave a partial
  // recorded message on someone's machine. Voicemail = wasted money.
  if (looksLikeVoicemail(partial)) {
    if (PARTIAL_DECIDED.has(callId)) return;
    PARTIAL_DECIDED.add(callId);
    console.log(`[partial] call=${callId} VOICEMAIL-GUARD fast-hangup — heard: "${partial.slice(0,100)}"`);
    try {
      const sid = call.twilio_call_sid;
      if (!sid) { PARTIAL_DECIDED.delete(callId); return; }
      const AC = process.env.TWILIO_ACCOUNT_SID;
      const TOK = process.env.TWILIO_AUTH_TOKEN;
      const auth = 'Basic ' + Buffer.from(`${AC}:${TOK}`).toString('base64');
      const r = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${AC}/Calls/${sid}.json`, {
        method: 'POST',
        headers: { Authorization: auth, 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({ Status: 'completed' }),
      });
      if (r.ok) {
        console.log(`[partial] call=${callId} VOICEMAIL-GUARD hangup ok`);
        try { data.patchRow && data.patchRow(callId, { notes: ((call.notes || '') + ' [auto-hangup: voicemail-IVR language guard (partial)]') }); } catch (e) {}
        data.updateStatus(callId, 'done');
      } else {
        console.error(`[partial] call=${callId} voicemail hangup failed: ${r.status}`);
        PARTIAL_DECIDED.delete(callId);
      }
    } catch (e) {
      console.error(`[partial] call=${callId} voicemail fast-hangup error:`, e.message);
      PARTIAL_DECIDED.delete(callId);
    }
    return;
  }

  // Cheap regex pre-filter — only hit the LLM when partial looks goal-relevant.
  const lower = partial.toLowerCase();
  const looksGoaly = /\b(\d{1,2}[:.]?\d{0,2}\s*(am|pm)|open\s+(at|from|monday|until)|closed|address is|located at|membership (is\s+)?(active|inactive|expired|current)|account is\s+(active|closed|past due)|at the tone|coordinated universal time|hours .{1,30} minutes)\b/.test(lower);
  if (!looksGoaly) return;
  console.log(`[partial] call=${callId} candidate: "${partial.slice(0,120)}"`);

  // Trigger fast hangup via Twilio call.update
  PARTIAL_DECIDED.add(callId);
  try {
    const sid = call.twilio_call_sid;
    if (!sid) return;
    const AC = process.env.TWILIO_ACCOUNT_SID;
    const TOK = process.env.TWILIO_AUTH_TOKEN;
    const auth = 'Basic ' + Buffer.from(`${AC}:${TOK}`).toString('base64');
    const pub = publicUrl(req);
    const goodbyeXml = await playOrSay('Got it, thank you. Goodbye.', pub);
    const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response>${goodbyeXml}<Hangup/></Response>`;
    const r = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${AC}/Calls/${sid}.json`, {
      method: 'POST',
      headers: { Authorization: auth, 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({ Twiml: twiml }),
    });
    if (r.ok) {
      console.log(`[partial] call=${callId} FAST-HANGUP via call.update (goal satisfied in partial)`);
      data.updateStatus(callId, 'done');
    } else {
      console.error(`[partial] call=${callId} call.update failed: ${r.status}`);
      PARTIAL_DECIDED.delete(callId); // allow retry
    }
  } catch (e) {
    console.error(`[partial] call=${callId} fast-hangup error:`, e.message);
    PARTIAL_DECIDED.delete(callId);
  }
});

// ── POST /twilio/status/:call_id ──────────────────────────────────────
// Maps Twilio call lifecycle to Butlr internal status.
router.post('/status/:call_id', (req, res) => {
  const callId = req.params.call_id;
  const cs = String(req.body.CallStatus || '').toLowerCase();
  // initiated, ringing, in-progress, completed, busy, failed, no-answer, canceled
  let internal = null;
  if (cs === 'initiated' || cs === 'ringing') internal = 'dialing';
  else if (cs === 'in-progress')              internal = 'connected';
  else if (cs === 'completed')                internal = 'done';
  else if (['busy','failed','no-answer','canceled'].includes(cs)) internal = 'failed';
  if (internal) data.updateStatus(callId, internal);
  // Conversation cleanup on terminal status. (STREAMED Set was removed
  // when we switched to the no-Redirect inline-Gather pattern.)
  if (cs === 'completed' || ['busy','failed','no-answer','canceled'].includes(cs)) {
    CONV.delete(callId);
    // Cost ledger — Twilio bills per-minute on completed calls. CallDuration
    // is in seconds. Best-effort log (never blocks the response).
    const durSec = Number(req.body.CallDuration || 0);
    if (durSec > 0) {
      try {
        const twilio = require('../lib/twilio');
        const minutes = (durSec / 60).toFixed(3);
        if (twilio.logCost) twilio.logCost('twilio_voice_us', minutes, 'voice_min', `call=${callId} dur=${durSec}s`);
      } catch (e) { console.error('[status-cost]', e.message); }
    }
  }
  res.type('text/xml').send('<Response/>');
});

// ── POST /twilio/recording/:call_id ───────────────────────────────────
// Twilio fires this when the recording is ready. We then download it and
// run Whisper. Both steps are best-effort; we log failures, never crash.
router.post('/recording/:call_id', async (req, res) => {
  const callId = req.params.call_id;
  const recordingUrl = req.body.RecordingUrl;
  const recordingSid = req.body.RecordingSid;
  // ack Twilio immediately
  res.type('text/xml').send('<Response/>');

  if (!recordingUrl) {
    console.error('[twilio-webhook] /recording: no RecordingUrl', { callId });
    return;
  }
  try {
    data.setRecordingMeta && data.setRecordingMeta(callId, { recording_url: recordingUrl, recording_sid: recordingSid });
    const r = await transcribe.recordAndTranscribe(callId, recordingUrl);
    if (r.ok) {
      data.setTranscriptMeta && data.setTranscriptMeta(callId, { transcript_path: r.transcriptPath, recording_path: r.mp3Path });
      console.log('[twilio-webhook] transcript ready', { callId, segments: r.text ? r.text.length : 0 });
    } else {
      console.error('[twilio-webhook] transcribe failed', { callId, ...r });
    }
  } catch (e) {
    console.error('[twilio-webhook] recording handler exception', e.message);
  }
});

// ── POST /twilio/amd/:call_id ─────────────────────────────────────────
// AMD callback. WE DO NOT AUTO-BRIDGE — Twilio's MachineDetection false-
// positives "human" on chatty IVRs (Wells Fargo, Chase, etc.). Auto-bridging
// also rewrites the TwiML mid-execution, killing the IVR DTMF presses
// before they can run. Just log the AMD result. Bridging is manual via
// the live page's "Bridge me NOW" button.
router.post('/amd/:call_id', async (req, res) => {
  const callId = req.params.call_id;
  const answeredBy = req.body.AnsweredBy;
  const callSid = req.body.CallSid;
  console.log('[twilio-webhook] AMD result', { callId, answeredBy, callSid });

  // AUTO-HANGUP on answering machine — Steve's standing rule (2026-05-26):
  // if AMD detects machine_start, machine_end_beep, machine_end_silence,
  // machine_end_other, or fax, hang up immediately instead of recording a voicemail.
  // Human and unknown stay on the call (unknown might be a chatty IVR / slow human).
  const machineKinds = ['machine_start', 'machine_end_beep', 'machine_end_silence', 'machine_end_other', 'fax'];
  if (machineKinds.includes(String(answeredBy))) {
    console.log(`[twilio-webhook] AMD=${answeredBy} for call=${callId} — auto-hangup`);
    try {
      const accountSid = process.env.TWILIO_ACCOUNT_SID;
      const authToken = process.env.TWILIO_AUTH_TOKEN;
      if (accountSid && authToken && callSid) {
        const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls/${callSid}.json`;
        const auth = 'Basic ' + Buffer.from(accountSid + ':' + authToken).toString('base64');
        const r = await fetch(url, {
          method: 'POST',
          headers: { 'Authorization': auth, 'Content-Type': 'application/x-www-form-urlencoded' },
          body: 'Status=completed',
        });
        console.log(`[twilio-webhook] hangup http=${r.status} for call=${callId}`);
      }
      try {
        const data = require('../lib/data');
        const call = data.getCallUnscoped(callId, true);
        if (call) data.patchRow(callId, { notes: (call.notes || '') + ` [auto-hangup: AMD ${answeredBy}]` });
      } catch (e) {}
    } catch (e) {
      console.error(`[twilio-webhook] AMD auto-hangup error for call=${callId}:`, e.message);
    }
  }
  res.type('text/xml').send('<Response/>');
});


// ── POST /twilio/bridge/:call_id ─────────────────────────────────────
// Manual bridge trigger. The user clicks "I'm ready, bridge me to the rep"
// on the live page (Phase 2.5) when hold music ends but AMD didn't fire human.
router.post('/bridge/:call_id', express.urlencoded({ extended: true }), async (req, res) => {
  const callId = req.params.call_id;
  if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).json({ ok: false });
  const call = data.getCallUnscoped(callId, true);
  if (!call) return res.status(404).json({ ok: false });
  const callSid = call.twilio_sid;
  if (!callSid) return res.status(400).json({ ok: false, error: 'no twilio_sid stored' });

  const pub = publicUrl(req);
  const accountSid = process.env.TWILIO_ACCOUNT_SID;
  const authToken  = process.env.TWILIO_AUTH_TOKEN;
  const bridgeTwiml = `<Response>
    <Say voice="Polly.Joanna">Connecting you now.</Say>
    <Dial timeout="20" callerId="${process.env.TWILIO_PHONE_NUMBER}">
      <Number>${call.callback_phone}</Number>
    </Dial>
  </Response>`;
  try {
    const r = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls/${callSid}.json`, {
      method: 'POST',
      headers: {
        'Authorization': 'Basic ' + Buffer.from(`${accountSid}:${authToken}`).toString('base64'),
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({ Twiml: bridgeTwiml }),
    });
    data.updateStatus(callId, 'connected');
    res.json({ ok: true, status: r.status });
  } catch (e) {
    res.status(500).json({ ok: false, error: e.message });
  }
});

// ── POST/GET /twilio/voice-default ────────────────────────────────────
// Number-level inbound fallback. Anyone dialing the Butlr Twilio number
// hears a polite recording — Butlr is outbound-only, the number is a
// caller-ID, not a customer line.
router.all('/voice-default', (req, res) => {
  console.log('[twilio-webhook] inbound voice on Butlr number', {
    From: req.body && req.body.From,
    CallSid: req.body && req.body.CallSid,
  });
  res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say voice="Polly.Joanna">Thank you for calling. This number is for outbound calls only. Please contact us through our website. Goodbye.</Say>
  <Hangup/>
</Response>`);
});

// ── POST /twilio/sms-inbound ──────────────────────────────────────────
// TCPA-compliance: STOP-keyword auto-suppression. Twilio's Messaging
// Service routes inbound SMS here. If the body is any FCC-recognized
// opt-out keyword, we add the sender's number to internal suppression
// (data/dnc-suppression.json) so future sendSms / dialCall block before
// dispatch. Reply with a single TCPA-compliant unsub confirmation.
//
// FCC-recognized opt-out keywords (47 CFR §64.1200): STOP, STOPALL,
// UNSUBSCRIBE, CANCEL, END, QUIT. Match case-insensitively, trimmed,
// against the full body so "STOP" with no trailing text wins but
// "PLEASE STOP CALLING ME" also wins.
//
// Also handles START / UNSTOP keywords — those REMOVE the number from
// suppression (re-opt-in), per FCC reciprocity rule.
//
// Configure on Twilio side: Messaging Service → A message comes in →
// Webhook → https://butlr.agentabrams.com/twilio/sms-inbound

// GET handler — friendly response when Steve (or anyone) opens the URL
// in a browser to verify it's wired. Twilio uses POST; this just confirms
// the route is mounted + alive.
router.get('/sms-inbound', (req, res) => {
  res.type('text/plain').send(
    'Butlr SMS inbound webhook (POST-only).\n\n' +
    'This URL is intended for Twilio Messaging Service inbound webhooks.\n' +
    'Configure in Twilio Console → Messaging Service → A message comes in → Webhook:\n' +
    '  https://butlr.agentabrams.com/twilio/sms-inbound  (HTTP POST)\n\n' +
    'When a customer texts STOP / UNSUBSCRIBE / CANCEL / END / QUIT, the\n' +
    'sender is auto-added to internal DNC suppression (see /admin/dnc).\n' +
    'START / UNSTOP re-opts them in.\n\n' +
    'Status: OK (route alive, POST handler ready).\n'
  );
});

router.post('/sms-inbound', express.urlencoded({ extended: true }), (req, res) => {
  const from = String((req.body && req.body.From) || '').trim();
  const bodyRaw = String((req.body && req.body.Body) || '').trim();
  const body = bodyRaw.toUpperCase();
  const stopKw = /\b(STOP|STOPALL|UNSUBSCRIBE|CANCEL|END|QUIT|REVOKE)\b/.test(body);
  const startKw = /\b(START|UNSTOP|YES)\b/.test(body) && !stopKw;
  console.log(`[sms-inbound] from=…${from.slice(-4)} body="${bodyRaw.slice(0,40)}" stop=${stopKw} start=${startKw}`);
  if (!from) return res.type('text/xml').send('<Response/>');

  // Reply text must be a single TCPA-compliant confirmation. Twilio's
  // <Message> verb sends it back to the sender's number automatically.
  let reply = '';
  try {
    const dnc = require('../lib/dnc-check');
    if (stopKw) {
      dnc.addToInternalSuppression(from, 'sms_stop_keyword', 'twilio_inbound');
      reply = 'You are unsubscribed from Butlr. No further messages will be sent. Reply START to re-subscribe.';
    } else if (startKw) {
      // Remove from internal suppression — re-opt-in.
      try {
        const fs = require('fs');
        let list = JSON.parse(fs.readFileSync(dnc.INTERNAL_SUPPRESSION, 'utf8'));
        if (Array.isArray(list)) {
          const target = dnc.e164Digits(from);
          const before = list.length;
          list = list.filter(e => dnc.e164Digits(e.phone) !== target);
          if (list.length !== before) {
            const tmp = dnc.INTERNAL_SUPPRESSION + '.tmp';
            fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
            fs.renameSync(tmp, dnc.INTERNAL_SUPPRESSION);
          }
        }
      } catch (e) { console.error('[sms-inbound] re-opt-in write:', e.message); }
      reply = 'You are re-subscribed to Butlr. Reply STOP to opt out at any time.';
    } else {
      // Unrecognized message — TCPA-compliant standard reply per FCC §64.1200(d).
      // Provides identity, contact, opt-out path. Do not engage in conversation.
      reply = 'Butlr automated reply. Help: visit butlr.agentabrams.com. Reply STOP to opt out.';
    }
  } catch (e) {
    console.error('[sms-inbound] dnc add failed:', e.message);
    reply = 'You are unsubscribed. Reply START to re-subscribe.';
  }
  res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<Response><Message>${escapeXml(reply)}</Message></Response>`);
});

// ── POST /twilio/status-default ───────────────────────────────────────
// Number-level status callback, log-only. Per-call StatusCallback is set
// on the outbound call via lib/twilio.js and routes to /twilio/status/:id.
router.post('/status-default', (req, res) => {
  console.log('[twilio-webhook] number-level status', {
    CallSid: req.body && req.body.CallSid,
    CallStatus: req.body && req.body.CallStatus,
    From: req.body && req.body.From,
    To: req.body && req.body.To,
  });
  res.type('text/xml').send('<Response/>');
});

// ── GET /twilio/audio/announce/:hash.mp3 ──────────────────────────────
// Serve the cached ElevenLabs MP3. Hash filename is opaque, no enumeration.
router.get('/audio/announce/:hash', (req, res) => {
  const hash = req.params.hash;
  if (!/^[a-f0-9]{6,64}\.mp3$/.test(hash)) return res.status(404).end();
  const fp = path.join(__dirname, '..', 'data', 'audio-cache', hash);
  if (!fs.existsSync(fp)) return res.status(404).end();
  res.type('audio/mpeg');
  res.setHeader('Cache-Control', 'public, max-age=86400');
  fs.createReadStream(fp).pipe(res);
});

function escapeXml(s) {
  return String(s || '').replace(/[<>&"']/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&apos;'}[c]));
}

// ── Voicemail-tree language detector ──────────────────────────────────
// Returns true when a transcribed utterance clearly belongs to a voicemail
// / answering-machine system rather than a live human or a normal business
// IVR. Twilio AMD frequently mislabels these as AnsweredBy=human, so this
// is the second line of defense (the first being /twilio/amd machine-hangup).
//
// Phrases are intentionally voicemail-SPECIFIC. We do NOT match generic
// "press N for X" menu language (that's a legit business IVR we want to
// navigate). We match the give-aways of a personal/business mailbox:
//   - "review the voicemail" / "to send the message" (mailbox edit menu)
//   - "leave a message" / "after the tone" / "at the beep"
//   - "the person you are trying to reach" / "is not available"
//   - "your call has been forwarded to voicemail"
//   - "mailbox is full" / "the voicemail box"
//   - "record your message" / "to re-record"
const VOICEMAIL_PATTERNS = [
  /\bleave (?:a|your) message\b/,
  /\bafter the (?:tone|beep)\b/,
  /\bat the (?:tone|beep)\b/,
  /\brecord your message\b/,
  /\bto re-?record\b/,
  /\breview (?:the|your) (?:voicemail|message)\b/,
  /\bto send the message\b/,
  /\bthe person you(?:'re| are)? (?:trying|attempting) to (?:reach|call)\b/,
  /\bis not available\b.*\bvoicemail\b/,
  /\bhas been forwarded to (?:voicemail|an automated voice messaging)\b/,
  /\bplease leave (?:a|your)\b/,
  /\bvoicemail (?:box|system|inbox)\b/,
  /\bmailbox (?:is full|belonging to)\b/,
  /\byour message will be (?:recorded|sent)\b/,
  /\bto leave a (?:call ?back|message)\b/,
  /\bpress (?:1|one) to (?:review|send|listen to) (?:the |your )?(?:voicemail|message|recording)\b/,
];
function looksLikeVoicemail(text) {
  const t = String(text || '').toLowerCase().trim();
  if (t.length < 6) return false;
  return VOICEMAIL_PATTERNS.some(re => re.test(t));
}

// ── Speak via ElevenLabs (cloned voice) with Polly fallback ───────────
// Steve's standing rule: never piper/say/coqui as primary — always EL.
// Cached MP3s land in data/audio-cache/<hash>.mp3 and are served by
// /twilio/audio/announce/:hash, so repeat phrases ("Thank you. Goodbye.")
// hit the cache and cost zero EL credits after the first synthesis.
async function playOrSay(text, pub) {
  try {
    const synth = await elevenlabs.synthesize(text);
    if (synth.ok) {
      const fname = path.basename(synth.path);
      return `<Play>${pub}/twilio/audio/announce/${fname}</Play>`;
    }
  } catch {}
  return `<Say voice="Polly.Joanna">${escapeXml(text)}</Say>`;
}

module.exports = router;