新增斜杠指令面板:/v2/panels 自动注册,帮助消息带指令按钮

- panel.py 调开放平台 2026-08 新增的 /v2/panels 接口,把 点歌/歌词/帮助
  三条指令同步到输入框 / 菜单:READY 时幂等同步(没有则创建、内容有变
  则更新、一致则跳过,重连自愈),target_type=all 对全部群生效;失败
  只记日志不影响运行;COMMAND_PANEL=off 可关闭
- 斜杠指令与 @机器人 消息归一解析:_normalize 剥开头 / 与 @ 前缀,
  与裸文本命令共用 handle_command(/点歌 稻香、@bot 点歌 稻香、
  点歌 稻香 三种写法等价)
- 只敲命令不带参数(斜杠指令参数可为空)时回复用法提示
- 帮助消息带 [🎤 点歌][📝 歌词] 指令按钮(action type=2,点击把
  /点歌 填进输入框),键盘无权限自动回退纯文本;帮助文案改以 / 指令呈现
This commit is contained in:
snowy-yang committed 2026-09-24 21:55:32 +08:00
1 parent d71809d144
commit 94ee3298b7
5 files changed
+303 -9

No files matched your search

+123 -1
View File
@@ -21,6 +21,7 @@ from musicbot.music import (
search,
send_menu,
)
from musicbot.panel import sync_command_panel
def make_api(
@@ -29,12 +30,14 @@ def make_api(
fail_event: bool = False,
fail_upload: bool = False,
fail_file_perm: bool = False,
fail_keyboard: bool = False,
):
"""替换 upload_group_file / Markdown 消息以模拟失败,request 记录其余调用。
fail_event=True 时拒绝一切带 event_id 的被动回复(模拟互动回执失效);
fail_upload=True 时上传一律抛 TimeoutError(模拟 QQ 侧抓流超过请求超时);
fail_file_perm=True 时 file_type=4(文件)返回 40093005(仅群主/管理员可传)。
fail_file_perm=True 时 file_type=4(文件)返回 40093005(仅群主/管理员可传);
fail_keyboard=True 时拒绝一切带 keyboard 的消息(模拟键盘无权限)。
"""
calls: list[dict] = []
@@ -43,6 +46,8 @@ def make_api(
body = kwargs.get("json", {})
if fail_md and body.get("msg_type") == 2:
raise ApiError(400, 304036, "无 Markdown 权限")
if fail_keyboard and "keyboard" in body:
raise ApiError(400, 304003, "无键盘权限")
if fail_event and "event_id" in body:
raise ApiError(400, 40034025, "请求参数event_id无效")
if path.endswith("/files"):
@@ -568,6 +573,55 @@ async def test_page_button_new_menu(env):
assert calls == [] # 非音乐按钮不处理也不回应
async def test_slash_commands_share_text_parsing(env):
"""斜杠指令与裸文本命令同一套解析:剥掉开头的 / 与 @机器人 前缀。"""
http, config = env
for content in ("点歌 稻香", "/点歌 稻香", "<@!BOT123> /点歌 稻香"):
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", content)
body = posts(calls)[0]["json"]
assert body["msg_type"] == 2
assert "点歌:稻香" in body["markdown"]["content"]
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "/歌词 稻香")
assert posts(calls)[0]["json"]["content"] == "第一行\n第二行"
async def test_slash_help_sends_command_keyboard(env):
"""/帮助 出帮助正文 + type=2 指令按钮(点一下往输入框填斜杠指令)。"""
http, config = env
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "/帮助")
body = posts(calls)[0]["json"]
assert "点歌" in body["content"] and "歌词" in body["content"]
rows = buttons(body)
assert len(rows) == 1
song_btn, lyric_btn = rows[0]["buttons"]
assert song_btn["action"]["type"] == 2
assert song_btn["action"]["data"] == "/点歌 "
assert lyric_btn["action"]["data"] == "/歌词 "
async def test_slash_help_falls_back_to_plain_text(env):
http, config = env
api, calls = make_api(fail_keyboard=True)
assert await handle_command(api, http, config, "G1", "m1", "/帮助")
sent = posts(calls)
assert len(sent) == 1 # 键盘被拒,回退为一条纯文本
assert "keyboard" not in sent[0]["json"]
assert "点歌" in sent[0]["json"]["content"]
async def test_bare_command_replies_usage(env):
"""斜杠菜单的参数可选时可能不带参发出来:只敲命令给用法提示。"""
http, config = env
for content, hint in (("点歌", "用法:点歌"), ("/歌词", "用法:歌词")):
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", content)
assert posts(calls)[0]["json"]["content"].startswith(hint)
async def test_lyric_miss_help_and_no_hit(env):
http, config = env
api, calls = make_api()
@@ -604,6 +658,74 @@ def test_trim_strips_tags_and_truncates():
assert len(trimmed) <= 100 + len("\n……(歌词过长已截断)")
class PanelRecording(BotApi):
"""预设 /v2/panels 响应并记录调用:fail_get=True 时查询抛 403。"""
def __init__(self, records: list | None = None, fail_get: bool = False) -> None:
self.records = records or []
self.fail_get = fail_get
self.calls: list[dict] = []
async def request(self, method: str, path: str, **kwargs: Any) -> Any:
if self.fail_get and path == "/v2/panels":
raise ApiError(403, 11253, "无权限")
self.calls.append({"method": method, "path": path, **kwargs})
if method == "GET":
# 响应整体包一层 {"data": ...},顺带覆盖 _unwrap 的拆包路径
return {"data": {"records": self.records, "is_end": True}}
if method == "POST":
return {"panel_id": "P1"}
return {"version": 2}
async def test_panel_created_when_missing():
api = PanelRecording()
await sync_command_panel(api)
assert [(c["method"], c["path"]) for c in api.calls] == [
("GET", "/v2/panels"),
("POST", "/v2/panels"),
]
body = api.calls[1]["json"]
assert body["scope"] == "group" and body["target_type"] == "all"
assert [i["name"] for i in body["panel"]["items"]] == ["点歌", "歌词", "帮助"]
async def test_panel_skipped_when_identical():
items = [
{"name": "点歌", "type": "command", "desc": "出选歌菜单,点编号选歌"},
{"name": "歌词", "type": "command", "desc": "查看歌词"},
{"name": "帮助", "type": "command", "desc": "查看帮助"},
]
api = PanelRecording(
records=[{"panel_id": "P9", "panel": {"items": items, "remark": "音乐bot"}}]
)
await sync_command_panel(api)
assert [(c["method"], c["path"]) for c in api.calls] == [("GET", "/v2/panels")]
async def test_panel_updated_when_differs():
# 这条用响应不包 {"data"} 外层的形态,覆盖 _unwrap 的另一分支
api = PanelRecording(
records=[
{
"panel_id": "P9",
"panel": {
"items": [{"name": "点歌", "type": "command", "desc": "旧"}],
"remark": "音乐bot",
},
}
]
)
await sync_command_panel(api)
assert (api.calls[1]["method"], api.calls[1]["path"]) == ("PUT", "/v2/panels/P9")
async def test_panel_failure_swallowed():
api = PanelRecording(fail_get=True)
await sync_command_panel(api) # 失败只记日志,绝不抛出
assert api.calls == []
def test_config_send_mode(monkeypatch):
"""SEND_MODE 默认 link(避开文件上传权限),file 可显式开启,非法值回落 link。"""
from musicbot.config import BotConfig