该提交新增了完整的BlueBubbles渠道插件,支持通过BlueBubbles Server集成iMessage功能,包含以下核心能力: 1. 支持私聊和群聊会话管理,自动区分会话类型 2. 完整的消息收发支持,包括文本、图片、语音、文件、视频消息 3. 支持消息反应、已读回执、消息编辑与撤回 4. 内置去重、防抖处理机制 5. 支持Webhook和WebSocket两种事件接收方式 6. 完善的权限与安全校验机制 7. 历史消息同步与抓包功能 8. TTS语音合成与发送支持 9. 群组管理能力,包括重命名、修改头像、增减成员等
344 lines
12 KiB
Python
344 lines
12 KiB
Python
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
|