diff --git a/gateway/config.py b/gateway/config.py index 6f30ee706430..cfc7a3811c00 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1675,14 +1675,26 @@ def _apply_env_overrides(config: GatewayConfig) -> None: if Platform.BLUEBUBBLES not in config.platforms: config.platforms[Platform.BLUEBUBBLES] = PlatformConfig() config.platforms[Platform.BLUEBUBBLES].enabled = True - config.platforms[Platform.BLUEBUBBLES].extra.update({ + bluebubbles_extra = { "server_url": bluebubbles_server_url.rstrip("/"), "password": bluebubbles_password, "webhook_host": os.getenv("BLUEBUBBLES_WEBHOOK_HOST", "127.0.0.1"), "webhook_port": int(os.getenv("BLUEBUBBLES_WEBHOOK_PORT", "8645")), "webhook_path": os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"), - "send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in {"true", "1", "yes"}, - }) + "send_read_receipts": ( + os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() + in {"true", "1", "yes"} + ), + } + bluebubbles_allowed_chats = os.getenv("BLUEBUBBLES_ALLOWED_CHATS") + if bluebubbles_allowed_chats: + bluebubbles_extra["allowed_chats"] = bluebubbles_allowed_chats + bluebubbles_ignore_group_chats = os.getenv("BLUEBUBBLES_IGNORE_GROUP_CHATS") + if bluebubbles_ignore_group_chats: + bluebubbles_extra["ignore_group_chats"] = ( + bluebubbles_ignore_group_chats.lower() in {"true", "1", "yes"} + ) + config.platforms[Platform.BLUEBUBBLES].extra.update(bluebubbles_extra) 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 ec852e3d6107..55b5642543ce 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -90,6 +90,24 @@ def _normalize_server_url(raw: str) -> str: return value.rstrip("/") +def _parse_csv_values(raw: Any) -> set[str]: + if raw is None: + return set() + if isinstance(raw, str): + values = raw.split(",") + elif isinstance(raw, (list, tuple, set)): + values = raw + else: + values = [raw] + return {str(value).strip() for value in values if str(value).strip()} + + +def _truthy(raw: Any) -> bool: + if isinstance(raw, bool): + return raw + return str(raw or "").strip().lower() in {"1", "true", "yes", "on"} + + @@ -124,6 +142,14 @@ def __init__(self, config: PlatformConfig): if not str(self.webhook_path).startswith("/"): self.webhook_path = f"/{self.webhook_path}" self.send_read_receipts = bool(extra.get("send_read_receipts", True)) + allowed_chats = extra.get("allowed_chats") + if allowed_chats is None: + allowed_chats = os.getenv("BLUEBUBBLES_ALLOWED_CHATS", "") + self._allowed_chats = _parse_csv_values(allowed_chats) + ignore_group_chats = extra.get("ignore_group_chats") + if ignore_group_chats is None: + ignore_group_chats = os.getenv("BLUEBUBBLES_IGNORE_GROUP_CHATS", "") + self._ignore_group_chats = _truthy(ignore_group_chats) self.client: Optional[httpx.AsyncClient] = None self._runner = None self._private_api_enabled: Optional[bool] = None @@ -778,6 +804,16 @@ def _value(*candidates: Any) -> Optional[str]: return candidate.strip() return None + def _chat_is_allowed( + self, + chat_guid: Optional[str], + chat_identifier: Optional[str], + ) -> bool: + if not self._allowed_chats or "*" in self._allowed_chats: + return True + candidates = {value for value in (chat_guid, chat_identifier) if value} + return bool(candidates & self._allowed_chats) + async def _handle_webhook(self, request): from aiohttp import web @@ -913,6 +949,18 @@ async def _handle_webhook(self, request): session_chat_id = chat_guid or chat_identifier is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or "")) + if is_group and self._ignore_group_chats: + logger.debug( + "[bluebubbles] dropping group chat message from %s", + _redact(sender), + ) + return web.Response(text="ok") + if not self._chat_is_allowed(chat_guid, chat_identifier): + logger.debug( + "[bluebubbles] dropping message from non-allowed chat %s", + _redact(session_chat_id or ""), + ) + return web.Response(text="ok") source = self.build_source( chat_id=session_chat_id, chat_name=chat_identifier or sender, diff --git a/gateway/run.py b/gateway/run.py index 9525e087507a..931eb6d1b1bb 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3991,6 +3991,7 @@ async def start(self) -> bool: "WECOM_CALLBACK_ALLOWED_USERS", "WEIXIN_ALLOWED_USERS", "BLUEBUBBLES_ALLOWED_USERS", + "BLUEBUBBLES_ALLOWED_CHATS", "QQ_ALLOWED_USERS", "YUANBAO_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS", @@ -6469,6 +6470,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: if source.chat_type in {"group", "forum", "channel"} and source.chat_id: chat_allowlist_env = { Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS", + Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_CHATS", Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS", }.get(source.platform, "") if chat_allowlist_env: @@ -6509,6 +6511,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: } platform_group_chat_env_map = { Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS", + Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_CHATS", Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS", } platform_allow_all_map = { @@ -6708,6 +6711,7 @@ def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str: "TELEGRAM_GROUP_ALLOWED_USERS", "TELEGRAM_GROUP_ALLOWED_CHATS", ), + Platform.BLUEBUBBLES: ("BLUEBUBBLES_ALLOWED_CHATS",), Platform.QQBOT: ("QQ_GROUP_ALLOWED_USERS",), } if os.getenv(platform_env_map.get(platform, ""), "").strip(): diff --git a/tests/conftest.py b/tests/conftest.py index 81067be6f3e9..9faefcb93604 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -246,6 +246,8 @@ def _looks_like_credential(name: str) -> bool: "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS", + "BLUEBUBBLES_ALLOWED_USERS", + "BLUEBUBBLES_ALLOWED_CHATS", "GATEWAY_ALLOWED_USERS", "GATEWAY_ALLOW_ALL_USERS", "TELEGRAM_ALLOW_ALL_USERS", @@ -255,6 +257,8 @@ def _looks_like_credential(name: str) -> bool: "SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS", "SMS_ALLOW_ALL_USERS", + "BLUEBUBBLES_ALLOW_ALL_USERS", + "BLUEBUBBLES_IGNORE_GROUP_CHATS", # Gateway home channels are set by /sethome in real profiles. Tests that # exercise dashboard notification toggles must opt in explicitly or they # can accidentally subscribe against a developer's real home channel. diff --git a/tests/gateway/test_bluebubbles.py b/tests/gateway/test_bluebubbles.py index dea806fe66b1..6781a7257b07 100644 --- a/tests/gateway/test_bluebubbles.py +++ b/tests/gateway/test_bluebubbles.py @@ -1,7 +1,12 @@ """Tests for the BlueBubbles iMessage gateway adapter.""" +import asyncio +import json +from types import SimpleNamespace + import pytest from gateway.config import Platform, PlatformConfig +from gateway.session import SessionSource def _make_adapter(monkeypatch, **extra): @@ -20,11 +25,23 @@ def _make_adapter(monkeypatch, **extra): return BlueBubblesAdapter(cfg) +class _WebhookRequest: + def __init__(self, payload): + self.query = {"password": "secret"} + self.headers = {} + self._payload = payload + + async def read(self): + return json.dumps(self._payload).encode("utf-8") + + class TestBlueBubblesConfigLoading: def test_apply_env_overrides_bluebubbles(self, monkeypatch): monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret") monkeypatch.setenv("BLUEBUBBLES_WEBHOOK_PORT", "9999") + monkeypatch.setenv("BLUEBUBBLES_ALLOWED_CHATS", "any;+;chat-uuid-abc123") + monkeypatch.setenv("BLUEBUBBLES_IGNORE_GROUP_CHATS", "true") from gateway.config import GatewayConfig, _apply_env_overrides config = GatewayConfig() @@ -35,6 +52,8 @@ def test_apply_env_overrides_bluebubbles(self, monkeypatch): assert bc.extra["server_url"] == "http://localhost:1234" assert bc.extra["password"] == "secret" assert bc.extra["webhook_port"] == 9999 + assert bc.extra["allowed_chats"] == "any;+;chat-uuid-abc123" + assert bc.extra["ignore_group_chats"] is True def test_home_channel_set_from_env(self, monkeypatch): monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") @@ -273,6 +292,111 @@ def test_extract_payload_record_fallback_to_message(self, monkeypatch): record = adapter._extract_payload_record(payload) assert record["text"] == "hello" + @pytest.mark.asyncio + async def test_webhook_ignores_group_chat_when_configured(self, monkeypatch): + adapter = _make_adapter(monkeypatch, ignore_group_chats=True) + handled = [] + + async def fake_handle_message(event): + handled.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + payload = { + "type": "new-message", + "data": { + "guid": "MESSAGE-GUID", + "text": "hello everyone", + "handle": {"address": "+15551234567"}, + "isFromMe": False, + "isGroup": True, + "chats": [{"guid": "any;+;chat-uuid-abc123"}], + }, + } + + response = await adapter._handle_webhook(_WebhookRequest(payload)) + await asyncio.sleep(0) + + assert response.status == 200 + assert handled == [] + + @pytest.mark.asyncio + async def test_webhook_allows_configured_group_chat(self, monkeypatch): + adapter = _make_adapter( + monkeypatch, + allowed_chats=["any;+;chat-uuid-abc123"], + ) + handled = [] + + async def fake_handle_message(event): + handled.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + payload = { + "type": "new-message", + "data": { + "guid": "MESSAGE-GUID", + "text": "hello everyone", + "handle": {"address": "+15551234567"}, + "isFromMe": False, + "isGroup": True, + "chats": [{"guid": "any;+;chat-uuid-abc123"}], + }, + } + + response = await adapter._handle_webhook(_WebhookRequest(payload)) + await asyncio.sleep(0) + + assert response.status == 200 + assert len(handled) == 1 + assert handled[0].source.chat_id == "any;+;chat-uuid-abc123" + assert handled[0].source.chat_type == "group" + + @pytest.mark.asyncio + async def test_webhook_drops_unlisted_chat_when_allowlist_set(self, monkeypatch): + adapter = _make_adapter( + monkeypatch, + allowed_chats=["any;+;allowed-chat"], + ) + handled = [] + + async def fake_handle_message(event): + handled.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + payload = { + "type": "new-message", + "data": { + "guid": "MESSAGE-GUID", + "text": "hello everyone", + "handle": {"address": "+15551234567"}, + "isFromMe": False, + "isGroup": True, + "chats": [{"guid": "any;+;other-chat"}], + }, + } + + response = await adapter._handle_webhook(_WebhookRequest(payload)) + await asyncio.sleep(0) + + assert response.status == 200 + assert handled == [] + + def test_allowed_chats_authorizes_group_chat(self, monkeypatch): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: False) + monkeypatch.setenv("BLUEBUBBLES_ALLOWED_CHATS", "any;+;chat-uuid-abc123") + source = SessionSource( + platform=Platform.BLUEBUBBLES, + chat_id="any;+;chat-uuid-abc123", + chat_type="group", + user_id=None, + user_name=None, + ) + + assert runner._is_user_authorized(source) is True + class TestBlueBubblesGuidResolution: def test_raw_guid_returned_as_is(self, monkeypatch): diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 93b617b06662..f460320876fb 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -366,6 +366,8 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `BLUEBUBBLES_WEBHOOK_PORT` | Webhook listener port (default: `8645`) | | `BLUEBUBBLES_HOME_CHANNEL` | Phone/email for cron/notification delivery | | `BLUEBUBBLES_ALLOWED_USERS` | Comma-separated authorized users | +| `BLUEBUBBLES_ALLOWED_CHATS` | Comma-separated BlueBubbles chat GUIDs the bot may answer in | +| `BLUEBUBBLES_IGNORE_GROUP_CHATS` | Drop inbound group chat messages (`true`/`false`) | | `BLUEBUBBLES_ALLOW_ALL_USERS` | Allow all users (`true`/`false`) | | `QQ_APP_ID` | QQ Bot App ID from [q.qq.com](https://q.qq.com) | | `QQ_CLIENT_SECRET` | QQ Bot App Secret from [q.qq.com](https://q.qq.com) | diff --git a/website/docs/user-guide/messaging/bluebubbles.md b/website/docs/user-guide/messaging/bluebubbles.md index 40af59a57bd5..5c4193a16b09 100644 --- a/website/docs/user-guide/messaging/bluebubbles.md +++ b/website/docs/user-guide/messaging/bluebubbles.md @@ -89,6 +89,8 @@ Hermes → BlueBubbles REST API → Messages.app → iMessage | `BLUEBUBBLES_WEBHOOK_PATH` | No | `/bluebubbles-webhook` | Webhook URL path | | `BLUEBUBBLES_HOME_CHANNEL` | No | — | Phone/email for cron delivery | | `BLUEBUBBLES_ALLOWED_USERS` | No | — | Comma-separated authorized users | +| `BLUEBUBBLES_ALLOWED_CHATS` | No | — | Comma-separated BlueBubbles chat GUIDs the bot may answer in | +| `BLUEBUBBLES_IGNORE_GROUP_CHATS` | No | `false` | Drop inbound group chat messages | | `BLUEBUBBLES_ALLOW_ALL_USERS` | No | `false` | Allow all users | Auto-marking messages as read is controlled by the `send_read_receipts` key under `platforms.bluebubbles.extra` in `~/.hermes/config.yaml` (default: `true`). There is no corresponding environment variable.