From 8b6abe01515db7663aa832f17b614ec05d008850 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 01:26:40 +0000 Subject: [PATCH] fix(web): heartbeat fallback after group inbound --- src/web/auto-reply.test.ts | 92 ++++++++++++++++++++++++++++++++++++++ src/web/auto-reply.ts | 23 +++++----- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index cbd104e43..8a97a5e9d 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -19,6 +19,7 @@ import { stripHeartbeatToken, } from "./auto-reply.js"; import type { sendMessageWhatsApp } from "./outbound.js"; +import { requestReplyHeartbeatNow } from "./reply-heartbeat-wake.js"; import { resetBaileysMocks, resetLoadConfigMock, @@ -741,6 +742,97 @@ describe("web auto-reply", () => { } }); + it("falls back to main recipient when last inbound is a group chat", async () => { + const now = Date.now(); + const store = await makeSessionStore({ + main: { + sessionId: "sid-main", + updatedAt: now, + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }); + + const replyResolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN })); + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = vi.fn( + async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + const onClose = new Promise(() => { + // stay open until aborted + }); + return { close: vi.fn(), onClose }; + }, + ); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never; + + setLoadConfigMock(() => ({ + inbound: { + allowFrom: ["+1555"], + groupChat: { requireMention: true, mentionPatterns: ["@clawd"] }, + reply: { mode: "command", session: { store: store.storePath } }, + }, + })); + + const controller = new AbortController(); + const run = monitorWebProvider( + false, + listenerFactory, + true, + replyResolver, + runtime, + controller.signal, + { replyHeartbeatMinutes: 10_000 }, + ); + + try { + await Promise.resolve(); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello group", + from: "123@g.us", + to: "+1555", + id: "g1", + sendComposing: vi.fn(), + reply: vi.fn(), + sendMedia: vi.fn(), + chatType: "group", + conversationId: "123@g.us", + chatId: "123@g.us", + }); + + // No mention => no auto-reply for the group message. + await new Promise((resolve) => setTimeout(resolve, 10)); + expect( + replyResolver.mock.calls.some( + (call) => call[0]?.Body !== HEARTBEAT_PROMPT, + ), + ).toBe(false); + + requestReplyHeartbeatNow({ coalesceMs: 0 }); + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.abort(); + await run; + + const heartbeatCall = replyResolver.mock.calls.find( + (call) => call[0]?.Body === HEARTBEAT_PROMPT, + ); + expect(heartbeatCall?.[0]?.From).toBe("+1555"); + expect(heartbeatCall?.[0]?.To).toBe("+1555"); + expect(heartbeatCall?.[0]?.MessageSid).toBe("sid-main"); + } finally { + controller.abort(); + await store.cleanup(); + } + }); + it("batches inbound messages while queue is busy and preserves timestamps", async () => { vi.useFakeTimers(); const originalMax = process.getMaxListeners(); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index dbdb9f1d0..4f790a38a 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1165,16 +1165,19 @@ export async function monitorWebProvider( if (!replyHeartbeatMinutes) { return { status: "skipped", reason: "disabled" }; } - if (lastInboundMsg?.chatType === "group") { + let heartbeatInboundMsg = lastInboundMsg; + if (heartbeatInboundMsg?.chatType === "group") { + // Heartbeats should never target group chats. If the last inbound activity + // was in a group, fall back to the main/direct session recipient instead + // of skipping heartbeats entirely. heartbeatLogger.info( { connectionId, reason: "last-inbound-group" }, - "reply heartbeat skipped", + "reply heartbeat falling back", ); - console.log(success("heartbeat: skipped (group chat)")); - return { status: "skipped", reason: "group-chat" }; + heartbeatInboundMsg = null; } const tickStart = Date.now(); - if (!lastInboundMsg) { + if (!heartbeatInboundMsg) { const fallbackTo = getFallbackRecipient(cfg); if (!fallbackTo) { heartbeatLogger.info( @@ -1230,12 +1233,12 @@ export async function monitorWebProvider( } try { - const snapshot = getSessionSnapshot(cfg, lastInboundMsg.from); + const snapshot = getSessionSnapshot(cfg, heartbeatInboundMsg.from); if (isVerbose()) { heartbeatLogger.info( { connectionId, - to: lastInboundMsg.from, + to: heartbeatInboundMsg.from, intervalMinutes: replyHeartbeatMinutes, sessionKey: snapshot.key, sessionId: snapshot.entry?.sessionId ?? null, @@ -1247,15 +1250,15 @@ export async function monitorWebProvider( const replyResult = await (replyResolver ?? getReplyFromConfig)( { Body: HEARTBEAT_PROMPT, - From: lastInboundMsg.from, - To: lastInboundMsg.to, + From: heartbeatInboundMsg.from, + To: heartbeatInboundMsg.to, MessageSid: snapshot.entry?.sessionId, MediaPath: undefined, MediaUrl: undefined, MediaType: undefined, }, { - onReplyStart: lastInboundMsg.sendComposing, + onReplyStart: heartbeatInboundMsg.sendComposing, isHeartbeat: true, }, );