1. 优化HTTP客户端关闭异常捕获范围 2. 重构事件工具方法抽取公共模块 3. 新增频控缓存常量与身份缓存配置 4. 完善适配器依赖注入与配置读取 5. 实现被动消息频控与令牌刷新串行化 6. 修复会话、状态、目录等适配器逻辑 7. 优化探测适配器账户获取逻辑 8. 完善凭据轮换与生命周期钩子 9. 优化WebSocket连接处理与心跳机制
534 lines
19 KiB
Python
534 lines
19 KiB
Python
"""QQ Bot API HTTP 客户端。
|
||
|
||
通过 httpx.AsyncClient 调用 QQ Bot API v2,封装 getAppAccessToken
|
||
管理、通用请求与 C2C/Group 消息发送、富媒体上传、用户查询等能力。
|
||
HTTP 异常与 QQ Bot 业务错误码通过 error_translator 转换为契约层异常,
|
||
不向外抛原生异常(INV-7)。
|
||
|
||
access_token 通过 CachePort 缓存(key 为 ``qqbot:token:{app_id}``,
|
||
TTL=expires_in-60s),客户端本身不持有 mutable 状态(INV-5)。
|
||
|
||
连接池由 lifecycle 统一管理(onInit 创建 / onUnload 关闭),通过
|
||
``attach_http_client`` 注入;未注入时降级为每请求独立 client(兼容
|
||
测试与未启用 lifecycle 的场景)。
|
||
|
||
依赖方向:仅 import yuxi.channels.contract.* 与标准库 / httpx,不污染
|
||
框架层。error_translator 为本插件内部模块。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from yuxi.channels.contract.dtos.config import ConfigScope
|
||
from yuxi.channels.contract.errors import DependencyError
|
||
from yuxi.channels.contract.ports.driven.cache_port import CachePort
|
||
from yuxi.channels.contract.ports.driven.config_port import ConfigPort
|
||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||
|
||
from . import error_translator
|
||
from ._constants import (
|
||
API_BASE_URL,
|
||
C2C_MESSAGES_PREFIX,
|
||
FILES_SUFFIX,
|
||
GATEWAY_ENDPOINT_PATH,
|
||
GROUP_MESSAGES_PREFIX,
|
||
INTERACTIONS_PREFIX,
|
||
MESSAGES_SUFFIX,
|
||
QQBOT_API_DEP,
|
||
TOKEN_DEFAULT_TTL,
|
||
TOKEN_ENDPOINT_PATH,
|
||
TOKEN_REFRESH_LEAD_TIME_SEC,
|
||
USERS_ENDPOINT_PREFIX,
|
||
)
|
||
|
||
_TOKEN_CACHE_KEY_PREFIX = "qqbot:token:"
|
||
|
||
|
||
class QQBotClient:
|
||
"""QQ Bot API HTTP 客户端。
|
||
|
||
通过 DI 接收 ConfigPort / CachePort / LoggerPort,不访问全局 settings
|
||
或 logger。access_token 通过 CachePort 缓存,客户端不持有 mutable
|
||
状态(INV-5)。所有方法为 async,HTTP 异常通过
|
||
``error_translator.wrap_http_exception`` 转换,QQ Bot 业务错误码非 0
|
||
通过 ``error_translator.translate_qqbot_error`` 转换。
|
||
|
||
连接池由 lifecycle 统一管理(onInit 创建 / onUnload 关闭),通过
|
||
``attach_http_client`` 注入;未注入时降级为每请求独立 client。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
config_port: ConfigPort,
|
||
cache_port: CachePort,
|
||
logger_port: LoggerPort,
|
||
) -> None:
|
||
self._config = config_port
|
||
self._cache = cache_port
|
||
self._logger = logger_port
|
||
# 由 lifecycle 注入的共享连接池;为 None 时降级为每请求独立 client
|
||
self._http_client: httpx.AsyncClient | None = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# 连接池管理
|
||
# ------------------------------------------------------------------
|
||
|
||
def attach_http_client(self, client: httpx.AsyncClient) -> None:
|
||
"""注入 lifecycle 管理的共享 httpx 连接池。
|
||
|
||
由 ``QQBotLifecycleHandler.onInit`` 调用,将创建好的连接池注入
|
||
客户端;``onUnload`` 关闭前由 lifecycle 置空以避免悬挂引用。
|
||
|
||
@consistency
|
||
- 幂等:重复调用以最后一次注入的 client 为准
|
||
"""
|
||
self._http_client = client
|
||
|
||
def detach_http_client(self) -> None:
|
||
"""解除共享连接池引用(由 lifecycle 在 onUnload 关闭前调用)。
|
||
|
||
@consistency
|
||
- 幂等:重复调用安全
|
||
"""
|
||
self._http_client = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# 内部辅助
|
||
# ------------------------------------------------------------------
|
||
|
||
async def _get_account_config(self, account_id: str) -> dict[str, Any]:
|
||
"""读取账户级配置(app_id / app_secret)。"""
|
||
cv_app_id = await self._config.get(
|
||
"app_id",
|
||
scope=ConfigScope.ACCOUNT,
|
||
target=account_id,
|
||
)
|
||
cv_app_secret = await self._config.get(
|
||
"app_secret",
|
||
scope=ConfigScope.ACCOUNT,
|
||
target=account_id,
|
||
)
|
||
return {
|
||
"app_id": cv_app_id.value,
|
||
"app_secret": cv_app_secret.value,
|
||
}
|
||
|
||
async def _get_channel_config(self, key: str, default: Any = None) -> Any:
|
||
"""读取渠道级配置。"""
|
||
cv = await self._config.get(
|
||
key,
|
||
scope=ConfigScope.CHANNEL,
|
||
target="qqbot",
|
||
)
|
||
value = cv.value
|
||
return value if value is not None else default
|
||
|
||
async def _get_api_base(self) -> str:
|
||
"""获取 QQ Bot API 基础地址。"""
|
||
return await self._get_channel_config("api_base", API_BASE_URL)
|
||
|
||
async def _get_request_timeout_seconds(self) -> float:
|
||
"""从配置读取请求超时(秒)。"""
|
||
timeout_ms = await self._get_channel_config("http_timeout_ms", 30000)
|
||
return float(timeout_ms) / 1000.0
|
||
|
||
def _token_cache_key(self, app_id: str) -> str:
|
||
return f"{_TOKEN_CACHE_KEY_PREFIX}{app_id}"
|
||
|
||
async def _get_token_cache_ttl(self, expires_in: int) -> int:
|
||
"""获取 token 缓存 TTL(秒),受 ``token_cache_ttl`` 配置约束。
|
||
|
||
优先使用 ``expires_in - TOKEN_REFRESH_LEAD_TIME_SEC``(提前刷新),
|
||
并以配置的 ``token_cache_ttl`` 作为上限,避免缓存超过平台有效期。
|
||
"""
|
||
ttl = max(expires_in - TOKEN_REFRESH_LEAD_TIME_SEC, 1)
|
||
configured = await self._get_channel_config("token_cache_ttl", TOKEN_DEFAULT_TTL)
|
||
try:
|
||
cap = int(configured)
|
||
except (TypeError, ValueError):
|
||
cap = TOKEN_DEFAULT_TTL
|
||
return min(ttl, cap)
|
||
|
||
def _token_lock_key(self, app_id: str) -> str:
|
||
"""token 刷新咨询锁 key。"""
|
||
return f"qqbot:token_lock:{app_id}"
|
||
|
||
async def _execute_http(
|
||
self,
|
||
method: str,
|
||
url: str,
|
||
headers: dict[str, str],
|
||
*,
|
||
json_body: dict | None = None,
|
||
params: dict | None = None,
|
||
trace_id: str = "",
|
||
) -> httpx.Response:
|
||
"""执行 HTTP 请求,HTTP 异常转换为契约异常。
|
||
|
||
不负责 token 注入与业务错误码校验,由调用方处理。优先复用
|
||
lifecycle 注入的共享连接池;未注入时降级为每请求独立
|
||
``httpx.AsyncClient``(兼容测试与未启用 lifecycle 的场景)。
|
||
|
||
``trace_id`` 由调用方注入,用于关联错误与调用链路。
|
||
"""
|
||
timeout = await self._get_request_timeout_seconds()
|
||
try:
|
||
if self._http_client is not None:
|
||
resp = await self._http_client.request(
|
||
method,
|
||
url,
|
||
json=json_body,
|
||
params=params,
|
||
headers=headers,
|
||
timeout=timeout,
|
||
)
|
||
else:
|
||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||
resp = await client.request(
|
||
method,
|
||
url,
|
||
json=json_body,
|
||
params=params,
|
||
headers=headers,
|
||
)
|
||
resp.raise_for_status()
|
||
except httpx.HTTPError as exc:
|
||
await self._logger.exception(
|
||
"QQBot HTTP request failed",
|
||
exc_info=exc,
|
||
method=method,
|
||
url=url,
|
||
trace_id=trace_id,
|
||
)
|
||
raise error_translator.wrap_http_exception(
|
||
exc,
|
||
timeout_ms=int(timeout * 1000),
|
||
trace_id=trace_id,
|
||
) from exc
|
||
return resp
|
||
|
||
@staticmethod
|
||
def _parse_json_response(resp: httpx.Response, *, trace_id: str = "") -> dict:
|
||
"""解析 JSON 响应,校验 QQ Bot 业务错误码。
|
||
|
||
``code`` 非 0 时通过 ``error_translator.translate_qqbot_error``
|
||
转换为契约异常。``code`` 为 0 时返回完整响应字典。
|
||
``trace_id`` 由调用方注入,用于关联错误与调用链路。
|
||
"""
|
||
data = resp.json()
|
||
code = data.get("code", 0)
|
||
if code != 0:
|
||
raise error_translator.translate_qqbot_error(
|
||
code,
|
||
data.get("message", ""),
|
||
trace_id=trace_id,
|
||
)
|
||
return data
|
||
|
||
# ------------------------------------------------------------------
|
||
# Token 管理
|
||
# ------------------------------------------------------------------
|
||
|
||
async def get_app_access_token(self, app_id: str, app_secret: str) -> tuple[str, int]:
|
||
"""获取 access_token,缓存命中返回剩余 TTL。
|
||
|
||
缓存写入时存储绝对过期时间戳 ``expires_at``,缓存命中时根据
|
||
``expires_at - now`` 计算剩余 TTL,避免长期持有 token 的调用方
|
||
误判有效期。token 刷新通过 ``acquireAdvisoryLock`` 串行化,避免
|
||
并发场景下重复请求 token 端点(M-08)。
|
||
|
||
QQ Bot token 端点:``POST /app/getAppAccessToken``,
|
||
请求体 ``{"appId": "...", "clientSecret": "..."}``,
|
||
响应体 ``{"access_token": "...", "expires_in": "7200"}``。
|
||
|
||
@return (token, remaining_expires_in)
|
||
"""
|
||
cache_key = self._token_cache_key(app_id)
|
||
cached = await self._cache.get(cache_key)
|
||
if cached.is_some():
|
||
token, expires_at = cached.unwrap()
|
||
remaining = max(int(expires_at - time.time()), 0)
|
||
return token, remaining
|
||
|
||
# 通过咨询锁串行化 token 刷新,避免并发重复请求
|
||
lock_key = self._token_lock_key(app_id)
|
||
lock_token = await self._cache.acquireAdvisoryLock(lock_key, ttl_seconds=30)
|
||
try:
|
||
# double-checked locking:持有锁后再次检查缓存
|
||
cached = await self._cache.get(cache_key)
|
||
if cached.is_some():
|
||
token, expires_at = cached.unwrap()
|
||
remaining = max(int(expires_at - time.time()), 0)
|
||
return token, remaining
|
||
|
||
api_base = await self._get_api_base()
|
||
url = f"{api_base}{TOKEN_ENDPOINT_PATH}"
|
||
body = {"appId": app_id, "clientSecret": app_secret}
|
||
|
||
resp = await self._execute_http("POST", url, headers={}, json_body=body)
|
||
data = self._parse_json_response(resp)
|
||
token = data.get("access_token", "")
|
||
expires_in_str = data.get("expires_in", str(TOKEN_DEFAULT_TTL))
|
||
try:
|
||
expires_in = int(expires_in_str)
|
||
except (TypeError, ValueError):
|
||
expires_in = TOKEN_DEFAULT_TTL
|
||
if expires_in <= 0:
|
||
raise DependencyError(
|
||
dep=QQBOT_API_DEP,
|
||
cause=ValueError(f"invalid expires_in value: {expires_in}"),
|
||
)
|
||
expires_at = time.time() + expires_in
|
||
ttl = await self._get_token_cache_ttl(expires_in)
|
||
await self._cache.set(
|
||
cache_key,
|
||
(token, expires_at),
|
||
ttl_seconds=ttl,
|
||
)
|
||
await self._logger.debug(
|
||
"QQBot access_token refreshed",
|
||
app_id=app_id,
|
||
expires_in=expires_in,
|
||
)
|
||
return token, expires_in
|
||
finally:
|
||
if lock_token is not None:
|
||
await self._cache.releaseAdvisoryLock(lock_token)
|
||
|
||
async def _get_token_for_account(self, account_id: str) -> str:
|
||
"""获取账户的 access_token。"""
|
||
account_config = await self._get_account_config(account_id)
|
||
token, _ = await self.get_app_access_token(
|
||
account_config["app_id"],
|
||
account_config["app_secret"],
|
||
)
|
||
return token
|
||
|
||
async def _build_auth_header(self, account_id: str) -> dict[str, str]:
|
||
"""获取账户 token 并构造 Authorization 头。"""
|
||
token = await self._get_token_for_account(account_id)
|
||
return {"Authorization": f"QQBot {token}"}
|
||
|
||
# ------------------------------------------------------------------
|
||
# 通用请求
|
||
# ------------------------------------------------------------------
|
||
|
||
async def _request_json(
|
||
self,
|
||
method: str,
|
||
path: str,
|
||
account_id: str,
|
||
*,
|
||
json_body: dict | None = None,
|
||
params: dict | None = None,
|
||
trace_id: str = "",
|
||
) -> dict:
|
||
"""发起带 access_token 的请求并返回校验后的 JSON。"""
|
||
headers = await self._build_auth_header(account_id)
|
||
api_base = await self._get_api_base()
|
||
url = f"{api_base}{path}"
|
||
resp = await self._execute_http(
|
||
method,
|
||
url,
|
||
headers,
|
||
json_body=json_body,
|
||
params=params,
|
||
trace_id=trace_id,
|
||
)
|
||
return self._parse_json_response(resp, trace_id=trace_id)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Gateway API
|
||
# ------------------------------------------------------------------
|
||
|
||
async def get_gateway(self, account_id: str) -> str:
|
||
"""获取 WebSocket Gateway 接入点 URL。
|
||
|
||
调用 ``GET /gateway``,返回 ``{"url": "wss://..."}``。
|
||
"""
|
||
data = await self._request_json("GET", GATEWAY_ENDPOINT_PATH, account_id)
|
||
return data.get("url", "")
|
||
|
||
# ------------------------------------------------------------------
|
||
# 消息发送 API
|
||
# ------------------------------------------------------------------
|
||
|
||
async def send_c2c_message(
|
||
self,
|
||
account_id: str,
|
||
openid: str,
|
||
msg_type: int,
|
||
content: dict[str, Any],
|
||
*,
|
||
msg_id: str | None = None,
|
||
msg_seq: int | None = None,
|
||
) -> dict:
|
||
"""发送 C2C 消息。
|
||
|
||
调用 ``POST /v2/users/{openid}/messages``。
|
||
"""
|
||
path = f"{C2C_MESSAGES_PREFIX}/{openid}{MESSAGES_SUFFIX}"
|
||
body: dict[str, Any] = {
|
||
"content": content.get("content", ""),
|
||
"msg_type": msg_type,
|
||
}
|
||
if "markdown" in content:
|
||
body["markdown"] = content["markdown"]
|
||
if "keyboard" in content:
|
||
body["keyboard"] = content["keyboard"]
|
||
if "media" in content:
|
||
body["media"] = content["media"]
|
||
if "ark" in content:
|
||
body["ark"] = content["ark"]
|
||
if msg_id:
|
||
body["msg_id"] = msg_id
|
||
if msg_seq is not None:
|
||
body["msg_seq"] = msg_seq
|
||
return await self._request_json("POST", path, account_id, json_body=body)
|
||
|
||
async def send_group_message(
|
||
self,
|
||
account_id: str,
|
||
group_openid: str,
|
||
msg_type: int,
|
||
content: dict[str, Any],
|
||
*,
|
||
msg_id: str | None = None,
|
||
msg_seq: int | None = None,
|
||
) -> dict:
|
||
"""发送群聊消息。
|
||
|
||
调用 ``POST /v2/groups/{group_openid}/messages``。
|
||
"""
|
||
path = f"{GROUP_MESSAGES_PREFIX}/{group_openid}{MESSAGES_SUFFIX}"
|
||
body: dict[str, Any] = {
|
||
"content": content.get("content", ""),
|
||
"msg_type": msg_type,
|
||
}
|
||
if "markdown" in content:
|
||
body["markdown"] = content["markdown"]
|
||
if "keyboard" in content:
|
||
body["keyboard"] = content["keyboard"]
|
||
if "media" in content:
|
||
body["media"] = content["media"]
|
||
if "ark" in content:
|
||
body["ark"] = content["ark"]
|
||
if msg_id:
|
||
body["msg_id"] = msg_id
|
||
if msg_seq is not None:
|
||
body["msg_seq"] = msg_seq
|
||
return await self._request_json("POST", path, account_id, json_body=body)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 消息操作 API
|
||
# ------------------------------------------------------------------
|
||
|
||
async def recall_c2c_message(
|
||
self,
|
||
account_id: str,
|
||
openid: str,
|
||
message_id: str,
|
||
) -> dict:
|
||
"""撤回 C2C 消息。
|
||
|
||
调用 ``DELETE /v2/users/{openid}/messages/{message_id}``。
|
||
"""
|
||
path = f"{C2C_MESSAGES_PREFIX}/{openid}{MESSAGES_SUFFIX}/{message_id}"
|
||
return await self._request_json("DELETE", path, account_id)
|
||
|
||
async def recall_group_message(
|
||
self,
|
||
account_id: str,
|
||
group_openid: str,
|
||
message_id: str,
|
||
) -> dict:
|
||
"""撤回群聊消息。
|
||
|
||
调用 ``DELETE /v2/groups/{group_openid}/messages/{message_id}``。
|
||
"""
|
||
path = f"{GROUP_MESSAGES_PREFIX}/{group_openid}{MESSAGES_SUFFIX}/{message_id}"
|
||
return await self._request_json("DELETE", path, account_id)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 富媒体上传 API
|
||
# ------------------------------------------------------------------
|
||
|
||
async def upload_c2c_file(
|
||
self,
|
||
account_id: str,
|
||
openid: str,
|
||
file_type: int,
|
||
url: str,
|
||
srv_send_msg: bool = False,
|
||
) -> dict:
|
||
"""上传 C2C 富媒体文件。
|
||
|
||
调用 ``POST /v2/users/{openid}/files``,返回 ``file_info``。
|
||
``file_type``: 1 图片 / 2 视频 / 3 语音 / 4 文件。
|
||
"""
|
||
path = f"{C2C_MESSAGES_PREFIX}/{openid}{FILES_SUFFIX}"
|
||
body = {
|
||
"file_type": file_type,
|
||
"url": url,
|
||
"srv_send_msg": srv_send_msg,
|
||
}
|
||
return await self._request_json("POST", path, account_id, json_body=body)
|
||
|
||
async def upload_group_file(
|
||
self,
|
||
account_id: str,
|
||
group_openid: str,
|
||
file_type: int,
|
||
url: str,
|
||
srv_send_msg: bool = False,
|
||
) -> dict:
|
||
"""上传群聊富媒体文件。
|
||
|
||
调用 ``POST /v2/groups/{group_openid}/files``,返回 ``file_info``。
|
||
"""
|
||
path = f"{GROUP_MESSAGES_PREFIX}/{group_openid}{FILES_SUFFIX}"
|
||
body = {
|
||
"file_type": file_type,
|
||
"url": url,
|
||
"srv_send_msg": srv_send_msg,
|
||
}
|
||
return await self._request_json("POST", path, account_id, json_body=body)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 互动回调 API
|
||
# ------------------------------------------------------------------
|
||
|
||
async def ack_interaction(
|
||
self,
|
||
account_id: str,
|
||
interaction_id: str,
|
||
code: int = 0,
|
||
) -> dict:
|
||
"""回应互动回调。
|
||
|
||
调用 ``PUT /interactions/{interaction_id}``,``code``: 0 成功 /
|
||
1 操作失败 / 2 操作频繁 / 3 重复操作 / 4 没有权限 / 5 仅管理员操作。
|
||
"""
|
||
path = f"{INTERACTIONS_PREFIX}/{interaction_id}"
|
||
body = {"code": code}
|
||
return await self._request_json("PUT", path, account_id, json_body=body)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 用户查询 API
|
||
# ------------------------------------------------------------------
|
||
|
||
async def get_user(self, account_id: str, openid: str) -> dict:
|
||
"""查询用户详情。
|
||
|
||
调用 ``GET /users/{openid}``,返回用户昵称、头像等信息。
|
||
"""
|
||
path = f"{USERS_ENDPOINT_PREFIX}/{openid}"
|
||
return await self._request_json("GET", path, account_id)
|
||
|
||
|
||
__all__ = ["QQBotClient"]
|