该提交新增了完整的BlueBubbles渠道插件,支持通过BlueBubbles Server集成iMessage功能,包含以下核心能力: 1. 支持私聊和群聊会话管理,自动区分会话类型 2. 完整的消息收发支持,包括文本、图片、语音、文件、视频消息 3. 支持消息反应、已读回执、消息编辑与撤回 4. 内置去重、防抖处理机制 5. 支持Webhook和WebSocket两种事件接收方式 6. 完善的权限与安全校验机制 7. 历史消息同步与抓包功能 8. TTS语音合成与发送支持 9. 群组管理能力,包括重命名、修改头像、增减成员等
202 lines
7.0 KiB
Python
202 lines
7.0 KiB
Python
import asyncio
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from yuxi.channel.extensions.bluebubbles.catchup import run_catchup
|
|
from yuxi.channel.extensions.bluebubbles.client import BlueBubblesClient, get_or_create_client
|
|
from yuxi.channel.extensions.bluebubbles.config import BlueBubblesConfigAdapter
|
|
from yuxi.channel.extensions.bluebubbles.dedupe import InboundDedupeStore
|
|
from yuxi.channel.extensions.bluebubbles.debounce import DebounceManager
|
|
from yuxi.channel.extensions.bluebubbles.monitor import process_inbound_message, websocket_loop
|
|
from yuxi.channel.extensions.bluebubbles.outbound import mark_chat_read
|
|
from yuxi.channel.extensions.bluebubbles.probe import probe_server, fetch_server_info
|
|
from yuxi.channel.extensions.bluebubbles.status import BlueBubblesStatus
|
|
from yuxi.channel.extensions.bluebubbles.webhook import create_webhook_router
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
config_adapter = BlueBubblesConfigAdapter()
|
|
|
|
|
|
class BlueBubblesGateway:
|
|
def __init__(self):
|
|
self._tasks: dict[str, asyncio.Task] = {}
|
|
self._clients: dict[str, BlueBubblesClient] = {}
|
|
self._abort_events: dict[str, asyncio.Event] = {}
|
|
self._dedupe_stores: dict[str, InboundDedupeStore] = {}
|
|
self._debounce_managers: dict[str, DebounceManager] = {}
|
|
self._statuses: dict[str, BlueBubblesStatus] = {}
|
|
self._webhook_routers: dict[str, object] = {}
|
|
|
|
async def start_account(self, config: dict, account_id: str, ctx) -> BlueBubblesStatus:
|
|
logger.info("Starting BlueBubbles account: %s", account_id)
|
|
|
|
account = await config_adapter.resolve_account(config, account_id)
|
|
if not account:
|
|
status = BlueBubblesStatus(account_id=account_id, issues=["Not configured"])
|
|
self._statuses[account_id] = status
|
|
return status
|
|
|
|
status = BlueBubblesStatus(
|
|
account_id=account_id,
|
|
server_url=account["server_url"],
|
|
)
|
|
self._statuses[account_id] = status
|
|
|
|
client = get_or_create_client(
|
|
account["server_url"],
|
|
account["password"],
|
|
account_id=account_id,
|
|
timeout_ms=account.get("send_timeout_ms", 30000),
|
|
allow_private_network=account.get("allow_private_network", False),
|
|
)
|
|
self._clients[account_id] = client
|
|
|
|
reachable = await probe_server(client)
|
|
status.connected = reachable
|
|
if not reachable:
|
|
status.issues.append("Server unreachable")
|
|
return status
|
|
|
|
server_info = await fetch_server_info(client)
|
|
status.private_api_enabled = server_info.private_api_enabled
|
|
status.imessage_logged_in = server_info.imessage_logged_in
|
|
status.os_version = server_info.os_version
|
|
status.macos_major = server_info.macos_major
|
|
|
|
if not server_info.imessage_logged_in:
|
|
status.issues.append("iMessage not logged in")
|
|
|
|
dedupe_store = InboundDedupeStore()
|
|
self._dedupe_stores[account_id] = dedupe_store
|
|
|
|
debounce_manager = DebounceManager(
|
|
window_ms=2500,
|
|
coalesce_dms=account.get("coalesce_same_sender_dms", False),
|
|
)
|
|
self._debounce_managers[account_id] = debounce_manager
|
|
|
|
abort_event = asyncio.Event()
|
|
self._abort_events[account_id] = abort_event
|
|
|
|
async def _on_message(msg):
|
|
if ctx.queue:
|
|
await ctx.queue.put(msg)
|
|
|
|
send_read_receipts = account.get("send_read_receipts", True)
|
|
private_api = server_info.private_api_enabled
|
|
|
|
async def _try_send_read_receipt(chat_guid: str):
|
|
if send_read_receipts and private_api:
|
|
await mark_chat_read(client, chat_guid)
|
|
|
|
task = asyncio.create_task(
|
|
websocket_loop(
|
|
client,
|
|
account_id,
|
|
abort_event,
|
|
dedupe_store=dedupe_store,
|
|
debounce_manager=debounce_manager,
|
|
on_message=_on_message,
|
|
send_read_receipt=_try_send_read_receipt if send_read_receipts else None,
|
|
),
|
|
name=f"bluebubbles-ws-{account_id}",
|
|
)
|
|
self._tasks[account_id] = task
|
|
status.ws_connected = True
|
|
|
|
webhook_secret = account.get("webhook_secret", "")
|
|
if webhook_secret:
|
|
self._webhook_routers[account_id] = create_webhook_router(
|
|
path=account.get("webhook_path", "/bluebubbles-webhook"),
|
|
webhook_secret=webhook_secret,
|
|
account_id=account_id,
|
|
dedupe_store=dedupe_store,
|
|
debounce_manager=debounce_manager,
|
|
on_message=_on_message,
|
|
send_read_receipt=_try_send_read_receipt if send_read_receipts else None,
|
|
)
|
|
status.webhook_configured = True
|
|
|
|
catchup_config = account.get("catchup")
|
|
if catchup_config and catchup_config.enabled:
|
|
state_dir = _resolve_state_dir(ctx)
|
|
asyncio.create_task(
|
|
_run_catchup_background(
|
|
client,
|
|
state_dir,
|
|
account_id,
|
|
catchup_config,
|
|
dedupe_store,
|
|
debounce_manager,
|
|
_on_message,
|
|
),
|
|
name=f"bluebubbles-catchup-{account_id}",
|
|
)
|
|
|
|
return status
|
|
|
|
async def stop_account(self, account_id: str):
|
|
logger.info("Stopping BlueBubbles account: %s", account_id)
|
|
|
|
abort = self._abort_events.pop(account_id, None)
|
|
if abort:
|
|
abort.set()
|
|
|
|
task = self._tasks.pop(account_id, None)
|
|
if task and not task.done():
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
client = self._clients.pop(account_id, None)
|
|
if client:
|
|
await client.close()
|
|
|
|
self._dedupe_stores.pop(account_id, None)
|
|
self._debounce_managers.pop(account_id, None)
|
|
self._webhook_routers.pop(account_id, None)
|
|
self._statuses.pop(account_id, None)
|
|
|
|
def get_status(self, account_id: str) -> BlueBubblesStatus | None:
|
|
return self._statuses.get(account_id)
|
|
|
|
def get_webhook_router(self, account_id: str):
|
|
return self._webhook_routers.get(account_id)
|
|
|
|
|
|
def _resolve_state_dir(ctx) -> Path:
|
|
if hasattr(ctx, "state_dir") and ctx.state_dir:
|
|
return Path(ctx.state_dir)
|
|
return Path("data")
|
|
|
|
|
|
async def _run_catchup_background(
|
|
client,
|
|
state_dir: Path,
|
|
account_id: str,
|
|
catchup_config,
|
|
dedupe_store,
|
|
debounce_manager,
|
|
on_message,
|
|
):
|
|
async def _process(msg_data: dict):
|
|
await process_inbound_message(
|
|
msg_data,
|
|
account_id,
|
|
dedupe_store=dedupe_store,
|
|
debounce_manager=debounce_manager,
|
|
on_message=on_message,
|
|
)
|
|
|
|
await run_catchup(
|
|
client,
|
|
state_dir,
|
|
account_id,
|
|
catchup_config,
|
|
process_message=_process,
|
|
dedupe_store=dedupe_store,
|
|
)
|