chore: format to 2-space and bump changelog

This commit is contained in:
Peter Steinberger
2025-11-26 00:53:53 +01:00
parent a67f4db5e2
commit e5f677803f
81 changed files with 7086 additions and 6999 deletions

View File

@@ -2,13 +2,13 @@ import Twilio from "twilio";
import type { EnvConfig } from "../env.js";
export function createClient(env: EnvConfig) {
// Twilio client using either auth token or API key/secret.
if ("authToken" in env.auth) {
return Twilio(env.accountSid, env.auth.authToken, {
accountSid: env.accountSid,
});
}
return Twilio(env.auth.apiKey, env.auth.apiSecret, {
accountSid: env.accountSid,
});
// Twilio client using either auth token or API key/secret.
if ("authToken" in env.auth) {
return Twilio(env.accountSid, env.auth.authToken, {
accountSid: env.accountSid,
});
}
return Twilio(env.auth.apiKey, env.auth.apiSecret, {
accountSid: env.accountSid,
});
}

View File

@@ -3,97 +3,97 @@ import { withWhatsAppPrefix } from "../utils.js";
import { createClient } from "./client.js";
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;
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;
};
// Remove duplicates by SID while preserving order.
export function uniqueBySid(messages: ListedMessage[]): ListedMessage[] {
const seen = new Set<string>();
const deduped: ListedMessage[] = [];
for (const m of messages) {
if (seen.has(m.sid)) continue;
seen.add(m.sid);
deduped.push(m);
}
return deduped;
const seen = new Set<string>();
const deduped: ListedMessage[] = [];
for (const m of messages) {
if (seen.has(m.sid)) continue;
seen.add(m.sid);
deduped.push(m);
}
return deduped;
}
// Sort messages newest -> oldest by dateCreated.
export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] {
return [...messages].sort((a, b) => {
const da = a.dateCreated?.getTime() ?? 0;
const db = b.dateCreated?.getTime() ?? 0;
return db - da;
});
return [...messages].sort((a, b) => {
const da = a.dateCreated?.getTime() ?? 0;
const db = b.dateCreated?.getTime() ?? 0;
return db - da;
});
}
// Merge inbound/outbound messages (recent first) for status commands and tests.
export async function listRecentMessages(
lookbackMinutes: number,
limit: number,
clientOverride?: ReturnType<typeof createClient>,
lookbackMinutes: number,
limit: number,
clientOverride?: ReturnType<typeof createClient>,
): Promise<ListedMessage[]> {
const env = readEnv();
const client = clientOverride ?? createClient(env);
const from = withWhatsAppPrefix(env.whatsappFrom);
const since = new Date(Date.now() - lookbackMinutes * 60_000);
const env = readEnv();
const client = clientOverride ?? createClient(env);
const from = withWhatsAppPrefix(env.whatsappFrom);
const since = new Date(Date.now() - lookbackMinutes * 60_000);
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.
const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100);
const inbound = await client.messages.list({
to: from,
dateSentAfter: since,
limit: fetchLimit,
});
const outbound = await client.messages.list({
from,
dateSentAfter: since,
limit: fetchLimit,
});
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.
const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100);
const inbound = await client.messages.list({
to: from,
dateSentAfter: since,
limit: fetchLimit,
});
const outbound = await client.messages.list({
from,
dateSentAfter: since,
limit: fetchLimit,
});
const inboundArr = Array.isArray(inbound) ? inbound : [];
const outboundArr = Array.isArray(outbound) ? outbound : [];
const combined = uniqueBySid(
[...inboundArr, ...outboundArr].map((m) => ({
sid: m.sid,
status: m.status ?? null,
direction: m.direction ?? null,
dateCreated: m.dateCreated,
from: m.from,
to: m.to,
body: m.body,
errorCode: m.errorCode ?? null,
errorMessage: m.errorMessage ?? null,
})),
);
const inboundArr = Array.isArray(inbound) ? inbound : [];
const outboundArr = Array.isArray(outbound) ? outbound : [];
const combined = uniqueBySid(
[...inboundArr, ...outboundArr].map((m) => ({
sid: m.sid,
status: m.status ?? null,
direction: m.direction ?? null,
dateCreated: m.dateCreated,
from: m.from,
to: m.to,
body: m.body,
errorCode: m.errorCode ?? null,
errorMessage: m.errorMessage ?? null,
})),
);
return sortByDateDesc(combined).slice(0, limit);
return sortByDateDesc(combined).slice(0, limit);
}
// Human-friendly single-line formatter for recent messages.
export function formatMessageLine(m: ListedMessage): string {
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
const dir =
m.direction === "inbound"
? "⬅️ "
: m.direction === "outbound-api" || m.direction === "outbound-reply"
? "➡️ "
: "↔️ ";
const status = m.status ?? "unknown";
const err =
m.errorCode != null
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
: "";
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
const bodyPreview =
body.length > 140 ? `${body.slice(0, 137)}` : body || "<empty>";
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
const dir =
m.direction === "inbound"
? "⬅️ "
: m.direction === "outbound-api" || m.direction === "outbound-reply"
? "➡️ "
: "↔️ ";
const status = m.status ?? "unknown";
const err =
m.errorCode != null
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
: "";
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
const bodyPreview =
body.length > 140 ? `${body.slice(0, 137)}` : body || "<empty>";
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
}

