diff --git a/README.md b/README.md index aea8747bb..25d5d432d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on **A) Personal WhatsApp Web (preferred: no Twilio creds, fastest setup)** 1. Link your account: `warelay login` (scan the QR). 2. Send a message: `warelay send --to +12345550000 --message "Hi from warelay"` (add `--provider web` if you want to force the web session). -3. Stay online & auto-reply: `warelay relay --verbose` (defaults to Web when logged in, falls back to Twilio otherwise). +3. Stay online & auto-reply: `warelay relay --verbose` (uses Web when you're logged in; if you're not linked, start it with `--provider twilio`). When a Web session drops, the relay exits instead of silently falling back so you notice and re-login. **B) Twilio WhatsApp number (for delivery status + webhooks)** 1. Copy `.env.example` → `.env`; set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` **or** `TWILIO_API_KEY`/`TWILIO_API_SECRET`, and `TWILIO_WHATSAPP_FROM=whatsapp:+19995550123` (optional `TWILIO_SENDER_SID`). @@ -79,8 +79,8 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on ## Providers - **Twilio (default):** needs `.env` creds + WhatsApp-enabled number; supports delivery tracking, polling, webhooks, and auto-reply typing indicators. -- **Web (`--provider web`):** uses your personal WhatsApp via Baileys; supports send/receive + auto-reply, but no delivery-status wait; cache lives in `~/.warelay/credentials/` (rerun `login` if logged out). -- **Auto-select (`relay` only):** `--provider auto` uses Web when logged in, otherwise Twilio polling. +- **Web (`--provider web`):** uses your personal WhatsApp via Baileys; supports send/receive + auto-reply, but no delivery-status wait; cache lives in `~/.warelay/credentials/` (rerun `login` if logged out). If the Web socket closes, the relay exits instead of pivoting to Twilio. +- **Auto-select (`relay` only):** `--provider auto` picks Web when a cache exists at start, otherwise Twilio polling. It will not swap from Web to Twilio mid-run if the Web session drops. Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business account) for automation instead of your primary personal account to avoid unexpected logouts or rate limits. @@ -171,6 +171,11 @@ Templating tokens: `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{Mess - Web provider dropped: rerun `pnpm warelay login`; credentials live in `~/.warelay/credentials/`. - Tailscale Funnel errors: update tailscale/tailscaled; check admin console that Funnel is enabled for this device. +### Maintainer notes (web provider internals) +- Web logic lives under `src/web/`: `session.ts` (auth/cache + provider pick), `login.ts` (QR login/logout), `outbound.ts`/`inbound.ts` (send/receive plumbing), `auto-reply.ts` (relay loop + reconnect/backoff), `media.ts` (download/resize helpers), and `reconnect.ts` (shared retry math). `test-helpers.ts` provides fixtures. +- The public surface remains the `src/provider-web.ts` barrel so existing imports keep working. +- Reconnects are capped and logged; no Twilio fallback occurs after a Web disconnect—restart the relay after re-linking. + ## FAQ & Safety - Twilio errors: **63016 “permission to send an SMS has not been enabled”** → ensure your number is WhatsApp-enabled; **63007 template not approved** → send a free-form session message within 24h or use an approved template; **63112 policy violation** → adjust content, shorten to <1600 chars, avoid links that trigger spam filters. Re-run `pnpm warelay status` to see the exact Twilio response body. - Does this store my messages? warelay only writes `~/.warelay/warelay.json` (config), `~/.warelay/credentials/` (WhatsApp Web auth), and `~/.warelay/sessions.json` (session IDs + timestamps). It does **not** persist message bodies beyond the session store. Logs stream to stdout/stderr and also `/tmp/warelay/warelay.log` (configurable via `logging.file`). diff --git a/docs/refactor/web-provider-split.md b/docs/refactor/web-provider-split.md deleted file mode 100644 index cd01be284..000000000 --- a/docs/refactor/web-provider-split.md +++ /dev/null @@ -1,26 +0,0 @@ -# Web Provider Refactor (Nov 26, 2025) - -Context: `src/provider-web.ts` was a 900+ line ball of mud mixing session management, outbound sends, inbound handling, auto-replies, and media helpers. We split it into focused modules under `src/web/` and adjusted tests/CLI behavior. - -## What changed -- New modules: `session.ts`, `login.ts`, `outbound.ts`, `inbound.ts`, `auto-reply.ts`, `media.ts`; barrel remains `src/provider-web.ts`. -- CLI adds `warelay logout` to clear `~/.warelay/credentials`; tested in `src/web/logout.test.ts`. -- Relay now **exits instead of falling back to Twilio** when the web provider fails (even in `--provider auto`), so outages are visible. -- Tests split accordingly; all suites green. -- Structured logging + heartbeats: web relay now emits structured logs with `runId`/`connectionId` plus periodic heartbeats (default every 60s) that include auth age and message counts. -- Bounded reconnects: web relay uses capped exponential backoff (default 2s→30s, max 12 attempts). CLI knobs `--web-retries`, `--web-retry-initial`, `--web-retry-max`, `--web-heartbeat` and config `web.reconnect`/`web.heartbeatSeconds` tune the behavior. -- Backoff reset after healthy uptime; logged-out state still exits immediately. -- Extracted reconnect/heartbeat helpers to `src/web/reconnect.ts` with unit tests. -- Added troubleshooting guide at `docs/refactor/web-relay-troubleshooting.md` (common errors, knobs, logs). - -## How to use -- Link: `warelay login --provider web` -- Logout: `warelay logout` (deletes `~/.warelay/credentials`) -- Run relay web-only: `warelay relay --provider web --verbose` - -## Follow-ups worth doing -- Document the new module boundaries in README/docs; add a one-liner explaining the no-fallback behavior. -- Add bounded backoff/jitter in `monitorWebProvider` reconnect loop with clearer exit codes. ✅ -- Tighten config validation (`mediaMaxMb`, etc.) on load. ✅ (schema now includes `web.*` knobs) -- Emit structured logs for reconnect/close reasons to help ops triage (status, isLoggedOut). ✅ -- Add quick troubleshooting snippets (how to read logs, restart relay, rotate creds). diff --git a/src/provider-web.barrel.test.ts b/src/provider-web.barrel.test.ts new file mode 100644 index 000000000..4eed6550a --- /dev/null +++ b/src/provider-web.barrel.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import * as mod from "./provider-web.js"; + +describe("provider-web barrel", () => { + it("exports the expected web helpers", () => { + expect(mod.createWaSocket).toBeTypeOf("function"); + expect(mod.loginWeb).toBeTypeOf("function"); + expect(mod.monitorWebProvider).toBeTypeOf("function"); + expect(mod.sendMessageWeb).toBeTypeOf("function"); + expect(mod.monitorWebInbox).toBeTypeOf("function"); + expect(mod.pickProvider).toBeTypeOf("function"); + expect(mod.WA_WEB_AUTH_DIR).toBeTruthy(); + }); +}); diff --git a/src/web/login.coverage.test.ts b/src/web/login.coverage.test.ts new file mode 100644 index 000000000..2825f18b5 --- /dev/null +++ b/src/web/login.coverage.test.ts @@ -0,0 +1,74 @@ +import fs from "node:fs/promises"; + +import { DisconnectReason } from "@whiskeysockets/baileys"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.useFakeTimers(); + +const rmMock = vi.spyOn(fs, "rm"); + +vi.mock("./session.js", () => { + const sockA = { ws: { close: vi.fn() } }; + const sockB = { ws: { close: vi.fn() } }; + const createWaSocket = vi.fn(async () => + createWaSocket.mock.calls.length === 0 ? sockA : sockB, + ); + const waitForWaConnection = vi.fn(); + const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`); + return { + createWaSocket, + waitForWaConnection, + formatError, + WA_WEB_AUTH_DIR: "/tmp/wa-creds", + }; +}); + +const { createWaSocket, waitForWaConnection, formatError } = await import( + "./session.js" +); +const { loginWeb } = await import("./login.js"); + +describe("loginWeb coverage", () => { + beforeEach(() => { + vi.clearAllMocks(); + rmMock.mockClear(); + }); + + it("restarts once when WhatsApp requests code 515", async () => { + waitForWaConnection + .mockRejectedValueOnce({ output: { statusCode: 515 } }) + .mockResolvedValueOnce(undefined); + + const runtime = { log: vi.fn(), error: vi.fn() } as never; + await loginWeb(false, waitForWaConnection as never, runtime); + + expect(createWaSocket).toHaveBeenCalledTimes(2); + const firstSock = await createWaSocket.mock.results[0].value; + expect(firstSock.ws.close).toHaveBeenCalled(); + vi.runAllTimers(); + const secondSock = await createWaSocket.mock.results[1].value; + expect(secondSock.ws.close).toHaveBeenCalled(); + }); + + it("clears creds and throws when logged out", async () => { + waitForWaConnection.mockRejectedValueOnce({ + output: { statusCode: DisconnectReason.loggedOut }, + }); + + await expect(loginWeb(false, waitForWaConnection as never)).rejects.toThrow( + /cache cleared/i, + ); + expect(rmMock).toHaveBeenCalledWith("/tmp/wa-creds", { + recursive: true, + force: true, + }); + }); + + it("formats and rethrows generic errors", async () => { + waitForWaConnection.mockRejectedValueOnce(new Error("boom")); + await expect(loginWeb(false, waitForWaConnection as never)).rejects.toThrow( + "formatted:Error: boom", + ); + expect(formatError).toHaveBeenCalled(); + }); +});