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

197 lines
6.6 KiB
Python

from __future__ import annotations
import hashlib
import ipaddress
from typing import Any, Literal
from urllib.parse import urlparse
import httpx
from yuxi.channels.adapters.bluebubbles.exceptions import (
BlueBubblesAuthenticationError,
BlueBubblesHTTPError,
)
from yuxi.channels.infra.circuit_breaker import CircuitBreaker
from yuxi.utils.logging_config import logger
_PRIVATE_IP_RANGES = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
SSRFMode = Literal["global_allow", "hostname_whitelist", "default_deny"]
AuthStrategy = Literal["header", "query", "both"]
def _is_private_host(host: str) -> bool:
try:
addr = ipaddress.ip_address(host)
except ValueError:
return False
return any(addr in net for net in _PRIVATE_IP_RANGES)
def _resolve_hostname(host: str) -> set[str]:
import socket
try:
addrs = socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
return {a[4][0] for a in addrs}
except socket.gaierror:
return set()
def _build_client_cache_key(server_url: str, account_id: str = "", credential: str = "") -> str:
raw = f"{server_url}|{account_id}|{credential}"
return hashlib.sha256(raw.encode()).hexdigest()
class BlueBubblesClient:
def __init__(
self,
server_url: str,
password: str,
timeout: float = 30.0,
max_retries: int = 3,
allow_private_network: bool = False,
ssrf_mode: SSRFMode = "default_deny",
hostname_whitelist: set[str] | None = None,
auth_strategy: AuthStrategy = "header",
account_id: str = "",
):
self._base_url = server_url.rstrip("/")
self._password = password
self._timeout = timeout
self._max_retries = max_retries
self._allow_private_network = allow_private_network
self._ssrf_mode: SSRFMode = ssrf_mode
self._hostname_whitelist: set[str] = hostname_whitelist or set()
self._auth_strategy: AuthStrategy = auth_strategy
self._account_id = account_id
self._client_cache_key = _build_client_cache_key(server_url, account_id, password)
self._client: httpx.AsyncClient | None = None
self._breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0)
@property
def base_url(self) -> str:
return self._base_url
@property
def client_cache_key(self) -> str:
return self._client_cache_key
async def __aenter__(self) -> BlueBubblesClient:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
return self
async def __aexit__(self, *args) -> None:
if self._client:
await self._client.aclose()
self._client = None
async def get(self, path: str, **kwargs) -> dict[str, Any]:
return await self._breaker.call(lambda: self._request("GET", path, **kwargs))
async def post(self, path: str, **kwargs) -> dict[str, Any]:
return await self._breaker.call(lambda: self._request("POST", path, **kwargs))
async def put(self, path: str, **kwargs) -> dict[str, Any]:
return await self._breaker.call(lambda: self._request("PUT", path, **kwargs))
async def delete(self, path: str, **kwargs) -> dict[str, Any]:
return await self._breaker.call(lambda: self._request("DELETE", path, **kwargs))
def _check_ssrf(self, url: str) -> None:
if self._ssrf_mode == "global_allow":
return
parsed = urlparse(url)
host = parsed.hostname or ""
if not host:
return
if self._ssrf_mode == "hostname_whitelist":
if host in self._hostname_whitelist:
return
resolved_ips = _resolve_hostname(host)
for ip_addr in resolved_ips:
if _is_private_host(ip_addr) and not self._allow_private_network:
raise BlueBubblesHTTPError(0, f"SSRF blocked: hostname '{host}' resolves to private IP '{ip_addr}'")
return
if self._allow_private_network:
return
for resolved_ip in _resolve_hostname(host):
if _is_private_host(resolved_ip):
raise BlueBubblesHTTPError(
0, f"SSRF blocked: private network host '{host}' resolves to '{resolved_ip}'"
)
if _is_private_host(host):
raise BlueBubblesHTTPError(0, f"SSRF blocked: private network host '{host}'")
def _build_auth_headers(self) -> dict[str, str]:
headers: dict[str, str] = {}
if self._auth_strategy in ("header", "both"):
headers["X-BlueBubbles-Password"] = self._password
return headers
def _build_url(self, path: str) -> str:
base = f"{self._base_url}{path}"
if self._auth_strategy in ("query", "both") and "?" not in path:
import urllib.parse
password_encoded = urllib.parse.quote(self._password, safe="")
sep = "&" if "?" in path else "?"
return f"{base}{sep}password={password_encoded}"
return base
async def _request(self, method: str, path: str, **kwargs) -> dict[str, Any]:
if self._client is None:
raise BlueBubblesHTTPError(0, "Client not initialized")
url = self._build_url(path)
self._check_ssrf(url)
headers = kwargs.pop("headers", {})
headers.update(self._build_auth_headers())
logger.debug(f"[BlueBubbles] {method} {self._base_url}{path}")
resp = await self._client.request(method, url, headers=headers, **kwargs)
if resp.status_code == 401:
raise BlueBubblesAuthenticationError("Invalid server password")
if resp.status_code >= 400:
raise BlueBubblesHTTPError(
resp.status_code,
message=resp.text[:500],
headers=dict(resp.headers),
)
return resp.json()
async def close(self) -> None:
if self._client:
await self._client.aclose()
self._client = None
async def download(self, file_path: str) -> bytes:
if self._client is None:
raise BlueBubblesHTTPError(0, "Client not initialized")
url = self._build_url(file_path)
self._check_ssrf(url)
headers = self._build_auth_headers()
resp = await self._breaker.call(lambda: self._client.get(url, headers=headers))
return resp.content