feat(heartbeat): allow manual message and dry-run for web/twilio
This commit is contained in:
@@ -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);
|
||||
|
||||
75
src/twilio/heartbeat.test.ts
Normal file
75
src/twilio/heartbeat.test.ts
Normal 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
89
src/twilio/heartbeat.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user