refactor!: 协议帧与业务事件分流,各接入方式配专有协议处理器

BaseProtocol 从 emitter.py 移入新模块 protocol.py,接口由 register(emitter)
改为 async on_frame(payload)(返回需回送的响应体);协议处理器不再注册进
emitter,而由 run_loop 注入对应的 connecter,由适配器逐帧调用。

破坏性变更:只有 op=0 的业务事件进事件队列交给 EventEmitter 分发,
HELLO/READY/INVALID_SESSION/op=13 等协议帧在连接器内就地处理——用户 handler
不再收到协议帧(此前 HELLO/INVALID_SESSION 等会路由到 handler)。
EventEmitter.register_protocol()/handle() 与 emitter 持有协议层的机制删除,
协议类不再用 register() 注册 handler。

附带修掉一处时序耦合:INVALID_SESSION 的会话重置此前依赖分发泵在重连前
消费掉该事件(慢 handler 会推迟它,导致带着已死 session_id 去 RESUME),
现在连接器在 break 重连前同步完成重置。

同时做的事件流优化:
- emit() 先取事件名、无 listener 时不构造 Event(未订阅事件省一次对象与拷贝)
- Event.data 改为惰性拷贝(只用 .typed/.raw 的 handler 不再触发 dict(d))
- ws 重连收尾 await 被取消的 reply/heartbeat 任务,避免异常无人取用
- EventQueue.put_event/put_reply 去掉多余的 return await

测试 80 全绿(新增协议帧处理测试组、ws 集成断言协议帧不触达 handler),
ruff/pyright 干净。文档 ARCHITECTURE/API/GUIDE/README/CHANGELOG 同步。
This commit is contained in:
snowy-yang committed 2026-09-20 21:57:57 +08:00
1 parent 71cbfdaf5d
commit 7accbd1ead
16 files changed
+335 -238

No files matched your search

