mirror of
https://github.com/snowy-yang/qqbotsdk.git
synced 2026-10-21 13:37:19 +00:00
refactor!: 移除 dishka,改为内置裸标注参数注入
- handler 形参直接标注组件类型(api: BotApi)即注入,解析为 emitter 内同步查表(payload dataclass / Event / services 登记类型 / 回退原始 dict)
This commit is contained in:
1 parent
953d44f62e
commit
cff0c142c2
16 files changed
+161
-240
No files matched your search
@@ -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 仓库分发
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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` 即可拿到实例。
|
||||||
|
|
||||||
## 发消息
|
## 发消息
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"}]
|
||||||
@@ -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" },
|
||||||
|
|||||||
Reference in new issue
Block a user