ForcePilot/backend/package/yuxi/channels/adapters/bluebubbles/adapter.py
Kris 3cfb7dee25 refactor(bluebubbles): 重构并新增多项蓝泡泡适配器功能
本次提交对蓝泡泡适配器进行了多维度改进:
1. 新增缓存超限自动清理逻辑,防止内存溢出
2. 增强流式打字同步功能,增加超时自动停止逻辑
3. 重构markdown渲染逻辑,修复格式匹配问题并支持更多语法
4. 新增配置校验工具函数,增加streaming_mode和auth_strategy的合法性校验
5. 新增多项环境配置参数,完善配置项
6. 重构初始化流程,新增多个工具类实例
7. 优化TTS禁用逻辑,新增aiohttp依赖检查
8. 重构流式消息发送逻辑,整合打字指示器逻辑
9. 新增健康状态检查的问题检测功能
2026-05-13 16:05:25 +08:00

1434 lines
60 KiB
Python

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", "<br>")
return f"<details {details_tag}><summary>Thinking...</summary>{lane_html}</details>\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)}