From 3cfb7dee251c6c000c00fc0f31a69958304efaff Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Wed, 13 May 2026 16:05:25 +0800 Subject: [PATCH] =?UTF-8?q?refactor(bluebubbles):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=A4=9A=E9=A1=B9=E8=93=9D=E6=B3=A1?= =?UTF-8?q?=E6=B3=A1=E9=80=82=E9=85=8D=E5=99=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交对蓝泡泡适配器进行了多维度改进: 1. 新增缓存超限自动清理逻辑,防止内存溢出 2. 增强流式打字同步功能,增加超时自动停止逻辑 3. 重构markdown渲染逻辑,修复格式匹配问题并支持更多语法 4. 新增配置校验工具函数,增加streaming_mode和auth_strategy的合法性校验 5. 新增多项环境配置参数,完善配置项 6. 重构初始化流程,新增多个工具类实例 7. 优化TTS禁用逻辑,新增aiohttp依赖检查 8. 重构流式消息发送逻辑,整合打字指示器逻辑 9. 新增健康状态检查的问题检测功能 --- .../channels/adapters/bluebubbles/adapter.py | 126 +++++++++++------- .../channels/adapters/bluebubbles/config.py | 65 ++++++++- .../adapters/bluebubbles/monitor_debounce.py | 4 + .../channels/adapters/bluebubbles/renderer.py | 60 +++++++-- .../bluebubbles/streaming_enhanced.py | 19 ++- 5 files changed, 217 insertions(+), 57 deletions(-) diff --git a/backend/package/yuxi/channels/adapters/bluebubbles/adapter.py b/backend/package/yuxi/channels/adapters/bluebubbles/adapter.py index 7cda2d49..c8e70def 100644 --- a/backend/package/yuxi/channels/adapters/bluebubbles/adapter.py +++ b/backend/package/yuxi/channels/adapters/bluebubbles/adapter.py @@ -9,11 +9,15 @@ from typing import Any from yuxi.channels.adapters.bluebubbles.accounts import parse_accounts_config from yuxi.channels.adapters.bluebubbles.action_gate import ActionGate -from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore, CatchupSummary, run_full_catchup +from yuxi.channels.adapters.bluebubbles.catchup import CatchupCursorStore, run_full_catchup from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient from yuxi.channels.adapters.bluebubbles.contact_resolver import resolve_contact +from yuxi.channels.adapters.bluebubbles.conversation_bindings import ConversationBindings +from yuxi.channels.adapters.bluebubbles.conversation_id import ConversationID +from yuxi.channels.adapters.bluebubbles.conversation_route import ConversationRoute from yuxi.channels.adapters.bluebubbles.directory import list_chats, list_contacts, search_chats, search_contacts from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesConnectionError, BlueBubblesHTTPError +from yuxi.channels.adapters.bluebubbles.exec_approval import ExecApproval from yuxi.channels.adapters.bluebubbles.group_actions import ( add_participant, leave_chat, @@ -22,9 +26,9 @@ from yuxi.channels.adapters.bluebubbles.group_actions import ( set_group_icon, ) from yuxi.channels.adapters.bluebubbles.inbound_dedupe import InboundDedupe +from yuxi.channels.adapters.bluebubbles.message_effects import send_with_effect from yuxi.channels.adapters.bluebubbles.monitor import BlueBubblesMonitor, InboundReorderBuffer from yuxi.channels.adapters.bluebubbles.monitor_debounce import MonitorDebounce -from yuxi.channels.adapters.bluebubbles.message_effects import send_with_effect from yuxi.channels.adapters.bluebubbles.probe import ( is_macos_26_or_higher, probe_imessage_login, @@ -49,6 +53,9 @@ from yuxi.channels.adapters.bluebubbles.send import ( ) from yuxi.channels.adapters.bluebubbles.sent_cache import SentMessageCache from yuxi.channels.adapters.bluebubbles.session import resolve_chat_type +from yuxi.channels.adapters.bluebubbles.session_route import SessionRoute +from yuxi.channels.adapters.bluebubbles.status_issues import StatusIssues +from yuxi.channels.adapters.bluebubbles.streaming_enhanced import StreamingTypingSync from yuxi.channels.adapters.bluebubbles.targets import resolve_chat_guid_with_auto_create from yuxi.channels.adapters.bluebubbles.voice import check_tts_available from yuxi.channels.adapters.bluebubbles.webhook_ingress import WebhookIngress @@ -132,6 +139,13 @@ class BlueBubblesAdapter(BaseChannelAdapter): group_actions = {"rename_group", "set_group_icon", "add_participant", "remove_participant", "leave_group"} if not group_actions.issubset(set(available)): base.group_management = False + + tts_available = check_tts_available() + if not tts_available["espeak"]: + base.tts.voice.enabled = False + elif not tts_available["ffmpeg"] and not tts_available["pydub"]: + base.tts.voice.enabled = False + return base meta = ChannelMeta(id="bluebubbles", label="BlueBubbles") @@ -168,9 +182,13 @@ class BlueBubblesAdapter(BaseChannelAdapter): self._health_monitor_config: dict = config.get("health_monitor", {}) if config else {} self._network_config: dict = config.get("network", {}) if config else {} self._allow_private_network: bool = ( - self._network_config.get("dangerously_allow_private_network", False) - or config.get("allow_private_network", False) - ) if config else False + ( + self._network_config.get("dangerously_allow_private_network", False) + or config.get("allow_private_network", False) + ) + if config + else False + ) self._media_local_roots: list[str] = config.get("media_local_roots", []) if config else [] self._require_mention: bool = config.get("require_mention", False) if config else False self._message_order_check: bool = config.get("message_order_check", True) if config else True @@ -187,14 +205,18 @@ class BlueBubblesAdapter(BaseChannelAdapter): self._catchup_task: asyncio.Task | None = None self._catchup_cursor_store: CatchupCursorStore | None = None self._sent_cache = SentMessageCache() + self._typing_sync = StreamingTypingSync() + self._conversation_bindings = ConversationBindings() + self._conversation_id = ConversationID() + self._conversation_route = ConversationRoute() + self._session_route = SessionRoute() + self._exec_approval = ExecApproval() + self._status_issues = StatusIssues() - tts_available = check_tts_available() - if not tts_available["espeak"]: - self.capabilities.tts.voice.enabled = False - logger.info("[BlueBubbles] TTS disabled: espeak not found") - elif not tts_available["ffmpeg"] and not tts_available["pydub"]: - self.capabilities.tts.voice.enabled = False - logger.info("[BlueBubbles] TTS disabled: no transcoder (ffmpeg or pydub) found") + try: + import aiohttp # noqa: F401 + except ImportError: + logger.info("[BlueBubbles] AI Vision unavailable: aiohttp not installed") self._text_chunk_limit: int = config.get("text_chunk_limit", 4096) if config else 4096 self._chunk_mode: str = config.get("chunk_mode", "newline") if config else "newline" @@ -286,6 +308,7 @@ class BlueBubblesAdapter(BaseChannelAdapter): ) self._secret_contract.audit("password", "connect", {"server_url": self._server_url}) await self._client.__aenter__() + self._typing_sync.set_client(self._client) health = await self.health_check() if health.status == "unhealthy": @@ -382,22 +405,40 @@ class BlueBubblesAdapter(BaseChannelAdapter): await account_client.__aenter__() self._account_clients[account_id] = account_client logger.info("[BlueBubbles] Connected account: %s", account_id) + + account_monitor = BlueBubblesMonitor( + server_url=account_cfg.server_url, + password=account_cfg.password, + on_event=self._handle_ws_event, + on_disconnect=lambda aid=account_id: logger.info("[BlueBubbles] Account WS disconnected: %s", aid), + on_reconnect=lambda aid=account_id: logger.info("[BlueBubbles] Account WS reconnected: %s", aid), + ) + await account_monitor.start() + self._account_monitors[account_id] = account_monitor + logger.info("[BlueBubbles] Account WS monitor started: %s", account_id) except Exception: logger.warning("[BlueBubbles] Failed to connect account: %s", account_id) async def disconnect(self) -> None: + await self._typing_sync.stop_all() if self._catchup_task and not self._catchup_task.done(): self._catchup_task.cancel() self._catchup_task = None self._catchup_cursor_store = None + for account_id, account_monitor in self._account_monitors.items(): + try: + await account_monitor.stop() + except Exception: + logger.debug("[BlueBubbles] Error stopping account monitor: %s", account_id) + self._account_monitors.clear() + for account_id, account_client in self._account_clients.items(): try: await account_client.__aexit__() except Exception: logger.debug("[BlueBubbles] Error disconnecting account: %s", account_id) self._account_clients.clear() - self._account_monitors.clear() if self._monitor: await self._monitor.stop() @@ -633,8 +674,9 @@ class BlueBubblesAdapter(BaseChannelAdapter): except Exception as e: return DeliveryResult(success=False, error=str(e)) - async def send_message_with_effect(self, chat_id: str, content: str, effect: str, - reply_to: str | None = None) -> DeliveryResult: + async def send_message_with_effect( + self, chat_id: str, content: str, effect: str, reply_to: str | None = None + ) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") try: @@ -675,43 +717,31 @@ class BlueBubblesAdapter(BaseChannelAdapter): return await self.send(response) key = f"{chat_id}:{msg_id}" - coalesce_enabled = self._block_streaming_coalesce.get("enabled", False) - max_flush_ms = self._block_streaming_coalesce.get("max_flush_interval_ms", 500) - if coalesce_enabled: - self._stream_buffers[key] = self._stream_buffers.get(key, "") + chunk - - now = time.monotonic() - last = self._last_edit_at.get(key, 0) - if not finished and now - last < max_flush_ms / 1000.0: - return DeliveryResult(success=True, message_id=msg_id) - - combined = self._stream_buffers.pop(key, "") - prev_sent = self._stream_sent.get(key, "") - full_content = prev_sent + combined - self._stream_sent[key] = full_content - - lane_content = self._lane_sent.get(key, "") - display_content = self._compose_stream_content(full_content, lane_content, finished) - - try: - await update_activity(self._client, chat_id, msg_id, display_content) - self._last_edit_at[key] = now - if finished: - self._cleanup_stream_key(key) - return DeliveryResult(success=True, message_id=msg_id) - except Exception as e: - logger.warning(f"[BlueBubbles] Stream chunk update failed: {e}") - return DeliveryResult(success=False, error=str(e)) + if self._streaming_typing_indicator: + if finished: + await self._typing_sync.stop_typing(chat_id) + else: + await self._typing_sync.start_typing(chat_id) self._stream_buffers[key] = self._stream_buffers.get(key, "") + chunk now = time.monotonic() last = self._last_edit_at.get(key, 0) - interval_s = self._edit_interval_ms / 1000.0 - if not finished and now - last < interval_s: - return DeliveryResult(success=True, message_id=msg_id) + coalesce_enabled = self._block_streaming_coalesce.get("enabled", False) + if coalesce_enabled: + max_flush_ms = self._block_streaming_coalesce.get("max_flush_interval_ms", 500) + if not finished and now - last < max_flush_ms / 1000.0: + return DeliveryResult(success=True, message_id=msg_id) + else: + interval_s = self._edit_interval_ms / 1000.0 + if not finished and now - last < interval_s: + return DeliveryResult(success=True, message_id=msg_id) + + return await self._flush_stream_chunk(key, chat_id, msg_id, finished) + + async def _flush_stream_chunk(self, key: str, chat_id: str, msg_id: str, finished: bool) -> DeliveryResult: combined = self._stream_buffers.pop(key, "") prev_sent = self._stream_sent.get(key, "") full_content = prev_sent + combined @@ -722,7 +752,7 @@ class BlueBubblesAdapter(BaseChannelAdapter): try: await update_activity(self._client, chat_id, msg_id, display_content) - self._last_edit_at[key] = now + self._last_edit_at[key] = time.monotonic() if finished: self._cleanup_stream_key(key) return DeliveryResult(success=True, message_id=msg_id) @@ -1111,6 +1141,10 @@ class BlueBubblesAdapter(BaseChannelAdapter): hm_enabled = self._health_monitor_config.get("enabled", True) health.metadata["health_monitor"] = {"enabled": hm_enabled} + ws_connected = self._monitor is not None and getattr(self._monitor, "_connected", False) + self._status_issues.check_connection(str(self._status.value), ws_connected) + health.metadata["issues"] = self._status_issues.get_issues() + return health except Exception as e: return HealthStatus( diff --git a/backend/package/yuxi/channels/adapters/bluebubbles/config.py b/backend/package/yuxi/channels/adapters/bluebubbles/config.py index b73e9320..242f994d 100644 --- a/backend/package/yuxi/channels/adapters/bluebubbles/config.py +++ b/backend/package/yuxi/channels/adapters/bluebubbles/config.py @@ -106,6 +106,13 @@ class BlueBubblesConfig(BaseModel): auth_header_name: str = "X-BB-Password" tts_prefer_caf: bool = True + def __repr__(self) -> str: + d = self.model_dump() + if d.get("password"): + d["password"] = "***" + fields = ", ".join(f"{k}={v!r}" for k, v in d.items()) + return f"{self.__class__.__name__}({fields})" + @field_validator("allow_private_network", mode="before") @classmethod def _deprecate_allow_private_network(cls, v, info) -> Any: @@ -116,6 +123,19 @@ class BlueBubblesConfig(BaseModel): ) return v + @classmethod + def _validate_literal(cls, value: str, valid_values: list[str], field_name: str, default: str) -> str: + if value not in valid_values: + logger.warning( + "[BlueBubbles] Invalid value '%s' for '%s', falling back to '%s'. Valid values: %s", + value, + field_name, + default, + valid_values, + ) + return default + return value + @classmethod def from_env(cls) -> BlueBubblesConfig: dm_raw = os.getenv("BLUEBUBBLES_DM_ALLOW_FROM", "") @@ -137,7 +157,12 @@ class BlueBubblesConfig(BaseModel): media_max_mb=int(os.getenv("BLUEBUBBLES_MEDIA_MAX_MB", "100")), enable_stickers=os.getenv("BLUEBUBBLES_ENABLE_STICKERS", "true").lower() == "true", media_local_roots=[x.strip() for x in media_roots_raw.split(",") if x.strip()] if media_roots_raw else [], - streaming_mode=os.getenv("BLUEBUBBLES_STREAMING_MODE", "partial"), + streaming_mode=cls._validate_literal( + os.getenv("BLUEBUBBLES_STREAMING_MODE", "partial"), + ["off", "partial", "block"], + "streaming_mode", + "partial", + ), edit_interval_ms=int(os.getenv("BLUEBUBBLES_EDIT_INTERVAL_MS", "500")), tapback_enabled=os.getenv("BLUEBUBBLES_TAPBACK_ENABLED", "true").lower() == "true", require_mention=os.getenv("BLUEBUBBLES_REQUIRE_MENTION", "false").lower() == "true", @@ -147,6 +172,42 @@ class BlueBubblesConfig(BaseModel): allow_private_network=os.getenv("BLUEBUBBLES_ALLOW_PRIVATE_NETWORK", "false").lower() == "true", webhook_path=os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles/webhook"), webhook_secret=os.getenv("BLUEBUBBLES_WEBHOOK_SECRET", ""), - auth_strategy=os.getenv("BLUEBUBBLES_AUTH_STRATEGY", "header"), + auth_strategy=cls._validate_literal( + os.getenv("BLUEBUBBLES_AUTH_STRATEGY", "header"), + ["header", "query", "both"], + "auth_strategy", + "header", + ), tts_prefer_caf=os.getenv("BLUEBUBBLES_TTS_PREFER_CAF", "true").lower() == "true", + streaming_typing_indicator=os.getenv("BLUEBUBBLES_STREAMING_TYPING", "true").lower() == "true", + message_order_check=os.getenv("BLUEBUBBLES_MESSAGE_ORDER_CHECK", "true").lower() == "true", + max_cache_entries=int(os.getenv("BLUEBUBBLES_MAX_CACHE_ENTRIES", "2048")), + dm_history_limit=int(os.getenv("BLUEBUBBLES_DM_HISTORY_LIMIT", "100")), + coalesce_same_sender_dms=os.getenv("BLUEBUBBLES_COALESCE_DMS", "false").lower() == "true", + enrich_group_participants_from_contacts=os.getenv("BLUEBUBBLES_ENRICH_GROUP", "true").lower() == "true", + config_writes=os.getenv("BLUEBUBBLES_CONFIG_WRITES", "true").lower() == "true", + block_streaming_coalesce=BlockStreamingCoalesceConfig( + enabled=os.getenv("BLUEBUBBLES_COALESCE_ENABLED", "false").lower() == "true", + max_flush_interval_ms=int(os.getenv("BLUEBUBBLES_COALESCE_MAX_FLUSH_MS", "500")), + ), + network=NetworkConfig( + dangerously_allow_private_network=os.getenv( + "BLUEBUBBLES_NETWORK_DANGEROUSLY_ALLOW_PRIVATE", "false" + ).lower() + == "true", + ), + markdown=MarkdownConfig( + enabled=os.getenv("BLUEBUBBLES_MARKDOWN_ENABLED", "true").lower() == "true", + code_blocks=os.getenv("BLUEBUBBLES_MARKDOWN_CODE_BLOCKS", "true").lower() == "true", + ), + catchup=CatchupConfig( + enabled=os.getenv("BLUEBUBBLES_CATCHUP_ENABLED", "true").lower() == "true", + max_age_minutes=int(os.getenv("BLUEBUBBLES_CATCHUP_MAX_AGE_MINUTES", "120")), + per_run_limit=int(os.getenv("BLUEBUBBLES_CATCHUP_PER_RUN_LIMIT", "50")), + first_run_lookback_minutes=int(os.getenv("BLUEBUBBLES_CATCHUP_FIRST_LOOKBACK", "30")), + max_failure_retries=int(os.getenv("BLUEBUBBLES_CATCHUP_MAX_FAILURES", "10")), + ), + health_monitor=HealthMonitorConfig( + enabled=os.getenv("BLUEBUBBLES_HEALTH_MONITOR_ENABLED", "true").lower() == "true", + ), ) diff --git a/backend/package/yuxi/channels/adapters/bluebubbles/monitor_debounce.py b/backend/package/yuxi/channels/adapters/bluebubbles/monitor_debounce.py index ea637fdd..b470ffa6 100644 --- a/backend/package/yuxi/channels/adapters/bluebubbles/monitor_debounce.py +++ b/backend/package/yuxi/channels/adapters/bluebubbles/monitor_debounce.py @@ -24,3 +24,7 @@ class MonitorDebounce: expired = [k for k, v in self._cache.items() if now - v > self.WINDOW_SECONDS] for k in expired: del self._cache[k] + if len(self._cache) > self.MAX_ENTRIES: + oldest = sorted(self._cache.items(), key=lambda x: x[1])[: len(self._cache) - self.MAX_ENTRIES] + for k, _ in oldest: + del self._cache[k] diff --git a/backend/package/yuxi/channels/adapters/bluebubbles/renderer.py b/backend/package/yuxi/channels/adapters/bluebubbles/renderer.py index 237480a5..b8edb918 100644 --- a/backend/package/yuxi/channels/adapters/bluebubbles/renderer.py +++ b/backend/package/yuxi/channels/adapters/bluebubbles/renderer.py @@ -77,8 +77,6 @@ def render_markdown_to_imessage(text: str) -> str: if not text: return "" - import re - codes: dict[str, str] = {} def _save_code(m: re.Match) -> str: @@ -88,12 +86,58 @@ def render_markdown_to_imessage(text: str) -> str: text = re.sub(r"`[^`]+`", _save_code, text) - text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) - text = re.sub(r"__(.+?)__", r"\1", text) - - text = re.sub(r"(?\1", text) + tokens = _tokenize_markdown(text) + result = _render_tokens(tokens) for key, val in codes.items(): - text = text.replace(key, val) + result = result.replace(key, val) - return text + return result + + +def _tokenize_markdown(text: str) -> list[tuple[str, str]]: + TOKEN_RE = re.compile(r"(\*\*|__|(? pos: + tokens.append(("text", text[pos : m.start()])) + tokens.append(("marker", m.group())) + pos = m.end() + if pos < len(text): + tokens.append(("text", text[pos:])) + return tokens + + +def _render_tokens(tokens: list[tuple[str, str]]) -> str: + stack: list[tuple[str, str]] = [] + result: list[str] = [] + + MARKER_MAP = { + "**": ("", ""), + "__": ("", ""), + "*": ("", ""), + "~": ("", ""), + "~~": ("", ""), + } + + for kind, value in tokens: + if kind == "text": + result.append(value) + elif kind == "marker": + if value in MARKER_MAP: + if stack and stack[-1][0] == value: + stack.pop() + result.append(MARKER_MAP[value][1]) + else: + stack.append((value, MARKER_MAP[value][0])) + result.append(MARKER_MAP[value][0]) + else: + result.append(value) + + for marker, _ in reversed(stack): + mapped = MARKER_MAP.get(marker) + if mapped: + result.append(mapped[1]) + + return "".join(result) diff --git a/backend/package/yuxi/channels/adapters/bluebubbles/streaming_enhanced.py b/backend/package/yuxi/channels/adapters/bluebubbles/streaming_enhanced.py index 3d0d2666..5f0be416 100644 --- a/backend/package/yuxi/channels/adapters/bluebubbles/streaming_enhanced.py +++ b/backend/package/yuxi/channels/adapters/bluebubbles/streaming_enhanced.py @@ -12,13 +12,20 @@ from yuxi.utils.logging_config import logger class StreamingTypingSync: TYPING_REFRESH_INTERVAL = 8.0 + DEFAULT_STREAM_TIMEOUT = 60.0 - def __init__(self, client: BlueBubblesClient | None = None): + def __init__( + self, + client: BlueBubblesClient | None = None, + stream_timeout: float = DEFAULT_STREAM_TIMEOUT, + ): self._client = client self._active_chats: set[str] = set() self._last_typing: dict[str, float] = {} self._keepalive_task: asyncio.Task | None = None self._running = False + self._stream_timeout = stream_timeout + self._stream_started: dict[str, float] = {} def set_client(self, client: BlueBubblesClient) -> None: self._client = client @@ -28,6 +35,7 @@ class StreamingTypingSync: return self._active_chats.add(chat_id) + self._stream_started.setdefault(chat_id, time.monotonic()) try: await send_typing_indicator(self._client, chat_id, display=True) self._last_typing[chat_id] = time.monotonic() @@ -40,6 +48,7 @@ class StreamingTypingSync: async def stop_typing(self, chat_id: str) -> None: self._active_chats.discard(chat_id) + self._stream_started.pop(chat_id, None) if not self._client: return try: @@ -74,6 +83,14 @@ class StreamingTypingSync: except Exception: logger.debug(f"[BlueBubbles] Typing keepalive failed for {chat_id}") + stream_started = self._stream_started.get(chat_id) + if stream_started and (now - stream_started) > self._stream_timeout: + logger.warning( + f"[BlueBubbles] Stream timeout for {chat_id}, " + f"({now - stream_started:.0f}s), auto-stopping typing" + ) + await self.stop_typing(chat_id) + async def send_stream_with_typing( adapter: Any,