From a89d7319a97ffb8c61958e4c71f5e28b339fa9b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Nov 2025 03:42:12 +0100 Subject: [PATCH] refactor: modularize cli helpers --- src/cli/deps.ts | 95 ++++- src/cli/program.ts | 3 +- src/cli/prompt.ts | 21 ++ src/cli/wait.ts | 8 + src/commands/send.ts | 4 +- src/commands/status.ts | 5 +- src/commands/up.ts | 5 +- src/commands/webhook.ts | 3 +- src/index.ts | 784 +--------------------------------------- src/infra/binaries.ts | 14 + src/infra/ports.ts | 107 ++++++ src/infra/tailscale.ts | 164 +++++++++ src/provider-web.ts | 102 +++++- src/twilio/senders.ts | 53 +++ 14 files changed, 591 insertions(+), 777 deletions(-) create mode 100644 src/cli/prompt.ts create mode 100644 src/cli/wait.ts create mode 100644 src/infra/binaries.ts create mode 100644 src/infra/ports.ts create mode 100644 src/infra/tailscale.ts create mode 100644 src/twilio/senders.ts diff --git a/src/cli/deps.ts b/src/cli/deps.ts index b2aa48c02..85357351c 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -1,4 +1,93 @@ -import { createDefaultDeps } from "../index.js"; -import { logWebSelfId, logTwilioFrom, monitorTwilio } from "../index.js"; +import { ensureBinary } from "../infra/binaries.js"; +import { ensurePortAvailable, handlePortError } from "../infra/ports.js"; +import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js"; +import { waitForever } from "./wait.js"; +import { readEnv } from "../env.js"; +import { monitorTwilio as monitorTwilioImpl } from "../twilio/monitor.js"; +import { sendMessage, waitForFinalStatus } from "../twilio/send.js"; +import { sendMessageWeb, monitorWebProvider, logWebSelfId } from "../provider-web.js"; +import { assertProvider, sleep } from "../utils.js"; +import { createClient } from "../twilio/client.js"; +import { listRecentMessages } from "../twilio/messages.js"; +import { updateWebhook } from "../twilio/update-webhook.js"; +import { findWhatsappSenderSid } from "../twilio/senders.js"; +import { startWebhook } from "../twilio/webhook.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { info } from "../globals.js"; +import { autoReplyIfConfigured } from "../auto-reply/reply.js"; -export { createDefaultDeps, logWebSelfId, logTwilioFrom, monitorTwilio }; +export type CliDeps = { + sendMessage: typeof sendMessage; + sendMessageWeb: typeof sendMessageWeb; + waitForFinalStatus: typeof waitForFinalStatus; + assertProvider: typeof assertProvider; + createClient?: typeof createClient; + monitorTwilio: typeof monitorTwilio; + listRecentMessages: typeof listRecentMessages; + ensurePortAvailable: typeof ensurePortAvailable; + startWebhook: typeof import("../twilio/webhook.js").startWebhook; + waitForever: typeof waitForever; + ensureBinary: typeof ensureBinary; + ensureFunnel: typeof ensureFunnel; + getTailnetHostname: typeof getTailnetHostname; + readEnv: typeof readEnv; + findWhatsappSenderSid: typeof findWhatsappSenderSid; + updateWebhook: typeof updateWebhook; + handlePortError: typeof handlePortError; + monitorWebProvider: typeof monitorWebProvider; +}; + +export async function monitorTwilio( + intervalSeconds: number, + lookbackMinutes: number, + clientOverride?: ReturnType, + maxIterations = Infinity, +) { + // Adapter that wires default deps/runtime for the Twilio monitor loop. + return monitorTwilioImpl(intervalSeconds, lookbackMinutes, { + client: clientOverride, + maxIterations, + deps: { + autoReplyIfConfigured, + listRecentMessages, + readEnv, + createClient, + sleep, + }, + runtime: defaultRuntime, + }); +} + +export function createDefaultDeps(): CliDeps { + // Default dependency bundle used by CLI commands and tests. + return { + sendMessage, + sendMessageWeb, + waitForFinalStatus, + assertProvider, + createClient, + monitorTwilio, + listRecentMessages, + ensurePortAvailable, + startWebhook, + waitForever, + ensureBinary, + ensureFunnel, + getTailnetHostname, + readEnv, + findWhatsappSenderSid, + updateWebhook, + handlePortError, + monitorWebProvider, + }; +} + +export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) { + // Log the configured Twilio sender for clarity in CLI output. + const env = readEnv(runtime); + runtime.log( + info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`), + ); +} + +export { logWebSelfId }; diff --git a/src/cli/program.ts b/src/cli/program.ts index d1756e6be..05aec99c5 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; -import { defaultRuntime, setVerbose, setYes, danger, info, warn } from "../globals.js"; +import { setVerbose, setYes, danger, info, warn } from "../globals.js"; +import { defaultRuntime } from "../runtime.js"; import { sendCommand } from "../commands/send.js"; import { statusCommand } from "../commands/status.js"; import { upCommand } from "../commands/up.js"; diff --git a/src/cli/prompt.ts b/src/cli/prompt.ts new file mode 100644 index 000000000..4036fc41e --- /dev/null +++ b/src/cli/prompt.ts @@ -0,0 +1,21 @@ +import readline from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; + +import { isVerbose, isYes } from "../globals.js"; + +export async function promptYesNo( + question: string, + defaultYes = false, +): Promise { + // Simple Y/N prompt honoring global --yes and verbosity flags. + if (isVerbose() && isYes()) return true; // redundant guard when both flags set + if (isYes()) return true; + const rl = readline.createInterface({ input, output }); + const suffix = defaultYes ? " [Y/n] " : " [y/N] "; + const answer = (await rl.question(`${question}${suffix}`)) + .trim() + .toLowerCase(); + rl.close(); + if (!answer) return defaultYes; + return answer.startsWith("y"); +} diff --git a/src/cli/wait.ts b/src/cli/wait.ts new file mode 100644 index 000000000..9057a3d1a --- /dev/null +++ b/src/cli/wait.ts @@ -0,0 +1,8 @@ +export function waitForever() { + // Keep event loop alive via an unref'ed interval plus a pending promise. + const interval = setInterval(() => {}, 1_000_000); + interval.unref(); + return new Promise(() => { + /* never resolve */ + }); +} diff --git a/src/commands/send.ts b/src/commands/send.ts index 85ca00fee..eb9b335aa 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -1,5 +1,7 @@ import { info } from "../globals.js"; -import type { CliDeps, Provider, RuntimeEnv } from "../index.js"; +import type { CliDeps } from "../cli/deps.js"; +import type { Provider } from "../utils.js"; +import type { RuntimeEnv } from "../runtime.js"; export async function sendCommand( opts: { diff --git a/src/commands/status.ts b/src/commands/status.ts index ce747056b..39fba78bd 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,5 +1,6 @@ -import type { CliDeps, RuntimeEnv } from "../index.js"; -import { formatMessageLine } from "../index.js"; +import type { CliDeps } from "../cli/deps.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { formatMessageLine } from "../twilio/messages.js"; export async function statusCommand( opts: { limit: string; lookback: string; json?: boolean }, diff --git a/src/commands/up.ts b/src/commands/up.ts index e04d06745..165437cbe 100644 --- a/src/commands/up.ts +++ b/src/commands/up.ts @@ -1,5 +1,6 @@ -import type { CliDeps, RuntimeEnv } from "../index.js"; -import { waitForever as defaultWaitForever } from "../index.js"; +import type { CliDeps } from "../cli/deps.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { waitForever as defaultWaitForever } from "../cli/wait.js"; export async function upCommand( opts: { port: string; path: string; verbose?: boolean; yes?: boolean }, diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts index 34a346160..359f61723 100644 --- a/src/commands/webhook.ts +++ b/src/commands/webhook.ts @@ -1,4 +1,5 @@ -import type { CliDeps, RuntimeEnv } from "../index.js"; +import type { CliDeps } from "../cli/deps.js"; +import type { RuntimeEnv } from "../runtime.js"; export async function webhookCommand( opts: { diff --git a/src/index.ts b/src/index.ts index 548d5a849..a2adc6787 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,34 +1,16 @@ #!/usr/bin/env node -import crypto from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; -import process, { stdin as input, stdout as output } from "node:process"; -import readline from "node:readline/promises"; +import process from "node:process"; import { fileURLToPath } from "node:url"; -import chalk from "chalk"; import dotenv from "dotenv"; -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, - type SpawnResult, -} from "./process/exec.js"; -import { defaultRuntime, type RuntimeEnv } from "./runtime.js"; +import type { TwilioRequester } from "./twilio/types.js"; +import { runCommandWithTimeout, runExec } from "./process/exec.js"; import { sendTypingIndicator } from "./twilio/typing.js"; -import { - autoReplyIfConfigured, - getReplyFromConfig, -} from "./auto-reply/reply.js"; +import { autoReplyIfConfigured, getReplyFromConfig } from "./auto-reply/reply.js"; 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 { @@ -37,18 +19,9 @@ import { findMessagingServiceSid as findMessagingServiceSidImpl, setMessagingServiceWebhook as setMessagingServiceWebhookImpl, } from "./twilio/update-webhook.js"; -import { - listRecentMessages, - formatMessageLine, - uniqueBySid, - sortByDateDesc, -} from "./twilio/messages.js"; +import { listRecentMessages, formatMessageLine, uniqueBySid, sortByDateDesc } from "./twilio/messages.js"; import { CLAUDE_BIN, parseClaudeJsonText } from "./auto-reply/claude.js"; -import { - applyTemplate, - type MsgContext, - type TemplateContext, -} from "./auto-reply/templating.js"; +import { applyTemplate, type MsgContext, type TemplateContext } from "./auto-reply/templating.js"; import { CONFIG_PATH, type WarelayConfig, @@ -62,24 +35,6 @@ 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 { - danger, - info, - isVerbose, - isYes, - logVerbose, - setVerbose, - setYes, - success, - warn, -} from "./globals.js"; -import { - loginWeb, - monitorWebInbox, - sendMessageWeb, - WA_WEB_AUTH_DIR, - webAuthExists, -} from "./provider-web.js"; import type { Provider } from "./utils.js"; import { assertProvider, @@ -100,6 +55,14 @@ import { saveSessionStore, SESSION_STORE_DEFAULT, } from "./config/sessions.js"; +import { ensurePortAvailable, describePortOwner, PortInUseError, handlePortError } from "./infra/ports.js"; +import { ensureBinary } from "./infra/binaries.js"; +import { ensureFunnel, ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./infra/tailscale.js"; +import { promptYesNo } from "./cli/prompt.js"; +import { waitForever } from "./cli/wait.js"; +import { findWhatsappSenderSid } from "./twilio/senders.js"; +import { createDefaultDeps, logTwilioFrom, logWebSelfId, monitorTwilio } from "./cli/deps.js"; +import { monitorWebProvider } from "./provider-web.js"; dotenv.config({ quiet: true }); @@ -107,717 +70,10 @@ import { buildProgram } from "./cli/program.js"; const program = buildProgram(); -type CliDeps = { - sendMessage: typeof sendMessage; - sendMessageWeb: typeof sendMessageWeb; - waitForFinalStatus: typeof waitForFinalStatus; - assertProvider: typeof assertProvider; - createClient?: typeof createClient; - monitorTwilio: typeof monitorTwilio; - listRecentMessages: typeof listRecentMessages; - ensurePortAvailable: typeof ensurePortAvailable; - startWebhook: typeof startWebhook; - waitForever: typeof waitForever; - ensureBinary: typeof ensureBinary; - ensureFunnel: typeof ensureFunnel; - getTailnetHostname: typeof getTailnetHostname; - readEnv: typeof readEnv; - findWhatsappSenderSid: typeof findWhatsappSenderSid; - updateWebhook: typeof updateWebhook; - handlePortError: typeof handlePortError; - monitorWebProvider: typeof monitorWebProvider; -}; - -function createDefaultDeps(): CliDeps { - return { - sendMessage, - sendMessageWeb, - waitForFinalStatus, - assertProvider, - createClient, - monitorTwilio, - listRecentMessages, - ensurePortAvailable, - startWebhook, - waitForever, - ensureBinary, - ensureFunnel, - getTailnetHostname, - readEnv, - findWhatsappSenderSid, - updateWebhook, - handlePortError, - monitorWebProvider, - }; -} - -class PortInUseError extends Error { - port: number; - - details?: string; - - constructor(port: number, details?: string) { - super(`Port ${port} is already in use.`); - this.name = "PortInUseError"; - this.port = port; - this.details = details; - } -} - -function isErrno(err: unknown): err is NodeJS.ErrnoException { - return Boolean(err && typeof err === "object" && "code" in err); -} - -async function describePortOwner(port: number): Promise { - // Best-effort process info for a listening port (macOS/Linux). - try { - const { stdout } = await runExec("lsof", [ - "-i", - `tcp:${port}`, - "-sTCP:LISTEN", - "-nP", - ]); - const trimmed = stdout.trim(); - if (trimmed) return trimmed; - } catch (err) { - logVerbose(`lsof unavailable: ${String(err)}`); - } - return undefined; -} - -async function ensurePortAvailable(port: number): Promise { - // Detect EADDRINUSE early with a friendly message. - try { - await new Promise((resolve, reject) => { - const tester = net - .createServer() - .once("error", (err) => reject(err)) - .once("listening", () => { - tester.close(() => resolve()); - }) - .listen(port); - }); - } catch (err) { - if (isErrno(err) && err.code === "EADDRINUSE") { - const details = await describePortOwner(port); - throw new PortInUseError(port, details); - } - throw err; - } -} - -async function handlePortError( - err: unknown, - port: number, - context: string, - runtime: RuntimeEnv = defaultRuntime, -): Promise { - if ( - err instanceof PortInUseError || - (isErrno(err) && err.code === "EADDRINUSE") - ) { - const details = - err instanceof PortInUseError - ? err.details - : await describePortOwner(port); - runtime.error(danger(`${context} failed: port ${port} is already in use.`)); - if (details) { - runtime.error(info("Port listener details:")); - runtime.error(details); - if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) { - runtime.error( - warn( - "It looks like another warelay instance is already running. Stop it or pick a different port.", - ), - ); - } - } - runtime.error( - info( - "Resolve by stopping the process using the port or passing --port .", - ), - ); - runtime.exit(1); - } - runtime.error(danger(`${context} failed: ${String(err)}`)); - return runtime.exit(1); -} - -async function ensureBinary( - name: string, - exec: typeof runExec = runExec, - runtime: RuntimeEnv = defaultRuntime, -): Promise { - // Abort early if a required CLI tool is missing. - await exec("which", [name]).catch(() => { - runtime.error(`Missing required binary: ${name}. Please install it.`); - runtime.exit(1); - }); -} - -async function promptYesNo( - question: string, - defaultYes = false, -): Promise { - if (isVerbose() && isYes()) return true; // redundant guard when both flags set - if (isYes()) return true; - const rl = readline.createInterface({ input, output }); - const suffix = defaultYes ? " [Y/n] " : " [y/N] "; - const answer = (await rl.question(`${question}${suffix}`)) - .trim() - .toLowerCase(); - rl.close(); - if (!answer) return defaultYes; - return answer.startsWith("y"); -} - -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, - }); -} - -// 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", - autoReply: string | undefined, - verbose: boolean, - runtime: RuntimeEnv = defaultRuntime, -): Promise { - return startWebhookImpl(port, path, autoReply, verbose, runtime); -} - -function waitForever() { - // Keep event loop alive via an unref'ed interval plus a pending promise. - const interval = setInterval(() => {}, 1_000_000); - interval.unref(); - return new Promise(() => { - /* never resolve */ - }); -} - -async function getTailnetHostname(exec: typeof runExec = runExec) { - // Derive tailnet hostname (or IP fallback) from tailscale status JSON. - const { stdout } = await exec("tailscale", ["status", "--json"]); - const parsed = stdout ? (JSON.parse(stdout) as Record) : {}; - const self = - typeof parsed.Self === "object" && parsed.Self !== null - ? (parsed.Self as Record) - : undefined; - const dns = - typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined; - const ips = Array.isArray(self?.TailscaleIPs) - ? (self.TailscaleIPs as string[]) - : []; - if (dns && dns.length > 0) return dns.replace(/\.$/, ""); - if (ips.length > 0) return ips[0]; - throw new Error("Could not determine Tailscale DNS or IP"); -} - -async function ensureGoInstalled( - exec: typeof runExec = runExec, - prompt: typeof promptYesNo = promptYesNo, - runtime: RuntimeEnv = defaultRuntime, -) { - // Ensure Go toolchain is present; offer Homebrew install if missing. - const hasGo = await exec("go", ["version"]).then( - () => true, - () => false, - ); - if (hasGo) return; - const install = await prompt( - "Go is not installed. Install via Homebrew (brew install go)?", - true, - ); - if (!install) { - runtime.error("Go is required to build tailscaled from source. Aborting."); - runtime.exit(1); - } - logVerbose("Installing Go via Homebrew…"); - await exec("brew", ["install", "go"]); -} - -async function ensureTailscaledInstalled( - exec: typeof runExec = runExec, - prompt: typeof promptYesNo = promptYesNo, - runtime: RuntimeEnv = defaultRuntime, -) { - // Ensure tailscaled binary exists; install via Homebrew tailscale if missing. - const hasTailscaled = await exec("tailscaled", ["--version"]).then( - () => true, - () => false, - ); - if (hasTailscaled) return; - - const install = await prompt( - "tailscaled not found. Install via Homebrew (tailscale package)?", - true, - ); - if (!install) { - runtime.error("tailscaled is required for user-space funnel. Aborting."); - runtime.exit(1); - } - logVerbose("Installing tailscaled via Homebrew…"); - await exec("brew", ["install", "tailscale"]); -} - -async function ensureFunnel( - port: number, - exec: typeof runExec = runExec, - runtime: RuntimeEnv = defaultRuntime, - prompt: typeof promptYesNo = promptYesNo, -) { - // Ensure Funnel is enabled and publish the webhook port. - try { - const statusOut = ( - await exec("tailscale", ["funnel", "status", "--json"]) - ).stdout.trim(); - const parsed = statusOut - ? (JSON.parse(statusOut) as Record) - : {}; - if (!parsed || Object.keys(parsed).length === 0) { - runtime.error( - danger("Tailscale Funnel is not enabled on this tailnet/device."), - ); - runtime.error( - info( - "Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)", - ), - ); - runtime.error( - info( - "macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS", - ), - ); - const proceed = await prompt( - "Attempt local setup with user-space tailscaled?", - true, - ); - if (!proceed) runtime.exit(1); - await ensureGoInstalled(exec, prompt, runtime); - await ensureTailscaledInstalled(exec, prompt, runtime); - } - - logVerbose(`Enabling funnel on port ${port}…`); - const { stdout } = await exec( - "tailscale", - ["funnel", "--yes", "--bg", `${port}`], - { - maxBuffer: 200_000, - timeoutMs: 15_000, - }, - ); - if (stdout.trim()) console.log(stdout.trim()); - } catch (err) { - const errOutput = err as { stdout?: unknown; stderr?: unknown }; - const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : ""; - const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : ""; - if (stdout.includes("Funnel is not enabled")) { - console.error(danger("Funnel is not enabled on this tailnet/device.")); - const linkMatch = stdout.match(/https?:\/\/\S+/); - if (linkMatch) { - console.error(info(`Enable it here: ${linkMatch[0]}`)); - } else { - console.error( - info( - "Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)", - ), - ); - } - } - if ( - stderr.includes("client version") || - stdout.includes("client version") - ) { - console.error( - warn( - "Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.", - ), - ); - } - runtime.error( - "Failed to enable Tailscale Funnel. Is it allowed on your tailnet?", - ); - runtime.error( - info( - "Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`", - ), - ); - if (isVerbose()) { - if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`)); - if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`)); - runtime.error(err as Error); - } - runtime.exit(1); - } -} - -async function findWhatsappSenderSid( - client: ReturnType, - 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); - } -} - - - -async function setMessagingServiceWebhook( - client: TwilioSenderListClient, - url: string, - method: "POST" | "GET" = "POST", -): Promise { - return setMessagingServiceWebhookImpl(client, url, method); -} - - -async function updateWebhook( - client: ReturnType, - senderSid: string, - url: string, - method: "POST" | "GET" = "POST", - runtime: RuntimeEnv = defaultRuntime, -) { - return updateWebhookImpl(client, senderSid, url, method, runtime); -} - -function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) { - const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"]; - const missing = required.filter((k) => !process.env[k]); - const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN); - const hasKey = Boolean( - process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET, - ); - if (missing.length > 0 || (!hasToken && !hasKey)) { - runtime.error( - danger( - `Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`, - ), - ); - runtime.exit(1); - } -} - -async function pickProvider(pref: Provider | "auto"): Promise { - if (pref !== "auto") return pref; - const hasWeb = await webAuthExists(); - if (hasWeb) return "web"; - return "twilio"; -} - -function readWebSelfId() { - const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json"); - try { - if (!fs.existsSync(credsPath)) { - return { e164: null, jid: null }; - } - const raw = fs.readFileSync(credsPath, "utf-8"); - const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined; - const jid = parsed?.me?.id ?? null; - const e164 = jid ? jidToE164(jid) : null; - return { e164, jid }; - } catch { - return { e164: null, jid: null }; - } -} - -function logWebSelfId(runtime: RuntimeEnv = defaultRuntime) { - const { e164, jid } = readWebSelfId(); - const details = - e164 || jid - ? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}` - : "unknown"; - runtime.log(info(`Listening on web session: ${details}`)); -} - -function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) { - const env = readEnv(runtime); - runtime.log( - info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`), - ); -} - -async function monitorTwilio( - intervalSeconds: number, - lookbackMinutes: number, - clientOverride?: ReturnType, - maxIterations = Infinity, -) { - // 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, - }, - ); -} - -async function monitorWebProvider( - verbose: boolean, - listenerFactory = monitorWebInbox, - keepAlive = true, - replyResolver: typeof getReplyFromConfig = getReplyFromConfig, -) { - // Listen for inbound personal WhatsApp Web messages and auto-reply if configured. - const listener = await listenerFactory({ - verbose, - onMessage: async (msg) => { - const ts = msg.timestamp - ? new Date(msg.timestamp).toISOString() - : new Date().toISOString(); - console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`); - - const replyText = await replyResolver( - { - Body: msg.body, - From: msg.from, - To: msg.to, - MessageSid: msg.id, - }, - { - onReplyStart: msg.sendComposing, - }, - ); - if (!replyText) return; - try { - await msg.reply(replyText); - if (isVerbose()) { - console.log(success(`↩️ Auto-replied to ${msg.from} (web)`)); - } - } catch (err) { - console.error( - danger( - `Failed sending web auto-reply to ${msg.from}: ${String(err)}`, - ), - ); - } - }, - }); - - console.log( - info( - "📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.", - ), - ); - process.on("SIGINT", () => { - void listener.close().finally(() => { - console.log("\n👋 Web monitor stopped"); - defaultRuntime.exit(0); - }); - }); - - if (keepAlive) { - await waitForever(); - } -} - -async function performSend( - opts: { - to: string; - message: string; - wait: string; - poll: string; - provider: Provider; - }, - deps: CliDeps, - _exitFn: (code: number) => never = defaultRuntime.exit, - _runtime: RuntimeEnv = defaultRuntime, -) { - deps.assertProvider(opts.provider); - const waitSeconds = Number.parseInt(opts.wait, 10); - const pollSeconds = Number.parseInt(opts.poll, 10); - - if (Number.isNaN(waitSeconds) || waitSeconds < 0) { - throw new Error("Wait must be >= 0 seconds"); - } - if (Number.isNaN(pollSeconds) || pollSeconds <= 0) { - throw new Error("Poll must be > 0 seconds"); - } - - if (opts.provider === "web") { - if (waitSeconds !== 0) { - console.log(info("Wait/poll are Twilio-only; ignored for provider=web.")); - } - await deps.sendMessageWeb(opts.to, opts.message, { verbose: isVerbose() }); - return; - } - - const result = await deps.sendMessage(opts.to, opts.message, runtime); - if (!result) return; - if (waitSeconds === 0) return; - await deps.waitForFinalStatus( - result.client, - result.sid, - waitSeconds, - pollSeconds, - ); -} - -async function performStatus( - opts: { limit: string; lookback: string; json?: boolean }, - deps: CliDeps, - _exitFn: (code: number) => never = defaultRuntime.exit, - _runtime: RuntimeEnv = defaultRuntime, -) { - const limit = Number.parseInt(opts.limit, 10); - const lookbackMinutes = Number.parseInt(opts.lookback, 10); - if (Number.isNaN(limit) || limit <= 0 || limit > 200) { - throw new Error("limit must be between 1 and 200"); - } - if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) { - throw new Error("lookback must be > 0 minutes"); - } - - const messages = await deps.listRecentMessages(lookbackMinutes, limit); - if (opts.json) { - console.log(JSON.stringify(messages, null, 2)); - return; - } - if (messages.length === 0) { - console.log("No messages found in the requested window."); - return; - } - for (const m of messages) { - console.log(formatMessageLine(m)); - } -} - -async function performWebhookSetup( - opts: { - port: string; - path: string; - reply?: string; - verbose?: boolean; - }, - deps: CliDeps, - _exitFn: (code: number) => never = defaultRuntime.exit, - _runtime: RuntimeEnv = defaultRuntime, -) { - const port = Number.parseInt(opts.port, 10); - if (Number.isNaN(port) || port <= 0 || port >= 65536) { - throw new Error("Port must be between 1 and 65535"); - } - await deps.ensurePortAvailable(port); - - const server = await deps.startWebhook( - port, - opts.path, - opts.reply, - Boolean(opts.verbose), - ); - return server; -} - -async function performUp( - opts: { - port: string; - path: string; - verbose?: boolean; - yes?: boolean; - }, - deps: CliDeps, - _exitFn: (code: number) => never = defaultRuntime.exit, - _runtime: RuntimeEnv = defaultRuntime, -) { - const port = Number.parseInt(opts.port, 10); - if (Number.isNaN(port) || port <= 0 || port >= 65536) { - throw new Error("Port must be between 1 and 65535"); - } - - await deps.ensurePortAvailable(port); - - // Validate env and binaries - const env = deps.readEnv(runtime); - await deps.ensureBinary("tailscale", runExec, runtime); - - // Enable Funnel first so we don't keep a webhook running on failure - await deps.ensureFunnel(port, runExec, runtime, promptYesNo); - const host = await deps.getTailnetHostname(runExec); - const publicUrl = `https://${host}${opts.path}`; - console.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`); - - // Start webhook locally (after funnel success) - const server = await deps.startWebhook( - port, - opts.path, - undefined, - Boolean(opts.verbose), - ); - - // Configure Twilio sender webhook - const client = createClient(env); - const senderSid = await deps.findWhatsappSenderSid( - client, - env.whatsappFrom, - env.whatsappSenderSid, - ); - await deps.updateWebhook(client, senderSid, publicUrl, "POST", runtime); - - console.log( - "\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.", - ); - return { server, publicUrl, senderSid }; -} +// Keep aliases for backwards compatibility with prior index exports. +const startWebhook = startWebhookImpl; +const setMessagingServiceWebhook = setMessagingServiceWebhookImpl; +const updateWebhook = updateWebhookImpl; export { assertProvider, @@ -849,10 +105,6 @@ export { PortInUseError, promptYesNo, createDefaultDeps, - performSend, - performStatus, - performUp, - performWebhookSetup, readEnv, resolveStorePath, runCommandWithTimeout, diff --git a/src/infra/binaries.ts b/src/infra/binaries.ts new file mode 100644 index 000000000..73af82273 --- /dev/null +++ b/src/infra/binaries.ts @@ -0,0 +1,14 @@ +import { runExec } from "../process/exec.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; + +export async function ensureBinary( + name: string, + exec: typeof runExec = runExec, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + // Abort early if a required CLI tool is missing. + await exec("which", [name]).catch(() => { + runtime.error(`Missing required binary: ${name}. Please install it.`); + runtime.exit(1); + }); +} diff --git a/src/infra/ports.ts b/src/infra/ports.ts new file mode 100644 index 000000000..3ff4104fd --- /dev/null +++ b/src/infra/ports.ts @@ -0,0 +1,107 @@ +import net from "node:net"; + +import chalk from "chalk"; + +import { runExec } from "../process/exec.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { danger, info, isVerbose, logVerbose, warn } from "../globals.js"; + +class PortInUseError extends Error { + port: number; + details?: string; + + constructor(port: number, details?: string) { + super(`Port ${port} is already in use.`); + this.name = "PortInUseError"; + this.port = port; + this.details = details; + } +} + +function isErrno(err: unknown): err is NodeJS.ErrnoException { + return Boolean(err && typeof err === "object" && "code" in err); +} + +export async function describePortOwner(port: number): Promise { + // Best-effort process info for a listening port (macOS/Linux). + try { + const { stdout } = await runExec("lsof", [ + "-i", + `tcp:${port}`, + "-sTCP:LISTEN", + "-nP", + ]); + const trimmed = stdout.trim(); + if (trimmed) return trimmed; + } catch (err) { + logVerbose(`lsof unavailable: ${String(err)}`); + } + return undefined; +} + +export async function ensurePortAvailable(port: number): Promise { + // Detect EADDRINUSE early with a friendly message. + try { + await new Promise((resolve, reject) => { + const tester = net + .createServer() + .once("error", (err) => reject(err)) + .once("listening", () => { + tester.close(() => resolve()); + }) + .listen(port); + }); + } catch (err) { + if (isErrno(err) && err.code === "EADDRINUSE") { + const details = await describePortOwner(port); + throw new PortInUseError(port, details); + } + throw err; + } +} + +export async function handlePortError( + err: unknown, + port: number, + context: string, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + // Uniform messaging for EADDRINUSE with optional owner details. + if ( + err instanceof PortInUseError || + (isErrno(err) && err.code === "EADDRINUSE") + ) { + const details = + err instanceof PortInUseError + ? err.details + : await describePortOwner(port); + runtime.error(danger(`${context} failed: port ${port} is already in use.`)); + if (details) { + runtime.error(info("Port listener details:")); + runtime.error(details); + if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) { + runtime.error( + warn( + "It looks like another warelay instance is already running. Stop it or pick a different port.", + ), + ); + } + } + runtime.error( + info( + "Resolve by stopping the process using the port or passing --port .", + ), + ); + runtime.exit(1); + } + runtime.error(danger(`${context} failed: ${String(err)}`)); + if (isVerbose()) { + const stdout = (err as { stdout?: string })?.stdout; + const stderr = (err as { stderr?: string })?.stderr; + if (stdout?.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`)); + if (stderr?.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`)); + } + return runtime.exit(1); +} + +export { PortInUseError }; diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts new file mode 100644 index 000000000..3a44a6833 --- /dev/null +++ b/src/infra/tailscale.ts @@ -0,0 +1,164 @@ +import chalk from "chalk"; + +import { danger, info, isVerbose, logVerbose, warn } from "../globals.js"; +import { promptYesNo } from "../cli/prompt.js"; +import { runExec } from "../process/exec.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { ensureBinary } from "./binaries.js"; + +export async function getTailnetHostname(exec: typeof runExec = runExec) { + // Derive tailnet hostname (or IP fallback) from tailscale status JSON. + const { stdout } = await exec("tailscale", ["status", "--json"]); + const parsed = stdout ? (JSON.parse(stdout) as Record) : {}; + const self = + typeof parsed.Self === "object" && parsed.Self !== null + ? (parsed.Self as Record) + : undefined; + const dns = + typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined; + const ips = Array.isArray(self?.TailscaleIPs) + ? (self.TailscaleIPs as string[]) + : []; + if (dns && dns.length > 0) return dns.replace(/\.$/, ""); + if (ips.length > 0) return ips[0]; + throw new Error("Could not determine Tailscale DNS or IP"); +} + +export async function ensureGoInstalled( + exec: typeof runExec = runExec, + prompt: typeof promptYesNo = promptYesNo, + runtime: RuntimeEnv = defaultRuntime, +) { + // Ensure Go toolchain is present; offer Homebrew install if missing. + const hasGo = await exec("go", ["version"]).then( + () => true, + () => false, + ); + if (hasGo) return; + const install = await prompt( + "Go is not installed. Install via Homebrew (brew install go)?", + true, + ); + if (!install) { + runtime.error("Go is required to build tailscaled from source. Aborting."); + runtime.exit(1); + } + logVerbose("Installing Go via Homebrew…"); + await exec("brew", ["install", "go"]); +} + +export async function ensureTailscaledInstalled( + exec: typeof runExec = runExec, + prompt: typeof promptYesNo = promptYesNo, + runtime: RuntimeEnv = defaultRuntime, +) { + // Ensure tailscaled binary exists; install via Homebrew tailscale if missing. + const hasTailscaled = await exec("tailscaled", ["--version"]).then( + () => true, + () => false, + ); + if (hasTailscaled) return; + + const install = await prompt( + "tailscaled not found. Install via Homebrew (tailscale package)?", + true, + ); + if (!install) { + runtime.error("tailscaled is required for user-space funnel. Aborting."); + runtime.exit(1); + } + logVerbose("Installing tailscaled via Homebrew…"); + await exec("brew", ["install", "tailscale"]); +} + +export async function ensureFunnel( + port: number, + exec: typeof runExec = runExec, + runtime: RuntimeEnv = defaultRuntime, + prompt: typeof promptYesNo = promptYesNo, +) { + // Ensure Funnel is enabled and publish the webhook port. + try { + const statusOut = ( + await exec("tailscale", ["funnel", "status", "--json"]) + ).stdout.trim(); + const parsed = statusOut + ? (JSON.parse(statusOut) as Record) + : {}; + if (!parsed || Object.keys(parsed).length === 0) { + runtime.error( + danger("Tailscale Funnel is not enabled on this tailnet/device."), + ); + runtime.error( + info( + "Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)", + ), + ); + runtime.error( + info( + "macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS", + ), + ); + const proceed = await prompt( + "Attempt local setup with user-space tailscaled?", + true, + ); + if (!proceed) runtime.exit(1); + await ensureBinary("brew", exec, runtime); + await ensureGoInstalled(exec, prompt, runtime); + await ensureTailscaledInstalled(exec, prompt, runtime); + } + + logVerbose(`Enabling funnel on port ${port}…`); + const { stdout } = await exec( + "tailscale", + ["funnel", "--yes", "--bg", `${port}`], + { + maxBuffer: 200_000, + timeoutMs: 15_000, + }, + ); + if (stdout.trim()) console.log(stdout.trim()); + } catch (err) { + const errOutput = err as { stdout?: unknown; stderr?: unknown }; + const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : ""; + const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : ""; + if (stdout.includes("Funnel is not enabled")) { + console.error(danger("Funnel is not enabled on this tailnet/device.")); + const linkMatch = stdout.match(/https?:\/\/\S+/); + if (linkMatch) { + console.error(info(`Enable it here: ${linkMatch[0]}`)); + } else { + console.error( + info( + "Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)", + ), + ); + } + } + if ( + stderr.includes("client version") || + stdout.includes("client version") + ) { + console.error( + warn( + "Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.", + ), + ); + } + runtime.error( + "Failed to enable Tailscale Funnel. Is it allowed on your tailnet?", + ); + runtime.error( + info( + "Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`", + ), + ); + if (isVerbose()) { + if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`)); + if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`)); + runtime.error(err as Error); + } + runtime.exit(1); + } +} diff --git a/src/provider-web.ts b/src/provider-web.ts index 1bdfa77bd..0696406c2 100644 --- a/src/provider-web.ts +++ b/src/provider-web.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; import type { proto } from "@whiskeysockets/baileys"; @@ -11,8 +12,12 @@ import { } from "@whiskeysockets/baileys"; import pino from "pino"; import qrcode from "qrcode-terminal"; -import { danger, info, logVerbose, success } from "./globals.js"; +import { danger, info, isVerbose, logVerbose, success, warn } from "./globals.js"; import { ensureDir, jidToE164, toWhatsappJid } from "./utils.js"; +import type { Provider } from "./utils.js"; +import { waitForever } from "./cli/wait.js"; +import { getReplyFromConfig } from "./auto-reply/reply.js"; +import { defaultRuntime, type RuntimeEnv } from "./runtime.js"; const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials"); @@ -271,6 +276,101 @@ export async function monitorWebInbox(options: { }; } +export async function monitorWebProvider( + verbose: boolean, + listenerFactory = monitorWebInbox, + keepAlive = true, + replyResolver: typeof getReplyFromConfig = getReplyFromConfig, + runtime: RuntimeEnv = defaultRuntime, +) { + // Listen for inbound personal WhatsApp Web messages and auto-reply if configured. + const listener = await listenerFactory({ + verbose, + onMessage: async (msg) => { + const ts = msg.timestamp + ? new Date(msg.timestamp).toISOString() + : new Date().toISOString(); + console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`); + + const replyText = await replyResolver( + { + Body: msg.body, + From: msg.from, + To: msg.to, + MessageSid: msg.id, + }, + { + onReplyStart: msg.sendComposing, + }, + ); + if (!replyText) return; + try { + await msg.reply(replyText); + if (isVerbose()) { + console.log(success(`↩️ Auto-replied to ${msg.from} (web)`)); + } + } catch (err) { + console.error( + danger( + `Failed sending web auto-reply to ${msg.from}: ${String(err)}`, + ), + ); + } + }, + }); + + console.log( + info( + "📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.", + ), + ); + process.on("SIGINT", () => { + void listener.close().finally(() => { + console.log("\n👋 Web monitor stopped"); + runtime.exit(0); + }); + }); + + if (keepAlive) { + await waitForever(); + } +} + +function readWebSelfId() { + // Read the cached WhatsApp Web identity (jid + E.164) from disk if present. + const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json"); + try { + if (!fs.existsSync(credsPath)) { + return { e164: null, jid: null }; + } + const raw = fs.readFileSync(credsPath, "utf-8"); + const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined; + const jid = parsed?.me?.id ?? null; + const e164 = jid ? jidToE164(jid) : null; + return { e164, jid }; + } catch { + return { e164: null, jid: null }; + } +} + +export function logWebSelfId(runtime: RuntimeEnv = defaultRuntime) { + // Human-friendly log of the currently linked personal web session. + const { e164, jid } = readWebSelfId(); + const details = + e164 || jid + ? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}` + : "unknown"; + runtime.log(info(`Listening on web session: ${details}`)); +} + +export async function pickProvider(pref: Provider | "auto"): Promise { + // Auto-select web when logged in; otherwise fall back to twilio. + if (pref !== "auto") return pref; + const hasWeb = await webAuthExists(); + if (hasWeb) return "web"; + return "twilio"; +} + function extractText(message: proto.IMessage | undefined): string | undefined { if (!message) return undefined; if (typeof message.conversation === "string" && message.conversation.trim()) { diff --git a/src/twilio/senders.ts b/src/twilio/senders.ts new file mode 100644 index 000000000..1653a7793 --- /dev/null +++ b/src/twilio/senders.ts @@ -0,0 +1,53 @@ +import { danger, info, isVerbose, logVerbose } from "../globals.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { withWhatsAppPrefix } from "../utils.js"; +import type { TwilioSenderListClient } from "./types.js"; + +export async function findWhatsappSenderSid( + 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); + } +}