Refactor CLI and Twilio modules; add helper tests and comments

This commit is contained in:
Peter Steinberger
2025-11-25 03:11:39 +01:00
parent c71abf13a1
commit afdaa7ef98
17 changed files with 996 additions and 734 deletions

View File

@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { monitorTwilio } from "./monitor.js";
describe("monitorTwilio", () => {
it("processes inbound messages once with injected deps", async () => {
const listRecentMessages = vi.fn().mockResolvedValue([
{
sid: "m1",
direction: "inbound",
dateCreated: new Date(),
from: "+1",
to: "+2",
body: "hi",
errorCode: null,
errorMessage: null,
status: null,
},
]);
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
const readEnv = vi.fn(() => ({ accountSid: "AC", whatsappFrom: "whatsapp:+1", auth: { accountSid: "AC", authToken: "t" } }));
const createClient = vi.fn(() => ({ messages: { create: vi.fn() } } as never));
const sleep = vi.fn().mockResolvedValue(undefined);
await monitorTwilio(0, 0, {
deps: { autoReplyIfConfigured, listRecentMessages, readEnv, createClient, sleep },
maxIterations: 1,
});
expect(listRecentMessages).toHaveBeenCalledTimes(1);
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
});
});

114
src/twilio/monitor.ts Normal file
View File

@@ -0,0 +1,114 @@
import chalk from "chalk";
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
import { danger, info, logVerbose, warn } from "../globals.js";
import { sleep, withWhatsAppPrefix } from "../utils.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { autoReplyIfConfigured } from "../auto-reply/reply.js";
import { createClient } from "./client.js";
import { readEnv } from "../env.js";
type MonitorDeps = {
autoReplyIfConfigured: typeof autoReplyIfConfigured;
listRecentMessages: (
lookbackMinutes: number,
limit: number,
clientOverride?: ReturnType<typeof createClient>,
) => Promise<ListedMessage[]>;
readEnv: typeof readEnv;
createClient: typeof createClient;
sleep: typeof sleep;
};
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
export type ListedMessage = {
sid: string;
status: string | null;
direction: string | null;
dateCreated: Date | undefined;
from?: string | null;
to?: string | null;
body?: string | null;
errorCode: number | null;
errorMessage: string | null;
};
type MonitorOptions = {
client?: ReturnType<typeof createClient>;
maxIterations?: number;
deps?: MonitorDeps;
runtime?: RuntimeEnv;
};
const defaultDeps: MonitorDeps = {
autoReplyIfConfigured,
listRecentMessages: () => Promise.resolve([]),
readEnv,
createClient,
sleep,
};
// Poll Twilio for inbound messages and auto-reply when configured.
export async function monitorTwilio(
pollSeconds: number,
lookbackMinutes: number,
opts?: MonitorOptions,
) {
const deps = opts?.deps ?? defaultDeps;
const runtime = opts?.runtime ?? defaultRuntime;
const maxIterations = opts?.maxIterations ?? Infinity;
const env = deps.readEnv(runtime);
const from = withWhatsAppPrefix(env.whatsappFrom);
const client = opts?.client ?? deps.createClient(env);
console.log(
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
);
let lastSeenSid: string | undefined;
let iterations = 0;
while (iterations < maxIterations) {
const messages =
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
const inboundOnly = messages.filter((m) => m.direction === "inbound");
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
const newestFirst = [...inboundOnly].sort(
(a, b) =>
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
);
await handleMessages(messages, client, lastSeenSid, deps, runtime);
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
iterations += 1;
if (iterations >= maxIterations) break;
await deps.sleep(Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000);
}
}
async function handleMessages(
messages: ListedMessage[],
client: ReturnType<typeof createClient>,
lastSeenSid: string | undefined,
deps: MonitorDeps,
runtime: RuntimeEnv,
) {
for (const m of messages) {
if (!m.sid) continue;
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
logVerbose(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
if (m.direction !== "inbound") continue;
if (!m.from || !m.to) continue;
try {
await deps.autoReplyIfConfigured(
client as unknown as {
messages: { create: (opts: unknown) => Promise<unknown> };
},
m as unknown as MessageInstance,
undefined,
runtime,
);
} catch (err) {
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
}
}
}

23
src/twilio/send.test.ts Normal file
View File

@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from "vitest";
import { waitForFinalStatus } from "./send.js";
describe("twilio send helpers", () => {
it("waitForFinalStatus resolves on delivered", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce({ status: "queued" })
.mockResolvedValueOnce({ status: "delivered" });
const client = { messages: vi.fn(() => ({ fetch })) } as never;
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
expect(fetch).toHaveBeenCalledTimes(2);
});
it("waitForFinalStatus exits on failure", async () => {
const fetch = vi.fn().mockResolvedValue({ status: "failed", errorMessage: "boom" });
const client = { messages: vi.fn(() => ({ fetch })) } as never;
const runtime = { log: console.log, error: () => {}, exit: vi.fn(() => { throw new Error("exit"); }) } as never;
await expect(waitForFinalStatus(client, "SM1", 1, 0.01, runtime)).rejects.toBeInstanceOf(Error);
});
});

