Skip to content
Open
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
4 changes: 3 additions & 1 deletion gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,7 @@ def _merge_platform_map(source_platforms: Any) -> None:
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
if "exclusive_bot_mentions" in platform_cfg:
bridged["exclusive_bot_mentions"] = platform_cfg["exclusive_bot_mentions"]
if plat == Platform.TELEGRAM and "observe_unmentioned_group_messages" in platform_cfg:
if plat in {Platform.TELEGRAM, Platform.WHATSAPP} and "observe_unmentioned_group_messages" in platform_cfg:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

WhatsApp config bridging is now plugin-owned: current main registers plugins/platforms/whatsapp/adapter.py::_apply_yaml_config() for this path. Please port this setting into that hook instead of restoring a WhatsApp-specific branch in gateway/config.py.

bridged["observe_unmentioned_group_messages"] = platform_cfg["observe_unmentioned_group_messages"]
if "dm_policy" in platform_cfg:
bridged["dm_policy"] = platform_cfg["dm_policy"]
Expand Down Expand Up @@ -1101,6 +1101,8 @@ def _merge_platform_map(source_platforms: Any) -> None:
if isinstance(whatsapp_cfg, dict):
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 "observe_unmentioned_group_messages" in whatsapp_cfg and not os.getenv("WHATSAPP_OBSERVE_UNMENTIONED_GROUP_MESSAGES"):
os.environ["WHATSAPP_OBSERVE_UNMENTIONED_GROUP_MESSAGES"] = str(whatsapp_cfg["observe_unmentioned_group_messages"]).lower()
if "mention_patterns" in whatsapp_cfg and not os.getenv("WHATSAPP_MENTION_PATTERNS"):
os.environ["WHATSAPP_MENTION_PATTERNS"] = json.dumps(whatsapp_cfg["mention_patterns"])
frc = whatsapp_cfg.get("free_response_chats")
Expand Down
104 changes: 101 additions & 3 deletions gateway/platforms/whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"""

import asyncio
import dataclasses
import json
import logging
import os
Expand All @@ -24,6 +25,7 @@
import shutil
import signal
import subprocess
from datetime import datetime, timezone

_IS_WINDOWS = platform.system() == "Windows"
from pathlib import Path
Expand Down Expand Up @@ -342,6 +344,14 @@ def _whatsapp_require_mention(self) -> bool:
return bool(configured)
return os.getenv("WHATSAPP_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"}

def _whatsapp_observe_unmentioned_group_messages(self) -> bool:
configured = self.config.extra.get("observe_unmentioned_group_messages")
if configured is not None:
if isinstance(configured, str):
return configured.lower() in {"true", "1", "yes", "on"}
return bool(configured)
return os.getenv("WHATSAPP_OBSERVE_UNMENTIONED_GROUP_MESSAGES", "false").lower() in {"true", "1", "yes", "on"}

def _whatsapp_free_response_chats(self) -> set[str]:
raw = self.config.extra.get("free_response_chats")
if raw is None:
Expand Down Expand Up @@ -526,6 +536,89 @@ def _should_process_message(self, data: Dict[str, Any]) -> bool:
if self._message_mentions_bot(data):
return True
return self._message_matches_mention_patterns(data)

def _whatsapp_group_observe_mode_enabled(self, data: Dict[str, Any]) -> bool:
"""Return True when group observe mode applies to this message."""
if not self._whatsapp_observe_unmentioned_group_messages():
return False
chat_id = str(data.get("chatId") or "")
if self._is_broadcast_chat(chat_id):
return False
if not data.get("isGroup", False):
return False
if not self._is_group_allowed(chat_id):
return False
if chat_id in self._whatsapp_free_response_chats():
return False
if not self._whatsapp_require_mention():
return False
return True

def _should_observe_unmentioned_group_message(self, data: Dict[str, Any]) -> bool:
"""Return True when a group message should be stored but not dispatched."""
if not self._whatsapp_group_observe_mode_enabled(data):
return False
body = str(data.get("body") or "").strip()
if body.startswith("/"):
return False
if self._message_is_reply_to_bot(data):
return False
if self._message_mentions_bot(data):
return False
if self._message_matches_mention_patterns(data):
return False
return True

@staticmethod
def _whatsapp_group_observe_shared_source(source):
"""Return a chat-scoped source for observed WhatsApp group context."""
return dataclasses.replace(source, user_id=None, user_name=None, user_id_alt=None)

@staticmethod
def _whatsapp_group_observe_attributed_text(event: MessageEvent) -> str:
user_id = event.source.user_id or "unknown"
sender = event.source.user_name or user_id
return f"[{sender}|{user_id}]\n{event.text or ''}"

def _apply_whatsapp_group_observe_attribution(self, event: MessageEvent) -> MessageEvent:
raw_message = event.raw_message if isinstance(event.raw_message, dict) else {}
if not self._whatsapp_group_observe_mode_enabled(raw_message):
return event
return dataclasses.replace(
event,
text=self._whatsapp_group_observe_attributed_text(event),
source=self._whatsapp_group_observe_shared_source(event.source),
)

async def _observe_unmentioned_group_message(self, data: Dict[str, Any]) -> None:
store = getattr(self, "_session_store", None)
if not store:
return
try:
event = await self._build_message_event(data, skip_gate=True)
if not event:
return
shared_source = self._whatsapp_group_observe_shared_source(event.source)
session_entry = store.get_or_create_session(shared_source)
entry = {
"role": "user",
"content": self._whatsapp_group_observe_attributed_text(event),
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
"observed": True,
}
if event.message_id:
entry["message_id"] = str(event.message_id)
store.append_to_transcript(session_entry.session_id, entry)
adapter_name = getattr(self, "name", "whatsapp")
logger.info(
"[%s] WhatsApp group message observed (no bot trigger): chat=%s from=%s",
adapter_name,
data.get("chatId") or "unknown",
event.source.user_id or "unknown",
)
except Exception as exc:
adapter_name = getattr(self, "name", "whatsapp")
logger.warning("[%s] Failed to observe WhatsApp group message: %s", adapter_name, exc)

async def connect(self) -> bool:
"""
Expand Down Expand Up @@ -1257,10 +1350,12 @@ async def _flush_text_batch(self, key: str) -> None:
if self._pending_text_batch_tasks.get(key) is current_task:
self._pending_text_batch_tasks.pop(key, None)

