mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(whatsapp): support inbound read receipts
This commit is contained in:
parent
560800f3cc
commit
35afa8ce06
8 changed files with 89 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue