fix(web): heartbeat fallback after group inbound
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user