"""Client for Huihui's self-hosted BOXIM production APIs.""" import hashlib import random import secrets import string import time from datetime import datetime, timedelta, timezone from typing import Any import httpx _CN_TZ = timezone(timedelta(hours=8)) class BoxIMError(RuntimeError): def __init__(self, message: str, *, code: Any = None, auth_error: bool = False): super().__init__(message) self.code = code self.auth_error = auth_error class BoxIMClient: """Exchange Huihui credentials and call BOXIM's private-message API.""" def __init__(self, config: dict): self.platform_base_url = config.get( "HUIHUI_PLATFORM_BASE_URL", "https://open.99hui.com/api" ).rstrip("/") self.im_base_url = config.get( "BOXIM_API_BASE_URL", "https://im.99hui.com/api" ).rstrip("/") self.app_id = config.get("HUIHUI_APP_ID", "") self.access_id = config.get("HUIHUI_ACCESS_ID", "") self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "") self.timeout = float(config.get("BOXIM_TIMEOUT_SECONDS", 20)) def _build_sign_params(self, extra: dict | None = None) -> dict: """Build the same signed form used by Huihui's current production app.""" params = { "appId": self.app_id, "accessId": self.access_id, "nonce": "".join(random.choices(string.ascii_lowercase + string.digits, k=12)), "timestamp": datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S"), "signType": "MD5", "signVersion": "1.0", **(extra or {}), } params.pop("accessSecret", None) params.pop("signature", None) sign_parts = [] for key in sorted(params): value = params[key] if value in (None, "", []): continue if isinstance(value, list): continue sign_parts.append(f"{key}={value}") sign_source = "&".join(sign_parts) + f"&accessSecret={self.access_secret}" params["signature"] = hashlib.md5(sign_source.encode("utf-8")).hexdigest().upper() return params @staticmethod def _response_payload(response: httpx.Response) -> dict: try: payload = response.json() except ValueError as exc: raise BoxIMError("BOXIM 返回了无效响应") from exc if not isinstance(payload, dict): raise BoxIMError("BOXIM 返回格式不正确") return payload async def exchange_access_token(self, huihui_token: str) -> dict: """Exchange a production Huihui token for a BOXIM access token.""" if not huihui_token: raise BoxIMError("缺少会会登录凭证", auth_error=True) if not (self.app_id and self.access_id and self.access_secret): raise BoxIMError("会会开放平台凭证未配置", auth_error=True) headers = { "Authorization": f"Bearer {huihui_token}", "appId": self.app_id, "windowAppId": self.app_id, } async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client: response = await client.post( f"{self.platform_base_url}/im/box/netease", headers=headers, data=self._build_sign_params(), ) payload = self._response_payload(response) data = payload.get("data") or {} code = payload.get("code") if response.status_code >= 400 or code not in (0, 200, "0", "200"): raise BoxIMError( payload.get("message") or "BOXIM 授权失败", code=code or response.status_code, auth_error=response.status_code in (400, 401, 403) or code in (400, 401, 40100, 403, "400", "401", "40100", "403"), ) if not data.get("accessToken"): raise BoxIMError("会会未返回 BOXIM 访问凭证", auth_error=True) return data async def _request( self, method: str, path: str, access_token: str, *, params: dict | None = None, json: dict | None = None, ) -> Any: headers = {"accessToken": access_token} async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.request( method, f"{self.im_base_url}{path}", headers=headers, params=params, json=json, ) payload = self._response_payload(response) code = payload.get("code") if response.status_code >= 400 or code not in (200, "200"): raise BoxIMError( payload.get("message") or "BOXIM 请求失败", code=code or response.status_code, auth_error=response.status_code in (400, 401, 403) or code in (400, 401, 403, "400", "401", "403"), ) return payload.get("data") async def get_self(self, access_token: str) -> dict: data = await self._request("GET", "/user/self", access_token) if not isinstance(data, dict) or data.get("id") is None: raise BoxIMError("BOXIM 未返回当前用户信息") return data async def fetch_private_messages(self, access_token: str, min_id: str = "0") -> list[dict]: data = await self._request( "GET", "/message/private/loadOfflineMessage", access_token, params={"minId": str(min_id or "0")}, ) if data is None: return [] if not isinstance(data, list): raise BoxIMError("BOXIM 私聊消息格式不正确") return [item for item in data if isinstance(item, dict)] async def send_private_message( self, access_token: str, peer_id: str, content: str, *, local_id: int | str | None = None, ) -> dict: local_id = int(local_id or (int(time.time() * 1000) * 1000 + secrets.randbelow(1000))) data = await self._request( "POST", "/message/private/send", access_token, json={ "localId": local_id, "recvId": int(peer_id) if str(peer_id).isdigit() else peer_id, "content": content, "type": 0, "receipt": False, "atUserIds": [], }, ) if not isinstance(data, dict): raise BoxIMError("BOXIM 未返回发送结果") return data