feat: OpenAPI 全量对齐——对照新旧两版官方文档补齐全部接口与事件

- 新增 panel 域:指令面板(斜线指令)增删改查/关联对象 + /v2/menu 全局自定义菜单
- 新增 group 域:群信息/成员/机器人群内状态、入群申请列表与审批、群黑名单、
  群禁言状态、入群自动审批策略(增删改查/执行/白名单)
- v2 补充:流式单聊消息、群/单聊大文件分片上传(预上传+分片确认)、分享链接
- guild 补充:子频道增删改、机器人频道列表、身份组 CRUD 与成员授予/收回、
  成员列表/踢出、全员与批量禁言、接口权限、消息频率设置
- channel 补充:子频道用户/身份组权限、频道私信(建会话/发信/撤回)
- 新增 audio 域(播放控制、上/下麦)与 forum 域(帖子增删查)
- 事件补齐 GROUP_MEMBER_ADD/REMOVE、GROUP_JOIN_REQUEST、SUBSCRIBE_MESSAGE_STATUS;
  Intent 与 intents.toml 补 GROUP_MEMBER_EVENT (1<<24) 分组(默认 false)
This commit is contained in:
snowy-yang committed 2026-09-23 17:46:37 +08:00
1 parent 13a1d971be
commit 15e1f5e303
15 files changed
+1677 -16

No files matched your search

