新增 iMessage 通道适配器完整实现,包含: 1. 核心适配器与工具工厂导出 2. 运行时存储、反射防护、会话路由等基础组件 3. 消息信封、线程管理、回复上下文格式化 4. Tapback 表情反应处理、自定义异常体系 5. 审批按钮、联系人解析、速率限制功能 6. 文本净化、目标解析、缓存管理模块 7. 配置 schema、多账户支持、安装向导等配置模块 8. 审计日志、媒体AI处理等扩展功能
107 lines
4.4 KiB
Python
107 lines
4.4 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import random
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
|
|
_MAX_ERROR_MSG_LEN = 200
|
|
|
|
|
|
class IMessageMonitor:
|
|
def __init__(self, config: dict[str, Any], bridge):
|
|
self._config = config
|
|
self._bridge = bridge
|
|
self._message_handler: Callable[[dict[str, Any]], Awaitable[None]] | None = None
|
|
self._task: asyncio.Task | None = None
|
|
self._reconnect_delay = 5.0
|
|
self._initial_reconnect_delay = config.get("ws_reconnect_initial_delay", 5.0)
|
|
self._max_reconnect_delay = config.get("ws_reconnect_max_delay", 60.0)
|
|
self._max_subscribe_attempts = config.get(
|
|
"watchSubscribeMaxAttempts", config.get("watch_subscribe_max_attempts", 3)
|
|
)
|
|
self._subscribe_retry_delay_ms = config.get(
|
|
"watchSubscribeRetryDelayMs", config.get("watch_subscribe_retry_delay_ms", 1000)
|
|
)
|
|
self._subscribe_attempts = 0
|
|
|
|
def on_message(self, handler: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
|
|
self._message_handler = handler
|
|
|
|
async def start(self) -> None:
|
|
if self._task and not self._task.done():
|
|
return
|
|
self._task = asyncio.create_task(self._ws_loop())
|
|
|
|
async def stop(self) -> None:
|
|
if self._task:
|
|
self._task.cancel()
|
|
try:
|
|
await self._task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._task = None
|
|
|
|
async def _ws_loop(self) -> None:
|
|
while True:
|
|
try:
|
|
self._subscribe_attempts = 0
|
|
await self._connect_ws()
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as e:
|
|
self._subscribe_attempts += 1
|
|
if self._subscribe_attempts >= self._max_subscribe_attempts:
|
|
logger.error(
|
|
f"[iMessage] Max subscribe attempts ({self._max_subscribe_attempts}) reached, stopping monitor"
|
|
)
|
|
break
|
|
delay = self._reconnect_delay * (0.5 + random.random())
|
|
err_msg = str(e)[:_MAX_ERROR_MSG_LEN]
|
|
logger.error(
|
|
f"[iMessage] WebSocket error (attempt {self._subscribe_attempts}), "
|
|
f"reconnecting in {delay:.1f}s: {err_msg}"
|
|
)
|
|
await asyncio.sleep(delay)
|
|
self._reconnect_delay = min(self._reconnect_delay * 1.5, self._max_reconnect_delay)
|
|
|
|
async def _connect_ws(self) -> None:
|
|
url = self._bridge.ws_url
|
|
logger.info(f"[iMessage] Connecting to BlueBubbles WebSocket: {url}")
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
headers = {"X-BlueBubbles-Password": self._bridge.password}
|
|
async with session.ws_connect(url, headers=headers) as ws:
|
|
self._reconnect_delay = self._initial_reconnect_delay
|
|
logger.info("[iMessage] BlueBubbles WebSocket connected")
|
|
|
|
async for msg in ws:
|
|
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
try:
|
|
data = json.loads(msg.data)
|
|
event_type = data.get("type", "")
|
|
logger.debug(f"[iMessage] WS event: {event_type}")
|
|
|
|
if event_type == "error":
|
|
err_data = data.get("data", {})
|
|
err_str = str(err_data)
|
|
if isinstance(err_data, dict):
|
|
err_str = err_data.get("message", err_data.get("error", str(err_data)))
|
|
logger.error(f"[iMessage] BlueBubbles error: {err_str[:_MAX_ERROR_MSG_LEN]}")
|
|
elif self._message_handler:
|
|
await self._message_handler(data)
|
|
except json.JSONDecodeError:
|
|
logger.warning(f"[iMessage] WS non-JSON message: {msg.data[:200]}")
|
|
elif msg.type == aiohttp.WSMsgType.ERROR:
|
|
logger.error(f"[iMessage] WS error: {ws.exception()}")
|
|
break
|
|
elif msg.type == aiohttp.WSMsgType.CLOSED:
|
|
logger.info("[iMessage] BlueBubbles WebSocket closed")
|
|
break
|