#!/usr/bin/env node import { execFile, spawn } from "node:child_process"; 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 { fileURLToPath } from "node:url"; import { promisify } from "node:util"; 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 { z } from "zod"; 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, CONFIG_DIR, jidToE164, normalizeE164, normalizePath, sleep, toWhatsappJid, withWhatsAppPrefix, } from "./utils.js"; dotenv.config({ quiet: true }); const program = new Command(); type AuthMode = | { accountSid: string; authToken: string } | { accountSid: string; apiKey: string; apiSecret: string }; 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, }; } 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; }; type EnvConfig = { accountSid: string; whatsappFrom: string; whatsappSenderSid?: string; auth: AuthMode; }; type RuntimeEnv = { log: typeof console.log; error: typeof console.error; exit: (code: number) => never; }; const defaultRuntime: RuntimeEnv = { log: console.log, error: console.error, exit: (code) => { process.exit(code); throw new Error("unreachable"); // satisfies tests when mocked }, }; const EnvSchema = z .object({ TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"), TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"), TWILIO_SENDER_SID: z.string().optional(), TWILIO_AUTH_TOKEN: z.string().optional(), TWILIO_API_KEY: z.string().optional(), TWILIO_API_SECRET: z.string().optional(), }) .superRefine((val, ctx) => { if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) { ctx.addIssue({ code: "custom", message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set", }); } if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) { ctx.addIssue({ code: "custom", message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set", }); } if ( !val.TWILIO_AUTH_TOKEN && !(val.TWILIO_API_KEY && val.TWILIO_API_SECRET) ) { ctx.addIssue({ code: "custom", message: "Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET", }); } }); function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig { // Load and validate Twilio auth + sender configuration from env. const parsed = EnvSchema.safeParse(process.env); if (!parsed.success) { runtime.error("Invalid environment configuration:"); parsed.error.issues.forEach((iss) => { runtime.error(`- ${iss.message}`); }); runtime.exit(1); } const { TWILIO_ACCOUNT_SID: accountSid, TWILIO_WHATSAPP_FROM: whatsappFrom, TWILIO_SENDER_SID: whatsappSenderSid, TWILIO_AUTH_TOKEN: authToken, TWILIO_API_KEY: apiKey, TWILIO_API_SECRET: apiSecret, } = parsed.data; let auth: AuthMode; if (apiKey && apiSecret) { auth = { accountSid, apiKey, apiSecret }; } else if (authToken) { auth = { accountSid, authToken }; } else { runtime.error("Missing Twilio auth configuration"); runtime.exit(1); throw new Error("unreachable"); } return { accountSid, whatsappFrom, whatsappSenderSid, auth, }; } const execFileAsync = promisify(execFile); type ExecResult = { stdout: string; stderr: string }; type ExecOptions = { maxBuffer?: number; timeoutMs?: number }; async function runExec( command: string, args: string[], { maxBuffer = 2_000_000, timeoutMs }: ExecOptions = {}, ): Promise { // Thin wrapper around execFile with utf8 output. if (isVerbose()) { console.log(`$ ${command} ${args.join(" ")}`); } try { const { stdout, stderr } = await execFileAsync(command, args, { maxBuffer, encoding: "utf8", timeout: timeoutMs, }); if (isVerbose()) { if (stdout.trim()) console.log(stdout.trim()); if (stderr.trim()) console.error(stderr.trim()); } return { stdout, stderr }; } catch (err) { if (isVerbose()) { console.error(danger(`Command failed: ${command} ${args.join(" ")}`)); } throw err; } } type SpawnResult = { stdout: string; stderr: string; code: number | null; signal: NodeJS.Signals | null; killed: boolean; }; async function runCommandWithTimeout( argv: string[], timeoutMs: number, ): Promise { // Spawn with inherited stdin (TTY) so tools like `claude` don't hang. return await new Promise((resolve, reject) => { const child = spawn(argv[0], argv.slice(1), { stdio: ["inherit", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; let settled = false; const timer = setTimeout(() => { child.kill("SIGKILL"); }, timeoutMs); child.stdout?.on("data", (d) => { stdout += d.toString(); }); child.stderr?.on("data", (d) => { stderr += d.toString(); }); child.on("error", (err) => { if (settled) return; settled = true; clearTimeout(timer); reject(err); }); child.on("close", (code, signal) => { if (settled) return; settled = true; clearTimeout(timer); resolve({ stdout, stderr, code, signal, killed: child.killed }); }); }); } 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"); } const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json"); type ReplyMode = "text" | "command"; type ClaudeOutputFormat = "text" | "json" | "stream-json"; type WarelayConfig = { inbound?: { allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) reply?: { mode: ReplyMode; text?: string; // for mode=text, can contain {{Body}} command?: string[]; // for mode=command, argv with templates template?: string; // prepend template string when building command/prompt timeoutSeconds?: number; // optional command timeout; defaults to 600s bodyPrefix?: string; // optional string prepended to Body before templating session?: SessionConfig; claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format }; }; }; type SessionScope = "per-sender" | "global"; type SessionConfig = { scope?: SessionScope; resetTriggers?: string[]; idleMinutes?: number; store?: string; sessionArgNew?: string[]; sessionArgResume?: string[]; sessionArgBeforeBody?: boolean; }; function loadConfig(): WarelayConfig { // Read ~/.warelay/warelay.json (JSON5) if present. try { if (!fs.existsSync(CONFIG_PATH)) return {}; const raw = fs.readFileSync(CONFIG_PATH, "utf-8"); const parsed = JSON5.parse(raw); if (typeof parsed !== "object" || parsed === null) return {}; return parsed as WarelayConfig; } catch (err) { console.error(`Failed to read config at ${CONFIG_PATH}`, err); return {}; } } type MsgContext = { Body?: string; From?: string; To?: string; MessageSid?: string; }; type GetReplyOptions = { onReplyStart?: () => Promise | void; }; 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); }); } type TemplateContext = MsgContext & { BodyStripped?: string; SessionId?: string; IsNewSession?: string; }; function extractClaudeText(payload: unknown): string | undefined { // Best-effort walker to find the primary text field in Claude JSON outputs. if (payload == null) return undefined; if (typeof payload === "string") return payload; if (Array.isArray(payload)) { for (const item of payload) { const found = extractClaudeText(item); if (found) return found; } return undefined; } if (typeof payload === "object") { const obj = payload as Record; if (typeof obj.text === "string") return obj.text; if (typeof obj.completion === "string") return obj.completion; if (typeof obj.output === "string") return obj.output; if (obj.message) { const inner = extractClaudeText(obj.message); if (inner) return inner; } if (Array.isArray(obj.messages)) { const inner = extractClaudeText(obj.messages); if (inner) return inner; } if (Array.isArray(obj.content)) { for (const block of obj.content) { if ( block && typeof block === "object" && (block as { type?: string }).type === "text" && typeof (block as { text?: unknown }).text === "string" ) { return (block as { text: string }).text; } const inner = extractClaudeText(block); if (inner) return inner; } } } return undefined; } function parseClaudeJsonText(raw: string): string | undefined { // 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 { const parsed = JSON.parse(candidate); const text = extractClaudeText(parsed); if (text) return text; } catch { // ignore parse errors; try next candidate } } return undefined; } type SessionEntry = { sessionId: string; updatedAt: number }; const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json"); const DEFAULT_RESET_TRIGGER = "/new"; const DEFAULT_IDLE_MINUTES = 60; const CLAUDE_BIN = "claude"; function resolveStorePath(store?: string) { if (!store) return SESSION_STORE_DEFAULT; if (store.startsWith("~")) return path.resolve(store.replace("~", os.homedir())); return path.resolve(store); } function loadSessionStore(storePath: string): Record { try { const raw = fs.readFileSync(storePath, "utf-8"); const parsed = JSON5.parse(raw); if (parsed && typeof parsed === "object") { return parsed as Record; } } catch { // ignore missing/invalid store; we'll recreate it } return {}; } async function saveSessionStore( storePath: string, store: Record, ) { await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); await fs.promises.writeFile( storePath, JSON.stringify(store, null, 2), "utf-8", ); } function deriveSessionKey(scope: SessionScope, ctx: MsgContext) { if (scope === "global") return "global"; const from = ctx.From ? normalizeE164(ctx.From) : ""; return from || "unknown"; } async function getReplyFromConfig( ctx: MsgContext, opts?: GetReplyOptions, configOverride?: WarelayConfig, commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout, ): Promise { // Choose reply from config: static text or external command stdout. const cfg = configOverride ?? loadConfig(); const reply = cfg.inbound?.reply; const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1); const timeoutMs = timeoutSeconds * 1000; let started = false; const onReplyStart = async () => { if (started) return; started = true; await opts?.onReplyStart?.(); }; // Optional session handling (conversation reuse + /new resets) const sessionCfg = reply?.session; const resetTriggers = sessionCfg?.resetTriggers?.length ? sessionCfg.resetTriggers : [DEFAULT_RESET_TRIGGER]; const idleMinutes = Math.max( sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1, ); const sessionScope = sessionCfg?.scope ?? "per-sender"; const storePath = resolveStorePath(sessionCfg?.store); let sessionId: string | undefined; let isNewSession = false; let bodyStripped: string | undefined; if (sessionCfg) { const trimmedBody = (ctx.Body ?? "").trim(); for (const trigger of resetTriggers) { if (!trigger) continue; if (trimmedBody === trigger) { isNewSession = true; bodyStripped = ""; break; } const triggerPrefix = `${trigger} `; if (trimmedBody.startsWith(triggerPrefix)) { isNewSession = true; bodyStripped = trimmedBody.slice(trigger.length).trimStart(); break; } } const sessionKey = deriveSessionKey(sessionScope, ctx); const store = loadSessionStore(storePath); const entry = store[sessionKey]; const idleMs = idleMinutes * 60_000; const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs; if (!isNewSession && freshEntry) { sessionId = entry.sessionId; } else { sessionId = crypto.randomUUID(); isNewSession = true; } store[sessionKey] = { sessionId, updatedAt: Date.now() }; await saveSessionStore(storePath, store); } const sessionCtx: TemplateContext = { ...ctx, BodyStripped: bodyStripped ?? ctx.Body, SessionId: sessionId, IsNewSession: isNewSession ? "true" : "false", }; // Optional prefix injected before Body for templating/command prompts. const bodyPrefix = reply?.bodyPrefix ? applyTemplate(reply.bodyPrefix, sessionCtx) : ""; const prefixedBody = bodyPrefix ? `${bodyPrefix}${sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""}` : (sessionCtx.BodyStripped ?? sessionCtx.Body); const templatingCtx: TemplateContext = { ...sessionCtx, Body: prefixedBody, BodyStripped: prefixedBody, }; // Optional allowlist by origin number (E.164 without whatsapp: prefix) const allowFrom = cfg.inbound?.allowFrom; if (Array.isArray(allowFrom) && allowFrom.length > 0) { const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); if (!allowFrom.includes(from)) { logVerbose( `Skipping auto-reply: sender ${from || ""} not in allowFrom list`, ); return undefined; } } if (!reply) { logVerbose("No inbound.reply configured; skipping auto-reply"); return undefined; } if (reply.mode === "text" && reply.text) { await onReplyStart(); logVerbose("Using text auto-reply from config"); return applyTemplate(reply.text, templatingCtx); } if (reply.mode === "command" && reply.command?.length) { await onReplyStart(); let argv = reply.command.map((part) => applyTemplate(part, templatingCtx)); const templatePrefix = reply.template ? applyTemplate(reply.template, templatingCtx) : ""; if (templatePrefix && argv.length > 0) { argv = [argv[0], templatePrefix, ...argv.slice(1)]; } // Ensure Claude commands can emit plain text by forcing --output-format when configured. // We inject the flags only when the user points at the `claude` binary and has opted in via config, // so existing custom argv or non-Claude commands remain untouched. if ( reply.claudeOutputFormat && argv.length > 0 && path.basename(argv[0]) === CLAUDE_BIN ) { const hasOutputFormat = argv.some( (part) => part === "--output-format" || part.startsWith("--output-format="), ); // Keep the final argument as the prompt/body; insert options just before it. const insertBeforeBody = Math.max(argv.length - 1, 0); if (!hasOutputFormat) { argv = [ ...argv.slice(0, insertBeforeBody), "--output-format", reply.claudeOutputFormat, ...argv.slice(insertBeforeBody), ]; } const hasPrintFlag = argv.some( (part) => part === "-p" || part === "--print", ); if (!hasPrintFlag) { const insertIdx = Math.max(argv.length - 1, 0); argv = [...argv.slice(0, insertIdx), "-p", ...argv.slice(insertIdx)]; } } // Inject session args if configured (use resume for existing, session-id for new) if (reply.session) { const sessionArgList = ( isNewSession ? (reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"]) : (reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"]) ).map((part) => applyTemplate(part, templatingCtx)); if (sessionArgList.length) { const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true; const insertAt = insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length; argv = [ ...argv.slice(0, insertAt), ...sessionArgList, ...argv.slice(insertAt), ]; } } const finalArgv = argv; logVerbose(`Running command auto-reply: ${finalArgv.join(" ")}`); const started = Date.now(); try { const { stdout, stderr, code, signal, killed } = await commandRunner( finalArgv, timeoutMs, ); let trimmed = stdout.trim(); if (stderr?.trim()) { logVerbose(`Command auto-reply stderr: ${stderr.trim()}`); } if (reply.claudeOutputFormat === "json" && trimmed) { // Claude JSON mode: extract the human text for both logging and reply. const extracted = parseClaudeJsonText(trimmed); if (extracted) { logVerbose( `Claude JSON parsed -> ${extracted.slice(0, 120)}${extracted.length > 120 ? "…" : ""}`, ); trimmed = extracted.trim(); } else { logVerbose("Claude JSON parse failed; returning raw stdout"); } } logVerbose( `Command auto-reply stdout (trimmed): ${trimmed || ""}`, ); logVerbose(`Command auto-reply finished in ${Date.now() - started}ms`); if ((code ?? 0) !== 0) { console.error( `Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`, ); return undefined; } if (killed && !signal) { console.error( `Command auto-reply process killed before completion (exit code ${code ?? "unknown"})`, ); return undefined; } return trimmed || undefined; } catch (err) { const elapsed = Date.now() - started; const anyErr = err as { killed?: boolean; signal?: string }; const timeoutHit = anyErr.killed === true || anyErr.signal === "SIGKILL"; const errorObj = err as { stdout?: string; stderr?: string; }; if (errorObj.stderr?.trim()) { logVerbose(`Command auto-reply stderr: ${errorObj.stderr.trim()}`); } if (timeoutHit) { console.error( `Command auto-reply timed out after ${elapsed}ms (limit ${timeoutMs}ms)`, ); } else { console.error(`Command auto-reply failed after ${elapsed}ms`, err); } return undefined; } } return undefined; } async function autoReplyIfConfigured( client: ReturnType, message: MessageInstance, configOverride?: WarelayConfig, runtime: RuntimeEnv = defaultRuntime, ): Promise { // Fire a config-driven reply (text or command) for the inbound message, if configured. const ctx: MsgContext = { Body: message.body ?? undefined, From: message.from ?? undefined, To: message.to ?? undefined, MessageSid: message.sid, }; const replyText = await getReplyFromConfig( ctx, { onReplyStart: () => sendTypingIndicator(client, message.sid, runtime), }, configOverride, ); if (!replyText) return; const replyFrom = message.to; const replyTo = message.from; if (!replyFrom || !replyTo) { if (isVerbose()) console.error( "Skipping auto-reply: missing to/from on inbound message", ctx, ); return; } logVerbose( `Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyText.length}`, ); try { await client.messages.create({ from: replyFrom, to: replyTo, body: replyText, }); if (isVerbose()) { console.log( success( `↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"})`, ), ); } } catch (err) { logTwilioSendError(err, replyTo ?? undefined, runtime); } } 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, }); } async function sendTypingIndicator( client: ReturnType, messageSid?: string, runtime: RuntimeEnv = defaultRuntime, ) { // Best-effort WhatsApp typing indicator (public beta as of Nov 2025). if (!messageSid) { logVerbose("Skipping typing indicator: missing MessageSid"); return; } try { const requester = client as unknown as TwilioRequester; await requester.request({ method: "post", uri: "https://messaging.twilio.com/v2/Indicators/Typing.json", form: { messageId: messageSid, channel: "whatsapp", }, }); logVerbose(`Sent typing indicator for inbound ${messageSid}`); } catch (err) { if (isVerbose()) { runtime.error(warn("Typing indicator failed (continuing without it)")); runtime.error(err as Error); } } } 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.", ); } async function startWebhook( port: number, path = "/webhook/whatsapp", autoReply: string | undefined, 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); }); } 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 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", ): 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; } } async function updateWebhook( client: ReturnType, senderSid: string, url: string, 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); } type TwilioApiError = { code?: number | string; status?: number | string; message?: string; moreInfo?: string; response?: { body?: unknown }; }; function formatTwilioError(err: unknown): string { const e = err as TwilioApiError; const pieces = []; if (e.code != null) pieces.push(`code ${e.code}`); if (e.status != null) pieces.push(`status ${e.status}`); if (e.message) pieces.push(e.message); if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`); return pieces.length ? pieces.join(" | ") : String(err); } function logTwilioSendError( err: unknown, destination?: string, runtime: RuntimeEnv = defaultRuntime, ) { const prefix = destination ? `to ${destination}: ` : ""; runtime.error( danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`), ); const body = (err as TwilioApiError)?.response?.body; if (body) { runtime.error(info("Response body:"), JSON.stringify(body, null, 2)); } } 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, ) { // 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)`, ); 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( 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 }; } type ListedMessage = { sid: string; status: string | null; direction: string | null; dateCreated?: Date | null; from?: string | null; to?: string | null; body?: string | null; errorCode?: number | null; errorMessage?: string | null; }; function uniqueBySid(messages: ListedMessage[]): ListedMessage[] { const seen = new Set(); const deduped: ListedMessage[] = []; for (const m of messages) { if (seen.has(m.sid)) continue; seen.add(m.sid); deduped.push(m); } return deduped; } function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] { return [...messages].sort((a, b) => { const da = a.dateCreated?.getTime() ?? 0; const db = b.dateCreated?.getTime() ?? 0; return db - da; }); } function formatMessageLine(m: ListedMessage): string { const ts = m.dateCreated?.toISOString() ?? "unknown-time"; const dir = m.direction === "inbound" ? "⬅️ " : m.direction === "outbound-api" || m.direction === "outbound-reply" ? "➡️ " : "↔️ "; const status = m.status ?? "unknown"; const err = m.errorCode != null ? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}` : ""; const body = (m.body ?? "").replace(/\s+/g, " ").trim(); const bodyPreview = body.length > 140 ? `${body.slice(0, 137)}…` : body || ""; return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`; } async function listRecentMessages( lookbackMinutes: number, limit: number, clientOverride?: ReturnType, ): Promise { const env = readEnv(); const client = clientOverride ?? createClient(env); const from = withWhatsAppPrefix(env.whatsappFrom); const since = new Date(Date.now() - lookbackMinutes * 60_000); // Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit. const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100); const inbound = await client.messages.list({ to: from, dateSentAfter: since, limit: fetchLimit, }); const outbound = await client.messages.list({ from, dateSentAfter: since, limit: fetchLimit, }); const combined = uniqueBySid( [...inbound, ...outbound].map((m) => ({ sid: m.sid, status: m.status ?? null, direction: m.direction ?? null, dateCreated: m.dateCreated, from: m.from, to: m.to, body: m.body, errorCode: m.errorCode ?? null, errorMessage: m.errorMessage ?? null, })), ); return sortByDateDesc(combined).slice(0, limit); } 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, applyTemplate, createClient, deriveSessionKey, describePortOwner, ensureBinary, ensureFunnel, ensureGoInstalled, ensurePortAvailable, ensureTailscaledInstalled, findIncomingNumberSid, findMessagingServiceSid, findWhatsappSenderSid, formatMessageLine, formatTwilioError, getReplyFromConfig, getTailnetHostname, handlePortError, logTwilioSendError, listRecentMessages, loadConfig, loadSessionStore, monitorTwilio, monitorWebProvider, normalizeE164, PortInUseError, promptYesNo, createDefaultDeps, performSend, performStatus, performUp, performWebhookSetup, readEnv, resolveStorePath, runCommandWithTimeout, runExec, saveSessionStore, sendMessage, sendTypingIndicator, setMessagingServiceWebhook, sortByDateDesc, startWebhook, updateWebhook, uniqueBySid, waitForFinalStatus, waitForever, toWhatsappJid, program, }; const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isMain) { program.parseAsync(process.argv); }