feat!: api 拆包、心跳迁入传输层、限流重试与后台 handler

This commit is contained in:
snowy-yang committed 2026-09-20 21:26:55 +08:00
1 parent 750ed81d96
commit 3237558128
30 files changed
+1145 -714

No files matched your search

+10
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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` 会统一取消在飞的后台任务。
## 发消息
### 被动回复(最常用)
+2 -2
View File
@@ -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")
+2 -2
View File
@@ -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")
-1
View File
@@ -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
View File
@@ -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()
-477
View File
@@ -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}")
+30
View File
@@ -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",
]
+66
View File
@@ -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}"
)
+112
View File
@@ -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)
+147
View File
@@ -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}")
+218
View File
@@ -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
View File
@@ -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
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -3
View File
@@ -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
+6 -34
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -6
View File
@@ -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] = []
+2 -2
View File
@@ -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] = []
Generated
-35
View File
@@ -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"