from __future__ import annotations import asyncio import os import tempfile import time from datetime import UTC, datetime 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, 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, remove_participant, rename_chat, 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.probe import ( is_macos_26_or_higher, probe_imessage_login, probe_macos_version, probe_private_api, ) from yuxi.channels.adapters.bluebubbles.secret_contract import SecretContract from yuxi.channels.adapters.bluebubbles.self_chat_cache import SelfChatCache from yuxi.channels.adapters.bluebubbles.send import ( download_attachment, remove_tapback, resolve_tapback_value, send_attachment, send_attachment_bytes, send_html_message, send_read_receipt, send_sticker, send_tapback, send_typing_indicator, unsend_message, update_activity, ) 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 from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.capabilities import ChannelCapabilities, TTSCapabilities, TTSVoiceCapabilities from yuxi.channels.exceptions import ( ChannelAuthenticationError, ) from yuxi.channels.meta import ChannelMeta from yuxi.channels.models import ( Attachment, ChannelIdentity, ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, ChatType, DeliveryResult, EventType, HealthStatus, MessageType, ) from yuxi.channels.registry import register_builtin_adapter from yuxi.utils.datetime_utils import utc_now_naive from yuxi.utils.logging_config import logger @register_builtin_adapter class BlueBubblesAdapter(BaseChannelAdapter): channel_id = "bluebubbles" channel_type = ChannelType.BLUEBUBBLES supports_markdown = True supports_streaming = True streaming_modes = ["off", "partial", "block"] max_media_size_mb = 100 _MEDIA_SUFFIX_MAP: dict[str, str] = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".bin", } @property def text_chunk_limit(self) -> int: return self._text_chunk_limit capabilities = ChannelCapabilities( chat_types=["direct", "group"], reactions=True, edit=True, unsend=True, reply=True, effects=True, media=True, group_management=True, supports_markdown=True, supports_streaming=True, streaming_modes=["off", "partial", "block"], block_streaming=True, lane_streaming=True, text_chunk_limit=4096, max_media_size_mb=100, tts=TTSCapabilities( voice=TTSVoiceCapabilities( synthesis_target="audio-file", transcodes_audio=True, enabled=True, ), ), ) @property def effective_capabilities(self) -> ChannelCapabilities: base = self.capabilities.model_copy(deep=True) available = self._action_gate.get_available_actions() if "edit" not in available: base.edit = False if "unsend" not in available: base.unsend = False 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") def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) self._status = ChannelStatus.DISCONNECTED self._client: BlueBubblesClient | None = None self._monitor: BlueBubblesMonitor | None = None self._account_clients: dict[str, BlueBubblesClient] = {} self._account_monitors: dict[str, BlueBubblesMonitor] = {} self._accounts_config: dict[str, Any] = {} self._server_url: str = config.get("server_url", "http://localhost:1234") if config else "http://localhost:1234" self._password: str = config.get("password", "") if config else "" self._server_info: dict[str, Any] | None = None self._agent_id: str | None = None self._dm_policy: str = config.get("dm_policy", "pairing") if config else "pairing" self._group_policy: str = config.get("group_policy", "allowlist") if config else "allowlist" self._dm_allow_from: list[str] = config.get("dm_allow_from", []) if config else [] self._group_allow_chats: list[str] = config.get("group_allow_chats", []) if config else [] self._paired_users: set[str] = set() self._pairing_notify_sent: set[str] = set() self._inbound_dedupe = InboundDedupe() self._self_chat_cache = SelfChatCache() self._monitor_debounce = MonitorDebounce() self._tapback_enabled: bool = config.get("tapback_enabled", True) if config else True self._edit_interval_ms: int = config.get("edit_interval_ms", 500) if config else 500 self._last_edit_at: dict[str, float] = {} self._stream_buffers: dict[str, str] = {} self._stream_sent: dict[str, str] = {} self._lane_buffers: dict[str, str] = {} self._lane_sent: dict[str, str] = {} self._streaming_typing_indicator: bool = config.get("streaming_typing_indicator", True) if config else True 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._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 self._reorder_buffer = InboundReorderBuffer() if self._message_order_check else None _wp_default = "/bluebubbles/webhook" self._webhook_path: str = config.get("webhook_path", _wp_default) if config else _wp_default self._webhook_secret: str = config.get("webhook_secret", "") if config else "" self._webhook_ingress = WebhookIngress(webhook_secret=self._webhook_secret) self._secret_contract = SecretContract() if self._password: self._secret_contract.register("password", "BlueBubbles server password") self._action_gate = ActionGate() 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() 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" self._send_read_receipts: bool = config.get("send_read_receipts", True) if config else True self._block_streaming: bool = config.get("block_streaming", False) if config else False self._send_timeout_ms: int = config.get("send_timeout_ms", 30000) if config else 30000 self._dm_history_limit: int = config.get("dm_history_limit", 100) if config else 100 self._name: str = config.get("name", "") if config else "" self._default_account: str = config.get("default_account", "") if config else "" self._config_writes: bool = config.get("config_writes", True) if config else True self._dms_overrides: dict = config.get("dms", {}) if config else {} self._groups_config: dict = config.get("groups", {}) if config else {} self._catchup_config: dict = config.get("catchup", {}) if config else {} self._markdown_config: dict = config.get("markdown", {}) if config else {} self.supports_markdown = self._markdown_config.get("enabled", True) self._block_streaming_coalesce: dict = config.get("block_streaming_coalesce", {}) if config else {} self._coalesce_same_sender_dms: bool = config.get("coalesce_same_sender_dms", False) if config else False self._coalesce_buffer: dict[str, list[dict[str, Any]]] = {} self._coalesce_tasks: dict[str, asyncio.Task] = {} self._enrich_group_participants: bool = ( config.get("enrich_group_participants_from_contacts", True) if config else True ) self._catchup_store = None raw_accounts = config.get("accounts", {}) if config else {} if isinstance(raw_accounts, dict): self._accounts_config = raw_accounts @property def agent_id(self) -> str: return self._agent_id or "" @agent_id.setter def agent_id(self, value: str) -> None: self._agent_id = value @property def account_name(self) -> str: return self._name def get_group_system_prompt(self, chat_guid: str) -> str | None: group_override = self._groups_config.get(chat_guid, {}) if isinstance(group_override, dict): prompt = group_override.get("system_prompt", "") if prompt: return prompt return None @property def webhook_ingress(self) -> WebhookIngress: return self._webhook_ingress @property def secret_contract(self) -> SecretContract: return self._secret_contract async def handle_webhook_event(self, headers: dict[str, str], body: bytes) -> bool: if not self._webhook_ingress.validate(headers, body): logger.warning("[BlueBubbles] Webhook signature validation failed") return False event = self._webhook_ingress.parse_event(body) if event is None: return False await self._handle_ws_event(event) return True async def connect(self) -> None: if self._status == ChannelStatus.CONNECTED: return self._status = ChannelStatus.CONNECTING logger.info(f"[BlueBubbles] Connecting to {self._server_url}") if not self._password: raise ChannelAuthenticationError() connect_timeout = max(self.config.get("request_timeout", 30.0), self._send_timeout_ms / 1000.0) send_timeout = max(self._send_timeout_ms / 1000.0, self.config.get("request_timeout", 30.0)) self._client = BlueBubblesClient( server_url=self._server_url, password=self._password, timeout=connect_timeout, connect_timeout=connect_timeout, send_timeout=send_timeout, allow_private_network=self._allow_private_network, auth_strategy=self.config.get("auth_strategy", "header"), auth_header_name=self.config.get("auth_header_name", "X-BB-Password"), ) 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": raise BlueBubblesConnectionError(health.last_error or "Server unhealthy") await self._probe_server_capabilities() self._monitor = BlueBubblesMonitor( server_url=self._server_url, password=self._password, on_event=self._handle_ws_event, on_disconnect=self._on_ws_disconnect, on_reconnect=self._on_ws_reconnect, ) await self._monitor.start() await self._connect_accounts() await self._start_catchup() self._status = ChannelStatus.CONNECTED logger.info("[BlueBubbles] Connected") async def _probe_server_capabilities(self) -> None: try: private_api = await probe_private_api(self._client) self._action_gate.set_private_api_available(private_api) logger.info("[BlueBubbles] Private API available: %s", private_api) except Exception: self._action_gate.set_private_api_available(False) logger.warning("[BlueBubbles] Failed to probe Private API, assuming unavailable") try: macos_version = await probe_macos_version(self._client) is_26_plus = is_macos_26_or_higher(macos_version) self._action_gate.set_macos_26_or_higher(is_26_plus) logger.info("[BlueBubbles] macOS version: %s (26+: %s)", macos_version, is_26_plus) except Exception: self._action_gate.set_macos_26_or_higher(False) logger.warning("[BlueBubbles] Failed to probe macOS version") async def _start_catchup(self) -> None: catchup_cfg = self._catchup_config if not catchup_cfg.get("enabled", True): return try: chats_resp = await self._client.get("/api/v1/chat/query") chats = chats_resp.get("data", []) if isinstance(chats_resp, dict) else [] chat_guids = [c.get("guid") for c in chats if c.get("guid")] if not chat_guids: return state_dir = self.config.get("state_dir", "./state") self._catchup_cursor_store = CatchupCursorStore(state_dir, self._account_id) self._catchup_task = asyncio.create_task( run_full_catchup( self._client, chat_guids, self._catchup_cursor_store, max_age_minutes=catchup_cfg.get("max_age_minutes", 120), per_run_limit=catchup_cfg.get("per_run_limit", 50), first_run_lookback_minutes=catchup_cfg.get("first_run_lookback_minutes", 30), max_failure_retries=catchup_cfg.get("max_failure_retries", 10), on_message=self._handle_catchup_message, ) ) logger.info("[BlueBubbles] Catchup task started for %d chats", len(chat_guids)) except Exception: logger.warning("[BlueBubbles] Failed to start catchup", exc_info=True) async def _handle_catchup_message(self, chat_guid: str, msg: dict) -> None: await self._handle_ws_event(msg) async def _connect_accounts(self) -> None: accounts = parse_accounts_config(self._accounts_config) for account_id, account_cfg in accounts.items(): if not account_cfg.enabled or account_id == "default": continue try: account_timeout = max(self.config.get("request_timeout", 30.0), self._send_timeout_ms / 1000.0) account_send_timeout = max(self._send_timeout_ms / 1000.0, self.config.get("request_timeout", 30.0)) account_client = BlueBubblesClient( server_url=account_cfg.server_url, password=account_cfg.password, timeout=account_timeout, connect_timeout=account_timeout, send_timeout=account_send_timeout, allow_private_network=self._allow_private_network, auth_strategy=self.config.get("auth_strategy", "header"), auth_header_name=self.config.get("auth_header_name", "X-BB-Password"), ) 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() if self._monitor: await self._monitor.stop() self._monitor = None if self._client: await self._client.__aexit__() self._client = None self._status = ChannelStatus.DISCONNECTED async def send(self, response: ChannelResponse) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") raw_chat_id = response.identity.channel_chat_id if self._is_handle_format(raw_chat_id): resolved = await resolve_chat_guid_with_auto_create( self._client, raw_chat_id, initial_text=response.content[:200] if response.message_type == MessageType.TEXT else "", ) if resolved: chat_guid = resolved else: return DeliveryResult(success=False, error=f"Failed to resolve or create chat for {raw_chat_id}") else: chat_guid = raw_chat_id if response.message_type in {MessageType.IMAGE, MessageType.VIDEO, MessageType.AUDIO, MessageType.FILE}: result = await self._send_media_message(response, chat_guid) else: reply_to_guid = response.metadata.get("reply_to_guid") or response.reply_to_message_id result = await self._send_text_chunked(chat_guid, response.content, reply_to_guid) if result.success and result.message_id: self._self_chat_cache.add(result.message_id) self._sent_cache.track(result.message_id, chat_guid) return result async def _send_media_message(self, response: ChannelResponse, chat_guid: str) -> DeliveryResult: if not response.attachments: return DeliveryResult(success=False, error="Missing attachment for media message") att = response.attachments[0] file_path = att.url or "" if not file_path: return DeliveryResult(success=False, error="Attachment has no URL") try: result = await send_attachment( self._client, chat_guid, file_path, caption=response.content or "", allowed_roots=self._media_local_roots or None, ) return DeliveryResult( success=True, message_id=result.get("guid") or result.get("messageGuid"), ) except BlueBubblesHTTPError as e: if e.retryable: try: result = await send_attachment( self._client, chat_guid, file_path, caption=response.content or "", allowed_roots=self._media_local_roots or None, ) return DeliveryResult( success=True, message_id=result.get("guid") or result.get("messageGuid"), ) except Exception as retry_e: logger.error(f"[BlueBubbles] Media send retry failed: {retry_e}") return DeliveryResult(success=False, error=str(retry_e)) return DeliveryResult(success=False, error=str(e)) except Exception as e: logger.error(f"[BlueBubbles] Media send failed: {e}") return DeliveryResult(success=False, error=str(e)) async def _send_text_chunked(self, chat_guid: str, content: str, reply_to_guid: str | None) -> DeliveryResult: if len(content) <= self.text_chunk_limit: try: return await send_html_message(self._client, chat_guid, content, reply_to_guid) except Exception as e: logger.error(f"[BlueBubbles] Send failed: {e}") return DeliveryResult(success=False, error=str(e)) chunks = self._split_text(content, self.text_chunk_limit) last_result = DeliveryResult(success=False, error="No chunks sent") for idx, chunk in enumerate(chunks): guide = reply_to_guid if idx == 0 else None try: last_result = await send_html_message(self._client, chat_guid, chunk, guide) except Exception as e: logger.error(f"[BlueBubbles] Chunk {idx + 1}/{len(chunks)} send failed: {e}") return DeliveryResult(success=False, error=str(e)) return last_result def _split_text(self, content: str, limit: int) -> list[str]: if len(content) <= limit: return [content] if self._chunk_mode == "newline": return self._split_by_newlines(content, limit) return self._split_by_length(content, limit) @staticmethod def _is_handle_format(chat_id: str) -> bool: return bool(chat_id and (chat_id.startswith("+") or "@" in chat_id or chat_id.count(";") == 1)) @staticmethod def _split_by_newlines(content: str, limit: int) -> list[str]: lines = content.split("\n") chunks: list[str] = [] current = "" for line in lines: if not current: if len(line) > limit: word_chunks = BlueBubblesAdapter._split_by_length(line, limit) for wc in word_chunks[:-1]: chunks.append(wc) current = word_chunks[-1] else: current = line continue candidate = f"{current}\n{line}" if len(candidate) <= limit: current = candidate else: chunks.append(current) if len(line) > limit: word_chunks = BlueBubblesAdapter._split_by_length(line, limit) for wc in word_chunks[:-1]: chunks.append(wc) current = word_chunks[-1] else: current = line if current: chunks.append(current) return chunks @staticmethod def _split_by_length(content: str, limit: int) -> list[str]: chunks: list[str] = [] remaining = content while len(remaining) > limit: split_at = limit for i in range(limit - 1, max(limit // 2, 0), -1): if remaining[i] in (" ", "\n", "\t"): split_at = i + 1 break chunks.append(remaining[:split_at]) remaining = remaining[split_at:] if remaining: chunks.append(remaining) return chunks async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") if media_type.upper() == "STICKER": return await self._send_sticker_media(chat_id, data) try: if isinstance(data, str): result = await send_attachment( self._client, chat_id, data, allowed_roots=self._media_local_roots or None, ) elif isinstance(data, bytes): suffix = self._MEDIA_SUFFIX_MAP.get(media_type, ".bin") filename = f"media{suffix}" try: result = await send_attachment_bytes(self._client, chat_id, data, filename) except Exception: with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: tmp.write(data) tmp_path = tmp.name try: result = await send_attachment( self._client, chat_id, tmp_path, allowed_roots=self._media_local_roots or None, ) finally: os.unlink(tmp_path) else: return DeliveryResult(success=False, error=f"Unsupported data type: {type(data)}") msg_id = result.get("guid") or result.get("messageGuid") if msg_id: self._self_chat_cache.add(msg_id) self._sent_cache.track(msg_id, chat_id) return DeliveryResult(success=True, message_id=msg_id) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: if not self._tapback_enabled: return DeliveryResult(success=False, error="Tapback reactions are disabled") if not self._client: return DeliveryResult(success=False, error="Client not initialized") tapback = resolve_tapback_value(emoji) if tapback is None: return DeliveryResult(success=False, error=f"Unsupported tapback: {emoji}") try: await send_tapback(self._client, chat_id, msg_id, tapback) return DeliveryResult(success=True, message_id=msg_id) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def _send_sticker_media(self, chat_id: str, sticker_data: Any) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") try: result = await send_sticker(self._client, chat_id, sticker_data) return result 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: if not self._client: return DeliveryResult(success=False, error="Client not initialized") try: result = await send_with_effect(self._client, chat_id, content, effect, reply_to) return DeliveryResult(success=True, message_id=result.get("guid")) except ValueError as e: return DeliveryResult(success=False, error=str(e)) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def get_chats(self) -> list[dict[str, Any]]: if not self._client: return [] return await list_chats(self._client) async def get_contacts(self) -> list[dict[str, Any]]: if not self._client: return [] return await list_contacts(self._client) async def find_chats(self, query: str) -> list[dict[str, Any]]: if not self._client: return [] return await search_chats(self._client, query) async def find_contacts(self, query: str) -> list[dict[str, Any]]: if not self._client: return [] return await search_contacts(self._client, query) async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") if not msg_id: identity = self._build_stream_identity(chat_id, "") response = ChannelResponse(identity=identity, content=chunk) return await self.send(response) key = f"{chat_id}:{msg_id}" 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) 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 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] = time.monotonic() 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)) async def send_lane_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool = False) -> DeliveryResult: if not self._client or not msg_id: return DeliveryResult(success=False, error="Client not initialized or no msg_id") key = f"{chat_id}:{msg_id}" self._lane_buffers[key] = self._lane_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) combined_lane = self._lane_buffers.pop(key, "") prev_lane = self._lane_sent.get(key, "") full_lane = prev_lane + combined_lane self._lane_sent[key] = full_lane reasoning_content = self._stream_sent.get(key, "") display_content = self._compose_stream_content(reasoning_content, full_lane, 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] Lane chunk update failed: {e}") return DeliveryResult(success=False, error=str(e)) @staticmethod def _compose_stream_content(reasoning: str, lane: str, finished: bool) -> str: if not lane: return reasoning details_tag = "open" if not finished else "" lane_html = lane.replace("\n", "
") return f"
Thinking...{lane_html}
\n\n{reasoning}" def _cleanup_stream_key(self, key: str) -> None: self._stream_buffers.pop(key, None) self._stream_sent.pop(key, None) self._lane_buffers.pop(key, None) self._lane_sent.pop(key, None) self._last_edit_at.pop(key, None) def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage: event_type = raw.get("type", "") data = raw.get("data", {}) if event_type == "chat-read-receipt": return self._build_event_message(EventType.MESSAGE_UPDATED, data, "read_receipt") if event_type == "typing-indicator": sender_guid = data.get("senderGuid", "unknown") chat_guid = data.get("chatGuid", "unknown") return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=sender_guid, channel_chat_id=chat_guid, ), event_type=EventType.MESSAGE_UPDATED, message_type=MessageType.TEXT, chat_type=resolve_chat_type(chat_guid), content="", metadata={"event": "typing", "display": data.get("display", False)}, ) if event_type == "chat-name-change": chat_guid = data.get("chatGuid", "unknown") return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id="system", channel_chat_id=chat_guid, ), event_type=EventType.MESSAGE_UPDATED, message_type=MessageType.TEXT, chat_type=resolve_chat_type(chat_guid), content="", metadata={"event": "chat_name_change", "new_name": data.get("newName", "")}, ) if event_type == "updated-message": return self._normalize_message_event(data) if event_type == "participant-removed": return self._build_participant_event(data, EventType.MEMBER_LEFT) if event_type == "participant-added": return self._build_participant_event(data, EventType.MEMBER_JOINED) if event_type != "new-message": return self._build_passthrough_event(data, event_type) return self._normalize_message_event(data) def _normalize_message_event(self, data: dict[str, Any]) -> ChannelMessage: chat = data.get("chat", {}) message = data.get("message", {}) sender = message.get("sender", {}) chat_guid = chat.get("guid", "unknown") chat_type = ChatType.GROUP if chat.get("isGroup", False) else ChatType.DIRECT sender_address = sender.get("address", "unknown") content = message.get("text", "") if not content: content = self._extract_special_content(message) if data.get("isTapback"): tapback_value = message.get("tapback", -1) content = self._describe_tapback(tapback_value) attachments = self._extract_attachments(message) message_type = self._resolve_message_type(message, attachments) metadata: dict[str, Any] = { "bluebubbles_chat_guid": chat_guid, "bluebubbles_message_guid": message.get("guid"), "chat_display_name": chat.get("displayName", ""), "is_group": chat.get("isGroup", False), "reply_to_guid": message.get("replyToGuid"), } mentions = self._detect_mentions(message) if mentions: metadata["mentions"] = mentions metadata["is_bot_mentioned"] = any(m == sender_address for m in mentions) if self._is_forwarded(message): metadata["is_forwarded"] = True if chat.get("isGroup"): metadata["group_name"] = chat.get("displayName", "") metadata["group_participants"] = [p.get("address") for p in chat.get("participants", [])] timestamp = utc_now_naive() date_created = message.get("dateCreated", 0) if date_created: try: timestamp = datetime.fromtimestamp(date_created / 1000, tz=UTC) except (OSError, ValueError): pass reply_to = message.get("replyToGuid") or data.get("replyToGuid") return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=sender_address, channel_chat_id=chat_guid, channel_message_id=message.get("guid"), ), event_type=EventType.MESSAGE_RECEIVED, message_type=message_type, chat_type=chat_type, content=content, attachments=attachments, reply_to_message_id=reply_to, metadata=metadata, timestamp=timestamp, ) def _extract_attachments(self, message: dict[str, Any]) -> list[Attachment]: result: list[Attachment] = [] for att in message.get("attachments", []): mime = att.get("mimeType", "") att_type = "file" if mime.startswith("image/"): att_type = "image" elif mime.startswith("video/"): att_type = "video" elif mime.startswith("audio/"): att_type = "audio" result.append( Attachment( type=att_type, url=att.get("filePath") or att.get("url"), filename=att.get("fileName"), mime_type=mime, size_bytes=att.get("fileSize", 0), ) ) return result def _resolve_message_type(self, message: dict[str, Any], attachments: list[Attachment]) -> MessageType: location = message.get("location") if isinstance(location, dict): if location.get("latitude") is not None and location.get("longitude") is not None: return MessageType.LOCATION sticker = message.get("sticker") if sticker is None: sticker = message.get("stickerData") if isinstance(sticker, dict): return MessageType.STICKER vcard = message.get("vcard") if vcard is None: vcard = message.get("contactDetails") if isinstance(vcard, dict): return MessageType.CARD if attachments: mime = attachments[0].mime_type or "" if mime.startswith("image/"): return MessageType.IMAGE elif mime.startswith("video/"): return MessageType.VIDEO elif mime.startswith("audio/"): return MessageType.AUDIO return MessageType.FILE return MessageType.TEXT def _extract_special_content(self, message: dict[str, Any]) -> str: location = message.get("location") if isinstance(location, dict): lat = location.get("latitude") lng = location.get("longitude") addr = location.get("address", "") if lat is not None and lng is not None: base = f"Location: ({lat}, {lng})" return f"{base} - {addr}" if addr else base sticker = message.get("sticker") if sticker is None: sticker = message.get("stickerData") if isinstance(sticker, dict): desc = sticker.get("stickerDescription") or sticker.get("description") or "" return desc or "[Sticker]" contact = message.get("vcard") if contact is None: contact = message.get("contactDetails") if isinstance(contact, dict): name = contact.get("name") or contact.get("displayName") or "" return name or "[Contact]" return "" def _describe_tapback(self, tapback_value: int) -> str: names = { 0: "❤️ Loved", 1: "👍 Liked", 2: "👎 Disliked", 3: "😄 Laughed", 4: "❗ Emphasized", 5: "❓ Questioned", } return names.get(tapback_value, "Reacted") def _build_event_message(self, event_type: EventType, data: dict[str, Any], event_name: str) -> ChannelMessage: chat_guid = data.get("chatGuid", "unknown") sender_guid = data.get("senderGuid", "unknown") return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=sender_guid, channel_chat_id=chat_guid, ), event_type=event_type, message_type=MessageType.TEXT, chat_type=resolve_chat_type(chat_guid), content="", metadata={"event": event_name, "raw": data}, ) def _build_passthrough_event(self, data: dict[str, Any], event_type: str) -> ChannelMessage: chat_guid = data.get("chatGuid", "unknown") return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id="system", channel_chat_id=chat_guid, ), event_type=EventType.MESSAGE_RECEIVED, message_type=MessageType.TEXT, chat_type=resolve_chat_type(chat_guid), content="", metadata={"event": event_type, "raw": data}, ) def _build_participant_event(self, data: dict[str, Any], event_type: EventType) -> ChannelMessage: chat_guid = data.get("chatGuid", "unknown") affected = data.get("address", data.get("affectedHandle", "unknown")) return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=affected, channel_chat_id=chat_guid, ), event_type=event_type, message_type=MessageType.TEXT, chat_type=resolve_chat_type(chat_guid), content="", metadata={ "event": event_type.value, "affected_handle": affected, "raw": data, }, ) @staticmethod def _detect_mentions(message: dict[str, Any], agent_handle: str = "") -> list[str]: mentioned: list[str] = [] text = message.get("text", "") if "@" in text: import re mention_pattern = re.compile(r"@(\S+)") mentioned = mention_pattern.findall(text) attributed_body = message.get("attributedBody", []) for part in attributed_body if isinstance(attributed_body, list) else []: mention_data = part.get("mention") if isinstance(part, dict) else None if isinstance(mention_data, dict): mentioned.append(mention_data.get("mention", "")) return mentioned @staticmethod def _is_forwarded(message: dict[str, Any]) -> bool: return bool(message.get("isForwarded", False)) def format_outbound(self, response: ChannelResponse) -> dict[str, Any]: chat_guid = response.identity.channel_chat_id if response.message_type in {MessageType.IMAGE, MessageType.VIDEO, MessageType.AUDIO, MessageType.FILE}: return { "endpoint": "/api/v1/message/attachment", "payload": { "chatGuid": chat_guid, "caption": response.content or "", }, } return { "endpoint": "/api/v1/message/text", "payload": { "chatGuid": chat_guid, "text": response.content, "isHTML": self.supports_markdown, }, } async def health_check(self) -> HealthStatus: if not self._client: try: check_client = BlueBubblesClient( server_url=self._server_url, password=self._password, timeout=10.0, allow_private_network=self._allow_private_network, ) async with check_client: result = await probe_imessage_login(check_client) return result except Exception as e: return HealthStatus(status="unhealthy", last_error=str(e)) try: health = await probe_imessage_login(self._client) if health.status == "healthy": health.last_connected_at = utc_now_naive() health.metadata["adapter_status"] = self._status.value 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( status="unhealthy", last_error=str(e), metadata={"adapter_status": self._status.value}, ) async def _handle_ws_event(self, event: dict[str, Any]) -> None: if self._reorder_buffer: self._reorder_buffer.enqueue(event) if not hasattr(self, "_reorder_buffer_bound"): self._reorder_buffer.set_on_flush(self._process_ordered_event) self._reorder_buffer_bound = True return await self._process_ws_event(event) async def _process_ordered_event(self, event: dict[str, Any]) -> None: await self._process_ws_event(event) async def _process_ws_event(self, event: dict[str, Any]) -> None: try: channel_msg = self.normalize_inbound(event) msg_guid = channel_msg.metadata.get("bluebubbles_message_guid") if msg_guid: if not self._monitor_debounce.should_process(msg_guid): return if self._self_chat_cache.is_self_message(msg_guid): return if self._inbound_dedupe.is_duplicate(msg_guid): return self._inbound_dedupe.mark_seen(msg_guid) if not self._is_message_allowed(channel_msg): return if ( self._coalesce_same_sender_dms and channel_msg.chat_type == ChatType.DIRECT and channel_msg.event_type == EventType.MESSAGE_RECEIVED ): sender_id = channel_msg.identity.channel_user_id await self._coalesce_dm(sender_id, channel_msg) return await self._handle_message(channel_msg) except Exception: logger.exception( "[BlueBubbles] Error processing WebSocket event: %s", event.get("type", "unknown"), ) async def _coalesce_dm(self, sender_id: str, channel_msg: ChannelMessage) -> None: buffer_key = f"dm:{sender_id}" if buffer_key not in self._coalesce_buffer: self._coalesce_buffer[buffer_key] = [] existing = self._coalesce_buffer[buffer_key] existing.append(channel_msg) if buffer_key in self._coalesce_tasks and not self._coalesce_tasks[buffer_key].done(): self._coalesce_tasks[buffer_key].cancel() debounce_ms = self._block_streaming_coalesce.get("max_flush_interval_ms", 500) self._coalesce_tasks[buffer_key] = asyncio.create_task(self._flush_coalesced_dms(buffer_key, debounce_ms)) async def _flush_coalesced_dms(self, buffer_key: str, debounce_ms: int) -> None: await asyncio.sleep(debounce_ms / 1000.0) messages = self._coalesce_buffer.pop(buffer_key, []) self._coalesce_tasks.pop(buffer_key, None) if not messages: return if len(messages) == 1: await self._handle_message(messages[0]) return combined_content = "\n".join(m.content for m in messages if m.content) combined_msg = messages[0] if combined_content: combined_msg = messages[0].model_copy(update={"content": combined_content}) await self._handle_message(combined_msg) def _is_message_allowed(self, channel_msg: ChannelMessage) -> bool: event_type = channel_msg.metadata.get("event", "") if event_type in {"typing", "read_receipt", "chat_name_change"}: return True if channel_msg.chat_type == ChatType.DIRECT: user_id = channel_msg.identity.channel_user_id dm_policy = self._dm_policy dm_allow_from = list(self._dm_allow_from) user_override = self._dms_overrides.get(user_id, {}) if isinstance(user_override, dict): if "dmPolicy" in user_override: dm_policy = user_override["dmPolicy"] if "dmAllowFrom" in user_override: dm_allow_from = user_override["dmAllowFrom"] allowed = self._check_policy(dm_policy, user_id, dm_allow_from) if allowed and dm_policy == "pairing": if user_id not in self._pairing_notify_sent: self._pairing_notify_sent.add(user_id) chat_guid = channel_msg.identity.channel_chat_id asyncio.create_task(self._send_pairing_notification(chat_guid)) return allowed if self._require_mention and channel_msg.chat_type == ChatType.GROUP: is_mentioned = channel_msg.metadata.get("is_bot_mentioned", False) if not is_mentioned: return False return self._check_policy( self._group_policy, channel_msg.identity.channel_chat_id, self._group_allow_chats, ) def _check_policy(self, policy: str, identifier: str, items: list[str]) -> bool: if policy == "open": return True if policy == "pairing": if identifier in self._paired_users: return True self._paired_users.add(identifier) return True if policy == "allowlist": return identifier in items if policy == "blocklist": return identifier not in items return True async def _send_pairing_notification(self, chat_guid: str) -> None: try: if self._client: await send_html_message( self._client, chat_guid, "Your pairing request has been approved.", ) except Exception: logger.debug("[BlueBubbles] Failed to send pairing notification to %s", chat_guid) async def get_user_info(self, channel_user_id: str) -> dict[str, Any]: if not self._client: return {} return await resolve_contact(self._client, channel_user_id) async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult: if not self._action_gate.is_action_allowed("edit"): return DeliveryResult(success=False, error="Edit not available on this server") if not self._client: return DeliveryResult(success=False, error="Client not initialized") try: await update_activity(self._client, chat_id, msg_id, content) return DeliveryResult(success=True, message_id=msg_id) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._action_gate.is_action_allowed("unsend"): return DeliveryResult(success=False, error="Unsend not available on this server") if not self._client: return DeliveryResult(success=False, error="Client not initialized") try: await unsend_message(self._client, chat_id, msg_id) return DeliveryResult(success=True, message_id=msg_id) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def download_media(self, file_id: str) -> bytes: if not self._client: raise BlueBubblesConnectionError("Client not initialized") return await download_attachment(self._client, file_id) async def send_typing(self, chat_id: str, display: bool = True) -> None: if self._client: try: await send_typing_indicator(self._client, chat_id, display) except Exception: logger.debug("[BlueBubbles] Typing indicator send skipped") async def send_read(self, chat_id: str) -> None: if not self._send_read_receipts: return if self._client: try: await send_read_receipt(self._client, chat_id) except Exception: logger.debug("[BlueBubbles] Read receipt send skipped") async def remove_reaction(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") try: await remove_tapback(self._client, chat_id, msg_id) return DeliveryResult(success=True, message_id=msg_id) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def rename_group(self, chat_guid: str, new_name: str) -> DeliveryResult: if not self._action_gate.is_action_allowed("rename_group"): return DeliveryResult(success=False, error="Rename group not available on this server") if not self._client: return DeliveryResult(success=False, error="Client not initialized") try: await rename_chat(self._client, chat_guid, new_name) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def set_group_icon(self, chat_guid: str, file_path: str) -> DeliveryResult: if not self._action_gate.is_action_allowed("set_group_icon"): return DeliveryResult(success=False, error="Set group icon not available on this server") if not self._client: return DeliveryResult(success=False, error="Client not initialized") try: await set_group_icon(self._client, chat_guid, file_path) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def add_group_participant(self, chat_guid: str, address: str) -> DeliveryResult: if not self._action_gate.is_action_allowed("add_participant"): return DeliveryResult(success=False, error="Add participant not available on this server") if not self._client: return DeliveryResult(success=False, error="Client not initialized") try: await add_participant(self._client, chat_guid, address) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def remove_group_participant(self, chat_guid: str, address: str) -> DeliveryResult: if not self._action_gate.is_action_allowed("remove_participant"): return DeliveryResult(success=False, error="Remove participant not available on this server") if not self._client: return DeliveryResult(success=False, error="Client not initialized") try: await remove_participant(self._client, chat_guid, address) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def leave_group(self, chat_guid: str) -> DeliveryResult: if not self._action_gate.is_action_allowed("leave_group"): return DeliveryResult(success=False, error="Leave group not available on this server") if not self._client: return DeliveryResult(success=False, error="Client not initialized") try: await leave_chat(self._client, chat_guid) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def _on_ws_disconnect(self) -> None: if self._status == ChannelStatus.CONNECTED: self._status = ChannelStatus.RECONNECTING logger.info("[BlueBubbles] WebSocket lost, reconnecting...") async def _on_ws_reconnect(self) -> None: self._status = ChannelStatus.CONNECTED logger.info("[BlueBubbles] WebSocket reconnected") if self._catchup_cursor_store and self._client: self._catchup_task = asyncio.create_task(self._start_catchup()) async def pre_connect(self) -> dict: try: check_client = BlueBubblesClient( server_url=self._server_url, password=self._password, timeout=10.0, allow_private_network=self._allow_private_network, ) async with check_client: server_info = await check_client.get("/api/v1/server/info") data = server_info.get("data", {}) return { "status": "ok", "imessage_connected": data.get("iMessageConnected", False), "server_version": data.get("version", "unknown"), } except Exception as e: return {"status": "error", "message": str(e)}