Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 61 additions & 6 deletions plugins/platforms/whatsapp/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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"):
Expand Down
87 changes: 87 additions & 0 deletions scripts/whatsapp-bridge/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
mediaPayloadForFile,
pollCreationMessageFromPayload,
pollUpdateForAggregation,
readReceiptKeyForMessage,
} from './bridge_helpers.js';

// Parse CLI args
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 '';
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
Expand Down
67 changes: 67 additions & 0 deletions scripts/whatsapp-bridge/bridge.native.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
mediaPayloadForFile,
pollCreationMessageFromPayload,
pollUpdateForAggregation,
readReceiptKeyForMessage,
} from './bridge_helpers.js';

// -- quoted outbound text -------------------------------------------------
Expand Down Expand Up @@ -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.');
29 changes: 29 additions & 0 deletions scripts/whatsapp-bridge/bridge_helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading