← back to Butlr

lib/dnc-check.js

164 lines

// 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,
};