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