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
18 changes: 15 additions & 3 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
48 changes: 48 additions & 0 deletions gateway/platforms/bluebubbles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}





Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This gate runs after the attachment loop above, so an ignored group message can still download and cache inbound attachments. Resolve the chat identity and apply the filter before attachment retrieval, with a test asserting the downloader is not called for an ignored group payload.

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,
Expand Down
4 changes: 4 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Current main extracted this authorization path into gateway/authz_mixin.py:329-343 and :388-391. Please port the BlueBubbles group-chat mapping (and the related unauthorized-DM allowlist handling) to that mixin; this old GatewayRunner edit will not implement the claimed authorization behavior on current main.

Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS",
}.get(source.platform, "")
if chat_allowlist_env:
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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():
Expand Down
4 changes: 4 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.
Expand Down
124 changes: 124 additions & 0 deletions tests/gateway/test_bluebubbles.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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()
Expand All @@ -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")
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions website/docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
2 changes: 2 additions & 0 deletions website/docs/user-guide/messaging/bluebubbles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down