← back to NationalPaperHangers

lib/butlr.js

88 lines

// Butlr client — places "Call this installer" calls via Butlr's external
// call-trigger API (POST /api/external/place-call).
//
// NPH and Butlr are fully separate services with separate databases. The
// ONLY contract between them is this HTTP endpoint. NPH never writes Butlr's
// data and Butlr never writes NPH's.
//
// Configuration (NPH .env):
//   BUTLR_API_URL       — base URL of the Butlr service
//                         (default http://localhost:9932; prod
//                          https://butlr.agentabrams.com)
//   BUTLR_EXTERNAL_SECRET — shared secret. Sent in the X-Butlr-External-Secret
//                         header. MUST match the value in Butlr's .env.
//                         If unset here, placeCall() fails fast — we never
//                         silently no-op a call the customer asked for.

const API_URL = (process.env.BUTLR_API_URL || 'http://localhost:9932').replace(/\/+$/, '');
const SECRET = process.env.BUTLR_EXTERNAL_SECRET || '';

// Modes the NPH modal offers — must match Butlr's routes/external.js MODES.
const MODES = new Set(['hold', 'bridge', 'ai_agent']);

// placeCall — hand a call off to Butlr.
//   opts.mode            — 'hold' | 'bridge' | 'ai_agent'  (required)
//   opts.installerPhone  — E.164-ish business number       (required)
//   opts.installerName   — business name for the announcement
//   opts.customerPhone   — customer's number               (required)
//   opts.customerName    — customer's name
//   opts.brief           — project brief (required for ai_agent)
//
// Returns { ok, status, body } — `status` is the HTTP status, `body` the
// parsed JSON. Network/timeout/missing-secret failures resolve with
// ok:false and an `error` string (this function never throws).
async function placeCall(opts = {}) {
  if (!SECRET) {
    return { ok: false, status: 0, error: 'butlr_not_configured',
      message: 'Butlr integration is not configured (BUTLR_EXTERNAL_SECRET unset).' };
  }
  if (!MODES.has(opts.mode)) {
    return { ok: false, status: 0, error: 'bad_mode', message: 'Unknown call mode.' };
  }

  const payload = {
    mode: opts.mode,
    installer_phone: opts.installerPhone || '',
    installer_name: opts.installerName || '',
    customer_phone: opts.customerPhone || '',
    customer_name: opts.customerName || '',
    brief: opts.brief || '',
  };

  // 10s timeout — Butlr's place-call kicks the dial async and returns fast,
  // but we still bound the wait so a hung Butlr can't pin an NPH request.
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), 10_000);
  try {
    const resp = await fetch(`${API_URL}/api/external/place-call`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Butlr-External-Secret': SECRET,
      },
      body: JSON.stringify(payload),
      signal: ctrl.signal,
    });
    let body = null;
    try { body = await resp.json(); } catch { /* non-JSON body */ }
    return { ok: resp.ok && !!(body && body.ok), status: resp.status, body };
  } catch (e) {
    const aborted = e.name === 'AbortError';
    return {
      ok: false, status: 0,
      error: aborted ? 'butlr_timeout' : 'butlr_unreachable',
      message: aborted ? 'Butlr did not respond in time.' : 'Could not reach the call service.',
    };
  } finally {
    clearTimeout(timer);
  }
}

// isConfigured — used by the route + view to decide whether to show the
// "Call this installer" button at all.
function isConfigured() {
  return !!SECRET;
}

module.exports = { placeCall, isConfigured, MODES, API_URL };