From 1866518574ef55452178f88ef6153ee2a0cb4486 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:14:01 -0700 Subject: [PATCH] feat(photon): group-chat mention gating for full channel parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the last missing parity piece vs the established channels: group chats can be made opt-in via a mention wake word, exactly like the BlueBubbles iMessage channel. - require_mention + mention_patterns, read from config.extra (config.yaml via the generic gateway bridge) or PHOTON_REQUIRE_MENTION / PHOTON_MENTION_PATTERNS env vars. Same shapes BlueBubbles accepts (list / JSON / comma / newline), same default Hermes wake words. - _dispatch_inbound drops unmatched group messages and strips the leading wake word from matched ones; DMs are never gated. - plugin.yaml + docs document both knobs and the config.yaml form. - New test_mention_gating.py (8 tests): default-off, group drop/pass, wake-word strip, DM bypass, custom patterns, env comma-list, invalid regex skip. The config.yaml -> extra bridge needed no core change — the generic shared-key loop in gateway/config.py already iterates plugin platforms (_shared_loop_targets += plugin_entries()), so require_mention / mention_patterns flow through automatically. Note: outbound media is the one capability Photon still can't reach — Photon exposes no HTTP send-attachment endpoint yet (documented API limitation), so the sidecar can't send files. Not faked. Validation: 34/34 photon tests; E2E confirms config.yaml require_mention + custom mention_patterns bridge through load_gateway_config into a live adapter and gate/strip correctly. --- plugins/platforms/photon/adapter.py | 98 ++++++++++++ plugins/platforms/photon/plugin.yaml | 8 + .../platforms/photon/test_mention_gating.py | 146 ++++++++++++++++++ website/docs/user-guide/messaging/photon.md | 33 ++++ 4 files changed, 285 insertions(+) create mode 100644 tests/plugins/platforms/photon/test_mention_gating.py 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 |