ForcePilot/backend/package/yuxi/channels/adapters/imessage/monitor.py
Kris 8f352d91bb feat(imessage): 新增投票管理、审计日志功能并优化多项细节
1. 新增poll命令支持创建、关闭、列出投票
2. 新增审计日志记录功能
3. 优化远程附件URL安全校验逻辑
4. 修复表格匹配正则,支持包含竖线的表格分隔行
5. 新增媒体AI处理能力,支持图片描述和音频转录
6. 完善配置校验和错误处理
7. 重构文本发送逻辑,增加重试机制
8. 新增投票投票处理逻辑,支持数字快捷投票
2026-05-13 16:10:03 +08:00

106 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