"""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"]