新增日志支持、卡片显示专辑封面、菜单去掉音源标注

- 日志:控制台 + logs/musicbot.log(按天轮转、保留 7 天),
  LOG_LEVEL/LOG_DIR 可配;目录不可写时退化为仅控制台;
  diagnose=False 避免把凭证写进日志
- 卡片用 Markdown 图片语法显示专辑封面(![封面 #240px #240px](img)),
  封面取自 MusicServer 的 img 字段(tx/wy 有、kw/kg 无)
- 按钮 data 加第 5 段(封面 URL),解码兼容旧 4 段,已发出的按钮不失效
- 菜单去掉每首歌后的(音源)标注;卡片保留"来源:"
- 测试补:封面渲染、无封面源、旧 data 兼容、LOG_LEVEL、日志落盘
This commit is contained in:
snowy-yang committed 2026-09-20 22:36:40 +08:00
1 parent 7a8adb4571
commit 9bcdad7aa1
7 files changed
+162 -24

No files matched your search

+83 -11
View File
@@ -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 "![封面 #240px #240px](https://img.test/cover.jpg)" 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: