import asyncio import json import logging from datetime import datetime, UTC import websockets from yuxi.channel.extensions.bluebubbles.client import BlueBubblesClient from yuxi.channel.extensions.bluebubbles.dedupe import InboundDedupeStore from yuxi.channel.extensions.bluebubbles.debounce import DebounceManager from yuxi.channel.extensions.bluebubbles.types import BlueBubblesMessage from yuxi.channel.message.models import UnifiedMessage, MessageType, PeerInfo, GroupContext from yuxi.channel.routing.models import PeerKind logger = logging.getLogger(__name__) async def process_inbound_message( raw_event: dict, account_id: str, dedupe_store: InboundDedupeStore | None = None, debounce_manager: DebounceManager | None = None, on_message=None, send_read_receipt=None, ): event_type = raw_event.get("event", "") logger.debug("Received event: %s from account %s", event_type, account_id) message_data = raw_event.get("data", raw_event) if event_type == "new-message": return await _handle_new_message( message_data, account_id, dedupe_store, debounce_manager, on_message, send_read_receipt=send_read_receipt, ) if event_type == "updated-message": return await _handle_updated_message(message_data, account_id, on_message) if event_type in ("group-name-change", "participant-added", "participant-removed"): return await _handle_group_event(event_type, message_data, account_id, on_message) if event_type == "typing-indicator": return await _handle_typing_indicator(message_data, account_id, on_message) if event_type == "chat-read-status-changed": return await _handle_read_status_changed(message_data, account_id, on_message) if event_type == "imessage-connection-update": _handle_connection_update(message_data, account_id) return None async def _handle_new_message( message_data: dict, account_id: str, dedupe_store: InboundDedupeStore | None, debounce_manager: DebounceManager | None, on_message, send_read_receipt=None, ): msg = _parse_bluebubbles_message(message_data) if dedupe_store and dedupe_store.is_duplicate(msg.guid, account_id): logger.debug("Duplicate message skipped: %s", msg.guid) return None if msg.is_from_me: return None unified = _to_unified_message(msg, account_id) if debounce_manager and on_message: await debounce_manager.enqueue(unified, on_message) elif on_message: await on_message(unified) if send_read_receipt and msg.chat_guid: try: await send_read_receipt(msg.chat_guid) except Exception: logger.debug("Auto read receipt failed for chat %s", msg.chat_guid) return unified async def _handle_updated_message(message_data: dict, account_id: str, on_message): msg = _parse_bluebubbles_message(message_data) if msg.is_from_me: return None if not msg.associated_message_type and not msg.subject: return None unified = _to_unified_message(msg, account_id) unified.message_type = MessageType.EVENT tapback_types = { "2000": "love", "2001": "like", "2002": "dislike", "2003": "laugh", "2004": "emphasize", "2005": "question", } if msg.associated_message_type and str(msg.associated_message_type) in tapback_types: reaction = tapback_types[str(msg.associated_message_type)] unified.content = f"👍 对消息 {msg.associated_message_guid} 添加了 {reaction} 反应" unified.metadata["reaction"] = reaction unified.metadata["target_msg_id"] = msg.associated_message_guid unified.metadata["event_kind"] = "reaction" elif msg.subject: unified.metadata["event_kind"] = "message_updated" unified.metadata["subject"] = msg.subject if on_message: await on_message(unified) return unified def _handle_connection_update(data: dict, account_id: str): imessage = data.get("imessage", {}) status = imessage.get("status", "unknown") if isinstance(imessage, dict) else "unknown" logger.info("iMessage connection update for account %s: status=%s", account_id, status) def _parse_bluebubbles_message(data: dict) -> BlueBubblesMessage: return BlueBubblesMessage( guid=data.get("guid", ""), chat_guid=data.get("chatGuid", data.get("chat_guid", "")), text=data.get("text", data.get("message", "")), sender=data.get("sender", ""), date_delivered=data.get("dateDelivered", data.get("date_delivered", 0)), date_read=data.get("dateRead", data.get("date_read", 0)), is_from_me=data.get("isFromMe", data.get("is_from_me", False)), attachments=data.get("attachments", []), associated_message_guid=data.get("associatedMessageGuid") or data.get("associated_message_guid"), associated_message_type=data.get("associatedMessageType") or data.get("associated_message_type"), subject=data.get("subject"), thread_originator_guid=data.get("threadOriginatorGuid") or data.get("thread_originator_guid"), expressive_send_style_id=data.get("expressiveSendStyleId") or data.get("expressive_send_style_id"), ) def _to_unified_message(msg: BlueBubblesMessage, account_id: str) -> UnifiedMessage: is_group = ";+;" in msg.chat_guid sender_kind = PeerKind.DIRECT sender = PeerInfo( kind=sender_kind, id=msg.sender, display_name=None, username=msg.sender, is_bot=False, ) group = None if is_group: group = GroupContext( id=msg.chat_guid, name=None, kind="group", ) timestamp = None if msg.date_delivered: try: timestamp = datetime.fromtimestamp(msg.date_delivered / 1000, tz=UTC) except (OSError, ValueError): pass message_type = MessageType.TEXT media_urls = [] if msg.attachments: message_type = MessageType.IMAGE for att in msg.attachments: url = att.get("url") or att.get("filePath") if url: media_urls.append(url) return UnifiedMessage( msg_id=msg.guid, channel_type="bluebubbles", account_id=account_id, content=msg.text or "", sender=sender, message_type=message_type, media_urls=media_urls, group=group, timestamp=timestamp, raw_payload={ "guid": msg.guid, "chat_guid": msg.chat_guid, "sender": msg.sender, "date_delivered": msg.date_delivered, "is_from_me": msg.is_from_me, "attachments": msg.attachments, "associated_message_guid": msg.associated_message_guid, "associated_message_type": msg.associated_message_type, "subject": msg.subject, "thread_originator_guid": msg.thread_originator_guid, "expressive_send_style_id": msg.expressive_send_style_id, }, reply_to_id=msg.thread_originator_guid, ) async def websocket_loop( client: BlueBubblesClient, account_id: str, abort_event: asyncio.Event, dedupe_store: InboundDedupeStore | None = None, debounce_manager: DebounceManager | None = None, on_message=None, send_read_receipt=None, ): ws_url = client.server_url.replace("http://", "ws://").replace("https://", "wss://") ws_url = f"{ws_url}/api/v1/ws?password={client.password}" while not abort_event.is_set(): try: async with websockets.connect(ws_url) as ws: async for raw in ws: if abort_event.is_set(): break try: update = json.loads(raw) except json.JSONDecodeError: continue await process_inbound_message( update, account_id, dedupe_store=dedupe_store, debounce_manager=debounce_manager, on_message=on_message, send_read_receipt=send_read_receipt, ) except asyncio.CancelledError: break except websockets.exceptions.ConnectionClosed: if not abort_event.is_set(): await asyncio.sleep(5) except Exception: if not abort_event.is_set(): await asyncio.sleep(5) # --------------------------------------------------------------------------- # Event handlers for non-message WebSocket / Webhook events # --------------------------------------------------------------------------- _typing_last_sent: dict[str, float] = {} async def _handle_group_event(event_type: str, message_data: dict, account_id: str, on_message): chat_guid = message_data.get("chatGuid", message_data.get("chat_guid", "")) sender = message_data.get("sender", message_data.get("handle", {}).get("id", "")) if event_type == "group-name-change": new_name = message_data.get("groupName", message_data.get("group_name", "")) content = f"📝 群聊名称已更改为「{new_name}」" metadata = {"action": "rename", "new_name": new_name} elif event_type == "participant-added": participant = message_data.get("participant", "") content = f"👥 {participant} 已加入群聊" metadata = {"action": "member_join", "participant": participant} else: participant = message_data.get("participant", "") content = f"👣 {participant} 已离开群聊" metadata = {"action": "member_leave", "participant": participant} unified = UnifiedMessage( msg_id=f"system-{chat_guid}-{event_type}-{int(datetime.now(UTC).timestamp() * 1000)}", channel_type="bluebubbles", account_id=account_id, content=content, sender=PeerInfo(kind=PeerKind.DIRECT, id=sender or chat_guid), message_type=MessageType.EVENT, group=GroupContext(id=chat_guid, kind="group"), timestamp=datetime.now(UTC), metadata=metadata, raw_payload=message_data, ) if on_message: await on_message(unified) return unified async def _handle_typing_indicator(message_data: dict, account_id: str, on_message): chat_guid = message_data.get("chatGuid", message_data.get("chat_guid", "")) sender = message_data.get("sender", "") display = message_data.get("displayName", "") # Throttle: one typing event per chat_guid every 5 seconds now = datetime.now(UTC).timestamp() last_key = f"{account_id}:{chat_guid}" last = _typing_last_sent.get(last_key, 0) if now - last < 5: return None _typing_last_sent[last_key] = now unified = UnifiedMessage( msg_id=f"typing-{chat_guid}-{sender}-{int(now * 1000)}", channel_type="bluebubbles", account_id=account_id, content=f"✏️ {display or sender} 正在输入...", sender=PeerInfo(kind=PeerKind.DIRECT, id=sender, display_name=display or None), message_type=MessageType.EVENT, group=GroupContext(id=chat_guid, kind="group") if ";+;" in chat_guid else None, timestamp=datetime.now(UTC), metadata={"event_kind": "typing", "chat_guid": chat_guid, "sender": sender}, raw_payload=message_data, ) if on_message: await on_message(unified) return unified async def _handle_read_status_changed(message_data: dict, account_id: str, on_message): chat_guid = message_data.get("chatGuid", message_data.get("chat_guid", "")) read_by = message_data.get("readBy", message_data.get("sender", "")) unified = UnifiedMessage( msg_id=f"read-{chat_guid}-{int(datetime.now(UTC).timestamp() * 1000)}", channel_type="bluebubbles", account_id=account_id, content=f"✅ 消息已被 {read_by} 阅读", sender=PeerInfo(kind=PeerKind.DIRECT, id=read_by), message_type=MessageType.EVENT, group=GroupContext(id=chat_guid, kind="group") if ";+;" in chat_guid else None, timestamp=datetime.now(UTC), metadata={"event_kind": "read_status", "chat_guid": chat_guid, "read_by": read_by}, raw_payload=message_data, ) if on_message: await on_message(unified) return unified