chore: format to 2-space and bump changelog

This commit is contained in:
Peter Steinberger 2025-11-26 00:53:53 +01:00
parent a67f4db5e2
commit e5f677803f
81 changed files with 7086 additions and 6999 deletions

View File

@ -1,6 +1,6 @@
# Changelog # Changelog
## [Unreleased] 1.0.5 ## 1.1.0 — 2025-11-25
### Pending ### Pending
- Web auto-replies now resize/recompress media and honor `inbound.reply.mediaMaxMb` in `~/.warelay/warelay.json` (default 5MB) to avoid provider/API limits. - Web auto-replies now resize/recompress media and honor `inbound.reply.mediaMaxMb` in `~/.warelay/warelay.json` (default 5MB) to avoid provider/API limits.

View File

@ -2,7 +2,8 @@
"$schema": "https://biomejs.dev/schemas/biome.json", "$schema": "https://biomejs.dev/schemas/biome.json",
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentWidth": 2 "indentWidth": 2,
"indentStyle": "space"
}, },
"linter": { "linter": {
"enabled": true, "enabled": true,

View File

@ -3,37 +3,37 @@ import { describe, expect, it } from "vitest";
import { parseClaudeJson, parseClaudeJsonText } from "./claude.js"; import { parseClaudeJson, parseClaudeJsonText } from "./claude.js";
describe("claude JSON parsing", () => { describe("claude JSON parsing", () => {
it("extracts text from single JSON object", () => { it("extracts text from single JSON object", () => {
const out = parseClaudeJsonText('{"text":"hello"}'); const out = parseClaudeJsonText('{"text":"hello"}');
expect(out).toBe("hello"); expect(out).toBe("hello");
}); });
it("extracts from newline-delimited JSON", () => { it("extracts from newline-delimited JSON", () => {
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}'); const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
expect(out).toBe("there"); expect(out).toBe("there");
}); });
it("returns undefined on invalid JSON", () => { it("returns undefined on invalid JSON", () => {
expect(parseClaudeJsonText("not json")).toBeUndefined(); expect(parseClaudeJsonText("not json")).toBeUndefined();
}); });
it("extracts text from Claude CLI result field and preserves metadata", () => { it("extracts text from Claude CLI result field and preserves metadata", () => {
const sample = { const sample = {
type: "result", type: "result",
subtype: "success", subtype: "success",
result: "hello from result field", result: "hello from result field",
duration_ms: 1234, duration_ms: 1234,
usage: { server_tool_use: { tool_a: 2 } }, usage: { server_tool_use: { tool_a: 2 } },
}; };
const parsed = parseClaudeJson(JSON.stringify(sample)); const parsed = parseClaudeJson(JSON.stringify(sample));
expect(parsed?.text).toBe("hello from result field"); expect(parsed?.text).toBe("hello from result field");
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 }); expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
expect(parsed?.valid).toBe(true); expect(parsed?.valid).toBe(true);
}); });
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => { it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
const parsed = parseClaudeJson('{"unexpected":1}'); const parsed = parseClaudeJson('{"unexpected":1}');
expect(parsed?.valid).toBe(false); expect(parsed?.valid).toBe(false);
expect(parsed?.text).toBeUndefined(); expect(parsed?.text).toBeUndefined();
}); });
}); });

View File

@ -4,159 +4,159 @@ import { z } from "zod";
// Preferred binary name for Claude CLI invocations. // Preferred binary name for Claude CLI invocations.
export const CLAUDE_BIN = "claude"; export const CLAUDE_BIN = "claude";
export const CLAUDE_IDENTITY_PREFIX = export const CLAUDE_IDENTITY_PREFIX =
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present."; "You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present.";
function extractClaudeText(payload: unknown): string | undefined { function extractClaudeText(payload: unknown): string | undefined {
// Best-effort walker to find the primary text field in Claude JSON outputs. // Best-effort walker to find the primary text field in Claude JSON outputs.
if (payload == null) return undefined; if (payload == null) return undefined;
if (typeof payload === "string") return payload; if (typeof payload === "string") return payload;
if (Array.isArray(payload)) { if (Array.isArray(payload)) {
for (const item of payload) { for (const item of payload) {
const found = extractClaudeText(item); const found = extractClaudeText(item);
if (found) return found; if (found) return found;
} }
return undefined; return undefined;
} }
if (typeof payload === "object") { if (typeof payload === "object") {
const obj = payload as Record<string, unknown>; const obj = payload as Record<string, unknown>;
if (typeof obj.result === "string") return obj.result; if (typeof obj.result === "string") return obj.result;
if (typeof obj.text === "string") return obj.text; if (typeof obj.text === "string") return obj.text;
if (typeof obj.completion === "string") return obj.completion; if (typeof obj.completion === "string") return obj.completion;
if (typeof obj.output === "string") return obj.output; if (typeof obj.output === "string") return obj.output;
if (obj.message) { if (obj.message) {
const inner = extractClaudeText(obj.message); const inner = extractClaudeText(obj.message);
if (inner) return inner; if (inner) return inner;
} }
if (Array.isArray(obj.messages)) { if (Array.isArray(obj.messages)) {
const inner = extractClaudeText(obj.messages); const inner = extractClaudeText(obj.messages);
if (inner) return inner; if (inner) return inner;
} }
if (Array.isArray(obj.content)) { if (Array.isArray(obj.content)) {
for (const block of obj.content) { for (const block of obj.content) {
if ( if (
block && block &&
typeof block === "object" && typeof block === "object" &&
(block as { type?: string }).type === "text" && (block as { type?: string }).type === "text" &&
typeof (block as { text?: unknown }).text === "string" typeof (block as { text?: unknown }).text === "string"
) { ) {
return (block as { text: string }).text; return (block as { text: string }).text;
} }
const inner = extractClaudeText(block); const inner = extractClaudeText(block);
if (inner) return inner; if (inner) return inner;
} }
} }
} }
return undefined; return undefined;
} }
export type ClaudeJsonParseResult = { export type ClaudeJsonParseResult = {
text?: string; text?: string;
parsed: unknown; parsed: unknown;
valid: boolean; valid: boolean;
}; };
const ClaudeJsonSchema = z const ClaudeJsonSchema = z
.object({ .object({
type: z.string().optional(), type: z.string().optional(),
subtype: z.string().optional(), subtype: z.string().optional(),
is_error: z.boolean().optional(), is_error: z.boolean().optional(),
result: z.string().optional(), result: z.string().optional(),
text: z.string().optional(), text: z.string().optional(),
completion: z.string().optional(), completion: z.string().optional(),
output: z.string().optional(), output: z.string().optional(),
message: z.any().optional(), message: z.any().optional(),
messages: z.any().optional(), messages: z.any().optional(),
content: z.any().optional(), content: z.any().optional(),
duration_ms: z.number().optional(), duration_ms: z.number().optional(),
duration_api_ms: z.number().optional(), duration_api_ms: z.number().optional(),
num_turns: z.number().optional(), num_turns: z.number().optional(),
session_id: z.string().optional(), session_id: z.string().optional(),
total_cost_usd: z.number().optional(), total_cost_usd: z.number().optional(),
usage: z.record(z.string(), z.any()).optional(), usage: z.record(z.string(), z.any()).optional(),
modelUsage: z.record(z.string(), z.any()).optional(), modelUsage: z.record(z.string(), z.any()).optional(),
}) })
.passthrough() .passthrough()
.refine( .refine(
(obj) => (obj) =>
typeof obj.result === "string" || typeof obj.result === "string" ||
typeof obj.text === "string" || typeof obj.text === "string" ||
typeof obj.completion === "string" || typeof obj.completion === "string" ||
typeof obj.output === "string" || typeof obj.output === "string" ||
obj.message !== undefined || obj.message !== undefined ||
obj.messages !== undefined || obj.messages !== undefined ||
obj.content !== undefined, obj.content !== undefined,
{ message: "Not a Claude JSON payload" }, { message: "Not a Claude JSON payload" },
); );
type ClaudeSafeParse = ReturnType<typeof ClaudeJsonSchema.safeParse>; type ClaudeSafeParse = ReturnType<typeof ClaudeJsonSchema.safeParse>;
export function parseClaudeJson( export function parseClaudeJson(
raw: string, raw: string,
): ClaudeJsonParseResult | undefined { ): ClaudeJsonParseResult | undefined {
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload. // Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
let firstParsed: unknown; let firstParsed: unknown;
const candidates = [ const candidates = [
raw, raw,
...raw ...raw
.split(/\n+/) .split(/\n+/)
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean), .filter(Boolean),
]; ];
for (const candidate of candidates) { for (const candidate of candidates) {
try { try {
const parsed = JSON.parse(candidate); const parsed = JSON.parse(candidate);
if (firstParsed === undefined) firstParsed = parsed; if (firstParsed === undefined) firstParsed = parsed;
let validation: ClaudeSafeParse | { success: false }; let validation: ClaudeSafeParse | { success: false };
try { try {
validation = ClaudeJsonSchema.safeParse(parsed); validation = ClaudeJsonSchema.safeParse(parsed);
} catch { } catch {
validation = { success: false } as const; validation = { success: false } as const;
} }
const validated = validation.success ? validation.data : parsed; const validated = validation.success ? validation.data : parsed;
const isLikelyClaude = const isLikelyClaude =
typeof validated === "object" && typeof validated === "object" &&
validated !== null && validated !== null &&
("result" in validated || ("result" in validated ||
"text" in validated || "text" in validated ||
"completion" in validated || "completion" in validated ||
"output" in validated); "output" in validated);
const text = extractClaudeText(validated); const text = extractClaudeText(validated);
if (text) if (text)
return { return {
parsed: validated, parsed: validated,
text, text,
// Treat parse as valid when schema passes or we still see Claude-like shape. // Treat parse as valid when schema passes or we still see Claude-like shape.
valid: Boolean(validation?.success || isLikelyClaude), valid: Boolean(validation?.success || isLikelyClaude),
}; };
} catch { } catch {
// ignore parse errors; try next candidate // ignore parse errors; try next candidate
} }
} }
if (firstParsed !== undefined) { if (firstParsed !== undefined) {
let validation: ClaudeSafeParse | { success: false }; let validation: ClaudeSafeParse | { success: false };
try { try {
validation = ClaudeJsonSchema.safeParse(firstParsed); validation = ClaudeJsonSchema.safeParse(firstParsed);
} catch { } catch {
validation = { success: false } as const; validation = { success: false } as const;
} }
const validated = validation.success ? validation.data : firstParsed; const validated = validation.success ? validation.data : firstParsed;
const isLikelyClaude = const isLikelyClaude =
typeof validated === "object" && typeof validated === "object" &&
validated !== null && validated !== null &&
("result" in validated || ("result" in validated ||
"text" in validated || "text" in validated ||
"completion" in validated || "completion" in validated ||
"output" in validated); "output" in validated);
return { return {
parsed: validated, parsed: validated,
text: extractClaudeText(validated), text: extractClaudeText(validated),
valid: Boolean(validation?.success || isLikelyClaude), valid: Boolean(validation?.success || isLikelyClaude),
}; };
} }
return undefined; return undefined;
} }
export function parseClaudeJsonText(raw: string): string | undefined { export function parseClaudeJsonText(raw: string): string | undefined {
const parsed = parseClaudeJson(raw); const parsed = parseClaudeJson(raw);
return parsed?.text; return parsed?.text;
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,24 @@
export type MsgContext = { export type MsgContext = {
Body?: string; Body?: string;
From?: string; From?: string;
To?: string; To?: string;
MessageSid?: string; MessageSid?: string;
MediaPath?: string; MediaPath?: string;
MediaUrl?: string; MediaUrl?: string;
MediaType?: string; MediaType?: string;
Transcript?: string; Transcript?: string;
}; };
export type TemplateContext = MsgContext & { export type TemplateContext = MsgContext & {
BodyStripped?: string; BodyStripped?: string;
SessionId?: string; SessionId?: string;
IsNewSession?: string; IsNewSession?: string;
}; };
// Simple {{Placeholder}} interpolation using inbound message context. // Simple {{Placeholder}} interpolation using inbound message context.
export function applyTemplate(str: string, ctx: TemplateContext) { export function applyTemplate(str: string, ctx: TemplateContext) {
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => { return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
const value = (ctx as Record<string, unknown>)[key]; const value = (ctx as Record<string, unknown>)[key];
return value == null ? "" : String(value); return value == null ? "" : String(value);
}); });
} }

View File

