Refactor CLI and Twilio modules; add helper tests and comments
This commit is contained in:
34
src/twilio/monitor.test.ts
Normal file
34
src/twilio/monitor.test.ts
Normal 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
114
src/twilio/monitor.ts
Normal 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
23
src/twilio/send.test.ts
Normal 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
67
src/twilio/send.ts
Normal 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
79
src/twilio/types.ts
Normal 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>;
|
||||
};
|
||||
60
src/twilio/update-webhook.test.ts
Normal file
60
src/twilio/update-webhook.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
194
src/twilio/update-webhook.ts
Normal file
194
src/twilio/update-webhook.ts
Normal 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
94
src/twilio/webhook.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user