+422
View File
@@ -172,6 +172,294 @@ async def test_respond_interaction():
assert kwargs["json"] == {"code": 0}
async def test_panel_create_list_detail():
api, cap = make_api()
panel = {"items": [{"type": "command", "name": "群签到", "desc": "每日签到"}]}
await api.create_panel(
"group", panel, target_type="specific", group_openids=["G1", "G2"]
)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/panels")
assert kwargs["json"] == {
"scope": "group",
"panel": panel,
"target_type": "specific",
"group_openids": ["G1", "G2"],
}
await api.get_panels("c2c", cursor="c1", limit=10)
method, path, kwargs = cap["call"]
assert (method, path) == ("GET", "/v2/panels")
assert kwargs["params"] == {"scope": "c2c", "cursor": "c1", "limit": 10}
await api.get_panel("P1")
assert cap["call"][:2] == ("GET", "/v2/panels/P1")
async def test_panel_update_delete_targets():
api, cap = make_api()
panel = {"items": [{"type": "command", "name": "新指令", "desc": "更新"}]}
await api.update_panel("P1", panel)
method, path, kwargs = cap["call"]
assert (method, path) == ("PUT", "/v2/panels/P1")
assert kwargs["json"] == {"panel": panel}
await api.delete_panel("P1")
assert cap["call"][:2] == ("DELETE", "/v2/panels/P1")
await api.update_panel_targets("P1", "add", group_openids=["G3"])
method, path, kwargs = cap["call"]
assert (method, path) == ("PUT", "/v2/panels/P1/target")
assert kwargs["json"] == {"op": "add", "group_openids": ["G3"]}
await api.update_panel_targets("P1", "del", user_openids=["U1"])
assert cap["call"][2]["json"] == {"op": "del", "user_openids": ["U1"]}
async def test_menu_get_and_update():
api, cap = make_api()
await api.get_menu()
assert cap["call"][:2] == ("GET", "/v2/menu")
menu = {
"items": [
{"type": "send_message", "name": "帮助", "send_message": "/help"},
{"type": "link", "name": "官网", "link": "https://example.com"},
]
}
await api.update_menu(menu)
method, path, kwargs = cap["call"]
assert (method, path) == ("PUT", "/v2/menu")
assert kwargs["json"] == {"menu": menu}
# ---------- v2 补充:流式消息 / 分片上传 / 分享链接 ----------
async def test_stream_message_first_and_next_chunk():
api, cap = make_api()
await api.post_c2c_stream_message("U1", "回答中", msg_id="m1", msg_seq=1)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/users/U1/stream_messages")
assert kwargs["json"] == {
"index": 0,
"input_state": 1,
"content_type": "markdown",
"content_raw": "回答中",
"msg_id": "m1",
"msg_seq": 1,
}
await api.post_c2c_stream_message(
"U1",
"回答中,更多内容",
stream_msg_id="s1",
index=1,
input_state=10,
input_mode="replace",
event_id="e1",
)
body = cap["call"][2]["json"]
assert body["stream_msg_id"] == "s1"
assert body["index"] == 1
assert body["input_state"] == 10
assert body["input_mode"] == "replace"
assert body["event_id"] == "e1"
assert "msg_id" not in body
async def test_chunked_upload_group_and_c2c():
api, cap = make_api()
common = {
"file_type": 2,
"file_size": 31457280,
"file_name": "demo.mp4",
"md5": "m",
"sha1": "s",
"md5_10m": "m10",
}
await api.prepare_group_upload("G1", **common)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/upload_prepare")
assert kwargs["json"] == {
"file_type": 2,
"file_size": "31457280", # 官方为字符串类型
"file_name": "demo.mp4",
"md5": "m",
"sha1": "s",
"md5_10m": "m10",
}
await api.finish_group_upload_part("G1", "up1", part_index=0)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/upload_part_finish")
assert kwargs["json"] == {"upload_id": "up1", "part_index": 0}
await api.prepare_c2c_upload("U1", **common)
assert cap["call"][:2] == ("POST", "/v2/users/U1/upload_prepare")
await api.finish_c2c_upload_part("U1", "up1", part_index=1, block_size=4096, md5="p1")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/users/U1/upload_part_finish")
assert kwargs["json"] == {
"upload_id": "up1",
"part_index": 1,
"block_size": "4096",
"md5": "p1",
}
async def test_generate_url_link():
api, cap = make_api()
await api.generate_url_link()
assert cap["call"][2]["json"] == {}
await api.generate_url_link("track_01")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/generate_url_link")
assert kwargs["json"] == {"callback_data": "track_01"}
# ---------- 频道补充:子频道增删改 / 机器人频道列表 ----------
async def test_channel_manage_and_me_guilds():
api, cap = make_api()
await api.create_channel("GID", "公告区", type=0, sub_type=1, position=3)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/guilds/GID/channels")
assert kwargs["json"] == {"name": "公告区", "type": 0, "sub_type": 1, "position": 3}
await api.update_channel("C1", name="新名字", position=5)
method, path, kwargs = cap["call"]
assert (method, path) == ("PATCH", "/channels/C1")
assert kwargs["json"] == {"name": "新名字", "position": 5}
await api.delete_channel("C1")
assert cap["call"][:2] == ("DELETE", "/channels/C1")
await api.get_me_guilds(after="G9", limit=20)
method, path, kwargs = cap["call"]
assert (method, path) == ("GET", "/users/@me/guilds")
assert kwargs["params"] == {"after": "G9", "limit": 20}
await api.get_me_guilds()
assert cap["call"][2].get("params") is None
# ---------- 群管理 ----------
async def test_group_info_members_state():
api, cap = make_api()
await api.get_group_info("G1")
assert cap["call"][:2] == ("GET", "/v2/groups/G1/info")
await api.get_group_members("G1", cursor="c1")
method, path, kwargs = cap["call"]
assert (method, path) == ("GET", "/v2/groups/G1/members")
assert kwargs["params"] == {"cursor": "c1"}
await api.get_group_member("G1", "M1")
assert cap["call"][:2] == ("GET", "/v2/groups/G1/members/M1")
await api.get_group_bot_state("G1")
assert cap["call"][:2] == ("GET", "/v2/groups/G1/bot_state")
await api.get_group_mute_state("G1")
assert cap["call"][:2] == ("GET", "/v2/groups/G1/restrict_chat_setting")
async def test_group_join_request_flow():
api, cap = make_api()
await api.get_group_join_requests("G1", limit=10)
method, path, kwargs = cap["call"]
assert (method, path) == ("GET", "/v2/groups/G1/join_request_list")
assert kwargs["params"] == {"limit": 10}
await api.review_group_join_request(
"G1", "M1", "approve", join_request_id="j1"
)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/approval_join_request/M1")
assert kwargs["json"] == {"op": "approve", "join_request_id": "j1"}
await api.review_group_join_request(
"G1", "M1", "decline", join_request_id="j1",
reject_reason="广告", add_to_member_blacklist=True,
)
body = cap["call"][2]["json"]
assert body == {
"op": "decline",
"join_request_id": "j1",
"reject_reason": "广告",
"add_to_member_blacklist": True,
}
async def test_group_blacklist_and_remove():
api, cap = make_api()
await api.get_group_blacklist("G1", cursor="c1", limit=50)
method, path, kwargs = cap["call"]
assert (method, path) == ("GET", "/v2/groups/G1/member_blacklist")
assert kwargs["params"] == {"cursor": "c1", "limit": 50}
await api.update_group_blacklist("G1", "add", ["M1", "M2"])
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/member_blacklist")
assert kwargs["json"] == {"op": "add", "member_openids": ["M1", "M2"]}
await api.remove_group_members("G1", ["M1"], add_to_member_blacklist=True)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/batch_remove_members")
assert kwargs["json"] == {
"member_openids": ["M1"],
"add_to_member_blacklist": True,
}
async def test_join_approval_strategies():
api, cap = make_api()
await api.get_join_approval_strategies(cursor="c1", limit=20)
method, path, kwargs = cap["call"]
assert (method, path) == ("GET", "/v2/groups/join_approval_strategy")
assert kwargs["params"] == {"cursor": "c1", "limit": 20}
await api.create_join_approval_strategy(
group_openids=["G1"], expire_at="2027-08-05T15:30:16+08:00"
)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/join_approval_strategy")
assert kwargs["json"] == {
"group_openids": ["G1"],
"expire_at": "2027-08-05T15:30:16+08:00",
}
await api.update_join_approval_strategy(
"st1", is_enable="off", group_action={"op": "add", "group_openids": ["G2"]}
)
method, path, kwargs = cap["call"]
assert (method, path) == ("PATCH", "/v2/groups/join_approval_strategy/st1")
assert kwargs["json"] == {
"is_enable": "off",
"group_action": {"op": "add", "group_openids": ["G2"]},
}
await api.delete_join_approval_strategy("st1")
assert cap["call"][:2] == ("DELETE", "/v2/groups/join_approval_strategy/st1")
await api.execute_join_approval_strategy("st1")
assert cap["call"][:2] == ("POST", "/v2/groups/join_approval_strategy/st1/execute")
await api.update_join_approval_strategy_whitelist("st1", "add", ["1234567"])
method, path, kwargs = cap["call"]
assert (method, path) == (
"POST",
"/v2/groups/join_approval_strategy/st1/whitelist_users",
)
assert kwargs["json"] == {"op": "add", "whitelist_users": ["1234567"]}
def test_button_defaults_and_overrides():
btn = button("点我", data="d1")
assert btn["render_data"] == {"label": "点我", "style": 1}
@@ -369,3 +657,137 @@ async def test_request_401_invalidates_token_and_retries_once():
assert token.invalidated == 1
assert token.fetches == 2
assert len(http.calls) == 2
# ---------- 旧版(频道侧)接口补齐 ----------
async def test_guild_roles_crud_and_member_role():
api, cap = make_api()
await api.create_guild_role("GID", "码农", color=0xFF0000, hoist=1)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/guilds/GID/roles")
assert kwargs["json"] == {"name": "码农", "color": 16711680, "hoist": 1}
await api.update_guild_role("GID", "R1", name="新名")
method, path, kwargs = cap["call"]
assert (method, path) == ("PATCH", "/guilds/GID/roles/R1")
assert kwargs["json"] == {"name": "新名"}
await api.delete_guild_role("GID", "R1")
assert cap["call"][:2] == ("DELETE", "/guilds/GID/roles/R1")
await api.add_guild_member_role("GID", "U1", "R1")
assert cap["call"][:2] == ("PUT", "/guilds/GID/members/U1/roles/R1")
await api.remove_guild_member_role("GID", "U1", "R1")
assert cap["call"][:2] == ("DELETE", "/guilds/GID/members/U1/roles/R1")
async def test_guild_members_mutes_and_api_permission():
api, cap = make_api()
await api.get_guild_members("GID")
assert cap["call"][:2] == ("GET", "/guilds/GID/members")
await api.remove_guild_member("GID", "U1", add_blacklist=True, delete_history_msg_days=7)
method, path, kwargs = cap["call"]
assert (method, path) == ("DELETE", "/guilds/GID/members/U1")
assert kwargs["json"] == {"add_blacklist": True, "delete_history_msg_days": 7}
await api.get_role_members("GID", "R1")
assert cap["call"][:2] == ("GET", "/guilds/GID/roles/R1/members")
await api.get_channel_online_nums("C1")
assert cap["call"][:2] == ("GET", "/channels/C1/online_nums")
await api.mute_guild("GID", mute_seconds="60")
method, path, kwargs = cap["call"]
assert (method, path) == ("PATCH", "/guilds/GID/mute")
assert kwargs["json"] == {"mute_seconds": "60"}
await api.mute_guild_members("GID", ["U1", "U2"], mute_end_timestamp="1700000000")
assert cap["call"][2]["json"] == {
"user_ids": ["U1", "U2"],
"mute_end_timestamp": "1700000000",
}
await api.get_guild_message_setting("GID")
assert cap["call"][:2] == ("GET", "/guilds/GID/message/setting")
await api.get_guild_api_permissions("GID")
assert cap["call"][:2] == ("GET", "/guilds/GID/api_permission")
await api.create_api_permission_demand("GID", "C1", "/channels/x/messages", "POST", "d")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/guilds/GID/api_permission/demand")
assert kwargs["json"] == {
"channel_id": "C1",
"api_identify": {"path": "/channels/x/messages", "method": "POST"},
"desc": "d",
}
async def test_channel_permissions_and_dms():
api, cap = make_api()
await api.get_channel_permissions("C1", "U1")
assert cap["call"][:2] == ("GET", "/channels/C1/members/U1/permissions")
await api.get_channel_role_permissions("C1", "R1")
assert cap["call"][:2] == ("GET", "/channels/C1/roles/R1/permissions")
await api.update_channel_permissions("C1", "U1", add="1", remove="4")
method, path, kwargs = cap["call"]
assert (method, path) == ("PUT", "/channels/C1/members/U1/permissions")
assert kwargs["json"] == {"add": "1", "remove": "4"}
await api.update_channel_role_permissions("C1", "R1", add="2")
assert cap["call"][2]["json"] == {"add": "2"}
await api.create_dms("U1", "GID")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/users/@me/dms")
assert kwargs["json"] == {"recipient_id": "U1", "source_guild_id": "GID"}
await api.post_dms_message("DG", content="hi", msg_id="m1", msg_seq=2)
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/dms/DG/messages")
assert kwargs["json"] == {"msg_seq": 2, "content": "hi", "msg_id": "m1"}
await api.withdraw_dms_message("DG", "m1", hide_tip=True)
method, path, kwargs = cap["call"]
assert (method, path) == ("DELETE", "/dms/DG/messages/m1")
assert kwargs["params"] == {"hidetip": "true"}
async def test_audio_and_forum():
api, cap = make_api()
await api.control_channel_audio("C1", audio_url="http://x/a.mp3", text="歌名")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/channels/C1/audio")
assert kwargs["json"] == {
"status": 0,
"audio_url": "http://x/a.mp3",
"text": "歌名",
}
await api.control_channel_audio("C1", status=3)
assert cap["call"][2]["json"] == {"status": 3}
await api.put_channel_mic("C1")
assert cap["call"][:2] == ("PUT", "/channels/C1/mic")
await api.delete_channel_mic("C1")
assert cap["call"][:2] == ("DELETE", "/channels/C1/mic")
await api.get_channel_threads("C1")
assert cap["call"][:2] == ("GET", "/channels/C1/threads")
await api.get_channel_thread("C1", "T1")
assert cap["call"][:2] == ("GET", "/channels/C1/threads/T1")
await api.put_channel_thread("C1", "标题", "内容", format=3)
method, path, kwargs = cap["call"]
assert (method, path) == ("PUT", "/channels/C1/threads")
assert kwargs["json"] == {"title": "标题", "content": "内容", "format": 3}
await api.delete_channel_thread("C1", "T1")
assert cap["call"][:2] == ("DELETE", "/channels/C1/threads/T1")
+56
View File
@@ -192,3 +192,59 @@ async def test_untyped_annotation_still_receives_raw_dict():
{"op": Opcode.DISPATCH, "t": "GROUP_AT_MESSAGE_CREATE", "d": {"content": "x"}}
)
assert received == [{"content": "x"}]
def test_parse_group_member_and_join_request_events():
from qqbotsdk.payloads import (
GroupJoinRequest,
GroupMemberChange,
SubscribeMessageStatus,
)
change = parse_event(
"GROUP_MEMBER_ADD",
{
"timestamp": 1784276757,
"group_openid": "G1",
"member_openid": "M1",
"user_openid": "U1",
},
)
assert isinstance(change, GroupMemberChange)
assert change.member_openid == "M1"
# 两事件 d 结构一致,共用 dataclass
remove = parse_event(
"GROUP_MEMBER_REMOVE",
{"timestamp": 1, "group_openid": "G1", "member_openid": "M2"},
)
assert isinstance(remove, GroupMemberChange)
assert remove.user_openid is None
req = parse_event(
"GROUP_JOIN_REQUEST",
{
"group_openid": "G1",
"join_request_id": "j1",
"member_openid": "M1",
"username": "小明",
"apply_source": "self_apply",
"verify_info": {"method": "verify_message", "verify_message": "来了"},
"auto_approved": {"strategy_id": "st1"},
"unknown_field": 1,
},
)
assert isinstance(req, GroupJoinRequest)
assert req.join_request_id == "j1"
assert req.verify_info == {"method": "verify_message", "verify_message": "来了"}
assert req.auto_approved == {"strategy_id": "st1"}
status = parse_event(
"SUBSCRIBE_MESSAGE_STATUS",
{
"openid": "U1",
"result": [{"template_id": 10001, "op": 1, "subscribe_id": "sub1"}],
},
)
assert isinstance(status, SubscribeMessageStatus)
assert status.group_openid is None
assert status.result[0]["subscribe_id"] == "sub1"