feat(whatsapp): support inbound read receipts

This commit is contained in:
Cyrus 2026-07-23 23:12:04 +01:00 committed by kshitij
parent 560800f3cc
commit 35afa8ce06
8 changed files with 89 additions and 0 deletions

View file

@ -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:

View file

@ -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

View file

@ -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,
});
});

View file

@ -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);

View file

@ -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);

View file

@ -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

View file

@ -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

View file

@ -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"