← back to Butlr
routes/external.js
216 lines
// External call-trigger API — lets trusted first-party sites (currently
// NationalPaperHangers.com) place a Butlr call without going through the
// owner-authed 4-step wizard.
//
// SECURITY MODEL
// This router is mounted OUTSIDE the requireOwner gate. It does NOT rely
// on a user cookie. Instead every request must carry a shared secret in
// the `X-Butlr-External-Secret` header. The secret is compared in
// constant time against process.env.BUTLR_EXTERNAL_SECRET.
// If BUTLR_EXTERNAL_SECRET is unset, the endpoint is hard-disabled (503)
// — fail-closed, never open.
//
// DATA SEPARATION
// NPH never touches Butlr's data files and vice-versa. The only contract
// is this HTTP endpoint. Calls placed here are owned by a dedicated
// service user ("external-service") so they don't pollute a human's queue
// and so getCall()'s per-user scoping still holds.
//
// THREE MODES (must match the NPH modal)
// hold — Butlr dials the business, waits through IVR/hold, calls the
// CUSTOMER back when a human answers. Needs customer_phone.
// bridge — Butlr/Twilio bridges customer <-> business directly. Needs
// customer_phone. (Same call row shape; callback_phone is the
// customer's number, which the live flow dials to bridge.)
// ai_agent — Butlr's AI calls the business, delivers a project brief,
// reports back. Needs `brief` + customer_phone (the SMS
// report-back contact).
//
// customer_phone is required for every mode — hold/bridge dial it, and
// addCall() requires a valid callback_phone on every call row regardless.
//
// CALL-ONCE RULE
// lib/data.js addCall() HARD-blocks dialing a phone that already has a
// non-terminal call, UNLESS allow_repeat:true. An NPH installer will
// legitimately be called by many different customers, so every
// NPH-originated call passes allow_repeat:true. The downstream hard cap
// of 4 calls/number still applies (data.js MAX_CALLS_PER_PHONE) — that
// protects the installer regardless of opt-in. See report for rationale.
//
// DRY-RUN
// Honors TWILIO_DRY_RUN exactly like every other Butlr call path. No
// special-casing — if TWILIO_DRY_RUN!=='0' (the default) nothing real
// is dialed; the call row just advances through the simulated state
// machine. The JSON response echoes `dry_run` so NPH can surface it.
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const data = require('../lib/data');
const users = require('../lib/users');
const SECRET_HEADER = 'x-butlr-external-secret';
const SERVICE_EMAIL = 'external-service@butlr.local';
// Lazily resolve (and, first time, create) the dedicated service user that
// owns every externally-originated call. Kept out of seedLegacyAdmin so it
// only exists once an external integration is actually wired.
let _serviceUserId = null;
async function getServiceUserId() {
if (_serviceUserId) return _serviceUserId;
let u = users.findByEmail(SERVICE_EMAIL);
if (!u) {
// Unguessable random password — this account never logs in interactively.
const r = await users.createUser({
email: SERVICE_EMAIL,
password: crypto.randomBytes(24).toString('base64url'),
role: 'user',
});
if (!r.ok) throw new Error('failed to create service user: ' + r.error);
u = users.findByEmail(SERVICE_EMAIL);
}
_serviceUserId = u.id;
return _serviceUserId;
}
// Constant-time secret check. Fail-closed when the env secret is unset.
function checkSecret(req) {
const expected = process.env.BUTLR_EXTERNAL_SECRET || '';
if (!expected) return { ok: false, code: 503, error: 'external_api_disabled' };
const supplied = String(req.headers[SECRET_HEADER] || '');
if (!supplied) return { ok: false, code: 401, error: 'missing_secret' };
const a = Buffer.from(expected);
const b = Buffer.from(supplied);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return { ok: false, code: 401, error: 'bad_secret' };
}
return { ok: true };
}
const MODES = new Set(['hold', 'bridge', 'ai_agent']);
router.post('/api/external/place-call', express.json(), async (req, res) => {
const gate = checkSecret(req);
if (!gate.ok) return res.status(gate.code).json({ ok: false, error: gate.error });
const b = req.body || {};
const mode = String(b.mode || '').trim();
const installerPhone = String(b.installer_phone || '').trim();
const installerName = String(b.installer_name || 'this business').trim().slice(0, 120);
const customerPhone = String(b.customer_phone || '').trim();
const customerName = String(b.customer_name || 'A customer').trim().slice(0, 80);
const brief = String(b.brief || '').trim().slice(0, 1500);
const errors = [];
if (!MODES.has(mode)) errors.push('mode must be one of: hold, bridge, ai_agent');
if (!installerPhone) errors.push('installer_phone is required');
// customer_phone is required for ALL modes: hold + bridge dial it directly,
// ai_agent needs it for the SMS report-back AND because Butlr's addCall()
// requires a valid callback_phone on every row.
if (!customerPhone) {
errors.push('customer_phone is required (where to call you back / report to)');
}
if (mode === 'ai_agent' && brief.length < 8) {
errors.push('brief (8+ chars) is required for ai_agent mode');
}
if (errors.length) return res.status(400).json({ ok: false, errors });
// Build the goal string per mode. addCall() requires goal >= 8 chars.
let goal;
if (mode === 'ai_agent') {
goal = `AI agent call on behalf of ${customerName}. Project brief: ${brief} `
+ `Ask about availability and a rough timeline, then end the call and report back.`;
} else if (mode === 'bridge') {
goal = `Click-to-call bridge: connect ${customerName} directly with ${installerName}. `
+ `${brief ? 'Context: ' + brief : 'Customer wants to discuss a wallcovering install.'}`;
} else {
goal = `Hold-for-me: wait through IVR/hold at ${installerName} and call `
+ `${customerName} back when a live person answers. `
+ `${brief ? 'Context: ' + brief : 'Customer wants to discuss a wallcovering install.'}`;
}
// callback_phone is the number Butlr reaches the customer on — the
// call-back target for hold, the bridge leg for bridge, the SMS
// report-back number for ai_agent. Always the customer's phone.
const callbackPhone = customerPhone;
let userId;
try {
userId = await getServiceUserId();
} catch (e) {
console.error('[external] service user error:', e.message);
return res.status(500).json({ ok: false, error: 'service_user_unavailable' });
}
const callRow = {
category: 'other',
business_name: installerName,
business_phone: installerPhone,
callback_phone: callbackPhone,
callback_name: customerName,
callback_email: '',
goal,
notes: `NPH call-installer · mode=${mode} · origin=nationalpaperhangers.com`,
max_hold_minutes: mode === 'bridge' ? 5 : 60,
max_spend_cents: 500,
consent_recording: 'on',
consent_terms: 'on',
notify_sms: customerPhone ? 'on' : '',
notify_email: '',
// The installer is a business the customer chose to call; many different
// customers will legitimately call the same installer. Bypass the
// once-per-phone block. The 4-calls/number hard cap in data.js still
// applies and is NOT bypassable.
allow_repeat: true,
};
const r = data.addCall(callRow, userId);
if (!r.ok) {
// max_retries_reached (the hard cap) surfaces here — return 429 so NPH
// can tell the customer the installer has been called too many times.
const isCap = (r.errors || []).some(e => /max_retries_reached/.test(e));
return res.status(isCap ? 429 : 400).json({ ok: false, errors: r.errors });
}
// Kick the dial in-process (same as the wizard's /new/submit) so the call
// starts moving immediately rather than waiting for the 5s worker tick.
try {
const twilio = require('../lib/twilio');
twilio.dialCall(r.call).catch(e => console.error('[external-dial]', e.message));
} catch (e) {
console.error('[external-dial-spawn]', e.message);
}
const dryRun = process.env.TWILIO_DRY_RUN !== '0';
const publicUrl = process.env.PUBLIC_URL || '';
res.json({
ok: true,
call_id: r.call.id,
mode,
status: r.call.status,
dry_run: dryRun,
// NPH can poll this to show live status. It is owner-scoped, so NPH
// cannot read it without the service user's cookie — it is returned for
// logging/debugging and future signed-status work, not as a live link.
status_path: `/api/calls/${r.call.id}/status`,
message: dryRun
? 'Call queued in DRY-RUN mode — no real phone call placed. Set TWILIO_DRY_RUN=0 on Butlr to dial for real.'
: 'Call queued and dialing.',
});
});
// Lightweight health probe so NPH can verify the integration is reachable
// and whether real dialing is enabled. Secret-gated like the main endpoint.
router.get('/api/external/health', (req, res) => {
const gate = checkSecret(req);
if (!gate.ok) return res.status(gate.code).json({ ok: false, error: gate.error });
res.json({
ok: true,
service: 'butlr-external',
twilio_dry_run: process.env.TWILIO_DRY_RUN !== '0',
modes: ['hold', 'bridge', 'ai_agent'],
});
});
module.exports = router;