67
src/twilio/send.ts Normal file
View File

@@ -0,0 +1,67 @@
import { success } from "../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { withWhatsAppPrefix, sleep } from "../utils.js";
import { readEnv } from "../env.js";
import { createClient } from "./client.js";
import { logTwilioSendError } from "./utils.js";
const successTerminalStatuses = new Set(["delivered", "read"]);
const failureTerminalStatuses = new Set(["failed", "undelivered", "canceled"]);
// Send outbound WhatsApp message; exit non-zero on API failure.
export async function sendMessage(
to: string,
body: string,
runtime: RuntimeEnv = defaultRuntime,
) {
const env = readEnv(runtime);
const client = createClient(env);
const from = withWhatsAppPrefix(env.whatsappFrom);
const toNumber = withWhatsAppPrefix(to);
try {
const message = await client.messages.create({
from,
to: toNumber,
body,
});
console.log(
success(
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
),
);
return { client, sid: message.sid };
} catch (err) {
logTwilioSendError(err, toNumber, runtime);
}
}
// Poll message status until delivered/failed or timeout.
export async function waitForFinalStatus(
client: ReturnType<typeof createClient>,
sid: string,
timeoutSeconds: number,
pollSeconds: number,
runtime: RuntimeEnv = defaultRuntime,
) {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const m = await client.messages(sid).fetch();
const status = m.status ?? "unknown";
if (successTerminalStatuses.has(status)) {
console.log(success(`✅ Delivered (status: ${status})`));
return;
}
if (failureTerminalStatuses.has(status)) {
runtime.error(
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
);
runtime.exit(1);
}
await sleep(pollSeconds * 1000);
}
console.log(
" Timed out waiting for final status; message may still be in flight.",
);
}

79
src/twilio/types.ts Normal file
View File

@@ -0,0 +1,79 @@
export type TwilioRequestOptions = {
method: "get" | "post";
uri: string;
params?: Record<string, string | number>;
form?: Record<string, string>;
body?: unknown;
contentType?: string;
};
export type TwilioSender = { sid: string; sender_id: string };
export type TwilioRequestResponse = {
data?: {
senders?: TwilioSender[];
};
};
export type IncomingNumber = {
sid: string;
phoneNumber: string;
smsUrl?: string;
};
export type TwilioChannelsSender = {
sid?: string;
senderId?: string;
sender_id?: string;
webhook?: {
callback_url?: string;
callback_method?: string;
fallback_url?: string;
fallback_method?: string;
};
};
export type ChannelSenderUpdater = {
update: (params: Record<string, string>) => Promise<unknown>;
};
export type IncomingPhoneNumberUpdater = {
update: (params: Record<string, string>) => Promise<unknown>;
};
export type IncomingPhoneNumbersClient = {
list: (params: {
phoneNumber: string;
limit?: number;
}) => Promise<IncomingNumber[]>;
get: (sid: string) => IncomingPhoneNumberUpdater;
} & ((sid: string) => IncomingPhoneNumberUpdater);
export type TwilioSenderListClient = {
messaging: {
v2: {
channelsSenders: {
list: (params: {
channel: string;
pageSize: number;
}) => Promise<TwilioChannelsSender[]>;
(
sid: string,
): ChannelSenderUpdater & {
fetch: () => Promise<TwilioChannelsSender>;
};
};
};
v1: {
services: (sid: string) => {
update: (params: Record<string, string>) => Promise<unknown>;
fetch: () => Promise<{ inboundRequestUrl?: string }>;
};
};
};
incomingPhoneNumbers: IncomingPhoneNumbersClient;
};
export type TwilioRequester = {
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
};