async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]:
async def _build_message_event(self, data: Dict[str, Any], *, skip_gate: bool = False) -> Optional[MessageEvent]:
"""Build a MessageEvent from bridge message data, downloading images to cache."""
try:
if not self._should_process_message(data):
if not skip_gate and not self._should_process_message(data):
if self._should_observe_unmentioned_group_message(data):
await self._observe_unmentioned_group_message(data)
return None

# Determine message type
Expand Down Expand Up @@ -1373,7 +1468,7 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv
except Exception as e:
print(f"[{self.name}] Failed to read document text: {e}", flush=True)

return MessageEvent(
event = MessageEvent(
text=body,
message_type=msg_type,
source=source,
Expand All @@ -1382,6 +1477,9 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv
media_urls=cached_urls,
media_types=media_types,
)
if not skip_gate:
event = self._apply_whatsapp_group_observe_attribution(event)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sharing the source is insufficient here: no channel_prompt marks this as context-only. Current gateway/run.py:779-790 extracts observed rows only for the Telegram marker, so these persisted rows will replay as ordinary user turns. Add a platform-neutral observed-context handoff and an end-to-end runner test.

return event
except Exception as e:
print(f"[{self.name}] Error building event: {e}")
return None
135 changes: 134 additions & 1 deletion tests/gateway/test_whatsapp_group_gating.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import json
import asyncio
from unittest.mock import AsyncMock

from gateway.config import Platform, PlatformConfig, load_gateway_config


def _make_adapter(require_mention=None, mention_patterns=None, free_response_chats=None,
dm_policy=None, allow_from=None, group_policy=None, group_allow_from=None):
dm_policy=None, allow_from=None, group_policy=None, group_allow_from=None,
observe_unmentioned_group_messages=None):
from gateway.platforms.whatsapp import WhatsAppAdapter

