From deded848eec228c98982090e1b5efda31c0a54e0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Nov 2025 17:49:34 +0100 Subject: [PATCH] Heartbeat: add relay helper and fix CLI tests --- CHANGELOG.md | 2 +- README.md | 3 +- docs/heartbeat.md | 5 ++- src/cli/program.test.ts | 28 +++++++++++++ src/cli/program.ts | 81 +++++++++++++++++++++++++++++++++++--- src/web/auto-reply.test.ts | 4 +- 6 files changed, 113 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2025e714e..28f027ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Changes - Web relay now supports configurable command heartbeats (`inbound.reply.heartbeatMinutes`, default 30m) that ping Claude with a `HEARTBEAT_OK` sentinel; outbound messages are skipped when the token is returned, and normal/verbose logs record each heartbeat tick. - New `warelay heartbeat` CLI triggers a one-off heartbeat (web provider, auto-detects logged-in session; optional `--to` override). Relay gains `--heartbeat-now` to fire an immediate heartbeat on startup. -- Added `warelay relay:tmux:heartbeat` helper to start relay in tmux and emit a startup heartbeat automatically. +- Added `warelay relay:heartbeat` (no tmux) and `warelay relay:tmux:heartbeat` helpers to start relay with an immediate startup heartbeat. - Heartbeat session handling now supports `inbound.reply.session.heartbeatIdleMinutes` and does not refresh `updatedAt` on skipped heartbeats, so sessions still expire on idle. ## 1.1.0 — 2025-11-26 diff --git a/README.md b/README.md index dcc581694..e78852c75 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on | `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` `--verbose` | | `warelay heartbeat` | Trigger one heartbeat poll (web) | `--provider ` `--to ` `--verbose` | +| `warelay relay:heartbeat` | Run relay with an immediate heartbeat (no tmux) | `--provider ` `--verbose` | | `warelay relay:tmux:heartbeat` | Start relay in tmux and fire a heartbeat on start (web) | _no flags_ | | `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port ` `--path ` `--reply ` `--verbose` `--yes` `--dry-run` | | `warelay login` | Link personal WhatsApp Web via QR | `--verbose` | @@ -124,7 +125,7 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a - When `heartbeatMinutes` is set (default 30 for `mode: "command"`), the relay periodically runs your command/Claude session with a heartbeat prompt. - If Claude replies exactly `HEARTBEAT_OK`, the message is suppressed; otherwise the reply (or media) is forwarded. Suppressions are still logged so you know the heartbeat ran. - Override session freshness for heartbeats with `session.heartbeatIdleMinutes` (defaults to `session.idleMinutes`). Heartbeat skips do **not** bump `updatedAt`, so sessions still expire normally. -- Trigger one manually with `warelay heartbeat` (web provider only, `--verbose` prints session info). Use `--heartbeat-now` to fire once at relay start. +- Trigger one manually with `warelay heartbeat` (web provider only, `--verbose` prints session info). Use `warelay relay:heartbeat` for a full relay run with an immediate heartbeat, or `--heartbeat-now` on `relay`/`relay:tmux:heartbeat`. ### Logging (optional) - File logs are written to `/tmp/warelay/warelay.log` by default. Levels: `silent | fatal | error | warn | info | debug | trace` (CLI `--verbose` forces `debug`). Web-provider inbound/outbound entries include message bodies and auto-reply text for easier auditing. diff --git a/docs/heartbeat.md b/docs/heartbeat.md index 506b4f321..0ba67715e 100644 --- a/docs/heartbeat.md +++ b/docs/heartbeat.md @@ -35,4 +35,7 @@ Goal: add a simple heartbeat poll for command-based auto-replies (Claude-driven) ## Documentation - Add a short README snippet under configuration showing `heartbeatMinutes` and the sentinel rule. -- Expose a CLI trigger: `warelay heartbeat` (web provider, defaults to first `allowFrom`; optional `--to` override). Relay supports `--heartbeat-now` to fire once at startup. +- Expose CLI triggers: + - `warelay heartbeat` (web provider, defaults to first `allowFrom`; optional `--to` override) + - `warelay relay:heartbeat` to run the relay loop with an immediate heartbeat (no tmux) + - Relay supports `--heartbeat-now` to fire once at startup (including `relay:tmux:heartbeat`). diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts index adb9e87eb..beda2661d 100644 --- a/src/cli/program.test.ts +++ b/src/cli/program.test.ts @@ -80,6 +80,10 @@ describe("cli program", () => { }); it("runs relay tmux attach command", async () => { + const originalIsTTY = process.stdout.isTTY; + (process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY = + true; + const program = buildProgram(); await program.parseAsync(["relay:tmux:attach"], { from: "user" }); expect(spawnRelayTmux).toHaveBeenCalledWith( @@ -87,5 +91,29 @@ describe("cli program", () => { true, false, ); + + (process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY = + originalIsTTY; + }); + + it("runs relay heartbeat command", async () => { + pickProvider.mockResolvedValue("web"); + monitorWebProvider.mockResolvedValue(undefined); + const originalExit = runtime.exit; + runtime.exit = vi.fn(); + const program = buildProgram(); + await program.parseAsync(["relay:heartbeat"], { from: "user" }); + expect(logWebSelfId).toHaveBeenCalled(); + expect(monitorWebProvider).toHaveBeenCalledWith( + false, + undefined, + true, + undefined, + runtime, + undefined, + { replyHeartbeatNow: true }, + ); + expect(runtime.exit).not.toHaveBeenCalled(); + runtime.exit = originalExit; }); }); diff --git a/src/cli/program.ts b/src/cli/program.ts index bcd0a0c65..bc614f633 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -177,7 +177,7 @@ Examples: program .command("heartbeat") - .description("Trigger a heartbeat poll once (web provider)") + .description("Trigger a heartbeat poll once (web provider, no tmux)") .option("--provider ", "auto | web", "auto") .option("--to ", "Override target E.164; defaults to allowFrom[0]") .option("--verbose", "Verbose logging", false) @@ -397,6 +397,62 @@ Examples: await monitorTwilio(intervalSeconds, lookbackMinutes); }); + program + .command("relay:heartbeat") + .description( + "Run relay with an immediate heartbeat (no tmux); requires web provider", + ) + .option("--provider ", "auto | web", "auto") + .option("--verbose", "Verbose logging", false) + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + const providerPref = String(opts.provider ?? "auto"); + if (!["auto", "web"].includes(providerPref)) { + defaultRuntime.error("--provider must be auto or web"); + defaultRuntime.exit(1); + return; + } + const provider = await pickProvider(providerPref as "auto" | "web"); + if (provider !== "web") { + defaultRuntime.error( + danger( + "Heartbeat relay is only supported for the web provider. Link with `warelay login --verbose`.", + ), + ); + defaultRuntime.exit(1); + return; + } + + logWebSelfId(defaultRuntime, true); + const cfg = loadConfig(); + const effectiveHeartbeat = resolveHeartbeatSeconds(cfg, undefined); + const effectivePolicy = resolveReconnectPolicy(cfg, undefined); + defaultRuntime.log( + info( + `Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`, + ), + ); + + try { + await monitorWebProvider( + Boolean(opts.verbose), + undefined, + true, + undefined, + defaultRuntime, + undefined, + { replyHeartbeatNow: true }, + ); + } catch (err) { + defaultRuntime.error( + danger( + `Web relay failed: ${String(err)}. Re-link with 'warelay login --provider web'.`, + ), + ); + defaultRuntime.exit(1); + } + }); + program .command("status") .description("Show recent WhatsApp messages (sent and received)") @@ -481,13 +537,16 @@ Examples: ) .action(async () => { try { + const shouldAttach = Boolean(process.stdout.isTTY); const session = await spawnRelayTmux( "pnpm warelay relay --verbose", - true, + shouldAttach, ); defaultRuntime.log( info( - `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`, + shouldAttach + ? `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")` + : `tmux session started: ${session} (pane running "pnpm warelay relay --verbose"); attach manually with "tmux attach -t ${session}"`, ), ); } catch (err) { @@ -505,6 +564,15 @@ Examples: ) .action(async () => { try { + if (!process.stdout.isTTY) { + defaultRuntime.error( + danger( + "Cannot attach: stdout is not a TTY. Run this in a terminal or use 'tmux attach -t warelay-relay' manually.", + ), + ); + defaultRuntime.exit(1); + return; + } await spawnRelayTmux("pnpm warelay relay --verbose", true, false); defaultRuntime.log(info("Attached to warelay-relay session.")); } catch (err) { @@ -522,13 +590,16 @@ Examples: ) .action(async () => { try { + const shouldAttach = Boolean(process.stdout.isTTY); const session = await spawnRelayTmux( "pnpm warelay relay --verbose --heartbeat-now", - true, + shouldAttach, ); defaultRuntime.log( info( - `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now")`, + shouldAttach + ? `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now")` + : `tmux session started: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now"); attach manually with "tmux attach -t ${session}"`, ), ); } catch (err) { diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 896c4e70b..2be608655 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -6,6 +6,7 @@ import sharp from "sharp"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { WarelayConfig } from "../config/config.js"; +import { resolveStorePath } from "../config/sessions.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; import { HEARTBEAT_TOKEN, @@ -20,7 +21,6 @@ import { resetLoadConfigMock, setLoadConfigMock, } from "./test-helpers.js"; -import { resolveStorePath } from "../config/sessions.js"; describe("heartbeat helpers", () => { it("strips heartbeat token and skips when only token", () => { @@ -137,7 +137,7 @@ describe("runWebHeartbeatOnce", () => { sender, replyResolver: resolver, }); - expect(sender).toHaveBeenCalledWith("+1333", "ALERT", { verbose: false }); + expect(sender).toHaveBeenCalledWith("+1999", "ALERT", { verbose: false }); }); it("does not refresh updatedAt when heartbeat is skipped", async () => {