diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index ac98e20dec..bd6b1976a3 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -7,8 +7,19 @@ import type { TelegramConfig } from "./types.telegram.js"; import type { WhatsAppConfig } from "./types.whatsapp.js"; import type { GroupPolicy } from "./types.base.js"; +export type ChannelHeartbeatVisibilityConfig = { + /** Show HEARTBEAT_OK acknowledgments in chat (default: false). */ + showOk?: boolean; + /** Show heartbeat alerts with actual content (default: true). */ + showAlerts?: boolean; + /** Emit indicator events for UI status display (default: true). */ + useIndicator?: boolean; +}; + export type ChannelDefaultsConfig = { groupPolicy?: GroupPolicy; + /** Default heartbeat visibility for all channels. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; }; export type ChannelsConfig = { diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 42e1d49f2f..f2fc68ffac 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -6,6 +6,7 @@ import type { OutboundRetryConfig, ReplyToMode, } from "./types.base.js"; +import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; import type { GroupToolPolicyConfig } from "./types.tools.js"; @@ -121,6 +122,8 @@ export type DiscordAccountConfig = { dm?: DiscordDmConfig; /** New per-guild config keyed by guild id or slug. */ guilds?: Record; + /** Heartbeat visibility settings for this channel. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; }; export type DiscordConfig = { diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index 72e298378e..ca83c0fe00 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -4,6 +4,7 @@ import type { GroupPolicy, MarkdownConfig, } from "./types.base.js"; +import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; import type { GroupToolPolicyConfig } from "./types.tools.js"; @@ -63,6 +64,8 @@ export type IMessageAccountConfig = { tools?: GroupToolPolicyConfig; } >; + /** Heartbeat visibility settings for this channel. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; }; export type IMessageConfig = { diff --git a/src/config/types.msteams.ts b/src/config/types.msteams.ts index 98b707500f..05e27527af 100644 --- a/src/config/types.msteams.ts +++ b/src/config/types.msteams.ts @@ -4,6 +4,7 @@ import type { GroupPolicy, MarkdownConfig, } from "./types.base.js"; +import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; import type { GroupToolPolicyConfig } from "./types.tools.js"; @@ -94,4 +95,6 @@ export type MSTeamsConfig = { mediaMaxMb?: number; /** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2"). */ sharePointSiteId?: string; + /** Heartbeat visibility settings for this channel. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; }; diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index f46fb0f8fe..94cb82f3df 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -4,6 +4,7 @@ import type { GroupPolicy, MarkdownConfig, } from "./types.base.js"; +import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist"; @@ -63,6 +64,8 @@ export type SignalAccountConfig = { reactionNotifications?: SignalReactionNotificationMode; /** Allowlist for reaction notifications when mode is allowlist. */ reactionAllowlist?: Array; + /** Heartbeat visibility settings for this channel. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; }; export type SignalConfig = { diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index e71b2e4233..0662bf36fa 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -5,6 +5,7 @@ import type { MarkdownConfig, ReplyToMode, } from "./types.base.js"; +import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; import type { GroupToolPolicyConfig } from "./types.tools.js"; @@ -136,6 +137,8 @@ export type SlackAccountConfig = { slashCommand?: SlackSlashCommandConfig; dm?: SlackDmConfig; channels?: Record; + /** Heartbeat visibility settings for this channel. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; }; export type SlackConfig = { diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 12894bcb78..1ef7e7387b 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -7,6 +7,7 @@ import type { OutboundRetryConfig, ReplyToMode, } from "./types.base.js"; +import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; import type { GroupToolPolicyConfig } from "./types.tools.js"; @@ -113,6 +114,8 @@ export type TelegramAccountConfig = { * - "extensive": agent can react liberally when appropriate */ reactionLevel?: "off" | "ack" | "minimal" | "extensive"; + /** Heartbeat visibility settings for this channel. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; }; export type TelegramTopicConfig = { diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 41e6e36d98..ce1851ea04 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -4,6 +4,7 @@ import type { GroupPolicy, MarkdownConfig, } from "./types.base.js"; +import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; import type { GroupToolPolicyConfig } from "./types.tools.js"; @@ -86,6 +87,8 @@ export type WhatsAppConfig = { }; /** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */ debounceMs?: number; + /** Heartbeat visibility settings for this channel. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; }; export type WhatsAppAccountConfig = { @@ -147,4 +150,6 @@ export type WhatsAppAccountConfig = { }; /** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */ debounceMs?: number; + /** Heartbeat visibility settings for this account. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; }; diff --git a/src/config/zod-schema.channels.ts b/src/config/zod-schema.channels.ts new file mode 100644 index 0000000000..ebabe1bae9 --- /dev/null +++ b/src/config/zod-schema.channels.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ChannelHeartbeatVisibilitySchema = z + .object({ + showOk: z.boolean().optional(), + showAlerts: z.boolean().optional(), + useIndicator: z.boolean().optional(), + }) + .strict() + .optional(); diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index fc2c4480e7..d67e9420bb 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -15,6 +15,7 @@ import { requireOpenAllowFrom, } from "./zod-schema.core.js"; import { ToolPolicySchema } from "./zod-schema.agent-runtime.js"; +import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js"; import { normalizeTelegramCommandDescription, normalizeTelegramCommandName, @@ -122,6 +123,7 @@ export const TelegramAccountSchemaBase = z .optional(), reactionNotifications: z.enum(["off", "own", "all"]).optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, }) .strict(); @@ -241,6 +243,7 @@ export const DiscordAccountSchema = z replyToMode: ReplyToModeSchema.optional(), dm: DiscordDmSchema.optional(), guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, }) .strict(); @@ -351,6 +354,7 @@ export const SlackAccountSchema = z .optional(), dm: SlackDmSchema.optional(), channels: z.record(z.string(), SlackChannelSchema.optional()).optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, }) .strict(); @@ -416,6 +420,7 @@ export const SignalAccountSchemaBase = z mediaMaxMb: z.number().int().positive().optional(), reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, }) .strict(); @@ -477,6 +482,7 @@ export const IMessageAccountSchemaBase = z .optional(), ) .optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, }) .strict(); @@ -553,6 +559,7 @@ export const BlueBubblesAccountSchemaBase = z blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), groups: z.record(z.string(), BlueBubblesGroupConfigSchema.optional()).optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, }) .strict(); @@ -630,6 +637,7 @@ export const MSTeamsConfigSchema = z mediaMaxMb: z.number().positive().optional(), /** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2") */ sharePointSiteId: z.string().optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, }) .strict() .superRefine((value, ctx) => { diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index b33e7f5d18..5a0d62379b 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -8,6 +8,7 @@ import { MarkdownConfigSchema, } from "./zod-schema.core.js"; import { ToolPolicySchema } from "./zod-schema.agent-runtime.js"; +import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js"; export const WhatsAppAccountSchema = z .object({ @@ -53,6 +54,7 @@ export const WhatsAppAccountSchema = z .strict() .optional(), debounceMs: z.number().int().nonnegative().optional().default(0), + heartbeat: ChannelHeartbeatVisibilitySchema, }) .strict() .superRefine((value, ctx) => { @@ -115,6 +117,7 @@ export const WhatsAppConfigSchema = z .strict() .optional(), debounceMs: z.number().int().nonnegative().optional().default(0), + heartbeat: ChannelHeartbeatVisibilitySchema, }) .strict() .superRefine((value, ctx) => { diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index a581197020..d69afcfb42 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -11,15 +11,18 @@ import { } from "./zod-schema.providers-core.js"; import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js"; import { GroupPolicySchema } from "./zod-schema.core.js"; +import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js"; export * from "./zod-schema.providers-core.js"; export * from "./zod-schema.providers-whatsapp.js"; +export { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js"; export const ChannelsSchema = z .object({ defaults: z .object({ groupPolicy: GroupPolicySchema.optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, }) .strict() .optional(), diff --git a/src/infra/heartbeat-events.ts b/src/infra/heartbeat-events.ts index 33356117f0..3055079c80 100644 --- a/src/infra/heartbeat-events.ts +++ b/src/infra/heartbeat-events.ts @@ -1,3 +1,5 @@ +export type HeartbeatIndicatorType = "ok" | "alert" | "error"; + export type HeartbeatEventPayload = { ts: number; status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed"; @@ -6,8 +8,30 @@ export type HeartbeatEventPayload = { durationMs?: number; hasMedia?: boolean; reason?: string; + /** The channel this heartbeat was sent to. */ + channel?: string; + /** Whether the message was silently suppressed (showOk: false). */ + silent?: boolean; + /** Indicator type for UI status display. */ + indicatorType?: HeartbeatIndicatorType; }; +export function resolveIndicatorType( + status: HeartbeatEventPayload["status"], +): HeartbeatIndicatorType | undefined { + switch (status) { + case "ok-empty": + case "ok-token": + return "ok"; + case "sent": + return "alert"; + case "failed": + return "error"; + case "skipped": + return undefined; + } +} + let lastHeartbeat: HeartbeatEventPayload | null = null; const listeners = new Set<(evt: HeartbeatEventPayload) => void>(); diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index f622b640e4..5af433d6c6 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -92,6 +92,135 @@ describe("resolveHeartbeatIntervalMs", () => { } }); + it("sends HEARTBEAT_OK when visibility.showOk is true", 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: { + workspace: tmpDir, + heartbeat: { + every: "5m", + target: "whatsapp", + }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"], heartbeat: { showOk: true } } }, + 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).toHaveBeenCalledTimes(1); + expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "HEARTBEAT_OK", expect.any(Object)); + } finally { + replySpy.mockRestore(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("skips heartbeat LLM calls when visibility disables all output", 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: { + workspace: tmpDir, + heartbeat: { + every: "5m", + target: "whatsapp", + }, + }, + }, + channels: { + whatsapp: { + allowFrom: ["*"], + heartbeat: { showOk: false, showAlerts: false, useIndicator: false }, + }, + }, + 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, + ), + ); + + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + const result = await runHeartbeatOnce({ + cfg, + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(sendWhatsApp).not.toHaveBeenCalled(); + expect(result).toEqual({ status: "skipped", reason: "alerts-disabled" }); + } 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"); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 9c8210acbc..435193a0e2 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -16,6 +16,7 @@ import { resolveHeartbeatPrompt as resolveHeartbeatPromptText, stripHeartbeatToken, } from "../auto-reply/heartbeat.js"; +import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { getChannelPlugin } from "../channels/plugins/index.js"; @@ -39,7 +40,8 @@ import { getQueueSize } from "../process/command-queue.js"; import { CommandLane } from "../process/lanes.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js"; -import { emitHeartbeatEvent } from "./heartbeat-events.js"; +import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js"; +import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js"; import { type HeartbeatRunResult, type HeartbeatWakeHandler, @@ -471,7 +473,16 @@ export async function runHeartbeatOnce(opts: { const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat); const previousUpdatedAt = entry?.updatedAt; const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); + const visibility = + delivery.channel !== "none" + ? resolveHeartbeatVisibility({ + cfg, + channel: delivery.channel, + accountId: delivery.accountId, + }) + : { showOk: false, showAlerts: true, useIndicator: true }; const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery }); + const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix; const prompt = resolveHeartbeatPrompt(cfg, heartbeat); const ctx = { Body: prompt, @@ -480,6 +491,43 @@ export async function runHeartbeatOnce(opts: { Provider: "heartbeat", SessionKey: sessionKey, }; + if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) { + emitHeartbeatEvent({ + status: "skipped", + reason: "alerts-disabled", + durationMs: Date.now() - startedAt, + channel: delivery.channel !== "none" ? delivery.channel : undefined, + }); + return { status: "skipped", reason: "alerts-disabled" }; + } + + const heartbeatOkText = responsePrefix + ? `${responsePrefix} ${HEARTBEAT_TOKEN}` + : HEARTBEAT_TOKEN; + const canAttemptHeartbeatOk = Boolean( + visibility.showOk && delivery.channel !== "none" && delivery.to, + ); + const maybeSendHeartbeatOk = async () => { + if (!canAttemptHeartbeatOk || delivery.channel === "none" || !delivery.to) return false; + const heartbeatPlugin = getChannelPlugin(delivery.channel); + if (heartbeatPlugin?.heartbeat?.checkReady) { + const readiness = await heartbeatPlugin.heartbeat.checkReady({ + cfg, + accountId: delivery.accountId, + deps: opts.deps, + }); + if (!readiness.ok) return false; + } + await deliverOutboundPayloads({ + cfg, + channel: delivery.channel, + to: delivery.to, + accountId: delivery.accountId, + payloads: [{ text: heartbeatOkText }], + deps: opts.deps, + }); + return true; + }; try { const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg); @@ -498,10 +546,14 @@ export async function runHeartbeatOnce(opts: { sessionKey, updatedAt: previousUpdatedAt, }); + const okSent = await maybeSendHeartbeatOk(); emitHeartbeatEvent({ status: "ok-empty", reason: opts.reason, durationMs: Date.now() - startedAt, + channel: delivery.channel !== "none" ? delivery.channel : undefined, + silent: !okSent, + indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-empty") : undefined, }); return { status: "ran", durationMs: Date.now() - startedAt }; } @@ -509,7 +561,7 @@ export async function runHeartbeatOnce(opts: { const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat); const normalized = normalizeHeartbeatReply( replyPayload, - resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix, + responsePrefix, ackMaxChars, ); const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia; @@ -519,10 +571,14 @@ export async function runHeartbeatOnce(opts: { sessionKey, updatedAt: previousUpdatedAt, }); + const okSent = await maybeSendHeartbeatOk(); emitHeartbeatEvent({ status: "ok-token", reason: opts.reason, durationMs: Date.now() - startedAt, + channel: delivery.channel !== "none" ? delivery.channel : undefined, + silent: !okSent, + indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined, }); return { status: "ran", durationMs: Date.now() - startedAt }; } @@ -556,6 +612,7 @@ export async function runHeartbeatOnce(opts: { preview: normalized.text.slice(0, 200), durationMs: Date.now() - startedAt, hasMedia: false, + channel: delivery.channel !== "none" ? delivery.channel : undefined, }); return { status: "ran", durationMs: Date.now() - startedAt }; } @@ -579,6 +636,20 @@ export async function runHeartbeatOnce(opts: { return { status: "ran", durationMs: Date.now() - startedAt }; } + if (!visibility.showAlerts) { + await restoreHeartbeatUpdatedAt({ storePath, sessionKey, updatedAt: previousUpdatedAt }); + emitHeartbeatEvent({ + status: "skipped", + reason: "alerts-disabled", + preview: previewText?.slice(0, 200), + durationMs: Date.now() - startedAt, + channel: delivery.channel, + hasMedia: mediaUrls.length > 0, + indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined, + }); + return { status: "ran", durationMs: Date.now() - startedAt }; + } + const deliveryAccountId = delivery.accountId; const heartbeatPlugin = getChannelPlugin(delivery.channel); if (heartbeatPlugin?.heartbeat?.checkReady) { @@ -594,6 +665,7 @@ export async function runHeartbeatOnce(opts: { preview: previewText?.slice(0, 200), durationMs: Date.now() - startedAt, hasMedia: mediaUrls.length > 0, + channel: delivery.channel, }); log.info("heartbeat: channel not ready", { channel: delivery.channel, @@ -642,6 +714,8 @@ export async function runHeartbeatOnce(opts: { preview: previewText?.slice(0, 200), durationMs: Date.now() - startedAt, hasMedia: mediaUrls.length > 0, + channel: delivery.channel, + indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined, }); return { status: "ran", durationMs: Date.now() - startedAt }; } catch (err) { @@ -650,6 +724,8 @@ export async function runHeartbeatOnce(opts: { status: "failed", reason, durationMs: Date.now() - startedAt, + channel: delivery.channel !== "none" ? delivery.channel : undefined, + indicatorType: visibility.useIndicator ? resolveIndicatorType("failed") : undefined, }); log.error(`heartbeat failed: ${reason}`, { error: reason }); return { status: "failed", reason }; diff --git a/src/infra/heartbeat-visibility.test.ts b/src/infra/heartbeat-visibility.test.ts new file mode 100644 index 0000000000..17a7dc1289 --- /dev/null +++ b/src/infra/heartbeat-visibility.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js"; + +describe("resolveHeartbeatVisibility", () => { + it("returns default values when no config is provided", () => { + const cfg = {} as ClawdbotConfig; + const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" }); + + expect(result).toEqual({ + showOk: false, + showAlerts: true, + useIndicator: true, + }); + }); + + it("uses channel defaults when provided", () => { + const cfg = { + channels: { + defaults: { + heartbeat: { + showOk: true, + showAlerts: false, + useIndicator: false, + }, + }, + }, + } as ClawdbotConfig; + + const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" }); + + expect(result).toEqual({ + showOk: true, + showAlerts: false, + useIndicator: false, + }); + }); + + it("per-channel config overrides channel defaults", () => { + const cfg = { + channels: { + defaults: { + heartbeat: { + showOk: false, + showAlerts: true, + useIndicator: true, + }, + }, + telegram: { + heartbeat: { + showOk: true, + }, + }, + }, + } as ClawdbotConfig; + + const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" }); + + expect(result).toEqual({ + showOk: true, + showAlerts: true, + useIndicator: true, + }); + }); + + it("per-account config overrides per-channel config", () => { + const cfg = { + channels: { + defaults: { + heartbeat: { + showOk: false, + showAlerts: true, + useIndicator: true, + }, + }, + telegram: { + heartbeat: { + showOk: false, + showAlerts: false, + }, + accounts: { + primary: { + heartbeat: { + showOk: true, + showAlerts: true, + }, + }, + }, + }, + }, + } as ClawdbotConfig; + + const result = resolveHeartbeatVisibility({ + cfg, + channel: "telegram", + accountId: "primary", + }); + + expect(result).toEqual({ + showOk: true, + showAlerts: true, + useIndicator: true, + }); + }); + + it("falls through to defaults when account has no heartbeat config", () => { + const cfg = { + channels: { + defaults: { + heartbeat: { + showOk: false, + }, + }, + telegram: { + heartbeat: { + showAlerts: false, + }, + accounts: { + primary: {}, + }, + }, + }, + } as ClawdbotConfig; + + const result = resolveHeartbeatVisibility({ + cfg, + channel: "telegram", + accountId: "primary", + }); + + expect(result).toEqual({ + showOk: false, + showAlerts: false, + useIndicator: true, + }); + }); + + it("handles missing accountId gracefully", () => { + const cfg = { + channels: { + telegram: { + heartbeat: { + showOk: true, + }, + accounts: { + primary: { + heartbeat: { + showOk: false, + }, + }, + }, + }, + }, + } as ClawdbotConfig; + + const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" }); + + expect(result.showOk).toBe(true); + }); + + it("handles non-existent account gracefully", () => { + const cfg = { + channels: { + telegram: { + heartbeat: { + showOk: true, + }, + accounts: { + primary: { + heartbeat: { + showOk: false, + }, + }, + }, + }, + }, + } as ClawdbotConfig; + + const result = resolveHeartbeatVisibility({ + cfg, + channel: "telegram", + accountId: "nonexistent", + }); + + expect(result.showOk).toBe(true); + }); + + it("works with whatsapp channel", () => { + const cfg = { + channels: { + whatsapp: { + heartbeat: { + showOk: true, + showAlerts: false, + }, + }, + }, + } as ClawdbotConfig; + + const result = resolveHeartbeatVisibility({ cfg, channel: "whatsapp" }); + + expect(result).toEqual({ + showOk: true, + showAlerts: false, + useIndicator: true, + }); + }); + + it("works with discord channel", () => { + const cfg = { + channels: { + discord: { + heartbeat: { + useIndicator: false, + }, + }, + }, + } as ClawdbotConfig; + + const result = resolveHeartbeatVisibility({ cfg, channel: "discord" }); + + expect(result).toEqual({ + showOk: false, + showAlerts: true, + useIndicator: false, + }); + }); + + it("works with slack channel", () => { + const cfg = { + channels: { + slack: { + heartbeat: { + showOk: true, + showAlerts: true, + useIndicator: true, + }, + }, + }, + } as ClawdbotConfig; + + const result = resolveHeartbeatVisibility({ cfg, channel: "slack" }); + + expect(result).toEqual({ + showOk: true, + showAlerts: true, + useIndicator: true, + }); + }); +}); diff --git a/src/infra/heartbeat-visibility.ts b/src/infra/heartbeat-visibility.ts new file mode 100644 index 0000000000..75555b8780 --- /dev/null +++ b/src/infra/heartbeat-visibility.ts @@ -0,0 +1,58 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { ChannelHeartbeatVisibilityConfig } from "../config/types.channels.js"; +import type { DeliverableMessageChannel } from "../utils/message-channel.js"; + +export type ResolvedHeartbeatVisibility = { + showOk: boolean; + showAlerts: boolean; + useIndicator: boolean; +}; + +const DEFAULT_VISIBILITY: ResolvedHeartbeatVisibility = { + showOk: false, // Silent by default + showAlerts: true, // Show content messages + useIndicator: true, // Emit indicator events +}; + +export function resolveHeartbeatVisibility(params: { + cfg: ClawdbotConfig; + channel: DeliverableMessageChannel; + accountId?: string; +}): ResolvedHeartbeatVisibility { + const { cfg, channel, accountId } = params; + + // Layer 1: Global channel defaults + const channelDefaults = cfg.channels?.defaults?.heartbeat; + + // Layer 2: Per-channel config (at channel root level) + const channelCfg = cfg.channels?.[channel] as + | { + heartbeat?: ChannelHeartbeatVisibilityConfig; + accounts?: Record; + } + | undefined; + const perChannel = channelCfg?.heartbeat; + + // Layer 3: Per-account config (most specific) + const accountCfg = accountId ? channelCfg?.accounts?.[accountId] : undefined; + const perAccount = accountCfg?.heartbeat; + + // Precedence: per-account > per-channel > channel-defaults > global defaults + return { + showOk: + perAccount?.showOk ?? + perChannel?.showOk ?? + channelDefaults?.showOk ?? + DEFAULT_VISIBILITY.showOk, + showAlerts: + perAccount?.showAlerts ?? + perChannel?.showAlerts ?? + channelDefaults?.showAlerts ?? + DEFAULT_VISIBILITY.showAlerts, + useIndicator: + perAccount?.useIndicator ?? + perChannel?.useIndicator ?? + channelDefaults?.useIndicator ?? + DEFAULT_VISIBILITY.useIndicator, + }; +} diff --git a/src/web/auto-reply/heartbeat-runner.ts b/src/web/auto-reply/heartbeat-runner.ts index 34becfb80b..1a194c4030 100644 --- a/src/web/auto-reply/heartbeat-runner.ts +++ b/src/web/auto-reply/heartbeat-runner.ts @@ -3,6 +3,7 @@ import { resolveHeartbeatPrompt, stripHeartbeatToken, } from "../../auto-reply/heartbeat.js"; +import { HEARTBEAT_TOKEN } from "../../auto-reply/tokens.js"; import { getReplyFromConfig } from "../../auto-reply/reply.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { resolveWhatsAppHeartbeatRecipients } from "../../channels/plugins/whatsapp-heartbeat.js"; @@ -13,7 +14,8 @@ import { resolveStorePath, updateSessionStore, } from "../../config/sessions.js"; -import { emitHeartbeatEvent } from "../../infra/heartbeat-events.js"; +import { emitHeartbeatEvent, resolveIndicatorType } from "../../infra/heartbeat-events.js"; +import { resolveHeartbeatVisibility } from "../../infra/heartbeat-visibility.js"; import { getChildLogger } from "../../logging.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { sendMessageWhatsApp } from "../outbound.js"; @@ -59,6 +61,11 @@ export async function runWebHeartbeatOnce(opts: { }); const cfg = cfgOverride ?? loadConfig(); + + // Resolve heartbeat visibility settings for WhatsApp + const visibility = resolveHeartbeatVisibility({ cfg, channel: "whatsapp" }); + const heartbeatOkText = HEARTBEAT_TOKEN; + const sessionCfg = cfg.session; const sessionScope = sessionCfg?.scope ?? "per-sender"; const mainKey = normalizeMainKey(sessionCfg?.mainKey); @@ -117,6 +124,8 @@ export async function runWebHeartbeatOnce(opts: { to, preview: overrideBody.slice(0, 160), hasMedia: false, + channel: "whatsapp", + indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined, }); heartbeatLogger.info( { @@ -131,6 +140,17 @@ export async function runWebHeartbeatOnce(opts: { return; } + if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) { + heartbeatLogger.info({ to, reason: "alerts-disabled" }, "heartbeat skipped"); + emitHeartbeatEvent({ + status: "skipped", + to, + reason: "alerts-disabled", + channel: "whatsapp", + }); + return; + } + const replyResult = await replyResolver( { Body: resolveHeartbeatPrompt(cfg.agents?.defaults?.heartbeat?.prompt), @@ -155,7 +175,32 @@ export async function runWebHeartbeatOnce(opts: { }, "heartbeat skipped", ); - emitHeartbeatEvent({ status: "ok-empty", to }); + let okSent = false; + if (visibility.showOk) { + if (dryRun) { + whatsappHeartbeatLog.info(`[dry-run] heartbeat ok -> ${to}`); + } else { + const sendResult = await sender(to, heartbeatOkText, { verbose }); + okSent = true; + heartbeatLogger.info( + { + to, + messageId: sendResult.messageId, + chars: heartbeatOkText.length, + reason: "heartbeat-ok", + }, + "heartbeat ok sent", + ); + whatsappHeartbeatLog.info(`heartbeat ok sent to ${to} (id ${sendResult.messageId})`); + } + } + emitHeartbeatEvent({ + status: "ok-empty", + to, + channel: "whatsapp", + silent: !okSent, + indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-empty") : undefined, + }); return; } @@ -188,7 +233,32 @@ export async function runWebHeartbeatOnce(opts: { { to, reason: "heartbeat-token", rawLength: replyPayload.text?.length }, "heartbeat skipped", ); - emitHeartbeatEvent({ status: "ok-token", to }); + let okSent = false; + if (visibility.showOk) { + if (dryRun) { + whatsappHeartbeatLog.info(`[dry-run] heartbeat ok -> ${to}`); + } else { + const sendResult = await sender(to, heartbeatOkText, { verbose }); + okSent = true; + heartbeatLogger.info( + { + to, + messageId: sendResult.messageId, + chars: heartbeatOkText.length, + reason: "heartbeat-ok", + }, + "heartbeat ok sent", + ); + whatsappHeartbeatLog.info(`heartbeat ok sent to ${to} (id ${sendResult.messageId})`); + } + } + emitHeartbeatEvent({ + status: "ok-token", + to, + channel: "whatsapp", + silent: !okSent, + indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined, + }); return; } @@ -197,6 +267,22 @@ export async function runWebHeartbeatOnce(opts: { } const finalText = stripped.text || replyPayload.text || ""; + + // Check if alerts are disabled for WhatsApp + if (!visibility.showAlerts) { + heartbeatLogger.info({ to, 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, reason: "dry-run", chars: finalText.length }, "heartbeat dry-run"); whatsappHeartbeatLog.info(`[dry-run] heartbeat -> ${to}: ${elide(finalText, 200)}`); @@ -209,6 +295,8 @@ export async function runWebHeartbeatOnce(opts: { to, preview: finalText.slice(0, 160), hasMedia, + channel: "whatsapp", + indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined, }); heartbeatLogger.info( { @@ -224,7 +312,13 @@ export async function runWebHeartbeatOnce(opts: { const reason = formatError(err); heartbeatLogger.warn({ to, error: reason }, "heartbeat failed"); whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`); - emitHeartbeatEvent({ status: "failed", to, reason }); + emitHeartbeatEvent({ + status: "failed", + to, + reason, + channel: "whatsapp", + indicatorType: visibility.useIndicator ? resolveIndicatorType("failed") : undefined, + }); throw err; } }