qqbotsdk-base-py 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qqbotsdk/api/agent.md ADDED
@@ -0,0 +1,40 @@
1
+ # agent.md — qqbotsdk-base/api(HTTP 会话与鉴权)
2
+
3
+ 适用范围:`packages/qqbotsdk-base/src/qqbotsdk/api/**`(`core.py`、`auth.py`)。
4
+
5
+ 职责:让所有业务包「不带任何鉴权代码」也能直接调用 OpenAPI。
6
+
7
+ ## MUST(必须做)
8
+
9
+ - MUST 让 `session` 保持**懒加载单例**:首次使用时才创建 `aiohttp.ClientSession`;`import` 阶段不得触碰事件循环。
10
+ - MUST 通过 `TraceConfig.on_request_start` 统一注入 `Authorization: QQBot <access_token>`,业务函数不得手写该请求头。
11
+ - MUST 只对 `https://api.bot.qq.com` 的请求注入鉴权头:
12
+ - 发往其它域名的绝对地址(COS 预签名上传)MUST NOT 带;
13
+ - `/app/getAppAccessToken` 本身 MUST NOT 带。
14
+ - MUST 在换取 token 时按**响应体 `code`** 判定失败(该接口业务错误仍返回 HTTP 200),失败抛 `QQBotAuthError`。
15
+ - MUST 容忍 `expires_in` 为字符串(官方示例是 `"7200"`)。
16
+ - MUST 在剩余有效期不足 60 秒时主动刷新,并用 `asyncio.Lock` 保证并发只换取一次。
17
+
18
+ ## SHOULD(应当做)
19
+
20
+ - SHOULD 保持 `get_access_token()` / `get_authorization()` / `reset_access_token()` 三个公开入口的签名稳定。
21
+ - SHOULD 让超时来自环境变量 `TIMEOUT`(毫秒,缺省 5000),便于部署调整。
22
+ - SHOULD 提供 `session.client` 暴露底层 `ClientSession`,供 `ws` 等需要原生能力的场景使用。
23
+
24
+ ## MUST NOT(禁止做)
25
+
26
+ - MUST NOT 在 `core.py` 里写业务接口逻辑;它只负责会话、请求头、错误面。
27
+ - MUST NOT 缓存到磁盘或用全局可变状态以外的机制持久化 token。
28
+ - MUST NOT 允许「无凭证时 import 就报错」:缺少 `APPID`/`APPSECRET` 只能在实际换取 token 时报错。
29
+ - MUST NOT 把 token 写进日志、异常 message 或 `repr`;需要标识时只打印长度或前几位掩码。
30
+ - MUST NOT 修改 `session` 的公开用法(`async with session.get(...) as resp`),否则 20+ 个业务模块会全部失效。
31
+
32
+ ## 自检
33
+
34
+ ```powershell
35
+ cd D:\QQBotSDK
36
+ .venv\Scripts\ruff.exe check packages\qqbotsdk-base\src\qqbotsdk\api
37
+ .venv\Scripts\pyright.exe
38
+ ```
39
+
40
+ 联调:`QQBOT` 凭证配置好后,`await get_me()` 应能返回机器人信息;注入假 token 时应得到 HTTP 401(说明头已注入)。
qqbotsdk/api/auth.py ADDED
@@ -0,0 +1,134 @@
1
+ """访问凭证(access_token)的获取, 缓存与自动刷新.
2
+
3
+ 凭证来自环境变量 ``APPID`` / ``APPSECRET``, 支持从项目根目录的 ``.env`` 读取.
4
+ 按官方文档, 凭证有效期 7200 秒, 且在过期前 60 秒内换取会直接返回新凭证, 因此这里
5
+ 在剩余有效时间不足 60 秒时主动刷新, 调用方无需关心过期.
6
+
7
+ ``QQBotAuthError`` 定义在 :mod:`qqbotsdk.errors`, 这里 re-export, 旧的
8
+ ``from qqbotsdk.api.auth import QQBotAuthError`` 仍然可用.
9
+ """
10
+
11
+ import asyncio
12
+ import os
13
+ import time
14
+ from typing import Final
15
+
16
+ from aiohttp import ClientSession
17
+ from dotenv import load_dotenv
18
+ from loguru import logger
19
+
20
+ from qqbotsdk.errors import QQBotAuthError
21
+
22
+ # 获取凭证的接口地址, 不走 BASE_URL 以避免与 core 循环依赖
23
+ TOKEN_URL: Final = "https://api.bot.qq.com/app/getAppAccessToken"
24
+
25
+ # 提前刷新余量: 官方文档为过期前 60 秒内可换到新凭证
26
+ REFRESH_MARGIN_SECONDS: Final = 60
27
+
28
+ load_dotenv()
29
+
30
+ _lock = asyncio.Lock()
31
+ _token: str | None = None
32
+ _expires_at: float = 0.0
33
+
34
+
35
+ def _credentials() -> tuple[str, str] | None:
36
+ """读取 appId/appSecret, 缺任一项视为未配置."""
37
+ app_id = os.getenv("APPID") or os.getenv("QQBOT_APPID")
38
+ app_secret = os.getenv("APPSECRET") or os.getenv("QQBOT_APPSECRET")
39
+ if not app_id or not app_secret:
40
+ return None
41
+ return app_id, app_secret
42
+
43
+
44
+ def _cached_token() -> str | None:
45
+ """命中缓存且未进入刷新窗口时返回当前凭证."""
46
+ if _token is not None and time.monotonic() < _expires_at - REFRESH_MARGIN_SECONDS:
47
+ return _token
48
+ return None
49
+
50
+
51
+ async def _fetch_token() -> tuple[str, int]:
52
+ credentials = _credentials()
53
+ if credentials is None:
54
+ raise QQBotAuthError(
55
+ "缺少 APPID / APPSECRET:请在 .env 或环境变量中配置机器人凭证"
56
+ )
57
+ app_id, app_secret = credentials
58
+
59
+ async with (
60
+ ClientSession() as client,
61
+ client.post(
62
+ TOKEN_URL,
63
+ json={"appId": app_id, "clientSecret": app_secret},
64
+ ) as resp,
65
+ ):
66
+ body: dict[str, object] = await resp.json(content_type=None)
67
+
68
+ # 该接口业务错误仍返回 HTTP 200, 需按 code 判定
69
+ if (code := body.get("code")) is not None:
70
+ message = body.get("message")
71
+ raise QQBotAuthError(
72
+ f"获取 access_token 失败:code={code} message={message!r}",
73
+ code=code if isinstance(code, int) else None,
74
+ )
75
+
76
+ token = body.get("access_token")
77
+ expires_in = body.get("expires_in")
78
+ if not isinstance(token, str) or not token:
79
+ raise QQBotAuthError(
80
+ f"获取 access_token 失败:响应缺少 access_token({body!r})"
81
+ )
82
+
83
+ try:
84
+ # 文档示例中 expires_in 为字符串 "7200", 两种形态都接受
85
+ ttl = int(expires_in) # pyright: ignore[reportArgumentType]
86
+ except (TypeError, ValueError):
87
+ ttl = 7200
88
+
89
+ return token, ttl
90
+
91
+
92
+ async def get_access_token(*, force: bool = False) -> str:
93
+ """返回可用的 access_token, 必要时刷新.
94
+
95
+ Args:
96
+ force: 忽略缓存强制刷新.
97
+ """
98
+ global _token, _expires_at
99
+
100
+ if not force and (cached := _cached_token()) is not None:
101
+ return cached
102
+
103
+ async with _lock:
104
+ # 并发进入时可能已被其他协程刷新, 二次检查避免重复请求
105
+ if not force and (cached := _cached_token()) is not None:
106
+ return cached
107
+
108
+ token, ttl = await _fetch_token()
109
+ _token = token
110
+ _expires_at = time.monotonic() + ttl
111
+ logger.debug(f"access_token 已刷新,有效期 {ttl} 秒")
112
+ return token
113
+
114
+
115
+ async def get_authorization() -> str:
116
+ """返回 Authorization 请求头的值, 格式为 ``QQBot ACCESS_TOKEN``."""
117
+ return f"QQBot {await get_access_token()}"
118
+
119
+
120
+ def reset_access_token() -> None:
121
+ """清空缓存凭证, 供测试或主动失效时使用."""
122
+ global _token, _expires_at
123
+ _token = None
124
+ _expires_at = 0.0
125
+
126
+
127
+ __all__ = [
128
+ "REFRESH_MARGIN_SECONDS",
129
+ "TOKEN_URL",
130
+ "QQBotAuthError",
131
+ "get_access_token",
132
+ "get_authorization",
133
+ "reset_access_token",
134
+ ]
qqbotsdk/api/core.py ADDED
@@ -0,0 +1,449 @@
1
+ """HTTP 会话: 懒加载 aiohttp 会话并自动注入鉴权请求头.
2
+
3
+ 对外暴露单例 ``session``, 用法与 ``aiohttp.ClientSession`` 一致::
4
+
5
+ async with session.get("/users/@me") as resp:
6
+ data = await resp.json()
7
+
8
+ 会话在首次使用时才创建, 因此可以在任意位置 import 本模块, 无需先有事件循环;
9
+ 发往 OpenAPI 域名的请求会自动附加 ``Authorization: QQBot <access_token>``,
10
+ 发往其它域名的绝对地址(例如素材上传的 COS 预签名 URL)则不附加该请求头.
11
+
12
+ 响应侧统一交给 :class:`QQBotResponse` 处理, 业务模块无需改动:
13
+
14
+ - 空响应 (204, 无 content-type, 空 body) 返回 ``{}``, 不再抛 ``ContentTypeError``;
15
+ - 非 JSON content-type 返回 ``{}``;
16
+ - OpenAPI 域名下 body 含非零 ``err_code`` / ``code`` 时抛 ``QQBotAPIError``;
17
+ - 命中 HTTP 429 (限频) 时自动退避重试, 最多 ``MAX_RETRIES`` 次, 见
18
+ :class:`_RetryingRequestContext`; 业务函数仍然只写 ``async with session.get(...)``.
19
+ """
20
+
21
+ import asyncio
22
+ import os
23
+ import random
24
+ from collections.abc import Awaitable, Callable, Generator, Mapping
25
+ from types import TracebackType
26
+ from typing import Any, Final, cast
27
+ from urllib.parse import urlparse
28
+
29
+ from aiohttp import ClientResponse, ClientSession, ClientTimeout, TraceConfig
30
+ from aiohttp.tracing import TraceRequestStartParams
31
+ from aiohttp.typedefs import DEFAULT_JSON_DECODER, JSONDecoder
32
+ from loguru import logger
33
+
34
+ from qqbotsdk.api.auth import get_authorization
35
+ from qqbotsdk.errors import QQBotAPIError
36
+
37
+ # OpenAPI 统一域名
38
+ BASE_URL: Final = "https://api.bot.qq.com"
39
+
40
+ # 默认请求超时(毫秒), 可由环境变量 TIMEOUT 覆盖
41
+ DEFAULT_TIMEOUT_MS: Final = 5000
42
+
43
+ # 429 自动重试: 最多重试次数(不含首次请求)
44
+ MAX_RETRIES: Final = 3
45
+
46
+ # 退避基准秒数: 第 n 次重试等待 RETRY_BASE_SECONDS * 2 ** n, 再叠加抖动
47
+ RETRY_BASE_SECONDS: Final = 1.0
48
+
49
+ # 一次请求的所有重试等待总和上限(秒)
50
+ RETRY_TOTAL_WAIT_SECONDS: Final = 30.0
51
+
52
+ # 抖动比例: 在退避时长上叠加 0~10% 随机量, 避免多客户端同时重试
53
+ _RETRY_JITTER_RATIO: Final = 0.1
54
+
55
+ # 需要自动重试的状态码. 429 表示请求未被处理, 重发不会重复产生副作用
56
+ _TOO_MANY_REQUESTS: Final = 429
57
+
58
+ # 换取凭证的接口本身不需要也不应该带鉴权头
59
+ _NO_AUTH_PATHS: Final = frozenset({"/app/getAppAccessToken"})
60
+
61
+ _API_HOST: Final[str] = urlparse(BASE_URL).hostname or ""
62
+
63
+ # 统一错误检查生效的域名. 默认只有 OpenAPI 域名, 其它域名(COS 预签名等)保持原样;
64
+ # 测试把本地假服务端的 host 加进来即可走同一段检查逻辑.
65
+ CHECKED_HOSTS: set[str] = {_API_HOST}
66
+
67
+ # OpenAPI 在响应头里回传的追踪 ID
68
+ _TRACE_ID_HEADER: Final = "X-Tps-trace-ID"
69
+
70
+ # 业务错误码字段: err_code 为通用字段, code 用于 /app/getAppAccessToken
71
+ _ERROR_CODE_KEYS: Final = ("err_code", "code")
72
+
73
+ # 错误体可能只含这些字段
74
+ _ERROR_BODY_KEYS: Final = frozenset({"code", "err_code", "message", "trace_id"})
75
+
76
+ # 官方说明 201/202 表示异步受理, 正常无响应体; 有错误体时按错误处理
77
+ _ASYNC_STATUSES: Final = frozenset({201, 202})
78
+
79
+
80
+ def _timeout_ms() -> int:
81
+ raw = os.getenv("TIMEOUT")
82
+ if not raw:
83
+ return DEFAULT_TIMEOUT_MS
84
+ try:
85
+ return int(raw)
86
+ except ValueError:
87
+ return DEFAULT_TIMEOUT_MS
88
+
89
+
90
+ def _client_timeout() -> ClientTimeout:
91
+ return ClientTimeout(total=_timeout_ms() / 1000)
92
+
93
+
94
+ def _is_checked_host(host: str | None) -> bool:
95
+ """该域名的响应是否走统一错误检查."""
96
+ return host is not None and host in CHECKED_HOSTS
97
+
98
+
99
+ def _is_json_content_type(content_type: str) -> bool:
100
+ """content-type 是否为 JSON, 含 ``+json`` 后缀."""
101
+ mime = content_type.split(";", 1)[0].strip().lower()
102
+ return mime == "application/json" or mime.endswith("+json")
103
+
104
+
105
+ def _as_int(value: object) -> int | None:
106
+ """把业务码规范成 int, 无法解析时返回 None."""
107
+ if isinstance(value, bool):
108
+ return None
109
+ if isinstance(value, int):
110
+ return value
111
+ if isinstance(value, str):
112
+ try:
113
+ return int(value.strip())
114
+ except ValueError:
115
+ return None
116
+ return None
117
+
118
+
119
+ def _error_code(body: Mapping[str, Any]) -> int | None:
120
+ """返回 body 中的非零业务错误码, 无错误时返回 None."""
121
+ for key in _ERROR_CODE_KEYS:
122
+ raw = body.get(key)
123
+ if raw is None or isinstance(raw, bool):
124
+ continue
125
+ code = _as_int(raw)
126
+ if code is None:
127
+ # 字段存在但不是数字(例如字符串错误标识), 同样视为错误
128
+ if isinstance(raw, str) and raw.strip():
129
+ return 0
130
+ continue
131
+ if code != 0:
132
+ return code
133
+ return None
134
+
135
+
136
+ def _message_of(body: Mapping[str, Any]) -> str:
137
+ """取服务端返回的错误描述."""
138
+ raw = body.get("message")
139
+ if raw is None:
140
+ return ""
141
+ return raw if isinstance(raw, str) else str(raw)
142
+
143
+
144
+ def _trace_id_of(headers: Mapping[str, str], body: Mapping[str, Any]) -> str | None:
145
+ """取追踪 ID: 优先响应头, 回退到 body 的 trace_id."""
146
+ header = headers.get(_TRACE_ID_HEADER)
147
+ if header:
148
+ return header
149
+ raw = body.get("trace_id")
150
+ return raw if isinstance(raw, str) and raw else None
151
+
152
+
153
+ def _looks_like_error_body(body: Mapping[str, Any]) -> bool:
154
+ """判断 201/202 的 body 是否只有错误字段."""
155
+ return "message" in body and set(body) <= _ERROR_BODY_KEYS
156
+
157
+
158
+ def _as_dict(payload: object) -> dict[str, Any] | None:
159
+ """payload 为 dict 时返回带类型注解的视图, 否则返回 None."""
160
+ if isinstance(payload, dict):
161
+ return cast("dict[str, Any]", payload)
162
+ return None
163
+
164
+
165
+ def _check_api_error(
166
+ body: Mapping[str, Any],
167
+ *,
168
+ status: int,
169
+ trace_id: str | None,
170
+ ) -> None:
171
+ """body 表示业务错误时抛 ``QQBotAPIError``."""
172
+ code = _error_code(body)
173
+ if code is not None:
174
+ raise QQBotAPIError(status, code, _message_of(body), trace_id)
175
+ if status in _ASYNC_STATUSES and _looks_like_error_body(body):
176
+ raise QQBotAPIError(status, 0, _message_of(body), trace_id)
177
+
178
+
179
+ class QQBotResponse(ClientResponse):
180
+ """带统一错误检查的 ``ClientResponse``, 由 ``ClientSession(response_class=...)`` 注入.
181
+
182
+ 只处理 ``CHECKED_HOSTS`` 里的域名, 其它域名(如 COS 预签名上传)走 aiohttp 原逻辑.
183
+ """
184
+
185
+ async def json(
186
+ self,
187
+ *,
188
+ encoding: str | None = None,
189
+ loads: JSONDecoder = DEFAULT_JSON_DECODER,
190
+ content_type: str | None = "application/json",
191
+ ) -> Any:
192
+ """解析响应体, 顺带做统一错误检查.
193
+
194
+ Args:
195
+ encoding: 显式指定编码, 缺省按 content-type 推导.
196
+ loads: JSON 解析函数, 默认 ``json.loads``.
197
+ content_type: 期望的 content-type, 传 None 跳过校验.
198
+
199
+ Raises:
200
+ QQBotAPIError: body 携带非零业务错误码, 或 201/202 只返回了错误体.
201
+ """
202
+ if not _is_checked_host(self.url.host):
203
+ return await super().json(
204
+ encoding=encoding,
205
+ loads=loads,
206
+ content_type=content_type,
207
+ )
208
+
209
+ # 只读取一次 body, 后续都复用 aiohttp 的缓存, 避免二次消费
210
+ raw = await self.read()
211
+ if not raw.strip():
212
+ return {}
213
+ if content_type and not _is_json_content_type(
214
+ self.headers.get("Content-Type", "")
215
+ ):
216
+ return {}
217
+
218
+ payload: Any = loads(raw.decode(encoding or self.get_encoding()))
219
+ body = _as_dict(payload)
220
+ if body is not None:
221
+ trace_id = _trace_id_of(self.headers, body)
222
+ _check_api_error(body, status=self.status, trace_id=trace_id)
223
+ return payload
224
+
225
+ def raise_for_status(self) -> None:
226
+ """HTTP 4xx/5xx 统一换成 ``QQBotAPIError``.
227
+
228
+ OpenAPI 域名下的状态码错误也归入同一异常体系, 调用方只需 ``except QQBotAPIError``;
229
+ 其它域名(如 COS 预签名上传)保持 aiohttp 原行为.
230
+ """
231
+ if not _is_checked_host(self.url.host):
232
+ super().raise_for_status()
233
+ return
234
+ if self.status < 400:
235
+ return
236
+ reason = f"HTTP {self.status} {self.reason}".strip()
237
+ raise QQBotAPIError(
238
+ self.status,
239
+ 0,
240
+ reason,
241
+ self.headers.get(_TRACE_ID_HEADER),
242
+ )
243
+
244
+
245
+ def _retry_after_seconds(headers: Mapping[str, str]) -> float | None:
246
+ """从 ``Retry-After`` 响应头取等待秒数, 缺失或不是合法秒数时返回 None."""
247
+ raw = headers.get("Retry-After")
248
+ if raw is None:
249
+ return None
250
+ try:
251
+ seconds = float(raw.strip())
252
+ except ValueError:
253
+ # HTTP-date 形式不解析, 直接退回指数退避
254
+ return None
255
+ return seconds if seconds >= 0 else None
256
+
257
+
258
+ def _retry_delay(attempt: int, headers: Mapping[str, str], *, waited: float) -> float:
259
+ """计算第 ``attempt`` 次(从 0 开始)重试前应等待的秒数.
260
+
261
+ 优先使用 ``Retry-After``; 否则按 ``RETRY_BASE_SECONDS * 2 ** attempt`` 并叠加
262
+ 抖动. 结果还要受 ``RETRY_TOTAL_WAIT_SECONDS`` 约束, 且不小于 0.
263
+ """
264
+ delay = _retry_after_seconds(headers)
265
+ if delay is None:
266
+ delay = RETRY_BASE_SECONDS * (2**attempt)
267
+ delay += delay * _RETRY_JITTER_RATIO * random.random()
268
+ return max(0.0, min(delay, RETRY_TOTAL_WAIT_SECONDS - waited))
269
+
270
+
271
+ class _RetryingRequestContext:
272
+ """``async with session.get(...) as resp`` 的替身, 命中 HTTP 429 时自动重试.
273
+
274
+ 429 表示服务端因限频**没有处理**该请求, 因此重发不会重复产生副作用, 即便
275
+ ``POST`` 也可以安全重试; 其它状态码(含 4xx/5xx 业务错误)一律不重试, 原样抛出.
276
+
277
+ 会话级 ``raise_for_status`` 在这里统一关掉: 先自行判定 429, 其余交给
278
+ :meth:`QQBotResponse.raise_for_status` 抛 ``QQBotAPIError``, 调用方感知不到差异.
279
+ """
280
+
281
+ __slots__ = ("_label", "_request", "_response")
282
+
283
+ def __init__(
284
+ self,
285
+ request: Callable[[], Awaitable[ClientResponse]],
286
+ *,
287
+ label: str = "",
288
+ ) -> None:
289
+ self._request = request
290
+ self._label = label
291
+ self._response: ClientResponse | None = None
292
+
293
+ def __await__(self) -> Generator[Any, None, ClientResponse]:
294
+ """保留 ``resp = await session.get(...)`` 的旧写法, 等价于直接进入上下文."""
295
+ return self.__aenter__().__await__()
296
+
297
+ async def __aenter__(self) -> ClientResponse:
298
+ waited = 0.0
299
+ for attempt in range(MAX_RETRIES + 1):
300
+ response = await self._request()
301
+ if response.status == _TOO_MANY_REQUESTS and attempt < MAX_RETRIES:
302
+ delay = _retry_delay(attempt, response.headers, waited=waited)
303
+ waited += delay
304
+ logger.warning(
305
+ f"请求被限频(429),{delay:.2f} 秒后重试第 {attempt + 1} 次:"
306
+ f"{self._label}"
307
+ )
308
+ response.release()
309
+ await asyncio.sleep(delay)
310
+ continue
311
+ self._raise_for_status(response)
312
+ self._response = response
313
+ return response
314
+ raise RuntimeError("429 重试流程异常")
315
+
316
+ async def __aexit__(
317
+ self,
318
+ exc_type: type[BaseException] | None,
319
+ exc: BaseException | None,
320
+ traceback: TracebackType | None,
321
+ ) -> None:
322
+ response = self._response
323
+ self._response = None
324
+ if response is not None:
325
+ response.release()
326
+
327
+ def _raise_for_status(self, response: ClientResponse) -> None:
328
+ """等价于 ``raise_for_status=True``: 非 2xx 抛错, 并释放连接避免泄漏."""
329
+ try:
330
+ response.raise_for_status()
331
+ except BaseException:
332
+ response.release()
333
+ raise
334
+
335
+
336
+ async def _inject_authorization(
337
+ _session: ClientSession,
338
+ _ctx: object,
339
+ params: TraceRequestStartParams,
340
+ ) -> None:
341
+ """请求发出前注入鉴权头, aiohttp 会 await 协程形式的 trace 回调."""
342
+ if params.url.host != _API_HOST:
343
+ return
344
+ if params.url.path in _NO_AUTH_PATHS:
345
+ return
346
+ params.headers["Authorization"] = await get_authorization()
347
+
348
+
349
+ def _build_trace_config() -> TraceConfig:
350
+ trace_config = TraceConfig()
351
+ trace_config.on_request_start.append(_inject_authorization)
352
+ return trace_config
353
+
354
+
355
+ class QQBotSession:
356
+ """``aiohttp.ClientSession`` 的懒加载包装, 方法签名与 aiohttp 一致."""
357
+
358
+ __slots__ = ("_client",)
359
+
360
+ def __init__(self) -> None:
361
+ self._client: ClientSession | None = None
362
+
363
+ def _ensure(self) -> ClientSession:
364
+ if self._client is None or self._client.closed:
365
+ self._client = ClientSession(
366
+ base_url=BASE_URL,
367
+ raise_for_status=True,
368
+ timeout=_client_timeout(),
369
+ trace_configs=[_build_trace_config()],
370
+ response_class=QQBotResponse,
371
+ )
372
+ return self._client
373
+
374
+ def _request_context(
375
+ self,
376
+ method: str,
377
+ url: str,
378
+ **kwargs: Any,
379
+ ) -> _RetryingRequestContext:
380
+ """构造可重试的请求上下文, 统一关闭 aiohttp 的会话级 ``raise_for_status``.
381
+
382
+ 业务函数照旧写 ``async with session.get(...) as resp``, 无需感知重试.
383
+ """
384
+
385
+ def send() -> Awaitable[ClientResponse]:
386
+ return self._ensure().request(
387
+ method,
388
+ url,
389
+ raise_for_status=False,
390
+ **kwargs,
391
+ )
392
+
393
+ # 日志标签去掉 query, 避免把 COS 预签名 URL 里的签名写进日志
394
+ return _RetryingRequestContext(send, label=f"{method} {url.split('?', 1)[0]}")
395
+
396
+ def get(self, url: str, **kwargs: Any) -> _RetryingRequestContext:
397
+ return self._request_context("GET", url, **kwargs)
398
+
399
+ def post(self, url: str, **kwargs: Any) -> _RetryingRequestContext:
400
+ return self._request_context("POST", url, **kwargs)
401
+
402
+ def put(self, url: str, **kwargs: Any) -> _RetryingRequestContext:
403
+ return self._request_context("PUT", url, **kwargs)
404
+
405
+ def patch(self, url: str, **kwargs: Any) -> _RetryingRequestContext:
406
+ return self._request_context("PATCH", url, **kwargs)
407
+
408
+ def delete(self, url: str, **kwargs: Any) -> _RetryingRequestContext:
409
+ return self._request_context("DELETE", url, **kwargs)
410
+
411
+ @property
412
+ def client(self) -> ClientSession:
413
+ """底层 aiohttp 会话, 供 WebSocket 等需要原生能力的场景使用."""
414
+ return self._ensure()
415
+
416
+ async def request(self, method: str, url: str, **kwargs: Any) -> ClientResponse:
417
+ """返回已读取完成的响应, 便于需要自行处理状态码的场景."""
418
+ return await self._ensure().request(method, url, **kwargs)
419
+
420
+ @property
421
+ def closed(self) -> bool:
422
+ return self._client is None or self._client.closed
423
+
424
+ async def close(self) -> None:
425
+ if self._client is not None and not self._client.closed:
426
+ await self._client.close()
427
+ self._client = None
428
+
429
+ async def __aenter__(self) -> "QQBotSession":
430
+ self._ensure()
431
+ return self
432
+
433
+ async def __aexit__(self, *_exc: object) -> None:
434
+ await self.close()
435
+
436
+
437
+ session = QQBotSession()
438
+
439
+ __all__ = [
440
+ "BASE_URL",
441
+ "CHECKED_HOSTS",
442
+ "DEFAULT_TIMEOUT_MS",
443
+ "MAX_RETRIES",
444
+ "RETRY_BASE_SECONDS",
445
+ "RETRY_TOTAL_WAIT_SECONDS",
446
+ "QQBotResponse",
447
+ "QQBotSession",
448
+ "session",
449
+ ]
@@ -0,0 +1,43 @@
1
+ """网关地址获取.
2
+
3
+ 网关 WebSocket 地址由 ``GET /gateway`` 下发, 可能随服务端调度变化, 因此每次建立
4
+ 连接前实时获取, 不在代码里硬编码域名.
5
+ """
6
+
7
+ from typing import Any, Final, cast
8
+
9
+ from qqbotsdk.api.core import session
10
+ from qqbotsdk.errors import QQBotGatewayError
11
+
12
+ # 网关地址接口路径
13
+ GATEWAY_PATH: Final = "/gateway"
14
+
15
+
16
+ async def fetch_gateway_url() -> str:
17
+ """调用 ``GET /gateway`` 并返回其中的 ``url`` 字段.
18
+
19
+ 鉴权请求头由 :mod:`qqbotsdk.api.core` 的会话自动注入, HTTP 错误由该会话的
20
+ ``raise_for_status`` 抛出, 这里不再包装.
21
+
22
+ Returns:
23
+ 网关 WebSocket 地址, 例如 ``wss://api.sgroup.qq.com/websocket``.
24
+
25
+ Raises:
26
+ QQBotGatewayError: 响应体缺少可用的 ``url`` 字段.
27
+ """
28
+ async with session.get(GATEWAY_PATH) as resp:
29
+ raw = await resp.json()
30
+
31
+ if not isinstance(raw, dict):
32
+ raise QQBotGatewayError("网关响应缺少 url")
33
+ body = cast("dict[str, Any]", raw)
34
+ url = body.get("url")
35
+ if not isinstance(url, str) or not url:
36
+ raise QQBotGatewayError("网关响应缺少 url")
37
+ return url
38
+
39
+
40
+ __all__ = [
41
+ "GATEWAY_PATH",
42
+ "fetch_gateway_url",
43
+ ]