View File

@@ -3,43 +3,43 @@ 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);
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,
});
await monitorTwilio(0, 0, {
deps: {
autoReplyIfConfigured,
listRecentMessages,
readEnv,
createClient,
sleep,
},
maxIterations: 1,
});
expect(listRecentMessages).toHaveBeenCalledTimes(1);
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
});
expect(listRecentMessages).toHaveBeenCalledTimes(1);
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
});
});

View File

@@ -8,122 +8,122 @@ import { sleep, withWhatsAppPrefix } from "../utils.js";
import { createClient } from "./client.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;
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;
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;
client?: ReturnType<typeof createClient>;
maxIterations?: number;
deps?: MonitorDeps;
runtime?: RuntimeEnv;
};
const defaultDeps: MonitorDeps = {
autoReplyIfConfigured,
listRecentMessages: () => Promise.resolve([]),
readEnv,
createClient,
sleep,
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,
pollSeconds: number,
lookbackMinutes: number,
opts?: MonitorOptions,
) {
const deps = opts?.deps ?? defaultDeps;
const runtime = opts?.runtime ?? defaultRuntime;
const maxIterations = opts?.maxIterations ?? Infinity;
let backoffMs = 1_000;
const deps = opts?.deps ?? defaultDeps;
const runtime = opts?.runtime ?? defaultRuntime;
const maxIterations = opts?.maxIterations ?? Infinity;
let backoffMs = 1_000;
const env = deps.readEnv(runtime);
const from = withWhatsAppPrefix(env.whatsappFrom);
const client = opts?.client ?? deps.createClient(env);
logInfo(
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
runtime,
);
const env = deps.readEnv(runtime);
const from = withWhatsAppPrefix(env.whatsappFrom);
const client = opts?.client ?? deps.createClient(env);
logInfo(
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
runtime,
);
let lastSeenSid: string | undefined;
let iterations = 0;
while (iterations < maxIterations) {
let messages: ListedMessage[] = [];
try {
messages =
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
backoffMs = 1_000; // reset after success
} catch (err) {
logWarn(
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
runtime,
);
await deps.sleep(backoffMs);
backoffMs = Math.min(backoffMs * 2, 10_000);
continue;
}
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,
);
}
let lastSeenSid: string | undefined;
let iterations = 0;
while (iterations < maxIterations) {
let messages: ListedMessage[] = [];
try {
messages =
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
backoffMs = 1_000; // reset after success
} catch (err) {
logWarn(
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
runtime,
);
await deps.sleep(backoffMs);
backoffMs = Math.min(backoffMs * 2, 10_000);
continue;
}
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,
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
logDebug(`[${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 import("./types.js").TwilioRequester & {
messages: { create: (opts: unknown) => Promise<unknown> };
},
m as unknown as MessageInstance,
undefined,
runtime,
);
} catch (err) {
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
}
}
for (const m of messages) {
if (!m.sid) continue;
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
logDebug(`[${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 import("./types.js").TwilioRequester & {
messages: { create: (opts: unknown) => Promise<unknown> };
},
m as unknown as MessageInstance,
undefined,
runtime,
);
} catch (err) {
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
}
}
}

View File

@@ -3,30 +3,30 @@ 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 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);
});
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);
});
});

View File

@@ -10,60 +10,60 @@ 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,
opts?: { mediaUrl?: string },
runtime: RuntimeEnv = defaultRuntime,
to: string,
body: string,
opts?: { mediaUrl?: string },
runtime: RuntimeEnv = defaultRuntime,
) {
const env = readEnv(runtime);
const client = createClient(env);
const from = withWhatsAppPrefix(env.whatsappFrom);
const toNumber = withWhatsAppPrefix(to);
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,
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
});
try {
const message = await client.messages.create({
from,
to: toNumber,
body,
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
});
logInfo(
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
runtime,
);
return { client, sid: message.sid };
} catch (err) {
logTwilioSendError(err, toNumber, runtime);
}
logInfo(
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
runtime,
);
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,
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)) {
logInfo(`✅ Delivered (status: ${status})`, runtime);
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);
}
logInfo(
" Timed out waiting for final status; message may still be in flight.",
runtime,
);
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)) {
logInfo(`✅ Delivered (status: ${status})`, runtime);
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);
}
logInfo(
" Timed out waiting for final status; message may still be in flight.",
runtime,
);
}

