import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as replyModule from "../auto-reply/reply.js"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import { runHeartbeatOnce } from "./heartbeat-runner.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createPluginRuntime } from "../plugins/runtime/index.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js"; // Avoid pulling optional runtime deps during isolated runs. vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); beforeEach(() => { const runtime = createPluginRuntime(); setTelegramRuntime(runtime); setWhatsAppRuntime(runtime); setActivePluginRegistry( createTestRegistry([ { pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" }, { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, ]), ); }); describe("resolveHeartbeatIntervalMs", () => { it("respects ackMaxChars for heartbeat acks", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { const cfg: ClawdbotConfig = { agents: { defaults: { heartbeat: { every: "5m", target: "whatsapp", ackMaxChars: 0, }, }, }, channels: { whatsapp: { allowFrom: ["*"] } }, session: { store: storePath }, }; const sessionKey = resolveMainSessionKey(cfg); await fs.writeFile( storePath, JSON.stringify( { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, }, null, 2, ), ); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK 🦞" }); const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid", }); await runHeartbeatOnce({ cfg, deps: { sendWhatsApp, getQueueSize: () => 0, nowMs: () => 0, webAuthExists: async () => true, hasActiveWebListener: () => true, }, }); expect(sendWhatsApp).toHaveBeenCalled(); } finally { replySpy.mockRestore(); await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("skips delivery for markup-wrapped HEARTBEAT_OK", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { const cfg: ClawdbotConfig = { agents: { defaults: { heartbeat: { every: "5m", target: "whatsapp", }, }, }, channels: { whatsapp: { allowFrom: ["*"] } }, session: { store: storePath }, }; const sessionKey = resolveMainSessionKey(cfg); await fs.writeFile( storePath, JSON.stringify( { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, }, null, 2, ), ); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid", }); await runHeartbeatOnce({ cfg, deps: { sendWhatsApp, getQueueSize: () => 0, nowMs: () => 0, webAuthExists: async () => true, hasActiveWebListener: () => true, }, }); expect(sendWhatsApp).not.toHaveBeenCalled(); } finally { replySpy.mockRestore(); await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("does not regress updatedAt when restoring heartbeat sessions", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { const originalUpdatedAt = 1000; const bumpedUpdatedAt = 2000; const cfg: ClawdbotConfig = { agents: { defaults: { heartbeat: { every: "5m", target: "whatsapp", }, }, }, channels: { whatsapp: { allowFrom: ["*"] } }, session: { store: storePath }, }; const sessionKey = resolveMainSessionKey(cfg); await fs.writeFile( storePath, JSON.stringify( { [sessionKey]: { sessionId: "sid", updatedAt: originalUpdatedAt, lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, }, null, 2, ), ); replySpy.mockImplementationOnce(async () => { const raw = await fs.readFile(storePath, "utf-8"); const parsed = JSON.parse(raw) as Record; if (parsed[sessionKey]) { parsed[sessionKey] = { ...parsed[sessionKey], updatedAt: bumpedUpdatedAt, }; } await fs.writeFile(storePath, JSON.stringify(parsed, null, 2)); return { text: "" }; }); await runHeartbeatOnce({ cfg, deps: { getQueueSize: () => 0, nowMs: () => 0, webAuthExists: async () => true, hasActiveWebListener: () => true, }, }); const finalStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< string, { updatedAt?: number } | undefined >; expect(finalStore[sessionKey]?.updatedAt).toBe(bumpedUpdatedAt); } finally { replySpy.mockRestore(); 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(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { const cfg: ClawdbotConfig = { agents: { defaults: { heartbeat: { every: "5m", target: "whatsapp" }, }, }, channels: { whatsapp: { allowFrom: ["*"] } }, session: { store: storePath }, }; const sessionKey = resolveMainSessionKey(cfg); await fs.writeFile( storePath, JSON.stringify( { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, }, null, 2, ), ); 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 }); } }); it("passes through accountId for telegram heartbeats", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; process.env.TELEGRAM_BOT_TOKEN = ""; try { const cfg: ClawdbotConfig = { agents: { defaults: { heartbeat: { every: "5m", target: "telegram" }, }, }, channels: { telegram: { botToken: "test-bot-token-123" } }, session: { store: storePath }, }; const sessionKey = resolveMainSessionKey(cfg); await fs.writeFile( storePath, JSON.stringify( { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), lastChannel: "telegram", lastProvider: "telegram", lastTo: "123456", }, }, null, 2, ), ); replySpy.mockResolvedValue({ text: "Hello from heartbeat" }); const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "123456", }); await runHeartbeatOnce({ cfg, deps: { sendTelegram, getQueueSize: () => 0, nowMs: () => 0, }, }); expect(sendTelegram).toHaveBeenCalledTimes(1); expect(sendTelegram).toHaveBeenCalledWith( "123456", "Hello from heartbeat", expect.objectContaining({ accountId: undefined, verbose: false }), ); } finally { replySpy.mockRestore(); if (prevTelegramToken === undefined) { delete process.env.TELEGRAM_BOT_TOKEN; } else { process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; } await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("does not pre-resolve telegram accountId (allows config-only account tokens)", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; process.env.TELEGRAM_BOT_TOKEN = ""; try { const cfg: ClawdbotConfig = { agents: { defaults: { heartbeat: { every: "5m", target: "telegram" }, }, }, channels: { telegram: { accounts: { work: { botToken: "test-bot-token-123" }, }, }, }, session: { store: storePath }, }; const sessionKey = resolveMainSessionKey(cfg); await fs.writeFile( storePath, JSON.stringify( { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), lastChannel: "telegram", lastProvider: "telegram", lastTo: "123456", }, }, null, 2, ), ); replySpy.mockResolvedValue({ text: "Hello from heartbeat" }); const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "123456", }); await runHeartbeatOnce({ cfg, deps: { sendTelegram, getQueueSize: () => 0, nowMs: () => 0, }, }); expect(sendTelegram).toHaveBeenCalledTimes(1); expect(sendTelegram).toHaveBeenCalledWith( "123456", "Hello from heartbeat", expect.objectContaining({ accountId: undefined, verbose: false }), ); } finally { replySpy.mockRestore(); if (prevTelegramToken === undefined) { delete process.env.TELEGRAM_BOT_TOKEN; } else { process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; } await fs.rm(tmpDir, { recursive: true, force: true }); } }); });