fix: skip whatsapp heartbeat when provider inactive

This commit is contained in:
Peter Steinberger
2025-12-27 19:34:01 +00:00
parent a61c27c4d0
commit 3a485a14a4
3 changed files with 104 additions and 1 deletions

View File

@@ -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.

View File

@@ -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 });
}
});
});

View File

@@ -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<boolean>;
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,