ForcePilot/backend/package/yuxi/channel/extensions/bluebubbles/client.py
Kris b018e3eda4 feat(bluebubbles): 新增BlueBubbles(iMessage)渠道插件完整实现
该提交新增了完整的BlueBubbles渠道插件,支持通过BlueBubbles Server集成iMessage功能,包含以下核心能力:
1.  支持私聊和群聊会话管理,自动区分会话类型
2.  完整的消息收发支持,包括文本、图片、语音、文件、视频消息
3.  支持消息反应、已读回执、消息编辑与撤回
4.  内置去重、防抖处理机制
5.  支持Webhook和WebSocket两种事件接收方式
6.  完善的权限与安全校验机制
7.  历史消息同步与抓包功能
8.  TTS语音合成与发送支持
9.  群组管理能力,包括重命名、修改头像、增减成员等
2026-05-21 10:40:33 +08:00

128 lines
4.1 KiB
Python

import hashlib
from urllib.parse import urlparse
import httpx
class BlueBubblesAuthStrategy:
def apply(self, request_kwargs: dict, password: str) -> dict:
raise NotImplementedError
class QueryStringAuth(BlueBubblesAuthStrategy):
def apply(self, request_kwargs: dict, password: str) -> dict:
params = request_kwargs.get("params", {})
if isinstance(params, dict):
params["password"] = password
else:
params = {"password": password}
request_kwargs["params"] = params
return request_kwargs
class HeaderAuth(BlueBubblesAuthStrategy):
def __init__(self, header_name: str = "X-BB-Password"):
self.header_name = header_name
def apply(self, request_kwargs: dict, password: str) -> dict:
headers = request_kwargs.get("headers", {})
if isinstance(headers, dict):
headers[self.header_name] = password
else:
headers = {self.header_name: password}
request_kwargs["headers"] = headers
return request_kwargs
class BlueBubblesClient:
def __init__(
self,
server_url: str,
password: str,
account_id: str = "default",
timeout_ms: int = 30000,
auth_strategy: BlueBubblesAuthStrategy | None = None,
allow_private_network: bool = False,
):
self.server_url = server_url.rstrip("/")
self.password = password
self.account_id = account_id
self.timeout = httpx.Timeout(timeout_ms / 1000.0)
self.auth_strategy = auth_strategy or QueryStringAuth()
self._trusted_hostname = self._resolve_hostname(server_url)
self._allow_private_network = allow_private_network
self._client: httpx.AsyncClient | None = None
@staticmethod
def _resolve_hostname(url: str) -> str | None:
try:
return urlparse(url).hostname
except Exception:
return None
def _build_transport(self) -> httpx.AsyncHTTPTransport | None:
if self._allow_private_network:
return None
return None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
base_url=self.server_url,
timeout=self.timeout,
transport=self._build_transport(),
)
return self._client
def _fingerprint(self) -> str:
raw = f"{self.account_id}:{self.password}:{type(self.auth_strategy).__name__}"
return hashlib.sha256(raw.encode()).hexdigest()[:8]
async def request(self, method: str, path: str, **kwargs) -> httpx.Response:
kwargs = self.auth_strategy.apply(kwargs, self.password)
client = await self._get_client()
return await client.request(method, path, **kwargs)
async def get(self, path: str, **kwargs) -> httpx.Response:
return await self.request("GET", path, **kwargs)
async def post(self, path: str, **kwargs) -> httpx.Response:
return await self.request("POST", path, **kwargs)
async def put(self, path: str, **kwargs) -> httpx.Response:
return await self.request("PUT", path, **kwargs)
async def delete(self, path: str, **kwargs) -> httpx.Response:
return await self.request("DELETE", path, **kwargs)
async def ping(self) -> bool:
try:
resp = await self.get("/")
return resp.status_code < 500
except Exception:
return False
async def close(self):
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
_client_cache: dict[str, BlueBubblesClient] = {}
def get_or_create_client(
server_url: str,
password: str,
account_id: str = "default",
**kwargs,
) -> BlueBubblesClient:
fingerprint = hashlib.sha256(f"{account_id}:{password}:{server_url}".encode()).hexdigest()[:12]
if fingerprint not in _client_cache:
_client_cache[fingerprint] = BlueBubblesClient(server_url, password, account_id, **kwargs)
return _client_cache[fingerprint]
def clear_client_cache():
_client_cache.clear()