mirror of
https://github.com/snowy-yang/qqbotsdk.git
synced 2026-10-21 11:04:48 +00:00
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:
1 parent
71cbfdaf5d
commit
7accbd1ead
16 files changed
+335
-238
No files matched your search
+112
-20
@@ -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,静默跳过
|
||||
Reference in new issue
Block a user