本次提交对蓝泡泡适配器进行了多维度改进: 1. 新增缓存超限自动清理逻辑,防止内存溢出 2. 增强流式打字同步功能,增加超时自动停止逻辑 3. 重构markdown渲染逻辑,修复格式匹配问题并支持更多语法 4. 新增配置校验工具函数,增加streaming_mode和auth_strategy的合法性校验 5. 新增多项环境配置参数,完善配置项 6. 重构初始化流程,新增多个工具类实例 7. 优化TTS禁用逻辑,新增aiohttp依赖检查 8. 重构流式消息发送逻辑,整合打字指示器逻辑 9. 新增健康状态检查的问题检测功能
1434 lines
60 KiB
Python
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)}
|