extra = {}
Expand All @@ -23,6 +25,8 @@ def _make_adapter(require_mention=None, mention_patterns=None, free_response_cha
extra["group_policy"] = group_policy
if group_allow_from is not None:
extra["group_allow_from"] = group_allow_from
if observe_unmentioned_group_messages is not None:
extra["observe_unmentioned_group_messages"] = observe_unmentioned_group_messages

adapter = object.__new__(WhatsAppAdapter)
adapter.platform = Platform.WHATSAPP
Expand Down Expand Up @@ -63,6 +67,23 @@ def _dm_message(body="hello", **overrides):
return data


class _FakeSessionEntry:
session_id = "whatsapp-group-session"


class _FakeSessionStore:
def __init__(self):
self.sources = []
self.messages = []

def get_or_create_session(self, source):
self.sources.append(source)
return _FakeSessionEntry()

def append_to_transcript(self, session_id, message, skip_db=False):
self.messages.append((session_id, message, skip_db))


# --- Existing tests (unchanged logic, updated helper) ---

def test_group_messages_can_be_opened_via_config():
Expand Down Expand Up @@ -90,6 +111,114 @@ def test_group_messages_can_require_direct_trigger_via_config():
assert adapter._should_process_message(_group_message("/status")) is True


def test_unmentioned_group_messages_can_be_observed_without_dispatching():
async def _run():
adapter = _make_adapter(
require_mention=True,
observe_unmentioned_group_messages=True,
)
store = _FakeSessionStore()
adapter._session_store = store

event = await adapter._build_message_event(
_group_message(
"side chatter",
senderId="6281234567890@s.whatsapp.net",
senderName="Alice Example",
messageId="msg-42",
)
)

assert event is None
assert len(store.messages) == 1
session_id, message, skip_db = store.messages[0]
assert session_id == "whatsapp-group-session"
assert skip_db is False
assert message["role"] == "user"
assert message["content"] == "[Alice Example|6281234567890@s.whatsapp.net]\nside chatter"
assert message["observed"] is True
assert message["message_id"] == "msg-42"
assert store.sources[0].chat_id == "120363001234567890@g.us"
assert store.sources[0].chat_type == "group"
assert store.sources[0].user_id is None
assert store.sources[0].user_name is None

asyncio.run(_run())


def test_observed_group_context_uses_shared_source_for_later_mentions():
async def _run():
adapter = _make_adapter(
require_mention=True,
observe_unmentioned_group_messages=True,
)

event = await adapter._build_message_event(
_group_message(
"@15551230000 what did Alice say?",
mentionedIds=["15551230000@s.whatsapp.net"],
senderId="6289999999999@s.whatsapp.net",
senderName="Bob Example",
)
)

assert event is not None
assert event.source.chat_id == "120363001234567890@g.us"
assert event.source.chat_type == "group"
assert event.source.user_id is None
assert event.source.user_name is None
assert event.text == "[Bob Example|6289999999999@s.whatsapp.net]\nwhat did Alice say?"

asyncio.run(_run())


def test_observe_flag_without_mention_gating_keeps_normal_group_event_source():
async def _run():
adapter = _make_adapter(
require_mention=False,
observe_unmentioned_group_messages=True,
)

event = await adapter._build_message_event(
_group_message(
"hello everyone",
senderId="6281234567890@s.whatsapp.net",
senderName="Alice Example",
)
)

assert event is not None
assert event.source.user_id == "6281234567890@s.whatsapp.net"
assert event.source.user_name == "Alice Example"
assert event.text == "hello everyone"

asyncio.run(_run())


def test_observe_flag_keeps_free_response_group_event_source():
async def _run():
adapter = _make_adapter(
require_mention=True,
free_response_chats=["120363001234567890@g.us"],
observe_unmentioned_group_messages=True,
)

event = await adapter._build_message_event(
_group_message(
"hello everyone",
senderId="6281234567890@s.whatsapp.net",
senderName="Alice Example",
)
)

assert event is not None
assert event.source.user_id == "6281234567890@s.whatsapp.net"
assert event.source.user_name == "Alice Example"
assert event.text == "hello everyone"

asyncio.run(_run())


def test_regex_mention_patterns_allow_custom_wake_words():
adapter = _make_adapter(require_mention=True, mention_patterns=[r"^\s*chompy\b"])

Expand All @@ -111,21 +240,25 @@ def test_config_bridges_whatsapp_group_settings(monkeypatch, tmp_path):
(hermes_home / "config.yaml").write_text(
"whatsapp:\n"
" require_mention: true\n"
" observe_unmentioned_group_messages: true\n"
" mention_patterns:\n"
" - \"^\\\\s*chompy\\\\b\"\n",
encoding="utf-8",
)

monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("WHATSAPP_REQUIRE_MENTION", raising=False)
monkeypatch.delenv("WHATSAPP_OBSERVE_UNMENTIONED_GROUP_MESSAGES", raising=False)
monkeypatch.delenv("WHATSAPP_MENTION_PATTERNS", raising=False)

config = load_gateway_config()

assert config is not None
assert config.platforms[Platform.WHATSAPP].extra["require_mention"] is True
assert config.platforms[Platform.WHATSAPP].extra["observe_unmentioned_group_messages"] is True
assert config.platforms[Platform.WHATSAPP].extra["mention_patterns"] == [r"^\s*chompy\b"]
assert __import__("os").environ["WHATSAPP_REQUIRE_MENTION"] == "true"
assert __import__("os").environ["WHATSAPP_OBSERVE_UNMENTIONED_GROUP_MESSAGES"] == "true"
assert json.loads(__import__("os").environ["WHATSAPP_MENTION_PATTERNS"]) == [r"^\s*chompy\b"]


Expand Down
Loading