新增BlueBubbles适配器全套核心工具类与服务,包含会话管理、消息去重、Webhook验证、缓存系统、账号配置解析、聊天消息处理等完整功能模块,支持iMessage消息收发、群管理、反应特效、语音合成等能力,提供完善的健康检查与配置校验流程。
1205 lines
49 KiB
Python
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)}
|