refactor: modularize cli helpers

This commit is contained in:
Peter Steinberger
2025-11-25 03:42:12 +01:00
parent 5c5a103abb
commit a89d7319a9
14 changed files with 591 additions and 777 deletions

View File

@@ -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<typeof createClient>,
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 };

View File

@@ -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";

21
src/cli/prompt.ts Normal file
View File

@@ -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<boolean> {
// 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");
}

8
src/cli/wait.ts Normal file
View File

@@ -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<void>(() => {
/* never resolve */
});
}

View File

@@ -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: {

View File

@@ -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 },

View File

@@ -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 },

View File

@@ -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: {

View File

@@ -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<string | undefined> {
// 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<void> {
// Detect EADDRINUSE early with a friendly message.
try {
await new Promise<void>((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<never> {
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 <free-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<void> {
// 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<boolean> {
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<import("http").Server> {
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<void>(() => {
/* 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<string, unknown>) : {};
const self =
typeof parsed.Self === "object" && parsed.Self !== null
? (parsed.Self as Record<string, unknown>)
: 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<string, unknown>)
: {};
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<typeof createClient>,
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<boolean> {
return setMessagingServiceWebhookImpl(client, url, method);
}
async function updateWebhook(
client: ReturnType<typeof createClient>,
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<Provider> {
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<typeof createClient>,
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,

14
src/infra/binaries.ts Normal file
View File

@@ -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<void> {
// 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);
});
}

107
src/infra/ports.ts Normal file
View File

@@ -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<string | undefined> {
// 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<void> {
// Detect EADDRINUSE early with a friendly message.
try {
await new Promise<void>((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<never> {
// 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 <free-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 };

164
src/infra/tailscale.ts Normal file
View File

@@ -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<string, unknown>) : {};
const self =
typeof parsed.Self === "object" && parsed.Self !== null
? (parsed.Self as Record<string, unknown>)
: 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<string, unknown>)
: {};
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);
}
}

View File

@@ -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<Provider> {
// 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()) {

53
src/twilio/senders.ts Normal file
View File

@@ -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);
}
}