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
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user