feat(heartbeat): add configurable visibility for heartbeat responses

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

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

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

View File

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