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
## [Unreleased] 1.0.5
## 1.1.0 — 2025-11-25
### Pending
- 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",
"formatter": {
"enabled": true,
"indentWidth": 2
"indentWidth": 2,
"indentStyle": "space"
},
"linter": {
"enabled": true,

View File

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

View File

@@ -4,159 +4,159 @@ import { z } from "zod";
// Preferred binary name for Claude CLI invocations.
export const CLAUDE_BIN = "claude";
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 {
// Best-effort walker to find the primary text field in Claude JSON outputs.
if (payload == null) return undefined;
if (typeof payload === "string") return payload;
if (Array.isArray(payload)) {
for (const item of payload) {
const found = extractClaudeText(item);
if (found) return found;
}
return undefined;
}
if (typeof payload === "object") {
const obj = payload as Record<string, unknown>;
if (typeof obj.result === "string") return obj.result;
if (typeof obj.text === "string") return obj.text;
if (typeof obj.completion === "string") return obj.completion;
if (typeof obj.output === "string") return obj.output;
if (obj.message) {
const inner = extractClaudeText(obj.message);
if (inner) return inner;
}
if (Array.isArray(obj.messages)) {
const inner = extractClaudeText(obj.messages);
if (inner) return inner;
}
if (Array.isArray(obj.content)) {
for (const block of obj.content) {
if (
block &&
typeof block === "object" &&
(block as { type?: string }).type === "text" &&
typeof (block as { text?: unknown }).text === "string"
) {
return (block as { text: string }).text;
}
const inner = extractClaudeText(block);
if (inner) return inner;
}
}
}
return undefined;
// Best-effort walker to find the primary text field in Claude JSON outputs.
if (payload == null) return undefined;
if (typeof payload === "string") return payload;
if (Array.isArray(payload)) {
for (const item of payload) {
const found = extractClaudeText(item);
if (found) return found;
}
return undefined;
}
if (typeof payload === "object") {
const obj = payload as Record<string, unknown>;
if (typeof obj.result === "string") return obj.result;
if (typeof obj.text === "string") return obj.text;
if (typeof obj.completion === "string") return obj.completion;
if (typeof obj.output === "string") return obj.output;
if (obj.message) {
const inner = extractClaudeText(obj.message);
if (inner) return inner;
}
if (Array.isArray(obj.messages)) {
const inner = extractClaudeText(obj.messages);
if (inner) return inner;
}
if (Array.isArray(obj.content)) {
for (const block of obj.content) {
if (
block &&
typeof block === "object" &&
(block as { type?: string }).type === "text" &&
typeof (block as { text?: unknown }).text === "string"
) {
return (block as { text: string }).text;
}
const inner = extractClaudeText(block);
if (inner) return inner;
}
}
}
return undefined;
}
export type ClaudeJsonParseResult = {
text?: string;
parsed: unknown;
valid: boolean;
text?: string;
parsed: unknown;
valid: boolean;
};
const ClaudeJsonSchema = z
.object({
type: z.string().optional(),
subtype: z.string().optional(),
is_error: z.boolean().optional(),
result: z.string().optional(),
text: z.string().optional(),
completion: z.string().optional(),
output: z.string().optional(),
message: z.any().optional(),
messages: z.any().optional(),
content: z.any().optional(),
duration_ms: z.number().optional(),
duration_api_ms: z.number().optional(),
num_turns: z.number().optional(),
session_id: z.string().optional(),
total_cost_usd: z.number().optional(),
usage: z.record(z.string(), z.any()).optional(),
modelUsage: z.record(z.string(), z.any()).optional(),
})
.passthrough()
.refine(
(obj) =>
typeof obj.result === "string" ||
typeof obj.text === "string" ||
typeof obj.completion === "string" ||
typeof obj.output === "string" ||
obj.message !== undefined ||
obj.messages !== undefined ||
obj.content !== undefined,
{ message: "Not a Claude JSON payload" },
);
.object({
type: z.string().optional(),
subtype: z.string().optional(),
is_error: z.boolean().optional(),
result: z.string().optional(),
text: z.string().optional(),
completion: z.string().optional(),
output: z.string().optional(),
message: z.any().optional(),
messages: z.any().optional(),
content: z.any().optional(),
duration_ms: z.number().optional(),
duration_api_ms: z.number().optional(),
num_turns: z.number().optional(),
session_id: z.string().optional(),
total_cost_usd: z.number().optional(),
usage: z.record(z.string(), z.any()).optional(),
modelUsage: z.record(z.string(), z.any()).optional(),
})
.passthrough()
.refine(
(obj) =>
typeof obj.result === "string" ||
typeof obj.text === "string" ||
typeof obj.completion === "string" ||
typeof obj.output === "string" ||
obj.message !== undefined ||
obj.messages !== undefined ||
obj.content !== undefined,
{ message: "Not a Claude JSON payload" },
);
type ClaudeSafeParse = ReturnType<typeof ClaudeJsonSchema.safeParse>;
export function parseClaudeJson(
raw: string,
raw: string,
): ClaudeJsonParseResult | undefined {
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
let firstParsed: unknown;
const candidates = [
raw,
...raw
.split(/\n+/)
.map((s) => s.trim())
.filter(Boolean),
];
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate);
if (firstParsed === undefined) firstParsed = parsed;
let validation: ClaudeSafeParse | { success: false };
try {
validation = ClaudeJsonSchema.safeParse(parsed);
} catch {
validation = { success: false } as const;
}
const validated = validation.success ? validation.data : parsed;
const isLikelyClaude =
typeof validated === "object" &&
validated !== null &&
("result" in validated ||
"text" in validated ||
"completion" in validated ||
"output" in validated);
const text = extractClaudeText(validated);
if (text)
return {
parsed: validated,
text,
// Treat parse as valid when schema passes or we still see Claude-like shape.
valid: Boolean(validation?.success || isLikelyClaude),
};
} catch {
// ignore parse errors; try next candidate
}
}
if (firstParsed !== undefined) {
let validation: ClaudeSafeParse | { success: false };
try {
validation = ClaudeJsonSchema.safeParse(firstParsed);
} catch {
validation = { success: false } as const;
}
const validated = validation.success ? validation.data : firstParsed;
const isLikelyClaude =
typeof validated === "object" &&
validated !== null &&
("result" in validated ||
"text" in validated ||
"completion" in validated ||
"output" in validated);
return {
parsed: validated,
text: extractClaudeText(validated),
valid: Boolean(validation?.success || isLikelyClaude),
};
}
return undefined;
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
let firstParsed: unknown;
const candidates = [
raw,
...raw
.split(/\n+/)
.map((s) => s.trim())
.filter(Boolean),
];
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate);
if (firstParsed === undefined) firstParsed = parsed;
let validation: ClaudeSafeParse | { success: false };
try {
validation = ClaudeJsonSchema.safeParse(parsed);
} catch {
validation = { success: false } as const;
}
const validated = validation.success ? validation.data : parsed;
const isLikelyClaude =
typeof validated === "object" &&
validated !== null &&
("result" in validated ||
"text" in validated ||
"completion" in validated ||
"output" in validated);
const text = extractClaudeText(validated);
if (text)
return {
parsed: validated,
text,
// Treat parse as valid when schema passes or we still see Claude-like shape.
valid: Boolean(validation?.success || isLikelyClaude),
};
} catch {
// ignore parse errors; try next candidate
}
}
if (firstParsed !== undefined) {
let validation: ClaudeSafeParse | { success: false };
try {
validation = ClaudeJsonSchema.safeParse(firstParsed);
} catch {
validation = { success: false } as const;
}
const validated = validation.success ? validation.data : firstParsed;
const isLikelyClaude =
typeof validated === "object" &&
validated !== null &&
("result" in validated ||
"text" in validated ||
"completion" in validated ||
"output" in validated);
return {
parsed: validated,
text: extractClaudeText(validated),
valid: Boolean(validation?.success || isLikelyClaude),
};
}
return undefined;
}
export function parseClaudeJsonText(raw: string): string | undefined {
const parsed = parseClaudeJson(raw);
return parsed?.text;
const parsed = parseClaudeJson(raw);
return parsed?.text;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,24 @@
export type MsgContext = {
Body?: string;
From?: string;
To?: string;
MessageSid?: string;
MediaPath?: string;
MediaUrl?: string;
MediaType?: string;
Transcript?: string;
Body?: string;
From?: string;
To?: string;
MessageSid?: string;
MediaPath?: string;
MediaUrl?: string;
MediaType?: string;
Transcript?: string;
};
export type TemplateContext = MsgContext & {
BodyStripped?: string;
SessionId?: string;
IsNewSession?: string;
BodyStripped?: string;
SessionId?: string;
IsNewSession?: string;
};
// Simple {{Placeholder}} interpolation using inbound message context.
export function applyTemplate(str: string, ctx: TemplateContext) {
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
const value = (ctx as Record<string, unknown>)[key];
return value == null ? "" : String(value);
});
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
const value = (ctx as Record<string, unknown>)[key];
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 { ensureMediaHosted } from "../media/host.js";
import {
logWebSelfId,
monitorWebProvider,
sendMessageWeb,
logWebSelfId,
monitorWebProvider,
sendMessageWeb,
} from "../providers/web/index.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { createClient } from "../twilio/client.js";
@@ -22,89 +22,89 @@ import { updateWebhook } from "../webhook/update.js";
import { waitForever } from "./wait.js";
export type CliDeps = {
sendMessage: typeof sendMessage;
sendMessageWeb: typeof sendMessageWeb;
waitForFinalStatus: typeof waitForFinalStatus;
assertProvider: typeof assertProvider;
createClient?: typeof createClient;
monitorTwilio: typeof monitorTwilio;
listRecentMessages: typeof listRecentMessages;
ensurePortAvailable: typeof ensurePortAvailable;
startWebhook: typeof startWebhook;
waitForever: typeof waitForever;
ensureBinary: typeof ensureBinary;
ensureFunnel: typeof ensureFunnel;
getTailnetHostname: typeof getTailnetHostname;
readEnv: typeof readEnv;
findWhatsappSenderSid: typeof findWhatsappSenderSid;
updateWebhook: typeof updateWebhook;
handlePortError: typeof handlePortError;
monitorWebProvider: typeof monitorWebProvider;
resolveTwilioMediaUrl: (
source: string,
opts: { serveMedia: boolean; runtime: RuntimeEnv },
) => Promise<string>;
sendMessage: typeof sendMessage;
sendMessageWeb: typeof sendMessageWeb;
waitForFinalStatus: typeof waitForFinalStatus;
assertProvider: typeof assertProvider;
createClient?: typeof createClient;
monitorTwilio: typeof monitorTwilio;
listRecentMessages: typeof listRecentMessages;
ensurePortAvailable: typeof ensurePortAvailable;
startWebhook: typeof startWebhook;
waitForever: typeof waitForever;
ensureBinary: typeof ensureBinary;
ensureFunnel: typeof ensureFunnel;
getTailnetHostname: typeof getTailnetHostname;
readEnv: typeof readEnv;
findWhatsappSenderSid: typeof findWhatsappSenderSid;
updateWebhook: typeof updateWebhook;
handlePortError: typeof handlePortError;
monitorWebProvider: typeof monitorWebProvider;
resolveTwilioMediaUrl: (
source: string,
opts: { serveMedia: boolean; runtime: RuntimeEnv },
) => Promise<string>;
};
export async function monitorTwilio(
intervalSeconds: number,
lookbackMinutes: number,
clientOverride?: ReturnType<typeof createClient>,
maxIterations = Infinity,
intervalSeconds: number,
lookbackMinutes: number,
clientOverride?: ReturnType<typeof createClient>,
maxIterations = Infinity,
) {
// Adapter that wires default deps/runtime for the Twilio monitor loop.
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
client: clientOverride,
maxIterations,
deps: {
autoReplyIfConfigured,
listRecentMessages,
readEnv,
createClient,
sleep,
},
runtime: defaultRuntime,
});
// Adapter that wires default deps/runtime for the Twilio monitor loop.
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
client: clientOverride,
maxIterations,
deps: {
autoReplyIfConfigured,
listRecentMessages,
readEnv,
createClient,
sleep,
},
runtime: defaultRuntime,
});
}
export function createDefaultDeps(): CliDeps {
// Default dependency bundle used by CLI commands and tests.
return {
sendMessage,
sendMessageWeb,
waitForFinalStatus,
assertProvider,
createClient,
monitorTwilio,
listRecentMessages,
ensurePortAvailable,
startWebhook,
waitForever,
ensureBinary,
ensureFunnel,
getTailnetHostname,
readEnv,
findWhatsappSenderSid,
updateWebhook,
handlePortError,
monitorWebProvider,
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
if (/^https?:\/\//i.test(source)) return source;
const hosted = await ensureMediaHosted(source, {
startServer: serveMedia,
runtime,
});
return hosted.url;
},
};
// Default dependency bundle used by CLI commands and tests.
return {
sendMessage,
sendMessageWeb,
waitForFinalStatus,
assertProvider,
createClient,
monitorTwilio,
listRecentMessages,
ensurePortAvailable,
startWebhook,
waitForever,
ensureBinary,
ensureFunnel,
getTailnetHostname,
readEnv,
findWhatsappSenderSid,
updateWebhook,
handlePortError,
monitorWebProvider,
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
if (/^https?:\/\//i.test(source)) return source;
const hosted = await ensureMediaHosted(source, {
startServer: serveMedia,
runtime,
});
return hosted.url;
},
};
}
export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) {
// Log the configured Twilio sender for clarity in CLI output.
const env = readEnv(runtime);
runtime.log(
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
);
// Log the configured Twilio sender for clarity in CLI output.
const env = readEnv(runtime);
runtime.log(
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
);
}
export { logWebSelfId };

