diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 6646dfdd1..27c9aad5a 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -105,6 +105,37 @@ describe("runWebHeartbeatOnce", () => { }); expect(sender).toHaveBeenCalledWith("+1555", "ALERT", { verbose: false }); }); + + it("falls back to most recent session when no to is provided", async () => { + const sender: typeof sendMessageWeb = vi + .fn() + .mockResolvedValue({ messageId: "m1", toJid: "jid" }); + const resolver = vi.fn(async () => ({ text: "ALERT" })); + // Seed session store + const now = Date.now(); + const store = { + "+1222": { sessionId: "s1", updatedAt: now - 1000 }, + "+1333": { sessionId: "s2", updatedAt: now }, + }; + const storePath = resolveStorePath(); + await fs.mkdir(resolveStorePath().replace("sessions.json", ""), { + recursive: true, + }); + await fs.writeFile(storePath, JSON.stringify(store)); + setLoadConfigMock({ + inbound: { + allowFrom: ["+1999"], + reply: { mode: "command", session: {} }, + }, + }); + await runWebHeartbeatOnce({ + to: "+1999", + verbose: false, + sender, + replyResolver: resolver, + }); + expect(sender).toHaveBeenCalledWith("+1333", "ALERT", { verbose: false }); + }); }); describe("web auto-reply", () => { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index c60255add..1f0d70018 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -2,10 +2,12 @@ import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { waitForever } from "../cli/wait.js"; import { loadConfig } from "../config/config.js"; +import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { danger, isVerbose, logVerbose, success } from "../globals.js"; import { logInfo } from "../logger.js"; import { getChildLogger } from "../logging.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { normalizeE164 } from "../utils.js"; import { monitorWebInbox } from "./inbound.js"; import { loadWebMedia } from "./media.js"; import { sendMessageWeb } from "./outbound.js"; @@ -141,6 +143,23 @@ export async function runWebHeartbeatOnce(opts: { } } +function getFallbackRecipient(cfg: ReturnType) { + const sessionCfg = cfg.inbound?.reply?.session; + const storePath = resolveStorePath(sessionCfg?.store); + const store = loadSessionStore(storePath); + const candidates = Object.entries(store).filter(([key]) => key !== "global"); + if (candidates.length === 0) { + return ( + (Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom[0]) || + null + ); + } + const mostRecent = candidates.sort( + (a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0), + )[0]; + return mostRecent ? normalizeE164(mostRecent[0]) : null; +} + async function deliverWebReply(params: { replyResult: ReplyPayload; msg: WebInboundMsg; @@ -437,15 +456,31 @@ export async function monitorWebProvider( if (!replyHeartbeatMinutes) return; const tickStart = Date.now(); if (!lastInboundMsg) { - heartbeatLogger.info( - { - connectionId, - reason: "no-recent-inbound", - durationMs: Date.now() - tickStart, - }, - "reply heartbeat skipped", - ); - console.log(success("heartbeat: skipped (no recent inbound)")); + const fallbackTo = getFallbackRecipient(cfg); + if (!fallbackTo) { + heartbeatLogger.info( + { + connectionId, + reason: "no-recent-inbound", + durationMs: Date.now() - tickStart, + }, + "reply heartbeat skipped", + ); + console.log(success("heartbeat: skipped (no recent inbound)")); + return; + } + if (isVerbose()) { + heartbeatLogger.info( + { connectionId, to: fallbackTo, reason: "fallback-session" }, + "reply heartbeat start", + ); + } + await runWebHeartbeatOnce({ + to: fallbackTo, + verbose, + replyResolver, + runtime, + }); return; }