From 0f3ed092339aea31f934245ecc30b050bfc7a38d Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:40:37 +0800 Subject: [PATCH] =?UTF-8?q?refactor!:=20handler=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=8F=AA=E6=8C=89=E6=A8=A1=E5=9E=8B/Event/=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E4=BC=A0=E5=8E=9F=E5=A7=8B=20d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解析不到的形参(无标注或标注三者皆非)在分发时抛 ValueError,经异常 隔离记日志;原始数据经 Event 的 .raw/.typed 自取。同步四处文档与测试。 --- README.md | 2 +- docs/API.md | 15 ++++++++------- docs/ARCHITECTURE.md | 2 +- docs/GUIDE.md | 20 ++++++++------------ src/qqbotsdk/emitter.py | 15 +++++++++------ tests/test_emitter.py | 37 +++++++++++++++++++------------------ tests/test_payloads.py | 15 --------------- 7 files changed, 46 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 3c9903f..328ce21 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/API.md b/docs/API.md index 6d1f3c3..6487f9f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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` 去重。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9739ca4..c4212b2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` 上。 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 126c1d0..f478356 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -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 配置格式与落盘。 diff --git a/src/qqbotsdk/emitter.py b/src/qqbotsdk/emitter.py index a7936b2..f499e05 100644 --- a/src/qqbotsdk/emitter.py +++ b/src/qqbotsdk/emitter.py @@ -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 diff --git a/tests/test_emitter.py b/tests/test_emitter.py index 53ac6e6..06647b7 100644 --- a/tests/test_emitter.py +++ b/tests/test_emitter.py @@ -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 diff --git a/tests/test_payloads.py b/tests/test_payloads.py index 8f8dfa9..c49744c 100644 --- a/tests/test_payloads.py +++ b/tests/test_payloads.py @@ -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,