From 8d6ecaacf7cad9029db78bc8160b4e74e4d80cbf Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:23:45 -0700 Subject: [PATCH] fix(bluebubbles): apply the require_mention gate before downloading attachments `_handle_webhook` ran `_collect_attachments` (one REST download per attachment) before the `is_group and self.require_mention` check, so every attachment on an unmentioned group message was fetched onto the host and then dropped. The gate only needs the text, which is available pre-download, so it now runs first; the "(attachment)" placeholder and the missing-fields check follow the download as before. Same class as the Teams fix in this PR (review follow-up). One invariant test: unmentioned group record with an attachment -> 0 `_download_attachment` calls, mentioned -> 1 and dispatched. --- gateway/platforms/bluebubbles.py | 12 ++++++---- tests/gateway/test_bluebubbles.py | 39 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index 4a9b4183be..05b9ceecee 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -579,19 +579,21 @@ class BlueBubblesAdapter(BasePlatformAdapter): if isinstance(assoc_type, int) and assoc_type in _TAPBACK_CODES: # tapback reactions delivered as messages return _ok() text = self._value(record.get("text"), record.get("message"), record.get("body")) or "" - media_urls, media_types, msg_type = await self._collect_attachments(record) - if not text and media_urls: - text = "(attachment)" chat_guid, chat_identifier, sender = self._resolve_chat_and_sender(payload, record) - if not sender or not (chat_guid or chat_identifier) or not text: - return web.json_response({"error": "missing message fields"}, status=400) session_chat_id = chat_guid or chat_identifier is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or "")) + # Mention gate BEFORE the attachment downloads: an unmentioned group message must not + # pull every attachment through the REST API only to be dropped. 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 _ok() text = self._clean_mention_text(text) + media_urls, media_types, msg_type = await self._collect_attachments(record) + if not text and media_urls: + text = "(attachment)" + if not sender or not (chat_guid or chat_identifier) or not text: + return web.json_response({"error": "missing message fields"}, status=400) source = self.build_source(chat_id=session_chat_id, chat_name=chat_identifier or sender, chat_type="group" if is_group else "dm", user_id=sender, user_name=sender, chat_id_alt=chat_identifier) diff --git a/tests/gateway/test_bluebubbles.py b/tests/gateway/test_bluebubbles.py index 7d95190cd0..8f9e0b34d0 100644 --- a/tests/gateway/test_bluebubbles.py +++ b/tests/gateway/test_bluebubbles.py @@ -1,6 +1,7 @@ """Tests for the BlueBubbles iMessage gateway adapter.""" import asyncio import json +from unittest.mock import AsyncMock import httpx import pytest @@ -567,3 +568,41 @@ class TestBlueBubblesTimeoutErrorNormalization: assert "500 Internal Server Error" in (result.error or "") + + +class TestBlueBubblesGateBeforeDownload: + """The require_mention gate must run BEFORE attachments are downloaded (review follow-up).""" + + @pytest.mark.asyncio + @pytest.mark.parametrize("text, downloads, handled_count", [ + ("look at this", 0, 0), # unmentioned group attachment: never fetched + ("hermes look at this", 1, 1), # mentioned: fetched and dispatched + ]) + async def test_unmentioned_group_attachment_is_not_downloaded( + self, monkeypatch, text, downloads, handled_count): + adapter = _make_adapter(monkeypatch, require_mention=True, send_read_receipts=False) + handled = [] + + async def fake_handle_message(event): + handled.append(event) + + download = AsyncMock(return_value="/tmp/cached.jpg") + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + monkeypatch.setattr(adapter, "_download_attachment", download) + response = await adapter._handle_webhook(_FakeBlueBubblesRequest({ + "type": "new-message", + "data": { + "guid": "msg-att-1", + "text": text, + "handle": {"address": "+15555550100"}, + "isFromMe": False, + "isGroup": True, + "chats": [{"guid": "iMessage;+;group-chat"}], + "attachments": [{"guid": "att-1", "mimeType": "image/jpeg"}], + }, + })) + await asyncio.sleep(0) + + assert response.status == 200 + assert download.await_count == downloads + assert len(handled) == handled_count