[object Object]

← back to Butlr

sms: /twilio/sms-inbound STOP-keyword auto-suppression (TCPA compliance)

4e0a5bc615b6f718951748e74cb02c81d0c97360 · 2026-05-13 12:32:17 -0700 · SteveStudio2

POST /twilio/sms-inbound — TCPA §64.1200(d) compliant inbound-SMS
handler. Twilio's Messaging Service routes inbound SMS here.

Keywords (case-insensitive, word-boundary match anywhere in body):
- STOP / STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT / REVOKE →
  dnc.addToInternalSuppression(from, 'sms_stop_keyword', 'twilio_inbound')
  Reply: "You are unsubscribed from Butlr. No further messages will be
  sent. Reply START to re-subscribe."
- START / UNSTOP / YES → removes from suppression (re-opt-in).
  Reply: "You are re-subscribed to Butlr. Reply STOP to opt out."
- Anything else → generic identity + opt-out reply (no engagement).

Atomic write on suppression updates (tmp + rename). No-op gracefully
if From is missing. dnc-check's addToInternalSuppression is already
idempotent so repeat STOPs don't duplicate.

test/sms-stop.test.js: 8/8 — STOP/UNSUBSCRIBE/CANCEL add, repeat STOP
is idempotent, START removes, random body doesn't change list, missing
From → 200 no-op, lowercase "stop" matches.

Configure on Twilio side: Messaging Service → Inbound webhook →
https://butlr.agentabrams.com/twilio/sms-inbound

npm test now: 49/49 across 6 files (orphan 8 + admin-gate 6 +
vapi-webhook 11 + dnc 9 + upload-watcher 7 + sms-stop 8).

Files touched

Diff

commit 4e0a5bc615b6f718951748e74cb02c81d0c97360
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 12:32:17 2026 -0700

    sms: /twilio/sms-inbound STOP-keyword auto-suppression (TCPA compliance)
    
    POST /twilio/sms-inbound — TCPA §64.1200(d) compliant inbound-SMS
    handler. Twilio's Messaging Service routes inbound SMS here.
    
    Keywords (case-insensitive, word-boundary match anywhere in body):
    - STOP / STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT / REVOKE →
      dnc.addToInternalSuppression(from, 'sms_stop_keyword', 'twilio_inbound')
      Reply: "You are unsubscribed from Butlr. No further messages will be
      sent. Reply START to re-subscribe."
    - START / UNSTOP / YES → removes from suppression (re-opt-in).
      Reply: "You are re-subscribed to Butlr. Reply STOP to opt out."
    - Anything else → generic identity + opt-out reply (no engagement).
    
    Atomic write on suppression updates (tmp + rename). No-op gracefully
    if From is missing. dnc-check's addToInternalSuppression is already
    idempotent so repeat STOPs don't duplicate.
    
    test/sms-stop.test.js: 8/8 — STOP/UNSUBSCRIBE/CANCEL add, repeat STOP
    is idempotent, START removes, random body doesn't change list, missing
    From → 200 no-op, lowercase "stop" matches.
    
    Configure on Twilio side: Messaging Service → Inbound webhook →
    https://butlr.agentabrams.com/twilio/sms-inbound
    
    npm test now: 49/49 across 6 files (orphan 8 + admin-gate 6 +
    vapi-webhook 11 + dnc 9 + upload-watcher 7 + sms-stop 8).
---
 data/dnc-blocks.jsonl     |   3 +
 package.json              |   2 +-
 routes/twilio-webhooks.js |  63 +++++++++++++++++++
 test/sms-stop.test.js     | 154 ++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 221 insertions(+), 1 deletion(-)

