From c1de9bc4fe7986fad418dd636221280d07038e28 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Nov 2025 17:04:43 +0100 Subject: [PATCH 1/9] feat: add heartbeat cli and relay trigger --- src/auto-reply/claude.ts | 2 +- src/cli/program.ts | 90 +++++++ src/config/config.ts | 2 + src/provider-web.ts | 3 + src/web/auto-reply.test.ts | 98 +++++++- src/web/auto-reply.ts | 489 +++++++++++++++++++++++++++++-------- 6 files changed, 576 insertions(+), 108 deletions(-) diff --git a/src/auto-reply/claude.ts b/src/auto-reply/claude.ts index 11b28bb84..b1a6518f4 100644 --- a/src/auto-reply/claude.ts +++ b/src/auto-reply/claude.ts @@ -4,7 +4,7 @@ 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. If a prompt is a heartbeat poll and nothing needs attention, reply with exactly HEARTBEAT_OK and nothing else; for any alert, do not include HEARTBEAT_OK."; function extractClaudeText(payload: unknown): string | undefined { // Best-effort walker to find the primary text field in Claude JSON outputs. diff --git a/src/cli/program.ts b/src/cli/program.ts index 068c1a2b0..bcd0a0c65 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -11,6 +11,7 @@ import { logoutWeb, monitorWebProvider, pickProvider, + runWebHeartbeatOnce, type WebMonitorTuning, } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; @@ -174,6 +175,62 @@ Examples: } }); + program + .command("heartbeat") + .description("Trigger a heartbeat poll once (web provider)") + .option("--provider ", "auto | web", "auto") + .option("--to ", "Override target E.164; defaults to allowFrom[0]") + .option("--verbose", "Verbose logging", false) + .addHelpText( + "after", + ` +Examples: + warelay heartbeat # uses web session + first allowFrom contact + warelay heartbeat --verbose # prints detailed heartbeat logs + warelay heartbeat --to +1555123 # override destination`, + ) + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + const cfg = loadConfig(); + const to = + opts.to ?? + (Array.isArray(cfg.inbound?.allowFrom) && + cfg.inbound?.allowFrom?.length > 0 + ? cfg.inbound.allowFrom[0] + : null); + if (!to) { + defaultRuntime.error( + danger( + "No destination found. Set inbound.allowFrom in ~/.warelay/warelay.json or pass --to .", + ), + ); + defaultRuntime.exit(1); + } + const providerPref = String(opts.provider ?? "auto"); + if (!["auto", "web"].includes(providerPref)) { + defaultRuntime.error("--provider must be auto or web"); + defaultRuntime.exit(1); + } + const provider = await pickProvider(providerPref as "auto" | "web"); + if (provider !== "web") { + defaultRuntime.error( + danger( + "Heartbeat is only supported for the web provider. Link with `warelay login --verbose`.", + ), + ); + defaultRuntime.exit(1); + } + try { + await runWebHeartbeatOnce({ + to, + verbose: Boolean(opts.verbose), + runtime: defaultRuntime, + }); + } catch { + defaultRuntime.exit(1); + } + }); + program .command("relay") .description("Auto-reply to inbound messages (auto-selects web or twilio)") @@ -197,6 +254,11 @@ Examples: "Initial reconnect backoff for web relay (ms)", ) .option("--web-retry-max ", "Max reconnect backoff for web relay (ms)") + .option( + "--heartbeat-now", + "Run a heartbeat immediately when relay starts (web provider)", + false, + ) .option("--verbose", "Verbose logging", false) .addHelpText( "after", @@ -234,6 +296,7 @@ Examples: opts.webRetryMax !== undefined ? Number.parseInt(String(opts.webRetryMax), 10) : undefined; + const heartbeatNow = Boolean(opts.heartbeatNow); if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) { defaultRuntime.error("Interval must be a positive integer"); defaultRuntime.exit(1); @@ -281,6 +344,7 @@ Examples: const webTuning: WebMonitorTuning = {}; if (webHeartbeat !== undefined) webTuning.heartbeatSeconds = webHeartbeat; + if (heartbeatNow) webTuning.replyHeartbeatNow = true; const reconnect: WebMonitorTuning["reconnect"] = {}; if (webRetries !== undefined) reconnect.maxAttempts = webRetries; if (webRetryInitial !== undefined) reconnect.initialMs = webRetryInitial; @@ -451,5 +515,31 @@ Examples: } }); + program + .command("relay:tmux:heartbeat") + .description( + "Run relay --verbose with an immediate heartbeat inside tmux (session warelay-relay), then attach", + ) + .action(async () => { + try { + const session = await spawnRelayTmux( + "pnpm warelay relay --verbose --heartbeat-now", + true, + ); + defaultRuntime.log( + info( + `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now")`, + ), + ); + } catch (err) { + defaultRuntime.error( + danger( + `Failed to start relay tmux session with heartbeat: ${String(err)}`, + ), + ); + defaultRuntime.exit(1); + } + }); + return program; } diff --git a/src/config/config.ts b/src/config/config.ts index 7d8d167cd..595c66e98 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -20,6 +20,7 @@ export type SessionConfig = { sendSystemOnce?: boolean; sessionIntro?: string; typingIntervalSeconds?: number; + heartbeatMinutes?: number; }; export type LoggingConfig = { @@ -97,6 +98,7 @@ const ReplySchema = z typingIntervalSeconds: z.number().int().positive().optional(), }) .optional(), + heartbeatMinutes: z.number().int().nonnegative().optional(), claudeOutputFormat: z .union([ z.literal("text"), diff --git a/src/provider-web.ts b/src/provider-web.ts index 6015a5c63..320f2171d 100644 --- a/src/provider-web.ts +++ b/src/provider-web.ts @@ -2,7 +2,10 @@ // module keeps responsibilities small and testable without changing the public API. export { DEFAULT_WEB_MEDIA_BYTES, + HEARTBEAT_PROMPT, + HEARTBEAT_TOKEN, monitorWebProvider, + runWebHeartbeatOnce, type WebMonitorTuning, } from "./web/auto-reply.js"; export { diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 522e871f7..6646dfdd1 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -3,14 +3,110 @@ import fs from "node:fs/promises"; import sharp from "sharp"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { WarelayConfig } from "../config/config.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; -import { monitorWebProvider } from "./auto-reply.js"; +import { + HEARTBEAT_TOKEN, + monitorWebProvider, + resolveReplyHeartbeatMinutes, + runWebHeartbeatOnce, + stripHeartbeatToken, +} from "./auto-reply.js"; +import type { sendMessageWeb } from "./outbound.js"; import { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock, } from "./test-helpers.js"; +describe("heartbeat helpers", () => { + it("strips heartbeat token and skips when only token", () => { + expect(stripHeartbeatToken(undefined)).toEqual({ + shouldSkip: true, + text: "", + }); + expect(stripHeartbeatToken(" ")).toEqual({ + shouldSkip: true, + text: "", + }); + expect(stripHeartbeatToken(HEARTBEAT_TOKEN)).toEqual({ + shouldSkip: true, + text: "", + }); + }); + + it("keeps content and removes token when mixed", () => { + expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({ + shouldSkip: false, + text: "ALERT", + }); + expect(stripHeartbeatToken(`hello`)).toEqual({ + shouldSkip: false, + text: "hello", + }); + }); + + it("resolves heartbeat minutes with default and overrides", () => { + const cfgBase: WarelayConfig = { + inbound: { + reply: { mode: "command" as const }, + }, + }; + expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30); + expect( + resolveReplyHeartbeatMinutes({ + inbound: { reply: { mode: "command", heartbeatMinutes: 5 } }, + }), + ).toBe(5); + expect( + resolveReplyHeartbeatMinutes({ + inbound: { reply: { mode: "command", heartbeatMinutes: 0 } }, + }), + ).toBeNull(); + expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7); + expect( + resolveReplyHeartbeatMinutes({ + inbound: { reply: { mode: "text" } }, + }), + ).toBeNull(); + }); +}); + +describe("runWebHeartbeatOnce", () => { + it("skips when heartbeat token returned", async () => { + const sender: typeof sendMessageWeb = vi.fn(); + const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN })); + setLoadConfigMock({ + inbound: { allowFrom: ["+1555"], reply: { mode: "command" } }, + }); + await runWebHeartbeatOnce({ + to: "+1555", + verbose: false, + sender, + replyResolver: resolver, + }); + expect(resolver).toHaveBeenCalled(); + expect(sender).not.toHaveBeenCalled(); + }); + + it("sends when alert text present", async () => { + const sender: typeof sendMessageWeb = vi + .fn() + .mockResolvedValue({ messageId: "m1", toJid: "jid" }); + const resolver = vi.fn(async () => ({ text: "ALERT" })); + setLoadConfigMock({ + inbound: { allowFrom: ["+1555"], reply: { mode: "command" } }, + }); + await runWebHeartbeatOnce({ + to: "+1555", + verbose: false, + sender, + replyResolver: resolver, + }); + expect(sender).toHaveBeenCalledWith("+1555", "ALERT", { verbose: false }); + }); +}); + describe("web auto-reply", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 2b7490fbd..c60255add 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1,4 +1,5 @@ import { getReplyFromConfig } from "../auto-reply/reply.js"; +import type { ReplyPayload } from "../auto-reply/types.js"; import { waitForever } from "../cli/wait.js"; import { loadConfig } from "../config/config.js"; import { danger, isVerbose, logVerbose, success } from "../globals.js"; @@ -7,6 +8,7 @@ import { getChildLogger } from "../logging.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { monitorWebInbox } from "./inbound.js"; import { loadWebMedia } from "./media.js"; +import { sendMessageWeb } from "./outbound.js"; import { computeBackoff, newConnectionId, @@ -18,16 +20,249 @@ import { import { getWebAuthAgeMs } from "./session.js"; const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024; +type WebInboundMsg = Parameters< + typeof monitorWebInbox +>[0]["onMessage"] extends (msg: infer M) => unknown + ? M + : never; export type WebMonitorTuning = { reconnect?: Partial; heartbeatSeconds?: number; + replyHeartbeatMinutes?: number; + replyHeartbeatNow?: boolean; sleep?: (ms: number, signal?: AbortSignal) => Promise; }; const formatDuration = (ms: number) => ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`; +const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30; +export const HEARTBEAT_TOKEN = "HEARTBEAT_OK"; +export const HEARTBEAT_PROMPT = + "HEARTBEAT ping — if nothing important happened, reply exactly HEARTBEAT_OK. Otherwise return a concise alert."; + +export function resolveReplyHeartbeatMinutes( + cfg: ReturnType, + overrideMinutes?: number, +) { + const raw = overrideMinutes ?? cfg.inbound?.reply?.heartbeatMinutes; + if (raw === 0) return null; + if (typeof raw === "number" && raw > 0) return raw; + return cfg.inbound?.reply?.mode === "command" + ? DEFAULT_REPLY_HEARTBEAT_MINUTES + : null; +} + +export function stripHeartbeatToken(raw?: string) { + if (!raw) return { shouldSkip: true, text: "" }; + const trimmed = raw.trim(); + if (!trimmed) return { shouldSkip: true, text: "" }; + if (trimmed === HEARTBEAT_TOKEN) return { shouldSkip: true, text: "" }; + const withoutToken = trimmed.replaceAll(HEARTBEAT_TOKEN, "").trim(); + return { + shouldSkip: withoutToken.length === 0, + text: withoutToken || trimmed, + }; +} + +export async function runWebHeartbeatOnce(opts: { + to: string; + verbose?: boolean; + replyResolver?: typeof getReplyFromConfig; + runtime?: RuntimeEnv; + sender?: typeof sendMessageWeb; +}) { + const { to, verbose = false } = opts; + const _runtime = opts.runtime ?? defaultRuntime; + const replyResolver = opts.replyResolver ?? getReplyFromConfig; + const sender = opts.sender ?? sendMessageWeb; + const runId = newConnectionId(); + const heartbeatLogger = getChildLogger({ + module: "web-heartbeat", + runId, + to, + }); + + const cfg = loadConfig(); + + try { + const replyResult = await replyResolver( + { + Body: HEARTBEAT_PROMPT, + From: to, + To: to, + MessageSid: undefined, + }, + undefined, + cfg, + ); + if ( + !replyResult || + (!replyResult.text && + !replyResult.mediaUrl && + !replyResult.mediaUrls?.length) + ) { + heartbeatLogger.info({ to, reason: "empty-reply" }, "heartbeat skipped"); + if (verbose) console.log(success("heartbeat: ok (empty reply)")); + return; + } + + const hasMedia = + (replyResult.mediaUrl ?? replyResult.mediaUrls?.length ?? 0) > 0; + const stripped = stripHeartbeatToken(replyResult.text); + if (stripped.shouldSkip && !hasMedia) { + heartbeatLogger.info( + { to, reason: "heartbeat-token", rawLength: replyResult.text?.length }, + "heartbeat skipped", + ); + console.log(success("heartbeat: ok (HEARTBEAT_OK)")); + return; + } + + if (hasMedia) { + heartbeatLogger.warn( + { to }, + "heartbeat reply contained media; sending text only", + ); + } + + const finalText = stripped.text || replyResult.text || ""; + const sendResult = await sender(to, finalText, { verbose }); + heartbeatLogger.info( + { to, messageId: sendResult.messageId, chars: finalText.length }, + "heartbeat sent", + ); + console.log(success(`heartbeat: alert sent to ${to}`)); + } catch (err) { + heartbeatLogger.warn({ to, error: String(err) }, "heartbeat failed"); + console.log(danger(`heartbeat: failed - ${String(err)}`)); + throw err; + } +} + +async function deliverWebReply(params: { + replyResult: ReplyPayload; + msg: WebInboundMsg; + maxMediaBytes: number; + replyLogger: ReturnType; + runtime: RuntimeEnv; + connectionId?: string; + skipLog?: boolean; +}) { + const { + replyResult, + msg, + maxMediaBytes, + replyLogger, + runtime, + connectionId, + skipLog, + } = params; + const replyStarted = Date.now(); + const mediaList = replyResult.mediaUrls?.length + ? replyResult.mediaUrls + : replyResult.mediaUrl + ? [replyResult.mediaUrl] + : []; + + if (mediaList.length === 0 && replyResult.text) { + await msg.reply(replyResult.text || ""); + if (!skipLog) { + logInfo( + `✅ Sent web reply to ${msg.from} (${(Date.now() - replyStarted).toFixed(0)}ms)`, + runtime, + ); + } + replyLogger.info( + { + correlationId: msg.id ?? newConnectionId(), + connectionId: connectionId ?? null, + to: msg.from, + from: msg.to, + text: replyResult.text, + mediaUrl: null, + mediaSizeBytes: null, + mediaKind: null, + durationMs: Date.now() - replyStarted, + }, + "auto-reply sent (text)", + ); + return; + } + + const cleanText = replyResult.text ?? undefined; + for (const [index, mediaUrl] of mediaList.entries()) { + try { + const media = await loadWebMedia(mediaUrl, maxMediaBytes); + if (isVerbose()) { + logVerbose( + `Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`, + ); + logVerbose( + `Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`, + ); + } + const caption = index === 0 ? cleanText || undefined : undefined; + if (media.kind === "image") { + await msg.sendMedia({ + image: media.buffer, + caption, + mimetype: media.contentType, + }); + } else if (media.kind === "audio") { + await msg.sendMedia({ + audio: media.buffer, + ptt: true, + mimetype: media.contentType, + caption, + }); + } else if (media.kind === "video") { + await msg.sendMedia({ + video: media.buffer, + caption, + mimetype: media.contentType, + }); + } else { + const fileName = mediaUrl.split("/").pop() ?? "file"; + const mimetype = media.contentType ?? "application/octet-stream"; + await msg.sendMedia({ + document: media.buffer, + fileName, + caption, + mimetype, + }); + } + logInfo( + `✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`, + runtime, + ); + replyLogger.info( + { + correlationId: msg.id ?? newConnectionId(), + connectionId: connectionId ?? null, + to: msg.from, + from: msg.to, + text: index === 0 ? (cleanText ?? null) : null, + mediaUrl, + mediaSizeBytes: media.buffer.length, + mediaKind: media.kind, + durationMs: Date.now() - replyStarted, + }, + "auto-reply sent (media)", + ); + } catch (err) { + console.error( + danger(`Failed sending web media to ${msg.from}: ${String(err)}`), + ); + if (index === 0 && cleanText) { + console.log(`⚠️ Media skipped; sent text-only to ${msg.from}`); + await msg.reply(cleanText || ""); + } + } + } +} + export async function monitorWebProvider( verbose: boolean, listenerFactory: typeof monitorWebInbox | undefined = monitorWebInbox, @@ -51,6 +286,10 @@ export async function monitorWebProvider( cfg, tuning.heartbeatSeconds, ); + const replyHeartbeatMinutes = resolveReplyHeartbeatMinutes( + cfg, + tuning.replyHeartbeatMinutes, + ); const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect); const sleep = tuning.sleep ?? @@ -79,8 +318,10 @@ export async function monitorWebProvider( const connectionId = newConnectionId(); const startedAt = Date.now(); let heartbeat: NodeJS.Timeout | null = null; + let replyHeartbeatTimer: NodeJS.Timeout | null = null; let lastMessageAt: number | null = null; let handledMessages = 0; + let lastInboundMsg: WebInboundMsg | null = null; const listener = await (listenerFactory ?? monitorWebInbox)({ verbose, @@ -106,7 +347,8 @@ export async function monitorWebProvider( console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`); - const replyStarted = Date.now(); + lastInboundMsg = msg; + const replyResult = await (replyResolver ?? getReplyFromConfig)( { Body: msg.body, @@ -133,122 +375,27 @@ export async function monitorWebProvider( return; } try { - const mediaList = replyResult.mediaUrls?.length - ? replyResult.mediaUrls - : replyResult.mediaUrl - ? [replyResult.mediaUrl] - : []; - - if (mediaList.length > 0) { - logVerbose( - `Web auto-reply media detected: ${mediaList.filter(Boolean).join(", ")}`, - ); - for (const [index, mediaUrl] of mediaList.entries()) { - try { - const media = await loadWebMedia(mediaUrl, maxMediaBytes); - if (isVerbose()) { - logVerbose( - `Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`, - ); - logVerbose( - `Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`, - ); - } - const caption = - index === 0 ? replyResult.text || undefined : undefined; - if (media.kind === "image") { - await msg.sendMedia({ - image: media.buffer, - caption, - mimetype: media.contentType, - }); - } else if (media.kind === "audio") { - await msg.sendMedia({ - audio: media.buffer, - ptt: true, - mimetype: media.contentType, - caption, - }); - } else if (media.kind === "video") { - await msg.sendMedia({ - video: media.buffer, - caption, - mimetype: media.contentType, - }); - } else { - const fileName = mediaUrl.split("/").pop() ?? "file"; - const mimetype = - media.contentType ?? "application/octet-stream"; - await msg.sendMedia({ - document: media.buffer, - fileName, - caption, - mimetype, - }); - } - logInfo( - `✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`, - runtime, - ); - replyLogger.info( - { - connectionId, - correlationId, - to: msg.from, - from: msg.to, - text: index === 0 ? (replyResult.text ?? null) : null, - mediaUrl, - mediaSizeBytes: media.buffer.length, - mediaKind: media.kind, - durationMs: Date.now() - replyStarted, - }, - "auto-reply sent (media)", - ); - } catch (err) { - console.error( - danger( - `Failed sending web media to ${msg.from}: ${String(err)}`, - ), - ); - if (index === 0 && replyResult.text) { - console.log( - `⚠️ Media skipped; sent text-only to ${msg.from}`, - ); - await msg.reply(replyResult.text || ""); - } - } - } - } else if (replyResult.text) { - await msg.reply(replyResult.text); - } - - const durationMs = Date.now() - replyStarted; - const hasMedia = mediaList.length > 0; + await deliverWebReply({ + replyResult, + msg, + maxMediaBytes, + replyLogger, + runtime, + connectionId, + }); if (isVerbose()) { console.log( success( - `↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${hasMedia ? ", media" : ""}, ${formatDuration(durationMs)})`, + `↩️ Auto-replied to ${msg.from} (web${replyResult.mediaUrl || replyResult.mediaUrls?.length ? ", media" : ""})`, ), ); } else { console.log( success( - `↩️ ${replyResult.text ?? ""}${hasMedia ? " (media)" : ""}`, + `↩️ ${replyResult.text ?? ""}${replyResult.mediaUrl || replyResult.mediaUrls?.length ? " (media)" : ""}`, ), ); } - replyLogger.info( - { - connectionId, - correlationId, - to: msg.from, - from: msg.to, - text: replyResult.text ?? null, - mediaUrl: mediaList[0] ?? null, - durationMs, - }, - "auto-reply sent", - ); } catch (err) { console.error( danger( @@ -261,6 +408,7 @@ export async function monitorWebProvider( const closeListener = async () => { if (heartbeat) clearInterval(heartbeat); + if (replyHeartbeatTimer) clearInterval(replyHeartbeatTimer); try { await listener.close(); } catch (err) { @@ -285,6 +433,135 @@ export async function monitorWebProvider( }, heartbeatSeconds * 1000); } + const runReplyHeartbeat = async () => { + if (!replyHeartbeatMinutes) return; + const tickStart = Date.now(); + if (!lastInboundMsg) { + heartbeatLogger.info( + { + connectionId, + reason: "no-recent-inbound", + durationMs: Date.now() - tickStart, + }, + "reply heartbeat skipped", + ); + console.log(success("heartbeat: skipped (no recent inbound)")); + return; + } + + try { + if (isVerbose()) { + heartbeatLogger.info( + { + connectionId, + to: lastInboundMsg.from, + intervalMinutes: replyHeartbeatMinutes, + }, + "reply heartbeat start", + ); + } + const replyResult = await (replyResolver ?? getReplyFromConfig)( + { + Body: HEARTBEAT_PROMPT, + From: lastInboundMsg.from, + To: lastInboundMsg.to, + MessageSid: undefined, + MediaPath: undefined, + MediaUrl: undefined, + MediaType: undefined, + }, + { + onReplyStart: lastInboundMsg.sendComposing, + }, + ); + + if ( + !replyResult || + (!replyResult.text && + !replyResult.mediaUrl && + !replyResult.mediaUrls?.length) + ) { + heartbeatLogger.info( + { + connectionId, + durationMs: Date.now() - tickStart, + reason: "empty-reply", + }, + "reply heartbeat skipped", + ); + console.log(success("heartbeat: ok (empty reply)")); + return; + } + + const stripped = stripHeartbeatToken(replyResult.text); + const hasMedia = + (replyResult.mediaUrl ?? replyResult.mediaUrls?.length ?? 0) > 0; + if (stripped.shouldSkip && !hasMedia) { + heartbeatLogger.info( + { + connectionId, + durationMs: Date.now() - tickStart, + reason: "heartbeat-token", + rawLength: replyResult.text?.length ?? 0, + }, + "reply heartbeat skipped", + ); + console.log(success("heartbeat: ok (HEARTBEAT_OK)")); + return; + } + + const cleanedReply: ReplyPayload = { + ...replyResult, + text: stripped.text, + }; + + await deliverWebReply({ + replyResult: cleanedReply, + msg: lastInboundMsg, + maxMediaBytes, + replyLogger, + runtime, + connectionId, + }); + + const durationMs = Date.now() - tickStart; + const summary = `heartbeat: alert sent (${formatDuration(durationMs)})`; + console.log(summary); + heartbeatLogger.info( + { + connectionId, + durationMs, + hasMedia, + chars: stripped.text?.length ?? 0, + }, + "reply heartbeat sent", + ); + } catch (err) { + const durationMs = Date.now() - tickStart; + heartbeatLogger.warn( + { + connectionId, + error: String(err), + durationMs, + }, + "reply heartbeat failed", + ); + console.log( + danger(`heartbeat: failed (${formatDuration(durationMs)})`), + ); + } + }; + + if (replyHeartbeatMinutes && !replyHeartbeatTimer) { + const intervalMs = replyHeartbeatMinutes * 60_000; + replyHeartbeatTimer = setInterval(() => { + void runReplyHeartbeat(); + }, intervalMs); + if (tuning.replyHeartbeatNow) { + void runReplyHeartbeat(); + } + } + logInfo( "📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.", runtime, From dce3c5176327aac96458b4c08e409f2d2458bcd9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Nov 2025 17:05:09 +0100 Subject: [PATCH 2/9] docs: document heartbeat triggers --- CHANGELOG.md | 4 +++- README.md | 10 +++++++++- docs/heartbeat.md | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 docs/heartbeat.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d28c8e16c..08e2838f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,9 @@ ## 1.1.1 — Unreleased ### Changes -- Placeholder for upcoming patch fixes. +- 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. ## 1.1.0 — 2025-11-26 diff --git a/README.md b/README.md index 25d5d432d..f1e0684fc 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,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` `--verbose` | | `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: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` | @@ -111,12 +113,18 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a bodyPrefix: "You are a concise WhatsApp assistant.\n\n", command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"], claudeOutputFormat: "text", - session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 } + session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 }, + heartbeatMinutes: 30 // optional; pings Claude every 30m and only sends if it omits HEARTBEAT_OK } } } ``` +#### Heartbeat pings (command mode) +- 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. +- Trigger one manually with `warelay heartbeat` (web provider only). Use `--heartbeat-now` to fire once at relay start. + ### 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. - Override in `~/.warelay/warelay.json`: diff --git a/docs/heartbeat.md b/docs/heartbeat.md new file mode 100644 index 000000000..d77349598 --- /dev/null +++ b/docs/heartbeat.md @@ -0,0 +1,37 @@ +# Heartbeat polling plan (2025-11-26) + +Goal: add a simple heartbeat poll for command-based auto-replies (Claude-driven) that only notifies users when something matters, using the `HEARTBEAT_OK` sentinel. + +## Prompt contract +- Extend the Claude system/identity text to explain: “If this is a heartbeat poll and nothing needs attention, reply exactly `HEARTBEAT_OK` and nothing else. For any alert, do **not** include `HEARTBEAT_OK`; just return the alert text.” +- Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts. + +## Config & defaults +- New config key: `inbound.reply.heartbeatMinutes` (number of minutes; `0` or undefined disables). +- Default: 30 minutes when a command-mode reply is configured. + +## Poller behavior +- When relay runs with command-mode auto-reply, start a timer with the resolved heartbeat interval. +- Each tick invokes the configured command with a short heartbeat body (e.g., “(heartbeat) summarize any important changes since last turn”) while reusing the active session args so Claude context stays warm. +- Abort timer on SIGINT/abort of the relay. + +## Sentinel handling +- Trim output. If the trimmed text equals `HEARTBEAT_OK` (case-sensitive) -> skip outbound message. +- Otherwise, send the text/media as normal, stripping the sentinel if it somehow appears. +- Treat empty output as `HEARTBEAT_OK` to avoid spurious pings. + +## Logging requirements +- Normal mode: single info line per tick, e.g., `heartbeat: ok (skipped)` or `heartbeat: alert sent (32ms)`. +- `--verbose`: log start/end, command argv, duration, and whether it was skipped/sent/error; include session ID and connection/run IDs via `getChildLogger` for correlation. +- On command failure: warn-level one-liner in normal mode; verbose log includes stdout/stderr snippets. + +## Failure/backoff +- If a heartbeat command errors, log it and retry on the next scheduled tick (no exponential backoff unless command repeatedly fails; keep it simple for now). + +## Tests to add +- Unit: sentinel detection (`HEARTBEAT_OK`, empty output, mixed text), skip vs send decision, default interval resolver (30m, override, disable). +- Unit/integration: verbose logger emits start/end lines; normal logger emits a single line. + +## 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. From 66c268e01a2347c0da9e12342e11a48862bec42b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Nov 2025 17:08:43 +0100 Subject: [PATCH 3/9] fix: heartbeat falls back to last session contact --- src/web/auto-reply.test.ts | 31 ++++++++++++++++++++++ src/web/auto-reply.ts | 53 +++++++++++++++++++++++++++++++------- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 6646dfdd1..27c9aad5a 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -105,6 +105,37 @@ describe("runWebHeartbeatOnce", () => { }); expect(sender).toHaveBeenCalledWith("+1555", "ALERT", { verbose: false }); }); + + it("falls back to most recent session when no to is provided", async () => { + const sender: typeof sendMessageWeb = vi + .fn() + .mockResolvedValue({ messageId: "m1", toJid: "jid" }); + const resolver = vi.fn(async () => ({ text: "ALERT" })); + // Seed session store + const now = Date.now(); + const store = { + "+1222": { sessionId: "s1", updatedAt: now - 1000 }, + "+1333": { sessionId: "s2", updatedAt: now }, + }; + const storePath = resolveStorePath(); + await fs.mkdir(resolveStorePath().replace("sessions.json", ""), { + recursive: true, + }); + await fs.writeFile(storePath, JSON.stringify(store)); + setLoadConfigMock({ + inbound: { + allowFrom: ["+1999"], + reply: { mode: "command", session: {} }, + }, + }); + await runWebHeartbeatOnce({ + to: "+1999", + verbose: false, + sender, + replyResolver: resolver, + }); + expect(sender).toHaveBeenCalledWith("+1333", "ALERT", { verbose: false }); + }); }); describe("web auto-reply", () => { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index c60255add..1f0d70018 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -2,10 +2,12 @@ import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { waitForever } from "../cli/wait.js"; import { loadConfig } from "../config/config.js"; +import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { danger, isVerbose, logVerbose, success } from "../globals.js"; import { logInfo } from "../logger.js"; import { getChildLogger } from "../logging.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { normalizeE164 } from "../utils.js"; import { monitorWebInbox } from "./inbound.js"; import { loadWebMedia } from "./media.js"; import { sendMessageWeb } from "./outbound.js"; @@ -141,6 +143,23 @@ export async function runWebHeartbeatOnce(opts: { } } +function getFallbackRecipient(cfg: ReturnType) { + const sessionCfg = cfg.inbound?.reply?.session; + const storePath = resolveStorePath(sessionCfg?.store); + const store = loadSessionStore(storePath); + const candidates = Object.entries(store).filter(([key]) => key !== "global"); + if (candidates.length === 0) { + return ( + (Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom[0]) || + null + ); + } + const mostRecent = candidates.sort( + (a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0), + )[0]; + return mostRecent ? normalizeE164(mostRecent[0]) : null; +} + async function deliverWebReply(params: { replyResult: ReplyPayload; msg: WebInboundMsg; @@ -437,15 +456,31 @@ export async function monitorWebProvider( if (!replyHeartbeatMinutes) return; const tickStart = Date.now(); if (!lastInboundMsg) { - heartbeatLogger.info( - { - connectionId, - reason: "no-recent-inbound", - durationMs: Date.now() - tickStart, - }, - "reply heartbeat skipped", - ); - console.log(success("heartbeat: skipped (no recent inbound)")); + const fallbackTo = getFallbackRecipient(cfg); + if (!fallbackTo) { + heartbeatLogger.info( + { + connectionId, + reason: "no-recent-inbound", + durationMs: Date.now() - tickStart, + }, + "reply heartbeat skipped", + ); + console.log(success("heartbeat: skipped (no recent inbound)")); + return; + } + if (isVerbose()) { + heartbeatLogger.info( + { connectionId, to: fallbackTo, reason: "fallback-session" }, + "reply heartbeat start", + ); + } + await runWebHeartbeatOnce({ + to: fallbackTo, + verbose, + replyResolver, + runtime, + }); return; } From 635b3231e58b27102f9c1046408ab751709873bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Nov 2025 17:12:28 +0100 Subject: [PATCH 4/9] chore: log heartbeat fallback and add test --- src/web/auto-reply.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 1f0d70018..6baca878c 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -481,6 +481,14 @@ export async function monitorWebProvider( replyResolver, runtime, }); + heartbeatLogger.info( + { + connectionId, + to: fallbackTo, + durationMs: Date.now() - tickStart, + }, + "reply heartbeat sent (fallback session)", + ); return; } From 8bb130791c4cb8d6b2256b7c26192a782679b5db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Nov 2025 17:20:48 +0100 Subject: [PATCH 5/9] chore: log heartbeat session snapshot --- src/web/auto-reply.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 6baca878c..aae01a7fb 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -2,7 +2,12 @@ import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { waitForever } from "../cli/wait.js"; import { loadConfig } from "../config/config.js"; -import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; +import { + DEFAULT_IDLE_MINUTES, + deriveSessionKey, + loadSessionStore, + resolveStorePath, +} from "../config/sessions.js"; import { danger, isVerbose, logVerbose, success } from "../globals.js"; import { logInfo } from "../logger.js"; import { getChildLogger } from "../logging.js"; @@ -160,6 +165,22 @@ function getFallbackRecipient(cfg: ReturnType) { return mostRecent ? normalizeE164(mostRecent[0]) : null; } +function getSessionSnapshot(cfg: ReturnType, from: string) { + const sessionCfg = cfg.inbound?.reply?.session; + const scope = sessionCfg?.scope ?? "per-sender"; + const key = deriveSessionKey(scope, { From: from, To: "", Body: "" }); + const store = loadSessionStore(resolveStorePath(sessionCfg?.store)); + const entry = store[key]; + const idleMinutes = Math.max( + sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, + 1, + ); + const fresh = !!( + entry && Date.now() - entry.updatedAt <= idleMinutes * 60_000 + ); + return { key, entry, fresh, idleMinutes }; +} + async function deliverWebReply(params: { replyResult: ReplyPayload; msg: WebInboundMsg; @@ -485,6 +506,7 @@ export async function monitorWebProvider( { connectionId, to: fallbackTo, + ...getSessionSnapshot(cfg, fallbackTo), durationMs: Date.now() - tickStart, }, "reply heartbeat sent (fallback session)", @@ -494,11 +516,15 @@ export async function monitorWebProvider( try { if (isVerbose()) { + const snapshot = getSessionSnapshot(cfg, lastInboundMsg.from); heartbeatLogger.info( { connectionId, to: lastInboundMsg.from, intervalMinutes: replyHeartbeatMinutes, + sessionKey: snapshot.key, + sessionId: snapshot.entry?.sessionId ?? null, + sessionFresh: snapshot.fresh, }, "reply heartbeat start", ); From f36e15e809cf44b916e6b7d5fc425c64729623e1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Nov 2025 17:21:59 +0100 Subject: [PATCH 6/9] chore: add verbose heartbeat session logging --- src/web/auto-reply.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index aae01a7fb..291519d8c 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -92,6 +92,19 @@ export async function runWebHeartbeatOnce(opts: { }); const cfg = loadConfig(); + const sessionSnapshot = getSessionSnapshot(cfg, to); + if (verbose) { + heartbeatLogger.info( + { + to, + sessionKey: sessionSnapshot.key, + sessionId: sessionSnapshot.entry?.sessionId ?? null, + sessionFresh: sessionSnapshot.fresh, + idleMinutes: sessionSnapshot.idleMinutes, + }, + "heartbeat session snapshot", + ); + } try { const replyResult = await replyResolver( @@ -110,7 +123,14 @@ export async function runWebHeartbeatOnce(opts: { !replyResult.mediaUrl && !replyResult.mediaUrls?.length) ) { - heartbeatLogger.info({ to, reason: "empty-reply" }, "heartbeat skipped"); + heartbeatLogger.info( + { + to, + reason: "empty-reply", + sessionId: sessionSnapshot.entry?.sessionId ?? null, + }, + "heartbeat skipped", + ); if (verbose) console.log(success("heartbeat: ok (empty reply)")); return; } From 588528f7d715b8bce40bb07f2554ac4ea70e9cfb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Nov 2025 17:26:17 +0100 Subject: [PATCH 7/9] feat: add heartbeat idle override and preserve session freshness --- src/config/config.ts | 2 ++ src/web/auto-reply.ts | 24 ++++++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index 595c66e98..7a838d112 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -13,6 +13,7 @@ export type SessionConfig = { scope?: SessionScope; resetTriggers?: string[]; idleMinutes?: number; + heartbeatIdleMinutes?: number; store?: string; sessionArgNew?: string[]; sessionArgResume?: string[]; @@ -89,6 +90,7 @@ const ReplySchema = z .optional(), resetTriggers: z.array(z.string()).optional(), idleMinutes: z.number().int().positive().optional(), + heartbeatIdleMinutes: z.number().int().positive().optional(), store: z.string().optional(), sessionArgNew: z.array(z.string()).optional(), sessionArgResume: z.array(z.string()).optional(), diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 291519d8c..6425ead15 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -7,6 +7,7 @@ import { deriveSessionKey, loadSessionStore, resolveStorePath, + saveSessionStore, } from "../config/sessions.js"; import { danger, isVerbose, logVerbose, success } from "../globals.js"; import { logInfo } from "../logger.js"; @@ -92,7 +93,7 @@ export async function runWebHeartbeatOnce(opts: { }); const cfg = loadConfig(); - const sessionSnapshot = getSessionSnapshot(cfg, to); + const sessionSnapshot = getSessionSnapshot(cfg, to, true); if (verbose) { heartbeatLogger.info( { @@ -112,7 +113,7 @@ export async function runWebHeartbeatOnce(opts: { Body: HEARTBEAT_PROMPT, From: to, To: to, - MessageSid: undefined, + MessageSid: sessionSnapshot.entry?.sessionId, }, undefined, cfg, @@ -139,6 +140,15 @@ export async function runWebHeartbeatOnce(opts: { (replyResult.mediaUrl ?? replyResult.mediaUrls?.length ?? 0) > 0; const stripped = stripHeartbeatToken(replyResult.text); if (stripped.shouldSkip && !hasMedia) { + // Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works. + const sessionCfg = cfg.inbound?.reply?.session; + const storePath = resolveStorePath(sessionCfg?.store); + const store = loadSessionStore(storePath); + if (sessionSnapshot.entry && store[sessionSnapshot.key]) { + store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt; + await saveSessionStore(storePath, store); + } + heartbeatLogger.info( { to, reason: "heartbeat-token", rawLength: replyResult.text?.length }, "heartbeat skipped", @@ -185,14 +195,20 @@ function getFallbackRecipient(cfg: ReturnType) { return mostRecent ? normalizeE164(mostRecent[0]) : null; } -function getSessionSnapshot(cfg: ReturnType, from: string) { +function getSessionSnapshot( + cfg: ReturnType, + from: string, + isHeartbeat = false, +) { const sessionCfg = cfg.inbound?.reply?.session; const scope = sessionCfg?.scope ?? "per-sender"; const key = deriveSessionKey(scope, { From: from, To: "", Body: "" }); const store = loadSessionStore(resolveStorePath(sessionCfg?.store)); const entry = store[key]; const idleMinutes = Math.max( - sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, + (isHeartbeat + ? (sessionCfg?.heartbeatIdleMinutes ?? sessionCfg?.idleMinutes) + : sessionCfg?.idleMinutes) ?? DEFAULT_IDLE_MINUTES, 1, ); const fresh = !!( From 94ea62ea870f6cdc8f48d0ff78b40d06fb2d72f9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Nov 2025 17:29:12 +0100 Subject: [PATCH 8/9] test: cover heartbeat skip preserving session timestamp --- src/web/auto-reply.test.ts | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 27c9aad5a..3219d6c62 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1,5 +1,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import sharp from "sharp"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -136,6 +138,46 @@ describe("runWebHeartbeatOnce", () => { }); expect(sender).toHaveBeenCalledWith("+1333", "ALERT", { verbose: false }); }); + + it("does not refresh updatedAt when heartbeat is skipped", async () => { + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), "warelay-heartbeat-"), + ); + const storePath = path.join(tmpDir, "sessions.json"); + const now = Date.now(); + const originalUpdated = now - 30 * 60 * 1000; + const store = { + "+1555": { sessionId: "sess1", updatedAt: originalUpdated }, + }; + await fs.writeFile(storePath, JSON.stringify(store)); + + const sender: typeof sendMessageWeb = vi.fn(); + const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN })); + setLoadConfigMock({ + inbound: { + allowFrom: ["+1555"], + reply: { + mode: "command", + session: { + store: storePath, + idleMinutes: 60, + heartbeatIdleMinutes: 10, + }, + }, + }, + }); + + await runWebHeartbeatOnce({ + to: "+1555", + verbose: false, + sender, + replyResolver: resolver, + }); + + const after = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(after["+1555"].updatedAt).toBe(originalUpdated); + expect(sender).not.toHaveBeenCalled(); + }); }); describe("web auto-reply", () => { From f227f0aee1f208834f6096bc013acd13c0714c00 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Nov 2025 17:31:56 +0100 Subject: [PATCH 9/9] docs: document heartbeat idle override and tests --- CHANGELOG.md | 1 + README.md | 3 ++- docs/heartbeat.md | 1 + src/web/auto-reply.test.ts | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08e2838f0..2025e714e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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. +- 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 f1e0684fc..dcc581694 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,8 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a #### Heartbeat pings (command mode) - 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. -- Trigger one manually with `warelay heartbeat` (web provider only). Use `--heartbeat-now` to fire once at relay start. +- 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. ### 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 d77349598..506b4f321 100644 --- a/docs/heartbeat.md +++ b/docs/heartbeat.md @@ -9,6 +9,7 @@ Goal: add a simple heartbeat poll for command-based auto-replies (Claude-driven) ## Config & defaults - New config key: `inbound.reply.heartbeatMinutes` (number of minutes; `0` or undefined disables). - Default: 30 minutes when a command-mode reply is configured. +- New optional idle override for heartbeats: `inbound.reply.session.heartbeatIdleMinutes` (defaults to `idleMinutes`). Heartbeat skips do **not** update the session `updatedAt` so idle expiry still works. ## Poller behavior - When relay runs with command-mode auto-reply, start a timer with the resolved heartbeat interval. diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 3219d6c62..896c4e70b 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -20,6 +20,7 @@ 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", () => {