Files
qqbotsdk/tests/test_webhook_integration.py
T
snowy-yang 7accbd1ead 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 同步。
2026-09-20 21:57:57 +08:00

123 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Webhook 接入的端到端集成测试:真实起 WebhookConnecter 的 http server,
覆盖 op=13 验证应答、合法签名放行、坏签名 401、按 id 去重。"""
import asyncio
import json
from typing import Any
import pytest
from aiohttp.test_utils import TestClient, TestServer
from cryptography.hazmat.primitives.asymmetric import ed25519
from qqbotsdk.config import Config
from qqbotsdk.connecter import WebhookConnecter
from qqbotsdk.crypto import build_seed
from qqbotsdk.emitter import EventEmitter
from qqbotsdk.payloads import GroupAtMessage
from qqbotsdk.webhook_protocol import WebhookProtocol
SECRET = "webhook-test-secret"
def make_config(port: int) -> Config:
return Config(
app_id="app",
app_secret=SECRET,
base_url="https://api.bot.qq.com",
timeout=None, # type: ignore[arg-type]
connecter="webhook",
intents_file="no-such-intents.toml",
webhook_host="127.0.0.1",
webhook_port=port,
webhook_path="/qqbot/webhook",
)
def sign(secret: str, timestamp: str, body: bytes) -> str:
key = ed25519.Ed25519PrivateKey.from_private_bytes(build_seed(secret))
return key.sign(timestamp.encode() + body).hex()
def event_body(event_id: str, content: str) -> dict[str, Any]:
return {
"op": 0,
"id": event_id,
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {"content": content, "group_openid": "G1"},
}
@pytest.mark.asyncio
async def test_webhook_end_to_end():
config = make_config(0)
emitter = EventEmitter()
connecter = WebhookConnecter(config, emitter.queue, WebhookProtocol(config))
received: list[GroupAtMessage] = []
@emitter.on("GROUP_AT_MESSAGE_CREATE")
async def on_msg(msg: GroupAtMessage) -> None:
received.append(msg)
dispatch_task = asyncio.create_task(emitter.dispatch())
server = TestServer(connecter._make_app())
client = TestClient(server)
await client.start_server()
try:
# 1. op=13 验证请求(不走验签),应答 plain_token + 对 event_ts+plain_token 的签名
resp = await client.post(
"/qqbot/webhook",
json={"op": 13, "d": {"plain_token": "pt", "event_ts": "1726000000"}},
)
assert resp.status == 200
ret = await resp.json()
assert ret["plain_token"] == "pt"
assert sign(SECRET, "1726000000", b"pt") == ret["signature"]
# 2. 合法签名的事件推送 → 放行 → handler 收到 dataclass
body = json.dumps(event_body("evt-1", "hello")).encode()
resp = await client.post(
"/qqbot/webhook",
data=body,
headers={
"X-Signature-Timestamp": "1726000001",
"X-Signature-Ed25519": sign(SECRET, "1726000001", body),
},
)
assert resp.status == 200
assert await resp.json() == {"opcode": 12}
for _ in range(50):
if received:
break
await asyncio.sleep(0.02)
assert len(received) == 1
assert received[0].content == "hello"
# 3. 坏签名 → 401,事件不入队
resp = await client.post(
"/qqbot/webhook",
data=body,
headers={
"X-Signature-Timestamp": "1726000002",
"X-Signature-Ed25519": "00" * 64,
},
)
assert resp.status == 401
# 4. 同 id 重复推送 → 去重跳过,handler 不再收到
resp = await client.post(
"/qqbot/webhook",
data=body,
headers={
"X-Signature-Timestamp": "1726000001",
"X-Signature-Ed25519": sign(SECRET, "1726000001", body),
},
)
assert resp.status == 200
await asyncio.sleep(0.1)
assert len(received) == 1
finally:
await client.close()
dispatch_task.cancel()
await asyncio.gather(dispatch_task, return_exceptions=True)