@ -6,9 +6,9 @@ import { ensurePortAvailable, handlePortError } from "../infra/ports.js";
import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js"; import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js";
import { ensureMediaHosted } from "../media/host.js"; import { ensureMediaHosted } from "../media/host.js";
import { import {
logWebSelfId, logWebSelfId,
monitorWebProvider, monitorWebProvider,
sendMessageWeb, sendMessageWeb,
} from "../providers/web/index.js"; } from "../providers/web/index.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { createClient } from "../twilio/client.js"; import { createClient } from "../twilio/client.js";
@ -22,89 +22,89 @@ import { updateWebhook } from "../webhook/update.js";
import { waitForever } from "./wait.js"; import { waitForever } from "./wait.js";
export type CliDeps = { export type CliDeps = {
sendMessage: typeof sendMessage; sendMessage: typeof sendMessage;
sendMessageWeb: typeof sendMessageWeb; sendMessageWeb: typeof sendMessageWeb;
waitForFinalStatus: typeof waitForFinalStatus; waitForFinalStatus: typeof waitForFinalStatus;
assertProvider: typeof assertProvider; assertProvider: typeof assertProvider;
createClient?: typeof createClient; createClient?: typeof createClient;
monitorTwilio: typeof monitorTwilio; monitorTwilio: typeof monitorTwilio;
listRecentMessages: typeof listRecentMessages; listRecentMessages: typeof listRecentMessages;
ensurePortAvailable: typeof ensurePortAvailable; ensurePortAvailable: typeof ensurePortAvailable;
startWebhook: typeof startWebhook; startWebhook: typeof startWebhook;
waitForever: typeof waitForever; waitForever: typeof waitForever;
ensureBinary: typeof ensureBinary; ensureBinary: typeof ensureBinary;
ensureFunnel: typeof ensureFunnel; ensureFunnel: typeof ensureFunnel;
getTailnetHostname: typeof getTailnetHostname; getTailnetHostname: typeof getTailnetHostname;
readEnv: typeof readEnv; readEnv: typeof readEnv;
findWhatsappSenderSid: typeof findWhatsappSenderSid; findWhatsappSenderSid: typeof findWhatsappSenderSid;
updateWebhook: typeof updateWebhook; updateWebhook: typeof updateWebhook;
handlePortError: typeof handlePortError; handlePortError: typeof handlePortError;
monitorWebProvider: typeof monitorWebProvider; monitorWebProvider: typeof monitorWebProvider;
resolveTwilioMediaUrl: ( resolveTwilioMediaUrl: (
source: string, source: string,
opts: { serveMedia: boolean; runtime: RuntimeEnv }, opts: { serveMedia: boolean; runtime: RuntimeEnv },
) => Promise<string>; ) => Promise<string>;
}; };
export async function monitorTwilio( export async function monitorTwilio(
intervalSeconds: number, intervalSeconds: number,
lookbackMinutes: number, lookbackMinutes: number,
clientOverride?: ReturnType<typeof createClient>, clientOverride?: ReturnType<typeof createClient>,
maxIterations = Infinity, maxIterations = Infinity,
) { ) {
// Adapter that wires default deps/runtime for the Twilio monitor loop. // Adapter that wires default deps/runtime for the Twilio monitor loop.
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, { return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
client: clientOverride, client: clientOverride,
maxIterations, maxIterations,
deps: { deps: {
autoReplyIfConfigured, autoReplyIfConfigured,
listRecentMessages, listRecentMessages,
readEnv, readEnv,
createClient, createClient,
sleep, sleep,
}, },
runtime: defaultRuntime, runtime: defaultRuntime,
}); });
} }
export function createDefaultDeps(): CliDeps { export function createDefaultDeps(): CliDeps {
// Default dependency bundle used by CLI commands and tests. // Default dependency bundle used by CLI commands and tests.
return { return {
sendMessage, sendMessage,
sendMessageWeb, sendMessageWeb,
waitForFinalStatus, waitForFinalStatus,
assertProvider, assertProvider,
createClient, createClient,
monitorTwilio, monitorTwilio,
listRecentMessages, listRecentMessages,
ensurePortAvailable, ensurePortAvailable,
startWebhook, startWebhook,
waitForever, waitForever,
ensureBinary, ensureBinary,
ensureFunnel, ensureFunnel,
getTailnetHostname, getTailnetHostname,
readEnv, readEnv,
findWhatsappSenderSid, findWhatsappSenderSid,
updateWebhook, updateWebhook,
handlePortError, handlePortError,
monitorWebProvider, monitorWebProvider,
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => { resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
if (/^https?:\/\//i.test(source)) return source; if (/^https?:\/\//i.test(source)) return source;
const hosted = await ensureMediaHosted(source, { const hosted = await ensureMediaHosted(source, {
startServer: serveMedia, startServer: serveMedia,
runtime, runtime,
}); });
return hosted.url; return hosted.url;
}, },
}; };
} }
export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) { export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) {
// Log the configured Twilio sender for clarity in CLI output. // Log the configured Twilio sender for clarity in CLI output.
const env = readEnv(runtime); const env = readEnv(runtime);
runtime.log( runtime.log(
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`), info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
); );
} }
export { logWebSelfId }; export { logWebSelfId };

View File

@ -14,11 +14,11 @@ const waitForever = vi.fn();
const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay"); const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay");
const runtime = { const runtime = {
log: vi.fn(), log: vi.fn(),
error: vi.fn(), error: vi.fn(),
exit: vi.fn(() => { exit: vi.fn(() => {
throw new Error("exit"); throw new Error("exit");
}), }),
}; };
vi.mock("../commands/send.js", () => ({ sendCommand })); vi.mock("../commands/send.js", () => ({ sendCommand }));
@ -27,63 +27,63 @@ vi.mock("../commands/webhook.js", () => ({ webhookCommand }));
vi.mock("../env.js", () => ({ ensureTwilioEnv })); vi.mock("../env.js", () => ({ ensureTwilioEnv }));
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime })); vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
vi.mock("../provider-web.js", () => ({ vi.mock("../provider-web.js", () => ({
loginWeb, loginWeb,
monitorWebProvider, monitorWebProvider,
pickProvider, pickProvider,
})); }));
vi.mock("./deps.js", () => ({ vi.mock("./deps.js", () => ({
createDefaultDeps: () => ({ waitForever }), createDefaultDeps: () => ({ waitForever }),
logTwilioFrom, logTwilioFrom,
logWebSelfId, logWebSelfId,
monitorTwilio, monitorTwilio,
})); }));
vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux })); vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux }));
const { buildProgram } = await import("./program.js"); const { buildProgram } = await import("./program.js");
describe("cli program", () => { describe("cli program", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("runs send with required options", async () => { it("runs send with required options", async () => {
const program = buildProgram(); const program = buildProgram();
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], { await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
from: "user", from: "user",
}); });
expect(sendCommand).toHaveBeenCalled(); expect(sendCommand).toHaveBeenCalled();
}); });
it("rejects invalid relay provider", async () => { it("rejects invalid relay provider", async () => {
const program = buildProgram(); const program = buildProgram();
await expect( await expect(
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }), program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
).rejects.toThrow("exit"); ).rejects.toThrow("exit");
expect(runtime.error).toHaveBeenCalledWith( expect(runtime.error).toHaveBeenCalledWith(
"--provider must be auto, web, or twilio", "--provider must be auto, web, or twilio",
); );
}); });
it("falls back to twilio when web relay fails", async () => { it("falls back to twilio when web relay fails", async () => {
pickProvider.mockResolvedValue("web"); pickProvider.mockResolvedValue("web");
monitorWebProvider.mockRejectedValue(new Error("no web")); monitorWebProvider.mockRejectedValue(new Error("no web"));
const program = buildProgram(); const program = buildProgram();
await program.parseAsync( await program.parseAsync(
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"], ["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
{ from: "user" }, { from: "user" },
); );
expect(logWebSelfId).toHaveBeenCalled(); expect(logWebSelfId).toHaveBeenCalled();
expect(ensureTwilioEnv).toHaveBeenCalled(); expect(ensureTwilioEnv).toHaveBeenCalled();
expect(monitorTwilio).toHaveBeenCalledWith(2, 1); expect(monitorTwilio).toHaveBeenCalledWith(2, 1);
}); });
it("runs relay tmux attach command", async () => { it("runs relay tmux attach command", async () => {
const program = buildProgram(); const program = buildProgram();
await program.parseAsync(["relay:tmux:attach"], { from: "user" }); await program.parseAsync(["relay:tmux:attach"], { from: "user" });
expect(spawnRelayTmux).toHaveBeenCalledWith( expect(spawnRelayTmux).toHaveBeenCalledWith(
"pnpm warelay relay --verbose", "pnpm warelay relay --verbose",
true, true,
false, false,
); );
}); });
}); });

View File

@ -10,327 +10,327 @@ import { defaultRuntime } from "../runtime.js";
import type { Provider } from "../utils.js"; import type { Provider } from "../utils.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { import {
createDefaultDeps, createDefaultDeps,
logTwilioFrom, logTwilioFrom,
logWebSelfId, logWebSelfId,
monitorTwilio, monitorTwilio,
} from "./deps.js"; } from "./deps.js";
import { spawnRelayTmux } from "./relay_tmux.js"; import { spawnRelayTmux } from "./relay_tmux.js";
export function buildProgram() { export function buildProgram() {
const program = new Command(); const program = new Command();
const PROGRAM_VERSION = VERSION; const PROGRAM_VERSION = VERSION;
const TAGLINE = const TAGLINE =
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked."; "Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
program program
.name("warelay") .name("warelay")
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)") .description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
.version(PROGRAM_VERSION); .version(PROGRAM_VERSION);
const formatIntroLine = (version: string, rich = true) => { const formatIntroLine = (version: string, rich = true) => {
const base = `📡 warelay ${version}${TAGLINE}`; const base = `📡 warelay ${version}${TAGLINE}`;
return rich && chalk.level > 0 return rich && chalk.level > 0
? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}` ? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}`
: base; : base;
}; };
program.configureHelp({ program.configureHelp({
optionTerm: (option) => chalk.yellow(option.flags), optionTerm: (option) => chalk.yellow(option.flags),
subcommandTerm: (cmd) => chalk.green(cmd.name()), subcommandTerm: (cmd) => chalk.green(cmd.name()),
}); });
program.configureOutput({ program.configureOutput({
writeOut: (str) => { writeOut: (str) => {
const colored = str const colored = str
.replace(/^Usage:/gm, chalk.bold.cyan("Usage:")) .replace(/^Usage:/gm, chalk.bold.cyan("Usage:"))
.replace(/^Options:/gm, chalk.bold.cyan("Options:")) .replace(/^Options:/gm, chalk.bold.cyan("Options:"))
.replace(/^Commands:/gm, chalk.bold.cyan("Commands:")); .replace(/^Commands:/gm, chalk.bold.cyan("Commands:"));
process.stdout.write(colored); process.stdout.write(colored);
}, },
writeErr: (str) => process.stderr.write(str), writeErr: (str) => process.stderr.write(str),
outputError: (str, write) => write(chalk.red(str)), outputError: (str, write) => write(chalk.red(str)),
}); });
if (process.argv.includes("-V") || process.argv.includes("--version")) { if (process.argv.includes("-V") || process.argv.includes("--version")) {
console.log(formatIntroLine(PROGRAM_VERSION)); console.log(formatIntroLine(PROGRAM_VERSION));
process.exit(0); process.exit(0);
} }
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`); program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
const examples = [ const examples = [
[ [
"warelay login --verbose", "warelay login --verbose",
"Link personal WhatsApp Web and show QR + connection logs.", "Link personal WhatsApp Web and show QR + connection logs.",
], ],
[ [
'warelay send --to +15551234567 --message "Hi" --provider web --json', 'warelay send --to +15551234567 --message "Hi" --provider web --json',
"Send via your web session and print JSON result.", "Send via your web session and print JSON result.",
], ],
[ [
"warelay relay --provider auto --interval 5 --lookback 15 --verbose", "warelay relay --provider auto --interval 5 --lookback 15 --verbose",
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.", "Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
], ],
[ [
"warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose", "warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
"Start webhook + Tailscale Funnel and update Twilio callbacks.", "Start webhook + Tailscale Funnel and update Twilio callbacks.",
], ],
[ [
"warelay status --limit 10 --lookback 60 --json", "warelay status --limit 10 --lookback 60 --json",
"Show last 10 messages from the past hour as JSON.", "Show last 10 messages from the past hour as JSON.",
], ],
] as const; ] as const;
const fmtExamples = examples const fmtExamples = examples
.map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`) .map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`)
.join("\n"); .join("\n");
program.addHelpText( program.addHelpText(
"afterAll", "afterAll",
`\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`, `\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`,
); );
program program
.command("login") .command("login")
.description("Link your personal WhatsApp via QR (web provider)") .description("Link your personal WhatsApp via QR (web provider)")
.option("--verbose", "Verbose connection logs", false) .option("--verbose", "Verbose connection logs", false)
.action(async (opts) => { .action(async (opts) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));
try { try {
await loginWeb(Boolean(opts.verbose)); await loginWeb(Boolean(opts.verbose));
} catch (err) { } catch (err) {
defaultRuntime.error(danger(`Web login failed: ${String(err)}`)); defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
}); });
program program
.command("send") .command("send")
.description("Send a WhatsApp message") .description("Send a WhatsApp message")
.requiredOption( .requiredOption(
"-t, --to <number>", "-t, --to <number>",
"Recipient number in E.164 (e.g. +15551234567)", "Recipient number in E.164 (e.g. +15551234567)",
) )
.requiredOption("-m, --message <text>", "Message body") .requiredOption("-m, --message <text>", "Message body")
.option( .option(
"--media <path-or-url>", "--media <path-or-url>",
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.", "Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
) )
.option( .option(
"--serve-media", "--serve-media",
"For Twilio: start a temporary media server if webhook is not running", "For Twilio: start a temporary media server if webhook is not running",
false, false,
) )
.option( .option(
"-w, --wait <seconds>", "-w, --wait <seconds>",
"Wait for delivery status (0 to skip)", "Wait for delivery status (0 to skip)",
"20", "20",
) )
.option("-p, --poll <seconds>", "Polling interval while waiting", "2") .option("-p, --poll <seconds>", "Polling interval while waiting", "2")
.option("--provider <provider>", "Provider: twilio | web", "twilio") .option("--provider <provider>", "Provider: twilio | web", "twilio")
.option("--dry-run", "Print payload and skip sending", false) .option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false) .option("--json", "Output result as JSON", false)
.option("--verbose", "Verbose logging", false) .option("--verbose", "Verbose logging", false)
.addHelpText( .addHelpText(
"after", "after",
` `
Examples: Examples:
warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default) warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default)
warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget
warelay send --to +15551234567 --message "Hi" --dry-run # print payload only warelay send --to +15551234567 --message "Hi" --dry-run # print payload only
warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`, warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
) )
.action(async (opts) => { .action(async (opts) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps(); const deps = createDefaultDeps();
try { try {
await sendCommand(opts, deps, defaultRuntime); await sendCommand(opts, deps, defaultRuntime);
} catch (err) { } catch (err) {
defaultRuntime.error(String(err)); defaultRuntime.error(String(err));
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
}); });
program program
.command("relay") .command("relay")
.description("Auto-reply to inbound messages (auto-selects web or twilio)") .description("Auto-reply to inbound messages (auto-selects web or twilio)")
.option("--provider <provider>", "auto | web | twilio", "auto") .option("--provider <provider>", "auto | web | twilio", "auto")
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5") .option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
.option( .option(
"-l, --lookback <minutes>", "-l, --lookback <minutes>",
"Initial lookback window for twilio mode", "Initial lookback window for twilio mode",
"5", "5",
) )
.option("--verbose", "Verbose logging", false) .option("--verbose", "Verbose logging", false)
.addHelpText( .addHelpText(
"after", "after",
` `
Examples: Examples:
warelay relay # auto: web if logged-in, else twilio poll warelay relay # auto: web if logged-in, else twilio poll
warelay relay --provider web # force personal web session warelay relay --provider web # force personal web session
warelay relay --provider twilio # force twilio poll warelay relay --provider twilio # force twilio poll
warelay relay --provider twilio --interval 2 --lookback 30 warelay relay --provider twilio --interval 2 --lookback 30
`, `,
) )
.action(async (opts) => { .action(async (opts) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));
const providerPref = String(opts.provider ?? "auto"); const providerPref = String(opts.provider ?? "auto");
if (!["auto", "web", "twilio"].includes(providerPref)) { if (!["auto", "web", "twilio"].includes(providerPref)) {
defaultRuntime.error("--provider must be auto, web, or twilio"); defaultRuntime.error("--provider must be auto, web, or twilio");
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
const intervalSeconds = Number.parseInt(opts.interval, 10); const intervalSeconds = Number.parseInt(opts.interval, 10);
const lookbackMinutes = Number.parseInt(opts.lookback, 10); const lookbackMinutes = Number.parseInt(opts.lookback, 10);
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) { if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
defaultRuntime.error("Interval must be a positive integer"); defaultRuntime.error("Interval must be a positive integer");
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) { if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) {
defaultRuntime.error("Lookback must be >= 0 minutes"); defaultRuntime.error("Lookback must be >= 0 minutes");
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
const provider = await pickProvider(providerPref as Provider | "auto"); const provider = await pickProvider(providerPref as Provider | "auto");
if (provider === "web") { if (provider === "web") {
logWebSelfId(defaultRuntime, true); logWebSelfId(defaultRuntime, true);
try { try {
await monitorWebProvider(Boolean(opts.verbose)); await monitorWebProvider(Boolean(opts.verbose));
return; return;
} catch (err) { } catch (err) {
if (providerPref === "auto") { if (providerPref === "auto") {
defaultRuntime.error( defaultRuntime.error(
warn("Web session unavailable; falling back to twilio."), warn("Web session unavailable; falling back to twilio."),
); );
} else { } else {
defaultRuntime.error(danger(`Web relay failed: ${String(err)}`)); defaultRuntime.error(danger(`Web relay failed: ${String(err)}`));
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
} }
} }
ensureTwilioEnv(); ensureTwilioEnv();
logTwilioFrom(); logTwilioFrom();
await monitorTwilio(intervalSeconds, lookbackMinutes); await monitorTwilio(intervalSeconds, lookbackMinutes);
}); });
program program
.command("status") .command("status")
.description("Show recent WhatsApp messages (sent and received)") .description("Show recent WhatsApp messages (sent and received)")
.option("-l, --limit <count>", "Number of messages to show", "20") .option("-l, --limit <count>", "Number of messages to show", "20")
.option("-b, --lookback <minutes>", "How far back to fetch messages", "240") .option("-b, --lookback <minutes>", "How far back to fetch messages", "240")
.option("--json", "Output JSON instead of text", false) .option("--json", "Output JSON instead of text", false)
.option("--verbose", "Verbose logging", false) .option("--verbose", "Verbose logging", false)
.addHelpText( .addHelpText(
"after", "after",
` `
Examples: Examples:
warelay status # last 20 msgs in past 4h warelay status # last 20 msgs in past 4h
warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m
warelay status --json --limit 50 # machine-readable output`, warelay status --json --limit 50 # machine-readable output`,
) )
.action(async (opts) => { .action(async (opts) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps(); const deps = createDefaultDeps();
try { try {
await statusCommand(opts, deps, defaultRuntime); await statusCommand(opts, deps, defaultRuntime);
} catch (err) { } catch (err) {
defaultRuntime.error(String(err)); defaultRuntime.error(String(err));
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
}); });
program program
.command("webhook") .command("webhook")
.description( .description(
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.", "Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
) )
.option("-p, --port <port>", "Port to listen on", "42873") .option("-p, --port <port>", "Port to listen on", "42873")
.option("-r, --reply <text>", "Optional auto-reply text") .option("-r, --reply <text>", "Optional auto-reply text")
.option("--path <path>", "Webhook path", "/webhook/whatsapp") .option("--path <path>", "Webhook path", "/webhook/whatsapp")
.option( .option(
"--ingress <mode>", "--ingress <mode>",
"Ingress: tailscale (funnel + Twilio update) | none (local only)", "Ingress: tailscale (funnel + Twilio update) | none (local only)",
"tailscale", "tailscale",
) )
.option("--verbose", "Log inbound and auto-replies", false) .option("--verbose", "Log inbound and auto-replies", false)
.option("-y, --yes", "Auto-confirm prompts when possible", false) .option("-y, --yes", "Auto-confirm prompts when possible", false)
.option("--dry-run", "Print planned actions without starting server", false) .option("--dry-run", "Print planned actions without starting server", false)
.addHelpText( .addHelpText(
"after", "after",
` `
Examples: Examples:
warelay webhook # ingress=tailscale (funnel + Twilio update) warelay webhook # ingress=tailscale (funnel + Twilio update)
warelay webhook --ingress none # local-only server (no funnel / no Twilio update) warelay webhook --ingress none # local-only server (no funnel / no Twilio update)
warelay webhook --port 45000 # pick a high, less-colliding port warelay webhook --port 45000 # pick a high, less-colliding port
warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file`, warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file`,
) )
// istanbul ignore next // istanbul ignore next
.action(async (opts) => { .action(async (opts) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));
setYes(Boolean(opts.yes)); setYes(Boolean(opts.yes));
const deps = createDefaultDeps(); const deps = createDefaultDeps();
try { try {
const server = await webhookCommand(opts, deps, defaultRuntime); const server = await webhookCommand(opts, deps, defaultRuntime);
if (!server) { if (!server) {
defaultRuntime.log( defaultRuntime.log(
info("Webhook dry-run complete; no server started."), info("Webhook dry-run complete; no server started."),
); );
return; return;
} }
process.on("SIGINT", () => { process.on("SIGINT", () => {
server.close(() => { server.close(() => {
console.log("\n👋 Webhook stopped"); console.log("\n👋 Webhook stopped");
defaultRuntime.exit(0); defaultRuntime.exit(0);
}); });
}); });
await deps.waitForever(); await deps.waitForever();
} catch (err) { } catch (err) {
defaultRuntime.error(String(err)); defaultRuntime.error(String(err));
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
}); });
program program
.command("relay:tmux") .command("relay:tmux")
.description( .description(
"Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach", "Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach",
) )
.action(async () => { .action(async () => {
try { try {
const session = await spawnRelayTmux( const session = await spawnRelayTmux(
"pnpm warelay relay --verbose", "pnpm warelay relay --verbose",
true, true,
); );
defaultRuntime.log( defaultRuntime.log(
info( info(
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`, `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`,
), ),
); );
} catch (err) { } catch (err) {
defaultRuntime.error( defaultRuntime.error(
danger(`Failed to start relay tmux session: ${String(err)}`), danger(`Failed to start relay tmux session: ${String(err)}`),
); );
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
}); });
program program
.command("relay:tmux:attach") .command("relay:tmux:attach")
.description( .description(
"Attach to the existing warelay-relay tmux session (no restart)", "Attach to the existing warelay-relay tmux session (no restart)",
) )
.action(async () => { .action(async () => {
try { try {
await spawnRelayTmux("pnpm warelay relay --verbose", true, false); await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
defaultRuntime.log(info("Attached to warelay-relay session.")); defaultRuntime.log(info("Attached to warelay-relay session."));
} catch (err) { } catch (err) {
defaultRuntime.error( defaultRuntime.error(
danger(`Failed to attach to warelay-relay: ${String(err)}`), danger(`Failed to attach to warelay-relay: ${String(err)}`),
); );
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
}); });
return program; return program;
} }

View File

@ -3,47 +3,47 @@ import { describe, expect, it, vi } from "vitest";
import { isYes, setVerbose, setYes } from "../globals.js"; import { isYes, setVerbose, setYes } from "../globals.js";
vi.mock("node:readline/promises", () => { vi.mock("node:readline/promises", () => {
const question = vi.fn<[], Promise<string>>(); const question = vi.fn<[], Promise<string>>();
const close = vi.fn(); const close = vi.fn();
const createInterface = vi.fn(() => ({ question, close })); const createInterface = vi.fn(() => ({ question, close }));
return { default: { createInterface } }; return { default: { createInterface } };
}); });
type ReadlineMock = { type ReadlineMock = {
default: { default: {
createInterface: () => { createInterface: () => {
question: ReturnType<typeof vi.fn<[], Promise<string>>>; question: ReturnType<typeof vi.fn<[], Promise<string>>>;
close: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn>;
}; };
}; };
}; };
const { promptYesNo } = await import("./prompt.js"); const { promptYesNo } = await import("./prompt.js");
const readline = (await import("node:readline/promises")) as ReadlineMock; const readline = (await import("node:readline/promises")) as ReadlineMock;
describe("promptYesNo", () => { describe("promptYesNo", () => {
it("returns true when global --yes is set", async () => { it("returns true when global --yes is set", async () => {
setYes(true); setYes(true);
setVerbose(false); setVerbose(false);
const result = await promptYesNo("Continue?"); const result = await promptYesNo("Continue?");
expect(result).toBe(true); expect(result).toBe(true);
expect(isYes()).toBe(true); expect(isYes()).toBe(true);
}); });
it("asks the question and respects default", async () => { it("asks the question and respects default", async () => {
setYes(false); setYes(false);
setVerbose(false); setVerbose(false);
const { question: questionMock } = readline.default.createInterface(); const { question: questionMock } = readline.default.createInterface();
questionMock.mockResolvedValueOnce(""); questionMock.mockResolvedValueOnce("");
const resultDefaultYes = await promptYesNo("Continue?", true); const resultDefaultYes = await promptYesNo("Continue?", true);
expect(resultDefaultYes).toBe(true); expect(resultDefaultYes).toBe(true);
questionMock.mockResolvedValueOnce("n"); questionMock.mockResolvedValueOnce("n");
const resultNo = await promptYesNo("Continue?", true); const resultNo = await promptYesNo("Continue?", true);
expect(resultNo).toBe(false); expect(resultNo).toBe(false);
questionMock.mockResolvedValueOnce("y"); questionMock.mockResolvedValueOnce("y");
const resultYes = await promptYesNo("Continue?", false); const resultYes = await promptYesNo("Continue?", false);
expect(resultYes).toBe(true); expect(resultYes).toBe(true);
}); });
}); });

View File

@ -4,18 +4,18 @@ import readline from "node:readline/promises";
import { isVerbose, isYes } from "../globals.js"; import { isVerbose, isYes } from "../globals.js";
export async function promptYesNo( export async function promptYesNo(
question: string, question: string,
defaultYes = false, defaultYes = false,
): Promise<boolean> { ): Promise<boolean> {
// Simple Y/N prompt honoring global --yes and verbosity flags. // Simple Y/N prompt honoring global --yes and verbosity flags.
if (isVerbose() && isYes()) return true; // redundant guard when both flags set if (isVerbose() && isYes()) return true; // redundant guard when both flags set
if (isYes()) return true; if (isYes()) return true;
const rl = readline.createInterface({ input, output }); const rl = readline.createInterface({ input, output });
const suffix = defaultYes ? " [Y/n] " : " [y/N] "; const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
const answer = (await rl.question(`${question}${suffix}`)) const answer = (await rl.question(`${question}${suffix}`))
.trim() .trim()
.toLowerCase(); .toLowerCase();
rl.close(); rl.close();
if (!answer) return defaultYes; if (!answer) return defaultYes;
return answer.startsWith("y"); return answer.startsWith("y");
} }

View File

@ -2,43 +2,43 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
// Mocks must be defined via vi.hoisted to avoid TDZ with ESM hoisting. // Mocks must be defined via vi.hoisted to avoid TDZ with ESM hoisting.
const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } = const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } =
vi.hoisted(() => { vi.hoisted(() => {
return { return {
monitorWebProvider: vi.fn().mockResolvedValue(undefined), monitorWebProvider: vi.fn().mockResolvedValue(undefined),
pickProvider: vi.fn().mockResolvedValue("web"), pickProvider: vi.fn().mockResolvedValue("web"),
logWebSelfId: vi.fn(), logWebSelfId: vi.fn(),
monitorTwilio: vi.fn().mockResolvedValue(undefined), monitorTwilio: vi.fn().mockResolvedValue(undefined),
}; };
}); });
vi.mock("../provider-web.js", () => ({ vi.mock("../provider-web.js", () => ({
monitorWebProvider, monitorWebProvider,
pickProvider, pickProvider,
logWebSelfId, logWebSelfId,
})); }));
vi.mock("../twilio/monitor.js", () => ({ vi.mock("../twilio/monitor.js", () => ({
monitorTwilio, monitorTwilio,
})); }));
import { buildProgram } from "./program.js"; import { buildProgram } from "./program.js";
describe("CLI relay command (e2e-ish)", () => { describe("CLI relay command (e2e-ish)", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("runs relay in web mode without crashing", async () => { it("runs relay in web mode without crashing", async () => {
const program = buildProgram(); const program = buildProgram();
program.exitOverride(); // throw instead of exiting process on error program.exitOverride(); // throw instead of exiting process on error
await expect( await expect(
program.parseAsync(["relay", "--provider", "web"], { from: "user" }), program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
).resolves.toBeInstanceOf(Object); ).resolves.toBeInstanceOf(Object);
expect(pickProvider).toHaveBeenCalledWith("web"); expect(pickProvider).toHaveBeenCalledWith("web");
expect(logWebSelfId).toHaveBeenCalledTimes(1); expect(logWebSelfId).toHaveBeenCalledTimes(1);
expect(monitorWebProvider).toHaveBeenCalledWith(false); expect(monitorWebProvider).toHaveBeenCalledWith(false);
expect(monitorTwilio).not.toHaveBeenCalled(); expect(monitorTwilio).not.toHaveBeenCalled();
}); });
}); });

View File

@ -3,45 +3,45 @@ import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", () => { vi.mock("node:child_process", () => {
const spawn = vi.fn((_cmd: string, _args: string[]) => { const spawn = vi.fn((_cmd: string, _args: string[]) => {
const proc = new EventEmitter() as EventEmitter & { const proc = new EventEmitter() as EventEmitter & {
kill: ReturnType<typeof vi.fn>; kill: ReturnType<typeof vi.fn>;
}; };
queueMicrotask(() => { queueMicrotask(() => {
proc.emit("exit", 0); proc.emit("exit", 0);
}); });
proc.kill = vi.fn(); proc.kill = vi.fn();
return proc; return proc;
}); });
return { spawn }; return { spawn };
}); });
const { spawnRelayTmux } = await import("./relay_tmux.js"); const { spawnRelayTmux } = await import("./relay_tmux.js");
const { spawn } = await import("node:child_process"); const { spawn } = await import("node:child_process");
describe("spawnRelayTmux", () => { describe("spawnRelayTmux", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("kills old session, starts new one, and attaches", async () => { it("kills old session, starts new one, and attaches", async () => {
const session = await spawnRelayTmux("echo hi", true, true); const session = await spawnRelayTmux("echo hi", true, true);
expect(session).toBe("warelay-relay"); expect(session).toBe("warelay-relay");
const spawnMock = spawn as unknown as vi.Mock; const spawnMock = spawn as unknown as vi.Mock;
expect(spawnMock.mock.calls.length).toBe(3); expect(spawnMock.mock.calls.length).toBe(3);
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>; const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
expect(calls[0][0]).toBe("tmux"); // kill-session expect(calls[0][0]).toBe("tmux"); // kill-session
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
expect(calls[2][1][0]).toBe("attach-session"); expect(calls[2][1][0]).toBe("attach-session");
}); });
it("can skip attach", async () => { it("can skip attach", async () => {
await spawnRelayTmux("echo hi", false, true); await spawnRelayTmux("echo hi", false, true);
const spawnMock = spawn as unknown as vi.Mock; const spawnMock = spawn as unknown as vi.Mock;
const hasAttach = spawnMock.mock.calls.some( const hasAttach = spawnMock.mock.calls.some(
(c) => (c) =>
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"), Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
); );
expect(hasAttach).toBe(false); expect(hasAttach).toBe(false);
}); });
}); });

View File

@ -3,48 +3,48 @@ import { spawn } from "node:child_process";
const SESSION = "warelay-relay"; const SESSION = "warelay-relay";
export async function spawnRelayTmux( export async function spawnRelayTmux(
cmd = "pnpm warelay relay --verbose", cmd = "pnpm warelay relay --verbose",
attach = true, attach = true,
restart = true, restart = true,
) { ) {
if (restart) { if (restart) {
await killSession(SESSION); await killSession(SESSION);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], { const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], {
stdio: "inherit", stdio: "inherit",
shell: false, shell: false,
}); });
child.on("error", reject); child.on("error", reject);
child.on("exit", (code) => { child.on("exit", (code) => {
if (code === 0) resolve(); if (code === 0) resolve();
else reject(new Error(`tmux exited with code ${code}`)); else reject(new Error(`tmux exited with code ${code}`));
}); });
}); });
} }
if (attach) { if (attach) {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const child = spawn("tmux", ["attach-session", "-t", SESSION], { const child = spawn("tmux", ["attach-session", "-t", SESSION], {
stdio: "inherit", stdio: "inherit",
shell: false, shell: false,
}); });
child.on("error", reject); child.on("error", reject);
child.on("exit", (code) => { child.on("exit", (code) => {
if (code === 0) resolve(); if (code === 0) resolve();
else reject(new Error(`tmux attach exited with code ${code}`)); else reject(new Error(`tmux attach exited with code ${code}`));
}); });
}); });
} }
return SESSION; return SESSION;
} }
async function killSession(name: string) { async function killSession(name: string) {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const child = spawn("tmux", ["kill-session", "-t", name], { const child = spawn("tmux", ["kill-session", "-t", name], {
stdio: "ignore", stdio: "ignore",
}); });
child.on("exit", () => resolve()); child.on("exit", () => resolve());
child.on("error", () => resolve()); child.on("error", () => resolve());
}); });
} }

View File

@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest";
import { waitForever } from "./wait.js"; import { waitForever } from "./wait.js";
describe("waitForever", () => { describe("waitForever", () => {
it("creates an unref'ed interval and returns a pending promise", () => { it("creates an unref'ed interval and returns a pending promise", () => {
const setIntervalSpy = vi.spyOn(global, "setInterval"); const setIntervalSpy = vi.spyOn(global, "setInterval");
const promise = waitForever(); const promise = waitForever();
expect(setIntervalSpy).toHaveBeenCalledWith( expect(setIntervalSpy).toHaveBeenCalledWith(
expect.any(Function), expect.any(Function),
1_000_000, 1_000_000,
); );
expect(promise).toBeInstanceOf(Promise); expect(promise).toBeInstanceOf(Promise);
setIntervalSpy.mockRestore(); setIntervalSpy.mockRestore();
}); });
}); });

View File

@ -1,8 +1,8 @@
export function waitForever() { export function waitForever() {
// Keep event loop alive via an unref'ed interval plus a pending promise. // Keep event loop alive via an unref'ed interval plus a pending promise.
const interval = setInterval(() => {}, 1_000_000); const interval = setInterval(() => {}, 1_000_000);
interval.unref(); interval.unref();
return new Promise<void>(() => { return new Promise<void>(() => {
/* never resolve */ /* never resolve */
}); });
} }

View File

@ -5,141 +5,141 @@ import type { RuntimeEnv } from "../runtime.js";
import { sendCommand } from "./send.js"; import { sendCommand } from "./send.js";
const runtime: RuntimeEnv = { const runtime: RuntimeEnv = {
log: vi.fn(), log: vi.fn(),
error: vi.fn(), error: vi.fn(),
exit: vi.fn(() => { exit: vi.fn(() => {
throw new Error("exit"); throw new Error("exit");
}), }),
}; };
const baseDeps = { const baseDeps = {
assertProvider: vi.fn(), assertProvider: vi.fn(),
sendMessageWeb: vi.fn(), sendMessageWeb: vi.fn(),
resolveTwilioMediaUrl: vi.fn(), resolveTwilioMediaUrl: vi.fn(),
sendMessage: vi.fn(), sendMessage: vi.fn(),
waitForFinalStatus: vi.fn(), waitForFinalStatus: vi.fn(),
} as unknown as CliDeps; } as unknown as CliDeps;
describe("sendCommand", () => { describe("sendCommand", () => {
it("validates wait and poll", async () => { it("validates wait and poll", async () => {
await expect(() => await expect(() =>
sendCommand( sendCommand(
{ {
to: "+1", to: "+1",
message: "hi", message: "hi",
wait: "-1", wait: "-1",
poll: "2", poll: "2",
provider: "twilio", provider: "twilio",
}, },
baseDeps, baseDeps,
runtime, runtime,
), ),
).rejects.toThrow("Wait must be >= 0 seconds"); ).rejects.toThrow("Wait must be >= 0 seconds");
await expect(() => await expect(() =>
sendCommand( sendCommand(
{ {
to: "+1", to: "+1",
message: "hi", message: "hi",
wait: "0", wait: "0",
poll: "0", poll: "0",
provider: "twilio", provider: "twilio",
}, },
baseDeps, baseDeps,
runtime, runtime,
), ),
).rejects.toThrow("Poll must be > 0 seconds"); ).rejects.toThrow("Poll must be > 0 seconds");
}); });
it("handles web dry-run and warns on wait", async () => { it("handles web dry-run and warns on wait", async () => {
const deps = { const deps = {
...baseDeps, ...baseDeps,
sendMessageWeb: vi.fn(), sendMessageWeb: vi.fn(),
} as CliDeps; } as CliDeps;
await sendCommand( await sendCommand(
{ {
to: "+1", to: "+1",
message: "hi", message: "hi",
wait: "5", wait: "5",
poll: "2", poll: "2",
provider: "web", provider: "web",
dryRun: true, dryRun: true,
media: "pic.jpg", media: "pic.jpg",
}, },
deps, deps,
runtime, runtime,
); );
expect(deps.sendMessageWeb).not.toHaveBeenCalled(); expect(deps.sendMessageWeb).not.toHaveBeenCalled();
}); });
it("sends via web and outputs JSON", async () => { it("sends via web and outputs JSON", async () => {
const deps = { const deps = {
...baseDeps, ...baseDeps,
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }), sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
} as CliDeps; } as CliDeps;
await sendCommand( await sendCommand(
{ {
to: "+1", to: "+1",
message: "hi", message: "hi",
wait: "1", wait: "1",
poll: "2", poll: "2",
provider: "web", provider: "web",
json: true, json: true,
}, },
deps, deps,
runtime, runtime,
); );
expect(deps.sendMessageWeb).toHaveBeenCalled(); expect(deps.sendMessageWeb).toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith( expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining('"provider": "web"'), expect.stringContaining('"provider": "web"'),
); );
}); });
it("supports twilio dry-run", async () => { it("supports twilio dry-run", async () => {
const deps = { ...baseDeps } as CliDeps; const deps = { ...baseDeps } as CliDeps;
await sendCommand( await sendCommand(
{ {
to: "+1", to: "+1",
message: "hi", message: "hi",
wait: "0", wait: "0",
poll: "2", poll: "2",
provider: "twilio", provider: "twilio",
dryRun: true, dryRun: true,
}, },
deps, deps,
runtime, runtime,
); );
expect(deps.sendMessage).not.toHaveBeenCalled(); expect(deps.sendMessage).not.toHaveBeenCalled();
}); });
it("sends via twilio with media and skips wait when zero", async () => { it("sends via twilio with media and skips wait when zero", async () => {
const deps = { const deps = {
...baseDeps, ...baseDeps,
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"), resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }), sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
waitForFinalStatus: vi.fn(), waitForFinalStatus: vi.fn(),
} as CliDeps; } as CliDeps;
await sendCommand( await sendCommand(
{ {
to: "+1", to: "+1",
message: "hi", message: "hi",
wait: "0", wait: "0",
poll: "2", poll: "2",
provider: "twilio", provider: "twilio",
media: "pic.jpg", media: "pic.jpg",
serveMedia: true, serveMedia: true,
json: true, json: true,
}, },
deps, deps,
runtime, runtime,
); );
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", { expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
serveMedia: true, serveMedia: true,
runtime, runtime,
}); });
expect(deps.waitForFinalStatus).not.toHaveBeenCalled(); expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith( expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining('"provider": "twilio"'), expect.stringContaining('"provider": "twilio"'),
); );
}); });
}); });

View File

@ -4,109 +4,109 @@ import type { RuntimeEnv } from "../runtime.js";
import type { Provider } from "../utils.js"; import type { Provider } from "../utils.js";
export async function sendCommand( export async function sendCommand(
opts: { opts: {
to: string; to: string;
message: string; message: string;
wait: string; wait: string;
poll: string; poll: string;
provider: Provider; provider: Provider;
json?: boolean; json?: boolean;
dryRun?: boolean; dryRun?: boolean;
media?: string; media?: string;
serveMedia?: boolean; serveMedia?: boolean;
}, },
deps: CliDeps, deps: CliDeps,
runtime: RuntimeEnv, runtime: RuntimeEnv,
) { ) {
deps.assertProvider(opts.provider); deps.assertProvider(opts.provider);
const waitSeconds = Number.parseInt(opts.wait, 10); const waitSeconds = Number.parseInt(opts.wait, 10);
const pollSeconds = Number.parseInt(opts.poll, 10); const pollSeconds = Number.parseInt(opts.poll, 10);
if (Number.isNaN(waitSeconds) || waitSeconds < 0) { if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
throw new Error("Wait must be >= 0 seconds"); throw new Error("Wait must be >= 0 seconds");
} }
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) { if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
throw new Error("Poll must be > 0 seconds"); throw new Error("Poll must be > 0 seconds");
} }
if (opts.provider === "web") { if (opts.provider === "web") {
if (opts.dryRun) { if (opts.dryRun) {
runtime.log( runtime.log(
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`, `[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
); );
return; return;
} }
if (waitSeconds !== 0) { if (waitSeconds !== 0) {
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web.")); runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
} }
const res = await deps const res = await deps
.sendMessageWeb(opts.to, opts.message, { .sendMessageWeb(opts.to, opts.message, {
verbose: false, verbose: false,
mediaUrl: opts.media, mediaUrl: opts.media,
}) })
.catch((err) => { .catch((err) => {
runtime.error(`❌ Web send failed: ${String(err)}`); runtime.error(`❌ Web send failed: ${String(err)}`);
throw err; throw err;
}); });
if (opts.json) { if (opts.json) {
runtime.log( runtime.log(
JSON.stringify( JSON.stringify(
{ {
provider: "web", provider: "web",
to: opts.to, to: opts.to,
messageId: res.messageId, messageId: res.messageId,
mediaUrl: opts.media ?? null, mediaUrl: opts.media ?? null,
}, },
null, null,
2, 2,
), ),
); );
} }
return; return;
} }
if (opts.dryRun) { if (opts.dryRun) {
runtime.log( runtime.log(
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`, `[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
); );
return; return;
} }
let mediaUrl: string | undefined; let mediaUrl: string | undefined;
if (opts.media) { if (opts.media) {
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, { mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
serveMedia: Boolean(opts.serveMedia), serveMedia: Boolean(opts.serveMedia),
runtime, runtime,
}); });
} }
const result = await deps.sendMessage( const result = await deps.sendMessage(
opts.to, opts.to,
opts.message, opts.message,
{ mediaUrl }, { mediaUrl },
runtime, runtime,
); );
if (opts.json) { if (opts.json) {
runtime.log( runtime.log(
JSON.stringify( JSON.stringify(
{ {
provider: "twilio", provider: "twilio",
to: opts.to, to: opts.to,
sid: result?.sid ?? null, sid: result?.sid ?? null,
mediaUrl: mediaUrl ?? null, mediaUrl: mediaUrl ?? null,
}, },
null, null,
2, 2,
), ),
); );
} }
if (!result) return; if (!result) return;
if (waitSeconds === 0) return; if (waitSeconds === 0) return;
await deps.waitForFinalStatus( await deps.waitForFinalStatus(
result.client, result.client,
result.sid, result.sid,
waitSeconds, waitSeconds,
pollSeconds, pollSeconds,
runtime, runtime,
); );
} }

View File

@ -5,46 +5,46 @@ import type { RuntimeEnv } from "../runtime.js";
import { statusCommand } from "./status.js"; import { statusCommand } from "./status.js";
vi.mock("../twilio/messages.js", () => ({ vi.mock("../twilio/messages.js", () => ({
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`, formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
})); }));
const runtime: RuntimeEnv = { const runtime: RuntimeEnv = {
log: vi.fn(), log: vi.fn(),
error: vi.fn(), error: vi.fn(),
exit: vi.fn(() => { exit: vi.fn(() => {
throw new Error("exit"); throw new Error("exit");
}), }),
}; };
const deps: CliDeps = { const deps: CliDeps = {
listRecentMessages: vi.fn(), listRecentMessages: vi.fn(),
} as unknown as CliDeps; } as unknown as CliDeps;
describe("statusCommand", () => { describe("statusCommand", () => {
it("validates limit and lookback", async () => { it("validates limit and lookback", async () => {
await expect( await expect(
statusCommand({ limit: "0", lookback: "10" }, deps, runtime), statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
).rejects.toThrow("limit must be between 1 and 200"); ).rejects.toThrow("limit must be between 1 and 200");
await expect( await expect(
statusCommand({ limit: "10", lookback: "0" }, deps, runtime), statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
).rejects.toThrow("lookback must be > 0 minutes"); ).rejects.toThrow("lookback must be > 0 minutes");
}); });
it("prints JSON when requested", async () => { it("prints JSON when requested", async () => {
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]); (deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
await statusCommand( await statusCommand(
{ limit: "5", lookback: "10", json: true }, { limit: "5", lookback: "10", json: true },
deps, deps,
runtime, runtime,
); );
expect(runtime.log).toHaveBeenCalledWith( expect(runtime.log).toHaveBeenCalledWith(
JSON.stringify([{ sid: "1" }], null, 2), JSON.stringify([{ sid: "1" }], null, 2),
); );
}); });
it("prints formatted lines otherwise", async () => { it("prints formatted lines otherwise", async () => {
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]); (deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime); await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
expect(runtime.log).toHaveBeenCalledWith("LINE:123"); expect(runtime.log).toHaveBeenCalledWith("LINE:123");
}); });
}); });

View File

@ -3,29 +3,29 @@ import type { RuntimeEnv } from "../runtime.js";
import { formatMessageLine } from "../twilio/messages.js"; import { formatMessageLine } from "../twilio/messages.js";
export async function statusCommand( export async function statusCommand(
opts: { limit: string; lookback: string; json?: boolean }, opts: { limit: string; lookback: string; json?: boolean },
deps: CliDeps, deps: CliDeps,
runtime: RuntimeEnv, runtime: RuntimeEnv,
) { ) {
const limit = Number.parseInt(opts.limit, 10); const limit = Number.parseInt(opts.limit, 10);
const lookbackMinutes = Number.parseInt(opts.lookback, 10); const lookbackMinutes = Number.parseInt(opts.lookback, 10);
if (Number.isNaN(limit) || limit <= 0 || limit > 200) { if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
throw new Error("limit must be between 1 and 200"); throw new Error("limit must be between 1 and 200");
} }
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) { if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
throw new Error("lookback must be > 0 minutes"); throw new Error("lookback must be > 0 minutes");
} }
const messages = await deps.listRecentMessages(lookbackMinutes, limit); const messages = await deps.listRecentMessages(lookbackMinutes, limit);
if (opts.json) { if (opts.json) {
runtime.log(JSON.stringify(messages, null, 2)); runtime.log(JSON.stringify(messages, null, 2));
return; return;
} }
if (messages.length === 0) { if (messages.length === 0) {
runtime.log("No messages found in the requested window."); runtime.log("No messages found in the requested window.");
return; return;
} }
for (const m of messages) { for (const m of messages) {
runtime.log(formatMessageLine(m)); runtime.log(formatMessageLine(m));
} }
} }

