From 12a3c11c6da2f7d903568bc513c6c091b043653b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 24 Nov 2025 16:56:15 +0100 Subject: [PATCH] Improve Twilio webhook update flow --- README.md | 2 +- src/index.ts | 116 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 83c5adce1..f0ff9cdce 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Small TypeScript CLI to send, monitor, and webhook WhatsApp messages via Twilio. - Customize path if desired: `--path /hooks/wa` - If no `--reply`, auto-reply can be configured via `~/.warelay/warelay.json` (JSON5) - Webhook/funnel “up”: `pnpm warelay up --port 42873 --path /webhook/whatsapp` - - Validates Twilio env, confirms `tailscale` binary, enables Tailscale Funnel, starts the webhook, and sets the Twilio incoming webhook to your Funnel URL. + - Validates Twilio env, confirms `tailscale` binary, enables Tailscale Funnel, starts the webhook, and sets the Twilio incoming webhook to your Funnel URL via the Twilio API (Channels/Senders → fallback to phone number → fallback to messaging service). - Requires Tailscale Funnel to be enabled for your tailnet/device (admin setting). If it isn’t enabled, the command will exit with instructions; alternatively expose the webhook via your own tunnel and set the Twilio URL manually. - Polling mode (no webhooks/funnel): `pnpm warelay poll --interval 5 --lookback 10 --verbose` - Useful fallback if Twilio webhook can’t reach you. diff --git a/src/index.ts b/src/index.ts index c485d16c4..ead580d11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ type TwilioRequestOptions = { params?: Record; form?: Record; body?: unknown; + contentType?: string; }; type TwilioSender = { sid: string; sender_id: string }; @@ -64,6 +65,12 @@ type TwilioChannelsSender = { sid?: string; senderId?: string; sender_id?: string; + webhook?: { + callback_url?: string; + callback_method?: string; + fallback_url?: string; + fallback_method?: string; + }; }; type ChannelSenderUpdater = { @@ -569,7 +576,7 @@ async function autoReplyIfConfigured( ); } } catch (err) { - console.error("Failed to auto-reply", err); + logTwilioSendError(err, replyTo ?? undefined); } } @@ -747,7 +754,7 @@ async function startWebhook( console.log(success(`↩️ Auto-replied to ${From}`)); } } catch (err) { - console.error("Failed to auto-reply", err); + logTwilioSendError(err, From ?? undefined); } } @@ -1044,8 +1051,13 @@ async function setMessagingServiceWebhook( InboundRequestUrl: url, InboundRequestMethod: method, }); - if (globalVerbose) - console.log(chalk.gray(`Updated Messaging Service ${msid} inbound URL`)); + const fetched = await client.messaging.v1.services(msid).fetch(); + const stored = fetched?.inboundRequestUrl; + console.log( + success( + `✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`, + ), + ); return true; } catch (err) { if (globalVerbose) console.error("Messaging Service update failed", err); @@ -1063,22 +1075,67 @@ async function updateWebhook( const requester = client as unknown as TwilioRequester; const clientTyped = client as unknown as TwilioSenderListClient; - // 1) Raw request (Channels/Senders) with explicit form fields (canonical for WA senders) + // 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA + try { + await requester.request({ + method: "post", + uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`, + body: { + webhook: { + callback_url: url, + callback_method: method, + }, + }, + contentType: "application/json", + }); + // Fetch to verify what Twilio stored + const fetched = await clientTyped.messaging.v2 + .channelsSenders(senderSid) + .fetch(); + const storedUrl = + fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; + if (storedUrl) { + console.log(success(`✅ Twilio sender webhook set to ${storedUrl}`)); + return; + } + if (globalVerbose) + console.error( + "Sender updated but webhook callback_url missing; will try fallbacks", + ); + } catch (err) { + if (globalVerbose) + console.error( + "channelsSenders request update failed, will try client helpers", + err, + ); + } + + // 1b) Form-encoded fallback for older Twilio stacks try { await requester.request({ method: "post", uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`, form: { - CallbackUrl: url, - CallbackMethod: method, + "Webhook.CallbackUrl": url, + "Webhook.CallbackMethod": method, }, }); - console.log(success(`✅ Twilio sender webhook set to ${url}`)); - return; + const fetched = + await clientTyped.messaging.v2.channelsSenders(senderSid).fetch(); + const storedUrl = + fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; + if (storedUrl) { + console.log(success(`✅ Twilio sender webhook set to ${storedUrl}`)); + return; + } + if (globalVerbose) + console.error( + "Form update succeeded but callback_url missing; will try helper fallback", + ); } catch (err) { if (globalVerbose) console.error( - "channelsSenders request update failed, will try client helpers", + "Form channelsSenders update failed, will try helper fallback", err, ); } @@ -1090,7 +1147,15 @@ async function updateWebhook( callbackUrl: url, callbackMethod: method, }); - console.log(success(`✅ Twilio sender webhook set to ${url}`)); + const fetched = + await clientTyped.messaging.v2.channelsSenders(senderSid).fetch(); + const storedUrl = + fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; + console.log( + success( + `✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`, + ), + ); return; } } catch (err) { @@ -1144,6 +1209,35 @@ function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } +type TwilioApiError = { + code?: number | string; + status?: number | string; + message?: string; + moreInfo?: string; + response?: { body?: unknown }; +}; + +function formatTwilioError(err: unknown): string { + const e = err as TwilioApiError; + const pieces = []; + if (e.code != null) pieces.push(`code ${e.code}`); + if (e.status != null) pieces.push(`status ${e.status}`); + if (e.message) pieces.push(e.message); + if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`); + return pieces.length ? pieces.join(" | ") : String(err); +} + +function logTwilioSendError(err: unknown, destination?: string) { + const prefix = destination ? `to ${destination}: ` : ""; + console.error( + danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`), + ); + const body = (err as TwilioApiError)?.response?.body; + if (body) { + console.error(info("Response body:"), JSON.stringify(body, null, 2)); + } +} + async function monitor(intervalSeconds: number, lookbackMinutes: number) { // Poll Twilio for inbound messages and stream them with de-dupe. const env = readEnv();