from __future__ import annotations import asyncio import mimetypes from pathlib import Path from typing import Any from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesHTTPError from yuxi.channels.models import DeliveryResult from yuxi.utils.logging_config import logger def _resolve_safe_path(file_path: str, allowed_roots: list[str] | None = None) -> Path: path = Path(file_path).resolve() if not path.is_file(): raise FileNotFoundError(f"File not found: {file_path}") if path.is_symlink(): raise PermissionError(f"Symlinks not allowed: {file_path}") if allowed_roots: root_paths = [Path(r).resolve() for r in allowed_roots] if not any(path.is_relative_to(root) for root in root_paths): raise PermissionError(f"File outside allowed roots: {file_path}") return path def _detect_mime(filename: str) -> str: mime_type, _ = mimetypes.guess_type(filename) return mime_type or "application/octet-stream" def _sanitize_filename(filename: str) -> str: name = filename.replace("\r", "").replace("\n", "").strip() return Path(name).name or "attachment" async def send_text( client: BlueBubblesClient, chat_guid: str, text: str, reply_to_guid: str | None = None, subject: str | None = None, ) -> dict[str, Any]: payload: dict[str, Any] = { "chatGuid": chat_guid, "text": text, "isHTML": False, } if reply_to_guid: payload["replyToGuid"] = reply_to_guid if subject: payload["subject"] = subject return await client.post("/api/v1/message/text", json=payload) async def send_html_message( client: BlueBubblesClient, chat_guid: str, html: str, reply_to_guid: str | None = None, max_retries: int = 3, ) -> DeliveryResult: payload: dict[str, Any] = { "chatGuid": chat_guid, "text": html, "isHTML": True, } if reply_to_guid: payload["replyToGuid"] = reply_to_guid try: result = await client.post("/api/v1/message/text", json=payload) return DeliveryResult( success=True, message_id=result.get("guid") or result.get("messageGuid"), ) except BlueBubblesHTTPError as e: if e.status_code == 429 or e.status_code >= 500: return await send_with_retry( client, "/api/v1/message/text", payload, max_retries, ) return DeliveryResult(success=False, error=str(e)) except Exception as e: logger.error(f"[BlueBubbles] HTML send failed: {e}") return DeliveryResult(success=False, error=str(e)) async def send_attachment( client: BlueBubblesClient, chat_guid: str, file_path: str, caption: str = "", reply_to_guid: str | None = None, allowed_roots: list[str] | None = None, ) -> dict[str, Any]: path = _resolve_safe_path(file_path, allowed_roots) content = await asyncio.to_thread(path.read_bytes) safe_name = _sanitize_filename(path.name) files = {"attachment": (safe_name, content)} data: dict[str, Any] = {"chatGuid": chat_guid} if caption: data["caption"] = caption if reply_to_guid: data["replyToGuid"] = reply_to_guid return await client.post("/api/v1/message/attachment", data=data, files=files) async def send_tapback( client: BlueBubblesClient, chat_guid: str, message_guid: str, tapback: int, ) -> dict[str, Any]: return await client.post( f"/api/v1/message/{chat_guid}/tapback", json={"messageGuid": message_guid, "tapback": tapback}, ) async def send_typing_indicator( client: BlueBubblesClient, chat_guid: str, display: bool = True, ) -> dict[str, Any]: return await client.post( f"/api/v1/chat/{chat_guid}/typing", json={"display": display}, ) async def send_read_receipt( client: BlueBubblesClient, chat_guid: str, ) -> dict[str, Any]: return await client.post( f"/api/v1/chat/{chat_guid}/readreceipt", json={}, ) async def update_activity( client: BlueBubblesClient, chat_guid: str, activity_id: str, text: str, ) -> dict[str, Any]: return await client.put( f"/api/v1/message/{chat_guid}/{activity_id}", json={"text": text, "isHTML": True}, ) async def download_attachment( client: BlueBubblesClient, file_path_or_url: str, ) -> bytes: return await client.download(file_path_or_url) TAPBACK_MAP: dict[str, int] = { "love": 0, "heart": 0, "โค๏ธ": 0, "โค": 0, "๐Ÿงก": 0, "๐Ÿ’›": 0, "๐Ÿ’š": 0, "๐Ÿ’™": 0, "๐Ÿ’œ": 0, "๐Ÿ–ค": 0, "๐Ÿค": 0, "๐ŸคŽ": 0, "๐Ÿ’•": 0, "๐Ÿ’—": 0, "๐Ÿ’–": 0, "๐Ÿ’": 0, "๐Ÿ’ž": 0, "๐Ÿ’“": 0, "heart_eyes": 0, "like": 1, "thumbs_up": 1, "thumbsup": 1, "๐Ÿ‘": 1, "๐Ÿ‘๐Ÿป": 1, "๐Ÿ‘๐Ÿผ": 1, "๐Ÿ‘๐Ÿฝ": 1, "๐Ÿ‘๐Ÿพ": 1, "๐Ÿ‘๐Ÿฟ": 1, "+1": 1, "yes": 1, "dislike": 2, "thumbs_down": 2, "thumbsdown": 2, "๐Ÿ‘Ž": 2, "๐Ÿ‘Ž๐Ÿป": 2, "๐Ÿ‘Ž๐Ÿผ": 2, "๐Ÿ‘Ž๐Ÿฝ": 2, "๐Ÿ‘Ž๐Ÿพ": 2, "๐Ÿ‘Ž๐Ÿฟ": 2, "-1": 2, "no": 2, "laugh": 3, "haha": 3, "๐Ÿ˜‚": 3, "๐Ÿ˜†": 3, "๐Ÿคฃ": 3, "๐Ÿ˜น": 3, "lol": 3, "rofl": 3, "exclaim": 4, "โ—": 4, "โ€ผ๏ธ": 4, "โ€ผ": 4, "โ•": 4, "!": 4, "!!": 4, "emphasize": 4, "question": 5, "โ“": 5, "โ‰๏ธ": 5, "โ‰": 5, "โ”": 5, "?": 5, "??": 5, "hmm": 5, } _LENIENT_FALLBACK = 0 def resolve_tapback_value(emoji_or_name: str) -> int | None: return TAPBACK_MAP.get(emoji_or_name.lower().strip().lstrip(":")) def normalize_tapback_lenient(emoji_or_name: str) -> int: value = resolve_tapback_value(emoji_or_name) if value is not None: return value return _LENIENT_FALLBACK def normalize_tapback_strict(emoji_or_name: str) -> int: value = resolve_tapback_value(emoji_or_name) if value is None: from yuxi.channels.adapters.bluebubbles.exceptions import TapbackError raise TapbackError(f"Unsupported tapback reaction: {emoji_or_name!r}") return value async def unsend_message( client: BlueBubblesClient, chat_guid: str, message_guid: str, ) -> dict[str, Any]: return await client.post( f"/api/v1/message/{chat_guid}/{message_guid}/unsend", json={}, ) async def remove_tapback( client: BlueBubblesClient, chat_guid: str, message_guid: str, ) -> dict[str, Any]: return await client.delete( f"/api/v1/message/{chat_guid}/tapback", json={"messageGuid": message_guid}, ) async def send_attachment_bytes( client: BlueBubblesClient, chat_guid: str, data: bytes, filename: str, caption: str = "", reply_to_guid: str | None = None, ) -> dict[str, Any]: safe_name = _sanitize_filename(filename) files = {"attachment": (safe_name, data)} form_data: dict[str, Any] = {"chatGuid": chat_guid} if caption: form_data["caption"] = caption if reply_to_guid: form_data["replyToGuid"] = reply_to_guid return await client.post("/api/v1/message/attachment", data=form_data, files=files) async def fetch_remote_media(client: BlueBubblesClient, url: str) -> bytes: import httpx async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as http_client: resp = await http_client.get(url) resp.raise_for_status() return resp.content async def send_silent_message( client: BlueBubblesClient, chat_guid: str, text: str, reply_to_guid: str | None = None, ) -> dict[str, Any]: payload: dict[str, Any] = { "chatGuid": chat_guid, "text": text, "isHTML": False, "disableNotification": True, } if reply_to_guid: payload["replyToGuid"] = reply_to_guid return await client.post("/api/v1/message/text", json=payload) async def send_with_retry( client: BlueBubblesClient, endpoint: str, payload: dict[str, Any], max_retries: int = 3, ) -> DeliveryResult: base_delay = 1.0 for attempt in range(max_retries): try: result = await client.post(endpoint, json=payload) return DeliveryResult( success=True, message_id=result.get("guid") or result.get("messageGuid"), ) except BlueBubblesHTTPError as e: if e.status_code == 429: retry_after = float(e.headers.get("Retry-After", base_delay)) await asyncio.sleep(retry_after) continue if e.status_code >= 500: delay = base_delay * (2**attempt) await asyncio.sleep(delay) continue return DeliveryResult(success=False, error=str(e)) except Exception as e: if attempt < max_retries - 1: delay = base_delay * (2**attempt) await asyncio.sleep(delay) continue return DeliveryResult(success=False, error=str(e)) return DeliveryResult(success=False, error=f"Failed after {max_retries} retries") async def send_sticker( client: BlueBubblesClient, chat_guid: str, sticker_data: dict[str, Any] | bytes, reply_to_guid: str | None = None, ) -> DeliveryResult: if isinstance(sticker_data, bytes): files = {"attachment": ("sticker.png", sticker_data, "image/png")} data: dict[str, Any] = {"chatGuid": chat_guid, "isSticker": True} if reply_to_guid: data["replyToGuid"] = reply_to_guid try: result = await client.post("/api/v1/message/attachment", data=data, files=files) return DeliveryResult( success=True, message_id=result.get("guid") or result.get("messageGuid"), ) except Exception as e: return DeliveryResult(success=False, error=str(e)) sticker_url = sticker_data.get("url") or sticker_data.get("filePath") or "" sticker_desc = sticker_data.get("stickerDescription") or sticker_data.get("description") or "" sticker_msg_id = sticker_data.get("messageGuid") or sticker_data.get("guid") or "" if sticker_msg_id: try: result = await client.post( f"/api/v1/message/{chat_guid}/{sticker_msg_id}/send", json={"isSticker": True}, ) return DeliveryResult( success=True, message_id=result.get("guid") or sticker_msg_id, ) except Exception: pass if sticker_url: try: import httpx async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as http_client: resp = await http_client.get(sticker_url) resp.raise_for_status() image_data = resp.content except Exception as e: return DeliveryResult(success=False, error=f"Failed to fetch sticker: {e}") files = {"attachment": ("sticker.png", image_data, "image/png")} data = {"chatGuid": chat_guid, "isSticker": True} if reply_to_guid: data["replyToGuid"] = reply_to_guid if sticker_desc: data["caption"] = sticker_desc try: result = await client.post("/api/v1/message/attachment", data=data, files=files) return DeliveryResult( success=True, message_id=result.get("guid") or result.get("messageGuid"), ) except Exception as e: return DeliveryResult(success=False, error=str(e)) return DeliveryResult(success=False, error="No valid sticker data provided") async def send_text_safe( client: BlueBubblesClient, chat_guid: str, text: str, reply_to_guid: str | None = None, max_retries: int = 3, ) -> DeliveryResult: try: result = await send_text(client, chat_guid, text, reply_to_guid) return DeliveryResult( success=True, message_id=result.get("guid") or result.get("messageGuid"), ) except BlueBubblesHTTPError as e: if e.status_code == 429 or e.status_code >= 500: payload: dict[str, Any] = {"chatGuid": chat_guid, "text": text} if reply_to_guid: payload["replyToGuid"] = reply_to_guid return await send_with_retry( client, "/api/v1/message/text", payload, max_retries, ) return DeliveryResult(success=False, error=str(e)) except Exception as e: logger.error(f"[BlueBubbles] Send failed: {e}") return DeliveryResult(success=False, error=str(e)) async def auto_create_chat( client: BlueBubblesClient, address: str, initial_text: str, ) -> dict[str, Any]: try: new_chat = await client.post( "/api/v1/chat/new", json={"address": address}, ) chat_guid = new_chat.get("guid") or new_chat.get("chatGuid", "") if chat_guid: result = await send_text(client, chat_guid, initial_text) return {"chatGuid": chat_guid, "result": result} raise BlueBubblesHTTPError(0, "Auto-created chat has no GUID") except BlueBubblesHTTPError: raise except Exception: logger.warning( "[BlueBubbles] Auto-create chat failed for %s, Private API may be unavailable", address, ) raise