View File

@ -5,72 +5,72 @@ import type { RuntimeEnv } from "../runtime.js";
import { upCommand } from "./up.js"; import { upCommand } from "./up.js";
const runtime: RuntimeEnv = { const runtime: RuntimeEnv = {
log: vi.fn(), log: vi.fn(),
error: vi.fn(), error: vi.fn(),
exit: vi.fn(() => { exit: vi.fn(() => {
throw new Error("exit"); throw new Error("exit");
}), }),
}; };
const makeDeps = (): CliDeps => ({ const makeDeps = (): CliDeps => ({
ensurePortAvailable: vi.fn().mockResolvedValue(undefined), ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
readEnv: vi.fn().mockReturnValue({ readEnv: vi.fn().mockReturnValue({
whatsappFrom: "whatsapp:+1555", whatsappFrom: "whatsapp:+1555",
whatsappSenderSid: "WW", whatsappSenderSid: "WW",
}), }),
ensureBinary: vi.fn().mockResolvedValue(undefined), ensureBinary: vi.fn().mockResolvedValue(undefined),
ensureFunnel: vi.fn().mockResolvedValue(undefined), ensureFunnel: vi.fn().mockResolvedValue(undefined),
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"), getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
startWebhook: vi.fn().mockResolvedValue({ server: true }), startWebhook: vi.fn().mockResolvedValue({ server: true }),
createClient: vi.fn().mockReturnValue({ client: true }), createClient: vi.fn().mockReturnValue({ client: true }),
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"), findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
updateWebhook: vi.fn().mockResolvedValue(undefined), updateWebhook: vi.fn().mockResolvedValue(undefined),
}); });
describe("upCommand", () => { describe("upCommand", () => {
it("throws on invalid port", async () => { it("throws on invalid port", async () => {
await expect(() => await expect(() =>
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime), upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
).rejects.toThrow("Port must be between 1 and 65535"); ).rejects.toThrow("Port must be between 1 and 65535");
}); });
it("performs dry run and returns mock data", async () => { it("performs dry run and returns mock data", async () => {
runtime.log.mockClear(); runtime.log.mockClear();
const result = await upCommand( const result = await upCommand(
{ port: "42873", path: "/cb", dryRun: true }, { port: "42873", path: "/cb", dryRun: true },
makeDeps(), makeDeps(),
runtime, runtime,
); );
expect(runtime.log).toHaveBeenCalledWith( expect(runtime.log).toHaveBeenCalledWith(
"[dry-run] would enable funnel on port 42873", "[dry-run] would enable funnel on port 42873",
); );
expect(result?.publicUrl).toBe("https://dry-run/cb"); expect(result?.publicUrl).toBe("https://dry-run/cb");
expect(result?.senderSid).toBeUndefined(); expect(result?.senderSid).toBeUndefined();
}); });
it("enables funnel, starts webhook, and updates Twilio", async () => { it("enables funnel, starts webhook, and updates Twilio", async () => {
const deps = makeDeps(); const deps = makeDeps();
const res = await upCommand( const res = await upCommand(
{ port: "42873", path: "/hook", verbose: true }, { port: "42873", path: "/hook", verbose: true },
deps, deps,
runtime, runtime,
); );
expect(deps.ensureBinary).toHaveBeenCalledWith( expect(deps.ensureBinary).toHaveBeenCalledWith(
"tailscale", "tailscale",
undefined, undefined,
runtime, runtime,
); );
expect(deps.ensureFunnel).toHaveBeenCalled(); expect(deps.ensureFunnel).toHaveBeenCalled();
expect(deps.startWebhook).toHaveBeenCalled(); expect(deps.startWebhook).toHaveBeenCalled();
expect(deps.updateWebhook).toHaveBeenCalledWith( expect(deps.updateWebhook).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
"SID123", "SID123",
"https://tailnet-host/hook", "https://tailnet-host/hook",
"POST", "POST",
runtime, runtime,
); );
expect(res?.publicUrl).toBe("https://tailnet-host/hook"); expect(res?.publicUrl).toBe("https://tailnet-host/hook");
// waiter is returned to keep the process alive in real use. // waiter is returned to keep the process alive in real use.
expect(typeof res?.waiter).toBe("function"); expect(typeof res?.waiter).toBe("function");
}); });
}); });

View File

