← back to Butlr

scripts/menu-mapper.js

147 lines

#!/usr/bin/env node
// Menu-mapper — fires N controlled calls with embedded TwiML, each pressing
// a different IVR digit, recording the resulting branch, and running the
// recording through local whisper.cpp. Bypasses the regular call queue and
// the AI gather-mode entirely so we can isolate per-branch IVR audio.
//
// Usage: node scripts/menu-mapper.js [to-phone] [digits-csv] [from-phone]
// Defaults: to=+13102872777 (MacEnthusiasts) digits=1,2,3,4,5 from=$TWILIO_PHONE_NUMBER

require('dotenv').config();
const fs   = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileP = promisify(execFile);

const TO     = process.argv[2] || '+13102872777';
const DIGITS = (process.argv[3] || '1,2,3,4,5').split(',').map(s => s.trim());
const FROM   = process.argv[4] || process.env.TWILIO_PHONE_NUMBER;
const SID    = process.env.TWILIO_ACCOUNT_SID;
const TOK    = process.env.TWILIO_AUTH_TOKEN;
const SPACING_SEC = 75;          // gap between calls — avoid burst pattern
const RECORD_SECONDS = 30;       // record 30s of post-digit audio

const ANNOUNCE = 'Hello. This is an automated assistant calling on behalf of Steve Abrams. This call may be recorded for quality. Mapping menu now.';

if (!SID || !TOK || !FROM) {
  console.error('Missing TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER in .env');
  process.exit(1);
}

const auth = 'Basic ' + Buffer.from(`${SID}:${TOK}`).toString('base64');
const RECORDINGS_DIR = path.join(__dirname, '..', 'data', 'menu-map');
fs.mkdirSync(RECORDINGS_DIR, { recursive: true });

function twimlForDigit(digit) {
  // Pause 7s so the IVR intro finishes before we press.
  // <Record> ends on silence OR maxLength.
  return `<Response>` +
    `<Say voice="Polly.Joanna">${ANNOUNCE}</Say>` +
    `<Pause length="7"/>` +
    `<Play digits="${digit}"/>` +
    `<Record maxLength="${RECORD_SECONDS}" timeout="5" playBeep="false" trim="do-not-trim"/>` +
    `<Hangup/>` +
  `</Response>`;
}

async function placeCall(digit) {
  const body = new URLSearchParams({
    To: TO,
    From: FROM,
    Twiml: twimlForDigit(digit),
  });
  const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${SID}/Calls.json`, {
    method: 'POST',
    headers: { 'Authorization': auth, 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  });
  const j = await resp.json();
  if (!resp.ok) throw new Error('Twilio dial failed: ' + JSON.stringify(j));
  return j.sid;
}

async function pollUntilDone(callSid, timeoutSec = 120) {
  const deadline = Date.now() + timeoutSec * 1000;
  while (Date.now() < deadline) {
    const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${SID}/Calls/${callSid}.json`, { headers: { 'Authorization': auth }});
    const j = await resp.json();
    if (['completed','failed','canceled','busy','no-answer'].includes(j.status)) return j;
    await new Promise(r => setTimeout(r, 3000));
  }
  return { status: 'timeout' };
}

async function fetchRecording(callSid, digit) {
  const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${SID}/Calls/${callSid}/Recordings.json`, { headers: { 'Authorization': auth }});
  const j = await resp.json();
  const rec = (j.recordings||[])[0];
  if (!rec) return null;
  const url = `https://api.twilio.com${rec.uri.replace(/\.json$/, '.mp3')}`;
  const mp3Resp = await fetch(url, { headers: { 'Authorization': auth }});
  if (!mp3Resp.ok) return null;
  const buf = Buffer.from(await mp3Resp.arrayBuffer());
  const dest = path.join(RECORDINGS_DIR, `digit-${digit}-${callSid}.mp3`);
  fs.writeFileSync(dest, buf);
  return { path: dest, bytes: buf.length, duration: rec.duration };
}

async function transcribeLocal(mp3Path) {
  const wav = mp3Path.replace(/\.mp3$/, '.wav');
  await execFileP('/opt/homebrew/bin/ffmpeg', ['-y','-i',mp3Path,'-ar','16000','-ac','1','-c:a','pcm_s16le',wav], { timeout: 30_000 });
  const model = path.join(process.env.HOME, '.cache/whisper-cpp/ggml-base.en.bin');
  const out   = wav.replace(/\.wav$/, '');
  await execFileP('/opt/homebrew/bin/whisper-cli', ['-m', model, '-f', wav, '--no-timestamps', '-t', '4', '-otxt', '-of', out], { timeout: 180_000, maxBuffer: 8*1024*1024 });
  const txtPath = out + '.txt';
  let text = '';
  if (fs.existsSync(txtPath)) text = fs.readFileSync(txtPath, 'utf8').trim();
  try { fs.unlinkSync(wav); } catch {}
  return text;
}

(async () => {
  const results = [];
  for (let i = 0; i < DIGITS.length; i++) {
    const digit = DIGITS[i];
    console.log(`\n━━━━━ Digit ${digit} (call ${i+1}/${DIGITS.length}) ━━━━━`);
    let row = { digit };
    try {
      const callSid = await placeCall(digit);
      row.call_sid = callSid;
      console.log(`  dial placed · sid=${callSid}`);
      const final = await pollUntilDone(callSid);
      row.status = final.status;
      row.duration = final.duration;
      console.log(`  call ${final.status} · ${final.duration}s`);
      if (final.status === 'completed') {
        // Twilio takes a few seconds to make the recording resource available
        await new Promise(r => setTimeout(r, 5000));
        const rec = await fetchRecording(callSid, digit);
        if (rec) {
          row.mp3 = rec.path;
          console.log(`  recording · ${rec.bytes} bytes · ${rec.duration}s`);
          row.transcript = await transcribeLocal(rec.path);
          console.log(`  transcript: ${row.transcript.slice(0,200)}…`);
        } else {
          row.error = 'no_recording';
        }
      }
    } catch (e) {
      row.error = e.message;
      console.error(`  ERROR: ${e.message}`);
    }
    results.push(row);
    if (i < DIGITS.length - 1) {
      console.log(`  waiting ${SPACING_SEC}s before next call...`);
      await new Promise(r => setTimeout(r, SPACING_SEC * 1000));
    }
  }
  const summary = path.join(RECORDINGS_DIR, `menu-map-${Date.now()}.json`);
  fs.writeFileSync(summary, JSON.stringify({ to: TO, digits: DIGITS, results }, null, 2));
  console.log(`\n━━━━━ Summary written to ${summary} ━━━━━`);
  console.log(`\nResults table:`);
  for (const r of results) {
    console.log(`  digit ${r.digit}: ${r.status||'?'} · ${r.duration||0}s · ${r.transcript ? r.transcript.slice(0,80)+'…' : (r.error||'no-transcript')}`);
  }
})();