← 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')}`);
}
})();