From f5a9ba9ee641e8317403ff79236fe955c1f1a881 Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Sun, 23 Aug 2026 17:03:34 -0300 Subject: [PATCH] perf(bluebubbles): move attachment reads off the event loop --- gateway/platforms/bluebubbles.py | 40 ++++++++++++++++-------------- tests/gateway/test_bluebubbles.py | 41 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 18 deletions(-) diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index ecc6baf45e..dd226ad752 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -16,6 +16,7 @@ import re import uuid from collections import OrderedDict from datetime import datetime +from pathlib import Path from typing import Any, Dict, List, Optional from urllib.parse import quote @@ -599,7 +600,7 @@ class BlueBubblesAdapter(BasePlatformAdapter): """Send a file attachment via BlueBubbles multipart upload.""" if not self.client: return SendResult(success=False, error="Not connected") - if not os.path.isfile(file_path): + if not await asyncio.to_thread(os.path.isfile, file_path): return SendResult(success=False, error=f"File not found: {file_path}") guid = await self._resolve_chat_guid(chat_id) @@ -608,23 +609,26 @@ class BlueBubblesAdapter(BasePlatformAdapter): fname = filename or os.path.basename(file_path) try: - with open(file_path, "rb") as f: - files = {"attachment": (fname, f, "application/octet-stream")} - data: Dict[str, str] = { - "chatGuid": guid, - "name": fname, - "tempGuid": uuid.uuid4().hex, - } - if is_audio_message: - data["isAudioMessage"] = "true" - res = await self.client.post( - self._api_url("/api/v1/message/attachment"), - files=files, - data=data, - timeout=120, - ) - res.raise_for_status() - result = res.json() + # httpx's async multipart iterator reads file-like objects through + # a synchronous chunk generator. Read the file off the event-loop + # thread before handing bytes to the client. + payload = await asyncio.to_thread(Path(file_path).read_bytes) + files = {"attachment": (fname, payload, "application/octet-stream")} + data: Dict[str, str] = { + "chatGuid": guid, + "name": fname, + "tempGuid": uuid.uuid4().hex, + } + if is_audio_message: + data["isAudioMessage"] = "true" + res = await self.client.post( + self._api_url("/api/v1/message/attachment"), + files=files, + data=data, + timeout=120, + ) + res.raise_for_status() + result = res.json() if caption: await self.send(chat_id, caption) diff --git a/tests/gateway/test_bluebubbles.py b/tests/gateway/test_bluebubbles.py index f4d606dca7..994a2e0c80 100644 --- a/tests/gateway/test_bluebubbles.py +++ b/tests/gateway/test_bluebubbles.py @@ -274,6 +274,47 @@ class TestBlueBubblesAttachmentDownload: assert result == "/tmp/test_image.png" +class TestBlueBubblesAttachmentSend: + @pytest.mark.asyncio + async def test_attachment_payload_is_read_before_async_upload(self, monkeypatch, tmp_path): + adapter = _make_adapter(monkeypatch) + file_path = tmp_path / "payload.bin" + payload = b"attachment-payload" + file_path.write_bytes(payload) + + captured = {} + + async def fake_resolve_chat_guid(chat_id): + return "iMessage;+;chat-guid" + + class MockResponse: + def raise_for_status(self): + pass + + def json(self): + return {"status": 200, "data": {"guid": "message-guid"}} + + class MockClient: + async def post(self, url, *, files, data, timeout): + captured.update(url=url, files=files, data=data, timeout=timeout) + return MockResponse() + + monkeypatch.setattr(adapter, "_resolve_chat_guid", fake_resolve_chat_guid) + adapter.client = MockClient() + + result = await adapter._send_attachment( + "target", str(file_path), filename="payload.bin" + ) + + assert result.success is True + assert captured["files"]["attachment"] == ( + "payload.bin", + payload, + "application/octet-stream", + ) + assert captured["data"]["chatGuid"] == "iMessage;+;chat-guid" + + # --------------------------------------------------------------------------- # Webhook registration # ---------------------------------------------------------------------------