ForcePilot/backend/package/yuxi/channel/extensions/bluebubbles/monitor.py
Kris b018e3eda4 feat(bluebubbles): 新增BlueBubbles(iMessage)渠道插件完整实现
该提交新增了完整的BlueBubbles渠道插件,支持通过BlueBubbles Server集成iMessage功能,包含以下核心能力:
1.  支持私聊和群聊会话管理,自动区分会话类型
2.  完整的消息收发支持,包括文本、图片、语音、文件、视频消息
3.  支持消息反应、已读回执、消息编辑与撤回
4.  内置去重、防抖处理机制
5.  支持Webhook和WebSocket两种事件接收方式
6.  完善的权限与安全校验机制
7.  历史消息同步与抓包功能
8.  TTS语音合成与发送支持
9.  群组管理能力,包括重命名、修改头像、增减成员等
2026-05-21 10:40:33 +08:00

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