diff --git a/src/utils.ts b/src/utils.ts index 39793ddbf..c629c00a1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -31,6 +31,28 @@ export function normalizeE164(number: string): string { return `+${digits}`; } +/** + * "Self-chat mode" heuristic (single phone): the gateway is logged in as the owner's own WhatsApp account, + * and `inbound.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the + * "bot" and the human are the same WhatsApp identity (e.g. auto read receipts, @mention JID triggers). + */ +export function isSelfChatMode( + selfE164: string | null | undefined, + allowFrom?: Array | null, +): boolean { + if (!selfE164) return false; + if (!Array.isArray(allowFrom) || allowFrom.length === 0) return false; + const normalizedSelf = normalizeE164(selfE164); + return allowFrom.some((n) => { + if (n === "*") return false; + try { + return normalizeE164(String(n)) === normalizedSelf; + } catch { + return false; + } + }); +} + export function toWhatsappJid(number: string): string { const e164 = normalizeE164(number); const digits = e164.replace(/\D/g, ""); diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index e98e7f73d..a1bdc5d0c 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1425,6 +1425,82 @@ describe("web auto-reply", () => { expect(payload.Body).toContain("[from: Bob (+222)]"); }); + it("ignores JID mentions in self-chat mode (group chats)", async () => { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "ok" }); + + setLoadConfigMock(() => ({ + inbound: { + // Self-chat heuristic: allowFrom includes selfE164. + allowFrom: ["+999"], + groupChat: { + requireMention: true, + mentionPatterns: ["\\bclawd\\b"], + }, + }, + })); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + // WhatsApp @mention of the owner should NOT trigger the bot in self-chat mode. + await capturedOnMessage?.({ + body: "@owner ping", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + id: "g-self-1", + senderE164: "+111", + senderName: "Alice", + mentionedJids: ["999@s.whatsapp.net"], + selfE164: "+999", + selfJid: "999@s.whatsapp.net", + sendComposing, + reply, + sendMedia, + }); + + expect(resolver).not.toHaveBeenCalled(); + + // Text-based mentionPatterns still work (user can type "clawd" explicitly). + await capturedOnMessage?.({ + body: "clawd ping", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + id: "g-self-2", + senderE164: "+222", + senderName: "Bob", + selfE164: "+999", + selfJid: "999@s.whatsapp.net", + sendComposing, + reply, + sendMedia, + }); + + expect(resolver).toHaveBeenCalledTimes(1); + + resetLoadConfigMock(); + }); + it("emits heartbeat logs with connection metadata", async () => { vi.useFakeTimers(); const logPath = `/tmp/clawdis-heartbeat-${crypto.randomUUID()}.log`; diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 62eca429d..2741213f1 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -19,7 +19,7 @@ import { logInfo } from "../logger.js"; import { getChildLogger } from "../logging.js"; import { getQueueSize } from "../process/command-queue.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { jidToE164, normalizeE164 } from "../utils.js"; +import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js"; import { setActiveWebListener } from "./active-listener.js"; import { monitorWebInbox } from "./inbound.js"; import { loadWebMedia } from "./media.js"; @@ -85,6 +85,7 @@ function elide(text?: string, limit = 400) { type MentionConfig = { requireMention: boolean; mentionRegexes: RegExp[]; + allowFrom?: Array; }; function buildMentionConfig(cfg: ReturnType): MentionConfig { @@ -100,7 +101,7 @@ function buildMentionConfig(cfg: ReturnType): MentionConfig { } }) .filter((r): r is RegExp => Boolean(r)) ?? []; - return { requireMention, mentionRegexes }; + return { requireMention, mentionRegexes, allowFrom: cfg.inbound?.allowFrom }; } function isBotMentioned( @@ -113,7 +114,9 @@ function isBotMentioned( .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") .toLowerCase(); - if (msg.mentionedJids?.length) { + const isSelfChat = isSelfChatMode(msg.selfE164, mentionCfg.allowFrom); + + if (msg.mentionedJids?.length && !isSelfChat) { const normalizedMentions = msg.mentionedJids .map((jid) => jidToE164(jid) ?? jid) .filter(Boolean); @@ -123,6 +126,8 @@ function isBotMentioned( const bareSelf = msg.selfJid.replace(/:\\d+/, ""); if (normalizedMentions.includes(bareSelf)) return true; } + } else if (msg.mentionedJids?.length && isSelfChat) { + // Self-chat mode: ignore WhatsApp @mention JIDs, otherwise @mentioning the owner in group chats triggers the bot. } const bodyClean = clean(msg.body); if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) return true; diff --git a/src/web/inbound.ts b/src/web/inbound.ts index 51ec075a6..7a52011ea 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -13,7 +13,7 @@ import { loadConfig } from "../config/config.js"; import { isVerbose, logVerbose } from "../globals.js"; import { getChildLogger } from "../logging.js"; import { saveMediaBuffer } from "../media/store.js"; -import { jidToE164, normalizeE164 } from "../utils.js"; +import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js"; import { createWaSocket, getStatusCode, @@ -116,22 +116,6 @@ export async function monitorWebInbox(options: { // Ignore status/broadcast traffic; we only care about direct chats. if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast")) continue; - if (id) { - const participant = msg.key?.participant; - try { - await sock.readMessages([ - { remoteJid, id, participant, fromMe: false }, - ]); - if (isVerbose()) { - const suffix = participant ? ` (participant ${participant})` : ""; - logVerbose( - `Marked message ${id} as read for ${remoteJid}${suffix}`, - ); - } - } catch (err) { - logVerbose(`Failed to mark message ${id} read: ${String(err)}`); - } - } const group = isJidGroup(remoteJid); const participantJid = msg.key?.participant ?? undefined; const senderE164 = participantJid ? jidToE164(participantJid) : null; @@ -160,6 +144,7 @@ export async function monitorWebInbox(options: { ? configuredAllowFrom : defaultAllowFrom; const isSamePhone = from === selfE164; + const isSelfChat = isSelfChatMode(selfE164, configuredAllowFrom); const allowlistEnabled = !group && Array.isArray(allowFrom) && allowFrom.length > 0; @@ -174,6 +159,26 @@ export async function monitorWebInbox(options: { } } + if (id && !isSelfChat) { + const participant = msg.key?.participant; + try { + await sock.readMessages([ + { remoteJid, id, participant, fromMe: false }, + ]); + if (isVerbose()) { + const suffix = participant ? ` (participant ${participant})` : ""; + logVerbose( + `Marked message ${id} as read for ${remoteJid}${suffix}`, + ); + } + } catch (err) { + logVerbose(`Failed to mark message ${id} read: ${String(err)}`); + } + } else if (id && isSelfChat && isVerbose()) { + // Self-chat mode: never auto-send read receipts (blue ticks) on behalf of the owner. + logVerbose(`Self-chat mode: skipping read receipt for ${id}`); + } + let body = extractText(msg.message ?? undefined); if (!body) { body = extractMediaPlaceholder(msg.message ?? undefined); diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index 16bccef7c..130fd6e6f 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -441,6 +441,56 @@ describe("web monitor inbox", () => { // Should NOT call onMessage for unauthorized senders expect(onMessage).not.toHaveBeenCalled(); + // Should NOT send read receipts for blocked senders (privacy + avoids Baileys Bad MAC churn). + expect(sock.readMessages).not.toHaveBeenCalled(); + + // Reset mock for other tests + mockLoadConfig.mockReturnValue({ + inbound: { + allowFrom: ["*"], + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + await listener.close(); + }); + + it("skips read receipts in self-chat mode", async () => { + mockLoadConfig.mockReturnValue({ + inbound: { + // Self-chat heuristic: allowFrom includes selfE164 (+123). + allowFrom: ["+123"], + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { id: "self1", fromMe: false, remoteJid: "123@s.whatsapp.net" }, + message: { conversation: "self ping" }, + messageTimestamp: 1_700_000_000, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).toHaveBeenCalledTimes(1); + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ from: "+123", to: "+123", body: "self ping" }), + ); + expect(sock.readMessages).not.toHaveBeenCalled(); // Reset mock for other tests mockLoadConfig.mockReturnValue({