From 35afa8ce06eaf7b7257b3d2fd1338887f92cc550 Mon Sep 17 00:00:00 2001 From: Cyrus Date: Thu, 23 Jul 2026 23:12:04 +0100 Subject: [PATCH] feat(whatsapp): support inbound read receipts --- gateway/config.py | 2 ++ plugins/platforms/whatsapp/adapter.py | 9 ++++++ scripts/whatsapp-bridge/bridge.js | 17 +++++++++++ .../whatsapp-bridge/bridge.native.test.mjs | 22 ++++++++++++++ scripts/whatsapp-bridge/bridge_helpers.js | 6 ++++ tests/gateway/test_whatsapp_connect.py | 1 + tests/gateway/test_whatsapp_reply_prefix.py | 29 +++++++++++++++++++ tests/gateway/test_whatsapp_stale_bridge.py | 3 ++ 8 files changed, 89 insertions(+) diff --git a/gateway/config.py b/gateway/config.py index 781a9421733c..3c6a631f95ca 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1550,6 +1550,8 @@ def load_gateway_config() -> GatewayConfig: bridged["cron_continuable_surface"] = platform_cfg["cron_continuable_surface"] if "require_mention" in platform_cfg: bridged["require_mention"] = platform_cfg["require_mention"] + if "send_read_receipts" in platform_cfg: + bridged["send_read_receipts"] = platform_cfg["send_read_receipts"] if plat == Platform.TELEGRAM and "allowed_chats" in platform_cfg: bridged["allowed_chats"] = platform_cfg["allowed_chats"] if plat == Platform.TELEGRAM and "group_allowed_chats" in platform_cfg: diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 8d654197e04a..b77c9eb52d26 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -379,6 +379,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): - allow_from: List of sender IDs allowed in DMs (when dm_policy="allowlist") - group_policy: "open" | "allowlist" | "disabled" | "pairing" — which groups are processed (default: "pairing") - group_allow_from: List of group JIDs allowed (when group_policy="allowlist") + - send_read_receipts: Mark accepted inbound WhatsApp messages as read Behavior (gating, mention parsing, markdown conversion, chunking) is provided by ``WhatsAppBehaviorMixin`` so the Cloud API adapter can @@ -410,6 +411,11 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): 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() self._group_allow_from = self._coerce_allow_list(config.extra.get("group_allow_from") or config.extra.get("groupAllowFrom")) + read_receipts = config.extra.get("send_read_receipts", False) + self._send_read_receipts = ( + read_receipts if isinstance(read_receipts, bool) + else str(read_receipts or "").strip().lower() in {"1", "true", "yes", "on"} + ) self._mention_patterns = self._compile_mention_patterns() self._message_queue: asyncio.Queue = asyncio.Queue() self._bridge_log_fh = None @@ -628,6 +634,9 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): bridge_env = with_hermes_node_path() if self._reply_prefix is not None: bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix + bridge_env["WHATSAPP_SEND_READ_RECEIPTS"] = ( + "true" if self._send_read_receipts else "false" + ) # Pass the profile-aware cache directories so the bridge writes # media where the Python side reads it. Without these the bridge # hardcodes ~/.hermes/{image,audio,document}_cache, which diverges diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 4b5733d16f84..500879a20bfb 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -39,6 +39,7 @@ import { buildTextSendPayload, createBoundedMessageStore, extractBridgeEvent, + inboundReadReceiptKeys, inferMediaType, mediaPayloadForFile, pollCreationMessageFromPayload, @@ -75,6 +76,12 @@ const FORWARD_OWNER_MESSAGES = typeof process.env.WHATSAPP_FORWARD_OWNER_MESSAGES === 'string' && ['1', 'true', 'yes', 'on'].includes(process.env.WHATSAPP_FORWARD_OWNER_MESSAGES.toLowerCase()); +const SEND_READ_RECEIPTS = + typeof process !== 'undefined' && + process.env && + typeof process.env.WHATSAPP_SEND_READ_RECEIPTS === 'string' && + ['1', 'true', 'yes', 'on'].includes(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')); // Cache directories: the Python gateway passes the profile-aware paths via @@ -647,6 +654,15 @@ async function startSocket() { } } + const receiptKeys = inboundReadReceiptKeys({ key: msg.key, enabled: SEND_READ_RECEIPTS }); + if (receiptKeys.length > 0) { + try { + await sock.readMessages(receiptKeys); + } catch (err) { + console.warn('[bridge] failed to send read receipt:', err.message); + } + } + const messageContent = getMessageContent(msg); if (messageContent.pollUpdateMessage) { const pollUpdateMessage = messageContent.pollUpdateMessage; @@ -1074,6 +1090,7 @@ app.get('/health', (req, res) => { queueLength: messageQueue.length, uptime: process.uptime(), scriptHash: SCRIPT_HASH, + sendReadReceipts: SEND_READ_RECEIPTS, }); }); diff --git a/scripts/whatsapp-bridge/bridge.native.test.mjs b/scripts/whatsapp-bridge/bridge.native.test.mjs index db0a8debead5..b31b95c46c30 100644 --- a/scripts/whatsapp-bridge/bridge.native.test.mjs +++ b/scripts/whatsapp-bridge/bridge.native.test.mjs @@ -18,11 +18,33 @@ import { createBoundedMessageStore, appendMediaFailureNote, extractBridgeEvent, + inboundReadReceiptKeys, mediaPayloadForFile, pollCreationMessageFromPayload, pollUpdateForAggregation, } from './bridge_helpers.js'; +// -- inbound read receipts ------------------------------------------------ +{ + const groupKey = { + id: 'incoming-group-1', + remoteJid: '120363001234567890@g.us', + participant: '15550001111@s.whatsapp.net', + fromMe: false, + }; + + assert.deepEqual(inboundReadReceiptKeys({ key: groupKey, enabled: false }), []); + assert.deepEqual( + inboundReadReceiptKeys({ key: { ...groupKey, fromMe: true }, enabled: true }), + [], + ); + const receiptKeys = inboundReadReceiptKeys({ key: groupKey, enabled: true }); + assert.equal(receiptKeys.length, 1); + assert.equal(receiptKeys[0], groupKey); + assert.equal(receiptKeys[0].participant, groupKey.participant); + console.log(' ✓ inbound read receipts preserve the original group message key'); +} + // -- quoted outbound text ------------------------------------------------- { const store = createBoundedMessageStore(2); diff --git a/scripts/whatsapp-bridge/bridge_helpers.js b/scripts/whatsapp-bridge/bridge_helpers.js index b99b761d3e86..fd5444d80462 100644 --- a/scripts/whatsapp-bridge/bridge_helpers.js +++ b/scripts/whatsapp-bridge/bridge_helpers.js @@ -494,6 +494,12 @@ export function inferMediaType(ext) { return 'document'; } +export function inboundReadReceiptKeys({ key, enabled }) { + if (!enabled || !key || key.fromMe || !key.id || !key.remoteJid) return []; + // Preserve participant for group messages: Baileys needs the original key. + return [key]; +} + export function mediaPayloadForFile({ buffer, filePath, mediaType, caption, fileName }) { const ext = filePath.toLowerCase().split('.').pop(); const type = mediaType || inferMediaType(ext); diff --git a/tests/gateway/test_whatsapp_connect.py b/tests/gateway/test_whatsapp_connect.py index 374ed74b8a7c..0cba4135031f 100644 --- a/tests/gateway/test_whatsapp_connect.py +++ b/tests/gateway/test_whatsapp_connect.py @@ -53,6 +53,7 @@ def _make_adapter(): adapter._bridge_log = None adapter._bridge_process = None adapter._reply_prefix = None + adapter._send_read_receipts = False adapter._running = False adapter._message_handler = None adapter._fatal_error_code = None diff --git a/tests/gateway/test_whatsapp_reply_prefix.py b/tests/gateway/test_whatsapp_reply_prefix.py index 867022ac7394..42383e42d14e 100644 --- a/tests/gateway/test_whatsapp_reply_prefix.py +++ b/tests/gateway/test_whatsapp_reply_prefix.py @@ -4,6 +4,8 @@ Covers: - config.yaml whatsapp.reply_prefix bridging into PlatformConfig.extra - WhatsAppAdapter reading reply_prefix from config.extra - Bridge subprocess receiving WHATSAPP_REPLY_PREFIX env var +- config.yaml whatsapp.send_read_receipts bridging into PlatformConfig.extra +- WhatsAppAdapter parsing send_read_receipts as a boolean - Config version covers all ENV_VARS_BY_VERSION keys (regression guard) """ @@ -77,6 +79,20 @@ class TestConfigYamlBridging: wa_config = config.platforms.get(Platform.WHATSAPP) assert "reply_prefix" not in wa_config.extra + def test_send_read_receipts_bridged_from_yaml(self, tmp_path): + """whatsapp.send_read_receipts reaches the adapter extra config.""" + config_yaml = tmp_path / "config.yaml" + config_yaml.write_text("whatsapp:\n send_read_receipts: true\n") + + with patch("gateway.config.get_hermes_home", return_value=tmp_path): + from gateway.config import load_gateway_config + with patch.dict("os.environ", {"WHATSAPP_ENABLED": "true"}, clear=False): + config = load_gateway_config() + + wa_config = config.platforms.get(Platform.WHATSAPP) + assert wa_config is not None + assert wa_config.extra.get("send_read_receipts") is True + # --------------------------------------------------------------------------- # WhatsAppAdapter __init__ @@ -104,6 +120,19 @@ class TestAdapterInit: adapter = WhatsAppAdapter(config) assert adapter._reply_prefix == "" + def test_send_read_receipts_boolean_and_string_values(self): + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter + + assert WhatsAppAdapter( + PlatformConfig(enabled=True, extra={"send_read_receipts": True}) + )._send_read_receipts is True + assert WhatsAppAdapter( + PlatformConfig(enabled=True, extra={"send_read_receipts": "yes"}) + )._send_read_receipts is True + assert WhatsAppAdapter( + PlatformConfig(enabled=True, extra={"send_read_receipts": "off"}) + )._send_read_receipts is False + # --------------------------------------------------------------------------- # Config version regression guard diff --git a/tests/gateway/test_whatsapp_stale_bridge.py b/tests/gateway/test_whatsapp_stale_bridge.py index 2447b7f0840c..ccc920c5630a 100644 --- a/tests/gateway/test_whatsapp_stale_bridge.py +++ b/tests/gateway/test_whatsapp_stale_bridge.py @@ -53,6 +53,7 @@ def _make_adapter(bridge_script: str = "/tmp/test-bridge.js", adapter._bridge_log = None adapter._bridge_process = None adapter._reply_prefix = None + adapter._send_read_receipts = False adapter._running = False adapter._message_handler = None adapter._fatal_error_code = None @@ -317,6 +318,7 @@ class TestCacheDirEnvPassthrough: bridge_script=str(bridge_dir / "bridge.js"), session_path=tmp_path / "session", ) + adapter._send_read_receipts = True mock_proc = MagicMock() mock_proc.poll.return_value = 1 mock_proc.returncode = 1 @@ -339,3 +341,4 @@ class TestCacheDirEnvPassthrough: assert env["HERMES_IMAGE_CACHE_DIR"] == str(get_image_cache_dir()) assert env["HERMES_AUDIO_CACHE_DIR"] == str(get_audio_cache_dir()) assert env["HERMES_DOCUMENT_CACHE_DIR"] == str(get_document_cache_dir()) + assert env["WHATSAPP_SEND_READ_RECEIPTS"] == "true"