该提交新增了基于BlueBubbles的iMessage渠道插件,支持单聊和群组消息,包含文本、图片、语音、文件和视频消息收发,支持消息编辑、撤回、回复、 reactions和输入状态提示,同时实现了账号配置、安全校验、配对授权、消息格式化与分片等完整功能。
266 lines
9.6 KiB
Python
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)
|