新增日志支持、卡片显示专辑封面、菜单去掉音源标注
- 日志:控制台 + logs/musicbot.log(按天轮转、保留 7 天), LOG_LEVEL/LOG_DIR 可配;目录不可写时退化为仅控制台; diagnose=False 避免把凭证写进日志 - 卡片用 Markdown 图片语法显示专辑封面(), 封面取自 MusicServer 的 img 字段(tx/wy 有、kw/kg 无) - 按钮 data 加第 5 段(封面 URL),解码兼容旧 4 段,已发出的按钮不失效 - 菜单去掉每首歌后的(音源)标注;卡片保留"来源:" - 测试补:封面渲染、无封面源、旧 data 兼容、LOG_LEVEL、日志落盘
This commit is contained in:
1 parent
7a8adb4571
commit
9bcdad7aa1
7 files changed
+162
-24
No files matched your search
@@ -2,10 +2,13 @@
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# 内嵌键盘上限 5 行 × 每行 5 个;歌曲占行 + 1 行翻页 → 每页最多 20 首
|
||||
_MAX_MENU_PAGE_SIZE = 20
|
||||
|
||||
_LOG_LEVELS = ("TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL")
|
||||
|
||||
|
||||
def _flag(name: str, default: bool) -> bool:
|
||||
"""读布尔环境变量:on/true/1/yes 为真,off/false/0/no 为假。"""
|
||||
@@ -33,6 +36,8 @@ class BotConfig:
|
||||
menu_page_size: int = 10 # 选歌菜单每页候选数(1~20)
|
||||
withdraw: bool = True # 自动撤回旧菜单/卡片是否开启
|
||||
send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链
|
||||
log_level: str = "INFO" # 控制台与文件日志级别
|
||||
log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "BotConfig":
|
||||
@@ -44,4 +49,12 @@ class BotConfig:
|
||||
menu_page_size=min(max(1, page_size), _MAX_MENU_PAGE_SIZE),
|
||||
withdraw=_flag("WITHDRAW", True),
|
||||
send_mode=_send_mode(),
|
||||
log_level=_log_level(),
|
||||
log_dir=Path(os.getenv("LOG_DIR", "logs")),
|
||||
)
|
||||
|
||||
|
||||
def _log_level() -> str:
|
||||
"""LOG_LEVEL:日志级别,非法值按 INFO。"""
|
||||
raw = os.getenv("LOG_LEVEL", "").strip().upper()
|
||||
return raw if raw in _LOG_LEVELS else "INFO"
|
||||
+34
-1
@@ -1,6 +1,7 @@
|
||||
"""装配入口:注册命令并运行 bot。"""
|
||||
"""装配入口:配置日志、注册命令并运行 bot。"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from loguru import logger
|
||||
@@ -9,9 +10,41 @@ from qqbotsdk import EventEmitter, run_loop
|
||||
from .config import BotConfig
|
||||
from .music import register
|
||||
|
||||
# 日志文件按天轮转、保留 7 天;目录写不进去时退化为仅控制台
|
||||
_LOG_RETENTION = "7 days"
|
||||
_LOG_FORMAT = (
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
||||
"<level>{level: <8}</level> | "
|
||||
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
|
||||
"<level>{message}</level>"
|
||||
)
|
||||
|
||||
|
||||
def setup_logging(config: BotConfig) -> None:
|
||||
"""日志同时输出到控制台与 logs/musicbot.log(按天轮转)。"""
|
||||
logger.remove() # 去掉 loguru 默认的 stderr handler,统一由这里接管
|
||||
logger.add(sys.stderr, level=config.log_level, format=_LOG_FORMAT)
|
||||
try:
|
||||
config.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.add(
|
||||
config.log_dir / "musicbot.log",
|
||||
level=config.log_level,
|
||||
format=_LOG_FORMAT,
|
||||
rotation="00:00", # 每天零点切分
|
||||
retention=_LOG_RETENTION,
|
||||
encoding="utf-8",
|
||||
enqueue=True, # 多任务写入串行化,避免并发写坏文件
|
||||
backtrace=True,
|
||||
diagnose=False, # 不回显变量值,避免把凭证写进日志
|
||||
)
|
||||
except OSError as e:
|
||||
logger.warning(f"日志目录不可写({config.log_dir}),仅输出到控制台: {e}")
|
||||
|
||||
|
||||
async def amain() -> None:
|
||||
config = BotConfig.load()
|
||||
setup_logging(config)
|
||||
logger.info(f"musicbot 启动,日志级别 {config.log_level}")
|
||||
ee = EventEmitter() # 队列由 emitter 自建,run_loop 复用同一实例
|
||||
# bot 自建连接池:直链探测不受 SDK 的 TIMEOUT 约束
|
||||
async with ClientSession() as http:
|
||||
|
||||
+20
-10
@@ -62,6 +62,7 @@ class Song:
|
||||
album: str | None = None
|
||||
interval: str | None = None
|
||||
album_id: str | None = None
|
||||
img: str | None = None # 专辑封面直链(部分源无此字段,如 kw/kg)
|
||||
|
||||
|
||||
# 每个群当前"可撤回"的 bot 消息 id(菜单或卡片):发新消息前撤回上一张,
|
||||
@@ -126,6 +127,7 @@ def _song_of(item: dict[str, Any]) -> Song | None:
|
||||
album=item.get("albumName"),
|
||||
interval=item.get("interval"),
|
||||
album_id=item.get("albumId"),
|
||||
img=item.get("img") or None,
|
||||
)
|
||||
|
||||
|
||||
@@ -278,22 +280,26 @@ async def _swap_message(
|
||||
|
||||
|
||||
def _song_data(action: str, song: Song) -> str:
|
||||
"""回调按钮携带的歌曲数据:自包含(歌名/歌手 % 转义,避免撞上分隔符),
|
||||
bot 收到回调直接还原 Song,无需记忆菜单快照。"""
|
||||
"""回调按钮携带的歌曲数据:自包含(歌名/歌手/封面 % 转义,避免撞上
|
||||
分隔符),bot 收到回调直接还原 Song,无需记忆菜单快照。"""
|
||||
return (
|
||||
f"音乐|{action}|{song.source}|{song.song_id}"
|
||||
f"|{_q(song.name)}|{_q(song.singer)}"
|
||||
f"|{_q(song.name)}|{_q(song.singer)}|{_q(song.img or '')}"
|
||||
)
|
||||
|
||||
|
||||
def _song_of_data(args: list[str]) -> Song:
|
||||
"""从回调 data 的 [source, song_id, name, singer] 还原 Song。"""
|
||||
source, song_id, name, singer = args
|
||||
"""从回调 data 的 [source, song_id, name, singer, img?] 还原 Song。
|
||||
|
||||
img 段是后加的:旧按钮的 data 只有 4 段,缺省按无封面处理。"""
|
||||
source, song_id, name, singer = args[:4]
|
||||
img = unquote(args[4]) if len(args) > 4 and args[4] else None
|
||||
return Song(
|
||||
source=source,
|
||||
song_id=song_id,
|
||||
name=unquote(name),
|
||||
singer=unquote(singer),
|
||||
img=img,
|
||||
)
|
||||
|
||||
|
||||
@@ -374,8 +380,7 @@ async def send_menu(
|
||||
lines = [f"## 点歌:{keyword}"]
|
||||
for i, song in enumerate(songs, 1):
|
||||
title = f"{song.name} - {song.singer}".strip(" -")
|
||||
source = _SOURCE_NAMES.get(song.source, song.source)
|
||||
lines.append(f"{i}. {title}({source})")
|
||||
lines.append(f"{i}. {title}")
|
||||
keyboard = _menu_keyboard(songs, keyword, page, config.menu_page_size)
|
||||
try:
|
||||
resp = await api.post_group_message(
|
||||
@@ -410,10 +415,13 @@ async def send_card(
|
||||
msg_id: str | None = None,
|
||||
event_id: str | None = None,
|
||||
) -> None:
|
||||
"""选中歌曲后弹出卡片:Markdown 歌名/歌手/来源/专辑/时长
|
||||
"""选中歌曲后弹出卡片:Markdown 封面图 + 歌名/歌手/来源/专辑/时长
|
||||
+ [▶ 播放][⬇ 下载] 回调按钮;Markdown 失败回退纯文本(按钮保留)。"""
|
||||
title = f"{song.name} - {song.singer}".strip(" -")
|
||||
lines = [f"## {title}"]
|
||||
if song.img:
|
||||
# QQ 后台会代理并缓存图片到客户端(不落库),宽高按 #宽px #高px 指定
|
||||
lines.append(f"")
|
||||
details = [f"来源:{_SOURCE_NAMES.get(song.source, song.source)}"]
|
||||
if song.album:
|
||||
details.append(f"专辑:{song.album}")
|
||||
@@ -603,6 +611,8 @@ async def handle_button(
|
||||
裸 uuid 会被 QQ 判为无效(40034025)。"""
|
||||
parts = data.split("|")
|
||||
action, args = (parts[1] if len(parts) > 1 else ""), parts[2:]
|
||||
# 歌曲按钮的 args 为 [source, song_id, name, singer](+ img,后加故兼容 4 段)
|
||||
is_song = action in ("pick", "play", "dl") and len(args) in (4, 5)
|
||||
try:
|
||||
if action == "page" and len(args) == 2:
|
||||
keyword, page = unquote(args[0]), int(args[1])
|
||||
@@ -610,12 +620,12 @@ async def handle_button(
|
||||
api, http, config, group_openid, keyword, page,
|
||||
event_id=event_id,
|
||||
)
|
||||
elif action == "pick" and len(args) == 4:
|
||||
elif action == "pick" and is_song:
|
||||
await send_card(
|
||||
api, http, config, group_openid, _song_of_data(args),
|
||||
event_id=event_id,
|
||||
)
|
||||
elif action in ("play", "dl") and len(args) == 4:
|
||||
elif action in ("play", "dl") and is_song:
|
||||
song = _song_of_data(args)
|
||||
try:
|
||||
await send_song(
|
||||
|
||||
Reference in new issue
Block a user