View File

@@ -4,50 +4,50 @@ import { withWhatsAppPrefix } from "../utils.js";
import type { TwilioSenderListClient } from "./types.js";
export async function findWhatsappSenderSid(
client: TwilioSenderListClient,
from: string,
explicitSenderSid?: string,
runtime: RuntimeEnv = defaultRuntime,
client: TwilioSenderListClient,
from: string,
explicitSenderSid?: string,
runtime: RuntimeEnv = defaultRuntime,
) {
// Use explicit sender SID if provided, otherwise list and match by sender_id.
if (explicitSenderSid) {
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
return explicitSenderSid;
}
try {
// Prefer official SDK list helper to avoid request-shape mismatches.
// Twilio helper types are broad; we narrow to expected shape.
const senderClient = client as unknown as TwilioSenderListClient;
const senders = await senderClient.messaging.v2.channelsSenders.list({
channel: "whatsapp",
pageSize: 50,
});
if (!senders) {
throw new Error('List senders response missing "senders" array');
}
const match = senders.find(
(s) =>
(typeof s.senderId === "string" &&
s.senderId === withWhatsAppPrefix(from)) ||
(typeof s.sender_id === "string" &&
s.sender_id === withWhatsAppPrefix(from)),
);
if (!match || typeof match.sid !== "string") {
throw new Error(
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
);
}
return match.sid;
} catch (err) {
runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
if (isVerbose()) {
runtime.error(err as Error);
}
runtime.error(
info(
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
),
);
runtime.exit(1);
}
// Use explicit sender SID if provided, otherwise list and match by sender_id.
if (explicitSenderSid) {
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
return explicitSenderSid;
}
try {
// Prefer official SDK list helper to avoid request-shape mismatches.
// Twilio helper types are broad; we narrow to expected shape.
const senderClient = client as unknown as TwilioSenderListClient;
const senders = await senderClient.messaging.v2.channelsSenders.list({
channel: "whatsapp",
pageSize: 50,
});
if (!senders) {
throw new Error('List senders response missing "senders" array');
}
const match = senders.find(
(s) =>
(typeof s.senderId === "string" &&
s.senderId === withWhatsAppPrefix(from)) ||
(typeof s.sender_id === "string" &&
s.sender_id === withWhatsAppPrefix(from)),
);
if (!match || typeof match.sid !== "string") {
throw new Error(
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
);
}
return match.sid;
} catch (err) {
runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
if (isVerbose()) {
runtime.error(err as Error);
}
runtime.error(
info(
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
),
);
runtime.exit(1);
}
}

View File

@@ -1,79 +1,79 @@
export type TwilioRequestOptions = {
method: "get" | "post";
uri: string;
params?: Record<string, string | number>;
form?: Record<string, string>;
body?: unknown;
contentType?: string;
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[];
};
data?: {
senders?: TwilioSender[];
};
};
export type IncomingNumber = {
sid: string;
phoneNumber: string;
smsUrl?: string;
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;
};
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>;
update: (params: Record<string, string>) => Promise<unknown>;
};
export type IncomingPhoneNumberUpdater = {
update: (params: Record<string, string>) => Promise<unknown>;
update: (params: Record<string, string>) => Promise<unknown>;
};
export type IncomingPhoneNumbersClient = {
list: (params: {
phoneNumber: string;
limit?: number;
}) => Promise<IncomingNumber[]>;
get: (sid: string) => IncomingPhoneNumberUpdater;
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;
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>;
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
};

View File