diff --git a/data/dnc-blocks.jsonl b/data/dnc-blocks.jsonl
index a6c3f2a..fb4ff5d 100644
--- a/data/dnc-blocks.jsonl
+++ b/data/dnc-blocks.jsonl
@@ -9,3 +9,6 @@
 {"at":"2026-05-13T18:12:07.062Z","phone_last4":"1212","phone_hash":"f84b6f1e6c516e8e33b58e707a9447030b9dbf8d0b22b1cb1c71fd942e7a54a7","channel":"call","reason":"federal_dnc","source":"ftc_donotcall"}
 {"at":"2026-05-13T18:12:07.065Z","phone_last4":"1212","phone_hash":"260c2ac371cd9dc5031bb35ee4604b8a86898bdf0050854abbd83a4816e392ce","channel":"sms","reason":"internal_suppression","source":"internal"}
 {"at":"2026-05-13T18:12:07.067Z","phone_last4":"1212","phone_hash":"f84b6f1e6c516e8e33b58e707a9447030b9dbf8d0b22b1cb1c71fd942e7a54a7","channel":"call","reason":"internal_suppression","source":"internal"}
+{"at":"2026-05-13T19:32:04.557Z","phone_last4":"1212","phone_hash":"f84b6f1e6c516e8e33b58e707a9447030b9dbf8d0b22b1cb1c71fd942e7a54a7","channel":"call","reason":"federal_dnc","source":"ftc_donotcall"}
+{"at":"2026-05-13T19:32:04.560Z","phone_last4":"1212","phone_hash":"260c2ac371cd9dc5031bb35ee4604b8a86898bdf0050854abbd83a4816e392ce","channel":"sms","reason":"internal_suppression","source":"internal"}
+{"at":"2026-05-13T19:32:04.562Z","phone_last4":"1212","phone_hash":"f84b6f1e6c516e8e33b58e707a9447030b9dbf8d0b22b1cb1c71fd942e7a54a7","channel":"call","reason":"internal_suppression","source":"internal"}
diff --git a/package.json b/package.json
index 4f39a77..6948325 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 && node test/dnc-check.test.js && node test/upload-watcher.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 && node test/upload-watcher.test.js && node test/sms-stop.test.js",
     "report": "node scripts/call-quality-report.js --remote"
   },
   "dependencies": {
diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index bb41028..42d0822 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -560,6 +560,69 @@ router.all('/voice-default', (req, res) => {
 </Response>`);
 });
 
+// ── POST /twilio/sms-inbound ──────────────────────────────────────────
+// TCPA-compliance: STOP-keyword auto-suppression. Twilio's Messaging
+// Service routes inbound SMS here. If the body is any FCC-recognized
+// opt-out keyword, we add the sender's number to internal suppression
+// (data/dnc-suppression.json) so future sendSms / dialCall block before
+// dispatch. Reply with a single TCPA-compliant unsub confirmation.
+//
+// FCC-recognized opt-out keywords (47 CFR §64.1200): STOP, STOPALL,
+// UNSUBSCRIBE, CANCEL, END, QUIT. Match case-insensitively, trimmed,
+// against the full body so "STOP" with no trailing text wins but
+// "PLEASE STOP CALLING ME" also wins.
+//
+// Also handles START / UNSTOP keywords — those REMOVE the number from
+// suppression (re-opt-in), per FCC reciprocity rule.
+//
+// Configure on Twilio side: Messaging Service → A message comes in →
+// Webhook → https://butlr.agentabrams.com/twilio/sms-inbound
+router.post('/sms-inbound', express.urlencoded({ extended: true }), (req, res) => {
+  const from = String((req.body && req.body.From) || '').trim();
+  const bodyRaw = String((req.body && req.body.Body) || '').trim();
+  const body = bodyRaw.toUpperCase();
+  const stopKw = /\b(STOP|STOPALL|UNSUBSCRIBE|CANCEL|END|QUIT|REVOKE)\b/.test(body);
+  const startKw = /\b(START|UNSTOP|YES)\b/.test(body) && !stopKw;
+  console.log(`[sms-inbound] from=…${from.slice(-4)} body="${bodyRaw.slice(0,40)}" stop=${stopKw} start=${startKw}`);
+  if (!from) return res.type('text/xml').send('<Response/>');
+
+  // Reply text must be a single TCPA-compliant confirmation. Twilio's
+  // <Message> verb sends it back to the sender's number automatically.
+  let reply = '';
+  try {
+    const dnc = require('../lib/dnc-check');
+    if (stopKw) {
+      dnc.addToInternalSuppression(from, 'sms_stop_keyword', 'twilio_inbound');
+      reply = 'You are unsubscribed from Butlr. No further messages will be sent. Reply START to re-subscribe.';
+    } else if (startKw) {
+      // Remove from internal suppression — re-opt-in.
+      try {
+        const fs = require('fs');
+        let list = JSON.parse(fs.readFileSync(dnc.INTERNAL_SUPPRESSION, 'utf8'));
+        if (Array.isArray(list)) {
+          const target = dnc.e164Digits(from);
+          const before = list.length;
+          list = list.filter(e => dnc.e164Digits(e.phone) !== target);
+          if (list.length !== before) {
+            const tmp = dnc.INTERNAL_SUPPRESSION + '.tmp';
+            fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
+            fs.renameSync(tmp, dnc.INTERNAL_SUPPRESSION);
+          }
+        }
+      } catch (e) { console.error('[sms-inbound] re-opt-in write:', e.message); }
+      reply = 'You are re-subscribed to Butlr. Reply STOP to opt out at any time.';
+    } else {
+      // Unrecognized message — TCPA-compliant standard reply per FCC §64.1200(d).
+      // Provides identity, contact, opt-out path. Do not engage in conversation.
+      reply = 'Butlr automated reply. Help: visit butlr.agentabrams.com. Reply STOP to opt out.';
+    }
+  } catch (e) {
+    console.error('[sms-inbound] dnc add failed:', e.message);
+    reply = 'You are unsubscribed. Reply START to re-subscribe.';
+  }
+  res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<Response><Message>${escapeXml(reply)}</Message></Response>`);
+});
+
 // ── POST /twilio/status-default ───────────────────────────────────────
 // Number-level status callback, log-only. Per-call StatusCallback is set
 // on the outbound call via lib/twilio.js and routes to /twilio/status/:id.
