from __future__ import annotations import asyncio import os import time from typing import Any from urllib.parse import quote, urlparse import aiohttp from yuxi.channels.adapters.imessage.exceptions import BlueBubblesHTTPError from yuxi.channels.adapters.imessage.media_security import validate_attachment_path from yuxi.channels.infra.circuit_breaker import CircuitBreaker from yuxi.channels.models import DeliveryResult from yuxi.utils.logging_config import logger def is_test_env() -> bool: return os.environ.get("NODE_ENV") == "test" or os.environ.get("PYTEST_VERSION", "") != "" class BlueBubblesClient: def __init__(self, config: dict[str, Any]): self._server_url = config.get("server_url", "http://localhost:1234").rstrip("/") self._password = config.get("password", "") self._http_timeout_s = config.get("http_timeout_s", config.get("httpTimeoutS", 30.0)) self._session: aiohttp.ClientSession | None = None self._max_retries = config.get("max_retries", 3) self._breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0) self._probe_timeout_ms = config.get("probeTimeoutMs", config.get("probe_timeout_ms", 10000)) self._remote_attachment_roots: list[str] = config.get( "remoteAttachmentRoots", config.get("remote_attachment_roots", []) ) self._auth_backoff_s = 1.0 self._last_auth_failure: float | None = None async def __aenter__(self) -> BlueBubblesClient: self._session = aiohttp.ClientSession( headers={"X-BlueBubbles-Password": self._password}, timeout=aiohttp.ClientTimeout(total=self._http_timeout_s), ) return self async def __aexit__(self, *args) -> None: await self.close() async def close(self) -> None: if self._session and not self._session.closed: await self._session.close() self._session = None async def _ensure_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: self._session = aiohttp.ClientSession( headers={"X-BlueBubbles-Password": self._password}, timeout=aiohttp.ClientTimeout(total=self._http_timeout_s), ) return self._session async def probe_server(self) -> dict[str, Any]: session = await self._ensure_session() try: timeout = aiohttp.ClientTimeout(total=self._probe_timeout_ms / 1000.0) async with session.get(f"{self._server_url}/api/v1/server/info", timeout=timeout) as resp: return await resp.json() except Exception as e: logger.error(f"[iMessage] probe_server failed: {e}") raise async def get_handle(self) -> dict[str, Any]: session = await self._ensure_session() try: async with session.get(f"{self._server_url}/api/v1/handle") as resp: return await resp.json() except Exception as e: logger.error(f"[iMessage] get_handle failed: {e}") raise async def send_text_message( self, chat_guid: str, content: str, reply_to: str | None = None, is_html: bool = False, mentions: list[str] | None = None, disable_notification: bool = False, ) -> DeliveryResult: payload: dict[str, Any] = {"chatGuid": chat_guid, "message": content} if is_html: payload["isHTML"] = True if reply_to: payload["replyTo"] = reply_to if mentions: payload["mentions"] = [{"type": "mention", "mention": m} for m in mentions] if disable_notification: payload["disableNotification"] = True return await self._send_with_retry( method="POST", path="/api/v1/message/text", json_payload=payload, ) async def send_typing_indicator(self, chat_guid: str, display: bool = True) -> DeliveryResult: return await self._send_with_retry( method="POST", path=f"/api/v1/chat/{chat_guid}/typing", json_payload={"display": display}, ) async def send_read_receipt(self, chat_guid: str) -> DeliveryResult: return await self._send_with_retry( method="POST", path=f"/api/v1/chat/{chat_guid}/readreceipt", json_payload={}, ) async def download_attachment(self, attachment_path: str) -> bytes: session = await self._ensure_session() if attachment_path.startswith(("http://", "https://")): parsed = urlparse(attachment_path) if parsed.netloc not in urlparse(self._server_url).netloc: raise BlueBubblesHTTPError(403, "Attachment URL origin mismatch") url = attachment_path else: sanitized = attachment_path.lstrip("/") if self._remote_attachment_roots and not validate_attachment_path(sanitized, self._remote_attachment_roots): raise BlueBubblesHTTPError(403, "Attachment path not in allowed roots") url = f"{self._server_url}/{quote(sanitized, safe='/')}" try: async with session.get(url) as resp: if resp.status != 200: raise BlueBubblesHTTPError(resp.status, f"Failed to download attachment: {resp.status}") return await resp.read() except BlueBubblesHTTPError: raise except Exception as e: logger.error(f"[iMessage] download_attachment failed: {e}") raise async def send_attachment( self, chat_guid: str, file_path: str, caption: str = "", reply_to: str | None = None, ) -> DeliveryResult: import mimetypes mime_type, _ = mimetypes.guess_type(file_path) try: loop = asyncio.get_running_loop() def _read_file(): with open(file_path, "rb") as f: return f.read() file_data = await loop.run_in_executor(None, _read_file) form = aiohttp.FormData() form.add_field( "file", file_data, filename=os.path.basename(file_path), content_type=mime_type or "application/octet-stream", ) form.add_field("chatGuid", chat_guid) if caption: form.add_field("caption", caption) if reply_to: form.add_field("replyTo", reply_to) return await self._send_with_retry( method="POST", path="/api/v1/message/attachment", data_payload=form, ) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_reaction( self, chat_guid: str, message_guid: str, tapback: int, ) -> DeliveryResult: payload = {"messageGuid": message_guid, "tapback": tapback} return await self._send_with_retry( method="POST", path=f"/api/v1/message/{chat_guid}/tapback", json_payload=payload, ) async def edit_message(self, message_guid: str, new_content: str) -> DeliveryResult: payload = {"message": new_content} return await self._send_with_retry( method="PUT", path=f"/api/v1/message/{message_guid}", json_payload=payload, ) async def delete_message(self, chat_guid: str, message_guid: str) -> DeliveryResult: payload = {"chatGuid": chat_guid, "messageGuid": message_guid} return await self._send_with_retry( method="POST", path="/api/v1/message/delete", json_payload=payload, ) async def get_chats(self) -> list[dict[str, Any]]: try: session = await self._ensure_session() async with session.get(f"{self._server_url}/api/v1/chat") as resp: data = await resp.json() return data.get("data", []) except Exception: logger.warning("[iMessage] Failed to fetch chats") return [] async def get_chat_messages(self, chat_guid: str, limit: int = 50) -> list[dict[str, Any]]: try: session = await self._ensure_session() params = {"limit": limit} async with session.get(f"{self._server_url}/api/v1/chat/{chat_guid}/message", params=params) as resp: data = await resp.json() return data.get("data", []) except Exception: logger.warning(f"[iMessage] Failed to fetch messages for chat {chat_guid}") return [] async def query_contacts(self, addresses: list[str]) -> list[dict[str, Any]]: session = await self._ensure_session() url = f"{self._server_url}/api/v1/contact/query" async with session.post(url, json={"addresses": addresses}) as resp: data = await resp.json() return data.get("data", []) async def unsend_message(self, message_guid: str) -> DeliveryResult: return await self._send_with_retry( method="POST", path=f"/api/v1/message/{message_guid}/unsend", json_payload={}, ) async def _send_with_retry( self, method: str, path: str, json_payload: dict[str, Any] | None = None, data_payload: aiohttp.FormData | None = None, ) -> DeliveryResult: async def _attempt() -> DeliveryResult: if self._last_auth_failure is not None: elapsed = time.time() - self._last_auth_failure wait_s = min(self._auth_backoff_s, 120.0) if elapsed < wait_s: remaining = wait_s - elapsed logger.debug(f"[iMessage] Auth backoff: waiting {remaining:.1f}s") await asyncio.sleep(remaining) else: self._last_auth_failure = None self._auth_backoff_s = 1.0 session = await self._ensure_session() url = f"{self._server_url}{path}" kwargs = {} if json_payload is not None: kwargs["json"] = json_payload if data_payload is not None: kwargs["data"] = data_payload try: async with session.request(method, url, **kwargs) as resp: data = await resp.json() if resp.status == 200: return DeliveryResult( success=True, message_id=data.get("data", {}).get("guid") or data.get("guid"), ) if resp.status == 401: self._last_auth_failure = time.time() self._auth_backoff_s = min(self._auth_backoff_s * 2, 120.0) raise _RetryableError(f"Authentication failed (401): {data.get('message', '')}") if resp.status == 429: raise _RetryableError(f"Rate limited: {data.get('message', '')}") if resp.status >= 500: raise _RetryableError(f"Server error {resp.status}: {data.get('message', '')}") return DeliveryResult(success=False, error=data.get("message", "Unknown error")) except _RetryableError: raise except Exception as e: raise _RetryableError(str(e)) async def _breaker_attempt() -> DeliveryResult: return await self._breaker.call(lambda: _attempt()) base_delay = 1.0 for attempt in range(self._max_retries): try: return await _breaker_attempt() except _RetryableError as e: if attempt < self._max_retries - 1: delay = base_delay * (2**attempt) logger.warning( f"[iMessage] {method} {path} failed (attempt {attempt + 1}/{self._max_retries}), " f"retrying in {delay:.1f}s: {e}" ) await asyncio.sleep(delay) continue logger.error(f"[iMessage] {method} {path} failed after {self._max_retries} attempts: {e}") return DeliveryResult(success=False, error=str(e)) return DeliveryResult(success=False, error=f"Failed after {self._max_retries} retries") @property def ws_url(self) -> str: url = self._server_url.replace("http://", "ws://").replace("https://", "wss://") return f"{url}/ws" @property def password(self) -> str: return self._password class _RetryableError(Exception): pass