@ -4,65 +4,65 @@ import { retryAsync } from "../infra/retry.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
export async function upCommand( export async function upCommand(
opts: { opts: {
port: string; port: string;
path: string; path: string;
verbose?: boolean; verbose?: boolean;
yes?: boolean; yes?: boolean;
dryRun?: boolean; dryRun?: boolean;
}, },
deps: CliDeps, deps: CliDeps,
runtime: RuntimeEnv, runtime: RuntimeEnv,
waiter: typeof defaultWaitForever = defaultWaitForever, waiter: typeof defaultWaitForever = defaultWaitForever,
) { ) {
const port = Number.parseInt(opts.port, 10); const port = Number.parseInt(opts.port, 10);
if (Number.isNaN(port) || port <= 0 || port >= 65536) { if (Number.isNaN(port) || port <= 0 || port >= 65536) {
throw new Error("Port must be between 1 and 65535"); throw new Error("Port must be between 1 and 65535");
} }
await deps.ensurePortAvailable(port); await deps.ensurePortAvailable(port);
const env = deps.readEnv(runtime); const env = deps.readEnv(runtime);
if (opts.dryRun) { if (opts.dryRun) {
runtime.log(`[dry-run] would enable funnel on port ${port}`); runtime.log(`[dry-run] would enable funnel on port ${port}`);
runtime.log(`[dry-run] would start webhook at path ${opts.path}`); runtime.log(`[dry-run] would start webhook at path ${opts.path}`);
runtime.log(`[dry-run] would update Twilio sender webhook`); runtime.log(`[dry-run] would update Twilio sender webhook`);
const publicUrl = `https://dry-run${opts.path}`; const publicUrl = `https://dry-run${opts.path}`;
return { server: undefined, publicUrl, senderSid: undefined, waiter }; return { server: undefined, publicUrl, senderSid: undefined, waiter };
} }
await deps.ensureBinary("tailscale", undefined, runtime); await deps.ensureBinary("tailscale", undefined, runtime);
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500); await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
const host = await deps.getTailnetHostname(); const host = await deps.getTailnetHostname();
const publicUrl = `https://${host}${opts.path}`; const publicUrl = `https://${host}${opts.path}`;
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`); runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
const server = await retryAsync( const server = await retryAsync(
() => () =>
deps.startWebhook( deps.startWebhook(
port, port,
opts.path, opts.path,
undefined, undefined,
Boolean(opts.verbose), Boolean(opts.verbose),
runtime, runtime,
), ),
3, 3,
300, 300,
); );
if (!deps.createClient) { if (!deps.createClient) {
throw new Error("Twilio client dependency missing"); throw new Error("Twilio client dependency missing");
} }
const twilioClient = deps.createClient(env); const twilioClient = deps.createClient(env);
const senderSid = await deps.findWhatsappSenderSid( const senderSid = await deps.findWhatsappSenderSid(
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient, twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
env.whatsappFrom, env.whatsappFrom,
env.whatsappSenderSid, env.whatsappSenderSid,
runtime, runtime,
); );
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime); await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
runtime.log( runtime.log(
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.", "\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
); );
return { server, publicUrl, senderSid, waiter }; return { server, publicUrl, senderSid, waiter };
} }

View File

@ -6,57 +6,57 @@ import type { RuntimeEnv } from "../runtime.js";
import { webhookCommand } from "./webhook.js"; import { webhookCommand } from "./webhook.js";
const runtime: RuntimeEnv = { const runtime: RuntimeEnv = {
log: vi.fn(), log: vi.fn(),
error: vi.fn(), error: vi.fn(),
exit: vi.fn(() => { exit: vi.fn(() => {
throw new Error("exit"); throw new Error("exit");
}), }),
}; };
const deps: CliDeps = { const deps: CliDeps = {
ensurePortAvailable: vi.fn().mockResolvedValue(undefined), ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
startWebhook: vi.fn().mockResolvedValue({ server: true }), startWebhook: vi.fn().mockResolvedValue({ server: true }),
}; };
describe("webhookCommand", () => { describe("webhookCommand", () => {
it("throws on invalid port", async () => { it("throws on invalid port", async () => {
await expect(() => await expect(() =>
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime), webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
).rejects.toThrow("Port must be between 1 and 65535"); ).rejects.toThrow("Port must be between 1 and 65535");
}); });
it("logs dry run instead of starting server", async () => { it("logs dry run instead of starting server", async () => {
runtime.log.mockClear(); runtime.log.mockClear();
const res = await webhookCommand( const res = await webhookCommand(
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" }, { port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
deps, deps,
runtime, runtime,
); );
expect(res).toBeUndefined(); expect(res).toBeUndefined();
expect(runtime.log).toHaveBeenCalledWith( expect(runtime.log).toHaveBeenCalledWith(
"[dry-run] would start webhook on port 42873 path /hook", "[dry-run] would start webhook on port 42873 path /hook",
); );
}); });
it("starts webhook when valid", async () => { it("starts webhook when valid", async () => {
const res = await webhookCommand( const res = await webhookCommand(
{ {
port: "42873", port: "42873",
path: "/hook", path: "/hook",
reply: "ok", reply: "ok",
verbose: true, verbose: true,
ingress: "none", ingress: "none",
}, },
deps, deps,
runtime, runtime,
); );
expect(deps.startWebhook).toHaveBeenCalledWith( expect(deps.startWebhook).toHaveBeenCalledWith(
42873, 42873,
"/hook", "/hook",
"ok", "ok",
true, true,
runtime, runtime,
); );
expect(res).toEqual({ server: true }); expect(res).toEqual({ server: true });
}); });
}); });

View File

@ -4,60 +4,60 @@ import type { RuntimeEnv } from "../runtime.js";
import { upCommand } from "./up.js"; import { upCommand } from "./up.js";
export async function webhookCommand( export async function webhookCommand(
opts: { opts: {
port: string; port: string;
path: string; path: string;
reply?: string; reply?: string;
verbose?: boolean; verbose?: boolean;
yes?: boolean; yes?: boolean;
ingress?: "tailscale" | "none"; ingress?: "tailscale" | "none";
dryRun?: boolean; dryRun?: boolean;
}, },
deps: CliDeps, deps: CliDeps,
runtime: RuntimeEnv, runtime: RuntimeEnv,
) { ) {
const port = Number.parseInt(opts.port, 10); const port = Number.parseInt(opts.port, 10);
if (Number.isNaN(port) || port <= 0 || port >= 65536) { if (Number.isNaN(port) || port <= 0 || port >= 65536) {
throw new Error("Port must be between 1 and 65535"); throw new Error("Port must be between 1 and 65535");
} }
const ingress = opts.ingress ?? "tailscale"; const ingress = opts.ingress ?? "tailscale";
// Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update). // Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update).
if (ingress === "tailscale") { if (ingress === "tailscale") {
const result = await upCommand( const result = await upCommand(
{ {
port: opts.port, port: opts.port,
path: opts.path, path: opts.path,
verbose: opts.verbose, verbose: opts.verbose,
yes: opts.yes, yes: opts.yes,
dryRun: opts.dryRun, dryRun: opts.dryRun,
}, },
deps, deps,
runtime, runtime,
); );
return result.server; return result.server;
} }
// Local-only webhook (no ingress / no Twilio update). // Local-only webhook (no ingress / no Twilio update).
await deps.ensurePortAvailable(port); await deps.ensurePortAvailable(port);
if (opts.reply === "dry-run" || opts.dryRun) { if (opts.reply === "dry-run" || opts.dryRun) {
runtime.log( runtime.log(
`[dry-run] would start webhook on port ${port} path ${opts.path}`, `[dry-run] would start webhook on port ${port} path ${opts.path}`,
); );
return undefined; return undefined;
} }
const server = await retryAsync( const server = await retryAsync(
() => () =>
deps.startWebhook( deps.startWebhook(
port, port,
opts.path, opts.path,
opts.reply, opts.reply,
Boolean(opts.verbose), Boolean(opts.verbose),
runtime, runtime,
), ),
3, 3,
300, 300,
); );
return server; return server;
} }

View File

@ -10,145 +10,145 @@ export type ClaudeOutputFormat = "text" | "json" | "stream-json";
export type SessionScope = "per-sender" | "global"; export type SessionScope = "per-sender" | "global";
export type SessionConfig = { export type SessionConfig = {
scope?: SessionScope; scope?: SessionScope;
resetTriggers?: string[]; resetTriggers?: string[];
idleMinutes?: number; idleMinutes?: number;
store?: string; store?: string;
sessionArgNew?: string[]; sessionArgNew?: string[];
sessionArgResume?: string[]; sessionArgResume?: string[];
sessionArgBeforeBody?: boolean; sessionArgBeforeBody?: boolean;
sendSystemOnce?: boolean; sendSystemOnce?: boolean;
sessionIntro?: string; sessionIntro?: string;
typingIntervalSeconds?: number; typingIntervalSeconds?: number;
}; };
export type LoggingConfig = { export type LoggingConfig = {
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace"; level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
file?: string; file?: string;
}; };
export type WarelayConfig = { export type WarelayConfig = {
logging?: LoggingConfig; logging?: LoggingConfig;
inbound?: { inbound?: {
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
transcribeAudio?: { transcribeAudio?: {
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
command: string[]; command: string[];
timeoutSeconds?: number; timeoutSeconds?: number;
}; };
reply?: { reply?: {
mode: ReplyMode; mode: ReplyMode;
text?: string; // for mode=text, can contain {{Body}} text?: string; // for mode=text, can contain {{Body}}
command?: string[]; // for mode=command, argv with templates command?: string[]; // for mode=command, argv with templates
cwd?: string; // working directory for command execution cwd?: string; // working directory for command execution
template?: string; // prepend template string when building command/prompt template?: string; // prepend template string when building command/prompt
timeoutSeconds?: number; // optional command timeout; defaults to 600s timeoutSeconds?: number; // optional command timeout; defaults to 600s
bodyPrefix?: string; // optional string prepended to Body before templating bodyPrefix?: string; // optional string prepended to Body before templating
mediaUrl?: string; // optional media attachment (path or URL) mediaUrl?: string; // optional media attachment (path or URL)
session?: SessionConfig; session?: SessionConfig;
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
mediaMaxMb?: number; // optional cap for outbound media (default 5MB) mediaMaxMb?: number; // optional cap for outbound media (default 5MB)
typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs
}; };
}; };
}; };
export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json"); export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json");
const ReplySchema = z const ReplySchema = z
.object({ .object({
mode: z.union([z.literal("text"), z.literal("command")]), mode: z.union([z.literal("text"), z.literal("command")]),
text: z.string().optional(), text: z.string().optional(),
command: z.array(z.string()).optional(), command: z.array(z.string()).optional(),
cwd: z.string().optional(), cwd: z.string().optional(),
template: z.string().optional(), template: z.string().optional(),
timeoutSeconds: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(),
bodyPrefix: z.string().optional(), bodyPrefix: z.string().optional(),
mediaUrl: z.string().optional(), mediaUrl: z.string().optional(),
mediaMaxMb: z.number().positive().optional(), mediaMaxMb: z.number().positive().optional(),
typingIntervalSeconds: z.number().int().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(),
session: z session: z
.object({ .object({
scope: z scope: z
.union([z.literal("per-sender"), z.literal("global")]) .union([z.literal("per-sender"), z.literal("global")])
.optional(), .optional(),
resetTriggers: z.array(z.string()).optional(), resetTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(), idleMinutes: z.number().int().positive().optional(),
store: z.string().optional(), store: z.string().optional(),
sessionArgNew: z.array(z.string()).optional(), sessionArgNew: z.array(z.string()).optional(),
sessionArgResume: z.array(z.string()).optional(), sessionArgResume: z.array(z.string()).optional(),
sessionArgBeforeBody: z.boolean().optional(), sessionArgBeforeBody: z.boolean().optional(),
sendSystemOnce: z.boolean().optional(), sendSystemOnce: z.boolean().optional(),
sessionIntro: z.string().optional(), sessionIntro: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(),
}) })
.optional(), .optional(),
claudeOutputFormat: z claudeOutputFormat: z
.union([ .union([
z.literal("text"), z.literal("text"),
z.literal("json"), z.literal("json"),
z.literal("stream-json"), z.literal("stream-json"),
z.undefined(), z.undefined(),
]) ])
.optional(), .optional(),
}) })
.refine( .refine(
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)), (val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
{ {
message: message:
"reply.text is required for mode=text; reply.command is required for mode=command", "reply.text is required for mode=text; reply.command is required for mode=command",
}, },
); );
const WarelaySchema = z.object({ const WarelaySchema = z.object({
logging: z logging: z
.object({ .object({
level: z level: z
.union([ .union([
z.literal("silent"), z.literal("silent"),
z.literal("fatal"), z.literal("fatal"),
z.literal("error"), z.literal("error"),
z.literal("warn"), z.literal("warn"),
z.literal("info"), z.literal("info"),
z.literal("debug"), z.literal("debug"),
z.literal("trace"), z.literal("trace"),
]) ])
.optional(), .optional(),
file: z.string().optional(), file: z.string().optional(),
}) })
.optional(), .optional(),
inbound: z inbound: z
.object({ .object({
allowFrom: z.array(z.string()).optional(), allowFrom: z.array(z.string()).optional(),
transcribeAudio: z transcribeAudio: z
.object({ .object({
command: z.array(z.string()), command: z.array(z.string()),
timeoutSeconds: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(),
}) })
.optional(), .optional(),
reply: ReplySchema.optional(), reply: ReplySchema.optional(),
}) })
.optional(), .optional(),
}); });
export function loadConfig(): WarelayConfig { export function loadConfig(): WarelayConfig {
// Read ~/.warelay/warelay.json (JSON5) if present. // Read ~/.warelay/warelay.json (JSON5) if present.
try { try {
if (!fs.existsSync(CONFIG_PATH)) return {}; if (!fs.existsSync(CONFIG_PATH)) return {};
const raw = fs.readFileSync(CONFIG_PATH, "utf-8"); const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
const parsed = JSON5.parse(raw); const parsed = JSON5.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {}; if (typeof parsed !== "object" || parsed === null) return {};
const validated = WarelaySchema.safeParse(parsed); const validated = WarelaySchema.safeParse(parsed);
if (!validated.success) { if (!validated.success) {
console.error("Invalid warelay config:"); console.error("Invalid warelay config:");
for (const iss of validated.error.issues) { for (const iss of validated.error.issues) {
console.error(`- ${iss.path.join(".")}: ${iss.message}`); console.error(`- ${iss.path.join(".")}: ${iss.message}`);
} }
return {}; return {};
} }
return validated.data as WarelayConfig; return validated.data as WarelayConfig;
} catch (err) { } catch (err) {
console.error(`Failed to read config at ${CONFIG_PATH}`, err); console.error(`Failed to read config at ${CONFIG_PATH}`, err);
return {}; return {};
} }
} }

View File

@ -3,17 +3,17 @@ import { describe, expect, it } from "vitest";
import { deriveSessionKey } from "./sessions.js"; import { deriveSessionKey } from "./sessions.js";
describe("sessions", () => { describe("sessions", () => {
it("returns normalized per-sender key", () => { it("returns normalized per-sender key", () => {
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe( expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
"+1555", "+1555",
); );
}); });
it("falls back to unknown when sender missing", () => { it("falls back to unknown when sender missing", () => {
expect(deriveSessionKey("per-sender", {})).toBe("unknown"); expect(deriveSessionKey("per-sender", {})).toBe("unknown");
}); });
it("global scope returns global", () => { it("global scope returns global", () => {
expect(deriveSessionKey("global", { From: "+1" })).toBe("global"); expect(deriveSessionKey("global", { From: "+1" })).toBe("global");
}); });
}); });

View File

@ -9,9 +9,9 @@ import { CONFIG_DIR, normalizeE164 } from "../utils.js";
export type SessionScope = "per-sender" | "global"; export type SessionScope = "per-sender" | "global";
export type SessionEntry = { export type SessionEntry = {
sessionId: string; sessionId: string;
updatedAt: number; updatedAt: number;
systemSent?: boolean; systemSent?: boolean;
}; };
export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json"); export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
@ -19,42 +19,42 @@ export const DEFAULT_RESET_TRIGGER = "/new";
export const DEFAULT_IDLE_MINUTES = 60; export const DEFAULT_IDLE_MINUTES = 60;
export function resolveStorePath(store?: string) { export function resolveStorePath(store?: string) {
if (!store) return SESSION_STORE_DEFAULT; if (!store) return SESSION_STORE_DEFAULT;
if (store.startsWith("~")) if (store.startsWith("~"))
return path.resolve(store.replace("~", os.homedir())); return path.resolve(store.replace("~", os.homedir()));
return path.resolve(store); return path.resolve(store);
} }
export function loadSessionStore( export function loadSessionStore(
storePath: string, storePath: string,
): Record<string, SessionEntry> { ): Record<string, SessionEntry> {
try { try {
const raw = fs.readFileSync(storePath, "utf-8"); const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw); const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") { if (parsed && typeof parsed === "object") {
return parsed as Record<string, SessionEntry>; return parsed as Record<string, SessionEntry>;
} }
} catch { } catch {
// ignore missing/invalid store; we'll recreate it // ignore missing/invalid store; we'll recreate it
} }
return {}; return {};
} }
export async function saveSessionStore( export async function saveSessionStore(
storePath: string, storePath: string,
store: Record<string, SessionEntry>, store: Record<string, SessionEntry>,
) { ) {
await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
await fs.promises.writeFile( await fs.promises.writeFile(
storePath, storePath,
JSON.stringify(store, null, 2), JSON.stringify(store, null, 2),
"utf-8", "utf-8",
); );
} }
// Decide which session bucket to use (per-sender vs global). // Decide which session bucket to use (per-sender vs global).
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) { export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
if (scope === "global") return "global"; if (scope === "global") return "global";
const from = ctx.From ? normalizeE164(ctx.From) : ""; const from = ctx.From ? normalizeE164(ctx.From) : "";
return from || "unknown"; return from || "unknown";
} }

View File

@ -4,94 +4,94 @@ import { ensureTwilioEnv, readEnv } from "./env.js";
import type { RuntimeEnv } from "./runtime.js"; import type { RuntimeEnv } from "./runtime.js";
const baseEnv = { const baseEnv = {
TWILIO_ACCOUNT_SID: "AC123", TWILIO_ACCOUNT_SID: "AC123",
TWILIO_WHATSAPP_FROM: "whatsapp:+1555", TWILIO_WHATSAPP_FROM: "whatsapp:+1555",
}; };
describe("env helpers", () => { describe("env helpers", () => {
const runtime: RuntimeEnv = { const runtime: RuntimeEnv = {
log: vi.fn(), log: vi.fn(),
error: vi.fn(), error: vi.fn(),
exit: vi.fn(() => { exit: vi.fn(() => {
throw new Error("exit"); throw new Error("exit");
}), }),
}; };
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
process.env = {}; process.env = {};
}); });
function setEnv(vars: Record<string, string | undefined>) { function setEnv(vars: Record<string, string | undefined>) {
process.env = {}; process.env = {};
for (const [k, v] of Object.entries(vars)) { for (const [k, v] of Object.entries(vars)) {
if (v === undefined) delete process.env[k]; if (v === undefined) delete process.env[k];
else process.env[k] = v; else process.env[k] = v;
} }
} }
it("reads env with auth token", () => { it("reads env with auth token", () => {
setEnv({ setEnv({
...baseEnv, ...baseEnv,
TWILIO_AUTH_TOKEN: "token", TWILIO_AUTH_TOKEN: "token",
TWILIO_API_KEY: undefined, TWILIO_API_KEY: undefined,
TWILIO_API_SECRET: undefined, TWILIO_API_SECRET: undefined,
}); });
const cfg = readEnv(runtime); const cfg = readEnv(runtime);
expect(cfg.accountSid).toBe("AC123"); expect(cfg.accountSid).toBe("AC123");
expect(cfg.whatsappFrom).toBe("whatsapp:+1555"); expect(cfg.whatsappFrom).toBe("whatsapp:+1555");
if ("authToken" in cfg.auth) { if ("authToken" in cfg.auth) {
expect(cfg.auth.authToken).toBe("token"); expect(cfg.auth.authToken).toBe("token");
} else { } else {
throw new Error("Expected auth token"); throw new Error("Expected auth token");
} }
}); });
it("reads env with API key/secret", () => { it("reads env with API key/secret", () => {
setEnv({ setEnv({
...baseEnv, ...baseEnv,
TWILIO_AUTH_TOKEN: undefined, TWILIO_AUTH_TOKEN: undefined,
TWILIO_API_KEY: "key", TWILIO_API_KEY: "key",
TWILIO_API_SECRET: "secret", TWILIO_API_SECRET: "secret",
}); });
const cfg = readEnv(runtime); const cfg = readEnv(runtime);
if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) { if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
expect(cfg.auth.apiKey).toBe("key"); expect(cfg.auth.apiKey).toBe("key");
expect(cfg.auth.apiSecret).toBe("secret"); expect(cfg.auth.apiSecret).toBe("secret");
} else { } else {
throw new Error("Expected API key/secret"); throw new Error("Expected API key/secret");
} }
}); });
it("fails fast on invalid env", () => { it("fails fast on invalid env", () => {
setEnv({ setEnv({
TWILIO_ACCOUNT_SID: "", TWILIO_ACCOUNT_SID: "",
TWILIO_WHATSAPP_FROM: "", TWILIO_WHATSAPP_FROM: "",
TWILIO_AUTH_TOKEN: undefined, TWILIO_AUTH_TOKEN: undefined,
TWILIO_API_KEY: undefined, TWILIO_API_KEY: undefined,
TWILIO_API_SECRET: undefined, TWILIO_API_SECRET: undefined,
}); });
expect(() => readEnv(runtime)).toThrow("exit"); expect(() => readEnv(runtime)).toThrow("exit");
expect(runtime.error).toHaveBeenCalled(); expect(runtime.error).toHaveBeenCalled();
}); });
it("ensureTwilioEnv passes when token present", () => { it("ensureTwilioEnv passes when token present", () => {
setEnv({ setEnv({
...baseEnv, ...baseEnv,
TWILIO_AUTH_TOKEN: "token", TWILIO_AUTH_TOKEN: "token",
TWILIO_API_KEY: undefined, TWILIO_API_KEY: undefined,
TWILIO_API_SECRET: undefined, TWILIO_API_SECRET: undefined,
}); });
expect(() => ensureTwilioEnv(runtime)).not.toThrow(); expect(() => ensureTwilioEnv(runtime)).not.toThrow();
}); });
it("ensureTwilioEnv fails when missing auth", () => { it("ensureTwilioEnv fails when missing auth", () => {
setEnv({ setEnv({
...baseEnv, ...baseEnv,
TWILIO_AUTH_TOKEN: undefined, TWILIO_AUTH_TOKEN: undefined,
TWILIO_API_KEY: undefined, TWILIO_API_KEY: undefined,
TWILIO_API_SECRET: undefined, TWILIO_API_SECRET: undefined,
}); });
expect(() => ensureTwilioEnv(runtime)).toThrow("exit"); expect(() => ensureTwilioEnv(runtime)).toThrow("exit");
}); });
}); });

View File

@ -4,103 +4,103 @@ import { danger } from "./globals.js";
import { defaultRuntime, type RuntimeEnv } from "./runtime.js"; import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
export type AuthMode = export type AuthMode =
| { accountSid: string; authToken: string } | { accountSid: string; authToken: string }
| { accountSid: string; apiKey: string; apiSecret: string }; | { accountSid: string; apiKey: string; apiSecret: string };
export type EnvConfig = { export type EnvConfig = {
accountSid: string; accountSid: string;
whatsappFrom: string; whatsappFrom: string;
whatsappSenderSid?: string; whatsappSenderSid?: string;
auth: AuthMode; auth: AuthMode;
}; };
const EnvSchema = z const EnvSchema = z
.object({ .object({
TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"), TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"),
TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"), TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"),
TWILIO_SENDER_SID: z.string().optional(), TWILIO_SENDER_SID: z.string().optional(),
TWILIO_AUTH_TOKEN: z.string().optional(), TWILIO_AUTH_TOKEN: z.string().optional(),
TWILIO_API_KEY: z.string().optional(), TWILIO_API_KEY: z.string().optional(),
TWILIO_API_SECRET: z.string().optional(), TWILIO_API_SECRET: z.string().optional(),
}) })
.superRefine((val, ctx) => { .superRefine((val, ctx) => {
if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) { if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set", message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set",
}); });
} }
if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) { if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set", message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set",
}); });
} }
if ( if (
!val.TWILIO_AUTH_TOKEN && !val.TWILIO_AUTH_TOKEN &&
!(val.TWILIO_API_KEY && val.TWILIO_API_SECRET) !(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)
) { ) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: message:
"Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET", "Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET",
}); });
} }
}); });
export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig { export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
// Load and validate Twilio auth + sender configuration from env. // Load and validate Twilio auth + sender configuration from env.
const parsed = EnvSchema.safeParse(process.env); const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) { if (!parsed.success) {
runtime.error("Invalid environment configuration:"); runtime.error("Invalid environment configuration:");
parsed.error.issues.forEach((iss) => { parsed.error.issues.forEach((iss) => {
runtime.error(`- ${iss.message}`); runtime.error(`- ${iss.message}`);
}); });
runtime.exit(1); runtime.exit(1);
} }
const { const {
TWILIO_ACCOUNT_SID: accountSid, TWILIO_ACCOUNT_SID: accountSid,
TWILIO_WHATSAPP_FROM: whatsappFrom, TWILIO_WHATSAPP_FROM: whatsappFrom,
TWILIO_SENDER_SID: whatsappSenderSid, TWILIO_SENDER_SID: whatsappSenderSid,
TWILIO_AUTH_TOKEN: authToken, TWILIO_AUTH_TOKEN: authToken,
TWILIO_API_KEY: apiKey, TWILIO_API_KEY: apiKey,
TWILIO_API_SECRET: apiSecret, TWILIO_API_SECRET: apiSecret,
} = parsed.data; } = parsed.data;
let auth: AuthMode; let auth: AuthMode;
if (apiKey && apiSecret) { if (apiKey && apiSecret) {
auth = { accountSid, apiKey, apiSecret }; auth = { accountSid, apiKey, apiSecret };
} else if (authToken) { } else if (authToken) {
auth = { accountSid, authToken }; auth = { accountSid, authToken };
} else { } else {
runtime.error("Missing Twilio auth configuration"); runtime.error("Missing Twilio auth configuration");
runtime.exit(1); runtime.exit(1);
throw new Error("unreachable"); throw new Error("unreachable");
} }
return { return {
accountSid, accountSid,
whatsappFrom, whatsappFrom,
whatsappSenderSid, whatsappSenderSid,
auth, auth,
}; };
} }
export function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) { export function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
// Guardrails: fail fast when Twilio env vars are missing or incomplete. // Guardrails: fail fast when Twilio env vars are missing or incomplete.
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"]; const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
const missing = required.filter((k) => !process.env[k]); const missing = required.filter((k) => !process.env[k]);
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN); const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
const hasKey = Boolean( const hasKey = Boolean(
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET, process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
); );
if (missing.length > 0 || (!hasToken && !hasKey)) { if (missing.length > 0 || (!hasToken && !hasKey)) {
runtime.error( runtime.error(
danger( danger(
`Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`, `Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`,
), ),
); );
runtime.exit(1); runtime.exit(1);
} }
} }

View File

@ -2,28 +2,28 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { isVerbose, isYes, logVerbose, setVerbose, setYes } from "./globals.js"; import { isVerbose, isYes, logVerbose, setVerbose, setYes } from "./globals.js";
describe("globals", () => { describe("globals", () => {
afterEach(() => { afterEach(() => {
setVerbose(false); setVerbose(false);
setYes(false); setYes(false);
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("toggles verbose flag and logs when enabled", () => { it("toggles verbose flag and logs when enabled", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
setVerbose(false); setVerbose(false);
logVerbose("hidden"); logVerbose("hidden");
expect(logSpy).not.toHaveBeenCalled(); expect(logSpy).not.toHaveBeenCalled();
setVerbose(true); setVerbose(true);
logVerbose("shown"); logVerbose("shown");
expect(isVerbose()).toBe(true); expect(isVerbose()).toBe(true);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown")); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown"));
}); });
it("stores yes flag", () => { it("stores yes flag", () => {
setYes(true); setYes(true);
expect(isYes()).toBe(true); expect(isYes()).toBe(true);
setYes(false); setYes(false);
expect(isYes()).toBe(false); expect(isYes()).toBe(false);
}); });
}); });

View File

@ -4,23 +4,23 @@ let globalVerbose = false;
let globalYes = false; let globalYes = false;
export function setVerbose(v: boolean) { export function setVerbose(v: boolean) {
globalVerbose = v; globalVerbose = v;
} }
export function isVerbose() { export function isVerbose() {
return globalVerbose; return globalVerbose;
} }
export function logVerbose(message: string) { export function logVerbose(message: string) {
if (globalVerbose) console.log(chalk.gray(message)); if (globalVerbose) console.log(chalk.gray(message));
} }
export function setYes(v: boolean) { export function setYes(v: boolean) {
globalYes = v; globalYes = v;
} }
export function isYes() { export function isYes() {
return globalYes; return globalYes;
} }
export const success = chalk.green; export const success = chalk.green;

View File

@ -6,134 +6,134 @@ import * as providerWeb from "./provider-web.js";
import { defaultRuntime } from "./runtime.js"; import { defaultRuntime } from "./runtime.js";
vi.mock("twilio", () => { vi.mock("twilio", () => {
const { factory } = createMockTwilio(); const { factory } = createMockTwilio();
return { default: factory }; return { default: factory };
}); });
import * as index from "./index.js"; import * as index from "./index.js";
import * as provider from "./provider-web.js"; import * as provider from "./provider-web.js";
beforeEach(() => { beforeEach(() => {
index.program.exitOverride(); index.program.exitOverride();
process.env.TWILIO_ACCOUNT_SID = "AC123"; process.env.TWILIO_ACCOUNT_SID = "AC123";
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567"; process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
process.env.TWILIO_AUTH_TOKEN = "token"; process.env.TWILIO_AUTH_TOKEN = "token";
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe("CLI commands", () => { describe("CLI commands", () => {
it("exposes login command", () => { it("exposes login command", () => {
const names = index.program.commands.map((c) => c.name()); const names = index.program.commands.map((c) => c.name());
expect(names).toContain("login"); expect(names).toContain("login");
}); });
it("send command routes to web provider", async () => { it("send command routes to web provider", async () => {
const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue(); const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue();
await index.program.parseAsync( await index.program.parseAsync(
[ [
"send", "send",
"--to", "--to",
"+1555", "+1555",
"--message", "--message",
"hi", "hi",
"--provider", "--provider",
"web", "web",
"--wait", "--wait",
"0", "0",
], ],
{ from: "user" }, { from: "user" },
); );
expect(sendWeb).toHaveBeenCalled(); expect(sendWeb).toHaveBeenCalled();
}); });
it("send command uses twilio path when provider=twilio", async () => { it("send command uses twilio path when provider=twilio", async () => {
const twilio = (await import("twilio")).default; const twilio = (await import("twilio")).default;
twilio._client.messages.create.mockResolvedValue({ sid: "SM1" }); twilio._client.messages.create.mockResolvedValue({ sid: "SM1" });
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue(); const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
await index.program.parseAsync( await index.program.parseAsync(
["send", "--to", "+1555", "--message", "hi", "--wait", "0"], ["send", "--to", "+1555", "--message", "hi", "--wait", "0"],
{ from: "user" }, { from: "user" },
); );
expect(twilio._client.messages.create).toHaveBeenCalled(); expect(twilio._client.messages.create).toHaveBeenCalled();
expect(wait).not.toHaveBeenCalled(); expect(wait).not.toHaveBeenCalled();
}); });
it("send command supports dry-run and skips sending", async () => { it("send command supports dry-run and skips sending", async () => {
const twilio = (await import("twilio")).default; const twilio = (await import("twilio")).default;
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue(); const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
await index.program.parseAsync( await index.program.parseAsync(
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"], ["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"],
{ from: "user" }, { from: "user" },
); );
expect(twilio._client.messages.create).not.toHaveBeenCalled(); expect(twilio._client.messages.create).not.toHaveBeenCalled();
expect(wait).not.toHaveBeenCalled(); expect(wait).not.toHaveBeenCalled();
}); });
it("send command outputs JSON when requested", async () => { it("send command outputs JSON when requested", async () => {
const twilio = (await import("twilio")).default; const twilio = (await import("twilio")).default;
twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" }); twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" });
const logSpy = vi.spyOn(defaultRuntime, "log"); const logSpy = vi.spyOn(defaultRuntime, "log");
await index.program.parseAsync( await index.program.parseAsync(
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"], ["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"],
{ from: "user" }, { from: "user" },
); );
expect(logSpy).toHaveBeenCalledWith( expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('"sid": "SMJSON"'), expect.stringContaining('"sid": "SMJSON"'),
); );
}); });
it("login command calls web login", async () => { it("login command calls web login", async () => {
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue(); const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
await index.program.parseAsync(["login"], { from: "user" }); await index.program.parseAsync(["login"], { from: "user" });
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
}); });
it("status command prints JSON", async () => { it("status command prints JSON", async () => {
const twilio = (await import("twilio")).default; const twilio = (await import("twilio")).default;
twilio._client.messages.list twilio._client.messages.list
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
sid: "1", sid: "1",
status: "delivered", status: "delivered",
direction: "inbound", direction: "inbound",
dateCreated: new Date("2024-01-01T00:00:00Z"), dateCreated: new Date("2024-01-01T00:00:00Z"),
from: "a", from: "a",
to: "b", to: "b",
body: "hi", body: "hi",
errorCode: null, errorCode: null,
errorMessage: null, errorMessage: null,
}, },
]) ])
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
sid: "2", sid: "2",
status: "sent", status: "sent",
direction: "outbound-api", direction: "outbound-api",
dateCreated: new Date("2024-01-02T00:00:00Z"), dateCreated: new Date("2024-01-02T00:00:00Z"),
from: "b", from: "b",
to: "a", to: "a",
body: "yo", body: "yo",
errorCode: null, errorCode: null,
errorMessage: null, errorMessage: null,
}, },
]); ]);
const runtime = { const runtime = {
...defaultRuntime, ...defaultRuntime,
log: vi.fn(), log: vi.fn(),
error: vi.fn(), error: vi.fn(),
exit: ((code: number) => { exit: ((code: number) => {
throw new Error(`exit ${code}`); throw new Error(`exit ${code}`);
}) as (code: number) => never, }) as (code: number) => never,
}; };
await statusCommand( await statusCommand(
{ limit: "1", lookback: "10", json: true }, { limit: "1", lookback: "10", json: true },
createDefaultDeps(), createDefaultDeps(),
runtime, runtime,
); );
expect(runtime.log).toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalled();
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

@ -2,28 +2,28 @@ import { describe, expect, it } from "vitest";
import { assertProvider, normalizeE164, toWhatsappJid } from "./index.js"; import { assertProvider, normalizeE164, toWhatsappJid } from "./index.js";
describe("normalizeE164", () => { describe("normalizeE164", () => {
it("strips whatsapp prefix and whitespace", () => { it("strips whatsapp prefix and whitespace", () => {
expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567"); expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567");
}); });
it("adds plus when missing", () => { it("adds plus when missing", () => {
expect(normalizeE164("1555123")).toBe("+1555123"); expect(normalizeE164("1555123")).toBe("+1555123");
}); });
}); });
describe("toWhatsappJid", () => { describe("toWhatsappJid", () => {
it("converts E164 to jid", () => { it("converts E164 to jid", () => {
expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net"); expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net");
}); });
}); });
describe("assertProvider", () => { describe("assertProvider", () => {
it("accepts valid providers", () => { it("accepts valid providers", () => {
expect(() => assertProvider("twilio")).not.toThrow(); expect(() => assertProvider("twilio")).not.toThrow();
expect(() => assertProvider("web")).not.toThrow(); expect(() => assertProvider("web")).not.toThrow();
}); });
it("throws on invalid provider", () => { it("throws on invalid provider", () => {
expect(() => assertProvider("invalid" as string)).toThrow(); expect(() => assertProvider("invalid" as string)).toThrow();
}); });
}); });

View File

@ -4,8 +4,8 @@ import { fileURLToPath } from "node:url";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { import {
autoReplyIfConfigured, autoReplyIfConfigured,
getReplyFromConfig, getReplyFromConfig,
} from "./auto-reply/reply.js"; } from "./auto-reply/reply.js";
import { applyTemplate } from "./auto-reply/templating.js"; import { applyTemplate } from "./auto-reply/templating.js";
import { createDefaultDeps, monitorTwilio } from "./cli/deps.js"; import { createDefaultDeps, monitorTwilio } from "./cli/deps.js";
@ -13,42 +13,42 @@ import { promptYesNo } from "./cli/prompt.js";
import { waitForever } from "./cli/wait.js"; import { waitForever } from "./cli/wait.js";
import { loadConfig } from "./config/config.js"; import { loadConfig } from "./config/config.js";
import { import {
deriveSessionKey, deriveSessionKey,
loadSessionStore, loadSessionStore,
resolveStorePath, resolveStorePath,
saveSessionStore, saveSessionStore,
} from "./config/sessions.js"; } from "./config/sessions.js";
import { readEnv } from "./env.js"; import { readEnv } from "./env.js";
import { ensureBinary } from "./infra/binaries.js"; import { ensureBinary } from "./infra/binaries.js";
import { import {
describePortOwner, describePortOwner,
ensurePortAvailable, ensurePortAvailable,
handlePortError, handlePortError,
PortInUseError, PortInUseError,
} from "./infra/ports.js"; } from "./infra/ports.js";
import { import {
ensureFunnel, ensureFunnel,
ensureGoInstalled, ensureGoInstalled,
ensureTailscaledInstalled, ensureTailscaledInstalled,
getTailnetHostname, getTailnetHostname,
} from "./infra/tailscale.js"; } from "./infra/tailscale.js";
import { runCommandWithTimeout, runExec } from "./process/exec.js"; import { runCommandWithTimeout, runExec } from "./process/exec.js";
import { monitorWebProvider } from "./provider-web.js"; import { monitorWebProvider } from "./provider-web.js";
import { createClient } from "./twilio/client.js"; import { createClient } from "./twilio/client.js";
import { import {
formatMessageLine, formatMessageLine,
listRecentMessages, listRecentMessages,
sortByDateDesc, sortByDateDesc,
uniqueBySid, uniqueBySid,
} from "./twilio/messages.js"; } from "./twilio/messages.js";
import { sendMessage, waitForFinalStatus } from "./twilio/send.js"; import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
import { findWhatsappSenderSid } from "./twilio/senders.js"; import { findWhatsappSenderSid } from "./twilio/senders.js";
import { sendTypingIndicator } from "./twilio/typing.js"; import { sendTypingIndicator } from "./twilio/typing.js";
import { import {
findIncomingNumberSid as findIncomingNumberSidImpl, findIncomingNumberSid as findIncomingNumberSidImpl,
findMessagingServiceSid as findMessagingServiceSidImpl, findMessagingServiceSid as findMessagingServiceSidImpl,
setMessagingServiceWebhook as setMessagingServiceWebhookImpl, setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
updateWebhook as updateWebhookImpl, updateWebhook as updateWebhookImpl,
} from "./twilio/update-webhook.js"; } from "./twilio/update-webhook.js";
import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js"; import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js";
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js"; import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
@ -66,56 +66,56 @@ const setMessagingServiceWebhook = setMessagingServiceWebhookImpl;
const updateWebhook = updateWebhookImpl; const updateWebhook = updateWebhookImpl;
export { export {
assertProvider, assertProvider,
autoReplyIfConfigured, autoReplyIfConfigured,
applyTemplate, applyTemplate,
createClient, createClient,
deriveSessionKey, deriveSessionKey,
describePortOwner, describePortOwner,
ensureBinary, ensureBinary,
ensureFunnel, ensureFunnel,
ensureGoInstalled, ensureGoInstalled,
ensurePortAvailable, ensurePortAvailable,
ensureTailscaledInstalled, ensureTailscaledInstalled,
findIncomingNumberSidImpl as findIncomingNumberSid, findIncomingNumberSidImpl as findIncomingNumberSid,
findMessagingServiceSidImpl as findMessagingServiceSid, findMessagingServiceSidImpl as findMessagingServiceSid,
findWhatsappSenderSid, findWhatsappSenderSid,
formatMessageLine, formatMessageLine,
formatTwilioError, formatTwilioError,
getReplyFromConfig, getReplyFromConfig,
getTailnetHostname, getTailnetHostname,
handlePortError, handlePortError,
logTwilioSendError, logTwilioSendError,
listRecentMessages, listRecentMessages,
loadConfig, loadConfig,
loadSessionStore, loadSessionStore,
monitorTwilio, monitorTwilio,
monitorWebProvider, monitorWebProvider,
normalizeE164, normalizeE164,
PortInUseError, PortInUseError,
promptYesNo, promptYesNo,
createDefaultDeps, createDefaultDeps,
readEnv, readEnv,
resolveStorePath, resolveStorePath,
runCommandWithTimeout, runCommandWithTimeout,
runExec, runExec,
saveSessionStore, saveSessionStore,
sendMessage, sendMessage,
sendTypingIndicator, sendTypingIndicator,
setMessagingServiceWebhook, setMessagingServiceWebhook,
sortByDateDesc, sortByDateDesc,
startWebhook, startWebhook,
updateWebhook, updateWebhook,
uniqueBySid, uniqueBySid,
waitForFinalStatus, waitForFinalStatus,
waitForever, waitForever,
toWhatsappJid, toWhatsappJid,
program, program,
}; };
const isMain = const isMain =
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMain) { if (isMain) {
program.parseAsync(process.argv); program.parseAsync(process.argv);
} }

View File

@ -5,34 +5,34 @@ import type { RuntimeEnv } from "../runtime.js";
import { ensureBinary } from "./binaries.js"; import { ensureBinary } from "./binaries.js";
describe("ensureBinary", () => { describe("ensureBinary", () => {
it("passes through when binary exists", async () => { it("passes through when binary exists", async () => {
const exec: typeof runExec = vi.fn().mockResolvedValue({ const exec: typeof runExec = vi.fn().mockResolvedValue({
stdout: "", stdout: "",
stderr: "", stderr: "",
}); });
const runtime: RuntimeEnv = { const runtime: RuntimeEnv = {
log: vi.fn(), log: vi.fn(),
error: vi.fn(), error: vi.fn(),
exit: vi.fn(), exit: vi.fn(),
}; };
await ensureBinary("node", exec, runtime); await ensureBinary("node", exec, runtime);
expect(exec).toHaveBeenCalledWith("which", ["node"]); expect(exec).toHaveBeenCalledWith("which", ["node"]);
}); });
it("logs and exits when missing", async () => { it("logs and exits when missing", async () => {
const exec: typeof runExec = vi const exec: typeof runExec = vi
.fn() .fn()
.mockRejectedValue(new Error("missing")); .mockRejectedValue(new Error("missing"));
const error = vi.fn(); const error = vi.fn();
const exit = vi.fn(() => { const exit = vi.fn(() => {
throw new Error("exit"); throw new Error("exit");
}); });
await expect( await expect(
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }), ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
).rejects.toThrow("exit"); ).rejects.toThrow("exit");
expect(error).toHaveBeenCalledWith( expect(error).toHaveBeenCalledWith(
"Missing required binary: ghost. Please install it.", "Missing required binary: ghost. Please install it.",
); );
expect(exit).toHaveBeenCalledWith(1); expect(exit).toHaveBeenCalledWith(1);
}); });
}); });

View File

@ -2,13 +2,13 @@ import { runExec } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
export async function ensureBinary( export async function ensureBinary(
name: string, name: string,
exec: typeof runExec = runExec, exec: typeof runExec = runExec,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
): Promise<void> { ): Promise<void> {
// Abort early if a required CLI tool is missing. // Abort early if a required CLI tool is missing.
await exec("which", [name]).catch(() => { await exec("which", [name]).catch(() => {
runtime.error(`Missing required binary: ${name}. Please install it.`); runtime.error(`Missing required binary: ${name}. Please install it.`);
runtime.exit(1); runtime.exit(1);
}); });
} }

View File

@ -2,35 +2,35 @@ import net from "node:net";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
ensurePortAvailable, ensurePortAvailable,
handlePortError, handlePortError,
PortInUseError, PortInUseError,
} from "./ports.js"; } from "./ports.js";
describe("ports helpers", () => { describe("ports helpers", () => {
it("ensurePortAvailable rejects when port busy", async () => { it("ensurePortAvailable rejects when port busy", async () => {
const server = net.createServer(); const server = net.createServer();
await new Promise((resolve) => server.listen(0, resolve)); await new Promise((resolve) => server.listen(0, resolve));
const port = (server.address() as net.AddressInfo).port; const port = (server.address() as net.AddressInfo).port;
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf( await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(
PortInUseError, PortInUseError,
); );
server.close(); server.close();
}); });
it("handlePortError exits nicely on EADDRINUSE", async () => { it("handlePortError exits nicely on EADDRINUSE", async () => {
const runtime = { const runtime = {
error: vi.fn(), error: vi.fn(),
log: vi.fn(), log: vi.fn(),
exit: vi.fn() as unknown as (code: number) => never, exit: vi.fn() as unknown as (code: number) => never,
}; };
await handlePortError( await handlePortError(
{ code: "EADDRINUSE" }, { code: "EADDRINUSE" },
1234, 1234,
"context", "context",
runtime, runtime,
).catch(() => {}); ).catch(() => {});
expect(runtime.error).toHaveBeenCalled(); expect(runtime.error).toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(1); expect(runtime.exit).toHaveBeenCalledWith(1);
}); });
}); });

View File

@ -5,103 +5,103 @@ import { runExec } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
class PortInUseError extends Error { class PortInUseError extends Error {
port: number; port: number;
details?: string; details?: string;
constructor(port: number, details?: string) { constructor(port: number, details?: string) {
super(`Port ${port} is already in use.`); super(`Port ${port} is already in use.`);
this.name = "PortInUseError"; this.name = "PortInUseError";
this.port = port; this.port = port;
this.details = details; this.details = details;
} }
} }
function isErrno(err: unknown): err is NodeJS.ErrnoException { function isErrno(err: unknown): err is NodeJS.ErrnoException {
return Boolean(err && typeof err === "object" && "code" in err); return Boolean(err && typeof err === "object" && "code" in err);
} }
export async function describePortOwner( export async function describePortOwner(
port: number, port: number,
): Promise<string | undefined> { ): Promise<string | undefined> {
// Best-effort process info for a listening port (macOS/Linux). // Best-effort process info for a listening port (macOS/Linux).
try { try {
const { stdout } = await runExec("lsof", [ const { stdout } = await runExec("lsof", [
"-i", "-i",
`tcp:${port}`, `tcp:${port}`,
"-sTCP:LISTEN", "-sTCP:LISTEN",
"-nP", "-nP",
]); ]);
const trimmed = stdout.trim(); const trimmed = stdout.trim();
if (trimmed) return trimmed; if (trimmed) return trimmed;
} catch (err) { } catch (err) {
logVerbose(`lsof unavailable: ${String(err)}`); logVerbose(`lsof unavailable: ${String(err)}`);
} }
return undefined; return undefined;
} }
export async function ensurePortAvailable(port: number): Promise<void> { export async function ensurePortAvailable(port: number): Promise<void> {
// Detect EADDRINUSE early with a friendly message. // Detect EADDRINUSE early with a friendly message.
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const tester = net const tester = net
.createServer() .createServer()
.once("error", (err) => reject(err)) .once("error", (err) => reject(err))
.once("listening", () => { .once("listening", () => {
tester.close(() => resolve()); tester.close(() => resolve());
}) })
.listen(port); .listen(port);
}); });
} catch (err) { } catch (err) {
if (isErrno(err) && err.code === "EADDRINUSE") { if (isErrno(err) && err.code === "EADDRINUSE") {
const details = await describePortOwner(port); const details = await describePortOwner(port);
throw new PortInUseError(port, details); throw new PortInUseError(port, details);
} }
throw err; throw err;
} }
} }
export async function handlePortError( export async function handlePortError(
err: unknown, err: unknown,
port: number, port: number,
context: string, context: string,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
): Promise<never> { ): Promise<never> {
// Uniform messaging for EADDRINUSE with optional owner details. // Uniform messaging for EADDRINUSE with optional owner details.
if ( if (
err instanceof PortInUseError || err instanceof PortInUseError ||
(isErrno(err) && err.code === "EADDRINUSE") (isErrno(err) && err.code === "EADDRINUSE")
) { ) {
const details = const details =
err instanceof PortInUseError err instanceof PortInUseError
? err.details ? err.details
: await describePortOwner(port); : await describePortOwner(port);
runtime.error(danger(`${context} failed: port ${port} is already in use.`)); runtime.error(danger(`${context} failed: port ${port} is already in use.`));
if (details) { if (details) {
runtime.error(info("Port listener details:")); runtime.error(info("Port listener details:"));
runtime.error(details); runtime.error(details);
if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) { if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) {
runtime.error( runtime.error(
warn( warn(
"It looks like another warelay instance is already running. Stop it or pick a different port.", "It looks like another warelay instance is already running. Stop it or pick a different port.",
), ),
); );
} }
} }
runtime.error( runtime.error(
info( info(
"Resolve by stopping the process using the port or passing --port <free-port>.", "Resolve by stopping the process using the port or passing --port <free-port>.",
), ),
); );
runtime.exit(1); runtime.exit(1);
} }
runtime.error(danger(`${context} failed: ${String(err)}`)); runtime.error(danger(`${context} failed: ${String(err)}`));
if (isVerbose()) { if (isVerbose()) {
const stdout = (err as { stdout?: string })?.stdout; const stdout = (err as { stdout?: string })?.stdout;
const stderr = (err as { stderr?: string })?.stderr; const stderr = (err as { stderr?: string })?.stderr;
if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`); if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`);
if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`); if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`);
} }
return runtime.exit(1); return runtime.exit(1);
} }
export { PortInUseError }; export { PortInUseError };

View File

@ -3,26 +3,26 @@ import { describe, expect, it, vi } from "vitest";
import { retryAsync } from "./retry.js"; import { retryAsync } from "./retry.js";
describe("retryAsync", () => { describe("retryAsync", () => {
it("returns on first success", async () => { it("returns on first success", async () => {
const fn = vi.fn().mockResolvedValue("ok"); const fn = vi.fn().mockResolvedValue("ok");
const result = await retryAsync(fn, 3, 10); const result = await retryAsync(fn, 3, 10);
expect(result).toBe("ok"); expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(1); expect(fn).toHaveBeenCalledTimes(1);
}); });
it("retries then succeeds", async () => { it("retries then succeeds", async () => {
const fn = vi const fn = vi
.fn() .fn()
.mockRejectedValueOnce(new Error("fail1")) .mockRejectedValueOnce(new Error("fail1"))
.mockResolvedValueOnce("ok"); .mockResolvedValueOnce("ok");
const result = await retryAsync(fn, 3, 1); const result = await retryAsync(fn, 3, 1);
expect(result).toBe("ok"); expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(2); expect(fn).toHaveBeenCalledTimes(2);
}); });
it("propagates after exhausting retries", async () => { it("propagates after exhausting retries", async () => {
const fn = vi.fn().mockRejectedValue(new Error("boom")); const fn = vi.fn().mockRejectedValue(new Error("boom"));
await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom"); await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom");
expect(fn).toHaveBeenCalledTimes(2); expect(fn).toHaveBeenCalledTimes(2);
}); });
}); });

View File

@ -1,18 +1,18 @@
export async function retryAsync<T>( export async function retryAsync<T>(
fn: () => Promise<T>, fn: () => Promise<T>,
attempts = 3, attempts = 3,
initialDelayMs = 300, initialDelayMs = 300,
): Promise<T> { ): Promise<T> {
let lastErr: unknown; let lastErr: unknown;
for (let i = 0; i < attempts; i += 1) { for (let i = 0; i < attempts; i += 1) {
try { try {
return await fn(); return await fn();
} catch (err) { } catch (err) {
lastErr = err; lastErr = err;
if (i === attempts - 1) break; if (i === attempts - 1) break;
const delay = initialDelayMs * 2 ** i; const delay = initialDelayMs * 2 ** i;
await new Promise((r) => setTimeout(r, delay)); await new Promise((r) => setTimeout(r, delay));
} }
} }
throw lastErr; throw lastErr;
} }

View File

@ -1,61 +1,61 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
ensureGoInstalled, ensureGoInstalled,
ensureTailscaledInstalled, ensureTailscaledInstalled,
getTailnetHostname, getTailnetHostname,
} from "./tailscale.js"; } from "./tailscale.js";
describe("tailscale helpers", () => { describe("tailscale helpers", () => {
it("parses DNS name from tailscale status", async () => { it("parses DNS name from tailscale status", async () => {
const exec = vi.fn().mockResolvedValue({ const exec = vi.fn().mockResolvedValue({
stdout: JSON.stringify({ stdout: JSON.stringify({
Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] }, Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] },
}), }),
}); });
const host = await getTailnetHostname(exec); const host = await getTailnetHostname(exec);
expect(host).toBe("host.tailnet.ts.net"); expect(host).toBe("host.tailnet.ts.net");
}); });
it("falls back to IP when DNS missing", async () => { it("falls back to IP when DNS missing", async () => {
const exec = vi.fn().mockResolvedValue({ const exec = vi.fn().mockResolvedValue({
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }), stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }),
}); });
const host = await getTailnetHostname(exec); const host = await getTailnetHostname(exec);
expect(host).toBe("100.2.2.2"); expect(host).toBe("100.2.2.2");
}); });
it("ensureGoInstalled installs when missing and user agrees", async () => { it("ensureGoInstalled installs when missing and user agrees", async () => {
const exec = vi const exec = vi
.fn() .fn()
.mockRejectedValueOnce(new Error("no go")) .mockRejectedValueOnce(new Error("no go"))
.mockResolvedValue({}); // brew install go .mockResolvedValue({}); // brew install go
const prompt = vi.fn().mockResolvedValue(true); const prompt = vi.fn().mockResolvedValue(true);
const runtime = { const runtime = {
error: vi.fn(), error: vi.fn(),
log: vi.fn(), log: vi.fn(),
exit: ((code: number) => { exit: ((code: number) => {
throw new Error(`exit ${code}`); throw new Error(`exit ${code}`);
}) as (code: number) => never, }) as (code: number) => never,
}; };
await ensureGoInstalled(exec as never, prompt, runtime); await ensureGoInstalled(exec as never, prompt, runtime);
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]); expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
}); });
it("ensureTailscaledInstalled installs when missing and user agrees", async () => { it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
const exec = vi const exec = vi
.fn() .fn()
.mockRejectedValueOnce(new Error("missing")) .mockRejectedValueOnce(new Error("missing"))
.mockResolvedValue({}); .mockResolvedValue({});
const prompt = vi.fn().mockResolvedValue(true); const prompt = vi.fn().mockResolvedValue(true);
const runtime = { const runtime = {
error: vi.fn(), error: vi.fn(),
log: vi.fn(), log: vi.fn(),
exit: ((code: number) => { exit: ((code: number) => {
throw new Error(`exit ${code}`); throw new Error(`exit ${code}`);
}) as (code: number) => never, }) as (code: number) => never,
}; };
await ensureTailscaledInstalled(exec as never, prompt, runtime); await ensureTailscaledInstalled(exec as never, prompt, runtime);
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]); expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
}); });
}); });

View File

@ -6,158 +6,158 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { ensureBinary } from "./binaries.js"; import { ensureBinary } from "./binaries.js";
export async function getTailnetHostname(exec: typeof runExec = runExec) { export async function getTailnetHostname(exec: typeof runExec = runExec) {
// Derive tailnet hostname (or IP fallback) from tailscale status JSON. // Derive tailnet hostname (or IP fallback) from tailscale status JSON.
const { stdout } = await exec("tailscale", ["status", "--json"]); const { stdout } = await exec("tailscale", ["status", "--json"]);
const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {}; const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
const self = const self =
typeof parsed.Self === "object" && parsed.Self !== null typeof parsed.Self === "object" && parsed.Self !== null
? (parsed.Self as Record<string, unknown>) ? (parsed.Self as Record<string, unknown>)
: undefined; : undefined;
const dns = const dns =
typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined; typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined;
const ips = Array.isArray(self?.TailscaleIPs) const ips = Array.isArray(self?.TailscaleIPs)
? (self.TailscaleIPs as string[]) ? (self.TailscaleIPs as string[])
: []; : [];
if (dns && dns.length > 0) return dns.replace(/\.$/, ""); if (dns && dns.length > 0) return dns.replace(/\.$/, "");
if (ips.length > 0) return ips[0]; if (ips.length > 0) return ips[0];
throw new Error("Could not determine Tailscale DNS or IP"); throw new Error("Could not determine Tailscale DNS or IP");
} }
export async function ensureGoInstalled( export async function ensureGoInstalled(
exec: typeof runExec = runExec, exec: typeof runExec = runExec,
prompt: typeof promptYesNo = promptYesNo, prompt: typeof promptYesNo = promptYesNo,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
) { ) {
// Ensure Go toolchain is present; offer Homebrew install if missing. // Ensure Go toolchain is present; offer Homebrew install if missing.
const hasGo = await exec("go", ["version"]).then( const hasGo = await exec("go", ["version"]).then(
() => true, () => true,
() => false, () => false,
); );
if (hasGo) return; if (hasGo) return;
const install = await prompt( const install = await prompt(
"Go is not installed. Install via Homebrew (brew install go)?", "Go is not installed. Install via Homebrew (brew install go)?",
true, true,
); );
if (!install) { if (!install) {
runtime.error("Go is required to build tailscaled from source. Aborting."); runtime.error("Go is required to build tailscaled from source. Aborting.");
runtime.exit(1); runtime.exit(1);
} }
logVerbose("Installing Go via Homebrew…"); logVerbose("Installing Go via Homebrew…");
await exec("brew", ["install", "go"]); await exec("brew", ["install", "go"]);
} }
export async function ensureTailscaledInstalled( export async function ensureTailscaledInstalled(
exec: typeof runExec = runExec, exec: typeof runExec = runExec,
prompt: typeof promptYesNo = promptYesNo, prompt: typeof promptYesNo = promptYesNo,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
) { ) {
// Ensure tailscaled binary exists; install via Homebrew tailscale if missing. // Ensure tailscaled binary exists; install via Homebrew tailscale if missing.
const hasTailscaled = await exec("tailscaled", ["--version"]).then( const hasTailscaled = await exec("tailscaled", ["--version"]).then(
() => true, () => true,
() => false, () => false,
); );
if (hasTailscaled) return; if (hasTailscaled) return;
const install = await prompt( const install = await prompt(
"tailscaled not found. Install via Homebrew (tailscale package)?", "tailscaled not found. Install via Homebrew (tailscale package)?",
true, true,
); );
if (!install) { if (!install) {
runtime.error("tailscaled is required for user-space funnel. Aborting."); runtime.error("tailscaled is required for user-space funnel. Aborting.");
runtime.exit(1); runtime.exit(1);
} }
logVerbose("Installing tailscaled via Homebrew…"); logVerbose("Installing tailscaled via Homebrew…");
await exec("brew", ["install", "tailscale"]); await exec("brew", ["install", "tailscale"]);
} }
export async function ensureFunnel( export async function ensureFunnel(
port: number, port: number,
exec: typeof runExec = runExec, exec: typeof runExec = runExec,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
prompt: typeof promptYesNo = promptYesNo, prompt: typeof promptYesNo = promptYesNo,
) { ) {
// Ensure Funnel is enabled and publish the webhook port. // Ensure Funnel is enabled and publish the webhook port.
try { try {
const statusOut = ( const statusOut = (
await exec("tailscale", ["funnel", "status", "--json"]) await exec("tailscale", ["funnel", "status", "--json"])
).stdout.trim(); ).stdout.trim();
const parsed = statusOut const parsed = statusOut
? (JSON.parse(statusOut) as Record<string, unknown>) ? (JSON.parse(statusOut) as Record<string, unknown>)
: {}; : {};
if (!parsed || Object.keys(parsed).length === 0) { if (!parsed || Object.keys(parsed).length === 0) {
runtime.error( runtime.error(
danger("Tailscale Funnel is not enabled on this tailnet/device."), danger("Tailscale Funnel is not enabled on this tailnet/device."),
); );
runtime.error( runtime.error(
info( info(
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)", "Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
), ),
); );
runtime.error( runtime.error(
info( info(
"macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS", "macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS",
), ),
); );
const proceed = await prompt( const proceed = await prompt(
"Attempt local setup with user-space tailscaled?", "Attempt local setup with user-space tailscaled?",
true, true,
); );
if (!proceed) runtime.exit(1); if (!proceed) runtime.exit(1);
await ensureBinary("brew", exec, runtime); await ensureBinary("brew", exec, runtime);
await ensureGoInstalled(exec, prompt, runtime); await ensureGoInstalled(exec, prompt, runtime);
await ensureTailscaledInstalled(exec, prompt, runtime); await ensureTailscaledInstalled(exec, prompt, runtime);
} }
logVerbose(`Enabling funnel on port ${port}`); logVerbose(`Enabling funnel on port ${port}`);
const { stdout } = await exec( const { stdout } = await exec(
"tailscale", "tailscale",
["funnel", "--yes", "--bg", `${port}`], ["funnel", "--yes", "--bg", `${port}`],
{ {
maxBuffer: 200_000, maxBuffer: 200_000,
timeoutMs: 15_000, timeoutMs: 15_000,
}, },
); );
if (stdout.trim()) console.log(stdout.trim()); if (stdout.trim()) console.log(stdout.trim());
} catch (err) { } catch (err) {
const errOutput = err as { stdout?: unknown; stderr?: unknown }; const errOutput = err as { stdout?: unknown; stderr?: unknown };
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : ""; const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : ""; const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
if (stdout.includes("Funnel is not enabled")) { if (stdout.includes("Funnel is not enabled")) {
console.error(danger("Funnel is not enabled on this tailnet/device.")); console.error(danger("Funnel is not enabled on this tailnet/device."));
const linkMatch = stdout.match(/https?:\/\/\S+/); const linkMatch = stdout.match(/https?:\/\/\S+/);
if (linkMatch) { if (linkMatch) {
console.error(info(`Enable it here: ${linkMatch[0]}`)); console.error(info(`Enable it here: ${linkMatch[0]}`));
} else { } else {
console.error( console.error(
info( info(
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)", "Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
), ),
); );
} }
} }
if ( if (
stderr.includes("client version") || stderr.includes("client version") ||
stdout.includes("client version") stdout.includes("client version")
) { ) {
console.error( console.error(
warn( warn(
"Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.", "Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.",
), ),
); );
} }
runtime.error( runtime.error(
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?", "Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
); );
runtime.error( runtime.error(
info( info(
"Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`", "Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
), ),
); );
if (isVerbose()) { if (isVerbose()) {
if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`)); if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`));
if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`)); if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`));
runtime.error(err as Error); runtime.error(err as Error);
} }
runtime.exit(1); runtime.exit(1);
} }
} }