diff --git a/test/sms-stop.test.js b/test/sms-stop.test.js
new file mode 100644
index 0000000..6d5a563
--- /dev/null
+++ b/test/sms-stop.test.js
@@ -0,0 +1,154 @@
+#!/usr/bin/env node
+// Unit test for /twilio/sms-inbound STOP-keyword auto-suppression.
+//
+// Spins up a tiny express app with just the twilio-webhooks router,
+// POSTs canonical Twilio inbound-SMS payloads for STOP / START / random,
+// asserts internal suppression list is updated correctly.
+
+process.env.HFM_NO_WORKER = '1';
+process.env.HFM_NO_WATCHER = '1';
+process.env.TWILIO_DRY_RUN = '0';
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const assert = require('assert');
+
+// Isolated suppression file
+const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'butlr-sms-stop-'));
+const tmpSuppress = path.join(tmpDir, 'dnc-suppression.json');
+const tmpDncFile = path.join(tmpDir, 'national-dnc.txt');
+fs.writeFileSync(tmpSuppress, '[]');
+fs.writeFileSync(tmpDncFile, '');  // empty federal DNC so checkPhone won't throw
+process.env.DNC_FILE = tmpDncFile;
+
+// Redirect dnc-suppression reads/writes to our tmp
+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(tmpSuppress + (String(p).endsWith('.tmp') ? '.tmp' : ''), ...rest);
+  return realWrite(p, ...rest);
+};
+fs.readFileSync = (p, ...rest) => {
+  if (String(p).includes('dnc-suppression')) return realRead(tmpSuppress, ...rest);
+  return realRead(p, ...rest);
+};
+fs.renameSync = (a, b) => {
+  const rewrite = (p) => String(p).includes('dnc-suppression') ? tmpSuppress + (String(p).endsWith('.tmp') ? '.tmp' : '') : p;
+  return realRename(rewrite(a), rewrite(b));
+};
+fs.existsSync = (p) => {
+  if (String(p).includes('dnc-suppression')) return realExists(tmpSuppress);
+  return realExists(p);
+};
+
+const express = require('express');
+const router = require('../routes/twilio-webhooks');
+const app = express();
+app.use('/twilio', router);
+
+function ok(name, fn) {
+  try { fn(); console.log(`  ✓ ${name}`); return 1; }
+  catch (e) { console.error(`  ✗ ${name}`); console.error('     ', e.message); return 0; }
+}
+
+async function post(from, body) {
+  return new Promise((resolve) => {
+    const server = app.listen(0, () => {
+      const port = server.address().port;
+      const bodyStr = `From=${encodeURIComponent(from)}&Body=${encodeURIComponent(body)}`;
+      const req = http.request({
+        hostname: '127.0.0.1', port, path: '/twilio/sms-inbound', method: 'POST',
+        headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(bodyStr) },
+      }, (res) => {
+        let chunks = '';
+        res.on('data', c => { chunks += c; });
+        res.on('end', () => { server.close(); resolve({ status: res.statusCode, body: chunks }); });
+      });
+      req.write(bodyStr);
+      req.end();
+    });
+  });
+}
+
+function readList() {
+  try { return JSON.parse(fs.readFileSync(tmpSuppress, 'utf8')); }
+  catch { return []; }
+}
+
+(async () => {
+  let pass = 0, total = 0;
+
+  // ── 1. STOP adds to suppression
+  total++; pass += ok('STOP adds to internal suppression', async () => {
+    const r = await post('+13105551001', 'STOP');
+    assert.strictEqual(r.status, 200);
+    assert.ok(r.body.includes('unsubscribed'), 'reply should confirm unsub');
+    const list = readList();
+    assert.strictEqual(list.length, 1);
+    assert.strictEqual(list[0].phone, '+13105551001');
+    assert.strictEqual(list[0].reason, 'sms_stop_keyword');
+  });
+
+  // ── 2. UNSUBSCRIBE also adds
+  total++; pass += ok('UNSUBSCRIBE adds another number', async () => {
+    const r = await post('+13105551002', 'unsubscribe please');
+    assert.strictEqual(r.status, 200);
+    const list = readList();
+    assert.strictEqual(list.length, 2);
+  });
+
+  // ── 3. CANCEL added
+  total++; pass += ok('CANCEL adds', async () => {
+    await post('+13105551003', 'CANCEL');
+    const list = readList();
+    assert.strictEqual(list.length, 3);
+  });
+
+  // ── 4. Re-stopping same number doesn't duplicate
+  total++; pass += ok('repeat STOP from same number is idempotent', async () => {
+    await post('+13105551001', 'STOP');
+    const list = readList();
+    assert.strictEqual(list.length, 3);  // still 3, no duplicate
+  });
+
+  // ── 5. START removes from suppression (re-opt-in)
+  total++; pass += ok('START removes from suppression', async () => {
+    const r = await post('+13105551001', 'START');
+    assert.strictEqual(r.status, 200);
+    assert.ok(r.body.includes('re-subscribed'), 'reply should confirm re-sub');
+    const list = readList();
+    assert.strictEqual(list.length, 2);
+    assert.ok(!list.some(e => e.phone === '+13105551001'));
+  });
+
+  // ── 6. Random message replies with generic + does NOT add to list
+  total++; pass += ok('random body does not change suppression', async () => {
+    const before = readList().length;
+    const r = await post('+13105559999', 'hi there');
+    assert.strictEqual(r.status, 200);
+    assert.ok(r.body.includes('Reply STOP'), 'should mention STOP');
+    const after = readList().length;
+    assert.strictEqual(after, before);
+  });
+
+  // ── 7. No From field → 200 with empty <Response/> (graceful)
+  total++; pass += ok('missing From returns 200 no-op', async () => {
+    const r = await post('', 'STOP');
+    assert.strictEqual(r.status, 200);
+    assert.ok(r.body.includes('Response'));
+  });
+
+  // ── 8. Case-insensitive
+  total++; pass += ok('case-insensitive: "stop" lowercase', async () => {
+    await post('+13105551010', 'stop');
+    const list = readList();
+    assert.ok(list.some(e => e.phone === '+13105551010'));
+  });
+
+  fs.writeFileSync = realWrite; fs.readFileSync = realRead; fs.renameSync = realRename; fs.existsSync = realExists;
+  fs.rmSync(tmpDir, { recursive: true, force: true });
+
+  console.log(`\n${pass}/${total} passed`);
+  process.exit(pass === total ? 0 : 1);
+})();

← f848efc cap repeat dials at 4 per phone — even with allow_repeat, su  ·  back to Butlr  ·  sms: friendly GET handler on /twilio/sms-inbound (was 404 in 6db4767 →