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