新增斜杠指令面板:/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
@@ -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
@@ -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)
|
||||
@@ -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}")
|
||||
Reference in new issue
Block a user