fix(web): heartbeat fallback after group inbound

This commit is contained in:
Peter Steinberger
2025-12-14 01:26:40 +00:00
parent 25eb40ab31
commit 8b6abe0151
2 changed files with 105 additions and 10 deletions

View File

@@ -19,6 +19,7 @@ import {
stripHeartbeatToken, stripHeartbeatToken,
} from "./auto-reply.js"; } from "./auto-reply.js";
import type { sendMessageWhatsApp } from "./outbound.js"; import type { sendMessageWhatsApp } from "./outbound.js";
import { requestReplyHeartbeatNow } from "./reply-heartbeat-wake.js";
import { import {
resetBaileysMocks, resetBaileysMocks,
resetLoadConfigMock, 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<void>)
| undefined;
const listenerFactory = vi.fn(
async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
const onClose = new Promise<void>(() => {
// 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 () => { it("batches inbound messages while queue is busy and preserves timestamps", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const originalMax = process.getMaxListeners(); const originalMax = process.getMaxListeners();

View File

@@ -1165,16 +1165,19 @@ export async function monitorWebProvider(
if (!replyHeartbeatMinutes) { if (!replyHeartbeatMinutes) {
return { status: "skipped", reason: "disabled" }; 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( heartbeatLogger.info(
{ connectionId, reason: "last-inbound-group" }, { connectionId, reason: "last-inbound-group" },
"reply heartbeat skipped", "reply heartbeat falling back",
); );
console.log(success("heartbeat: skipped (group chat)")); heartbeatInboundMsg = null;
return { status: "skipped", reason: "group-chat" };
} }
const tickStart = Date.now(); const tickStart = Date.now();
if (!lastInboundMsg) { if (!heartbeatInboundMsg) {
const fallbackTo = getFallbackRecipient(cfg); const fallbackTo = getFallbackRecipient(cfg);
if (!fallbackTo) { if (!fallbackTo) {
heartbeatLogger.info( heartbeatLogger.info(
@@ -1230,12 +1233,12 @@ export async function monitorWebProvider(
} }
try { try {
const snapshot = getSessionSnapshot(cfg, lastInboundMsg.from); const snapshot = getSessionSnapshot(cfg, heartbeatInboundMsg.from);
if (isVerbose()) { if (isVerbose()) {
heartbeatLogger.info( heartbeatLogger.info(
{ {
connectionId, connectionId,
to: lastInboundMsg.from, to: heartbeatInboundMsg.from,
intervalMinutes: replyHeartbeatMinutes, intervalMinutes: replyHeartbeatMinutes,
sessionKey: snapshot.key, sessionKey: snapshot.key,
sessionId: snapshot.entry?.sessionId ?? null, sessionId: snapshot.entry?.sessionId ?? null,
@@ -1247,15 +1250,15 @@ export async function monitorWebProvider(
const replyResult = await (replyResolver ?? getReplyFromConfig)( const replyResult = await (replyResolver ?? getReplyFromConfig)(
{ {
Body: HEARTBEAT_PROMPT, Body: HEARTBEAT_PROMPT,
From: lastInboundMsg.from, From: heartbeatInboundMsg.from,
To: lastInboundMsg.to, To: heartbeatInboundMsg.to,
MessageSid: snapshot.entry?.sessionId, MessageSid: snapshot.entry?.sessionId,
MediaPath: undefined, MediaPath: undefined,
MediaUrl: undefined, MediaUrl: undefined,
MediaType: undefined, MediaType: undefined,
}, },
{ {
onReplyStart: lastInboundMsg.sendComposing, onReplyStart: heartbeatInboundMsg.sendComposing,
isHeartbeat: true, isHeartbeat: true,
}, },
); );