diff --git a/CHANGELOG.md b/CHANGELOG.md index 187c8fbcc..05826d4b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`. - CLI/Docs: add per-command CLI doc pages and link them from `clawdbot --help`. - Browser: copy the installed Chrome extension path to clipboard after `clawdbot browser extension install/path`. +- WhatsApp: add `channels.whatsapp.sendReadReceipts` to disable auto read receipts. (#882) — thanks @chrisrodz. ### Fixes - Browser: add tests for snapshot labels/efficient query params and labeled image responses. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 86eebc24d..e74f220dd 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -93,6 +93,7 @@ group messages, so use admin if you need full visibility. - Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`. - Replies always route back to the same Telegram chat. - Long-polling uses grammY runner with per-chat sequencing; overall concurrency is capped by `agents.defaults.maxConcurrent`. +- Telegram Bot API does not support read receipts; there is no `sendReadReceipts` option. ## Formatting (Telegram HTML) - Outbound Telegram text uses `parse_mode: "HTML"` (Telegram’s supported tag subset). diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index d2cafe96c..e66e18769 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -138,6 +138,32 @@ Behavior: - Self-chat mode (allowFrom includes your number) avoids auto read receipts and ignores mention JIDs. - Read receipts sent for non-self-chat DMs. +## Read receipts +By default, the gateway marks inbound WhatsApp messages as read (blue ticks) once they are accepted. + +Disable globally: +```json5 +{ + channels: { whatsapp: { sendReadReceipts: false } } +} +``` + +Disable per account: +```json5 +{ + channels: { + whatsapp: { + accounts: { + personal: { sendReadReceipts: false } + } + } + } +} +``` + +Notes: +- Self-chat mode always skips read receipts. + ## WhatsApp FAQ: sending messages + pairing **Will Clawdbot message random contacts when I link WhatsApp?** diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 589e5ade8..a1f90fd7d 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -430,6 +430,22 @@ For groups, use `channels.whatsapp.groupPolicy` + `channels.whatsapp.groupAllowF } ``` +### `channels.whatsapp.sendReadReceipts` + +Controls whether inbound WhatsApp messages are marked as read (blue ticks). Default: `true`. + +Self-chat mode always skips read receipts, even when enabled. + +Per-account override: `channels.whatsapp.accounts..sendReadReceipts`. + +```json5 +{ + channels: { + whatsapp: { sendReadReceipts: false } + } +} +``` + ### `channels.whatsapp.accounts` (multi-account) Run multiple WhatsApp accounts in one gateway: diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 82f467530..c28178f06 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -14,6 +14,8 @@ export type WhatsAppConfig = { capabilities?: string[]; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; + /** Send read receipts for incoming messages (default true). */ + sendReadReceipts?: boolean; /** * Inbound message prefix (WhatsApp only). * Default: `[{agents.list[].identity.name}]` (or `[clawdbot]`) when allowFrom is empty, else `""`. @@ -84,6 +86,8 @@ export type WhatsAppAccountConfig = { configWrites?: boolean; /** If false, do not start this WhatsApp account provider. Default: true. */ enabled?: boolean; + /** Send read receipts for incoming messages (default true). */ + sendReadReceipts?: boolean; /** Inbound message prefix override for this account (WhatsApp only). */ messagePrefix?: string; /** Override auth directory (Baileys multi-file auth state). */ diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 3636c302c..962bb5619 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -13,6 +13,7 @@ export const WhatsAppAccountSchema = z capabilities: z.array(z.string()).optional(), configWrites: z.boolean().optional(), enabled: z.boolean().optional(), + sendReadReceipts: z.boolean().optional(), messagePrefix: z.string().optional(), /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ authDir: z.string().optional(), @@ -62,6 +63,7 @@ export const WhatsAppConfigSchema = z accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(), capabilities: z.array(z.string()).optional(), configWrites: z.boolean().optional(), + sendReadReceipts: z.boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), messagePrefix: z.string().optional(), selfChatMode: z.boolean().optional(), diff --git a/src/web/accounts.ts b/src/web/accounts.ts index d1c4e1722..618b57d2d 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -12,6 +12,7 @@ export type ResolvedWhatsAppAccount = { accountId: string; name?: string; enabled: boolean; + sendReadReceipts: boolean; messagePrefix?: string; authDir: string; isLegacyAuthDir: boolean; @@ -125,6 +126,7 @@ export function resolveWhatsAppAccount(params: { cfg: ClawdbotConfig; accountId?: string | null; }): ResolvedWhatsAppAccount { + const rootCfg = params.cfg.channels?.whatsapp; const accountId = params.accountId?.trim() || resolveDefaultWhatsAppAccountId(params.cfg); const accountCfg = resolveAccountConfig(params.cfg, accountId); const enabled = accountCfg?.enabled !== false; @@ -136,22 +138,21 @@ export function resolveWhatsAppAccount(params: { accountId, name: accountCfg?.name?.trim() || undefined, enabled, + sendReadReceipts: accountCfg?.sendReadReceipts ?? rootCfg?.sendReadReceipts ?? true, messagePrefix: - accountCfg?.messagePrefix ?? - params.cfg.channels?.whatsapp?.messagePrefix ?? - params.cfg.messages?.messagePrefix, + accountCfg?.messagePrefix ?? rootCfg?.messagePrefix ?? params.cfg.messages?.messagePrefix, authDir, isLegacyAuthDir: isLegacy, - selfChatMode: accountCfg?.selfChatMode ?? params.cfg.channels?.whatsapp?.selfChatMode, - dmPolicy: accountCfg?.dmPolicy ?? params.cfg.channels?.whatsapp?.dmPolicy, - allowFrom: accountCfg?.allowFrom ?? params.cfg.channels?.whatsapp?.allowFrom, - groupAllowFrom: accountCfg?.groupAllowFrom ?? params.cfg.channels?.whatsapp?.groupAllowFrom, - groupPolicy: accountCfg?.groupPolicy ?? params.cfg.channels?.whatsapp?.groupPolicy, - textChunkLimit: accountCfg?.textChunkLimit ?? params.cfg.channels?.whatsapp?.textChunkLimit, - mediaMaxMb: accountCfg?.mediaMaxMb ?? params.cfg.channels?.whatsapp?.mediaMaxMb, - blockStreaming: accountCfg?.blockStreaming ?? params.cfg.channels?.whatsapp?.blockStreaming, - ackReaction: accountCfg?.ackReaction ?? params.cfg.channels?.whatsapp?.ackReaction, - groups: accountCfg?.groups ?? params.cfg.channels?.whatsapp?.groups, + selfChatMode: accountCfg?.selfChatMode ?? rootCfg?.selfChatMode, + dmPolicy: accountCfg?.dmPolicy ?? rootCfg?.dmPolicy, + allowFrom: accountCfg?.allowFrom ?? rootCfg?.allowFrom, + groupAllowFrom: accountCfg?.groupAllowFrom ?? rootCfg?.groupAllowFrom, + groupPolicy: accountCfg?.groupPolicy ?? rootCfg?.groupPolicy, + textChunkLimit: accountCfg?.textChunkLimit ?? rootCfg?.textChunkLimit, + mediaMaxMb: accountCfg?.mediaMaxMb ?? rootCfg?.mediaMaxMb, + blockStreaming: accountCfg?.blockStreaming ?? rootCfg?.blockStreaming, + ackReaction: accountCfg?.ackReaction ?? rootCfg?.ackReaction, + groups: accountCfg?.groups ?? rootCfg?.groups, }; } diff --git a/src/web/auto-reply/monitor.ts b/src/web/auto-reply/monitor.ts index 328b49a55..c8d139032 100644 --- a/src/web/auto-reply/monitor.ts +++ b/src/web/auto-reply/monitor.ts @@ -174,6 +174,7 @@ export async function monitorWebChannel( accountId: account.accountId, authDir: account.authDir, mediaMaxMb: account.mediaMaxMb, + sendReadReceipts: account.sendReadReceipts, onMessage: async (msg: WebInboundMsg) => { handledMessages += 1; lastMessageAt = Date.now(); diff --git a/src/web/inbound/monitor.ts b/src/web/inbound/monitor.ts index 395de7cb6..3c92fe4a2 100644 --- a/src/web/inbound/monitor.ts +++ b/src/web/inbound/monitor.ts @@ -26,6 +26,8 @@ export async function monitorWebInbox(options: { authDir: string; onMessage: (msg: WebInboundMessage) => Promise; mediaMaxMb?: number; + /** Send read receipts for incoming messages (default true). */ + sendReadReceipts?: boolean; }) { const inboundLogger = getChildLogger({ module: "web-inbound" }); const inboundConsoleLog = createSubsystemLogger("gateway/channels/whatsapp").child("inbound"); @@ -139,7 +141,7 @@ export async function monitorWebInbox(options: { }); if (!access.allowed) continue; - if (id && !access.isSelfChat) { + if (id && !access.isSelfChat && options.sendReadReceipts !== false) { const participant = msg.key?.participant; try { await sock.readMessages([{ remoteJid, id, participant, fromMe: false }]); 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 1b0f2d3b2..b1ce05c2d 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 @@ -210,6 +210,35 @@ describe("web monitor inbox", () => { await listener.close(); }); + it("skips read receipts when disabled", async () => { + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ + verbose: false, + onMessage, + sendReadReceipts: false, + }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { id: "rr-off-1", fromMe: false, remoteJid: "222@s.whatsapp.net" }, + message: { conversation: "read receipts off" }, + messageTimestamp: 1_700_000_000, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).toHaveBeenCalledTimes(1); + expect(sock.readMessages).not.toHaveBeenCalled(); + + await listener.close(); + }); + it("lets group messages through even when sender not in allowFrom", async () => { mockLoadConfig.mockReturnValue({ channels: { whatsapp: { allowFrom: ["+1234"], groupPolicy: "open" } },