diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e462532..f376f83c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.1.0 — 2025-11-25 ### CLI & Providers -- Bundles a single `warelay` CLI with commands for `send`, `relay`, `status`, `webhook`, `up`, `login`, and tmux helpers `relay:tmux` / `relay:tmux:attach` (see `src/cli/program.ts`). +- Bundles a single `warelay` CLI with commands for `send`, `relay`, `status`, `webhook`, `up`, `login`, and tmux helpers `relay:tmux` / `relay:tmux:attach` (see `src/cli/program.ts`); `webhook` now accepts `--ingress tailscale|none` with `up` as an alias for the Tailscale path. - Supports two messaging backends: **Twilio** (default) and **personal WhatsApp Web**; `relay --provider auto` selects Web when a cached login exists, otherwise falls back to Twilio polling (`provider-web.ts`, `cli/program.ts`). - `send` can target either provider, optionally wait for delivery status (Twilio only), output JSON, dry-run payloads, and attach media (`commands/send.ts`). - `status` merges inbound + outbound Twilio traffic with formatted lines or JSON output (`commands/status.ts`, `twilio/messages.ts`). diff --git a/README.md b/README.md index 4c0998e70..f3055e7f7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on 2. Send a message: `warelay send --to +12345550000 --message "Hi from warelay"`. 3. Receive replies: - Polling (no ingress): `warelay relay --provider twilio --interval 5 --lookback 10` - - Webhook + public URL via Tailscale Funnel: `warelay up --port 42873 --path /webhook/whatsapp --verbose` + - Webhook + public URL via Tailscale Funnel: `warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose` (alias: `warelay up`) > Already developing locally? You can still run `pnpm install` and `pnpm warelay ...` from the repo, but end users only need the npm package. @@ -23,7 +23,7 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on - **Two providers:** Twilio (default) for reliable delivery + status; Web provider for quick personal sends/receives via QR login. - **Auto-replies:** Static templates or external commands (Claude-aware), with per-sender or global sessions and `/new` resets. - Claude setup guide: see `docs/claude-config.md` for the exact Claude CLI configuration we support. -- **Webhook in one go:** `warelay up` enables Tailscale Funnel, runs the webhook server, and updates the Twilio sender callback URL. +- **Webhook in one go:** `warelay webhook --ingress tailscale` enables Tailscale Funnel, runs the webhook server, and updates the Twilio sender callback URL (alias: `warelay up`). - **Polling fallback:** `relay` polls Twilio when webhooks aren’t available; works headless. - **Status + delivery tracking:** `status` shows recent inbound/outbound; `send` can wait for final Twilio status. @@ -33,8 +33,8 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on | `warelay send` | Send a WhatsApp message (Twilio or Web) | `--to ` `--message ` `--wait ` `--poll ` `--provider twilio\|web` `--json` `--dry-run` | | `warelay relay` | Auto-reply loop (poll Twilio or listen on Web) | `--provider ` `--interval ` `--lookback ` `--verbose` | | `warelay status` | Show recent sent/received messages | `--limit ` `--lookback ` `--json` | -| `warelay webhook` | Run local inbound webhook server | `--port ` `--path ` `--reply ` `--verbose` `--yes` `--dry-run` | -| `warelay up` | Turn on webhook + Tailscale Funnel + Twilio callback | `--port ` `--path ` `--verbose` `--yes` `--dry-run` | +| `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port ` `--path ` `--reply ` `--verbose` `--yes` `--dry-run` | +| `warelay up` | Alias: `warelay webhook --ingress tailscale` | `--port ` `--path ` `--verbose` `--yes` `--dry-run` | | `warelay login` | Link personal WhatsApp Web via QR | `--verbose` | ### Sending images @@ -58,7 +58,7 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a | `TWILIO_AUTH_TOKEN` | Yes* | Auth token (or use API key/secret) | | `TWILIO_API_KEY` | Yes* | API key if not using auth token | | `TWILIO_API_SECRET` | Yes* | API secret paired with `TWILIO_API_KEY` | -| `TWILIO_WHATSAPP_FROM` | Yes (Twilio provider) | WhatsApp-enabled sender, e.g. `whatsapp:+15551234567` | +| `TWILIO_WHATSAPP_FROM` | Yes (Twilio provider) | WhatsApp-enabled sender, e.g. `whatsapp:+19995550123` | | `TWILIO_SENDER_SID` | Optional | Overrides auto-discovery of the sender SID | (*Provide either auth token OR api key/secret.) @@ -110,8 +110,8 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a Templating tokens: `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{MessageSid}}`, plus `{{SessionId}}` and `{{IsNewSession}}` when sessions are enabled. ## Webhook & Tailscale Flow -- `warelay webhook` starts the local Express server on your chosen port/path; add `--reply "Got it"` for a static reply when no config file is present. -- `warelay up` adds Funnel: checks `tailscale`, enables `tailscale funnel `, prints the public URL (`https://`), starts the webhook, discovers the WhatsApp sender SID, and updates Twilio callbacks to the Funnel URL. +- `warelay webhook --ingress none` starts the local Express server on your chosen port/path; add `--reply "Got it"` for a static reply when no config file is present. +- `warelay webhook --ingress tailscale` (alias: `warelay up`) enables Tailscale Funnel, prints the public URL (`https://`), starts the webhook, discovers the WhatsApp sender SID, and updates Twilio callbacks to the Funnel URL. - If Funnel is not allowed on your tailnet, the CLI exits with guidance; you can still use `relay --provider twilio` to poll without webhooks. ## Troubleshooting Tips diff --git a/src/cli/program.ts b/src/cli/program.ts index b3d96466b..3d029a6e6 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -173,11 +173,16 @@ Examples: program .command("webhook") .description( - "Run a local webhook server for inbound WhatsApp (works with Tailscale/port forward)", + "Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.", ) .option("-p, --port ", "Port to listen on", "42873") .option("-r, --reply ", "Optional auto-reply text") .option("--path ", "Webhook path", "/webhook/whatsapp") + .option( + "--ingress ", + "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) @@ -185,13 +190,10 @@ Examples: "after", ` Examples: - warelay webhook # listen on 42873 + 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 - -With Tailscale: - tailscale serve tcp 42873 127.0.0.1:42873 - (then set Twilio webhook URL to your tailnet IP:42873/webhook/whatsapp)`, + warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file`, ) // istanbul ignore next .action(async (opts) => { @@ -222,7 +224,7 @@ With Tailscale: program .command("up") .description( - "Bring up webhook + Tailscale Funnel + Twilio callback (default webhook mode)", + "Alias: webhook --ingress tailscale (Funnel + Twilio callback)", ) .option("-p, --port ", "Port to listen on", "42873") .option("--path ", "Webhook path", "/webhook/whatsapp") diff --git a/src/commands/send.test.ts b/src/commands/send.test.ts new file mode 100644 index 000000000..f4ee08f2a --- /dev/null +++ b/src/commands/send.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { CliDeps } from "../cli/deps.js"; +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"); + }), +}; + +const baseDeps = { + 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"); + + 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("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("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\""), + ); + }); +}); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts new file mode 100644 index 000000000..088d88d03 --- /dev/null +++ b/src/commands/status.test.ts @@ -0,0 +1,50 @@ +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: any) => `LINE:${m.sid}`, +})); + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), +}; + +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 any).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 any).mockResolvedValue([{ sid: "123" }]); + await statusCommand({ limit: "1", lookback: "5" }, deps, runtime); + expect(runtime.log).toHaveBeenCalledWith("LINE:123"); + }); +}); diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts index 1df0f0262..9b8c807c2 100644 --- a/src/commands/webhook.ts +++ b/src/commands/webhook.ts @@ -1,6 +1,7 @@ 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: { @@ -9,6 +10,8 @@ export async function webhookCommand( reply?: string; verbose?: boolean; yes?: boolean; + ingress?: "tailscale" | "none"; + dryRun?: boolean; }, deps: CliDeps, runtime: RuntimeEnv, @@ -17,8 +20,28 @@ export async function webhookCommand( 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") { + if (opts.reply === "dry-run" || opts.dryRun) { runtime.log( `[dry-run] would start webhook on port ${port} path ${opts.path}`, ); diff --git a/src/env.test.ts b/src/env.test.ts new file mode 100644 index 000000000..ad4f60f09 --- /dev/null +++ b/src/env.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ensureTwilioEnv, readEnv } from "./env.js"; +import type { RuntimeEnv } from "./runtime.js"; + +const baseEnv = { + 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"); + }), + }; + + function setEnv(vars: Record) { + Object.assign(process.env, vars); + } + + 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"); + expect("authToken" in cfg.auth && cfg.auth.authToken).toBe("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); + expect("apiKey" in cfg.auth && cfg.auth.apiKey).toBe("key"); + expect("apiSecret" in cfg.auth && cfg.auth.apiSecret).toBe("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("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"); + }); +}); diff --git a/src/providers/web/index.ts b/src/providers/web/index.ts index 873ae6fdb..44aaba6d2 100644 --- a/src/providers/web/index.ts +++ b/src/providers/web/index.ts @@ -1,12 +1,12 @@ export { createWaSocket, + waitForWaConnection, + sendMessageWeb, loginWeb, - logWebSelfId, monitorWebInbox, monitorWebProvider, - pickProvider, - sendMessageWeb, - WA_WEB_AUTH_DIR, - waitForWaConnection, webAuthExists, + logWebSelfId, + pickProvider, + WA_WEB_AUTH_DIR, } from "../../provider-web.js";