diff --git a/gateway/config.py b/gateway/config.py index 59fdfa54e..62818b076 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1722,6 +1722,22 @@ def _apply_env_overrides(config: GatewayConfig) -> None: "webhook_path": os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"), "send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in {"true", "1", "yes"}, }) + bluebubbles_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION") + if bluebubbles_require_mention is not None: + config.platforms[Platform.BLUEBUBBLES].extra["require_mention"] = ( + bluebubbles_require_mention.lower() in {"true", "1", "yes", "on"} + ) + bluebubbles_mention_patterns = os.getenv("BLUEBUBBLES_MENTION_PATTERNS") + if bluebubbles_mention_patterns: + try: + parsed_patterns = json.loads(bluebubbles_mention_patterns) + except Exception: + parsed_patterns = [ + part.strip() + for part in bluebubbles_mention_patterns.replace("\n", ",").split(",") + if part.strip() + ] + config.platforms[Platform.BLUEBUBBLES].extra["mention_patterns"] = parsed_patterns bluebubbles_home = os.getenv("BLUEBUBBLES_HOME_CHANNEL") if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms: config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel( diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index 2fc4102b6..b90507c98 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -44,6 +44,15 @@ DEFAULT_WEBHOOK_PORT = 8645 DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook" MAX_TEXT_LENGTH = 4000 +# BlueBubbles/iMessage does not expose a stable bot mention identity like +# Slack (<@U...>), Telegram (@botname), or Matrix (MXID). When users opt into +# group mention gating without custom aliases, use conservative Hermes wake +# words so `require_mention: true` is a one-line enablement path. +DEFAULT_MENTION_PATTERNS = [ + r"(? bool: + for value in values: + if value is None: + continue + if isinstance(value, bool): + return value + text = str(value).strip().lower() + if not text: + continue + return text in {"true", "1", "yes", "on"} + return default + + @staticmethod + def _coerce_mention_patterns(raw: Any) -> List[str]: + if raw is None: + return list(DEFAULT_MENTION_PATTERNS) + if isinstance(raw, list): + values = raw + elif isinstance(raw, str): + text = raw.strip() + if not text: + return [] + try: + parsed = json.loads(text) + except Exception: + parsed = None + if isinstance(parsed, list): + values = parsed + else: + values = [ + part.strip() + for line in text.splitlines() + for part in line.split(",") + if part.strip() + ] + else: + values = [raw] + return [str(value).strip() for value in values if str(value).strip()] + + def _compile_mention_patterns(self, raw: Any) -> List[re.Pattern]: + compiled: List[re.Pattern] = [] + for pattern in self._coerce_mention_patterns(raw): + try: + compiled.append(re.compile(pattern, re.IGNORECASE)) + except re.error as exc: + logger.warning("[%s] Invalid BlueBubbles mention pattern %r: %s", self.name, pattern, exc) + if compiled: + logger.info("[%s] Loaded %d BlueBubbles mention pattern(s)", self.name, len(compiled)) + 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 BlueBubbles wake word before dispatch. + + Custom mention patterns are regular expressions, so stripping only a + leading match avoids 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 + async def _api_get(self, path: str) -> Dict[str, Any]: assert self.client is not None res = await self.client.get(self._api_url(path)) @@ -921,6 +1011,13 @@ class BlueBubblesAdapter(BasePlatformAdapter): session_chat_id = chat_guid or chat_identifier is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or "")) + if is_group and self.require_mention: + if not self._message_matches_mention_patterns(text): + logger.debug( + "[bluebubbles] ignoring group message (require_mention=true, no mention pattern matched)" + ) + return web.Response(text="ok") + text = self._clean_mention_text(text) source = self.build_source( chat_id=session_chat_id, chat_name=chat_identifier or sender, diff --git a/tests/gateway/test_bluebubbles.py b/tests/gateway/test_bluebubbles.py index e42d050c8..7d4a71378 100644 --- a/tests/gateway/test_bluebubbles.py +++ b/tests/gateway/test_bluebubbles.py @@ -1,4 +1,7 @@ """Tests for the BlueBubbles iMessage gateway adapter.""" +import asyncio +import json + import pytest from gateway.config import Platform, PlatformConfig @@ -25,6 +28,8 @@ class TestBlueBubblesConfigLoading: monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret") monkeypatch.setenv("BLUEBUBBLES_WEBHOOK_PORT", "9999") + monkeypatch.setenv("BLUEBUBBLES_REQUIRE_MENTION", "true") + monkeypatch.setenv("BLUEBUBBLES_MENTION_PATTERNS", r'["(?i)^amos\\b"]') from gateway.config import GatewayConfig, _apply_env_overrides config = GatewayConfig() @@ -35,6 +40,8 @@ class TestBlueBubblesConfigLoading: assert bc.extra["server_url"] == "http://localhost:1234" assert bc.extra["password"] == "secret" assert bc.extra["webhook_port"] == 9999 + assert bc.extra["require_mention"] is True + assert bc.extra["mention_patterns"] == ["(?i)^amos\\b"] def test_home_channel_set_from_env(self, monkeypatch): monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") @@ -130,6 +137,131 @@ class TestBlueBubblesHelpers: adapter = _make_adapter(monkeypatch, server_url="localhost:1234") assert adapter.server_url == "http://localhost:1234" + def test_default_mention_patterns_match_hermes_variants(self, monkeypatch): + adapter = _make_adapter(monkeypatch, require_mention=True) + + assert adapter.require_mention is True + assert adapter._message_matches_mention_patterns("Hermes, summarize this") + assert adapter._message_matches_mention_patterns("@Hermes agent help") + assert not adapter._message_matches_mention_patterns("casual family chatter") + assert not adapter._message_matches_mention_patterns("antihermes should not match") + + def test_custom_mention_patterns_override_defaults(self, monkeypatch): + adapter = _make_adapter( + monkeypatch, + require_mention=True, + mention_patterns=[r"(?