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.
This commit is contained in:
teknium1
2026-09-18 03:23:45 -07:00
committed by Teknium
parent 7f13d3f43d
commit 8d6ecaacf7
2 changed files with 46 additions and 5 deletions

View File

@@ -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)

View File

@@ -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