feat(whatsapp): opt-in read receipts (blue ticks) for accepted inbound messages - #65016
Conversation
Duplicate of #8690 — same underlying WhatsApp read-receipt (blue-tick) feature via the Baileys |
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Approved
Looks Good
- Feat(whatsapp): opt-in read receipts (blue ticks) for accepted inbound messages
- 231 additions, clean feature addition
- No issues detected
Reviewed by Hermes Agent
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the config.yaml-owned, opt-in implementation and the focused bridge-key tests. The read-receipt gap remains on current main, but there is one delivery-semantics issue to resolve before this is safe.
Problems
- The new call after
messageQueue.push(event)inscripts/whatsapp-bridge/bridge.js:800runs before the Python adapter evaluates_should_process_message. Group policy and mention rejection occur ingateway/platforms/whatsapp_common.py:348-380, reached fromplugins/platforms/whatsapp/adapter.py:1251-1257and:1331-1335. A group message rejected for policy or a missing mention could therefore receive a visible blue tick. - The helper tests do not cover this cross-layer ordering. Existing coverage confirms that an unmentioned group is rejected at
tests/gateway/test_whatsapp_group_gating.py:110-113.
Suggested changes
- Trigger the receipt only after adapter admission, retaining the Baileys key fields required for group receipts through the bridge/adapter boundary.
- Cover rejected group-policy/no-mention cases plus accepted DM, mentioned-group, and debounced text-batch paths. Text batching dispatches later at
plugins/platforms/whatsapp/adapter.py:1284-1326.
This is an automated hermes-sweeper review.
| @@ -749,6 +796,11 @@ async function startSocket() { | |||
|
|
|||
| messageStore.remember(msg); | |||
| messageQueue.push(event); | |||
| // Only accepted messages reach this point (junk/blocked ones already | |||
| // `continue`d above), so this is the correct place to clear the unread | |||
There was a problem hiding this comment.
This queue point is before the adapter's _should_process_message() gate, which enforces group_policy and require_mention in gateway/platforms/whatsapp_common.py:348-380. With receipts enabled, an unmentioned or policy-rejected group event can be marked read even though _build_message_event() drops it. Please defer the receipt until after that admission decision.
…d messages The Baileys bridge never called `sock.readMessages()`, so senders only ever saw double-grey ticks and a human sharing the bot's WhatsApp account on a linked device kept accumulating unread badges for messages the agent had already handled. This wires up read receipts, matching OpenClaw's WhatsApp bridge. Closes the gap tracked in NousResearch#6539 and NousResearch#6055. Delivery semantics — the receipt fires only after *both* admission layers pass: - The bridge's intake gates (self-chat/allowlist/DM/group policy, echo, empty) in messages.upsert; and - the Python adapter's `_should_process_message` gate (group policy + require-mention / free-response) in whatsapp_common.py, which runs after the adapter polls /messages. So the bridge no longer marks read at the upsert queue point (that would blue-tick group messages the adapter later drops for policy or a missing mention). Instead the adapter calls a new `POST /mark-read` on the bridge once `_build_message_event` returns a non-None event, carrying the Baileys key fields (chatId + messageId, plus the sender participant for groups) across the boundary. Design: - New pure helper `readReceiptKeyForMessage()` decides whether/how to mark read: skips `fromMe` and malformed keys (no TypeError); skips status@broadcast, broadcast lists, and channels/newsletters; includes the sender participant JID for groups and omits it for DMs. Used by both the endpoint and the flag guard. - `POST /mark-read` is fire-and-forget and self-catching, so a read round-trip or failure never stalls or breaks message processing; the adapter also gates on the flag to avoid a needless round-trip when receipts are off. - Opt-in, default OFF (a read receipt is a visible, privacy-relevant signal to the sender). User-facing surface is `whatsapp.read_receipts` in config.yaml, bridged to the internal `WHATSAPP_READ_RECEIPTS` env var by the plugin's `_apply_yaml_config` hook (per the config.yaml rubric in AGENTS.md), env winning over YAML like the sibling keys; both the bridge and the adapter read that env. Tests: - bridge.native.test.mjs: helper coverage for DM vs group, fromMe, status/broadcast/newsletter, and malformed keys. - tests/gateway/test_whatsapp_read_receipts.py: config->env bridging; and the cross-layer ordering — rejected (group-policy / no-mention) messages get no receipt, admitted DM / mentioned-group / debounced-text messages are marked read before dispatch, with the correct group-vs-DM participant payload. Docs: - website/docs/user-guide/messaging/whatsapp.md: new "Read Receipts" section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PYekTTHiABoZUgTRAPRiu6
56de71c to
d49915c
Compare
|
Thanks @teknium1 — good catch on the cross-layer ordering. Fixed in the latest push. What changed
Tests (
Local run: the WhatsApp suite (incl. Docs note updated to say receipts are sent only after full admission (incl. the group require-mention gate). |
|
Merged via #73322 which salvaged #70340 by @maff-t2b. Your PR used a |
What does this PR do?
Wires up WhatsApp read receipts (blue ticks) in the Baileys bridge. Today
scripts/whatsapp-bridge/bridge.jsnever callssock.readMessages(), so senders only ever see double-grey ticks, and a human who also has the bot's WhatsApp account linked on their own phone keeps accumulating unread badges for messages the agent has already handled. This matches OpenClaw's WhatsApp bridge behavior.The receipt is emitted only after a message passes every intake gate (self-chat / allowlist / DM / group policy, echo, empty) and is accepted into the queue — so blocked or ignored messages are never marked read. This was the recurring correctness bug in the earlier attempts (rejected DMs getting blue ticks).
It is opt-in, default OFF — a read receipt is a visible, privacy-relevant signal to the sender, so operators enable it explicitly. The user-facing surface is
whatsapp.read_receiptsinconfig.yaml, bridged to the internalWHATSAPP_READ_RECEIPTSenv var by the plugin's_apply_yaml_confighook (per the config.yaml rubric inAGENTS.md), env winning over YAML like the sibling keys.DM vs group is handled correctly (group receipts carry the sender's
participantJID; DMs omit it), andstatus@broadcast, broadcast lists, and channels/newsletters are skipped. The bridge call is fire-and-forget and self-catching, so a read round-trip or failure never stalls or breaks upsert processing.Prior art — this consolidates a saturated PR cluster
This feature (issues #6539 / #6055) has several earlier attempts that have stalled, all flagged by maintainer triage as competing PRs that should be consolidated into one:
main.config.yaml, and that the described tests weren't in the diff.This PR addresses that consolidated review feedback: receipts fire only after the acceptance gates, the flag is a
config.yamlsetting bridged internally, missing-key/fromMe/status/broadcast/newsletter and DM-vs-group cases are handled, tests are included, and it applies cleanly to currentmain. Maintainers can close the stalled siblings in favor of this one.Related but not fixed here: #27198 (messages stuck at one tick — a delivery-ACK issue, distinct from read receipts).
Related Issue
Fixes #6539
Fixes #6055
Type of Change
Changes Made
scripts/whatsapp-bridge/bridge_helpers.js— new pure helperreadReceiptKeyForMessage(msg): returns the Baileys read-receipt key ornull; skipsfromMeand malformed keys (noTypeError), skipsstatus@broadcast/@broadcast/@newsletter, includesparticipantfor@g.usgroups and omits it for DMs.scripts/whatsapp-bridge/bridge.js— opt-inWHATSAPP_READ_RECEIPTSflag (default off);markMessageReadIfEnabled()called at the accept point inmessages.upsert(aftermessageQueue.push); startup log when enabled.plugins/platforms/whatsapp/adapter.py—_apply_yaml_configbridgeswhatsapp.read_receipts→WHATSAPP_READ_RECEIPTS(env wins over YAML).scripts/whatsapp-bridge/bridge.native.test.mjs— helper tests (DM vs group,fromMe, status/broadcast/newsletter, malformed keys).tests/gateway/test_whatsapp_read_receipts.py— config.yaml → env bridging tests (true / false / absent / env-precedence).website/docs/user-guide/messaging/whatsapp.md— new "Read Receipts (Blue Ticks)" section.How to Test
Automated
I ran the Node helper tests and the WhatsApp Python suite (
tests/gateway/test_whatsapp_*.py, incl.test_whatsapp_read_receipts,test_whatsapp_reply_prefix,test_whatsapp_connect,test_whatsapp_stale_bridge,test_whatsapp_group_gating,test_whatsapp_from_owner) — 87 passed locally. (Note:pytest-asynciois required for the async adapter tests.)Manual
~/.hermes/config.yamlset:WHATSAPP_READ_RECEIPTS.Checklist
Code
feat(whatsapp): …)botmode)Documentation & Housekeeping
website/docs/user-guide/messaging/whatsapp.md)cli-config.yaml.example— N/A (per-platform WhatsApp keys likereply_prefixare documented in the messaging docs, not the example file)CONTRIBUTING.md/AGENTS.md— N/A (no architecture/workflow change)Screenshots / Logs
Bridge logs
👁️ WHATSAPP_READ_RECEIPTS=on …at startup when enabled; withWHATSAPP_DEBUG=1, accepted messages emit amarked_readdebug event and read failures emitmark_read_failed(surfaced, not swallowed silently).🤖 Generated with Claude Code