mirror of
https://github.com/snowy-yang/qqbotsdk.git
synced 2026-10-21 07:59:07 +00:00
feat!: api 拆包、心跳迁入传输层、限流重试与后台 handler
This commit is contained in:
1 parent
750ed81d96
commit
3237558128
30 files changed
+1145
-714
No files matched your search
@@ -10,18 +10,28 @@
|
||||
- Markdown 与内嵌按钮一等支持:`post_group_message`/`post_c2c_message` 新增 `markdown`/`keyboard` 参数(自动置 msg_type=2,与 content 互斥);新增 `respond_interaction()` 回应按钮回调;`Interaction` payload 补齐 `chat_type`/`scene`/`group_openid` 等官方字段
|
||||
- `Event` 新增 `event_id` 属性(推送信封顶层的事件 id,与 op/t/d 平级):非消息事件(GROUP_ADD_ROBOT 等)的被动回复不再需要手动 `event.raw["id"]`
|
||||
- 按钮快捷构造与点击处理:`button()`/`keyboard()` 生成内嵌按钮(免手写嵌套 dict);`Interaction` 新增 `button_data` 属性(被点按钮的 action.data);examples/group_bot 补 `/btn` 卡片与 `INTERACTION_CREATE` 回调示例
|
||||
- handler 后台并发:`@ee.on(..., background=True)` 把 handler 放进后台任务,不阻塞事件流(同事件内失去先后保证,返回值不回流);`run_loop` 退出经 `emitter.close()` 统一取消在飞任务
|
||||
- OpenAPI 限流与鉴权自愈:`BotApi.request` 对 429 自动重试(最多 3 次,优先 `Retry-After`,脏值容错解析,退避封顶 30s);401 强制刷新 token 后重试一次,token 被服务端提前作废不再直接抛错
|
||||
- 文档站点:基于 docsify(侧边栏、全文搜索、代码复制、mermaid 图渲染、分页),`npx docsify-cli serve` 本地预览;新增 `examples/README.md` 示例索引
|
||||
|
||||
### 变更
|
||||
|
||||
- **移除 dishka 依赖,改为内置参数注入**:handler 形参直接标注组件类型(`api: BotApi`)即注入,不再需要 `Inject[T]` 包装;组件改在 `run_loop` 显式装配并按类型登记到 `emitter.services`(自定义组件同样登记即可注入)。破坏性变更:`Inject`、`make_container`、`use_container` 随之移除,原 `api: Inject[BotApi]` 写法改为 `api: BotApi`
|
||||
- `EventEmitter` 的 `queue` 参数改为可选:不传自动创建队列,`EventEmitter()` 即可直接注册 handler;需多处共享队列时仍可显式传 `EventQueue`(`ee.queue` 取实例)。文档与示例统一改为无参构造
|
||||
- **`api.py` 拆分为 `api/` 包**:`core.py` 请求内核(鉴权/限流重试/错误归一)+ `v2.py`(群/单聊与按钮构造)、`channel.py`(频道消息)、`guild.py`(频道管理与查询)三个域方法集组合成 `BotApi`;方法名与导入路径不变(`from qqbotsdk.api import BotApi, button, keyboard` 照旧),新增域加模块即可
|
||||
- **心跳归传输层**:心跳从 `WebsocketProtocol`(定时调度任务)迁入 `WebsocketConnecter`(asyncio 任务随连接生灭),断线期间不再往 reply 队列堆积过期心跳,重连也不会先冲出陈旧心跳
|
||||
- 重连退避只在连接稳定存活(≥60s)后才重置归一,抖动的服务端不再被 1s 间隔反复探测
|
||||
- `AccessToken` 并发安全:换取过程持 `asyncio.Lock` 双重检查(并发首调只发一次请求);到期判断改用 monotonic 时钟并预留 30s 提前刷新余量;新增 `invalidate()` 供 401 时强制作废
|
||||
- 组件构造签名瘦身:`WebsocketProtocol(config, session, token)` 移除未使用的 `queue`;`WebsocketConnecter` 改收 `(config, http, token, session, queue)`;`WebhookConnecter` 改收 `(config, queue, handle_event)`,op=13 应答经注入的回调取得,不再依赖具体 `EventEmitter`;`Session` 合并 `sequence_id`/`seq` 双属性为 `seq`(心跳序列号无历史时发 0 而非 null);移除服务端不会推送的 HEARTBEAT 死代码 handler;`ApiError`/`TokenError` 的错误响应解析合并为 `model.error_parts`
|
||||
|
||||
### 移除
|
||||
|
||||
- apscheduler 依赖(心跳迁入 connecter 后不再需要调度器)
|
||||
- 音乐插件示例(`plugins/`、根目录 `main.py`)已拆分为独立的点歌 bot 项目(含 Markdown 卡片、按钮回调与内置短网址多线程下载代理),不再随 SDK 仓库分发
|
||||
|
||||
### 修复
|
||||
|
||||
- **INVALID_SESSION 可续传标记失效**:op=9 的 `d` 是布尔值(true=可 RESUME),此前参数注入回退把 d 字典化成 `{}`(恒 falsy),任何 INVALID_SESSION 都会清空会话——RESUME 续传永远走不到,断线期间本可回放的消息丢失;注入回退改为直传原始 d,`async def on_invalid(resumable: bool)` 现在能拿到真实布尔
|
||||
- token 请求失败时抛出 `TokenError`(携带 QQ 返回的 `code`/`message`),不再抛出掩盖真实原因的 `KeyError: 'access_token'`
|
||||
- 修正被动回复 `event_id` 的指引:官方要求取网关帧最外层的 id(`Event.event_id`),事件体里的裸 UUID(`Interaction.id` 等)不能作 `event_id`,此前文档与 docstring 的说法有误
|
||||
- 响应 `Content-Type` 非 JSON 时不再抛 `ContentTypeError`(个别接口如回应互动返回 200 text/plain + `{}` body);JSON 解析统一切换 ujson
|
||||
|
||||
@@ -69,9 +69,9 @@ WEBHOOK_PATH=/qqbot/webhook
|
||||
编写入口(如 `main.py`),把自己构造的 `EventEmitter` 传给 `main()`:
|
||||
|
||||
```python
|
||||
from qqbotsdk import EventEmitter, EventQueue, main
|
||||
from qqbotsdk import EventEmitter, main
|
||||
|
||||
ee = EventEmitter(EventQueue())
|
||||
ee = EventEmitter()
|
||||
|
||||
# 方式一:裸参数(d 为原始事件数据)
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
||||
|
||||
+13
-7
@@ -16,9 +16,9 @@ from qqbotsdk import (
|
||||
|---|---|
|
||||
| `main(emitter=None)` | 同步入口:装配组件并阻塞运行,Ctrl+C 优雅退出 |
|
||||
| `run_loop(emitter=None)` | 异步入口:`asyncio.run(run_loop(ee))` 的内部实现,可自行 await |
|
||||
| `EventEmitter(queue)` | 分发主体,`@ee.on(...)` 注册 handler |
|
||||
| `EventEmitter(queue=None)` | 分发主体,`@ee.on(..., background=True)` 注册 handler(可选后台并发);`queue` 不传自动创建 |
|
||||
| `ee.services` | `dict[type, Any]`,按类型登记可注入组件;`run_loop` 装配 SDK 内置组件,也可登记自定义类型 |
|
||||
| `EventQueue()` | 事件/应答队列;自建 emitter 时传入,`run_loop` 复用同一实例 |
|
||||
| `EventQueue()` | 事件/应答队列;`EventEmitter` 默认自建,需多处共享时手动传入(`ee.queue` 取实例) |
|
||||
| `Config` | frozen dataclass,环境变量一次性读齐(见下文) |
|
||||
| `Connecter` | 连接适配器 Protocol(接口),按 `CONNECTER` 自动选择实现 |
|
||||
| `BaseProtocol` | 协议层抽象基类,自定义协议处理器时继承并实现 `register(emitter)` |
|
||||
@@ -34,15 +34,19 @@ from qqbotsdk.events import Event
|
||||
## EventEmitter
|
||||
|
||||
```python
|
||||
ee = EventEmitter(EventQueue())
|
||||
ee = EventEmitter()
|
||||
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE") # DISPATCH 事件用 t 名
|
||||
@ee.on("INVALID_SESSION") # 协议事件用 Opcode 名(IDENTIFY/RESUME/HEARTBEAT/...)
|
||||
async def handler(...): ...
|
||||
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE", background=True) # 后台并发执行,不阻塞事件流
|
||||
async def slow(...): ...
|
||||
```
|
||||
|
||||
- **事件命名**:op=0(DISPATCH)按 payload 的 `t` 名路由;其余按 `Opcode` 名路由。协议事件(op≠0)已由内置协议层处理,一般只需监听业务事件;handler 返回 dict 时协议事件的返回值会回流给 ws 发送(业务事件不会)。
|
||||
- **异常隔离**:handler 异常就地记录(loguru),不影响同事件其他 handler 与主循环。
|
||||
- **后台并发**:`background=True` 的 handler 进后台任务,emit 不等它;同一事件内失去先后保证,返回值不回流,仅用于业务 handler。`run_loop` 退出时统一取消在飞后台任务(`ee.close()`)。
|
||||
- **`ee.handle(payload)`**:同步分发并返回首个 handler 的非空返回值,供协议层应答 op=13 等场景,业务代码不用。
|
||||
|
||||
### handler 参数注入约定
|
||||
@@ -51,10 +55,10 @@ async def handler(...): ...
|
||||
|
||||
| 标注 | 得到 | 未命中时 |
|
||||
|---|---|---|
|
||||
| payload dataclass(`payloads` 中的类型) | `Event.typed` 解析对象 | 回退传原始 dict |
|
||||
| payload dataclass(`payloads` 中的类型) | `Event.typed` 解析对象 | 回退传原始 d |
|
||||
| `Event` | 事件封装对象 | — |
|
||||
| 组件类型(`BotApi`/`Session`/`Config` 等已登记进 `ee.services` 的类型) | 登记的实例 | 回退传原始 dict |
|
||||
| 无标注 | 原始事件 dict | — |
|
||||
| 组件类型(`BotApi`/`Session`/`Config` 等已登记进 `ee.services` 的类型) | 登记的实例 | 回退传原始 d |
|
||||
| 无标注 | 原始 d(业务事件即事件 dict;协议事件可能是标量,如 `INVALID_SESSION` 的 `resumable: bool`) | — |
|
||||
|
||||
## Event
|
||||
|
||||
@@ -92,6 +96,8 @@ async def handler(...): ...
|
||||
|
||||
`from qqbotsdk.api import BotApi`。经参数注入(`api: BotApi`)。自动附带 `Authorization: QQBot <token>` 鉴权头并刷新 token;HTTP ≥400 抛 `ApiError`。
|
||||
|
||||
实现按域拆在 `qqbotsdk/api/` 包内(`core` 请求内核 + `v2`/`channel`/`guild` 方法集组合成 `BotApi`),方法名与下表一致;429 限流自动重试(最多 3 次,优先 `Retry-After`,退避封顶 30s),401 强制刷新 token 后重试一次。
|
||||
|
||||
### 通用
|
||||
|
||||
| 方法 | 说明 |
|
||||
@@ -159,7 +165,7 @@ async def handler(...): ...
|
||||
|
||||
### ApiError
|
||||
|
||||
`ApiError(RuntimeError)`,HTTP ≥400 时抛出:
|
||||
`ApiError(RuntimeError)`,重试后仍失败(HTTP ≥400)时抛出:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|---|---|
|
||||
|
||||
+23
-21
@@ -7,29 +7,29 @@
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph 接入层["接入层 connecter.py"]
|
||||
WS["WebsocketConnecter\ngateway握手 + ws长连接\n指数退避重连 + 心跳看门狗"]
|
||||
WS["WebsocketConnecter\ngateway握手 + ws长连接\n心跳随连接生灭 + 心跳看门狗\n指数退避重连(稳定连接才归一)"]
|
||||
WH["WebhookConnecter\naiohttp server\n验签 + TTL去重"]
|
||||
end
|
||||
|
||||
subgraph 分发层["分发层 emitter.py"]
|
||||
Q[("EventQueue\nmsg_queue / reply_queue")]
|
||||
EE["EventEmitter\nop/t 路由 + 参数注入\nhandler 异常隔离"]
|
||||
EE["EventEmitter\nop/t 路由 + 参数注入\nhandler 异常隔离\nbackground 后台并发"]
|
||||
end
|
||||
|
||||
subgraph 协议层["协议层 BaseProtocol"]
|
||||
WSP["WebsocketProtocol\nIDENTIFY/RESUME\napscheduler 心跳\ns 序列号跟踪"]
|
||||
WSP["WebsocketProtocol\nIDENTIFY/RESUME\ns 序列号跟踪"]
|
||||
WHP["WebhookProtocol\nop=13 验证应答"]
|
||||
end
|
||||
|
||||
subgraph 业务层["业务层"]
|
||||
API["BotApi\nREST 封装 + ApiError"]
|
||||
API["BotApi\nREST 封装(core + v2/channel/guild)\n429/401 自动重试"]
|
||||
USER["用户 handler\n@ee.on(...)"]
|
||||
end
|
||||
|
||||
subgraph 基础设施["基础设施"]
|
||||
CFG["Config\n.env 一次性读齐"]
|
||||
HTTP[("ClientSession\n共享连接池")]
|
||||
TK["AccessToken\n缓存 + 自动续期"]
|
||||
TK["AccessToken\n缓存 + 自动续期\n并发锁 + 401 作废"]
|
||||
CRY["crypto\nEd25519 验签/签名"]
|
||||
end
|
||||
|
||||
@@ -39,7 +39,8 @@ flowchart TB
|
||||
WH -- "事件 payload" --> Q
|
||||
Q -- "dispatch 泵" --> EE
|
||||
EE -- "on_payload" --> WSP
|
||||
WSP -- "IDENTIFY/HEARTBEAT 等\npayload 返回值" --> Q
|
||||
WSP -- "IDENTIFY/RESUME 等\npayload 返回值" --> Q
|
||||
WS -- "HEARTBEAT(心跳任务)" --> Q
|
||||
EE -- "Event 注入" --> USER
|
||||
USER -- "api: BotApi" --> API
|
||||
API --> QQ
|
||||
@@ -71,8 +72,8 @@ sequenceDiagram
|
||||
P-->>Q: IDENTIFY/RESUME payload → reply_queue
|
||||
WS->>QQ: send(过滤 NOT_SET 字段)
|
||||
|
||||
loop 每次心跳间隔
|
||||
P->>Q: HEARTBEAT payload(apscheduler 定时)
|
||||
loop 每次心跳间隔(心跳任务随连接生灭)
|
||||
WS->>Q: HEARTBEAT payload
|
||||
WS->>QQ: send
|
||||
QQ->>WS: HEARTBEAT_ACK(刷新看门狗时钟)
|
||||
end
|
||||
@@ -90,10 +91,10 @@ sequenceDiagram
|
||||
|
||||
| 层 | 模块 | 职责 | 不允许出现 |
|
||||
|---|---|---|---|
|
||||
| 接入层 | `connecter.py` | 传输细节:握手、重连、验签、去重 | 事件业务语义、handler 调用 |
|
||||
| 分发层 | `emitter.py` | 路由、参数注入、异常隔离、应答回流 | 传输细节、具体协议状态机 |
|
||||
| 协议层 | `ws_protocol.py` `webhook_protocol.py` | 鉴权/心跳/验证等协议状态机 | 传输细节(只经队列收发) |
|
||||
| 业务层 | `api.py` `payloads.py` + 用户代码 | OpenAPI 调用、payload 解析与机器人逻辑 | 协议细节 |
|
||||
| 接入层 | `connecter.py` | 传输细节:握手、心跳、重连、验签、去重 | 事件业务语义、协议状态机 |
|
||||
| 分发层 | `emitter.py` | 路由、参数注入、异常隔离、后台并发、应答回流 | 传输细节、具体协议状态机 |
|
||||
| 协议层 | `ws_protocol.py` `webhook_protocol.py` | 鉴权/验证等协议状态机(会话语义) | 传输细节(只经队列收发) |
|
||||
| 业务层 | `api/` `payloads.py` + 用户代码 | OpenAPI 调用、payload 解析与机器人逻辑 | 协议细节 |
|
||||
|
||||
跨层共享的最小公共物:`model.py`(Opcode/Payload/Intent)、`queue.py`(事件通道)、`config.py`、`session.py`(ws 会话状态)、`payloads.py`(事件 dataclass 与解析,被 `events.py`/`emitter.py` 使用)。
|
||||
|
||||
@@ -101,22 +102,23 @@ sequenceDiagram
|
||||
|
||||
- 全部组件在 `run_loop`(`__init__.py`)显式构造装配,无 DI 框架,也无任何全局单例。
|
||||
- 组件按类型登记进 `emitter.services`,handler 形参标注类型即得实例;用户自定义组件同样登记即可注入。
|
||||
- 唯一的 `ClientSession` 在 `run_loop` 构造,`finally` 中统一释放(全 SDK 共享一个连接池)。
|
||||
- 接入方式的选择收敛在 `run_loop` 的 `match config.connecter` 装配分支,主体代码只依赖 `BaseProtocol`/`Connecter` 抽象。
|
||||
- 唯一的 `ClientSession` 在 `run_loop` 构造,`finally` 中先 `emitter.close()` 收尾后台 handler、再统一释放连接池(全 SDK 共享一个连接池)。
|
||||
- 接入方式的选择收敛在 `run_loop` 的 `match config.connecter` 装配分支,主体代码只依赖 `BaseProtocol`/`Connecter` 抽象;两个 connecter 的依赖面一致(config + queue + 各自所需的窄依赖),`WebhookConnecter` 经 `handle_event` 回调取得 op=13 应答,不感知 `EventEmitter`。
|
||||
- 用户在 `main(ee)` 之前构造的 `EventEmitter` 会被复用(连同它的队列),保证 handler 注册在主体实际使用的实例上。
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
1. **管线而非回调直调**:事件一律经 `EventQueue` 异步流转,接入与分发两侧解耦——Webhook handler 里不需要知道 ws 的存在,反之亦然。代价是多一跳队列延迟(可忽略)。
|
||||
2. **协议层状态机独立**(`BaseProtocol.register/on_payload`):emitter 不认识"心跳""序列号"这些 ws 概念;协议类通过返回 payload dict 经 reply 队列外发。
|
||||
3. **看门狗两层互补**(见 `connecter.py` 模块注释):静默超时抓连接彻底失联,ACK 期限检查抓"有事件流但心跳已死"。阈值 `_SILENCE_FACTOR=1.1`、`_ACK_FACTOR=1.2` 是按 ACK 自然节奏校准的,**不能压到 1× 整**,否则正常抖动会周期性误杀(误杀由 RESUME 兜底,不丢消息)。
|
||||
4. **handler 异常就地隔离**:`_invoke` 捕获记日志,`dispatch` 主循环另有兜底;单条事件的单个 handler 失败不影响进程。
|
||||
5. **注入约定**:handler 参数按形参标注解析——`payloads.py` 里的 dataclass → 解析后的 payload 对象;`Event` → 事件封装;登记进 `emitter.services` 的组件类型(如 `BotApi`)→ 实例;未标注或未登记 → 回退传原始 `d`,保持旧处理器兼容。解析是纯同步查表(`get_type_hints` + dict),无框架、无运行时魔法。
|
||||
2. **协议层只管会话语义,心跳归传输层**:`WebsocketProtocol` 负责 IDENTIFY/RESUME/序列号(说什么),`WebsocketConnecter` 负责连接与心跳(怎么保活)——心跳任务随连接生灭(HELLO 给出间隔后启动,断线即取消),断线期间不在 reply 队列堆积过期心跳,也免去了调度器的生命周期管理。协议类通过返回 payload dict 经 reply 队列外发。
|
||||
3. **看门狗两层互补**(见 `connecter.py` 模块注释):静默超时抓连接彻底失联,ACK 期限检查抓"有事件流但心跳已死"。阈值 `_SILENCE_FACTOR=1.1`、`_ACK_FACTOR=1.2` 是按 ACK 自然节奏校准的,**不能压到 1× 整**,否则正常抖动会周期性误杀(误杀由 RESUME 兜底,不丢消息)。重连退避只在连接稳定存活(`_STABLE_SECONDS`)后才归一,抖动的服务端不会被 1s 间隔反复探测。
|
||||
4. **handler 异常就地隔离**:`_invoke` 捕获记日志,`dispatch` 主循环另有兜底;单条事件的单个 handler 失败不影响进程。`background=True` 的 handler 进后台任务(emit 不等它),任务由 emitter 持强引用、`close()` 统一取消。
|
||||
5. **注入约定**:handler 参数按形参标注解析——`payloads.py` 里的 dataclass → 解析后的 payload 对象;`Event` → 事件封装;登记进 `emitter.services` 的组件类型(如 `BotApi`)→ 实例;未标注或未登记 → 回退传**原始 d**(协议事件的 d 可能是标量,如 INVALID_SESSION 的 resumable 布尔;dict 化会丢真值——这里曾出过 bug)。解析是纯同步查表(`get_type_hints` + dict),无框架、无运行时魔法。
|
||||
6. **OpenAPI 请求内核自治**:`api/core.py` 统一处理鉴权头、429 限流重试(优先 `Retry-After`,脏值容错,退避封顶)与 401 刷新 token 重试一次;v2/channel/guild 三个域方法集继承内核组合成 `BotApi`,新增域加模块即可,方法对用户始终平铺在 `BotApi` 上。
|
||||
|
||||
## 扩展点
|
||||
|
||||
- **新增接入方式**(如轮询/其他平台):实现 `Connecter.run()` 与 `BaseProtocol.register()`,在 `run_loop` 的 `match` 装配分支加一个 `case`,`Config.load` 的白名单加一个名字。
|
||||
- **新增业务 API**:在 `api.py` 的 `BotApi` 上加方法(或独立模块组合 `BotApi`),全部自动获得鉴权、超时与错误处理。群/单聊消息经 `_post_message` 统一处理 msg_id/msg_seq/media 语义;已有方法覆盖:单聊/群聊消息与富媒体上传、频道消息/撤回/表态、公告、精华消息、日程、禁言、频道信息查询(guild/channels/roles/member)。
|
||||
- **新增业务 API**:在 `api/` 对应域模块(`v2`/`channel`/`guild`)加方法即自动平铺到 `BotApi`,全部获得鉴权、限流重试与错误处理;新增域则新建模块并组合进 `api/__init__.py` 的 `BotApi`。群/单聊消息经 `_post_message` 统一处理 msg_id/msg_seq/media 语义;已有方法覆盖:单聊/群聊消息与富媒体上传、频道消息/撤回/表态、公告、精华消息、日程、禁言、频道信息查询(guild/channels/roles/member)。
|
||||
- **新增事件类型 dataclass**:在 `payloads.py` 定义 dataclass 并登记 `EVENT_TYPES`,handler 即可按类型标注拿解析结果;未收录事件回退传 dict。当前已覆盖 intents.toml 的全部事件组(消息、成员变动、审核、表态、互动、论坛、音频)。
|
||||
|
||||
## 测试
|
||||
@@ -125,7 +127,7 @@ sequenceDiagram
|
||||
|
||||
- 纯逻辑:crypto 往返、intents 解析、Config 校验、`_route` 路由边界、webhook 去重、序列号单调性、handler 异常隔离与 reply 回流、payload dataclass 解析、BotApi 各业务方法的拼参(Recording 子类截获 request)。
|
||||
- **网络集成**(`test_ws_integration.py` / `test_webhook_integration.py`):
|
||||
- WebSocket:aiohttp 假网关(token 接口 + gateway + ws 协议行为)端到端跑通 HELLO → IDENTIFY → READY 会话捕获 → apscheduler 心跳/ACK → 业务事件 dataclass 分发;
|
||||
- WebSocket:aiohttp 假网关(token 接口 + gateway + ws 协议行为)端到端跑通 HELLO → IDENTIFY → READY 会话捕获 → 心跳/ACK(心跳任务随连接生灭)→ 业务事件 dataclass 分发;
|
||||
- Webhook:真实起 `WebhookConnecter` 的 http server(TestServer),覆盖 op=13 验证应答(签名内容正确性)、合法签名放行分发、坏签名 401、同 id 去重。
|
||||
|
||||
两套集成测试验证的都是真实连接路径,改接入层/协议层后跑 `uv run pytest` 即可回归。
|
||||
另有 429/401 重试、token 并发锁、op=9 resumable 布尔注入、background 后台并发与 `close()` 收尾的专项单测。两套集成测试验证的都是真实连接路径,改接入层/协议层后跑 `uv run pytest` 即可回归。
|
||||
+16
-4
@@ -76,9 +76,9 @@ SDK 启动时读取 `intents.toml` 计算订阅掩码,把想收的事件改成
|
||||
用装饰器把 handler 注册到 `EventEmitter`,事件名即线上 payload 的 `t` 名(如 `GROUP_AT_MESSAGE_CREATE`)。三种写法可混用:
|
||||
|
||||
```python
|
||||
from qqbotsdk import EventEmitter, EventQueue
|
||||
from qqbotsdk import EventEmitter
|
||||
|
||||
ee = EventEmitter(EventQueue())
|
||||
ee = EventEmitter()
|
||||
|
||||
# 方式一:裸参数,d 为原始事件 dict
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
||||
@@ -107,8 +107,8 @@ handler 参数按标注解析的完整规则:
|
||||
|---|---|
|
||||
| payload dataclass(如 `GroupAtMessage`) | 解析后的 dataclass 对象;未收录事件回退原始 dict |
|
||||
| `Event` | 事件封装对象(`.raw`/`.data`/`.typed`/`.user_id`/`.group_id`/`.content`/`.message_id`/`.event_id`) |
|
||||
| 组件类型(`BotApi`/`Session`/`Config` 等) | `emitter.services` 中按类型登记的实例;未登记回退传原始 dict |
|
||||
| 无标注 | 原始事件 dict |
|
||||
| 组件类型(`BotApi`/`Session`/`Config` 等) | `emitter.services` 中按类型登记的实例;未登记回退传原始 d |
|
||||
| 无标注 | 原始 d(业务事件即事件 dict;协议事件可能是标量,如 `INVALID_SESSION` 的 `resumable: bool`) |
|
||||
|
||||
注意:
|
||||
|
||||
@@ -117,6 +117,18 @@ handler 参数按标注解析的完整规则:
|
||||
- 未订阅(intents.toml 为 `false`)的事件不会到达 handler。
|
||||
- 自定义组件也能注入:`ee.services[MyService] = MyService(...)` 登记后,handler 形参标注 `MyService` 即可拿到实例。
|
||||
|
||||
### 后台并发 handler
|
||||
|
||||
默认同一事件的所有 handler 串行执行,某个慢 handler 会延迟后续事件。调用密集、不关心先后顺序的 handler 可加 `background=True` 丢进后台任务,事件流不再等它:
|
||||
|
||||
```python
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE", background=True)
|
||||
async def slow_work(msg: GroupAtMessage, api: BotApi):
|
||||
... # 耗时处理,不阻塞其他事件的分发
|
||||
```
|
||||
|
||||
代价是同一事件内 handler 间失去先后保证,且返回值不回流(本就只对协议事件回流),因此仅用于业务 handler。进程退出时 `run_loop` 会统一取消在飞的后台任务。
|
||||
|
||||
## 发消息
|
||||
|
||||
### 被动回复(最常用)
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from qqbotsdk import EventEmitter, EventQueue, main
|
||||
from qqbotsdk import EventEmitter, main
|
||||
from qqbotsdk.api import BotApi, button, keyboard
|
||||
from qqbotsdk.events import Event
|
||||
from qqbotsdk.payloads import C2CMessage, GroupAtMessage, Interaction
|
||||
|
||||
ee = EventEmitter(EventQueue())
|
||||
ee = EventEmitter()
|
||||
|
||||
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
运行:uv run python examples/webhook_bot/main.py
|
||||
"""
|
||||
|
||||
from qqbotsdk import EventEmitter, EventQueue, main
|
||||
from qqbotsdk import EventEmitter, main
|
||||
from qqbotsdk.api import BotApi
|
||||
from qqbotsdk.payloads import GroupAtMessage
|
||||
|
||||
ee = EventEmitter(EventQueue())
|
||||
ee = EventEmitter()
|
||||
|
||||
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
||||
|
||||
@@ -11,7 +11,6 @@ authors = [
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aiohttp>=3.14.3",
|
||||
"apscheduler>=3.11.3",
|
||||
"cryptography>=50.0.1",
|
||||
"loguru>=0.7.3",
|
||||
"pyee>=14.0.0",
|
||||
|
||||
+19
-15
@@ -1,6 +1,6 @@
|
||||
"""qqbotsdk:QQ 官方机器人 SDK 入口。
|
||||
|
||||
用法:构造 `EventEmitter(EventQueue())` 注册 handler,交给 `main(ee)`
|
||||
用法:构造 `EventEmitter()` 注册 handler,交给 `main(ee)`
|
||||
启动;连接方式由 .env 的 CONNECTER 决定(websocket / webhook)。
|
||||
架构详情见 docs/ARCHITECTURE.md。
|
||||
"""
|
||||
@@ -33,30 +33,32 @@ async def run_loop(emitter: EventEmitter | None = None) -> None:
|
||||
参数注入;共享的 HTTP 连接池随 run_loop 结束释放。
|
||||
"""
|
||||
if emitter is None:
|
||||
emitter = EventEmitter(EventQueue())
|
||||
emitter = EventEmitter()
|
||||
|
||||
config = Config.load()
|
||||
http = ClientSession(timeout=config.timeout)
|
||||
session = Session()
|
||||
token = AccessToken(config, http)
|
||||
api = BotApi(config, http, token)
|
||||
emitter.services.update({
|
||||
EventEmitter: emitter,
|
||||
EventQueue: emitter.queue,
|
||||
Config: config,
|
||||
Session: session,
|
||||
ClientSession: http,
|
||||
AccessToken: token,
|
||||
BotApi: api,
|
||||
})
|
||||
emitter.services.update(
|
||||
{
|
||||
EventEmitter: emitter,
|
||||
EventQueue: emitter.queue,
|
||||
Config: config,
|
||||
Session: session,
|
||||
ClientSession: http,
|
||||
AccessToken: token,
|
||||
BotApi: api,
|
||||
}
|
||||
)
|
||||
|
||||
match config.connecter:
|
||||
case "websocket":
|
||||
protocol = WebsocketProtocol(config, emitter.queue, session, token)
|
||||
connecter = WebsocketConnecter(config, http, token, emitter.queue)
|
||||
protocol = WebsocketProtocol(config, session, token)
|
||||
connecter = WebsocketConnecter(config, http, token, session, emitter.queue)
|
||||
case "webhook":
|
||||
protocol = WebhookProtocol(config, emitter.queue, session)
|
||||
connecter = WebhookConnecter(config, emitter, emitter.queue)
|
||||
protocol = WebhookProtocol(config)
|
||||
connecter = WebhookConnecter(config, emitter.queue, emitter.handle)
|
||||
case unknown:
|
||||
raise ValueError(f"未知的 CONNECTER: {unknown}")
|
||||
|
||||
@@ -65,6 +67,8 @@ async def run_loop(emitter: EventEmitter | None = None) -> None:
|
||||
try:
|
||||
await asyncio.gather(emitter.dispatch(), connecter.run())
|
||||
finally:
|
||||
# 在飞的后台 handler 先于连接池收尾,避免用到已关闭的 ClientSession
|
||||
await emitter.close()
|
||||
# 全 SDK 共享一个连接池,统一在此释放
|
||||
await http.close()
|
||||
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
"""OpenAPI 客户端:自动附带鉴权头,HTTP ≥400 抛 ApiError。
|
||||
|
||||
消息接口按接入面分组:v2 群/单聊(openid)、频道(channel_id)。
|
||||
被动回复规则(官方文档 2026-09 核对):携带 msg_id(或 event_id,
|
||||
二选一)为被动回复——单聊 60 分钟内最多 4 次,群聊 5 分钟内最多 5 次;
|
||||
同一条消息多次回复需递增 msg_seq(相同 msg_id+msg_seq 会发送失败);
|
||||
不携带则为主动消息(另有频控,群/单聊每日每对象 1000 条)。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import ujson
|
||||
from aiohttp import ClientSession
|
||||
from loguru import logger
|
||||
|
||||
from .config import Config
|
||||
from .token import AccessToken
|
||||
|
||||
# msg_type 取值
|
||||
MSG_TYPE_TEXT = 0
|
||||
MSG_TYPE_MARKDOWN = 2
|
||||
MSG_TYPE_MEDIA = 7
|
||||
# file_type 取值(富媒体上传)
|
||||
FILE_TYPE_IMAGE = 1
|
||||
FILE_TYPE_VIDEO = 2
|
||||
FILE_TYPE_VOICE = 3
|
||||
FILE_TYPE_FILE = 4
|
||||
|
||||
|
||||
def button(
|
||||
label: str,
|
||||
data: str = "",
|
||||
*,
|
||||
type: int = 1,
|
||||
permission: int = 2,
|
||||
style: int = 1,
|
||||
id: str | None = None,
|
||||
) -> dict:
|
||||
"""构造单个内嵌按钮 dict,供 keyboard() 组装后传入 keyboard= 参数。
|
||||
|
||||
type: 1 回调(点击推 INTERACTION_CREATE)/ 0 跳转链接(data 为 url)/
|
||||
2 指令(data 插入输入框)。permission: 2 所有人 / 1 管理员 / 0 指定
|
||||
用户。label 官方限 10 字以内;要补嵌套字段(如 visited_label、
|
||||
prompt)直接修改返回的 dict。
|
||||
"""
|
||||
return {
|
||||
"id": id or uuid4().hex[:8],
|
||||
"render_data": {"label": label, "style": style},
|
||||
"action": {"type": type, "permission": {"type": permission}, "data": data},
|
||||
}
|
||||
|
||||
|
||||
def keyboard(*rows: dict | list[dict]) -> dict:
|
||||
"""组装内嵌键盘(keyboard= 参数):每个位置参数为一行,
|
||||
单个按钮独占一行,按钮列表同行并排。"""
|
||||
return {
|
||||
"content": {
|
||||
"rows": [
|
||||
{"buttons": row if isinstance(row, list) else [row]} for row in rows
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
"""OpenAPI 调用失败:HTTP 非 2xx 或业务 code 非 0。"""
|
||||
|
||||
def __init__(self, status: int, code: Any, message: Any) -> None:
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.message = message
|
||||
super().__init__(f"API 调用失败: HTTP {status}, code={code}, message={message}")
|
||||
|
||||
|
||||
class BotApi:
|
||||
"""通用 REST 封装;具体的业务接口在此之上做薄封装。"""
|
||||
|
||||
def __init__(self, config: Config, http: ClientSession, token: AccessToken) -> None:
|
||||
self._config = config
|
||||
self._http = http
|
||||
self._token = token
|
||||
|
||||
async def request(self, method: str, path: str, **kwargs) -> Any:
|
||||
access_token = await self._token.get_access_token()
|
||||
headers = {"Authorization": f"QQBot {access_token}"}
|
||||
async with self._http.request(
|
||||
method,
|
||||
f"{self._config.base_url}{path}",
|
||||
headers=headers,
|
||||
**kwargs,
|
||||
) as resp:
|
||||
# 个别接口 200 响应的 Content-Type 不是 JSON(body 仍是 JSON,
|
||||
# 如回应互动返回 200 text/plain + {}),跳过标头校验
|
||||
data = await resp.json(loads=ujson.loads, content_type=None)
|
||||
if resp.status >= 400:
|
||||
logger.warning(f"{method} {path} 失败: {data}")
|
||||
code = data.get("code") if isinstance(data, dict) else None
|
||||
message = data.get("message") if isinstance(data, dict) else data
|
||||
raise ApiError(resp.status, code, message)
|
||||
return data
|
||||
|
||||
async def get(self, path: str, **kwargs) -> Any:
|
||||
return await self.request("GET", path, **kwargs)
|
||||
|
||||
async def post(self, path: str, **kwargs) -> Any:
|
||||
return await self.request("POST", path, **kwargs)
|
||||
|
||||
async def delete(self, path: str, **kwargs) -> Any:
|
||||
return await self.request("DELETE", path, **kwargs)
|
||||
|
||||
async def put(self, path: str, **kwargs) -> Any:
|
||||
return await self.request("PUT", path, **kwargs)
|
||||
|
||||
async def patch(self, path: str, **kwargs) -> Any:
|
||||
return await self.request("PATCH", path, **kwargs)
|
||||
|
||||
# ---------- 通用 ----------
|
||||
|
||||
async def me(self) -> dict:
|
||||
"""机器人自身信息(GET /users/@me)。"""
|
||||
return await self.get("/users/@me")
|
||||
|
||||
async def respond_interaction(self, interaction_id: str, code: int = 0) -> dict:
|
||||
"""回应互动事件(PUT /interactions/{interaction_id})。
|
||||
|
||||
按钮点击(INTERACTION_CREATE type=11)等互动收到后必须回应,
|
||||
否则客户端一直 loading 直到超时;同一 id 只能回应一次。
|
||||
"""
|
||||
return await self.put(f"/interactions/{interaction_id}", json={"code": code})
|
||||
|
||||
# ---------- v2 单聊(C2C) ----------
|
||||
|
||||
async def post_c2c_message(
|
||||
self,
|
||||
openid: str,
|
||||
content: str = "",
|
||||
msg_id: str | None = None,
|
||||
msg_seq: int = 1,
|
||||
media: dict | None = None,
|
||||
event_id: str | None = None,
|
||||
markdown: str | dict | None = None,
|
||||
keyboard: dict | None = None,
|
||||
**extra: Any,
|
||||
) -> dict:
|
||||
"""发单聊消息(POST /v2/users/{openid}/messages)。
|
||||
|
||||
media 为富媒体上传接口返回的 {"file_info": ...} 结构,
|
||||
此时 content 作媒体下方文本。msg_id(回复消息)与 event_id
|
||||
(响应事件)二选一,均为被动回复。markdown 为字符串或完整
|
||||
dict(msg_type=2,与 content 互斥);keyboard 为内嵌键盘
|
||||
结构({"content": {"rows": [...]}} 或 {"id": ...})。
|
||||
"""
|
||||
return await self._post_message(
|
||||
f"/v2/users/{openid}/messages",
|
||||
content=content,
|
||||
msg_id=msg_id,
|
||||
msg_seq=msg_seq,
|
||||
media=media,
|
||||
event_id=event_id,
|
||||
markdown=markdown,
|
||||
keyboard=keyboard,
|
||||
**extra,
|
||||
)
|
||||
|
||||
async def upload_c2c_file(
|
||||
self, openid: str, file_type: int, url: str, srv_send_msg: bool = False
|
||||
) -> dict:
|
||||
"""上传单聊富媒体(POST /v2/users/{openid}/files)。
|
||||
|
||||
返回含 file_info,可传入 post_c2c_message 的 media 直接发送,
|
||||
或 srv_send_msg=True 直接作为消息发出。
|
||||
"""
|
||||
return await self.post(
|
||||
f"/v2/users/{openid}/files",
|
||||
json={"file_type": file_type, "url": url, "srv_send_msg": srv_send_msg},
|
||||
)
|
||||
|
||||
# ---------- v2 群聊 ----------
|
||||
|
||||
async def post_group_message(
|
||||
self,
|
||||
group_openid: str,
|
||||
content: str = "",
|
||||
msg_id: str | None = None,
|
||||
msg_seq: int = 1,
|
||||
media: dict | None = None,
|
||||
event_id: str | None = None,
|
||||
markdown: str | dict | None = None,
|
||||
keyboard: dict | None = None,
|
||||
**extra: Any,
|
||||
) -> dict:
|
||||
"""发群消息(POST /v2/groups/{group_openid}/messages)。
|
||||
|
||||
msg_id(回复消息)与 event_id(响应 GROUP_ADD_ROBOT /
|
||||
INTERACTION_CREATE 等事件)二选一。markdown 为字符串或完整
|
||||
dict(msg_type=2,与 content 互斥);keyboard 为内嵌键盘
|
||||
结构({"content": {"rows": [...]}} 或 {"id": ...})。
|
||||
"""
|
||||
return await self._post_message(
|
||||
f"/v2/groups/{group_openid}/messages",
|
||||
content=content,
|
||||
msg_id=msg_id,
|
||||
msg_seq=msg_seq,
|
||||
media=media,
|
||||
event_id=event_id,
|
||||
markdown=markdown,
|
||||
keyboard=keyboard,
|
||||
**extra,
|
||||
)
|
||||
|
||||
async def upload_group_file(
|
||||
self, group_openid: str, file_type: int, url: str, srv_send_msg: bool = False
|
||||
) -> dict:
|
||||
"""上传群富媒体(POST /v2/groups/{group_openid}/files)。"""
|
||||
return await self.post(
|
||||
f"/v2/groups/{group_openid}/files",
|
||||
json={"file_type": file_type, "url": url, "srv_send_msg": srv_send_msg},
|
||||
)
|
||||
|
||||
async def _post_message(
|
||||
self,
|
||||
path: str,
|
||||
content: str,
|
||||
msg_id: str | None,
|
||||
msg_seq: int,
|
||||
media: dict | None,
|
||||
event_id: str | None,
|
||||
markdown: str | dict | None = None,
|
||||
keyboard: dict | None = None,
|
||||
**extra: Any,
|
||||
) -> dict:
|
||||
# media > markdown > 纯文本;markdown 与 content 互斥(官方约定)
|
||||
if media is not None:
|
||||
body: dict[str, Any] = {"msg_type": MSG_TYPE_MEDIA, "media": media}
|
||||
if content:
|
||||
body["content"] = content
|
||||
elif markdown is not None:
|
||||
body = {
|
||||
"msg_type": MSG_TYPE_MARKDOWN,
|
||||
"markdown": markdown if isinstance(markdown, dict) else {"content": markdown},
|
||||
}
|
||||
else:
|
||||
body = {"msg_type": MSG_TYPE_TEXT}
|
||||
if content:
|
||||
body["content"] = content
|
||||
if msg_id:
|
||||
body["msg_id"] = msg_id
|
||||
body["msg_seq"] = msg_seq
|
||||
elif event_id:
|
||||
body["event_id"] = event_id
|
||||
body["msg_seq"] = msg_seq
|
||||
if keyboard is not None:
|
||||
body["keyboard"] = keyboard
|
||||
body.update(extra)
|
||||
return await self.post(path, json=body)
|
||||
|
||||
async def _withdraw_v2_message(self, path: str, message_id: str) -> dict:
|
||||
# 发送超过 2 分钟的消息不可撤回
|
||||
return await self.delete(f"{path}/messages/{message_id}")
|
||||
|
||||
async def withdraw_group_message(self, group_openid: str, message_id: str) -> dict:
|
||||
"""撤回群消息(DELETE /v2/groups/{group_openid}/messages/{message_id})。
|
||||
|
||||
群管理员可撤回自己与普通成员的消息;普通成员仅能撤回自己的。
|
||||
"""
|
||||
return await self._withdraw_v2_message(f"/v2/groups/{group_openid}", message_id)
|
||||
|
||||
async def withdraw_c2c_message(self, openid: str, message_id: str) -> dict:
|
||||
"""撤回单聊消息(DELETE /v2/users/{openid}/messages/{message_id})。"""
|
||||
return await self._withdraw_v2_message(f"/v2/users/{openid}", message_id)
|
||||
|
||||
async def mute_group_member(self, group_openid: str, mute_expire_at: str) -> dict:
|
||||
"""群成员禁言(POST /v2/groups/{group_openid}/restrict_chat_setting)。
|
||||
|
||||
mute_expire_at 为禁言到期时间(RFC3339 格式,如
|
||||
"2026-01-01T12:00:00+08:00");传当前时间之前即解除禁言。
|
||||
"""
|
||||
return await self.post(
|
||||
f"/v2/groups/{group_openid}/restrict_chat_setting",
|
||||
json={"mute_expire_at": mute_expire_at},
|
||||
)
|
||||
|
||||
# ---------- 频道 ----------
|
||||
|
||||
async def post_channel_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
content: str = "",
|
||||
msg_id: str | None = None,
|
||||
msg_seq: int = 1,
|
||||
image: str | None = None,
|
||||
**extra: Any,
|
||||
) -> dict:
|
||||
"""发频道消息(POST /channels/{channel_id}/messages)。
|
||||
|
||||
image 为图片 URL;其余字段(embed/ark/markdown 等)经 extra 透传。
|
||||
"""
|
||||
body: dict[str, Any] = {"msg_seq": msg_seq}
|
||||
if content:
|
||||
body["content"] = content
|
||||
if msg_id:
|
||||
body["msg_id"] = msg_id
|
||||
if image:
|
||||
body["image"] = image
|
||||
body.update(extra)
|
||||
return await self.post(f"/channels/{channel_id}/messages", json=body)
|
||||
|
||||
async def withdraw_channel_message(
|
||||
self, channel_id: str, message_id: str, hide_tip: bool = False
|
||||
) -> dict:
|
||||
"""撤回频道消息(DELETE /channels/{channel_id}/messages/{message_id})。
|
||||
|
||||
hide_tip 控制是否隐藏撤回提示小灰条(官方参数名为 hidetip);
|
||||
仅私域机器人可用。
|
||||
"""
|
||||
return await self.delete(
|
||||
f"/channels/{channel_id}/messages/{message_id}",
|
||||
params={"hidetip": str(hide_tip).lower()},
|
||||
)
|
||||
|
||||
async def put_channel_reaction(
|
||||
self, channel_id: str, message_id: str, emoji_type: int, emoji_id: str
|
||||
) -> dict:
|
||||
"""对频道消息表态(PUT .../reactions/{type}/{emoji_id})。
|
||||
|
||||
emoji_type=1 为系统表情,emoji_id 为系统表情序号(字符串)。
|
||||
"""
|
||||
return await self.put(
|
||||
f"/channels/{channel_id}/messages/{message_id}"
|
||||
f"/reactions/{emoji_type}/{emoji_id}"
|
||||
)
|
||||
|
||||
async def delete_channel_reaction(
|
||||
self, channel_id: str, message_id: str, emoji_type: int, emoji_id: str
|
||||
) -> dict:
|
||||
"""删除自己对该消息的表态(DELETE .../reactions/{type}/{emoji_id})。"""
|
||||
return await self.delete(
|
||||
f"/channels/{channel_id}/messages/{message_id}"
|
||||
f"/reactions/{emoji_type}/{emoji_id}"
|
||||
)
|
||||
|
||||
# ---------- 频道管理 ----------
|
||||
|
||||
async def create_guild_announce(
|
||||
self,
|
||||
guild_id: str,
|
||||
channel_id: str,
|
||||
message_id: str,
|
||||
announces_type: int = 0,
|
||||
**extra: Any,
|
||||
) -> dict:
|
||||
"""创建频道公告(POST /guilds/{guild_id}/announces)。
|
||||
|
||||
announces_type: 0 成员公告(默认)、1 欢迎公告;
|
||||
recommend_channels 等经 extra 透传(与消息类公告互斥)。
|
||||
"""
|
||||
body = {
|
||||
"channel_id": channel_id,
|
||||
"message_id": message_id,
|
||||
"announces_type": announces_type,
|
||||
}
|
||||
body.update(extra)
|
||||
return await self.post(f"/guilds/{guild_id}/announces", json=body)
|
||||
|
||||
async def delete_guild_announce(self, guild_id: str, message_id: str) -> dict:
|
||||
"""删除频道公告(DELETE /guilds/{guild_id}/announces/{message_id})。
|
||||
|
||||
删除推荐子频道类型的公告需传 message_id="all"。
|
||||
"""
|
||||
return await self.delete(f"/guilds/{guild_id}/announces/{message_id}")
|
||||
|
||||
async def pin_channel_message(self, channel_id: str, message_id: str) -> dict:
|
||||
"""添加精华消息(PUT /channels/{channel_id}/pins/{message_id})。"""
|
||||
return await self.put(f"/channels/{channel_id}/pins/{message_id}")
|
||||
|
||||
async def unpin_channel_message(self, channel_id: str, message_id: str) -> dict:
|
||||
"""移出精华消息(DELETE /channels/{channel_id}/pins/{message_id})。"""
|
||||
return await self.delete(f"/channels/{channel_id}/pins/{message_id}")
|
||||
|
||||
async def get_channel_pins(self, channel_id: str) -> dict:
|
||||
"""获取精华消息列表(GET /channels/{channel_id}/pins)。"""
|
||||
return await self.get(f"/channels/{channel_id}/pins")
|
||||
|
||||
async def get_channel_schedules(
|
||||
self, channel_id: str, since: int | None = None
|
||||
) -> list | dict:
|
||||
"""获取子频道日程列表(GET /channels/{channel_id}/schedules)。
|
||||
|
||||
默认返回当天日程;传 since(毫秒级时间戳)返回结束时间在其后的日程。
|
||||
"""
|
||||
params = {"since": str(since)} if since is not None else None
|
||||
return await self.get(f"/channels/{channel_id}/schedules", params=params)
|
||||
|
||||
async def get_channel_schedule(self, channel_id: str, schedule_id: str) -> dict:
|
||||
"""获取单个日程(GET /channels/{channel_id}/schedules/{schedule_id})。"""
|
||||
return await self.get(f"/channels/{channel_id}/schedules/{schedule_id}")
|
||||
|
||||
async def create_channel_schedule(
|
||||
self,
|
||||
channel_id: str,
|
||||
name: str,
|
||||
start_timestamp: str,
|
||||
end_timestamp: str,
|
||||
jump_channel_id: str,
|
||||
remind_type: str = "0",
|
||||
description: str = "",
|
||||
) -> dict:
|
||||
"""创建日程(POST /channels/{channel_id}/schedules)。
|
||||
|
||||
时间为毫秒级时间戳字符串;remind_type: "0" 不提醒 / "1" 开始时提醒
|
||||
(官方字段为字符串)。请求体按官方约定包在 "schedule" 字段里。
|
||||
需管理频道权限;单管理员每天限 10 次。
|
||||
"""
|
||||
return await self.post(
|
||||
f"/channels/{channel_id}/schedules",
|
||||
json={
|
||||
"schedule": {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"start_timestamp": start_timestamp,
|
||||
"end_timestamp": end_timestamp,
|
||||
"jump_channel_id": jump_channel_id,
|
||||
"remind_type": remind_type,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def update_channel_schedule(
|
||||
self, channel_id: str, schedule_id: str, **fields: Any
|
||||
) -> dict:
|
||||
"""修改日程(PATCH /channels/{channel_id}/schedules/{schedule_id})。
|
||||
|
||||
schedule 内的字段(name/start_timestamp/...)经 fields 透传。
|
||||
"""
|
||||
return await self.patch(
|
||||
f"/channels/{channel_id}/schedules/{schedule_id}",
|
||||
json={"schedule": {"id": schedule_id, **fields}},
|
||||
)
|
||||
|
||||
async def delete_channel_schedule(self, channel_id: str, schedule_id: str) -> dict:
|
||||
"""删除日程(DELETE /channels/{channel_id}/schedules/{schedule_id})。"""
|
||||
return await self.delete(f"/channels/{channel_id}/schedules/{schedule_id}")
|
||||
|
||||
async def mute_guild_member(
|
||||
self, guild_id: str, user_id: str, mute_seconds: int = 0
|
||||
) -> dict:
|
||||
"""禁言/解禁频道成员(PATCH /guilds/{guild_id}/members/{user_id}/mute)。
|
||||
|
||||
mute_seconds 为禁言时长(秒),传 0 解除禁言;上限 28 天。
|
||||
"""
|
||||
return await self.patch(
|
||||
f"/guilds/{guild_id}/members/{user_id}/mute",
|
||||
json={"mute_seconds": str(mute_seconds)},
|
||||
)
|
||||
|
||||
# ---------- 频道信息查询 ----------
|
||||
|
||||
async def get_guild(self, guild_id: str) -> dict:
|
||||
"""获取频道信息(GET /guilds/{guild_id})。"""
|
||||
return await self.get(f"/guilds/{guild_id}")
|
||||
|
||||
async def get_guild_channels(self, guild_id: str) -> list | dict:
|
||||
"""获取频道下的子频道列表(GET /channels/{guild_id}/channels)。"""
|
||||
return await self.get(f"/channels/{guild_id}/channels")
|
||||
|
||||
async def get_channel(self, channel_id: str) -> dict:
|
||||
"""获取子频道信息(GET /channels/{channel_id})。"""
|
||||
return await self.get(f"/channels/{channel_id}")
|
||||
|
||||
async def get_guild_roles(self, guild_id: str) -> dict:
|
||||
"""获取频道身份组列表(GET /guilds/{guild_id}/roles)。"""
|
||||
return await self.get(f"/guilds/{guild_id}/roles")
|
||||
|
||||
async def get_guild_member(self, guild_id: str, user_id: str) -> dict:
|
||||
"""获取成员详情(GET /guilds/{guild_id}/members/{user_id})。"""
|
||||
return await self.get(f"/guilds/{guild_id}/members/{user_id}")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""OpenAPI 客户端:BotApi 按域组合——v2 群/单聊、频道消息、频道管理。
|
||||
|
||||
各域模块是方法集(继承 BaseApi 共享请求内核:鉴权头注入、429 自动
|
||||
重试、401 刷新 token 重试、统一 ApiError),方法名与拆分前完全一致。
|
||||
"""
|
||||
|
||||
from .channel import ChannelApi
|
||||
from .core import ApiError
|
||||
from .core import BaseApi as BaseApi
|
||||
from .guild import GuildApi
|
||||
from .v2 import V2Api, button, keyboard
|
||||
|
||||
|
||||
class BotApi(V2Api, ChannelApi, GuildApi):
|
||||
"""通用 REST 封装;具体业务接口见各域方法集。
|
||||
|
||||
BaseApi(请求内核)经域方法集继承自然落在 MRO 末尾,
|
||||
构造签名 (config, http, token) 由它提供。
|
||||
"""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ApiError",
|
||||
"BotApi",
|
||||
"ChannelApi",
|
||||
"GuildApi",
|
||||
"V2Api",
|
||||
"button",
|
||||
"keyboard",
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""频道消息方法集(channel_id 接入面):发送、撤回与表情表态。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .core import BaseApi
|
||||
|
||||
|
||||
class ChannelApi(BaseApi):
|
||||
"""频道消息方法集;请求内核(鉴权/重试/错误)由 BaseApi 提供。"""
|
||||
|
||||
async def post_channel_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
content: str = "",
|
||||
msg_id: str | None = None,
|
||||
msg_seq: int = 1,
|
||||
image: str | None = None,
|
||||
**extra: Any,
|
||||
) -> dict:
|
||||
"""发频道消息(POST /channels/{channel_id}/messages)。
|
||||
|
||||
image 为图片 URL;其余字段(embed/ark/markdown 等)经 extra 透传。
|
||||
"""
|
||||
body: dict[str, Any] = {"msg_seq": msg_seq}
|
||||
if content:
|
||||
body["content"] = content
|
||||
if msg_id:
|
||||
body["msg_id"] = msg_id
|
||||
if image:
|
||||
body["image"] = image
|
||||
body.update(extra)
|
||||
return await self.post(f"/channels/{channel_id}/messages", json=body)
|
||||
|
||||
async def withdraw_channel_message(
|
||||
self, channel_id: str, message_id: str, hide_tip: bool = False
|
||||
) -> dict:
|
||||
"""撤回频道消息(DELETE /channels/{channel_id}/messages/{message_id})。
|
||||
|
||||
hide_tip 控制是否隐藏撤回提示小灰条(官方参数名为 hidetip);
|
||||
仅私域机器人可用。
|
||||
"""
|
||||
return await self.delete(
|
||||
f"/channels/{channel_id}/messages/{message_id}",
|
||||
params={"hidetip": str(hide_tip).lower()},
|
||||
)
|
||||
|
||||
async def put_channel_reaction(
|
||||
self, channel_id: str, message_id: str, emoji_type: int, emoji_id: str
|
||||
) -> dict:
|
||||
"""对频道消息表态(PUT .../reactions/{type}/{emoji_id})。
|
||||
|
||||
emoji_type=1 为系统表情,emoji_id 为系统表情序号(字符串)。
|
||||
"""
|
||||
return await self.put(
|
||||
f"/channels/{channel_id}/messages/{message_id}"
|
||||
f"/reactions/{emoji_type}/{emoji_id}"
|
||||
)
|
||||
|
||||
async def delete_channel_reaction(
|
||||
self, channel_id: str, message_id: str, emoji_type: int, emoji_id: str
|
||||
) -> dict:
|
||||
"""删除自己对该消息的表态(DELETE .../reactions/{type}/{emoji_id})。"""
|
||||
return await self.delete(
|
||||
f"/channels/{channel_id}/messages/{message_id}"
|
||||
f"/reactions/{emoji_type}/{emoji_id}"
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""OpenAPI 请求内核:鉴权头注入、限流/鉴权失败重试与统一错误。
|
||||
|
||||
失败语义(官方文档 2026-07 核对):
|
||||
- 429 触发令牌桶限流,请求未被服务端处理,重试安全——优先按
|
||||
Retry-After 等待(线上见过 "30,120" 这类脏值,解析容错),缺失时
|
||||
指数退避,最多重试 `_RETRY_LIMIT` 次,等待时长封顶 `_RETRY_CAP`;
|
||||
- 401 多为 token 被服务端提前作废,强制刷新 token 后重试一次;
|
||||
- 其余 HTTP ≥400 抛 ApiError,code/message 取自响应体。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import ujson
|
||||
from aiohttp import ClientResponse, ClientSession
|
||||
from loguru import logger
|
||||
|
||||
from ..config import Config
|
||||
from ..model import error_parts
|
||||
from ..token import AccessToken
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
"""OpenAPI 调用失败:HTTP 非 2xx 或业务 code 非 0。"""
|
||||
|
||||
def __init__(self, status: int, code: Any, message: Any) -> None:
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.message = message
|
||||
super().__init__(f"API 调用失败: HTTP {status}, code={code}, message={message}")
|
||||
|
||||
|
||||
class BaseApi:
|
||||
"""REST 请求内核;域方法集(v2/channel/guild)继承它做薄封装。"""
|
||||
|
||||
# 429 重试次数上限;Retry-After 缺失时的退避基数(秒)与等待封顶
|
||||
_RETRY_LIMIT = 3
|
||||
_RETRY_BASE = 1.0
|
||||
_RETRY_CAP = 30.0
|
||||
|
||||
def __init__(self, config: Config, http: ClientSession, token: AccessToken) -> None:
|
||||
self._config = config
|
||||
self._http = http
|
||||
self._token = token
|
||||
|
||||
async def request(self, method: str, path: str, **kwargs) -> Any:
|
||||
auth_retried = False
|
||||
for attempt in range(self._RETRY_LIMIT + 1):
|
||||
access_token = await self._token.get_access_token()
|
||||
headers = {"Authorization": f"QQBot {access_token}"}
|
||||
async with self._http.request(
|
||||
method,
|
||||
f"{self._config.base_url}{path}",
|
||||
headers=headers,
|
||||
**kwargs,
|
||||
) as resp:
|
||||
# 个别接口 200 响应的 Content-Type 不是 JSON(body 仍是 JSON,
|
||||
# 如回应互动返回 200 text/plain + {}),跳过标头校验
|
||||
data = await resp.json(loads=ujson.loads, content_type=None)
|
||||
if resp.status == 429 and attempt < self._RETRY_LIMIT:
|
||||
delay = self._retry_delay(resp, attempt)
|
||||
logger.warning(f"{method} {path} 触发频率限制,{delay:.0f}s 后重试")
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
if resp.status == 401 and not auth_retried:
|
||||
auth_retried = True
|
||||
self._token.invalidate()
|
||||
logger.warning(f"{method} {path} 鉴权失效,刷新 token 后重试")
|
||||
continue
|
||||
if resp.status >= 400:
|
||||
logger.warning(f"{method} {path} 失败: {data}")
|
||||
raise ApiError(resp.status, *error_parts(data))
|
||||
return data
|
||||
raise ApiError(429, None, "频率限制重试次数用尽") # 兜底,理论不可达
|
||||
|
||||
def _retry_delay(self, resp: ClientResponse, attempt: int) -> float:
|
||||
raw = resp.headers.get("Retry-After")
|
||||
if raw:
|
||||
try:
|
||||
return min(float(raw.split(",")[0].strip()), self._RETRY_CAP)
|
||||
except ValueError:
|
||||
pass
|
||||
return min(self._RETRY_BASE * 2**attempt, self._RETRY_CAP)
|
||||
|
||||
# ---------- 通用 ----------
|
||||
|
||||
async def me(self) -> dict:
|
||||
"""机器人自身信息(GET /users/@me)。"""
|
||||
return await self.get("/users/@me")
|
||||
|
||||
async def respond_interaction(self, interaction_id: str, code: int = 0) -> dict:
|
||||
"""回应互动事件(PUT /interactions/{interaction_id})。
|
||||
|
||||
按钮点击(INTERACTION_CREATE type=11)等互动收到后必须回应,
|
||||
否则客户端一直 loading 直到超时;同一 id 只能回应一次。
|
||||
"""
|
||||
return await self.put(f"/interactions/{interaction_id}", json={"code": code})
|
||||
|
||||
async def get(self, path: str, **kwargs) -> Any:
|
||||
return await self.request("GET", path, **kwargs)
|
||||
|
||||
async def post(self, path: str, **kwargs) -> Any:
|
||||
return await self.request("POST", path, **kwargs)
|
||||
|
||||
async def delete(self, path: str, **kwargs) -> Any:
|
||||
return await self.request("DELETE", path, **kwargs)
|
||||
|
||||
async def put(self, path: str, **kwargs) -> Any:
|
||||
return await self.request("PUT", path, **kwargs)
|
||||
|
||||
async def patch(self, path: str, **kwargs) -> Any:
|
||||
return await self.request("PATCH", path, **kwargs)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""频道(guild)管理方法集:公告、精华消息、日程、成员禁言与信息查询。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .core import BaseApi
|
||||
|
||||
|
||||
class GuildApi(BaseApi):
|
||||
"""频道管理与查询方法集;请求内核(鉴权/重试/错误)由 BaseApi 提供。"""
|
||||
|
||||
# ---------- 公告与精华 ----------
|
||||
|
||||
async def create_guild_announce(
|
||||
self,
|
||||
guild_id: str,
|
||||
channel_id: str,
|
||||
message_id: str,
|
||||
announces_type: int = 0,
|
||||
**extra: Any,
|
||||
) -> dict:
|
||||
"""创建频道公告(POST /guilds/{guild_id}/announces)。
|
||||
|
||||
announces_type: 0 成员公告(默认)、1 欢迎公告;
|
||||
recommend_channels 等经 extra 透传(与消息类公告互斥)。
|
||||
"""
|
||||
body = {
|
||||
"channel_id": channel_id,
|
||||
"message_id": message_id,
|
||||
"announces_type": announces_type,
|
||||
}
|
||||
body.update(extra)
|
||||
return await self.post(f"/guilds/{guild_id}/announces", json=body)
|
||||
|
||||
async def delete_guild_announce(self, guild_id: str, message_id: str) -> dict:
|
||||
"""删除频道公告(DELETE /guilds/{guild_id}/announces/{message_id})。
|
||||
|
||||
删除推荐子频道类型的公告需传 message_id="all"。
|
||||
"""
|
||||
return await self.delete(f"/guilds/{guild_id}/announces/{message_id}")
|
||||
|
||||
async def pin_channel_message(self, channel_id: str, message_id: str) -> dict:
|
||||
"""添加精华消息(PUT /channels/{channel_id}/pins/{message_id})。"""
|
||||
return await self.put(f"/channels/{channel_id}/pins/{message_id}")
|
||||
|
||||
async def unpin_channel_message(self, channel_id: str, message_id: str) -> dict:
|
||||
"""移出精华消息(DELETE /channels/{channel_id}/pins/{message_id})。"""
|
||||
return await self.delete(f"/channels/{channel_id}/pins/{message_id}")
|
||||
|
||||
async def get_channel_pins(self, channel_id: str) -> dict:
|
||||
"""获取精华消息列表(GET /channels/{channel_id}/pins)。"""
|
||||
return await self.get(f"/channels/{channel_id}/pins")
|
||||
|
||||
# ---------- 日程 ----------
|
||||
|
||||
async def get_channel_schedules(
|
||||
self, channel_id: str, since: int | None = None
|
||||
) -> list | dict:
|
||||
"""获取子频道日程列表(GET /channels/{channel_id}/schedules)。
|
||||
|
||||
默认返回当天日程;传 since(毫秒级时间戳)返回结束时间在其后的日程。
|
||||
"""
|
||||
params = {"since": str(since)} if since is not None else None
|
||||
return await self.get(f"/channels/{channel_id}/schedules", params=params)
|
||||
|
||||
async def get_channel_schedule(self, channel_id: str, schedule_id: str) -> dict:
|
||||
"""获取单个日程(GET /channels/{channel_id}/schedules/{schedule_id})。"""
|
||||
return await self.get(f"/channels/{channel_id}/schedules/{schedule_id}")
|
||||
|
||||
async def create_channel_schedule(
|
||||
self,
|
||||
channel_id: str,
|
||||
name: str,
|
||||
start_timestamp: str,
|
||||
end_timestamp: str,
|
||||
jump_channel_id: str,
|
||||
remind_type: str = "0",
|
||||
description: str = "",
|
||||
) -> dict:
|
||||
"""创建日程(POST /channels/{channel_id}/schedules)。
|
||||
|
||||
时间为毫秒级时间戳字符串;remind_type: "0" 不提醒 / "1" 开始时提醒
|
||||
(官方字段为字符串)。请求体按官方约定包在 "schedule" 字段里。
|
||||
需管理频道权限;单管理员每天限 10 次。
|
||||
"""
|
||||
return await self.post(
|
||||
f"/channels/{channel_id}/schedules",
|
||||
json={
|
||||
"schedule": {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"start_timestamp": start_timestamp,
|
||||
"end_timestamp": end_timestamp,
|
||||
"jump_channel_id": jump_channel_id,
|
||||
"remind_type": remind_type,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def update_channel_schedule(
|
||||
self, channel_id: str, schedule_id: str, **fields: Any
|
||||
) -> dict:
|
||||
"""修改日程(PATCH /channels/{channel_id}/schedules/{schedule_id})。
|
||||
|
||||
schedule 内的字段(name/start_timestamp/...)经 fields 透传。
|
||||
"""
|
||||
return await self.patch(
|
||||
f"/channels/{channel_id}/schedules/{schedule_id}",
|
||||
json={"schedule": {"id": schedule_id, **fields}},
|
||||
)
|
||||
|
||||
async def delete_channel_schedule(self, channel_id: str, schedule_id: str) -> dict:
|
||||
"""删除日程(DELETE /channels/{channel_id}/schedules/{schedule_id})。"""
|
||||
return await self.delete(f"/channels/{channel_id}/schedules/{schedule_id}")
|
||||
|
||||
# ---------- 成员管理与信息查询 ----------
|
||||
|
||||
async def mute_guild_member(
|
||||
self, guild_id: str, user_id: str, mute_seconds: int = 0
|
||||
) -> dict:
|
||||
"""禁言/解禁频道成员(PATCH /guilds/{guild_id}/members/{user_id}/mute)。
|
||||
|
||||
mute_seconds 为禁言时长(秒),传 0 解除禁言;上限 28 天。
|
||||
"""
|
||||
return await self.patch(
|
||||
f"/guilds/{guild_id}/members/{user_id}/mute",
|
||||
json={"mute_seconds": str(mute_seconds)},
|
||||
)
|
||||
|
||||
async def get_guild(self, guild_id: str) -> dict:
|
||||
"""获取频道信息(GET /guilds/{guild_id})。"""
|
||||
return await self.get(f"/guilds/{guild_id}")
|
||||
|
||||
async def get_guild_channels(self, guild_id: str) -> list | dict:
|
||||
"""获取频道下的子频道列表(GET /channels/{guild_id}/channels)。"""
|
||||
return await self.get(f"/channels/{guild_id}/channels")
|
||||
|
||||
async def get_channel(self, channel_id: str) -> dict:
|
||||
"""获取子频道信息(GET /channels/{channel_id})。"""
|
||||
return await self.get(f"/channels/{channel_id}")
|
||||
|
||||
async def get_guild_roles(self, guild_id: str) -> dict:
|
||||
"""获取频道身份组列表(GET /guilds/{guild_id}/roles)。"""
|
||||
return await self.get(f"/guilds/{guild_id}/roles")
|
||||
|
||||
async def get_guild_member(self, guild_id: str, user_id: str) -> dict:
|
||||
"""获取成员详情(GET /guilds/{guild_id}/members/{user_id})。"""
|
||||
return await self.get(f"/guilds/{guild_id}/members/{user_id}")
|
||||
@@ -0,0 +1,218 @@
|
||||
"""v2 群聊/单聊方法集(openid 接入面):消息、富媒体上传、撤回、禁言,
|
||||
以及内嵌按钮/键盘的快捷构造。
|
||||
|
||||
被动回复规则(官方文档 2026-09 核对):携带 msg_id(或 event_id,
|
||||
二选一)为被动回复——单聊 60 分钟内最多 4 次,群聊 5 分钟内最多 5 次;
|
||||
同一条消息多次回复需递增 msg_seq(相同 msg_id+msg_seq 会发送失败);
|
||||
不携带则为主动消息(另有频控,群/单聊每日每对象 1000 条)。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from .core import BaseApi
|
||||
|
||||
# msg_type 取值
|
||||
MSG_TYPE_TEXT = 0
|
||||
MSG_TYPE_MARKDOWN = 2
|
||||
MSG_TYPE_MEDIA = 7
|
||||
# file_type 取值(富媒体上传)
|
||||
FILE_TYPE_IMAGE = 1
|
||||
FILE_TYPE_VIDEO = 2
|
||||
FILE_TYPE_VOICE = 3
|
||||
FILE_TYPE_FILE = 4
|
||||
|
||||
|
||||
def button(
|
||||
label: str,
|
||||
data: str = "",
|
||||
*,
|
||||
type: int = 1,
|
||||
permission: int = 2,
|
||||
style: int = 1,
|
||||
id: str | None = None,
|
||||
) -> dict:
|
||||
"""构造单个内嵌按钮 dict,供 keyboard() 组装后传入 keyboard= 参数。
|
||||
|
||||
type: 1 回调(点击推 INTERACTION_CREATE)/ 0 跳转链接(data 为 url)/
|
||||
2 指令(data 插入输入框)。permission: 2 所有人 / 1 管理员 / 0 指定
|
||||
用户。label 官方限 10 字以内;要补嵌套字段(如 visited_label、
|
||||
prompt)直接修改返回的 dict。
|
||||
"""
|
||||
return {
|
||||
"id": id or uuid4().hex[:8],
|
||||
"render_data": {"label": label, "style": style},
|
||||
"action": {"type": type, "permission": {"type": permission}, "data": data},
|
||||
}
|
||||
|
||||
|
||||
def keyboard(*rows: dict | list[dict]) -> dict:
|
||||
"""组装内嵌键盘(keyboard= 参数):每个位置参数为一行,
|
||||
单个按钮独占一行,按钮列表同行并排。"""
|
||||
return {
|
||||
"content": {
|
||||
"rows": [
|
||||
{"buttons": row if isinstance(row, list) else [row]} for row in rows
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class V2Api(BaseApi):
|
||||
"""v2 群聊/单聊方法集;请求内核(鉴权/重试/错误)由 BaseApi 提供。"""
|
||||
|
||||
async def post_c2c_message(
|
||||
self,
|
||||
openid: str,
|
||||
content: str = "",
|
||||
msg_id: str | None = None,
|
||||
msg_seq: int = 1,
|
||||
media: dict | None = None,
|
||||
event_id: str | None = None,
|
||||
markdown: str | dict | None = None,
|
||||
keyboard: dict | None = None,
|
||||
**extra: Any,
|
||||
) -> dict:
|
||||
"""发单聊消息(POST /v2/users/{openid}/messages)。
|
||||
|
||||
media 为富媒体上传接口返回的 {"file_info": ...} 结构,
|
||||
此时 content 作媒体下方文本。msg_id(回复消息)与 event_id
|
||||
(响应事件)二选一,均为被动回复。markdown 为字符串或完整
|
||||
dict(msg_type=2,与 content 互斥);keyboard 为内嵌键盘
|
||||
结构({"content": {"rows": [...]}} 或 {"id": ...})。
|
||||
"""
|
||||
return await self._post_message(
|
||||
f"/v2/users/{openid}/messages",
|
||||
content=content,
|
||||
msg_id=msg_id,
|
||||
msg_seq=msg_seq,
|
||||
media=media,
|
||||
event_id=event_id,
|
||||
markdown=markdown,
|
||||
keyboard=keyboard,
|
||||
**extra,
|
||||
)
|
||||
|
||||
async def upload_c2c_file(
|
||||
self, openid: str, file_type: int, url: str, srv_send_msg: bool = False
|
||||
) -> dict:
|
||||
"""上传单聊富媒体(POST /v2/users/{openid}/files)。
|
||||
|
||||
返回含 file_info,可传入 post_c2c_message 的 media 直接发送,
|
||||
或 srv_send_msg=True 直接作为消息发出。
|
||||
"""
|
||||
return await self._upload_v2_file(
|
||||
f"/v2/users/{openid}", file_type, url, srv_send_msg
|
||||
)
|
||||
|
||||
async def post_group_message(
|
||||
self,
|
||||
group_openid: str,
|
||||
content: str = "",
|
||||
msg_id: str | None = None,
|
||||
msg_seq: int = 1,
|
||||
media: dict | None = None,
|
||||
event_id: str | None = None,
|
||||
markdown: str | dict | None = None,
|
||||
keyboard: dict | None = None,
|
||||
**extra: Any,
|
||||
) -> dict:
|
||||
"""发群消息(POST /v2/groups/{group_openid}/messages)。
|
||||
|
||||
msg_id(回复消息)与 event_id(响应 GROUP_ADD_ROBOT /
|
||||
INTERACTION_CREATE 等事件)二选一。markdown 为字符串或完整
|
||||
dict(msg_type=2,与 content 互斥);keyboard 为内嵌键盘
|
||||
结构({"content": {"rows": [...]}} 或 {"id": ...})。
|
||||
"""
|
||||
return await self._post_message(
|
||||
f"/v2/groups/{group_openid}/messages",
|
||||
content=content,
|
||||
msg_id=msg_id,
|
||||
msg_seq=msg_seq,
|
||||
media=media,
|
||||
event_id=event_id,
|
||||
markdown=markdown,
|
||||
keyboard=keyboard,
|
||||
**extra,
|
||||
)
|
||||
|
||||
async def upload_group_file(
|
||||
self, group_openid: str, file_type: int, url: str, srv_send_msg: bool = False
|
||||
) -> dict:
|
||||
"""上传群富媒体(POST /v2/groups/{group_openid}/files)。"""
|
||||
return await self._upload_v2_file(
|
||||
f"/v2/groups/{group_openid}", file_type, url, srv_send_msg
|
||||
)
|
||||
|
||||
async def _upload_v2_file(
|
||||
self, base: str, file_type: int, url: str, srv_send_msg: bool
|
||||
) -> dict:
|
||||
return await self.post(
|
||||
f"{base}/files",
|
||||
json={"file_type": file_type, "url": url, "srv_send_msg": srv_send_msg},
|
||||
)
|
||||
|
||||
async def _post_message(
|
||||
self,
|
||||
path: str,
|
||||
content: str,
|
||||
msg_id: str | None,
|
||||
msg_seq: int,
|
||||
media: dict | None,
|
||||
event_id: str | None,
|
||||
markdown: str | dict | None = None,
|
||||
keyboard: dict | None = None,
|
||||
**extra: Any,
|
||||
) -> dict:
|
||||
# media > markdown > 纯文本;markdown 与 content 互斥(官方约定)
|
||||
if media is not None:
|
||||
body: dict[str, Any] = {"msg_type": MSG_TYPE_MEDIA, "media": media}
|
||||
if content:
|
||||
body["content"] = content
|
||||
elif markdown is not None:
|
||||
body = {
|
||||
"msg_type": MSG_TYPE_MARKDOWN,
|
||||
"markdown": markdown
|
||||
if isinstance(markdown, dict)
|
||||
else {"content": markdown},
|
||||
}
|
||||
else:
|
||||
body = {"msg_type": MSG_TYPE_TEXT}
|
||||
if content:
|
||||
body["content"] = content
|
||||
if msg_id:
|
||||
body["msg_id"] = msg_id
|
||||
body["msg_seq"] = msg_seq
|
||||
elif event_id:
|
||||
body["event_id"] = event_id
|
||||
body["msg_seq"] = msg_seq
|
||||
if keyboard is not None:
|
||||
body["keyboard"] = keyboard
|
||||
body.update(extra)
|
||||
return await self.post(path, json=body)
|
||||
|
||||
async def _withdraw_v2_message(self, path: str, message_id: str) -> dict:
|
||||
# 发送超过 2 分钟的消息不可撤回
|
||||
return await self.delete(f"{path}/messages/{message_id}")
|
||||
|
||||
async def withdraw_group_message(self, group_openid: str, message_id: str) -> dict:
|
||||
"""撤回群消息(DELETE /v2/groups/{group_openid}/messages/{message_id})。
|
||||
|
||||
群管理员可撤回自己与普通成员的消息;普通成员仅能撤回自己的。
|
||||
"""
|
||||
return await self._withdraw_v2_message(f"/v2/groups/{group_openid}", message_id)
|
||||
|
||||
async def withdraw_c2c_message(self, openid: str, message_id: str) -> dict:
|
||||
"""撤回单聊消息(DELETE /v2/users/{openid}/messages/{message_id})。"""
|
||||
return await self._withdraw_v2_message(f"/v2/users/{openid}", message_id)
|
||||
|
||||
async def mute_group_member(self, group_openid: str, mute_expire_at: str) -> dict:
|
||||
"""群成员禁言(POST /v2/groups/{group_openid}/restrict_chat_setting)。
|
||||
|
||||
mute_expire_at 为禁言到期时间(RFC3339 格式,如
|
||||
"2026-01-01T12:00:00+08:00");传当前时间之前即解除禁言。
|
||||
"""
|
||||
return await self.post(
|
||||
f"/v2/groups/{group_openid}/restrict_chat_setting",
|
||||
json={"mute_expire_at": mute_expire_at},
|
||||
)
|
||||
+47
-14
@@ -1,15 +1,19 @@
|
||||
"""连接适配器:把两种接入方式(WebSocket / Webhook)归一为事件流入、reply 队列流出。
|
||||
|
||||
- WebsocketConnecter:gateway 握手 + ws 长连接,指数退避重连;
|
||||
看门狗两层互补——receive 的静默超时抓"连接彻底静默",
|
||||
- WebsocketConnecter:gateway 握手 + ws 长连接,指数退避重连(连接稳定
|
||||
存活才把退避归一,抖动的服务端不会被 1s 间隔反复探测);心跳任务随
|
||||
连接生灭——HELLO 给出间隔后周期投递,断线即取消,不在 reply 队列堆积
|
||||
过期心跳;看门狗两层互补——receive 的静默超时抓"连接彻底静默",
|
||||
收到消息时的 ACK 期限检查抓"有事件流但心跳已死";
|
||||
- WebhookConnecter:aiohttp server 接收回调,验签失败 401,
|
||||
按 payload id 做 60s TTL 去重,正常推送进事件队列。
|
||||
按 payload id 做 60s TTL 去重,正常推送进事件队列;op=13 验证请求
|
||||
经注入的 handle_event 同步取得应答(HTTP 响应不走 reply 队列)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from time import monotonic
|
||||
from typing import Protocol
|
||||
from typing import Any, Protocol
|
||||
|
||||
import aiohttp
|
||||
import ujson
|
||||
@@ -18,9 +22,9 @@ from loguru import logger
|
||||
|
||||
from .config import Config
|
||||
from .crypto import verify_sig
|
||||
from .emitter import EventEmitter
|
||||
from .model import NOT_SET, Opcode, Payload
|
||||
from .model import NOT_SET, Opcode, Payload, payload_of
|
||||
from .queue import EventQueue
|
||||
from .session import Session
|
||||
from .token import AccessToken
|
||||
|
||||
|
||||
@@ -37,25 +41,30 @@ class WebsocketConnecter:
|
||||
# 不能压到 1× 整——ACK 的自然间隔就是 1×,正常抖动会贴边误杀;TCP 下丢包表现为延迟而非静默
|
||||
_SILENCE_FACTOR = 1.1
|
||||
_ACK_FACTOR = 1.2
|
||||
# 连接存活超过该时长才算稳定,才把退避归一
|
||||
_STABLE_SECONDS = 60.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
http: aiohttp.ClientSession,
|
||||
token: AccessToken,
|
||||
session: Session,
|
||||
queue: EventQueue,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._http = http
|
||||
self._token = token
|
||||
self._session = session
|
||||
self._queue = queue
|
||||
|
||||
async def run(self) -> None:
|
||||
backoff: float = 1
|
||||
while True:
|
||||
try:
|
||||
await self._connect()
|
||||
backoff = 1
|
||||
lived = await self._connect()
|
||||
if lived >= self._STABLE_SECONDS:
|
||||
backoff = 1
|
||||
except RateLimitError:
|
||||
logger.warning("gateway 触发频率限制")
|
||||
backoff = 60
|
||||
@@ -67,7 +76,9 @@ class WebsocketConnecter:
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
|
||||
async def _connect(self) -> None:
|
||||
async def _connect(self) -> float:
|
||||
"""建立一次连接直到断开,返回存活秒数(供 run 判断是否重置退避)。"""
|
||||
started = monotonic()
|
||||
access_token = await self._token.get_access_token()
|
||||
headers = {"Authorization": f"QQBot {access_token}"}
|
||||
|
||||
@@ -84,15 +95,22 @@ class WebsocketConnecter:
|
||||
|
||||
async with self._http.ws_connect(gateway, headers=headers) as ws:
|
||||
logger.info("WebSocket 已连接")
|
||||
receive = asyncio.create_task(self.receive_helper(ws))
|
||||
# HELLO 才给出心跳间隔;receive 解析到后经 Future 交给心跳任务
|
||||
interval: asyncio.Future[float] = asyncio.get_running_loop().create_future()
|
||||
receive = asyncio.create_task(self.receive_helper(ws, interval))
|
||||
reply = asyncio.create_task(self.reply_helper(ws))
|
||||
heartbeat = asyncio.create_task(self.heartbeat_helper(interval))
|
||||
try:
|
||||
await receive
|
||||
finally:
|
||||
reply.cancel()
|
||||
heartbeat.cancel()
|
||||
logger.info("WebSocket 已断开")
|
||||
return monotonic() - started
|
||||
|
||||
async def receive_helper(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
||||
async def receive_helper(
|
||||
self, ws: aiohttp.ClientWebSocketResponse, interval_fut: asyncio.Future[float]
|
||||
) -> None:
|
||||
interval: float = 0
|
||||
last_ack = monotonic()
|
||||
while not ws.closed:
|
||||
@@ -107,6 +125,8 @@ class WebsocketConnecter:
|
||||
d = data.get("d")
|
||||
if isinstance(d, dict):
|
||||
interval = d.get("heartbeat_interval", 41_250) / 1000
|
||||
if not interval_fut.done():
|
||||
interval_fut.set_result(interval)
|
||||
elif op == Opcode.HEARTBEAT_ACK:
|
||||
last_ack = monotonic()
|
||||
await self._queue.put_event(data)
|
||||
@@ -115,6 +135,14 @@ class WebsocketConnecter:
|
||||
if monotonic() - last_ack > interval * self._ACK_FACTOR:
|
||||
raise TimeoutError
|
||||
|
||||
async def heartbeat_helper(self, interval_fut: asyncio.Future[float]) -> None:
|
||||
"""HELLO 给出间隔后周期投递心跳;断线随任务取消,不堆积过期心跳。"""
|
||||
interval = await interval_fut
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
# 服务端不会推送 op=1,心跳只由本任务经 reply 队列发出
|
||||
await self._queue.put_reply(payload_of(Opcode.HEARTBEAT, self._session.seq))
|
||||
|
||||
async def reply_helper(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
||||
while not ws.closed:
|
||||
payload: Payload = await self._queue.get_reply()
|
||||
@@ -129,11 +157,16 @@ class WebhookConnecter:
|
||||
_DEDUP_TTL = 60
|
||||
|
||||
def __init__(
|
||||
self, config: Config, emitter: EventEmitter, queue: EventQueue
|
||||
self,
|
||||
config: Config,
|
||||
queue: EventQueue,
|
||||
handle_event: Callable[[Payload], Awaitable[Any]],
|
||||
) -> None:
|
||||
"""handle_event 供 op=13 验证请求同步取应答(通常传 emitter.handle);
|
||||
与 ws 侧对齐,本类只依赖队列与该回调,不感知 EventEmitter。"""
|
||||
self._config = config
|
||||
self._emitter = emitter
|
||||
self._queue = queue
|
||||
self._handle_event = handle_event
|
||||
self._seen: dict[str, float] = {}
|
||||
|
||||
def _make_app(self) -> web.Application:
|
||||
@@ -173,7 +206,7 @@ class WebhookConnecter:
|
||||
|
||||
if payload.get("op") == Opcode.VALIDATION:
|
||||
logger.info("收到 Webhook 验证请求")
|
||||
ret = await self._emitter.handle(payload)
|
||||
ret = await self._handle_event(payload)
|
||||
if ret is None:
|
||||
return web.json_response(
|
||||
{"error": "missing validation handler"}, status=500
|
||||
|
||||
+58
-12
@@ -3,11 +3,16 @@
|
||||
- `dispatch()` 泵循环消费 EventQueue.msg_queue,`emit()` 按
|
||||
op(DISPATCH 用 t 名、其余用 Opcode 名)路由到 `@on` 注册的 handler;
|
||||
- handler 参数按形参标注注入:payload dataclass → 解析对象,`Event` →
|
||||
事件封装,`services` 里登记的组件类型 → 实例,其余直传原始 d;
|
||||
事件封装,`services` 里登记的组件类型 → 实例,其余直传原始 d
|
||||
(可能是 dict、标量甚至 None,协议事件的 d 不保证是 dict);
|
||||
- 业务 handler(op=0)返回值不回流;协议层(op≠0)返回的 payload
|
||||
进 reply 队列由 ws 发出。handler 异常就地隔离,不影响其他 handler。
|
||||
- `on(..., background=True)` 把 handler 放进后台任务并发执行:
|
||||
不阻塞事件流,但同事件 handler 间失去先后保证,返回值不回流;
|
||||
进程退出经 `close()` 统一取消在飞任务。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Callable
|
||||
@@ -35,16 +40,18 @@ class BaseProtocol(ABC):
|
||||
|
||||
|
||||
class EventEmitter:
|
||||
def __init__(self, queue: EventQueue) -> None:
|
||||
def __init__(self, queue: EventQueue | None = None) -> None:
|
||||
self._ee = AsyncIOEventEmitter()
|
||||
self._queue = queue
|
||||
self._queue = queue if queue is not None else EventQueue()
|
||||
self.services: dict[type, Any] = {}
|
||||
self._protocol: BaseProtocol | None = None
|
||||
self._hints: dict[Callable[..., Any], dict[str, Any]] = {}
|
||||
self._background: set[Callable[..., Any]] = set()
|
||||
self._bg_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
@property
|
||||
def queue(self) -> EventQueue:
|
||||
"""本 emitter 消费的事件队列;外部构造 emitter 时由 run_loop 复用同一实例。"""
|
||||
"""本 emitter 消费的事件队列;未显式传入时构造即自建,run_loop 复用同一实例。"""
|
||||
return self._queue
|
||||
|
||||
def register_protocol(self, protocol: BaseProtocol) -> None:
|
||||
@@ -52,25 +59,42 @@ class EventEmitter:
|
||||
protocol.register(self)
|
||||
|
||||
@overload
|
||||
def on(self, event: Opcode | str) -> Callable[[Handler], Handler]: ...
|
||||
def on(
|
||||
self, event: Opcode | str, *, background: bool = False
|
||||
) -> Callable[[Handler], Handler]: ...
|
||||
|
||||
@overload
|
||||
def on(self, event: Opcode | str, fn: Handler) -> Handler: ...
|
||||
def on(
|
||||
self, event: Opcode | str, fn: Handler, *, background: bool = False
|
||||
) -> Handler: ...
|
||||
|
||||
def on(
|
||||
self, event: Opcode | str, fn: Handler | None = None
|
||||
self,
|
||||
event: Opcode | str,
|
||||
fn: Handler | None = None,
|
||||
*,
|
||||
background: bool = False,
|
||||
) -> Handler | Callable[[Handler], Handler]:
|
||||
name = event.name if isinstance(event, Opcode) else event
|
||||
|
||||
def decorator(fn: Handler) -> Handler:
|
||||
self._ee.on(name, fn)
|
||||
if background:
|
||||
self._background.add(fn)
|
||||
return fn
|
||||
|
||||
if fn is None:
|
||||
return decorator
|
||||
|
||||
self._ee.on(name, fn)
|
||||
return fn
|
||||
return decorator(fn)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""取消全部在飞后台任务并等待收尾;run_loop 退出时先于连接池调用。"""
|
||||
if not self._bg_tasks:
|
||||
return
|
||||
for task in self._bg_tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*self._bg_tasks, return_exceptions=True)
|
||||
|
||||
async def dispatch(self) -> None:
|
||||
while True:
|
||||
@@ -115,8 +139,12 @@ class EventEmitter:
|
||||
if not listeners:
|
||||
return
|
||||
|
||||
reply = op != Opcode.DISPATCH
|
||||
for fn in listeners:
|
||||
await self._invoke(fn, event, reply=op != Opcode.DISPATCH)
|
||||
if fn in self._background:
|
||||
self._spawn(fn, event)
|
||||
else:
|
||||
await self._invoke(fn, event, reply=reply)
|
||||
|
||||
async def handle(self, data: Payload) -> Any:
|
||||
"""同步分发事件并返回首个处理器的非空返回值,用于 op=13 等需要应答的协议事件。"""
|
||||
@@ -143,11 +171,29 @@ class EventEmitter:
|
||||
if reply and isinstance(ret, dict):
|
||||
await self._queue.put_reply(cast(Payload, ret))
|
||||
|
||||
def _spawn(self, fn: Callable[..., Awaitable[Any]], event: Event) -> None:
|
||||
"""handler 进后台任务,emit 不等它;任务由 _bg_tasks 持强引用防 GC。"""
|
||||
task = asyncio.create_task(self._run_background(fn, event))
|
||||
self._bg_tasks.add(task)
|
||||
task.add_done_callback(self._bg_tasks.discard)
|
||||
|
||||
async def _run_background(
|
||||
self, fn: Callable[..., Awaitable[Any]], event: Event
|
||||
) -> None:
|
||||
try:
|
||||
await fn(*self._resolve_params(fn, event))
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"后台事件处理器 {getattr(fn, '__qualname__', fn)} 异常: {e}"
|
||||
)
|
||||
|
||||
def _resolve_params(
|
||||
self, fn: Callable[..., Awaitable[Any]], event: Event
|
||||
) -> list[Any]:
|
||||
"""按 handler 形参标注解析:payload dataclass → 解析对象,`Event` → 事件
|
||||
封装,`services` 登记的组件类型 → 实例,其余(含无标注)→ 原始 d。"""
|
||||
封装,`services` 登记的组件类型 → 实例,其余(含无标注)→ 原始 d
|
||||
(Event.data 是 dict 化视图,标量 d 如 INVALID_SESSION 的 true/false
|
||||
必须走这里才能拿到原值)。"""
|
||||
hints = self._hints.get(fn)
|
||||
if hints is None:
|
||||
hints = get_type_hints(fn)
|
||||
@@ -163,5 +209,5 @@ class EventEmitter:
|
||||
elif hint in self.services:
|
||||
args.append(self.services[hint])
|
||||
else:
|
||||
args.append(event.data)
|
||||
args.append(event.raw.get("d"))
|
||||
return args
|
||||
@@ -89,3 +89,14 @@ class Intent(IntFlag):
|
||||
|
||||
def payload_of(op: Opcode, d: Any = NOT_SET) -> Payload:
|
||||
return {"id": NOT_SET, "op": op, "d": d, "s": NOT_SET, "t": NOT_SET}
|
||||
|
||||
|
||||
def error_parts(data: Any) -> tuple[Any, Any]:
|
||||
"""从错误响应体取 (code, message);非 dict 响应整体作 message。
|
||||
|
||||
QQ 的失败响应 HTTP 可能仍是 200 且 Content-Type 不规范,
|
||||
ApiError/TokenError 统一用它暴露真实原因。
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return data.get("code"), data.get("message")
|
||||
return None, data
|
||||
+8
-13
@@ -6,19 +6,14 @@ class Session:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.session_id: str | None = None
|
||||
self._sequence_id: int | None = None
|
||||
|
||||
@property
|
||||
def sequence_id(self) -> int | None:
|
||||
return self._sequence_id
|
||||
|
||||
def update_sequence(self, value: int) -> None:
|
||||
"""只接受不回退的序列号,乱序/重放的旧值会被忽略。"""
|
||||
current = self._sequence_id or 0
|
||||
if value >= current:
|
||||
self._sequence_id = value
|
||||
self._seq: int | None = None
|
||||
|
||||
@property
|
||||
def seq(self) -> int:
|
||||
"""RESUME 时使用的序列号,无历史序列号时为 0。"""
|
||||
return self._sequence_id or 0
|
||||
"""已收到的最大 s 序列号(心跳/RESUME 用),无历史时为 0。"""
|
||||
return self._seq or 0
|
||||
|
||||
def update_sequence(self, value: int) -> None:
|
||||
"""只接受不回退的序列号,乱序/重放的旧值会被忽略。"""
|
||||
if value >= self.seq:
|
||||
self._seq = value
|
||||
+42
-21
@@ -1,12 +1,19 @@
|
||||
"""access_token 获取与缓存:到期前直接复用,过期自动重新换取。"""
|
||||
"""access_token 获取与缓存:到期前直接复用,过期自动重新换取。
|
||||
|
||||
import time
|
||||
并发安全:换取过程持 asyncio.Lock 双重检查,多个协程同时首调只发一次请求;
|
||||
到期判断基于 monotonic 时钟并预留提前刷新余量,不受系统改表时间影响,
|
||||
也避免卡着到期点刷新在边界上 401。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
import ujson
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from .config import Config
|
||||
from .model import error_parts
|
||||
|
||||
|
||||
class TokenError(RuntimeError):
|
||||
@@ -26,34 +33,48 @@ class TokenError(RuntimeError):
|
||||
|
||||
|
||||
class AccessToken:
|
||||
# 提前刷新余量(秒):expires_in 末段视为不可信
|
||||
_EXPIRY_MARGIN = 30.0
|
||||
|
||||
def __init__(self, config: Config, http: ClientSession) -> None:
|
||||
self._config = config
|
||||
self._http = http
|
||||
self._access_token = ""
|
||||
self._expires_at = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_access_token(self) -> str:
|
||||
if time.time() < self._expires_at:
|
||||
if not self._expired():
|
||||
return self._access_token
|
||||
async with self._lock:
|
||||
if not self._expired():
|
||||
return self._access_token
|
||||
|
||||
payload = {
|
||||
"appId": self._config.app_id,
|
||||
"clientSecret": self._config.app_secret,
|
||||
}
|
||||
payload = {
|
||||
"appId": self._config.app_id,
|
||||
"clientSecret": self._config.app_secret,
|
||||
}
|
||||
|
||||
async with self._http.post(
|
||||
f"{self._config.base_url}/app/getAppAccessToken",
|
||||
json=payload,
|
||||
) as resp:
|
||||
# 与 request() 同理:个别接口以非 JSON Content-Type 返回 JSON body
|
||||
data = await resp.json(loads=ujson.loads, content_type=None)
|
||||
# 失败时 QQ 返回 code/message 且 HTTP 可能仍是 200,
|
||||
# 以 access_token 是否在为准判断成败,抛出真实原因
|
||||
if not isinstance(data, dict) or "access_token" not in data:
|
||||
code = data.get("code") if isinstance(data, dict) else None
|
||||
message = data.get("message") if isinstance(data, dict) else data
|
||||
raise TokenError(resp.status, code, message)
|
||||
self._access_token = data["access_token"]
|
||||
self._expires_at = time.time() + int(data["expires_in"])
|
||||
async with self._http.post(
|
||||
f"{self._config.base_url}/app/getAppAccessToken",
|
||||
json=payload,
|
||||
) as resp:
|
||||
# 与 ApiCore.request 同理:个别接口以非 JSON Content-Type 返回 JSON body
|
||||
data = await resp.json(loads=ujson.loads, content_type=None)
|
||||
# 失败时 QQ 返回 code/message 且 HTTP 可能仍是 200,
|
||||
# 以 access_token 是否在为准判断成败,抛出真实原因
|
||||
if not isinstance(data, dict) or "access_token" not in data:
|
||||
raise TokenError(resp.status, *error_parts(data))
|
||||
self._access_token = data["access_token"]
|
||||
self._expires_at = monotonic() + max(
|
||||
int(data["expires_in"]) - self._EXPIRY_MARGIN, 1
|
||||
)
|
||||
|
||||
return self._access_token
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""作废缓存 token(如 OpenAPI 侧 401),下次获取强制重新换取。"""
|
||||
self._expires_at = 0.0
|
||||
|
||||
def _expired(self) -> bool:
|
||||
return monotonic() >= self._expires_at
|
||||
@@ -6,12 +6,10 @@ from .config import Config
|
||||
from .crypto import sign_msg
|
||||
from .emitter import BaseProtocol, EventEmitter
|
||||
from .model import Opcode, ValidationData
|
||||
from .queue import EventQueue
|
||||
from .session import Session
|
||||
|
||||
|
||||
class WebhookProtocol(BaseProtocol):
|
||||
def __init__(self, config: Config, queue: EventQueue, session: Session) -> None:
|
||||
def __init__(self, config: Config) -> None:
|
||||
self._config = config
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
"""WebSocket 接入的协议层:IDENTIFY/RESUME 鉴权、定时心跳、序列号跟踪。
|
||||
"""WebSocket 接入的协议层:IDENTIFY/RESUME 鉴权、序列号跟踪与会话保持。
|
||||
|
||||
识别 HELLO 后启动 apscheduler 心跳;断线重连时若已有 session_id
|
||||
则发 RESUME 续传,收到不可恢复的 INVALID_SESSION 才清空会话重新 IDENTIFY。
|
||||
心跳与连接生命周期归 WebsocketConnecter;本层只负责会话语义——
|
||||
识别 HELLO 后按有无 session_id 决定 RESUME 续传或 IDENTIFY 新建,
|
||||
收到不可恢复的 INVALID_SESSION(d=false)才清空会话重新 IDENTIFY。
|
||||
"""
|
||||
|
||||
from tomllib import load
|
||||
from typing import override
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from loguru import logger
|
||||
|
||||
from .config import Config
|
||||
from .emitter import BaseProtocol, EventEmitter
|
||||
from .model import (
|
||||
HelloData,
|
||||
IdentifyData,
|
||||
Intent,
|
||||
Opcode,
|
||||
@@ -22,7 +21,6 @@ from .model import (
|
||||
payload_of,
|
||||
)
|
||||
from .payloads import Ready
|
||||
from .queue import EventQueue
|
||||
from .session import Session
|
||||
from .token import AccessToken
|
||||
|
||||
@@ -49,24 +47,14 @@ def get_intents(path: str) -> int:
|
||||
|
||||
|
||||
class WebsocketProtocol(BaseProtocol):
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
queue: EventQueue,
|
||||
session: Session,
|
||||
token: AccessToken,
|
||||
) -> None:
|
||||
def __init__(self, config: Config, session: Session, token: AccessToken) -> None:
|
||||
self._config = config
|
||||
self._queue = queue
|
||||
self._session = session
|
||||
self._token = token
|
||||
self._scheduler = AsyncIOScheduler()
|
||||
self._heartbeat_job_id = "heartbeat"
|
||||
|
||||
@override
|
||||
def register(self, emitter: EventEmitter) -> None:
|
||||
emitter.on(Opcode.HELLO)(self.identify)
|
||||
emitter.on(Opcode.HEARTBEAT)(self.heartbeat)
|
||||
emitter.on("READY")(self.ready)
|
||||
emitter.on(Opcode.INVALID_SESSION)(self.invalid_session)
|
||||
|
||||
@@ -76,17 +64,7 @@ class WebsocketProtocol(BaseProtocol):
|
||||
if isinstance(seq, int):
|
||||
self._session.update_sequence(seq)
|
||||
|
||||
async def identify(self, data: HelloData) -> Payload:
|
||||
if not self._scheduler.running:
|
||||
self._scheduler.add_job(
|
||||
self.send_heartbeat,
|
||||
"interval",
|
||||
seconds=data["heartbeat_interval"] / 1000,
|
||||
id=self._heartbeat_job_id,
|
||||
)
|
||||
self._scheduler.start()
|
||||
logger.info(f"心跳任务已启动,间隔 {data['heartbeat_interval']}ms")
|
||||
|
||||
async def identify(self) -> Payload:
|
||||
access_token = await self._token.get_access_token()
|
||||
|
||||
d: ResumeData | IdentifyData
|
||||
@@ -109,12 +87,6 @@ class WebsocketProtocol(BaseProtocol):
|
||||
|
||||
return payload_of(op, d)
|
||||
|
||||
async def heartbeat(self, _) -> Payload:
|
||||
return payload_of(Opcode.HEARTBEAT, self._session.sequence_id)
|
||||
|
||||
async def send_heartbeat(self) -> None:
|
||||
await self._queue.put_reply(await self.heartbeat(None))
|
||||
|
||||
async def ready(self, ready: Ready) -> None:
|
||||
self._session.session_id = ready.session_id
|
||||
logger.info("会话已建立(READY)")
|
||||
|
||||
+144
-3
@@ -1,7 +1,8 @@
|
||||
import pytest
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from aiohttp.test_utils import TestServer
|
||||
|
||||
from qqbotsdk.api import BotApi, button, keyboard
|
||||
from qqbotsdk.api import ApiError, BotApi, button, keyboard
|
||||
from qqbotsdk.config import Config
|
||||
from qqbotsdk.token import AccessToken
|
||||
|
||||
@@ -155,8 +156,9 @@ async def test_post_message_markdown_dict_and_event_id():
|
||||
|
||||
async def test_media_takes_precedence_over_markdown():
|
||||
api, cap = make_api()
|
||||
await api.post_group_message("G1", content="看图", msg_id="m1",
|
||||
media={"file_info": "f"}, markdown="# t")
|
||||
await api.post_group_message(
|
||||
"G1", content="看图", msg_id="m1", media={"file_info": "f"}, markdown="# t"
|
||||
)
|
||||
body = cap["call"][2]["json"]
|
||||
assert body["msg_type"] == 7
|
||||
assert "markdown" not in body
|
||||
@@ -228,3 +230,142 @@ async def test_request_tolerates_non_json_content_type():
|
||||
await http.close()
|
||||
finally:
|
||||
await server.close()
|
||||
|
||||
|
||||
# ---------- 请求内核:429 限流重试 / 401 刷新 token 重试 ----------
|
||||
|
||||
|
||||
class ScriptedResponse:
|
||||
def __init__(self, status: int, payload, headers: dict | None = None) -> None:
|
||||
self.status = status
|
||||
self.headers = headers or {}
|
||||
self._payload = payload
|
||||
|
||||
async def json(self, **kwargs):
|
||||
return self._payload
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
class ScriptedSession:
|
||||
"""按序弹出响应,记录 request 调用。"""
|
||||
|
||||
def __init__(self, *responses: ScriptedResponse) -> None:
|
||||
self._responses = list(responses)
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
def request(self, method: str, url: str, **kwargs):
|
||||
self.calls.append((method, url))
|
||||
return self._responses.pop(0)
|
||||
|
||||
|
||||
class StubToken:
|
||||
def __init__(self) -> None:
|
||||
self.fetches = 0
|
||||
self.invalidated = 0
|
||||
|
||||
async def get_access_token(self) -> str:
|
||||
self.fetches += 1
|
||||
return "t1"
|
||||
|
||||
def invalidate(self) -> None:
|
||||
self.invalidated += 1
|
||||
|
||||
|
||||
def make_core_api(http: ScriptedSession, token: StubToken | None = None) -> BotApi:
|
||||
config = Config(
|
||||
app_id="id",
|
||||
app_secret="s",
|
||||
base_url="https://x",
|
||||
timeout=ClientTimeout(total=5),
|
||||
connecter="websocket",
|
||||
intents_file="intents.toml",
|
||||
webhook_host="0.0.0.0",
|
||||
webhook_port=8080,
|
||||
webhook_path="/qqbot/webhook",
|
||||
)
|
||||
return BotApi(config, http, token or StubToken()) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_request_retries_429_honoring_retry_after(monkeypatch):
|
||||
delays: list[float] = []
|
||||
|
||||
async def fake_sleep(delay):
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("qqbotsdk.api.core.asyncio.sleep", fake_sleep)
|
||||
http = ScriptedSession(
|
||||
ScriptedResponse(
|
||||
429, {"code": 11244, "message": "limit"}, {"Retry-After": "2"}
|
||||
),
|
||||
ScriptedResponse(200, {"ok": 1}),
|
||||
)
|
||||
api = make_core_api(http)
|
||||
assert await api.request("POST", "/v1/x") == {"ok": 1}
|
||||
assert len(http.calls) == 2
|
||||
assert delays == [2.0]
|
||||
|
||||
|
||||
async def test_request_429_dirty_header_falls_back_to_backoff(monkeypatch):
|
||||
delays: list[float] = []
|
||||
|
||||
async def fake_sleep(delay):
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("qqbotsdk.api.core.asyncio.sleep", fake_sleep)
|
||||
http = ScriptedSession(
|
||||
# 线上见过的脏值 "30,120":取首段 30
|
||||
ScriptedResponse(429, {}, {"Retry-After": "30,120"}),
|
||||
# 无 Retry-After:指数退避 1 * 2**1 = 2
|
||||
ScriptedResponse(429, {}),
|
||||
ScriptedResponse(200, {}),
|
||||
)
|
||||
api = make_core_api(http)
|
||||
assert await api.get("/v1/x") == {}
|
||||
assert delays == [30.0, 2.0]
|
||||
|
||||
|
||||
async def test_request_429_retry_after_capped(monkeypatch):
|
||||
delays: list[float] = []
|
||||
|
||||
async def fake_sleep(delay):
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("qqbotsdk.api.core.asyncio.sleep", fake_sleep)
|
||||
http = ScriptedSession(
|
||||
ScriptedResponse(429, {}, {"Retry-After": "3600"}),
|
||||
ScriptedResponse(200, {}),
|
||||
)
|
||||
api = make_core_api(http)
|
||||
await api.get("/v1/x")
|
||||
assert delays == [30.0] # 封顶,不真睡一小时
|
||||
|
||||
|
||||
async def test_request_429_exhausted_raises_api_error(monkeypatch):
|
||||
async def fake_sleep(delay):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("qqbotsdk.api.core.asyncio.sleep", fake_sleep)
|
||||
http = ScriptedSession(*(ScriptedResponse(429, {}) for _ in range(4)))
|
||||
api = make_core_api(http)
|
||||
with pytest.raises(ApiError) as exc_info:
|
||||
await api.get("/v1/x")
|
||||
assert exc_info.value.status == 429
|
||||
assert len(http.calls) == 4 # 首次 + 3 次重试
|
||||
|
||||
|
||||
async def test_request_401_invalidates_token_and_retries_once():
|
||||
http = ScriptedSession(
|
||||
ScriptedResponse(401, {"code": -1, "message": "unauthorized"}),
|
||||
ScriptedResponse(200, {"ok": 1}),
|
||||
)
|
||||
token = StubToken()
|
||||
api = make_core_api(http, token)
|
||||
assert await api.get("/v1/x") == {"ok": 1}
|
||||
assert token.invalidated == 1
|
||||
assert token.fetches == 2
|
||||
assert len(http.calls) == 2
|
||||
+5
-2
@@ -1,6 +1,5 @@
|
||||
from qqbotsdk.config import Config
|
||||
from qqbotsdk.connecter import WebhookConnecter
|
||||
from qqbotsdk.emitter import EventEmitter
|
||||
from qqbotsdk.queue import EventQueue
|
||||
|
||||
|
||||
@@ -16,7 +15,11 @@ def make_connecter() -> WebhookConnecter:
|
||||
webhook_port=8080,
|
||||
webhook_path="/qqbot/webhook",
|
||||
)
|
||||
return WebhookConnecter(config, EventEmitter(EventQueue()), EventQueue())
|
||||
|
||||
async def noop_handle(payload):
|
||||
return None
|
||||
|
||||
return WebhookConnecter(config, EventQueue(), noop_handle)
|
||||
|
||||
|
||||
def test_first_push_is_not_duplicate():
|
||||
|
||||
+96
-11
@@ -1,28 +1,27 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from qqbotsdk.config import Config
|
||||
from qqbotsdk.emitter import EventEmitter
|
||||
from qqbotsdk.model import Opcode, payload_of
|
||||
from qqbotsdk.payloads import GroupAtMessage
|
||||
from qqbotsdk.queue import EventQueue
|
||||
from qqbotsdk.session import Session
|
||||
from qqbotsdk.token import AccessToken
|
||||
from qqbotsdk.ws_protocol import WebsocketProtocol
|
||||
|
||||
|
||||
def test_sequence_only_moves_forward():
|
||||
s = Session()
|
||||
assert s.sequence_id is None
|
||||
assert s.seq == 0
|
||||
s.update_sequence(5)
|
||||
s.update_sequence(3) # 回退被忽略
|
||||
assert s.sequence_id == 5
|
||||
assert s.seq == 5
|
||||
s.update_sequence(7)
|
||||
assert s.sequence_id == 7
|
||||
assert s.seq == 7
|
||||
|
||||
|
||||
def test_seq_defaults_to_zero():
|
||||
s = Session()
|
||||
assert s.seq == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_exception_is_isolated():
|
||||
queue = EventQueue()
|
||||
@@ -96,7 +95,93 @@ async def test_unregistered_annotation_falls_back_to_raw_dict():
|
||||
async def handler(dep: Session): # Session 未登记进 services
|
||||
received.append(dep)
|
||||
|
||||
await emitter.emit(
|
||||
{"op": Opcode.DISPATCH, "t": "READY", "d": {"session_id": "s1"}}
|
||||
)
|
||||
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {"session_id": "s1"}})
|
||||
assert received == [{"session_id": "s1"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scalar_d_falls_back_to_raw_value():
|
||||
"""回归:op=9 INVALID_SESSION 的 d 是布尔值,DI 回退必须传原始 d。
|
||||
此前实现把 d dict 化成 {}(恒 falsy),resumable=true 也被当成不可续传。
|
||||
走真实协议层 handler 验证路由 → 注入 → 会话状态的全链路。"""
|
||||
config = Config(
|
||||
app_id="a",
|
||||
app_secret="s",
|
||||
base_url="https://x",
|
||||
timeout=None, # type: ignore[arg-type]
|
||||
connecter="websocket",
|
||||
intents_file="no-such.toml",
|
||||
webhook_host="0.0.0.0",
|
||||
webhook_port=8080,
|
||||
webhook_path="/x",
|
||||
)
|
||||
emitter = EventEmitter()
|
||||
session = Session()
|
||||
session.session_id = "S1"
|
||||
emitter.register_protocol(
|
||||
WebsocketProtocol(config, session, AccessToken(config, None)) # type: ignore[arg-type]
|
||||
)
|
||||
seen: list[bool] = []
|
||||
|
||||
@emitter.on(Opcode.INVALID_SESSION)
|
||||
async def spy(resumable: bool):
|
||||
seen.append(resumable)
|
||||
|
||||
await emitter.emit({"op": Opcode.INVALID_SESSION, "d": True})
|
||||
assert seen == [True]
|
||||
assert session.session_id == "S1" # 可续传,会话保留
|
||||
|
||||
await emitter.emit({"op": Opcode.INVALID_SESSION, "d": False})
|
||||
assert seen == [True, False]
|
||||
assert session.session_id is None # 不可续传,会话清空
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_handler_does_not_block_dispatch():
|
||||
"""background=True:慢 handler 进后台任务,不阻塞同一事件的后续 handler。"""
|
||||
emitter = EventEmitter()
|
||||
slow_done = asyncio.Event()
|
||||
quick_done = asyncio.Event()
|
||||
|
||||
@emitter.on("GROUP_AT_MESSAGE_CREATE", background=True)
|
||||
async def slow(msg: GroupAtMessage):
|
||||
await asyncio.sleep(0.05)
|
||||
slow_done.set()
|
||||
|
||||
@emitter.on("GROUP_AT_MESSAGE_CREATE")
|
||||
async def quick(msg: GroupAtMessage):
|
||||
quick_done.set()
|
||||
|
||||
await emitter.emit(
|
||||
{
|
||||
"op": Opcode.DISPATCH,
|
||||
"t": "GROUP_AT_MESSAGE_CREATE",
|
||||
"d": {"content": "hi"},
|
||||
}
|
||||
)
|
||||
# emit 返回时快 handler 已跑完,慢 handler 仍在后台
|
||||
assert quick_done.is_set()
|
||||
assert not slow_done.is_set()
|
||||
await asyncio.wait_for(slow_done.wait(), timeout=2)
|
||||
await asyncio.sleep(0) # 让已完成任务的 done_callback 清理 _bg_tasks
|
||||
await emitter.close()
|
||||
assert not emitter._bg_tasks # 收尾后在飞任务清空
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_cancels_inflight_background_tasks():
|
||||
emitter = EventEmitter()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
@emitter.on("READY", background=True)
|
||||
async def forever():
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
raise
|
||||
|
||||
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
|
||||
await asyncio.sleep(0) # 让后台任务实际启动(未启动的任务被 cancel 不会执行函数体)
|
||||
await emitter.close()
|
||||
assert cancelled.is_set()
|
||||
+1
-2
@@ -2,12 +2,11 @@ import pytest
|
||||
|
||||
from qqbotsdk.emitter import EventEmitter
|
||||
from qqbotsdk.model import Opcode
|
||||
from qqbotsdk.queue import EventQueue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def emitter() -> EventEmitter:
|
||||
return EventEmitter(EventQueue())
|
||||
return EventEmitter() # 无参构造,覆盖 queue 默认自建路径
|
||||
|
||||
|
||||
def test_route_dispatch_uses_t(emitter: EventEmitter):
|
||||
|
||||
+61
-25
@@ -1,4 +1,6 @@
|
||||
"""AccessToken:成功换取与失败时抛 TokenError(带 QQ 的 code/message)。"""
|
||||
"""AccessToken:缓存复用、失败抛 TokenError、并发只换取一次、invalidate 强制刷新。"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from aiohttp import ClientTimeout
|
||||
@@ -7,27 +9,8 @@ from qqbotsdk.config import Config
|
||||
from qqbotsdk.token import AccessToken, TokenError
|
||||
|
||||
|
||||
def make_token(payload) -> AccessToken:
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._payload = payload
|
||||
|
||||
async def json(self, **kwargs):
|
||||
return self._payload
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
class FakeSession:
|
||||
def post(self, *args, **kwargs):
|
||||
return FakeResponse()
|
||||
|
||||
config = Config(
|
||||
def make_config() -> Config:
|
||||
return Config(
|
||||
app_id="id",
|
||||
app_secret="s",
|
||||
base_url="https://x",
|
||||
@@ -38,14 +21,46 @@ def make_token(payload) -> AccessToken:
|
||||
webhook_port=8080,
|
||||
webhook_path="/qqbot/webhook",
|
||||
)
|
||||
return AccessToken(config, FakeSession()) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload, status: int = 200) -> None:
|
||||
self.status = status
|
||||
self._payload = payload
|
||||
|
||||
async def json(self, **kwargs):
|
||||
return self._payload
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""按序弹出响应体,记录 post 次数。"""
|
||||
|
||||
def __init__(self, *payloads) -> None:
|
||||
self._payloads = list(payloads)
|
||||
self.posts = 0
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
self.posts += 1
|
||||
return FakeResponse(self._payloads.pop(0))
|
||||
|
||||
|
||||
def make_token(*payloads) -> AccessToken:
|
||||
return AccessToken(make_config(), FakeSession(*payloads)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_success_caches_token():
|
||||
token = make_token({"access_token": "t1", "expires_in": 7200})
|
||||
session = FakeSession({"access_token": "t1", "expires_in": 7200})
|
||||
token = AccessToken(make_config(), session) # type: ignore[arg-type]
|
||||
assert await token.get_access_token() == "t1"
|
||||
# 缓存期内直接复用,不再发请求(FakeSession 也没有第二次响应的意义)
|
||||
# 缓存期内直接复用,不再发请求
|
||||
assert await token.get_access_token() == "t1"
|
||||
assert session.posts == 1
|
||||
|
||||
|
||||
async def test_error_payload_raises_token_error():
|
||||
@@ -62,3 +77,24 @@ async def test_non_dict_payload_raises_token_error():
|
||||
with pytest.raises(TokenError) as exc_info:
|
||||
await token.get_access_token()
|
||||
assert exc_info.value.message == "gateway busy"
|
||||
|
||||
|
||||
async def test_concurrent_first_fetch_only_requests_once():
|
||||
session = FakeSession({"access_token": "t1", "expires_in": 7200})
|
||||
token = AccessToken(make_config(), session) # type: ignore[arg-type]
|
||||
|
||||
results = await asyncio.gather(*(token.get_access_token() for _ in range(5)))
|
||||
assert results == ["t1"] * 5
|
||||
assert session.posts == 1 # 双重检查锁:并发首调只换取一次
|
||||
|
||||
|
||||
async def test_invalidate_forces_refetch():
|
||||
session = FakeSession(
|
||||
{"access_token": "t1", "expires_in": 7200},
|
||||
{"access_token": "t2", "expires_in": 7200},
|
||||
)
|
||||
token = AccessToken(make_config(), session) # type: ignore[arg-type]
|
||||
assert await token.get_access_token() == "t1"
|
||||
token.invalidate() # 模拟服务端提前作废
|
||||
assert await token.get_access_token() == "t2"
|
||||
assert session.posts == 2
|
||||
@@ -14,8 +14,6 @@ from qqbotsdk.connecter import WebhookConnecter
|
||||
from qqbotsdk.crypto import build_seed
|
||||
from qqbotsdk.emitter import EventEmitter
|
||||
from qqbotsdk.payloads import GroupAtMessage
|
||||
from qqbotsdk.queue import EventQueue
|
||||
from qqbotsdk.session import Session
|
||||
from qqbotsdk.webhook_protocol import WebhookProtocol
|
||||
|
||||
SECRET = "webhook-test-secret"
|
||||
@@ -52,10 +50,9 @@ def event_body(event_id: str, content: str) -> dict[str, Any]:
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_end_to_end():
|
||||
config = make_config(0)
|
||||
queue = EventQueue()
|
||||
emitter = EventEmitter(queue)
|
||||
emitter.register_protocol(WebhookProtocol(config, queue, Session()))
|
||||
connecter = WebhookConnecter(config, emitter, queue)
|
||||
emitter = EventEmitter()
|
||||
emitter.register_protocol(WebhookProtocol(config))
|
||||
connecter = WebhookConnecter(config, emitter.queue, emitter.handle)
|
||||
|
||||
received: list[GroupAtMessage] = []
|
||||
|
||||
|
||||
@@ -114,9 +114,9 @@ async def test_websocket_end_to_end():
|
||||
queue = EventQueue()
|
||||
session = Session()
|
||||
emitter = EventEmitter(queue)
|
||||
protocol = WebsocketProtocol(config, queue, session, token)
|
||||
protocol = WebsocketProtocol(config, session, token)
|
||||
emitter.register_protocol(protocol)
|
||||
connecter = WebsocketConnecter(config, http, token, queue)
|
||||
connecter = WebsocketConnecter(config, http, token, session, queue)
|
||||
|
||||
received: list[GroupAtMessage] = []
|
||||
|
||||
|
||||
@@ -104,18 +104,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apscheduler"
|
||||
version = "3.11.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzlocal" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/6b/eeff360196bb20b312c9e762a820fd1b2c6d809466c755ef57863478e454/apscheduler-3.11.3.tar.gz", hash = "sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a", size = 110312, upload-time = "2026-06-28T19:39:22.493Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/c9/8638db32514dbb9157b3d82680c6faea89283523edf9ed2415ea3884f2ae/apscheduler-3.11.3-py3-none-any.whl", hash = "sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30", size = 66024, upload-time = "2026-06-28T19:39:20.982Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
@@ -632,7 +620,6 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "apscheduler" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "loguru" },
|
||||
{ name = "pyee" },
|
||||
@@ -651,7 +638,6 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.14.3" },
|
||||
{ name = "apscheduler", specifier = ">=3.11.3" },
|
||||
{ name = "cryptography", specifier = ">=50.0.1" },
|
||||
{ name = "loguru", specifier = ">=0.7.3" },
|
||||
{ name = "pyee", specifier = ">=14.0.0" },
|
||||
@@ -701,27 +687,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzlocal"
|
||||
version = "5.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ujson"
|
||||
version = "6.0.0"
|
||||
|
||||
Reference in new issue
Block a user