实现完整的QQ Bot渠道插件,包含所有必要适配器、客户端、生命周期处理器与配置清单: 1. 新增15个功能适配器覆盖入站、出站、会话、身份解析等全流程 2. 实现QQ Bot API客户端与错误翻译逻辑 3. 完善插件生命周期管理与资源池配置 4. 附带完整的manifest.json声明配置
492 lines
17 KiB
Python
492 lines
17 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 _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"]
|