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