feat(heartbeat): allow manual message and dry-run for web/twilio
parent
84f2595349
commit
12d7be7cad
|
|
@ -17,6 +17,7 @@ import {
|
||||||
type WebMonitorTuning,
|
type WebMonitorTuning,
|
||||||
} from "../provider-web.js";
|
} from "../provider-web.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import { runTwilioHeartbeatOnce } from "../twilio/heartbeat.js";
|
||||||
import type { Provider } from "../utils.js";
|
import type { Provider } from "../utils.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -179,8 +180,10 @@ Examples:
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("heartbeat")
|
.command("heartbeat")
|
||||||
.description("Trigger a heartbeat poll once (web provider, no tmux)")
|
.description(
|
||||||
.option("--provider <provider>", "auto | web", "auto")
|
"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("--to <number>", "Override target E.164; defaults to allowFrom[0]")
|
||||||
.option(
|
.option(
|
||||||
"--session-id <id>",
|
"--session-id <id>",
|
||||||
|
|
@ -191,6 +194,12 @@ Examples:
|
||||||
"Send heartbeat to all active sessions (or allowFrom entries when none)",
|
"Send heartbeat to all active sessions (or allowFrom entries when none)",
|
||||||
false,
|
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)
|
.option("--verbose", "Verbose logging", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
|
|
@ -200,6 +209,7 @@ Examples:
|
||||||
warelay heartbeat --verbose # prints detailed heartbeat logs
|
warelay heartbeat --verbose # prints detailed heartbeat logs
|
||||||
warelay heartbeat --to +1555123 # override destination
|
warelay heartbeat --to +1555123 # override destination
|
||||||
warelay heartbeat --session-id <uuid> --to +1555123 # resume a specific session
|
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`,
|
warelay heartbeat --all # send to every active session recipient or allowFrom entry`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
|
|
@ -233,27 +243,43 @@ Examples:
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
const providerPref = String(opts.provider ?? "auto");
|
const providerPref = String(opts.provider ?? "auto");
|
||||||
if (!["auto", "web"].includes(providerPref)) {
|
if (!["auto", "web", "twilio"].includes(providerPref)) {
|
||||||
defaultRuntime.error("--provider must be auto or web");
|
defaultRuntime.error("--provider must be auto, web, or twilio");
|
||||||
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);
|
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 {
|
try {
|
||||||
for (const to of recipients) {
|
for (const to of recipients) {
|
||||||
await runWebHeartbeatOnce({
|
if (provider === "web") {
|
||||||
to,
|
await runWebHeartbeatOnce({
|
||||||
verbose: Boolean(opts.verbose),
|
to,
|
||||||
runtime: defaultRuntime,
|
verbose: Boolean(opts.verbose),
|
||||||
sessionId: opts.sessionId,
|
runtime: defaultRuntime,
|
||||||
});
|
sessionId: opts.sessionId,
|
||||||
|
overrideBody,
|
||||||
|
dryRun,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await runTwilioHeartbeatOnce({
|
||||||
|
to,
|
||||||
|
verbose: Boolean(opts.verbose),
|
||||||
|
runtime: defaultRuntime,
|
||||||
|
overrideBody,
|
||||||
|
dryRun,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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"]?.sessionId).toBe(sessionId);
|
||||||
expect(stored["+1999"]?.updatedAt).toBeDefined();
|
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", () => {
|
describe("web auto-reply", () => {
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,17 @@ export async function runWebHeartbeatOnce(opts: {
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
sender?: typeof sendMessageWeb;
|
sender?: typeof sendMessageWeb;
|
||||||
sessionId?: string;
|
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 _runtime = opts.runtime ?? defaultRuntime;
|
||||||
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
|
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
|
||||||
const sender = opts.sender ?? sendMessageWeb;
|
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 {
|
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(
|
const replyResult = await replyResolver(
|
||||||
{
|
{
|
||||||
Body: HEARTBEAT_PROMPT,
|
Body: HEARTBEAT_PROMPT,
|
||||||
|
|
@ -177,6 +217,17 @@ export async function runWebHeartbeatOnce(opts: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalText = stripped.text || replyResult.text || "";
|
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 });
|
const sendResult = await sender(to, finalText, { verbose });
|
||||||
heartbeatLogger.info(
|
heartbeatLogger.info(
|
||||||
{ to, messageId: sendResult.messageId, chars: finalText.length },
|
{ to, messageId: sendResult.messageId, chars: finalText.length },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue