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:
committed by
Peter Steinberger
parent
9b12275fe1
commit
f9cf508cff
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
10
src/config/zod-schema.channels.ts
Normal file
10
src/config/zod-schema.channels.ts
Normal 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();
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
250
src/infra/heartbeat-visibility.test.ts
Normal file
250
src/infra/heartbeat-visibility.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
58
src/infra/heartbeat-visibility.ts
Normal file
58
src/infra/heartbeat-visibility.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user