diff --git a/CHANGELOG.md b/CHANGELOG.md index 4502ad81e..e01eb8fac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ ### Fixes - Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam. - Heartbeat delivery now uses the last non-empty payload, preventing tool preambles from swallowing the final reply. +- Heartbeats now skip WhatsApp delivery when the web provider is inactive or unlinked (instead of logging “no active gateway listener”). - Heartbeat failure logs now include the error reason instead of `[object Object]`. - Duration strings now accept `h` (hours) where durations are parsed (e.g., heartbeat intervals). - WhatsApp inbound now normalizes more wrapper types so quoted reply bodies are extracted reliably. diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index 9d390bfbf..001b66319 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -160,7 +160,13 @@ describe("runHeartbeatOnce", () => { await runHeartbeatOnce({ cfg, - deps: { sendWhatsApp, getQueueSize: () => 0, nowMs: () => 0 }, + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, }); expect(sendWhatsApp).toHaveBeenCalledTimes(1); @@ -174,4 +180,59 @@ describe("runHeartbeatOnce", () => { await fs.rm(tmpDir, { recursive: true, force: true }); } }); + + it("skips WhatsApp delivery when not linked or running", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-hb-")); + const storePath = path.join(tmpDir, "sessions.json"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + await fs.writeFile( + storePath, + JSON.stringify( + { + main: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }, + null, + 2, + ), + ); + + const cfg: ClawdisConfig = { + agent: { + heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, + }, + routing: { allowFrom: ["*"] }, + session: { store: storePath }, + }; + + replySpy.mockResolvedValue({ text: "Heartbeat alert" }); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + const res = await runHeartbeatOnce({ + cfg, + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => false, + hasActiveWebListener: () => false, + }, + }); + + expect(res.status).toBe("skipped"); + expect(res).toMatchObject({ reason: "whatsapp-not-linked" }); + expect(sendWhatsApp).not.toHaveBeenCalled(); + } finally { + replySpy.mockRestore(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index f2f6dfc6f..9440df2e3 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -18,9 +18,11 @@ import { sendMessageDiscord } from "../discord/send.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging.js"; import { getQueueSize } from "../process/command-queue.js"; +import { webAuthExists } from "../providers/web/index.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { sendMessageTelegram } from "../telegram/send.js"; import { normalizeE164 } from "../utils.js"; +import { getActiveWebListener } from "../web/active-listener.js"; import { sendMessageWhatsApp } from "../web/outbound.js"; import { emitHeartbeatEvent } from "./heartbeat-events.js"; import { @@ -49,6 +51,8 @@ type HeartbeatDeps = { sendDiscord?: typeof sendMessageDiscord; getQueueSize?: (lane?: string) => number; nowMs?: () => number; + webAuthExists?: () => Promise; + hasActiveWebListener?: () => boolean; }; const log = createSubsystemLogger("gateway/heartbeat"); @@ -143,6 +147,26 @@ function resolveHeartbeatSender(params: { return candidates[0] ?? "heartbeat"; } +async function resolveWhatsAppReadiness( + cfg: ClawdisConfig, + deps?: HeartbeatDeps, +): Promise<{ ok: boolean; reason: string }> { + if (cfg.web?.enabled === false) { + return { ok: false, reason: "whatsapp-disabled" }; + } + const authExists = await (deps?.webAuthExists ?? webAuthExists)(); + if (!authExists) { + return { ok: false, reason: "whatsapp-not-linked" }; + } + const listenerActive = deps?.hasActiveWebListener + ? deps.hasActiveWebListener() + : Boolean(getActiveWebListener()); + if (!listenerActive) { + return { ok: false, reason: "whatsapp-not-running" }; + } + return { ok: true, reason: "ok" }; +} + export function resolveHeartbeatDeliveryTarget(params: { cfg: ClawdisConfig; entry?: SessionEntry; @@ -392,6 +416,23 @@ export async function runHeartbeatOnce(opts: { return { status: "ran", durationMs: Date.now() - startedAt }; } + if (delivery.channel === "whatsapp") { + const readiness = await resolveWhatsAppReadiness(cfg, opts.deps); + if (!readiness.ok) { + emitHeartbeatEvent({ + status: "skipped", + reason: readiness.reason, + preview: normalized.text?.slice(0, 200), + durationMs: Date.now() - startedAt, + hasMedia: mediaUrls.length > 0, + }); + log.info("heartbeat: whatsapp not ready", { + reason: readiness.reason, + }); + return { status: "skipped", reason: readiness.reason }; + } + } + const deps = { sendWhatsApp: opts.deps?.sendWhatsApp ?? sendMessageWhatsApp, sendTelegram: opts.deps?.sendTelegram ?? sendMessageTelegram,