diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index 30448da3a03..1b49d6cef86 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -28,6 +28,7 @@ import hmac import json import logging import os +import re import secrets import shutil import signal @@ -93,6 +94,15 @@ _DEDUP_WINDOW_SECONDS = 48 * 3600 _SIDECAR_DIR = Path(__file__).parent / "sidecar" +# Group-chat mention wake words. When ``require_mention`` is enabled, group +# messages are ignored unless they match one of these patterns — same +# behavior and defaults as the BlueBubbles iMessage channel so the two +# iMessage adapters gate group chats identically. +_DEFAULT_MENTION_PATTERNS = [ + r"(? "list[re.Pattern]": + """Compile group-mention wake words from config/env. + + ``raw`` is a list (config or env JSON), a string (env var: JSON + list, or comma/newline-separated), or None (use Hermes defaults). + Mirrors the BlueBubbles implementation so both iMessage channels + accept the same configuration shapes. + """ + if raw is None: + patterns = list(_DEFAULT_MENTION_PATTERNS) + elif isinstance(raw, str): + text = raw.strip() + try: + loaded = json.loads(text) if text else [] + except Exception: + loaded = None + patterns = loaded if isinstance(loaded, list) else [ + part.strip() + for line in text.splitlines() + for part in line.split(",") + ] + elif isinstance(raw, list): + patterns = raw + else: + patterns = [raw] + + compiled: "list[re.Pattern]" = [] + for pattern in patterns: + text = str(pattern).strip() + if not text: + continue + try: + compiled.append(re.compile(text, re.IGNORECASE)) + except re.error as exc: + logger.warning("[photon] Invalid mention pattern %r: %s", text, exc) + return compiled + + def _message_matches_mention_patterns(self, text: str) -> bool: + if not text or not self._mention_patterns: + return False + return any(pattern.search(text) for pattern in self._mention_patterns) + + def _clean_mention_text(self, text: str) -> str: + """Strip a leading wake word before dispatch. + + Custom mention patterns are regexes, so we only strip a leading + match to avoid deleting ordinary words later in the prompt. + """ + if not text: + return text + for pattern in self._mention_patterns: + match = pattern.match(text.lstrip()) + if match: + cleaned = text.lstrip()[match.end():].lstrip(" ,:-") + return cleaned or text + return text + # -- Connection lifecycle --------------------------------------------- async def connect(self) -> bool: @@ -441,6 +526,19 @@ class PhotonAdapter(BasePlatformAdapter): text = f"[Photon content type not handled: {content.get('type')}]" mtype = MessageType.TEXT + # Group-mention gating (parity with BlueBubbles). In group chats with + # require_mention enabled, drop messages that don't hit a wake word; + # strip the leading wake word from the ones that do. DMs are never + # gated. + if chat_type == "group" and self.require_mention: + if not self._message_matches_mention_patterns(text): + logger.debug( + "[photon] ignoring group message " + "(require_mention=true, no mention pattern matched)" + ) + return + text = self._clean_mention_text(text) + source = self.build_source( chat_id=space_id, chat_name=space_id, diff --git a/plugins/platforms/photon/plugin.yaml b/plugins/platforms/photon/plugin.yaml index 0f7cc1be973..ebdce35ed57 100644 --- a/plugins/platforms/photon/plugin.yaml +++ b/plugins/platforms/photon/plugin.yaml @@ -73,6 +73,14 @@ optional_env: description: "Allow any sender to trigger the bot (dev only — disables allowlist)" prompt: "Allow all users? (true/false)" password: false + - name: PHOTON_REQUIRE_MENTION + description: "Ignore group-chat messages unless they match a mention wake word (true/false, default false)" + prompt: "Require a mention in group chats?" + password: false + - name: PHOTON_MENTION_PATTERNS + description: "Mention wake-word regexes for group chats (JSON list or comma/newline-separated; defaults to Hermes wake words)" + prompt: "Group mention patterns" + password: false - name: PHOTON_HOME_CHANNEL description: "Default Spectrum space ID for cron / notification delivery" prompt: "Home space ID" diff --git a/tests/plugins/platforms/photon/test_mention_gating.py b/tests/plugins/platforms/photon/test_mention_gating.py new file mode 100644 index 00000000000..3eaf6de22a0 --- /dev/null +++ b/tests/plugins/platforms/photon/test_mention_gating.py @@ -0,0 +1,146 @@ +"""Group-chat mention-gating tests for PhotonAdapter. + +Parity with the BlueBubbles iMessage channel: when ``require_mention`` is +enabled, group messages are dropped unless they hit a wake-word pattern, +and the leading wake word is stripped from the ones that pass. DMs are +never gated. + +These call ``_dispatch_inbound`` directly (no aiohttp / ports) and assert +on what reaches ``handle_message``. +""" +from __future__ import annotations + +from typing import List + +import pytest + +from gateway.config import PlatformConfig +from gateway.platforms.base import MessageEvent +from plugins.platforms.photon.adapter import PhotonAdapter + + +def _make_adapter(monkeypatch: pytest.MonkeyPatch, extra: dict | None = None) -> PhotonAdapter: + monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id") + monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret") + monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False) + monkeypatch.delenv("PHOTON_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("PHOTON_MENTION_PATTERNS", raising=False) + cfg = PlatformConfig(enabled=True, token="", extra=extra or {}) + return PhotonAdapter(cfg) + + +def _group_payload(text: str) -> dict: + return { + "event": "messages", + "message": { + "id": f"grp-{abs(hash(text))}", + "timestamp": "2026-05-14T19:06:32.000Z", + "sender": {"id": "+15551234567"}, + "space": {"id": "any;+;group-guid-xyz"}, + "content": {"type": "text", "text": text}, + }, + } + + +def _dm_payload(text: str) -> dict: + return { + "event": "messages", + "message": { + "id": f"dm-{abs(hash(text))}", + "timestamp": "2026-05-14T19:06:32.000Z", + "sender": {"id": "+15551234567"}, + "space": {"id": "any;-;+15551234567"}, + "content": {"type": "text", "text": text}, + }, + } + + +def _capture(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]: + captured: List[MessageEvent] = [] + + async def fake_handle(event: MessageEvent) -> None: + captured.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle) + return captured + + +def test_require_mention_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch) + assert adapter.require_mention is False + # Defaults compile to the two Hermes wake-word patterns. + assert len(adapter._mention_patterns) == 2 + + +@pytest.mark.asyncio +async def test_group_message_dropped_without_mention(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch, extra={"require_mention": True}) + captured = _capture(adapter, monkeypatch) + + await adapter._dispatch_inbound(_group_payload("just chatting, no wake word")) + assert captured == [] + + +@pytest.mark.asyncio +async def test_group_message_passes_and_strips_wake_word(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch, extra={"require_mention": True}) + captured = _capture(adapter, monkeypatch) + + await adapter._dispatch_inbound(_group_payload("Hermes what's the weather")) + assert len(captured) == 1 + # Leading wake word stripped before dispatch. + assert captured[0].text == "what's the weather" + + +@pytest.mark.asyncio +async def test_dm_never_gated(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch, extra={"require_mention": True}) + captured = _capture(adapter, monkeypatch) + + await adapter._dispatch_inbound(_dm_payload("no wake word here")) + assert len(captured) == 1 + assert captured[0].text == "no wake word here" + + +@pytest.mark.asyncio +async def test_require_mention_off_passes_group_messages(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch) # require_mention defaults off + captured = _capture(adapter, monkeypatch) + + await adapter._dispatch_inbound(_group_payload("plain group chatter")) + assert len(captured) == 1 + assert captured[0].text == "plain group chatter" + + +def test_custom_mention_patterns_from_config(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter( + monkeypatch, + extra={"require_mention": True, "mention_patterns": [r"(? None: + monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id") + monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret") + monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False) + monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true") + monkeypatch.setenv("PHOTON_MENTION_PATTERNS", r"bot\b, assistant\b") + cfg = PlatformConfig(enabled=True, token="", extra={}) + adapter = PhotonAdapter(cfg) + assert adapter.require_mention is True + assert len(adapter._mention_patterns) == 2 + assert adapter._message_matches_mention_patterns("hey bot") is True + + +def test_invalid_pattern_skipped(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter( + monkeypatch, + extra={"require_mention": True, "mention_patterns": ["(unclosed", r"good\b"]}, + ) + # Bad regex dropped, good one kept. + assert len(adapter._mention_patterns) == 1 + assert adapter._message_matches_mention_patterns("a good thing") is True diff --git a/website/docs/user-guide/messaging/photon.md b/website/docs/user-guide/messaging/photon.md index 41f4b54aa76..d6f533c9e77 100644 --- a/website/docs/user-guide/messaging/photon.md +++ b/website/docs/user-guide/messaging/photon.md @@ -100,6 +100,37 @@ When `PHOTON_ALLOWED_USERS` is set, unknown senders are silently ignored rather than offered a pairing code (the allowlist signals you deliberately restricted access). +### Require mentions in group chats + +By default Hermes responds to every authorized DM and group message. +To make group chats opt-in, enable mention gating (DMs still always +work): + +```yaml +gateway: + platforms: + photon: + enabled: true + require_mention: true +``` + +With `require_mention: true`, group-chat messages are ignored unless +they match a wake-word pattern. The defaults match `Hermes` and +`@Hermes agent` variants. For a custom agent name, set regex patterns: + +```yaml +gateway: + platforms: + photon: + require_mention: true + mention_patterns: + - '(? # remove one | `PHOTON_HOME_CHANNEL_NAME`| (unset) | Human label for the home channel | | `PHOTON_ALLOWED_USERS` | (unset) | Comma-separated E.164 allowlist | | `PHOTON_ALLOW_ALL_USERS` | `false` | Dev only — accept any sender | +| `PHOTON_REQUIRE_MENTION` | `false` | Require a wake word before responding in groups | +| `PHOTON_MENTION_PATTERNS` | Hermes wake words | JSON list / comma / newline regex patterns for group mentions | | `PHOTON_API_HOST` | `spectrum.photon.codes` | Override the Spectrum management API host | | `PHOTON_DASHBOARD_HOST` | `app.photon.codes` | Override the dashboard / device-login host |