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