diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab9a4f9a..04ae92ed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204. - Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639. - Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads. +- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904) - CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands. - Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors. - Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`. diff --git a/src/web/inbound/access-control.pairing-history.test.ts b/src/web/inbound/access-control.pairing-history.test.ts new file mode 100644 index 000000000..795839bf2 --- /dev/null +++ b/src/web/inbound/access-control.pairing-history.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { checkInboundAccessControl } from "./access-control.js"; + +const sendMessageMock = vi.fn(); +const readAllowFromStoreMock = vi.fn(); +const upsertPairingRequestMock = vi.fn(); + +let config: Record = {}; + +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => config, + }; +}); + +vi.mock("../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), + upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), +})); + +beforeEach(() => { + config = { + channels: { + whatsapp: { + dmPolicy: "pairing", + allowFrom: [], + }, + }, + }; + sendMessageMock.mockReset().mockResolvedValue(undefined); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); +}); + +describe("checkInboundAccessControl", () => { + it("suppresses pairing replies for historical DMs on connect", async () => { + const connectedAtMs = 1_000_000; + const messageTimestampMs = connectedAtMs - 31_000; + + const result = await checkInboundAccessControl({ + accountId: "default", + from: "+15550001111", + selfE164: "+15550009999", + senderE164: "+15550001111", + group: false, + pushName: "Sam", + isFromMe: false, + messageTimestampMs, + connectedAtMs, + pairingGraceMs: 30_000, + sock: { sendMessage: sendMessageMock }, + remoteJid: "15550001111@s.whatsapp.net", + }); + + expect(result.allowed).toBe(false); + expect(upsertPairingRequestMock).not.toHaveBeenCalled(); + expect(sendMessageMock).not.toHaveBeenCalled(); + }); + + it("sends pairing replies for live DMs", async () => { + const connectedAtMs = 1_000_000; + const messageTimestampMs = connectedAtMs - 10_000; + + const result = await checkInboundAccessControl({ + accountId: "default", + from: "+15550001111", + selfE164: "+15550009999", + senderE164: "+15550001111", + group: false, + pushName: "Sam", + isFromMe: false, + messageTimestampMs, + connectedAtMs, + pairingGraceMs: 30_000, + sock: { sendMessage: sendMessageMock }, + remoteJid: "15550001111@s.whatsapp.net", + }); + + expect(result.allowed).toBe(false); + expect(upsertPairingRequestMock).toHaveBeenCalled(); + expect(sendMessageMock).toHaveBeenCalled(); + }); +}); diff --git a/src/web/inbound/access-control.ts b/src/web/inbound/access-control.ts index f96bf5515..458e8422c 100644 --- a/src/web/inbound/access-control.ts +++ b/src/web/inbound/access-control.ts @@ -15,6 +15,8 @@ export type InboundAccessControlResult = { resolvedAccountId: string; }; +const PAIRING_REPLY_HISTORY_GRACE_MS = 30_000; + export async function checkInboundAccessControl(params: { accountId: string; from: string; @@ -23,6 +25,9 @@ export async function checkInboundAccessControl(params: { group: boolean; pushName?: string; isFromMe: boolean; + messageTimestampMs?: number; + connectedAtMs?: number; + pairingGraceMs?: number; sock: { sendMessage: (jid: string, content: { text: string }) => Promise; }; @@ -48,6 +53,14 @@ export async function checkInboundAccessControl(params: { (configuredAllowFrom && configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined); const isSamePhone = params.from === params.selfE164; const isSelfChat = isSelfChatMode(params.selfE164, configuredAllowFrom); + const pairingGraceMs = + typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0 + ? params.pairingGraceMs + : PAIRING_REPLY_HISTORY_GRACE_MS; + const suppressPairingReply = + typeof params.connectedAtMs === "number" && + typeof params.messageTimestampMs === "number" && + params.messageTimestampMs < params.connectedAtMs - pairingGraceMs; // Pre-compute normalized allowlists for filtering. const dmHasWildcard = allowFrom?.includes("*") ?? false; @@ -128,25 +141,29 @@ export async function checkInboundAccessControl(params: { (normalizedAllowFrom.length > 0 && normalizedAllowFrom.includes(candidate)); if (!allowed) { if (dmPolicy === "pairing") { - const { code, created } = await upsertChannelPairingRequest({ - channel: "whatsapp", - id: candidate, - meta: { name: (params.pushName ?? "").trim() || undefined }, - }); - if (created) { - logVerbose( - `whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`, - ); - try { - await params.sock.sendMessage(params.remoteJid, { - text: buildPairingReply({ - channel: "whatsapp", - idLine: `Your WhatsApp phone number: ${candidate}`, - code, - }), - }); - } catch (err) { - logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`); + if (suppressPairingReply) { + logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`); + } else { + const { code, created } = await upsertChannelPairingRequest({ + channel: "whatsapp", + id: candidate, + meta: { name: (params.pushName ?? "").trim() || undefined }, + }); + if (created) { + logVerbose( + `whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`, + ); + try { + await params.sock.sendMessage(params.remoteJid, { + text: buildPairingReply({ + channel: "whatsapp", + idLine: `Your WhatsApp phone number: ${candidate}`, + code, + }), + }); + } catch (err) { + logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`); + } } } } else { diff --git a/src/web/inbound/monitor.ts b/src/web/inbound/monitor.ts index b6e8a575b..a7d4f0031 100644 --- a/src/web/inbound/monitor.ts +++ b/src/web/inbound/monitor.ts @@ -40,6 +40,7 @@ export async function monitorWebInbox(options: { authDir: options.authDir, }); await waitForWaConnection(sock); + const connectedAtMs = Date.now(); let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null; const onClose = new Promise((resolve) => { @@ -171,6 +172,9 @@ export async function monitorWebInbox(options: { groupSubject = meta.subject; groupParticipants = meta.participants; } + const messageTimestampMs = msg.messageTimestamp + ? Number(msg.messageTimestamp) * 1000 + : undefined; const access = await checkInboundAccessControl({ accountId: options.accountId, @@ -180,6 +184,8 @@ export async function monitorWebInbox(options: { group, pushName: msg.pushName ?? undefined, isFromMe: Boolean(msg.key?.fromMe), + messageTimestampMs, + connectedAtMs, sock: { sendMessage: (jid, content) => sock.sendMessage(jid, content) }, remoteJid, }); @@ -253,7 +259,7 @@ export async function monitorWebInbox(options: { const sendMedia = async (payload: AnyMessageContent) => { await sock.sendMessage(chatJid, payload); }; - const timestamp = msg.messageTimestamp ? Number(msg.messageTimestamp) * 1000 : undefined; + const timestamp = messageTimestampMs; const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined); const senderName = msg.pushName ?? undefined; diff --git a/src/web/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts b/src/web/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts index 1e63def15..4dc7c7ed3 100644 --- a/src/web/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts +++ b/src/web/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts @@ -76,6 +76,7 @@ import { resetLogger, setLoggerOverride } from "../logging.js"; import { monitorWebInbox, resetWebInboundDedupe } from "./inbound.js"; const ACCOUNT_ID = "default"; +const nowSeconds = (offsetMs = 0) => Math.floor((Date.now() + offsetMs) / 1000); let authDir: string; describe("web monitor inbox", () => { @@ -112,7 +113,7 @@ describe("web monitor inbox", () => { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); const upsert = { @@ -121,7 +122,7 @@ describe("web monitor inbox", () => { { key: { id: "auth1", fromMe: false, remoteJid: "999@s.whatsapp.net" }, message: { conversation: "authorized message" }, - messageTimestamp: 1_700_000_000, + messageTimestamp: nowSeconds(60_000), }, ], }; @@ -167,7 +168,7 @@ describe("web monitor inbox", () => { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); // Message from self (sock.user.id is "123@s.whatsapp.net" in mock) @@ -177,7 +178,7 @@ describe("web monitor inbox", () => { { key: { id: "self1", fromMe: false, remoteJid: "123@s.whatsapp.net" }, message: { conversation: "self message" }, - messageTimestamp: 1_700_000_000, + messageTimestamp: nowSeconds(60_000), }, ], }; @@ -210,7 +211,7 @@ describe("web monitor inbox", () => { .mockResolvedValueOnce({ code: "PAIRCODE", created: false }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); // Message from someone else should be blocked @@ -224,7 +225,7 @@ describe("web monitor inbox", () => { remoteJid: "999@s.whatsapp.net", }, message: { conversation: "ping" }, - messageTimestamp: 1_700_000_000, + messageTimestamp: nowSeconds(), }, ], }; @@ -250,7 +251,7 @@ describe("web monitor inbox", () => { remoteJid: "999@s.whatsapp.net", }, message: { conversation: "ping again" }, - messageTimestamp: 1_700_000_002, + messageTimestamp: nowSeconds(), }, ], }; @@ -271,7 +272,7 @@ describe("web monitor inbox", () => { remoteJid: "123@s.whatsapp.net", }, message: { conversation: "self ping" }, - messageTimestamp: 1_700_000_001, + messageTimestamp: nowSeconds(), }, ], }; @@ -315,7 +316,7 @@ describe("web monitor inbox", () => { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); const upsert = { @@ -328,7 +329,7 @@ describe("web monitor inbox", () => { remoteJid: "999@s.whatsapp.net", }, message: { conversation: "hello" }, - messageTimestamp: 1_700_000_000, + messageTimestamp: nowSeconds(), }, ], }; @@ -366,7 +367,7 @@ describe("web monitor inbox", () => { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); const upsert = { @@ -379,7 +380,7 @@ describe("web monitor inbox", () => { remoteJid: "999@s.whatsapp.net", }, message: { conversation: "hello again" }, - messageTimestamp: 1_700_000_000, + messageTimestamp: nowSeconds(), }, ], }; @@ -404,7 +405,7 @@ describe("web monitor inbox", () => { it("handles append messages by marking them read but skipping auto-reply", async () => { const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); const upsert = { @@ -417,7 +418,7 @@ describe("web monitor inbox", () => { remoteJid: "999@s.whatsapp.net", }, message: { conversation: "old message" }, - messageTimestamp: 1_700_000_000, + messageTimestamp: nowSeconds(), pushName: "History Sender", }, ], diff --git a/src/web/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test.ts b/src/web/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test.ts index b1ce05c2d..a7a41b578 100644 --- a/src/web/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test.ts +++ b/src/web/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test.ts @@ -76,6 +76,7 @@ import { resetLogger, setLoggerOverride } from "../logging.js"; import { monitorWebInbox, resetWebInboundDedupe } from "./inbound.js"; const _ACCOUNT_ID = "default"; +const nowSeconds = (offsetMs = 0) => Math.floor((Date.now() + offsetMs) / 1000); let authDir: string; describe("web monitor inbox", () => { @@ -114,7 +115,7 @@ describe("web monitor inbox", () => { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: _ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); // Message from unauthorized sender +999 (not in allowFrom) @@ -128,7 +129,7 @@ describe("web monitor inbox", () => { remoteJid: "999@s.whatsapp.net", }, message: { conversation: "unauthorized message" }, - messageTimestamp: 1_700_000_000, + messageTimestamp: nowSeconds(), }, ], }; @@ -175,7 +176,7 @@ describe("web monitor inbox", () => { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: _ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); const upsert = { @@ -184,7 +185,7 @@ describe("web monitor inbox", () => { { key: { id: "self1", fromMe: false, remoteJid: "123@s.whatsapp.net" }, message: { conversation: "self ping" }, - messageTimestamp: 1_700_000_000, + messageTimestamp: nowSeconds(), }, ], }; @@ -214,6 +215,8 @@ describe("web monitor inbox", () => { const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, + accountId: _ACCOUNT_ID, + authDir, onMessage, sendReadReceipts: false, }); @@ -225,7 +228,7 @@ describe("web monitor inbox", () => { { key: { id: "rr-off-1", fromMe: false, remoteJid: "222@s.whatsapp.net" }, message: { conversation: "read receipts off" }, - messageTimestamp: 1_700_000_000, + messageTimestamp: nowSeconds(), }, ], }; @@ -249,7 +252,7 @@ describe("web monitor inbox", () => { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: _ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); const upsert = { @@ -263,6 +266,7 @@ describe("web monitor inbox", () => { participant: "999@s.whatsapp.net", }, message: { conversation: "unauthorized group message" }, + messageTimestamp: nowSeconds(), }, ], }; @@ -289,7 +293,7 @@ describe("web monitor inbox", () => { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: _ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); const upsert = { @@ -303,6 +307,7 @@ describe("web monitor inbox", () => { participant: "999@s.whatsapp.net", }, message: { conversation: "group message should be blocked" }, + messageTimestamp: nowSeconds(), }, ], }; @@ -332,7 +337,7 @@ describe("web monitor inbox", () => { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: _ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); const upsert = { @@ -346,6 +351,7 @@ describe("web monitor inbox", () => { participant: "999@s.whatsapp.net", }, message: { conversation: "unauthorized group sender" }, + messageTimestamp: nowSeconds(), }, ], }; @@ -375,7 +381,7 @@ describe("web monitor inbox", () => { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: _ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); const upsert = { @@ -389,6 +395,7 @@ describe("web monitor inbox", () => { participant: "15551234567@s.whatsapp.net", }, message: { conversation: "authorized group sender" }, + messageTimestamp: nowSeconds(), }, ], }; @@ -421,7 +428,7 @@ describe("web monitor inbox", () => { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: _ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); const upsert = { @@ -435,6 +442,7 @@ describe("web monitor inbox", () => { participant: "9999999999@s.whatsapp.net", // Random sender }, message: { conversation: "wildcard group sender" }, + messageTimestamp: nowSeconds(), }, ], }; @@ -465,7 +473,7 @@ describe("web monitor inbox", () => { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ verbose: false, accountId: _ACCOUNT_ID, authDir, onMessage }); const sock = await createWaSocket(); const upsert = { @@ -479,6 +487,7 @@ describe("web monitor inbox", () => { participant: "999@s.whatsapp.net", }, message: { conversation: "blocked by empty allowlist" }, + messageTimestamp: nowSeconds(), }, ], };