← back to Butlr
scripts/sales-call.js
101 lines
#!/usr/bin/env node
// One-shot sales call to MacEnthusiasts via the validated press-2 path.
// Strategy: dial → wait 7s for IVR intro → press 2 → wait for human pickup
// → loop a short consent+question twice → record 60s response → hangup.
//
// Uses inline TwiML (no hosted webhook needed) so it doesn't go through
// the Butlr AI-gather route that's currently malformed.
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 = '+13102872777';
const FROM = process.env.TWILIO_PHONE_NUMBER;
const SID = process.env.TWILIO_ACCOUNT_SID;
const TOK = process.env.TWILIO_AUTH_TOKEN;
if (!SID || !TOK || !FROM) { console.error('missing creds'); process.exit(1); }
const auth = 'Basic ' + Buffer.from(`${SID}:${TOK}`).toString('base64');
// Short, condensed message — must be plain-prose, no markup, ~12s of speech max.
// The rep hears this AFTER pressing 2 routes us to the sales queue.
const PITCH = "Hi, this is an automated assistant calling on behalf of Steve Abrams. " +
"This call may be recorded for quality. " +
"Steve is interested in your refurbished Mac Studio M1 Ultra with 128 gigabytes of RAM and 2 terabyte SSD. " +
"He would also like a quote on the 4 terabyte variant. " +
"His budget is under 5000 dollars out the door including tax. " +
"Steve plans to pick up in person at your Pico Boulevard store. " +
"Please leave a price quote here and Steve will call back directly. Thank you.";
const twiml = `<Response>` +
// IVR intro plays for ~7s, then press 2 for sales
`<Pause length="7"/>` +
`<Play digits="2"/>` +
// Wait for sales queue to ring to a human
`<Pause length="2"/>` +
// Loop the pitch twice — so the human hears it regardless of pickup timing
`<Say voice="Polly.Joanna" loop="2">${PITCH.replace(/[<>&]/g,'')}</Say>` +
// Record up to 60 seconds of human response, end on 4s of silence
`<Record maxLength="60" timeout="4" playBeep="false" trim="do-not-trim"/>` +
`<Hangup/>` +
`</Response>`;
(async () => {
console.log('Placing sales call to MacEnthusiasts...');
const body = new URLSearchParams({ To: TO, From: FROM, Twiml: twiml });
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) { console.error('dial failed:', j); process.exit(1); }
const callSid = j.sid;
console.log(` call sid: ${callSid}`);
// Poll
const deadline = Date.now() + 150_000;
let final;
while (Date.now() < deadline) {
const r = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${SID}/Calls/${callSid}.json`, { headers: { Authorization: auth }});
final = await r.json();
console.log(` ${final.status} duration=${final.duration||0}s`);
if (['completed','failed','canceled','busy','no-answer'].includes(final.status)) break;
await new Promise(r => setTimeout(r, 4000));
}
console.log('\nFinal:', JSON.stringify({status:final.status, duration:final.duration+'s', answered_by:final.answered_by, price:final.price}, null, 2));
// Pull recording
await new Promise(r => setTimeout(r, 6000));
const recR = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${SID}/Calls/${callSid}/Recordings.json`, { headers: { Authorization: auth }});
const recJ = await recR.json();
const rec = (recJ.recordings||[])[0];
if (!rec) { console.log('No recording resource yet — call may not have entered Record block'); return; }
const mp3Url = `https://api.twilio.com${rec.uri.replace(/\.json$/, '.mp3')}`;
const mp3R = await fetch(mp3Url, { headers: { Authorization: auth }});
const buf = Buffer.from(await mp3R.arrayBuffer());
const dest = path.join(__dirname, '..', 'data', 'menu-map', `sales-${callSid}.mp3`);
fs.writeFileSync(dest, buf);
console.log(`Recording saved: ${dest} (${buf.length} bytes, ${rec.duration}s)`);
// Whisper local
const wav = dest.replace(/\.mp3$/, '.wav');
await execFileP('/opt/homebrew/bin/ffmpeg', ['-y','-i',dest,'-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 txt = fs.readFileSync(out + '.txt', 'utf8').trim();
try { fs.unlinkSync(wav); fs.unlinkSync(out + '.txt'); } catch {}
console.log('\n━━━ TRANSCRIPT ━━━');
console.log(txt);
console.log('━━━━━━━━━━━━━━━━━━');
// Play it locally for Steve
console.log('\nPlaying back recording...');
require('child_process').spawn('afplay', [dest], { detached: true, stdio: 'ignore' }).unref();
})();