import { appendCronStyleCurrentTimeLine } from "../../../../src/agents/current-time.js"; import { resolveHeartbeatReplyPayload } from "../../../../src/auto-reply/heartbeat-reply-payload.js"; import { resolveHeartbeatPrompt } from "../../../../src/auto-reply/heartbeat.js"; import { getReplyFromConfig } from "../../../../src/auto-reply/reply.js"; import { resolveWhatsAppHeartbeatRecipients } from "../../../../src/channels/plugins/whatsapp-heartbeat.js"; import { loadConfig } from "../../../../src/config/config.js"; import { loadSessionStore, resolveSessionKey, resolveStorePath, updateSessionStore, } from "../../../../src/config/sessions.js"; import { emitHeartbeatEvent, resolveIndicatorType, } from "../../../../src/infra/heartbeat-events.js"; import { resolveHeartbeatVisibility } from "../../../../src/infra/heartbeat-visibility.js"; import { getChildLogger } from "../../../../src/logging.js"; import { redactIdentifier } from "../../../../src/logging/redact-identifier.js"; import { normalizeMainKey } from "../../../../src/routing/session-key.js"; import { sendMessageWhatsApp } from "../outbound.js"; import { newConnectionId } from "../reconnect.js"; import { formatError } from "../session.js"; import { whatsappHeartbeatLog } from "./loggers.js"; import { getSessionSnapshot } from "./session-snapshot.js"; export async function runWebHeartbeatOnce(opts: { cfg?: ReturnType; to: string; verbose?: boolean; replyResolver?: typeof getReplyFromConfig; sender?: typeof sendMessageWhatsApp; sessionId?: string; overrideBody?: string; dryRun?: boolean; }) { const { cfg: cfgOverride, to, verbose = false, sessionId, overrideBody, dryRun = false } = opts; const replyResolver = opts.replyResolver ?? getReplyFromConfig; const sender = opts.sender ?? sendMessageWhatsApp; const runId = newConnectionId(); const redactedTo = redactIdentifier(to); const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId, to: redactedTo, }); const cfg = cfgOverride ?? loadConfig(); // Resolve heartbeat visibility settings for WhatsApp const visibility = resolveHeartbeatVisibility({ cfg, channel: "whatsapp" }); const maybeSendHeartbeatOkSummary = async (summary: string): Promise => { if (!visibility.showOk || !summary.trim()) { return false; } if (dryRun) { whatsappHeartbeatLog.info(`[dry-run] heartbeat ok -> ${redactedTo}`); return false; } const sendResult = await sender(to, summary, { verbose, cfg }); heartbeatLogger.info( { to: redactedTo, messageId: sendResult.messageId, chars: summary.length, reason: "heartbeat-ok", }, "heartbeat ok sent", ); whatsappHeartbeatLog.info(`heartbeat ok sent to ${redactedTo} (id ${sendResult.messageId})`); return true; }; const sessionCfg = cfg.session; const sessionScope = sessionCfg?.scope ?? "per-sender"; const mainKey = normalizeMainKey(sessionCfg?.mainKey); const sessionKey = resolveSessionKey(sessionScope, { From: to }, mainKey); if (sessionId) { const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); const current = store[sessionKey] ?? {}; store[sessionKey] = { ...current, sessionId, updatedAt: Date.now(), }; await updateSessionStore(storePath, (nextStore) => { const nextCurrent = nextStore[sessionKey] ?? current; nextStore[sessionKey] = { ...nextCurrent, sessionId, updatedAt: Date.now(), }; }); } const sessionSnapshot = getSessionSnapshot(cfg, to, true); if (verbose) { heartbeatLogger.info( { to: redactedTo, sessionKey: sessionSnapshot.key, sessionId: sessionId ?? sessionSnapshot.entry?.sessionId ?? null, sessionFresh: sessionSnapshot.fresh, resetMode: sessionSnapshot.resetPolicy.mode, resetAtHour: sessionSnapshot.resetPolicy.atHour, idleMinutes: sessionSnapshot.resetPolicy.idleMinutes ?? null, dailyResetAt: sessionSnapshot.dailyResetAt ?? null, idleExpiresAt: sessionSnapshot.idleExpiresAt ?? null, }, "heartbeat session snapshot", ); } if (overrideBody && overrideBody.trim().length === 0) { throw new Error("Override body must be non-empty when provided."); } try { if (overrideBody) { if (dryRun) { whatsappHeartbeatLog.info( `[dry-run] web send -> ${redactedTo} (${overrideBody.trim().length} chars, manual message)`, ); return; } const sendResult = await sender(to, overrideBody, { verbose, cfg }); emitHeartbeatEvent({ status: "sent", to, preview: overrideBody.slice(0, 160), hasMedia: false, channel: "whatsapp", indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined, }); heartbeatLogger.info( { to: redactedTo, messageId: sendResult.messageId, chars: overrideBody.length, reason: "manual-message", }, "manual heartbeat message sent", ); whatsappHeartbeatLog.info( `manual heartbeat sent to ${redactedTo} (id ${sendResult.messageId})`, ); return; } if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) { heartbeatLogger.info({ to: redactedTo, reason: "alerts-disabled" }, "heartbeat skipped"); emitHeartbeatEvent({ status: "skipped", to, reason: "alerts-disabled", channel: "whatsapp", }); return; } const defaults = cfg.agents?.defaults?.heartbeat; const heartbeatPrompt = await resolveHeartbeatPrompt({ prompt: defaults?.prompt }); if (!heartbeatPrompt) { heartbeatLogger.info({ to: redactedTo, reason: "no-prompt" }, "heartbeat skipped"); emitHeartbeatEvent({ status: "skipped", to, reason: "no-prompt", channel: "whatsapp", }); return; } const replyResult = await replyResolver( { Body: appendCronStyleCurrentTimeLine(heartbeatPrompt, cfg, Date.now()), From: to, To: to, MessageSid: sessionId ?? sessionSnapshot.entry?.sessionId, }, { isHeartbeat: true }, cfg, ); const replyPayload = resolveHeartbeatReplyPayload(replyResult); if ( !replyPayload || (!replyPayload.text && !replyPayload.mediaUrl && !replyPayload.mediaUrls?.length) ) { heartbeatLogger.info( { to: redactedTo, reason: "empty-reply", sessionId: sessionSnapshot.entry?.sessionId ?? null, }, "heartbeat skipped", ); const okSent = await maybeSendHeartbeatOkSummary(""); emitHeartbeatEvent({ status: "ok-empty", to, channel: "whatsapp", silent: !okSent, indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-empty") : undefined, }); return; } const hasMedia = Boolean(replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0); const report = replyPayload.heartbeatReport; // When heartbeat_report says nothing done, skip delivery (restore session timestamp). if (report && !report.anythingDone && !hasMedia) { // Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works. const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); if (sessionSnapshot.entry && store[sessionSnapshot.key]) { store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt; await updateSessionStore(storePath, (nextStore) => { const nextEntry = nextStore[sessionSnapshot.key]; if (!nextEntry) { return; } nextStore[sessionSnapshot.key] = { ...nextEntry, updatedAt: sessionSnapshot.entry.updatedAt, }; }); } heartbeatLogger.info( { to: redactedTo, reason: "heartbeat-report-skip", rawLength: replyPayload.text?.length }, "heartbeat skipped", ); const okSent = await maybeSendHeartbeatOkSummary(report.summary?.trim() ?? ""); emitHeartbeatEvent({ status: "ok-token", to, channel: "whatsapp", silent: !okSent, indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined, }); return; } if (hasMedia) { heartbeatLogger.warn( { to: redactedTo }, "heartbeat reply contained media; sending text only", ); } const finalText = (report?.summary?.trim() || replyPayload.text || "").trim(); // Check if alerts are disabled for WhatsApp if (!visibility.showAlerts) { heartbeatLogger.info({ to: redactedTo, 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: redactedTo, reason: "dry-run", chars: finalText.length }, "heartbeat dry-run", ); whatsappHeartbeatLog.info(`[dry-run] heartbeat -> ${redactedTo} (${finalText.length} chars)`); return; } const sendResult = await sender(to, finalText, { verbose, cfg }); emitHeartbeatEvent({ status: "sent", to, preview: finalText.slice(0, 160), hasMedia, channel: "whatsapp", indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined, }); heartbeatLogger.info( { to: redactedTo, messageId: sendResult.messageId, chars: finalText.length, }, "heartbeat sent", ); whatsappHeartbeatLog.info(`heartbeat alert sent to ${redactedTo}`); } catch (err) { const reason = formatError(err); heartbeatLogger.warn({ to: redactedTo, error: reason }, "heartbeat failed"); whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`); emitHeartbeatEvent({ status: "failed", to, reason, channel: "whatsapp", indicatorType: visibility.useIndicator ? resolveIndicatorType("failed") : undefined, }); throw err; } } export function resolveHeartbeatRecipients( cfg: ReturnType, opts: { to?: string; all?: boolean } = {}, ) { return resolveWhatsAppHeartbeatRecipients(cfg, opts); }