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

+122
View File
@@ -0,0 +1,122 @@
from qqbotsdk.api import BotApi
def make_api() -> tuple[BotApi, dict]:
"""替换掉 request 记录入参,只测业务方法拼参。"""
captured: dict = {}
class Recording(BotApi):
async def request(self, method, path, **kwargs): # type: ignore[override]
captured["call"] = (method, path, kwargs)
return {}
return Recording.__new__(Recording), captured
async def test_post_group_message_passive():
api, cap = make_api()
await api.post_group_message("G1", content="hi", msg_id="m1", msg_seq=2)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/messages")
assert kwargs["json"] == {
"msg_type": 0,
"content": "hi",
"msg_id": "m1",
"msg_seq": 2,
}
async def test_post_c2c_message_active_no_msg_seq():
api, cap = make_api()
await api.post_c2c_message("U1", content="yo")
_, path, kwargs = cap["call"]
assert path == "/v2/users/U1/messages"
assert kwargs["json"] == {"msg_type": 0, "content": "yo"}
async def test_post_message_with_media_sets_msg_type():
api, cap = make_api()
media = {"file_info": "f1"}
await api.post_group_message("G1", content="看图", msg_id="m1", media=media)
body = cap["call"][2]["json"]
assert body["msg_type"] == 7
assert body["media"] == media
async def test_upload_group_file():
api, cap = make_api()
await api.upload_group_file("G1", file_type=1, url="http://x/img.png")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/files")
assert kwargs["json"] == {
"file_type": 1,
"url": "http://x/img.png",
"srv_send_msg": False,
}
async def test_channel_message_and_reaction():
api, cap = make_api()
await api.post_channel_message("C1", content="hi", msg_id="m1", image="http://i")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/channels/C1/messages")
assert kwargs["json"] == {
"msg_seq": 1,
"content": "hi",
"msg_id": "m1",
"image": "http://i",
}
await api.put_channel_reaction("C1", "m1", emoji_type=1, emoji_id="4")
assert cap["call"][:2] == ("PUT", "/channels/C1/messages/m1/reactions/1/4")
await api.delete_channel_reaction("C1", "m1", emoji_type=1, emoji_id="4")
assert cap["call"][:2] == ("DELETE", "/channels/C1/messages/m1/reactions/1/4")
await api.withdraw_channel_message("C1", "m1", hide_tip=True)
method, path, kwargs = cap["call"]
assert (method, path) == ("DELETE", "/channels/C1/messages/m1")
assert kwargs["params"] == {"hidetip": "true"}
async def test_withdraw_v2_messages():
api, cap = make_api()
await api.withdraw_group_message("G1", "ROBOT1.0_x")
assert cap["call"][:2] == ("DELETE", "/v2/groups/G1/messages/ROBOT1.0_x")
await api.withdraw_c2c_message("U1", "ROBOT1.0_y")
assert cap["call"][:2] == ("DELETE", "/v2/users/U1/messages/ROBOT1.0_y")
async def test_event_id_passive_reply():
api, cap = make_api()
await api.post_group_message("G1", content="hi", event_id="e1")
body = cap["call"][2]["json"]
assert body["event_id"] == "e1"
assert "msg_id" not in body
assert body["msg_seq"] == 1
async def test_mute_and_schedule_official_field_names():
api, cap = make_api()
await api.mute_guild_member("GID", "UID", mute_seconds=3600)
assert cap["call"][2]["json"] == {"mute_seconds": "3600"}
await api.create_channel_schedule(
"C1", "活动", "1642076453000", "1642083653000", "C2"
)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/channels/C1/schedules")
info = kwargs["json"]["schedule"]
assert info["remind_type"] == "0"
assert "schedule_info" not in kwargs["json"]
await api.mute_group_member("G1", "2026-01-01T12:00:00+08:00")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/restrict_chat_setting")
assert kwargs["json"] == {"mute_expire_at": "2026-01-01T12:00:00+08:00"}
async def test_me():
api, cap = make_api()
await api.me()
assert cap["call"][:2] == ("GET", "/users/@me")
+43
View File
@@ -0,0 +1,43 @@
from cryptography.hazmat.primitives.asymmetric import ed25519
from qqbotsdk.crypto import build_seed, sign_msg, verify_sig
def test_build_seed_pads_to_32_bytes():
assert len(build_seed("abc")) == 32
assert len(build_seed("x" * 32)) == 32
assert len(build_seed("x" * 40)) == 32
# 同一 secret 展开结果稳定
assert build_seed("abc") == build_seed("abc")
def test_sign_and_verify_roundtrip():
secret = "test-secret"
timestamp = "1726000000"
plain_token = "plain_token"
sig = sign_msg(secret, timestamp + plain_token)
# sign_msg 产出 hex 编码的 Ed25519 签名(64 字节 = 128 hex 字符)
assert len(sig) == 128
assert verify_sig(secret, timestamp, sig, plain_token.encode())
def test_verify_sig_accepts_valid_signature():
secret = "test-secret"
timestamp = "1726000000"
body = b'{"plain_token":"abc"}'
# 签名内容 = timestamp + body
key = ed25519.Ed25519PrivateKey.from_private_bytes(build_seed(secret))
signature = key.sign(timestamp.encode() + body).hex()
assert verify_sig(secret, timestamp, signature, body)
def test_verify_sig_rejects_tampered_body():
secret = "test-secret"
timestamp = "1726000000"
body = b'{"plain_token":"abc"}'
sig = sign_msg(secret, timestamp + body.decode())
assert not verify_sig(secret, timestamp, sig, b'{"plain_token":"xyz"}')
def test_verify_sig_rejects_bad_hex():
assert not verify_sig("secret", "123", "not-hex", b"body")
+47
View File
@@ -0,0 +1,47 @@
from qqbotsdk.config import Config
from qqbotsdk.connecter import WebhookConnecter
from qqbotsdk.emitter import EventEmitter
from qqbotsdk.queue import EventQueue
def make_connecter() -> WebhookConnecter:
config = Config(
app_id="app",
app_secret="secret",
base_url="https://api.bot.qq.com",
timeout=None, # type: ignore[arg-type]
connecter="webhook",
intents_file="intents.toml",
webhook_host="0.0.0.0",
webhook_port=8080,
webhook_path="/qqbot/webhook",
)
return WebhookConnecter(config, EventEmitter(EventQueue()), EventQueue())
def test_first_push_is_not_duplicate():
c = make_connecter()
assert not c._duplicated("evt-1")
def test_same_id_within_ttl_is_duplicate():
c = make_connecter()
assert not c._duplicated("evt-1")
assert c._duplicated("evt-1")
def test_none_or_empty_id_never_duplicates():
c = make_connecter()
assert not c._duplicated(None)
assert not c._duplicated("")
assert not c._duplicated(None)
def test_expired_entries_are_evicted():
c = make_connecter()
assert not c._duplicated("evt-1")
# 人为把记录改成过期
for key in list(c._seen):
c._seen[key] -= c._DEDUP_TTL + 1
assert not c._duplicated("evt-1")
assert "evt-1" in c._seen # 以新时间戳重新记录
+55
View File
@@ -0,0 +1,55 @@
import pytest
from qqbotsdk.emitter import EventEmitter
from qqbotsdk.model import Opcode, payload_of
from qqbotsdk.queue import EventQueue
from qqbotsdk.session import Session
def test_sequence_only_moves_forward():
s = Session()
assert s.sequence_id is None
s.update_sequence(5)
s.update_sequence(3) # 回退被忽略
assert s.sequence_id == 5
s.update_sequence(7)
assert s.sequence_id == 7
assert s.seq == 7
def test_seq_defaults_to_zero():
s = Session()
assert s.seq == 0
@pytest.mark.asyncio
async def test_handler_exception_is_isolated():
queue = EventQueue()
emitter = EventEmitter(queue)
called: list[str] = []
@emitter.on("READY")
async def bad(_):
raise RuntimeError("boom")
@emitter.on("READY")
async def good(_):
called.append("ok")
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
assert called == ["ok"]
@pytest.mark.asyncio
async def test_reply_payload_is_enqueued():
queue = EventQueue()
emitter = EventEmitter(queue)
@emitter.on(Opcode.HEARTBEAT)
async def beat(_):
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
+60
View File
@@ -0,0 +1,60 @@
import pytest
from qqbotsdk.config import Config
from qqbotsdk.ws_protocol import get_intents
@pytest.fixture(autouse=True)
def env(monkeypatch):
monkeypatch.setenv("APPID", "123")
monkeypatch.setenv("APPSECRET", "s")
monkeypatch.delenv("CONNECTER", raising=False)
monkeypatch.delenv("BASE_URL", raising=False)
def write(tmp_path, content: str) -> str:
path = tmp_path / "intents.toml"
path.write_text(content, encoding="utf-8")
return str(path)
def test_missing_file_returns_zero(tmp_path):
assert get_intents(str(tmp_path / "nope.toml")) == 0
def test_enabled_group_sets_bit(tmp_path):
path = write(tmp_path, "[GROUP_AND_C2C_EVENT]\nC2C_MESSAGE_CREATE = true\n")
assert get_intents(path) & (1 << 25)
def test_all_disabled_returns_zero(tmp_path):
path = write(tmp_path, "[GUILDS]\nGUILD_CREATE = false\n")
assert get_intents(path) == 0
def test_unknown_group_is_skipped(tmp_path):
path = write(
tmp_path, "[NOT_A_GROUP]\nFOO = true\n\n[GUILDS]\nGUILD_CREATE = true\n"
)
assert get_intents(path) == (1 << 0)
def test_config_load_defaults():
config = Config.load()
assert config.app_id == "123"
assert config.app_secret == "s"
assert config.connecter == "websocket"
assert config.base_url == "https://api.bot.qq.com"
assert config.webhook_port == 8080
def test_config_requires_appid(monkeypatch):
monkeypatch.delenv("APPID", raising=False)
with pytest.raises(ValueError, match="APPID"):
Config.load()
def test_config_rejects_unknown_connecter(monkeypatch):
monkeypatch.setenv("CONNECTER", "carrier-pigeon")
with pytest.raises(ValueError, match="carrier-pigeon"):
Config.load()
+98
View File
@@ -0,0 +1,98 @@
from qqbotsdk.emitter import EventEmitter
from qqbotsdk.model import Opcode
from qqbotsdk.payloads import (
C2CMessage,
GroupAtMessage,
Ready,
parse_event,
)
from qqbotsdk.queue import EventQueue
def test_parse_group_at_message():
msg = parse_event(
"GROUP_AT_MESSAGE_CREATE",
{
"author": {"id": None, "member_openid": "M1"},
"content": "hi",
"group_openid": "G1",
"id": "msgid-1",
"timestamp": "2026-01-01",
},
)
assert isinstance(msg, GroupAtMessage)
assert msg.group_openid == "G1"
assert msg.user_openid == "M1"
assert msg.id == "msgid-1"
assert msg.author is not None and msg.author.member_openid == "M1"
def test_parse_c2c_message():
msg = parse_event(
"C2C_MESSAGE_CREATE",
{"author": {"user_openid": "U1"}, "content": "hello", "id": "m2"},
)
assert isinstance(msg, C2CMessage)
assert msg.user_openid == "U1"
def test_parse_ready():
ready = parse_event(
"READY", {"session_id": "s1", "version": 1, "user": {"id": "b"}}
)
assert isinstance(ready, Ready)
assert ready.session_id == "s1"
def test_parse_unknown_type_returns_dict():
d = {"whatever": 1}
assert parse_event("SOME_FUTURE_EVENT", d) is d
assert parse_event(None, d) is d
def test_parse_ignores_extra_and_missing_fields():
msg = parse_event("GROUP_AT_MESSAGE_CREATE", {"content": "x", "future_field": 9})
assert isinstance(msg, GroupAtMessage)
assert msg.content == "x"
assert msg.group_openid is None
def test_parse_non_dict_d_returns_dict():
d = 42 # 心跳等 opcode 的 d 是数字
assert parse_event("GROUP_AT_MESSAGE_CREATE", d) == 42
async def test_typed_annotation_receives_dataclass():
queue = EventQueue()
emitter = EventEmitter(queue)
received: list[GroupAtMessage] = []
@emitter.on("GROUP_AT_MESSAGE_CREATE")
async def handler(msg: GroupAtMessage):
received.append(msg)
await emitter.emit(
{
"op": Opcode.DISPATCH,
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {"content": "hi", "group_openid": "G1"},
}
)
assert len(received) == 1
assert received[0].content == "hi"
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"}]
+37
View File
@@ -0,0 +1,37 @@
import pytest
from qqbotsdk.emitter import EventEmitter
from qqbotsdk.model import Opcode
from qqbotsdk.queue import EventQueue
@pytest.fixture
def emitter() -> EventEmitter:
return EventEmitter(EventQueue())
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 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"
def test_route_rejects_missing_op(emitter: EventEmitter):
assert emitter._route({"d": {}}) is None
def test_route_rejects_unknown_opcode(emitter: EventEmitter):
assert emitter._route({"op": 999}) is None
def test_route_rejects_dispatch_without_t(emitter: EventEmitter):
assert emitter._route({"op": Opcode.DISPATCH, "d": {}}) is None
+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)
+155
View File
@@ -0,0 +1,155 @@
"""WebSocket 接入的端到端集成测试:起一个假网关(token 接口 + gateway + ws),
真实跑通 HELLO → IDENTIFY → READY → 心跳 ACK → 业务事件分发的完整时序。"""
import asyncio
import json
from typing import Any
import pytest
from aiohttp import ClientSession, WSMsgType, web
from qqbotsdk.config import Config
from qqbotsdk.connecter import WebsocketConnecter
from qqbotsdk.emitter import EventEmitter
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
class FakeGateway:
"""模拟 QQ 开放平台的鉴权、gateway 与 ws 协议行为。"""
def __init__(self) -> None:
self.identifies: list[dict] = []
self.heartbeats = 0
self.runner: web.AppRunner | None = None
self.port = 0
# 测试用同步点
self.ready_sent = asyncio.Event()
self.got_dispatch = asyncio.Event()
async def start(self) -> None:
app = web.Application()
app.router.add_post("/app/getAppAccessToken", self._token)
app.router.add_get("/gateway", self._gateway)
app.router.add_get("/ws", self._ws)
self.runner = web.AppRunner(app)
await self.runner.setup()
site = web.TCPSite(self.runner, "127.0.0.1", 0)
await site.start()
assert site._server is not None
self.port = site._server.sockets[0].getsockname()[1]
async def stop(self) -> None:
assert self.runner is not None
await self.runner.cleanup()
async def _token(self, request: web.Request) -> web.Response:
return web.json_response({"access_token": "test-token", "expires_in": 9999999})
async def _gateway(self, request: web.Request) -> web.Response:
return web.json_response({"url": f"http://127.0.0.1:{self.port}/ws"})
async def _ws(self, request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse()
await ws.prepare(request)
await ws.send_json({"op": 10, "d": {"heartbeat_interval": 300}})
s = 0
async for msg in ws:
if msg.type != WSMsgType.TEXT:
break
payload: dict[str, Any] = json.loads(msg.data)
op = payload.get("op")
if op == 2: # IDENTIFY
self.identifies.append(payload)
s += 1
await ws.send_json(
{
"op": 0,
"s": s,
"t": "READY",
"d": {"session_id": "S1", "user": {"id": "bot"}},
}
)
self.ready_sent.set()
# READY 之后立刻推一条业务事件
s += 1
await ws.send_json(
{
"op": 0,
"s": s,
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {"content": "ping", "group_openid": "G1"},
}
)
elif op == 1: # HEARTBEAT
self.heartbeats += 1
await ws.send_json({"op": 11})
return ws
@pytest.mark.asyncio
async def test_websocket_end_to_end():
gw = FakeGateway()
await gw.start()
try:
config = Config(
app_id="app",
app_secret="secret",
base_url=f"http://127.0.0.1:{gw.port}",
timeout=None, # type: ignore[arg-type]
connecter="websocket",
intents_file="no-such-intents.toml", # 测试环境无配置,intents=0
webhook_host="127.0.0.1",
webhook_port=0,
webhook_path="/x",
)
http = ClientSession(timeout=config.timeout)
token = AccessToken(config, http)
queue = EventQueue()
session = Session()
emitter = EventEmitter(queue)
protocol = WebsocketProtocol(config, queue, session, token)
emitter.register_protocol(protocol)
connecter = WebsocketConnecter(config, http, token, queue)
received: list[GroupAtMessage] = []
@emitter.on("GROUP_AT_MESSAGE_CREATE")
async def on_msg(msg: GroupAtMessage) -> None:
received.append(msg)
gw.got_dispatch.set()
dispatch_task = asyncio.create_task(emitter.dispatch())
connect_task = asyncio.create_task(connecter.run())
try:
await asyncio.wait_for(gw.ready_sent.wait(), timeout=10)
await asyncio.wait_for(gw.got_dispatch.wait(), timeout=10)
await asyncio.sleep(0.5) # 留出至少一个心跳周期(300ms)
# READY 已被协议层捕获,session_id 用于后续 RESUME
assert session.session_id == "S1"
# 心跳定时器真实运行,服务端收到了 HEARTBEAT
assert gw.heartbeats >= 1
# IDENTIFY 形态正确(token 前缀 / shard / intents)
identify = gw.identifies[0]
assert identify["op"] == 2
assert identify["d"]["token"] == "QQBot test-token"
assert identify["d"]["intents"] == 0
assert identify["d"]["shard"] == [0, 1]
# 业务事件以 dataclass 形式分发到 handler
assert len(received) == 1
assert received[0].content == "ping"
assert received[0].group_openid == "G1"
finally:
dispatch_task.cancel()
connect_task.cancel()
await asyncio.gather(dispatch_task, connect_task, return_exceptions=True)
await http.close()
finally:
await gw.stop()