211 lines
7.5 KiB
Python
211 lines
7.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,
|
|
40101,
|
|
403,
|
|
"400",
|
|
"401",
|
|
"40100",
|
|
"40101",
|
|
"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, 40100, 40101, 403, "400", "401", "40100", "40101", "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 mark_private_messages_read(
|
|
self,
|
|
access_token: str,
|
|
friend_id: int | str,
|
|
message_id: int | str,
|
|
) -> None:
|
|
"""Mark one private conversation read through its latest received message."""
|
|
friend_id_text = str(friend_id).strip()
|
|
message_id_text = str(message_id).strip()
|
|
if not friend_id_text.isdigit() or not message_id_text.isdigit():
|
|
raise BoxIMError("BOXIM 已读回执参数不正确")
|
|
await self._request(
|
|
"PUT",
|
|
"/message/private/readed",
|
|
access_token,
|
|
params={
|
|
"friendId": int(friend_id_text),
|
|
"messageId": int(message_id_text),
|
|
},
|
|
)
|
|
|
|
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
|