feat: 按钮快捷构造、event_id 语义修正与 TokenError

This commit is contained in:
snowy-yang committed 2026-09-16 21:42:33 +08:00
1 parent cff0c142c2
commit 097bc96d8b
16 files changed
+431 -35

No files matched your search

+66 -1
View File
@@ -1,4 +1,9 @@
from qqbotsdk.api import BotApi
from aiohttp import ClientSession, ClientTimeout, web
from aiohttp.test_utils import TestServer
from qqbotsdk.api import BotApi, button, keyboard
from qqbotsdk.config import Config
from qqbotsdk.token import AccessToken
def make_api() -> tuple[BotApi, dict]:
@@ -163,3 +168,63 @@ async def test_respond_interaction():
method, path, kwargs = cap["call"]
assert (method, path) == ("PUT", "/interactions/I1")
assert kwargs["json"] == {"code": 0}
def test_button_defaults_and_overrides():
btn = button("点我", data="d1")
assert btn["render_data"] == {"label": "点我", "style": 1}
assert btn["action"] == {"type": 1, "permission": {"type": 2}, "data": "d1"}
assert len(btn["id"]) == 8 # 未指定 id 时自动生成短 id
btn2 = button("跳转", data="https://x", type=0, permission=1, style=0, id="b2")
assert btn2["id"] == "b2"
assert btn2["action"]["type"] == 0
assert btn2["action"]["permission"] == {"type": 1}
assert btn2["render_data"]["style"] == 0
def test_keyboard_rows():
b1, b2, b3 = button("a", data="1"), button("b", data="2"), button("c", data="3")
kb = keyboard(b1, [b2, b3])
rows = kb["content"]["rows"]
assert rows[0]["buttons"] == [b1] # 单按钮独占一行
assert rows[1]["buttons"] == [b2, b3] # 列表同行并排
assert len(rows) == 2
async def test_request_tolerates_non_json_content_type():
"""回应互动等接口 200 响应的 Content-Type 是 text/plain(body 为 JSON),
request 不应抛 ContentTypeError(真实 HTTP 栈回归)。"""
async def handler(request):
return web.Response(text="{}", content_type="text/plain")
app = web.Application()
app.router.add_route("PUT", "/interactions/I1", handler)
server = TestServer(app)
await server.start_server()
try:
config = Config(
app_id="id",
app_secret="s",
base_url=f"http://{server.host}:{server.port}",
timeout=ClientTimeout(total=5),
connecter="websocket",
intents_file="intents.toml",
webhook_host="0.0.0.0",
webhook_port=8080,
webhook_path="/qqbot/webhook",
)
class FixedToken(AccessToken):
async def get_access_token(self) -> str:
return "test-token"
http = ClientSession()
try:
api = BotApi(config, http, FixedToken(config, http))
assert await api.respond_interaction("I1") == {}
finally:
await http.close()
finally:
await server.close()
+39
View File
@@ -0,0 +1,39 @@
"""Event 封装属性:message_id 取自事件体 d.id,event_id 取自信封顶层 id。"""
from qqbotsdk.events import Event
def make_event() -> Event:
return Event(
{
"op": 0,
"id": "evt-1", # 信封顶层,与 op/t/d 平级
"t": "GROUP_ADD_ROBOT",
"d": {"group_openid": "G1", "op_member_openid": "M1"},
}
)
def test_event_id_from_envelope():
event = make_event()
assert event.event_id == "evt-1"
assert event.group_id == "G1"
def test_message_id_from_event_body():
event = Event(
{
"op": 0,
"id": "evt-2",
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {"id": "msgid-1", "group_openid": "G1", "content": "hi"},
}
)
assert event.message_id == "msgid-1" # 消息事件回复用 msg_id
assert event.event_id == "evt-2" # 信封顶层 id 另有其值
def test_event_id_defaults_to_none():
event = Event({"op": 0, "t": "READY", "d": {}})
assert event.event_id is None
assert event.message_id is None
+22
View File
@@ -3,6 +3,7 @@ from qqbotsdk.model import Opcode
from qqbotsdk.payloads import (
C2CMessage,
GroupAtMessage,
Interaction,
Ready,
parse_event,
)
@@ -62,6 +63,27 @@ def test_parse_non_dict_d_returns_dict():
assert parse_event("GROUP_AT_MESSAGE_CREATE", d) == 42
def test_interaction_button_data():
inter = parse_event(
"INTERACTION_CREATE",
{
"id": "i1",
"chat_type": 1,
"group_openid": "G1",
"data": {"resolved": {"button_data": "btn|like"}},
},
)
assert isinstance(inter, Interaction)
assert inter.button_data == "btn|like"
def test_interaction_button_data_defaults_to_empty():
inter = Interaction(id="i2", data=None)
assert inter.button_data == ""
inter = Interaction(id="i3", data={"resolved": None})
assert inter.button_data == ""
async def test_typed_annotation_receives_dataclass():
queue = EventQueue()
emitter = EventEmitter(queue)
+64
View File
@@ -0,0 +1,64 @@
"""AccessToken:成功换取与失败时抛 TokenError(带 QQ 的 code/message)。"""
import pytest
from aiohttp import ClientTimeout
from qqbotsdk.config import Config
from qqbotsdk.token import AccessToken, TokenError
def make_token(payload) -> AccessToken:
class FakeResponse:
status = 200
def __init__(self) -> None:
self._payload = payload
async def json(self, **kwargs):
return self._payload
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
class FakeSession:
def post(self, *args, **kwargs):
return FakeResponse()
config = Config(
app_id="id",
app_secret="s",
base_url="https://x",
timeout=ClientTimeout(total=5),
connecter="websocket",
intents_file="intents.toml",
webhook_host="0.0.0.0",
webhook_port=8080,
webhook_path="/qqbot/webhook",
)
return AccessToken(config, FakeSession()) # type: ignore[arg-type]
async def test_success_caches_token():
token = make_token({"access_token": "t1", "expires_in": 7200})
assert await token.get_access_token() == "t1"
# 缓存期内直接复用,不再发请求(FakeSession 也没有第二次响应的意义)
assert await token.get_access_token() == "t1"
async def test_error_payload_raises_token_error():
token = make_token({"code": 11253, "message": "invalid appid or secret"})
with pytest.raises(TokenError) as exc_info:
await token.get_access_token()
assert exc_info.value.code == 11253
assert exc_info.value.message == "invalid appid or secret"
assert "invalid appid or secret" in str(exc_info.value)
async def test_non_dict_payload_raises_token_error():
token = make_token("gateway busy")
with pytest.raises(TokenError) as exc_info:
await token.get_access_token()
assert exc_info.value.message == "gateway busy"