Skip to content

feat(whatsapp): opt-in read receipts (blue ticks) for accepted inbound messages - #65016

Closed
configurator-dorbot wants to merge 1 commit into
NousResearch:mainfrom
configurator-dorbot:feat/whatsapp-read-receipts
Closed

feat(whatsapp): opt-in read receipts (blue ticks) for accepted inbound messages#65016
configurator-dorbot wants to merge 1 commit into
NousResearch:mainfrom
configurator-dorbot:feat/whatsapp-read-receipts

Conversation

@configurator-dorbot

Copy link
Copy Markdown

What does this PR do?

Wires up WhatsApp read receipts (blue ticks) in the Baileys bridge. Today scripts/whatsapp-bridge/bridge.js never calls sock.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_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.

DM vs group is handled correctly (group receipts carry the sender's participant JID; DMs omit it), and status@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:

This PR addresses that consolidated review feedback: receipts fire only after the acceptance gates, the flag is a config.yaml setting bridged internally, missing-key/fromMe/status/broadcast/newsletter and DM-vs-group cases are handled, tests are included, and it applies cleanly to current main. 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

  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

  • scripts/whatsapp-bridge/bridge_helpers.js — new pure helper readReceiptKeyForMessage(msg): returns the Baileys read-receipt key or null; skips fromMe and malformed keys (no TypeError), skips status@broadcast / @broadcast / @newsletter, includes participant for @g.us groups and omits it for DMs.
  • scripts/whatsapp-bridge/bridge.js — opt-in WHATSAPP_READ_RECEIPTS flag (default off); markMessageReadIfEnabled() called at the accept point in messages.upsert (after messageQueue.push); startup log when enabled.
  • plugins/platforms/whatsapp/adapter.py_apply_yaml_config bridges whatsapp.read_receiptsWHATSAPP_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

# Bridge helper unit tests (Node)
cd scripts/whatsapp-bridge && node bridge.native.test.mjs

# Config→env bridging (Python)
pytest tests/gateway/test_whatsapp_read_receipts.py -q

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-asyncio is required for the async adapter tests.)

Manual

  1. In ~/.hermes/config.yaml set:
    whatsapp:
      read_receipts: true
  2. Restart the gateway/bridge so the bridge picks up WHATSAPP_READ_RECEIPTS.
  3. From an allowed user, send the bot a DM and a group message → the sender sees blue ticks once the agent accepts them.
  4. Send from a non-allowlisted user (blocked) or to a status/broadcast → no read receipt.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (feat(whatsapp): …)
  • I searched for existing PRs to make sure this isn't a duplicate (see "Prior art" above — this consolidates the stalled cluster)
  • My PR contains only changes related to this feature (no unrelated commits)
  • I've run the relevant tests and they pass (see "How to Test")
  • I've added tests for my changes
  • I've tested on my platform: Linux (self-hosted gateway, bot mode)

Documentation & Housekeeping

  • I've updated relevant documentation (website/docs/user-guide/messaging/whatsapp.md)
  • cli-config.yaml.example — N/A (per-platform WhatsApp keys like reply_prefix are documented in the messaging docs, not the example file)
  • CONTRIBUTING.md / AGENTS.md — N/A (no architecture/workflow change)
  • I've considered cross-platform impact — the bridge JS and Python env bridging are platform-agnostic
  • I've updated tool descriptions/schemas — N/A

Screenshots / Logs

Bridge logs 👁️ WHATSAPP_READ_RECEIPTS=on … at startup when enabled; with WHATSAPP_DEBUG=1, accepted messages emit a marked_read debug event and read failures emit mark_read_failed (surfaced, not swallowed silently).

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/feature New feature or request comp/plugins Plugin system and bundled plugins platform/whatsapp WhatsApp Business adapter P3 Low — cosmetic, nice to have sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages duplicate This issue or pull request already exists labels Jul 15, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Duplicate of #8690 — same underlying WhatsApp read-receipt (blue-tick) feature via the Baileys sock.readMessages() mechanism, which #8690 (earliest open) introduced. This PR is a cleaner take (opt-in, default OFF, emitting only after the intake gates pass to avoid the rejected-DM blue-tick bug), so it's worth comparing against the cluster — but it duplicates the same feature. Related: feature request #6539 and sibling PRs #27032 / #13185, which also target the same anchor. A maintainer should pick one canonical implementation from the cluster.

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) in scripts/whatsapp-bridge/bridge.js:800 runs before the Python adapter evaluates _should_process_message. Group policy and mention rejection occur in gateway/platforms/whatsapp_common.py:348-380, reached from plugins/platforms/whatsapp/adapter.py:1251-1257 and :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.

Comment thread scripts/whatsapp-bridge/bridge.js Outdated
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 16, 2026
…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
@configurator-dorbot
configurator-dorbot force-pushed the feat/whatsapp-read-receipts branch from 56de71c to d49915c Compare July 16, 2026 07:31
@configurator-dorbot

Copy link
Copy Markdown
Author

Thanks @teknium1 — good catch on the cross-layer ordering. Fixed in the latest push.

What changed

  • The bridge no longer marks read at the messageQueue.push(event) point in messages.upsert — that was before the adapter's _should_process_message gate, so a policy-/mention-rejected group message could get a blue tick.
  • Added POST /mark-read to the bridge (accepts chatId, messageId, optional participant/fromMe; all skip rules + the opt-in flag are enforced in markMessageReadIfEnabledreadReceiptKeyForMessage).
  • The adapter now fires the receipt only after admission: _dispatch_incoming(msg_data) calls _mark_read_if_enabled(msg_data) only when _build_message_event returns a non-None event (i.e. _should_process_message — group policy + require-mention/free-response — passed), then dispatches. Text still routes through the debounce batch afterward. The Baileys key fields are carried across the boundary (group receipts include the sender participant; DMs omit it).

Tests (tests/gateway/test_whatsapp_read_receipts.py)

  • Rejected message (_build_message_event → None) gets no receipt and is not dispatched.
  • Admitted non-text and admitted debounced-text are marked read before dispatch (ordering asserted).
  • Group payload includes participant, DM omits it; disabled/missing-id make no request.
  • Existing bridge-key coverage (fromMe, status/broadcast/newsletter, DM vs group) stays in bridge.native.test.mjs.

Local run: the WhatsApp suite (incl. test_whatsapp_text_batching, test_whatsapp_group_gating) and the Node helper tests pass (106 Python + Node helpers).

Docs note updated to say receipts are sent only after full admission (incl. the group require-mention gate).

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Merged via #73322 which salvaged #70340 by @maff-t2b. Your PR used a WHATSAPP_READ_RECEIPTS env var approach; we went with #70340's config.yaml approach per the project convention (.env is for secrets only). Your _dispatch_incoming() extraction and docs update were noted as good ideas. Thanks for the thorough implementation!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins duplicate This issue or pull request already exists P3 Low — cosmetic, nice to have platform/whatsapp WhatsApp Business adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: feat(whatsapp): send read receipts (blue ticks) for processed messages WhatsApp read receipts not sent back to WhatsApp servers

5 participants