@@ -2,42 +2,42 @@ import { isVerbose, logVerbose, warn } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
type TwilioRequestOptions = {
method: "get" | "post";
uri: string;
params?: Record<string, string | number>;
form?: Record<string, string>;
body?: unknown;
contentType?: string;
method: "get" | "post";
uri: string;
params?: Record<string, string | number>;
form?: Record<string, string>;
body?: unknown;
contentType?: string;
};
type TwilioRequester = {
request: (options: TwilioRequestOptions) => Promise<unknown>;
request: (options: TwilioRequestOptions) => Promise<unknown>;
};
export async function sendTypingIndicator(
client: TwilioRequester,
runtime: RuntimeEnv,
messageSid?: string,
client: TwilioRequester,
runtime: RuntimeEnv,
messageSid?: string,
) {
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
if (!messageSid) {
logVerbose("Skipping typing indicator: missing MessageSid");
return;
}
try {
await client.request({
method: "post",
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
form: {
messageId: messageSid,
channel: "whatsapp",
},
});
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
} catch (err) {
if (isVerbose()) {
runtime.error(warn("Typing indicator failed (continuing without it)"));
runtime.error(err as Error);
}
}
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
if (!messageSid) {
logVerbose("Skipping typing indicator: missing MessageSid");
return;
}
try {
await client.request({
method: "post",
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
form: {
messageId: messageSid,
channel: "whatsapp",
},
});
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
} catch (err) {
if (isVerbose()) {
runtime.error(warn("Typing indicator failed (continuing without it)"));
runtime.error(err as Error);
}
}
}

View File

@@ -1,61 +1,61 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
findIncomingNumberSid,
findMessagingServiceSid,
setMessagingServiceWebhook,
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";
process.env.TWILIO_AUTH_TOKEN = "dummy-token";
});
beforeEach(() => {
process.env.TWILIO_ACCOUNT_SID = "AC";
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
process.env.TWILIO_AUTH_TOKEN = "dummy-token";
});
afterEach(() => {
Object.entries(envBackup).forEach(([k, v]) => {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
});
});
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("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("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);
});
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

@@ -6,193 +6,193 @@ import type { createClient } from "./client.js";
import type { TwilioRequester, TwilioSenderListClient } from "./types.js";
export async function findIncomingNumberSid(
client: TwilioSenderListClient,
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;
}
// 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,
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;
}
// 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",
runtime: RuntimeEnv = defaultRuntime,
client: TwilioSenderListClient,
url: string,
method: "POST" | "GET",
runtime: RuntimeEnv = defaultRuntime,
): 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;
logInfo(
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
runtime,
);
return true;
} catch {
return false;
}
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;
logInfo(
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
runtime,
);
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,
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;
// 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) {
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
return;
}
if (isVerbose())
logError(
"Sender updated but webhook callback_url missing; will try fallbacks",
runtime,
);
} catch (err) {
if (isVerbose())
logError(
`channelsSenders request update failed, will try client helpers: ${String(err)}`,
runtime,
);
}
// 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) {
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
return;
}
if (isVerbose())
logError(
"Sender updated but webhook callback_url missing; will try fallbacks",
runtime,
);
} catch (err) {
if (isVerbose())
logError(
`channelsSenders request update failed, will try client helpers: ${String(err)}`,
runtime,
);
}
// 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) {
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
return;
}
if (isVerbose())
logError(
"Form update succeeded but callback_url missing; will try helper fallback",
runtime,
);
} catch (err) {
if (isVerbose())
logError(
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
runtime,
);
}
// 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) {
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
return;
}
if (isVerbose())
logError(
"Form update succeeded but callback_url missing; will try helper fallback",
runtime,
);
} catch (err) {
if (isVerbose())
logError(
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
runtime,
);
}
// 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;
logInfo(
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
runtime,
);
return;
}
} catch (err) {
if (isVerbose())
logError(
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
runtime,
);
}
// 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;
logInfo(
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
runtime,
);
return;
}
} catch (err) {
if (isVerbose())
logError(
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
runtime,
);
}
// 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,
});
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
return;
}
} catch (err) {
if (isVerbose())
logError(
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
runtime,
);
}
// 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,
});
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
return;
}
} catch (err) {
if (isVerbose())
logError(
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
runtime,
);
}
runtime.error(
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
);
runtime.error(
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
);
}

View File

@@ -2,36 +2,36 @@ import { danger, info } from "../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
type TwilioApiError = {
code?: number | string;
status?: number | string;
message?: string;
moreInfo?: string;
response?: { body?: unknown };
code?: number | string;
status?: number | string;
message?: string;
moreInfo?: string;
response?: { body?: unknown };
};
export function formatTwilioError(err: unknown): string {
// Normalize Twilio error objects into a single readable 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);
// Normalize Twilio error objects into a single readable 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);
}
export function logTwilioSendError(
err: unknown,
destination?: string,
runtime: RuntimeEnv = defaultRuntime,
err: unknown,
destination?: string,
runtime: RuntimeEnv = defaultRuntime,
) {
// Friendly error logger for send failures, including response body when present.
const prefix = destination ? `to ${destination}: ` : "";
runtime.error(
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
);
const body = (err as TwilioApiError)?.response?.body;
if (body) {
runtime.error(info("Response body:"), JSON.stringify(body, null, 2));
}
// Friendly error logger for send failures, including response body when present.
const prefix = destination ? `to ${destination}: ` : "";
runtime.error(
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
);
const body = (err as TwilioApiError)?.response?.body;
if (body) {
runtime.error(info("Response body:"), JSON.stringify(body, null, 2));
}
}

