refactor: modularize cli helpers
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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
21
src/cli/prompt.ts
Normal 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
8
src/cli/wait.ts
Normal 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 */
|
||||
});
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
784
src/index.ts
784
src/index.ts
@@ -1,34 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process, { stdin as input, stdout as output } from "node:process";
|
||||
import readline from "node:readline/promises";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import chalk from "chalk";
|
||||
import dotenv from "dotenv";
|
||||
import JSON5 from "json5";
|
||||
import Twilio from "twilio";
|
||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||
import type { TwilioSenderListClient, TwilioRequester } from "./twilio/types.js";
|
||||
import {
|
||||
runCommandWithTimeout,
|
||||
runExec,
|
||||
type SpawnResult,
|
||||
} from "./process/exec.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||
import type { TwilioRequester } from "./twilio/types.js";
|
||||
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
||||
import { sendTypingIndicator } from "./twilio/typing.js";
|
||||
import {
|
||||
autoReplyIfConfigured,
|
||||
getReplyFromConfig,
|
||||
} from "./auto-reply/reply.js";
|
||||
import { autoReplyIfConfigured, getReplyFromConfig } from "./auto-reply/reply.js";
|
||||
import { readEnv, ensureTwilioEnv, type EnvConfig } from "./env.js";
|
||||
import { createClient } from "./twilio/client.js";
|
||||
import { logTwilioSendError, formatTwilioError } from "./twilio/utils.js";
|
||||
import { monitorTwilio as monitorTwilioImpl } from "./twilio/monitor.js";
|
||||
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
|
||||
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
|
||||
import {
|
||||
@@ -37,18 +19,9 @@ import {
|
||||
findMessagingServiceSid as findMessagingServiceSidImpl,
|
||||
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
||||
} from "./twilio/update-webhook.js";
|
||||
import {
|
||||
listRecentMessages,
|
||||
formatMessageLine,
|
||||
uniqueBySid,
|
||||
sortByDateDesc,
|
||||
} from "./twilio/messages.js";
|
||||
import { listRecentMessages, formatMessageLine, uniqueBySid, sortByDateDesc } from "./twilio/messages.js";
|
||||
import { CLAUDE_BIN, parseClaudeJsonText } from "./auto-reply/claude.js";
|
||||
import {
|
||||
applyTemplate,
|
||||
type MsgContext,
|
||||
type TemplateContext,
|
||||
} from "./auto-reply/templating.js";
|
||||
import { applyTemplate, type MsgContext, type TemplateContext } from "./auto-reply/templating.js";
|
||||
import {
|
||||
CONFIG_PATH,
|
||||
type WarelayConfig,
|
||||
@@ -62,24 +35,6 @@ import { sendCommand } from "./commands/send.js";
|
||||
import { statusCommand } from "./commands/status.js";
|
||||
import { upCommand } from "./commands/up.js";
|
||||
import { webhookCommand } from "./commands/webhook.js";
|
||||
import {
|
||||
danger,
|
||||
info,
|
||||
isVerbose,
|
||||
isYes,
|
||||
logVerbose,
|
||||
setVerbose,
|
||||
setYes,
|
||||
success,
|
||||
warn,
|
||||
} from "./globals.js";
|
||||
import {
|
||||
loginWeb,
|
||||
monitorWebInbox,
|
||||
sendMessageWeb,
|
||||
WA_WEB_AUTH_DIR,
|
||||
webAuthExists,
|
||||
} from "./provider-web.js";
|
||||
import type { Provider } from "./utils.js";
|
||||
import {
|
||||
assertProvider,
|
||||
@@ -100,6 +55,14 @@ import {
|
||||
saveSessionStore,
|
||||
SESSION_STORE_DEFAULT,
|
||||
} from "./config/sessions.js";
|
||||
import { ensurePortAvailable, describePortOwner, PortInUseError, handlePortError } from "./infra/ports.js";
|
||||
import { ensureBinary } from "./infra/binaries.js";
|
||||
import { ensureFunnel, ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./infra/tailscale.js";
|
||||
import { promptYesNo } from "./cli/prompt.js";
|
||||
import { waitForever } from "./cli/wait.js";
|
||||
import { findWhatsappSenderSid } from "./twilio/senders.js";
|
||||
import { createDefaultDeps, logTwilioFrom, logWebSelfId, monitorTwilio } from "./cli/deps.js";
|
||||
import { monitorWebProvider } from "./provider-web.js";
|
||||
|
||||
dotenv.config({ quiet: true });
|
||||
|
||||
@@ -107,717 +70,10 @@ import { buildProgram } from "./cli/program.js";
|
||||
|
||||
const program = buildProgram();
|
||||
|
||||
type CliDeps = {
|
||||
sendMessage: typeof sendMessage;
|
||||
sendMessageWeb: typeof sendMessageWeb;
|
||||
waitForFinalStatus: typeof waitForFinalStatus;
|
||||
assertProvider: typeof assertProvider;
|
||||
createClient?: typeof createClient;
|
||||
monitorTwilio: typeof monitorTwilio;
|
||||
listRecentMessages: typeof listRecentMessages;
|
||||
ensurePortAvailable: typeof ensurePortAvailable;
|
||||
startWebhook: typeof startWebhook;
|
||||
waitForever: typeof waitForever;
|
||||
ensureBinary: typeof ensureBinary;
|
||||
ensureFunnel: typeof ensureFunnel;
|
||||
getTailnetHostname: typeof getTailnetHostname;
|
||||
readEnv: typeof readEnv;
|
||||
findWhatsappSenderSid: typeof findWhatsappSenderSid;
|
||||
updateWebhook: typeof updateWebhook;
|
||||
handlePortError: typeof handlePortError;
|
||||
monitorWebProvider: typeof monitorWebProvider;
|
||||
};
|
||||
|
||||
function createDefaultDeps(): CliDeps {
|
||||
return {
|
||||
sendMessage,
|
||||
sendMessageWeb,
|
||||
waitForFinalStatus,
|
||||
assertProvider,
|
||||
createClient,
|
||||
monitorTwilio,
|
||||
listRecentMessages,
|
||||
ensurePortAvailable,
|
||||
startWebhook,
|
||||
waitForever,
|
||||
ensureBinary,
|
||||
ensureFunnel,
|
||||
getTailnetHostname,
|
||||
readEnv,
|
||||
findWhatsappSenderSid,
|
||||
updateWebhook,
|
||||
handlePortError,
|
||||
monitorWebProvider,
|
||||
};
|
||||
}
|
||||
|
||||
class PortInUseError extends Error {
|
||||
port: number;
|
||||
|
||||
details?: string;
|
||||
|
||||
constructor(port: number, details?: string) {
|
||||
super(`Port ${port} is already in use.`);
|
||||
this.name = "PortInUseError";
|
||||
this.port = port;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
||||
return Boolean(err && typeof err === "object" && "code" in err);
|
||||
}
|
||||
|
||||
async function describePortOwner(port: number): Promise<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
14
src/infra/binaries.ts
Normal 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
107
src/infra/ports.ts
Normal 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
164
src/infra/tailscale.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
53
src/twilio/senders.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user