+19 -39
View File
@@ -2,14 +2,12 @@ import asyncio
import pytest
from qqbotsdk.config import Config
from qqbotsdk.emitter import EventEmitter
from qqbotsdk.events import Event
from qqbotsdk.model import Opcode, payload_of
from qqbotsdk.payloads import GroupAtMessage
from qqbotsdk.queue import EventQueue
from qqbotsdk.session import Session
from qqbotsdk.token import AccessToken
from qqbotsdk.ws_protocol import WebsocketProtocol
def test_sequence_only_moves_forward():
@@ -41,7 +39,8 @@ async def test_handler_exception_is_isolated():
@pytest.mark.asyncio
async def test_reply_payload_is_enqueued():
async def test_protocol_frame_never_reaches_business_handler():
"""协议帧改由适配器的协议处理器就近处理,不再经 emitter 回流应答。"""
queue = EventQueue()
emitter = EventEmitter(queue)
@@ -50,9 +49,7 @@ async def test_reply_payload_is_enqueued():
return payload_of(Opcode.HEARTBEAT, 42)
await emitter.emit({"op": Opcode.HEARTBEAT, "d": 0})
reply = await queue.get_reply()
assert reply["op"] == Opcode.HEARTBEAT
assert reply["d"] == 42
assert queue.reply_queue.empty()
@pytest.mark.asyncio
@@ -126,40 +123,23 @@ async def test_service_registered_after_handler_still_injects():
@pytest.mark.asyncio
async def test_scalar_d_falls_back_to_raw_value():
"""回归:op=9 INVALID_SESSION 的 d 是布尔值,DI 回退必须传原始 d。
此前实现把 d dict 化成 {}(恒 falsy),resumable=true 也被当成不可续传。
走真实协议层 handler 验证路由 → 注入 → 会话状态的全链路。"""
config = Config(
app_id="a",
app_secret="s",
base_url="https://x",
timeout=None, # type: ignore[arg-type]
connecter="websocket",
intents_file="no-such.toml",
webhook_host="0.0.0.0",
webhook_port=8080,
webhook_path="/x",
)
async def test_typed_and_data_see_same_content():
"""`.typed` 与 `.data` 均可用且一致;只取 `.typed` 的 handler 不触发 data 拷贝。"""
emitter = EventEmitter()
session = Session()
session.session_id = "S1"
emitter.register_protocol(
WebsocketProtocol(config, session, AccessToken(config, None)) # type: ignore[arg-type]
seen: list[tuple[str | None, str | None]] = []
@emitter.on("GROUP_AT_MESSAGE_CREATE")
async def handler(msg: GroupAtMessage, event: Event):
seen.append((msg.content, event.data["content"]))
await emitter.emit(
{
"op": Opcode.DISPATCH,
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {"content": "hi", "group_openid": "G1"},
}
)
seen: list[bool] = []
@emitter.on(Opcode.INVALID_SESSION)
async def spy(resumable: bool):
seen.append(resumable)
await emitter.emit({"op": Opcode.INVALID_SESSION, "d": True})
assert seen == [True]
assert session.session_id == "S1" # 可续传,会话保留
await emitter.emit({"op": Opcode.INVALID_SESSION, "d": False})
assert seen == [True, False]
assert session.session_id is None # 不可续传,会话清空
assert seen == [("hi", "hi")]
@pytest.mark.asyncio
+112 -20
View File
@@ -1,36 +1,128 @@
"""协议帧处理测试:协议处理器(WebsocketProtocol)就近处理握手续传语义,
不进事件分发;只有业务事件(op=0)由 emitter 分发给用户 handler。"""
import pytest
from qqbotsdk.config import Config
from qqbotsdk.emitter import EventEmitter
from qqbotsdk.model import Opcode
from qqbotsdk.session import Session
from qqbotsdk.ws_protocol import WebsocketProtocol
@pytest.fixture
def emitter() -> EventEmitter:
return EventEmitter() # 无参构造,覆盖 queue 默认自建路径
class FakeToken:
"""手搓 token 源,避免构造真实 AccessToken 去发网络请求。"""
async def get_access_token(self) -> str:
return "tok"
def test_route_dispatch_uses_t(emitter: EventEmitter):
routed = emitter._route({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
assert routed is not None
op, name, event = routed
assert op == Opcode.DISPATCH
assert name == "READY"
assert event.type == "READY"
def make_protocol(session: Session | None = None) -> tuple[WebsocketProtocol, Session]:
session = session or Session()
config = Config(
app_id="app",
app_secret="secret",
base_url="https://api.bot.qq.com",
timeout=None, # type: ignore[arg-type]
connecter="websocket",
intents_file="no-such.toml",
webhook_host="0.0.0.0",
webhook_port=8080,
webhook_path="/x",
)
return WebsocketProtocol(config, session, FakeToken()), session # type: ignore[arg-type]
def test_route_non_dispatch_uses_opcode_name(emitter: EventEmitter):
routed = emitter._route({"op": Opcode.HELLO, "d": {"heartbeat_interval": 1}})
assert routed is not None
assert routed[1] == "HELLO"
@pytest.mark.asyncio
async def test_hello_without_session_identifies():
protocol, _ = make_protocol()
reply = await protocol.on_frame(
{"op": Opcode.HELLO, "d": {"heartbeat_interval": 1}}
)
assert reply is not None
assert reply["op"] == Opcode.IDENTIFY
assert reply["d"]["token"] == "QQBot tok"
assert reply["d"]["shard"] == (0, 1)
def test_route_rejects_missing_op(emitter: EventEmitter):
assert emitter._route({"d": {}}) is None
@pytest.mark.asyncio
async def test_hello_with_session_resumes():
protocol, session = make_protocol()
session.session_id = "S1"
session.update_sequence(7)
reply = await protocol.on_frame({"op": Opcode.HELLO, "d": {}})
assert reply is not None
assert reply["op"] == Opcode.RESUME
assert reply["d"]["session_id"] == "S1"
assert reply["d"]["seq"] == 7
def test_route_rejects_unknown_opcode(emitter: EventEmitter):
assert emitter._route({"op": 999}) is None
@pytest.mark.asyncio
async def test_ready_captures_session_without_reply():
protocol, session = make_protocol()
reply = await protocol.on_frame(
{"op": Opcode.DISPATCH, "t": "READY", "d": {"session_id": "S9"}}
)
assert reply is None
assert session.session_id == "S9"
def test_route_rejects_dispatch_without_t(emitter: EventEmitter):
assert emitter._route({"op": Opcode.DISPATCH, "d": {}}) is None
@pytest.mark.asyncio
async def test_sequence_is_tracked_from_frames():
protocol, session = make_protocol()
await protocol.on_frame({"op": Opcode.DISPATCH, "t": "X", "s": 5, "d": {}})
await protocol.on_frame(
{"op": Opcode.DISPATCH, "t": "X", "s": 3, "d": {}}
) # 回退被忽略
assert session.seq == 5
@pytest.mark.asyncio
async def test_invalid_session_resets_only_when_not_resumable():
protocol, session = make_protocol()
session.session_id = "S1"
await protocol.on_frame({"op": Opcode.INVALID_SESSION, "d": True})
assert session.session_id == "S1" # 可续传,会话保留
await protocol.on_frame({"op": Opcode.INVALID_SESSION, "d": False})
assert session.session_id is None # 不可续传,下次重连走 IDENTIFY
@pytest.mark.asyncio
async def test_business_event_returns_no_reply():
"""业务事件(op=0 非 READY)协议层不产生应答,只留给 emitter 分发。"""
protocol, _ = make_protocol()
reply = await protocol.on_frame(
{"op": Opcode.DISPATCH, "t": "GROUP_AT_MESSAGE_CREATE", "d": {"content": "hi"}}
)
assert reply is None
@pytest.mark.asyncio
async def test_emit_drops_protocol_frames():
"""协议帧不进分发队列;万一进来也不得触达业务 handler。"""
emitter = EventEmitter()
called: list[str] = []
@emitter.on(Opcode.HELLO)
async def on_hello(_):
called.append("hello")
await emitter.emit({"op": Opcode.HELLO, "d": {"heartbeat_interval": 1}})
assert called == []
@pytest.mark.asyncio
async def test_emit_drops_dispatch_without_t():
emitter = EventEmitter()
@emitter.on("READY")
async def on_ready(_):
raise AssertionError("不应被调用")
await emitter.emit({"op": Opcode.DISPATCH, "d": {}})
@pytest.mark.asyncio
async def test_emit_ignores_missing_op():
emitter = EventEmitter()
await emitter.emit({"d": {}}) # 无 op,静默跳过
+1 -2
View File
@@ -51,8 +51,7 @@ def event_body(event_id: str, content: str) -> dict[str, Any]:
async def test_webhook_end_to_end():
config = make_config(0)
emitter = EventEmitter()
emitter.register_protocol(WebhookProtocol(config))
connecter = WebhookConnecter(config, emitter.queue, emitter.handle)
connecter = WebhookConnecter(config, emitter.queue, WebhookProtocol(config))
received: list[GroupAtMessage] = []
+14 -2
View File
@@ -11,6 +11,7 @@ from aiohttp import ClientSession, WSMsgType, web
from qqbotsdk.config import Config
from qqbotsdk.connecter import WebsocketConnecter
from qqbotsdk.emitter import EventEmitter
from qqbotsdk.model import Opcode
from qqbotsdk.payloads import GroupAtMessage
from qqbotsdk.queue import EventQueue
from qqbotsdk.session import Session
@@ -115,16 +116,25 @@ async def test_websocket_end_to_end():
session = Session()
emitter = EventEmitter(queue)
protocol = WebsocketProtocol(config, session, token)
emitter.register_protocol(protocol)
connecter = WebsocketConnecter(config, http, token, session, queue)
connecter = WebsocketConnecter(config, http, token, session, queue, protocol)
received: list[GroupAtMessage] = []
# 协议帧(HELLO/HEARTBEAT_ACK)绝不该触达 handler——只有 op=0 业务事件入队
protocol_frames: list[object] = []
@emitter.on("GROUP_AT_MESSAGE_CREATE")
async def on_msg(msg: GroupAtMessage) -> None:
received.append(msg)
gw.got_dispatch.set()
@emitter.on(Opcode.HELLO)
async def on_hello(_) -> None:
protocol_frames.append("HELLO")
@emitter.on(Opcode.HEARTBEAT_ACK)
async def on_ack(_) -> None:
protocol_frames.append("HEARTBEAT_ACK")
dispatch_task = asyncio.create_task(emitter.dispatch())
connect_task = asyncio.create_task(connecter.run())
try:
@@ -146,6 +156,8 @@ async def test_websocket_end_to_end():
assert len(received) == 1
assert received[0].content == "ping"
assert received[0].group_openid == "G1"
# 协议帧(HELLO/HEARTBEAT_ACK)没进分发路径
assert protocol_frames == []
finally:
dispatch_task.cancel()
connect_task.cancel()