← back to Butlr
server.js
240 lines
require('dotenv').config();
const express = require('express');
const http = require('http');
const path = require('path');
const morgan = require('morgan');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const publicRoutes = require('./routes/public');
const listenRoutes = require('./routes/listen');
const agentLogRoutes = require('./routes/agent-log');
const twilioWebhooks = require('./routes/twilio-webhooks');
const vapiWebhooks = require('./routes/vapi-webhooks');
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');
// Seed the legacy admin (Steve) so existing BUTLR_OWNER_TOKEN cookies
// keep working through the migration. Idempotent — safe to run on every boot.
try { users.seedLegacyAdmin(); } catch (e) { console.error('[users] seedLegacyAdmin failed:', e.message); }
// ── Crash diagnostics ─────────────────────────────────────────────────
// pm2 restarted butlr ~5 times in ~10 min during the 2026-05-12 session,
// all with clean SIGINT (signal 2) exits — no JS stack traces in the
// error log. Add explicit handlers so future crashes show what threw.
// Keep the process alive on caught exceptions; only SIGTERM/SIGINT exit.
process.on('uncaughtException', (err) => {
console.error('[CRASH] uncaughtException at', new Date().toISOString(), err && err.stack || err);
});
process.on('unhandledRejection', (reason, p) => {
console.error('[CRASH] unhandledRejection at', new Date().toISOString(), reason && reason.stack || reason);
});
process.on('SIGINT', () => { console.error('[SIGNAL] SIGINT received at', new Date().toISOString()); process.exit(0); });
process.on('SIGTERM', () => { console.error('[SIGNAL] SIGTERM received at', new Date().toISOString()); process.exit(0); });
// ── Pre-warm the AI agent's LLM ────────────────────────────────────────
// On Kamatera CPU, qwen2.5:7b cold-start is ~11s; warm response is ~3s.
// Phone calls hit BUTLR_LLM_TIMEOUT_MS (12s) on cold cache, time out, and
// emit the deflection fallback string. Fire a dummy /api/chat at startup
// so the model is resident in RAM by the time the first real call lands.
// keep_alive: '24h' tells ollama not to evict.
(async () => {
const OLLAMA = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
const MODEL = process.env.BUTLR_LLM_MODEL || 'qwen2.5:7b';
try {
const t0 = Date.now();
await fetch(`${OLLAMA}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: MODEL, stream: false, keep_alive: '24h', messages: [{ role: 'user', content: 'warm' }], options: { num_predict: 4 } }),
});
console.log(`[startup] pre-warmed ${MODEL} in ${Date.now() - t0}ms`);
} catch (e) {
console.error('[startup] LLM pre-warm failed (call latency will be cold):', e.message);
}
})();
const app = express();
const PORT = parseInt(process.env.PORT || '9932', 10);
const PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
const IS_PROD = process.env.NODE_ENV === 'production';
app.set('trust proxy', 1);
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.disable('x-powered-by');
// Security baseline (per dw-site-build standing rule, 2026-05-12)
// connectSrc now allows ws:/wss: same-origin for the listen-in bridge.
// mediaSrc allows blob:/self for AudioContext playback.
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
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:'],
connectSrc: ["'self'", 'ws:', 'wss:'],
mediaSrc: ["'self'", 'blob:'],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
upgradeInsecureRequests: [],
},
},
strictTransportSecurity: { maxAge: 31536000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
app.use((req, res, next) => {
res.setHeader('Permissions-Policy', 'geolocation=(self), microphone=(), camera=(), payment=(self)');
next();
});
// Rate limiting — 200/min/IP global, 20/min/IP on /api (submission flood guard)
app.use(rateLimit({ windowMs: 60_000, max: 200, standardHeaders: 'draft-7', legacyHeaders: false }));
app.use('/api', rateLimit({ windowMs: 60_000, max: 20 }));
app.use(morgan(IS_PROD ? 'combined' : 'dev'));
app.use(express.json({ limit: '64kb' }));
app.use(express.urlencoded({ extended: true, limit: '64kb' }));
app.use(express.static(path.join(__dirname, 'public'), {
maxAge: '1h',
setHeaders(res, p) {
if (p.endsWith('.html')) res.setHeader('Cache-Control', 'no-store, must-revalidate');
},
}));
app.use((req, res, next) => {
res.locals.PUBLIC_URL = PUBLIC_URL;
next();
});
// Twilio webhooks must NOT pass through morgan body-noise & must accept Twilio's
// own urlencoded callbacks — mount before publicRoutes so /twilio/* short-circuits.
app.use('/twilio', twilioWebhooks);
// Vapi webhooks — assistant serverUrl is https://butlr.agentabrams.com/vapi/webhook.
app.use('/vapi', vapiWebhooks);
// External call-trigger API — first-party sites (NationalPaperHangers.com)
// place calls via POST /api/external/place-call. Mounted BEFORE the
// owner-auth gate; the router enforces its own shared-secret header check.
app.use('/', externalRoutes);
// Owner-auth: /login + /logout (always public). Then hard-gate every route
// that exposes call data so no future route can leak by accident.
const ownerRouter = express.Router();
mountLogin(ownerRouter);
app.use('/', ownerRouter);
// Hard-gate every path under /calls, /listen, and /api/calls. Wildcard so
// no future route under those prefixes can accidentally serve unauthenticated.
app.use(['/calls', '/listen', '/api/calls'], requireOwner);
// Also require sign-in to start a NEW call (/new wizard + /new/submit).
app.use(['/new'], requireOwner);
// Admin maintenance UI — also owner-gated. Per-route checks role==='admin'.
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) => {
res.status(404).render('public/404', { title: 'Not found — Butlr' });
});
app.use((err, req, res, next) => {
console.error('[error]', err && err.stack ? err.stack : err);
res.status(500).render('public/error', {
title: 'Server error — Butlr',
message: 'Something went wrong.',
});
});
// Plain http server so we can attach the WebSocket bridge on the same port.
const server = http.createServer(app);
attachListenBridge(server);
server.listen(PORT, () => {
console.log(`holdforme listening on :${PORT} (${IS_PROD ? 'prod' : 'dev'}) · ws bridge attached`);
// Start the call worker — DRY_RUN by default; set TWILIO_DRY_RUN=0 + Twilio creds for live.
// Skip if HFM_NO_WORKER=1 (useful for unit tests that don't want the background tick).
if (!process.env.HFM_NO_WORKER) {
try {
const twilio = require('./lib/twilio');
twilio.start({ intervalMs: 5000 });
} catch (e) {
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 {
const uploadWatcher = require('./lib/upload-watcher');
uploadWatcher.start({ intervalMs: 30_000 });
} catch (e) {
console.error('[upload-watcher] failed to start:', e.message);
}
});