新增斜杠指令面板:/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

+9 -1
View File
@@ -2,7 +2,7 @@
QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两步点歌: 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. **歌曲卡片**——点编号弹出卡片:专辑封面图 + 歌名/歌手/来源 + `[▶ 播放][⬇ 下载]` 回调按钮,再点才执行 2. **歌曲卡片**——点编号弹出卡片:专辑封面图 + 歌名/歌手/来源 + `[▶ 播放][⬇ 下载]` 回调按钮,再点才执行
- **专辑封面**:卡片用 Markdown 图片语法 `![封面 #240px #240px](img)` 显示封面(QQ 后台会代理并缓存图片到客户端,不落库);封面来自 MusicServer 的 `img` 字段,tx/wy 有值,kw/kg 无(这些源卡片就不带图) - **专辑封面**:卡片用 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`。 两个事件共用同一个 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 ```bash
+2
View File
@@ -36,6 +36,7 @@ class BotConfig:
menu_page_size: int = 10 # 选歌菜单每页候选数(1~20) menu_page_size: int = 10 # 选歌菜单每页候选数(1~20)
withdraw: bool = True # 自动撤回旧菜单/卡片是否开启 withdraw: bool = True # 自动撤回旧菜单/卡片是否开启
send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链 send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链
command_panel: bool = True # 启动/重连时自动注册指令面板(输入框 / 菜单)
log_level: str = "INFO" # 控制台与文件日志级别 log_level: str = "INFO" # 控制台与文件日志级别
log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台 log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台
reload: bool = False # 热重载(开发用;--reload 亦可开启) reload: bool = False # 热重载(开发用;--reload 亦可开启)
@@ -50,6 +51,7 @@ class BotConfig:
menu_page_size=min(max(1, page_size), _MAX_MENU_PAGE_SIZE), menu_page_size=min(max(1, page_size), _MAX_MENU_PAGE_SIZE),
withdraw=_flag("WITHDRAW", True), withdraw=_flag("WITHDRAW", True),
send_mode=_send_mode(), send_mode=_send_mode(),
command_panel=_flag("COMMAND_PANEL", True),
log_level=_log_level(), log_level=_log_level(),
log_dir=Path(os.getenv("LOG_DIR", "logs")), log_dir=Path(os.getenv("LOG_DIR", "logs")),
reload=_flag("RELOAD", False), reload=_flag("RELOAD", False),
+84 -7
View File
@@ -18,12 +18,15 @@ from loguru import logger
from qqbotsdk import EventEmitter from qqbotsdk import EventEmitter
from qqbotsdk.api import ApiError, BotApi from qqbotsdk.api import ApiError, BotApi
from qqbotsdk.events import Event from qqbotsdk.events import Event
from qqbotsdk.payloads import GroupAtMessage, Interaction from qqbotsdk.payloads import GroupAtMessage, Interaction, Ready
from .config import BotConfig from .config import BotConfig
from .panel import sync_command_panel
_SONG_RE = re.compile(r"点歌\s+(\S.*)") _SONG_RE = re.compile(r"点歌\s+(\S.*)")
_LYRIC_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_TS_RE = re.compile(r"\[\d+:\d+(?:\.\d+)?\]")
_LRC_TAG_RE = re.compile(r"(?:\[[^\]]*\])+") _LRC_TAG_RE = re.compile(r"(?:\[[^\]]*\])+")
_LYRIC_LIMIT = 600 _LYRIC_LIMIT = 600
@@ -37,7 +40,12 @@ _UPLOAD_TIMEOUT = ClientTimeout(total=120)
# 群文件(file_type=4)上传仅限群主/管理员,语音(3)不受限;无权限时改发直链 # 群文件(file_type=4)上传仅限群主/管理员,语音(3)不受限;无权限时改发直链
_NO_FILE_PERMISSION = 40093005 _NO_FILE_PERMISSION = 40093005
_HELP = "♪ 音乐 bot\n点歌 <歌名 [歌手]>:出选歌菜单,点编号选歌\n歌词 <歌名>:查看歌词" _HELP = (
"♪ 音乐 bot\n"
"/点歌 <歌名 [歌手]>:出选歌菜单,点编号选歌\n"
"/歌词 <歌名>:查看歌词\n"
"输入框敲 / 也能唤出这些指令"
)
_SOURCE_NAMES = { _SOURCE_NAMES = {
"tx": "QQ音乐", "kw": "酷我", "kg": "酷狗", "wy": "网易云", "mg": "咪咕", "tx": "QQ音乐", "kw": "酷我", "kg": "酷狗", "wy": "网易云", "mg": "咪咕",
@@ -224,6 +232,16 @@ def _q(s: str) -> str:
return quote(s, safe="") 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( async def _reply(
api: BotApi, api: BotApi,
group_openid: str, group_openid: str,
@@ -555,6 +573,39 @@ async def send_lyric(
await _reply(api, group_openid, _trim(text), msg_id, event_id) 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( async def _search_source(
http: ClientSession, config: BotConfig, keyword: str, source: str http: ClientSession, config: BotConfig, keyword: str, source: str
) -> Song | None: ) -> Song | None:
@@ -680,6 +731,16 @@ async def _find(
return song 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( async def handle_command(
api: BotApi, api: BotApi,
http: ClientSession, http: ClientSession,
@@ -690,21 +751,29 @@ async def handle_command(
) -> bool: ) -> bool:
"""解析一条群消息里的音乐命令并执行;返回是否命中。 """解析一条群消息里的音乐命令并执行;返回是否命中。
选歌/播放/下载现在都由内嵌按钮(回调)承担,这里只处理文字命令。""" 裸文本(点歌/歌词/帮助)、@机器人 与斜杠指令(/点歌 …)共用这里;
if match := _SONG_RE.search(content): 选歌/播放/下载由内嵌按钮(回调)承担,这里只处理文字命令。"""
text = _normalize(content)
if match := _SONG_RE.search(text):
keyword = match.group(1).strip() keyword = match.group(1).strip()
logger.info(f"[{group_openid}] 点歌:{keyword}") logger.info(f"[{group_openid}] 点歌:{keyword}")
await send_menu(api, http, config, group_openid, keyword, msg_id=msg_id) await send_menu(api, http, config, group_openid, keyword, msg_id=msg_id)
return True 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() keyword = match.group(1).strip()
logger.info(f"[{group_openid}] 查歌词:{keyword}") logger.info(f"[{group_openid}] 查歌词:{keyword}")
if song := await _find(api, http, config, group_openid, msg_id, 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) await send_lyric(api, http, config, group_openid, song, msg_id=msg_id)
return True 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}] 查看帮助") 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 True
return False return False
@@ -763,3 +832,11 @@ def register(ee: EventEmitter, config: BotConfig, http: ClientSession) -> None:
api, http, config, act.group_openid, act.id, api, http, config, act.group_openid, act.id,
act.button_data, event_id=ev.event_id or 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)
+85
View File
@@ -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}")
+123 -1
View File
@@ -21,6 +21,7 @@ from musicbot.music import (
search, search,
send_menu, send_menu,
) )
from musicbot.panel import sync_command_panel
def make_api( def make_api(
@@ -29,12 +30,14 @@ def make_api(
fail_event: bool = False, fail_event: bool = False,
fail_upload: bool = False, fail_upload: bool = False,
fail_file_perm: bool = False, fail_file_perm: bool = False,
fail_keyboard: bool = False,
): ):
"""替换 upload_group_file / Markdown 消息以模拟失败,request 记录其余调用。 """替换 upload_group_file / Markdown 消息以模拟失败,request 记录其余调用。
fail_event=True 时拒绝一切带 event_id 的被动回复(模拟互动回执失效); fail_event=True 时拒绝一切带 event_id 的被动回复(模拟互动回执失效);
fail_upload=True 时上传一律抛 TimeoutError(模拟 QQ 侧抓流超过请求超时); 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] = [] calls: list[dict] = []
@@ -43,6 +46,8 @@ def make_api(
body = kwargs.get("json", {}) body = kwargs.get("json", {})
if fail_md and body.get("msg_type") == 2: if fail_md and body.get("msg_type") == 2:
raise ApiError(400, 304036, "无 Markdown 权限") raise ApiError(400, 304036, "无 Markdown 权限")
if fail_keyboard and "keyboard" in body:
raise ApiError(400, 304003, "无键盘权限")
if fail_event and "event_id" in body: if fail_event and "event_id" in body:
raise ApiError(400, 40034025, "请求参数event_id无效") raise ApiError(400, 40034025, "请求参数event_id无效")
if path.endswith("/files"): if path.endswith("/files"):
@@ -568,6 +573,55 @@ async def test_page_button_new_menu(env):
assert calls == [] # 非音乐按钮不处理也不回应 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): async def test_lyric_miss_help_and_no_hit(env):
http, config = env http, config = env
api, calls = make_api() api, calls = make_api()
@@ -604,6 +658,74 @@ def test_trim_strips_tags_and_truncates():
assert len(trimmed) <= 100 + len("\n……(歌词过长已截断)") 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): def test_config_send_mode(monkeypatch):
"""SEND_MODE 默认 link(避开文件上传权限),file 可显式开启,非法值回落 link。""" """SEND_MODE 默认 link(避开文件上传权限),file 可显式开启,非法值回落 link。"""
from musicbot.config import BotConfig from musicbot.config import BotConfig