feat(heartbeat): allow manual message and dry-run for web/twilio

This commit is contained in:
Peter Steinberger
2025-11-28 08:14:07 +01:00
parent 84f2595349
commit 12d7be7cad
5 changed files with 300 additions and 20 deletions

View File

@@ -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 <provider>", "auto | web", "auto")
.description(
"Trigger a heartbeat or manual send once (web or twilio, no tmux)",
)
.option("--provider <provider>", "auto | web | twilio", "auto")
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
.option(
"--session-id <id>",
@@ -191,6 +194,12 @@ Examples:
"Send heartbeat to all active sessions (or allowFrom entries when none)",
false,
)
.option(
"--message <text>",
"Send a custom message instead of the heartbeat probe (web or twilio provider)",
)
.option("--body <text>", "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 <uuid> --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);

View File

@@ -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(),
);
});
});

89
src/twilio/heartbeat.ts Normal file
View File

@@ -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;
}
}

View File

@@ -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", () => {

View File

@@ -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 },