View File

@ -11,72 +11,72 @@ import { resetLogger, setLoggerOverride } from "./logging.js";
import type { RuntimeEnv } from "./runtime.js"; import type { RuntimeEnv } from "./runtime.js";
describe("logger helpers", () => { describe("logger helpers", () => {
afterEach(() => { afterEach(() => {
resetLogger(); resetLogger();
setLoggerOverride(null); setLoggerOverride(null);
setVerbose(false); setVerbose(false);
}); });
it("formats messages through runtime log/error", () => { it("formats messages through runtime log/error", () => {
const log = vi.fn(); const log = vi.fn();
const error = vi.fn(); const error = vi.fn();
const runtime: RuntimeEnv = { log, error, exit: vi.fn() }; const runtime: RuntimeEnv = { log, error, exit: vi.fn() };
logInfo("info", runtime); logInfo("info", runtime);
logWarn("warn", runtime); logWarn("warn", runtime);
logSuccess("ok", runtime); logSuccess("ok", runtime);
logError("bad", runtime); logError("bad", runtime);
expect(log).toHaveBeenCalledTimes(3); expect(log).toHaveBeenCalledTimes(3);
expect(error).toHaveBeenCalledTimes(1); expect(error).toHaveBeenCalledTimes(1);
}); });
it("only logs debug when verbose is enabled", () => { it("only logs debug when verbose is enabled", () => {
const logVerbose = vi.spyOn(console, "log"); const logVerbose = vi.spyOn(console, "log");
setVerbose(false); setVerbose(false);
logDebug("quiet"); logDebug("quiet");
expect(logVerbose).not.toHaveBeenCalled(); expect(logVerbose).not.toHaveBeenCalled();
setVerbose(true); setVerbose(true);
logVerbose.mockClear(); logVerbose.mockClear();
logDebug("loud"); logDebug("loud");
expect(logVerbose).toHaveBeenCalled(); expect(logVerbose).toHaveBeenCalled();
logVerbose.mockRestore(); logVerbose.mockRestore();
}); });
it("writes to configured log file at configured level", () => { it("writes to configured log file at configured level", () => {
const logPath = pathForTest(); const logPath = pathForTest();
cleanup(logPath); cleanup(logPath);
setLoggerOverride({ level: "debug", file: logPath }); setLoggerOverride({ level: "debug", file: logPath });
logInfo("hello"); logInfo("hello");
logDebug("debug-only"); logDebug("debug-only");
const content = fs.readFileSync(logPath, "utf-8"); const content = fs.readFileSync(logPath, "utf-8");
expect(content).toContain("hello"); expect(content).toContain("hello");
expect(content).toContain("debug-only"); expect(content).toContain("debug-only");
cleanup(logPath); cleanup(logPath);
}); });
it("filters messages below configured level", () => { it("filters messages below configured level", () => {
const logPath = pathForTest(); const logPath = pathForTest();
cleanup(logPath); cleanup(logPath);
setLoggerOverride({ level: "warn", file: logPath }); setLoggerOverride({ level: "warn", file: logPath });
logInfo("info-only"); logInfo("info-only");
logWarn("warn-only"); logWarn("warn-only");
const content = fs.readFileSync(logPath, "utf-8"); const content = fs.readFileSync(logPath, "utf-8");
expect(content).not.toContain("info-only"); expect(content).not.toContain("info-only");
expect(content).toContain("warn-only"); expect(content).toContain("warn-only");
cleanup(logPath); cleanup(logPath);
}); });
}); });
function pathForTest() { function pathForTest() {
return path.join(os.tmpdir(), `warelay-log-${crypto.randomUUID()}.log`); return path.join(os.tmpdir(), `warelay-log-${crypto.randomUUID()}.log`);
} }
function cleanup(file: string) { function cleanup(file: string) {
try { try {
fs.rmSync(file, { force: true }); fs.rmSync(file, { force: true });
} catch { } catch {
// ignore // ignore
} }
} }

View File

@ -1,42 +1,42 @@
import { import {
danger, danger,
info, info,
isVerbose, isVerbose,
logVerbose, logVerbose,
success, success,
warn, warn,
} from "./globals.js"; } from "./globals.js";
import { getLogger } from "./logging.js"; import { getLogger } from "./logging.js";
import { defaultRuntime, type RuntimeEnv } from "./runtime.js"; import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) { export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) {
runtime.log(info(message)); runtime.log(info(message));
getLogger().info(message); getLogger().info(message);
} }
export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) { export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) {
runtime.log(warn(message)); runtime.log(warn(message));
getLogger().warn(message); getLogger().warn(message);
} }
export function logSuccess( export function logSuccess(
message: string, message: string,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
) { ) {
runtime.log(success(message)); runtime.log(success(message));
getLogger().info(message); getLogger().info(message);
} }
export function logError( export function logError(
message: string, message: string,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
) { ) {
runtime.error(danger(message)); runtime.error(danger(message));
getLogger().error(message); getLogger().error(message);
} }
export function logDebug(message: string) { export function logDebug(message: string) {
// Always emit to file logger (level-filtered); console only when verbose. // Always emit to file logger (level-filtered); console only when verbose.
getLogger().debug(message); getLogger().debug(message);
if (isVerbose()) logVerbose(message); if (isVerbose()) logVerbose(message);
} }

View File

@ -10,23 +10,23 @@ const DEFAULT_LOG_DIR = path.join(os.tmpdir(), "warelay");
export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "warelay.log"); export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "warelay.log");
const ALLOWED_LEVELS: readonly LevelWithSilent[] = [ const ALLOWED_LEVELS: readonly LevelWithSilent[] = [
"silent", "silent",
"fatal", "fatal",
"error", "error",
"warn", "warn",
"info", "info",
"debug", "debug",
"trace", "trace",
]; ];
export type LoggerSettings = { export type LoggerSettings = {
level?: LevelWithSilent; level?: LevelWithSilent;
file?: string; file?: string;
}; };
type ResolvedSettings = { type ResolvedSettings = {
level: LevelWithSilent; level: LevelWithSilent;
file: string; file: string;
}; };
let cachedLogger: Logger | null = null; let cachedLogger: Logger | null = null;
@ -34,68 +34,68 @@ let cachedSettings: ResolvedSettings | null = null;
let overrideSettings: LoggerSettings | null = null; let overrideSettings: LoggerSettings | null = null;
function normalizeLevel(level?: string): LevelWithSilent { function normalizeLevel(level?: string): LevelWithSilent {
if (isVerbose()) return "debug"; if (isVerbose()) return "debug";
const candidate = level ?? "info"; const candidate = level ?? "info";
return ALLOWED_LEVELS.includes(candidate as LevelWithSilent) return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
? (candidate as LevelWithSilent) ? (candidate as LevelWithSilent)
: "info"; : "info";
} }
function resolveSettings(): ResolvedSettings { function resolveSettings(): ResolvedSettings {
const cfg: WarelayConfig["logging"] | undefined = const cfg: WarelayConfig["logging"] | undefined =
overrideSettings ?? loadConfig().logging; overrideSettings ?? loadConfig().logging;
const level = normalizeLevel(cfg?.level); const level = normalizeLevel(cfg?.level);
const file = cfg?.file ?? DEFAULT_LOG_FILE; const file = cfg?.file ?? DEFAULT_LOG_FILE;
return { level, file }; return { level, file };
} }
function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) { function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
if (!a) return true; if (!a) return true;
return a.level !== b.level || a.file !== b.file; return a.level !== b.level || a.file !== b.file;
} }
function buildLogger(settings: ResolvedSettings): Logger { function buildLogger(settings: ResolvedSettings): Logger {
fs.mkdirSync(path.dirname(settings.file), { recursive: true }); fs.mkdirSync(path.dirname(settings.file), { recursive: true });
const destination = pino.destination({ const destination = pino.destination({
dest: settings.file, dest: settings.file,
mkdir: true, mkdir: true,
sync: true, // deterministic for tests; log volume is modest. sync: true, // deterministic for tests; log volume is modest.
}); });
return pino( return pino(
{ {
level: settings.level, level: settings.level,
base: undefined, base: undefined,
timestamp: pino.stdTimeFunctions.isoTime, timestamp: pino.stdTimeFunctions.isoTime,
}, },
destination, destination,
); );
} }
export function getLogger(): Logger { export function getLogger(): Logger {
const settings = resolveSettings(); const settings = resolveSettings();
if (!cachedLogger || settingsChanged(cachedSettings, settings)) { if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
cachedLogger = buildLogger(settings); cachedLogger = buildLogger(settings);
cachedSettings = settings; cachedSettings = settings;
} }
return cachedLogger; return cachedLogger;
} }
export function getChildLogger( export function getChildLogger(
bindings?: Bindings, bindings?: Bindings,
opts?: { level?: LevelWithSilent }, opts?: { level?: LevelWithSilent },
): Logger { ): Logger {
return getLogger().child(bindings ?? {}, opts); return getLogger().child(bindings ?? {}, opts);
} }
// Test helpers // Test helpers
export function setLoggerOverride(settings: LoggerSettings | null) { export function setLoggerOverride(settings: LoggerSettings | null) {
overrideSettings = settings; overrideSettings = settings;
cachedLogger = null; cachedLogger = null;
cachedSettings = null; cachedSettings = null;
} }
export function resetLogger() { export function resetLogger() {
cachedLogger = null; cachedLogger = null;
cachedSettings = null; cachedSettings = null;
overrideSettings = null; overrideSettings = null;
} }

View File

@ -6,26 +6,26 @@ export const MAX_DOCUMENT_BYTES = 100 * 1024 * 1024; // 100MB
export type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; export type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
export function mediaKindFromMime(mime?: string | null): MediaKind { export function mediaKindFromMime(mime?: string | null): MediaKind {
if (!mime) return "unknown"; if (!mime) return "unknown";
if (mime.startsWith("image/")) return "image"; if (mime.startsWith("image/")) return "image";
if (mime.startsWith("audio/")) return "audio"; if (mime.startsWith("audio/")) return "audio";
if (mime.startsWith("video/")) return "video"; if (mime.startsWith("video/")) return "video";
if (mime === "application/pdf") return "document"; if (mime === "application/pdf") return "document";
if (mime.startsWith("application/")) return "document"; if (mime.startsWith("application/")) return "document";
return "unknown"; return "unknown";
} }
export function maxBytesForKind(kind: MediaKind): number { export function maxBytesForKind(kind: MediaKind): number {
switch (kind) { switch (kind) {
case "image": case "image":
return MAX_IMAGE_BYTES; return MAX_IMAGE_BYTES;
case "audio": case "audio":
return MAX_AUDIO_BYTES; return MAX_AUDIO_BYTES;
case "video": case "video":
return MAX_VIDEO_BYTES; return MAX_VIDEO_BYTES;
case "document": case "document":
return MAX_DOCUMENT_BYTES; return MAX_DOCUMENT_BYTES;
default: default:
return MAX_DOCUMENT_BYTES; return MAX_DOCUMENT_BYTES;
} }
} }

View File

@ -12,11 +12,11 @@ const logInfo = vi.fn();
vi.mock("./store.js", () => ({ saveMediaSource })); vi.mock("./store.js", () => ({ saveMediaSource }));
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname })); vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
vi.mock("../infra/ports.js", async () => { vi.mock("../infra/ports.js", async () => {
const actual = const actual =
await vi.importActual<typeof import("../infra/ports.js")>( await vi.importActual<typeof import("../infra/ports.js")>(
"../infra/ports.js", "../infra/ports.js",
); );
return { ensurePortAvailable, PortInUseError: actual.PortInUseError }; return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
}); });
vi.mock("./server.js", () => ({ startMediaServer })); vi.mock("./server.js", () => ({ startMediaServer }));
vi.mock("../logger.js", () => ({ logInfo })); vi.mock("../logger.js", () => ({ logInfo }));
@ -25,69 +25,69 @@ const { ensureMediaHosted } = await import("./host.js");
const { PortInUseError } = await import("../infra/ports.js"); const { PortInUseError } = await import("../infra/ports.js");
describe("ensureMediaHosted", () => { describe("ensureMediaHosted", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("throws and cleans up when server not allowed to start", async () => { it("throws and cleans up when server not allowed to start", async () => {
saveMediaSource.mockResolvedValue({ saveMediaSource.mockResolvedValue({
id: "id1", id: "id1",
path: "/tmp/file1", path: "/tmp/file1",
size: 5, size: 5,
}); });
getTailnetHostname.mockResolvedValue("tailnet-host"); getTailnetHostname.mockResolvedValue("tailnet-host");
ensurePortAvailable.mockResolvedValue(undefined); ensurePortAvailable.mockResolvedValue(undefined);
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined); const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
await expect( await expect(
ensureMediaHosted("/tmp/file1", { startServer: false }), ensureMediaHosted("/tmp/file1", { startServer: false }),
).rejects.toThrow("requires the webhook/Funnel server"); ).rejects.toThrow("requires the webhook/Funnel server");
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1"); expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
rmSpy.mockRestore(); rmSpy.mockRestore();
}); });
it("starts media server when allowed", async () => { it("starts media server when allowed", async () => {
saveMediaSource.mockResolvedValue({ saveMediaSource.mockResolvedValue({
id: "id2", id: "id2",
path: "/tmp/file2", path: "/tmp/file2",
size: 9, size: 9,
}); });
getTailnetHostname.mockResolvedValue("tail.net"); getTailnetHostname.mockResolvedValue("tail.net");
ensurePortAvailable.mockResolvedValue(undefined); ensurePortAvailable.mockResolvedValue(undefined);
const fakeServer = { unref: vi.fn() } as unknown as Server; const fakeServer = { unref: vi.fn() } as unknown as Server;
startMediaServer.mockResolvedValue(fakeServer); startMediaServer.mockResolvedValue(fakeServer);
const result = await ensureMediaHosted("/tmp/file2", { const result = await ensureMediaHosted("/tmp/file2", {
startServer: true, startServer: true,
port: 1234, port: 1234,
}); });
expect(startMediaServer).toHaveBeenCalledWith( expect(startMediaServer).toHaveBeenCalledWith(
1234, 1234,
expect.any(Number), expect.any(Number),
expect.anything(), expect.anything(),
); );
expect(logInfo).toHaveBeenCalled(); expect(logInfo).toHaveBeenCalled();
expect(result).toEqual({ expect(result).toEqual({
url: "https://tail.net/media/id2", url: "https://tail.net/media/id2",
id: "id2", id: "id2",
size: 9, size: 9,
}); });
}); });
it("skips server start when port already in use", async () => { it("skips server start when port already in use", async () => {
saveMediaSource.mockResolvedValue({ saveMediaSource.mockResolvedValue({
id: "id3", id: "id3",
path: "/tmp/file3", path: "/tmp/file3",
size: 7, size: 7,
}); });
getTailnetHostname.mockResolvedValue("tail.net"); getTailnetHostname.mockResolvedValue("tail.net");
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc")); ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
const result = await ensureMediaHosted("/tmp/file3", { const result = await ensureMediaHosted("/tmp/file3", {
startServer: false, startServer: false,
port: 3000, port: 3000,
}); });
expect(startMediaServer).not.toHaveBeenCalled(); expect(startMediaServer).not.toHaveBeenCalled();
expect(result.url).toBe("https://tail.net/media/id3"); expect(result.url).toBe("https://tail.net/media/id3");
}); });
}); });

View File

@ -12,54 +12,54 @@ const TTL_MS = 2 * 60 * 1000;
let mediaServer: import("http").Server | null = null; let mediaServer: import("http").Server | null = null;
export type HostedMedia = { export type HostedMedia = {
url: string; url: string;
id: string; id: string;
size: number; size: number;
}; };
export async function ensureMediaHosted( export async function ensureMediaHosted(
source: string, source: string,
opts: { opts: {
port?: number; port?: number;
startServer?: boolean; startServer?: boolean;
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
} = {}, } = {},
): Promise<HostedMedia> { ): Promise<HostedMedia> {
const port = opts.port ?? DEFAULT_PORT; const port = opts.port ?? DEFAULT_PORT;
const runtime = opts.runtime ?? defaultRuntime; const runtime = opts.runtime ?? defaultRuntime;
const saved = await saveMediaSource(source); const saved = await saveMediaSource(source);
const hostname = await getTailnetHostname(); const hostname = await getTailnetHostname();
// Decide whether we must start a media server. // Decide whether we must start a media server.
const needsServerStart = await isPortFree(port); const needsServerStart = await isPortFree(port);
if (needsServerStart && !opts.startServer) { if (needsServerStart && !opts.startServer) {
await fs.rm(saved.path).catch(() => {}); await fs.rm(saved.path).catch(() => {});
throw new Error( throw new Error(
"Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.", "Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.",
); );
} }
if (needsServerStart && opts.startServer) { if (needsServerStart && opts.startServer) {
if (!mediaServer) { if (!mediaServer) {
mediaServer = await startMediaServer(port, TTL_MS, runtime); mediaServer = await startMediaServer(port, TTL_MS, runtime);
logInfo( logInfo(
`📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`, `📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`,
runtime, runtime,
); );
mediaServer.unref?.(); mediaServer.unref?.();
} }
} }
const url = `https://${hostname}/media/${saved.id}`; const url = `https://${hostname}/media/${saved.id}`;
return { url, id: saved.id, size: saved.size }; return { url, id: saved.id, size: saved.size };
} }
async function isPortFree(port: number) { async function isPortFree(port: number) {
try { try {
await ensurePortAvailable(port); await ensurePortAvailable(port);
return true; return true;
} catch (err) { } catch (err) {
if (err instanceof PortInUseError) return false; if (err instanceof PortInUseError) return false;
throw err; throw err;
} }
} }

View File

@ -1,53 +1,101 @@
// Shared helpers for parsing MEDIA tokens from command/stdout text. // Shared helpers for parsing MEDIA tokens from command/stdout text.
// Allow optional wrapping backticks and punctuation after the token; capture the core token. // Allow optional wrapping backticks and punctuation after the token; capture the core token.
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\s`]+)`?/i; export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi;
export function normalizeMediaSource(src: string) { export function normalizeMediaSource(src: string) {
return src.startsWith("file://") ? src.replace("file://", "") : src; return src.startsWith("file://") ? src.replace("file://", "") : src;
} }
function cleanCandidate(raw: string) { function cleanCandidate(raw: string) {
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, ""); return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
} }
function isValidMedia(candidate: string) { function isValidMedia(candidate: string) {
if (!candidate) return false; if (!candidate) return false;
if (candidate.length > 1024) return false; if (candidate.length > 1024) return false;
if (/\s/.test(candidate)) return false; if (/\s/.test(candidate)) return false;
return ( return (
/^https?:\/\//i.test(candidate) || /^https?:\/\//i.test(candidate) ||
candidate.startsWith("/") || candidate.startsWith("/") ||
candidate.startsWith("./") candidate.startsWith("./")
); );
} }
export function splitMediaFromOutput(raw: string): { export function splitMediaFromOutput(raw: string): {
text: string; text: string;
mediaUrl?: string; mediaUrls?: string[];
mediaUrl?: string; // legacy first item for backward compatibility
} { } {
const trimmedRaw = raw.trim(); const trimmedRaw = raw.trim();
const match = MEDIA_TOKEN_RE.exec(trimmedRaw); if (!trimmedRaw) return { text: "" };
if (!match?.[1]) return { text: trimmedRaw };
const candidate = normalizeMediaSource(cleanCandidate(match[1])); const media: string[] = [];
const mediaUrl = isValidMedia(candidate) ? candidate : undefined; let foundMediaToken = false;
const cleanedText = mediaUrl // Collect tokens line by line so we can strip them cleanly.
? trimmedRaw const lines = trimmedRaw.split("\n");
.replace(match[0], "") const keptLines: string[] = [];
.replace(/[ \t]+\n/g, "\n")
.replace(/[ \t]{2,}/g, " ")
.replace(/\n{2,}/g, "\n")
.trim()
: trimmedRaw
.split("\n")
.filter((line) => !MEDIA_TOKEN_RE.test(line))
.join("\n")
.replace(/[ \t]+\n/g, "\n")
.replace(/[ \t]{2,}/g, " ")
.replace(/\n{2,}/g, "\n")
.trim();
return mediaUrl ? { text: cleanedText, mediaUrl } : { text: cleanedText }; for (const line of lines) {
const matches = Array.from(line.matchAll(MEDIA_TOKEN_RE));
if (matches.length === 0) {
keptLines.push(line);
continue;
}
foundMediaToken = true;
const pieces: string[] = [];
let cursor = 0;
let hasValidMedia = false;
for (const match of matches) {
const start = match.index ?? 0;
pieces.push(line.slice(cursor, start));
const payload = match[1];
const parts = payload.split(/\s+/).filter(Boolean);
const invalidParts: string[] = [];
for (const part of parts) {
const candidate = normalizeMediaSource(cleanCandidate(part));
if (isValidMedia(candidate)) {
media.push(candidate);
hasValidMedia = true;
} else {
invalidParts.push(part);
}
}
if (hasValidMedia && invalidParts.length > 0) {
pieces.push(invalidParts.join(" "));
}
cursor = start + match[0].length;
}
pieces.push(line.slice(cursor));
const cleanedLine = pieces
.join("")
.replace(/[ \t]{2,}/g, " ")
.trim();
// If the line becomes empty, drop it.
if (cleanedLine) {
keptLines.push(cleanedLine);
}
}
const cleanedText = keptLines
.join("\n")
.replace(/[ \t]+\n/g, "\n")
.replace(/[ \t]{2,}/g, " ")
.replace(/\n{2,}/g, "\n")
.trim();
if (media.length === 0) {
return { text: foundMediaToken ? cleanedText : trimmedRaw };
}
return { text: cleanedText, mediaUrls: media, mediaUrl: media[0] };
} }

View File

@ -8,45 +8,45 @@ const MEDIA_DIR = path.join(process.cwd(), "tmp-media-test");
const cleanOldMedia = vi.fn().mockResolvedValue(undefined); const cleanOldMedia = vi.fn().mockResolvedValue(undefined);
vi.mock("./store.js", () => ({ vi.mock("./store.js", () => ({
getMediaDir: () => MEDIA_DIR, getMediaDir: () => MEDIA_DIR,
cleanOldMedia, cleanOldMedia,
})); }));
const { startMediaServer } = await import("./server.js"); const { startMediaServer } = await import("./server.js");
describe("media server", () => { describe("media server", () => {
beforeAll(async () => { beforeAll(async () => {
await fs.rm(MEDIA_DIR, { recursive: true, force: true }); await fs.rm(MEDIA_DIR, { recursive: true, force: true });
await fs.mkdir(MEDIA_DIR, { recursive: true }); await fs.mkdir(MEDIA_DIR, { recursive: true });
}); });
afterAll(async () => { afterAll(async () => {
await fs.rm(MEDIA_DIR, { recursive: true, force: true }); await fs.rm(MEDIA_DIR, { recursive: true, force: true });
}); });
it("serves media and cleans up after send", async () => { it("serves media and cleans up after send", async () => {
const file = path.join(MEDIA_DIR, "file1"); const file = path.join(MEDIA_DIR, "file1");
await fs.writeFile(file, "hello"); await fs.writeFile(file, "hello");
const server = await startMediaServer(0, 5_000); const server = await startMediaServer(0, 5_000);
const port = (server.address() as AddressInfo).port; const port = (server.address() as AddressInfo).port;
const res = await fetch(`http://localhost:${port}/media/file1`); const res = await fetch(`http://localhost:${port}/media/file1`);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(await res.text()).toBe("hello"); expect(await res.text()).toBe("hello");
await new Promise((r) => setTimeout(r, 600)); await new Promise((r) => setTimeout(r, 600));
await expect(fs.stat(file)).rejects.toThrow(); await expect(fs.stat(file)).rejects.toThrow();
await new Promise((r) => server.close(r)); await new Promise((r) => server.close(r));
}); });
it("expires old media", async () => { it("expires old media", async () => {
const file = path.join(MEDIA_DIR, "old"); const file = path.join(MEDIA_DIR, "old");
await fs.writeFile(file, "stale"); await fs.writeFile(file, "stale");
const past = Date.now() - 10_000; const past = Date.now() - 10_000;
await fs.utimes(file, past / 1000, past / 1000); await fs.utimes(file, past / 1000, past / 1000);
const server = await startMediaServer(0, 1_000); const server = await startMediaServer(0, 1_000);
const port = (server.address() as AddressInfo).port; const port = (server.address() as AddressInfo).port;
const res = await fetch(`http://localhost:${port}/media/old`); const res = await fetch(`http://localhost:${port}/media/old`);
expect(res.status).toBe(410); expect(res.status).toBe(410);
await expect(fs.stat(file)).rejects.toThrow(); await expect(fs.stat(file)).rejects.toThrow();
await new Promise((r) => server.close(r)); await new Promise((r) => server.close(r));
}); });
}); });

View File

@ -9,53 +9,53 @@ import { cleanOldMedia, getMediaDir } from "./store.js";
const DEFAULT_TTL_MS = 2 * 60 * 1000; const DEFAULT_TTL_MS = 2 * 60 * 1000;
export function attachMediaRoutes( export function attachMediaRoutes(
app: Express, app: Express,
ttlMs = DEFAULT_TTL_MS, ttlMs = DEFAULT_TTL_MS,
_runtime: RuntimeEnv = defaultRuntime, _runtime: RuntimeEnv = defaultRuntime,
) { ) {
const mediaDir = getMediaDir(); const mediaDir = getMediaDir();
app.get("/media/:id", async (req, res) => { app.get("/media/:id", async (req, res) => {
const id = req.params.id; const id = req.params.id;
const file = path.join(mediaDir, id); const file = path.join(mediaDir, id);
try { try {
const stat = await fs.stat(file); const stat = await fs.stat(file);
if (Date.now() - stat.mtimeMs > ttlMs) { if (Date.now() - stat.mtimeMs > ttlMs) {
await fs.rm(file).catch(() => {}); await fs.rm(file).catch(() => {});
res.status(410).send("expired"); res.status(410).send("expired");
return; return;
} }
res.sendFile(file); res.sendFile(file);
// best-effort single-use cleanup after response ends // best-effort single-use cleanup after response ends
res.on("finish", () => { res.on("finish", () => {
setTimeout(() => { setTimeout(() => {
fs.rm(file).catch(() => {}); fs.rm(file).catch(() => {});
}, 500); }, 500);
}); });
} catch { } catch {
res.status(404).send("not found"); res.status(404).send("not found");
} }
}); });
// periodic cleanup // periodic cleanup
setInterval(() => { setInterval(() => {
void cleanOldMedia(ttlMs); void cleanOldMedia(ttlMs);
}, ttlMs).unref(); }, ttlMs).unref();
} }
export async function startMediaServer( export async function startMediaServer(
port: number, port: number,
ttlMs = DEFAULT_TTL_MS, ttlMs = DEFAULT_TTL_MS,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
): Promise<Server> { ): Promise<Server> {
const app = express(); const app = express();
attachMediaRoutes(app, ttlMs, runtime); attachMediaRoutes(app, ttlMs, runtime);
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const server = app.listen(port); const server = app.listen(port);
server.once("listening", () => resolve(server)); server.once("listening", () => resolve(server));
server.once("error", (err) => { server.once("error", (err) => {
runtime.error(danger(`Media server failed: ${String(err)}`)); runtime.error(danger(`Media server failed: ${String(err)}`));
reject(err); reject(err);
}); });
}); });
} }

View File

@ -7,54 +7,54 @@ const realOs = await vi.importActual<typeof import("node:os")>("node:os");
const HOME = path.join(realOs.tmpdir(), "warelay-home-test"); const HOME = path.join(realOs.tmpdir(), "warelay-home-test");
vi.mock("node:os", () => ({ vi.mock("node:os", () => ({
default: { homedir: () => HOME }, default: { homedir: () => HOME },
homedir: () => HOME, homedir: () => HOME,
})); }));
const store = await import("./store.js"); const store = await import("./store.js");
describe("media store", () => { describe("media store", () => {
beforeAll(async () => { beforeAll(async () => {
await fs.rm(HOME, { recursive: true, force: true }); await fs.rm(HOME, { recursive: true, force: true });
}); });
afterAll(async () => { afterAll(async () => {
await fs.rm(HOME, { recursive: true, force: true }); await fs.rm(HOME, { recursive: true, force: true });
}); });
it("creates and returns media directory", async () => { it("creates and returns media directory", async () => {
const dir = await store.ensureMediaDir(); const dir = await store.ensureMediaDir();
expect(dir).toContain("warelay-home-test"); expect(dir).toContain("warelay-home-test");
const stat = await fs.stat(dir); const stat = await fs.stat(dir);
expect(stat.isDirectory()).toBe(true); expect(stat.isDirectory()).toBe(true);
}); });
it("saves buffers and enforces size limit", async () => { it("saves buffers and enforces size limit", async () => {
const buf = Buffer.from("hello"); const buf = Buffer.from("hello");
const saved = await store.saveMediaBuffer(buf, "text/plain"); const saved = await store.saveMediaBuffer(buf, "text/plain");
const savedStat = await fs.stat(saved.path); const savedStat = await fs.stat(saved.path);
expect(savedStat.size).toBe(buf.length); expect(savedStat.size).toBe(buf.length);
expect(saved.contentType).toBe("text/plain"); expect(saved.contentType).toBe("text/plain");
const huge = Buffer.alloc(5 * 1024 * 1024 + 1); const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
await expect(store.saveMediaBuffer(huge)).rejects.toThrow( await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
"Media exceeds 5MB limit", "Media exceeds 5MB limit",
); );
}); });
it("copies local files and cleans old media", async () => { it("copies local files and cleans old media", async () => {
const srcFile = path.join(HOME, "tmp-src.txt"); const srcFile = path.join(HOME, "tmp-src.txt");
await fs.mkdir(HOME, { recursive: true }); await fs.mkdir(HOME, { recursive: true });
await fs.writeFile(srcFile, "local file"); await fs.writeFile(srcFile, "local file");
const saved = await store.saveMediaSource(srcFile); const saved = await store.saveMediaSource(srcFile);
expect(saved.size).toBe(10); expect(saved.size).toBe(10);
const savedStat = await fs.stat(saved.path); const savedStat = await fs.stat(saved.path);
expect(savedStat.isFile()).toBe(true); expect(savedStat.isFile()).toBe(true);
// make the file look old and ensure cleanOldMedia removes it // make the file look old and ensure cleanOldMedia removes it
const past = Date.now() - 10_000; const past = Date.now() - 10_000;
await fs.utimes(saved.path, past / 1000, past / 1000); await fs.utimes(saved.path, past / 1000, past / 1000);
await store.cleanOldMedia(1); await store.cleanOldMedia(1);
await expect(fs.stat(saved.path)).rejects.toThrow(); await expect(fs.stat(saved.path)).rejects.toThrow();
}); });
}); });

View File

