ForcePilot/backend/package/yuxi/channels/adapters/bluebubbles/adapter.py
Kris 6aa1d52a8b feat(bluebubbles): 实现完整的BlueBubbles适配器基础组件
新增BlueBubbles适配器全套核心工具类与服务,包含会话管理、消息去重、Webhook验证、缓存系统、账号配置解析、聊天消息处理等完整功能模块,支持iMessage消息收发、群管理、反应特效、语音合成等能力,提供完善的健康检查与配置校验流程。
2026-05-12 00:42:57 +08:00

1205 lines
49 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.client import BlueBubblesClient
from yuxi.channels.adapters.bluebubbles.contact_resolver import resolve_contact
from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesConnectionError, BlueBubblesHTTPError
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.monitor import BlueBubblesMonitor, InboundReorderBuffer
from yuxi.channels.adapters.bluebubbles.monitor_debounce import MonitorDebounce
from yuxi.channels.adapters.bluebubbles.probe import probe_imessage_login
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_tapback,
send_typing_indicator,
unsend_message,
update_activity,
)
from yuxi.channels.adapters.bluebubbles.session import resolve_chat_type
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
@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,
),
),
)
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._allow_private_network: bool = 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._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._health_monitor_config: dict = config.get("health_monitor", {}) if config else {}
self._network_config: dict = config.get("network", {}) if config else {}
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 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)
self._client = BlueBubblesClient(
server_url=self._server_url,
password=self._password,
timeout=connect_timeout,
allow_private_network=self._allow_private_network,
auth_strategy=self.config.get("auth_strategy", "header"),
)
self._secret_contract.audit("password", "connect", {"server_url": self._server_url})
await self._client.__aenter__()
health = await self.health_check()
if health.status == "unhealthy":
raise BlueBubblesConnectionError(health.last_error or "Server unhealthy")
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()
self._status = ChannelStatus.CONNECTED
logger.info("[BlueBubbles] Connected")
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_client = BlueBubblesClient(
server_url=account_cfg.server_url,
password=account_cfg.password,
timeout=account_timeout,
allow_private_network=self._allow_private_network,
auth_strategy=self.config.get("auth_strategy", "header"),
)
await account_client.__aenter__()
self._account_clients[account_id] = account_client
logger.info("[BlueBubbles] Connected account: %s", account_id)
except Exception:
logger.warning("[BlueBubbles] Failed to connect account: %s", account_id)
async def disconnect(self) -> None:
for account_id, account_client in self._account_clients.items():
try:
await account_client.__aexit__()
except Exception:
logger.debug("[BlueBubbles] Error disconnecting account: %s", account_id)
self._account_clients.clear()
self._account_monitors.clear()
if self._monitor:
await self._monitor.stop()
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")
chat_guid = response.identity.channel_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)
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 _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")
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_map = {
"image": ".jpg",
"video": ".mp4",
"audio": ".mp3",
"file": ".bin",
}
suffix = suffix_map.get(media_type, ".bin")
filename = f"media{suffix}"
try:
result = await send_attachment_bytes(self._client, chat_id, data, filename)
except Exception:
suffix_map2 = {
"image": ".jpg",
"video": ".mp4",
"audio": ".mp3",
"file": ".bin",
}
suffix2 = suffix_map2.get(media_type, ".bin")
with tempfile.NamedTemporaryFile(suffix=suffix2, 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)
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_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}"
coalesce_enabled = self._block_streaming_coalesce.get("enabled", False)
max_flush_ms = self._block_streaming_coalesce.get("max_flush_interval_ms", 500)
if coalesce_enabled:
self._stream_buffers[key] = self._stream_buffers.get(key, "") + chunk
now = time.monotonic()
last = self._last_edit_at.get(key, 0)
if not finished and now - last < max_flush_ms / 1000.0:
return DeliveryResult(success=True, message_id=msg_id)
combined = self._stream_buffers.pop(key, "")
prev_sent = self._stream_sent.get(key, "")
full_content = prev_sent + combined
self._stream_sent[key] = full_content
lane_content = self._lane_sent.get(key, "")
display_content = self._compose_stream_content(full_content, lane_content, finished)
try:
await update_activity(self._client, chat_id, msg_id, display_content)
self._last_edit_at[key] = now
if finished:
self._cleanup_stream_key(key)
return DeliveryResult(success=True, message_id=msg_id)
except Exception as e:
logger.warning(f"[BlueBubbles] Stream chunk update failed: {e}")
return DeliveryResult(success=False, error=str(e))
self._stream_buffers[key] = self._stream_buffers.get(key, "") + chunk
now = time.monotonic()
last = self._last_edit_at.get(key, 0)
interval_s = self._edit_interval_ms / 1000.0
if not finished and now - last < interval_s:
return DeliveryResult(success=True, message_id=msg_id)
combined = self._stream_buffers.pop(key, "")
prev_sent = self._stream_sent.get(key, "")
full_content = prev_sent + combined
self._stream_sent[key] = full_content
lane_content = self._lane_sent.get(key, "")
display_content = self._compose_stream_content(full_content, lane_content, finished)
try:
await update_activity(self._client, chat_id, msg_id, display_content)
self._last_edit_at[key] = now
if finished:
self._cleanup_stream_key(key)
return DeliveryResult(success=True, message_id=msg_id)
except Exception as e:
logger.warning(f"[BlueBubbles] Stream chunk update failed: {e}")
return DeliveryResult(success=False, error=str(e))
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}
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._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._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._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._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._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._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._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")
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)}