from __future__ import annotations import base64 import logging import os import httpx from yuxi.channel.extensions.imessage.errors import IMessageSendError, classify_http_error from yuxi.channel.extensions.imessage.format import IMessageFormatAdapter from yuxi.channel.protocols import OutboundDeliveryMode, OutboundDeliveryCapabilities, OutboundPresentationCapabilities logger = logging.getLogger(__name__) DEFAULT_CHUNK_LIMIT = 4000 class IMessageOutboundAdapter: delivery_mode = OutboundDeliveryMode.DIRECT chunker_mode = "newline" text_chunk_limit = DEFAULT_CHUNK_LIMIT poll_max_options = None supports_poll_duration_seconds = False supports_anonymous_polls = False extract_markdown_images = False presentation_capabilities = OutboundPresentationCapabilities( supported=False, buttons=False, selects=False, context=False, divider=False, ) delivery_capabilities = OutboundDeliveryCapabilities( pin=False, durable_final_text=True, durable_final_media=False, ) def __init__(self, echo_cache=None): self._format = IMessageFormatAdapter() self._echo_cache = echo_cache self._http: httpx.AsyncClient | None = None async def _get_http(self) -> httpx.AsyncClient: if self._http is None: self._http = httpx.AsyncClient(timeout=httpx.Timeout(30.0)) return self._http def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: return self._format.chunk_text(text, limit) def sanitize_text(self, text: str, payload: object) -> str: return text def should_skip_plain_text_sanitization(self, payload: object) -> bool: return True def normalize_payload(self, payload: object, config: dict, account_id: str | None = None) -> object | None: return payload def resolve_effective_text_chunk_limit( self, config: dict, account_id: str | None = None, fallback_limit: int | None = None ) -> int | None: return self.text_chunk_limit async def send_text( self, target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, _account: dict | None = None, ) -> None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) formatted = self._format.format_for_imessage(content) if self._echo_cache: self._echo_cache.remember(target_id, formatted) payload = { "text": formatted, "chatGuid": target_id, } if reply_to_id: payload["replyToGuid"] = reply_to_id try: resp = await http.post( f"{server_url}/api/v1/message/text", json=payload, headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) except httpx.RequestError as e: raise IMessageSendError(f"Failed to send text: {e}") from e async def send_media( self, target_id: str, media_url: str, media_type: str, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, _account: dict | None = None, ) -> None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: media_resp = await http.get(media_url) media_resp.raise_for_status() media_data = media_resp.content except Exception as e: raise IMessageSendError(f"Failed to download media: {e}") from e media_b64 = base64.b64encode(media_data).decode("utf-8") content_type = media_resp.headers.get("content-type", media_type) payload = { "chatGuid": target_id, "base64Data": media_b64, "mimeType": content_type, } try: resp = await http.post( f"{server_url}/api/v1/message/attachment", json=payload, headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) except httpx.RequestError as e: raise IMessageSendError(f"Failed to send media: {e}") from e async def send_reaction( self, target_id: str, message_id: str, reaction: str, *, account_id: str | None = None, _account: dict | None = None, ) -> None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) payload = { "chatGuid": target_id, "reaction": reaction, } try: resp = await http.post( f"{server_url}/api/v1/message/{message_id}/reaction", json=payload, headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) except httpx.RequestError as e: raise IMessageSendError(f"Failed to send reaction: {e}") from e async def edit_message( self, target_id: str, message_id: str, content: str, *, thread_id: str | None = None, account_id: str | None = None, _account: dict | None = None, ) -> str | None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) formatted = self._format.format_for_imessage(content) payload = {"text": formatted} try: resp = await http.patch( f"{server_url}/api/v1/message/{message_id}", json=payload, headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) return message_id except httpx.RequestError as e: raise IMessageSendError(f"Failed to edit message: {e}") from e async def unsend_message( self, target_id: str, message_id: str, *, account_id: str | None = None, _account: dict | None = None, ) -> bool: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.delete( f"{server_url}/api/v1/message/{message_id}", headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) return True except httpx.RequestError as e: raise IMessageSendError(f"Failed to unsend message: {e}") from e async def fetch_chat_history( self, chat_guid: str, *, limit: int = 50, before: int | None = None, after: int | None = None, account_id: str | None = None, _account: dict | None = None, ) -> list[dict]: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) params: dict = {"limit": limit} if before: params["before"] = before if after: params["after"] = after try: resp = await http.get( f"{server_url}/api/v1/chat/{chat_guid}/message", params=params, headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) data = resp.json() return data.get("data", []) except httpx.RequestError as e: raise IMessageSendError(f"Failed to fetch chat history: {e}") from e async def send_typing_indicator( self, chat_guid: str, *, account_id: str | None = None, _account: dict | None = None, ) -> None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: await http.post( f"{server_url}/api/v1/chat/{chat_guid}/typing", headers={"Password": password}, ) except httpx.RequestError as e: logger.warning("Failed to send typing indicator: %s", e) async def send_read_receipt( self, chat_guid: str, *, account_id: str | None = None, _account: dict | None = None, ) -> None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: await http.post( f"{server_url}/api/v1/chat/{chat_guid}/readreceipt", headers={"Password": password}, ) except httpx.RequestError as e: logger.warning("Failed to send read receipt: %s", e) async def rename_group( self, chat_guid: str, name: str, *, account_id: str | None = None, _account: dict | None = None, ) -> bool: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.patch( f"{server_url}/api/v1/chat/{chat_guid}/rename", json={"name": name}, headers={"Password": password}, ) return resp.status_code < 400 except httpx.RequestError as e: raise IMessageSendError(f"Failed to rename group: {e}") from e async def add_participant( self, chat_guid: str, handle: str, *, account_id: str | None = None, _account: dict | None = None, ) -> bool: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.post( f"{server_url}/api/v1/chat/{chat_guid}/addparticipant", json={"address": handle}, headers={"Password": password}, ) return resp.status_code < 400 except httpx.RequestError as e: raise IMessageSendError(f"Failed to add participant: {e}") from e async def remove_participant( self, chat_guid: str, handle: str, *, account_id: str | None = None, _account: dict | None = None, ) -> bool: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.post( f"{server_url}/api/v1/chat/{chat_guid}/removeparticipant", json={"address": handle}, headers={"Password": password}, ) return resp.status_code < 400 except httpx.RequestError as e: raise IMessageSendError(f"Failed to remove participant: {e}") from e async def leave_group( self, chat_guid: str, *, account_id: str | None = None, _account: dict | None = None, ) -> bool: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.post( f"{server_url}/api/v1/chat/{chat_guid}/leave", headers={"Password": password}, ) return resp.status_code < 400 except httpx.RequestError as e: raise IMessageSendError(f"Failed to leave group: {e}") from e async def list_chats( self, *, account_id: str | None = None, _account: dict | None = None, ) -> list[dict]: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.get( f"{server_url}/api/v1/chat", headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) data = resp.json() return data.get("data", []) except httpx.RequestError as e: raise IMessageSendError(f"Failed to list chats: {e}") from e async def get_chat( self, chat_guid: str, *, account_id: str | None = None, _account: dict | None = None, ) -> dict | None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.get( f"{server_url}/api/v1/chat/{chat_guid}", headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) return resp.json() except httpx.RequestError as e: raise IMessageSendError(f"Failed to get chat: {e}") from e async def send_sms( self, target_id: str, content: str, *, account_id: str | None = None, _account: dict | None = None, ) -> None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) formatted = self._format.format_for_imessage(content) payload = {"text": formatted, "chatGuid": target_id, "method": "sms"} try: resp = await http.post( f"{server_url}/api/v1/message/text", json=payload, headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) except httpx.RequestError as e: raise IMessageSendError(f"Failed to send SMS: {e}") from e async def send_mention( self, chat_guid: str, message_id: str, mentions: list[str], *, account_id: str | None = None, _account: dict | None = None, ) -> None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.post( f"{server_url}/api/v1/message/{message_id}/sendmention", json={"mentions": mentions}, headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) except httpx.RequestError as e: raise IMessageSendError(f"Failed to send mention: {e}") from e async def send_with_effect( self, chat_guid: str, message_id: str, effect: str, *, account_id: str | None = None, _account: dict | None = None, ) -> None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) valid_effects = {"slam", "loud", "gentle", "invisibleink"} if effect not in valid_effects: raise ValueError(f"Invalid effect: {effect}. Must be one of {valid_effects}") try: resp = await http.post( f"{server_url}/api/v1/message/{message_id}/sendwitheffect", json={"effect": effect}, headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) except httpx.RequestError as e: raise IMessageSendError(f"Failed to send effect: {e}") from e async def send_with_subject( self, chat_guid: str, message_id: str, subject: str, *, account_id: str | None = None, _account: dict | None = None, ) -> None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.post( f"{server_url}/api/v1/message/{message_id}/sendwithsubject", json={"subject": subject}, headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) except httpx.RequestError as e: raise IMessageSendError(f"Failed to send subject: {e}") from e async def list_handles( self, *, account_id: str | None = None, _account: dict | None = None, ) -> list[dict]: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) resp = await http.get( f"{server_url}/api/v1/handle", headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) return resp.json().get("data", []) async def get_handle( self, address: str, *, account_id: str | None = None, _account: dict | None = None, ) -> dict | None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) resp = await http.get( f"{server_url}/api/v1/handle/{address}", headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) return resp.json() async def check_focus_status( self, *, account_id: str | None = None, _account: dict | None = None, ) -> dict: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) resp = await http.get( f"{server_url}/api/v1/focus", headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) return resp.json() async def check_facetime_status( self, *, account_id: str | None = None, _account: dict | None = None, ) -> dict: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) resp = await http.get( f"{server_url}/api/v1/facetime", headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) return resp.json() async def create_chat( self, handles: list[str], *, account_id: str | None = None, _account: dict | None = None, ) -> dict | None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.post( f"{server_url}/api/v1/chat/new", json={"addresses": handles}, headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) return resp.json() except httpx.RequestError as e: raise IMessageSendError(f"Failed to create chat: {e}") from e async def delete_chat( self, chat_guid: str, *, account_id: str | None = None, _account: dict | None = None, ) -> bool: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.delete( f"{server_url}/api/v1/chat/{chat_guid}", headers={"Password": password}, ) return resp.status_code < 400 except httpx.RequestError as e: raise IMessageSendError(f"Failed to delete chat: {e}") from e async def mark_chat_read( self, chat_guid: str, *, account_id: str | None = None, _account: dict | None = None, ) -> bool: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.post( f"{server_url}/api/v1/chat/{chat_guid}/markread", headers={"Password": password}, ) return resp.status_code < 400 except httpx.RequestError as e: logger.warning("Failed to mark chat read: %s", e) return False async def mark_chat_unread( self, chat_guid: str, *, account_id: str | None = None, _account: dict | None = None, ) -> bool: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.post( f"{server_url}/api/v1/chat/{chat_guid}/markunread", headers={"Password": password}, ) return resp.status_code < 400 except httpx.RequestError as e: logger.warning("Failed to mark chat unread: %s", e) return False async def force_notify( self, message_id: str, *, account_id: str | None = None, _account: dict | None = None, ) -> bool: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.post( f"{server_url}/api/v1/message/{message_id}/forcenotify", headers={"Password": password}, ) return resp.status_code < 400 except httpx.RequestError as e: raise IMessageSendError(f"Failed to force notify: {e}") from e async def set_group_icon( self, chat_guid: str, icon_path: str, *, account_id: str | None = None, _account: dict | None = None, ) -> bool: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) try: resp = await http.post( f"{server_url}/api/v1/chat/{chat_guid}/seticon", json={"path": icon_path}, headers={"Password": password}, ) return resp.status_code < 400 except httpx.RequestError as e: raise IMessageSendError(f"Failed to set group icon: {e}") from e async def get_digital_touch( self, message_id: str, *, account_id: str | None = None, _account: dict | None = None, ) -> dict | None: http = await self._get_http() server_url, password = await self._resolve_server(account_id, _account) resp = await http.get( f"{server_url}/api/v1/message/{message_id}/digitaltouch", headers={"Password": password}, ) if resp.status_code >= 400: raise classify_http_error(resp.status_code, resp.text) return resp.json() async def send_card( self, target_id: str, card_content: dict, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> str | None: return None async def edit_card( self, target_id: str, message_id: str, card_content: dict, *, thread_id: str | None = None, account_id: str | None = None, ) -> str | None: return None async def send_payload(self, ctx: object) -> object: return None async def send_poll(self, ctx: object) -> object: return None async def render_presentation(self, payload: object, presentation: object, ctx: object) -> object | None: return None async def pin_delivered_message(self, config: dict, target_ref: object, message_id: str, pin: object) -> None: pass async def before_deliver_payload( self, config: dict, target_ref: object, payload: object, hint: object | None = None ) -> None: pass async def after_deliver_payload(self, config: dict, target_ref: object, payload: object, results: list) -> None: pass def resolve_target( self, to: str | None = None, *, config: dict | None = None, allow_from: list[str] | None = None, account_id: str | None = None, mode: str | None = None, ) -> tuple[bool, str]: if not to: return False, "Target is required" return True, to def should_treat_delivered_text_as_visible(self, kind: str, text: str | None = None) -> bool: return True async def _resolve_server(self, account_id: str | None = None, _account: dict | None = None) -> tuple[str, str]: if _account: server_url = _account.get("server_url", "") password = _account.get("password", "") if server_url and password: return server_url, password server_url = os.getenv("IMESSAGE_SERVER_URL", "http://localhost:1234") password = os.getenv("IMESSAGE_SERVER_PASSWORD", "") return server_url, password