diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 07f12cd513de..bc9eb1d6df9f 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -406,6 +406,13 @@ def __init__(self, config: PlatformConfig): get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") )) self._reply_prefix: Optional[str] = config.extra.get("reply_prefix") + # Opt-in read receipts (blue ticks). Same WHATSAPP_READ_RECEIPTS env + # the bridge reads (set from config.yaml whatsapp.read_receipts by + # _apply_yaml_config). We gate here too so a disabled deployment never + # makes the extra /mark-read round-trip per accepted message. + self._read_receipts: bool = str( + os.getenv("WHATSAPP_READ_RECEIPTS", "") + ).strip().lower() in {"1", "true", "yes", "on"} self._dm_policy = str(config.extra.get("dm_policy") or os.getenv("WHATSAPP_DM_POLICY", "pairing")).strip().lower() self._allow_from = self._coerce_allow_list(config.extra.get("allow_from") or config.extra.get("allowFrom")) self._group_policy = str(config.extra.get("group_policy") or os.getenv("WHATSAPP_GROUP_POLICY", "pairing")).strip().lower() @@ -1249,12 +1256,7 @@ async def _poll_messages(self) -> None: if resp.status == 200: messages = await resp.json() for msg_data in messages: - event = await self._build_message_event(msg_data) - if event: - if event.message_type == MessageType.TEXT: - self._enqueue_text_event(event) - else: - await self.handle_message(event) + await self._dispatch_incoming(msg_data) except asyncio.CancelledError: break except Exception as e: @@ -1267,6 +1269,55 @@ async def _poll_messages(self) -> None: await asyncio.sleep(1) # Poll interval + async def _dispatch_incoming(self, msg_data: Dict[str, Any]) -> None: + """Admit one polled bridge message, then dispatch it. + + ``_build_message_event`` returns ``None`` when the adapter's admission + gate (``_should_process_message`` — broadcast/status, group allowlist, + DM policy, group require-mention / free-response) rejects the message. + Only once it returns a real event do we (a) send the read receipt — + after admission, so a policy/mention-rejected group message never gets + a blue tick — and (b) dispatch it (text is debounce-batched). + """ + event = await self._build_message_event(msg_data) + if not event: + return + await self._mark_read_if_enabled(msg_data) + if event.message_type == MessageType.TEXT: + self._enqueue_text_event(event) + else: + await self.handle_message(event) + + async def _mark_read_if_enabled(self, msg_data: Dict[str, Any]) -> None: + """Best-effort read receipt for an admitted inbound message. + + Fire-and-forget: a failed receipt must never interrupt message + processing. The bridge (POST /mark-read) re-checks the receipts flag + and applies the skip rules (fromMe, status/broadcast/newsletter, DM vs + group participant); we gate on the same flag here only to avoid an + unnecessary round-trip when receipts are disabled. + """ + if not self._read_receipts or not self._http_session: + return + chat_id = msg_data.get("chatId") + message_id = msg_data.get("messageId") + if not chat_id or not message_id: + return + payload = {"chatId": chat_id, "messageId": message_id} + # Group receipts must carry the sender's participant JID; DMs omit it. + if msg_data.get("isGroup") and msg_data.get("senderId"): + payload["participant"] = msg_data["senderId"] + try: + import aiohttp + async with self._http_session.post( + f"http://127.0.0.1:{self._bridge_port}/mark-read", + json=payload, + timeout=aiohttp.ClientTimeout(total=5), + ): + pass + except Exception: + pass # never let a read-receipt failure break processing + # ── Text debounce batching ────────────────────────────────────── _SPLIT_THRESHOLD = 6000 # WhatsApp supports ~65K chars; generous threshold @@ -1717,6 +1768,10 @@ def _apply_yaml_config(yaml_cfg: dict, whatsapp_cfg: dict) -> dict | None: take precedence over YAML. Returns None — everything flows through env. """ import json as _json + if "read_receipts" in whatsapp_cfg and not os.getenv("WHATSAPP_READ_RECEIPTS"): + # Opt-in read receipts (blue ticks) for accepted inbound messages. The + # Node bridge reads WHATSAPP_READ_RECEIPTS and accepts 1/true/yes/on. + os.environ["WHATSAPP_READ_RECEIPTS"] = str(whatsapp_cfg["read_receipts"]).lower() if "require_mention" in whatsapp_cfg and not os.getenv("WHATSAPP_REQUIRE_MENTION"): os.environ["WHATSAPP_REQUIRE_MENTION"] = str(whatsapp_cfg["require_mention"]).lower() if "mention_patterns" in whatsapp_cfg and not os.getenv("WHATSAPP_MENTION_PATTERNS"): diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 4b5733d16f84..e14a0730fadd 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -43,6 +43,7 @@ import { mediaPayloadForFile, pollCreationMessageFromPayload, pollUpdateForAggregation, + readReceiptKeyForMessage, } from './bridge_helpers.js'; // Parse CLI args @@ -75,6 +76,25 @@ const FORWARD_OWNER_MESSAGES = typeof process.env.WHATSAPP_FORWARD_OWNER_MESSAGES === 'string' && ['1', 'true', 'yes', 'on'].includes(process.env.WHATSAPP_FORWARD_OWNER_MESSAGES.toLowerCase()); +// Send WhatsApp read receipts (blue ticks) for inbound messages once they're +// accepted for processing. Mirrors OpenClaw's WhatsApp bridge: when a human +// shares the bot's WhatsApp account via a linked device (common in `bot` +// mode), marking handled messages read stops them piling up as unread badges +// on that person's real WhatsApp client, and the original sender sees that +// their message was read. +// +// Opt-in (default OFF) so existing deployments see no behavior change — +// emitting a read receipt is a visible, privacy-relevant signal to the +// sender, so operators turn it on explicitly. User-facing surface is +// `whatsapp.read_receipts` in config.yaml, which the Python adapter bridges +// into this WHATSAPP_READ_RECEIPTS env var (1/true/yes/on). See +// NousResearch/hermes-agent#6539. +const READ_RECEIPTS_ENABLED = + typeof process !== 'undefined' && + process.env && + typeof process.env.WHATSAPP_READ_RECEIPTS === 'string' && + ['1', 'true', 'yes', 'on'].includes(process.env.WHATSAPP_READ_RECEIPTS.trim().toLowerCase()); + const PORT = parseInt(getArg('port', '3000'), 10); const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.hermes', 'whatsapp', 'session')); // Cache directories: the Python gateway passes the profile-aware paths via @@ -199,6 +219,33 @@ function normalizeWhatsAppId(value) { return String(value).replace(':', '@'); } +// Send a WhatsApp read receipt for an accepted inbound message. Best-effort: +// a failed read must never interrupt message processing, so errors are +// swallowed (surfaced only under WHATSAPP_DEBUG). Skipping rules (fromMe, +// status/broadcast/newsletter, DM vs group participant) live in the pure +// readReceiptKeyForMessage() helper so they can be unit-tested. +async function markMessageReadIfEnabled(msg) { + if (!READ_RECEIPTS_ENABLED) return; + if (!sock || typeof sock.readMessages !== 'function') return; + const receiptKey = readReceiptKeyForMessage(msg); + if (!receiptKey) return; + try { + await sock.readMessages([receiptKey]); + emitDebugEvent({ + stage: 'marked_read', + chatId: redactWhatsAppId(receiptKey.remoteJid), + messageId: receiptKey.id, + }); + } catch (err) { + emitDebugEvent({ + stage: 'mark_read_failed', + chatId: redactWhatsAppId(receiptKey.remoteJid), + messageId: receiptKey.id, + error: err?.message || String(err), + }); + } +} + function redactWhatsAppId(value) { const raw = String(value || '').trim(); if (!raw) return ''; @@ -749,6 +796,12 @@ async function startSocket() { messageStore.remember(msg); messageQueue.push(event); + // NOTE: read receipts are intentionally NOT sent here. The bridge's + // intake gates are only the first admission layer; the Python adapter + // applies a second one (group policy + require-mention in + // _should_process_message) after polling /messages. Marking read here + // would blue-tick group messages the adapter later drops. Instead the + // adapter calls POST /mark-read once it has admitted the message. emitDebugEvent({ stage: 'queued', chatId: redactWhatsAppId(chatId), @@ -1042,6 +1095,37 @@ app.post('/typing', async (req, res) => { } }); +// Mark a single inbound message as read (blue ticks). Called by the Python +// adapter *after* it has admitted the message (i.e. after its group-policy / +// require-mention gate), so rejected messages never get a receipt. The +// READ_RECEIPTS_ENABLED flag and all skip rules (fromMe, status/broadcast/ +// newsletter, DM vs group participant) are enforced in +// markMessageReadIfEnabled → readReceiptKeyForMessage. +app.post('/mark-read', async (req, res) => { + if (!sock || connectionState !== 'connected') { + return res.status(503).json({ error: 'Not connected to WhatsApp' }); + } + + const { chatId, messageId, participant, fromMe } = req.body || {}; + if (!chatId || !messageId) { + return res.status(400).json({ error: 'chatId and messageId are required' }); + } + + try { + await markMessageReadIfEnabled({ + key: { + remoteJid: chatId, + id: messageId, + participant: participant || undefined, + fromMe: !!fromMe, + }, + }); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + // Chat info app.get('/chat/:id', async (req, res) => { const chatId = req.params.id; @@ -1112,6 +1196,9 @@ if (PAIR_ONLY) { if (WHATSAPP_MODE === 'bot' && FORWARD_OWNER_MESSAGES) { console.log(`👤 WHATSAPP_FORWARD_OWNER_MESSAGES=true — owner-typed messages will be forwarded with fromOwner:true`); } + if (READ_RECEIPTS_ENABLED) { + console.log(`👁️ WHATSAPP_READ_RECEIPTS=on — accepted inbound messages will be marked read (blue ticks).`); + } console.log(); startSocket(); }); diff --git a/scripts/whatsapp-bridge/bridge.native.test.mjs b/scripts/whatsapp-bridge/bridge.native.test.mjs index db0a8debead5..27fe1f811c96 100644 --- a/scripts/whatsapp-bridge/bridge.native.test.mjs +++ b/scripts/whatsapp-bridge/bridge.native.test.mjs @@ -21,6 +21,7 @@ import { mediaPayloadForFile, pollCreationMessageFromPayload, pollUpdateForAggregation, + readReceiptKeyForMessage, } from './bridge_helpers.js'; // -- quoted outbound text ------------------------------------------------- @@ -383,4 +384,70 @@ import { console.log(' ✓ captioned failed download keeps caption and appends note'); } +// -- read receipts (mark accepted inbound messages as read) --------------- +{ + // DM: participant is omitted, receipt carries remoteJid + id. + const dm = readReceiptKeyForMessage({ + key: { id: 'in-dm-1', remoteJid: '15551234567@s.whatsapp.net', fromMe: false }, + }); + assert.deepEqual(dm, { remoteJid: '15551234567@s.whatsapp.net', id: 'in-dm-1' }); + assert.equal('participant' in dm, false); + console.log(' ✓ DM read receipt omits participant'); +} + +{ + // Group: sender participant JID must be included for correct attribution. + const group = readReceiptKeyForMessage({ + key: { + id: 'in-grp-1', + remoteJid: '120363000000000000@g.us', + participant: '15550001111@s.whatsapp.net', + fromMe: false, + }, + }); + assert.deepEqual(group, { + remoteJid: '120363000000000000@g.us', + id: 'in-grp-1', + participant: '15550001111@s.whatsapp.net', + }); + console.log(' ✓ group read receipt includes sender participant'); +} + +{ + // fromMe (our own / owner-typed) messages are never marked read. + assert.equal( + readReceiptKeyForMessage({ + key: { id: 'mine-1', remoteJid: '15551234567@s.whatsapp.net', fromMe: true }, + }), + null, + ); + console.log(' ✓ fromMe messages are not marked read'); +} + +{ + // Status / broadcast / newsletter (channel) JIDs must never get a read. + assert.equal( + readReceiptKeyForMessage({ key: { id: 's', remoteJid: 'status@broadcast', fromMe: false } }), + null, + ); + assert.equal( + readReceiptKeyForMessage({ key: { id: 'b', remoteJid: '99999999@broadcast', fromMe: false } }), + null, + ); + assert.equal( + readReceiptKeyForMessage({ key: { id: 'n', remoteJid: '12345@newsletter', fromMe: false } }), + null, + ); + console.log(' ✓ status/broadcast/newsletter messages are not marked read'); +} + +{ + // Malformed keys are skipped rather than throwing. + assert.equal(readReceiptKeyForMessage(undefined), null); + assert.equal(readReceiptKeyForMessage({}), null); + assert.equal(readReceiptKeyForMessage({ key: { remoteJid: 'x@s.whatsapp.net' } }), null); + assert.equal(readReceiptKeyForMessage({ key: { id: 'x' } }), null); + console.log(' ✓ malformed message keys yield null (no throw)'); +} + console.log('\n✅ All WhatsApp native bridge helper tests passed.'); diff --git a/scripts/whatsapp-bridge/bridge_helpers.js b/scripts/whatsapp-bridge/bridge_helpers.js index b99b761d3e86..ab5ff4515100 100644 --- a/scripts/whatsapp-bridge/bridge_helpers.js +++ b/scripts/whatsapp-bridge/bridge_helpers.js @@ -40,6 +40,35 @@ export function getContextInfo(messageContent) { return {}; } +// Decide whether — and with which key — an inbound message should be marked +// read via sock.readMessages([...]). Kept pure so the read-receipt policy can +// be unit-tested without a live Baileys socket. +// +// Returns a Baileys message key ({ remoteJid, id, participant? }) when a read +// receipt is appropriate, or null to skip. Skips: +// - fromMe messages — our own / owner-typed; already "read" by definition. +// - status@broadcast — status updates aren't a conversation. +// - @broadcast lists — broadcast recipients don't get individual reads. +// - @newsletter JIDs — channels/newsletters aren't 1:1/group chats. +// For groups the sender's participant JID is included so the WhatsApp server +// can attribute the read to the right member; DMs omit it. +export function readReceiptKeyForMessage(msg) { + const key = msg?.key; + if (!key || !key.id || !key.remoteJid) return null; + if (key.fromMe) return null; + + const remoteJid = String(key.remoteJid); + if (remoteJid === 'status@broadcast') return null; + if (remoteJid.endsWith('@broadcast')) return null; + if (remoteJid.endsWith('@newsletter')) return null; + + const receiptKey = { remoteJid, id: key.id }; + if (remoteJid.endsWith('@g.us') && key.participant) { + receiptKey.participant = key.participant; + } + return receiptKey; +} + export function createBoundedMessageStore(limit = 512) { const byId = new Map(); diff --git a/tests/gateway/test_whatsapp_read_receipts.py b/tests/gateway/test_whatsapp_read_receipts.py new file mode 100644 index 000000000000..f92250af0bb0 --- /dev/null +++ b/tests/gateway/test_whatsapp_read_receipts.py @@ -0,0 +1,205 @@ +"""Tests for WhatsApp read-receipts (blue tick) support. + +Two layers are covered: + +1. Config bridging — the opt-in `whatsapp.read_receipts` config.yaml key is + translated into the `WHATSAPP_READ_RECEIPTS` env var (consumed by both the + Node bridge and the adapter) by the `_apply_yaml_config` hook. + +2. Cross-layer admission ordering — the receipt is sent only *after* the + adapter admits the message (`_build_message_event` returns non-None, i.e. + `_should_process_message` passed), so a group message rejected for group + policy or a missing mention never gets a blue tick. The receipt payload + carries the sender's participant JID for groups and omits it for DMs. + +The receipt *skip rules* themselves (fromMe, status/broadcast/newsletter, DM +vs group participant on the Baileys key) live in the bridge and are unit-tested +in `scripts/whatsapp-bridge/bridge.native.test.mjs`; the bridge starts a socket +and HTTP server at import, so it can't be exercised from pytest. +""" + +import asyncio +from unittest.mock import patch, MagicMock + +from gateway.config import PlatformConfig +from gateway.platforms.base import MessageType +from plugins.platforms.whatsapp.adapter import _apply_yaml_config, WhatsAppAdapter + + +# --------------------------------------------------------------------------- +# 1. config.yaml -> WHATSAPP_READ_RECEIPTS env bridging +# --------------------------------------------------------------------------- + + +class TestReadReceiptsYamlBridging: + def test_true_sets_env(self): + with patch.dict("os.environ", {}, clear=True): + _apply_yaml_config({}, {"read_receipts": True}) + import os + assert os.environ.get("WHATSAPP_READ_RECEIPTS") == "true" + + def test_false_sets_env_off(self): + with patch.dict("os.environ", {}, clear=True): + _apply_yaml_config({}, {"read_receipts": False}) + import os + # The bridge treats anything not in {1,true,yes,on} as off. + assert os.environ.get("WHATSAPP_READ_RECEIPTS") == "false" + + def test_absent_key_leaves_env_unset(self): + with patch.dict("os.environ", {}, clear=True): + _apply_yaml_config({}, {"reply_prefix": "x"}) + import os + assert "WHATSAPP_READ_RECEIPTS" not in os.environ + + def test_env_var_takes_precedence_over_yaml(self): + with patch.dict("os.environ", {"WHATSAPP_READ_RECEIPTS": "1"}, clear=True): + _apply_yaml_config({}, {"read_receipts": False}) + import os + assert os.environ.get("WHATSAPP_READ_RECEIPTS") == "1" + + +# --------------------------------------------------------------------------- +# 2. adapter admission ordering + receipt payload +# --------------------------------------------------------------------------- + + +class _CapturingSession: + """Minimal aiohttp-like session that records the last POST.""" + + def __init__(self): + self.calls = [] + + def post(self, url, json=None, timeout=None): + self.calls.append({"url": url, "json": json}) + session = self + + class _Ctx: + async def __aenter__(self): + return MagicMock(status=200) + + async def __aexit__(self, *exc): + return False + + return _Ctx() + + +def _make_adapter(read_receipts=True): + adapter = WhatsAppAdapter(PlatformConfig(enabled=True)) + adapter._read_receipts = read_receipts + adapter._bridge_port = 3999 + adapter._http_session = _CapturingSession() + return adapter + + +class TestDispatchOrdering: + """`_dispatch_incoming` marks read only after admission, then dispatches.""" + + def test_rejected_message_is_not_marked_read(self): + adapter = _make_adapter() + marked = [] + + async def fake_build(_data): + return None # _should_process_message rejected it + + async def fake_mark(data): + marked.append(data) + + async def fake_handle(_event): + raise AssertionError("rejected message must not be dispatched") + + adapter._build_message_event = fake_build + adapter._mark_read_if_enabled = fake_mark + adapter.handle_message = fake_handle + + asyncio.run(adapter._dispatch_incoming( + {"chatId": "g@g.us", "messageId": "1", "isGroup": True} + )) + assert marked == [], "policy/mention-rejected message got a read receipt" + + def test_admitted_message_is_marked_read_then_dispatched(self): + adapter = _make_adapter() + order = [] + + event = MagicMock() + event.message_type = MessageType.PHOTO # non-text -> handle_message + + async def fake_build(_data): + return event + + async def fake_mark(_data): + order.append("mark") + + async def fake_handle(_event): + order.append("dispatch") + + adapter._build_message_event = fake_build + adapter._mark_read_if_enabled = fake_mark + adapter.handle_message = fake_handle + + asyncio.run(adapter._dispatch_incoming( + {"chatId": "g@g.us", "messageId": "1", "isGroup": True, + "senderId": "u@s.whatsapp.net"} + )) + assert order == ["mark", "dispatch"], order + + def test_admitted_text_is_batched_after_mark(self): + adapter = _make_adapter() + order = [] + + event = MagicMock() + event.message_type = MessageType.TEXT + + async def fake_build(_data): + return event + + async def fake_mark(_data): + order.append("mark") + + adapter._build_message_event = fake_build + adapter._mark_read_if_enabled = fake_mark + adapter._enqueue_text_event = lambda _e: order.append("enqueue") + + asyncio.run(adapter._dispatch_incoming( + {"chatId": "u@s.whatsapp.net", "messageId": "1", "isGroup": False} + )) + assert order == ["mark", "enqueue"], order + + +class TestMarkReadPayload: + """`_mark_read_if_enabled` posts the right key, and only when enabled.""" + + def test_group_payload_includes_participant(self): + adapter = _make_adapter(read_receipts=True) + asyncio.run(adapter._mark_read_if_enabled( + {"chatId": "g@g.us", "messageId": "m1", "isGroup": True, + "senderId": "u@s.whatsapp.net"} + )) + call = adapter._http_session.calls[-1] + assert call["url"].endswith("/mark-read") + assert call["json"] == { + "chatId": "g@g.us", "messageId": "m1", + "participant": "u@s.whatsapp.net", + } + + def test_dm_payload_omits_participant(self): + adapter = _make_adapter(read_receipts=True) + asyncio.run(adapter._mark_read_if_enabled( + {"chatId": "u@s.whatsapp.net", "messageId": "m1", "isGroup": False, + "senderId": "u@s.whatsapp.net"} + )) + assert adapter._http_session.calls[-1]["json"] == { + "chatId": "u@s.whatsapp.net", "messageId": "m1", + } + + def test_disabled_makes_no_request(self): + adapter = _make_adapter(read_receipts=False) + asyncio.run(adapter._mark_read_if_enabled( + {"chatId": "g@g.us", "messageId": "m1", "isGroup": True, + "senderId": "u@s.whatsapp.net"} + )) + assert adapter._http_session.calls == [] + + def test_missing_message_id_makes_no_request(self): + adapter = _make_adapter(read_receipts=True) + asyncio.run(adapter._mark_read_if_enabled({"chatId": "g@g.us"})) + assert adapter._http_session.calls == [] diff --git a/website/docs/user-guide/messaging/whatsapp.md b/website/docs/user-guide/messaging/whatsapp.md index b447b534707b..9d01f8816000 100644 --- a/website/docs/user-guide/messaging/whatsapp.md +++ b/website/docs/user-guide/messaging/whatsapp.md @@ -184,6 +184,37 @@ whatsapp: --- +## Read Receipts (Blue Ticks) + +By default Hermes does **not** send read receipts, so senders see only the delivered +(double grey) ticks — even after the agent has processed their message. Turn on read +receipts so accepted inbound messages are marked read (blue ticks), matching how a +human — or OpenClaw's WhatsApp bridge — behaves: + +```yaml +# ~/.hermes/config.yaml +whatsapp: + read_receipts: true # default false — opt-in (emits blue ticks to senders) +``` + +This is most useful in `bot` mode when a person also has the bot's WhatsApp account +linked on their own phone: marking handled messages read keeps unread badges from +piling up on their real client. + +Notes: + +- Only messages the agent actually accepts are marked read — the receipt is sent + *after* full admission (allowlist / DM policy, and for groups the group policy + **and** the require-mention / free-response gate). A group message that is + ignored for lacking a mention, or blocked by policy, never gets a receipt. +- Direct chats, groups, status updates, broadcast lists, and channels/newsletters are + handled correctly (group receipts carry the sender's participant; status/broadcast/ + channel messages are skipped). +- Read receipts are a visible, privacy-relevant signal to the sender, which is why + this is opt-in rather than on by default. + +--- + ## Message Formatting & Delivery WhatsApp supports **streaming (progressive) responses** — the bot edits its message in real-time as the AI generates text, just like Discord and Telegram. Internally, WhatsApp is classified as a TIER_MEDIUM platform for delivery capabilities.