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,
} 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<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 () => {
vi.useFakeTimers();
const originalMax = process.getMaxListeners();

View File

@@ -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,
},
);