From bd511be53dcd027d0e93953bf9b344e6fb4ebcba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 08:40:02 +0100 Subject: [PATCH] refactor(whatsapp): remove legacy heartbeat runners --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- extensions/whatsapp/runtime-api.ts | 2 - extensions/whatsapp/src/auto-reply.impl.ts | 1 - .../auto-reply/heartbeat-runner.runtime.ts | 33 -- .../src/auto-reply/heartbeat-runner.test.ts | 214 ------------ .../src/auto-reply/heartbeat-runner.ts | 330 ------------------ extensions/whatsapp/src/channel.ts | 2 - .../src/heartbeat-recipients.runtime.ts | 5 - .../whatsapp/src/heartbeat-recipients.test.ts | 197 ----------- .../whatsapp/src/heartbeat-recipients.ts | 98 ------ extensions/whatsapp/src/runtime-api.ts | 1 - src/channels/plugins/types.adapters.ts | 7 - .../plugin-sdk-runtime-api-guardrails.test.ts | 2 +- .../runtime/runtime-web-channel-plugin.ts | 14 - 14 files changed, 3 insertions(+), 907 deletions(-) delete mode 100644 extensions/whatsapp/src/auto-reply/heartbeat-runner.runtime.ts delete mode 100644 extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts delete mode 100644 extensions/whatsapp/src/auto-reply/heartbeat-runner.ts delete mode 100644 extensions/whatsapp/src/heartbeat-recipients.runtime.ts delete mode 100644 extensions/whatsapp/src/heartbeat-recipients.test.ts delete mode 100644 extensions/whatsapp/src/heartbeat-recipients.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index dc8766b354f..de278140249 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/extensions/whatsapp/runtime-api.ts b/extensions/whatsapp/runtime-api.ts index 9732e1bc5d3..46c68084a38 100644 --- a/extensions/whatsapp/runtime-api.ts +++ b/extensions/whatsapp/runtime-api.ts @@ -37,8 +37,6 @@ export { HEARTBEAT_PROMPT, HEARTBEAT_TOKEN, monitorWebChannel, - resolveHeartbeatRecipients, - runWebHeartbeatOnce, SILENT_REPLY_TOKEN, stripHeartbeatToken, type WebChannelStatus, diff --git a/extensions/whatsapp/src/auto-reply.impl.ts b/extensions/whatsapp/src/auto-reply.impl.ts index e936c63e732..13b7d59d094 100644 --- a/extensions/whatsapp/src/auto-reply.impl.ts +++ b/extensions/whatsapp/src/auto-reply.impl.ts @@ -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"; diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.runtime.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.runtime.ts deleted file mode 100644 index ed8cf20c642..00000000000 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.runtime.ts +++ /dev/null @@ -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"; diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts deleted file mode 100644 index 05f0fde03d0..00000000000 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts +++ /dev/null @@ -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, - 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; - let sender: typeof sendMessageWhatsApp; - let replyResolverMock: ReturnType; - let replyResolver: typeof getReplyFromConfig; - let runWebHeartbeatOnce: typeof import("./heartbeat-runner.js").runWebHeartbeatOnce; - - const buildRunArgs = (overrides: Record = {}) => ({ - 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"'); - }); -}); diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts deleted file mode 100644 index 8e5a33b7ff7..00000000000 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts +++ /dev/null @@ -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): 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; - 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 => { - 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, - opts: { to?: string; all?: boolean; accountId?: string } = {}, -) { - return resolveWhatsAppHeartbeatRecipients(cfg, opts); -} diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index ac423e5531d..56755448053 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -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 = ...(accountId ? { accountId } : {}), }); }, - resolveRecipients: ({ cfg, opts }) => resolveWhatsAppHeartbeatRecipients(cfg, opts), }, status: createAsyncComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { diff --git a/extensions/whatsapp/src/heartbeat-recipients.runtime.ts b/extensions/whatsapp/src/heartbeat-recipients.runtime.ts deleted file mode 100644 index 8d1a83a9a2a..00000000000 --- a/extensions/whatsapp/src/heartbeat-recipients.runtime.ts +++ /dev/null @@ -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"; diff --git a/extensions/whatsapp/src/heartbeat-recipients.test.ts b/extensions/whatsapp/src/heartbeat-recipients.test.ts deleted file mode 100644 index 2e522d47460..00000000000 --- a/extensions/whatsapp/src/heartbeat-recipients.test.ts +++ /dev/null @@ -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 { - return { - bindings: [], - channels: {}, - ...overrides, - } as OpenClawConfig; -} - -describe("resolveWhatsAppHeartbeatRecipients", () => { - function setSessionStore(store: Record) { - loadSessionStoreMock.mockReturnValue(store); - } - - function resolveWith( - cfgOverrides: Partial = {}, - opts?: Parameters[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", - }); - }); -}); diff --git a/extensions/whatsapp/src/heartbeat-recipients.ts b/extensions/whatsapp/src/heartbeat-recipients.ts deleted file mode 100644 index 961ba358ddb..00000000000 --- a/extensions/whatsapp/src/heartbeat-recipients.ts +++ /dev/null @@ -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(); - 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" }; -} diff --git a/extensions/whatsapp/src/runtime-api.ts b/extensions/whatsapp/src/runtime-api.ts index fbabe5890c2..1e6a7ed8ae4 100644 --- a/extensions/whatsapp/src/runtime-api.ts +++ b/extensions/whatsapp/src/runtime-api.ts @@ -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, diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index b7ec912c993..477f16a54fc 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -387,13 +387,6 @@ export type ChannelHeartbeatAdapter = { threadId?: string | number | null; deps?: ChannelHeartbeatDeps; }) => Promise | void; - resolveRecipients?: (params: { - cfg: OpenClawConfig; - opts?: { to?: string; all?: boolean; accountId?: string }; - }) => { - recipients: string[]; - source: string; - }; }; type ChannelDirectorySelfParams = { diff --git a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts index de390bb9721..c0d86a45ea8 100644 --- a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts @@ -224,7 +224,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { '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";', diff --git a/src/plugins/runtime/runtime-web-channel-plugin.ts b/src/plugins/runtime/runtime-web-channel-plugin.ts index 9b5c4781ac7..843ee9b0e1b 100644 --- a/src/plugins/runtime/runtime-web-channel-plugin.ts +++ b/src/plugins/runtime/runtime-web-channel-plugin.ts @@ -98,13 +98,11 @@ type WebChannelHeavyRuntimeModule = { ) => Promise>; monitorWebChannel: (...args: unknown[]) => Promise; monitorWebInbox: (...args: unknown[]) => Promise; - runWebHeartbeatOnce: (...args: unknown[]) => Promise; startWebLoginWithQr: (...args: unknown[]) => Promise; waitForWaConnection: (sock: unknown) => Promise; waitForWebLogin: (...args: unknown[]) => Promise; 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 -): ReturnType { - return (await getHeavyExport("runWebHeartbeatOnce"))(...args); -} - export async function startWebLoginWithQr( ...args: Parameters ): ReturnType { @@ -371,9 +363,3 @@ export function getDefaultLocalRoots( ): ReturnType { return getDefaultLocalRootsImpl(...args); } - -export function resolveHeartbeatRecipients( - ...args: Parameters -): ReturnType { - return loadCurrentHeavyModuleSync().resolveHeartbeatRecipients(...args); -}