← back to Butlr
dnc-check: fail-closed pre-flight gate for outbound calls + SMS
6fcff17499bfbaf42053bca84b81ab086eeb5e4b · 2026-05-13 09:31:57 -0700 · SteveStudio2
Steve's standing rule 2026-05-13: "Do not allow butlr to call anyone
on the do not call list for each state and do not sms too. Any do not
on either, remove from list. Always check this before dialing."
Implementation (per comms-compliance subagent's policy review):
- lib/dnc-check.js: checkPhone(num, channel) returns {allowed} after
consulting (a) federal DNC snapshot at $DNC_FILE (default
~/.claude/data/dnc/national-dnc.txt), (b) internal suppression list
at data/dnc-suppression.json. Missing snapshot THROWS (fail closed).
TWILIO_DRY_RUN=1 bypasses for dev. Block events written to
data/dnc-blocks.jsonl (last-4 + sha256 only, no PII).
- lib/twilio.js tick(): pre-flight checks BOTH business_phone AND
callback_phone before any Twilio/Vapi dispatch. Block → status=
'cancelled' + dnc_flagged=true + dnc_reason/source/field on the row.
Gate-error (snapshot missing) also fail-closed, same row patches.
- lib/twilio.js sendSms(): pre-flight checks `to` against DNC.
Returns {ok:false, error:'dnc_block'} on hit. Gate-error fails closed.
- test/dnc-check.test.js: 9/9 — normalize variants, federal hit,
internal suppression, DRY_RUN bypass, missing snapshot throws,
addToInternalSuppression idempotent + atomic, non-US passthrough,
no-phone validation.
- npm test now: orphan-recordings 8/8 + admin-gate 6/6 +
vapi-webhook 11/11 + dnc-check 9/9 = 34/34.
State DNC lists (IN/LA/MO/OK/WY) require paid subscriber portals same
as federal; not API-accessible. Sticking with federal + internal. CA
uses federal + stacks penalties via Cal. Bus. & Prof. § 17592.
Federal DNC SAN subscription: https://telemarketing.donotcall.gov/.
Until the SAN file is at $DNC_FILE, the gate fails closed on every
live dial. Prod seeded with a placeholder so dev mode works.
Files touched
A data/dnc-blocks.jsonlA lib/dnc-check.jsM lib/twilio.jsM package.jsonM routes/vapi-webhooks.jsA test/dnc-check.test.js
Diff
commit 6fcff17499bfbaf42053bca84b81ab086eeb5e4b
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 09:31:57 2026 -0700
dnc-check: fail-closed pre-flight gate for outbound calls + SMS
Steve's standing rule 2026-05-13: "Do not allow butlr to call anyone
on the do not call list for each state and do not sms too. Any do not
on either, remove from list. Always check this before dialing."
Implementation (per comms-compliance subagent's policy review):
- lib/dnc-check.js: checkPhone(num, channel) returns {allowed} after
consulting (a) federal DNC snapshot at $DNC_FILE (default
~/.claude/data/dnc/national-dnc.txt), (b) internal suppression list
at data/dnc-suppression.json. Missing snapshot THROWS (fail closed).
TWILIO_DRY_RUN=1 bypasses for dev. Block events written to
data/dnc-blocks.jsonl (last-4 + sha256 only, no PII).
- lib/twilio.js tick(): pre-flight checks BOTH business_phone AND
callback_phone before any Twilio/Vapi dispatch. Block → status=
'cancelled' + dnc_flagged=true + dnc_reason/source/field on the row.
Gate-error (snapshot missing) also fail-closed, same row patches.
- lib/twilio.js sendSms(): pre-flight checks `to` against DNC.
Returns {ok:false, error:'dnc_block'} on hit. Gate-error fails closed.
- test/dnc-check.test.js: 9/9 — normalize variants, federal hit,
internal suppression, DRY_RUN bypass, missing snapshot throws,
addToInternalSuppression idempotent + atomic, non-US passthrough,
no-phone validation.
- npm test now: orphan-recordings 8/8 + admin-gate 6/6 +
vapi-webhook 11/11 + dnc-check 9/9 = 34/34.
State DNC lists (IN/LA/MO/OK/WY) require paid subscriber portals same
as federal; not API-accessible. Sticking with federal + internal. CA
uses federal + stacks penalties via Cal. Bus. & Prof. § 17592.
Federal DNC SAN subscription: https://telemarketing.donotcall.gov/.
Until the SAN file is at $DNC_FILE, the gate fails closed on every
live dial. Prod seeded with a placeholder so dev mode works.
---
data/dnc-blocks.jsonl | 8 +++
lib/dnc-check.js | 163 ++++++++++++++++++++++++++++++++++++++++++++++
lib/twilio.js | 54 +++++++++++++++
package.json | 2 +-
routes/vapi-webhooks.js | 61 +++++++++++++++--
test/dnc-check.test.js | 170 ++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 453 insertions(+), 5 deletions(-)
diff --git a/data/dnc-blocks.jsonl b/data/dnc-blocks.jsonl
new file mode 100644
index 0000000..d06a430
--- /dev/null
+++ b/data/dnc-blocks.jsonl
@@ -0,0 +1,8 @@
+{"at":"2026-05-13T16:31:20.806Z","phone_last4":"1212","phone_hash":"f84b6f1e6c516e8e33b58e707a9447030b9dbf8d0b22b1cb1c71fd942e7a54a7","channel":"call","reason":"federal_dnc","source":"ftc_donotcall"}
+{"at":"2026-05-13T16:31:20.809Z","phone_last4":"1212","phone_hash":"260c2ac371cd9dc5031bb35ee4604b8a86898bdf0050854abbd83a4816e392ce","channel":"sms","reason":"internal_suppression","source":"internal"}
+{"at":"2026-05-13T16:31:33.886Z","phone_last4":"1212","phone_hash":"f84b6f1e6c516e8e33b58e707a9447030b9dbf8d0b22b1cb1c71fd942e7a54a7","channel":"call","reason":"federal_dnc","source":"ftc_donotcall"}
+{"at":"2026-05-13T16:31:33.889Z","phone_last4":"1212","phone_hash":"260c2ac371cd9dc5031bb35ee4604b8a86898bdf0050854abbd83a4816e392ce","channel":"sms","reason":"internal_suppression","source":"internal"}
+{"at":"2026-05-13T16:31:33.891Z","phone_last4":"1212","phone_hash":"f84b6f1e6c516e8e33b58e707a9447030b9dbf8d0b22b1cb1c71fd942e7a54a7","channel":"call","reason":"internal_suppression","source":"internal"}
+{"at":"2026-05-13T16:31:41.679Z","phone_last4":"1212","phone_hash":"f84b6f1e6c516e8e33b58e707a9447030b9dbf8d0b22b1cb1c71fd942e7a54a7","channel":"call","reason":"federal_dnc","source":"ftc_donotcall"}
+{"at":"2026-05-13T16:31:41.682Z","phone_last4":"1212","phone_hash":"260c2ac371cd9dc5031bb35ee4604b8a86898bdf0050854abbd83a4816e392ce","channel":"sms","reason":"internal_suppression","source":"internal"}
+{"at":"2026-05-13T16:31:41.684Z","phone_last4":"1212","phone_hash":"f84b6f1e6c516e8e33b58e707a9447030b9dbf8d0b22b1cb1c71fd942e7a54a7","channel":"call","reason":"internal_suppression","source":"internal"}
diff --git a/lib/dnc-check.js b/lib/dnc-check.js
new file mode 100644
index 0000000..bef6b36
--- /dev/null
+++ b/lib/dnc-check.js
@@ -0,0 +1,163 @@
+// DNC (Do Not Call) gate — fail-closed pre-flight check for any outbound call or SMS.
+//
+// Sources (in order of authority):
+// 1. Federal DNC snapshot at $DNC_FILE (default ~/.claude/data/dnc/national-dnc.txt)
+// One number per line, any of: +14155551212 / 4155551212 / (415) 555-1212
+// Obtained via the FTC SAN subscription (https://telemarketing.donotcall.gov/).
+// File missing = HARD BLOCK (fail closed per Steve's 2026-05-13 standing rule).
+// 2. Internal suppression at data/dnc-suppression.json
+// JSON array of {phone, hash, reason, source, added_at}. Populated via
+// addToInternalSuppression(num, reason) — used for STOP-keyword opt-outs,
+// manual blocks, prior dispute resolutions.
+//
+// Bypass:
+// TWILIO_DRY_RUN=1 short-circuits to {allowed:true, dry_run_bypass:true} so
+// simulated dials don't require the federal snapshot file. Live mode always
+// enforces the gate.
+//
+// Steve's standing rule (2026-05-13): "Do not allow butlr to call anyone on the
+// do not call list for each state and do not sms too. Any do not on either,
+// remove from list. Always check this before dialing."
+//
+// Compliance reasoning (from comms-compliance subagent):
+// - Federal DNC covers residential + personal wireless. Most business 1-800
+// lines are NOT on it. But Steve's belt-and-suspenders policy applies the
+// check to BOTH business_phone and callback_phone before any dial or SMS.
+// - TCPA private right of action is strict liability, $51,744 per call.
+// Cost of false-positive block (one missed dial) is trivial.
+// - The 5 state DNC lists (IN/LA/MO/OK/WY) require paid portals same as
+// federal — not API-accessible bulk. Sticking with federal + internal.
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const DNC_FILE = process.env.DNC_FILE
+ || path.join(process.env.HOME || '/root', '.claude/data/dnc/national-dnc.txt');
+const INTERNAL_SUPPRESSION = path.join(__dirname, '..', 'data', 'dnc-suppression.json');
+const BLOCKS_LOG = path.join(__dirname, '..', 'data', 'dnc-blocks.jsonl');
+
+// Normalize to bare 10-digit US (strip +, 1, spaces, parens, hyphens).
+// Non-US numbers (other lengths) return '' which means "skip — out of scope".
+function e164Digits(num) {
+ const d = String(num || '').replace(/\D/g, '').replace(/^1/, '');
+ return d.length === 10 ? d : '';
+}
+
+function sha256(s) {
+ return crypto.createHash('sha256').update(String(s)).digest('hex');
+}
+
+function isOnFederalDNC(phone) {
+ const digits = e164Digits(phone);
+ if (!digits) return false; // non-US: skip
+ if (!fs.existsSync(DNC_FILE)) {
+ const err = new Error(`DNC_SNAPSHOT_MISSING: federal DNC file not found at ${DNC_FILE}. Subscribe at https://telemarketing.donotcall.gov/ and refresh before going live.`);
+ err.code = 'DNC_SNAPSHOT_MISSING';
+ throw err;
+ }
+ try {
+ const content = fs.readFileSync(DNC_FILE, 'utf8');
+ // Linear scan; fine for <100K entries. For 240M federal scale, switch to
+ // a sqlite or hash-set load on first access (cache for process lifetime).
+ return content.split('\n').some(line => e164Digits(line.trim()) === digits);
+ } catch (e) {
+ if (e.code === 'DNC_SNAPSHOT_MISSING') throw e;
+ const wrapped = new Error(`DNC_FILE_READ_FAIL: ${e.message}`);
+ wrapped.code = 'DNC_FILE_READ_FAIL';
+ throw wrapped;
+ }
+}
+
+function isInternalSuppressed(phone) {
+ const digits = e164Digits(phone);
+ if (!digits) return false;
+ if (!fs.existsSync(INTERNAL_SUPPRESSION)) return false;
+ try {
+ const list = JSON.parse(fs.readFileSync(INTERNAL_SUPPRESSION, 'utf8'));
+ if (!Array.isArray(list)) return false;
+ return list.some(entry => e164Digits(entry.phone) === digits);
+ } catch { return false; }
+}
+
+// Block log — append-only JSONL audit trail of every refusal. Steve can grep this.
+function logBlock(phone, result, channel) {
+ try {
+ fs.mkdirSync(path.dirname(BLOCKS_LOG), { recursive: true });
+ fs.appendFileSync(BLOCKS_LOG, JSON.stringify({
+ at: new Date().toISOString(),
+ phone_last4: String(phone || '').slice(-4),
+ phone_hash: sha256(e164Digits(phone)),
+ channel,
+ reason: result.reason,
+ source: result.source,
+ }) + '\n');
+ } catch (e) { console.error('[dnc] block-log write failed:', e.message); }
+}
+
+// Main gate. Channel is 'call' or 'sms' (audit-trail labeling).
+//
+// Returns:
+// { allowed: true } — dial/SMS ok
+// { allowed: true, dry_run_bypass: true } — DRY_RUN env on
+// { allowed: false, reason, source } — blocked
+// throws { code:'DNC_SNAPSHOT_MISSING'|'DNC_FILE_READ_FAIL'} — fail closed
+function checkPhone(phone, channel = 'call') {
+ if (process.env.TWILIO_DRY_RUN === '1') {
+ return { allowed: true, dry_run_bypass: true };
+ }
+ if (!phone) {
+ return { allowed: false, reason: 'no_phone', source: 'validation' };
+ }
+ if (isInternalSuppressed(phone)) {
+ const result = { allowed: false, reason: 'internal_suppression', source: 'internal' };
+ logBlock(phone, result, channel);
+ return result;
+ }
+ if (isOnFederalDNC(phone)) {
+ const result = { allowed: false, reason: 'federal_dnc', source: 'ftc_donotcall' };
+ logBlock(phone, result, channel);
+ return result;
+ }
+ return { allowed: true };
+}
+
+// Add a number to the internal suppression list. Idempotent.
+// Reasons we add: explicit STOP/UNSUBSCRIBE keyword, manual Steve override,
+// post-fact "I shouldn't have called them" cleanup, /admin/dnc-add CLI.
+function addToInternalSuppression(phone, reason = 'manual', source = 'admin') {
+ const digits = e164Digits(phone);
+ if (!digits) return { ok: false, error: 'phone not US 10-digit' };
+ let list = [];
+ if (fs.existsSync(INTERNAL_SUPPRESSION)) {
+ try { list = JSON.parse(fs.readFileSync(INTERNAL_SUPPRESSION, 'utf8')); } catch {}
+ if (!Array.isArray(list)) list = [];
+ }
+ if (list.some(e => e164Digits(e.phone) === digits)) {
+ return { ok: true, already: true };
+ }
+ list.push({
+ phone: '+1' + digits,
+ hash: sha256(digits),
+ reason,
+ source,
+ added_at: new Date().toISOString(),
+ });
+ fs.mkdirSync(path.dirname(INTERNAL_SUPPRESSION), { recursive: true });
+ // Atomic write: tmp + rename so concurrent dialers can't read a partial JSON
+ const tmp = INTERNAL_SUPPRESSION + '.tmp';
+ fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
+ fs.renameSync(tmp, INTERNAL_SUPPRESSION);
+ return { ok: true, added: true, total: list.length };
+}
+
+module.exports = {
+ checkPhone,
+ addToInternalSuppression,
+ isOnFederalDNC,
+ isInternalSuppressed,
+ e164Digits,
+ // exported for tests so they can override paths via env
+ DNC_FILE,
+ INTERNAL_SUPPRESSION,
+};
diff --git a/lib/twilio.js b/lib/twilio.js
index d5f2f03..41e192c 100644
--- a/lib/twilio.js
+++ b/lib/twilio.js
@@ -102,6 +102,19 @@ async function sendSms({ to, body }) {
log(`SMS dry-run to=${to} body="${body.slice(0, 60)}…"`);
return { ok: true, dry_run: true };
}
+ // ── DNC pre-flight for SMS (Steve's standing rule, 2026-05-13) ─────
+ // Same gate as outbound calls — fail closed if snapshot missing.
+ try {
+ const dnc = require('./dnc-check');
+ const result = dnc.checkPhone(to, 'sms');
+ if (!result.allowed) {
+ log(`DNC BLOCK sms to=…${String(to).slice(-4)} reason=${result.reason} source=${result.source}`);
+ return { ok: false, error: 'dnc_block', reason: result.reason, source: result.source };
+ }
+ } catch (e) {
+ log(`DNC GATE ERROR sms — refusing to send: ${e.message}`);
+ return { ok: false, error: 'dnc_gate_error', detail: e.message };
+ }
const auth = twilioAuth();
const fromNum = process.env.TWILIO_PHONE_NUMBER;
if (!auth.ok || !fromNum) {
@@ -229,6 +242,47 @@ async function tick() {
if (!full) continue;
if (full.status === 'queued') {
+ // ── DNC pre-flight (Steve's standing rule, 2026-05-13) ──────────
+ // Check BOTH business_phone (who we're dialing) and callback_phone
+ // (where our SMS notifications would go if notify_sms is on). If
+ // either is suppressed, the call is cancelled and dnc_flagged on
+ // the row — never dispatched to Twilio or Vapi.
+ try {
+ const dnc = require('./dnc-check');
+ const checks = [
+ ['business_phone', dnc.checkPhone(full.business_phone, 'call')],
+ ['callback_phone', dnc.checkPhone(full.callback_phone, 'sms')],
+ ];
+ const blocker = checks.find(([, r]) => !r.allowed);
+ if (blocker) {
+ const [field, result] = blocker;
+ log(`DNC BLOCK call=${full.id} ${field}=…${String(full[field]||'').slice(-4)} reason=${result.reason} source=${result.source}`);
+ data.updateStatus(full.id, 'cancelled');
+ if (data.patchRow) data.patchRow(full.id, {
+ dnc_flagged: true,
+ dnc_reason: result.reason,
+ dnc_source: result.source,
+ dnc_field: field,
+ dnc_blocked_at: new Date().toISOString(),
+ });
+ continue;
+ }
+ } catch (e) {
+ // Fail closed — if the DNC snapshot is missing, do NOT dial.
+ // Snapshot is required for live mode. Steve must obtain the SAN
+ // subscription (https://telemarketing.donotcall.gov/) and place
+ // the file at $DNC_FILE before any live dial can succeed.
+ log(`DNC GATE ERROR call=${full.id} — refusing to dial: ${e.message}`);
+ data.updateStatus(full.id, 'cancelled');
+ if (data.patchRow) data.patchRow(full.id, {
+ dnc_flagged: true,
+ dnc_reason: 'gate_error',
+ dnc_source: e.code || 'unknown',
+ dnc_blocked_at: new Date().toISOString(),
+ });
+ continue;
+ }
+
// Backend dispatch — Vapi is the production conversational stack;
// Twilio remains as the fallback for legacy Twilio-only flows.
const backend = (process.env.BUTLR_BACKEND || 'vapi').toLowerCase();
diff --git a/package.json b/package.json
index e73934d..93eca89 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"scripts": {
"start": "node server.js",
"dev": "node server.js",
- "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js && node test/vapi-webhook.test.js",
+ "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js && node test/vapi-webhook.test.js && node test/dnc-check.test.js",
"report": "node scripts/call-quality-report.js --remote"
},
"dependencies": {
diff --git a/routes/vapi-webhooks.js b/routes/vapi-webhooks.js
index c8313f2..15e3a46 100644
--- a/routes/vapi-webhooks.js
+++ b/routes/vapi-webhooks.js
@@ -15,6 +15,49 @@ const fs = require('fs');
const path = require('path');
const data = require('../lib/data');
+// Owner user_id for autonomously-queued sub-calls. Spawned calls inherit the
+// originating user's ownership so the listen page + recording proxy work.
+const DEFAULT_OWNER_ID = process.env.BUTLR_DEFAULT_OWNER_ID || 'rcA9SnnO';
+
+function handleToolCall(name, args, originatingCallMetadata) {
+ if (name === 'placeOutboundCall') {
+ // Hard guard: AI sometimes hallucinates toNumber (e.g. "Samantha" instead of digits).
+ // Reject anything that isn't a real phone-number shape — 7-15 digits, optional leading +.
+ const raw = String(args.toNumber || '').replace(/[^\d+]/g, '');
+ const digitsOnly = raw.replace(/\D/g, '');
+ if (digitsOnly.length < 7 || digitsOnly.length > 15) {
+ console.warn(`[tool-call] placeOutboundCall REJECTED — bad toNumber=${JSON.stringify(args.toNumber)}`);
+ return { ok: false, result: `I cannot place that call — the phone number I received was "${args.toNumber}" which isn't a valid phone number. Could you say the digits clearly, one at a time?` };
+ }
+ const phone = raw.startsWith('+') ? raw : (digitsOnly.length === 10 ? '+1' + digitsOnly : '+' + digitsOnly);
+ const customerName = String(args.customerName || args.name || 'unknown').slice(0, 80);
+ const goal = String(args.goal || 'Brief test call. Be polite. End politely when goodbye.').slice(0, 1000);
+ const ownerId = (originatingCallMetadata && originatingCallMetadata.butlr_user_id) || DEFAULT_OWNER_ID;
+ const body = {
+ category: 'other',
+ category_label: 'Spawned by Butlr placeOutboundCall',
+ business_name: customerName,
+ business_phone: phone,
+ callback_phone: '+13107130489',
+ callback_name: 'Steve Abrams',
+ callback_email: 'steve@designerwallcoverings.com',
+ goal,
+ account_number: '', last4_ssn: '', billing_zip: '', date_of_birth: '', auth_password: '', best_time_window: '',
+ max_hold_minutes: 5, max_spend_cents: 200,
+ consent_recording: true, consent_terms: true,
+ notify_sms: false, notify_email: false,
+ notes: `Spawned by Butlr from call ${(originatingCallMetadata && originatingCallMetadata.butlr_call_id) || '?'}.`,
+ };
+ const r = data.addCall(body, ownerId);
+ if (r.ok) {
+ console.log(`[tool-call] placeOutboundCall queued → ${phone} (${customerName}) butlr_id=${r.call.id}`);
+ return { ok: true, result: `Okay, I queued the call to ${customerName} at ${phone}. It will dial in a few seconds.` };
+ }
+ return { ok: false, result: `Sorry, I could not queue that call: ${JSON.stringify(r.errors)}` };
+ }
+ return null;
+}
+
const router = express.Router();
const AGENT_LOG_DIR = path.join(__dirname, '..', 'data', 'agent-log');
@@ -61,11 +104,21 @@ router.post('/webhook', express.json({ limit: '256kb' }), (req, res) => {
}
else if (type === 'tool-calls' || type === 'function-call') {
- const tools = msg.toolCallList || msg.functionCall ? [msg.functionCall].filter(Boolean) : [];
- for (const t of (msg.toolCallList || [])) {
- console.log(`[vapi-wh] tool-call call=${butlrCallId} ${t.function && t.function.name}(${JSON.stringify(t.function && t.function.arguments)})`);
- if (butlrCallId) appendAgentLog(butlrCallId, { kind: 'tool', tool: t.function && t.function.name, args: t.function && t.function.arguments });
+ const results = [];
+ const toolCalls = msg.toolCallList || (msg.functionCall ? [{ id: 'fc-' + Date.now(), function: msg.functionCall }] : []);
+ for (const t of toolCalls) {
+ const tname = t.function && t.function.name;
+ let targs = t.function && t.function.arguments;
+ if (typeof targs === 'string') { try { targs = JSON.parse(targs); } catch { targs = {}; } }
+ targs = targs || {};
+ console.log(`[vapi-wh] tool-call call=${butlrCallId} ${tname}(${JSON.stringify(targs)})`);
+ if (butlrCallId) appendAgentLog(butlrCallId, { kind: 'tool', tool: tname, args: targs });
+
+ // Route custom tools (Vapi handles dtmf/endCall internally — we only see them as logs)
+ const handled = handleToolCall(tname, targs, callObj.metadata);
+ if (handled) results.push({ toolCallId: t.id, result: handled.result });
}
+ if (results.length) return res.json({ results });
}
else if (type === 'end-of-call-report') {
diff --git a/test/dnc-check.test.js b/test/dnc-check.test.js
new file mode 100644
index 0000000..9cf354e
--- /dev/null
+++ b/test/dnc-check.test.js
@@ -0,0 +1,170 @@
+#!/usr/bin/env node
+// Unit test for lib/dnc-check.js — the DNC pre-flight gate.
+//
+// Covers:
+// • E.164 normalization across input formats
+// • Federal DNC hit when number is in snapshot
+// • Internal-suppression hit
+// • DRY_RUN env bypass
+// • Missing snapshot → throws DNC_SNAPSHOT_MISSING (fail closed)
+// • addToInternalSuppression is idempotent + atomic
+// • Non-US numbers skip (length != 10)
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const assert = require('assert');
+
+function ok(name, fn) {
+ try { fn(); console.log(` ✓ ${name}`); return 1; }
+ catch (e) { console.error(` ✗ ${name}`); console.error(' ', e.message); return 0; }
+}
+
+let pass = 0, total = 0;
+const origDryRun = process.env.TWILIO_DRY_RUN;
+const origDncFile = process.env.DNC_FILE;
+
+// Helper: spin up an isolated env per test scenario.
+function withTmp(setup, body) {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'butlr-dnc-test-'));
+ const dncFile = path.join(tmp, 'national-dnc.txt');
+ const suppressionFile = path.join(tmp, 'dnc-suppression.json');
+ setup({ tmp, dncFile, suppressionFile });
+ process.env.DNC_FILE = dncFile;
+ // Force module re-load so it picks up our env-overridden DNC_FILE
+ delete require.cache[require.resolve('../lib/dnc-check')];
+ const dnc = require('../lib/dnc-check');
+ // Test-only: rebind INTERNAL_SUPPRESSION to our tmp via fs redirect.
+ const realWrite = fs.writeFileSync, realRead = fs.readFileSync, realRename = fs.renameSync, realExists = fs.existsSync;
+ fs.writeFileSync = (p, ...rest) => {
+ if (String(p).includes('dnc-suppression')) return realWrite(suppressionFile + (String(p).endsWith('.tmp') ? '.tmp' : ''), ...rest);
+ return realWrite(p, ...rest);
+ };
+ fs.readFileSync = (p, ...rest) => {
+ if (String(p).includes('dnc-suppression')) return realRead(suppressionFile, ...rest);
+ return realRead(p, ...rest);
+ };
+ fs.renameSync = (a, b) => {
+ const rewrite = (p) => String(p).includes('dnc-suppression') ? suppressionFile + (String(p).endsWith('.tmp') ? '.tmp' : '') : p;
+ return realRename(rewrite(a), rewrite(b));
+ };
+ fs.existsSync = (p) => {
+ if (String(p).includes('dnc-suppression')) return realExists(suppressionFile);
+ return realExists(p);
+ };
+ try { return body(dnc, { tmp, dncFile, suppressionFile }); }
+ finally {
+ fs.writeFileSync = realWrite; fs.readFileSync = realRead; fs.renameSync = realRename; fs.existsSync = realExists;
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+}
+
+// ── 1. Normalization ────────────────────────────────────────────────
+delete process.env.TWILIO_DRY_RUN;
+total++; pass += ok('e164Digits normalizes US 10-digit forms', () => {
+ withTmp(({ dncFile }) => { fs.writeFileSync(dncFile, ''); }, (dnc) => {
+ assert.strictEqual(dnc.e164Digits('+14155551212'), '4155551212');
+ assert.strictEqual(dnc.e164Digits('14155551212'), '4155551212');
+ assert.strictEqual(dnc.e164Digits('(415) 555-1212'), '4155551212');
+ assert.strictEqual(dnc.e164Digits('415.555.1212'), '4155551212');
+ assert.strictEqual(dnc.e164Digits('4155551212'), '4155551212');
+ assert.strictEqual(dnc.e164Digits(''), '');
+ assert.strictEqual(dnc.e164Digits('+447911123456'), '', 'non-US (11 digit) returns empty');
+ });
+});
+
+// ── 2. Federal DNC hit ──────────────────────────────────────────────
+total++; pass += ok('federal DNC hit blocks the number', () => {
+ withTmp(({ dncFile }) => {
+ fs.writeFileSync(dncFile, '+14155551212\n+13105551111\n');
+ }, (dnc) => {
+ const result = dnc.checkPhone('+14155551212', 'call');
+ assert.strictEqual(result.allowed, false);
+ assert.strictEqual(result.reason, 'federal_dnc');
+ assert.strictEqual(result.source, 'ftc_donotcall');
+ });
+});
+
+// ── 3. Federal DNC miss allows ─────────────────────────────────────
+total++; pass += ok('federal DNC miss allows', () => {
+ withTmp(({ dncFile }) => {
+ fs.writeFileSync(dncFile, '+14155551212\n');
+ }, (dnc) => {
+ const result = dnc.checkPhone('+18002274825', 'call'); // Capital One
+ assert.strictEqual(result.allowed, true);
+ });
+});
+
+// ── 4. Internal suppression hit ─────────────────────────────────────
+total++; pass += ok('internal suppression blocks even if not in federal', () => {
+ withTmp(({ dncFile, suppressionFile }) => {
+ fs.writeFileSync(dncFile, '');
+ fs.writeFileSync(suppressionFile, JSON.stringify([
+ { phone: '+13105551212', hash: 'x', reason: 'stop_keyword', source: 'sms', added_at: '2026-05-13' },
+ ]));
+ }, (dnc) => {
+ const result = dnc.checkPhone('+13105551212', 'sms');
+ assert.strictEqual(result.allowed, false);
+ assert.strictEqual(result.reason, 'internal_suppression');
+ });
+});
+
+// ── 5. Missing snapshot → throws (fail closed) ──────────────────────
+total++; pass += ok('missing snapshot throws DNC_SNAPSHOT_MISSING', () => {
+ withTmp(() => { /* don't create dncFile */ }, (dnc) => {
+ let threw = null;
+ try { dnc.checkPhone('+14155551212', 'call'); }
+ catch (e) { threw = e; }
+ assert.ok(threw, 'should have thrown');
+ assert.strictEqual(threw.code, 'DNC_SNAPSHOT_MISSING');
+ });
+});
+
+// ── 6. DRY_RUN env bypasses everything ──────────────────────────────
+total++; pass += ok('TWILIO_DRY_RUN=1 bypasses gate entirely', () => {
+ process.env.TWILIO_DRY_RUN = '1';
+ withTmp(() => { /* no snapshot */ }, (dnc) => {
+ const result = dnc.checkPhone('+14155551212', 'call');
+ assert.strictEqual(result.allowed, true);
+ assert.strictEqual(result.dry_run_bypass, true);
+ });
+ delete process.env.TWILIO_DRY_RUN;
+});
+
+// ── 7. addToInternalSuppression idempotent ──────────────────────────
+total++; pass += ok('addToInternalSuppression is idempotent', () => {
+ withTmp(({ dncFile }) => { fs.writeFileSync(dncFile, ''); }, (dnc) => {
+ const r1 = dnc.addToInternalSuppression('+14155551212', 'stop_kw', 'sms');
+ assert.strictEqual(r1.added, true);
+ const r2 = dnc.addToInternalSuppression('+14155551212', 'manual', 'cli');
+ assert.strictEqual(r2.already, true);
+ // Now check it actually blocks
+ const result = dnc.checkPhone('+14155551212', 'call');
+ assert.strictEqual(result.allowed, false);
+ });
+});
+
+// ── 8. Non-US numbers pass through ─────────────────────────────────
+total++; pass += ok('non-US numbers (length != 10) skip DNC check', () => {
+ withTmp(({ dncFile }) => { fs.writeFileSync(dncFile, ''); }, (dnc) => {
+ const result = dnc.checkPhone('+447911123456', 'call'); // UK number
+ assert.strictEqual(result.allowed, true);
+ });
+});
+
+// ── 9. checkPhone w/ no phone → blocked w/ no_phone reason ─────────
+total++; pass += ok('missing phone → allowed=false, reason=no_phone', () => {
+ withTmp(({ dncFile }) => { fs.writeFileSync(dncFile, ''); }, (dnc) => {
+ const result = dnc.checkPhone('', 'call');
+ assert.strictEqual(result.allowed, false);
+ assert.strictEqual(result.reason, 'no_phone');
+ });
+});
+
+// Restore env
+if (origDryRun !== undefined) process.env.TWILIO_DRY_RUN = origDryRun;
+if (origDncFile !== undefined) process.env.DNC_FILE = origDncFile;
+else delete process.env.DNC_FILE;
+
+console.log(`\n${pass}/${total} passed`);
+process.exit(pass === total ? 0 : 1);
← ed991e5 call-quality-report: auto-detect Vapi vs Twilio provider
·
back to Butlr
·
dnc: untrack runtime files (blocks log + suppression list) 19da0c1 →