ForcePilot/backend/package/yuxi/channel/extensions/imessage/monitor.py
Kris 4d52f634fe feat(imessage): 新增iMessage渠道插件完整实现
该提交新增了基于BlueBubbles的iMessage渠道插件,支持单聊和群组消息,包含文本、图片、语音、文件和视频消息收发,支持消息编辑、撤回、回复、 reactions和输入状态提示,同时实现了账号配置、安全校验、配对授权、消息格式化与分片等完整功能。
2026-05-21 10:50:15 +08:00

266 lines
9.6 KiB
Python

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)