新增斜杠指令面板:/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:
1 parent
d71809d144
commit
94ee3298b7
5 files changed
+303
-9
No files matched your search
+123
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user