chore: format to 2-space and bump changelog
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user