perf(bluebubbles): move attachment reads off the event loop
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user