ForcePilot/backend/package/yuxi/channels/plugins/qqbot/qqbot_client.py

492 lines
17 KiB
Python
Raw Normal View History

"""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
loggeraccess_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 _execute_http(
self,
method: str,
url: str,
headers: dict[str, str],
*,
json_body: dict | None = None,
params: dict | None = None,
) -> httpx.Response:
"""执行 HTTP 请求HTTP 异常转换为契约异常。
不负责 token 注入与业务错误码校验由调用方处理优先复用
lifecycle 注入的共享连接池未注入时降级为每请求独立
``httpx.AsyncClient``兼容测试与未启用 lifecycle 的场景
"""
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,
)
raise error_translator.wrap_http_exception(
exc,
timeout_ms=int(timeout * 1000),
) from exc
return resp
@staticmethod
def _parse_json_response(resp: httpx.Response) -> dict:
"""解析 JSON 响应,校验 QQ Bot 业务错误码。
``code`` 0 时通过 ``error_translator.translate_qqbot_error``
转换为契约异常``code`` 0 时返回完整响应字典
"""
data = resp.json()
code = data.get("code", 0)
if code != 0:
raise error_translator.translate_qqbot_error(
code,
data.get("message", ""),
) from None
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 的调用方
误判有效期
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
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 = max(expires_in - TOKEN_REFRESH_LEAD_TIME_SEC, 1)
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
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,
) -> 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,
)
return self._parse_json_response(resp)
# ------------------------------------------------------------------
# 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"]