qqbotsdk: QQ 官方机器人 SDK(协议层/DI/业务 API/测试/文档)

This commit is contained in:
snowy-yang committed 2026-09-11 19:49:17 +08:00
commit 0c364bf803
32 files changed
+3760

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
"""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.queue import EventQueue
from qqbotsdk.session import Session
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)
queue = EventQueue()
emitter = EventEmitter(queue)
emitter.register_protocol(WebhookProtocol(config, queue, Session()))
connecter = WebhookConnecter(config, emitter, queue)
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)