From afdaa7ef9887621c3dff19df38ce673b466e8613 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Nov 2025 03:11:39 +0100 Subject: [PATCH] Refactor CLI and Twilio modules; add helper tests and comments --- src/auto-reply/claude.test.ts | 20 + src/auto-reply/claude.ts | 3 +- src/auto-reply/templating.ts | 2 +- src/cli/deps.ts | 4 + src/cli/program.ts | 239 +++++++++ src/config/sessions.test.ts | 20 + src/config/sessions.ts | 1 + src/index.ts | 774 ++---------------------------- src/process/exec.ts | 2 +- src/twilio/monitor.test.ts | 34 ++ src/twilio/monitor.ts | 114 +++++ src/twilio/send.test.ts | 23 + src/twilio/send.ts | 67 +++ src/twilio/types.ts | 79 +++ src/twilio/update-webhook.test.ts | 60 +++ src/twilio/update-webhook.ts | 194 ++++++++ src/twilio/webhook.ts | 94 ++++ 17 files changed, 996 insertions(+), 734 deletions(-) create mode 100644 src/auto-reply/claude.test.ts create mode 100644 src/cli/deps.ts create mode 100644 src/cli/program.ts create mode 100644 src/config/sessions.test.ts create mode 100644 src/twilio/monitor.test.ts create mode 100644 src/twilio/monitor.ts create mode 100644 src/twilio/send.test.ts create mode 100644 src/twilio/send.ts create mode 100644 src/twilio/types.ts create mode 100644 src/twilio/update-webhook.test.ts create mode 100644 src/twilio/update-webhook.ts create mode 100644 src/twilio/webhook.ts diff --git a/src/auto-reply/claude.test.ts b/src/auto-reply/claude.test.ts new file mode 100644 index 000000000..743949279 --- /dev/null +++ b/src/auto-reply/claude.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { parseClaudeJsonText } from "./claude.js"; + +describe("claude JSON parsing", () => { + it("extracts text from single JSON object", () => { + const out = parseClaudeJsonText('{"text":"hello"}'); + expect(out).toBe("hello"); + }); + + it("extracts from newline-delimited JSON", () => { + const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}'); + expect(out).toBe("there"); + }); + + it("returns undefined on invalid JSON", () => { + expect(parseClaudeJsonText("not json")).toBeUndefined(); + }); +}); + diff --git a/src/auto-reply/claude.ts b/src/auto-reply/claude.ts index 83a8aaa95..3a438048c 100644 --- a/src/auto-reply/claude.ts +++ b/src/auto-reply/claude.ts @@ -1,5 +1,6 @@ // Helpers specific to Claude CLI output/argv handling. +// Preferred binary name for Claude CLI invocations. export const CLAUDE_BIN = "claude"; function extractClaudeText(payload: unknown): string | undefined { @@ -45,7 +46,7 @@ function extractClaudeText(payload: unknown): string | undefined { } export function parseClaudeJsonText(raw: string): string | undefined { - // Handle a single JSON blob or newline-delimited JSON; return the first extracted text. + // Handle a single JSON blob or newline-delimited JSON; return the first extracted text. const candidates = [raw, ...raw.split(/\n+/).map((s) => s.trim()).filter(Boolean)]; for (const candidate of candidates) { try { diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index b4494332d..5929d27a7 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -11,8 +11,8 @@ export type TemplateContext = MsgContext & { IsNewSession?: string; }; +// Simple {{Placeholder}} interpolation using inbound message context. export function applyTemplate(str: string, ctx: TemplateContext) { - // Simple {{Placeholder}} interpolation using inbound message context. return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => { const value = (ctx as Record)[key]; return value == null ? "" : String(value); diff --git a/src/cli/deps.ts b/src/cli/deps.ts new file mode 100644 index 000000000..b2aa48c02 --- /dev/null +++ b/src/cli/deps.ts @@ -0,0 +1,4 @@ +import { createDefaultDeps } from "../index.js"; +import { logWebSelfId, logTwilioFrom, monitorTwilio } from "../index.js"; + +export { createDefaultDeps, logWebSelfId, logTwilioFrom, monitorTwilio }; diff --git a/src/cli/program.ts b/src/cli/program.ts new file mode 100644 index 000000000..d1756e6be --- /dev/null +++ b/src/cli/program.ts @@ -0,0 +1,239 @@ +import { Command } from "commander"; + +import { defaultRuntime, setVerbose, setYes, danger, info, warn } from "../globals.js"; +import { sendCommand } from "../commands/send.js"; +import { statusCommand } from "../commands/status.js"; +import { upCommand } from "../commands/up.js"; +import { webhookCommand } from "../commands/webhook.js"; +import { loginWeb, monitorWebProvider } from "../provider-web.js"; +import { pickProvider } from "../provider-web.js"; +import type { Provider } from "../utils.js"; +import { createDefaultDeps, logWebSelfId, logTwilioFrom, monitorTwilio } from "./deps.js"; +import { ensureTwilioEnv } from "../env.js"; + +export function buildProgram() { + const program = new Command(); + + program + .name("warelay") + .description("WhatsApp relay CLI (Twilio or WhatsApp Web session)") + .version("1.0.0"); + + program + .command("web:login") + .description("Link your personal WhatsApp via QR (web provider)") + .option("--verbose", "Verbose connection logs", false) + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + try { + await loginWeb(Boolean(opts.verbose)); + } catch (err) { + defaultRuntime.error(danger(`Web login failed: ${String(err)}`)); + defaultRuntime.exit(1); + } + }); + + program + .command("login") + .description("Alias for web:login (personal WhatsApp Web QR link)") + .option("--verbose", "Verbose connection logs", false) + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + try { + await loginWeb(Boolean(opts.verbose)); + } catch (err) { + defaultRuntime.error(danger(`Web login failed: ${String(err)}`)); + defaultRuntime.exit(1); + } + }); + + program + .command("send") + .description("Send a WhatsApp message") + .requiredOption( + "-t, --to ", + "Recipient number in E.164 (e.g. +15551234567)", + ) + .requiredOption("-m, --message ", "Message body") + .option("-w, --wait ", "Wait for delivery status (0 to skip)", "20") + .option("-p, --poll ", "Polling interval while waiting", "2") + .option("--provider ", "Provider: twilio | web", "twilio") + .addHelpText( + "after", + ` +Examples: + warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default) + warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget + warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`, + ) + .action(async (opts) => { + const deps = createDefaultDeps(); + try { + await sendCommand(opts, deps, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + program + .command("relay") + .description("Auto-reply to inbound messages (auto-selects web or twilio)") + .option("--provider ", "auto | web | twilio", "auto") + .option("-i, --interval ", "Polling interval for twilio mode", "5") + .option( + "-l, --lookback ", + "Initial lookback window for twilio mode", + "5", + ) + .option("--verbose", "Verbose logging", false) + .addHelpText( + "after", + ` +Examples: + warelay relay # auto: web if logged-in, else twilio poll + warelay relay --provider web # force personal web session + warelay relay --provider twilio # force twilio poll + warelay relay --provider twilio --interval 2 --lookback 30 +`, + ) + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + const providerPref = String(opts.provider ?? "auto"); + if (!["auto", "web", "twilio"].includes(providerPref)) { + defaultRuntime.error("--provider must be auto, web, or twilio"); + defaultRuntime.exit(1); + } + const intervalSeconds = Number.parseInt(opts.interval, 10); + const lookbackMinutes = Number.parseInt(opts.lookback, 10); + if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) { + defaultRuntime.error("Interval must be a positive integer"); + defaultRuntime.exit(1); + } + if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) { + defaultRuntime.error("Lookback must be >= 0 minutes"); + defaultRuntime.exit(1); + } + + const provider = await pickProvider(providerPref as Provider | "auto"); + + if (provider === "web") { + defaultRuntime.log(infoFmt("Provider: web (personal WhatsApp Web session)")); + logWebSelfId(); + try { + await monitorWebProvider(Boolean(opts.verbose)); + return; + } catch (err) { + if (providerPref === "auto") { + defaultRuntime.error( + warn("Web session unavailable; falling back to twilio."), + ); + } else { + defaultRuntime.error(danger(`Web relay failed: ${String(err)}`)); + defaultRuntime.exit(1); + } + } + } + + ensureTwilioEnv(); + logTwilioFrom(); + await monitorTwilio(intervalSeconds, lookbackMinutes); + }); + + program + .command("status") + .description("Show recent WhatsApp messages (sent and received)") + .option("-l, --limit ", "Number of messages to show", "20") + .option("-b, --lookback ", "How far back to fetch messages", "240") + .option("--json", "Output JSON instead of text", false) + .addHelpText( + "after", + ` +Examples: + warelay status # last 20 msgs in past 4h + warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m + warelay status --json --limit 50 # machine-readable output`, + ) + .action(async (opts) => { + const deps = createDefaultDeps(); + try { + await statusCommand(opts, deps, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + program + .command("webhook") + .description( + "Run a local webhook server for inbound WhatsApp (works with Tailscale/port forward)", + ) + .option("-p, --port ", "Port to listen on", "42873") + .option("-r, --reply ", "Optional auto-reply text") + .option("--path ", "Webhook path", "/webhook/whatsapp") + .option("--verbose", "Log inbound and auto-replies", false) + .option("-y, --yes", "Auto-confirm prompts when possible", false) + .addHelpText( + "after", + ` +Examples: + warelay webhook # listen on 42873 + warelay webhook --port 45000 # pick a high, less-colliding port + warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file + +With Tailscale: + tailscale serve tcp 42873 127.0.0.1:42873 + (then set Twilio webhook URL to your tailnet IP:42873/webhook/whatsapp)`, + ) + // istanbul ignore next + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + setYes(Boolean(opts.yes)); + const deps = createDefaultDeps(); + try { + const server = await webhookCommand(opts, deps, defaultRuntime); + process.on("SIGINT", () => { + server.close(() => { + console.log("\nšŸ‘‹ Webhook stopped"); + defaultRuntime.exit(0); + }); + }); + await deps.waitForever(); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + program + .command("up") + .description( + "Bring up webhook + Tailscale Funnel + Twilio callback (default webhook mode)", + ) + .option("-p, --port ", "Port to listen on", "42873") + .option("--path ", "Webhook path", "/webhook/whatsapp") + .option("--verbose", "Verbose logging during setup/webhook", false) + .option("-y, --yes", "Auto-confirm prompts when possible", false) + // istanbul ignore next + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + setYes(Boolean(opts.yes)); + const deps = createDefaultDeps(); + try { + const { server } = await upCommand(opts, deps, defaultRuntime); + process.on("SIGINT", () => { + server.close(() => { + console.log("\nšŸ‘‹ Webhook stopped"); + defaultRuntime.exit(0); + }); + }); + await deps.waitForever(); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + return program; +} diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts new file mode 100644 index 000000000..f53ffdbbc --- /dev/null +++ b/src/config/sessions.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { deriveSessionKey } from "./sessions.js"; + +describe("sessions", () => { + it("returns normalized per-sender key", () => { + expect( + deriveSessionKey("per-sender", { From: "whatsapp:+1555" }), + ).toBe("+1555"); + }); + + it("falls back to unknown when sender missing", () => { + expect(deriveSessionKey("per-sender", {})).toBe("unknown"); + }); + + it("global scope returns global", () => { + expect(deriveSessionKey("global", { From: "+1" })).toBe("global"); + }); +}); + diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 70ca22a52..5bd53e5ef 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -47,6 +47,7 @@ export async function saveSessionStore( ); } +// Decide which session bucket to use (per-sender vs global). export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) { if (scope === "global") return "global"; const from = ctx.From ? normalizeE164(ctx.From) : ""; diff --git a/src/index.ts b/src/index.ts index 04de4dae0..12fb5c5c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,14 +8,12 @@ import process, { stdin as input, stdout as output } from "node:process"; import readline from "node:readline/promises"; import { fileURLToPath } from "node:url"; -import bodyParser from "body-parser"; import chalk from "chalk"; -import { Command } from "commander"; import dotenv from "dotenv"; -import express, { type Request, type Response } from "express"; import JSON5 from "json5"; import Twilio from "twilio"; import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js"; +import type { TwilioSenderListClient, TwilioRequester } from "./twilio/types.js"; import { runCommandWithTimeout, runExec, @@ -30,6 +28,19 @@ import { import { readEnv, ensureTwilioEnv, type EnvConfig } from "./env.js"; import { createClient } from "./twilio/client.js"; import { logTwilioSendError, formatTwilioError } from "./twilio/utils.js"; +import { monitorTwilio as monitorTwilioImpl } from "./twilio/monitor.js"; +import { sendMessage, waitForFinalStatus } from "./twilio/send.js"; +import { startWebhook as startWebhookImpl } from "./twilio/webhook.js"; +import { + updateWebhook as updateWebhookImpl, + findIncomingNumberSid as findIncomingNumberSidImpl, + findMessagingServiceSid as findMessagingServiceSidImpl, + setMessagingServiceWebhook as setMessagingServiceWebhookImpl, +} from "./twilio/update-webhook.js"; +import { + findIncomingNumberSid as findIncomingNumberSid, + findMessagingServiceSid as findMessagingServiceSid, +} from "./twilio/update-webhook.js"; import { CLAUDE_BIN, parseClaudeJsonText } from "./auto-reply/claude.js"; import { applyTemplate, @@ -90,7 +101,9 @@ import { dotenv.config({ quiet: true }); -const program = new Command(); +import { buildProgram } from "./cli/program.js"; + +const program = buildProgram(); type CliDeps = { sendMessage: typeof sendMessage; @@ -136,87 +149,6 @@ function createDefaultDeps(): CliDeps { }; } -type TwilioRequestOptions = { - method: "get" | "post"; - uri: string; - params?: Record; - form?: Record; - body?: unknown; - contentType?: string; -}; - -type TwilioSender = { sid: string; sender_id: string }; - -type TwilioRequestResponse = { - data?: { - senders?: TwilioSender[]; - }; -}; - -type IncomingNumber = { - sid: string; - phoneNumber: string; - smsUrl?: string; -}; - -type TwilioChannelsSender = { - sid?: string; - senderId?: string; - sender_id?: string; - webhook?: { - callback_url?: string; - callback_method?: string; - fallback_url?: string; - fallback_method?: string; - }; -}; - -type ChannelSenderUpdater = { - update: (params: Record) => Promise; -}; - -type IncomingPhoneNumberUpdater = { - update: (params: Record) => Promise; -}; - -type IncomingPhoneNumbersClient = { - list: (params: { - phoneNumber: string; - limit?: number; - }) => Promise; - get: (sid: string) => IncomingPhoneNumberUpdater; -} & ((sid: string) => IncomingPhoneNumberUpdater); - -type TwilioSenderListClient = { - messaging: { - v2: { - channelsSenders: { - list: (params: { - channel: string; - pageSize: number; - }) => Promise; - ( - sid: string, - ): ChannelSenderUpdater & { - fetch: () => Promise; - }; - }; - }; - v1: { - services: (sid: string) => { - update: (params: Record) => Promise; - fetch: () => Promise<{ inboundRequestUrl?: string }>; - }; - }; - }; - incomingPhoneNumbers: IncomingPhoneNumbersClient; -}; - -type TwilioRequester = { - request: (options: TwilioRequestOptions) => Promise; -}; - - class PortInUseError extends Error { port: number; @@ -349,91 +281,9 @@ function createClient(env: EnvConfig) { }); } -async function sendMessage( - to: string, - body: string, - runtime: RuntimeEnv = defaultRuntime, -) { - // Send outbound WhatsApp message; exit non-zero on API failure. - const env = readEnv(runtime); - const client = createClient(env); - const from = withWhatsAppPrefix(env.whatsappFrom); - const toNumber = withWhatsAppPrefix(to); - - try { - const message = await client.messages.create({ - from, - to: toNumber, - body, - }); - - console.log( - success( - `āœ… Request accepted. Message SID: ${message.sid} -> ${toNumber}`, - ), - ); - return { client, sid: message.sid }; - } catch (err) { - const anyErr = err as { - code?: string | number; - message?: unknown; - moreInfo?: unknown; - status?: string | number; - response?: { body?: unknown }; - }; - const { code, status } = anyErr; - const msg = - typeof anyErr?.message === "string" - ? anyErr.message - : (anyErr?.message ?? err); - const more = anyErr?.moreInfo; - runtime.error( - `āŒ Twilio send failed${code ? ` (code ${code})` : ""}${status ? ` status ${status}` : ""}: ${msg}`, - ); - if (more) console.error(`More info: ${more}`); - // Some Twilio errors include response.body with more context. - const responseBody = anyErr?.response?.body; - if (responseBody) { - console.error("Response body:", JSON.stringify(responseBody, null, 2)); - } - runtime.exit(1); - } -} - -const successTerminalStatuses = new Set(["delivered", "read"]); -const failureTerminalStatuses = new Set(["failed", "undelivered", "canceled"]); - -async function waitForFinalStatus( - client: ReturnType, - sid: string, - timeoutSeconds: number, - pollSeconds: number, - runtime: RuntimeEnv = defaultRuntime, -) { - // Poll message status until delivered/failed or timeout. - const deadline = Date.now() + timeoutSeconds * 1000; - while (Date.now() < deadline) { - const m = await client.messages(sid).fetch(); - const status = m.status ?? "unknown"; - if (successTerminalStatuses.has(status)) { - console.log(success(`āœ… Delivered (status: ${status})`)); - return; - } - if (failureTerminalStatuses.has(status)) { - runtime.error( - `āŒ Delivery failed (status: ${status}${ - m.errorCode ? `, code ${m.errorCode}` : "" - })${m.errorMessage ? `: ${m.errorMessage}` : ""}`, - ); - runtime.exit(1); - } - await sleep(pollSeconds * 1000); - } - console.log( - "ā„¹ļø Timed out waiting for final status; message may still be in flight.", - ); -} +// sendMessage / waitForFinalStatus now live in src/twilio/send.ts and are imported above. +// startWebhook now lives in src/twilio/webhook.ts; keep shim for existing imports/tests. async function startWebhook( port: number, path = "/webhook/whatsapp", @@ -441,91 +291,7 @@ async function startWebhook( verbose: boolean, runtime: RuntimeEnv = defaultRuntime, ): Promise { - const normalizedPath = normalizePath(path); - // Start Express webhook; generate replies via config or CLI flag. - const env = readEnv(runtime); - const app = express(); - - // Twilio sends application/x-www-form-urlencoded - 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 ?? {}; - console.log( - `[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${ - MessageSid ?? "no-sid" - })`, - ); - if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`)); - - const client = createClient(env); - let replyText = autoReply; - if (!replyText) { - replyText = await getReplyFromConfig( - { - Body, - From, - To, - MessageSid, - }, - { - onReplyStart: () => sendTypingIndicator(client, MessageSid, runtime), - }, - ); - } - - if (replyText) { - try { - await client.messages.create({ - from: To, - to: From, - body: replyText, - }); - if (verbose) { - runtime.log(success(`ā†©ļø Auto-replied to ${From}`)); - } - } catch (err) { - logTwilioSendError(err, From ?? undefined, runtime); - } - } - - // Respond 200 OK to Twilio - res.type("text/xml").send(""); - }); - - app.use((_req, res) => { - if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`)); - res.status(404).send("warelay webhook: not found"); - }); - - return await new Promise((resolve, reject) => { - const server = app.listen(port); - - const onListening = () => { - cleanup(); - runtime.log( - `šŸ“„ Webhook listening on http://localhost:${port}${normalizedPath}`, - ); - resolve(server); - }; - - const onError = (err: NodeJS.ErrnoException) => { - cleanup(); - reject(err); - }; - - const cleanup = () => { - server.off("listening", onListening); - server.off("error", onError); - }; - - server.once("listening", onListening); - server.once("error", onError); - }); + return startWebhookImpl(port, path, autoReply, verbose, runtime); } function waitForever() { @@ -742,78 +508,17 @@ async function findWhatsappSenderSid( } } -async function findIncomingNumberSid( - client: TwilioSenderListClient, -): Promise { - // Try to locate the underlying phone number and return its SID for webhook fallback. - const env = readEnv(); - const phone = env.whatsappFrom.replace("whatsapp:", ""); - try { - const list = await client.incomingPhoneNumbers.list({ - phoneNumber: phone, - limit: 2, - }); - if (!list || list.length === 0) return null; - if (list.length > 1 && isVerbose()) { - console.error( - warn("Multiple incoming numbers matched; using the first."), - ); - } - return list[0]?.sid ?? null; - } catch (err) { - if (isVerbose()) console.error("incomingPhoneNumbers.list failed", err); - return null; - } -} -async function findMessagingServiceSid( - client: TwilioSenderListClient, -): Promise { - // 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 (err) { - if (isVerbose()) console.error("findMessagingServiceSid failed", err); - return null; - } -} async function setMessagingServiceWebhook( client: TwilioSenderListClient, url: string, - method: "POST" | "GET", + method: "POST" | "GET" = "POST", ): Promise { - const msid = await findMessagingServiceSid(client); - if (!msid) return false; - try { - await client.messaging.v1.services(msid).update({ - InboundRequestUrl: url, - InboundRequestMethod: method, - }); - const fetched = await client.messaging.v1.services(msid).fetch(); - const stored = fetched?.inboundRequestUrl; - console.log( - success( - `āœ… Messaging Service webhook set to ${stored ?? url} (service ${msid})`, - ), - ); - return true; - } catch (err) { - if (isVerbose()) console.error("Messaging Service update failed", err); - return false; - } + return setMessagingServiceWebhookImpl(client, url, method); } + async function updateWebhook( client: ReturnType, senderSid: string, @@ -821,139 +526,7 @@ async function updateWebhook( method: "POST" | "GET" = "POST", runtime: RuntimeEnv = defaultRuntime, ) { - // 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", - }); - // Fetch to verify what Twilio stored - const fetched = await clientTyped.messaging.v2 - .channelsSenders(senderSid) - .fetch(); - const storedUrl = - fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; - if (storedUrl) { - console.log(success(`āœ… Twilio sender webhook set to ${storedUrl}`)); - return; - } - if (isVerbose()) - console.error( - "Sender updated but webhook callback_url missing; will try fallbacks", - ); - } catch (err) { - if (isVerbose()) - console.error( - "channelsSenders request update failed, will try client helpers", - err, - ); - } - - // 1b) Form-encoded fallback for older Twilio stacks - try { - await requester.request({ - method: "post", - uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`, - form: { - "Webhook.CallbackUrl": url, - "Webhook.CallbackMethod": method, - }, - }); - const fetched = await clientTyped.messaging.v2 - .channelsSenders(senderSid) - .fetch(); - const storedUrl = - fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; - if (storedUrl) { - console.log(success(`āœ… Twilio sender webhook set to ${storedUrl}`)); - return; - } - if (isVerbose()) - console.error( - "Form update succeeded but callback_url missing; will try helper fallback", - ); - } catch (err) { - if (isVerbose()) - console.error( - "Form channelsSenders update failed, will try helper fallback", - err, - ); - } - - // 2) SDK helper fallback (if supported by this client) - try { - if (clientTyped.messaging?.v2?.channelsSenders) { - await clientTyped.messaging.v2.channelsSenders(senderSid).update({ - callbackUrl: url, - callbackMethod: method, - }); - const fetched = await clientTyped.messaging.v2 - .channelsSenders(senderSid) - .fetch(); - const storedUrl = - fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; - console.log( - success( - `āœ… Twilio sender webhook set to ${storedUrl ?? url} (helper API)`, - ), - ); - return; - } - } catch (err) { - if (isVerbose()) - console.error( - "channelsSenders helper update failed, will try phone number fallback", - err, - ); - } - - // 3) Incoming phone number fallback (works for many WA senders) - try { - const phoneSid = await findIncomingNumberSid(clientTyped); - if (phoneSid) { - const phoneNumberUpdater = clientTyped.incomingPhoneNumbers(phoneSid); - await phoneNumberUpdater.update({ - smsUrl: url, - smsMethod: method, - }); - console.log(success(`āœ… Twilio phone webhook set to ${url}`)); - return; - } - } catch (err) { - if (isVerbose()) console.error("Incoming number update failed", err); - } - - // 4) Messaging Service fallback (some WA senders are tied to a service) - const messagingServiceUpdated = await setMessagingServiceWebhook( - clientTyped, - url, - method, - ); - if (messagingServiceUpdated) return; - - runtime.error(danger("Failed to set Twilio webhook.")); - runtime.error( - info( - "Double-check your sender SID and credentials; you can set TWILIO_SENDER_SID to force a specific sender.", - ), - ); - runtime.error( - info( - "Tip: if webhooks are blocked, use polling instead: `pnpm warelay relay --provider twilio --interval 5 --lookback 10`", - ), - ); - runtime.exit(1); + return updateWebhookImpl(client, senderSid, url, method, runtime); } function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) { @@ -1018,65 +591,23 @@ async function monitorTwilio( clientOverride?: ReturnType, maxIterations = Infinity, ) { - // Poll Twilio for inbound messages and stream them with de-dupe. - const env = readEnv(); - const client = clientOverride ?? createClient(env); - const from = withWhatsAppPrefix(env.whatsappFrom); - - let since = new Date(Date.now() - lookbackMinutes * 60_000); - const seen = new Set(); - - console.log( - `šŸ“” Monitoring inbound messages to ${from} (poll ${intervalSeconds}s, lookback ${lookbackMinutes}m)`, + // Delegate to the refactored monitor in src/twilio/monitor.ts. + return monitorTwilioImpl( + intervalSeconds, + lookbackMinutes, + { + client: clientOverride, + maxIterations, + deps: { + autoReplyIfConfigured, + listRecentMessages, + readEnv, + createClient, + sleep, + }, + runtime: defaultRuntime, + }, ); - - const updateSince = (date?: Date | null) => { - if (!date) return; - if (date.getTime() > since.getTime()) { - since = date; - } - }; - - let keepRunning = true; - process.once("SIGINT", () => { - if (!keepRunning) return; - keepRunning = false; - console.log("\nšŸ‘‹ Stopping monitor"); - }); - - let iterations = 0; - while (keepRunning && iterations < maxIterations) { - try { - const messages = await client.messages.list({ - to: from, - dateSentAfter: since, - limit: 50, - }); - - const inboundMessages = messages - .filter((m: MessageInstance) => m.direction === "inbound") - .sort((a: MessageInstance, b: MessageInstance) => { - const da = a.dateCreated?.getTime() ?? 0; - const db = b.dateCreated?.getTime() ?? 0; - return da - db; - }); - - for (const m of inboundMessages) { - if (seen.has(m.sid)) continue; - seen.add(m.sid); - const time = m.dateCreated?.toISOString() ?? "unknown time"; - const fromNum = m.from ?? "unknown sender"; - console.log(`\n[${time}] ${fromNum} -> ${m.to}: ${m.body ?? ""}`); - updateSince(m.dateCreated); - void autoReplyIfConfigured(client, m); - } - } catch (err) { - console.error("Error while polling messages", err); - } - - await sleep(intervalSeconds * 1000); - iterations += 1; - } } async function monitorWebProvider( @@ -1359,8 +890,10 @@ async function listRecentMessages( limit: fetchLimit, }); + const inboundArr = Array.isArray(inbound) ? inbound : []; + const outboundArr = Array.isArray(outbound) ? outbound : []; const combined = uniqueBySid( - [...inbound, ...outbound].map((m) => ({ + [...inboundArr, ...outboundArr].map((m) => ({ sid: m.sid, status: m.status ?? null, direction: m.direction ?? null, @@ -1376,227 +909,6 @@ async function listRecentMessages( return sortByDateDesc(combined).slice(0, limit); } -program - .name("warelay") - .description("WhatsApp relay CLI (Twilio or WhatsApp Web session)") - .version("1.0.0"); - -program - .command("web:login") - .description("Link your personal WhatsApp via QR (web provider)") - .option("--verbose", "Verbose connection logs", false) - .action(async (opts) => { - setVerbose(Boolean(opts.verbose)); - try { - await loginWeb(Boolean(opts.verbose)); - } catch (err) { - defaultRuntime.error(danger(`Web login failed: ${String(err)}`)); - defaultRuntime.exit(1); - } - }); - -program - .command("login") - .description("Alias for web:login (personal WhatsApp Web QR link)") - .option("--verbose", "Verbose connection logs", false) - .action(async (opts) => { - setVerbose(Boolean(opts.verbose)); - try { - await loginWeb(Boolean(opts.verbose)); - } catch (err) { - defaultRuntime.error(danger(`Web login failed: ${String(err)}`)); - defaultRuntime.exit(1); - } - }); - -program - .command("send") - .description("Send a WhatsApp message") - .requiredOption( - "-t, --to ", - "Recipient number in E.164 (e.g. +15551234567)", - ) - .requiredOption("-m, --message ", "Message body") - .option("-w, --wait ", "Wait for delivery status (0 to skip)", "20") - .option("-p, --poll ", "Polling interval while waiting", "2") - .option("--provider ", "Provider: twilio | web", "twilio") - .addHelpText( - "after", - ` -Examples: - warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default) - warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget - warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`, - ) - .action(async (opts) => { - const deps = createDefaultDeps(); - try { - await sendCommand(opts, deps, defaultRuntime); - } catch (err) { - defaultRuntime.error(String(err)); - defaultRuntime.exit(1); - } - }); - -program - .command("relay") - .description("Auto-reply to inbound messages (auto-selects web or twilio)") - .option("--provider ", "auto | web | twilio", "auto") - .option("-i, --interval ", "Polling interval for twilio mode", "5") - .option( - "-l, --lookback ", - "Initial lookback window for twilio mode", - "5", - ) - .option("--verbose", "Verbose logging", false) - .addHelpText( - "after", - ` -Examples: - warelay relay # auto: web if logged-in, else twilio poll - warelay relay --provider web # force personal web session - warelay relay --provider twilio # force twilio poll - warelay relay --provider twilio --interval 2 --lookback 30 -`, - ) - .action(async (opts) => { - setVerbose(Boolean(opts.verbose)); - const providerPref = String(opts.provider ?? "auto"); - if (!["auto", "web", "twilio"].includes(providerPref)) { - defaultRuntime.error("--provider must be auto, web, or twilio"); - defaultRuntime.exit(1); - } - const intervalSeconds = Number.parseInt(opts.interval, 10); - const lookbackMinutes = Number.parseInt(opts.lookback, 10); - if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) { - defaultRuntime.error("Interval must be a positive integer"); - defaultRuntime.exit(1); - } - if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) { - defaultRuntime.error("Lookback must be >= 0 minutes"); - defaultRuntime.exit(1); - } - - const provider = await pickProvider(providerPref as Provider | "auto"); - - if (provider === "web") { - defaultRuntime.log(info("Provider: web (personal WhatsApp Web session)")); - logWebSelfId(); - try { - await monitorWebProvider(Boolean(opts.verbose)); - return; - } catch (err) { - if (providerPref === "auto") { - defaultRuntime.error( - warn("Web session unavailable; falling back to twilio."), - ); - } else { - defaultRuntime.error(danger(`Web relay failed: ${String(err)}`)); - defaultRuntime.exit(1); - } - } - } - - ensureTwilioEnv(); - logTwilioFrom(); - await monitorTwilio(intervalSeconds, lookbackMinutes); - }); - -program - .command("status") - .description("Show recent WhatsApp messages (sent and received)") - .option("-l, --limit ", "Number of messages to show", "20") - .option("-b, --lookback ", "How far back to fetch messages", "240") - .option("--json", "Output JSON instead of text", false) - .addHelpText( - "after", - ` -Examples: - warelay status # last 20 msgs in past 4h - warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m - warelay status --json --limit 50 # machine-readable output`, - ) - .action(async (opts) => { - const deps = createDefaultDeps(); - try { - await statusCommand(opts, deps, defaultRuntime); - } catch (err) { - defaultRuntime.error(String(err)); - defaultRuntime.exit(1); - } - }); - -program - .command("webhook") - .description( - "Run a local webhook server for inbound WhatsApp (works with Tailscale/port forward)", - ) - .option("-p, --port ", "Port to listen on", "42873") - .option("-r, --reply ", "Optional auto-reply text") - .option("--path ", "Webhook path", "/webhook/whatsapp") - .option("--verbose", "Log inbound and auto-replies", false) - .option("-y, --yes", "Auto-confirm prompts when possible", false) - .addHelpText( - "after", - ` -Examples: - warelay webhook # listen on 42873 - warelay webhook --port 45000 # pick a high, less-colliding port - warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file - -With Tailscale: - tailscale serve tcp 42873 127.0.0.1:42873 - (then set Twilio webhook URL to your tailnet IP:42873/webhook/whatsapp)`, - ) - // istanbul ignore next - .action(async (opts) => { - setVerbose(Boolean(opts.verbose)); - setYes(Boolean(opts.yes)); - const deps = createDefaultDeps(); - try { - const server = await webhookCommand(opts, deps, defaultRuntime); - process.on("SIGINT", () => { - server.close(() => { - console.log("\nšŸ‘‹ Webhook stopped"); - defaultRuntime.exit(0); - }); - }); - await deps.waitForever(); - } catch (err) { - defaultRuntime.error(String(err)); - defaultRuntime.exit(1); - } - }); - -program - .command("up") - .description( - "Bring up webhook + Tailscale Funnel + Twilio callback (default webhook mode)", - ) - .option("-p, --port ", "Port to listen on", "42873") - .option("--path ", "Webhook path", "/webhook/whatsapp") - .option("--verbose", "Verbose logging during setup/webhook", false) - .option("-y, --yes", "Auto-confirm prompts when possible", false) - // istanbul ignore next - .action(async (opts) => { - setVerbose(Boolean(opts.verbose)); - setYes(Boolean(opts.yes)); - const deps = createDefaultDeps(); - try { - const { server } = await upCommand(opts, deps, defaultRuntime); - process.on("SIGINT", () => { - server.close(() => { - console.log("\nšŸ‘‹ Webhook stopped"); - defaultRuntime.exit(0); - }); - }); - await deps.waitForever(); - } catch (err) { - defaultRuntime.error(String(err)); - defaultRuntime.exit(1); - } - }); - export { assertProvider, autoReplyIfConfigured, diff --git a/src/process/exec.ts b/src/process/exec.ts index afccb6b8a..675914714 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -2,12 +2,12 @@ import { execFile, spawn } from "node:child_process"; import { danger, isVerbose } from "../globals.js"; +// Simple promise-wrapped execFile with optional verbosity logging. export async function runExec( command: string, args: string[], timeoutMs = 10_000, ): Promise<{ stdout: string; stderr: string }> { - // Simple promise-wrapped execFile with optional verbosity logging. try { const { stdout, stderr } = await execFile(command, args, { timeout: timeoutMs, diff --git a/src/twilio/monitor.test.ts b/src/twilio/monitor.test.ts new file mode 100644 index 000000000..09657bbee --- /dev/null +++ b/src/twilio/monitor.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; + +import { monitorTwilio } from "./monitor.js"; + +describe("monitorTwilio", () => { + it("processes inbound messages once with injected deps", async () => { + const listRecentMessages = vi.fn().mockResolvedValue([ + { + sid: "m1", + direction: "inbound", + dateCreated: new Date(), + from: "+1", + to: "+2", + body: "hi", + errorCode: null, + errorMessage: null, + status: null, + }, + ]); + const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined); + const readEnv = vi.fn(() => ({ accountSid: "AC", whatsappFrom: "whatsapp:+1", auth: { accountSid: "AC", authToken: "t" } })); + const createClient = vi.fn(() => ({ messages: { create: vi.fn() } } as never)); + const sleep = vi.fn().mockResolvedValue(undefined); + + await monitorTwilio(0, 0, { + deps: { autoReplyIfConfigured, listRecentMessages, readEnv, createClient, sleep }, + maxIterations: 1, + }); + + expect(listRecentMessages).toHaveBeenCalledTimes(1); + expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1); + }); +}); + diff --git a/src/twilio/monitor.ts b/src/twilio/monitor.ts new file mode 100644 index 000000000..0cba236dd --- /dev/null +++ b/src/twilio/monitor.ts @@ -0,0 +1,114 @@ +import chalk from "chalk"; +import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js"; + +import { danger, info, logVerbose, warn } from "../globals.js"; +import { sleep, withWhatsAppPrefix } from "../utils.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { autoReplyIfConfigured } from "../auto-reply/reply.js"; +import { createClient } from "./client.js"; +import { readEnv } from "../env.js"; + +type MonitorDeps = { + autoReplyIfConfigured: typeof autoReplyIfConfigured; + listRecentMessages: ( + lookbackMinutes: number, + limit: number, + clientOverride?: ReturnType, + ) => Promise; + readEnv: typeof readEnv; + createClient: typeof createClient; + sleep: typeof sleep; +}; + +const DEFAULT_POLL_INTERVAL_SECONDS = 5; + +export type ListedMessage = { + sid: string; + status: string | null; + direction: string | null; + dateCreated: Date | undefined; + from?: string | null; + to?: string | null; + body?: string | null; + errorCode: number | null; + errorMessage: string | null; +}; + +type MonitorOptions = { + client?: ReturnType; + maxIterations?: number; + deps?: MonitorDeps; + runtime?: RuntimeEnv; +}; + +const defaultDeps: MonitorDeps = { + autoReplyIfConfigured, + listRecentMessages: () => Promise.resolve([]), + readEnv, + createClient, + sleep, +}; + +// Poll Twilio for inbound messages and auto-reply when configured. +export async function monitorTwilio( + pollSeconds: number, + lookbackMinutes: number, + opts?: MonitorOptions, +) { + const deps = opts?.deps ?? defaultDeps; + const runtime = opts?.runtime ?? defaultRuntime; + const maxIterations = opts?.maxIterations ?? Infinity; + + const env = deps.readEnv(runtime); + const from = withWhatsAppPrefix(env.whatsappFrom); + const client = opts?.client ?? deps.createClient(env); + console.log( + `šŸ“” Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`, + ); + + let lastSeenSid: string | undefined; + let iterations = 0; + while (iterations < maxIterations) { + const messages = + (await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? []; + const inboundOnly = messages.filter((m) => m.direction === "inbound"); + // Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports). + const newestFirst = [...inboundOnly].sort( + (a, b) => + (b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0), + ); + await handleMessages(messages, client, lastSeenSid, deps, runtime); + lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid; + iterations += 1; + if (iterations >= maxIterations) break; + await deps.sleep(Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000); + } +} + +async function handleMessages( + messages: ListedMessage[], + client: ReturnType, + lastSeenSid: string | undefined, + deps: MonitorDeps, + runtime: RuntimeEnv, +) { + for (const m of messages) { + if (!m.sid) continue; + if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen + logVerbose(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`); + if (m.direction !== "inbound") continue; + if (!m.from || !m.to) continue; + try { + await deps.autoReplyIfConfigured( + client as unknown as { + messages: { create: (opts: unknown) => Promise }; + }, + m as unknown as MessageInstance, + undefined, + runtime, + ); + } catch (err) { + runtime.error(danger(`Auto-reply failed: ${String(err)}`)); + } + } +} diff --git a/src/twilio/send.test.ts b/src/twilio/send.test.ts new file mode 100644 index 000000000..7c788945f --- /dev/null +++ b/src/twilio/send.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; + +import { waitForFinalStatus } from "./send.js"; + +describe("twilio send helpers", () => { + it("waitForFinalStatus resolves on delivered", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce({ status: "queued" }) + .mockResolvedValueOnce({ status: "delivered" }); + const client = { messages: vi.fn(() => ({ fetch })) } as never; + await waitForFinalStatus(client, "SM1", 2, 0.01, console as never); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("waitForFinalStatus exits on failure", async () => { + const fetch = vi.fn().mockResolvedValue({ status: "failed", errorMessage: "boom" }); + const client = { messages: vi.fn(() => ({ fetch })) } as never; + const runtime = { log: console.log, error: () => {}, exit: vi.fn(() => { throw new Error("exit"); }) } as never; + await expect(waitForFinalStatus(client, "SM1", 1, 0.01, runtime)).rejects.toBeInstanceOf(Error); + }); +}); + diff --git a/src/twilio/send.ts b/src/twilio/send.ts new file mode 100644 index 000000000..a40a6e374 --- /dev/null +++ b/src/twilio/send.ts @@ -0,0 +1,67 @@ +import { success } from "../globals.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { withWhatsAppPrefix, sleep } from "../utils.js"; +import { readEnv } from "../env.js"; +import { createClient } from "./client.js"; +import { logTwilioSendError } from "./utils.js"; + +const successTerminalStatuses = new Set(["delivered", "read"]); +const failureTerminalStatuses = new Set(["failed", "undelivered", "canceled"]); + +// Send outbound WhatsApp message; exit non-zero on API failure. +export async function sendMessage( + to: string, + body: string, + runtime: RuntimeEnv = defaultRuntime, +) { + const env = readEnv(runtime); + const client = createClient(env); + const from = withWhatsAppPrefix(env.whatsappFrom); + const toNumber = withWhatsAppPrefix(to); + + try { + const message = await client.messages.create({ + from, + to: toNumber, + body, + }); + + console.log( + success( + `āœ… Request accepted. Message SID: ${message.sid} -> ${toNumber}`, + ), + ); + return { client, sid: message.sid }; + } catch (err) { + logTwilioSendError(err, toNumber, runtime); + } +} + +// Poll message status until delivered/failed or timeout. +export async function waitForFinalStatus( + client: ReturnType, + sid: string, + timeoutSeconds: number, + pollSeconds: number, + runtime: RuntimeEnv = defaultRuntime, +) { + const deadline = Date.now() + timeoutSeconds * 1000; + while (Date.now() < deadline) { + const m = await client.messages(sid).fetch(); + const status = m.status ?? "unknown"; + if (successTerminalStatuses.has(status)) { + console.log(success(`āœ… Delivered (status: ${status})`)); + return; + } + if (failureTerminalStatuses.has(status)) { + runtime.error( + `āŒ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`, + ); + runtime.exit(1); + } + await sleep(pollSeconds * 1000); + } + console.log( + "ā„¹ļø Timed out waiting for final status; message may still be in flight.", + ); +} diff --git a/src/twilio/types.ts b/src/twilio/types.ts new file mode 100644 index 000000000..c11eea69c --- /dev/null +++ b/src/twilio/types.ts @@ -0,0 +1,79 @@ +export type TwilioRequestOptions = { + method: "get" | "post"; + uri: string; + params?: Record; + form?: Record; + body?: unknown; + contentType?: string; +}; + +export type TwilioSender = { sid: string; sender_id: string }; + +export type TwilioRequestResponse = { + data?: { + senders?: TwilioSender[]; + }; +}; + +export type IncomingNumber = { + sid: string; + phoneNumber: string; + smsUrl?: string; +}; + +export type TwilioChannelsSender = { + sid?: string; + senderId?: string; + sender_id?: string; + webhook?: { + callback_url?: string; + callback_method?: string; + fallback_url?: string; + fallback_method?: string; + }; +}; + +export type ChannelSenderUpdater = { + update: (params: Record) => Promise; +}; + +export type IncomingPhoneNumberUpdater = { + update: (params: Record) => Promise; +}; + +export type IncomingPhoneNumbersClient = { + list: (params: { + phoneNumber: string; + limit?: number; + }) => Promise; + get: (sid: string) => IncomingPhoneNumberUpdater; +} & ((sid: string) => IncomingPhoneNumberUpdater); + +export type TwilioSenderListClient = { + messaging: { + v2: { + channelsSenders: { + list: (params: { + channel: string; + pageSize: number; + }) => Promise; + ( + sid: string, + ): ChannelSenderUpdater & { + fetch: () => Promise; + }; + }; + }; + v1: { + services: (sid: string) => { + update: (params: Record) => Promise; + fetch: () => Promise<{ inboundRequestUrl?: string }>; + }; + }; + }; + incomingPhoneNumbers: IncomingPhoneNumbersClient; +}; + +export type TwilioRequester = { + request: (options: TwilioRequestOptions) => Promise; +}; diff --git a/src/twilio/update-webhook.test.ts b/src/twilio/update-webhook.test.ts new file mode 100644 index 000000000..06621f622 --- /dev/null +++ b/src/twilio/update-webhook.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; + +import { + findIncomingNumberSid, + findMessagingServiceSid, + setMessagingServiceWebhook, +} from "./update-webhook.js"; + +const envBackup = { ...process.env } as Record; + +describe("update-webhook helpers", () => { + beforeEach(() => { + process.env.TWILIO_ACCOUNT_SID = "AC"; + process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555"; + }); + + afterEach(() => { + Object.entries(envBackup).forEach(([k, v]) => { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + }); + }); + + it("findIncomingNumberSid returns first match", async () => { + const client = { + incomingPhoneNumbers: { + list: async () => [{ sid: "PN1", phoneNumber: "+1555" }], + }, + } as never; + const sid = await findIncomingNumberSid(client); + expect(sid).toBe("PN1"); + }); + + it("findMessagingServiceSid reads messagingServiceSid", async () => { + const client = { + incomingPhoneNumbers: { + list: async () => [{ messagingServiceSid: "MG1" }], + }, + } as never; + const sid = await findMessagingServiceSid(client); + expect(sid).toBe("MG1"); + }); + + it("setMessagingServiceWebhook updates via service helper", async () => { + const update = async (_: unknown) => {}; + const fetch = async () => ({ inboundRequestUrl: "https://cb" }); + const client = { + messaging: { + v1: { + services: () => ({ update, fetch }), + }, + }, + incomingPhoneNumbers: { + list: async () => [{ messagingServiceSid: "MG1" }], + }, + } as never; + const ok = await setMessagingServiceWebhook(client, "https://cb", "POST"); + expect(ok).toBe(true); + }); +}); diff --git a/src/twilio/update-webhook.ts b/src/twilio/update-webhook.ts new file mode 100644 index 000000000..9c144b57d --- /dev/null +++ b/src/twilio/update-webhook.ts @@ -0,0 +1,194 @@ +import { success, isVerbose, warn } from "../globals.js"; +import { readEnv } from "../env.js"; +import { normalizeE164 } from "../utils.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { createClient } from "./client.js"; +import type { TwilioSenderListClient, TwilioRequester } from "./types.js"; + +export async function findIncomingNumberSid(client: TwilioSenderListClient): Promise { + // Look up incoming phone number SID matching the configured WhatsApp number. + try { + const env = readEnv(); + const phone = env.whatsappFrom.replace("whatsapp:", ""); + const list = await client.incomingPhoneNumbers.list({ + phoneNumber: phone, + limit: 1, + }); + return list?.[0]?.sid ?? null; + } catch { + return null; + } +} + +export async function findMessagingServiceSid(client: TwilioSenderListClient): Promise { + // Attempt to locate a messaging service tied to the WA phone number (webhook fallback). + type IncomingNumberWithService = { messagingServiceSid?: string }; + try { + const env = readEnv(); + const phone = env.whatsappFrom.replace("whatsapp:", ""); + const list = await client.incomingPhoneNumbers.list({ + phoneNumber: phone, + limit: 1, + }); + const msid = + (list?.[0] as IncomingNumberWithService | undefined) + ?.messagingServiceSid ?? null; + return msid; + } catch { + return null; + } +} + +export async function setMessagingServiceWebhook( + client: TwilioSenderListClient, + url: string, + method: "POST" | "GET", +): Promise { + const msid = await findMessagingServiceSid(client); + if (!msid) return false; + try { + await client.messaging.v1.services(msid).update({ + InboundRequestUrl: url, + InboundRequestMethod: method, + }); + const fetched = await client.messaging.v1.services(msid).fetch(); + const stored = fetched?.inboundRequestUrl; + console.log( + success( + `āœ… Messaging Service webhook set to ${stored ?? url} (service ${msid})`, + ), + ); + return true; + } catch { + return false; + } +} + + +// Update sender webhook URL with layered fallbacks (channels, form, helper, phone). +export async function updateWebhook( + client: ReturnType, + senderSid: string, + url: string, + method: "POST" | "GET" = "POST", + runtime: RuntimeEnv, +) { + // Point Twilio sender webhook at the provided URL. + const requester = client as unknown as TwilioRequester; + const clientTyped = client as unknown as TwilioSenderListClient; + + // 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA + try { + await requester.request({ + method: "post", + uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`, + body: { + webhook: { + callback_url: url, + callback_method: method, + }, + }, + contentType: "application/json", + }); + const fetched = await clientTyped.messaging.v2 + .channelsSenders(senderSid) + .fetch(); + const storedUrl = + fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; + if (storedUrl) { + console.log(success(`āœ… Twilio sender webhook set to ${storedUrl}`)); + return; + } + if (isVerbose()) + console.error( + "Sender updated but webhook callback_url missing; will try fallbacks", + ); + } catch (err) { + if (isVerbose()) + console.error( + "channelsSenders request update failed, will try client helpers", + err, + ); + } + + // 1b) Form-encoded fallback for older Twilio stacks + try { + await requester.request({ + method: "post", + uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`, + form: { + "Webhook.CallbackUrl": url, + "Webhook.CallbackMethod": method, + }, + }); + const fetched = await clientTyped.messaging.v2 + .channelsSenders(senderSid) + .fetch(); + const storedUrl = + fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; + if (storedUrl) { + console.log(success(`āœ… Twilio sender webhook set to ${storedUrl}`)); + return; + } + if (isVerbose()) + console.error( + "Form update succeeded but callback_url missing; will try helper fallback", + ); + } catch (err) { + if (isVerbose()) + console.error( + "Form channelsSenders update failed, will try helper fallback", + err, + ); + } + + // 2) SDK helper fallback (if supported by this client) + try { + if (clientTyped.messaging?.v2?.channelsSenders) { + await clientTyped.messaging.v2.channelsSenders(senderSid).update({ + callbackUrl: url, + callbackMethod: method, + }); + const fetched = await clientTyped.messaging.v2 + .channelsSenders(senderSid) + .fetch(); + const storedUrl = + fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; + console.log( + success( + `āœ… Twilio sender webhook set to ${storedUrl ?? url} (helper API)`, + ), + ); + return; + } + } catch (err) { + if (isVerbose()) + console.error( + "channelsSenders helper update failed, will try phone number fallback", + err, + ); + } + + // 3) Incoming phone number fallback (works for many WA senders) + try { + const phoneSid = await findIncomingNumberSid(clientTyped); + if (phoneSid) { + await clientTyped.incomingPhoneNumbers(phoneSid).update({ + smsUrl: url, + smsMethod: method, + }); + console.log(success(`āœ… Phone webhook set to ${url} (number ${phoneSid})`)); + return; + } + } catch (err) { + if (isVerbose()) + console.error( + "Incoming phone number webhook update failed; no more fallbacks", + err, + ); + } + + runtime.error( + `āŒ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`, + ); +} diff --git a/src/twilio/webhook.ts b/src/twilio/webhook.ts new file mode 100644 index 000000000..8a0fb9285 --- /dev/null +++ b/src/twilio/webhook.ts @@ -0,0 +1,94 @@ +import express, { type Request, type Response } from "express"; +import bodyParser from "body-parser"; +import chalk from "chalk"; +import type { Server } from "http"; + +import { success, logVerbose, danger } from "../globals.js"; +import { readEnv } from "../env.js"; +import { createClient } from "./client.js"; +import { normalizePath } from "../utils.js"; +import { getReplyFromConfig } from "../auto-reply/reply.js"; +import { sendTypingIndicator } from "./typing.js"; +import { logTwilioSendError } from "./utils.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; + +/** Start the inbound webhook HTTP server and wire optional auto-replies. */ +export async function startWebhook( + port: number, + path = "/webhook/whatsapp", + autoReply: string | undefined, + verbose: boolean, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + const normalizedPath = normalizePath(path); + const env = readEnv(runtime); + const app = express(); + + // Twilio sends application/x-www-form-urlencoded payloads. + app.use(bodyParser.urlencoded({ extended: false })); + app.use((req, _res, next) => { + runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`)); + next(); + }); + + app.post(normalizedPath, async (req: Request, res: Response) => { + const { From, To, Body, MessageSid } = req.body ?? {}; + runtime.log(` +[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`); + if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`)); + + const client = createClient(env); + let replyText = autoReply; + if (!replyText) { + replyText = await getReplyFromConfig( + { Body, From, To, MessageSid }, + { + onReplyStart: () => sendTypingIndicator(client, MessageSid, runtime), + }, + ); + } + + if (replyText) { + try { + await client.messages.create({ from: To, to: From, body: replyText }); + if (verbose) runtime.log(success(`ā†©ļø Auto-replied to ${From}`)); + } catch (err) { + logTwilioSendError(err, From ?? undefined, runtime); + } + } + + // Respond 200 OK to Twilio. + res.type("text/xml").send(""); + }); + + app.use((_req, res) => { + if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`)); + res.status(404).send("warelay webhook: not found"); + }); + + // Start server and resolve once listening; reject on bind error. + return await new Promise((resolve, reject) => { + const server = app.listen(port); + + const onListening = () => { + cleanup(); + runtime.log( + `šŸ“„ Webhook listening on http://localhost:${port}${normalizedPath}`, + ); + resolve(server); + }; + + const onError = (err: NodeJS.ErrnoException) => { + cleanup(); + reject(err); + }; + + const cleanup = () => { + server.off("listening", onListening); + server.off("error", onError); + }; + + server.once("listening", onListening); + server.once("error", onError); + }); +}