← back to Butlr
sync local from prod — reconcile butlr drift
1295f61ff1de66e68b7b613e3db5cffdbff73d37 · 2026-05-26 09:40:30 -0700 · SteveStudio2
Prod (root@45.61.58.125:/root/public-projects/butlr) was substantially
ahead of local after Steve's direct-on-box edits. Pulled prod → local
(excludes: .env, node_modules, data/, *.log, *.bak-*, *.pre-*; no --delete).
This supersedes my redundant local AMD commit (f4b29f0) — Steve's prod
version of the answering-machine auto-hangup is cleaner and broader:
- triggers on [machine_start, machine_end_beep, machine_end_silence,
machine_end_other, fax] (mine missed fax)
- hangs up via Status=completed (cleaner than my Twiml=<Hangup/>)
- patches the call row notes with [auto-hangup: AMD <kind>]
Also brought down prod-only work that was never on local:
- lib/scheduling.js + routes/scheduled.js (appointment scheduling)
- routes/live.js (live-calls surface)
- views/public/calendar.ejs
- public/audio/intro-steve.mp3 (call intro)
- scripts/dial-sequence.sh
- modified: server.js, lib/data.js, lib/twilio.js, lib/vapi.js,
routes/listen.js, routes/public.js, several views
All key files node -c clean. Local is now a faithful mirror of the live
butlr service, so a future /deploy won't clobber prod's work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M .gitignoreM lib/data.jsM lib/listen-bridge.jsM lib/owner-auth.jsA lib/scheduling.jsM lib/twilio.jsM lib/vapi.jsA public/audio/intro-steve.mp3M public/css/site.cssM routes/listen.jsA routes/live.jsM routes/public.jsA routes/scheduled.jsM routes/twilio-webhooks.jsA scripts/dial-sequence.shM server.jsM views/partials/footer.ejsM views/partials/head.ejsM views/partials/header.ejsA views/public/calendar.ejsM views/public/calls.ejsM views/public/home.ejsM views/public/listen.ejsM views/public/wizard-4-callback.ejs
Diff
commit 1295f61ff1de66e68b7b613e3db5cffdbff73d37
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Tue May 26 09:40:30 2026 -0700
sync local from prod — reconcile butlr drift
Prod (root@45.61.58.125:/root/public-projects/butlr) was substantially
ahead of local after Steve's direct-on-box edits. Pulled prod → local
(excludes: .env, node_modules, data/, *.log, *.bak-*, *.pre-*; no --delete).
This supersedes my redundant local AMD commit (f4b29f0) — Steve's prod
version of the answering-machine auto-hangup is cleaner and broader:
- triggers on [machine_start, machine_end_beep, machine_end_silence,
machine_end_other, fax] (mine missed fax)
- hangs up via Status=completed (cleaner than my Twiml=<Hangup/>)
- patches the call row notes with [auto-hangup: AMD <kind>]
Also brought down prod-only work that was never on local:
- lib/scheduling.js + routes/scheduled.js (appointment scheduling)
- routes/live.js (live-calls surface)
- views/public/calendar.ejs
- public/audio/intro-steve.mp3 (call intro)
- scripts/dial-sequence.sh
- modified: server.js, lib/data.js, lib/twilio.js, lib/vapi.js,
routes/listen.js, routes/public.js, several views
All key files node -c clean. Local is now a faithful mirror of the live
butlr service, so a future /deploy won't clobber prod's work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
.gitignore | 5 -
lib/data.js | 2 +-
lib/listen-bridge.js | 210 +++++++++++++++----------
lib/owner-auth.js | 8 +
lib/scheduling.js | 183 ++++++++++++++++++++++
lib/twilio.js | 18 ++-
lib/vapi.js | 77 ++++++++-
public/audio/intro-steve.mp3 | Bin 0 -> 95338 bytes
public/css/site.css | 23 +++
routes/listen.js | 19 +++
routes/live.js | 311 +++++++++++++++++++++++++++++++++++++
routes/public.js | 33 ++++
routes/scheduled.js | 80 ++++++++++
routes/twilio-webhooks.js | 156 +++++++++++--------
scripts/dial-sequence.sh | 39 +++++
server.js | 60 +++++--
views/partials/footer.ejs | 1 +
views/partials/head.ejs | 2 +-
views/partials/header.ejs | 1 +
views/public/calendar.ejs | 151 ++++++++++++++++++
views/public/calls.ejs | 62 ++++++--
views/public/home.ejs | 126 +++++++++++++++
views/public/listen.ejs | 11 ++
views/public/wizard-4-callback.ejs | 49 +++++-
24 files changed, 1444 insertions(+), 183 deletions(-)
diff --git a/.gitignore b/.gitignore
index a3b42bb..d18e73b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,8 +21,3 @@ data/password-resets.json.*
data/uploads/
data/uploads-meta.json
-# Snapshot / backup files — never commit or serve
-*.bak
-*.bak.*
-*.pre-*
-.pre-*
diff --git a/lib/data.js b/lib/data.js
index cd5a162..4172cee 100644
--- a/lib/data.js
+++ b/lib/data.js
@@ -175,7 +175,7 @@ function addCall(body, userId) {
// than 4 times total. This protects the recipient regardless of how the
// user opts in via retry buttons. Counts ALL prior calls (including
// failed/canceled) so a number can't be ground down by repeated retries.
- const MAX_CALLS_PER_PHONE = 4;
+ const MAX_CALLS_PER_PHONE = 12;
if (allPriors.length >= MAX_CALLS_PER_PHONE) {
return {
ok: false,
diff --git a/lib/listen-bridge.js b/lib/listen-bridge.js
index 644e99a..c893d79 100644
--- a/lib/listen-bridge.js
+++ b/lib/listen-bridge.js
@@ -1,12 +1,15 @@
// Live-audio listen bridge.
//
-// Twilio Voice → <Start><Stream url="wss://.../stream/<call_id>"/> sends
-// inbound µ-law audio frames at 8kHz mono in 20ms chunks as base64 inside
-// JSON envelopes: { event: "media", media: { payload: "<base64>" } }
+// Twilio Voice → <Start><Stream url="wss://.../stream/<call_id>" track="both_tracks"/>
+// sends µ-law audio frames at 8kHz mono in 20ms chunks as base64 JSON
+// envelopes: { event: "media", media: { track: "inbound"|"outbound", payload: "<base64>" } }.
//
-// We accept those, decode µ-law → PCM16 LE, and broadcast the raw PCM bytes
-// to every browser WebSocket listening on /listen/<call_id>. The browser
-// uses AudioContext to play it back.
+// With track="both_tracks" the two halves of the conversation arrive as
+// SEPARATE frames distinguished by media.track — they are NOT pre-mixed.
+// We archive each track into its own .pcm file and ffmpeg-merge to a
+// STEREO MP3 at call end (inbound=left, outbound=right). Live broadcast
+// to /listen-ws is inbound-only for now (single-channel, matches the
+// browser's existing playback path; no client changes needed).
//
// Two WebSocket "lanes" on the same upgrade handler — Twilio Streams on
// /stream/:call_id, browser listeners on /listen-ws/:call_id.
@@ -18,101 +21,138 @@ const path = require('path');
const { spawn } = require('child_process');
// ── Per-call audio archive ──────────────────────────────────────────────
-// As Twilio MediaStreams send µ-law audio to our /stream/<id> WS lane,
-// we ALSO append the decoded PCM16 LE bytes to data/recordings/<id>.pcm.
-// On stream close we shell out to ffmpeg, convert PCM → MP3, drop the .pcm,
-// and (optionally) kick off local Whisper transcription. This way every
-// call gets persisted to disk even if no browser is connected — the bug
-// that lost the 2026-05-12 Wells Fargo call (DTCI7PuZ).
+// Each call gets two raw-PCM files (inbound + outbound), ffmpeg-merged
+// into a stereo MP3 on close. Persists to disk regardless of whether
+// any browser is listening — the bug that lost the 2026-05-12 Wells
+// Fargo call (DTCI7PuZ).
const RECORDINGS_DIR = path.join(__dirname, '..', 'data', 'recordings');
fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
-const pcmStreams = new Map(); // callId → { fd, bytes }
+const pcmStreams = new Map(); // callId → handle{ inboundFd, outboundFd, paths, bytes }
// ── Orphan-PCM recovery on boot ─────────────────────────────────────
// If the process died mid-call (SIGINT from zombie pm2 God, OOM, etc.),
// the WS close handler never fired and PCM files sit unconverted on
-// disk. Scan recordings/ at module-load time, convert any orphan .pcm
-// → .mp3 via ffmpeg, kick off whisper.cpp transcription, then unlink
-// the .pcm. Best-effort — failures are logged, not thrown.
+// disk. Scan recordings/ at module-load time and ffmpeg-merge:
+// • paired <id>.inbound.pcm + <id>.outbound.pcm → stereo .mp3
+// • lone <id>.inbound.pcm OR <id>.outbound.pcm → mono .mp3
+// • legacy <id>.pcm (pre-2026-05-21 schema) → mono .mp3
+// Best-effort — failures are logged, not thrown.
(function recoverOrphanPcms() {
let entries;
try { entries = fs.readdirSync(RECORDINGS_DIR); } catch { return; }
- const pcms = entries.filter(f => f.endsWith('.pcm'));
- if (!pcms.length) return;
- console.log(`[listen-bridge] orphan-PCM recovery: found ${pcms.length} unfinalized recording(s)`);
- for (const pcmFile of pcms) {
- const callId = pcmFile.replace(/\.pcm$/, '');
- const pcmPath = path.join(RECORDINGS_DIR, pcmFile);
+
+ const byCall = new Map(); // callId → { inbound?:path, outbound?:path, legacy?:path }
+ for (const f of entries) {
+ const inMatch = f.match(/^([A-Za-z0-9_-]{6,16})\.inbound\.pcm$/);
+ const outMatch = f.match(/^([A-Za-z0-9_-]{6,16})\.outbound\.pcm$/);
+ const oldMatch = f.match(/^([A-Za-z0-9_-]{6,16})\.pcm$/);
+ if (inMatch) (byCall.get(inMatch[1]) || byCall.set(inMatch[1], {}).get(inMatch[1])).inbound = path.join(RECORDINGS_DIR, f);
+ else if (outMatch) (byCall.get(outMatch[1]) || byCall.set(outMatch[1], {}).get(outMatch[1])).outbound = path.join(RECORDINGS_DIR, f);
+ else if (oldMatch) (byCall.get(oldMatch[1]) || byCall.set(oldMatch[1], {}).get(oldMatch[1])).legacy = path.join(RECORDINGS_DIR, f);
+ }
+ if (!byCall.size) return;
+ console.log(`[listen-bridge] orphan-PCM recovery: ${byCall.size} unfinalized recording(s)`);
+ for (const [callId, parts] of byCall) {
const mp3Path = path.join(RECORDINGS_DIR, `${callId}.mp3`);
- let bytes = 0;
- try { bytes = fs.statSync(pcmPath).size; } catch { continue; }
- if (bytes < 4096) { try { fs.unlinkSync(pcmPath); } catch {}; continue; }
- if (fs.existsSync(mp3Path)) { try { fs.unlinkSync(pcmPath); } catch {}; continue; }
- const ff = spawn('ffmpeg', ['-y','-f','s16le','-ar','8000','-ac','1','-i', pcmPath, '-codec:a','libmp3lame','-q:a','4', mp3Path], { stdio: 'ignore' });
- ff.on('exit', (code) => {
- if (code !== 0) { console.error(`[listen-bridge] orphan recovery ffmpeg exit ${code} call=${callId}`); return; }
- try { fs.unlinkSync(pcmPath); } catch {}
- console.log(`[listen-bridge] recovered orphan call=${callId} → ${path.basename(mp3Path)} (${bytes} pcm bytes)`);
- try {
- const t = require('./transcribe');
- if (t && t.transcribe) t.transcribe(callId, mp3Path).then(r => {
- if (r && r.ok) console.log(`[listen-bridge] orphan auto-transcribed call=${callId} (source=${r.source})`);
- }).catch(() => {});
- } catch {}
- });
+ if (fs.existsSync(mp3Path)) {
+ for (const p of Object.values(parts)) { try { fs.unlinkSync(p); } catch {} }
+ continue;
+ }
+ runFfmpegMerge(callId, parts, mp3Path, /*sourceHint*/'orphan');
}
})();
+// runFfmpegMerge handles all three input shapes:
+// { inbound, outbound } → stereo (inbound=L, outbound=R)
+// { inbound } → mono
+// { outbound } → mono
+// { legacy } → mono (pre-stereo schema)
+function runFfmpegMerge(callId, parts, mp3Path, sourceHint) {
+ const hasIn = !!parts.inbound && safeSize(parts.inbound) >= 4096;
+ const hasOut = !!parts.outbound && safeSize(parts.outbound) >= 4096;
+ const hasOld = !!parts.legacy && safeSize(parts.legacy) >= 4096;
+ if (!hasIn && !hasOut && !hasOld) {
+ // All under-size or empty — drop everything, no MP3.
+ for (const p of Object.values(parts)) { try { fs.unlinkSync(p); } catch {} }
+ return;
+ }
+
+ let args;
+ let mode;
+ if (hasIn && hasOut) {
+ // Stereo: inbound left, outbound right.
+ args = [
+ '-y',
+ '-f','s16le','-ar','8000','-ac','1','-i', parts.inbound,
+ '-f','s16le','-ar','8000','-ac','1','-i', parts.outbound,
+ '-filter_complex', '[0:a][1:a]amerge=inputs=2[a]',
+ '-map','[a]','-ac','2',
+ '-codec:a','libmp3lame','-q:a','4',
+ mp3Path,
+ ];
+ mode = 'stereo';
+ } else {
+ const monoPath = hasIn ? parts.inbound : (hasOut ? parts.outbound : parts.legacy);
+ args = ['-y','-f','s16le','-ar','8000','-ac','1','-i', monoPath,
+ '-codec:a','libmp3lame','-q:a','4', mp3Path];
+ mode = hasIn ? 'mono(in)' : (hasOut ? 'mono(out)' : 'mono(legacy)');
+ }
+
+ const ff = spawn('ffmpeg', args, { stdio: 'ignore' });
+ ff.on('exit', (code) => {
+ if (code !== 0) {
+ console.error(`[listen-bridge] ffmpeg exit ${code} for call=${callId} (${sourceHint}, ${mode})`);
+ return;
+ }
+ for (const p of Object.values(parts)) { try { fs.unlinkSync(p); } catch {} }
+ const sIn = parts.inbound ? safeSize(parts.inbound) : 0;
+ const sOut = parts.outbound ? safeSize(parts.outbound) : 0;
+ console.log(`[listen-bridge] archived ${mode} ${sourceHint} call=${callId} → ${path.basename(mp3Path)} (in:${sIn} out:${sOut})`);
+ try {
+ const t = require('./transcribe');
+ if (t && t.transcribe) t.transcribe(callId, mp3Path).then(r => {
+ if (r && r.ok) console.log(`[listen-bridge] auto-transcribed call=${callId} (source=${r.source})`);
+ }).catch(() => {});
+ } catch {}
+ });
+}
+function safeSize(p) { try { return fs.statSync(p).size; } catch { return 0; } }
+
function openArchive(callId) {
if (pcmStreams.has(callId)) return pcmStreams.get(callId);
- const pcmPath = path.join(RECORDINGS_DIR, `${callId}.pcm`);
- const fd = fs.openSync(pcmPath, 'a');
- const handle = { fd, pcmPath, bytes: 0 };
+ const inboundPath = path.join(RECORDINGS_DIR, `${callId}.inbound.pcm`);
+ const outboundPath = path.join(RECORDINGS_DIR, `${callId}.outbound.pcm`);
+ const inboundFd = fs.openSync(inboundPath, 'a');
+ const outboundFd = fs.openSync(outboundPath, 'a');
+ const handle = { inboundFd, outboundFd, inboundPath, outboundPath, inboundBytes: 0, outboundBytes: 0 };
pcmStreams.set(callId, handle);
return handle;
}
-function archiveAppend(callId, pcmBuf) {
+function archiveAppend(callId, track, pcmBuf) {
const h = openArchive(callId);
- try { fs.writeSync(h.fd, pcmBuf); h.bytes += pcmBuf.length; } catch {}
+ try {
+ if (track === 'outbound') { fs.writeSync(h.outboundFd, pcmBuf); h.outboundBytes += pcmBuf.length; }
+ else { fs.writeSync(h.inboundFd, pcmBuf); h.inboundBytes += pcmBuf.length; }
+ } catch {}
}
function finalizeArchive(callId) {
const h = pcmStreams.get(callId);
if (!h) return;
pcmStreams.delete(callId);
- try { fs.closeSync(h.fd); } catch {}
- if (h.bytes < 4096) { try { fs.unlinkSync(h.pcmPath); } catch {} ; return; }
+ try { fs.closeSync(h.inboundFd); } catch {}
+ try { fs.closeSync(h.outboundFd); } catch {}
- // Convert raw PCM16 LE @ 8kHz mono → MP3
const mp3Path = path.join(RECORDINGS_DIR, `${callId}.mp3`);
- const ff = spawn('ffmpeg', ['-y','-f','s16le','-ar','8000','-ac','1','-i', h.pcmPath, '-codec:a','libmp3lame','-q:a','4', mp3Path], { stdio: 'ignore' });
- ff.on('exit', (code) => {
- if (code === 0) {
- try { fs.unlinkSync(h.pcmPath); } catch {}
- console.log(`[listen-bridge] archived call=${callId} → ${path.basename(mp3Path)} (${h.bytes} pcm bytes)`);
- // Best-effort post-call transcription via the existing transcribe module
- try {
- const t = require('./transcribe');
- if (t && t.transcribe) {
- t.transcribe(callId, mp3Path).then(r => {
- if (r && r.ok) console.log(`[listen-bridge] auto-transcribed call=${callId} (source=${r.source})`);
- }).catch(() => {});
- }
- } catch {}
- } else {
- console.error(`[listen-bridge] ffmpeg exit ${code} for call=${callId}`);
- }
- });
+ runFfmpegMerge(callId, { inbound: h.inboundPath, outbound: h.outboundPath }, mp3Path, /*sourceHint*/'live');
}
-// µ-law → linear PCM16 LE decoder (G.711 standard).
-// Bit-fiddly but small. Adapted from RFC 5391.
+// µ-law → linear PCM16 LE decoder (G.711 standard). Bit-fiddly but small.
// G.711 decode bias is 0x84 (132) and is added INSIDE the << exponent shift.
// This was 33 — under-counted by 4× — which left quiet samples (exponent 0)
// correct but nonlinearly crushed every loud sample (e.g. 0x00 decoded to
-// -19551 instead of -32124). That amplitude-dependent distortion is what made
-// the recordings and the live-listen feed sound horrible.
+// -19551 instead of -32124). That amplitude-dependent distortion is what
+// made the recordings and the live-listen feed sound horrible.
const MULAW_BIAS = 0x84;
-const MULAW_CLIP = 32635;
function mulawDecodeOne(mulawByte) {
mulawByte = ~mulawByte & 0xff;
const sign = (mulawByte & 0x80) ? -1 : 1;
@@ -125,21 +165,18 @@ function mulawDecodeOne(mulawByte) {
function mulawDecodeBuffer(mulawBuf) {
const out = Buffer.alloc(mulawBuf.length * 2);
for (let i = 0; i < mulawBuf.length; i++) {
- const s = mulawDecodeOne(mulawBuf[i]);
- out.writeInt16LE(s, i * 2);
+ out.writeInt16LE(mulawDecodeOne(mulawBuf[i]), i * 2);
}
return out;
}
// callId → Set<browserWs>
const listeners = new Map();
-
function getListeners(callId) {
let set = listeners.get(callId);
if (!set) { set = new Set(); listeners.set(callId, set); }
return set;
}
-
function broadcastPcm(callId, pcmBuf) {
const set = listeners.get(callId);
if (!set || set.size === 0) return;
@@ -160,13 +197,7 @@ function attachListenBridge(httpServer) {
const lane = m[1];
const callId = m[2];
- // Defense-in-depth: only Twilio may open the /stream lane. Prevents
- // an outside attacker from injecting fake media frames into a real
- // call_id. Twilio's documented UAs for Media Streams have varied —
- // observed in 2026-05 nginx logs: "Twilio.TmeWs/1.0" (TwiML Media
- // Engine WebSocket). Older Twilio docs mention "TwilioProxy/...".
- // Allow either, plus any Twilio.*/x.y form to be defensive about
- // future renames. Reject anything else (browsers, curl, scanners).
+ // Defense-in-depth: only Twilio may open the /stream lane.
if (lane === 'stream') {
const ua = String(req.headers['user-agent'] || '');
if (!/^Twilio[A-Za-z.]*\/[0-9]/.test(ua)) {
@@ -191,15 +222,26 @@ function attachListenBridge(httpServer) {
}
// lane === 'stream' (Twilio Media Streams)
+ let msgCount = 0;
+ let nonMediaCount = 0;
+ let mediaCount = 0;
ws.on('message', (data, isBinary) => {
- if (isBinary) return; // Twilio sends JSON
+ msgCount++;
+ if (msgCount <= 3) {
+ // Log the first 3 raw messages so we can see what Twilio is actually sending.
+ console.log(`[listen-bridge] stream/${callId} msg#${msgCount} isBinary=${isBinary} len=${data.length} preview=${data.toString('utf8').slice(0,200)}`);
+ }
+ if (isBinary) { console.log(`[listen-bridge] stream/${callId} ignored binary msg #${msgCount}`); return; }
let msg;
- try { msg = JSON.parse(data.toString('utf8')); } catch { return; }
+ try { msg = JSON.parse(data.toString('utf8')); } catch (e) { console.log(`[listen-bridge] stream/${callId} parse fail: ${e.message}`); return; }
+ if (msg.event !== 'media') { nonMediaCount++; if (nonMediaCount <= 5) console.log(`[listen-bridge] stream/${callId} non-media event: ${msg.event}`); }
if (msg.event !== 'media' || !msg.media || !msg.media.payload) return;
+ mediaCount++;
+ const track = (msg.media.track === 'outbound') ? 'outbound' : 'inbound';
const mulaw = Buffer.from(msg.media.payload, 'base64');
const pcm = mulawDecodeBuffer(mulaw);
- archiveAppend(callId, pcm); // persist to disk (always, regardless of browser listeners)
- broadcastPcm(callId, pcm); // fan out to any connected browser listeners
+ archiveAppend(callId, track, pcm); // both tracks → separate .pcm files
+ if (track === 'inbound') broadcastPcm(callId, pcm); // live listen = inbound only (mic)
});
ws.on('close', () => { finalizeArchive(callId); });
ws.on('error', () => { finalizeArchive(callId); });
diff --git a/lib/owner-auth.js b/lib/owner-auth.js
index a5b40c0..588aa5a 100644
--- a/lib/owner-auth.js
+++ b/lib/owner-auth.js
@@ -72,6 +72,14 @@ function requireOwner(req, res, next) {
req.user = user;
req.ownerAuthed = true;
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
+ // If user authed via ?owner_token= (query) and no cookie was sent, promote
+ // to a persistent session cookie so subsequent requests don't need the
+ // token in the URL. Honors the same one-year maxAge as a normal login.
+ const cookieAlready = parseCookies(req)[COOKIE_NAME];
+ const queryToken = req.query && req.query.owner_token;
+ if (!cookieAlready && queryToken && user.owner_token === queryToken) {
+ setSessionCookie(res, queryToken);
+ }
return next();
}
// Use req.originalUrl (full path, NOT relative to mount point) to detect /api/*.
diff --git a/lib/scheduling.js b/lib/scheduling.js
new file mode 100644
index 0000000..dbd353d
--- /dev/null
+++ b/lib/scheduling.js
@@ -0,0 +1,183 @@
+// lib/scheduling.js — JSON-backed scheduling layer for Butlr.
+//
+// Two kinds of scheduled work:
+// (1) one-shot — schedule_at is a single ISO timestamp; row fires once
+// (2) recurring — cron_expr (cron-style 5-field) computes next_fire_at on each tick
+//
+// The worker tick runs every 60s. For each row whose next_fire_at <= now, it
+// dispatches via the same /api/external/place-call path used for immediate
+// calls (honors repeat-call gate, dry-run env, etc.).
+//
+// Hard rule — scheduled calls inherit ALL gating that immediate calls have.
+// MAX_CALLS_PER_PHONE applies. DNC list applies. consent_recording must be
+// true. No bypass.
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const FILE = path.join(__dirname, '..', 'data', 'scheduled-calls.json');
+
+function readAll() {
+ try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); }
+ catch (e) { if (e.code === 'ENOENT') return []; throw e; }
+}
+function writeAll(rows) {
+ fs.writeFileSync(FILE, JSON.stringify(rows, null, 2));
+}
+function genId() {
+ return crypto.randomBytes(6).toString('base64url');
+}
+
+// Tiny cron evaluator — supports 5-field expressions with `*`, `*/N`, `N`,
+// `N-M`, `A,B,C`. Fields: minute hour day-of-month month day-of-week.
+// Returns the next Date >= `from` matching the expression.
+function parseField(field, min, max) {
+ if (field === '*') return null; // match-all
+ const out = new Set();
+ for (const part of field.split(',')) {
+ let m;
+ if ((m = part.match(/^\*\/(\d+)$/))) {
+ const step = parseInt(m[1], 10);
+ for (let i = min; i <= max; i += step) out.add(i);
+ } else if ((m = part.match(/^(\d+)-(\d+)$/))) {
+ for (let i = parseInt(m[1], 10); i <= parseInt(m[2], 10); i++) out.add(i);
+ } else if (/^\d+$/.test(part)) {
+ out.add(parseInt(part, 10));
+ } else {
+ throw new Error('bad cron field: ' + field);
+ }
+ }
+ return out;
+}
+function nextCronFire(expr, from = new Date()) {
+ const parts = String(expr).trim().split(/\s+/);
+ if (parts.length !== 5) throw new Error('cron must be 5 fields (m h dom mon dow)');
+ const [minF, hourF, domF, monF, dowF] = parts;
+ const sets = {
+ minute: parseField(minF, 0, 59),
+ hour: parseField(hourF, 0, 23),
+ dom: parseField(domF, 1, 31),
+ mon: parseField(monF, 1, 12),
+ dow: parseField(dowF, 0, 6),
+ };
+ const d = new Date(from);
+ d.setSeconds(0, 0);
+ d.setMinutes(d.getMinutes() + 1); // strictly forward
+ // Brute-force scan up to 1 year ahead — fine for sane crons.
+ for (let i = 0; i < 366 * 24 * 60; i++) {
+ if ((!sets.minute || sets.minute.has(d.getMinutes())) &&
+ (!sets.hour || sets.hour.has(d.getHours())) &&
+ (!sets.dom || sets.dom.has(d.getDate())) &&
+ (!sets.mon || sets.mon.has(d.getMonth() + 1)) &&
+ (!sets.dow || sets.dow.has(d.getDay()))) {
+ return new Date(d);
+ }
+ d.setMinutes(d.getMinutes() + 1);
+ }
+ return null;
+}
+
+// Public API
+function listScheduled(userId, opts = {}) {
+ const all = readAll();
+ let rows = userId ? all.filter(r => r.user_id === userId) : all;
+ if (opts.status) rows = rows.filter(r => r.status === opts.status);
+ if (opts.from) rows = rows.filter(r => r.next_fire_at && new Date(r.next_fire_at) >= new Date(opts.from));
+ if (opts.to) rows = rows.filter(r => r.next_fire_at && new Date(r.next_fire_at) <= new Date(opts.to));
+ return rows.sort((a, b) => new Date(a.next_fire_at || 0) - new Date(b.next_fire_at || 0));
+}
+
+function addScheduled({ user_id, business_name, business_phone, goal, callback_phone, callback_name, schedule_at, cron_expr, category, max_hold_minutes, max_spend_cents, consent_recording }) {
+ if (!business_phone) throw new Error('business_phone required');
+ if (!schedule_at && !cron_expr) throw new Error('schedule_at or cron_expr required');
+
+ let next;
+ if (cron_expr) {
+ next = nextCronFire(cron_expr);
+ if (!next) throw new Error('cron expression never fires in next year');
+ } else {
+ next = new Date(schedule_at);
+ if (isNaN(next.getTime())) throw new Error('schedule_at not parseable');
+ if (next < new Date()) throw new Error('schedule_at is in the past');
+ }
+
+ const row = {
+ id: 'S' + genId(),
+ user_id: user_id || null,
+ business_name: business_name || '',
+ business_phone,
+ goal: goal || '',
+ callback_phone: callback_phone || '',
+ callback_name: callback_name || '',
+ category: category || 'other',
+ max_hold_minutes: max_hold_minutes || 5,
+ max_spend_cents: max_spend_cents || 50,
+ consent_recording: !!consent_recording,
+ schedule_at: cron_expr ? null : next.toISOString(),
+ cron_expr: cron_expr || null,
+ next_fire_at: next.toISOString(),
+ status: 'scheduled',
+ fire_count: 0,
+ last_fire_at: null,
+ last_call_id: null,
+ created_at: new Date().toISOString(),
+ };
+ const all = readAll();
+ all.push(row);
+ writeAll(all);
+ return row;
+}
+
+function cancelScheduled(id, userId) {
+ const all = readAll();
+ const r = all.find(x => x.id === id);
+ if (!r) return false;
+ if (userId && r.user_id !== userId) return false;
+ r.status = 'canceled';
+ r.next_fire_at = null;
+ writeAll(all);
+ return true;
+}
+
+// Worker tick — finds due rows and dispatches them. Returns array of fired IDs.
+// `dispatchFn` is injected so this stays test-friendly + doesn't import
+// circular deps into data.js.
+async function tickWorker(dispatchFn) {
+ const all = readAll();
+ const now = new Date();
+ const fired = [];
+ for (const r of all) {
+ if (r.status !== 'scheduled') continue;
+ if (!r.next_fire_at) continue;
+ if (new Date(r.next_fire_at) > now) continue;
+
+ try {
+ const result = await dispatchFn(r);
+ r.fire_count = (r.fire_count || 0) + 1;
+ r.last_fire_at = now.toISOString();
+ r.last_call_id = (result && result.call_id) || null;
+
+ if (r.cron_expr) {
+ // Recurring — compute next fire, leave status='scheduled'
+ const next = nextCronFire(r.cron_expr);
+ r.next_fire_at = next ? next.toISOString() : null;
+ if (!r.next_fire_at) r.status = 'done';
+ } else {
+ // One-shot — mark done
+ r.status = 'done';
+ r.next_fire_at = null;
+ }
+ fired.push({ id: r.id, ok: true, call_id: r.last_call_id });
+ } catch (e) {
+ r.status = 'failed';
+ r.last_fire_at = now.toISOString();
+ r.last_error = String(e.message || e).slice(0, 240);
+ fired.push({ id: r.id, ok: false, error: r.last_error });
+ }
+ }
+ if (fired.length) writeAll(all);
+ return fired;
+}
+
+module.exports = { listScheduled, addScheduled, cancelScheduled, tickWorker, nextCronFire };
diff --git a/lib/twilio.js b/lib/twilio.js
index dab1c9d..91f4f98 100644
--- a/lib/twilio.js
+++ b/lib/twilio.js
@@ -41,6 +41,16 @@ function log(...args) { console.log('[twilio]', new Date().toISOString(), ...arg
// See ~/.claude/projects/-Users-stevestudio2/memory/feedback_holdforme_always_announce.md
function buildAnnouncement(call) {
const onBehalfOf = call.callback_name || 'a Butlr customer';
+ // BUTLR_INTRO_VARIANT=soft → drop the 'automated assistant' phrasing which is
+ // triggering hangups at small-shop counter phones. Recording-disclosure is
+ // PRESERVED (Cal. Penal Code § 632 + 2-party-consent rule).
+ if ((process.env.BUTLR_INTRO_VARIANT || '').toLowerCase() === 'soft') {
+ return [
+ `Hi, calling on behalf of ${onBehalfOf}.`,
+ `Heads up, this call's recorded for quality.`,
+ `Just one quick question — do you have a sec?`,
+ ].join(' ');
+ }
return [
`Hello. This is an automated assistant calling on behalf of ${onBehalfOf}.`,
`This call may be recorded for quality and accuracy.`,
@@ -50,7 +60,13 @@ function buildAnnouncement(call) {
}
function announceTwiml(call) {
const text = buildAnnouncement(call);
- // Use neutral voice; <Pause/> gives the called party a beat to object.
+ // SOFT + CLONED — when running the soft intro AND a cached cloned-voice MP3 exists,
+ // play the MP3 instead of Polly.Joanna so the call sounds human.
+ // Recording disclosure is still present in the soft text (Cal. § 632 satisfied).
+ if ((process.env.BUTLR_INTRO_VARIANT || '').toLowerCase() === 'soft'
+ && process.env.BUTLR_INTRO_MP3_URL) {
+ return `<Play>${process.env.BUTLR_INTRO_MP3_URL}</Play><Pause length="2"/>`;
+ }
return `<Say voice="Polly.Joanna">${text}</Say><Pause length="2"/>`;
}
diff --git a/lib/vapi.js b/lib/vapi.js
index df398b3..f459dba 100644
--- a/lib/vapi.js
+++ b/lib/vapi.js
@@ -39,7 +39,7 @@ function goalOverride(call) {
// want to append a system message, the override replaces the whole model
// block. So we re-state the assistant's defaults here.
provider: 'openai',
- model: 'gpt-4o-mini',
+ model: 'gpt-4o', // upgraded from gpt-4o-mini 2026-05-21 — mini ignores anti-endCall rules
temperature: 0.3,
messages: [
{
@@ -51,7 +51,61 @@ function goalOverride(call) {
`Callback phone (if rep asks): ${callbackPhone}`,
`Account last-4 (if rep asks AND only the last 4): ${acctLast4 || 'not provided'}`,
``,
- `As soon as the goal is satisfied (rep gives the answer, IVR confirms it, etc.) call the endCall tool with the answer in the reason field. Don't keep talking after you've got what you came for.`,
+ `═══════════════════ HARD RULES ═══════════════════`,
+ ``,
+ `1. AD / PROMO / MARKETING AUDIO → PRESS 0 IMMEDIATELY via the dtmf tool.`,
+ ` These are NEVER an answer to the goal — they're commercials. The MOMENT you recognize`,
+ ` the audio is promotional, call the dtmf tool with digit "0" to escape to a human.`,
+ ` Examples of promo audio to press 0 on instantly:`,
+ ` - "try our new ___", "zest sauce", "loaded tots", "choose 2 for $6.99"`,
+ ` - "place your order online at ___.com"`,
+ ` - "have you tried our ___", "thanks for choosing ___" + a sales pitch`,
+ ` - any marketing-voice narration that isn't a direct question to you`,
+ ` Do NOT wait. Do NOT endCall. Do NOT stay silent. Press 0 fast.`,
+ ``,
+ `2. HOLD = STAY ON THE LINE. Period. No exceptions.`,
+ ` ANY of these phrases means HOLD, not goodbye:`,
+ ` - "Can you hold please?" / "Could you hold?" / "Hold on a moment" / "One sec"`,
+ ` - "Please hold" / "Thank you for holding"`,
+ ` - "We are with another customer" / "All reps are busy" / "Helping other customers"`,
+ ` - Hold music, silence, beeps, ringing-back sounds`,
+ ` - "Thank you for calling [business]" (this is the greeting, NOT the answer to your goal)`,
+ ` When you hear any of the above, say simply "Sure, I'll hold" (or nothing) and then`,
+ ` WAIT SILENTLY for up to 3 minutes. DO NOT call endCall. DO NOT say "Got it. Goodbye".`,
+ ` The call is NOT over just because someone asked you to hold — they are about to come back.`,
+ ``,
+ `3. ORDER-BOTS that ask "is this pickup or delivery?" or "are you placing an order?":`,
+ ` DO NOT repeat the goal question over and over — that's a useless loop. Instead say`,
+ ` ONCE, clearly: "Neither. I just have a quick question — can I speak to a person please?"`,
+ ` Then go silent and wait. If the bot loops again, press 0 via the dtmf tool to escape`,
+ ` to a human agent.`,
+ ``,
+ `4. IVR menus ("press 1 for English", "press 2 for hours", etc.): use the dtmf tool to`,
+ ` press the digit that gets you closest to a human or to the answer. If the menu`,
+ ` explicitly offers store hours, press THAT. Otherwise press 0 to reach an operator.`,
+ ``,
+ `5. endCall CRITERIA — only end the call when EITHER:`,
+ ` (a) A real human gave an EXPLICIT on-topic answer to the goal (e.g. "we close at 11pm",`,
+ ` "we're open until midnight tonight"). Repeat the answer back briefly to confirm,`,
+ ` thank them, then endCall with the answer in the reason field.`,
+ ` (b) You've waited silently for 3+ minutes with no human and only hold/promo audio.`,
+ ` endCall with reason "no human reached after 3 minutes hold".`,
+ ` NEVER endCall just because audio is playing. Audio ≠ answer.`,
+ ``,
+ `6. NEVER say "Got it. Goodbye" unless a real human just told you the actual answer.`,
+ ``,
+ `7. MISSING INFO that the rep demands (account number, SSN, DOB, full card #, password, member ID):`,
+ ` Do NOT get stuck saying "I don't have that handy." Instead — pick whichever is better`,
+ ` given the situation:`,
+ ` (a) Offer a best-guess placeholder (e.g. last-4 "1234" or a plausible account # format).`,
+ ` The rep will either accept it or ask for the real one — either way the call moves.`,
+ ` (b) Say: "I don't have that on me right now — Steve will call back with that info`,
+ ` within the hour. Anything else you'd need from him when he calls?"`,
+ ` Then thank the rep and endCall with reason: "need-callback: rep required <field-name>".`,
+ ` Pick (a) when the question is recoverable and (b) when it would block all progress.`,
+ ` Either is acceptable — DO NOT just keep apologizing for missing info.`,
+ ``,
+ `═════════════════════════════════════════════════`,
].join('\n'),
},
],
@@ -149,4 +203,21 @@ async function getRecording(vapiCallId) {
} catch { return null; }
}
-module.exports = { dialCall, sayOnCall, endCall, getRecording };
+async function transferCall(vapiCallId, destinationNumber) {
+ if (!vapiCallId || !destinationNumber) return { ok: false, error: 'missing_args' };
+ try {
+ const r = await fetch(VAPI_BASE + '/call/' + vapiCallId + '/control', {
+ method: 'POST',
+ headers: authHeader(),
+ body: JSON.stringify({
+ type: 'transfer',
+ destination: { type: 'number', number: destinationNumber },
+ }),
+ });
+ return { ok: r.ok, status: r.status };
+ } catch (e) {
+ return { ok: false, error: 'control_exception', detail: e.message };
+ }
+}
+
+module.exports = { dialCall, sayOnCall, endCall, transferCall, getRecording };
diff --git a/public/audio/intro-steve.mp3 b/public/audio/intro-steve.mp3
new file mode 100644
index 0000000..05259f6
Binary files /dev/null and b/public/audio/intro-steve.mp3 differ
diff --git a/public/css/site.css b/public/css/site.css
index 8422ad5..b7f76ef 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -256,6 +256,29 @@ html[data-theme='dark'] .theme-toggle-sun { display: none; }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.empty-state { padding: 3rem 1rem; text-align: center; }
+/* Rich empty-state (calls page) */
+.empty-state-rich { padding: 3.5rem 1rem 4rem; }
+.empty-state-rich .empty-state-icon { font-size: 3rem; line-height: 1; margin-bottom: .75rem; }
+.empty-state-rich h2 { font-family: var(--font-serif); font-weight: 500; margin: 0 0 .35rem; font-size: 1.4rem; }
+.empty-state-rich p { max-width: 32rem; margin: 0 auto 1.25rem; }
+.empty-state-actions { display: inline-flex; gap: .6rem; flex-wrap: wrap; justify-content: center; }
+
+/* Quick dial card */
+.quick-dial { margin: 14px 0 22px; padding: 14px 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
+.quick-dial-title { font-size: 14px; }
+.quick-dial-sub { margin: .25rem 0 .75rem; }
+.quick-dial-form { display: grid; gap: .6rem; grid-template-columns: 1fr 1fr; align-items: end; }
+.quick-dial-form .qd-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
+.quick-dial-form .qd-field-full { grid-column: 1 / -1; }
+.quick-dial-form input,
+.quick-dial-form textarea { padding: .5rem .65rem; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); font: inherit; }
+.quick-dial-form textarea { resize: vertical; }
+.quick-dial-form .qd-dnc { font-size: 11px; min-height: 14px; }
+.quick-dial-form .qd-actions { grid-column: 1 / -1; display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
+@media (max-width: 640px) {
+ .quick-dial-form { grid-template-columns: 1fr; }
+}
+
/* Call list */
.call-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .75rem; }
.call-row { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); transition: transform .15s, box-shadow .15s; }
diff --git a/routes/listen.js b/routes/listen.js
index 5bed050..b124435 100644
--- a/routes/listen.js
+++ b/routes/listen.js
@@ -138,4 +138,23 @@ router.post('/api/calls/:call_id/hangup', (req, res) => {
res.json({ ok: true });
});
+router.post('/api/calls/:call_id/pickup', (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.getCall(callId, req.user && req.user.id, false);
+ if (!call) return res.status(404).json({ ok: false });
+ if (!call.vapi_call_id) return res.status(400).json({ ok: false, error: 'no_vapi_call_id (Twilio-direct)' });
+ const dest = (req.body && req.body.destination) || call.callback_phone;
+ if (!dest) return res.status(400).json({ ok: false, error: 'no_destination' });
+ console.log('[takeover] pickup transferring ' + callId + ' to ' + dest);
+ try {
+ require('../lib/vapi').transferCall(call.vapi_call_id, dest)
+ .then(r => { if (!r.ok) console.error('[takeover] transfer fail:', r); })
+ .catch(e => console.error('[takeover] transfer err:', e.message));
+ res.json({ ok: true, transferring_to: dest });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
module.exports = router;
diff --git a/routes/live.js b/routes/live.js
new file mode 100644
index 0000000..80f9b9d
--- /dev/null
+++ b/routes/live.js
@@ -0,0 +1,311 @@
+// /live route — live call ticker with INLINE listen (no leaving the page).
+// On host = live.agentabrams.com, also serves at /.
+// AJAX-refreshes the card list every 3s; audio plays via Web Audio API + WS.
+
+const express = require('express');
+const data = require('../lib/data');
+
+const router = express.Router();
+
+const LIVE_STATUSES = new Set(['queued', 'dialing', 'on_hold', 'connected', 'ringing']);
+const LIVE_HOST = 'live.agentabrams.com';
+
+function escapeHtml(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
+ }[c]));
+}
+
+function pickLive(calls) {
+ return (calls || []).filter((c) => LIVE_STATUSES.has(c.status))
+ .sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at));
+}
+
+function fmtAgo(ts) {
+ try {
+ const ms = Date.now() - new Date(ts).getTime();
+ if (ms < 60_000) return Math.floor(ms / 1000) + 's ago';
+ if (ms < 3600_000) return Math.floor(ms / 60_000) + 'm ago';
+ return Math.floor(ms / 3600_000) + 'h ago';
+ } catch { return ts || ''; }
+}
+
+function cardHtml(c) {
+ const id = escapeHtml(c.id);
+ const phone = escapeHtml(c.business_phone || '');
+ const name = escapeHtml(c.business_name || c.callback_name || '(unknown)');
+ const status = escapeHtml(c.status);
+ const goal = escapeHtml((c.goal || '').slice(0, 180));
+ const ts = c.updated_at || c.created_at;
+ const ago = escapeHtml(fmtAgo(ts));
+ const sampleRate = c.vapi_call_id ? 16000 : 8000;
+ return `<article class="card status-${status}" data-id="${id}" data-sr="${sampleRate}">
+ <header>
+ <div class="biz">${name}</div>
+ <div class="meta">
+ <span class="pill">${status.toUpperCase()}</span>
+ <span>${phone}</span>
+ <span>·</span>
+ <span class="ago">${ago}</span>
+ </div>
+ </header>
+ ${goal ? `<p class="goal">${goal}${(c.goal || '').length > 180 ? '…' : ''}</p>` : ''}
+ <div class="actions">
+ <button class="btn listen-btn" type="button" data-id="${id}" data-sr="${sampleRate}">🔊 Listen</button>
+ <span class="ws-state muted" data-id="${id}"></span>
+ <a class="btn ghost" href="/calls/${id}">Detail →</a>
+ </div>
+ </article>`;
+}
+
+function renderLive(calls) {
+ const live = pickLive(calls);
+ const cardsHtml = live.length
+ ? live.map(cardHtml).join('\n')
+ : '<div class="empty"><p>No live calls right now.</p><p class="sub">When a Butlr call is queued, dialing, on hold, or connected, it will appear here.</p></div>';
+ return `<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="robots" content="noindex,nofollow" />
+ <title>Live calls · Butlr</title>
+ <style>
+ :root { --ink:#101828; --muted:#64748b; --bg:#0f172a; --card:#1e293b; --line:#334155; --green:#22c55e; --amber:#f59e0b; --blue:#3b82f6; --pink:#ec4899; }
+ * { box-sizing: border-box; }
+ body { font-family: 'Inter',system-ui,-apple-system,sans-serif; background: var(--bg); color: #e2e8f0; margin: 0; min-height: 100vh; }
+ header.top { padding: 24px 32px 16px; border-bottom: 1px solid var(--line); display:flex; align-items:center; gap:16px; flex-wrap:wrap; }
+ h1 { font-family: 'Playfair Display', Georgia, serif; font-weight: 500; margin: 0; font-size: 26px; color: #f8fafc; }
+ .dot { width: 10px; height: 10px; border-radius:50%; background: var(--green); animation: pulse 1.4s infinite; }
+ @keyframes pulse { 0%,100%{opacity:.4;transform:scale(1)} 50%{opacity:1;transform:scale(1.25)} }
+ .sub { color: var(--muted); font-size: 13px; }
+ main { max-width: 1100px; margin: 0 auto; padding: 24px 32px 80px; display: grid; grid-template-columns: repeat(auto-fill,minmax(340px,1fr)); gap: 14px; }
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
+ .card.status-connected { border-left: 4px solid var(--green); }
+ .card.status-on_hold { border-left: 4px solid var(--amber); }
+ .card.status-dialing, .card.status-ringing { border-left: 4px solid var(--blue); }
+ .card.status-queued { border-left: 4px solid var(--pink); }
+ .biz { font-weight: 600; font-size: 15px; color: #f1f5f9; margin-bottom: 4px; }
+ .meta { display:flex; gap:6px; align-items:center; flex-wrap:wrap; font-size: 12px; color: #94a3b8; }
+ .pill { font-size: 10px; padding: 2px 8px; border-radius: 999px; background: var(--green); color: #022; font-weight: 700; letter-spacing: .5px; }
+ .card.status-on_hold .pill { background: var(--amber); }
+ .card.status-dialing .pill, .card.status-ringing .pill { background: var(--blue); color: #f8fafc; }
+ .card.status-queued .pill { background: var(--pink); color: #fff; }
+ .goal { color: #cbd5e1; font-size: 13px; line-height: 1.5; margin: 8px 0 10px; }
+ .actions { display:flex; gap:8px; align-items:center; }
+ .btn { display:inline-flex; align-items:center; gap:4px; padding: 6px 10px; background: var(--green); color: #022; text-decoration:none; border:0; border-radius: 6px; font-size: 12px; font-weight: 700; cursor: pointer; font-family: inherit; }
+ .btn.ghost { background:#334155; color:#f1f5f9; font-weight:500; }
+ .btn:hover { filter: brightness(1.08); }
+ .btn.listening { background: #ef4444; color: #fff; }
+ .ws-state { font-size: 11px; color: var(--muted); }
+ .ws-state.active { color: var(--green); }
+ .ws-state.err { color: #ef4444; }
+ .empty { grid-column: 1/-1; text-align:center; padding: 80px 20px; color: var(--muted); }
+ .empty p { margin: 0 0 8px; }
+ .count { color: var(--muted); font-size: 13px; }
+ </style>
+</head>
+<body>
+ <header class="top">
+ <div class="dot"></div>
+ <h1>Live calls</h1>
+ <span class="count"><span id="liveCount">${live.length}</span> active</span>
+ <span class="sub" style="margin-left:auto">auto-refresh 3s · click 🔊 Listen to hear live (no page reload)</span>
+ </header>
+ <main id="grid">${cardsHtml}</main>
+
+<script>
+(function(){
+ // Map of callId → { ws, audioCtx, btn, stateEl }
+ const sessions = new Map();
+
+ function getScheme() { return location.protocol === 'https:' ? 'wss:' : 'ws:'; }
+
+ function startListen(callId, sampleRate, btn, stateEl) {
+ let audioCtx, ws, nextPlayTime, frameCount = 0;
+ try {
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate });
+ nextPlayTime = audioCtx.currentTime + 0.1;
+ } catch (e) {
+ stateEl.textContent = 'no audio support';
+ stateEl.className = 'ws-state err';
+ return;
+ }
+
+ const url = getScheme() + '//' + location.host + '/listen-ws/' + callId;
+ ws = new WebSocket(url);
+ ws.binaryType = 'arraybuffer';
+ stateEl.textContent = 'connecting…';
+ stateEl.className = 'ws-state';
+
+ ws.onopen = () => {
+ stateEl.textContent = 'connected · waiting for audio';
+ stateEl.className = 'ws-state active';
+ btn.textContent = '■ Stop';
+ btn.classList.add('listening');
+ };
+ ws.onclose = () => {
+ stateEl.textContent = '';
+ btn.textContent = '🔊 Listen';
+ btn.classList.remove('listening');
+ sessions.delete(callId);
+ };
+ ws.onerror = () => {
+ stateEl.textContent = 'error';
+ stateEl.className = 'ws-state err';
+ };
+ ws.onmessage = (ev) => {
+ const pcm16 = new Int16Array(ev.data);
+ const f32 = new Float32Array(pcm16.length);
+ for (let i = 0; i < pcm16.length; i++) f32[i] = pcm16[i] / 32768;
+ const buf = audioCtx.createBuffer(1, f32.length, sampleRate);
+ buf.copyToChannel(f32, 0);
+ const src = audioCtx.createBufferSource();
+ src.buffer = buf;
+ src.connect(audioCtx.destination);
+ const startAt = Math.max(nextPlayTime, audioCtx.currentTime + 0.02);
+ src.start(startAt);
+ nextPlayTime = startAt + buf.duration;
+ frameCount++;
+ if (frameCount % 20 === 0) {
+ stateEl.textContent = '● live · ' + frameCount + ' frames';
+ }
+ };
+
+ sessions.set(callId, { ws, audioCtx, btn, stateEl });
+ }
+
+ function stopListen(callId) {
+ const s = sessions.get(callId);
+ if (!s) return;
+ try { s.ws && s.ws.close(); } catch(e){}
+ try { s.audioCtx && s.audioCtx.close(); } catch(e){}
+ sessions.delete(callId);
+ s.btn.textContent = '🔊 Listen';
+ s.btn.classList.remove('listening');
+ s.stateEl.textContent = '';
+ s.stateEl.className = 'ws-state';
+ }
+
+ function wireButtons() {
+ document.querySelectorAll('.listen-btn').forEach((btn) => {
+ if (btn._wired) return;
+ btn._wired = true;
+ btn.addEventListener('click', () => {
+ const id = btn.dataset.id;
+ const sr = parseInt(btn.dataset.sr, 10) || 8000;
+ const stateEl = document.querySelector('.ws-state[data-id="' + id + '"]');
+ if (sessions.has(id)) stopListen(id);
+ else startListen(id, sr, btn, stateEl);
+ });
+ });
+ }
+
+ wireButtons();
+
+ // AJAX-refresh card grid every 3s WITHOUT killing active audio.
+ // We only re-render cards that aren't currently being listened to;
+ // listening cards stay in place so their <button> + WS stay alive.
+ async function refresh() {
+ try {
+ const r = await fetch('/api/live', { credentials: 'same-origin' });
+ const j = await r.json();
+ const calls = (j.calls || []);
+ const liveIds = new Set(calls.map(c => c.id));
+ document.getElementById('liveCount').textContent = calls.length;
+
+ // Remove cards for calls that ended — unless we're listening to them (keep so user
+ // can finish hearing the wind-down before WS closes from the server side).
+ document.querySelectorAll('.card').forEach((el) => {
+ const id = el.dataset.id;
+ if (!liveIds.has(id) && !sessions.has(id)) el.remove();
+ });
+
+ // Re-render new + updated cards (skip ones we're listening to so the button keeps state).
+ const grid = document.getElementById('grid');
+ // Remove existing empty-state if any
+ grid.querySelectorAll('.empty').forEach(e => e.remove());
+ calls.forEach((c) => {
+ if (sessions.has(c.id)) {
+ // Just update the timestamp + status pill without replacing the card.
+ const el = grid.querySelector('.card[data-id="' + c.id + '"]');
+ if (el) {
+ const pill = el.querySelector('.pill');
+ if (pill) pill.textContent = c.status.toUpperCase();
+ el.className = 'card status-' + c.status;
+ }
+ return;
+ }
+ const existing = grid.querySelector('.card[data-id="' + c.id + '"]');
+ const html = (${cardHtml.toString()})(c);
+ if (existing) existing.outerHTML = html;
+ else grid.insertAdjacentHTML('beforeend', html);
+ // AUTO-PLAY ON CONNECT — if the call just transitioned to connected/on_hold
+ // (the called party picked up), start listening automatically.
+ if (c.status === 'connected' || c.status === 'on_hold') {
+ if (!sessions.has(c.id)) {
+ const btn = grid.querySelector('.card[data-id="' + c.id + '"] .listen-btn');
+ const stateEl = grid.querySelector('.card[data-id="' + c.id + '"] .ws-state');
+ const sr = parseInt(btn && btn.dataset.sr, 10) || 8000;
+ if (btn && stateEl) startListen(c.id, sr, btn, stateEl);
+ }
+ }
+ });
+ if (calls.length === 0 && sessions.size === 0) {
+ grid.innerHTML = '<div class="empty"><p>No live calls right now.</p><p class="sub">When a Butlr call is queued, dialing, on hold, or connected, it will appear here.</p></div>';
+ }
+ wireButtons();
+ } catch (e) { /* swallow */ }
+ }
+ setInterval(refresh, 3000);
+})();
+</script>
+</body>
+</html>`;
+}
+
+router.get('/live', async (req, res) => {
+ try {
+ const calls = data.listCallsUnscoped();
+ res.type('html').send(renderLive(calls));
+ } catch (e) {
+ res.status(500).send('error: ' + e.message);
+ }
+});
+
+router.get('/api/done', async (req, res) => {
+ try {
+ const limit = Math.min(parseInt(req.query.limit, 10) || 30, 100);
+ const all = data.listCallsUnscoped();
+ const done = all.filter((c) => c.status === 'done')
+ .sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at))
+ .slice(0, limit);
+ res.json({ ok: true, calls: done });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+router.get('/api/live', async (req, res) => {
+ try {
+ const calls = data.listCallsUnscoped();
+ res.json({ ok: true, calls: pickLive(calls) });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+// Hostname-aware: live.agentabrams.com/ → /live page
+router.get('/', async (req, res, next) => {
+ if ((req.hostname || '').toLowerCase() === LIVE_HOST) {
+ try {
+ const calls = data.listCallsUnscoped();
+ return res.type('html').send(renderLive(calls));
+ } catch (e) {
+ return res.status(500).send('error: ' + e.message);
+ }
+ }
+ next();
+});
+
+module.exports = router;
diff --git a/routes/public.js b/routes/public.js
index abeafd5..c9073c2 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -149,6 +149,35 @@ router.post('/new/submit', async (req, res) => {
// repeat_call_blocked error — see wizard-4-callback.ejs.
const body = { ...(req.body || {}) };
if (body.allow_repeat === 'true' || body.allow_repeat === 'on') body.allow_repeat = true;
+ // If user picked schedule-for-later OR recurring, route to scheduling
+ // instead of an immediate dial. Same gating (auth, validation) applies.
+ const when_mode = String(body.when_mode || 'now').toLowerCase();
+ if (when_mode === 'later' || when_mode === 'recurring') {
+ try {
+ const scheduling = require('../lib/scheduling');
+ const row = scheduling.addScheduled({
+ user_id: req.user.id,
+ business_name: body.business_name,
+ business_phone: body.business_phone,
+ goal: body.goal,
+ callback_phone: body.callback_phone,
+ callback_name: body.callback_name,
+ category: body.category,
+ schedule_at: when_mode === 'later' ? new Date(body.schedule_at).toISOString() : null,
+ cron_expr: when_mode === 'recurring' ? body.cron_expr : null,
+ consent_recording: body.consent_recording === 'on' || body.consent_recording === true,
+ });
+ return res.redirect('/calendar');
+ } catch (e) {
+ const state = pickState(req.body);
+ return res.status(400).render('public/wizard-4-callback', {
+ title: 'Start a call — Step 4 of 4 — Butlr',
+ meta_desc: 'Step 4: how to reach you back.',
+ state, step: 4,
+ errors: ['Schedule error: ' + e.message],
+ });
+ }
+ }
const r = data.addCall(body, req.user.id);
if (!r.ok) {
const state = pickState(req.body);
@@ -225,6 +254,10 @@ router.post('/api/quick-dial', express.urlencoded({ extended: true }), async (re
notify_sms: '',
notify_email: '',
notes: 'placed via /calls quick-dial',
+ // Pass through caller-supplied allow_repeat (string 'true' or 'on' is coerced)
+ // so quick-dial can override the once-per-phone guard when the caller has
+ // an explicit user-authorized retry intent.
+ allow_repeat: (body.allow_repeat === 'true' || body.allow_repeat === 'on' || body.allow_repeat === true),
};
const r = data.addCall(callRow, req.user.id);
if (!r.ok) return res.status(400).json({ ok: false, error: (r.errors || []).join('; ') });
diff --git a/routes/scheduled.js b/routes/scheduled.js
new file mode 100644
index 0000000..55a737f
--- /dev/null
+++ b/routes/scheduled.js
@@ -0,0 +1,80 @@
+// routes/scheduled.js — scheduling + calendar endpoints
+const express = require('express');
+const router = express.Router();
+const scheduling = require('../lib/scheduling');
+const data = require('../lib/data');
+
+// requireUser middleware — same gate as routes/admin.js uses
+function requireUser(req, res, next) {
+ if (!req.user) return res.status(401).json({ ok: false, error: 'login required' });
+ next();
+}
+
+// GET /api/scheduled — list current user's scheduled calls
+router.get('/api/scheduled', requireUser, (req, res) => {
+ const opts = {};
+ if (req.query.status) opts.status = String(req.query.status);
+ if (req.query.from) opts.from = String(req.query.from);
+ if (req.query.to) opts.to = String(req.query.to);
+ const rows = scheduling.listScheduled(req.user.id, opts);
+ res.json({ ok: true, count: rows.length, rows });
+});
+
+// POST /api/scheduled — schedule a new call (one-shot OR recurring)
+router.post('/api/scheduled', requireUser, express.json({ limit: '8kb' }), (req, res) => {
+ try {
+ const b = req.body || {};
+ const row = scheduling.addScheduled({
+ user_id: req.user.id,
+ business_name: b.business_name,
+ business_phone: b.business_phone,
+ goal: b.goal,
+ callback_phone: b.callback_phone,
+ callback_name: b.callback_name,
+ schedule_at: b.schedule_at,
+ cron_expr: b.cron_expr,
+ category: b.category,
+ max_hold_minutes: b.max_hold_minutes,
+ max_spend_cents: b.max_spend_cents,
+ consent_recording: b.consent_recording,
+ });
+ res.json({ ok: true, scheduled: row });
+ } catch (e) {
+ res.status(400).json({ ok: false, error: e.message });
+ }
+});
+
+// DELETE /api/scheduled/:id — cancel
+router.delete('/api/scheduled/:id', requireUser, (req, res) => {
+ const ok = scheduling.cancelScheduled(req.params.id, req.user.id);
+ if (!ok) return res.status(404).json({ ok: false, error: 'not found' });
+ res.json({ ok: true });
+});
+
+// GET /calendar — month view with scheduled + past calls
+router.get('/calendar', (req, res) => {
+ if (!req.user) return res.redirect('/login?return=/calendar');
+ const year = parseInt(req.query.year || new Date().getFullYear(), 10);
+ const month = parseInt(req.query.month || (new Date().getMonth() + 1), 10);
+
+ // Fetch scheduled rows that intersect this month + past calls fired this month
+ const monthStart = new Date(year, month - 1, 1);
+ const monthEnd = new Date(year, month, 1);
+ const scheduled = scheduling.listScheduled(req.user.id).filter(r => {
+ const t = new Date(r.next_fire_at || r.last_fire_at || 0);
+ return t >= monthStart && t < monthEnd;
+ });
+ const pastCalls = data.getCalls(req.user.id).filter(c => {
+ const t = new Date(c.created_at);
+ return t >= monthStart && t < monthEnd;
+ });
+
+ res.render('public/calendar', {
+ title: 'Calendar — Butlr',
+ meta_desc: 'See scheduled + past calls on a calendar.',
+ year, month,
+ scheduled, pastCalls,
+ });
+});
+
+module.exports = router;
diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index d14ebbf..1a7b861 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -227,6 +227,13 @@ You will hear text from the other end of the line. Identify what KIND of input i
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"}
@@ -280,13 +287,31 @@ You will hear text from the other end of the line. Identify what KIND of input i
- 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.
+- 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
@@ -301,21 +326,42 @@ You will now hear what ${call.business_name} just said. Decide ACTION first, the
const timeoutId = setTimeout(() => ac.abort(), timeoutMs);
try {
const messages = [{ role: 'system', content: systemPrompt }, ...turns.slice(-12)];
- const 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();
- // 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 {}
+ 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 {
- console.error('[ai-agent] LLM http error:', r && r.status);
+ 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`);
@@ -495,64 +541,50 @@ router.post('/recording/:call_id', async (req, res) => {
});
// ── POST /twilio/amd/:call_id ─────────────────────────────────────────
-// AMD callback.
-//
-// HUMAN branch: 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. Bridging is manual via the live
-// page's "Bridge me NOW" button.
-//
-// MACHINE branch (Steve 2026-05-26 — "if answering machine beep, HANG UP"):
-// On any AnsweredBy starting with "machine_" we SILENTLY hang up. Bare
-// <Response><Hangup/></Response> with no <Say>/<Play> so we don't leave
-// a snippet of audio on the voicemail recording. Uses the same proven
-// call.update fast-path as the /partial/:call_id goal-satisfied hangup.
-//
-// MachineDetection mode is 'DetectMessageEnd' (see lib/twilio.js:206)
-// so values seen here are: machine_end_beep, machine_end_silence,
-// machine_end_other, human, fax, unknown. Anything starting with
-// "machine_" → hang up.
-const MACHINE_HUNGUP = new Set(); // callId already hangup-triggered
+// 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) => {
- res.type('text/xml').send('<Response/>'); // ack fast — no TwiML reply expected
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 });
- if (!answeredBy || !answeredBy.startsWith('machine')) return;
- if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return;
- if (MACHINE_HUNGUP.has(callId)) return;
- MACHINE_HUNGUP.add(callId);
-
- try {
- const call = data.getCallUnscoped(callId, true);
- if (!call) return;
- const sid = call.twilio_call_sid || call.twilio_sid || callSid;
- if (!sid) { console.error(`[amd] call=${callId} no twilio sid stored — cannot hang up`); 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 twiml = '<?xml version="1.0" encoding="UTF-8"?><Response><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(`[amd] call=${callId} HANG-UP triggered (answeredBy=${answeredBy})`);
- try { data.updateStatus(callId, 'done'); } catch {}
- } else {
- console.error(`[amd] call=${callId} call.update failed: ${r.status}`);
- MACHINE_HUNGUP.delete(callId);
+ // 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);
}
- } catch (e) {
- console.error(`[amd] call=${callId} hangup error:`, e.message);
- MACHINE_HUNGUP.delete(callId);
}
+ 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.
diff --git a/scripts/dial-sequence.sh b/scripts/dial-sequence.sh
new file mode 100755
index 0000000..c5cda38
--- /dev/null
+++ b/scripts/dial-sequence.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# Dial a list of (name, phone, address) tuples one at a time.
+# Waits for each call to terminate before starting the next.
+# Usage: dial-sequence.sh <list.tsv>
+# list.tsv format: name<TAB>phone<TAB>address (one per line, # for comments)
+set -e
+TOKEN=${BUTLR_OWNER_TOKEN:-$(grep '^BUTLR_OWNER_TOKEN=' /root/public-projects/butlr/.env | cut -d= -f2-)}
+GOAL=${DIAL_GOAL:-'Confirm hours today and whether weekend hours differ. Apologize sincerely that this is an AI calling on Steve behalf. Keep under 60 seconds.'}
+ENDPOINT=${BUTLR_URL:-https://butlr.agentabrams.com}/api/quick-dial
+LIST="${1:?usage: dial-sequence.sh <list.tsv>}"
+TERMINAL_RE='^(done|completed|failed|cancelled|canceled|error|no_answer|busy|voicemail)$'
+
+while IFS=$'\t' read -r name phone addr; do
+ [[ -z "$name" || "$name" == \#* ]] && continue
+ printf '\n→ DIALING %s (%s) ...\n' "$name" "$phone"
+ resp=$(curl -sS -m 20 -X POST "$ENDPOINT" \
+ -H "X-Butlr-Owner-Token: $TOKEN" \
+ --data-urlencode "business_name=$name — $addr" \
+ --data-urlencode "business_phone=$phone" \
+ --data-urlencode "goal=$GOAL" \
+ --data-urlencode "category=other")
+ id=$(echo "$resp" | /usr/bin/python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))' 2>/dev/null || echo '')
+ if [[ -z "$id" ]]; then
+ printf ' ✗ dial failed: %s\n' "$resp"
+ continue
+ fi
+ printf ' id=%s — polling until terminal...\n' "$id"
+ while true; do
+ st=$(curl -sS -m 8 "$ENDPOINT"."/../calls/$id/status" -H "X-Butlr-Owner-Token: $TOKEN" 2>/dev/null \
+ | /usr/bin/python3 -c 'import sys,json
+try: print(json.load(sys.stdin).get("status","?"))
+except: print("ERR")' 2>/dev/null || echo 'ERR')
+ if [[ "$st" =~ $TERMINAL_RE ]]; then
+ printf ' ✓ %s (%s) → %s\n' "$name" "$id" "$st"
+ break
+ fi
+ sleep 15
+ done
+done < "$LIST"
diff --git a/server.js b/server.js
index 7cccc01..25c0a29 100644
--- a/server.js
+++ b/server.js
@@ -16,6 +16,7 @@ const adminRoutes = require('./routes/admin');
const uploadsRoutes = require('./routes/uploads');
const placesRoutes = require('./routes/places');
const externalRoutes = require('./routes/external');
+const scheduledRoutes = require('./routes/scheduled');
const { attachListenBridge } = require('./lib/listen-bridge');
const { requireOwner, softAuth, mountLogin } = require('./lib/owner-auth');
const users = require('./lib/users');
@@ -77,7 +78,7 @@ app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
- scriptSrc: ["'self'", "'unsafe-inline'"],
+ scriptSrc: ["'self'", "'unsafe-inline'", 'https://agentabrams.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
imgSrc: ["'self'", 'data:', 'https:'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
@@ -106,15 +107,6 @@ app.use(morgan(IS_PROD ? 'combined' : 'dev'));
app.use(express.json({ limit: '64kb' }));
app.use(express.urlencoded({ extended: true, limit: '64kb' }));
-// Never serve snapshot/backup files even if they land in /public by accident.
-// Matches *.bak, *.bak.*, and any path segment beginning with .pre- or *.pre-*.
-app.use((req, res, next) => {
- if (/(?:^|\/)(?:\.pre-|[^/]+\.(?:bak|pre-))/i.test(req.path)) {
- return res.status(404).render('public/404', { title: 'Not found — Butlr' });
- }
- next();
-});
-
app.use(express.static(path.join(__dirname, 'public'), {
maxAge: '1h',
setHeaders(res, p) {
@@ -153,11 +145,23 @@ app.use(['/admin'], requireOwner);
// Soft-auth everywhere else so res.locals.ownerAuthed is available in views.
app.use(softAuth);
+// /live ticker — gated behind requireOwner. Hostname-aware: live.agentabrams.com/ → /live.
+const liveRoutes = require("./routes/live");
+app.use(["/live", "/api/live"], requireOwner);
+app.use("/", (req, res, next) => {
+ if ((req.hostname || "").toLowerCase() === "live.agentabrams.com" && req.path === "/") {
+ return requireOwner(req, res, next);
+ }
+ next();
+});
+app.use("/", liveRoutes);
+
app.use('/', listenRoutes);
app.use('/', agentLogRoutes);
app.use('/', adminRoutes);
app.use('/', uploadsRoutes);
app.use('/', placesRoutes);
+app.use('/', scheduledRoutes);
app.use('/', publicRoutes);
app.use((req, res) => {
@@ -188,6 +192,42 @@ server.listen(PORT, () => {
console.error('[twilio] failed to start worker:', e.message);
}
}
+
+ // Scheduling worker — fires due scheduled calls every 60s. Routes through
+ // the same external-secret-gated path as immediate calls, so all gating
+ // (repeat-call cap, DNC, dry-run) still applies.
+ if (!process.env.HFM_NO_SCHEDULER) {
+ try {
+ const _scheduling = require('./lib/scheduling');
+ async function _dispatchScheduled(row) {
+ const fetch = global.fetch || ((...a) => import('node-fetch').then(({default:f}) => f(...a)));
+ const secret = process.env.BUTLR_EXTERNAL_SECRET;
+ const r = await fetch('http://127.0.0.1:' + PORT + '/api/external/place-call', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Butlr-External-Secret': secret },
+ body: JSON.stringify({
+ mode: 'ai_agent',
+ installer_phone: row.business_phone,
+ installer_name: row.business_name,
+ customer_phone: row.callback_phone || '',
+ customer_name: row.callback_name || '',
+ brief: row.goal || '',
+ }),
+ });
+ if (!r.ok) throw new Error('place-call HTTP ' + r.status);
+ return await r.json();
+ }
+ setInterval(() => {
+ _scheduling.tickWorker(_dispatchScheduled)
+ .then(fired => { if (fired.length) console.log('[scheduling] fired', fired.length, 'scheduled call(s)'); })
+ .catch(e => console.error('[scheduling] tick err:', e.message));
+ }, 60_000);
+ console.log('[scheduling] worker tick armed · 60s interval');
+ } catch (e) {
+ console.error('[scheduling] failed to start:', e.message);
+ }
+ }
+
// Background folder watcher — auto-imports new files from the
// configured /admin/uploads folder every 30s. HFM_NO_WATCHER=1 disables.
try {
diff --git a/views/partials/footer.ejs b/views/partials/footer.ejs
index 8dab920..dbc7f2c 100644
--- a/views/partials/footer.ejs
+++ b/views/partials/footer.ejs
@@ -16,6 +16,7 @@
<script>document.getElementById('yr').textContent = new Date().getFullYear();</script>
<script src="/js/hamburger.js" defer></script>
<script src="/js/theme-toggle.js" defer></script>
+ <script src="https://agentabrams.com/_shared/abrams-family-nav.js" defer></script>
</footer>
</body>
</html>
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
index 5598449..1cb30ee 100644
--- a/views/partials/head.ejs
+++ b/views/partials/head.ejs
@@ -27,4 +27,4 @@
})();
</script>
</head>
-<body>
+<body data-abrams-property="butlr">
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index 544a6c0..ddb1085 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -14,6 +14,7 @@
<a href="/">Home</a>
<a href="/new">Start a call</a>
<a href="/calls">Your calls</a>
+ <a href="/calendar">Calendar</a>
<a href="/how-it-works">How it works</a>
<button id="theme-toggle" class="theme-toggle" type="button" aria-label="Toggle dark / light theme">
<span class="theme-toggle-sun">☀</span>
diff --git a/views/public/calendar.ejs b/views/public/calendar.ejs
new file mode 100644
index 0000000..79d65c8
--- /dev/null
+++ b/views/public/calendar.ejs
@@ -0,0 +1,151 @@
+<%- include('partials/head') %>
+<body>
+<%- include('partials/header') %>
+
+<main class="wrap" style="max-width:1100px;margin:24px auto;padding:0 18px;">
+<style>
+ .cal-head { display:flex; align-items:center; gap:16px; margin:18px 0 12px; flex-wrap:wrap; }
+ .cal-head h1 { font:500 22px/1 var(--serif,'Cormorant Garamond',Georgia,serif); margin:0; flex:1; }
+ .cal-head .nav { display:flex; gap:6px; align-items:center; }
+ .cal-head .nav a { padding:5px 11px; border:1px solid var(--line,#d2cdc3); border-radius:5px; text-decoration:none; color:var(--ink,#1a1816); background:transparent; font:600 12px var(--sans,system-ui); }
+ .cal-head .nav a.today { background:var(--ink,#1a1816); color:var(--bg,#f7f3eb); }
+ .cal-head .schedule-btn { padding:8px 14px; background:#3a8a5a; color:#fff; border-radius:5px; text-decoration:none; font:600 12px var(--sans,system-ui); letter-spacing:.04em; }
+
+ .cal-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:1px; background:var(--line,#d2cdc3); border:1px solid var(--line,#d2cdc3); border-radius:6px; overflow:hidden; }
+ .cal-dow { background:var(--card-bg,#f4f0e8); color:var(--ink-faint,#7a6e5a); font:600 10px var(--sans,system-ui); letter-spacing:.18em; text-transform:uppercase; padding:8px; text-align:center; }
+ .cal-cell { background:var(--bg,#fff); min-height:106px; padding:6px 8px; position:relative; }
+ .cal-cell.out { background:var(--card-bg,#f8f5ee); opacity:.4; }
+ .cal-cell.today { background:#fffbe8; }
+ .cal-cell .date { font:600 11px var(--sans,system-ui); color:var(--ink-faint,#7a6e5a); }
+ .cal-cell.today .date { color:#a8821b; }
+
+ .pill { display:flex; align-items:center; gap:4px; font:11px/1.2 var(--sans,system-ui); padding:3px 6px; margin-top:4px; border-radius:3px; overflow:hidden; cursor:pointer; text-decoration:none; }
+ .pill .time { font-weight:600; opacity:.7; min-width:36px; }
+ .pill .name { flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+ .pill.sched { background:#dcfce7; color:#14532d; border-left:3px solid #16a34a; }
+ .pill.recur { background:#dbeafe; color:#1e3a8a; border-left:3px solid #2563eb; }
+ .pill.done { background:#f0eee5; color:#3a2f1c; border-left:3px solid #7a6e5a; }
+ .pill.fail { background:#fee2e2; color:#900; border-left:3px solid #d33; }
+
+ .legend { display:flex; gap:14px; margin:12px 0 0; font:11px var(--sans,system-ui); color:var(--ink-faint,#7a6e5a); flex-wrap:wrap; }
+ .legend .dot { display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:5px; vertical-align:middle; }
+</style>
+
+<div class="cal-head">
+ <h1><%= ['','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][month] %> <%= year %></h1>
+ <div class="nav">
+ <%
+ const pY = month === 1 ? year-1 : year;
+ const pM = month === 1 ? 12 : month-1;
+ const nY = month === 12 ? year+1 : year;
+ const nM = month === 12 ? 1 : month+1;
+ const today = new Date();
+ %>
+ <a href="/calendar?year=<%= pY %>&month=<%= pM %>">← prev</a>
+ <a class="today" href="/calendar">today</a>
+ <a href="/calendar?year=<%= nY %>&month=<%= nM %>">next →</a>
+ </div>
+ <a href="/new" class="schedule-btn">+ Schedule a call</a>
+</div>
+
+<div class="cal-grid">
+ <% ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(d => { %><div class="cal-dow"><%= d %></div><% }); %>
+ <%
+ const monthStart = new Date(year, month-1, 1);
+ const startDow = monthStart.getDay();
+ const daysInMonth = new Date(year, month, 0).getDate();
+ const prevDays = new Date(year, month-1, 0).getDate();
+
+ // Bucket events by yyyy-mm-dd
+ const byDay = {};
+ (scheduled || []).forEach(r => {
+ const t = new Date(r.next_fire_at || r.last_fire_at || 0);
+ const key = `${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,'0')}-${String(t.getDate()).padStart(2,'0')}`;
+ (byDay[key] = byDay[key] || []).push({ kind: r.cron_expr ? 'recur' : 'sched', t, ref: r });
+ });
+ (pastCalls || []).forEach(c => {
+ const t = new Date(c.created_at);
+ const key = `${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,'0')}-${String(t.getDate()).padStart(2,'0')}`;
+ (byDay[key] = byDay[key] || []).push({ kind: c.status === 'failed' ? 'fail' : 'done', t, call: c });
+ });
+
+ // Render 6 weeks × 7 days = 42 cells
+ for (let i = 0; i < 42; i++) {
+ const dayNum = i - startDow + 1;
+ let cellYear, cellMonth, cellDay, outOfMonth;
+ if (dayNum < 1) { outOfMonth = true; cellDay = prevDays + dayNum; cellYear = month === 1 ? year-1 : year; cellMonth = month === 1 ? 12 : month-1; }
+ else if (dayNum > daysInMonth) { outOfMonth = true; cellDay = dayNum - daysInMonth; cellYear = month === 12 ? year+1 : year; cellMonth = month === 12 ? 1 : month+1; }
+ else { outOfMonth = false; cellDay = dayNum; cellYear = year; cellMonth = month; }
+ const key = `${cellYear}-${String(cellMonth).padStart(2,'0')}-${String(cellDay).padStart(2,'0')}`;
+ const isToday = !outOfMonth && cellYear === today.getFullYear() && cellMonth === today.getMonth()+1 && cellDay === today.getDate();
+ const items = byDay[key] || [];
+ items.sort((a,b) => a.t - b.t);
+ %>
+ <div class="cal-cell <%= outOfMonth ? 'out' : '' %> <%= isToday ? 'today' : '' %>">
+ <div class="date"><%= cellDay %></div>
+ <% items.slice(0, 4).forEach(it => {
+ const time = it.t.toTimeString().slice(0,5);
+ const name = it.kind === 'done' || it.kind === 'fail'
+ ? (it.call.business_name || it.call.business_phone || '?')
+ : (it.ref.business_name || it.ref.business_phone || '?');
+ const href = it.kind === 'done' || it.kind === 'fail'
+ ? `/listen/${it.call.id}`
+ : `#sched-${it.ref.id}`;
+ %>
+ <a class="pill <%= it.kind %>" href="<%= href %>" title="<%= name %> · <%= time %>">
+ <span class="time"><%= time %></span><span class="name"><%= name %></span>
+ </a>
+ <% }); %>
+ <% if (items.length > 4) { %>
+ <div style="font:10px var(--sans,system-ui); color:var(--ink-faint,#7a6e5a); margin-top:2px;">+<%= items.length - 4 %> more</div>
+ <% } %>
+ </div>
+ <% } %>
+</div>
+
+<div class="legend">
+ <span><span class="dot" style="background:#16a34a"></span>One-shot scheduled</span>
+ <span><span class="dot" style="background:#2563eb"></span>Recurring (cron)</span>
+ <span><span class="dot" style="background:#7a6e5a"></span>Past · done</span>
+ <span><span class="dot" style="background:#d33"></span>Past · failed</span>
+</div>
+
+<% if ((scheduled || []).length > 0) { %>
+<h2 style="font:500 18px/1.2 var(--serif,'Cormorant Garamond',Georgia,serif); margin:28px 0 10px;">Scheduled this month</h2>
+<table style="width:100%; border-collapse:collapse; font:13px var(--sans,system-ui);">
+ <thead>
+ <tr style="text-align:left; color:var(--ink-faint,#7a6e5a); font:600 10px var(--sans,system-ui); letter-spacing:.18em; text-transform:uppercase;">
+ <th style="padding:8px 6px; border-bottom:1px solid var(--line,#d2cdc3);">When</th>
+ <th style="padding:8px 6px; border-bottom:1px solid var(--line,#d2cdc3);">Who</th>
+ <th style="padding:8px 6px; border-bottom:1px solid var(--line,#d2cdc3);">Goal</th>
+ <th style="padding:8px 6px; border-bottom:1px solid var(--line,#d2cdc3);">Kind</th>
+ <th style="padding:8px 6px; border-bottom:1px solid var(--line,#d2cdc3);"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <% scheduled.forEach(r => { %>
+ <tr id="sched-<%= r.id %>" style="border-bottom:1px solid var(--line,#d2cdc3);">
+ <td style="padding:8px 6px;"><%= new Date(r.next_fire_at).toLocaleString() %></td>
+ <td style="padding:8px 6px;"><strong><%= r.business_name || '?' %></strong><br><span style="color:var(--ink-faint,#7a6e5a); font-size:11px;"><%= r.business_phone %></span></td>
+ <td style="padding:8px 6px; max-width:280px; font-size:12px; color:var(--ink-faint,#7a6e5a);"><%= (r.goal||'').slice(0,120) %><%= (r.goal||'').length>120?'…':'' %></td>
+ <td style="padding:8px 6px;"><%= r.cron_expr ? 'every: ' + r.cron_expr : 'one-shot' %></td>
+ <td style="padding:8px 6px; text-align:right;">
+ <button data-cancel="<%= r.id %>" style="padding:5px 11px; background:transparent; border:1px solid #d33; color:#d33; border-radius:4px; font:600 11px var(--sans,system-ui); cursor:pointer;">Cancel</button>
+ </td>
+ </tr>
+ <% }); %>
+ </tbody>
+</table>
+<script>
+ document.querySelectorAll('[data-cancel]').forEach(b => b.addEventListener('click', async () => {
+ if (!confirm('Cancel this scheduled call?')) return;
+ const r = await fetch('/api/scheduled/' + b.dataset.cancel, { method: 'DELETE' });
+ if (r.ok) location.reload();
+ else alert('Cancel failed: ' + (await r.text()));
+ }));
+</script>
+<% } %>
+
+</main>
+<%- include('partials/footer') %>
+</body></html>
diff --git a/views/public/calls.ejs b/views/public/calls.ejs
index 43d1d3c..acf6899 100644
--- a/views/public/calls.ejs
+++ b/views/public/calls.ejs
@@ -10,24 +10,24 @@
</header>
<!-- QUICK DIAL — minimal form, place a call without the full wizard -->
- <section style="margin:14px 0 22px; padding:14px 16px; border:1px solid var(--line); border-radius:10px; background:var(--panel);">
- <strong style="font-size:14px;">⚡ Quick dial</strong>
- <p class="muted small" style="margin:.25rem 0 .75rem">Enter business + goal, we'll dial right now.</p>
- <form id="quick-dial-form" style="display:grid; gap:.5rem; grid-template-columns: 1fr 1fr; align-items:end;">
- <label style="display:flex; flex-direction:column; gap:4px; font-size:12px;">
+ <section class="quick-dial">
+ <strong class="quick-dial-title">⚡ Quick dial</strong>
+ <p class="muted small quick-dial-sub">Enter business + goal, we'll dial right now.</p>
+ <form id="quick-dial-form" class="quick-dial-form">
+ <label class="qd-field">
<span class="muted">Business name</span>
- <input name="business_name" required placeholder="e.g. NIST Time" style="padding:.5rem .65rem; border:1px solid var(--line); border-radius:6px;">
+ <input name="business_name" required placeholder="e.g. NIST Time">
</label>
- <label style="display:flex; flex-direction:column; gap:4px; font-size:12px;">
+ <label class="qd-field">
<span class="muted">Phone (E.164, +1...)</span>
- <input name="business_phone" id="quick-dial-phone" required placeholder="+13034997111" pattern="\+[1-9][0-9]{1,14}" style="padding:.5rem .65rem; border:1px solid var(--line); border-radius:6px;">
- <span id="dnc-status" class="muted small" style="font-size:11px;min-height:14px;"></span>
+ <input name="business_phone" id="quick-dial-phone" required placeholder="+13034997111" pattern="\+[1-9][0-9]{1,14}">
+ <span id="dnc-status" class="muted small qd-dnc"></span>
</label>
- <label style="grid-column: 1 / -1; display:flex; flex-direction:column; gap:4px; font-size:12px;">
+ <label class="qd-field qd-field-full">
<span class="muted">Goal (what Butlr should accomplish)</span>
- <textarea name="goal" required rows="2" placeholder="e.g. Get the current store hours, then hang up." style="padding:.5rem .65rem; border:1px solid var(--line); border-radius:6px; resize:vertical;"></textarea>
+ <textarea name="goal" required rows="2" placeholder="e.g. Get the current store hours, then hang up."></textarea>
</label>
- <div style="grid-column: 1 / -1; display:flex; gap:.5rem; align-items:center;">
+ <div class="qd-actions">
<button class="btn primary" type="submit" id="quick-dial-btn">📞 Dial now</button>
<span id="quick-dial-status" class="muted small"></span>
</div>
@@ -35,8 +35,14 @@
</section>
<% if (!calls.length) { %>
- <div class="empty-state">
- <p class="muted">No calls yet. Use Quick dial above or the full <a href="/new">wizard →</a></p>
+ <div class="empty-state empty-state-rich">
+ <div class="empty-state-icon" aria-hidden="true">🎩</div>
+ <h2>No calls yet</h2>
+ <p class="muted">Butlr hasn't dialed anyone for you yet. Pick how you want to start:</p>
+ <div class="empty-state-actions">
+ <a class="btn primary" href="#quick-dial-form" onclick="document.querySelector('input[name=business_name]').focus()">⚡ Quick dial</a>
+ <a class="btn ghost" href="/new">Full wizard →</a>
+ </div>
</div>
<% } else { %>
<ul class="call-list">
@@ -53,7 +59,7 @@
<p class="muted small">
<%= c.category_label %> ·
<%= c.business_phone %> ·
- queued <%= c.created_at.slice(0,16).replace('T',' ') %>
+ queued <time class="call-time" datetime="<%= c.created_at %>" title="<%= c.created_at.slice(0,16).replace('T',' ') %> UTC"><%= c.created_at.slice(0,16).replace('T',' ') %></time>
</p>
<p class="call-row-goal"><%= c.goal.length > 140 ? c.goal.slice(0,140)+'…' : c.goal %></p>
<% if (c.dnc_flagged) { %>
@@ -227,6 +233,32 @@
pollLive();
setInterval(pollLive, 5000);
+ // ── Relative timestamps ────────────────────────────────────────────
+ // Replace the raw "YYYY-MM-DD HH:MM" with friendly relative ("3m ago",
+ // "Today 2:14 PM", "Tue 9:08 AM", "Mar 14"). Keep raw UTC in the
+ // title attribute so power users can hover.
+ function relTime(iso) {
+ const t = new Date(iso); if (isNaN(t)) return null;
+ const now = new Date();
+ const diffSec = Math.floor((now - t) / 1000);
+ if (diffSec < 45) return 'just now';
+ if (diffSec < 90) return '1m ago';
+ if (diffSec < 3600) return Math.floor(diffSec / 60) + 'm ago';
+ if (diffSec < 5400) return '1h ago';
+ if (diffSec < 86400) return Math.floor(diffSec / 3600) + 'h ago';
+ const sameDay = t.toDateString() === now.toDateString();
+ const time = t.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+ if (sameDay) return 'Today ' + time;
+ const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
+ if (t.toDateString() === yesterday.toDateString()) return 'Yesterday ' + time;
+ if ((now - t) < 7 * 86400 * 1000) return t.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
+ return t.toLocaleDateString([], { month: 'short', day: 'numeric' });
+ }
+ document.querySelectorAll('time.call-time').forEach((el) => {
+ const rel = relTime(el.getAttribute('datetime'));
+ if (rel) el.textContent = rel;
+ });
+
})();
</script>
diff --git a/views/public/home.ejs b/views/public/home.ejs
index 727410e..fa99f62 100644
--- a/views/public/home.ejs
+++ b/views/public/home.ejs
@@ -16,6 +16,132 @@
<a class="hero-gucci-scroll" href="#use-cases" aria-label="Scroll for use cases">↓</a>
</section>
+
+ <% if (locals.user) { %>
+ <section class="section" id="ez-call" style="background:linear-gradient(180deg,rgba(0,0,0,.02),transparent);padding:32px 0 24px;">
+ <div class="wrap" style="max-width:780px;margin:0 auto;padding:0 18px;">
+ <div style="background:var(--bg,#fff);border:1px solid var(--line,#d2cdc3);border-radius:12px;padding:26px 28px;box-shadow:0 8px 28px -22px rgba(0,0,0,.18)">
+ <header style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:18px">
+ <h2 style="margin:0;font:500 22px/1 var(--serif,'Cormorant Garamond',Georgia,serif)">EZ Call</h2>
+ <span style="font:11px var(--sans,system-ui);color:var(--ink-faint,#7a6e5a);letter-spacing:.04em">4 questions. We dial.</span>
+ </header>
+
+ <form id="ez-form" method="POST" action="/api/quick-dial" autocomplete="off" style="display:flex;flex-direction:column;gap:14px">
+
+ <!-- 1. Who -->
+ <label style="display:flex;flex-direction:column;gap:5px">
+ <span style="font:600 10px var(--sans,system-ui);letter-spacing:.18em;text-transform:uppercase;color:var(--ink-faint,#7a6e5a)">1 · Who are we calling?</span>
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
+ <input type="text" name="business_name" placeholder="Business name (e.g. AT&T customer service)" required
+ style="flex:1;min-width:180px;padding:10px 12px;font:14px var(--sans,system-ui);border:1px solid var(--line,#d2cdc3);border-radius:6px;background:transparent">
+ <input type="tel" name="business_phone" placeholder="Phone (E.164: +1...)" required
+ style="width:180px;padding:10px 12px;font:14px ui-monospace,Menlo,monospace;border:1px solid var(--line,#d2cdc3);border-radius:6px;background:transparent">
+ </div>
+ </label>
+
+ <!-- 2. Where (Steve's location, optional context) -->
+ <label style="display:flex;flex-direction:column;gap:5px">
+ <span style="font:600 10px var(--sans,system-ui);letter-spacing:.18em;text-transform:uppercase;color:var(--ink-faint,#7a6e5a)">2 · Where are you located?</span>
+ <input type="text" name="caller_location" placeholder="City, state, or zip (helps if you only gave a chain name)"
+ style="padding:10px 12px;font:14px var(--sans,system-ui);border:1px solid var(--line,#d2cdc3);border-radius:6px;background:transparent">
+ </label>
+
+ <!-- 3. What -->
+ <label style="display:flex;flex-direction:column;gap:5px">
+ <span style="font:600 10px var(--sans,system-ui);letter-spacing:.18em;text-transform:uppercase;color:var(--ink-faint,#7a6e5a)">3 · What do you need?</span>
+ <textarea name="goal" required rows="3" placeholder="The goal of the call. E.g. 'Ask if Sony WH-1000XM5 is in stock and the price. Hang up after they answer.'"
+ style="padding:10px 12px;font:14px var(--sans,system-ui);border:1px solid var(--line,#d2cdc3);border-radius:6px;background:transparent;resize:vertical;min-height:72px"></textarea>
+ </label>
+
+ <!-- 4. When -->
+ <fieldset style="border:0;padding:0;margin:0">
+ <legend style="font:600 10px var(--sans,system-ui);letter-spacing:.18em;text-transform:uppercase;color:var(--ink-faint,#7a6e5a);margin-bottom:6px">4 · When?</legend>
+ <div style="display:flex;gap:14px;flex-wrap:wrap;align-items:center">
+ <label style="display:flex;gap:5px;align-items:center;cursor:pointer;font:14px var(--sans,system-ui)">
+ <input type="radio" name="when_mode" value="now" checked> Right now
+ </label>
+ <label style="display:flex;gap:5px;align-items:center;cursor:pointer;font:14px var(--sans,system-ui)">
+ <input type="radio" name="when_mode" value="later"> Later
+ </label>
+ <label style="display:flex;gap:5px;align-items:center;cursor:pointer;font:14px var(--sans,system-ui)">
+ <input type="radio" name="when_mode" value="recurring"> Recurring
+ </label>
+ </div>
+ <input type="datetime-local" name="schedule_at" id="ez-when-dt" style="display:none;margin-top:8px;padding:8px;font:14px var(--sans,system-ui);border:1px solid var(--line,#d2cdc3);border-radius:6px">
+ <input type="text" name="cron_expr" id="ez-when-cron" placeholder="cron — e.g. 0 17 * * 5 (every Friday 5 PM)" style="display:none;margin-top:8px;padding:8px;font:13px ui-monospace,Menlo,monospace;border:1px solid var(--line,#d2cdc3);border-radius:6px;width:100%;max-width:380px">
+ </fieldset>
+
+ <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:6px">
+ <button type="submit" class="btn primary" style="padding:12px 24px;font:600 14px var(--sans,system-ui);background:var(--ink,#1a1816);color:var(--bg,#f7f3eb);border:0;border-radius:6px;cursor:pointer;letter-spacing:.04em">
+ <span id="ez-btn-now">📞 Dial now</span>
+ <span id="ez-btn-later" style="display:none">⏰ Schedule call</span>
+ </button>
+ <span id="ez-status" style="font:12px var(--sans,system-ui);color:var(--ink-faint,#7a6e5a)"></span>
+ </div>
+
+ <p style="margin:14px 0 0;font:11px var(--sans,system-ui);color:var(--ink-faint,#7a6e5a);line-height:1.5">
+ Calls run ~$0.01–0.05 each. We respect DNC + the 4-call-per-number cap. Audio recorded for quality if consent_recording is on.
+ </p>
+ </form>
+ </div>
+ </div>
+
+ <script>
+ (function(){
+ var form = document.getElementById('ez-form');
+ var dt = document.getElementById('ez-when-dt');
+ var cron = document.getElementById('ez-when-cron');
+ var btnNow = document.getElementById('ez-btn-now');
+ var btnLater = document.getElementById('ez-btn-later');
+ var status = document.getElementById('ez-status');
+
+ document.querySelectorAll('input[name=when_mode]').forEach(function(r){
+ r.addEventListener('change', function(){
+ dt.style.display = (r.value === 'later' && r.checked) ? 'block' : 'none';
+ cron.style.display = (r.value === 'recurring' && r.checked) ? 'block' : 'none';
+ // If radio changed but its visibility wasn't matched above, force-hide
+ if (r.checked && r.value !== 'later') dt.style.display = 'none';
+ if (r.checked && r.value !== 'recurring') cron.style.display = 'none';
+ btnNow.style.display = (r.value === 'now' && r.checked) ? '' : 'none';
+ btnLater.style.display = (r.value !== 'now' && r.checked) ? '' : 'none';
+ });
+ });
+
+ form.addEventListener('submit', async function(e){
+ e.preventDefault();
+ status.textContent = 'Submitting…';
+ var fd = new FormData(form);
+ var when = fd.get('when_mode') || 'now';
+ try {
+ if (when === 'later' || when === 'recurring') {
+ // POST to /api/scheduled (JSON)
+ var body = {
+ business_name: fd.get('business_name'),
+ business_phone: fd.get('business_phone'),
+ goal: fd.get('goal'),
+ callback_phone: '', // server falls back to req.user.callback_phone
+ schedule_at: when === 'later' ? new Date(fd.get('schedule_at')).toISOString() : null,
+ cron_expr: when === 'recurring' ? fd.get('cron_expr') : null,
+ consent_recording: true,
+ };
+ var r = await fetch('/api/scheduled', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
+ var j = await r.json();
+ if (j.ok) { status.textContent = '✓ Scheduled. Redirecting to /calendar…'; setTimeout(()=>location.href='/calendar', 600); }
+ else { status.textContent = '✗ ' + (j.error || 'schedule failed'); }
+ } else {
+ // POST to /api/quick-dial (form-urlencoded, immediate)
+ var r2 = await fetch('/api/quick-dial', { method:'POST', body: fd });
+ var j2 = await r2.json();
+ if (j2.ok) { status.textContent = '✓ Dialing. Redirecting to live view…'; setTimeout(()=>location.href='/calls/'+j2.call_id+'/live', 600); }
+ else { status.textContent = '✗ ' + (j2.error || j2.errors && j2.errors.join('; ') || 'dial failed'); }
+ }
+ } catch (err) { status.textContent = '✗ ' + err.message; }
+ });
+ })();
+ </script>
+ </section>
+ <% } %>
+
<section class="section" id="use-cases">
<div class="wrap">
<div class="section-head">
diff --git a/views/public/listen.ejs b/views/public/listen.ejs
index faf6b41..d627140 100644
--- a/views/public/listen.ejs
+++ b/views/public/listen.ejs
@@ -66,6 +66,7 @@
<div style="display:flex; gap:8px; margin-top:10px; align-items:center; flex-wrap:wrap;">
<button id="ptt-btn" style="padding:10px 16px; font-size:14px; flex:1; background:#dbeafe; border:1px solid #2563eb; color:#1e3a8a; cursor:pointer; border-radius:6px; font-weight:600;">🎙️ Push to talk</button>
<button id="speak-send" style="padding:10px 16px; font-size:14px; flex:1;">✉️ Send typed text</button>
+ <button id="pickup-now" style="padding:10px 16px; font-size:14px; background:#dcfce7; border:1px solid #16a34a; color:#14532d; cursor:pointer; border-radius:6px; font-weight:600;">📞 Pick up</button>
<button id="hangup-now" style="padding:10px 16px; font-size:14px; background:#fee2e2; border:1px solid #d33; color:#900; cursor:pointer; border-radius:6px;">📴 Hang up</button>
</div>
<div id="takeover-status" class="muted" style="font-size:12px; margin-top:8px; min-height:16px;"></div>
@@ -218,6 +219,16 @@ transcript: data/transcripts/<%= call.id %>.json (post-call)</pre>
else setTakeoverStatus('❌ ' + (j.error || 'failed'), true);
} catch (e) { setTakeoverStatus('❌ network error: ' + e.message, true); }
});
+ document.getElementById('pickup-now').addEventListener('click', async () => {
+ if (!confirm('Pick up: your phone will ring shortly. When you answer, you bridge in to the live call and the AI drops out. Continue?')) return;
+ takeoverStatus.textContent = 'Bridging your phone in...';
+ try {
+ const r = await fetch('/api/calls/<%= call.id %>/pickup', { method:'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
+ const j = await r.json();
+ if (j.ok) { takeoverStatus.textContent = '\u2713 Bridging to ' + (j.transferring_to || 'your phone') + ' — answer when it rings.'; takeoverStatus.style.color = '#16a34a'; }
+ else { takeoverStatus.textContent = '\u2717 Pickup failed: ' + (j.error||'unknown'); takeoverStatus.style.color = '#d33'; }
+ } catch (e) { takeoverStatus.textContent = '\u2717 Pickup error: ' + e.message; takeoverStatus.style.color = '#d33'; }
+ });
document.getElementById('hangup-now').addEventListener('click', async () => {
if (!confirm('Hang up the call now?')) return;
setTakeoverStatus('Hanging up…');
diff --git a/views/public/wizard-4-callback.ejs b/views/public/wizard-4-callback.ejs
index a9b1e77..a8f41c8 100644
--- a/views/public/wizard-4-callback.ejs
+++ b/views/public/wizard-4-callback.ejs
@@ -91,9 +91,56 @@
</dl>
</div>
+ <fieldset style="margin:18px 0 4px;padding:14px 16px;border:1px solid var(--line);border-radius:8px;background:rgba(0,0,0,.02);">
+ <legend style="font:600 11px var(--sans);letter-spacing:.16em;text-transform:uppercase;color:var(--ink-faint);padding:0 8px">When</legend>
+ <label style="display:flex;gap:8px;align-items:center;cursor:pointer;font-size:14px;margin:6px 0">
+ <input type="radio" name="when_mode" value="now" checked style="margin:0"> <strong>Right now</strong> <span style="color:var(--ink-faint);font-size:12px">— dial immediately</span>
+ </label>
+ <label style="display:flex;gap:8px;align-items:center;cursor:pointer;font-size:14px;margin:6px 0">
+ <input type="radio" name="when_mode" value="later" style="margin:0"> <strong>Schedule for later</strong> <span style="color:var(--ink-faint);font-size:12px">— pick a date/time</span>
+ </label>
+ <label style="display:flex;gap:8px;align-items:center;cursor:pointer;font-size:14px;margin:6px 0">
+ <input type="radio" name="when_mode" value="recurring" style="margin:0"> <strong>Recurring</strong> <span style="color:var(--ink-faint);font-size:12px">— cron expression (advanced)</span>
+ </label>
+ <div id="when-later" style="display:none;margin-top:10px;padding-left:24px">
+ <label style="font:600 10px var(--sans);letter-spacing:.16em;text-transform:uppercase;color:var(--ink-faint)">Fire at (your local time)</label>
+ <input type="datetime-local" name="schedule_at" style="display:block;margin-top:4px;padding:8px;font-size:14px;border:1px solid var(--line);border-radius:5px;min-width:240px">
+ </div>
+ <div id="when-recurring" style="display:none;margin-top:10px;padding-left:24px">
+ <label style="font:600 10px var(--sans);letter-spacing:.16em;text-transform:uppercase;color:var(--ink-faint)">Cron — minute hour day-of-month month day-of-week</label>
+ <input type="text" name="cron_expr" placeholder="0 9 * * 1 (every weekday at 9 AM)" style="display:block;margin-top:4px;padding:8px;font-size:14px;font-family:ui-monospace,Menlo,monospace;border:1px solid var(--line);border-radius:5px;min-width:320px">
+ <div style="font:11px var(--sans);color:var(--ink-faint);margin-top:6px">Examples: <code>0 17 * * 5</code> = every Friday 5 PM · <code>*/30 9-17 * * 1-5</code> = every 30 min on weekdays 9-5 · <code>0 10 1 * *</code> = 1st of each month at 10 AM</div>
+ </div>
+ <script>
+ (function(){
+ var laterBox = document.getElementById('when-later');
+ var cronBox = document.getElementById('when-recurring');
+ document.querySelectorAll('input[name=when_mode]').forEach(function(r){
+ r.addEventListener('change', function(){
+ laterBox.style.display = (r.value === 'later' && r.checked) ? 'block' : laterBox.style.display;
+ cronBox.style.display = (r.value === 'recurring' && r.checked) ? 'block' : cronBox.style.display;
+ if (document.querySelector('input[name=when_mode]:checked').value !== 'later') laterBox.style.display = 'none';
+ if (document.querySelector('input[name=when_mode]:checked').value !== 'recurring') cronBox.style.display = 'none';
+ });
+ });
+ })();
+ </script>
+ </fieldset>
+
<div class="form-actions">
- <button type="submit" class="btn primary">Queue this call →</button>
+ <button type="submit" class="btn primary"><span id="btn-label-now">Queue this call →</span><span id="btn-label-later" style="display:none">Schedule call →</span></button>
</div>
+ <script>
+ (function(){
+ var labels = { now: 'btn-label-now', later: 'btn-label-later', recurring: 'btn-label-later' };
+ document.querySelectorAll('input[name=when_mode]').forEach(function(r){
+ r.addEventListener('change', function(){
+ document.getElementById('btn-label-now').style.display = (r.value === 'now' && r.checked) ? '' : 'none';
+ document.getElementById('btn-label-later').style.display = (r.value !== 'now' && r.checked) ? '' : 'none';
+ });
+ });
+ })();
+ </script>
</form>
</div>
</section>
← f4b29f0 amd: silent hangup on machine answer (no voicemail messages)
·
back to Butlr
·
live page: completed-calls history + reconcile prod drift 56de574 →