refactor(whatsapp): remove legacy heartbeat runners

This commit is contained in:
Peter Steinberger
2026-05-02 08:40:02 +01:00
parent 0c9d1ab87f
commit bd511be53d
14 changed files with 3 additions and 907 deletions

View File

@@ -1,2 +1,2 @@
7c25208c10ba075f76719883b7b2aefe4cf5e42328bad3acff1c5055350d344f plugin-sdk-api-baseline.json
6cac90f85065bcbd447911a0c7c54e7d6992278fd1b95a3e78ae4be3f185848a plugin-sdk-api-baseline.jsonl
84befa4ad71bee22d9ea91a6ff689532deb3783143af7488a98a7341d5ce5f25 plugin-sdk-api-baseline.json
046bb0c9bc40bfb2f8a323bf658c45eeeb486571301757abc5472018db7d2189 plugin-sdk-api-baseline.jsonl

View File

@@ -37,8 +37,6 @@ export {
HEARTBEAT_PROMPT,
HEARTBEAT_TOKEN,
monitorWebChannel,
resolveHeartbeatRecipients,
runWebHeartbeatOnce,
SILENT_REPLY_TOKEN,
stripHeartbeatToken,
type WebChannelStatus,

View File

@@ -2,6 +2,5 @@ export { HEARTBEAT_PROMPT, stripHeartbeatToken } from "openclaw/plugin-sdk/reply
export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "openclaw/plugin-sdk/reply-runtime";
export { DEFAULT_WEB_MEDIA_BYTES } from "./auto-reply/constants.js";
export { resolveHeartbeatRecipients, runWebHeartbeatOnce } from "./auto-reply/heartbeat-runner.js";
export { monitorWebChannel } from "./auto-reply/monitor.js";
export type { WebChannelStatus, WebMonitorTuning } from "./auto-reply/types.js";

View File

@@ -1,33 +0,0 @@
export { appendCronStyleCurrentTimeLine } from "openclaw/plugin-sdk/agent-runtime";
export {
canonicalizeMainSessionAlias,
loadSessionStore,
resolveSessionKey,
resolveStorePath,
updateSessionStore,
} from "openclaw/plugin-sdk/session-store-runtime";
export { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
export {
emitHeartbeatEvent,
resolveHeartbeatVisibility,
resolveIndicatorType,
} from "openclaw/plugin-sdk/heartbeat-runtime";
export {
hasOutboundReplyContent,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
export {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
HEARTBEAT_TOKEN,
getReplyFromConfig,
resolveHeartbeatPrompt,
resolveHeartbeatReplyPayload,
stripHeartbeatToken,
} from "openclaw/plugin-sdk/reply-runtime";
export { normalizeMainKey } from "openclaw/plugin-sdk/routing";
export { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
export { redactIdentifier } from "openclaw/plugin-sdk/text-runtime";
export { resolveWhatsAppHeartbeatRecipients } from "../runtime-api.js";
export { sendMessageWhatsApp } from "../send.js";
export { formatError } from "../session.js";
export { whatsappHeartbeatLog } from "./loggers.js";

View File

@@ -1,214 +0,0 @@
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { sendMessageWhatsApp } from "../send.js";
import type { getReplyFromConfig } from "./heartbeat-runner.runtime.js";
const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
const state = vi.hoisted(() => ({
visibility: { showAlerts: true, showOk: true, useIndicator: false },
store: {} as Record<string, { updatedAt?: number; sessionId?: string }>,
snapshot: {
key: "k",
entry: { sessionId: "s1", updatedAt: 123 },
fresh: false,
resetPolicy: { mode: "none", atHour: null, idleMinutes: null },
dailyResetAt: null as number | null,
idleExpiresAt: null as number | null,
},
events: [] as unknown[],
loggerInfoCalls: [] as unknown[][],
loggerWarnCalls: [] as unknown[][],
heartbeatInfoLogs: [] as string[],
heartbeatWarnLogs: [] as string[],
}));
vi.mock("./heartbeat-runner.runtime.js", () => {
const logger = {
child: () => logger,
info: (...args: unknown[]) => state.loggerInfoCalls.push(args),
warn: (...args: unknown[]) => state.loggerWarnCalls.push(args),
error: vi.fn(),
debug: vi.fn(),
};
return {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS: 32,
HEARTBEAT_TOKEN,
appendCronStyleCurrentTimeLine: (body: string) =>
`${body}\nCurrent time: 2026-02-15T00:00:00Z (mock)`,
canonicalizeMainSessionAlias: ({ sessionKey }: { sessionKey: string }) => sessionKey,
emitHeartbeatEvent: (event: unknown) => state.events.push(event),
formatError: (err: unknown) => `ERR:${String(err)}`,
getChildLogger: () => logger,
getReplyFromConfig: vi.fn(async () => undefined),
hasOutboundReplyContent: (payload: { text?: string } | undefined) =>
Boolean(payload?.text?.trim()),
loadConfig: () => ({ agents: { defaults: {} }, session: {} }),
loadSessionStore: () => state.store,
normalizeMainKey: () => null,
redactIdentifier,
resolveHeartbeatPrompt: (prompt?: string) => prompt || "Heartbeat",
resolveHeartbeatReplyPayload: (reply: unknown) => reply,
resolveHeartbeatVisibility: () => state.visibility,
resolveIndicatorType: (status: string) => `indicator:${status}`,
resolveSendableOutboundReplyParts: (payload: { text?: string }) => ({
text: payload.text ?? "",
hasMedia: false,
}),
resolveSessionKey: () => "k",
resolveStorePath: () => "/tmp/store.json",
resolveWhatsAppHeartbeatRecipients: () => [],
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })),
stripHeartbeatToken: (text: string) => {
const trimmed = text.trim();
if (trimmed === HEARTBEAT_TOKEN) {
return { shouldSkip: true, text: "" };
}
return { shouldSkip: false, text: trimmed };
},
updateSessionStore: async (_path: string, updater: (store: typeof state.store) => void) => {
updater(state.store);
},
whatsappHeartbeatLog: {
info: (msg: string) => state.heartbeatInfoLogs.push(msg),
warn: (msg: string) => state.heartbeatWarnLogs.push(msg),
},
};
});
vi.mock("./session-snapshot.js", () => ({
getSessionSnapshot: () => state.snapshot,
}));
vi.mock("../reconnect.js", () => ({
newConnectionId: () => "run-1",
}));
describe("runWebHeartbeatOnce", () => {
let senderMock: ReturnType<typeof vi.fn>;
let sender: typeof sendMessageWhatsApp;
let replyResolverMock: ReturnType<typeof vi.fn>;
let replyResolver: typeof getReplyFromConfig;
let runWebHeartbeatOnce: typeof import("./heartbeat-runner.js").runWebHeartbeatOnce;
const buildRunArgs = (overrides: Record<string, unknown> = {}) => ({
cfg: { agents: { defaults: {} }, session: {} } as never,
to: "+123",
sender,
replyResolver,
...overrides,
});
beforeAll(async () => {
({ runWebHeartbeatOnce } = await import("./heartbeat-runner.js"));
});
beforeEach(() => {
state.visibility = { showAlerts: true, showOk: true, useIndicator: false };
state.store = { k: { updatedAt: 999, sessionId: "s1" } };
state.snapshot = {
key: "k",
entry: { sessionId: "s1", updatedAt: 123 },
fresh: false,
resetPolicy: { mode: "none", atHour: null, idleMinutes: null },
dailyResetAt: null,
idleExpiresAt: null,
};
state.events = [];
state.loggerInfoCalls = [];
state.loggerWarnCalls = [];
state.heartbeatInfoLogs = [];
state.heartbeatWarnLogs = [];
senderMock = vi.fn(async () => ({ messageId: "m1" }));
sender = senderMock as unknown as typeof sendMessageWhatsApp;
replyResolverMock = vi.fn(async () => undefined);
replyResolver = replyResolverMock as unknown as typeof getReplyFromConfig;
});
it("supports manual override body dry-run without sending", async () => {
await runWebHeartbeatOnce(buildRunArgs({ overrideBody: "hello", dryRun: true }));
expect(senderMock).not.toHaveBeenCalled();
expect(state.events).toHaveLength(0);
});
it("sends HEARTBEAT_OK when reply is empty and showOk is enabled", async () => {
await runWebHeartbeatOnce(buildRunArgs());
expect(senderMock).toHaveBeenCalledWith(
"+123",
HEARTBEAT_TOKEN,
expect.objectContaining({ verbose: false, cfg: expect.any(Object) }),
);
expect(state.events).toEqual(
expect.arrayContaining([expect.objectContaining({ status: "ok-empty", silent: false })]),
);
});
it("injects a cron-style Current time line into the heartbeat prompt", async () => {
await runWebHeartbeatOnce(
buildRunArgs({
cfg: { agents: { defaults: { heartbeat: { prompt: "Ops check" } } }, session: {} } as never,
dryRun: true,
}),
);
expect(replyResolver).toHaveBeenCalledTimes(1);
const ctx = replyResolverMock.mock.calls[0]?.[0];
expect(ctx?.Body).toContain("Ops check");
expect(ctx?.Body).toContain("Current time: 2026-02-15T00:00:00Z (mock)");
});
it("treats heartbeat token-only replies as ok-token and preserves session updatedAt", async () => {
replyResolverMock.mockResolvedValue({ text: HEARTBEAT_TOKEN });
await runWebHeartbeatOnce(buildRunArgs());
expect(state.store.k?.updatedAt).toBe(123);
expect(senderMock).toHaveBeenCalledWith(
"+123",
HEARTBEAT_TOKEN,
expect.objectContaining({ verbose: false, cfg: expect.any(Object) }),
);
expect(state.events).toEqual(
expect.arrayContaining([expect.objectContaining({ status: "ok-token", silent: false })]),
);
});
it("skips sending alerts when showAlerts is disabled but still emits a skipped event", async () => {
state.visibility = { showAlerts: false, showOk: true, useIndicator: true };
replyResolverMock.mockResolvedValue({ text: "ALERT" });
await runWebHeartbeatOnce(buildRunArgs());
expect(senderMock).not.toHaveBeenCalled();
expect(state.events).toEqual(
expect.arrayContaining([
expect.objectContaining({ status: "skipped", reason: "alerts-disabled", preview: "ALERT" }),
]),
);
});
it("emits failed events when sending throws and rethrows the error", async () => {
replyResolverMock.mockResolvedValue({ text: "ALERT" });
senderMock.mockRejectedValueOnce(new Error("nope"));
await expect(runWebHeartbeatOnce(buildRunArgs())).rejects.toThrow("nope");
expect(state.events).toEqual(
expect.arrayContaining([
expect.objectContaining({ status: "failed", reason: "ERR:Error: nope" }),
]),
);
});
it("redacts recipient and omits body preview in heartbeat logs", async () => {
replyResolverMock.mockResolvedValue({ text: "sensitive heartbeat body" });
await runWebHeartbeatOnce(buildRunArgs({ dryRun: true }));
const expected = redactIdentifier("+123");
const heartbeatLogs = state.heartbeatInfoLogs.join("\n");
const childLoggerLogs = state.loggerInfoCalls.map((entry) => JSON.stringify(entry)).join("\n");
expect(heartbeatLogs).toContain(expected);
expect(heartbeatLogs).not.toContain("+123");
expect(heartbeatLogs).not.toContain("sensitive heartbeat body");
expect(childLoggerLogs).toContain(expected);
expect(childLoggerLogs).not.toContain("+123");
expect(childLoggerLogs).not.toContain("sensitive heartbeat body");
expect(childLoggerLogs).not.toContain('"preview"');
});
});

View File

@@ -1,330 +0,0 @@
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { newConnectionId } from "../reconnect.js";
import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
HEARTBEAT_TOKEN,
appendCronStyleCurrentTimeLine,
canonicalizeMainSessionAlias,
emitHeartbeatEvent,
formatError,
getRuntimeConfig,
getChildLogger,
getReplyFromConfig,
hasOutboundReplyContent,
loadSessionStore,
normalizeMainKey,
redactIdentifier,
resolveHeartbeatPrompt,
resolveHeartbeatReplyPayload,
resolveHeartbeatVisibility,
resolveIndicatorType,
resolveSendableOutboundReplyParts,
resolveSessionKey,
resolveStorePath,
resolveWhatsAppHeartbeatRecipients,
sendMessageWhatsApp,
stripHeartbeatToken,
updateSessionStore,
whatsappHeartbeatLog,
} from "./heartbeat-runner.runtime.js";
import { getSessionSnapshot } from "./session-snapshot.js";
function resolveDefaultAgentIdFromConfig(cfg: ReturnType<typeof getRuntimeConfig>): string {
const agents = cfg.agents?.list ?? [];
const chosen = agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? "main";
return normalizeOptionalLowercaseString(chosen) ?? "main";
}
export async function runWebHeartbeatOnce(opts: {
cfg?: ReturnType<typeof getRuntimeConfig>;
to: string;
verbose?: boolean;
replyResolver?: typeof getReplyFromConfig;
sender?: typeof sendMessageWhatsApp;
sessionId?: string;
overrideBody?: string;
dryRun?: boolean;
}) {
const { cfg: cfgOverride, to, verbose = false, sessionId, overrideBody, dryRun = false } = opts;
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
const sender = opts.sender ?? sendMessageWhatsApp;
const runId = newConnectionId();
const redactedTo = redactIdentifier(to);
const heartbeatLogger = getChildLogger({
module: "web-heartbeat",
runId,
to: redactedTo,
});
const cfg = cfgOverride ?? getRuntimeConfig();
// Resolve heartbeat visibility settings for WhatsApp
const visibility = resolveHeartbeatVisibility({ cfg, channel: "whatsapp" });
const heartbeatOkText = HEARTBEAT_TOKEN;
const maybeSendHeartbeatOk = async (): Promise<boolean> => {
if (!visibility.showOk) {
return false;
}
if (dryRun) {
whatsappHeartbeatLog.info(`[dry-run] heartbeat ok -> ${redactedTo}`);
return false;
}
const sendResult = await sender(to, heartbeatOkText, { verbose, cfg });
heartbeatLogger.info(
{
to: redactedTo,
messageId: sendResult.messageId,
chars: heartbeatOkText.length,
reason: "heartbeat-ok",
},
"heartbeat ok sent",
);
whatsappHeartbeatLog.info(`heartbeat ok sent to ${redactedTo} (id ${sendResult.messageId})`);
return true;
};
const sessionCfg = cfg.session;
const sessionScope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
// Canonicalize so the written key matches what read paths produce (#29683).
const rawSessionKey = resolveSessionKey(sessionScope, { From: to }, mainKey);
const sessionKey = canonicalizeMainSessionAlias({
cfg,
agentId: resolveDefaultAgentIdFromConfig(cfg),
sessionKey: rawSessionKey,
});
if (sessionId) {
const storePath = resolveStorePath(cfg.session?.store);
const store = loadSessionStore(storePath);
const current = store[sessionKey] ?? {};
store[sessionKey] = {
...current,
sessionId,
updatedAt: Date.now(),
};
await updateSessionStore(storePath, (nextStore) => {
const nextCurrent = nextStore[sessionKey] ?? current;
nextStore[sessionKey] = {
...nextCurrent,
sessionId,
updatedAt: Date.now(),
};
});
}
const sessionSnapshot = getSessionSnapshot(cfg, to, true, { sessionKey });
if (verbose) {
heartbeatLogger.info(
{
to: redactedTo,
sessionKey: sessionSnapshot.key,
sessionId: sessionId ?? sessionSnapshot.entry?.sessionId ?? null,
sessionFresh: sessionSnapshot.fresh,
resetMode: sessionSnapshot.resetPolicy.mode,
resetAtHour: sessionSnapshot.resetPolicy.atHour,
idleMinutes: sessionSnapshot.resetPolicy.idleMinutes ?? null,
dailyResetAt: sessionSnapshot.dailyResetAt ?? null,
idleExpiresAt: sessionSnapshot.idleExpiresAt ?? null,
},
"heartbeat session snapshot",
);
}
if (overrideBody && overrideBody.trim().length === 0) {
throw new Error("Override body must be non-empty when provided.");
}
try {
if (overrideBody) {
if (dryRun) {
whatsappHeartbeatLog.info(
`[dry-run] web send -> ${redactedTo} (${overrideBody.trim().length} chars, manual message)`,
);
return;
}
const sendResult = await sender(to, overrideBody, { verbose, cfg });
emitHeartbeatEvent({
status: "sent",
to,
preview: overrideBody.slice(0, 160),
hasMedia: false,
channel: "whatsapp",
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
});
heartbeatLogger.info(
{
to: redactedTo,
messageId: sendResult.messageId,
chars: overrideBody.length,
reason: "manual-message",
},
"manual heartbeat message sent",
);
whatsappHeartbeatLog.info(
`manual heartbeat sent to ${redactedTo} (id ${sendResult.messageId})`,
);
return;
}
if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) {
heartbeatLogger.info({ to: redactedTo, reason: "alerts-disabled" }, "heartbeat skipped");
emitHeartbeatEvent({
status: "skipped",
to,
reason: "alerts-disabled",
channel: "whatsapp",
});
return;
}
const replyResult = await replyResolver(
{
Body: appendCronStyleCurrentTimeLine(
resolveHeartbeatPrompt(cfg.agents?.defaults?.heartbeat?.prompt),
cfg,
Date.now(),
),
From: to,
To: to,
MessageSid: sessionId ?? sessionSnapshot.entry?.sessionId,
},
{ isHeartbeat: true },
cfg,
);
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
if (!replyPayload || !hasOutboundReplyContent(replyPayload)) {
heartbeatLogger.info(
{
to: redactedTo,
reason: "empty-reply",
sessionId: sessionSnapshot.entry?.sessionId ?? null,
},
"heartbeat skipped",
);
const okSent = await maybeSendHeartbeatOk();
emitHeartbeatEvent({
status: "ok-empty",
to,
channel: "whatsapp",
silent: !okSent,
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-empty") : undefined,
});
return;
}
const reply = resolveSendableOutboundReplyParts(replyPayload);
const hasMedia = reply.hasMedia;
const ackMaxChars = Math.max(
0,
cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
);
const stripped = stripHeartbeatToken(replyPayload.text, {
mode: "heartbeat",
maxAckChars: ackMaxChars,
});
if (stripped.shouldSkip && !hasMedia) {
// Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.
const storePath = resolveStorePath(cfg.session?.store);
const store = loadSessionStore(storePath);
if (sessionSnapshot.entry && store[sessionSnapshot.key]) {
store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt;
await updateSessionStore(storePath, (nextStore) => {
const nextEntry = nextStore[sessionSnapshot.key];
if (!nextEntry) {
return;
}
nextStore[sessionSnapshot.key] = {
...nextEntry,
updatedAt: sessionSnapshot.entry.updatedAt,
};
});
}
heartbeatLogger.info(
{ to: redactedTo, reason: "heartbeat-token", rawLength: replyPayload.text?.length },
"heartbeat skipped",
);
const okSent = await maybeSendHeartbeatOk();
emitHeartbeatEvent({
status: "ok-token",
to,
channel: "whatsapp",
silent: !okSent,
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined,
});
return;
}
if (hasMedia) {
heartbeatLogger.warn(
{ to: redactedTo },
"heartbeat reply contained media; sending text only",
);
}
const finalText = stripped.text || reply.text;
// Check if alerts are disabled for WhatsApp
if (!visibility.showAlerts) {
heartbeatLogger.info({ to: redactedTo, reason: "alerts-disabled" }, "heartbeat skipped");
emitHeartbeatEvent({
status: "skipped",
to,
reason: "alerts-disabled",
preview: finalText.slice(0, 200),
channel: "whatsapp",
hasMedia,
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
});
return;
}
if (dryRun) {
heartbeatLogger.info(
{ to: redactedTo, reason: "dry-run", chars: finalText.length },
"heartbeat dry-run",
);
whatsappHeartbeatLog.info(`[dry-run] heartbeat -> ${redactedTo} (${finalText.length} chars)`);
return;
}
const sendResult = await sender(to, finalText, { verbose, cfg });
emitHeartbeatEvent({
status: "sent",
to,
preview: finalText.slice(0, 160),
hasMedia,
channel: "whatsapp",
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
});
heartbeatLogger.info(
{
to: redactedTo,
messageId: sendResult.messageId,
chars: finalText.length,
},
"heartbeat sent",
);
whatsappHeartbeatLog.info(`heartbeat alert sent to ${redactedTo}`);
} catch (err) {
const reason = formatError(err);
heartbeatLogger.warn({ to: redactedTo, error: reason }, "heartbeat failed");
whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`);
emitHeartbeatEvent({
status: "failed",
to,
reason,
channel: "whatsapp",
indicatorType: visibility.useIndicator ? resolveIndicatorType("failed") : undefined,
});
throw err;
}
}
export function resolveHeartbeatRecipients(
cfg: ReturnType<typeof getRuntimeConfig>,
opts: { to?: string; all?: boolean; accountId?: string } = {},
) {
return resolveWhatsAppHeartbeatRecipients(cfg, opts);
}

View File

@@ -25,7 +25,6 @@ import {
resolveWhatsAppGroupRequireMention,
resolveWhatsAppGroupToolPolicy,
} from "./group-policy.js";
import { resolveWhatsAppHeartbeatRecipients } from "./heartbeat-recipients.js";
import { checkWhatsAppHeartbeatReady } from "./heartbeat.js";
import {
isWhatsAppGroupJid,
@@ -183,7 +182,6 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
...(accountId ? { accountId } : {}),
});
},
resolveRecipients: ({ cfg, opts }) => resolveWhatsAppHeartbeatRecipients(cfg, opts),
},
status: createAsyncComputedAccountStatusAdapter<ResolvedWhatsAppAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, {

View File

@@ -1,5 +0,0 @@
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
export { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
export { normalizeChannelId } from "openclaw/plugin-sdk/channel-targets";
export { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";

View File

@@ -1,197 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveWhatsAppHeartbeatRecipients } from "./heartbeat-recipients.js";
import type { OpenClawConfig } from "./runtime-api.js";
const loadSessionStoreMock = vi.hoisted(() => vi.fn());
vi.mock("./heartbeat-recipients.runtime.js", () => ({
DEFAULT_ACCOUNT_ID: "default",
loadSessionStore: loadSessionStoreMock,
resolveStorePath: vi.fn(() => "/tmp/test-sessions.json"),
normalizeChannelId: (value?: string | null) => {
const trimmed = value?.trim().toLowerCase();
return trimmed ? (trimmed as "whatsapp") : null;
},
normalizeE164: (value?: string | null) => {
const digits = (value ?? "").replace(/[^\d+]/g, "");
if (!digits) {
return "";
}
return digits.startsWith("+") ? digits : `+${digits}`;
},
}));
function makeCfg(overrides?: Partial<OpenClawConfig>): OpenClawConfig {
return {
bindings: [],
channels: {},
...overrides,
} as OpenClawConfig;
}
describe("resolveWhatsAppHeartbeatRecipients", () => {
function setSessionStore(store: Record<string, unknown>) {
loadSessionStoreMock.mockReturnValue(store);
}
function resolveWith(
cfgOverrides: Partial<OpenClawConfig> = {},
opts?: Parameters<typeof resolveWhatsAppHeartbeatRecipients>[1],
) {
return resolveWhatsAppHeartbeatRecipients(makeCfg(cfgOverrides), opts);
}
function setSingleUnauthorizedSessionWithAllowFrom() {
setSessionStore({
a: { lastChannel: "whatsapp", lastTo: "+15550000099", updatedAt: 2, sessionId: "a" },
});
}
beforeEach(() => {
loadSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({});
});
it("uses configured allowFrom recipients when session recipients are ambiguous", () => {
setSessionStore({
a: { lastChannel: "whatsapp", lastTo: "+15550000001", updatedAt: 2, sessionId: "a" },
b: { lastChannel: "whatsapp", lastTo: "+15550000002", updatedAt: 1, sessionId: "b" },
});
const result = resolveWith({
channels: { whatsapp: { allowFrom: ["+15550000001"] } as never },
});
expect(result).toEqual({ recipients: ["+15550000001"], source: "session-single" });
});
it("falls back to allowFrom when no session recipient is authorized", () => {
setSingleUnauthorizedSessionWithAllowFrom();
const result = resolveWith({
channels: { whatsapp: { allowFrom: ["+15550000001"] } as never },
});
expect(result).toEqual({ recipients: ["+15550000001"], source: "allowFrom" });
});
it("includes both session and allowFrom recipients when --all is set", () => {
setSingleUnauthorizedSessionWithAllowFrom();
const result = resolveWith(
{ channels: { whatsapp: { allowFrom: ["+15550000001"] } as never } },
{ all: true },
);
expect(result).toEqual({
recipients: ["+15550000099", "+15550000001"],
source: "all",
});
});
it("returns explicit --to recipient and source flag", () => {
setSessionStore({
a: { lastChannel: "whatsapp", lastTo: "+15550000099", updatedAt: 2, sessionId: "a" },
});
const result = resolveWith({}, { to: " +1 555 000 7777 " });
expect(result).toEqual({ recipients: ["+15550007777"], source: "flag" });
});
it("returns ambiguous session recipients when no allowFrom list exists", () => {
setSessionStore({
a: { lastChannel: "whatsapp", lastTo: "+15550000001", updatedAt: 2, sessionId: "a" },
b: { lastChannel: "whatsapp", lastTo: "+15550000002", updatedAt: 1, sessionId: "b" },
});
const result = resolveWith();
expect(result).toEqual({
recipients: ["+15550000001", "+15550000002"],
source: "session-ambiguous",
});
});
it("returns single session recipient when allowFrom is empty", () => {
setSessionStore({
a: { lastChannel: "whatsapp", lastTo: "+15550000001", updatedAt: 2, sessionId: "a" },
});
const result = resolveWith();
expect(result).toEqual({ recipients: ["+15550000001"], source: "session-single" });
});
it("returns all authorized session recipients when allowFrom matches multiple", () => {
setSessionStore({
a: { lastChannel: "whatsapp", lastTo: "+15550000001", updatedAt: 2, sessionId: "a" },
b: { lastChannel: "whatsapp", lastTo: "+15550000002", updatedAt: 1, sessionId: "b" },
c: { lastChannel: "whatsapp", lastTo: "+15550000003", updatedAt: 0, sessionId: "c" },
});
const result = resolveWith({
channels: { whatsapp: { allowFrom: ["+15550000001", "+15550000002"] } as never },
});
expect(result).toEqual({
recipients: ["+15550000001", "+15550000002"],
source: "session-ambiguous",
});
});
it("ignores session store when session scope is global", () => {
setSessionStore({
a: { lastChannel: "whatsapp", lastTo: "+15550000001", updatedAt: 2, sessionId: "a" },
});
const result = resolveWith({
session: { scope: "global" } as OpenClawConfig["session"],
channels: { whatsapp: { allowFrom: ["*", "+15550000009"] } as never },
});
expect(result).toEqual({ recipients: ["+15550000009"], source: "allowFrom" });
});
it("uses the requested account allowFrom config without pairing-store recipients", () => {
setSessionStore({
a: { lastChannel: "whatsapp", lastTo: "+15550000077", updatedAt: 2, sessionId: "a" },
});
const result = resolveWith(
{
channels: {
whatsapp: {
allowFrom: ["+15550000001"],
accounts: {
work: {
allowFrom: ["+15550000003"],
},
},
} as never,
},
},
{ accountId: "work" },
);
expect(result).toEqual({
recipients: ["+15550000003"],
source: "allowFrom",
});
});
it("uses configured defaultAccount allowFrom config when accountId is omitted", () => {
setSessionStore({
a: { lastChannel: "whatsapp", lastTo: "+15550000077", updatedAt: 2, sessionId: "a" },
});
const result = resolveWith({
channels: {
whatsapp: {
defaultAccount: "work",
allowFrom: ["+15550000001"],
accounts: {
work: {
allowFrom: ["+15550000003"],
},
},
} as never,
},
});
expect(result).toEqual({
recipients: ["+15550000003"],
source: "allowFrom",
});
});
});

View File

@@ -1,98 +0,0 @@
import { resolveDefaultWhatsAppAccountId, resolveWhatsAppAccount } from "./accounts.js";
import {
DEFAULT_ACCOUNT_ID,
loadSessionStore,
normalizeChannelId,
normalizeE164,
resolveStorePath,
type OpenClawConfig,
} from "./heartbeat-recipients.runtime.js";
type HeartbeatRecipientsResult = { recipients: string[]; source: string };
type HeartbeatRecipientsOpts = { to?: string; all?: boolean; accountId?: string };
function getSessionRecipients(cfg: OpenClawConfig) {
const sessionCfg = cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
if (scope === "global") {
return [];
}
const storePath = resolveStorePath(cfg.session?.store);
const store = loadSessionStore(storePath);
const isGroupKey = (key: string) =>
key.includes(":group:") || key.includes(":channel:") || key.includes("@g.us");
const isCronKey = (key: string) => key.startsWith("cron:");
const recipients = Object.entries(store)
.filter(([key]) => key !== "global" && key !== "unknown")
.filter(([key]) => !isGroupKey(key) && !isCronKey(key))
.map(([_, entry]) => ({
to:
normalizeChannelId(entry?.lastChannel) === "whatsapp" && entry?.lastTo
? normalizeE164(entry.lastTo)
: "",
updatedAt: entry?.updatedAt ?? 0,
}))
.filter(({ to }) => to.length > 1)
.toSorted((a, b) => b.updatedAt - a.updatedAt);
const seen = new Set<string>();
return recipients.filter((recipient) => {
if (seen.has(recipient.to)) {
return false;
}
seen.add(recipient.to);
return true;
});
}
export function resolveWhatsAppHeartbeatRecipients(
cfg: OpenClawConfig,
opts: HeartbeatRecipientsOpts = {},
): HeartbeatRecipientsResult {
if (opts.to) {
return { recipients: [normalizeE164(opts.to)], source: "flag" };
}
const sessionRecipients = getSessionRecipients(cfg);
const resolvedAccountId =
opts.accountId?.trim() || resolveDefaultWhatsAppAccountId(cfg) || DEFAULT_ACCOUNT_ID;
const configuredAllowFrom = (
resolveWhatsAppAccount({ cfg, accountId: resolvedAccountId }).allowFrom ?? []
)
.filter((value) => value !== "*")
.map(normalizeE164);
const unique = (list: string[]) => [...new Set(list.filter(Boolean))];
const allowFrom = unique(configuredAllowFrom);
if (opts.all) {
return {
recipients: unique([...sessionRecipients.map((entry) => entry.to), ...allowFrom]),
source: "all",
};
}
if (allowFrom.length > 0) {
const allowSet = new Set(allowFrom);
const authorizedSessionRecipients = sessionRecipients
.map((entry) => entry.to)
.filter((recipient) => allowSet.has(recipient));
if (authorizedSessionRecipients.length === 1) {
return { recipients: [authorizedSessionRecipients[0]], source: "session-single" };
}
if (authorizedSessionRecipients.length > 1) {
return { recipients: authorizedSessionRecipients, source: "session-ambiguous" };
}
return { recipients: allowFrom, source: "allowFrom" };
}
if (sessionRecipients.length === 1) {
return { recipients: [sessionRecipients[0].to], source: "session-single" };
}
if (sessionRecipients.length > 1) {
return { recipients: sessionRecipients.map((entry) => entry.to), source: "session-ambiguous" };
}
return { recipients: allowFrom, source: "allowFrom" };
}

View File

@@ -27,7 +27,6 @@ export {
resolveWhatsAppGroupIntroHint,
resolveWhatsAppMentionStripRegexes,
} from "./group-intro.js";
export { resolveWhatsAppHeartbeatRecipients } from "./heartbeat-recipients.js";
export { createWhatsAppOutboundBase } from "./outbound-base.js";
export {
isWhatsAppGroupJid,

View File

@@ -387,13 +387,6 @@ export type ChannelHeartbeatAdapter = {
threadId?: string | number | null;
deps?: ChannelHeartbeatDeps;
}) => Promise<void> | void;
resolveRecipients?: (params: {
cfg: OpenClawConfig;
opts?: { to?: string; all?: boolean; accountId?: string };
}) => {
recipients: string[];
source: string;
};
};
type ChannelDirectorySelfParams = {

View File

@@ -224,7 +224,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'export { handleWhatsAppAction, whatsAppActionRuntime } from "./src/action-runtime.js";',
'export { createWhatsAppLoginTool } from "./src/agent-tools-login.js";',
'export { formatWhatsAppWebAuthStatusState, getWebAuthAgeMs, hasWebCredsSync, logWebSelfId, logoutWeb, pickWebChannel, readCredsJsonRaw, readWebAuthExistsBestEffort, readWebAuthExistsForDecision, readWebAuthSnapshot, readWebAuthSnapshotBestEffort, readWebAuthState, readWebSelfId, readWebSelfIdentity, readWebSelfIdentityForDecision, resolveDefaultWebAuthDir, resolveWebCredsBackupPath, resolveWebCredsPath, restoreCredsFromBackupIfNeeded, WA_WEB_AUTH_DIR, webAuthExists, WHATSAPP_AUTH_UNSTABLE_CODE, WhatsAppAuthUnstableError, type WhatsAppWebAuthState } from "./src/auth-store.js";',
'export { DEFAULT_WEB_MEDIA_BYTES, HEARTBEAT_PROMPT, HEARTBEAT_TOKEN, monitorWebChannel, resolveHeartbeatRecipients, runWebHeartbeatOnce, SILENT_REPLY_TOKEN, stripHeartbeatToken, type WebChannelStatus, type WebMonitorTuning } from "./src/auto-reply.js";',
'export { DEFAULT_WEB_MEDIA_BYTES, HEARTBEAT_PROMPT, HEARTBEAT_TOKEN, monitorWebChannel, SILENT_REPLY_TOKEN, stripHeartbeatToken, type WebChannelStatus, type WebMonitorTuning } from "./src/auto-reply.js";',
'export { extractContactContext, extractLocationData, extractMediaPlaceholder, extractText, monitorWebInbox, resetWebInboundDedupe, type WebInboundMessage, type WebListenerCloseReason } from "./src/inbound.js";',
'export { loginWeb } from "./src/login.js";',
'export { getDefaultLocalRoots, loadWebMedia, loadWebMediaRaw, LocalMediaAccessError, optimizeImageToJpeg, optimizeImageToPng, type LocalMediaAccessErrorCode, type WebMediaResult } from "./src/media.js";',

View File

@@ -98,13 +98,11 @@ type WebChannelHeavyRuntimeModule = {
) => Promise<AgentToolResult<unknown>>;
monitorWebChannel: (...args: unknown[]) => Promise<unknown>;
monitorWebInbox: (...args: unknown[]) => Promise<unknown>;
runWebHeartbeatOnce: (...args: unknown[]) => Promise<unknown>;
startWebLoginWithQr: (...args: unknown[]) => Promise<unknown>;
waitForWaConnection: (sock: unknown) => Promise<void>;
waitForWebLogin: (...args: unknown[]) => Promise<unknown>;
extractMediaPlaceholder: (...args: unknown[]) => unknown;
extractText: (...args: unknown[]) => unknown;
resolveHeartbeatRecipients: (...args: unknown[]) => unknown;
};
type WebChannelRuntimeModuleKind = "heavy" | "light";
@@ -335,12 +333,6 @@ export async function optimizeImageToJpeg(
return await optimizeImageToJpegImpl(...args);
}
export async function runWebHeartbeatOnce(
...args: Parameters<WebChannelHeavyRuntimeModule["runWebHeartbeatOnce"]>
): ReturnType<WebChannelHeavyRuntimeModule["runWebHeartbeatOnce"]> {
return (await getHeavyExport("runWebHeartbeatOnce"))(...args);
}
export async function startWebLoginWithQr(
...args: Parameters<WebChannelHeavyRuntimeModule["startWebLoginWithQr"]>
): ReturnType<WebChannelHeavyRuntimeModule["startWebLoginWithQr"]> {
@@ -371,9 +363,3 @@ export function getDefaultLocalRoots(
): ReturnType<typeof getDefaultLocalRootsImpl> {
return getDefaultLocalRootsImpl(...args);
}
export function resolveHeartbeatRecipients(
...args: Parameters<WebChannelHeavyRuntimeModule["resolveHeartbeatRecipients"]>
): ReturnType<WebChannelHeavyRuntimeModule["resolveHeartbeatRecipients"]> {
return loadCurrentHeavyModuleSync().resolveHeartbeatRecipients(...args);
}