@ -11,108 +11,108 @@ const MAX_BYTES = 5 * 1024 * 1024; // 5MB
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
export function getMediaDir() { export function getMediaDir() {
return MEDIA_DIR; return MEDIA_DIR;
} }
export async function ensureMediaDir() { export async function ensureMediaDir() {
await fs.mkdir(MEDIA_DIR, { recursive: true }); await fs.mkdir(MEDIA_DIR, { recursive: true });
return MEDIA_DIR; return MEDIA_DIR;
} }
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) { export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
await ensureMediaDir(); await ensureMediaDir();
const entries = await fs.readdir(MEDIA_DIR).catch(() => []); const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
const now = Date.now(); const now = Date.now();
await Promise.all( await Promise.all(
entries.map(async (file) => { entries.map(async (file) => {
const full = path.join(MEDIA_DIR, file); const full = path.join(MEDIA_DIR, file);
const stat = await fs.stat(full).catch(() => null); const stat = await fs.stat(full).catch(() => null);
if (!stat) return; if (!stat) return;
if (now - stat.mtimeMs > ttlMs) { if (now - stat.mtimeMs > ttlMs) {
await fs.rm(full).catch(() => {}); await fs.rm(full).catch(() => {});
} }
}), }),
); );
} }
function looksLikeUrl(src: string) { function looksLikeUrl(src: string) {
return /^https?:\/\//i.test(src); return /^https?:\/\//i.test(src);
} }
async function downloadToFile( async function downloadToFile(
url: string, url: string,
dest: string, dest: string,
headers?: Record<string, string>, headers?: Record<string, string>,
) { ) {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const req = request(url, { headers }, (res) => { const req = request(url, { headers }, (res) => {
if (!res.statusCode || res.statusCode >= 400) { if (!res.statusCode || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`)); reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
return; return;
} }
let total = 0; let total = 0;
const out = createWriteStream(dest); const out = createWriteStream(dest);
res.on("data", (chunk) => { res.on("data", (chunk) => {
total += chunk.length; total += chunk.length;
if (total > MAX_BYTES) { if (total > MAX_BYTES) {
req.destroy(new Error("Media exceeds 5MB limit")); req.destroy(new Error("Media exceeds 5MB limit"));
} }
}); });
pipeline(res, out) pipeline(res, out)
.then(() => resolve()) .then(() => resolve())
.catch(reject); .catch(reject);
}); });
req.on("error", reject); req.on("error", reject);
req.end(); req.end();
}); });
} }
export type SavedMedia = { export type SavedMedia = {
id: string; id: string;
path: string; path: string;
size: number; size: number;
contentType?: string; contentType?: string;
}; };
export async function saveMediaSource( export async function saveMediaSource(
source: string, source: string,
headers?: Record<string, string>, headers?: Record<string, string>,
subdir = "", subdir = "",
): Promise<SavedMedia> { ): Promise<SavedMedia> {
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR; const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
await cleanOldMedia(); await cleanOldMedia();
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const dest = path.join(dir, id); const dest = path.join(dir, id);
if (looksLikeUrl(source)) { if (looksLikeUrl(source)) {
await downloadToFile(source, dest, headers); await downloadToFile(source, dest, headers);
const stat = await fs.stat(dest); const stat = await fs.stat(dest);
return { id, path: dest, size: stat.size }; return { id, path: dest, size: stat.size };
} }
// local path // local path
const stat = await fs.stat(source); const stat = await fs.stat(source);
if (!stat.isFile()) { if (!stat.isFile()) {
throw new Error("Media path is not a file"); throw new Error("Media path is not a file");
} }
if (stat.size > MAX_BYTES) { if (stat.size > MAX_BYTES) {
throw new Error("Media exceeds 5MB limit"); throw new Error("Media exceeds 5MB limit");
} }
await fs.copyFile(source, dest); await fs.copyFile(source, dest);
return { id, path: dest, size: stat.size }; return { id, path: dest, size: stat.size };
} }
export async function saveMediaBuffer( export async function saveMediaBuffer(
buffer: Buffer, buffer: Buffer,
contentType?: string, contentType?: string,
subdir = "inbound", subdir = "inbound",
): Promise<SavedMedia> { ): Promise<SavedMedia> {
if (buffer.byteLength > MAX_BYTES) { if (buffer.byteLength > MAX_BYTES) {
throw new Error("Media exceeds 5MB limit"); throw new Error("Media exceeds 5MB limit");
} }
const dir = path.join(MEDIA_DIR, subdir); const dir = path.join(MEDIA_DIR, subdir);
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const dest = path.join(dir, id); const dest = path.join(dir, id);
await fs.writeFile(dest, buffer); await fs.writeFile(dest, buffer);
return { id, path: dest, size: buffer.byteLength, contentType }; return { id, path: dest, size: buffer.byteLength, contentType };
} }

View File

@ -3,53 +3,53 @@ import { describe, expect, it } from "vitest";
import { enqueueCommand, getQueueSize } from "./command-queue.js"; import { enqueueCommand, getQueueSize } from "./command-queue.js";
describe("command queue", () => { describe("command queue", () => {
it("runs tasks one at a time in order", async () => { it("runs tasks one at a time in order", async () => {
let active = 0; let active = 0;
let maxActive = 0; let maxActive = 0;
const calls: number[] = []; const calls: number[] = [];
const makeTask = (id: number) => async () => { const makeTask = (id: number) => async () => {
active += 1; active += 1;
maxActive = Math.max(maxActive, active); maxActive = Math.max(maxActive, active);
calls.push(id); calls.push(id);
await new Promise((resolve) => setTimeout(resolve, 15)); await new Promise((resolve) => setTimeout(resolve, 15));
active -= 1; active -= 1;
return id; return id;
}; };
const results = await Promise.all([ const results = await Promise.all([
enqueueCommand(makeTask(1)), enqueueCommand(makeTask(1)),
enqueueCommand(makeTask(2)), enqueueCommand(makeTask(2)),
enqueueCommand(makeTask(3)), enqueueCommand(makeTask(3)),
]); ]);
expect(results).toEqual([1, 2, 3]); expect(results).toEqual([1, 2, 3]);
expect(calls).toEqual([1, 2, 3]); expect(calls).toEqual([1, 2, 3]);
expect(maxActive).toBe(1); expect(maxActive).toBe(1);
expect(getQueueSize()).toBe(0); expect(getQueueSize()).toBe(0);
}); });
it("invokes onWait callback when a task waits past the threshold", async () => { it("invokes onWait callback when a task waits past the threshold", async () => {
let waited: number | null = null; let waited: number | null = null;
let queuedAhead: number | null = null; let queuedAhead: number | null = null;
// First task holds the queue long enough to trigger wait notice. // First task holds the queue long enough to trigger wait notice.
const first = enqueueCommand(async () => { const first = enqueueCommand(async () => {
await new Promise((resolve) => setTimeout(resolve, 30)); await new Promise((resolve) => setTimeout(resolve, 30));
}); });
const second = enqueueCommand(async () => {}, { const second = enqueueCommand(async () => {}, {
warnAfterMs: 5, warnAfterMs: 5,
onWait: (ms, ahead) => { onWait: (ms, ahead) => {
waited = ms; waited = ms;
queuedAhead = ahead; queuedAhead = ahead;
}, },
}); });
await Promise.all([first, second]); await Promise.all([first, second]);
expect(waited).not.toBeNull(); expect(waited).not.toBeNull();
expect(waited as number).toBeGreaterThanOrEqual(5); expect(waited as number).toBeGreaterThanOrEqual(5);
expect(queuedAhead).toBe(0); expect(queuedAhead).toBe(0);
}); });
}); });

View File

@ -2,57 +2,57 @@
// Ensures only one command runs at a time across webhook, poller, and web inbox flows. // Ensures only one command runs at a time across webhook, poller, and web inbox flows.
type QueueEntry = { type QueueEntry = {
task: () => Promise<unknown>; task: () => Promise<unknown>;
resolve: (value: unknown) => void; resolve: (value: unknown) => void;
reject: (reason?: unknown) => void; reject: (reason?: unknown) => void;
enqueuedAt: number; enqueuedAt: number;
warnAfterMs: number; warnAfterMs: number;
onWait?: (waitMs: number, queuedAhead: number) => void; onWait?: (waitMs: number, queuedAhead: number) => void;
}; };
const queue: QueueEntry[] = []; const queue: QueueEntry[] = [];
let draining = false; let draining = false;
async function drainQueue() { async function drainQueue() {
if (draining) return; if (draining) return;
draining = true; draining = true;
while (queue.length) { while (queue.length) {
const entry = queue.shift() as QueueEntry; const entry = queue.shift() as QueueEntry;
const waitedMs = Date.now() - entry.enqueuedAt; const waitedMs = Date.now() - entry.enqueuedAt;
if (waitedMs >= entry.warnAfterMs) { if (waitedMs >= entry.warnAfterMs) {
entry.onWait?.(waitedMs, queue.length); entry.onWait?.(waitedMs, queue.length);
} }
try { try {
const result = await entry.task(); const result = await entry.task();
entry.resolve(result); entry.resolve(result);
} catch (err) { } catch (err) {
entry.reject(err); entry.reject(err);
} }
} }
draining = false; draining = false;
} }
export function enqueueCommand<T>( export function enqueueCommand<T>(
task: () => Promise<T>, task: () => Promise<T>,
opts?: { opts?: {
warnAfterMs?: number; warnAfterMs?: number;
onWait?: (waitMs: number, queuedAhead: number) => void; onWait?: (waitMs: number, queuedAhead: number) => void;
}, },
): Promise<T> { ): Promise<T> {
const warnAfterMs = opts?.warnAfterMs ?? 2_000; const warnAfterMs = opts?.warnAfterMs ?? 2_000;
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
queue.push({ queue.push({
task: () => task(), task: () => task(),
resolve: (value) => resolve(value as T), resolve: (value) => resolve(value as T),
reject, reject,
enqueuedAt: Date.now(), enqueuedAt: Date.now(),
warnAfterMs, warnAfterMs,
onWait: opts?.onWait, onWait: opts?.onWait,
}); });
void drainQueue(); void drainQueue();
}); });
} }
export function getQueueSize() { export function getQueueSize() {
return queue.length + (draining ? 1 : 0); return queue.length + (draining ? 1 : 0);
} }

View File

@ -8,86 +8,86 @@ const execFileAsync = promisify(execFile);
// Simple promise-wrapped execFile with optional verbosity logging. // Simple promise-wrapped execFile with optional verbosity logging.
export async function runExec( export async function runExec(
command: string, command: string,
args: string[], args: string[],
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000, opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
): Promise<{ stdout: string; stderr: string }> { ): Promise<{ stdout: string; stderr: string }> {
const options = const options =
typeof opts === "number" typeof opts === "number"
? { timeout: opts, encoding: "utf8" as const } ? { timeout: opts, encoding: "utf8" as const }
: { : {
timeout: opts.timeoutMs, timeout: opts.timeoutMs,
maxBuffer: opts.maxBuffer, maxBuffer: opts.maxBuffer,
encoding: "utf8" as const, encoding: "utf8" as const,
}; };
try { try {
const { stdout, stderr } = await execFileAsync(command, args, options); const { stdout, stderr } = await execFileAsync(command, args, options);
if (isVerbose()) { if (isVerbose()) {
if (stdout.trim()) logDebug(stdout.trim()); if (stdout.trim()) logDebug(stdout.trim());
if (stderr.trim()) logError(stderr.trim()); if (stderr.trim()) logError(stderr.trim());
} }
return { stdout, stderr }; return { stdout, stderr };
} catch (err) { } catch (err) {
if (isVerbose()) { if (isVerbose()) {
logError(danger(`Command failed: ${command} ${args.join(" ")}`)); logError(danger(`Command failed: ${command} ${args.join(" ")}`));
} }
throw err; throw err;
} }
} }
export type SpawnResult = { export type SpawnResult = {
stdout: string; stdout: string;
stderr: string; stderr: string;
code: number | null; code: number | null;
signal: NodeJS.Signals | null; signal: NodeJS.Signals | null;
killed: boolean; killed: boolean;
}; };
export type CommandOptions = { export type CommandOptions = {
timeoutMs: number; timeoutMs: number;
cwd?: string; cwd?: string;
}; };
export async function runCommandWithTimeout( export async function runCommandWithTimeout(
argv: string[], argv: string[],
optionsOrTimeout: number | CommandOptions, optionsOrTimeout: number | CommandOptions,
): Promise<SpawnResult> { ): Promise<SpawnResult> {
const options: CommandOptions = const options: CommandOptions =
typeof optionsOrTimeout === "number" typeof optionsOrTimeout === "number"
? { timeoutMs: optionsOrTimeout } ? { timeoutMs: optionsOrTimeout }
: optionsOrTimeout; : optionsOrTimeout;
const { timeoutMs, cwd } = options; const { timeoutMs, cwd } = options;
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang. // Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const child = spawn(argv[0], argv.slice(1), { const child = spawn(argv[0], argv.slice(1), {
stdio: ["inherit", "pipe", "pipe"], stdio: ["inherit", "pipe", "pipe"],
cwd, cwd,
}); });
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
let settled = false; let settled = false;
const timer = setTimeout(() => { const timer = setTimeout(() => {
child.kill("SIGKILL"); child.kill("SIGKILL");
}, timeoutMs); }, timeoutMs);
child.stdout?.on("data", (d) => { child.stdout?.on("data", (d) => {
stdout += d.toString(); stdout += d.toString();
}); });
child.stderr?.on("data", (d) => { child.stderr?.on("data", (d) => {
stderr += d.toString(); stderr += d.toString();
}); });
child.on("error", (err) => { child.on("error", (err) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
clearTimeout(timer); clearTimeout(timer);
reject(err); reject(err);
}); });
child.on("close", (code, signal) => { child.on("close", (code, signal) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
clearTimeout(timer); clearTimeout(timer);
resolve({ stdout, stderr, code, signal, killed: child.killed }); resolve({ stdout, stderr, code, signal, killed: child.killed });
}); });
}); });
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,16 @@
export { createClient } from "../../twilio/client.js"; export { createClient } from "../../twilio/client.js";
export { export {
formatMessageLine, formatMessageLine,
listRecentMessages, listRecentMessages,
} from "../../twilio/messages.js"; } from "../../twilio/messages.js";
export { monitorTwilio } from "../../twilio/monitor.js"; export { monitorTwilio } from "../../twilio/monitor.js";
export { sendMessage, waitForFinalStatus } from "../../twilio/send.js"; export { sendMessage, waitForFinalStatus } from "../../twilio/send.js";
export { findWhatsappSenderSid } from "../../twilio/senders.js"; export { findWhatsappSenderSid } from "../../twilio/senders.js";
export { sendTypingIndicator } from "../../twilio/typing.js"; export { sendTypingIndicator } from "../../twilio/typing.js";
export { export {
findIncomingNumberSid, findIncomingNumberSid,
findMessagingServiceSid, findMessagingServiceSid,
setMessagingServiceWebhook, setMessagingServiceWebhook,
updateWebhook, updateWebhook,
} from "../../twilio/update-webhook.js"; } from "../../twilio/update-webhook.js";
export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js"; export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js";

View File

@ -4,16 +4,16 @@ import * as impl from "../../provider-web.js";
import * as entry from "./index.js"; import * as entry from "./index.js";
describe("providers/web entrypoint", () => { describe("providers/web entrypoint", () => {
it("re-exports web provider helpers", () => { it("re-exports web provider helpers", () => {
expect(entry.createWaSocket).toBe(impl.createWaSocket); expect(entry.createWaSocket).toBe(impl.createWaSocket);
expect(entry.loginWeb).toBe(impl.loginWeb); expect(entry.loginWeb).toBe(impl.loginWeb);
expect(entry.logWebSelfId).toBe(impl.logWebSelfId); expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox); expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider); expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
expect(entry.pickProvider).toBe(impl.pickProvider); expect(entry.pickProvider).toBe(impl.pickProvider);
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb); expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR); expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection); expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
expect(entry.webAuthExists).toBe(impl.webAuthExists); expect(entry.webAuthExists).toBe(impl.webAuthExists);
}); });
}); });

View File

@ -1,13 +1,13 @@
/* istanbul ignore file */ /* istanbul ignore file */
export { export {
createWaSocket, createWaSocket,
loginWeb, loginWeb,
logWebSelfId, logWebSelfId,
monitorWebInbox, monitorWebInbox,
monitorWebProvider, monitorWebProvider,
pickProvider, pickProvider,
sendMessageWeb, sendMessageWeb,
WA_WEB_AUTH_DIR, WA_WEB_AUTH_DIR,
waitForWaConnection, waitForWaConnection,
webAuthExists, webAuthExists,
} from "../../provider-web.js"; } from "../../provider-web.js";

View File

@ -1,14 +1,14 @@
export type RuntimeEnv = { export type RuntimeEnv = {
log: typeof console.log; log: typeof console.log;
error: typeof console.error; error: typeof console.error;
exit: (code: number) => never; exit: (code: number) => never;
}; };
export const defaultRuntime: RuntimeEnv = { export const defaultRuntime: RuntimeEnv = {
log: console.log, log: console.log,
error: console.error, error: console.error,
exit: (code) => { exit: (code) => {
process.exit(code); process.exit(code);
throw new Error("unreachable"); // satisfies tests when mocked throw new Error("unreachable"); // satisfies tests when mocked
}, },
}; };

View File

@ -2,13 +2,13 @@ import Twilio from "twilio";
import type { EnvConfig } from "../env.js"; import type { EnvConfig } from "../env.js";
export function createClient(env: EnvConfig) { export function createClient(env: EnvConfig) {
// Twilio client using either auth token or API key/secret. // Twilio client using either auth token or API key/secret.
if ("authToken" in env.auth) { if ("authToken" in env.auth) {
return Twilio(env.accountSid, env.auth.authToken, { return Twilio(env.accountSid, env.auth.authToken, {
accountSid: env.accountSid, accountSid: env.accountSid,
}); });
} }
return Twilio(env.auth.apiKey, env.auth.apiSecret, { return Twilio(env.auth.apiKey, env.auth.apiSecret, {
accountSid: env.accountSid, accountSid: env.accountSid,
}); });
} }

View File

@ -3,97 +3,97 @@ import { withWhatsAppPrefix } from "../utils.js";
import { createClient } from "./client.js"; import { createClient } from "./client.js";
export type ListedMessage = { export type ListedMessage = {
sid: string; sid: string;
status: string | null; status: string | null;
direction: string | null; direction: string | null;
dateCreated: Date | undefined; dateCreated: Date | undefined;
from?: string | null; from?: string | null;
to?: string | null; to?: string | null;
body?: string | null; body?: string | null;
errorCode: number | null; errorCode: number | null;
errorMessage: string | null; errorMessage: string | null;
}; };
// Remove duplicates by SID while preserving order. // Remove duplicates by SID while preserving order.
export function uniqueBySid(messages: ListedMessage[]): ListedMessage[] { export function uniqueBySid(messages: ListedMessage[]): ListedMessage[] {
const seen = new Set<string>(); const seen = new Set<string>();
const deduped: ListedMessage[] = []; const deduped: ListedMessage[] = [];
for (const m of messages) { for (const m of messages) {
if (seen.has(m.sid)) continue; if (seen.has(m.sid)) continue;
seen.add(m.sid); seen.add(m.sid);
deduped.push(m); deduped.push(m);
} }
return deduped; return deduped;
} }
// Sort messages newest -> oldest by dateCreated. // Sort messages newest -> oldest by dateCreated.
export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] { export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] {
return [...messages].sort((a, b) => { return [...messages].sort((a, b) => {
const da = a.dateCreated?.getTime() ?? 0; const da = a.dateCreated?.getTime() ?? 0;
const db = b.dateCreated?.getTime() ?? 0; const db = b.dateCreated?.getTime() ?? 0;
return db - da; return db - da;
}); });
} }
// Merge inbound/outbound messages (recent first) for status commands and tests. // Merge inbound/outbound messages (recent first) for status commands and tests.
export async function listRecentMessages( export async function listRecentMessages(
lookbackMinutes: number, lookbackMinutes: number,
limit: number, limit: number,
clientOverride?: ReturnType<typeof createClient>, clientOverride?: ReturnType<typeof createClient>,
): Promise<ListedMessage[]> { ): Promise<ListedMessage[]> {
const env = readEnv(); const env = readEnv();
const client = clientOverride ?? createClient(env); const client = clientOverride ?? createClient(env);
const from = withWhatsAppPrefix(env.whatsappFrom); const from = withWhatsAppPrefix(env.whatsappFrom);
const since = new Date(Date.now() - lookbackMinutes * 60_000); const since = new Date(Date.now() - lookbackMinutes * 60_000);
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit. // Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.
const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100); const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100);
const inbound = await client.messages.list({ const inbound = await client.messages.list({
to: from, to: from,
dateSentAfter: since, dateSentAfter: since,
limit: fetchLimit, limit: fetchLimit,
}); });
const outbound = await client.messages.list({ const outbound = await client.messages.list({
from, from,
dateSentAfter: since, dateSentAfter: since,
limit: fetchLimit, limit: fetchLimit,
}); });
const inboundArr = Array.isArray(inbound) ? inbound : []; const inboundArr = Array.isArray(inbound) ? inbound : [];
const outboundArr = Array.isArray(outbound) ? outbound : []; const outboundArr = Array.isArray(outbound) ? outbound : [];
const combined = uniqueBySid( const combined = uniqueBySid(
[...inboundArr, ...outboundArr].map((m) => ({ [...inboundArr, ...outboundArr].map((m) => ({
sid: m.sid, sid: m.sid,
status: m.status ?? null, status: m.status ?? null,
direction: m.direction ?? null, direction: m.direction ?? null,
dateCreated: m.dateCreated, dateCreated: m.dateCreated,
from: m.from, from: m.from,
to: m.to, to: m.to,
body: m.body, body: m.body,
errorCode: m.errorCode ?? null, errorCode: m.errorCode ?? null,
errorMessage: m.errorMessage ?? null, errorMessage: m.errorMessage ?? null,
})), })),
); );
return sortByDateDesc(combined).slice(0, limit); return sortByDateDesc(combined).slice(0, limit);
} }
// Human-friendly single-line formatter for recent messages. // Human-friendly single-line formatter for recent messages.
export function formatMessageLine(m: ListedMessage): string { export function formatMessageLine(m: ListedMessage): string {
const ts = m.dateCreated?.toISOString() ?? "unknown-time"; const ts = m.dateCreated?.toISOString() ?? "unknown-time";
const dir = const dir =
m.direction === "inbound" m.direction === "inbound"
? "⬅️ " ? "⬅️ "
: m.direction === "outbound-api" || m.direction === "outbound-reply" : m.direction === "outbound-api" || m.direction === "outbound-reply"
? "➡️ " ? "➡️ "
: "↔️ "; : "↔️ ";
const status = m.status ?? "unknown"; const status = m.status ?? "unknown";
const err = const err =
m.errorCode != null m.errorCode != null
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}` ? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
: ""; : "";
const body = (m.body ?? "").replace(/\s+/g, " ").trim(); const body = (m.body ?? "").replace(/\s+/g, " ").trim();
const bodyPreview = const bodyPreview =
body.length > 140 ? `${body.slice(0, 137)}` : body || "<empty>"; body.length > 140 ? `${body.slice(0, 137)}` : body || "<empty>";
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`; return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
} }

View File

@ -3,43 +3,43 @@ import { describe, expect, it, vi } from "vitest";
import { monitorTwilio } from "./monitor.js"; import { monitorTwilio } from "./monitor.js";
describe("monitorTwilio", () => { describe("monitorTwilio", () => {
it("processes inbound messages once with injected deps", async () => { it("processes inbound messages once with injected deps", async () => {
const listRecentMessages = vi.fn().mockResolvedValue([ const listRecentMessages = vi.fn().mockResolvedValue([
{ {
sid: "m1", sid: "m1",
direction: "inbound", direction: "inbound",
dateCreated: new Date(), dateCreated: new Date(),
from: "+1", from: "+1",
to: "+2", to: "+2",
body: "hi", body: "hi",
errorCode: null, errorCode: null,
errorMessage: null, errorMessage: null,
status: null, status: null,
}, },
]); ]);
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined); const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
const readEnv = vi.fn(() => ({ const readEnv = vi.fn(() => ({
accountSid: "AC", accountSid: "AC",
whatsappFrom: "whatsapp:+1", whatsappFrom: "whatsapp:+1",
auth: { accountSid: "AC", authToken: "t" }, auth: { accountSid: "AC", authToken: "t" },
})); }));
const createClient = vi.fn( const createClient = vi.fn(
() => ({ messages: { create: vi.fn() } }) as never, () => ({ messages: { create: vi.fn() } }) as never,
); );
const sleep = vi.fn().mockResolvedValue(undefined); const sleep = vi.fn().mockResolvedValue(undefined);
await monitorTwilio(0, 0, { await monitorTwilio(0, 0, {
deps: { deps: {
autoReplyIfConfigured, autoReplyIfConfigured,
listRecentMessages, listRecentMessages,
readEnv, readEnv,
createClient, createClient,
sleep, sleep,
}, },
maxIterations: 1, maxIterations: 1,
}); });
expect(listRecentMessages).toHaveBeenCalledTimes(1); expect(listRecentMessages).toHaveBeenCalledTimes(1);
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1); expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@ -8,122 +8,122 @@ import { sleep, withWhatsAppPrefix } from "../utils.js";
import { createClient } from "./client.js"; import { createClient } from "./client.js";
type MonitorDeps = { type MonitorDeps = {
autoReplyIfConfigured: typeof autoReplyIfConfigured; autoReplyIfConfigured: typeof autoReplyIfConfigured;
listRecentMessages: ( listRecentMessages: (
lookbackMinutes: number, lookbackMinutes: number,
limit: number, limit: number,
clientOverride?: ReturnType<typeof createClient>, clientOverride?: ReturnType<typeof createClient>,
) => Promise<ListedMessage[]>; ) => Promise<ListedMessage[]>;
readEnv: typeof readEnv; readEnv: typeof readEnv;
createClient: typeof createClient; createClient: typeof createClient;
sleep: typeof sleep; sleep: typeof sleep;
}; };
const DEFAULT_POLL_INTERVAL_SECONDS = 5; const DEFAULT_POLL_INTERVAL_SECONDS = 5;
export type ListedMessage = { export type ListedMessage = {
sid: string; sid: string;
status: string | null; status: string | null;
direction: string | null; direction: string | null;
dateCreated: Date | undefined; dateCreated: Date | undefined;
from?: string | null; from?: string | null;
to?: string | null; to?: string | null;
body?: string | null; body?: string | null;
errorCode: number | null; errorCode: number | null;
errorMessage: string | null; errorMessage: string | null;
}; };
type MonitorOptions = { type MonitorOptions = {
client?: ReturnType<typeof createClient>; client?: ReturnType<typeof createClient>;
maxIterations?: number; maxIterations?: number;
deps?: MonitorDeps; deps?: MonitorDeps;
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
}; };
const defaultDeps: MonitorDeps = { const defaultDeps: MonitorDeps = {
autoReplyIfConfigured, autoReplyIfConfigured,
listRecentMessages: () => Promise.resolve([]), listRecentMessages: () => Promise.resolve([]),
readEnv, readEnv,
createClient, createClient,
sleep, sleep,
}; };
// Poll Twilio for inbound messages and auto-reply when configured. // Poll Twilio for inbound messages and auto-reply when configured.
export async function monitorTwilio( export async function monitorTwilio(
pollSeconds: number, pollSeconds: number,
lookbackMinutes: number, lookbackMinutes: number,
opts?: MonitorOptions, opts?: MonitorOptions,
) { ) {
const deps = opts?.deps ?? defaultDeps; const deps = opts?.deps ?? defaultDeps;
const runtime = opts?.runtime ?? defaultRuntime; const runtime = opts?.runtime ?? defaultRuntime;
const maxIterations = opts?.maxIterations ?? Infinity; const maxIterations = opts?.maxIterations ?? Infinity;
let backoffMs = 1_000; let backoffMs = 1_000;
const env = deps.readEnv(runtime); const env = deps.readEnv(runtime);
const from = withWhatsAppPrefix(env.whatsappFrom); const from = withWhatsAppPrefix(env.whatsappFrom);
const client = opts?.client ?? deps.createClient(env); const client = opts?.client ?? deps.createClient(env);
logInfo( logInfo(
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`, `📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
runtime, runtime,
); );
let lastSeenSid: string | undefined; let lastSeenSid: string | undefined;
let iterations = 0; let iterations = 0;
while (iterations < maxIterations) { while (iterations < maxIterations) {
let messages: ListedMessage[] = []; let messages: ListedMessage[] = [];
try { try {
messages = messages =
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? []; (await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
backoffMs = 1_000; // reset after success backoffMs = 1_000; // reset after success
} catch (err) { } catch (err) {
logWarn( logWarn(
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`, `Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
runtime, runtime,
); );
await deps.sleep(backoffMs); await deps.sleep(backoffMs);
backoffMs = Math.min(backoffMs * 2, 10_000); backoffMs = Math.min(backoffMs * 2, 10_000);
continue; continue;
} }
const inboundOnly = messages.filter((m) => m.direction === "inbound"); const inboundOnly = messages.filter((m) => m.direction === "inbound");
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports). // Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
const newestFirst = [...inboundOnly].sort( const newestFirst = [...inboundOnly].sort(
(a, b) => (a, b) =>
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0), (b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
); );
await handleMessages(messages, client, lastSeenSid, deps, runtime); await handleMessages(messages, client, lastSeenSid, deps, runtime);
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid; lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
iterations += 1; iterations += 1;
if (iterations >= maxIterations) break; if (iterations >= maxIterations) break;
await deps.sleep( await deps.sleep(
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000, Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
); );
} }
} }
async function handleMessages( async function handleMessages(
messages: ListedMessage[], messages: ListedMessage[],
client: ReturnType<typeof createClient>, client: ReturnType<typeof createClient>,
lastSeenSid: string | undefined, lastSeenSid: string | undefined,
deps: MonitorDeps, deps: MonitorDeps,
runtime: RuntimeEnv, runtime: RuntimeEnv,
) { ) {
for (const m of messages) { for (const m of messages) {
if (!m.sid) continue; if (!m.sid) continue;
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`); logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
if (m.direction !== "inbound") continue; if (m.direction !== "inbound") continue;
if (!m.from || !m.to) continue; if (!m.from || !m.to) continue;
try { try {
await deps.autoReplyIfConfigured( await deps.autoReplyIfConfigured(
client as unknown as import("./types.js").TwilioRequester & { client as unknown as import("./types.js").TwilioRequester & {
messages: { create: (opts: unknown) => Promise<unknown> }; messages: { create: (opts: unknown) => Promise<unknown> };
}, },
m as unknown as MessageInstance, m as unknown as MessageInstance,
undefined, undefined,
runtime, runtime,
); );
} catch (err) { } catch (err) {
runtime.error(danger(`Auto-reply failed: ${String(err)}`)); runtime.error(danger(`Auto-reply failed: ${String(err)}`));
} }
} }
} }

View File

@ -3,30 +3,30 @@ import { describe, expect, it, vi } from "vitest";
import { waitForFinalStatus } from "./send.js"; import { waitForFinalStatus } from "./send.js";
describe("twilio send helpers", () => { describe("twilio send helpers", () => {
it("waitForFinalStatus resolves on delivered", async () => { it("waitForFinalStatus resolves on delivered", async () => {
const fetch = vi const fetch = vi
.fn() .fn()
.mockResolvedValueOnce({ status: "queued" }) .mockResolvedValueOnce({ status: "queued" })
.mockResolvedValueOnce({ status: "delivered" }); .mockResolvedValueOnce({ status: "delivered" });
const client = { messages: vi.fn(() => ({ fetch })) } as never; const client = { messages: vi.fn(() => ({ fetch })) } as never;
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never); await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
expect(fetch).toHaveBeenCalledTimes(2); expect(fetch).toHaveBeenCalledTimes(2);
}); });
it("waitForFinalStatus exits on failure", async () => { it("waitForFinalStatus exits on failure", async () => {
const fetch = vi const fetch = vi
.fn() .fn()
.mockResolvedValue({ status: "failed", errorMessage: "boom" }); .mockResolvedValue({ status: "failed", errorMessage: "boom" });
const client = { messages: vi.fn(() => ({ fetch })) } as never; const client = { messages: vi.fn(() => ({ fetch })) } as never;
const runtime = { const runtime = {
log: console.log, log: console.log,
error: () => {}, error: () => {},
exit: vi.fn(() => { exit: vi.fn(() => {
throw new Error("exit"); throw new Error("exit");
}), }),
} as never; } as never;
await expect( await expect(
waitForFinalStatus(client, "SM1", 1, 0.01, runtime), waitForFinalStatus(client, "SM1", 1, 0.01, runtime),
).rejects.toBeInstanceOf(Error); ).rejects.toBeInstanceOf(Error);
}); });
}); });

View File

@ -10,60 +10,60 @@ const failureTerminalStatuses = new Set(["failed", "undelivered", "canceled"]);
// Send outbound WhatsApp message; exit non-zero on API failure. // Send outbound WhatsApp message; exit non-zero on API failure.
export async function sendMessage( export async function sendMessage(
to: string, to: string,
body: string, body: string,
opts?: { mediaUrl?: string }, opts?: { mediaUrl?: string },
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
) { ) {
const env = readEnv(runtime); const env = readEnv(runtime);
const client = createClient(env); const client = createClient(env);
const from = withWhatsAppPrefix(env.whatsappFrom); const from = withWhatsAppPrefix(env.whatsappFrom);
const toNumber = withWhatsAppPrefix(to); const toNumber = withWhatsAppPrefix(to);
try { try {
const message = await client.messages.create({ const message = await client.messages.create({
from, from,
to: toNumber, to: toNumber,
body, body,
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined, mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
}); });
logInfo( logInfo(
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`, `✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
runtime, runtime,
); );
return { client, sid: message.sid }; return { client, sid: message.sid };
} catch (err) { } catch (err) {
logTwilioSendError(err, toNumber, runtime); logTwilioSendError(err, toNumber, runtime);
} }
} }
// Poll message status until delivered/failed or timeout. // Poll message status until delivered/failed or timeout.
export async function waitForFinalStatus( export async function waitForFinalStatus(
client: ReturnType<typeof createClient>, client: ReturnType<typeof createClient>,
sid: string, sid: string,
timeoutSeconds: number, timeoutSeconds: number,
pollSeconds: number, pollSeconds: number,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
) { ) {
const deadline = Date.now() + timeoutSeconds * 1000; const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const m = await client.messages(sid).fetch(); const m = await client.messages(sid).fetch();
const status = m.status ?? "unknown"; const status = m.status ?? "unknown";
if (successTerminalStatuses.has(status)) { if (successTerminalStatuses.has(status)) {
logInfo(`✅ Delivered (status: ${status})`, runtime); logInfo(`✅ Delivered (status: ${status})`, runtime);
return; return;
} }
if (failureTerminalStatuses.has(status)) { if (failureTerminalStatuses.has(status)) {
runtime.error( runtime.error(
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`, `❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
); );
runtime.exit(1); runtime.exit(1);
} }
await sleep(pollSeconds * 1000); await sleep(pollSeconds * 1000);
} }
logInfo( logInfo(
" Timed out waiting for final status; message may still be in flight.", " Timed out waiting for final status; message may still be in flight.",
runtime, runtime,
); );
} }

View File

