ForcePilot/backend/package/yuxi/channels/plugins/qqbot/qqbot_client.py
Kris c888db4e1f refactor(qqbot): 重构并完善QQ Bot适配器功能
1. 优化HTTP客户端关闭异常捕获范围
2. 重构事件工具方法抽取公共模块
3. 新增频控缓存常量与身份缓存配置
4. 完善适配器依赖注入与配置读取
5. 实现被动消息频控与令牌刷新串行化
6. 修复会话、状态、目录等适配器逻辑
7. 优化探测适配器账户获取逻辑
8. 完善凭据轮换与生命周期钩子
9. 优化WebSocket连接处理与心跳机制
2026-07-09 04:19:12 +08:00

534 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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。所有方法为 asyncHTTP 异常通过
``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"]