diff --git a/src/index.ts b/src/index.ts index 12fb5c5c8..548d5a849 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,9 +38,11 @@ import { setMessagingServiceWebhook as setMessagingServiceWebhookImpl, } from "./twilio/update-webhook.js"; import { - findIncomingNumberSid as findIncomingNumberSid, - findMessagingServiceSid as findMessagingServiceSid, -} from "./twilio/update-webhook.js"; + listRecentMessages, + formatMessageLine, + uniqueBySid, + sortByDateDesc, +} from "./twilio/messages.js"; import { CLAUDE_BIN, parseClaudeJsonText } from "./auto-reply/claude.js"; import { applyTemplate, @@ -817,98 +819,6 @@ async function performUp( return { server, publicUrl, senderSid }; } -type ListedMessage = { - sid: string; - status: string | null; - direction: string | null; - dateCreated?: Date | null; - from?: string | null; - to?: string | null; - body?: string | null; - errorCode?: number | null; - errorMessage?: string | null; -}; - -function uniqueBySid(messages: ListedMessage[]): ListedMessage[] { - const seen = new Set(); - const deduped: ListedMessage[] = []; - for (const m of messages) { - if (seen.has(m.sid)) continue; - seen.add(m.sid); - deduped.push(m); - } - return deduped; -} - -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; - }); -} - -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 || ""; - return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`; -} - -async function listRecentMessages( - lookbackMinutes: number, - limit: number, - clientOverride?: ReturnType, -): Promise { - 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, - }); - - 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); -} - export { assertProvider, autoReplyIfConfigured, @@ -921,8 +831,8 @@ export { ensureGoInstalled, ensurePortAvailable, ensureTailscaledInstalled, - findIncomingNumberSid, - findMessagingServiceSid, + findIncomingNumberSidImpl as findIncomingNumberSid, + findMessagingServiceSidImpl as findMessagingServiceSid, findWhatsappSenderSid, formatMessageLine, formatTwilioError, diff --git a/src/twilio/messages.ts b/src/twilio/messages.ts new file mode 100644 index 000000000..2ffa742e2 --- /dev/null +++ b/src/twilio/messages.ts @@ -0,0 +1,90 @@ +import { withWhatsAppPrefix } from "../utils.js"; +import { readEnv } from "../env.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; +}; + +// Remove duplicates by SID while preserving order. +export function uniqueBySid(messages: ListedMessage[]): ListedMessage[] { + const seen = new Set(); + 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; + }); +} + +// Merge inbound/outbound messages (recent first) for status commands and tests. +export async function listRecentMessages( + lookbackMinutes: number, + limit: number, + clientOverride?: ReturnType, +): Promise { + 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 }); + + 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); +} + +// 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 || ""; + return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`; +}