View File

@@ -16,143 +16,143 @@ import { logTwilioSendError } from "./utils.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,
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();
const normalizedPath = normalizePath(path);
const env = readEnv(runtime);
const app = express();
attachMediaRoutes(app, undefined, runtime);
// 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();
});
attachMediaRoutes(app, undefined, runtime);
// 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(`
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 ?? ""}`));
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
let mediaPath: string | undefined;
let mediaUrlInbound: string | undefined;
let mediaType: string | undefined;
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
mediaUrlInbound = req.body.MediaUrl0 as string;
mediaType =
typeof req.body?.MediaContentType0 === "string"
? (req.body.MediaContentType0 as string)
: undefined;
try {
const creds = buildTwilioBasicAuth(env);
const saved = await saveMediaSource(
mediaUrlInbound,
{
Authorization: `Basic ${creds}`,
},
"inbound",
);
mediaPath = saved.path;
if (!mediaType && saved.contentType) mediaType = saved.contentType;
} catch (err) {
runtime.error(
danger(`Failed to download inbound media: ${String(err)}`),
);
}
}
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
let mediaPath: string | undefined;
let mediaUrlInbound: string | undefined;
let mediaType: string | undefined;
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
mediaUrlInbound = req.body.MediaUrl0 as string;
mediaType =
typeof req.body?.MediaContentType0 === "string"
? (req.body.MediaContentType0 as string)
: undefined;
try {
const creds = buildTwilioBasicAuth(env);
const saved = await saveMediaSource(
mediaUrlInbound,
{
Authorization: `Basic ${creds}`,
},
"inbound",
);
mediaPath = saved.path;
if (!mediaType && saved.contentType) mediaType = saved.contentType;
} catch (err) {
runtime.error(
danger(`Failed to download inbound media: ${String(err)}`),
);
}
}
const client = createClient(env);
let replyResult: ReplyPayload | undefined =
autoReply !== undefined ? { text: autoReply } : undefined;
if (!replyResult) {
replyResult = await getReplyFromConfig(
{
Body,
From,
To,
MessageSid,
MediaPath: mediaPath,
MediaUrl: mediaUrlInbound,
MediaType: mediaType,
},
{
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
},
);
}
const client = createClient(env);
let replyResult: ReplyPayload | undefined =
autoReply !== undefined ? { text: autoReply } : undefined;
if (!replyResult) {
replyResult = await getReplyFromConfig(
{
Body,
From,
To,
MessageSid,
MediaPath: mediaPath,
MediaUrl: mediaUrlInbound,
MediaType: mediaType,
},
{
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
},
);
}
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
try {
let mediaUrl = replyResult.mediaUrl;
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
const hosted = await ensureMediaHosted(mediaUrl);
mediaUrl = hosted.url;
}
await client.messages.create({
from: To,
to: From,
body: replyResult.text ?? "",
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
});
if (verbose)
runtime.log(
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
);
} catch (err) {
logTwilioSendError(err, From ?? undefined, runtime);
}
}
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
try {
let mediaUrl = replyResult.mediaUrl;
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
const hosted = await ensureMediaHosted(mediaUrl);
mediaUrl = hosted.url;
}
await client.messages.create({
from: To,
to: From,
body: replyResult.text ?? "",
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
});
if (verbose)
runtime.log(
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
);
} catch (err) {
logTwilioSendError(err, From ?? undefined, runtime);
}
}
// Respond 200 OK to Twilio.
res.type("text/xml").send("<Response></Response>");
});
// 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");
});
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);
// 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 onListening = () => {
cleanup();
runtime.log(
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
);
resolve(server);
};
const onError = (err: NodeJS.ErrnoException) => {
cleanup();
reject(err);
};
const onError = (err: NodeJS.ErrnoException) => {
cleanup();
reject(err);
};
const cleanup = () => {
server.off("listening", onListening);
server.off("error", onError);
};
const cleanup = () => {
server.off("listening", onListening);
server.off("error", onError);
};
server.once("listening", onListening);
server.once("error", onError);
});
server.once("listening", onListening);
server.once("error", onError);
});
}
function buildTwilioBasicAuth(env: EnvConfig) {
if ("authToken" in env.auth) {
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
"base64",
);
}
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
"base64",
);
if ("authToken" in env.auth) {
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
"base64",
);
}
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
"base64",
);
}