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 { WhatsAppConfig } from "./types.whatsapp.js";
|
||||||
import type { GroupPolicy } from "./types.base.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 = {
|
export type ChannelDefaultsConfig = {
|
||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
|
/** Default heartbeat visibility for all channels. */
|
||||||
|
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelsConfig = {
|
export type ChannelsConfig = {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
OutboundRetryConfig,
|
OutboundRetryConfig,
|
||||||
ReplyToMode,
|
ReplyToMode,
|
||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
@@ -121,6 +122,8 @@ export type DiscordAccountConfig = {
|
|||||||
dm?: DiscordDmConfig;
|
dm?: DiscordDmConfig;
|
||||||
/** New per-guild config keyed by guild id or slug. */
|
/** New per-guild config keyed by guild id or slug. */
|
||||||
guilds?: Record<string, DiscordGuildEntry>;
|
guilds?: Record<string, DiscordGuildEntry>;
|
||||||
|
/** Heartbeat visibility settings for this channel. */
|
||||||
|
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordConfig = {
|
export type DiscordConfig = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
GroupPolicy,
|
GroupPolicy,
|
||||||
MarkdownConfig,
|
MarkdownConfig,
|
||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
@@ -63,6 +64,8 @@ export type IMessageAccountConfig = {
|
|||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
/** Heartbeat visibility settings for this channel. */
|
||||||
|
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IMessageConfig = {
|
export type IMessageConfig = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
GroupPolicy,
|
GroupPolicy,
|
||||||
MarkdownConfig,
|
MarkdownConfig,
|
||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
@@ -94,4 +95,6 @@ export type MSTeamsConfig = {
|
|||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2"). */
|
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2"). */
|
||||||
sharePointSiteId?: string;
|
sharePointSiteId?: string;
|
||||||
|
/** Heartbeat visibility settings for this channel. */
|
||||||
|
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
GroupPolicy,
|
GroupPolicy,
|
||||||
MarkdownConfig,
|
MarkdownConfig,
|
||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
|
|
||||||
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
||||||
@@ -63,6 +64,8 @@ export type SignalAccountConfig = {
|
|||||||
reactionNotifications?: SignalReactionNotificationMode;
|
reactionNotifications?: SignalReactionNotificationMode;
|
||||||
/** Allowlist for reaction notifications when mode is allowlist. */
|
/** Allowlist for reaction notifications when mode is allowlist. */
|
||||||
reactionAllowlist?: Array<string | number>;
|
reactionAllowlist?: Array<string | number>;
|
||||||
|
/** Heartbeat visibility settings for this channel. */
|
||||||
|
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SignalConfig = {
|
export type SignalConfig = {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
MarkdownConfig,
|
MarkdownConfig,
|
||||||
ReplyToMode,
|
ReplyToMode,
|
||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
@@ -136,6 +137,8 @@ export type SlackAccountConfig = {
|
|||||||
slashCommand?: SlackSlashCommandConfig;
|
slashCommand?: SlackSlashCommandConfig;
|
||||||
dm?: SlackDmConfig;
|
dm?: SlackDmConfig;
|
||||||
channels?: Record<string, SlackChannelConfig>;
|
channels?: Record<string, SlackChannelConfig>;
|
||||||
|
/** Heartbeat visibility settings for this channel. */
|
||||||
|
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SlackConfig = {
|
export type SlackConfig = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
OutboundRetryConfig,
|
OutboundRetryConfig,
|
||||||
ReplyToMode,
|
ReplyToMode,
|
||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
@@ -113,6 +114,8 @@ export type TelegramAccountConfig = {
|
|||||||
* - "extensive": agent can react liberally when appropriate
|
* - "extensive": agent can react liberally when appropriate
|
||||||
*/
|
*/
|
||||||
reactionLevel?: "off" | "ack" | "minimal" | "extensive";
|
reactionLevel?: "off" | "ack" | "minimal" | "extensive";
|
||||||
|
/** Heartbeat visibility settings for this channel. */
|
||||||
|
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TelegramTopicConfig = {
|
export type TelegramTopicConfig = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
GroupPolicy,
|
GroupPolicy,
|
||||||
MarkdownConfig,
|
MarkdownConfig,
|
||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.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). */
|
/** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
|
/** Heartbeat visibility settings for this channel. */
|
||||||
|
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WhatsAppAccountConfig = {
|
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). */
|
/** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */
|
||||||
debounceMs?: number;
|
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,
|
requireOpenAllowFrom,
|
||||||
} from "./zod-schema.core.js";
|
} from "./zod-schema.core.js";
|
||||||
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
|
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
|
||||||
|
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
|
||||||
import {
|
import {
|
||||||
normalizeTelegramCommandDescription,
|
normalizeTelegramCommandDescription,
|
||||||
normalizeTelegramCommandName,
|
normalizeTelegramCommandName,
|
||||||
@@ -122,6 +123,7 @@ export const TelegramAccountSchemaBase = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
reactionNotifications: z.enum(["off", "own", "all"]).optional(),
|
reactionNotifications: z.enum(["off", "own", "all"]).optional(),
|
||||||
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
|
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
|
||||||
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -241,6 +243,7 @@ export const DiscordAccountSchema = z
|
|||||||
replyToMode: ReplyToModeSchema.optional(),
|
replyToMode: ReplyToModeSchema.optional(),
|
||||||
dm: DiscordDmSchema.optional(),
|
dm: DiscordDmSchema.optional(),
|
||||||
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
|
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
|
||||||
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -351,6 +354,7 @@ export const SlackAccountSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
dm: SlackDmSchema.optional(),
|
dm: SlackDmSchema.optional(),
|
||||||
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
|
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
|
||||||
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -416,6 +420,7 @@ export const SignalAccountSchemaBase = z
|
|||||||
mediaMaxMb: z.number().int().positive().optional(),
|
mediaMaxMb: z.number().int().positive().optional(),
|
||||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -477,6 +482,7 @@ export const IMessageAccountSchemaBase = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -553,6 +559,7 @@ export const BlueBubblesAccountSchemaBase = z
|
|||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
groups: z.record(z.string(), BlueBubblesGroupConfigSchema.optional()).optional(),
|
groups: z.record(z.string(), BlueBubblesGroupConfigSchema.optional()).optional(),
|
||||||
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -630,6 +637,7 @@ export const MSTeamsConfigSchema = z
|
|||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2") */
|
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2") */
|
||||||
sharePointSiteId: z.string().optional(),
|
sharePointSiteId: z.string().optional(),
|
||||||
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
MarkdownConfigSchema,
|
MarkdownConfigSchema,
|
||||||
} from "./zod-schema.core.js";
|
} from "./zod-schema.core.js";
|
||||||
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
|
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
|
||||||
|
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
|
||||||
|
|
||||||
export const WhatsAppAccountSchema = z
|
export const WhatsAppAccountSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -53,6 +54,7 @@ export const WhatsAppAccountSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
debounceMs: z.number().int().nonnegative().optional().default(0),
|
debounceMs: z.number().int().nonnegative().optional().default(0),
|
||||||
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
@@ -115,6 +117,7 @@ export const WhatsAppConfigSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
debounceMs: z.number().int().nonnegative().optional().default(0),
|
debounceMs: z.number().int().nonnegative().optional().default(0),
|
||||||
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ import {
|
|||||||
} from "./zod-schema.providers-core.js";
|
} from "./zod-schema.providers-core.js";
|
||||||
import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js";
|
import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js";
|
||||||
import { GroupPolicySchema } from "./zod-schema.core.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-core.js";
|
||||||
export * from "./zod-schema.providers-whatsapp.js";
|
export * from "./zod-schema.providers-whatsapp.js";
|
||||||
|
export { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
|
||||||
|
|
||||||
export const ChannelsSchema = z
|
export const ChannelsSchema = z
|
||||||
.object({
|
.object({
|
||||||
defaults: z
|
defaults: z
|
||||||
.object({
|
.object({
|
||||||
groupPolicy: GroupPolicySchema.optional(),
|
groupPolicy: GroupPolicySchema.optional(),
|
||||||
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export type HeartbeatIndicatorType = "ok" | "alert" | "error";
|
||||||
|
|
||||||
export type HeartbeatEventPayload = {
|
export type HeartbeatEventPayload = {
|
||||||
ts: number;
|
ts: number;
|
||||||
status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed";
|
status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed";
|
||||||
@@ -6,8 +8,30 @@ export type HeartbeatEventPayload = {
|
|||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
hasMedia?: boolean;
|
hasMedia?: boolean;
|
||||||
reason?: string;
|
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;
|
let lastHeartbeat: HeartbeatEventPayload | null = null;
|
||||||
const listeners = new Set<(evt: HeartbeatEventPayload) => void>();
|
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 () => {
|
it("skips delivery for markup-wrapped HEARTBEAT_OK", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
resolveHeartbeatPrompt as resolveHeartbeatPromptText,
|
resolveHeartbeatPrompt as resolveHeartbeatPromptText,
|
||||||
stripHeartbeatToken,
|
stripHeartbeatToken,
|
||||||
} from "../auto-reply/heartbeat.js";
|
} from "../auto-reply/heartbeat.js";
|
||||||
|
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import { getChannelPlugin } from "../channels/plugins/index.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 { CommandLane } from "../process/lanes.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.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 {
|
import {
|
||||||
type HeartbeatRunResult,
|
type HeartbeatRunResult,
|
||||||
type HeartbeatWakeHandler,
|
type HeartbeatWakeHandler,
|
||||||
@@ -471,7 +473,16 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
|
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
|
||||||
const previousUpdatedAt = entry?.updatedAt;
|
const previousUpdatedAt = entry?.updatedAt;
|
||||||
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
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 { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
|
||||||
|
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix;
|
||||||
const prompt = resolveHeartbeatPrompt(cfg, heartbeat);
|
const prompt = resolveHeartbeatPrompt(cfg, heartbeat);
|
||||||
const ctx = {
|
const ctx = {
|
||||||
Body: prompt,
|
Body: prompt,
|
||||||
@@ -480,6 +491,43 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
Provider: "heartbeat",
|
Provider: "heartbeat",
|
||||||
SessionKey: sessionKey,
|
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 {
|
try {
|
||||||
const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg);
|
const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg);
|
||||||
@@ -498,10 +546,14 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
updatedAt: previousUpdatedAt,
|
updatedAt: previousUpdatedAt,
|
||||||
});
|
});
|
||||||
|
const okSent = await maybeSendHeartbeatOk();
|
||||||
emitHeartbeatEvent({
|
emitHeartbeatEvent({
|
||||||
status: "ok-empty",
|
status: "ok-empty",
|
||||||
reason: opts.reason,
|
reason: opts.reason,
|
||||||
durationMs: Date.now() - startedAt,
|
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 };
|
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
}
|
}
|
||||||
@@ -509,7 +561,7 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
|
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
|
||||||
const normalized = normalizeHeartbeatReply(
|
const normalized = normalizeHeartbeatReply(
|
||||||
replyPayload,
|
replyPayload,
|
||||||
resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
|
responsePrefix,
|
||||||
ackMaxChars,
|
ackMaxChars,
|
||||||
);
|
);
|
||||||
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
|
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
|
||||||
@@ -519,10 +571,14 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
updatedAt: previousUpdatedAt,
|
updatedAt: previousUpdatedAt,
|
||||||
});
|
});
|
||||||
|
const okSent = await maybeSendHeartbeatOk();
|
||||||
emitHeartbeatEvent({
|
emitHeartbeatEvent({
|
||||||
status: "ok-token",
|
status: "ok-token",
|
||||||
reason: opts.reason,
|
reason: opts.reason,
|
||||||
durationMs: Date.now() - startedAt,
|
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 };
|
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
}
|
}
|
||||||
@@ -556,6 +612,7 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
preview: normalized.text.slice(0, 200),
|
preview: normalized.text.slice(0, 200),
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
hasMedia: false,
|
hasMedia: false,
|
||||||
|
channel: delivery.channel !== "none" ? delivery.channel : undefined,
|
||||||
});
|
});
|
||||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
}
|
}
|
||||||
@@ -579,6 +636,20 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
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 deliveryAccountId = delivery.accountId;
|
||||||
const heartbeatPlugin = getChannelPlugin(delivery.channel);
|
const heartbeatPlugin = getChannelPlugin(delivery.channel);
|
||||||
if (heartbeatPlugin?.heartbeat?.checkReady) {
|
if (heartbeatPlugin?.heartbeat?.checkReady) {
|
||||||
@@ -594,6 +665,7 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
preview: previewText?.slice(0, 200),
|
preview: previewText?.slice(0, 200),
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
hasMedia: mediaUrls.length > 0,
|
hasMedia: mediaUrls.length > 0,
|
||||||
|
channel: delivery.channel,
|
||||||
});
|
});
|
||||||
log.info("heartbeat: channel not ready", {
|
log.info("heartbeat: channel not ready", {
|
||||||
channel: delivery.channel,
|
channel: delivery.channel,
|
||||||
@@ -642,6 +714,8 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
preview: previewText?.slice(0, 200),
|
preview: previewText?.slice(0, 200),
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
hasMedia: mediaUrls.length > 0,
|
hasMedia: mediaUrls.length > 0,
|
||||||
|
channel: delivery.channel,
|
||||||
|
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
|
||||||
});
|
});
|
||||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -650,6 +724,8 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
status: "failed",
|
status: "failed",
|
||||||
reason,
|
reason,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
|
channel: delivery.channel !== "none" ? delivery.channel : undefined,
|
||||||
|
indicatorType: visibility.useIndicator ? resolveIndicatorType("failed") : undefined,
|
||||||
});
|
});
|
||||||
log.error(`heartbeat failed: ${reason}`, { error: reason });
|
log.error(`heartbeat failed: ${reason}`, { error: reason });
|
||||||
return { status: "failed", 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,
|
resolveHeartbeatPrompt,
|
||||||
stripHeartbeatToken,
|
stripHeartbeatToken,
|
||||||
} from "../../auto-reply/heartbeat.js";
|
} from "../../auto-reply/heartbeat.js";
|
||||||
|
import { HEARTBEAT_TOKEN } from "../../auto-reply/tokens.js";
|
||||||
import { getReplyFromConfig } from "../../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import { resolveWhatsAppHeartbeatRecipients } from "../../channels/plugins/whatsapp-heartbeat.js";
|
import { resolveWhatsAppHeartbeatRecipients } from "../../channels/plugins/whatsapp-heartbeat.js";
|
||||||
@@ -13,7 +14,8 @@ import {
|
|||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
updateSessionStore,
|
updateSessionStore,
|
||||||
} from "../../config/sessions.js";
|
} 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 { getChildLogger } from "../../logging.js";
|
||||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||||
import { sendMessageWhatsApp } from "../outbound.js";
|
import { sendMessageWhatsApp } from "../outbound.js";
|
||||||
@@ -59,6 +61,11 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cfg = cfgOverride ?? loadConfig();
|
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 sessionCfg = cfg.session;
|
||||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||||
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
|
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
|
||||||
@@ -117,6 +124,8 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
to,
|
to,
|
||||||
preview: overrideBody.slice(0, 160),
|
preview: overrideBody.slice(0, 160),
|
||||||
hasMedia: false,
|
hasMedia: false,
|
||||||
|
channel: "whatsapp",
|
||||||
|
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
|
||||||
});
|
});
|
||||||
heartbeatLogger.info(
|
heartbeatLogger.info(
|
||||||
{
|
{
|
||||||
@@ -131,6 +140,17 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
return;
|
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(
|
const replyResult = await replyResolver(
|
||||||
{
|
{
|
||||||
Body: resolveHeartbeatPrompt(cfg.agents?.defaults?.heartbeat?.prompt),
|
Body: resolveHeartbeatPrompt(cfg.agents?.defaults?.heartbeat?.prompt),
|
||||||
@@ -155,7 +175,32 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
},
|
},
|
||||||
"heartbeat skipped",
|
"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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +233,32 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
{ to, reason: "heartbeat-token", rawLength: replyPayload.text?.length },
|
{ to, reason: "heartbeat-token", rawLength: replyPayload.text?.length },
|
||||||
"heartbeat skipped",
|
"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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +267,22 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const finalText = stripped.text || replyPayload.text || "";
|
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) {
|
if (dryRun) {
|
||||||
heartbeatLogger.info({ to, reason: "dry-run", chars: finalText.length }, "heartbeat dry-run");
|
heartbeatLogger.info({ to, reason: "dry-run", chars: finalText.length }, "heartbeat dry-run");
|
||||||
whatsappHeartbeatLog.info(`[dry-run] heartbeat -> ${to}: ${elide(finalText, 200)}`);
|
whatsappHeartbeatLog.info(`[dry-run] heartbeat -> ${to}: ${elide(finalText, 200)}`);
|
||||||
@@ -209,6 +295,8 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
to,
|
to,
|
||||||
preview: finalText.slice(0, 160),
|
preview: finalText.slice(0, 160),
|
||||||
hasMedia,
|
hasMedia,
|
||||||
|
channel: "whatsapp",
|
||||||
|
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
|
||||||
});
|
});
|
||||||
heartbeatLogger.info(
|
heartbeatLogger.info(
|
||||||
{
|
{
|
||||||
@@ -224,7 +312,13 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
const reason = formatError(err);
|
const reason = formatError(err);
|
||||||
heartbeatLogger.warn({ to, error: reason }, "heartbeat failed");
|
heartbeatLogger.warn({ to, error: reason }, "heartbeat failed");
|
||||||
whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`);
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user