View File

@@ -0,0 +1,60 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import {
findIncomingNumberSid,
findMessagingServiceSid,
setMessagingServiceWebhook,
} from "./update-webhook.js";
const envBackup = { ...process.env } as Record<string, string | undefined>;
describe("update-webhook helpers", () => {
beforeEach(() => {
process.env.TWILIO_ACCOUNT_SID = "AC";
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
});
afterEach(() => {
Object.entries(envBackup).forEach(([k, v]) => {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
});
});
it("findIncomingNumberSid returns first match", async () => {
const client = {
incomingPhoneNumbers: {
list: async () => [{ sid: "PN1", phoneNumber: "+1555" }],
},
} as never;
const sid = await findIncomingNumberSid(client);
expect(sid).toBe("PN1");
});
it("findMessagingServiceSid reads messagingServiceSid", async () => {
const client = {
incomingPhoneNumbers: {
list: async () => [{ messagingServiceSid: "MG1" }],
},
} as never;
const sid = await findMessagingServiceSid(client);
expect(sid).toBe("MG1");
});
it("setMessagingServiceWebhook updates via service helper", async () => {
const update = async (_: unknown) => {};
const fetch = async () => ({ inboundRequestUrl: "https://cb" });
const client = {
messaging: {
v1: {
services: () => ({ update, fetch }),
},
},
incomingPhoneNumbers: {
list: async () => [{ messagingServiceSid: "MG1" }],
},
} as never;
const ok = await setMessagingServiceWebhook(client, "https://cb", "POST");
expect(ok).toBe(true);
});
});

View File

@@ -0,0 +1,194 @@
import { success, isVerbose, warn } from "../globals.js";
import { readEnv } from "../env.js";
import { normalizeE164 } from "../utils.js";
import type { RuntimeEnv } from "../runtime.js";
import { createClient } from "./client.js";
import type { TwilioSenderListClient, TwilioRequester } from "./types.js";
export async function findIncomingNumberSid(client: TwilioSenderListClient): Promise<string | null> {
// Look up incoming phone number SID matching the configured WhatsApp number.
try {
const env = readEnv();
const phone = env.whatsappFrom.replace("whatsapp:", "");
const list = await client.incomingPhoneNumbers.list({
phoneNumber: phone,
limit: 1,
});
return list?.[0]?.sid ?? null;
} catch {
return null;
}
}
export async function findMessagingServiceSid(client: TwilioSenderListClient): Promise<string | null> {
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
type IncomingNumberWithService = { messagingServiceSid?: string };
try {
const env = readEnv();
const phone = env.whatsappFrom.replace("whatsapp:", "");
const list = await client.incomingPhoneNumbers.list({
phoneNumber: phone,
limit: 1,
});
const msid =
(list?.[0] as IncomingNumberWithService | undefined)
?.messagingServiceSid ?? null;
return msid;
} catch {
return null;
}
}
export async function setMessagingServiceWebhook(
client: TwilioSenderListClient,
url: string,
method: "POST" | "GET",
): Promise<boolean> {
const msid = await findMessagingServiceSid(client);
if (!msid) return false;
try {
await client.messaging.v1.services(msid).update({
InboundRequestUrl: url,
InboundRequestMethod: method,
});
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 {
return false;
}
}
// Update sender webhook URL with layered fallbacks (channels, form, helper, phone).
export async function updateWebhook(
client: ReturnType<typeof createClient>,
senderSid: string,
url: string,
method: "POST" | "GET" = "POST",
runtime: RuntimeEnv,
) {
// Point Twilio sender webhook at the provided URL.
const requester = client as unknown as TwilioRequester;
const clientTyped = client as unknown as TwilioSenderListClient;
// 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",
});
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 (isVerbose())
console.error(
"Sender updated but webhook callback_url missing; will try fallbacks",
);
} catch (err) {
if (isVerbose())
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: {
"Webhook.CallbackUrl": url,
"Webhook.CallbackMethod": method,
},
});
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 (isVerbose())
console.error(
"Form update succeeded but callback_url missing; will try helper fallback",
);
} catch (err) {
if (isVerbose())
console.error(
"Form channelsSenders update failed, will try helper fallback",
err,
);
}
// 2) SDK helper fallback (if supported by this client)
try {
if (clientTyped.messaging?.v2?.channelsSenders) {
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
callbackUrl: url,
callbackMethod: method,
});
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) {
if (isVerbose())
console.error(
"channelsSenders helper update failed, will try phone number fallback",
err,
);
}
// 3) Incoming phone number fallback (works for many WA senders)
try {
const phoneSid = await findIncomingNumberSid(clientTyped);
if (phoneSid) {
await clientTyped.incomingPhoneNumbers(phoneSid).update({
smsUrl: url,
smsMethod: method,
});
console.log(success(`✅ Phone webhook set to ${url} (number ${phoneSid})`));
return;
}
} catch (err) {
if (isVerbose())
console.error(
"Incoming phone number webhook update failed; no more fallbacks",
err,
);
}
runtime.error(
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
);
}

