feat(heartbeat): add configurable visibility for heartbeat responses

Add per-channel and per-account heartbeat visibility settings:
- showOk: hide/show HEARTBEAT_OK messages (default: false)
- showAlerts: hide/show alert messages (default: true)
- useIndicator: emit typing indicator events (default: true)

Config precedence: per-account > per-channel > channel-defaults > global

This allows silencing routine heartbeat acks while still surfacing
alerts when something needs attention.
This commit is contained in:
Dave Lauer
2026-01-22 10:54:07 -05:00
committed by Peter Steinberger
parent 9b12275fe1
commit f9cf508cff
18 changed files with 695 additions and 6 deletions

View File

@@ -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 = {

View File

@@ -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<string, DiscordGuildEntry>;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
export type DiscordConfig = {

View File

@@ -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 = {

View File

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

View File

@@ -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<string | number>;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
export type SignalConfig = {

View File

@@ -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<string, SlackChannelConfig>;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
export type SlackConfig = {

View File

@@ -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 = {

View File

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

View File

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

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, { heartbeat?: ChannelHeartbeatVisibilityConfig }>;
}
| 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,
};
}

View File

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