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

+84 -7
View File
@@ -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)