From 12d7be7cade98e561544a80b60764c9e6b0ce755 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 28 Nov 2025 08:14:07 +0100 Subject: [PATCH] feat(heartbeat): allow manual message and dry-run for web/twilio --- src/cli/program.ts | 64 ++++++++++++++++++-------- src/twilio/heartbeat.test.ts | 75 ++++++++++++++++++++++++++++++ src/twilio/heartbeat.ts | 89 ++++++++++++++++++++++++++++++++++++ src/web/auto-reply.test.ts | 39 ++++++++++++++++ src/web/auto-reply.ts | 53 ++++++++++++++++++++- 5 files changed, 300 insertions(+), 20 deletions(-) create mode 100644 src/twilio/heartbeat.test.ts create mode 100644 src/twilio/heartbeat.ts diff --git a/src/cli/program.ts b/src/cli/program.ts index b268a7178..b6547a9a5 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -17,6 +17,7 @@ import { type WebMonitorTuning, } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; +import { runTwilioHeartbeatOnce } from "../twilio/heartbeat.js"; import type { Provider } from "../utils.js"; import { VERSION } from "../version.js"; import { @@ -179,8 +180,10 @@ Examples: program .command("heartbeat") - .description("Trigger a heartbeat poll once (web provider, no tmux)") - .option("--provider ", "auto | web", "auto") + .description( + "Trigger a heartbeat or manual send once (web or twilio, no tmux)", + ) + .option("--provider ", "auto | web | twilio", "auto") .option("--to ", "Override target E.164; defaults to allowFrom[0]") .option( "--session-id ", @@ -191,6 +194,12 @@ Examples: "Send heartbeat to all active sessions (or allowFrom entries when none)", false, ) + .option( + "--message ", + "Send a custom message instead of the heartbeat probe (web or twilio provider)", + ) + .option("--body ", "Alias for --message") + .option("--dry-run", "Print the resolved payload without sending", false) .option("--verbose", "Verbose logging", false) .addHelpText( "after", @@ -200,6 +209,7 @@ Examples: warelay heartbeat --verbose # prints detailed heartbeat logs warelay heartbeat --to +1555123 # override destination warelay heartbeat --session-id --to +1555123 # resume a specific session + warelay heartbeat --message "Ping" --provider twilio warelay heartbeat --all # send to every active session recipient or allowFrom entry`, ) .action(async (opts) => { @@ -233,27 +243,43 @@ Examples: 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`.", - ), - ); + if (!["auto", "web", "twilio"].includes(providerPref)) { + defaultRuntime.error("--provider must be auto, web, or twilio"); defaultRuntime.exit(1); } + + const overrideBody = + (opts.message as string | undefined) || + (opts.body as string | undefined) || + undefined; + const dryRun = Boolean(opts.dryRun); + + const provider = + providerPref === "twilio" + ? "twilio" + : await pickProvider(providerPref as "auto" | "web"); + if (provider === "twilio") ensureTwilioEnv(); + try { for (const to of recipients) { - await runWebHeartbeatOnce({ - to, - verbose: Boolean(opts.verbose), - runtime: defaultRuntime, - sessionId: opts.sessionId, - }); + if (provider === "web") { + await runWebHeartbeatOnce({ + to, + verbose: Boolean(opts.verbose), + runtime: defaultRuntime, + sessionId: opts.sessionId, + overrideBody, + dryRun, + }); + } else { + await runTwilioHeartbeatOnce({ + to, + verbose: Boolean(opts.verbose), + runtime: defaultRuntime, + overrideBody, + dryRun, + }); + } } } catch { defaultRuntime.exit(1); diff --git a/src/twilio/heartbeat.test.ts b/src/twilio/heartbeat.test.ts new file mode 100644 index 000000000..216f759da --- /dev/null +++ b/src/twilio/heartbeat.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; + +import { HEARTBEAT_TOKEN } from "../web/auto-reply.js"; +import { runTwilioHeartbeatOnce } from "./heartbeat.js"; + +vi.mock("./send.js", () => ({ + sendMessage: vi.fn(), +})); + +vi.mock("../auto-reply/reply.js", () => ({ + getReplyFromConfig: vi.fn(), +})); + +// eslint-disable-next-line import/first +import { getReplyFromConfig } from "../auto-reply/reply.js"; +// eslint-disable-next-line import/first +import { sendMessage } from "./send.js"; + +const sendMessageMock = sendMessage as unknown as vi.Mock; +const replyResolverMock = getReplyFromConfig as unknown as vi.Mock; + +describe("runTwilioHeartbeatOnce", () => { + it("sends manual override body and skips resolver", async () => { + sendMessageMock.mockResolvedValue({}); + await runTwilioHeartbeatOnce({ + to: "+1555", + overrideBody: "hello manual", + }); + expect(sendMessage).toHaveBeenCalledWith( + "+1555", + "hello manual", + undefined, + expect.anything(), + ); + expect(replyResolverMock).not.toHaveBeenCalled(); + }); + + it("dry-run manual message avoids sending", async () => { + sendMessageMock.mockReset(); + await runTwilioHeartbeatOnce({ + to: "+1555", + overrideBody: "hello manual", + dryRun: true, + }); + expect(sendMessage).not.toHaveBeenCalled(); + expect(replyResolverMock).not.toHaveBeenCalled(); + }); + + it("skips send when resolver returns heartbeat token", async () => { + replyResolverMock.mockResolvedValue({ + text: HEARTBEAT_TOKEN, + }); + sendMessageMock.mockReset(); + await runTwilioHeartbeatOnce({ + to: "+1555", + }); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("sends resolved heartbeat text when present", async () => { + replyResolverMock.mockResolvedValue({ + text: "ALERT!", + }); + sendMessageMock.mockReset().mockResolvedValue({}); + await runTwilioHeartbeatOnce({ + to: "+1555", + }); + expect(sendMessage).toHaveBeenCalledWith( + "+1555", + "ALERT!", + undefined, + expect.anything(), + ); + }); +}); diff --git a/src/twilio/heartbeat.ts b/src/twilio/heartbeat.ts new file mode 100644 index 000000000..3eb096d6f --- /dev/null +++ b/src/twilio/heartbeat.ts @@ -0,0 +1,89 @@ +import { getReplyFromConfig } from "../auto-reply/reply.js"; +import { danger, success } from "../globals.js"; +import { logInfo } from "../logger.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { HEARTBEAT_PROMPT, stripHeartbeatToken } from "../web/auto-reply.js"; +import { sendMessage } from "./send.js"; + +type ReplyResolver = typeof getReplyFromConfig; + +export async function runTwilioHeartbeatOnce(opts: { + to: string; + verbose?: boolean; + runtime?: RuntimeEnv; + replyResolver?: ReplyResolver; + overrideBody?: string; + dryRun?: boolean; +}) { + const { + to, + verbose: _verbose = false, + runtime = defaultRuntime, + overrideBody, + dryRun = false, + } = opts; + const replyResolver = opts.replyResolver ?? getReplyFromConfig; + + if (overrideBody && overrideBody.trim().length === 0) { + throw new Error("Override body must be non-empty when provided."); + } + + try { + if (overrideBody) { + if (dryRun) { + logInfo( + `[dry-run] twilio send -> ${to}: ${overrideBody.trim()} (manual message)`, + runtime, + ); + return; + } + await sendMessage(to, overrideBody, undefined, runtime); + logInfo(success(`sent manual message to ${to} (twilio)`), runtime); + return; + } + + const replyResult = await replyResolver( + { + Body: HEARTBEAT_PROMPT, + From: to, + To: to, + MessageSid: undefined, + }, + undefined, + ); + + if ( + !replyResult || + (!replyResult.text && + !replyResult.mediaUrl && + !replyResult.mediaUrls?.length) + ) { + logInfo("heartbeat skipped: empty reply", runtime); + return; + } + + const hasMedia = Boolean( + replyResult.mediaUrl || (replyResult.mediaUrls?.length ?? 0) > 0, + ); + const stripped = stripHeartbeatToken(replyResult.text); + if (stripped.shouldSkip && !hasMedia) { + logInfo(success("heartbeat: ok (HEARTBEAT_OK)"), runtime); + return; + } + + const finalText = stripped.text || replyResult.text || ""; + if (dryRun) { + logInfo( + `[dry-run] heartbeat -> ${to}: ${finalText.slice(0, 200)}`, + runtime, + ); + return; + } + + await sendMessage(to, finalText, undefined, runtime); + logInfo(success(`heartbeat sent to ${to} (twilio)`), runtime); + } catch (err) { + runtime.error(danger(`Heartbeat failed: ${String(err)}`)); + throw err; + } +} diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index f4dbcb7d0..429b4c457 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -351,6 +351,45 @@ describe("runWebHeartbeatOnce", () => { expect(stored["+1999"]?.sessionId).toBe(sessionId); expect(stored["+1999"]?.updatedAt).toBeDefined(); }); + + it("sends overrideBody directly and skips resolver", async () => { + const sender: typeof sendMessageWeb = vi + .fn() + .mockResolvedValue({ messageId: "m1", toJid: "jid" }); + const resolver = vi.fn(); + setLoadConfigMock({ + inbound: { allowFrom: ["+1555"], reply: { mode: "command" } }, + }); + await runWebHeartbeatOnce({ + to: "+1555", + verbose: false, + sender, + replyResolver: resolver, + overrideBody: "manual ping", + }); + expect(sender).toHaveBeenCalledWith("+1555", "manual ping", { + verbose: false, + }); + expect(resolver).not.toHaveBeenCalled(); + }); + + it("dry-run overrideBody prints and skips send", async () => { + const sender: typeof sendMessageWeb = vi.fn(); + const resolver = vi.fn(); + setLoadConfigMock({ + inbound: { allowFrom: ["+1555"], reply: { mode: "command" } }, + }); + await runWebHeartbeatOnce({ + to: "+1555", + verbose: false, + sender, + replyResolver: resolver, + overrideBody: "dry", + dryRun: true, + }); + expect(sender).not.toHaveBeenCalled(); + expect(resolver).not.toHaveBeenCalled(); + }); }); describe("web auto-reply", () => { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 452fb0f91..70e31a30a 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -81,8 +81,17 @@ export async function runWebHeartbeatOnce(opts: { runtime?: RuntimeEnv; sender?: typeof sendMessageWeb; sessionId?: string; + overrideBody?: string; + dryRun?: boolean; }) { - const { cfg: cfgOverride, to, verbose = false, sessionId } = opts; + const { + cfg: cfgOverride, + to, + verbose = false, + sessionId, + overrideBody, + dryRun = false, + } = opts; const _runtime = opts.runtime ?? defaultRuntime; const replyResolver = opts.replyResolver ?? getReplyFromConfig; const sender = opts.sender ?? sendMessageWeb; @@ -118,7 +127,38 @@ export async function runWebHeartbeatOnce(opts: { ); } + if (overrideBody && overrideBody.trim().length === 0) { + throw new Error("Override body must be non-empty when provided."); + } + try { + if (overrideBody) { + if (dryRun) { + console.log( + success( + `[dry-run] web send -> ${to}: ${overrideBody.trim()} (manual message)`, + ), + ); + return; + } + const sendResult = await sender(to, overrideBody, { verbose }); + heartbeatLogger.info( + { + to, + messageId: sendResult.messageId, + chars: overrideBody.length, + reason: "manual-message", + }, + "manual heartbeat message sent", + ); + console.log( + success( + `sent manual message to ${to} (web), id ${sendResult.messageId}`, + ), + ); + return; + } + const replyResult = await replyResolver( { Body: HEARTBEAT_PROMPT, @@ -177,6 +217,17 @@ export async function runWebHeartbeatOnce(opts: { } const finalText = stripped.text || replyResult.text || ""; + if (dryRun) { + heartbeatLogger.info( + { to, reason: "dry-run", chars: finalText.length }, + "heartbeat dry-run", + ); + console.log( + success(`[dry-run] heartbeat -> ${to}: ${finalText.slice(0, 200)}`), + ); + return; + } + const sendResult = await sender(to, finalText, { verbose }); heartbeatLogger.info( { to, messageId: sendResult.messageId, chars: finalText.length },