From 135d930c99eb2604a796a4668625ba15b916e3c1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Nov 2025 17:26:17 +0100 Subject: [PATCH] feat: add heartbeat idle override and preserve session freshness --- src/config/config.ts | 2 ++ src/web/auto-reply.ts | 24 ++++++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index 595c66e98..7a838d112 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -13,6 +13,7 @@ export type SessionConfig = { scope?: SessionScope; resetTriggers?: string[]; idleMinutes?: number; + heartbeatIdleMinutes?: number; store?: string; sessionArgNew?: string[]; sessionArgResume?: string[]; @@ -89,6 +90,7 @@ const ReplySchema = z .optional(), resetTriggers: z.array(z.string()).optional(), idleMinutes: z.number().int().positive().optional(), + heartbeatIdleMinutes: z.number().int().positive().optional(), store: z.string().optional(), sessionArgNew: z.array(z.string()).optional(), sessionArgResume: z.array(z.string()).optional(), diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 291519d8c..6425ead15 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -7,6 +7,7 @@ import { deriveSessionKey, loadSessionStore, resolveStorePath, + saveSessionStore, } from "../config/sessions.js"; import { danger, isVerbose, logVerbose, success } from "../globals.js"; import { logInfo } from "../logger.js"; @@ -92,7 +93,7 @@ export async function runWebHeartbeatOnce(opts: { }); const cfg = loadConfig(); - const sessionSnapshot = getSessionSnapshot(cfg, to); + const sessionSnapshot = getSessionSnapshot(cfg, to, true); if (verbose) { heartbeatLogger.info( { @@ -112,7 +113,7 @@ export async function runWebHeartbeatOnce(opts: { Body: HEARTBEAT_PROMPT, From: to, To: to, - MessageSid: undefined, + MessageSid: sessionSnapshot.entry?.sessionId, }, undefined, cfg, @@ -139,6 +140,15 @@ export async function runWebHeartbeatOnce(opts: { (replyResult.mediaUrl ?? replyResult.mediaUrls?.length ?? 0) > 0; const stripped = stripHeartbeatToken(replyResult.text); if (stripped.shouldSkip && !hasMedia) { + // Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works. + const sessionCfg = cfg.inbound?.reply?.session; + const storePath = resolveStorePath(sessionCfg?.store); + const store = loadSessionStore(storePath); + if (sessionSnapshot.entry && store[sessionSnapshot.key]) { + store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt; + await saveSessionStore(storePath, store); + } + heartbeatLogger.info( { to, reason: "heartbeat-token", rawLength: replyResult.text?.length }, "heartbeat skipped", @@ -185,14 +195,20 @@ function getFallbackRecipient(cfg: ReturnType) { return mostRecent ? normalizeE164(mostRecent[0]) : null; } -function getSessionSnapshot(cfg: ReturnType, from: string) { +function getSessionSnapshot( + cfg: ReturnType, + from: string, + isHeartbeat = false, +) { const sessionCfg = cfg.inbound?.reply?.session; const scope = sessionCfg?.scope ?? "per-sender"; const key = deriveSessionKey(scope, { From: from, To: "", Body: "" }); const store = loadSessionStore(resolveStorePath(sessionCfg?.store)); const entry = store[key]; const idleMinutes = Math.max( - sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, + (isHeartbeat + ? (sessionCfg?.heartbeatIdleMinutes ?? sessionCfg?.idleMinutes) + : sessionCfg?.idleMinutes) ?? DEFAULT_IDLE_MINUTES, 1, ); const fresh = !!(