94
src/twilio/webhook.ts Normal file
View File

@@ -0,0 +1,94 @@
import express, { type Request, type Response } from "express";
import bodyParser from "body-parser";
import chalk from "chalk";
import type { Server } from "http";
import { success, logVerbose, danger } from "../globals.js";
import { readEnv } from "../env.js";
import { createClient } from "./client.js";
import { normalizePath } from "../utils.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import { sendTypingIndicator } from "./typing.js";
import { logTwilioSendError } from "./utils.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
/** Start the inbound webhook HTTP server and wire optional auto-replies. */
export async function startWebhook(
port: number,
path = "/webhook/whatsapp",
autoReply: string | undefined,
verbose: boolean,
runtime: RuntimeEnv = defaultRuntime,
): Promise<Server> {
const normalizedPath = normalizePath(path);
const env = readEnv(runtime);
const app = express();
// Twilio sends application/x-www-form-urlencoded payloads.
app.use(bodyParser.urlencoded({ extended: false }));
app.use((req, _res, next) => {
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
next();
});
app.post(normalizedPath, async (req: Request, res: Response) => {
const { From, To, Body, MessageSid } = req.body ?? {};
runtime.log(`
[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`);
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
const client = createClient(env);
let replyText = autoReply;
if (!replyText) {
replyText = await getReplyFromConfig(
{ Body, From, To, MessageSid },
{
onReplyStart: () => sendTypingIndicator(client, MessageSid, runtime),
},
);
}
if (replyText) {
try {
await client.messages.create({ from: To, to: From, body: replyText });
if (verbose) runtime.log(success(`↩️ Auto-replied to ${From}`));
} catch (err) {
logTwilioSendError(err, From ?? undefined, runtime);
}
}
// Respond 200 OK to Twilio.
res.type("text/xml").send("<Response></Response>");
});
app.use((_req, res) => {
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
res.status(404).send("warelay webhook: not found");
});
// Start server and resolve once listening; reject on bind error.
return await new Promise((resolve, reject) => {
const server = app.listen(port);
const onListening = () => {
cleanup();
runtime.log(
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
);
resolve(server);
};
const onError = (err: NodeJS.ErrnoException) => {
cleanup();
reject(err);
};
const cleanup = () => {
server.off("listening", onListening);
server.off("error", onError);
};
server.once("listening", onListening);
server.once("error", onError);
});
}