← 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
M routes/twilio-webhooks.js
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 →