新增斜杠指令面板:/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
@@ -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 图片语法 `` 显示封面(QQ 后台会代理并缓存图片到客户端,不落库);封面来自 MusicServer 的 `img` 字段,tx/wy 有值,kw/kg 无(这些源卡片就不带图)
|
- **专辑封面**:卡片用 Markdown 图片语法 `` 显示封面(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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user