mirror of
https://github.com/snowy-yang/qqbotsdk.git
synced 2026-10-21 12:24:49 +00:00
qqbotsdk: QQ 官方机器人 SDK(协议层/DI/业务 API/测试/文档)
This commit is contained in:
commit
0c364bf803
32 files changed
+3760
No files matched your search
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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 # 以新时间戳重新记录
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user