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