diff --git a/README.md b/README.md index 33a5384..68c62b5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两步点歌: -1. **选歌菜单**——`点歌 <歌名 [歌手]>` 只负责选歌:Markdown 歌单(编号 + 歌名/歌手,默认 QQ 音乐源,搜不到自动聚合兜底),下方每行 5 个编号回调按钮 + `[上一页][下一页]`(每页 `MENU_PAGE_SIZE` 首,默认 10,上限 20) +1. **选歌菜单**——`点歌 <歌名 [歌手]>`(或斜杠指令 `/点歌 <歌名 [歌手]>`)只负责选歌:Markdown 歌单(编号 + 歌名/歌手,默认 QQ 音乐源,搜不到自动聚合兜底),下方每行 5 个编号回调按钮 + `[上一页][下一页]`(每页 `MENU_PAGE_SIZE` 首,默认 10,上限 20) 2. **歌曲卡片**——点编号弹出卡片:专辑封面图 + 歌名/歌手/来源 + `[▶ 播放][⬇ 下载]` 回调按钮,再点才执行 - **专辑封面**:卡片用 Markdown 图片语法 `![封面 #240px #240px](img)` 显示封面(QQ 后台会代理并缓存图片到客户端,不落库);封面来自 MusicServer 的 `img` 字段,tx/wy 有值,kw/kg 无(这些源卡片就不带图) @@ -36,6 +36,14 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两 两个事件共用同一个 dataclass 与 handler,被动回复都用该消息的 `msg_id`(群聊 5 分钟内有效、每条最多回复 5 次)。全量事件**不支持 `event_id`**——官方 event_id 支持列表只有 `INTERACTION_CREATE`、`GROUP_ADD_ROBOT`、`GROUP_MSG_RECEIVE`。 +## 斜杠指令(输入框 `/` 菜单) + +群里输入框敲 `/` 唤出的「指令面板」由 bot **调接口自动注册**(开放平台 2026-08 新增的 `/v2/panels` 系列接口):每次建连(READY)时把 `点歌`/`歌词`/`帮助` 三条指令同步上去——没有就创建(`target_type=all` 对全部群生效)、内容有变就更新、一致则跳过,重连即自愈;失败只记日志不影响运行。`COMMAND_PANEL=off` 可关闭,改在开放平台后台「功能配置 → 指令」手工配置。 + +指令消息以 `/点歌 稻香` 形式推给 bot(走 `GROUP_AT_MESSAGE_CREATE`,指令自动 @ 机器人),与文本命令共用同一套解析——`_normalize` 会剥掉开头的 `/` 与 `@机器人` 前缀,`点歌 稻香`、`/点歌 稻香`、`@bot /点歌 稻香` 三种写法等价。只敲命令不带参数(斜杠指令参数可为空)时回复用法提示。 + +「帮助」消息带 `[🎤 点歌][📝 歌词]` 指令按钮(action `type=2`):点击把 `/点歌 ` 填进输入框,补上歌名发送即可;键盘无权限时自动回退纯文本。 + ## 运行 ```bash diff --git a/musicbot/config.py b/musicbot/config.py index 46919c8..151ea2c 100644 --- a/musicbot/config.py +++ b/musicbot/config.py @@ -36,6 +36,7 @@ class BotConfig: menu_page_size: int = 10 # 选歌菜单每页候选数(1~20) withdraw: bool = True # 自动撤回旧菜单/卡片是否开启 send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链 + command_panel: bool = True # 启动/重连时自动注册指令面板(输入框 / 菜单) log_level: str = "INFO" # 控制台与文件日志级别 log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台 reload: bool = False # 热重载(开发用;--reload 亦可开启) @@ -50,6 +51,7 @@ class BotConfig: menu_page_size=min(max(1, page_size), _MAX_MENU_PAGE_SIZE), withdraw=_flag("WITHDRAW", True), send_mode=_send_mode(), + command_panel=_flag("COMMAND_PANEL", True), log_level=_log_level(), log_dir=Path(os.getenv("LOG_DIR", "logs")), reload=_flag("RELOAD", False), diff --git a/musicbot/music.py b/musicbot/music.py index 2778e67..202c7b5 100644 --- a/musicbot/music.py +++ b/musicbot/music.py @@ -18,12 +18,15 @@ from loguru import logger from qqbotsdk import EventEmitter from qqbotsdk.api import ApiError, BotApi from qqbotsdk.events import Event -from qqbotsdk.payloads import GroupAtMessage, Interaction +from qqbotsdk.payloads import GroupAtMessage, Interaction, Ready from .config import BotConfig +from .panel import sync_command_panel _SONG_RE = re.compile(r"点歌\s+(\S.*)") _LYRIC_RE = re.compile(r"歌词\s+(\S.*)") +# @机器人 前缀("<@!12345> 点歌 稻香");开头的 "/"(斜杠指令)在 _normalize 剥 +_MENTION_RE = re.compile(r"<@!?\w+>") _LRC_TS_RE = re.compile(r"\[\d+:\d+(?:\.\d+)?\]") _LRC_TAG_RE = re.compile(r"(?:\[[^\]]*\])+") _LYRIC_LIMIT = 600 @@ -37,7 +40,12 @@ _UPLOAD_TIMEOUT = ClientTimeout(total=120) # 群文件(file_type=4)上传仅限群主/管理员,语音(3)不受限;无权限时改发直链 _NO_FILE_PERMISSION = 40093005 -_HELP = "♪ 音乐 bot\n点歌 <歌名 [歌手]>:出选歌菜单,点编号选歌\n歌词 <歌名>:查看歌词" +_HELP = ( + "♪ 音乐 bot\n" + "/点歌 <歌名 [歌手]>:出选歌菜单,点编号选歌\n" + "/歌词 <歌名>:查看歌词\n" + "输入框敲 / 也能唤出这些指令" +) _SOURCE_NAMES = { "tx": "QQ音乐", "kw": "酷我", "kg": "酷狗", "wy": "网易云", "mg": "咪咕", @@ -224,6 +232,16 @@ def _q(s: str) -> str: return quote(s, safe="") +def _cmd_button(bid: str, label: str, data: str) -> dict: + """指令按钮(type=2):点击把 data 填进聊天输入框,用户补上参数再发送, + 等价于手敲斜杠指令——不产生回调,bot 收到的是普通消息。""" + return { + "id": bid, + "render_data": {"label": label, "visited_label": label, "style": 1}, + "action": {"type": 2, "permission": {"type": 2}, "data": data}, + } + + async def _reply( api: BotApi, group_openid: str, @@ -555,6 +573,39 @@ async def send_lyric( await _reply(api, group_openid, _trim(text), msg_id, event_id) +def _help_keyboard() -> dict: + """帮助键盘:[🎤 点歌][📝 歌词] 指令按钮,点一下把斜杠指令填进输入框。""" + return { + "content": { + "rows": [ + {"buttons": [ + _cmd_button("help-cmd-song", "🎤 点歌", "/点歌 "), + _cmd_button("help-cmd-lyric", "📝 歌词", "/歌词 "), + ]} + ] + } + } + + +async def _send_help(api: BotApi, group_openid: str, msg_id: str) -> None: + """发帮助:正文 + 指令按钮(输入框 / 菜单的入口);按钮被拒回退纯文本。""" + try: + resp = await api.post_group_message( + group_openid, content=_HELP, msg_id=msg_id, keyboard=_help_keyboard() + ) + except _API_FAILS as e: + logger.warning(f"带指令按钮的帮助发送失败,回退纯文本: {e!r}") + try: + resp = await api.post_group_message( + group_openid, content=_HELP, msg_id=msg_id + ) + except _API_FAILS as e: + logger.warning(f"帮助发送失败: {e!r}") + return + if sent := _sent_id(resp): + _mark(sent) + + async def _search_source( http: ClientSession, config: BotConfig, keyword: str, source: str ) -> Song | None: @@ -680,6 +731,16 @@ async def _find( return song +def _normalize(content: str) -> str: + """统一命令文本:剥掉 @机器人 前缀与开头的斜杠。 + + 斜杠指令(开放平台「指令管理」配置后,群里输入框 / 唤出菜单触发) + 推来的 content 形如 "/点歌 稻香";@机器人是 "<@!bot> 点歌 稻香"; + 全量模式是裸文本——三者归一后共用同一套命令解析。""" + text = _MENTION_RE.sub("", content, count=1).strip() + return text[1:].strip() if text.startswith("/") else text + + async def handle_command( api: BotApi, http: ClientSession, @@ -690,21 +751,29 @@ async def handle_command( ) -> bool: """解析一条群消息里的音乐命令并执行;返回是否命中。 - 选歌/播放/下载现在都由内嵌按钮(回调)承担,这里只处理文字命令。""" - if match := _SONG_RE.search(content): + 裸文本(点歌/歌词/帮助)、@机器人 与斜杠指令(/点歌 …)共用这里; + 选歌/播放/下载由内嵌按钮(回调)承担,这里只处理文字命令。""" + text = _normalize(content) + if match := _SONG_RE.search(text): keyword = match.group(1).strip() logger.info(f"[{group_openid}] 点歌:{keyword}") await send_menu(api, http, config, group_openid, keyword, msg_id=msg_id) return True - if match := _LYRIC_RE.search(content): + if text == "点歌": + await _reply(api, group_openid, "用法:点歌 <歌名 [歌手]>", msg_id=msg_id) + return True + if match := _LYRIC_RE.search(text): keyword = match.group(1).strip() logger.info(f"[{group_openid}] 查歌词:{keyword}") if song := await _find(api, http, config, group_openid, msg_id, keyword): await send_lyric(api, http, config, group_openid, song, msg_id=msg_id) return True - if "音乐帮助" in content: + if text == "歌词": + await _reply(api, group_openid, "用法:歌词 <歌名>", msg_id=msg_id) + return True + if text == "帮助" or "音乐帮助" in text: logger.info(f"[{group_openid}] 查看帮助") - await _reply(api, group_openid, _HELP, msg_id=msg_id) + await _send_help(api, group_openid, msg_id) return True return False @@ -763,3 +832,11 @@ def register(ee: EventEmitter, config: BotConfig, http: ClientSession) -> None: api, http, config, act.group_openid, act.id, act.button_data, event_id=ev.event_id or act.id, ) + + # 指令面板(输入框 / 菜单)跟着连接走:每次建连(READY)都幂等同步一次, + # 重连即自愈;RESUME 续传不重发 READY,不会重复刷 + if config.command_panel: + + @ee.on("READY") + async def on_ready(_: Ready, api: BotApi) -> None: + await sync_command_panel(api) diff --git a/musicbot/panel.py b/musicbot/panel.py new file mode 100644 index 0000000..e2c7fc3 --- /dev/null +++ b/musicbot/panel.py @@ -0,0 +1,85 @@ +"""指令面板(群里输入框敲 / 唤出的指令菜单)自动注册。 + +调开放平台 2026-08 新增的 /v2/panels 接口,把点歌指令同步到 QQ: +启动与每次重连(READY)各同步一次——没有就创建(target_type=all +对全部群生效),内容有变就更新,一致则跳过,幂等可重入。 +失败只记日志不影响运行(如功能未对机器人开放)。 +""" + +from typing import Any + +from aiohttp import ClientError +from loguru import logger +from qqbotsdk.api import ApiError, BotApi + +_API_FAILS = (ApiError, TimeoutError, ClientError) + +# 面板挂在群聊场景,对所有群生效 +_SCOPE = "group" +_TARGET_TYPE = "all" +# remark 兼作身份标识:重连同步时按它找到自己注册过的面板 +_REMARK = "音乐bot" + +_PANEL_ITEMS = [ + {"name": "点歌", "type": "command", "desc": "出选歌菜单,点编号选歌"}, + {"name": "歌词", "type": "command", "desc": "查看歌词"}, + {"name": "帮助", "type": "command", "desc": "查看帮助"}, +] + +_PANEL_BODY = {"items": _PANEL_ITEMS, "remark": _REMARK} + + +def _signature(items: list[dict]) -> list[tuple]: + """面板条目的比较指纹:只取本模块管理的字段,忽略其余与键序。""" + return [ + ( + i.get("name"), + i.get("type"), + i.get("desc"), + i.get("only_admin", False), + i.get("link"), + ) + for i in items + ] + + +def _unwrap(body: Any, key: str) -> Any: + """响应可能整体包一层 {"data": ...},也可能直接是数据本体,两者都认。""" + if not isinstance(body, dict): + return body + inner = body.get("data") + return inner if key not in body and isinstance(inner, dict) else body + + +def _mine(records: list[Any]) -> dict | None: + """在面板列表里找本 bot 注册过的那张(按 remark 识别)。""" + for record in records: + if not isinstance(record, dict): + continue + if (record.get("panel") or {}).get("remark") == _REMARK: + return record + return None + + +async def sync_command_panel(api: BotApi) -> None: + """把点歌指令面板同步到 QQ(幂等)。任何失败只记日志,绝不抛出。""" + try: + resp = await api.get("/v2/panels", params={"scope": _SCOPE, "limit": "20"}) + records = _unwrap(resp, "records").get("records") or [] + mine = _mine(records) + unchanged = mine and _signature(mine["panel"].get("items") or []) == _signature( + _PANEL_ITEMS + ) + if unchanged: + logger.info("指令面板已是最新(点歌/歌词/帮助)") + return + if mine: + await api.put(f"/v2/panels/{mine['panel_id']}", json={"panel": _PANEL_BODY}) + logger.info("指令面板已更新(点歌/歌词/帮助)") + else: + body = {"scope": _SCOPE, "panel": _PANEL_BODY, "target_type": _TARGET_TYPE} + await api.post("/v2/panels", json=body) + logger.info("指令面板已创建(点歌/歌词/帮助,对全部群生效)") + except _API_FAILS as e: + # 失败不影响运行;指令可改为在开放平台后台手工配置 + logger.warning(f"指令面板同步失败: {e!r}") diff --git a/tests/test_music.py b/tests/test_music.py index adb490b..44d1ca7 100644 --- a/tests/test_music.py +++ b/tests/test_music.py @@ -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