diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 4f2d5541b6a..5337c5d6a43 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -413,7 +413,13 @@ export async function monitorWebInbox(options: { // If this is history/offline catch-up, mark read above but skip auto-reply. if (upsert.type === "append") { - continue; + const APPEND_RECENT_GRACE_MS = 60_000; + const msgTsRaw = msg.messageTimestamp; + const msgTsNum = msgTsRaw != null ? Number(msgTsRaw) : NaN; + const msgTsMs = Number.isFinite(msgTsNum) ? msgTsNum * 1000 : 0; + if (msgTsMs < connectedAtMs - APPEND_RECENT_GRACE_MS) { + continue; + } } const enriched = await enrichInboundMessage(msg); diff --git a/extensions/whatsapp/src/monitor-inbox.append-upsert.test.ts b/extensions/whatsapp/src/monitor-inbox.append-upsert.test.ts new file mode 100644 index 00000000000..e5746455432 --- /dev/null +++ b/extensions/whatsapp/src/monitor-inbox.append-upsert.test.ts @@ -0,0 +1,149 @@ +import "./monitor-inbox.test-harness.js"; +import { describe, expect, it, vi } from "vitest"; +import { monitorWebInbox } from "./inbound.js"; +import { + DEFAULT_ACCOUNT_ID, + getAuthDir, + getSock, + installWebMonitorInboxUnitTestHooks, +} from "./monitor-inbox.test-harness.js"; + +describe("append upsert handling (#20952)", () => { + installWebMonitorInboxUnitTestHooks(); + type InboxOnMessage = NonNullable[0]["onMessage"]>; + + async function tick() { + await new Promise((resolve) => setImmediate(resolve)); + } + + async function startInboxMonitor(onMessage: InboxOnMessage) { + const listener = await monitorWebInbox({ + verbose: false, + onMessage, + accountId: DEFAULT_ACCOUNT_ID, + authDir: getAuthDir(), + }); + return { listener, sock: getSock() }; + } + + it("processes recent append messages (within 60s of connect)", async () => { + const onMessage = vi.fn(async () => {}); + const { listener, sock } = await startInboxMonitor(onMessage); + + // Timestamp ~5 seconds ago — recent, should be processed. + const recentTs = Math.floor(Date.now() / 1000) - 5; + sock.ev.emit("messages.upsert", { + type: "append", + messages: [ + { + key: { id: "recent-1", fromMe: false, remoteJid: "120363@g.us" }, + message: { conversation: "hello from group" }, + messageTimestamp: recentTs, + pushName: "Tester", + }, + ], + }); + await tick(); + + expect(onMessage).toHaveBeenCalledTimes(1); + + await listener.close(); + }); + + it("skips stale append messages (older than 60s before connect)", async () => { + const onMessage = vi.fn(async () => {}); + const { listener, sock } = await startInboxMonitor(onMessage); + + // Timestamp 5 minutes ago — stale history sync, should be skipped. + const staleTs = Math.floor(Date.now() / 1000) - 300; + sock.ev.emit("messages.upsert", { + type: "append", + messages: [ + { + key: { id: "stale-1", fromMe: false, remoteJid: "120363@g.us" }, + message: { conversation: "old history sync" }, + messageTimestamp: staleTs, + pushName: "OldTester", + }, + ], + }); + await tick(); + + expect(onMessage).not.toHaveBeenCalled(); + + await listener.close(); + }); + + it("skips append messages with NaN/non-finite timestamps", async () => { + const onMessage = vi.fn(async () => {}); + const { listener, sock } = await startInboxMonitor(onMessage); + + // NaN timestamp should be treated as 0 (stale) and skipped. + sock.ev.emit("messages.upsert", { + type: "append", + messages: [ + { + key: { id: "nan-1", fromMe: false, remoteJid: "120363@g.us" }, + message: { conversation: "bad timestamp" }, + messageTimestamp: NaN, + pushName: "BadTs", + }, + ], + }); + await tick(); + + expect(onMessage).not.toHaveBeenCalled(); + + await listener.close(); + }); + + it("handles Long-like protobuf timestamps correctly", async () => { + const onMessage = vi.fn(async () => {}); + const { listener, sock } = await startInboxMonitor(onMessage); + + // Baileys can deliver messageTimestamp as a Long object (from protobufjs). + // Number(longObj) calls valueOf() and returns the numeric value. + const recentTs = Math.floor(Date.now() / 1000) - 5; + const longLike = { low: recentTs, high: 0, unsigned: true, valueOf: () => recentTs }; + sock.ev.emit("messages.upsert", { + type: "append", + messages: [ + { + key: { id: "long-1", fromMe: false, remoteJid: "120363@g.us" }, + message: { conversation: "long timestamp" }, + messageTimestamp: longLike, + pushName: "LongTs", + }, + ], + }); + await tick(); + + expect(onMessage).toHaveBeenCalledTimes(1); + + await listener.close(); + }); + + it("always processes notify messages regardless of timestamp", async () => { + const onMessage = vi.fn(async () => {}); + const { listener, sock } = await startInboxMonitor(onMessage); + + // Very old timestamp but type=notify — should always be processed. + const oldTs = Math.floor(Date.now() / 1000) - 86400; + sock.ev.emit("messages.upsert", { + type: "notify", + messages: [ + { + key: { id: "notify-1", fromMe: false, remoteJid: "999@s.whatsapp.net" }, + message: { conversation: "normal message" }, + messageTimestamp: oldTs, + pushName: "User", + }, + ], + }); + await tick(); + + expect(onMessage).toHaveBeenCalledTimes(1); + + await listener.close(); + }); +});