from __future__ import annotations import logging from datetime import datetime from yuxi.channel.extensions.imessage.types import BlueBubblesMessageEvent from yuxi.channel.message.models import ( GroupContext, MessageType, PeerInfo, UnifiedMessage, ) from yuxi.channel.routing.models import PeerKind logger = logging.getLogger(__name__) _TAPBACK_ADDED = { 2000: "love", 2001: "like", 2002: "dislike", 2003: "laugh", 2004: "emphasize", 2005: "question", } _TAPBACK_REMOVED = { 3000: "love", 3001: "like", 3002: "dislike", 3003: "laugh", 3004: "emphasize", 3005: "question", } _TAPBACK_CODES = _TAPBACK_ADDED | _TAPBACK_REMOVED class IMessageMonitor: def __init__(self, echo_cache=None): self._echo_cache = echo_cache def parse_event(self, raw: dict, account_id: str) -> UnifiedMessage | None: event = BlueBubblesMessageEvent.from_json(raw) if event.is_from_me: return None group_action = event.group_action_type if group_action and group_action in _TAPBACK_CODES: return self._parse_tapback(raw, event, account_id) if not event.text and not event.attachments: return None if self._echo_cache and self._echo_cache.is_echo(event.chat_guid or "", event.text or ""): return None msg_type = self._resolve_message_type(event) sender = PeerInfo( kind=PeerKind.DIRECT, id=event.sender_handle or "unknown", display_name=event.sender_name, is_bot=False, is_self=event.is_from_me, ) group: GroupContext | None = None is_group = event.chat_identifier and ";" in (event.chat_identifier or "") if is_group and event.chat_guid: group = GroupContext( id=event.chat_guid, name=event.chat_display_name, ) media_urls: list[str] = [] media_types: list[str] = [] for att in event.attachments: att_guid = att.get("guid") or att.get("attachmentGuid") mime = att.get("mimeType", "application/octet-stream") if att_guid: media_urls.append(att_guid) media_types.append(mime) else: path = att.get("path") or att.get("filePath") if path: media_urls.append(path) media_types.append(mime) timestamp = None if event.date_created: timestamp = datetime.fromtimestamp(event.date_created / 1000) return UnifiedMessage( msg_id=f"im:{event.guid}", channel_type="imessage", account_id=account_id, content=event.text or "", sender=sender, message_type=msg_type, media_urls=media_urls, media_types=media_types, group=group, timestamp=timestamp, raw_payload=raw, message_thread_id=event.thread_originator_guid, metadata={ "is_from_me": event.is_from_me, "chat_identifier": event.chat_identifier, }, ) @staticmethod def _resolve_message_type(event: BlueBubblesMessageEvent) -> MessageType: if not event.attachments: return MessageType.TEXT mime = event.attachments[0].get("mimeType", "") if mime.startswith("image/"): return MessageType.IMAGE if mime.startswith("audio/"): return MessageType.VOICE if mime.startswith("video/"): return MessageType.VIDEO return MessageType.FILE @staticmethod def _parse_tapback(raw: dict, event, account_id: str) -> UnifiedMessage | None: reaction_name = _TAPBACK_CODES.get(event.group_action_type) if not reaction_name: return None action = "added" if event.group_action_type < 3000 else "removed" associated_guid = raw.get("associatedMessageGuid") or "" is_group = event.chat_identifier and ";" in (event.chat_identifier or "") group_info = None if is_group and event.chat_guid: group_info = GroupContext( id=event.chat_guid, name=event.chat_display_name, ) return UnifiedMessage( msg_id=f"im:reaction:{event.guid}", channel_type="imessage", account_id=account_id, content=reaction_name, message_type=MessageType.TEXT, sender=PeerInfo( kind=PeerKind.DIRECT if not is_group else PeerKind.GROUP, id=event.sender_handle or "", display_name=event.sender_name or event.sender_handle or "", ), group=group_info, raw_payload={ "reaction": reaction_name, "action": action, "associated_message_guid": associated_guid, "tapback_code": event.group_action_type, }, metadata={"event_type": "tapback"}, ) async def handle_updated_message(self, raw: dict, account_id: str, queue) -> None: event = BlueBubblesMessageEvent.from_json(raw) if not event.guid: return unified = UnifiedMessage( msg_id=f"im:update:{event.guid}", channel_type="imessage", account_id=account_id, content=event.text or "", message_type=MessageType.EVENT, sender=PeerInfo( kind=PeerKind.CHANNEL, id="system", display_name="iMessage", ), raw_payload={ "action": "message_updated", "original_guid": event.guid, "updated_text": event.text, "chat_guid": event.chat_guid, "is_from_me": event.is_from_me, }, metadata={"event_type": "updated-message"}, ) await queue.put(unified) def handle_send_error(self, raw: dict) -> None: error_message = raw.get("error", "Unknown send error") chat_guid = raw.get("chatGuid", "unknown") logger.error( "iMessage send error: chat=%s, error=%s, raw=%s", chat_guid, error_message, raw, ) async def handle_group_event(self, raw: dict, event_type: str, account_id: str, queue) -> None: chat = raw.get("chat") or {} chat_guid = chat.get("guid", "") participant = raw.get("participant", "") action_map = { "participant-removed": "member_removed", "participant-added": "member_added", "participant-left": "member_left", "group-name-changed": "group_renamed", "group-icon-changed": "group_icon_changed", "group-icon-removed": "group_icon_removed", } action = action_map.get(event_type, "group_event") content = "" if event_type == "group-name-changed": content = raw.get("newName", "") elif participant: content = participant unified = UnifiedMessage( msg_id=f"im:group:{chat_guid}", channel_type="imessage", account_id=account_id, content=content, message_type=MessageType.EVENT, sender=PeerInfo(kind=PeerKind.CHANNEL, id="system", display_name="iMessage"), group=GroupContext(id=chat_guid, name=chat.get("displayName")), raw_payload={"action": action, "chat_guid": chat_guid, "participant": participant}, metadata={"event_type": event_type}, ) await queue.put(unified) def handle_typing_indicator(self, raw: dict, account_id: str, queue) -> None: sender_handle = raw.get("sender", "") chat_guid = raw.get("chatGuid", "") display_name = raw.get("displayName", sender_handle) is_typing = raw.get("typing", False) unified = UnifiedMessage( msg_id=f"im:typing:{chat_guid}:{sender_handle}", channel_type="imessage", account_id=account_id, content="", message_type=MessageType.EVENT, sender=PeerInfo(kind=PeerKind.DIRECT, id=sender_handle, display_name=display_name), raw_payload={"action": "typing", "chat_guid": chat_guid, "typing": is_typing}, metadata={"event_type": "typing-indicator", "is_typing": is_typing}, ) queue.put_nowait(unified) def handle_chat_read_status_changed(self, raw: dict, account_id: str, queue) -> None: chat_guid = raw.get("chatGuid", "") unified = UnifiedMessage( msg_id=f"im:read:{chat_guid}", channel_type="imessage", account_id=account_id, content="", message_type=MessageType.EVENT, sender=PeerInfo(kind=PeerKind.CHANNEL, id="system", display_name="iMessage"), raw_payload={"action": "read_status_changed", "chat_guid": chat_guid}, metadata={"event_type": "chat-read-status-changed"}, ) queue.put_nowait(unified) def handle_alias_removed(self, raw: dict, account_id: str, queue) -> None: alias = raw.get("alias", "") unified = UnifiedMessage( msg_id=f"im:alias-removed:{alias}", channel_type="imessage", account_id=account_id, content="", message_type=MessageType.EVENT, sender=PeerInfo(kind=PeerKind.CHANNEL, id="system", display_name="iMessage"), raw_payload={"action": "alias_removed", "alias": alias}, metadata={"event_type": "imessage-alias-removed"}, ) queue.put_nowait(unified)