chore: sync source updates

This commit is contained in:
Peter Steinberger
2025-11-25 12:12:13 +01:00
parent ea745764d7
commit 800c7a1e1f
26 changed files with 462 additions and 376 deletions

View File

@@ -1,17 +1,17 @@
import { withWhatsAppPrefix } from "../utils.js";
import { readEnv } from "../env.js";
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.
@@ -37,54 +37,63 @@ export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] {
// 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

@@ -1,12 +1,11 @@
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
import { danger, warn } from "../globals.js";
import { sleep, withWhatsAppPrefix } from "../utils.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { autoReplyIfConfigured } from "../auto-reply/reply.js";
import { createClient } from "./client.js";
import { readEnv } from "../env.js";
import { danger } from "../globals.js";
import { logDebug, logInfo, logWarn } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { sleep, withWhatsAppPrefix } from "../utils.js";
import { createClient } from "./client.js";
type MonitorDeps = {
autoReplyIfConfigured: typeof autoReplyIfConfigured;
@@ -95,7 +94,9 @@ export async function monitorTwilio(
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
iterations += 1;
if (iterations >= maxIterations) break;
await deps.sleep(Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000);
await deps.sleep(
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
);
}
}

View File

@@ -1,8 +1,7 @@
import { success } from "../globals.js";
import { readEnv } from "../env.js";
import { logInfo } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { withWhatsAppPrefix, sleep } from "../utils.js";
import { readEnv } from "../env.js";
import { sleep, withWhatsAppPrefix } from "../utils.js";
import { createClient } from "./client.js";
import { logTwilioSendError } from "./utils.js";
@@ -29,7 +28,10 @@ export async function sendMessage(
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
});
logInfo(`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`, runtime);
logInfo(
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
runtime,
);
return { client, sid: message.sid };
} catch (err) {
logTwilioSendError(err, toNumber, runtime);

View File

@@ -1,4 +1,4 @@
import { warn, isVerbose, logVerbose } from "../globals.js";
import { isVerbose, logVerbose, warn } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
type TwilioRequestOptions = {

View File

@@ -1,12 +1,13 @@
import { isVerbose, success, warn } from "../globals.js";
import { logError, logInfo } from "../logger.js";
import { readEnv } from "../env.js";
import { normalizeE164 } from "../utils.js";
import { isVerbose } from "../globals.js";
import { logError, logInfo } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { createClient } from "./client.js";
import type { TwilioSenderListClient, TwilioRequester } from "./types.js";
import type { createClient } from "./client.js";
import type { TwilioRequester, TwilioSenderListClient } from "./types.js";
export async function findIncomingNumberSid(client: TwilioSenderListClient): Promise<string | null> {
export async function findIncomingNumberSid(
client: TwilioSenderListClient,
): Promise<string | null> {
// Look up incoming phone number SID matching the configured WhatsApp number.
try {
const env = readEnv();
@@ -21,7 +22,9 @@ export async function findIncomingNumberSid(client: TwilioSenderListClient): Pro
}
}
export async function findMessagingServiceSid(client: TwilioSenderListClient): Promise<string | null> {
export async function findMessagingServiceSid(
client: TwilioSenderListClient,
): Promise<string | null> {
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
type IncomingNumberWithService = { messagingServiceSid?: string };
try {
@@ -65,7 +68,6 @@ export async function setMessagingServiceWebhook(
}
}
// Update sender webhook URL with layered fallbacks (channels, form, helper, phone).
export async function updateWebhook(
client: ReturnType<typeof createClient>,

View File

@@ -1,142 +1,158 @@
import express, { type Request, type Response } from "express";
import type { Server } from "node:http";
import bodyParser from "body-parser";
import chalk from "chalk";
import type { Server } from "http";
import { success, logVerbose, danger } from "../globals.js";
import { readEnv, type EnvConfig } from "../env.js";
import { createClient } from "./client.js";
import { normalizePath } from "../utils.js";
import express, { type Request, type Response } from "express";
import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js";
import { sendTypingIndicator } from "./typing.js";
import { logTwilioSendError } from "./utils.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { type EnvConfig, readEnv } from "../env.js";
import { danger, success } from "../globals.js";
import { ensureMediaHosted } from "../media/host.js";
import { attachMediaRoutes } from "../media/server.js";
import { saveMediaSource } from "../media/store.js";
import { ensureMediaHosted } from "../media/host.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizePath } from "../utils.js";
import { createClient } from "./client.js";
import { sendTypingIndicator } from "./typing.js";
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.accountSid}:${env.auth.authToken}`).toString(
"base64",
);
}
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString("base64");
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
"base64",
);
}