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

+2
View File
@@ -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),
+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)
+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}")