[object Object]

← back to Butlr

amd: silent hangup on machine answer (no voicemail messages)

f4b29f0a715821c3d18c2b287ae297fb491d2b78 · 2026-05-26 09:29:51 -0700 · SteveStudio2

Steve 2026-05-26: "if answering machine beep, HANG UP."

The /twilio/amd/:call_id handler was previously log-only with a "WE DO NOT
AUTO-BRIDGE" note explaining the human-detection false-positive risk on
chatty IVRs. That stays true for the HUMAN branch.

This patch adds a MACHINE branch: when Twilio's AnsweredBy starts with
"machine_" (with our DetectMessageEnd mode that's machine_end_beep,
machine_end_silence, or machine_end_other), the handler fires the same
proven call.update fast-path as /partial/:call_id's goal-satisfied
hangup — but with BARE <Response><Hangup/></Response>, no <Say>/<Play>.
Bare = no audio gets recorded onto the voicemail. Silent disconnect.

State tracked in MACHINE_HUNGUP Set (callId dedupe) so a delayed
duplicate AMD callback doesn't re-fire. On Twilio API failure we
delete from the set to allow retry on next webhook.

Status updates to 'done' on successful hangup (matches the partial-hangup
pattern at line 428).

Acked the webhook before doing async work (res.type('text/xml').send
moved to top) so Twilio doesn't retry the AMD callback while we're
mid-call.update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit f4b29f0a715821c3d18c2b287ae297fb491d2b78
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Tue May 26 09:29:51 2026 -0700

    amd: silent hangup on machine answer (no voicemail messages)
    
    Steve 2026-05-26: "if answering machine beep, HANG UP."
    
    The /twilio/amd/:call_id handler was previously log-only with a "WE DO NOT
    AUTO-BRIDGE" note explaining the human-detection false-positive risk on
    chatty IVRs. That stays true for the HUMAN branch.
    
    This patch adds a MACHINE branch: when Twilio's AnsweredBy starts with
    "machine_" (with our DetectMessageEnd mode that's machine_end_beep,
    machine_end_silence, or machine_end_other), the handler fires the same
    proven call.update fast-path as /partial/:call_id's goal-satisfied
    hangup — but with BARE <Response><Hangup/></Response>, no <Say>/<Play>.
    Bare = no audio gets recorded onto the voicemail. Silent disconnect.
    
    State tracked in MACHINE_HUNGUP Set (callId dedupe) so a delayed
    duplicate AMD callback doesn't re-fire. On Twilio API failure we
    delete from the set to allow retry on next webhook.
    
    Status updates to 'done' on successful hangup (matches the partial-hangup
    pattern at line 428).
    
    Acked the webhook before doing async work (res.type('text/xml').send
    moved to top) so Twilio doesn't retry the AMD callback while we're
    mid-call.update.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 routes/twilio-webhooks.js | 61 ++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 53 insertions(+), 8 deletions(-)

diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index bfee015..d14ebbf 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -495,17 +495,62 @@ router.post('/recording/:call_id', async (req, res) => {
 });
 
 // ── POST /twilio/amd/:call_id ─────────────────────────────────────────
-// AMD callback. WE DO NOT AUTO-BRIDGE — Twilio's MachineDetection false-
-// positives "human" on chatty IVRs (Wells Fargo, Chase, etc.). Auto-bridging
-// also rewrites the TwiML mid-execution, killing the IVR DTMF presses
-// before they can run. Just log the AMD result. Bridging is manual via
-// the live page's "Bridge me NOW" button.
-router.post('/amd/:call_id', (req, res) => {
+// AMD callback.
+//
+// HUMAN branch: WE DO NOT AUTO-BRIDGE — Twilio's MachineDetection
+// false-positives "human" on chatty IVRs (Wells Fargo, Chase, etc.).
+// Auto-bridging also rewrites the TwiML mid-execution, killing the IVR
+// DTMF presses before they can run. Bridging is manual via the live
+// page's "Bridge me NOW" button.
+//
+// MACHINE branch (Steve 2026-05-26 — "if answering machine beep, HANG UP"):
+// On any AnsweredBy starting with "machine_" we SILENTLY hang up. Bare
+// <Response><Hangup/></Response> with no <Say>/<Play> so we don't leave
+// a snippet of audio on the voicemail recording. Uses the same proven
+// call.update fast-path as the /partial/:call_id goal-satisfied hangup.
+//
+// MachineDetection mode is 'DetectMessageEnd' (see lib/twilio.js:206)
+// so values seen here are: machine_end_beep, machine_end_silence,
+// machine_end_other, human, fax, unknown. Anything starting with
+// "machine_" → hang up.
+const MACHINE_HUNGUP = new Set(); // callId already hangup-triggered
+router.post('/amd/:call_id', async (req, res) => {
+  res.type('text/xml').send('<Response/>'); // ack fast — no TwiML reply expected
   const callId = req.params.call_id;
   const answeredBy = req.body.AnsweredBy;
   const callSid = req.body.CallSid;
-  console.log('[twilio-webhook] AMD result (log-only, no auto-bridge)', { callId, answeredBy, callSid });
-  res.type('text/xml').send('<Response/>');
+  console.log('[twilio-webhook] AMD result', { callId, answeredBy, callSid });
+
+  if (!answeredBy || !answeredBy.startsWith('machine')) return;
+  if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return;
+  if (MACHINE_HUNGUP.has(callId)) return;
+  MACHINE_HUNGUP.add(callId);
+
+  try {
+    const call = data.getCallUnscoped(callId, true);
+    if (!call) return;
+    const sid = call.twilio_call_sid || call.twilio_sid || callSid;
+    if (!sid) { console.error(`[amd] call=${callId} no twilio sid stored — cannot hang up`); return; }
+    const AC = process.env.TWILIO_ACCOUNT_SID;
+    const TOK = process.env.TWILIO_AUTH_TOKEN;
+    const auth = 'Basic ' + Buffer.from(`${AC}:${TOK}`).toString('base64');
+    const twiml = '<?xml version="1.0" encoding="UTF-8"?><Response><Hangup/></Response>';
+    const r = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${AC}/Calls/${sid}.json`, {
+      method: 'POST',
+      headers: { Authorization: auth, 'Content-Type': 'application/x-www-form-urlencoded' },
+      body: new URLSearchParams({ Twiml: twiml }),
+    });
+    if (r.ok) {
+      console.log(`[amd] call=${callId} HANG-UP triggered (answeredBy=${answeredBy})`);
+      try { data.updateStatus(callId, 'done'); } catch {}
+    } else {
+      console.error(`[amd] call=${callId} call.update failed: ${r.status}`);
+      MACHINE_HUNGUP.delete(callId);
+    }
+  } catch (e) {
+    console.error(`[amd] call=${callId} hangup error:`, e.message);
+    MACHINE_HUNGUP.delete(callId);
+  }
 });
 
 // ── POST /twilio/bridge/:call_id ─────────────────────────────────────

← a5dc24d gitignore + serve-time 404-guard for .bak/.pre- snapshot fil  ·  back to Butlr  ·  sync local from prod — reconcile butlr drift 1295f61 →