Improve Twilio webhook update flow

This commit is contained in:
Peter Steinberger
2025-11-24 16:56:15 +01:00
parent 07f0a26419
commit 12a3c11c6d
2 changed files with 106 additions and 12 deletions

View File

@@ -23,7 +23,7 @@ Small TypeScript CLI to send, monitor, and webhook WhatsApp messages via Twilio.
- Customize path if desired: `--path /hooks/wa` - Customize path if desired: `--path /hooks/wa`
- If no `--reply`, auto-reply can be configured via `~/.warelay/warelay.json` (JSON5) - If no `--reply`, auto-reply can be configured via `~/.warelay/warelay.json` (JSON5)
- Webhook/funnel “up”: `pnpm warelay up --port 42873 --path /webhook/whatsapp` - 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 isnt enabled, the command will exit with instructions; alternatively expose the webhook via your own tunnel and set the Twilio URL manually. - Requires Tailscale Funnel to be enabled for your tailnet/device (admin setting). If it isnt 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` - Polling mode (no webhooks/funnel): `pnpm warelay poll --interval 5 --lookback 10 --verbose`
- Useful fallback if Twilio webhook cant reach you. - Useful fallback if Twilio webhook cant reach you.

View File

@@ -44,6 +44,7 @@ type TwilioRequestOptions = {
params?: Record<string, string | number>; params?: Record<string, string | number>;
form?: Record<string, string>; form?: Record<string, string>;
body?: unknown; body?: unknown;
contentType?: string;
}; };
type TwilioSender = { sid: string; sender_id: string }; type TwilioSender = { sid: string; sender_id: string };
@@ -64,6 +65,12 @@ type TwilioChannelsSender = {
sid?: string; sid?: string;
senderId?: string; senderId?: string;
sender_id?: string; sender_id?: string;
webhook?: {
callback_url?: string;
callback_method?: string;
fallback_url?: string;
fallback_method?: string;
};
}; };
type ChannelSenderUpdater = { type ChannelSenderUpdater = {
@@ -569,7 +576,7 @@ async function autoReplyIfConfigured(
); );
} }
} catch (err) { } 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}`)); console.log(success(`↩️ Auto-replied to ${From}`));
} }
} catch (err) { } catch (err) {
console.error("Failed to auto-reply", err); logTwilioSendError(err, From ?? undefined);
} }
} }
@@ -1044,8 +1051,13 @@ async function setMessagingServiceWebhook(
InboundRequestUrl: url, InboundRequestUrl: url,
InboundRequestMethod: method, InboundRequestMethod: method,
}); });
if (globalVerbose) const fetched = await client.messaging.v1.services(msid).fetch();
console.log(chalk.gray(`Updated Messaging Service ${msid} inbound URL`)); const stored = fetched?.inboundRequestUrl;
console.log(
success(
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
),
);
return true; return true;
} catch (err) { } catch (err) {
if (globalVerbose) console.error("Messaging Service update failed", 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 requester = client as unknown as TwilioRequester;
const clientTyped = client as unknown as TwilioSenderListClient; 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 { try {
await requester.request({ await requester.request({
method: "post", method: "post",
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`, uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
form: { form: {
CallbackUrl: url, "Webhook.CallbackUrl": url,
CallbackMethod: method, "Webhook.CallbackMethod": method,
}, },
}); });
console.log(success(`✅ Twilio sender webhook set to ${url}`)); const fetched =
return; 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) { } catch (err) {
if (globalVerbose) if (globalVerbose)
console.error( console.error(
"channelsSenders request update failed, will try client helpers", "Form channelsSenders update failed, will try helper fallback",
err, err,
); );
} }
@@ -1090,7 +1147,15 @@ async function updateWebhook(
callbackUrl: url, callbackUrl: url,
callbackMethod: method, 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; return;
} }
} catch (err) { } catch (err) {
@@ -1144,6 +1209,35 @@ function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)); 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) { async function monitor(intervalSeconds: number, lookbackMinutes: number) {
// Poll Twilio for inbound messages and stream them with de-dupe. // Poll Twilio for inbound messages and stream them with de-dupe.
const env = readEnv(); const env = readEnv();