Improve Twilio webhook update flow
This commit is contained in:
@@ -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 isn’t 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 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`
|
- Polling mode (no webhooks/funnel): `pnpm warelay poll --interval 5 --lookback 10 --verbose`
|
||||||
- Useful fallback if Twilio webhook can’t reach you.
|
- Useful fallback if Twilio webhook can’t reach you.
|
||||||
|
|||||||
116
src/index.ts
116
src/index.ts
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user