diff --git a/.env.example b/.env.example index 0317296ba1e9..654dd00ca5fb 100644 --- a/.env.example +++ b/.env.example @@ -286,6 +286,7 @@ BROWSER_INACTIVITY_TIMEOUT=120 # WhatsApp (built-in Baileys bridge — run `hermes whatsapp` to pair) # WHATSAPP_ENABLED=false # WHATSAPP_ALLOWED_USERS=15551234567 +# WHATSAPP_SEND_READ_RECEIPTS=true # Auto-mark incoming messages as read (default: true) # Email (IMAP/SMTP — send and receive emails as Hermes) # For Gmail: enable 2FA → create App Password at https://myaccount.google.com/apppasswords diff --git a/gateway/config.py b/gateway/config.py index 7ce105f331b7..bc32a798f58e 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -644,6 +644,8 @@ def load_gateway_config() -> GatewayConfig: if isinstance(frc, list): frc = ",".join(str(v) for v in frc) os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc) + if "send_read_receipts" in whatsapp_cfg and not os.getenv("WHATSAPP_SEND_READ_RECEIPTS"): + os.environ["WHATSAPP_SEND_READ_RECEIPTS"] = str(whatsapp_cfg["send_read_receipts"]).lower() # Matrix settings → env vars (env vars take precedence) matrix_cfg = yaml_cfg.get("matrix", {}) diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 70cf8e95d9fa..cb600d4c57c2 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -27,6 +27,7 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from import { randomBytes } from 'crypto'; import qrcode from 'qrcode-terminal'; import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js'; +import { shouldSendReadReceipt } from './read_receipts.js'; // Parse CLI args const args = process.argv.slice(2); @@ -41,6 +42,10 @@ const WHATSAPP_DEBUG = typeof process.env.WHATSAPP_DEBUG === 'string' && ['1', 'true', 'yes', 'on'].includes(process.env.WHATSAPP_DEBUG.toLowerCase()); +const WHATSAPP_SEND_READ_RECEIPTS = !['0', 'false', 'no', 'off'].includes( + String(process.env.WHATSAPP_SEND_READ_RECEIPTS || '').toLowerCase() +); + const PORT = parseInt(getArg('port', '3000'), 10); const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.hermes', 'whatsapp', 'session')); const IMAGE_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'image_cache'); @@ -232,6 +237,14 @@ async function startSocket() { continue; } + if (shouldSendReadReceipt(msg, { enabled: WHATSAPP_SEND_READ_RECEIPTS })) { + sock.readMessages([msg.key]).catch((err) => { + if (WHATSAPP_DEBUG) { + console.error('[bridge] readMessages failed:', err?.message || err); + } + }); + } + const messageContent = getMessageContent(msg); const contextInfo = getContextInfo(messageContent); const mentionedIds = Array.from(new Set((contextInfo?.mentionedJid || []).map(normalizeWhatsAppId).filter(Boolean))); diff --git a/scripts/whatsapp-bridge/read_receipts.js b/scripts/whatsapp-bridge/read_receipts.js new file mode 100644 index 000000000000..8d84bc5c8c9f --- /dev/null +++ b/scripts/whatsapp-bridge/read_receipts.js @@ -0,0 +1,6 @@ +export function shouldSendReadReceipt(msg, { enabled }) { + if (!enabled) return false; + if (!msg?.key) return false; + if (msg.key.fromMe) return false; + return true; +} diff --git a/scripts/whatsapp-bridge/read_receipts.test.mjs b/scripts/whatsapp-bridge/read_receipts.test.mjs new file mode 100644 index 000000000000..a27cb1d15491 --- /dev/null +++ b/scripts/whatsapp-bridge/read_receipts.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { shouldSendReadReceipt } from './read_receipts.js'; + +const inboundMsg = { key: { id: 'abc', remoteJid: '19175395595@s.whatsapp.net', fromMe: false } }; +const outboundMsg = { key: { id: 'xyz', remoteJid: '19175395595@s.whatsapp.net', fromMe: true } }; + +test('shouldSendReadReceipt returns true for inbound messages when enabled', () => { + assert.equal(shouldSendReadReceipt(inboundMsg, { enabled: true }), true); +}); + +test('shouldSendReadReceipt returns false when feature is disabled', () => { + assert.equal(shouldSendReadReceipt(inboundMsg, { enabled: false }), false); +}); + +test('shouldSendReadReceipt returns false for fromMe messages regardless of enabled flag', () => { + assert.equal(shouldSendReadReceipt(outboundMsg, { enabled: true }), false); +}); + +test('shouldSendReadReceipt returns false for malformed messages without a key', () => { + assert.equal(shouldSendReadReceipt({}, { enabled: true }), false); + assert.equal(shouldSendReadReceipt(null, { enabled: true }), false); +}); diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 54cba2b89c41..7a6eadd62b41 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -190,6 +190,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `WHATSAPP_ALLOWED_USERS` | Comma-separated phone numbers (with country code, no `+`), or `*` to allow all senders | | `WHATSAPP_ALLOW_ALL_USERS` | Allow all WhatsApp senders without an allowlist (`true`/`false`) | | `WHATSAPP_DEBUG` | Log raw message events in the bridge for troubleshooting (`true`/`false`) | +| `WHATSAPP_SEND_READ_RECEIPTS` | Auto-mark incoming messages as read after the allowlist filter (`true`/`false`, default `true`) | | `SIGNAL_HTTP_URL` | signal-cli daemon HTTP endpoint (for example `http://127.0.0.1:8080`) | | `SIGNAL_ACCOUNT` | Bot phone number in E.164 format | | `SIGNAL_ALLOWED_USERS` | Comma-separated E.164 phone numbers or UUIDs | diff --git a/website/docs/user-guide/messaging/whatsapp.md b/website/docs/user-guide/messaging/whatsapp.md index e4a8def0773f..41f9e7665b9f 100644 --- a/website/docs/user-guide/messaging/whatsapp.md +++ b/website/docs/user-guide/messaging/whatsapp.md @@ -99,6 +99,9 @@ WHATSAPP_MODE=bot # "bot" or "self-chat" WHATSAPP_ALLOWED_USERS=15551234567 # Comma-separated phone numbers (with country code, no +) # WHATSAPP_ALLOWED_USERS=* # OR use * to allow everyone # WHATSAPP_ALLOW_ALL_USERS=true # OR set this flag instead (same effect as *) + +# Optional +# WHATSAPP_SEND_READ_RECEIPTS=false # Disable auto-mark-as-read for allowlisted senders (default: true) ``` :::tip Allow-all shorthand @@ -115,10 +118,12 @@ unauthorized_dm_behavior: pair whatsapp: unauthorized_dm_behavior: ignore + send_read_receipts: true ``` - `unauthorized_dm_behavior: pair` is the global default. Unknown DM senders get a pairing code. - `whatsapp.unauthorized_dm_behavior: ignore` makes WhatsApp stay silent for unauthorized DMs, which is usually the better choice for a private number. +- `whatsapp.send_read_receipts: false` disables blue-tick read receipts for allowlisted senders (defaults to `true`). Then start the gateway: