From 9bcdad7aa14e0c260f584c40eef1b93929d692fc Mon Sep 17 00:00:00 2001
From: snowy-yang <102860110+snowy-yang@users.noreply.github.com>
Date: Sun, 20 Sep 2026 22:36:40 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=97=A5=E5=BF=97=E6=94=AF?=
=?UTF-8?q?=E6=8C=81=E3=80=81=E5=8D=A1=E7=89=87=E6=98=BE=E7=A4=BA=E4=B8=93?=
=?UTF-8?q?=E8=BE=91=E5=B0=81=E9=9D=A2=E3=80=81=E8=8F=9C=E5=8D=95=E5=8E=BB?=
=?UTF-8?q?=E6=8E=89=E9=9F=B3=E6=BA=90=E6=A0=87=E6=B3=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 日志:控制台 + logs/musicbot.log(按天轮转、保留 7 天),
LOG_LEVEL/LOG_DIR 可配;目录不可写时退化为仅控制台;
diagnose=False 避免把凭证写进日志
- 卡片用 Markdown 图片语法显示专辑封面(),
封面取自 MusicServer 的 img 字段(tx/wy 有、kw/kg 无)
- 按钮 data 加第 5 段(封面 URL),解码兼容旧 4 段,已发出的按钮不失效
- 菜单去掉每首歌后的(音源)标注;卡片保留"来源:"
- 测试补:封面渲染、无封面源、旧 data 兼容、LOG_LEVEL、日志落盘
---
.env.example | 5 +++
.gitignore | 1 +
README.md | 8 +++-
musicbot/config.py | 13 +++++++
musicbot/main.py | 35 ++++++++++++++++-
musicbot/music.py | 30 ++++++++++-----
tests/test_music.py | 94 +++++++++++++++++++++++++++++++++++++++------
7 files changed, 162 insertions(+), 24 deletions(-)
diff --git a/.env.example b/.env.example
index edd9da9..ee6974c 100644
--- a/.env.example
+++ b/.env.example
@@ -20,3 +20,8 @@ WITHDRAW=on
# file 先上传群文件、失败回退直链(需机器人在群里是群主/管理员)
# 播放按钮不受影响,始终发语音。非法值按 link 处理。
SEND_MODE=link
+
+# 日志:级别可选 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL(默认 INFO);
+# 日志同时输出到控制台与 LOG_DIR/musicbot.log(按天轮转、保留 7 天)
+LOG_LEVEL=INFO
+LOG_DIR=logs
diff --git a/.gitignore b/.gitignore
index 653faa5..8ba214b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# 密钥与本地数据
.env
downloads/
+logs/
# Python
__pycache__/
diff --git a/README.md b/README.md
index c41720b..3c37a97 100644
--- a/README.md
+++ b/README.md
@@ -2,13 +2,15 @@
QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两步点歌:
-1. **选歌菜单**——`点歌 <歌名 [歌手]>` 只负责选歌:Markdown 歌单(编号 + 歌名/歌手/来源,默认 QQ 音乐源,搜不到自动聚合兜底),下方每行 5 个编号回调按钮 + `[上一页][下一页]`(每页 `MENU_PAGE_SIZE` 首,默认 10,上限 20)
-2. **歌曲卡片**——点编号弹出卡片:歌名/歌手/来源 + `[▶ 播放][⬇ 下载]` 回调按钮,再点才执行
+1. **选歌菜单**——`点歌 <歌名 [歌手]>` 只负责选歌:Markdown 歌单(编号 + 歌名/歌手,默认 QQ 音乐源,搜不到自动聚合兜底),下方每行 5 个编号回调按钮 + `[上一页][下一页]`(每页 `MENU_PAGE_SIZE` 首,默认 10,上限 20)
+2. **歌曲卡片**——点编号弹出卡片:专辑封面图 + 歌名/歌手/来源 + `[▶ 播放][⬇ 下载]` 回调按钮,再点才执行
+- **专辑封面**:卡片用 Markdown 图片语法 `` 显示封面(QQ 后台会代理并缓存图片到客户端,不落库);封面来自 MusicServer 的 `img` 字段,tx/wy 有值,kw/kg 无(这些源卡片就不带图)
- 回调按钮(type=1)点一下直接生效,无需再按发送;播放发语音、下载发文件或直链(见 `SEND_MODE`),失败回退直链文本;无 Markdown 权限自动回退文本
- **下载发送方式**(`SEND_MODE`,默认 `link`):群文件上传仅限群主/管理员(40093005),故默认直接发直链;`file` 则先尝试上传群文件、失败(含无权限)回退直链。播放始终发语音
- **直链候选源回退**:tx 等源直链常受 CDN 风控抓不动(bot 抓不动时 QQ 服务器侧上传同样报 40093007),上传前先探测直链可抓性,不可抓则按 kw/wy/mg 逐个搜索换源
- **自动撤回**(`WITHDRAW`,默认开):翻页撤旧菜单、选歌后撤菜单、下载/播放点击后撤卡片(无论发送成败都撤,卡片是一次性 UI);关闭后所有消息都保留
+- **日志**:控制台 + `LOG_DIR/musicbot.log`(按天轮转、保留 7 天),级别由 `LOG_LEVEL` 控制;目录不可写时自动退化为仅控制台
- 按钮回调需 `intents.toml` 开启 `INTERACTION`(本仓库已开启)
## 配置
@@ -22,6 +24,8 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两
| `MENU_PAGE_SIZE` | `10` | 选歌菜单每页候选数,1~20(内嵌键盘 5 行所限) |
| `WITHDRAW` | `on` | 自动撤回旧菜单/卡片;`off` 则全部保留 |
| `SEND_MODE` | `link` | 下载按钮:`link` 直接发直链 / `file` 先传群文件(需群主/管理员) |
+| `LOG_LEVEL` | `INFO` | 日志级别 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL |
+| `LOG_DIR` | `logs` | 日志目录(自动按天轮转、保留 7 天) |
### 不 @ 机器人也能点歌
diff --git a/musicbot/config.py b/musicbot/config.py
index c6b5667..8bca2d4 100644
--- a/musicbot/config.py
+++ b/musicbot/config.py
@@ -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"
diff --git a/musicbot/main.py b/musicbot/main.py
index f4e2438..005b5ac 100644
--- a/musicbot/main.py
+++ b/musicbot/main.py
@@ -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 = (
+ "{time:YYYY-MM-DD HH:mm:ss.SSS} | "
+ "{level: <8} | "
+ "{name}:{function}:{line} - "
+ "{message}"
+)
+
+
+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:
diff --git a/musicbot/music.py b/musicbot/music.py
index 4f390e2..606fe5c 100644
--- a/musicbot/music.py
+++ b/musicbot/music.py
@@ -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(
diff --git a/tests/test_music.py b/tests/test_music.py
index 824b949..b04234c 100644
--- a/tests/test_music.py
+++ b/tests/test_music.py
@@ -3,6 +3,7 @@
from collections.abc import AsyncIterator
from dataclasses import replace
+from pathlib import Path
from typing import Any
from urllib.parse import quote
@@ -123,6 +124,7 @@ class FakeMusicServer:
"singer": "歌手T",
"albumName": "专辑T",
"interval": "04:00",
+ "img": "https://img.test/cover.jpg",
},
{"source": "kw", "songmid": "S1", "name": name, "singer": "歌手A"},
]
@@ -181,10 +183,13 @@ def _reset_menu_state():
music._seen_messages.clear()
-# 回调按钮 data:音乐|动作|源|song_id|歌名|歌手(歌名/歌手 % 转义)
-PICK_1 = "音乐|pick|tx|T1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BT"
-PLAY_TX = "音乐|play|tx|T1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BT"
-DL_TX = "音乐|dl|tx|T1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BT"
+# 回调按钮 data:音乐|动作|源|song_id|歌名|歌手|封面(歌名/歌手/封面 % 转义)
+IMG_URL = "https://img.test/cover.jpg"
+PICK_1 = music._song_data("pick", music.Song("tx", "T1", "稻香", "歌手T", img=IMG_URL))
+PLAY_TX = music._song_data("play", music.Song("tx", "T1", "稻香", "歌手T", img=IMG_URL))
+DL_TX = music._song_data("dl", music.Song("tx", "T1", "稻香", "歌手T", img=IMG_URL))
+# 旧版按钮 data(无封面段)—— 兼容性用例
+PICK_1_LEGACY = "音乐|pick|tx|T1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BT"
def posts(calls: list[dict]) -> list[dict]:
@@ -214,7 +219,7 @@ async def test_search_prefers_tx_and_falls_back(server, env):
async def test_menu_only_selects_songs(env):
- """搜索菜单只负责选歌:编号回调按钮,不放播放/下载、也不放任何链接。"""
+ """搜索菜单只负责选歌:编号回调按钮,不放播放/下载、不放链接、不带音源。"""
http, config = env
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "点歌 稻香")
@@ -222,10 +227,10 @@ async def test_menu_only_selects_songs(env):
assert body["msg_type"] == 2
md = body["markdown"]["content"]
assert "## 点歌:稻香" in md
- assert "1. 稻香 - 歌手T(QQ音乐)" in md
- assert "(QQ音乐)" in md and "(酷我)" in md
+ assert "1. 稻香 - 歌手T" in md
+ assert "QQ音乐" not in md and "酷我" not in md # 每首歌后面不再标音源
assert "http" not in md # 菜单里没有任何链接
- # 编号回调按钮(type=1):点一下弹卡片,data 自包含歌曲
+ # 编号回调按钮(type=1):点一下弹卡片,data 自包含歌曲(含封面)
rows = buttons(body)
assert [b["render_data"]["label"] for b in rows[0]["buttons"]] == ["1", "2"]
assert [b["action"]["type"] for b in rows[0]["buttons"]] == [1, 1]
@@ -287,9 +292,9 @@ async def test_menu_fallback_keeps_keyboard(env):
async def test_pick_button_sends_card(env):
- """点编号 → 弹歌曲卡片:歌名/来源 + [▶播放][⬇下载] 回调按钮。
+ """点编号 → 弹歌曲卡片:封面图 + 歌名/来源 + [▶播放][⬇下载] 回调按钮。
- 卡片由回调 data 还原(自包含 4 字段),故不含专辑/时长等菜单外信息。"""
+ 卡片由回调 data 还原(自包含 song 字段),故不含专辑/时长等菜单外信息。"""
http, config = env
api, calls = make_api()
await handle_button(
@@ -299,7 +304,8 @@ async def test_pick_button_sends_card(env):
assert card["msg_type"] == 2
md = card["markdown"]["content"]
assert "## 稻香 - 歌手T" in md
- assert "来源:QQ音乐" in md
+ assert "" in md # 专辑封面
+ assert "来源:QQ音乐" in md # 卡片保留音源
assert labels(card) == [["▶ 播放", "⬇ 下载"]]
assert [b["action"]["type"] for b in buttons(card)[0]["buttons"]] == [1, 1]
assert [b["action"]["data"] for b in buttons(card)[0]["buttons"]] == [
@@ -309,6 +315,27 @@ async def test_pick_button_sends_card(env):
assert (calls[-1]["method"], calls[-1]["path"]) == ("PUT", "/interactions/I1")
+async def test_card_without_img_when_source_has_none(env):
+ """无封面源的歌曲(如 kw):卡片不含图片,其余照常。"""
+ http, config = env
+ api, calls = make_api()
+ no_img = "音乐|pick|kw|S1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BA|"
+ await handle_button(api, http, config, "G1", "I1", no_img)
+ md = posts(calls)[0]["json"]["markdown"]["content"]
+ assert "![封面" not in md
+ assert "## 稻香 - 歌手A" in md
+
+
+async def test_legacy_button_data_without_img(env):
+ """旧版 4 段 data 仍能处理(封面缺省),保证已发出的菜单按钮不失效。"""
+ http, config = env
+ api, calls = make_api()
+ assert await handle_button(api, http, config, "G1", "I1", PICK_1_LEGACY)
+ md = posts(calls)[0]["json"]["markdown"]["content"]
+ assert "## 稻香 - 歌手T" in md
+ assert "![封面" not in md
+
+
async def test_card_play_button_sends_voice(env):
"""卡片上的 [▶ 播放] 是回调按钮:点一下直接发语音。"""
http, config = env
@@ -591,6 +618,51 @@ def test_config_send_mode(monkeypatch):
assert BotConfig.load().send_mode == "link" # 非法值不报错,按默认处理
+def test_config_log_level(monkeypatch):
+ """LOG_LEVEL 默认 INFO,可显式设置(大小写不敏感),非法值回落 INFO。"""
+ from musicbot.config import BotConfig
+
+ monkeypatch.delenv("LOG_LEVEL", raising=False)
+ assert BotConfig.load().log_level == "INFO"
+
+ monkeypatch.setenv("LOG_LEVEL", "debug")
+ assert BotConfig.load().log_level == "DEBUG"
+
+ monkeypatch.setenv("LOG_LEVEL", "乱写")
+ assert BotConfig.load().log_level == "INFO"
+
+
+def test_setup_logging_writes_file(tmp_path: Path):
+ """setup_logging 同时写控制台与文件;写不进去时退化为仅控制台不抛错。"""
+ from loguru import logger as lg
+
+ from musicbot.config import BotConfig
+ from musicbot.main import setup_logging
+
+ cfg = BotConfig(
+ music_server_url="http://127.0.0.1:1",
+ music_quality="128k",
+ music_source="tx",
+ log_level="INFO",
+ log_dir=tmp_path,
+ )
+ try:
+ setup_logging(cfg)
+ lg.info("测试日志落盘")
+ lg.complete() # 等 enqueue 队列刷完
+ logfile = tmp_path / "musicbot.log"
+ assert logfile.is_file()
+ assert "测试日志落盘" in logfile.read_text(encoding="utf-8")
+ finally:
+ lg.remove() # 清掉本用例加的 handler,避免影响其他测试
+
+ # 目录不可写(用一个文件当目录)→ 不抛错,仅告警
+ blocked = tmp_path / "not-a-dir"
+ blocked.write_text("x", encoding="utf-8")
+ setup_logging(replace(cfg, log_dir=blocked))
+ lg.remove()
+
+
# ---- 全量模式(不 @ 也能用)与去重 ----
def make_emitter(api, http, config) -> EventEmitter: