qqbotsdk-base-py 2.0.0__tar.gz
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_base_py-2.0.0/PKG-INFO +96 -0
- qqbotsdk_base_py-2.0.0/README.md +83 -0
- qqbotsdk_base_py-2.0.0/pyproject.toml +24 -0
- qqbotsdk_base_py-2.0.0/pyproject.toml.orig +23 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/api/agent.md +40 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/api/auth.py +134 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/api/core.py +449 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/api/gateway.py +43 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/errors.py +92 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/model/agent.md +48 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/model/core.py +30 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/model/sub/messages.py +209 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/model/sub/upload.py +100 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/ws/__init__.py +22 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/ws/agent.md +39 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/ws/client.py +590 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/ws/intents.py +42 -0
- qqbotsdk_base_py-2.0.0/src/qqbotsdk/ws/payload.py +100 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: qqbotsdk-base-py
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: QQ 官方机器人 SDK 基座包:模型基类、共享消息模型与 HTTP 会话
|
|
5
|
+
Author: snowy-yang
|
|
6
|
+
Author-email: snowy-yang <102860110+snowy-yang@users.noreply.github.com>
|
|
7
|
+
Requires-Dist: aiohttp>=3.14.5
|
|
8
|
+
Requires-Dist: loguru>=0.7.3
|
|
9
|
+
Requires-Dist: pydantic>=2.14.1
|
|
10
|
+
Requires-Dist: python-dotenv>=1.2.4
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# qqbotsdk-base-py
|
|
15
|
+
|
|
16
|
+
QQ 官方机器人 SDK 基座包。各业务域包共用的最小依赖面与共享代码:
|
|
17
|
+
|
|
18
|
+
- `qqbotsdk.model.core`:`QQBotBaseModel`(pydantic 基类)
|
|
19
|
+
- `qqbotsdk.model.sub.messages`:共享消息模型(键盘、Markdown、引用、富媒体等)
|
|
20
|
+
- `qqbotsdk.api.core`:懒加载的共享 `session`(指向 `https://api.bot.qq.com`,自动注入鉴权头)
|
|
21
|
+
- `qqbotsdk.api.auth`:`access_token` 的获取、缓存与自动刷新
|
|
22
|
+
- `qqbotsdk.ws`:网关 WebSocket 客户端
|
|
23
|
+
- 依赖:`aiohttp`、`pydantic`、`loguru`、`python-dotenv`
|
|
24
|
+
|
|
25
|
+
## 安装
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install qqbotsdk-base-py
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
通常不需要直接安装;装 `qqbotsdk-py` 或 `qqbotsdk-guild-py` 时会自动带上。
|
|
32
|
+
|
|
33
|
+
## 鉴权
|
|
34
|
+
|
|
35
|
+
在 `.env` 或环境变量里配置 `APPID` / `APPSECRET`(可选 `TIMEOUT`,单位毫秒):
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from qqbotsdk.api.auth import get_access_token
|
|
39
|
+
from qqbotsdk.api.bot import get_me # 请求会自动带上 Authorization: QQBot <token>
|
|
40
|
+
from qqbotsdk.api.core import session
|
|
41
|
+
|
|
42
|
+
me = await get_me() # 不用手写请求头
|
|
43
|
+
token = await get_access_token() # 需要自己持有时
|
|
44
|
+
await session.close() # 进程退出前收尾
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- 凭证进程内缓存,有效期 7200 秒,剩余不足 60 秒时自动换取;并发调用只换一次。
|
|
48
|
+
- 换取凭证的接口本身不带鉴权头;发往其它域名的绝对地址(如 COS 预签名上传)也不带。
|
|
49
|
+
- `session` 在首次使用时才创建,因此可以在任意位置 import,不需要先有事件循环。
|
|
50
|
+
|
|
51
|
+
## 命名空间
|
|
52
|
+
|
|
53
|
+
三个发行包共享 `qqbotsdk` 导入命名空间(各包的 `qqbotsdk/` 顶层目录不含 `__init__.py`,子包如 `qqbotsdk/ws/` 仍带自己的 `__init__.py` 以汇总导出),可任意组合安装。
|
|
54
|
+
|
|
55
|
+
## WebSocket 网关
|
|
56
|
+
|
|
57
|
+
`qqbotsdk.ws` 提供网关客户端(只依赖 aiohttp 与 pydantic,不含业务模型):
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import asyncio
|
|
61
|
+
|
|
62
|
+
from qqbotsdk.ws import DEFAULT_INTENTS, QQBotWebSocket
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def main() -> None:
|
|
66
|
+
async with QQBotWebSocket(intents=DEFAULT_INTENTS) as ws: # 网关地址自动获取
|
|
67
|
+
await ws.wait_ready()
|
|
68
|
+
async for payload in ws:
|
|
69
|
+
print(payload.t, payload.d)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
asyncio.run(main())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
要点:
|
|
76
|
+
|
|
77
|
+
- `Intents`(`IntFlag`)声明订阅的事件位;`DEFAULT_INTENTS` = 群与单聊消息 + 公域频道消息 + 频道成员 + 交互事件。
|
|
78
|
+
- `async for` 只产出 `op=0` 的 Dispatch 事件(`payload.t` / `payload.d` / `payload.s` / `payload.id`),READY 与 RESUMED 也会产出;READY 的事件体可用 `ReadyContent` 解析。
|
|
79
|
+
- **不做分片**:固定单连接,`Identify` 一律上报 `shard=[0, 1]`,没有 `shard` 参数,也不封装 `GET /gateway/bot`;需要多连接负载均衡请自行拆分。
|
|
80
|
+
- 心跳按 Hello 下发的 `heartbeat_interval` 由独立 `asyncio.Task` 定时发送,收到 op=11 ACK 后复位,退出时自动取消。
|
|
81
|
+
- 断线自动重连(指数退避):仍持有 `session_id` 与 `seq` 时走 Resume,否则重新 Identify;不可重试的关闭码(4001、4002、4010~4014、4914、4915)会直接终止。
|
|
82
|
+
- `url` 省略时**每次建连前自动调用 `GET /gateway` 获取**(地址可能随调度变化,不硬编码域名);也可显式传 `url=` 覆盖。
|
|
83
|
+
- 因致命关闭码停止时,`ws.last_error` 是 `QQBotGatewayError`(带 `code`,例如 `4914` 已下架、`4915` 已封禁)。
|
|
84
|
+
- `await ws.wait_ready()` 等到 READY 或 RESUMED;`await ws.close()` 或退出 `async with` 时关闭连接并取消后台任务;日志不会打印 access_token。
|
|
85
|
+
- 可注册回调:`ws.on("GROUP_AT_MESSAGE_CREATE", handler)` / `ws.on(WILDCARD_EVENT, handler)`,回调以独立任务派发、不阻塞收包,异常只记日志;`ws.off(...)` 取消,注册回调后 `async for` 仍可用。
|
|
86
|
+
- HTTP 429 自动重试(最多 3 次、`Retry-After` 优先、指数退避 + 抖动、总等待 ≤ 30 秒),可用 `MAX_RETRIES` / `RETRY_BASE_SECONDS` 调整。
|
|
87
|
+
|
|
88
|
+
## 错误体系
|
|
89
|
+
|
|
90
|
+
`qqbotsdk.errors` 定义三级错误,全部继承 `QQBotError`:
|
|
91
|
+
|
|
92
|
+
- `QQBotAPIError`:HTTP 4xx/5xx、body 带非零 `err_code`/`code`、201/202 错误体(`status`/`err_code`/`message`/`trace_id`)。
|
|
93
|
+
- `QQBotAuthError`:凭证缺失或换取 access_token 失败。
|
|
94
|
+
- `QQBotGatewayError`:网关致命关闭码、网关响应缺少 `url`。
|
|
95
|
+
|
|
96
|
+
`api/core.py` 通过自定义 `response_class` 统一做这套检查,因此业务 api 函数里看不到任何错误处理代码。
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# qqbotsdk-base-py
|
|
2
|
+
|
|
3
|
+
QQ 官方机器人 SDK 基座包。各业务域包共用的最小依赖面与共享代码:
|
|
4
|
+
|
|
5
|
+
- `qqbotsdk.model.core`:`QQBotBaseModel`(pydantic 基类)
|
|
6
|
+
- `qqbotsdk.model.sub.messages`:共享消息模型(键盘、Markdown、引用、富媒体等)
|
|
7
|
+
- `qqbotsdk.api.core`:懒加载的共享 `session`(指向 `https://api.bot.qq.com`,自动注入鉴权头)
|
|
8
|
+
- `qqbotsdk.api.auth`:`access_token` 的获取、缓存与自动刷新
|
|
9
|
+
- `qqbotsdk.ws`:网关 WebSocket 客户端
|
|
10
|
+
- 依赖:`aiohttp`、`pydantic`、`loguru`、`python-dotenv`
|
|
11
|
+
|
|
12
|
+
## 安装
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install qqbotsdk-base-py
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
通常不需要直接安装;装 `qqbotsdk-py` 或 `qqbotsdk-guild-py` 时会自动带上。
|
|
19
|
+
|
|
20
|
+
## 鉴权
|
|
21
|
+
|
|
22
|
+
在 `.env` 或环境变量里配置 `APPID` / `APPSECRET`(可选 `TIMEOUT`,单位毫秒):
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from qqbotsdk.api.auth import get_access_token
|
|
26
|
+
from qqbotsdk.api.bot import get_me # 请求会自动带上 Authorization: QQBot <token>
|
|
27
|
+
from qqbotsdk.api.core import session
|
|
28
|
+
|
|
29
|
+
me = await get_me() # 不用手写请求头
|
|
30
|
+
token = await get_access_token() # 需要自己持有时
|
|
31
|
+
await session.close() # 进程退出前收尾
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- 凭证进程内缓存,有效期 7200 秒,剩余不足 60 秒时自动换取;并发调用只换一次。
|
|
35
|
+
- 换取凭证的接口本身不带鉴权头;发往其它域名的绝对地址(如 COS 预签名上传)也不带。
|
|
36
|
+
- `session` 在首次使用时才创建,因此可以在任意位置 import,不需要先有事件循环。
|
|
37
|
+
|
|
38
|
+
## 命名空间
|
|
39
|
+
|
|
40
|
+
三个发行包共享 `qqbotsdk` 导入命名空间(各包的 `qqbotsdk/` 顶层目录不含 `__init__.py`,子包如 `qqbotsdk/ws/` 仍带自己的 `__init__.py` 以汇总导出),可任意组合安装。
|
|
41
|
+
|
|
42
|
+
## WebSocket 网关
|
|
43
|
+
|
|
44
|
+
`qqbotsdk.ws` 提供网关客户端(只依赖 aiohttp 与 pydantic,不含业务模型):
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
|
|
49
|
+
from qqbotsdk.ws import DEFAULT_INTENTS, QQBotWebSocket
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def main() -> None:
|
|
53
|
+
async with QQBotWebSocket(intents=DEFAULT_INTENTS) as ws: # 网关地址自动获取
|
|
54
|
+
await ws.wait_ready()
|
|
55
|
+
async for payload in ws:
|
|
56
|
+
print(payload.t, payload.d)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
要点:
|
|
63
|
+
|
|
64
|
+
- `Intents`(`IntFlag`)声明订阅的事件位;`DEFAULT_INTENTS` = 群与单聊消息 + 公域频道消息 + 频道成员 + 交互事件。
|
|
65
|
+
- `async for` 只产出 `op=0` 的 Dispatch 事件(`payload.t` / `payload.d` / `payload.s` / `payload.id`),READY 与 RESUMED 也会产出;READY 的事件体可用 `ReadyContent` 解析。
|
|
66
|
+
- **不做分片**:固定单连接,`Identify` 一律上报 `shard=[0, 1]`,没有 `shard` 参数,也不封装 `GET /gateway/bot`;需要多连接负载均衡请自行拆分。
|
|
67
|
+
- 心跳按 Hello 下发的 `heartbeat_interval` 由独立 `asyncio.Task` 定时发送,收到 op=11 ACK 后复位,退出时自动取消。
|
|
68
|
+
- 断线自动重连(指数退避):仍持有 `session_id` 与 `seq` 时走 Resume,否则重新 Identify;不可重试的关闭码(4001、4002、4010~4014、4914、4915)会直接终止。
|
|
69
|
+
- `url` 省略时**每次建连前自动调用 `GET /gateway` 获取**(地址可能随调度变化,不硬编码域名);也可显式传 `url=` 覆盖。
|
|
70
|
+
- 因致命关闭码停止时,`ws.last_error` 是 `QQBotGatewayError`(带 `code`,例如 `4914` 已下架、`4915` 已封禁)。
|
|
71
|
+
- `await ws.wait_ready()` 等到 READY 或 RESUMED;`await ws.close()` 或退出 `async with` 时关闭连接并取消后台任务;日志不会打印 access_token。
|
|
72
|
+
- 可注册回调:`ws.on("GROUP_AT_MESSAGE_CREATE", handler)` / `ws.on(WILDCARD_EVENT, handler)`,回调以独立任务派发、不阻塞收包,异常只记日志;`ws.off(...)` 取消,注册回调后 `async for` 仍可用。
|
|
73
|
+
- HTTP 429 自动重试(最多 3 次、`Retry-After` 优先、指数退避 + 抖动、总等待 ≤ 30 秒),可用 `MAX_RETRIES` / `RETRY_BASE_SECONDS` 调整。
|
|
74
|
+
|
|
75
|
+
## 错误体系
|
|
76
|
+
|
|
77
|
+
`qqbotsdk.errors` 定义三级错误,全部继承 `QQBotError`:
|
|
78
|
+
|
|
79
|
+
- `QQBotAPIError`:HTTP 4xx/5xx、body 带非零 `err_code`/`code`、201/202 错误体(`status`/`err_code`/`message`/`trace_id`)。
|
|
80
|
+
- `QQBotAuthError`:凭证缺失或换取 access_token 失败。
|
|
81
|
+
- `QQBotGatewayError`:网关致命关闭码、网关响应缺少 `url`。
|
|
82
|
+
|
|
83
|
+
`api/core.py` 通过自定义 `response_class` 统一做这套检查,因此业务 api 函数里看不到任何错误处理代码。
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "qqbotsdk-base-py"
|
|
3
|
+
version = "2.0.0"
|
|
4
|
+
description = "QQ 官方机器人 SDK 基座包:模型基类、共享消息模型与 HTTP 会话"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"aiohttp>=3.14.5",
|
|
9
|
+
"loguru>=0.7.3",
|
|
10
|
+
"pydantic>=2.14.1",
|
|
11
|
+
"python-dotenv>=1.2.4",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[project.authors]]
|
|
15
|
+
name = "snowy-yang"
|
|
16
|
+
email = "102860110+snowy-yang@users.noreply.github.com"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.13.5,<0.14.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
[tool.uv.build-backend]
|
|
23
|
+
module-name = "qqbotsdk"
|
|
24
|
+
namespace = true
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "qqbotsdk-base-py"
|
|
3
|
+
version = "2.0.0"
|
|
4
|
+
description = "QQ 官方机器人 SDK 基座包:模型基类、共享消息模型与 HTTP 会话"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "snowy-yang", email = "102860110+snowy-yang@users.noreply.github.com" },
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"aiohttp>=3.14.5",
|
|
12
|
+
"loguru>=0.7.3",
|
|
13
|
+
"pydantic>=2.14.1",
|
|
14
|
+
"python-dotenv>=1.2.4",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.13.5,<0.14.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[tool.uv.build-backend]
|
|
22
|
+
module-name = "qqbotsdk"
|
|
23
|
+
namespace = true
|
|
@@ -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(说明头已注入)。
|
|
@@ -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
|
+
]
|