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

149 lines
5.3 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import websockets
from yuxi.channel.extensions.imessage.errors import IMessageAuthError
from yuxi.channel.extensions.imessage.monitor import IMessageMonitor
from yuxi.channel.extensions.imessage.types import IMessageGatewayContext
logger = logging.getLogger(__name__)
class IMessageGatewayAdapter:
def __init__(self, monitor=None):
self._monitor = monitor or IMessageMonitor()
self._ws = None
self._ctx: IMessageGatewayContext | None = None
self._receive_task: asyncio.Task | None = None
self._ping_task: asyncio.Task | None = None
self._running = False
self._reconnect_attempt = 0
async def start(self, ctx) -> object:
account = getattr(ctx, "account", {}) or {}
server_url = account.get("server_url", "")
password = account.get("password", "")
account_id = account.get("account_id", "default")
self._ctx = IMessageGatewayContext(
server_url=server_url,
password=password,
account_id=account_id,
)
if not server_url or not password:
raise IMessageAuthError("BlueBubbles server URL and password are required")
self._running = True
queue = getattr(ctx, "queue", asyncio.Queue())
self._receive_task = asyncio.create_task(self._ws_loop(queue))
self._ping_task = asyncio.create_task(self._ping_loop())
return queue
async def stop(self, ctx) -> None:
self._running = False
for task in [self._receive_task, self._ping_task]:
if task and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._ws = None
async def _ws_loop(self, queue: asyncio.Queue) -> None:
while self._running:
try:
await self._connect_and_listen(queue)
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("WebSocket loop error: %s", e)
if not self._running:
break
delay = self._reconnect_delay()
logger.info("Reconnecting in %.1fs (attempt %d)", delay, self._reconnect_attempt)
await asyncio.sleep(delay)
async def _connect_and_listen(self, queue: asyncio.Queue) -> None:
if not self._ctx:
return
ws_url = self._ctx.ws_url
if self._ctx.password:
ws_url = f"{ws_url}?password={self._ctx.password}"
self._reconnect_attempt += 1
async with websockets.connect(ws_url, ping_interval=30, ping_timeout=10, close_timeout=5) as ws:
self._ws = ws
self._reconnect_attempt = 0
logger.info("Connected to BlueBubbles WebSocket: %s", self._ctx.server_url)
async for raw in ws:
if not self._running:
break
try:
event = json.loads(raw)
except json.JSONDecodeError:
continue
event_type = event.get("type", "")
if event_type == "new-message":
unified_msg = self._monitor.parse_event(event, self._ctx.account_id)
if unified_msg:
await queue.put(unified_msg)
elif event_type == "updated-message":
await self._monitor.handle_updated_message(event, self._ctx.account_id, queue)
elif event_type == "message-send-error":
self._monitor.handle_send_error(event)
elif event_type in (
"participant-removed", "participant-added", "participant-left",
"group-name-changed", "group-icon-changed", "group-icon-removed",
):
self._monitor.handle_group_event(event, event_type, self._ctx.account_id, queue)
elif event_type == "typing-indicator":
self._monitor.handle_typing_indicator(event, self._ctx.account_id, queue)
elif event_type == "chat-read-status-changed":
self._monitor.handle_chat_read_status_changed(event, self._ctx.account_id, queue)
elif event_type == "imessage-alias-removed":
self._monitor.handle_alias_removed(event, self._ctx.account_id, queue)
else:
unified_msg = self._monitor.parse_event(event, self._ctx.account_id)
if unified_msg:
await queue.put(unified_msg)
self._ws = None
def _reconnect_delay(self) -> float:
ctx = self._ctx
if not ctx:
return 60.0
if self._reconnect_attempt > ctx.max_reconnect_attempts:
return ctx.reconnect_max_delay
delay = min(
ctx.reconnect_min_delay * (2 ** (self._reconnect_attempt - 1)),
ctx.reconnect_max_delay,
)
return delay
async def _ping_loop(self) -> None:
while self._running:
await asyncio.sleep(30)
if not self._ws or not self._running:
continue
try:
await self._ws.ping()
except Exception:
pass