mirror of
https://github.com/snowy-yang/qqbotsdk.git
synced 2026-10-21 07:59:07 +00:00
refactor!: handler 参数只按模型/Event/组件注入,不再回退传原始 d
解析不到的形参(无标注或标注三者皆非)在分发时抛 ValueError,经异常 隔离记日志;原始数据经 Event 的 .raw/.typed 自取。同步四处文档与测试。
This commit is contained in:
1 parent
1d9f509b25
commit
0f3ed09233
7 files changed
+46
-60
No files matched your search
@@ -75,7 +75,7 @@ from qqbotsdk.api import BotApi
|
||||
|
||||
ee = EventEmitter()
|
||||
|
||||
# handler 形参按标注注入 payload 与 BotApi 等组件(也支持裸参数 d)
|
||||
# handler 形参按标注注入 payload 与 BotApi 等组件
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
||||
async def on_group_message(msg: GroupAtMessage, api: BotApi):
|
||||
# 被动回复带 msg_id;多次回复递增 msg_seq;失败抛 ApiError
|
||||
|
||||
+8
-7
@@ -54,12 +54,13 @@ async def slow(...): ...
|
||||
|
||||
按参数类型标注解析,可任意混用:
|
||||
|
||||
| 标注 | 得到 | 未命中时 |
|
||||
|---|---|---|
|
||||
| payload dataclass(`payloads` 中的类型) | `Event.typed` 解析对象 | 回退传原始 d |
|
||||
| `Event` | 事件封装对象 | — |
|
||||
| 组件类型(`BotApi`/`Session`/`Config` 等已登记进 `ee.services` 的类型) | 登记的实例 | 回退传原始 d |
|
||||
| 无标注 | 原始 d(业务事件即事件 dict;协议事件可能是标量,如 `INVALID_SESSION` 的 `resumable: bool`) | — |
|
||||
| 标注 | 得到 |
|
||||
|---|---|
|
||||
| payload dataclass(`payloads` 中的类型) | `Event.typed` 解析对象 |
|
||||
| `Event` | 事件封装对象 |
|
||||
| 组件类型(`BotApi`/`Session`/`Config` 等已登记进 `ee.services` 的类型) | 登记的实例 |
|
||||
|
||||
无标注或标注以上三者皆非的形参在分发时抛 `ValueError`(记日志、按 handler 隔离)——SDK 不注入原始 d,要事件体字段就标注 payload dataclass,要完整信封就标注 `Event`。
|
||||
|
||||
## Event
|
||||
|
||||
@@ -284,7 +285,7 @@ async def slow(...): ...
|
||||
| AUDIO_ACTION | `AUDIO_START` / `AUDIO_FINISH` / `AUDIO_ON_MIC` / `AUDIO_OFF_MIC` | `AudioAction` |
|
||||
| —(DISPATCH 协议事件) | `READY` | `Ready` |
|
||||
|
||||
未收录的事件(如私域 `MESSAGE_CREATE`、`DIRECT_MESSAGE_DELETE`、`GUILD_MEMBER_*`)handler 收到原始 dict;新增事件在 `payloads.py` 加 dataclass 并登记 `EVENT_TYPES` 即可。
|
||||
未收录的事件(如私域 `MESSAGE_CREATE`、`DIRECT_MESSAGE_DELETE`、`GUILD_MEMBER_*`)没有对应 dataclass 可标注,请标注 `Event` 经 `.typed`(未知事件类型为 dict)或 `.raw` 访问;新增事件在 `payloads.py` 加 dataclass 并登记 `EVENT_TYPES` 即可。
|
||||
|
||||
`GROUP_MESSAGE_CREATE` 是群消息**全量模式**:需在开放平台开通"接收所有消息"权限,群里每条消息(不限 @机器人)都推送此事件,与 `GROUP_AT_MESSAGE_CREATE` 共用 `GroupAtMessage`。官方提示同一 msg_id 可能重复推送,业务侧需按 `id` 去重。
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ sequenceDiagram
|
||||
2. **协议层只管会话语义,心跳归传输层**:`WebsocketProtocol` 负责 IDENTIFY/RESUME/序列号(说什么),`WebsocketConnecter` 负责连接与心跳(怎么保活)——心跳任务随连接生灭(HELLO 给出间隔后启动,断线即取消),断线期间不在出站缓冲堆积过期心跳,也免去了调度器的生命周期管理。协议处理器的应答经 `on_frame` 返回值交给 connecter:ws 与心跳一起进 connecter 私有的出站缓冲、由单任务发送(维持 ws 单写者不变量),webhook 则直接写进 HTTP 响应体。
|
||||
3. **看门狗两层互补 + 连接寿命主动重连**(见 `connecter.py` 模块注释):静默超时抓连接彻底失联,ACK 期限检查抓"有事件流但心跳已死"。阈值 `_SILENCE_FACTOR=1.1`、`_ACK_FACTOR=1.2` 是按 ACK 自然节奏校准的,**不能压到 1× 整**,否则正常抖动会周期性误杀(误杀由 RESUME 兜底,不丢消息)。重连退避只在连接稳定存活(`_STABLE_SECONDS`)后才归一,抖动的服务端不会被 1s 间隔反复探测。此外服务端约 1 小时会**无预警**断开连接(实测),故连接存活到 `_SERVER_LIFETIME - _RECONNECT_MARGIN`(默认 50 分钟)时 SDK 主动收尾重连——收帧超时取"静默阈值"与"寿命余量"两个截止中的先到者,静默仍交给看门狗,寿命到点则正常结束本轮连接、由 `run()` 判定为健康断开后立刻 RESUME 续传。
|
||||
4. **handler 异常就地隔离**:`_invoke` 捕获记日志,`dispatch` 主循环另有兜底;单条事件的单个 handler 失败不影响进程。`background=True` 的 handler 进后台任务(emit 不等它),任务由 emitter 持强引用、`close()` 统一取消。
|
||||
5. **注入约定**:handler 参数按形参标注解析——`payloads.py` 里的 dataclass → 解析后的 payload 对象;`Event` → 事件封装;登记进 `emitter.services` 的组件类型(如 `BotApi`)→ 实例;未标注或未登记 → 回退传**原始 d**。解析是纯同步查表,无框架、无运行时魔法:每个 handler 的 `get_type_hints` + 形参名只解析一次并缓存,但 `services` 命中在每条事件时实时判定,保证"先注册 handler、后登记组件"的装配顺序可用。
|
||||
5. **注入约定**:handler 参数按形参标注解析——`payloads.py` 里的 dataclass → 解析后的 payload 对象;`Event` → 事件封装;登记进 `emitter.services` 的组件类型(如 `BotApi`)→ 实例;无标注或标注三者皆非 → 抛 `ValueError`(原始数据不注入,需要时标 `Event` 自取 `.raw`/`.typed`)。解析是纯同步查表,无框架、无运行时魔法:每个 handler 的 `get_type_hints` + 形参名只解析一次并缓存,但 `services` 命中在每条事件时实时判定,保证"先注册 handler、后登记组件"的装配顺序可用(`run_loop` 在分发开始前登记全部内置组件,正常流程不会误抛)。
|
||||
6. **事件注册不依赖第三方库**:`EventEmitter` 内部就是一个 `事件名 → handler 列表` 的字典,`on()` 追加、`emit()` 顺序遍历。不需要 pyee 那套 `once`/`new_listener`/错误事件机制(SDK 用不到),少一个依赖、语义也一眼可读。注册语义:同一函数对同一事件重复注册只生效一次(沿用原先 pyee 的行为),分发按注册顺序串行执行(`background=True` 的除外)。
|
||||
7. **解析计划与派生密钥都预计算**:`payloads._parse_plan` 把 dataclass 的"字段名 + 是否需解析成 Author"按类缓存一次(不再每次事件重跑 `dataclasses.fields` 与联合类型解包),解析时未收录字段一律忽略——线上 payload 会带新增/未声明字段,透传会让构造报错。`crypto` 按 AppSecret 缓存派生出的 Ed25519 私钥(派生约 40µs,而每个 webhook 请求都要验签)。都是"值为输入的纯函数、结果与输入一一对应",缓存安全且收益明确;不追求极致速度,只去掉明显的重复计算。
|
||||
8. **OpenAPI 请求内核自治**:`api/core.py` 统一处理鉴权头、429 限流重试(优先 `Retry-After`,脏值容错,退避封顶)与 401 刷新 token 重试一次;v2/channel/guild 三个域方法集继承内核组合成 `BotApi`,新增域加模块即可,方法对用户始终平铺在 `BotApi` 上。
|
||||
|
||||
+8
-12
@@ -82,26 +82,21 @@ QQ 网关只认分组位掩码,**组内单个事件无法单独开关**——
|
||||
|
||||
## 编写 handler
|
||||
|
||||
用装饰器把 handler 注册到 `EventEmitter`,事件名即线上 payload 的 `t` 名(如 `GROUP_AT_MESSAGE_CREATE`)。三种写法可混用:
|
||||
用装饰器把 handler 注册到 `EventEmitter`,事件名即线上 payload 的 `t` 名(如 `GROUP_AT_MESSAGE_CREATE`)。参数按类型标注注入,可混用:
|
||||
|
||||
```python
|
||||
from qqbotsdk import EventEmitter
|
||||
|
||||
ee = EventEmitter()
|
||||
|
||||
# 方式一:裸参数,d 为原始事件 dict
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
||||
async def raw(d):
|
||||
print(d)
|
||||
|
||||
# 方式二:标注 payload dataclass,拿到解析后的对象
|
||||
# 方式一:标注 payload dataclass,拿到解析后的对象
|
||||
from qqbotsdk.payloads import GroupAtMessage
|
||||
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
||||
async def typed(msg: GroupAtMessage):
|
||||
print(msg.group_openid, msg.user_openid, msg.content)
|
||||
|
||||
# 方式三:参数注入,标注组件类型即拿到实例(payload 与组件可任意组合)
|
||||
# 方式二:参数注入,标注组件类型即拿到实例(payload 与组件可任意组合)
|
||||
from qqbotsdk.api import BotApi
|
||||
from qqbotsdk.payloads import GroupAtMessage
|
||||
|
||||
@@ -114,10 +109,11 @@ handler 参数按标注解析的完整规则:
|
||||
|
||||
| 参数标注 | 得到 |
|
||||
|---|---|
|
||||
| payload dataclass(如 `GroupAtMessage`) | 解析后的 dataclass 对象;未收录事件回退原始 dict |
|
||||
| payload dataclass(如 `GroupAtMessage`) | 解析后的 dataclass 对象 |
|
||||
| `Event` | 事件封装对象,只含信封语义(`.raw`/`.data`/`.typed`/`.type`/`.event_id`);事件体字段请标注 payload dataclass 获取 |
|
||||
| 组件类型(`BotApi`/`Session`/`Config` 等) | `emitter.services` 中按类型登记的实例;未登记回退传原始 d |
|
||||
| 无标注 | 原始 d(即事件 dict) |
|
||||
| 组件类型(`BotApi`/`Session`/`Config` 等) | `emitter.services` 中按类型登记的实例 |
|
||||
|
||||
无标注或标注以上三者皆非的形参会在分发时抛 `ValueError`(记日志、按 handler 隔离)——SDK 不注入原始数据:要事件体字段就标注 payload dataclass,要完整信封就标注 `Event`。
|
||||
|
||||
注意:
|
||||
|
||||
@@ -239,5 +235,5 @@ uv run python main.py
|
||||
- **`ApiError`**:HTTP ≥400 时抛出,`e.status`/`e.code`/`e.message` 可用于排查(如被动回复超时、msg_seq 重复、内容审核不通过)。
|
||||
- **日志出现 `TokenError: 获取 access_token 失败`**:appid/secret 配置错误或被平台拒绝,`code`/`message` 就是 QQ 返回的真实原因,对照官方错误码排查即可。
|
||||
- **handler 没被触发**:检查 intents.toml 中该事件所属分组是否为 `true`;群/单聊事件需开通对应能力;频道消息事件区分公私域。
|
||||
- **想收原始数据**:handler 裸参数或标 `Event`,`event.raw` 是完整线上 payload(含 op/t/d)。
|
||||
- **想收原始数据**:标注 `Event`,`event.raw` 是完整线上 payload(含 op/t/d),`event.typed` 是事件体解析结果(未收录事件类型为 dict)。
|
||||
- **日志**:SDK 用 loguru 输出中文日志,连接、重连、API 失败、handler 异常均有记录,可直接用 loguru 配置格式与落盘。
|
||||
@@ -4,8 +4,8 @@
|
||||
按 t 名路由到 `@on` 注册的 handler;协议帧(HELLO/INVALID_SESSION/op=13 等)
|
||||
不进队列,由连接适配器的协议处理器就地处理(见 protocol.py);
|
||||
- handler 参数按形参标注注入:payload dataclass → 解析对象,`Event` →
|
||||
事件封装,`services` 里登记的组件类型 → 实例,其余直传原始 d
|
||||
(可能是 dict、标量甚至 None);
|
||||
事件封装,`services` 里登记的组件类型 → 实例;无标注或标注三者皆非
|
||||
则解析即抛错(经异常隔离记日志)——不注入原始 d;
|
||||
- 业务 handler 返回值不回流;handler 异常就地隔离,不影响其他 handler。
|
||||
- `on(..., background=True)` 把 handler 放进后台任务并发执行:
|
||||
不阻塞事件流,但同事件 handler 间失去先后保证;
|
||||
@@ -144,9 +144,8 @@ class EventEmitter:
|
||||
self, fn: Callable[..., Awaitable[Any]], event: Event
|
||||
) -> list[Any]:
|
||||
"""按 handler 形参标注解析:payload dataclass → 解析对象,`Event` → 事件
|
||||
封装,`services` 登记的组件类型 → 实例,其余(含无标注)→ 原始 d
|
||||
(Event.data 是 dict 化视图,标量 d 如 INVALID_SESSION 的 true/false
|
||||
必须走这里才能拿到原值)。"""
|
||||
封装,`services` 登记的组件类型 → 实例;无标注或标注三者皆非则抛
|
||||
ValueError——原始 d 不注入,静默传 dict 比报错更难排查。"""
|
||||
cached = self._signatures.get(fn)
|
||||
if cached is None:
|
||||
cached = (
|
||||
@@ -166,5 +165,9 @@ class EventEmitter:
|
||||
elif hint in self.services:
|
||||
args.append(self.services[hint])
|
||||
else:
|
||||
args.append(event.raw.get("d"))
|
||||
raise ValueError(
|
||||
f"事件处理器 {getattr(fn, '__qualname__', fn)} 的形参 {name} "
|
||||
f"不可注入(标注 {hint!r} 只能是 payload dataclass、Event "
|
||||
"或已登记进 services 的组件类型)"
|
||||
)
|
||||
return args
|
||||
+19
-18
@@ -26,7 +26,7 @@ async def test_same_handler_registered_twice_runs_once():
|
||||
emitter = EventEmitter()
|
||||
calls: list[str] = []
|
||||
|
||||
async def handler(_):
|
||||
async def handler():
|
||||
calls.append("hit")
|
||||
|
||||
emitter.on("READY", handler)
|
||||
@@ -41,11 +41,11 @@ async def test_handlers_run_in_registration_order():
|
||||
order: list[int] = []
|
||||
|
||||
@emitter.on("READY")
|
||||
async def first(_):
|
||||
async def first():
|
||||
order.append(1)
|
||||
|
||||
@emitter.on("READY")
|
||||
async def second(_):
|
||||
async def second():
|
||||
order.append(2)
|
||||
|
||||
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
|
||||
@@ -59,11 +59,11 @@ async def test_handler_exception_is_isolated():
|
||||
called: list[str] = []
|
||||
|
||||
@emitter.on("READY")
|
||||
async def bad(_):
|
||||
async def bad():
|
||||
raise RuntimeError("boom")
|
||||
|
||||
@emitter.on("READY")
|
||||
async def good(_):
|
||||
async def good():
|
||||
called.append("ok")
|
||||
|
||||
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
|
||||
@@ -77,8 +77,8 @@ async def test_protocol_frame_never_reaches_business_handler():
|
||||
called: list[object] = []
|
||||
|
||||
@emitter.on(Opcode.HEARTBEAT)
|
||||
async def beat(d):
|
||||
called.append(d)
|
||||
async def beat():
|
||||
called.append("hit")
|
||||
|
||||
await emitter.emit({"op": Opcode.HEARTBEAT, "d": 0})
|
||||
assert called == []
|
||||
@@ -94,11 +94,11 @@ async def test_service_annotation_resolves_from_services():
|
||||
|
||||
store = Store()
|
||||
emitter.services[Store] = store
|
||||
received: list[tuple[GroupAtMessage, Store, dict]] = []
|
||||
received: list[tuple[GroupAtMessage, Store]] = []
|
||||
|
||||
@emitter.on("GROUP_AT_MESSAGE_CREATE")
|
||||
async def handler(msg: GroupAtMessage, st: Store, d):
|
||||
received.append((msg, st, d))
|
||||
async def handler(msg: GroupAtMessage, st: Store):
|
||||
received.append((msg, st))
|
||||
|
||||
await emitter.emit(
|
||||
{
|
||||
@@ -108,24 +108,25 @@ async def test_service_annotation_resolves_from_services():
|
||||
}
|
||||
)
|
||||
assert len(received) == 1
|
||||
msg, got, d = received[0]
|
||||
msg, got = 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] = []
|
||||
async def test_unresolvable_param_raises_instead_of_raw_dict():
|
||||
"""原始 d 不再注入:无标注或标注不可解析的形参直接抛错(由异常隔离记日志)。"""
|
||||
emitter = EventEmitter()
|
||||
received: list[object] = []
|
||||
|
||||
@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"}]
|
||||
event = Event({"op": Opcode.DISPATCH, "t": "READY", "d": {"session_id": "s1"}})
|
||||
with pytest.raises(ValueError, match="不可注入"):
|
||||
emitter._resolve_params(handler, event)
|
||||
assert received == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -179,21 +179,6 @@ async def test_typed_annotation_receives_dataclass():
|
||||
assert received[0].group_openid == "G1"
|
||||
|
||||
|
||||
async def test_untyped_annotation_still_receives_raw_dict():
|
||||
queue = EventQueue()
|
||||
emitter = EventEmitter(queue)
|
||||
received: list[dict] = []
|
||||
|
||||
@emitter.on("GROUP_AT_MESSAGE_CREATE")
|
||||
async def handler(d):
|
||||
received.append(d)
|
||||
|
||||
await emitter.emit(
|
||||
{"op": Opcode.DISPATCH, "t": "GROUP_AT_MESSAGE_CREATE", "d": {"content": "x"}}
|
||||
)
|
||||
assert received == [{"content": "x"}]
|
||||
|
||||
|
||||
def test_parse_group_member_and_join_request_events():
|
||||
from qqbotsdk.payloads import (
|
||||
GroupJoinRequest,
|
||||
|
||||
Reference in new issue
Block a user