@ -4,50 +4,50 @@ import { withWhatsAppPrefix } from "../utils.js";
import type { TwilioSenderListClient } from "./types.js"; import type { TwilioSenderListClient } from "./types.js";
export async function findWhatsappSenderSid( export async function findWhatsappSenderSid(
client: TwilioSenderListClient, client: TwilioSenderListClient,
from: string, from: string,
explicitSenderSid?: string, explicitSenderSid?: string,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
) { ) {
// Use explicit sender SID if provided, otherwise list and match by sender_id. // Use explicit sender SID if provided, otherwise list and match by sender_id.
if (explicitSenderSid) { if (explicitSenderSid) {
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`); logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
return explicitSenderSid; return explicitSenderSid;
} }
try { try {
// Prefer official SDK list helper to avoid request-shape mismatches. // Prefer official SDK list helper to avoid request-shape mismatches.
// Twilio helper types are broad; we narrow to expected shape. // Twilio helper types are broad; we narrow to expected shape.
const senderClient = client as unknown as TwilioSenderListClient; const senderClient = client as unknown as TwilioSenderListClient;
const senders = await senderClient.messaging.v2.channelsSenders.list({ const senders = await senderClient.messaging.v2.channelsSenders.list({
channel: "whatsapp", channel: "whatsapp",
pageSize: 50, pageSize: 50,
}); });
if (!senders) { if (!senders) {
throw new Error('List senders response missing "senders" array'); throw new Error('List senders response missing "senders" array');
} }
const match = senders.find( const match = senders.find(
(s) => (s) =>
(typeof s.senderId === "string" && (typeof s.senderId === "string" &&
s.senderId === withWhatsAppPrefix(from)) || s.senderId === withWhatsAppPrefix(from)) ||
(typeof s.sender_id === "string" && (typeof s.sender_id === "string" &&
s.sender_id === withWhatsAppPrefix(from)), s.sender_id === withWhatsAppPrefix(from)),
); );
if (!match || typeof match.sid !== "string") { if (!match || typeof match.sid !== "string") {
throw new Error( throw new Error(
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`, `Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
); );
} }
return match.sid; return match.sid;
} catch (err) { } catch (err) {
runtime.error(danger("Unable to list WhatsApp senders via Twilio API.")); runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
if (isVerbose()) { if (isVerbose()) {
runtime.error(err as Error); runtime.error(err as Error);
} }
runtime.error( runtime.error(
info( info(
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).", "Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
), ),
); );
runtime.exit(1); runtime.exit(1);
} }
} }

View File

@ -1,79 +1,79 @@
export type TwilioRequestOptions = { export type TwilioRequestOptions = {
method: "get" | "post"; method: "get" | "post";
uri: string; uri: string;
params?: Record<string, string | number>; params?: Record<string, string | number>;
form?: Record<string, string>; form?: Record<string, string>;
body?: unknown; body?: unknown;
contentType?: string; contentType?: string;
}; };
export type TwilioSender = { sid: string; sender_id: string }; export type TwilioSender = { sid: string; sender_id: string };
export type TwilioRequestResponse = { export type TwilioRequestResponse = {
data?: { data?: {
senders?: TwilioSender[]; senders?: TwilioSender[];
}; };
}; };
export type IncomingNumber = { export type IncomingNumber = {
sid: string; sid: string;
phoneNumber: string; phoneNumber: string;
smsUrl?: string; smsUrl?: string;
}; };
export type TwilioChannelsSender = { export type TwilioChannelsSender = {
sid?: string; sid?: string;
senderId?: string; senderId?: string;
sender_id?: string; sender_id?: string;
webhook?: { webhook?: {
callback_url?: string; callback_url?: string;
callback_method?: string; callback_method?: string;
fallback_url?: string; fallback_url?: string;
fallback_method?: string; fallback_method?: string;
}; };
}; };
export type ChannelSenderUpdater = { export type ChannelSenderUpdater = {
update: (params: Record<string, string>) => Promise<unknown>; update: (params: Record<string, string>) => Promise<unknown>;
}; };
export type IncomingPhoneNumberUpdater = { export type IncomingPhoneNumberUpdater = {
update: (params: Record<string, string>) => Promise<unknown>; update: (params: Record<string, string>) => Promise<unknown>;
}; };
export type IncomingPhoneNumbersClient = { export type IncomingPhoneNumbersClient = {
list: (params: { list: (params: {
phoneNumber: string; phoneNumber: string;
limit?: number; limit?: number;
}) => Promise<IncomingNumber[]>; }) => Promise<IncomingNumber[]>;
get: (sid: string) => IncomingPhoneNumberUpdater; get: (sid: string) => IncomingPhoneNumberUpdater;
} & ((sid: string) => IncomingPhoneNumberUpdater); } & ((sid: string) => IncomingPhoneNumberUpdater);
export type TwilioSenderListClient = { export type TwilioSenderListClient = {
messaging: { messaging: {
v2: { v2: {
channelsSenders: { channelsSenders: {
list: (params: { list: (params: {
channel: string; channel: string;
pageSize: number; pageSize: number;
}) => Promise<TwilioChannelsSender[]>; }) => Promise<TwilioChannelsSender[]>;
( (
sid: string, sid: string,
): ChannelSenderUpdater & { ): ChannelSenderUpdater & {
fetch: () => Promise<TwilioChannelsSender>; fetch: () => Promise<TwilioChannelsSender>;
}; };
}; };
}; };
v1: { v1: {
services: (sid: string) => { services: (sid: string) => {
update: (params: Record<string, string>) => Promise<unknown>; update: (params: Record<string, string>) => Promise<unknown>;
fetch: () => Promise<{ inboundRequestUrl?: string }>; fetch: () => Promise<{ inboundRequestUrl?: string }>;
}; };
}; };
}; };
incomingPhoneNumbers: IncomingPhoneNumbersClient; incomingPhoneNumbers: IncomingPhoneNumbersClient;
}; };
export type TwilioRequester = { export type TwilioRequester = {
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>; request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
}; };

View File

@ -2,42 +2,42 @@ import { isVerbose, logVerbose, warn } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
type TwilioRequestOptions = { type TwilioRequestOptions = {
method: "get" | "post"; method: "get" | "post";
uri: string; uri: string;
params?: Record<string, string | number>; params?: Record<string, string | number>;
form?: Record<string, string>; form?: Record<string, string>;
body?: unknown; body?: unknown;
contentType?: string; contentType?: string;
}; };
type TwilioRequester = { type TwilioRequester = {
request: (options: TwilioRequestOptions) => Promise<unknown>; request: (options: TwilioRequestOptions) => Promise<unknown>;
}; };
export async function sendTypingIndicator( export async function sendTypingIndicator(
client: TwilioRequester, client: TwilioRequester,
runtime: RuntimeEnv, runtime: RuntimeEnv,
messageSid?: string, messageSid?: string,
) { ) {
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025). // Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
if (!messageSid) { if (!messageSid) {
logVerbose("Skipping typing indicator: missing MessageSid"); logVerbose("Skipping typing indicator: missing MessageSid");
return; return;
} }
try { try {
await client.request({ await client.request({
method: "post", method: "post",
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json", uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
form: { form: {
messageId: messageSid, messageId: messageSid,
channel: "whatsapp", channel: "whatsapp",
}, },
}); });
logVerbose(`Sent typing indicator for inbound ${messageSid}`); logVerbose(`Sent typing indicator for inbound ${messageSid}`);
} catch (err) { } catch (err) {
if (isVerbose()) { if (isVerbose()) {
runtime.error(warn("Typing indicator failed (continuing without it)")); runtime.error(warn("Typing indicator failed (continuing without it)"));
runtime.error(err as Error); runtime.error(err as Error);
} }
} }
} }

View File

@ -1,61 +1,61 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { import {
findIncomingNumberSid, findIncomingNumberSid,
findMessagingServiceSid, findMessagingServiceSid,
setMessagingServiceWebhook, setMessagingServiceWebhook,
} from "./update-webhook.js"; } from "./update-webhook.js";
const envBackup = { ...process.env } as Record<string, string | undefined>; const envBackup = { ...process.env } as Record<string, string | undefined>;
describe("update-webhook helpers", () => { describe("update-webhook helpers", () => {
beforeEach(() => { beforeEach(() => {
process.env.TWILIO_ACCOUNT_SID = "AC"; process.env.TWILIO_ACCOUNT_SID = "AC";
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555"; process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
process.env.TWILIO_AUTH_TOKEN = "dummy-token"; process.env.TWILIO_AUTH_TOKEN = "dummy-token";
}); });
afterEach(() => { afterEach(() => {
Object.entries(envBackup).forEach(([k, v]) => { Object.entries(envBackup).forEach(([k, v]) => {
if (v === undefined) delete process.env[k]; if (v === undefined) delete process.env[k];
else process.env[k] = v; else process.env[k] = v;
}); });
}); });
it("findIncomingNumberSid returns first match", async () => { it("findIncomingNumberSid returns first match", async () => {
const client = { const client = {
incomingPhoneNumbers: { incomingPhoneNumbers: {
list: async () => [{ sid: "PN1", phoneNumber: "+1555" }], list: async () => [{ sid: "PN1", phoneNumber: "+1555" }],
}, },
} as never; } as never;
const sid = await findIncomingNumberSid(client); const sid = await findIncomingNumberSid(client);
expect(sid).toBe("PN1"); expect(sid).toBe("PN1");
}); });
it("findMessagingServiceSid reads messagingServiceSid", async () => { it("findMessagingServiceSid reads messagingServiceSid", async () => {
const client = { const client = {
incomingPhoneNumbers: { incomingPhoneNumbers: {
list: async () => [{ messagingServiceSid: "MG1" }], list: async () => [{ messagingServiceSid: "MG1" }],
}, },
} as never; } as never;
const sid = await findMessagingServiceSid(client); const sid = await findMessagingServiceSid(client);
expect(sid).toBe("MG1"); expect(sid).toBe("MG1");
}); });
it("setMessagingServiceWebhook updates via service helper", async () => { it("setMessagingServiceWebhook updates via service helper", async () => {
const update = async (_: unknown) => {}; const update = async (_: unknown) => {};
const fetch = async () => ({ inboundRequestUrl: "https://cb" }); const fetch = async () => ({ inboundRequestUrl: "https://cb" });
const client = { const client = {
messaging: { messaging: {
v1: { v1: {
services: () => ({ update, fetch }), services: () => ({ update, fetch }),
}, },
}, },
incomingPhoneNumbers: { incomingPhoneNumbers: {
list: async () => [{ messagingServiceSid: "MG1" }], list: async () => [{ messagingServiceSid: "MG1" }],
}, },
} as never; } as never;
const ok = await setMessagingServiceWebhook(client, "https://cb", "POST"); const ok = await setMessagingServiceWebhook(client, "https://cb", "POST");
expect(ok).toBe(true); expect(ok).toBe(true);
}); });
}); });

View File

@ -6,193 +6,193 @@ import type { createClient } from "./client.js";
import type { TwilioRequester, TwilioSenderListClient } from "./types.js"; import type { TwilioRequester, TwilioSenderListClient } from "./types.js";
export async function findIncomingNumberSid( export async function findIncomingNumberSid(
client: TwilioSenderListClient, client: TwilioSenderListClient,
): Promise<string | null> { ): Promise<string | null> {
// Look up incoming phone number SID matching the configured WhatsApp number. // Look up incoming phone number SID matching the configured WhatsApp number.
try { try {
const env = readEnv(); const env = readEnv();
const phone = env.whatsappFrom.replace("whatsapp:", ""); const phone = env.whatsappFrom.replace("whatsapp:", "");
const list = await client.incomingPhoneNumbers.list({ const list = await client.incomingPhoneNumbers.list({
phoneNumber: phone, phoneNumber: phone,
limit: 1, limit: 1,
}); });
return list?.[0]?.sid ?? null; return list?.[0]?.sid ?? null;
} catch { } catch {
return null; return null;
} }
} }
export async function findMessagingServiceSid( export async function findMessagingServiceSid(
client: TwilioSenderListClient, client: TwilioSenderListClient,
): Promise<string | null> { ): Promise<string | null> {
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback). // Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
type IncomingNumberWithService = { messagingServiceSid?: string }; type IncomingNumberWithService = { messagingServiceSid?: string };
try { try {
const env = readEnv(); const env = readEnv();
const phone = env.whatsappFrom.replace("whatsapp:", ""); const phone = env.whatsappFrom.replace("whatsapp:", "");
const list = await client.incomingPhoneNumbers.list({ const list = await client.incomingPhoneNumbers.list({
phoneNumber: phone, phoneNumber: phone,
limit: 1, limit: 1,
}); });
const msid = const msid =
(list?.[0] as IncomingNumberWithService | undefined) (list?.[0] as IncomingNumberWithService | undefined)
?.messagingServiceSid ?? null; ?.messagingServiceSid ?? null;
return msid; return msid;
} catch { } catch {
return null; return null;
} }
} }
export async function setMessagingServiceWebhook( export async function setMessagingServiceWebhook(
client: TwilioSenderListClient, client: TwilioSenderListClient,
url: string, url: string,
method: "POST" | "GET", method: "POST" | "GET",
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
): Promise<boolean> { ): Promise<boolean> {
const msid = await findMessagingServiceSid(client); const msid = await findMessagingServiceSid(client);
if (!msid) return false; if (!msid) return false;
try { try {
await client.messaging.v1.services(msid).update({ await client.messaging.v1.services(msid).update({
InboundRequestUrl: url, InboundRequestUrl: url,
InboundRequestMethod: method, InboundRequestMethod: method,
}); });
const fetched = await client.messaging.v1.services(msid).fetch(); const fetched = await client.messaging.v1.services(msid).fetch();
const stored = fetched?.inboundRequestUrl; const stored = fetched?.inboundRequestUrl;
logInfo( logInfo(
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`, `✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
runtime, runtime,
); );
return true; return true;
} catch { } catch {
return false; return false;
} }
} }
// Update sender webhook URL with layered fallbacks (channels, form, helper, phone). // Update sender webhook URL with layered fallbacks (channels, form, helper, phone).
export async function updateWebhook( export async function updateWebhook(
client: ReturnType<typeof createClient>, client: ReturnType<typeof createClient>,
senderSid: string, senderSid: string,
url: string, url: string,
method: "POST" | "GET" = "POST", method: "POST" | "GET" = "POST",
runtime: RuntimeEnv, runtime: RuntimeEnv,
) { ) {
// Point Twilio sender webhook at the provided URL. // Point Twilio sender webhook at the provided URL.
const requester = client as unknown as TwilioRequester; const requester = client as unknown as TwilioRequester;
const clientTyped = client as unknown as TwilioSenderListClient; const clientTyped = client as unknown as TwilioSenderListClient;
// 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA // 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA
try { try {
await requester.request({ await requester.request({
method: "post", method: "post",
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`, uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
body: { body: {
webhook: { webhook: {
callback_url: url, callback_url: url,
callback_method: method, callback_method: method,
}, },
}, },
contentType: "application/json", contentType: "application/json",
}); });
const fetched = await clientTyped.messaging.v2 const fetched = await clientTyped.messaging.v2
.channelsSenders(senderSid) .channelsSenders(senderSid)
.fetch(); .fetch();
const storedUrl = const storedUrl =
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
if (storedUrl) { if (storedUrl) {
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime); logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
return; return;
} }
if (isVerbose()) if (isVerbose())
logError( logError(
"Sender updated but webhook callback_url missing; will try fallbacks", "Sender updated but webhook callback_url missing; will try fallbacks",
runtime, runtime,
); );
} catch (err) { } catch (err) {
if (isVerbose()) if (isVerbose())
logError( logError(
`channelsSenders request update failed, will try client helpers: ${String(err)}`, `channelsSenders request update failed, will try client helpers: ${String(err)}`,
runtime, runtime,
); );
} }
// 1b) Form-encoded fallback for older Twilio stacks // 1b) Form-encoded fallback for older Twilio stacks
try { try {
await requester.request({ await requester.request({
method: "post", method: "post",
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`, uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
form: { form: {
"Webhook.CallbackUrl": url, "Webhook.CallbackUrl": url,
"Webhook.CallbackMethod": method, "Webhook.CallbackMethod": method,
}, },
}); });
const fetched = await clientTyped.messaging.v2 const fetched = await clientTyped.messaging.v2
.channelsSenders(senderSid) .channelsSenders(senderSid)
.fetch(); .fetch();
const storedUrl = const storedUrl =
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
if (storedUrl) { if (storedUrl) {
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime); logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
return; return;
} }
if (isVerbose()) if (isVerbose())
logError( logError(
"Form update succeeded but callback_url missing; will try helper fallback", "Form update succeeded but callback_url missing; will try helper fallback",
runtime, runtime,
); );
} catch (err) { } catch (err) {
if (isVerbose()) if (isVerbose())
logError( logError(
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`, `Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
runtime, runtime,
); );
} }
// 2) SDK helper fallback (if supported by this client) // 2) SDK helper fallback (if supported by this client)
try { try {
if (clientTyped.messaging?.v2?.channelsSenders) { if (clientTyped.messaging?.v2?.channelsSenders) {
await clientTyped.messaging.v2.channelsSenders(senderSid).update({ await clientTyped.messaging.v2.channelsSenders(senderSid).update({
callbackUrl: url, callbackUrl: url,
callbackMethod: method, callbackMethod: method,
}); });
const fetched = await clientTyped.messaging.v2 const fetched = await clientTyped.messaging.v2
.channelsSenders(senderSid) .channelsSenders(senderSid)
.fetch(); .fetch();
const storedUrl = const storedUrl =
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
logInfo( logInfo(
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`, `✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
runtime, runtime,
); );
return; return;
} }
} catch (err) { } catch (err) {
if (isVerbose()) if (isVerbose())
logError( logError(
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`, `channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
runtime, runtime,
); );
} }
// 3) Incoming phone number fallback (works for many WA senders) // 3) Incoming phone number fallback (works for many WA senders)
try { try {
const phoneSid = await findIncomingNumberSid(clientTyped); const phoneSid = await findIncomingNumberSid(clientTyped);
if (phoneSid) { if (phoneSid) {
await clientTyped.incomingPhoneNumbers(phoneSid).update({ await clientTyped.incomingPhoneNumbers(phoneSid).update({
smsUrl: url, smsUrl: url,
smsMethod: method, smsMethod: method,
}); });
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime); logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
return; return;
} }
} catch (err) { } catch (err) {
if (isVerbose()) if (isVerbose())
logError( logError(
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`, `Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
runtime, runtime,
); );
} }
runtime.error( runtime.error(
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`, `❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
); );
} }

View File

@ -2,36 +2,36 @@ import { danger, info } from "../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
type TwilioApiError = { type TwilioApiError = {
code?: number | string; code?: number | string;
status?: number | string; status?: number | string;
message?: string; message?: string;
moreInfo?: string; moreInfo?: string;
response?: { body?: unknown }; response?: { body?: unknown };
}; };
export function formatTwilioError(err: unknown): string { export function formatTwilioError(err: unknown): string {
// Normalize Twilio error objects into a single readable string. // Normalize Twilio error objects into a single readable string.
const e = err as TwilioApiError; const e = err as TwilioApiError;
const pieces = []; const pieces = [];
if (e.code != null) pieces.push(`code ${e.code}`); if (e.code != null) pieces.push(`code ${e.code}`);
if (e.status != null) pieces.push(`status ${e.status}`); if (e.status != null) pieces.push(`status ${e.status}`);
if (e.message) pieces.push(e.message); if (e.message) pieces.push(e.message);
if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`); if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`);
return pieces.length ? pieces.join(" | ") : String(err); return pieces.length ? pieces.join(" | ") : String(err);
} }
export function logTwilioSendError( export function logTwilioSendError(
err: unknown, err: unknown,
destination?: string, destination?: string,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
) { ) {
// Friendly error logger for send failures, including response body when present. // Friendly error logger for send failures, including response body when present.
const prefix = destination ? `to ${destination}: ` : ""; const prefix = destination ? `to ${destination}: ` : "";
runtime.error( runtime.error(
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`), danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
); );
const body = (err as TwilioApiError)?.response?.body; const body = (err as TwilioApiError)?.response?.body;
if (body) { if (body) {
runtime.error(info("Response body:"), JSON.stringify(body, null, 2)); runtime.error(info("Response body:"), JSON.stringify(body, null, 2));
} }
} }

View File

@ -16,143 +16,143 @@ import { logTwilioSendError } from "./utils.js";
/** Start the inbound webhook HTTP server and wire optional auto-replies. */ /** Start the inbound webhook HTTP server and wire optional auto-replies. */
export async function startWebhook( export async function startWebhook(
port: number, port: number,
path = "/webhook/whatsapp", path = "/webhook/whatsapp",
autoReply: string | undefined, autoReply: string | undefined,
verbose: boolean, verbose: boolean,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
): Promise<Server> { ): Promise<Server> {
const normalizedPath = normalizePath(path); const normalizedPath = normalizePath(path);
const env = readEnv(runtime); const env = readEnv(runtime);
const app = express(); const app = express();
attachMediaRoutes(app, undefined, runtime); attachMediaRoutes(app, undefined, runtime);
// Twilio sends application/x-www-form-urlencoded payloads. // Twilio sends application/x-www-form-urlencoded payloads.
app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.urlencoded({ extended: false }));
app.use((req, _res, next) => { app.use((req, _res, next) => {
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`)); runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
next(); next();
}); });
app.post(normalizedPath, async (req: Request, res: Response) => { app.post(normalizedPath, async (req: Request, res: Response) => {
const { From, To, Body, MessageSid } = req.body ?? {}; const { From, To, Body, MessageSid } = req.body ?? {};
runtime.log(` runtime.log(`
[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`); [INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`);
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`)); if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10); const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
let mediaPath: string | undefined; let mediaPath: string | undefined;
let mediaUrlInbound: string | undefined; let mediaUrlInbound: string | undefined;
let mediaType: string | undefined; let mediaType: string | undefined;
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") { if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
mediaUrlInbound = req.body.MediaUrl0 as string; mediaUrlInbound = req.body.MediaUrl0 as string;
mediaType = mediaType =
typeof req.body?.MediaContentType0 === "string" typeof req.body?.MediaContentType0 === "string"
? (req.body.MediaContentType0 as string) ? (req.body.MediaContentType0 as string)
: undefined; : undefined;
try { try {
const creds = buildTwilioBasicAuth(env); const creds = buildTwilioBasicAuth(env);
const saved = await saveMediaSource( const saved = await saveMediaSource(
mediaUrlInbound, mediaUrlInbound,
{ {
Authorization: `Basic ${creds}`, Authorization: `Basic ${creds}`,
}, },
"inbound", "inbound",
); );
mediaPath = saved.path; mediaPath = saved.path;
if (!mediaType && saved.contentType) mediaType = saved.contentType; if (!mediaType && saved.contentType) mediaType = saved.contentType;
} catch (err) { } catch (err) {
runtime.error( runtime.error(
danger(`Failed to download inbound media: ${String(err)}`), danger(`Failed to download inbound media: ${String(err)}`),
); );
} }
} }
const client = createClient(env); const client = createClient(env);
let replyResult: ReplyPayload | undefined = let replyResult: ReplyPayload | undefined =
autoReply !== undefined ? { text: autoReply } : undefined; autoReply !== undefined ? { text: autoReply } : undefined;
if (!replyResult) { if (!replyResult) {
replyResult = await getReplyFromConfig( replyResult = await getReplyFromConfig(
{ {
Body, Body,
From, From,
To, To,
MessageSid, MessageSid,
MediaPath: mediaPath, MediaPath: mediaPath,
MediaUrl: mediaUrlInbound, MediaUrl: mediaUrlInbound,
MediaType: mediaType, MediaType: mediaType,
}, },
{ {
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid), onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
}, },
); );
} }
if (replyResult && (replyResult.text || replyResult.mediaUrl)) { if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
try { try {
let mediaUrl = replyResult.mediaUrl; let mediaUrl = replyResult.mediaUrl;
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) { if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
const hosted = await ensureMediaHosted(mediaUrl); const hosted = await ensureMediaHosted(mediaUrl);
mediaUrl = hosted.url; mediaUrl = hosted.url;
} }
await client.messages.create({ await client.messages.create({
from: To, from: To,
to: From, to: From,
body: replyResult.text ?? "", body: replyResult.text ?? "",
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}), ...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
}); });
if (verbose) if (verbose)
runtime.log( runtime.log(
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`), success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
); );
} catch (err) { } catch (err) {
logTwilioSendError(err, From ?? undefined, runtime); logTwilioSendError(err, From ?? undefined, runtime);
} }
} }
// Respond 200 OK to Twilio. // Respond 200 OK to Twilio.
res.type("text/xml").send("<Response></Response>"); res.type("text/xml").send("<Response></Response>");
}); });
app.use((_req, res) => { app.use((_req, res) => {
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`)); if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
res.status(404).send("warelay webhook: not found"); res.status(404).send("warelay webhook: not found");
}); });
// Start server and resolve once listening; reject on bind error. // Start server and resolve once listening; reject on bind error.
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const server = app.listen(port); const server = app.listen(port);
const onListening = () => { const onListening = () => {
cleanup(); cleanup();
runtime.log( runtime.log(
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`, `📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
); );
resolve(server); resolve(server);
}; };
const onError = (err: NodeJS.ErrnoException) => { const onError = (err: NodeJS.ErrnoException) => {
cleanup(); cleanup();
reject(err); reject(err);
}; };
const cleanup = () => { const cleanup = () => {
server.off("listening", onListening); server.off("listening", onListening);
server.off("error", onError); server.off("error", onError);
}; };
server.once("listening", onListening); server.once("listening", onListening);
server.once("error", onError); server.once("error", onError);
}); });
} }
function buildTwilioBasicAuth(env: EnvConfig) { function buildTwilioBasicAuth(env: EnvConfig) {
if ("authToken" in env.auth) { if ("authToken" in env.auth) {
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString( return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
"base64", "base64",
); );
} }
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString( return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
"base64", "base64",
); );
} }

View File

@ -3,67 +3,67 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
assertProvider, assertProvider,
ensureDir, ensureDir,
normalizeE164, normalizeE164,
normalizePath, normalizePath,
sleep, sleep,
toWhatsappJid, toWhatsappJid,
withWhatsAppPrefix, withWhatsAppPrefix,
} from "./utils.js"; } from "./utils.js";
describe("normalizePath", () => { describe("normalizePath", () => {
it("adds leading slash when missing", () => { it("adds leading slash when missing", () => {
expect(normalizePath("foo")).toBe("/foo"); expect(normalizePath("foo")).toBe("/foo");
}); });
it("keeps existing slash", () => { it("keeps existing slash", () => {
expect(normalizePath("/bar")).toBe("/bar"); expect(normalizePath("/bar")).toBe("/bar");
}); });
}); });
describe("withWhatsAppPrefix", () => { describe("withWhatsAppPrefix", () => {
it("adds whatsapp prefix", () => { it("adds whatsapp prefix", () => {
expect(withWhatsAppPrefix("+1555")).toBe("whatsapp:+1555"); expect(withWhatsAppPrefix("+1555")).toBe("whatsapp:+1555");
}); });
it("leaves prefixed intact", () => { it("leaves prefixed intact", () => {
expect(withWhatsAppPrefix("whatsapp:+1555")).toBe("whatsapp:+1555"); expect(withWhatsAppPrefix("whatsapp:+1555")).toBe("whatsapp:+1555");
}); });
}); });
describe("ensureDir", () => { describe("ensureDir", () => {
it("creates nested directory", async () => { it("creates nested directory", async () => {
const tmp = await fs.promises.mkdtemp( const tmp = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "warelay-test-"), path.join(os.tmpdir(), "warelay-test-"),
); );
const target = path.join(tmp, "nested", "dir"); const target = path.join(tmp, "nested", "dir");
await ensureDir(target); await ensureDir(target);
expect(fs.existsSync(target)).toBe(true); expect(fs.existsSync(target)).toBe(true);
}); });
}); });
describe("sleep", () => { describe("sleep", () => {
it("resolves after delay using fake timers", async () => { it("resolves after delay using fake timers", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const promise = sleep(1000); const promise = sleep(1000);
vi.advanceTimersByTime(1000); vi.advanceTimersByTime(1000);
await expect(promise).resolves.toBeUndefined(); await expect(promise).resolves.toBeUndefined();
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });
describe("assertProvider", () => { describe("assertProvider", () => {
it("throws for invalid provider", () => { it("throws for invalid provider", () => {
expect(() => assertProvider("bad" as string)).toThrow(); expect(() => assertProvider("bad" as string)).toThrow();
}); });
}); });
describe("normalizeE164 & toWhatsappJid", () => { describe("normalizeE164 & toWhatsappJid", () => {
it("strips formatting and prefixes", () => { it("strips formatting and prefixes", () => {
expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567"); expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567");
expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe( expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe(
"5551234567@s.whatsapp.net", "5551234567@s.whatsapp.net",
); );
}); });
}); });

View File

@ -2,49 +2,49 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
export async function ensureDir(dir: string) { export async function ensureDir(dir: string) {
await fs.promises.mkdir(dir, { recursive: true }); await fs.promises.mkdir(dir, { recursive: true });
} }
export type Provider = "twilio" | "web"; export type Provider = "twilio" | "web";
export function assertProvider(input: string): asserts input is Provider { export function assertProvider(input: string): asserts input is Provider {
if (input !== "twilio" && input !== "web") { if (input !== "twilio" && input !== "web") {
throw new Error("Provider must be 'twilio' or 'web'"); throw new Error("Provider must be 'twilio' or 'web'");
} }
} }
export function normalizePath(p: string): string { export function normalizePath(p: string): string {
if (!p.startsWith("/")) return `/${p}`; if (!p.startsWith("/")) return `/${p}`;
return p; return p;
} }
export function withWhatsAppPrefix(number: string): string { export function withWhatsAppPrefix(number: string): string {
return number.startsWith("whatsapp:") ? number : `whatsapp:${number}`; return number.startsWith("whatsapp:") ? number : `whatsapp:${number}`;
} }
export function normalizeE164(number: string): string { export function normalizeE164(number: string): string {
const withoutPrefix = number.replace(/^whatsapp:/, "").trim(); const withoutPrefix = number.replace(/^whatsapp:/, "").trim();
const digits = withoutPrefix.replace(/[^\d+]/g, ""); const digits = withoutPrefix.replace(/[^\d+]/g, "");
if (digits.startsWith("+")) return `+${digits.slice(1)}`; if (digits.startsWith("+")) return `+${digits.slice(1)}`;
return `+${digits}`; return `+${digits}`;
} }
export function toWhatsappJid(number: string): string { export function toWhatsappJid(number: string): string {
const e164 = normalizeE164(number); const e164 = normalizeE164(number);
const digits = e164.replace(/\D/g, ""); const digits = e164.replace(/\D/g, "");
return `${digits}@s.whatsapp.net`; return `${digits}@s.whatsapp.net`;
} }
export function jidToE164(jid: string): string | null { export function jidToE164(jid: string): string | null {
// Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234. // Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234.
const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/); const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/);
if (!match) return null; if (!match) return null;
const digits = match[1]; const digits = match[1];
return `+${digits}`; return `+${digits}`;
} }
export function sleep(ms: number) { export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
export const CONFIG_DIR = `${os.homedir()}/.warelay`; export const CONFIG_DIR = `${os.homedir()}/.warelay`;

View File

@ -4,7 +4,7 @@ import * as impl from "../twilio/webhook.js";
import * as entry from "./server.js"; import * as entry from "./server.js";
describe("webhook server wrapper", () => { describe("webhook server wrapper", () => {
it("re-exports startWebhook", () => { it("re-exports startWebhook", () => {
expect(entry.startWebhook).toBe(impl.startWebhook); expect(entry.startWebhook).toBe(impl.startWebhook);
}); });
}); });

View File

@ -4,12 +4,12 @@ import * as impl from "../twilio/update-webhook.js";
import * as entry from "./update.js"; import * as entry from "./update.js";
describe("webhook update wrappers", () => { describe("webhook update wrappers", () => {
it("mirror the Twilio implementations", () => { it("mirror the Twilio implementations", () => {
expect(entry.updateWebhook).toBe(impl.updateWebhook); expect(entry.updateWebhook).toBe(impl.updateWebhook);
expect(entry.findIncomingNumberSid).toBe(impl.findIncomingNumberSid); expect(entry.findIncomingNumberSid).toBe(impl.findIncomingNumberSid);
expect(entry.findMessagingServiceSid).toBe(impl.findMessagingServiceSid); expect(entry.findMessagingServiceSid).toBe(impl.findMessagingServiceSid);
expect(entry.setMessagingServiceWebhook).toBe( expect(entry.setMessagingServiceWebhook).toBe(
impl.setMessagingServiceWebhook, impl.setMessagingServiceWebhook,
); );
}); });
}); });

View File

@ -1,7 +1,7 @@
/* istanbul ignore file */ /* istanbul ignore file */
export { export {
findIncomingNumberSid, findIncomingNumberSid,
findMessagingServiceSid, findMessagingServiceSid,
setMessagingServiceWebhook, setMessagingServiceWebhook,
updateWebhook, updateWebhook,
} from "../twilio/update-webhook.js"; } from "../twilio/update-webhook.js";