Files
huihuiSquare/digital-avatar-app/backend/services/boxim_client.py
T

179 lines
6.5 KiB
Python

"""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