ForcePilot/backend/package/yuxi/channels/adapters/bluebubbles/client.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

210 lines
7.1 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 = "",
connect_timeout: float | None = None,
send_timeout: float | None = None,
auth_header_name: str = "X-BB-Password",
):
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._auth_header_name = auth_header_name
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)
self._connect_timeout = connect_timeout or timeout
self._send_timeout = send_timeout or timeout
@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(
connect=self._connect_timeout,
read=self._send_timeout,
write=self._send_timeout,
pool=self._connect_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[self._auth_header_name] = 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