refactor!: 移除 dishka,改为内置裸标注参数注入

- handler 形参直接标注组件类型(api: BotApi)即注入,解析为 emitter 内同步查表(payload dataclass / Event / services 登记类型 / 回退原始 dict)
This commit is contained in:
snowy-yang committed 2026-09-16 19:18:06 +08:00
1 parent 953d44f62e
commit cff0c142c2
16 files changed
+161 -240

No files matched your search

+4
View File
@@ -9,6 +9,10 @@
- Markdown 与内嵌按钮一等支持:`post_group_message`/`post_c2c_message` 新增 `markdown`/`keyboard` 参数(自动置 msg_type=2,与 content 互斥);新增 `respond_interaction()` 回应按钮回调;`Interaction` payload 补齐 `chat_type`/`scene`/`group_openid` 等官方字段 - Markdown 与内嵌按钮一等支持:`post_group_message`/`post_c2c_message` 新增 `markdown`/`keyboard` 参数(自动置 msg_type=2,与 content 互斥);新增 `respond_interaction()` 回应按钮回调;`Interaction` payload 补齐 `chat_type`/`scene`/`group_openid` 等官方字段
### 变更
- **移除 dishka 依赖,改为内置参数注入**:handler 形参直接标注组件类型(`api: BotApi`)即注入,不再需要 `Inject[T]` 包装;组件改在 `run_loop` 显式装配并按类型登记到 `emitter.services`(自定义组件同样登记即可注入)。破坏性变更:`Inject`、`make_container`、`use_container` 随之移除,原 `api: Inject[BotApi]` 写法改为 `api: BotApi`
### 移除 ### 移除
- 音乐插件示例(`plugins/`、根目录 `main.py`)已拆分为独立的点歌 bot 项目(含 Markdown 卡片、按钮回调与内置短网址多线程下载代理),不再随 SDK 仓库分发 - 音乐插件示例(`plugins/`、根目录 `main.py`)已拆分为独立的点歌 bot 项目(含 Markdown 卡片、按钮回调与内置短网址多线程下载代理),不再随 SDK 仓库分发
+5 -6
View File
@@ -14,7 +14,7 @@ flowchart TB
end end
subgraph 分发层 subgraph 分发层
Q[("EventQueue")] Q[("EventQueue")]
EE["EventEmitter(路由 / DI 注入 / 异常隔离)"] EE["EventEmitter(路由 / 参数注入 / 异常隔离)"]
end end
subgraph 协议层 subgraph 协议层
WSP["WebsocketProtocol(鉴权 / 心跳 / 序列号)"] WSP["WebsocketProtocol(鉴权 / 心跳 / 序列号)"]
@@ -36,7 +36,7 @@ flowchart TB
WS --> QQ WS --> QQ
``` ```
组件生命周期由 dishka 容器统一装配(APP scope,无全局单例),依赖关系、数据流与设计决策详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。 组件在 `run_loop` 显式装配,并按类型登记到 `emitter.services` 供 handler 参数注入(无全局单例);依赖关系、数据流与设计决策详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
## 特性 ## 特性
@@ -45,7 +45,7 @@ flowchart TB
- Webhook 接入自带 Ed25519 验签、op=13 验证应答与 60s TTL 事件去重 - Webhook 接入自带 Ed25519 验签、op=13 验证应答与 60s TTL 事件去重
- 事件订阅由项目根目录的 `intents.toml` 控制,将对应事件改为 `true` 即可 - 事件订阅由项目根目录的 `intents.toml` 控制,将对应事件改为 `true` 即可
- Markdown 与内嵌按钮一等支持:`post_group_message(..., markdown=..., keyboard=...)` 自动置 msg_type=2,`respond_interaction()` 回应按钮回调 - Markdown 与内嵌按钮一等支持:`post_group_message(..., markdown=..., keyboard=...)` 自动置 msg_type=2,`respond_interaction()` 回应按钮回调
- 组件生命周期由 dishka DI 容器统一管理,全 SDK 共享一个 HTTP 连接池 - 组件在 `run_loop` 显式装配(无 DI 框架依赖),全 SDK 共享一个 HTTP 连接池
## 配置 ## 配置
@@ -85,13 +85,12 @@ from qqbotsdk.payloads import GroupAtMessage
async def on_group_message(msg: GroupAtMessage): async def on_group_message(msg: GroupAtMessage):
print(msg.group_openid, msg.user_openid, msg.content) print(msg.group_openid, msg.user_openid, msg.content)
# 方式三:依赖注入(dishka) # 方式三:参数注入(按形参标注解析,`BotApi` 等组件由 SDK 装配)
from qqbotsdk import Inject
from qqbotsdk.api import BotApi from qqbotsdk.api import BotApi
from qqbotsdk.events import Event from qqbotsdk.events import Event
@ee.on("GROUP_AT_MESSAGE_CREATE") @ee.on("GROUP_AT_MESSAGE_CREATE")
async def handle(event: Event, api: Inject[BotApi]): async def handle(event: Event, api: BotApi):
print(event.user_id, event.group_id, event.content) print(event.user_id, event.group_id, event.content)
# 被动回复:带 msg_id(群聊 5 分钟/单聊 60 分钟内有效,最多分别回复 5/4 次); # 被动回复:带 msg_id(群聊 5 分钟/单聊 60 分钟内有效,最多分别回复 5/4 次);
# 同一条消息多次回复递增 msg_seq # 同一条消息多次回复递增 msg_seq
+7 -9
View File
@@ -6,21 +6,19 @@
```python ```python
from qqbotsdk import ( from qqbotsdk import (
main, run_loop, # 入口 main, run_loop, # 入口(组件在 run_loop 显式装配)
EventEmitter, BaseProtocol, EventQueue, # 分发层 EventEmitter, BaseProtocol, EventQueue, # 分发层
Inject, make_container, # DI
Config, Connecter, # 配置与连接器接口 Config, Connecter, # 配置与连接器接口
) )
``` ```
| 名称 | 说明 | | 名称 | 说明 |
|---|---| |---|---|
| `main(emitter=None)` | 同步入口:装配容器并阻塞运行,Ctrl+C 优雅退出 | | `main(emitter=None)` | 同步入口:装配组件并阻塞运行,Ctrl+C 优雅退出 |
| `run_loop(emitter=None)` | 异步入口:`asyncio.run(run_loop(ee))` 的内部实现,可自行 await | | `run_loop(emitter=None)` | 异步入口:`asyncio.run(run_loop(ee))` 的内部实现,可自行 await |
| `EventEmitter(queue)` | 分发主体,`@ee.on(...)` 注册 handler | | `EventEmitter(queue)` | 分发主体,`@ee.on(...)` 注册 handler |
| `EventQueue()` | 事件/应答队列;自建 emitter 时传入,容器会复用同一实例 | | `ee.services` | `dict[type, Any]`,按类型登记可注入组件;`run_loop` 装配 SDK 内置组件,也可登记自定义类型 |
| `Inject` | dishka `FromDishka` 别名,handler 参数标注 `Inject[T]` 即注入 T | | `EventQueue()` | 事件/应答队列;自建 emitter 时传入,`run_loop` 复用同一实例 |
| `make_container(emitter=None)` | 构建 dishka 容器;一般不用直接调,`main` 已封装 |
| `Config` | frozen dataclass,环境变量一次性读齐(见下文) | | `Config` | frozen dataclass,环境变量一次性读齐(见下文) |
| `Connecter` | 连接适配器 Protocol(接口),按 `CONNECTER` 自动选择实现 | | `Connecter` | 连接适配器 Protocol(接口),按 `CONNECTER` 自动选择实现 |
| `BaseProtocol` | 协议层抽象基类,自定义协议处理器时继承并实现 `register(emitter)` | | `BaseProtocol` | 协议层抽象基类,自定义协议处理器时继承并实现 `register(emitter)` |
@@ -55,7 +53,7 @@ async def handler(...): ...
|---|---|---| |---|---|---|
| payload dataclass(`payloads` 中的类型) | `Event.typed` 解析对象 | 回退传原始 dict | | payload dataclass(`payloads` 中的类型) | `Event.typed` 解析对象 | 回退传原始 dict |
| `Event` | 事件封装对象 | — | | `Event` | 事件封装对象 | — |
| `Inject[T]` | DI 容器解析的 T(如 `BotApi`) | 回退传原始 dict | | 组件类型(`BotApi`/`Session`/`Config` 等已登记进 `ee.services` 的类型) | 登记的实例 | 回退传原始 dict |
| 无标注 | 原始事件 dict | — | | 无标注 | 原始事件 dict | — |
## Event ## Event
@@ -91,7 +89,7 @@ async def handler(...): ...
## BotApi ## BotApi
`from qqbotsdk.api import BotApi`。经 DI 注入(`api: Inject[BotApi]`)。自动附带 `Authorization: QQBot <token>` 鉴权头并刷新 token;HTTP ≥400 抛 `ApiError`。 `from qqbotsdk.api import BotApi`。经参数注入(`api: BotApi`)。自动附带 `Authorization: QQBot <token>` 鉴权头并刷新 token;HTTP ≥400 抛 `ApiError`。
### 通用 ### 通用
@@ -224,4 +222,4 @@ DirectMessage # 同 AtMessage
| `webhook_protocol.py` | op=13 验证应答 | | `webhook_protocol.py` | op=13 验证应答 |
| `queue.py` / `session.py` / `token.py` / `crypto.py` | 队列、ws 会话状态、token 缓存、Ed25519 签名 | | `queue.py` / `session.py` / `token.py` / `crypto.py` | 队列、ws 会话状态、token 缓存、Ed25519 签名 |
| `model.py` | Opcode、payload 类型、Intent 位掩码 | | `model.py` | Opcode、payload 类型、Intent 位掩码 |
| `di.py` | dishka provider 与 `ADAPTERS` 接线表 | | `__init__.py` | `main`/`run_loop` 入口与组件显式装配 |
+13 -12
View File
@@ -13,7 +13,7 @@ flowchart TB
subgraph 分发层["分发层 emitter.py"] subgraph 分发层["分发层 emitter.py"]
Q[("EventQueue\nmsg_queue / reply_queue")] Q[("EventQueue\nmsg_queue / reply_queue")]
EE["EventEmitter\nop/t 路由 + DI 注入\nhandler 异常隔离"] EE["EventEmitter\nop/t 路由 + 参数注入\nhandler 异常隔离"]
end end
subgraph 协议层["协议层 BaseProtocol"] subgraph 协议层["协议层 BaseProtocol"]
@@ -41,7 +41,7 @@ flowchart TB
EE -- "on_payload" --> WSP EE -- "on_payload" --> WSP
WSP -- "IDENTIFY/HEARTBEAT 等\npayload 返回值" --> Q WSP -- "IDENTIFY/HEARTBEAT 等\npayload 返回值" --> Q
EE -- "Event 注入" --> USER EE -- "Event 注入" --> USER
USER -- "Inject[BotApi]" --> API USER -- "api: BotApi" --> API
API --> QQ API --> QQ
WS --> QQ WS --> QQ
TK --> HTTP TK --> HTTP
@@ -49,7 +49,7 @@ flowchart TB
WSP --> TK WSP --> TK
WHP --> CRY WHP --> CRY
WH --> CRY WH --> CRY
CFG -. "构造注入" .-> WS & WH & API & TK & WSP & WHP CFG -. "构造传参" .-> WS & WH & API & TK & WSP & WHP
``` ```
## 数据流(WebSocket 接入为例) ## 数据流(WebSocket 接入为例)
@@ -80,7 +80,7 @@ sequenceDiagram
QQ->>WS: DISPATCH 事件 (op=0, t=GROUP_AT_MESSAGE_CREATE) QQ->>WS: DISPATCH 事件 (op=0, t=GROUP_AT_MESSAGE_CREATE)
WS->>Q: put_event WS->>Q: put_event
Q->>EE: dispatch 泵 Q->>EE: dispatch 泵
EE->>H: 开 REQUEST scope,注入 Event/Inject[BotApi] EE->>H: 按形参标注注入 Event/BotApi
H->>EE: 返回值不回流(op=0) H->>EE: 返回值不回流(op=0)
``` ```
@@ -91,7 +91,7 @@ sequenceDiagram
| 层 | 模块 | 职责 | 不允许出现 | | 层 | 模块 | 职责 | 不允许出现 |
|---|---|---|---| |---|---|---|---|
| 接入层 | `connecter.py` | 传输细节:握手、重连、验签、去重 | 事件业务语义、handler 调用 | | 接入层 | `connecter.py` | 传输细节:握手、重连、验签、去重 | 事件业务语义、handler 调用 |
| 分发层 | `emitter.py` | 路由、DI 注入、异常隔离、应答回流 | 传输细节、具体协议状态机 | | 分发层 | `emitter.py` | 路由、参数注入、异常隔离、应答回流 | 传输细节、具体协议状态机 |
| 协议层 | `ws_protocol.py` `webhook_protocol.py` | 鉴权/心跳/验证等协议状态机 | 传输细节(只经队列收发) | | 协议层 | `ws_protocol.py` `webhook_protocol.py` | 鉴权/心跳/验证等协议状态机 | 传输细节(只经队列收发) |
| 业务层 | `api.py` `payloads.py` + 用户代码 | OpenAPI 调用、payload 解析与机器人逻辑 | 协议细节 | | 业务层 | `api.py` `payloads.py` + 用户代码 | OpenAPI 调用、payload 解析与机器人逻辑 | 协议细节 |
@@ -99,22 +99,23 @@ sequenceDiagram
## 生命周期与依赖装配 ## 生命周期与依赖装配
- 全部组件由 dishka 以 **APP scope** 构造注入(`di.py`),无任何全局单例。 - 全部组件在 `run_loop`(`__init__.py`)显式构造装配,无 DI 框架,也无任何全局单例。
- 唯一的 `ClientSession` 以 async-generator provider 注册,`container.close()` 时统一释放。 - 组件按类型登记进 `emitter.services`,handler 形参标注类型即得实例;用户自定义组件同样登记即可注入。
- 接入方式的选择收敛在 `ADAPTERS` 表:`make_container()` 按 `CONNECTER` 环境变量动态 `provider.provide(实现类, provides=抽象)`,主体代码只依赖 `BaseProtocol`/`Connecter` 抽象。 - 唯一的 `ClientSession` 在 `run_loop` 构造,`finally` 中统一释放(全 SDK 共享一个连接池)。
- 用户在 `main(ee)` 之前构造的 `EventEmitter` 会被容器复用(连同它的队列),保证 handler 注册在主体实际使用的实例上。 - 接入方式的选择收敛在 `run_loop` 的 `match config.connecter` 装配分支,主体代码只依赖 `BaseProtocol`/`Connecter` 抽象。
- 用户在 `main(ee)` 之前构造的 `EventEmitter` 会被复用(连同它的队列),保证 handler 注册在主体实际使用的实例上。
## 关键设计决策 ## 关键设计决策
1. **管线而非回调直调**:事件一律经 `EventQueue` 异步流转,接入与分发两侧解耦——Webhook handler 里不需要知道 ws 的存在,反之亦然。代价是多一跳队列延迟(可忽略)。 1. **管线而非回调直调**:事件一律经 `EventQueue` 异步流转,接入与分发两侧解耦——Webhook handler 里不需要知道 ws 的存在,反之亦然。代价是多一跳队列延迟(可忽略)。
2. **协议层状态机独立**(`BaseProtocol.register/on_payload`):emitter 不认识"心跳""序列号"这些 ws 概念;协议类通过返回 payload dict 经 reply 队列外发。 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 兜底,不丢消息)。 3. **看门狗两层互补**(见 `connecter.py` 模块注释):静默超时抓连接彻底失联,ACK 期限检查抓"有事件流但心跳已死"。阈值 `_SILENCE_FACTOR=1.1`、`_ACK_FACTOR=1.2` 是按 ACK 自然节奏校准的,**不能压到 1× 整**,否则正常抖动会周期性误杀(误杀由 RESUME 兜底,不丢消息)。
4. **handler 异常就地隔离**:`_invoke`/`_call_direct` 捕获记日志,`dispatch` 主循环另有兜底;单条事件的单个 handler 失败不影响进程。 4. **handler 异常就地隔离**:`_invoke` 捕获记日志,`dispatch` 主循环另有兜底;单条事件的单个 handler 失败不影响进程。
5. **注入约定**:handler 参数标注 `Inject[T]`(或裸标 `Event`/`Session`/`BotApi`)→ 从 dishka scope 解析;标注 `payloads.py` 里的 dataclass 类型 → 拿到解析后的 payload 对象(无容器路径同样生效);未标注或解析失败 → 回退传原始 `d`,保持旧处理器兼容。注意 `Inject` 不能改成 PEP 695 懒别名(`get_type_hints` 不解包)。 5. **注入约定**:handler 参数按形参标注解析——`payloads.py` 里的 dataclass → 解析后的 payload 对象;`Event` → 事件封装;登记进 `emitter.services` 的组件类型(如 `BotApi`)→ 实例;未标注或未登记 → 回退传原始 `d`,保持旧处理器兼容。解析是纯同步查表(`get_type_hints` + dict),无框架、无运行时魔法。
## 扩展点 ## 扩展点
- **新增接入方式**(如轮询/其他平台):实现 `Connecter.run()` 与 `BaseProtocol.register()`,在 `di.ADAPTERS` 注册一行,`Config.load` 的白名单加一个名字。 - **新增接入方式**(如轮询/其他平台):实现 `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.py` 的 `BotApi` 上加方法(或独立模块组合 `BotApi`),全部自动获得鉴权、超时与错误处理。群/单聊消息经 `_post_message` 统一处理 msg_id/msg_seq/media 语义;已有方法覆盖:单聊/群聊消息与富媒体上传、频道消息/撤回/表态、公告、精华消息、日程、禁言、频道信息查询(guild/channels/roles/member)。
- **新增事件类型 dataclass**:在 `payloads.py` 定义 dataclass 并登记 `EVENT_TYPES`,handler 即可按类型标注拿解析结果;未收录事件回退传 dict。当前已覆盖 intents.toml 的全部事件组(消息、成员变动、审核、表态、互动、论坛、音频)。 - **新增事件类型 dataclass**:在 `payloads.py` 定义 dataclass 并登记 `EVENT_TYPES`,handler 即可按类型标注拿解析结果;未收录事件回退传 dict。当前已覆盖 intents.toml 的全部事件组(消息、成员变动、审核、表态、互动、论坛、音频)。
+5 -4
View File
@@ -76,7 +76,7 @@ SDK 启动时读取 `intents.toml` 计算订阅掩码,把想收的事件改成
用装饰器把 handler 注册到 `EventEmitter`,事件名即线上 payload 的 `t` 名(如 `GROUP_AT_MESSAGE_CREATE`)。三种写法可混用: 用装饰器把 handler 注册到 `EventEmitter`,事件名即线上 payload 的 `t` 名(如 `GROUP_AT_MESSAGE_CREATE`)。三种写法可混用:
```python ```python
from qqbotsdk import EventEmitter, EventQueue, Inject from qqbotsdk import EventEmitter, EventQueue
ee = EventEmitter(EventQueue()) ee = EventEmitter(EventQueue())
@@ -92,12 +92,12 @@ from qqbotsdk.payloads import GroupAtMessage
async def typed(msg: GroupAtMessage): async def typed(msg: GroupAtMessage):
print(msg.group_openid, msg.user_openid, msg.content) print(msg.group_openid, msg.user_openid, msg.content)
# 方式三:依赖注入 # 方式三:参数注入,标注组件类型即拿到实例
from qqbotsdk.api import BotApi from qqbotsdk.api import BotApi
from qqbotsdk.events import Event from qqbotsdk.events import Event
@ee.on("GROUP_AT_MESSAGE_CREATE") @ee.on("GROUP_AT_MESSAGE_CREATE")
async def di(event: Event, api: Inject[BotApi]): async def di(event: Event, api: BotApi):
await api.post_group_message(event.group_id, content="收到", msg_id=event.message_id) await api.post_group_message(event.group_id, content="收到", msg_id=event.message_id)
``` ```
@@ -107,7 +107,7 @@ handler 参数按标注解析的完整规则:
|---|---| |---|---|
| payload dataclass(如 `GroupAtMessage`) | 解析后的 dataclass 对象;未收录事件回退原始 dict | | payload dataclass(如 `GroupAtMessage`) | 解析后的 dataclass 对象;未收录事件回退原始 dict |
| `Event` | 事件封装对象(`.raw`/`.data`/`.typed`/`.user_id`/`.group_id`/`.content`/`.message_id`) | | `Event` | 事件封装对象(`.raw`/`.data`/`.typed`/`.user_id`/`.group_id`/`.content`/`.message_id`) |
| `Inject[T]` | DI 容器解析的 `T`(如 `BotApi`);解析失败回退传原始 dict | | 组件类型(`BotApi`/`Session`/`Config` 等) | `emitter.services` 中按类型登记的实例;未登记回退传原始 dict |
| 无标注 | 原始事件 dict | | 无标注 | 原始事件 dict |
注意: 注意:
@@ -115,6 +115,7 @@ handler 参数按标注解析的完整规则:
- 业务事件(op=0)处理器的**返回值不会回流**,回复消息请在 handler 里显式调 API。 - 业务事件(op=0)处理器的**返回值不会回流**,回复消息请在 handler 里显式调 API。
- 单个 handler 抛异常只记录日志,不影响同事件其他 handler 和主循环。 - 单个 handler 抛异常只记录日志,不影响同事件其他 handler 和主循环。
- 未订阅(intents.toml 为 `false`)的事件不会到达 handler。 - 未订阅(intents.toml 为 `false`)的事件不会到达 handler。
- 自定义组件也能注入:`ee.services[MyService] = MyService(...)` 登记后,handler 形参标注 `MyService` 即可拿到实例。
## 发消息 ## 发消息
+4 -4
View File
@@ -10,7 +10,7 @@
from loguru import logger from loguru import logger
from qqbotsdk import EventEmitter, EventQueue, Inject, main from qqbotsdk import EventEmitter, EventQueue, main
from qqbotsdk.api import BotApi from qqbotsdk.api import BotApi
from qqbotsdk.events import Event from qqbotsdk.events import Event
from qqbotsdk.payloads import C2CMessage, GroupAtMessage from qqbotsdk.payloads import C2CMessage, GroupAtMessage
@@ -19,7 +19,7 @@ ee = EventEmitter(EventQueue())
@ee.on("GROUP_AT_MESSAGE_CREATE") @ee.on("GROUP_AT_MESSAGE_CREATE")
async def on_group_message(msg: GroupAtMessage, api: Inject[BotApi]) -> None: async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
if not (msg.group_openid and msg.id): if not (msg.group_openid and msg.id):
return return
@@ -41,7 +41,7 @@ async def on_group_message(msg: GroupAtMessage, api: Inject[BotApi]) -> None:
@ee.on("GROUP_ADD_ROBOT") @ee.on("GROUP_ADD_ROBOT")
async def on_robot_added(event: Event, api: Inject[BotApi]) -> None: async def on_robot_added(event: Event, api: BotApi) -> None:
group_openid = event.group_id group_openid = event.group_id
# 入群是事件而非消息:事件 ID 在推送 envelope 顶层 id 字段, # 入群是事件而非消息:事件 ID 在推送 envelope 顶层 id 字段,
# 作 event_id 即可在 5 分钟内被动回复欢迎语(拿不到则跳过) # 作 event_id 即可在 5 分钟内被动回复欢迎语(拿不到则跳过)
@@ -55,7 +55,7 @@ async def on_robot_added(event: Event, api: Inject[BotApi]) -> None:
@ee.on("C2C_MESSAGE_CREATE") @ee.on("C2C_MESSAGE_CREATE")
async def on_c2c_message(msg: C2CMessage, api: Inject[BotApi]) -> None: async def on_c2c_message(msg: C2CMessage, api: BotApi) -> None:
user_openid = msg.user_openid user_openid = msg.user_openid
if not (user_openid and msg.id): if not (user_openid and msg.id):
return return
+2 -2
View File
@@ -11,7 +11,7 @@
运行:uv run python examples/webhook_bot/main.py 运行:uv run python examples/webhook_bot/main.py
""" """
from qqbotsdk import EventEmitter, EventQueue, Inject, main from qqbotsdk import EventEmitter, EventQueue, main
from qqbotsdk.api import BotApi from qqbotsdk.api import BotApi
from qqbotsdk.payloads import GroupAtMessage from qqbotsdk.payloads import GroupAtMessage
@@ -19,7 +19,7 @@ ee = EventEmitter(EventQueue())
@ee.on("GROUP_AT_MESSAGE_CREATE") @ee.on("GROUP_AT_MESSAGE_CREATE")
async def on_group_message(msg: GroupAtMessage, api: Inject[BotApi]) -> None: async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
if not (msg.group_openid and msg.id): if not (msg.group_openid and msg.id):
return return
await api.post_group_message( await api.post_group_message(
-1
View File
@@ -13,7 +13,6 @@ dependencies = [
"aiohttp>=3.14.3", "aiohttp>=3.14.3",
"apscheduler>=3.11.3", "apscheduler>=3.11.3",
"cryptography>=50.0.1", "cryptography>=50.0.1",
"dishka>=1.10.1",
"loguru>=0.7.3", "loguru>=0.7.3",
"pyee>=14.0.0", "pyee>=14.0.0",
"python-dotenv>=1.2.3", "python-dotenv>=1.2.3",
+45 -14
View File
@@ -7,35 +7,66 @@
import asyncio import asyncio
from aiohttp import ClientSession
from dotenv import load_dotenv from dotenv import load_dotenv
from loguru import logger from loguru import logger
from .api import BotApi
from .config import Config from .config import Config
from .connecter import Connecter from .connecter import Connecter as Connecter
from .di import Inject as Inject from .connecter import WebhookConnecter, WebsocketConnecter
from .di import make_container
from .emitter import BaseProtocol as BaseProtocol from .emitter import BaseProtocol as BaseProtocol
from .emitter import EventEmitter as EventEmitter from .emitter import EventEmitter as EventEmitter
from .queue import EventQueue as EventQueue from .queue import EventQueue as EventQueue
from .session import Session
from .token import AccessToken
from .webhook_protocol import WebhookProtocol
from .ws_protocol import WebsocketProtocol
load_dotenv() load_dotenv()
async def run_loop(emitter: EventEmitter | None = None) -> None: async def run_loop(emitter: EventEmitter | None = None) -> None:
"""启动 SDK。可传入外部构造的 EventEmitter 以注册 handler; """启动 SDK。可传入外部构造的 EventEmitter 以注册 handler;不传时自建。
不传时由容器自建。"""
container = make_container(emitter)
try:
emitter = await container.get(EventEmitter)
emitter.use_container(container)
emitter.register_protocol(await container.get(BaseProtocol))
connecter = await container.get(Connecter)
logger.info(f"qqbotsdk 启动,连接方式: {Config.load().connecter}") 全部组件在此显式装配,并按类型登记到 emitter.services 供 handler
参数注入;共享的 HTTP 连接池随 run_loop 结束释放。
"""
if emitter is None:
emitter = EventEmitter(EventQueue())
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,
})
match config.connecter:
case "websocket":
protocol = WebsocketProtocol(config, emitter.queue, session, token)
connecter = WebsocketConnecter(config, http, token, emitter.queue)
case "webhook":
protocol = WebhookProtocol(config, emitter.queue, session)
connecter = WebhookConnecter(config, emitter, emitter.queue)
case unknown:
raise ValueError(f"未知的 CONNECTER: {unknown}")
emitter.register_protocol(protocol)
logger.info(f"qqbotsdk 启动,连接方式: {config.connecter}")
try:
await asyncio.gather(emitter.dispatch(), connecter.run()) await asyncio.gather(emitter.dispatch(), connecter.run())
finally: finally:
# 容器关闭时统一释放共享的 HTTP 会话等 APP 级资源 # 全 SDK 共享一个连接池,统一在此释放
await container.close() await http.close()
def main(emitter: EventEmitter | None = None) -> None: def main(emitter: EventEmitter | None = None) -> None:
+2 -2
View File
@@ -1,4 +1,4 @@
"""全局配置:启动时从环境变量一次性读齐(校验缺失/非法值),经 DI 注入各组件。""" """全局配置:启动时从环境变量一次性读齐(校验缺失/非法值),由 run_loop 装配到各组件。"""
import os import os
from dataclasses import dataclass from dataclasses import dataclass
@@ -15,7 +15,7 @@ def _required_env(name: str) -> str:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class Config: class Config:
"""SDK 全局配置:启动时从环境变量一次性读齐,经 DI 注入各组件。""" """SDK 全局配置:启动时从环境变量一次性读齐,由 run_loop 装配到各组件。"""
app_id: str app_id: str
app_secret: str app_secret: str
-83
View File
@@ -1,83 +0,0 @@
"""依赖装配:dishka 容器的 provider 定义与 ADAPTERS 接线表。
所有组件均为 APP scope 单例;按 CONNECTER 环境变量动态 provide
具体的连接适配器与协议处理器。业务代码注入用 `Inject[T]`(FromDishka
别名),裸标注 `Event`/`Session`/`BotApi` 也可直接解析。
"""
from collections.abc import AsyncIterator
from aiohttp import ClientSession
from dishka import (
AsyncContainer,
FromDishka,
Provider,
Scope,
from_context,
make_async_container,
provide,
)
from .api import BotApi
from .config import Config
from .connecter import Connecter, WebhookConnecter, WebsocketConnecter
from .emitter import BaseProtocol, EventEmitter
from .events import Event
from .queue import EventQueue
from .session import Session
from .token import AccessToken
from .webhook_protocol import WebhookProtocol
from .ws_protocol import WebsocketProtocol
Inject = FromDishka
# 接入方式 → (连接适配器, 协议处理器),均由 dishka 按构造签名自动装配
ADAPTERS: dict[str, tuple[type[Connecter], type[BaseProtocol]]] = {
"websocket": (WebsocketConnecter, WebsocketProtocol),
"webhook": (WebhookConnecter, WebhookProtocol),
}
class AppProvider(Provider):
event = from_context(Event, scope=Scope.REQUEST)
@provide(scope=Scope.APP)
def get_config(self) -> Config:
return Config.load()
@provide(scope=Scope.APP)
def get_session(self) -> Session:
return Session()
@provide(scope=Scope.APP)
def get_token(self, config: Config, http: ClientSession) -> AccessToken:
return AccessToken(config, http)
@provide(scope=Scope.APP)
def get_api(
self, config: Config, http: ClientSession, token: AccessToken
) -> BotApi:
return BotApi(config, http, token)
@provide(scope=Scope.APP)
async def get_http(self, config: Config) -> AsyncIterator[ClientSession]:
# 全 SDK 共享一个连接池,随容器关闭
session = ClientSession(timeout=config.timeout)
yield session
await session.close()
def make_container(emitter: EventEmitter | None = None) -> AsyncContainer:
"""构建 DI 容器。传入外部构造的 EventEmitter 时,容器复用该实例及其队列,
保证用户在 main() 之前注册的 handler 与主体是同一个 emitter。"""
if emitter is None:
emitter = EventEmitter(EventQueue())
provider = AppProvider()
provider.provide(lambda: emitter, provides=EventEmitter, scope=Scope.APP)
provider.provide(lambda: emitter.queue, provides=EventQueue, scope=Scope.APP)
connecter_cls, protocol_cls = ADAPTERS[Config.load().connecter]
provider.provide(protocol_cls, provides=BaseProtocol, scope=Scope.APP)
provider.provide(connecter_cls, provides=Connecter, scope=Scope.APP)
return make_async_container(provider)
+26 -90
View File
@@ -2,8 +2,8 @@
- `dispatch()` 泵循环消费 EventQueue.msg_queue,`emit()` 按 - `dispatch()` 泵循环消费 EventQueue.msg_queue,`emit()` 按
op(DISPATCH 用 t 名、其余用 Opcode 名)路由到 `@on` 注册的 handler; op(DISPATCH 用 t 名、其余用 Opcode 名)路由到 `@on` 注册的 handler;
- DI 容器在位时按 handler 参数标注注入(`Inject[T]`/`Event` 等, - handler 参数按形参标注注入:payload dataclass → 解析对象,`Event` →
解析失败回退传原始 d),否则直传 d; 事件封装,`services` 里登记的组件类型 → 实例,其余直传原始 d;
- 业务 handler(op=0)返回值不回流;协议层(op≠0)返回的 payload - 业务 handler(op=0)返回值不回流;协议层(op≠0)返回的 payload
进 reply 队列由 ws 发出。handler 异常就地隔离,不影响其他 handler。 进 reply 队列由 ws 发出。handler 异常就地隔离,不影响其他 handler。
""" """
@@ -11,10 +11,8 @@
import inspect import inspect
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any, TypeVar, cast, get_args, get_type_hints, overload from typing import Any, TypeVar, cast, get_type_hints, overload
from dishka import AsyncContainer
from dishka.exceptions import NoFactoryError
from loguru import logger from loguru import logger
from pyee.asyncio import AsyncIOEventEmitter from pyee.asyncio import AsyncIOEventEmitter
@@ -40,18 +38,15 @@ class EventEmitter:
def __init__(self, queue: EventQueue) -> None: def __init__(self, queue: EventQueue) -> None:
self._ee = AsyncIOEventEmitter() self._ee = AsyncIOEventEmitter()
self._queue = queue self._queue = queue
self._container: AsyncContainer | None = None self.services: dict[type, Any] = {}
self._protocol: BaseProtocol | None = None self._protocol: BaseProtocol | None = None
self._hints: dict[Callable[..., Any], dict[str, Any]] = {} self._hints: dict[Callable[..., Any], dict[str, Any]] = {}
@property @property
def queue(self) -> EventQueue: def queue(self) -> EventQueue:
"""本 emitter 消费的事件队列;外部构造 emitter 时由容器复用同一实例。""" """本 emitter 消费的事件队列;外部构造 emitter 时由 run_loop 复用同一实例。"""
return self._queue return self._queue
def use_container(self, container: AsyncContainer) -> None:
self._container = container
def register_protocol(self, protocol: BaseProtocol) -> None: def register_protocol(self, protocol: BaseProtocol) -> None:
self._protocol = protocol self._protocol = protocol
protocol.register(self) protocol.register(self)
@@ -120,14 +115,8 @@ class EventEmitter:
if not listeners: if not listeners:
return return
if self._container is None: for fn in listeners:
for fn in listeners: await self._invoke(fn, event, reply=op != Opcode.DISPATCH)
await self._call_direct(fn, event, reply=op != Opcode.DISPATCH)
return
async with self._container({Event: event}) as scope:
for fn in listeners:
await self._invoke(scope, fn, event, reply=op != Opcode.DISPATCH)
async def handle(self, data: Payload) -> Any: async def handle(self, data: Payload) -> Any:
"""同步分发事件并返回首个处理器的非空返回值,用于 op=13 等需要应答的协议事件。""" """同步分发事件并返回首个处理器的非空返回值,用于 op=13 等需要应答的协议事件。"""
@@ -137,24 +126,31 @@ class EventEmitter:
_, event_name, event = routed _, event_name, event = routed
for fn in self._ee.listeners(event_name): for fn in self._ee.listeners(event_name):
if self._container is None: ret = await fn(*self._resolve_params(fn, event))
args = self._direct_args(fn, event)
ret = await fn(*args)
else:
async with self._container({Event: event}) as scope:
args, kwargs = await self._resolve_params(scope, fn, event)
ret = await fn(*args, **kwargs)
if ret is not None: if ret is not None:
return ret return ret
return None return None
def _direct_args( async def _invoke(
self, fn: Callable[..., Awaitable[Any]], event: Event, reply: bool
) -> None:
# 用户 handler 的异常在这里就地隔离,不影响同一事件的其他 handler
try:
ret: Any = await fn(*self._resolve_params(fn, event))
except Exception as e:
logger.exception(f"事件处理器 {getattr(fn, '__qualname__', fn)} 异常: {e}")
return
if reply and isinstance(ret, dict):
await self._queue.put_reply(cast(Payload, ret))
def _resolve_params(
self, fn: Callable[..., Awaitable[Any]], event: Event self, fn: Callable[..., Awaitable[Any]], event: Event
) -> list[Any]: ) -> list[Any]:
"""无 DI 容器时的参数解析:Event → 事件对象,payload 类型 → dataclass,其余 → 原始 d。""" """按 handler 形参标注解析:payload dataclass → 解析对象,`Event` → 事件
封装,`services` 登记的组件类型 → 实例,其余(含无标注)→ 原始 d。"""
hints = self._hints.get(fn) hints = self._hints.get(fn)
if hints is None: if hints is None:
hints = get_type_hints(fn, include_extras=True) hints = get_type_hints(fn)
self._hints[fn] = hints self._hints[fn] = hints
args: list[Any] = [] args: list[Any] = []
@@ -164,68 +160,8 @@ class EventEmitter:
args.append(event.typed) args.append(event.typed)
elif hint is Event: elif hint is Event:
args.append(event) args.append(event)
elif hint in self.services:
args.append(self.services[hint])
else: else:
args.append(event.data) args.append(event.data)
return args return args
async def _call_direct(
self, fn: Callable[..., Awaitable[Any]], event: Event, reply: bool
) -> None:
# 用户 handler 的异常在这里就地隔离(无 DI 容器的直连路径)
try:
ret: Any = await fn(*self._direct_args(fn, event))
except Exception as e:
logger.exception(f"事件处理器 {getattr(fn, '__qualname__', fn)} 异常: {e}")
return
if reply and isinstance(ret, dict):
await self._queue.put_reply(cast(Payload, ret))
async def _invoke(
self,
scope: AsyncContainer,
fn: Callable[..., Awaitable[Any]],
event: Event,
reply: bool,
) -> None:
# 用户 handler 的异常在这里就地隔离,不影响同一事件的其他 handler
try:
args, kwargs = await self._resolve_params(scope, fn, event)
ret: Any = await fn(*args, **kwargs)
except Exception as e:
logger.exception(f"事件处理器 {getattr(fn, '__qualname__', fn)} 异常: {e}")
return
if reply and isinstance(ret, dict):
await self._queue.put_reply(cast(Payload, ret))
async def _resolve_params(
self,
scope: AsyncContainer,
fn: Callable[..., Awaitable[Any]],
event: Event,
) -> tuple[tuple[Any, ...], dict[str, Any]]:
hints = self._hints.get(fn)
if hints is None:
hints = get_type_hints(fn, include_extras=True)
self._hints[fn] = hints
positional: list[Any] = []
kwargs: dict[str, Any] = {}
for name in inspect.signature(fn).parameters:
hint = hints.get(name)
if hint is None:
positional.append(event.data)
continue
if hint in PAYLOAD_TYPES:
kwargs[name] = event.typed
continue
if hint is Event:
kwargs[name] = event
continue
target = get_args(hint)[0] if hasattr(hint, "__metadata__") else hint
try:
kwargs[name] = await scope.get(target)
except NoFactoryError:
positional.append(event.data)
return tuple(positional), kwargs
+1 -1
View File
@@ -2,7 +2,7 @@
class Session: class Session:
"""RESUME 续传所需的状态载体,由 DI 容器以 APP 单例持有。""" """RESUME 续传所需的状态载体,由 run_loop 构造并在 emitter.services 共享。"""
def __init__(self) -> None: def __init__(self) -> None:
self.session_id: str | None = None self.session_id: str | None = None
-1
View File
@@ -11,7 +11,6 @@ from .session import Session
class WebhookProtocol(BaseProtocol): class WebhookProtocol(BaseProtocol):
# 构造签名与 WebsocketProtocol 保持一致,供 ADAPTERS 统一实例化
def __init__(self, config: Config, queue: EventQueue, session: Session) -> None: def __init__(self, config: Config, queue: EventQueue, session: Session) -> None:
self._config = config self._config = config
+47
View File
@@ -2,6 +2,7 @@ import pytest
from qqbotsdk.emitter import EventEmitter from qqbotsdk.emitter import EventEmitter
from qqbotsdk.model import Opcode, payload_of from qqbotsdk.model import Opcode, payload_of
from qqbotsdk.payloads import GroupAtMessage
from qqbotsdk.queue import EventQueue from qqbotsdk.queue import EventQueue
from qqbotsdk.session import Session from qqbotsdk.session import Session
@@ -53,3 +54,49 @@ async def test_reply_payload_is_enqueued():
reply = await queue.get_reply() reply = await queue.get_reply()
assert reply["op"] == Opcode.HEARTBEAT assert reply["op"] == Opcode.HEARTBEAT
assert reply["d"] == 42 assert reply["d"] == 42
@pytest.mark.asyncio
async def test_service_annotation_resolves_from_services():
queue = EventQueue()
emitter = EventEmitter(queue)
class Store:
pass
store = Store()
emitter.services[Store] = store
received: list[tuple[GroupAtMessage, Store, dict]] = []
@emitter.on("GROUP_AT_MESSAGE_CREATE")
async def handler(msg: GroupAtMessage, st: Store, d):
received.append((msg, st, d))
await emitter.emit(
{
"op": Opcode.DISPATCH,
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {"content": "hi", "group_openid": "G1"},
}
)
assert len(received) == 1
msg, got, d = received[0]
assert got is store
assert isinstance(msg, GroupAtMessage) and msg.content == "hi"
assert d == {"content": "hi", "group_openid": "G1"}
@pytest.mark.asyncio
async def test_unregistered_annotation_falls_back_to_raw_dict():
queue = EventQueue()
emitter = EventEmitter(queue)
received: list[dict] = []
@emitter.on("READY")
async def handler(dep: Session): # Session 未登记进 services
received.append(dep)
await emitter.emit(
{"op": Opcode.DISPATCH, "t": "READY", "d": {"session_id": "s1"}}
)
assert received == [{"session_id": "s1"}]
Generated
-11
View File
@@ -257,15 +257,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
] ]
[[package]]
name = "dishka"
version = "1.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/97/da/b75b5fe665d16725e1d46e291c8dd4b0ed1731c7964f41087fda92f03d40/dishka-1.10.1.tar.gz", hash = "sha256:51fb624b9acc948b90f57128460061d1f3a1780121fe1c7d14c565e8bbff2083", size = 282008, upload-time = "2026-04-25T12:07:57.587Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/08/dd3259a319ba45c48d975caf237c830c7873a203288cb4ab186cc5bef585/dishka-1.10.1-py3-none-any.whl", hash = "sha256:84f40c1115eb391ce3fb558fd7946c5bcaf6361c35b77b7a185b7697a5720ec7", size = 120062, upload-time = "2026-04-25T12:07:59.153Z" },
]
[[package]] [[package]]
name = "frozenlist" name = "frozenlist"
version = "1.8.0" version = "1.8.0"
@@ -643,7 +634,6 @@ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "apscheduler" }, { name = "apscheduler" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "dishka" },
{ name = "loguru" }, { name = "loguru" },
{ name = "pyee" }, { name = "pyee" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
@@ -662,7 +652,6 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.14.3" }, { name = "aiohttp", specifier = ">=3.14.3" },
{ name = "apscheduler", specifier = ">=3.11.3" }, { name = "apscheduler", specifier = ">=3.11.3" },
{ name = "cryptography", specifier = ">=50.0.1" }, { name = "cryptography", specifier = ">=50.0.1" },
{ name = "dishka", specifier = ">=1.10.1" },
{ name = "loguru", specifier = ">=0.7.3" }, { name = "loguru", specifier = ">=0.7.3" },
{ name = "pyee", specifier = ">=14.0.0" }, { name = "pyee", specifier = ">=14.0.0" },
{ name = "python-dotenv", specifier = ">=1.2.3" }, { name = "python-dotenv", specifier = ">=1.2.3" },