chore: drop twilio and go web-only

This commit is contained in:
Peter Steinberger
2025-12-05 19:03:59 +00:00
parent 869cc3d497
commit 7c7314f673
50 changed files with 335 additions and 5019 deletions

View File

@@ -1,5 +1,4 @@
import crypto from "node:crypto";
import { chunkText } from "../auto-reply/chunk.js";
import { runCommandReply } from "../auto-reply/command-reply.js";
import {
applyTemplate,
@@ -22,11 +21,8 @@ import {
type SessionEntry,
saveSessionStore,
} from "../config/sessions.js";
import { ensureTwilioEnv } from "../env.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { pickProvider } from "../provider-web.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import type { Provider } from "../utils.js";
import { sendViaIpc } from "../web/ipc.js";
type AgentCommandOpts = {
@@ -38,7 +34,6 @@ type AgentCommandOpts = {
json?: boolean;
timeout?: string;
deliver?: boolean;
provider?: Provider | "auto";
};
type SessionResolution = {
@@ -344,14 +339,6 @@ export async function agentCommand(
}
const deliver = opts.deliver === true;
let provider: Provider | "auto" | undefined = opts.provider ?? "auto";
if (deliver) {
provider =
provider === "twilio"
? "twilio"
: await pickProvider((provider ?? "auto") as Provider | "auto");
if (provider === "twilio") ensureTwilioEnv();
}
for (const payload of payloads) {
const lines: string[] = [];
@@ -366,55 +353,29 @@ export async function agentCommand(
if (deliver && opts.to) {
const text = payload.text ?? "";
const media = mediaList;
if (provider === "web") {
// Prefer IPC to reuse the running relay; fall back to direct web send.
let sentViaIpc = false;
const ipcResult = await sendViaIpc(opts.to, text, media[0]);
if (ipcResult) {
sentViaIpc = ipcResult.success;
if (ipcResult.success && media.length > 1) {
for (const extra of media.slice(1)) {
await sendViaIpc(opts.to, "", extra);
}
}
}
if (!sentViaIpc) {
if (text || media.length === 0) {
await deps.sendMessageWeb(opts.to, text, {
verbose: false,
mediaUrl: media[0],
});
}
// Prefer IPC to reuse the running relay; fall back to direct web send.
let sentViaIpc = false;
const ipcResult = await sendViaIpc(opts.to, text, media[0]);
if (ipcResult) {
sentViaIpc = ipcResult.success;
if (ipcResult.success && media.length > 1) {
for (const extra of media.slice(1)) {
await deps.sendMessageWeb(opts.to, "", {
verbose: false,
mediaUrl: extra,
});
await sendViaIpc(opts.to, "", extra);
}
}
} else {
const chunks = chunkText(text, 1600);
const resolvedMedia = await Promise.all(
media.map((m) =>
deps.resolveTwilioMediaUrl(m, { serveMedia: false, runtime }),
),
);
const firstMedia = resolvedMedia[0];
if (chunks.length === 0) chunks.push("");
for (let i = 0; i < chunks.length; i++) {
const bodyChunk = chunks[i];
const attach = i === 0 ? firstMedia : undefined;
await deps.sendMessage(
opts.to,
bodyChunk,
{ mediaUrl: attach },
runtime,
);
}
if (!sentViaIpc) {
if (text || media.length === 0) {
await deps.sendMessageWeb(opts.to, text, {
verbose: false,
mediaUrl: media[0],
});
}
if (resolvedMedia.length > 1) {
for (const extra of resolvedMedia.slice(1)) {
await deps.sendMessage(opts.to, "", { mediaUrl: extra }, runtime);
}
for (const extra of media.slice(1)) {
await deps.sendMessageWeb(opts.to, "", {
verbose: false,
mediaUrl: extra,
});
}
}
}

View File

@@ -4,8 +4,9 @@ import type { CliDeps } from "../cli/deps.js";
import type { RuntimeEnv } from "../runtime.js";
import { sendCommand } from "./send.js";
const sendViaIpcMock = vi.fn().mockResolvedValue(null);
vi.mock("../web/ipc.js", () => ({
sendViaIpc: vi.fn().mockResolvedValue(null),
sendViaIpc: (...args: unknown[]) => sendViaIpcMock(...args),
}));
const runtime: RuntimeEnv = {
@@ -16,59 +17,19 @@ const runtime: RuntimeEnv = {
}),
};
const baseDeps = {
assertProvider: vi.fn(),
const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
sendMessageWeb: vi.fn(),
resolveTwilioMediaUrl: vi.fn(),
sendMessage: vi.fn(),
waitForFinalStatus: vi.fn(),
} as unknown as CliDeps;
...overrides,
});
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");
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;
it("skips send on dry-run", async () => {
const deps = makeDeps();
await sendCommand(
{
to: "+1",
message: "hi",
wait: "5",
poll: "2",
provider: "web",
dryRun: true,
media: "pic.jpg",
},
deps,
runtime,
@@ -76,74 +37,54 @@ describe("sendCommand", () => {
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
});
it("sends via web and outputs JSON", async () => {
const deps = {
...baseDeps,
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
} as CliDeps;
it("uses IPC when available", async () => {
sendViaIpcMock.mockResolvedValueOnce({ success: true, messageId: "ipc1" });
const deps = makeDeps();
await sendCommand(
{
to: "+1",
message: "hi",
wait: "1",
poll: "2",
provider: "web",
json: true,
},
deps,
runtime,
);
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("ipc1"));
});
it("falls back to direct send when IPC fails", async () => {
sendViaIpcMock.mockResolvedValueOnce({ success: false, error: "nope" });
const deps = makeDeps({
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "direct1" }),
});
await sendCommand(
{
to: "+1",
message: "hi",
media: "pic.jpg",
},
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;
it("emits json output", async () => {
sendViaIpcMock.mockResolvedValueOnce(null);
const deps = makeDeps({
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "direct2" }),
});
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"'),
expect.stringContaining('"provider": "web"'),
);
});
});

View File

@@ -1,148 +1,81 @@
import type { CliDeps } from "../cli/deps.js";
import { info, success } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import type { Provider } from "../utils.js";
import { sendViaIpc } from "../web/ipc.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,
) {
deps.assertProvider(opts.provider);
const waitSeconds = Number.parseInt(opts.wait, 10);
const pollSeconds = Number.parseInt(opts.poll, 10);
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
throw new Error("Wait must be >= 0 seconds");
}
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
throw new Error("Poll must be > 0 seconds");
}
if (opts.provider === "web") {
if (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."));
}
// Try to send via IPC to running relay first (avoids Signal session corruption)
const ipcResult = await sendViaIpc(opts.to, opts.message, opts.media);
if (ipcResult) {
if (ipcResult.success) {
runtime.log(
success(`✅ Sent via relay IPC. Message ID: ${ipcResult.messageId}`),
);
if (opts.json) {
runtime.log(
JSON.stringify(
{
provider: "web",
via: "ipc",
to: opts.to,
messageId: ipcResult.messageId,
mediaUrl: opts.media ?? null,
},
null,
2,
),
);
}
return;
}
// IPC failed but relay is running - warn and fall back
runtime.log(
info(
`IPC send failed (${ipcResult.error}), falling back to direct connection`,
),
);
}
// Fall back to direct connection (creates new Baileys socket)
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",
via: "direct",
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})` : ""}`,
`[dry-run] would send via web -> ${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,
});
// Try to send via IPC to running relay first (avoids Signal session corruption)
const ipcResult = await sendViaIpc(opts.to, opts.message, opts.media);
if (ipcResult) {
if (ipcResult.success) {
runtime.log(
success(`✅ Sent via relay IPC. Message ID: ${ipcResult.messageId}`),
);
if (opts.json) {
runtime.log(
JSON.stringify(
{
provider: "web",
via: "ipc",
to: opts.to,
messageId: ipcResult.messageId,
mediaUrl: opts.media ?? null,
},
null,
2,
),
);
}
return;
}
// IPC failed but relay is running - warn and fall back
runtime.log(
info(
`IPC send failed (${ipcResult.error}), falling back to direct connection`,
),
);
}
const result = await deps.sendMessage(
opts.to,
opts.message,
{ mediaUrl },
runtime,
);
// Fall back to direct connection (creates new Baileys socket)
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: "twilio",
provider: "web",
via: "direct",
to: opts.to,
sid: result?.sid ?? null,
mediaUrl: mediaUrl ?? null,
messageId: res.messageId,
mediaUrl: opts.media ?? null,
},
null,
2,
),
);
}
if (!result) return;
if (waitSeconds === 0) return;
await deps.waitForFinalStatus(
result.client,
result.sid,
waitSeconds,
pollSeconds,
runtime,
);
}

View File

@@ -1,50 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js";
import type { RuntimeEnv } from "../runtime.js";
import { statusCommand } from "./status.js";
vi.mock("../twilio/messages.js", () => ({
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
const mocks = vi.hoisted(() => ({
loadSessionStore: vi.fn().mockReturnValue({
"+1000": { updatedAt: Date.now() - 60_000 },
}),
resolveStorePath: vi.fn().mockReturnValue("/tmp/sessions.json"),
webAuthExists: vi.fn().mockResolvedValue(true),
getWebAuthAgeMs: vi.fn().mockReturnValue(5000),
logWebSelfId: vi.fn(),
}));
const runtime: RuntimeEnv = {
vi.mock("../config/sessions.js", () => ({
loadSessionStore: mocks.loadSessionStore,
resolveStorePath: mocks.resolveStorePath,
}));
vi.mock("../web/session.js", () => ({
webAuthExists: mocks.webAuthExists,
getWebAuthAgeMs: mocks.getWebAuthAgeMs,
logWebSelfId: mocks.logWebSelfId,
}));
vi.mock("../config/config.js", () => ({
loadConfig: () => ({ inbound: { reply: { session: {} } } }),
}));
import { statusCommand } from "./status.js";
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
exit: vi.fn(),
};
const deps: CliDeps = {
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("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),
);
await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse((runtime.log as vi.Mock).mock.calls[0][0]);
expect(payload.web.linked).toBe(true);
expect(payload.sessions.count).toBe(1);
expect(payload.sessions.path).toBe("/tmp/sessions.json");
});
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");
(runtime.log as vi.Mock).mockClear();
await statusCommand({}, runtime as never);
const logs = (runtime.log as vi.Mock).mock.calls.map((c) => String(c[0]));
expect(logs.some((l) => l.includes("Web session"))).toBe(true);
expect(logs.some((l) => l.includes("Active sessions"))).toBe(true);
expect(mocks.logWebSelfId).toHaveBeenCalled();
});
});

View File

@@ -1,31 +1,81 @@
import type { CliDeps } from "../cli/deps.js";
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { formatMessageLine } from "../twilio/messages.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
import {
getWebAuthAgeMs,
logWebSelfId,
webAuthExists,
} from "../web/session.js";
const formatAge = (ms: number | null | undefined) => {
if (!ms || ms < 0) return "unknown";
const minutes = Math.round(ms / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
};
export async function statusCommand(
opts: { limit: string; lookback: string; json?: boolean },
deps: CliDeps,
opts: { json?: boolean },
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 cfg = loadConfig();
const linked = await webAuthExists();
const authAgeMs = getWebAuthAgeMs();
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
const store = loadSessionStore(storePath);
const sessions = Object.entries(store)
.filter(([key]) => key !== "global" && key !== "unknown")
.map(([key, entry]) => ({ key, updatedAt: entry?.updatedAt ?? 0 }))
.sort((a, b) => b.updatedAt - a.updatedAt);
const recent = sessions.slice(0, 5).map((s) => ({
key: s.key,
updatedAt: s.updatedAt || null,
age: s.updatedAt ? Date.now() - s.updatedAt : null,
}));
const summary = {
web: {
linked,
authAgeMs,
},
heartbeatSeconds,
sessions: {
path: storePath,
count: sessions.length,
recent,
},
} as const;
if (opts.json) {
runtime.log(JSON.stringify(summary, null, 2));
return;
}
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
if (opts.json) {
runtime.log(JSON.stringify(messages, null, 2));
return;
runtime.log(
`Web session: ${linked ? "linked" : "not linked"}${linked ? ` (last refreshed ${formatAge(authAgeMs)})` : ""}`,
);
if (linked) {
logWebSelfId(runtime, true);
}
if (messages.length === 0) {
runtime.log("No messages found in the requested window.");
return;
}
for (const m of messages) {
runtime.log(formatMessageLine(m));
runtime.log(info(`Heartbeat: ${heartbeatSeconds}s`));
runtime.log(info(`Session store: ${storePath}`));
runtime.log(info(`Active sessions: ${sessions.length}`));
if (recent.length > 0) {
runtime.log("Recent sessions:");
for (const r of recent) {
runtime.log(
`- ${r.key} (${r.updatedAt ? formatAge(Date.now() - r.updatedAt) : "no activity"})`,
);
}
} else {
runtime.log("No session activity yet.");
}
}

View File

@@ -1,76 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js";
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");
}),
};
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),
});
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("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");
});
});

View File

@@ -1,68 +0,0 @@
import type { CliDeps } from "../cli/deps.js";
import { waitForever as defaultWaitForever } from "../cli/wait.js";
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,
) {
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}`);
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);
runtime.log(
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
);
return { server, publicUrl, senderSid, waiter };
}

View File

@@ -1,62 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js";
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");
}),
};
const deps: CliDeps = {
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("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 });
});
});

View File

@@ -1,63 +0,0 @@
import type { CliDeps } from "../cli/deps.js";
import { retryAsync } from "../infra/retry.js";
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,
) {
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";
// 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;
}