View File

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

View File

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

View File

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

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.
const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } =
vi.hoisted(() => {
return {
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
pickProvider: vi.fn().mockResolvedValue("web"),
logWebSelfId: vi.fn(),
monitorTwilio: vi.fn().mockResolvedValue(undefined),
};
});
vi.hoisted(() => {
return {
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
pickProvider: vi.fn().mockResolvedValue("web"),
logWebSelfId: vi.fn(),
monitorTwilio: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("../provider-web.js", () => ({
monitorWebProvider,
pickProvider,
logWebSelfId,
monitorWebProvider,
pickProvider,
logWebSelfId,
}));
vi.mock("../twilio/monitor.js", () => ({
monitorTwilio,
monitorTwilio,
}));
import { buildProgram } from "./program.js";
describe("CLI relay command (e2e-ish)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
it("runs relay in web mode without crashing", async () => {
const program = buildProgram();
program.exitOverride(); // throw instead of exiting process on error
it("runs relay in web mode without crashing", async () => {
const program = buildProgram();
program.exitOverride(); // throw instead of exiting process on error
await expect(
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
).resolves.toBeInstanceOf(Object);
await expect(
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
).resolves.toBeInstanceOf(Object);
expect(pickProvider).toHaveBeenCalledWith("web");
expect(logWebSelfId).toHaveBeenCalledTimes(1);
expect(monitorWebProvider).toHaveBeenCalledWith(false);
expect(monitorTwilio).not.toHaveBeenCalled();
});
expect(pickProvider).toHaveBeenCalledWith("web");
expect(logWebSelfId).toHaveBeenCalledTimes(1);
expect(monitorWebProvider).toHaveBeenCalledWith(false);
expect(monitorTwilio).not.toHaveBeenCalled();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,65 +4,65 @@ import { retryAsync } from "../infra/retry.js";
import type { RuntimeEnv } from "../runtime.js";
export async function upCommand(
opts: {
port: string;
path: string;
verbose?: boolean;
yes?: boolean;
dryRun?: boolean;
},
deps: CliDeps,
runtime: RuntimeEnv,
waiter: typeof defaultWaitForever = defaultWaitForever,
opts: {
port: string;
path: string;
verbose?: boolean;
yes?: boolean;
dryRun?: boolean;
},
deps: CliDeps,
runtime: RuntimeEnv,
waiter: typeof defaultWaitForever = defaultWaitForever,
) {
const port = Number.parseInt(opts.port, 10);
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
throw new Error("Port must be between 1 and 65535");
}
const port = Number.parseInt(opts.port, 10);
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
throw new Error("Port must be between 1 and 65535");
}
await deps.ensurePortAvailable(port);
const env = deps.readEnv(runtime);
if (opts.dryRun) {
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 update Twilio sender webhook`);
const publicUrl = `https://dry-run${opts.path}`;
return { server: undefined, publicUrl, senderSid: undefined, waiter };
}
await deps.ensureBinary("tailscale", undefined, runtime);
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
const host = await deps.getTailnetHostname();
const publicUrl = `https://${host}${opts.path}`;
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
await deps.ensurePortAvailable(port);
const env = deps.readEnv(runtime);
if (opts.dryRun) {
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 update Twilio sender webhook`);
const publicUrl = `https://dry-run${opts.path}`;
return { server: undefined, publicUrl, senderSid: undefined, waiter };
}
await deps.ensureBinary("tailscale", undefined, runtime);
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
const host = await deps.getTailnetHostname();
const publicUrl = `https://${host}${opts.path}`;
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
const server = await retryAsync(
() =>
deps.startWebhook(
port,
opts.path,
undefined,
Boolean(opts.verbose),
runtime,
),
3,
300,
);
const server = await retryAsync(
() =>
deps.startWebhook(
port,
opts.path,
undefined,
Boolean(opts.verbose),
runtime,
),
3,
300,
);
if (!deps.createClient) {
throw new Error("Twilio client dependency missing");
}
const twilioClient = deps.createClient(env);
const senderSid = await deps.findWhatsappSenderSid(
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
env.whatsappFrom,
env.whatsappSenderSid,
runtime,
);
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
if (!deps.createClient) {
throw new Error("Twilio client dependency missing");
}
const twilioClient = deps.createClient(env);
const senderSid = await deps.findWhatsappSenderSid(
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
env.whatsappFrom,
env.whatsappSenderSid,
runtime,
);
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
runtime.log(
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
);
runtime.log(
"\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";
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
const deps: CliDeps = {
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
startWebhook: vi.fn().mockResolvedValue({ server: true }),
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
startWebhook: vi.fn().mockResolvedValue({ server: true }),
};
describe("webhookCommand", () => {
it("throws on invalid port", async () => {
await expect(() =>
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
).rejects.toThrow("Port must be between 1 and 65535");
});
it("throws on invalid port", async () => {
await expect(() =>
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
).rejects.toThrow("Port must be between 1 and 65535");
});
it("logs dry run instead of starting server", async () => {
runtime.log.mockClear();
const res = await webhookCommand(
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
deps,
runtime,
);
expect(res).toBeUndefined();
expect(runtime.log).toHaveBeenCalledWith(
"[dry-run] would start webhook on port 42873 path /hook",
);
});
it("logs dry run instead of starting server", async () => {
runtime.log.mockClear();
const res = await webhookCommand(
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
deps,
runtime,
);
expect(res).toBeUndefined();
expect(runtime.log).toHaveBeenCalledWith(
"[dry-run] would start webhook on port 42873 path /hook",
);
});
it("starts webhook when valid", async () => {
const res = await webhookCommand(
{
port: "42873",
path: "/hook",
reply: "ok",
verbose: true,
ingress: "none",
},
deps,
runtime,
);
expect(deps.startWebhook).toHaveBeenCalledWith(
42873,
"/hook",
"ok",
true,
runtime,
);
expect(res).toEqual({ server: true });
});
it("starts webhook when valid", async () => {
const res = await webhookCommand(
{
port: "42873",
path: "/hook",
reply: "ok",
verbose: true,
ingress: "none",
},
deps,
runtime,
);
expect(deps.startWebhook).toHaveBeenCalledWith(
42873,
"/hook",
"ok",
true,
runtime,
);
expect(res).toEqual({ server: true });
});
});

View File

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

View File

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

View File

@@ -3,17 +3,17 @@ import { describe, expect, it } from "vitest";
import { deriveSessionKey } from "./sessions.js";
describe("sessions", () => {
it("returns normalized per-sender key", () => {
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
"+1555",
);
});
it("returns normalized per-sender key", () => {
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
"+1555",
);
});
it("falls back to unknown when sender missing", () => {
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
});
it("falls back to unknown when sender missing", () => {
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
});
it("global scope returns global", () => {
expect(deriveSessionKey("global", { From: "+1" })).toBe("global");
});
it("global scope returns 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 SessionEntry = {
sessionId: string;
updatedAt: number;
systemSent?: boolean;
sessionId: string;
updatedAt: number;
systemSent?: boolean;
};
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 function resolveStorePath(store?: string) {
if (!store) return SESSION_STORE_DEFAULT;
if (store.startsWith("~"))
return path.resolve(store.replace("~", os.homedir()));
return path.resolve(store);
if (!store) return SESSION_STORE_DEFAULT;
if (store.startsWith("~"))
return path.resolve(store.replace("~", os.homedir()));
return path.resolve(store);
}
export function loadSessionStore(
storePath: string,
storePath: string,
): Record<string, SessionEntry> {
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") {
return parsed as Record<string, SessionEntry>;
}
} catch {
// ignore missing/invalid store; we'll recreate it
}
return {};
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") {
return parsed as Record<string, SessionEntry>;
}
} catch {
// ignore missing/invalid store; we'll recreate it
}
return {};
}
export async function saveSessionStore(
storePath: string,
store: Record<string, SessionEntry>,
storePath: string,
store: Record<string, SessionEntry>,
) {
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
await fs.promises.writeFile(
storePath,
JSON.stringify(store, null, 2),
"utf-8",
);
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
await fs.promises.writeFile(
storePath,
JSON.stringify(store, null, 2),
"utf-8",
);
}
// Decide which session bucket to use (per-sender vs global).
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
if (scope === "global") return "global";
const from = ctx.From ? normalizeE164(ctx.From) : "";
return from || "unknown";
if (scope === "global") return "global";
const from = ctx.From ? normalizeE164(ctx.From) : "";
return from || "unknown";
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,8 @@ import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import {
autoReplyIfConfigured,
getReplyFromConfig,
autoReplyIfConfigured,
getReplyFromConfig,
} from "./auto-reply/reply.js";
import { applyTemplate } from "./auto-reply/templating.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 { loadConfig } from "./config/config.js";
import {
deriveSessionKey,
loadSessionStore,
resolveStorePath,
saveSessionStore,
deriveSessionKey,
loadSessionStore,
resolveStorePath,
saveSessionStore,
} from "./config/sessions.js";
import { readEnv } from "./env.js";
import { ensureBinary } from "./infra/binaries.js";
import {
describePortOwner,
ensurePortAvailable,
handlePortError,
PortInUseError,
describePortOwner,
ensurePortAvailable,
handlePortError,
PortInUseError,
} from "./infra/ports.js";
import {
ensureFunnel,
ensureGoInstalled,
ensureTailscaledInstalled,
getTailnetHostname,
ensureFunnel,
ensureGoInstalled,
ensureTailscaledInstalled,
getTailnetHostname,
} from "./infra/tailscale.js";
import { runCommandWithTimeout, runExec } from "./process/exec.js";
import { monitorWebProvider } from "./provider-web.js";
import { createClient } from "./twilio/client.js";
import {
formatMessageLine,
listRecentMessages,
sortByDateDesc,
uniqueBySid,
formatMessageLine,
listRecentMessages,
sortByDateDesc,
uniqueBySid,
} from "./twilio/messages.js";
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
import { findWhatsappSenderSid } from "./twilio/senders.js";
import { sendTypingIndicator } from "./twilio/typing.js";
import {
findIncomingNumberSid as findIncomingNumberSidImpl,
findMessagingServiceSid as findMessagingServiceSidImpl,
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
updateWebhook as updateWebhookImpl,
findIncomingNumberSid as findIncomingNumberSidImpl,
findMessagingServiceSid as findMessagingServiceSidImpl,
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
updateWebhook as updateWebhookImpl,
} from "./twilio/update-webhook.js";
import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js";
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
@@ -66,56 +66,56 @@ const setMessagingServiceWebhook = setMessagingServiceWebhookImpl;
const updateWebhook = updateWebhookImpl;
export {
assertProvider,
autoReplyIfConfigured,
applyTemplate,
createClient,
deriveSessionKey,
describePortOwner,
ensureBinary,
ensureFunnel,
ensureGoInstalled,
ensurePortAvailable,
ensureTailscaledInstalled,
findIncomingNumberSidImpl as findIncomingNumberSid,
findMessagingServiceSidImpl as findMessagingServiceSid,
findWhatsappSenderSid,
formatMessageLine,
formatTwilioError,
getReplyFromConfig,
getTailnetHostname,
handlePortError,
logTwilioSendError,
listRecentMessages,
loadConfig,
loadSessionStore,
monitorTwilio,
monitorWebProvider,
normalizeE164,
PortInUseError,
promptYesNo,
createDefaultDeps,
readEnv,
resolveStorePath,
runCommandWithTimeout,
runExec,
saveSessionStore,
sendMessage,
sendTypingIndicator,
setMessagingServiceWebhook,
sortByDateDesc,
startWebhook,
updateWebhook,
uniqueBySid,
waitForFinalStatus,
waitForever,
toWhatsappJid,
program,
assertProvider,
autoReplyIfConfigured,
applyTemplate,
createClient,
deriveSessionKey,
describePortOwner,
ensureBinary,
ensureFunnel,
ensureGoInstalled,
ensurePortAvailable,
ensureTailscaledInstalled,
findIncomingNumberSidImpl as findIncomingNumberSid,
findMessagingServiceSidImpl as findMessagingServiceSid,
findWhatsappSenderSid,
formatMessageLine,
formatTwilioError,
getReplyFromConfig,
getTailnetHostname,
handlePortError,
logTwilioSendError,
listRecentMessages,
loadConfig,
loadSessionStore,
monitorTwilio,
monitorWebProvider,
normalizeE164,
PortInUseError,
promptYesNo,
createDefaultDeps,
readEnv,
resolveStorePath,
runCommandWithTimeout,
runExec,
saveSessionStore,
sendMessage,
sendTypingIndicator,
setMessagingServiceWebhook,
sortByDateDesc,
startWebhook,
updateWebhook,
uniqueBySid,
waitForFinalStatus,
waitForever,
toWhatsappJid,
program,
};
const isMain =
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
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";
describe("ensureBinary", () => {
it("passes through when binary exists", async () => {
const exec: typeof runExec = vi.fn().mockResolvedValue({
stdout: "",
stderr: "",
});
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await ensureBinary("node", exec, runtime);
expect(exec).toHaveBeenCalledWith("which", ["node"]);
});
it("passes through when binary exists", async () => {
const exec: typeof runExec = vi.fn().mockResolvedValue({
stdout: "",
stderr: "",
});
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await ensureBinary("node", exec, runtime);
expect(exec).toHaveBeenCalledWith("which", ["node"]);
});
it("logs and exits when missing", async () => {
const exec: typeof runExec = vi
.fn()
.mockRejectedValue(new Error("missing"));
const error = vi.fn();
const exit = vi.fn(() => {
throw new Error("exit");
});
await expect(
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
).rejects.toThrow("exit");
expect(error).toHaveBeenCalledWith(
"Missing required binary: ghost. Please install it.",
);
expect(exit).toHaveBeenCalledWith(1);
});
it("logs and exits when missing", async () => {
const exec: typeof runExec = vi
.fn()
.mockRejectedValue(new Error("missing"));
const error = vi.fn();
const exit = vi.fn(() => {
throw new Error("exit");
});
await expect(
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
).rejects.toThrow("exit");
expect(error).toHaveBeenCalledWith(
"Missing required binary: ghost. Please install it.",
);
expect(exit).toHaveBeenCalledWith(1);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,42 +1,42 @@
import {
danger,
info,
isVerbose,
logVerbose,
success,
warn,
danger,
info,
isVerbose,
logVerbose,
success,
warn,
} from "./globals.js";
import { getLogger } from "./logging.js";
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) {
runtime.log(info(message));
getLogger().info(message);
runtime.log(info(message));
getLogger().info(message);
}
export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) {
runtime.log(warn(message));
getLogger().warn(message);
runtime.log(warn(message));
getLogger().warn(message);
}
export function logSuccess(
message: string,
runtime: RuntimeEnv = defaultRuntime,
message: string,
runtime: RuntimeEnv = defaultRuntime,
) {
runtime.log(success(message));
getLogger().info(message);
runtime.log(success(message));
getLogger().info(message);
}
export function logError(
message: string,
runtime: RuntimeEnv = defaultRuntime,
message: string,
runtime: RuntimeEnv = defaultRuntime,
) {
runtime.error(danger(message));
getLogger().error(message);
runtime.error(danger(message));
getLogger().error(message);
}
export function logDebug(message: string) {
// Always emit to file logger (level-filtered); console only when verbose.
getLogger().debug(message);
if (isVerbose()) logVerbose(message);
// Always emit to file logger (level-filtered); console only when verbose.
getLogger().debug(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");
const ALLOWED_LEVELS: readonly LevelWithSilent[] = [
"silent",
"fatal",
"error",
"warn",
"info",
"debug",
"trace",
"silent",
"fatal",
"error",
"warn",
"info",
"debug",
"trace",
];
export type LoggerSettings = {
level?: LevelWithSilent;
file?: string;
level?: LevelWithSilent;
file?: string;
};
type ResolvedSettings = {
level: LevelWithSilent;
file: string;
level: LevelWithSilent;
file: string;
};
let cachedLogger: Logger | null = null;
@@ -34,68 +34,68 @@ let cachedSettings: ResolvedSettings | null = null;
let overrideSettings: LoggerSettings | null = null;
function normalizeLevel(level?: string): LevelWithSilent {
if (isVerbose()) return "debug";
const candidate = level ?? "info";
return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
? (candidate as LevelWithSilent)
: "info";
if (isVerbose()) return "debug";
const candidate = level ?? "info";
return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
? (candidate as LevelWithSilent)
: "info";
}
function resolveSettings(): ResolvedSettings {
const cfg: WarelayConfig["logging"] | undefined =
overrideSettings ?? loadConfig().logging;
const level = normalizeLevel(cfg?.level);
const file = cfg?.file ?? DEFAULT_LOG_FILE;
return { level, file };
const cfg: WarelayConfig["logging"] | undefined =
overrideSettings ?? loadConfig().logging;
const level = normalizeLevel(cfg?.level);
const file = cfg?.file ?? DEFAULT_LOG_FILE;
return { level, file };
}
function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
if (!a) return true;
return a.level !== b.level || a.file !== b.file;
if (!a) return true;
return a.level !== b.level || a.file !== b.file;
}
function buildLogger(settings: ResolvedSettings): Logger {
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
const destination = pino.destination({
dest: settings.file,
mkdir: true,
sync: true, // deterministic for tests; log volume is modest.
});
return pino(
{
level: settings.level,
base: undefined,
timestamp: pino.stdTimeFunctions.isoTime,
},
destination,
);
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
const destination = pino.destination({
dest: settings.file,
mkdir: true,
sync: true, // deterministic for tests; log volume is modest.
});
return pino(
{
level: settings.level,
base: undefined,
timestamp: pino.stdTimeFunctions.isoTime,
},
destination,
);
}
export function getLogger(): Logger {
const settings = resolveSettings();
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
cachedLogger = buildLogger(settings);
cachedSettings = settings;
}
return cachedLogger;
const settings = resolveSettings();
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
cachedLogger = buildLogger(settings);
cachedSettings = settings;
}
return cachedLogger;
}
export function getChildLogger(
bindings?: Bindings,
opts?: { level?: LevelWithSilent },
bindings?: Bindings,
opts?: { level?: LevelWithSilent },
): Logger {
return getLogger().child(bindings ?? {}, opts);
return getLogger().child(bindings ?? {}, opts);
}
// Test helpers
export function setLoggerOverride(settings: LoggerSettings | null) {
overrideSettings = settings;
cachedLogger = null;
cachedSettings = null;
overrideSettings = settings;
cachedLogger = null;
cachedSettings = null;
}
export function resetLogger() {
cachedLogger = null;
cachedSettings = null;
overrideSettings = null;
cachedLogger = null;
cachedSettings = 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 function mediaKindFromMime(mime?: string | null): MediaKind {
if (!mime) return "unknown";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("audio/")) return "audio";
if (mime.startsWith("video/")) return "video";
if (mime === "application/pdf") return "document";
if (mime.startsWith("application/")) return "document";
return "unknown";
if (!mime) return "unknown";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("audio/")) return "audio";
if (mime.startsWith("video/")) return "video";
if (mime === "application/pdf") return "document";
if (mime.startsWith("application/")) return "document";
return "unknown";
}
export function maxBytesForKind(kind: MediaKind): number {
switch (kind) {
case "image":
return MAX_IMAGE_BYTES;
case "audio":
return MAX_AUDIO_BYTES;
case "video":
return MAX_VIDEO_BYTES;
case "document":
return MAX_DOCUMENT_BYTES;
default:
return MAX_DOCUMENT_BYTES;
}
switch (kind) {
case "image":
return MAX_IMAGE_BYTES;
case "audio":
return MAX_AUDIO_BYTES;
case "video":
return MAX_VIDEO_BYTES;
case "document":
return MAX_DOCUMENT_BYTES;
default:
return MAX_DOCUMENT_BYTES;
}
}

View File

@@ -12,11 +12,11 @@ const logInfo = vi.fn();
vi.mock("./store.js", () => ({ saveMediaSource }));
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
vi.mock("../infra/ports.js", async () => {
const actual =
await vi.importActual<typeof import("../infra/ports.js")>(
"../infra/ports.js",
);
return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
const actual =
await vi.importActual<typeof import("../infra/ports.js")>(
"../infra/ports.js",
);
return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
});
vi.mock("./server.js", () => ({ startMediaServer }));
vi.mock("../logger.js", () => ({ logInfo }));
@@ -25,69 +25,69 @@ const { ensureMediaHosted } = await import("./host.js");
const { PortInUseError } = await import("../infra/ports.js");
describe("ensureMediaHosted", () => {
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
it("throws and cleans up when server not allowed to start", async () => {
saveMediaSource.mockResolvedValue({
id: "id1",
path: "/tmp/file1",
size: 5,
});
getTailnetHostname.mockResolvedValue("tailnet-host");
ensurePortAvailable.mockResolvedValue(undefined);
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
it("throws and cleans up when server not allowed to start", async () => {
saveMediaSource.mockResolvedValue({
id: "id1",
path: "/tmp/file1",
size: 5,
});
getTailnetHostname.mockResolvedValue("tailnet-host");
ensurePortAvailable.mockResolvedValue(undefined);
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
await expect(
ensureMediaHosted("/tmp/file1", { startServer: false }),
).rejects.toThrow("requires the webhook/Funnel server");
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
rmSpy.mockRestore();
});
await expect(
ensureMediaHosted("/tmp/file1", { startServer: false }),
).rejects.toThrow("requires the webhook/Funnel server");
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
rmSpy.mockRestore();
});
it("starts media server when allowed", async () => {
saveMediaSource.mockResolvedValue({
id: "id2",
path: "/tmp/file2",
size: 9,
});
getTailnetHostname.mockResolvedValue("tail.net");
ensurePortAvailable.mockResolvedValue(undefined);
const fakeServer = { unref: vi.fn() } as unknown as Server;
startMediaServer.mockResolvedValue(fakeServer);
it("starts media server when allowed", async () => {
saveMediaSource.mockResolvedValue({
id: "id2",
path: "/tmp/file2",
size: 9,
});
getTailnetHostname.mockResolvedValue("tail.net");
ensurePortAvailable.mockResolvedValue(undefined);
const fakeServer = { unref: vi.fn() } as unknown as Server;
startMediaServer.mockResolvedValue(fakeServer);
const result = await ensureMediaHosted("/tmp/file2", {
startServer: true,
port: 1234,
});
expect(startMediaServer).toHaveBeenCalledWith(
1234,
expect.any(Number),
expect.anything(),
);
expect(logInfo).toHaveBeenCalled();
expect(result).toEqual({
url: "https://tail.net/media/id2",
id: "id2",
size: 9,
});
});
const result = await ensureMediaHosted("/tmp/file2", {
startServer: true,
port: 1234,
});
expect(startMediaServer).toHaveBeenCalledWith(
1234,
expect.any(Number),
expect.anything(),
);
expect(logInfo).toHaveBeenCalled();
expect(result).toEqual({
url: "https://tail.net/media/id2",
id: "id2",
size: 9,
});
});
it("skips server start when port already in use", async () => {
saveMediaSource.mockResolvedValue({
id: "id3",
path: "/tmp/file3",
size: 7,
});
getTailnetHostname.mockResolvedValue("tail.net");
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
it("skips server start when port already in use", async () => {
saveMediaSource.mockResolvedValue({
id: "id3",
path: "/tmp/file3",
size: 7,
});
getTailnetHostname.mockResolvedValue("tail.net");
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
const result = await ensureMediaHosted("/tmp/file3", {
startServer: false,
port: 3000,
});
expect(startMediaServer).not.toHaveBeenCalled();
expect(result.url).toBe("https://tail.net/media/id3");
});
const result = await ensureMediaHosted("/tmp/file3", {
startServer: false,
port: 3000,
});
expect(startMediaServer).not.toHaveBeenCalled();
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;
export type HostedMedia = {
url: string;
id: string;
size: number;
url: string;
id: string;
size: number;
};
export async function ensureMediaHosted(
source: string,
opts: {
port?: number;
startServer?: boolean;
runtime?: RuntimeEnv;
} = {},
source: string,
opts: {
port?: number;
startServer?: boolean;
runtime?: RuntimeEnv;
} = {},
): Promise<HostedMedia> {
const port = opts.port ?? DEFAULT_PORT;
const runtime = opts.runtime ?? defaultRuntime;
const port = opts.port ?? DEFAULT_PORT;
const runtime = opts.runtime ?? defaultRuntime;
const saved = await saveMediaSource(source);
const hostname = await getTailnetHostname();
const saved = await saveMediaSource(source);
const hostname = await getTailnetHostname();
// Decide whether we must start a media server.
const needsServerStart = await isPortFree(port);
if (needsServerStart && !opts.startServer) {
await fs.rm(saved.path).catch(() => {});
throw new Error(
"Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.",
);
}
if (needsServerStart && opts.startServer) {
if (!mediaServer) {
mediaServer = await startMediaServer(port, TTL_MS, runtime);
logInfo(
`📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`,
runtime,
);
mediaServer.unref?.();
}
}
// Decide whether we must start a media server.
const needsServerStart = await isPortFree(port);
if (needsServerStart && !opts.startServer) {
await fs.rm(saved.path).catch(() => {});
throw new Error(
"Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.",
);
}
if (needsServerStart && opts.startServer) {
if (!mediaServer) {
mediaServer = await startMediaServer(port, TTL_MS, runtime);
logInfo(
`📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`,
runtime,
);
mediaServer.unref?.();
}
}
const url = `https://${hostname}/media/${saved.id}`;
return { url, id: saved.id, size: saved.size };
const url = `https://${hostname}/media/${saved.id}`;
return { url, id: saved.id, size: saved.size };
}
async function isPortFree(port: number) {
try {
await ensurePortAvailable(port);
return true;
} catch (err) {
if (err instanceof PortInUseError) return false;
throw err;
}
try {
await ensurePortAvailable(port);
return true;
} catch (err) {
if (err instanceof PortInUseError) return false;
throw err;
}
}

View File

@@ -1,53 +1,101 @@
// Shared helpers for parsing MEDIA tokens from command/stdout text.
// 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) {
return src.startsWith("file://") ? src.replace("file://", "") : src;
return src.startsWith("file://") ? src.replace("file://", "") : src;
}
function cleanCandidate(raw: string) {
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
}
function isValidMedia(candidate: string) {
if (!candidate) return false;
if (candidate.length > 1024) return false;
if (/\s/.test(candidate)) return false;
return (
/^https?:\/\//i.test(candidate) ||
candidate.startsWith("/") ||
candidate.startsWith("./")
);
if (!candidate) return false;
if (candidate.length > 1024) return false;
if (/\s/.test(candidate)) return false;
return (
/^https?:\/\//i.test(candidate) ||
candidate.startsWith("/") ||
candidate.startsWith("./")
);
}
export function splitMediaFromOutput(raw: string): {
text: string;
mediaUrl?: string;
text: string;
mediaUrls?: string[];
mediaUrl?: string; // legacy first item for backward compatibility
} {
const trimmedRaw = raw.trim();
const match = MEDIA_TOKEN_RE.exec(trimmedRaw);
if (!match?.[1]) return { text: trimmedRaw };
const trimmedRaw = raw.trim();
if (!trimmedRaw) return { text: "" };
const candidate = normalizeMediaSource(cleanCandidate(match[1]));
const mediaUrl = isValidMedia(candidate) ? candidate : undefined;
const media: string[] = [];
let foundMediaToken = false;
const cleanedText = mediaUrl
? trimmedRaw
.replace(match[0], "")
.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();
// Collect tokens line by line so we can strip them cleanly.
const lines = trimmedRaw.split("\n");
const keptLines: string[] = [];
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);
vi.mock("./store.js", () => ({
getMediaDir: () => MEDIA_DIR,
cleanOldMedia,
getMediaDir: () => MEDIA_DIR,
cleanOldMedia,
}));
const { startMediaServer } = await import("./server.js");
describe("media server", () => {
beforeAll(async () => {
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
await fs.mkdir(MEDIA_DIR, { recursive: true });
});
beforeAll(async () => {
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
await fs.mkdir(MEDIA_DIR, { recursive: true });
});
afterAll(async () => {
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
});
it("serves media and cleans up after send", async () => {
const file = path.join(MEDIA_DIR, "file1");
await fs.writeFile(file, "hello");
const server = await startMediaServer(0, 5_000);
const port = (server.address() as AddressInfo).port;
const res = await fetch(`http://localhost:${port}/media/file1`);
expect(res.status).toBe(200);
expect(await res.text()).toBe("hello");
await new Promise((r) => setTimeout(r, 600));
await expect(fs.stat(file)).rejects.toThrow();
await new Promise((r) => server.close(r));
});
it("serves media and cleans up after send", async () => {
const file = path.join(MEDIA_DIR, "file1");
await fs.writeFile(file, "hello");
const server = await startMediaServer(0, 5_000);
const port = (server.address() as AddressInfo).port;
const res = await fetch(`http://localhost:${port}/media/file1`);
expect(res.status).toBe(200);
expect(await res.text()).toBe("hello");
await new Promise((r) => setTimeout(r, 600));
await expect(fs.stat(file)).rejects.toThrow();
await new Promise((r) => server.close(r));
});
it("expires old media", async () => {
const file = path.join(MEDIA_DIR, "old");
await fs.writeFile(file, "stale");
const past = Date.now() - 10_000;
await fs.utimes(file, past / 1000, past / 1000);
const server = await startMediaServer(0, 1_000);
const port = (server.address() as AddressInfo).port;
const res = await fetch(`http://localhost:${port}/media/old`);
expect(res.status).toBe(410);
await expect(fs.stat(file)).rejects.toThrow();
await new Promise((r) => server.close(r));
});
it("expires old media", async () => {
const file = path.join(MEDIA_DIR, "old");
await fs.writeFile(file, "stale");
const past = Date.now() - 10_000;
await fs.utimes(file, past / 1000, past / 1000);
const server = await startMediaServer(0, 1_000);
const port = (server.address() as AddressInfo).port;
const res = await fetch(`http://localhost:${port}/media/old`);
expect(res.status).toBe(410);
await expect(fs.stat(file)).rejects.toThrow();
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;
export function attachMediaRoutes(
app: Express,
ttlMs = DEFAULT_TTL_MS,
_runtime: RuntimeEnv = defaultRuntime,
app: Express,
ttlMs = DEFAULT_TTL_MS,
_runtime: RuntimeEnv = defaultRuntime,
) {
const mediaDir = getMediaDir();
const mediaDir = getMediaDir();
app.get("/media/:id", async (req, res) => {
const id = req.params.id;
const file = path.join(mediaDir, id);
try {
const stat = await fs.stat(file);
if (Date.now() - stat.mtimeMs > ttlMs) {
await fs.rm(file).catch(() => {});
res.status(410).send("expired");
return;
}
res.sendFile(file);
// best-effort single-use cleanup after response ends
res.on("finish", () => {
setTimeout(() => {
fs.rm(file).catch(() => {});
}, 500);
});
} catch {
res.status(404).send("not found");
}
});
app.get("/media/:id", async (req, res) => {
const id = req.params.id;
const file = path.join(mediaDir, id);
try {
const stat = await fs.stat(file);
if (Date.now() - stat.mtimeMs > ttlMs) {
await fs.rm(file).catch(() => {});
res.status(410).send("expired");
return;
}
res.sendFile(file);
// best-effort single-use cleanup after response ends
res.on("finish", () => {
setTimeout(() => {
fs.rm(file).catch(() => {});
}, 500);
});
} catch {
res.status(404).send("not found");
}
});
// periodic cleanup
setInterval(() => {
void cleanOldMedia(ttlMs);
}, ttlMs).unref();
// periodic cleanup
setInterval(() => {
void cleanOldMedia(ttlMs);
}, ttlMs).unref();
}
export async function startMediaServer(
port: number,
ttlMs = DEFAULT_TTL_MS,
runtime: RuntimeEnv = defaultRuntime,
port: number,
ttlMs = DEFAULT_TTL_MS,
runtime: RuntimeEnv = defaultRuntime,
): Promise<Server> {
const app = express();
attachMediaRoutes(app, ttlMs, runtime);
return await new Promise((resolve, reject) => {
const server = app.listen(port);
server.once("listening", () => resolve(server));
server.once("error", (err) => {
runtime.error(danger(`Media server failed: ${String(err)}`));
reject(err);
});
});
const app = express();
attachMediaRoutes(app, ttlMs, runtime);
return await new Promise((resolve, reject) => {
const server = app.listen(port);
server.once("listening", () => resolve(server));
server.once("error", (err) => {
runtime.error(danger(`Media server failed: ${String(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");
vi.mock("node:os", () => ({
default: { homedir: () => HOME },
homedir: () => HOME,
default: { homedir: () => HOME },
homedir: () => HOME,
}));
const store = await import("./store.js");
describe("media store", () => {
beforeAll(async () => {
await fs.rm(HOME, { recursive: true, force: true });
});
beforeAll(async () => {
await fs.rm(HOME, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(HOME, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(HOME, { recursive: true, force: true });
});
it("creates and returns media directory", async () => {
const dir = await store.ensureMediaDir();
expect(dir).toContain("warelay-home-test");
const stat = await fs.stat(dir);
expect(stat.isDirectory()).toBe(true);
});
it("creates and returns media directory", async () => {
const dir = await store.ensureMediaDir();
expect(dir).toContain("warelay-home-test");
const stat = await fs.stat(dir);
expect(stat.isDirectory()).toBe(true);
});
it("saves buffers and enforces size limit", async () => {
const buf = Buffer.from("hello");
const saved = await store.saveMediaBuffer(buf, "text/plain");
const savedStat = await fs.stat(saved.path);
expect(savedStat.size).toBe(buf.length);
expect(saved.contentType).toBe("text/plain");
it("saves buffers and enforces size limit", async () => {
const buf = Buffer.from("hello");
const saved = await store.saveMediaBuffer(buf, "text/plain");
const savedStat = await fs.stat(saved.path);
expect(savedStat.size).toBe(buf.length);
expect(saved.contentType).toBe("text/plain");
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
"Media exceeds 5MB limit",
);
});
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
"Media exceeds 5MB limit",
);
});
it("copies local files and cleans old media", async () => {
const srcFile = path.join(HOME, "tmp-src.txt");
await fs.mkdir(HOME, { recursive: true });
await fs.writeFile(srcFile, "local file");
const saved = await store.saveMediaSource(srcFile);
expect(saved.size).toBe(10);
const savedStat = await fs.stat(saved.path);
expect(savedStat.isFile()).toBe(true);
it("copies local files and cleans old media", async () => {
const srcFile = path.join(HOME, "tmp-src.txt");
await fs.mkdir(HOME, { recursive: true });
await fs.writeFile(srcFile, "local file");
const saved = await store.saveMediaSource(srcFile);
expect(saved.size).toBe(10);
const savedStat = await fs.stat(saved.path);
expect(savedStat.isFile()).toBe(true);
// make the file look old and ensure cleanOldMedia removes it
const past = Date.now() - 10_000;
await fs.utimes(saved.path, past / 1000, past / 1000);
await store.cleanOldMedia(1);
await expect(fs.stat(saved.path)).rejects.toThrow();
});
// make the file look old and ensure cleanOldMedia removes it
const past = Date.now() - 10_000;
await fs.utimes(saved.path, past / 1000, past / 1000);
await store.cleanOldMedia(1);
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
export function getMediaDir() {
return MEDIA_DIR;
return MEDIA_DIR;
}
export async function ensureMediaDir() {
await fs.mkdir(MEDIA_DIR, { recursive: true });
return MEDIA_DIR;
await fs.mkdir(MEDIA_DIR, { recursive: true });
return MEDIA_DIR;
}
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
await ensureMediaDir();
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
const now = Date.now();
await Promise.all(
entries.map(async (file) => {
const full = path.join(MEDIA_DIR, file);
const stat = await fs.stat(full).catch(() => null);
if (!stat) return;
if (now - stat.mtimeMs > ttlMs) {
await fs.rm(full).catch(() => {});
}
}),
);
await ensureMediaDir();
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
const now = Date.now();
await Promise.all(
entries.map(async (file) => {
const full = path.join(MEDIA_DIR, file);
const stat = await fs.stat(full).catch(() => null);
if (!stat) return;
if (now - stat.mtimeMs > ttlMs) {
await fs.rm(full).catch(() => {});
}
}),
);
}
function looksLikeUrl(src: string) {
return /^https?:\/\//i.test(src);
return /^https?:\/\//i.test(src);
}
async function downloadToFile(
url: string,
dest: string,
headers?: Record<string, string>,
url: string,
dest: string,
headers?: Record<string, string>,
) {
await new Promise<void>((resolve, reject) => {
const req = request(url, { headers }, (res) => {
if (!res.statusCode || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
return;
}
let total = 0;
const out = createWriteStream(dest);
res.on("data", (chunk) => {
total += chunk.length;
if (total > MAX_BYTES) {
req.destroy(new Error("Media exceeds 5MB limit"));
}
});
pipeline(res, out)
.then(() => resolve())
.catch(reject);
});
req.on("error", reject);
req.end();
});
await new Promise<void>((resolve, reject) => {
const req = request(url, { headers }, (res) => {
if (!res.statusCode || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
return;
}
let total = 0;
const out = createWriteStream(dest);
res.on("data", (chunk) => {
total += chunk.length;
if (total > MAX_BYTES) {
req.destroy(new Error("Media exceeds 5MB limit"));
}
});
pipeline(res, out)
.then(() => resolve())
.catch(reject);
});
req.on("error", reject);
req.end();
});
}
export type SavedMedia = {
id: string;
path: string;
size: number;
contentType?: string;
id: string;
path: string;
size: number;
contentType?: string;
};
export async function saveMediaSource(
source: string,
headers?: Record<string, string>,
subdir = "",
source: string,
headers?: Record<string, string>,
subdir = "",
): Promise<SavedMedia> {
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
await fs.mkdir(dir, { recursive: true });
await cleanOldMedia();
const id = crypto.randomUUID();
const dest = path.join(dir, id);
if (looksLikeUrl(source)) {
await downloadToFile(source, dest, headers);
const stat = await fs.stat(dest);
return { id, path: dest, size: stat.size };
}
// local path
const stat = await fs.stat(source);
if (!stat.isFile()) {
throw new Error("Media path is not a file");
}
if (stat.size > MAX_BYTES) {
throw new Error("Media exceeds 5MB limit");
}
await fs.copyFile(source, dest);
return { id, path: dest, size: stat.size };
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
await fs.mkdir(dir, { recursive: true });
await cleanOldMedia();
const id = crypto.randomUUID();
const dest = path.join(dir, id);
if (looksLikeUrl(source)) {
await downloadToFile(source, dest, headers);
const stat = await fs.stat(dest);
return { id, path: dest, size: stat.size };
}
// local path
const stat = await fs.stat(source);
if (!stat.isFile()) {
throw new Error("Media path is not a file");
}
if (stat.size > MAX_BYTES) {
throw new Error("Media exceeds 5MB limit");
}
await fs.copyFile(source, dest);
return { id, path: dest, size: stat.size };
}
export async function saveMediaBuffer(
buffer: Buffer,
contentType?: string,
subdir = "inbound",
buffer: Buffer,
contentType?: string,
subdir = "inbound",
): Promise<SavedMedia> {
if (buffer.byteLength > MAX_BYTES) {
throw new Error("Media exceeds 5MB limit");
}
const dir = path.join(MEDIA_DIR, subdir);
await fs.mkdir(dir, { recursive: true });
const id = crypto.randomUUID();
const dest = path.join(dir, id);
await fs.writeFile(dest, buffer);
return { id, path: dest, size: buffer.byteLength, contentType };
if (buffer.byteLength > MAX_BYTES) {
throw new Error("Media exceeds 5MB limit");
}
const dir = path.join(MEDIA_DIR, subdir);
await fs.mkdir(dir, { recursive: true });
const id = crypto.randomUUID();
const dest = path.join(dir, id);
await fs.writeFile(dest, buffer);
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";
describe("command queue", () => {
it("runs tasks one at a time in order", async () => {
let active = 0;
let maxActive = 0;
const calls: number[] = [];
it("runs tasks one at a time in order", async () => {
let active = 0;
let maxActive = 0;
const calls: number[] = [];
const makeTask = (id: number) => async () => {
active += 1;
maxActive = Math.max(maxActive, active);
calls.push(id);
await new Promise((resolve) => setTimeout(resolve, 15));
active -= 1;
return id;
};
const makeTask = (id: number) => async () => {
active += 1;
maxActive = Math.max(maxActive, active);
calls.push(id);
await new Promise((resolve) => setTimeout(resolve, 15));
active -= 1;
return id;
};
const results = await Promise.all([
enqueueCommand(makeTask(1)),
enqueueCommand(makeTask(2)),
enqueueCommand(makeTask(3)),
]);
const results = await Promise.all([
enqueueCommand(makeTask(1)),
enqueueCommand(makeTask(2)),
enqueueCommand(makeTask(3)),
]);
expect(results).toEqual([1, 2, 3]);
expect(calls).toEqual([1, 2, 3]);
expect(maxActive).toBe(1);
expect(getQueueSize()).toBe(0);
});
expect(results).toEqual([1, 2, 3]);
expect(calls).toEqual([1, 2, 3]);
expect(maxActive).toBe(1);
expect(getQueueSize()).toBe(0);
});
it("invokes onWait callback when a task waits past the threshold", async () => {
let waited: number | null = null;
let queuedAhead: number | null = null;
it("invokes onWait callback when a task waits past the threshold", async () => {
let waited: number | null = null;
let queuedAhead: number | null = null;
// First task holds the queue long enough to trigger wait notice.
const first = enqueueCommand(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
});
// First task holds the queue long enough to trigger wait notice.
const first = enqueueCommand(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
});
const second = enqueueCommand(async () => {}, {
warnAfterMs: 5,
onWait: (ms, ahead) => {
waited = ms;
queuedAhead = ahead;
},
});
const second = enqueueCommand(async () => {}, {
warnAfterMs: 5,
onWait: (ms, ahead) => {
waited = ms;
queuedAhead = ahead;
},
});
await Promise.all([first, second]);
await Promise.all([first, second]);
expect(waited).not.toBeNull();
expect(waited as number).toBeGreaterThanOrEqual(5);
expect(queuedAhead).toBe(0);
});
expect(waited).not.toBeNull();
expect(waited as number).toBeGreaterThanOrEqual(5);
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.
type QueueEntry = {
task: () => Promise<unknown>;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
enqueuedAt: number;
warnAfterMs: number;
onWait?: (waitMs: number, queuedAhead: number) => void;
task: () => Promise<unknown>;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
enqueuedAt: number;
warnAfterMs: number;
onWait?: (waitMs: number, queuedAhead: number) => void;
};
const queue: QueueEntry[] = [];
let draining = false;
async function drainQueue() {
if (draining) return;
draining = true;
while (queue.length) {
const entry = queue.shift() as QueueEntry;
const waitedMs = Date.now() - entry.enqueuedAt;
if (waitedMs >= entry.warnAfterMs) {
entry.onWait?.(waitedMs, queue.length);
}
try {
const result = await entry.task();
entry.resolve(result);
} catch (err) {
entry.reject(err);
}
}
draining = false;
if (draining) return;
draining = true;
while (queue.length) {
const entry = queue.shift() as QueueEntry;
const waitedMs = Date.now() - entry.enqueuedAt;
if (waitedMs >= entry.warnAfterMs) {
entry.onWait?.(waitedMs, queue.length);
}
try {
const result = await entry.task();
entry.resolve(result);
} catch (err) {
entry.reject(err);
}
}
draining = false;
}
export function enqueueCommand<T>(
task: () => Promise<T>,
opts?: {
warnAfterMs?: number;
onWait?: (waitMs: number, queuedAhead: number) => void;
},
task: () => Promise<T>,
opts?: {
warnAfterMs?: number;
onWait?: (waitMs: number, queuedAhead: number) => void;
},
): Promise<T> {
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
return new Promise<T>((resolve, reject) => {
queue.push({
task: () => task(),
resolve: (value) => resolve(value as T),
reject,
enqueuedAt: Date.now(),
warnAfterMs,
onWait: opts?.onWait,
});
void drainQueue();
});
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
return new Promise<T>((resolve, reject) => {
queue.push({
task: () => task(),
resolve: (value) => resolve(value as T),
reject,
enqueuedAt: Date.now(),
warnAfterMs,
onWait: opts?.onWait,
});
void drainQueue();
});
}
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.
export async function runExec(
command: string,
args: string[],
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
command: string,
args: string[],
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
): Promise<{ stdout: string; stderr: string }> {
const options =
typeof opts === "number"
? { timeout: opts, encoding: "utf8" as const }
: {
timeout: opts.timeoutMs,
maxBuffer: opts.maxBuffer,
encoding: "utf8" as const,
};
try {
const { stdout, stderr } = await execFileAsync(command, args, options);
if (isVerbose()) {
if (stdout.trim()) logDebug(stdout.trim());
if (stderr.trim()) logError(stderr.trim());
}
return { stdout, stderr };
} catch (err) {
if (isVerbose()) {
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
}
throw err;
}
const options =
typeof opts === "number"
? { timeout: opts, encoding: "utf8" as const }
: {
timeout: opts.timeoutMs,
maxBuffer: opts.maxBuffer,
encoding: "utf8" as const,
};
try {
const { stdout, stderr } = await execFileAsync(command, args, options);
if (isVerbose()) {
if (stdout.trim()) logDebug(stdout.trim());
if (stderr.trim()) logError(stderr.trim());
}
return { stdout, stderr };
} catch (err) {
if (isVerbose()) {
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
}
throw err;
}
}
export type SpawnResult = {
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
killed: boolean;
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
killed: boolean;
};
export type CommandOptions = {
timeoutMs: number;
cwd?: string;
timeoutMs: number;
cwd?: string;
};
export async function runCommandWithTimeout(
argv: string[],
optionsOrTimeout: number | CommandOptions,
argv: string[],
optionsOrTimeout: number | CommandOptions,
): Promise<SpawnResult> {
const options: CommandOptions =
typeof optionsOrTimeout === "number"
? { timeoutMs: optionsOrTimeout }
: optionsOrTimeout;
const { timeoutMs, cwd } = options;
const options: CommandOptions =
typeof optionsOrTimeout === "number"
? { timeoutMs: optionsOrTimeout }
: optionsOrTimeout;
const { timeoutMs, cwd } = options;
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
return await new Promise((resolve, reject) => {
const child = spawn(argv[0], argv.slice(1), {
stdio: ["inherit", "pipe", "pipe"],
cwd,
});
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
child.kill("SIGKILL");
}, timeoutMs);
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
return await new Promise((resolve, reject) => {
const child = spawn(argv[0], argv.slice(1), {
stdio: ["inherit", "pipe", "pipe"],
cwd,
});
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
child.kill("SIGKILL");
}, timeoutMs);
child.stdout?.on("data", (d) => {
stdout += d.toString();
});
child.stderr?.on("data", (d) => {
stderr += d.toString();
});
child.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(err);
});
child.on("close", (code, signal) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ stdout, stderr, code, signal, killed: child.killed });
});
});
child.stdout?.on("data", (d) => {
stdout += d.toString();
});
child.stderr?.on("data", (d) => {
stderr += d.toString();
});
child.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(err);
});
child.on("close", (code, signal) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ stdout, stderr, code, signal, killed: child.killed });
});
});
}

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 {
formatMessageLine,
listRecentMessages,
formatMessageLine,
listRecentMessages,
} from "../../twilio/messages.js";
export { monitorTwilio } from "../../twilio/monitor.js";
export { sendMessage, waitForFinalStatus } from "../../twilio/send.js";
export { findWhatsappSenderSid } from "../../twilio/senders.js";
export { sendTypingIndicator } from "../../twilio/typing.js";
export {
findIncomingNumberSid,
findMessagingServiceSid,
setMessagingServiceWebhook,
updateWebhook,
findIncomingNumberSid,
findMessagingServiceSid,
setMessagingServiceWebhook,
updateWebhook,
} from "../../twilio/update-webhook.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";
describe("providers/web entrypoint", () => {
it("re-exports web provider helpers", () => {
expect(entry.createWaSocket).toBe(impl.createWaSocket);
expect(entry.loginWeb).toBe(impl.loginWeb);
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
expect(entry.pickProvider).toBe(impl.pickProvider);
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
expect(entry.webAuthExists).toBe(impl.webAuthExists);
});
it("re-exports web provider helpers", () => {
expect(entry.createWaSocket).toBe(impl.createWaSocket);
expect(entry.loginWeb).toBe(impl.loginWeb);
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
expect(entry.pickProvider).toBe(impl.pickProvider);
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
expect(entry.webAuthExists).toBe(impl.webAuthExists);
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,122 +8,122 @@ import { sleep, withWhatsAppPrefix } from "../utils.js";
import { createClient } from "./client.js";
type MonitorDeps = {
autoReplyIfConfigured: typeof autoReplyIfConfigured;
listRecentMessages: (
lookbackMinutes: number,
limit: number,
clientOverride?: ReturnType<typeof createClient>,
) => Promise<ListedMessage[]>;
readEnv: typeof readEnv;
createClient: typeof createClient;
sleep: typeof sleep;
autoReplyIfConfigured: typeof autoReplyIfConfigured;
listRecentMessages: (
lookbackMinutes: number,
limit: number,
clientOverride?: ReturnType<typeof createClient>,
) => Promise<ListedMessage[]>;
readEnv: typeof readEnv;
createClient: typeof createClient;
sleep: typeof sleep;
};
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
export type ListedMessage = {
sid: string;
status: string | null;
direction: string | null;
dateCreated: Date | undefined;
from?: string | null;
to?: string | null;
body?: string | null;
errorCode: number | null;
errorMessage: string | null;
sid: string;
status: string | null;
direction: string | null;
dateCreated: Date | undefined;
from?: string | null;
to?: string | null;
body?: string | null;
errorCode: number | null;
errorMessage: string | null;
};
type MonitorOptions = {
client?: ReturnType<typeof createClient>;
maxIterations?: number;
deps?: MonitorDeps;
runtime?: RuntimeEnv;
client?: ReturnType<typeof createClient>;
maxIterations?: number;
deps?: MonitorDeps;
runtime?: RuntimeEnv;
};
const defaultDeps: MonitorDeps = {
autoReplyIfConfigured,
listRecentMessages: () => Promise.resolve([]),
readEnv,
createClient,
sleep,
autoReplyIfConfigured,
listRecentMessages: () => Promise.resolve([]),
readEnv,
createClient,
sleep,
};
// Poll Twilio for inbound messages and auto-reply when configured.
export async function monitorTwilio(
pollSeconds: number,
lookbackMinutes: number,
opts?: MonitorOptions,
pollSeconds: number,
lookbackMinutes: number,
opts?: MonitorOptions,
) {
const deps = opts?.deps ?? defaultDeps;
const runtime = opts?.runtime ?? defaultRuntime;
const maxIterations = opts?.maxIterations ?? Infinity;
let backoffMs = 1_000;
const deps = opts?.deps ?? defaultDeps;
const runtime = opts?.runtime ?? defaultRuntime;
const maxIterations = opts?.maxIterations ?? Infinity;
let backoffMs = 1_000;
const env = deps.readEnv(runtime);
const from = withWhatsAppPrefix(env.whatsappFrom);
const client = opts?.client ?? deps.createClient(env);
logInfo(
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
runtime,
);
const env = deps.readEnv(runtime);
const from = withWhatsAppPrefix(env.whatsappFrom);
const client = opts?.client ?? deps.createClient(env);
logInfo(
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
runtime,
);
let lastSeenSid: string | undefined;
let iterations = 0;
while (iterations < maxIterations) {
let messages: ListedMessage[] = [];
try {
messages =
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
backoffMs = 1_000; // reset after success
} catch (err) {
logWarn(
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
runtime,
);
await deps.sleep(backoffMs);
backoffMs = Math.min(backoffMs * 2, 10_000);
continue;
}
const inboundOnly = messages.filter((m) => m.direction === "inbound");
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
const newestFirst = [...inboundOnly].sort(
(a, b) =>
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
);
await handleMessages(messages, client, lastSeenSid, deps, runtime);
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
iterations += 1;
if (iterations >= maxIterations) break;
await deps.sleep(
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
);
}
let lastSeenSid: string | undefined;
let iterations = 0;
while (iterations < maxIterations) {
let messages: ListedMessage[] = [];
try {
messages =
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
backoffMs = 1_000; // reset after success
} catch (err) {
logWarn(
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
runtime,
);
await deps.sleep(backoffMs);
backoffMs = Math.min(backoffMs * 2, 10_000);
continue;
}
const inboundOnly = messages.filter((m) => m.direction === "inbound");
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
const newestFirst = [...inboundOnly].sort(
(a, b) =>
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
);
await handleMessages(messages, client, lastSeenSid, deps, runtime);
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
iterations += 1;
if (iterations >= maxIterations) break;
await deps.sleep(
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
);
}
}
async function handleMessages(
messages: ListedMessage[],
client: ReturnType<typeof createClient>,
lastSeenSid: string | undefined,
deps: MonitorDeps,
runtime: RuntimeEnv,
messages: ListedMessage[],
client: ReturnType<typeof createClient>,
lastSeenSid: string | undefined,
deps: MonitorDeps,
runtime: RuntimeEnv,
) {
for (const m of messages) {
if (!m.sid) continue;
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
if (m.direction !== "inbound") continue;
if (!m.from || !m.to) continue;
try {
await deps.autoReplyIfConfigured(
client as unknown as import("./types.js").TwilioRequester & {
messages: { create: (opts: unknown) => Promise<unknown> };
},
m as unknown as MessageInstance,
undefined,
runtime,
);
} catch (err) {
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
}
}
for (const m of messages) {
if (!m.sid) continue;
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
if (m.direction !== "inbound") continue;
if (!m.from || !m.to) continue;
try {
await deps.autoReplyIfConfigured(
client as unknown as import("./types.js").TwilioRequester & {
messages: { create: (opts: unknown) => Promise<unknown> };
},
m as unknown as MessageInstance,
undefined,
runtime,
);
} catch (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";
describe("twilio send helpers", () => {
it("waitForFinalStatus resolves on delivered", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce({ status: "queued" })
.mockResolvedValueOnce({ status: "delivered" });
const client = { messages: vi.fn(() => ({ fetch })) } as never;
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
expect(fetch).toHaveBeenCalledTimes(2);
});
it("waitForFinalStatus resolves on delivered", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce({ status: "queued" })
.mockResolvedValueOnce({ status: "delivered" });
const client = { messages: vi.fn(() => ({ fetch })) } as never;
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
expect(fetch).toHaveBeenCalledTimes(2);
});
it("waitForFinalStatus exits on failure", async () => {
const fetch = vi
.fn()
.mockResolvedValue({ status: "failed", errorMessage: "boom" });
const client = { messages: vi.fn(() => ({ fetch })) } as never;
const runtime = {
log: console.log,
error: () => {},
exit: vi.fn(() => {
throw new Error("exit");
}),
} as never;
await expect(
waitForFinalStatus(client, "SM1", 1, 0.01, runtime),
).rejects.toBeInstanceOf(Error);
});
it("waitForFinalStatus exits on failure", async () => {
const fetch = vi
.fn()
.mockResolvedValue({ status: "failed", errorMessage: "boom" });
const client = { messages: vi.fn(() => ({ fetch })) } as never;
const runtime = {
log: console.log,
error: () => {},
exit: vi.fn(() => {
throw new Error("exit");
}),
} as never;
await expect(
waitForFinalStatus(client, "SM1", 1, 0.01, runtime),
).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.
export async function sendMessage(
to: string,
body: string,
opts?: { mediaUrl?: string },
runtime: RuntimeEnv = defaultRuntime,
to: string,
body: string,
opts?: { mediaUrl?: string },
runtime: RuntimeEnv = defaultRuntime,
) {
const env = readEnv(runtime);
const client = createClient(env);
const from = withWhatsAppPrefix(env.whatsappFrom);
const toNumber = withWhatsAppPrefix(to);
const env = readEnv(runtime);
const client = createClient(env);
const from = withWhatsAppPrefix(env.whatsappFrom);
const toNumber = withWhatsAppPrefix(to);
try {
const message = await client.messages.create({
from,
to: toNumber,
body,
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
});
try {
const message = await client.messages.create({
from,
to: toNumber,
body,
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
});
logInfo(
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
runtime,
);
return { client, sid: message.sid };
} catch (err) {
logTwilioSendError(err, toNumber, runtime);
}
logInfo(
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
runtime,
);
return { client, sid: message.sid };
} catch (err) {
logTwilioSendError(err, toNumber, runtime);
}
}
// Poll message status until delivered/failed or timeout.
export async function waitForFinalStatus(
client: ReturnType<typeof createClient>,
sid: string,
timeoutSeconds: number,
pollSeconds: number,
runtime: RuntimeEnv = defaultRuntime,
client: ReturnType<typeof createClient>,
sid: string,
timeoutSeconds: number,
pollSeconds: number,
runtime: RuntimeEnv = defaultRuntime,
) {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const m = await client.messages(sid).fetch();
const status = m.status ?? "unknown";
if (successTerminalStatuses.has(status)) {
logInfo(`✅ Delivered (status: ${status})`, runtime);
return;
}
if (failureTerminalStatuses.has(status)) {
runtime.error(
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
);
runtime.exit(1);
}
await sleep(pollSeconds * 1000);
}
logInfo(
" Timed out waiting for final status; message may still be in flight.",
runtime,
);
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const m = await client.messages(sid).fetch();
const status = m.status ?? "unknown";
if (successTerminalStatuses.has(status)) {
logInfo(`✅ Delivered (status: ${status})`, runtime);
return;
}
if (failureTerminalStatuses.has(status)) {
runtime.error(
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
);
runtime.exit(1);
}
await sleep(pollSeconds * 1000);
}
logInfo(
" Timed out waiting for final status; message may still be in flight.",
runtime,
);
}

View File

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

View File

@@ -1,79 +1,79 @@
export type TwilioRequestOptions = {
method: "get" | "post";
uri: string;
params?: Record<string, string | number>;
form?: Record<string, string>;
body?: unknown;
contentType?: string;
method: "get" | "post";
uri: string;
params?: Record<string, string | number>;
form?: Record<string, string>;
body?: unknown;
contentType?: string;
};
export type TwilioSender = { sid: string; sender_id: string };
export type TwilioRequestResponse = {
data?: {
senders?: TwilioSender[];
};
data?: {
senders?: TwilioSender[];
};
};
export type IncomingNumber = {
sid: string;
phoneNumber: string;
smsUrl?: string;
sid: string;
phoneNumber: string;
smsUrl?: string;
};
export type TwilioChannelsSender = {
sid?: string;
senderId?: string;
sender_id?: string;
webhook?: {
callback_url?: string;
callback_method?: string;
fallback_url?: string;
fallback_method?: string;
};
sid?: string;
senderId?: string;
sender_id?: string;
webhook?: {
callback_url?: string;
callback_method?: string;
fallback_url?: string;
fallback_method?: string;
};
};
export type ChannelSenderUpdater = {
update: (params: Record<string, string>) => Promise<unknown>;
update: (params: Record<string, string>) => Promise<unknown>;
};
export type IncomingPhoneNumberUpdater = {
update: (params: Record<string, string>) => Promise<unknown>;
update: (params: Record<string, string>) => Promise<unknown>;
};
export type IncomingPhoneNumbersClient = {
list: (params: {
phoneNumber: string;
limit?: number;
}) => Promise<IncomingNumber[]>;
get: (sid: string) => IncomingPhoneNumberUpdater;
list: (params: {
phoneNumber: string;
limit?: number;
}) => Promise<IncomingNumber[]>;
get: (sid: string) => IncomingPhoneNumberUpdater;
} & ((sid: string) => IncomingPhoneNumberUpdater);
export type TwilioSenderListClient = {
messaging: {
v2: {
channelsSenders: {
list: (params: {
channel: string;
pageSize: number;
}) => Promise<TwilioChannelsSender[]>;
(
sid: string,
): ChannelSenderUpdater & {
fetch: () => Promise<TwilioChannelsSender>;
};
};
};
v1: {
services: (sid: string) => {
update: (params: Record<string, string>) => Promise<unknown>;
fetch: () => Promise<{ inboundRequestUrl?: string }>;
};
};
};
incomingPhoneNumbers: IncomingPhoneNumbersClient;
messaging: {
v2: {
channelsSenders: {
list: (params: {
channel: string;
pageSize: number;
}) => Promise<TwilioChannelsSender[]>;
(
sid: string,
): ChannelSenderUpdater & {
fetch: () => Promise<TwilioChannelsSender>;
};
};
};
v1: {
services: (sid: string) => {
update: (params: Record<string, string>) => Promise<unknown>;
fetch: () => Promise<{ inboundRequestUrl?: string }>;
};
};
};
incomingPhoneNumbers: IncomingPhoneNumbersClient;
};
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";
type TwilioRequestOptions = {
method: "get" | "post";
uri: string;
params?: Record<string, string | number>;
form?: Record<string, string>;
body?: unknown;
contentType?: string;
method: "get" | "post";
uri: string;
params?: Record<string, string | number>;
form?: Record<string, string>;
body?: unknown;
contentType?: string;
};
type TwilioRequester = {
request: (options: TwilioRequestOptions) => Promise<unknown>;
request: (options: TwilioRequestOptions) => Promise<unknown>;
};
export async function sendTypingIndicator(
client: TwilioRequester,
runtime: RuntimeEnv,
messageSid?: string,
client: TwilioRequester,
runtime: RuntimeEnv,
messageSid?: string,
) {
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
if (!messageSid) {
logVerbose("Skipping typing indicator: missing MessageSid");
return;
}
try {
await client.request({
method: "post",
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
form: {
messageId: messageSid,
channel: "whatsapp",
},
});
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
} catch (err) {
if (isVerbose()) {
runtime.error(warn("Typing indicator failed (continuing without it)"));
runtime.error(err as Error);
}
}
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
if (!messageSid) {
logVerbose("Skipping typing indicator: missing MessageSid");
return;
}
try {
await client.request({
method: "post",
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
form: {
messageId: messageSid,
channel: "whatsapp",
},
});
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
} catch (err) {
if (isVerbose()) {
runtime.error(warn("Typing indicator failed (continuing without it)"));
runtime.error(err as Error);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,49 +2,49 @@ import fs from "node:fs";
import os from "node:os";
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 function assertProvider(input: string): asserts input is Provider {
if (input !== "twilio" && input !== "web") {
throw new Error("Provider must be 'twilio' or 'web'");
}
if (input !== "twilio" && input !== "web") {
throw new Error("Provider must be 'twilio' or 'web'");
}
}
export function normalizePath(p: string): string {
if (!p.startsWith("/")) return `/${p}`;
return p;
if (!p.startsWith("/")) return `/${p}`;
return p;
}
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 {
const withoutPrefix = number.replace(/^whatsapp:/, "").trim();
const digits = withoutPrefix.replace(/[^\d+]/g, "");
if (digits.startsWith("+")) return `+${digits.slice(1)}`;
return `+${digits}`;
const withoutPrefix = number.replace(/^whatsapp:/, "").trim();
const digits = withoutPrefix.replace(/[^\d+]/g, "");
if (digits.startsWith("+")) return `+${digits.slice(1)}`;
return `+${digits}`;
}
export function toWhatsappJid(number: string): string {
const e164 = normalizeE164(number);
const digits = e164.replace(/\D/g, "");
return `${digits}@s.whatsapp.net`;
const e164 = normalizeE164(number);
const digits = e164.replace(/\D/g, "");
return `${digits}@s.whatsapp.net`;
}
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.
const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/);
if (!match) return null;
const digits = match[1];
return `+${digits}`;
// 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$/);
if (!match) return null;
const digits = match[1];
return `+${digits}`;
}
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`;

View File

@@ -4,7 +4,7 @@ import * as impl from "../twilio/webhook.js";
import * as entry from "./server.js";
describe("webhook server wrapper", () => {
it("re-exports startWebhook", () => {
expect(entry.startWebhook).toBe(impl.startWebhook);
});
it("re-exports 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";
describe("webhook update wrappers", () => {
it("mirror the Twilio implementations", () => {
expect(entry.updateWebhook).toBe(impl.updateWebhook);
expect(entry.findIncomingNumberSid).toBe(impl.findIncomingNumberSid);
expect(entry.findMessagingServiceSid).toBe(impl.findMessagingServiceSid);
expect(entry.setMessagingServiceWebhook).toBe(
impl.setMessagingServiceWebhook,
);
});
it("mirror the Twilio implementations", () => {
expect(entry.updateWebhook).toBe(impl.updateWebhook);
expect(entry.findIncomingNumberSid).toBe(impl.findIncomingNumberSid);
expect(entry.findMessagingServiceSid).toBe(impl.findMessagingServiceSid);
expect(entry.setMessagingServiceWebhook).toBe(
impl.setMessagingServiceWebhook,
);
});
});

View File

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