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

- 日志:控制台 + 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

+5
View File
@@ -20,3 +20,8 @@ WITHDRAW=on
# file 先上传群文件、失败回退直链(需机器人在群里是群主/管理员) # file 先上传群文件、失败回退直链(需机器人在群里是群主/管理员)
# 播放按钮不受影响,始终发语音。非法值按 link 处理。 # 播放按钮不受影响,始终发语音。非法值按 link 处理。
SEND_MODE=link SEND_MODE=link
# 日志:级别可选 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL(默认 INFO);
# 日志同时输出到控制台与 LOG_DIR/musicbot.log(按天轮转、保留 7 天)
LOG_LEVEL=INFO
LOG_DIR=logs
+1
View File
@@ -1,6 +1,7 @@
# 密钥与本地数据 # 密钥与本地数据
.env .env
downloads/ downloads/
logs/
# Python # Python
__pycache__/ __pycache__/
+6 -2
View File
@@ -2,13 +2,15 @@
QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两步点歌: QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两步点歌:
1. **选歌菜单**——`点歌 <歌名 [歌手]>` 只负责选歌:Markdown 歌单(编号 + 歌名/歌手/来源,默认 QQ 音乐源,搜不到自动聚合兜底),下方每行 5 个编号回调按钮 + `[上一页][下一页]`(每页 `MENU_PAGE_SIZE` 首,默认 10,上限 20) 1. **选歌菜单**——`点歌 <歌名 [歌手]>` 只负责选歌:Markdown 歌单(编号 + 歌名/歌手,默认 QQ 音乐源,搜不到自动聚合兜底),下方每行 5 个编号回调按钮 + `[上一页][下一页]`(每页 `MENU_PAGE_SIZE` 首,默认 10,上限 20)
2. **歌曲卡片**——点编号弹出卡片:歌名/歌手/来源 + `[▶ 播放][⬇ 下载]` 回调按钮,再点才执行 2. **歌曲卡片**——点编号弹出卡片:专辑封面图 + 歌名/歌手/来源 + `[▶ 播放][⬇ 下载]` 回调按钮,再点才执行
- **专辑封面**:卡片用 Markdown 图片语法 `![封面 #240px #240px](img)` 显示封面(QQ 后台会代理并缓存图片到客户端,不落库);封面来自 MusicServer 的 `img` 字段,tx/wy 有值,kw/kg 无(这些源卡片就不带图)
- 回调按钮(type=1)点一下直接生效,无需再按发送;播放发语音、下载发文件或直链(见 `SEND_MODE`),失败回退直链文本;无 Markdown 权限自动回退文本 - 回调按钮(type=1)点一下直接生效,无需再按发送;播放发语音、下载发文件或直链(见 `SEND_MODE`),失败回退直链文本;无 Markdown 权限自动回退文本
- **下载发送方式**(`SEND_MODE`,默认 `link`):群文件上传仅限群主/管理员(40093005),故默认直接发直链;`file` 则先尝试上传群文件、失败(含无权限)回退直链。播放始终发语音 - **下载发送方式**(`SEND_MODE`,默认 `link`):群文件上传仅限群主/管理员(40093005),故默认直接发直链;`file` 则先尝试上传群文件、失败(含无权限)回退直链。播放始终发语音
- **直链候选源回退**:tx 等源直链常受 CDN 风控抓不动(bot 抓不动时 QQ 服务器侧上传同样报 40093007),上传前先探测直链可抓性,不可抓则按 kw/wy/mg 逐个搜索换源 - **直链候选源回退**:tx 等源直链常受 CDN 风控抓不动(bot 抓不动时 QQ 服务器侧上传同样报 40093007),上传前先探测直链可抓性,不可抓则按 kw/wy/mg 逐个搜索换源
- **自动撤回**(`WITHDRAW`,默认开):翻页撤旧菜单、选歌后撤菜单、下载/播放点击后撤卡片(无论发送成败都撤,卡片是一次性 UI);关闭后所有消息都保留 - **自动撤回**(`WITHDRAW`,默认开):翻页撤旧菜单、选歌后撤菜单、下载/播放点击后撤卡片(无论发送成败都撤,卡片是一次性 UI);关闭后所有消息都保留
- **日志**:控制台 + `LOG_DIR/musicbot.log`(按天轮转、保留 7 天),级别由 `LOG_LEVEL` 控制;目录不可写时自动退化为仅控制台
- 按钮回调需 `intents.toml` 开启 `INTERACTION`(本仓库已开启) - 按钮回调需 `intents.toml` 开启 `INTERACTION`(本仓库已开启)
## 配置 ## 配置
@@ -22,6 +24,8 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两
| `MENU_PAGE_SIZE` | `10` | 选歌菜单每页候选数,1~20(内嵌键盘 5 行所限) | | `MENU_PAGE_SIZE` | `10` | 选歌菜单每页候选数,1~20(内嵌键盘 5 行所限) |
| `WITHDRAW` | `on` | 自动撤回旧菜单/卡片;`off` 则全部保留 | | `WITHDRAW` | `on` | 自动撤回旧菜单/卡片;`off` 则全部保留 |
| `SEND_MODE` | `link` | 下载按钮:`link` 直接发直链 / `file` 先传群文件(需群主/管理员) | | `SEND_MODE` | `link` | 下载按钮:`link` 直接发直链 / `file` 先传群文件(需群主/管理员) |
| `LOG_LEVEL` | `INFO` | 日志级别 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL |
| `LOG_DIR` | `logs` | 日志目录(自动按天轮转、保留 7 天) |
### 不 @ 机器人也能点歌 ### 不 @ 机器人也能点歌
+13
View File
@@ -2,10 +2,13 @@
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
# 内嵌键盘上限 5 行 × 每行 5 个;歌曲占行 + 1 行翻页 → 每页最多 20 首 # 内嵌键盘上限 5 行 × 每行 5 个;歌曲占行 + 1 行翻页 → 每页最多 20 首
_MAX_MENU_PAGE_SIZE = 20 _MAX_MENU_PAGE_SIZE = 20
_LOG_LEVELS = ("TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL")
def _flag(name: str, default: bool) -> bool: def _flag(name: str, default: bool) -> bool:
"""读布尔环境变量:on/true/1/yes 为真,off/false/0/no 为假。""" """读布尔环境变量:on/true/1/yes 为真,off/false/0/no 为假。"""
@@ -33,6 +36,8 @@ class BotConfig:
menu_page_size: int = 10 # 选歌菜单每页候选数(1~20) menu_page_size: int = 10 # 选歌菜单每页候选数(1~20)
withdraw: bool = True # 自动撤回旧菜单/卡片是否开启 withdraw: bool = True # 自动撤回旧菜单/卡片是否开启
send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链 send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链
log_level: str = "INFO" # 控制台与文件日志级别
log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台
@classmethod @classmethod
def load(cls) -> "BotConfig": def load(cls) -> "BotConfig":
@@ -44,4 +49,12 @@ class BotConfig:
menu_page_size=min(max(1, page_size), _MAX_MENU_PAGE_SIZE), menu_page_size=min(max(1, page_size), _MAX_MENU_PAGE_SIZE),
withdraw=_flag("WITHDRAW", True), withdraw=_flag("WITHDRAW", True),
send_mode=_send_mode(), 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
View File
@@ -1,6 +1,7 @@
"""装配入口:注册命令并运行 bot。""" """装配入口:配置日志、注册命令并运行 bot。"""
import asyncio import asyncio
import sys
from aiohttp import ClientSession from aiohttp import ClientSession
from loguru import logger from loguru import logger
@@ -9,9 +10,41 @@ from qqbotsdk import EventEmitter, run_loop
from .config import BotConfig from .config import BotConfig
from .music import register 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: async def amain() -> None:
config = BotConfig.load() config = BotConfig.load()
setup_logging(config)
logger.info(f"musicbot 启动,日志级别 {config.log_level}")
ee = EventEmitter() # 队列由 emitter 自建,run_loop 复用同一实例 ee = EventEmitter() # 队列由 emitter 自建,run_loop 复用同一实例
# bot 自建连接池:直链探测不受 SDK 的 TIMEOUT 约束 # bot 自建连接池:直链探测不受 SDK 的 TIMEOUT 约束
async with ClientSession() as http: async with ClientSession() as http:
+20 -10
View File
@@ -62,6 +62,7 @@ class Song:
album: str | None = None album: str | None = None
interval: str | None = None interval: str | None = None
album_id: str | None = None album_id: str | None = None
img: str | None = None # 专辑封面直链(部分源无此字段,如 kw/kg)
# 每个群当前"可撤回"的 bot 消息 id(菜单或卡片):发新消息前撤回上一张, # 每个群当前"可撤回"的 bot 消息 id(菜单或卡片):发新消息前撤回上一张,
@@ -126,6 +127,7 @@ def _song_of(item: dict[str, Any]) -> Song | None:
album=item.get("albumName"), album=item.get("albumName"),
interval=item.get("interval"), interval=item.get("interval"),
album_id=item.get("albumId"), 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: def _song_data(action: str, song: Song) -> str:
"""回调按钮携带的歌曲数据:自包含(歌名/歌手 % 转义,避免撞上分隔符), """回调按钮携带的歌曲数据:自包含(歌名/歌手/封面 % 转义,避免撞上
bot 收到回调直接还原 Song,无需记忆菜单快照。""" 分隔符),bot 收到回调直接还原 Song,无需记忆菜单快照。"""
return ( return (
f"音乐|{action}|{song.source}|{song.song_id}" 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: def _song_of_data(args: list[str]) -> Song:
"""从回调 data 的 [source, song_id, name, singer] 还原 Song。""" """从回调 data 的 [source, song_id, name, singer, img?] 还原 Song。
source, song_id, name, singer = args
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( return Song(
source=source, source=source,
song_id=song_id, song_id=song_id,
name=unquote(name), name=unquote(name),
singer=unquote(singer), singer=unquote(singer),
img=img,
) )
@@ -374,8 +380,7 @@ async def send_menu(
lines = [f"## 点歌:{keyword}"] lines = [f"## 点歌:{keyword}"]
for i, song in enumerate(songs, 1): for i, song in enumerate(songs, 1):
title = f"{song.name} - {song.singer}".strip(" -") title = f"{song.name} - {song.singer}".strip(" -")
source = _SOURCE_NAMES.get(song.source, song.source) lines.append(f"{i}. {title}")
lines.append(f"{i}. {title}({source})")
keyboard = _menu_keyboard(songs, keyword, page, config.menu_page_size) keyboard = _menu_keyboard(songs, keyword, page, config.menu_page_size)
try: try:
resp = await api.post_group_message( resp = await api.post_group_message(
@@ -410,10 +415,13 @@ async def send_card(
msg_id: str | None = None, msg_id: str | None = None,
event_id: str | None = None, event_id: str | None = None,
) -> None: ) -> None:
"""选中歌曲后弹出卡片:Markdown 歌名/歌手/来源/专辑/时长 """选中歌曲后弹出卡片:Markdown 封面图 + 歌名/歌手/来源/专辑/时长
+ [▶ 播放][⬇ 下载] 回调按钮;Markdown 失败回退纯文本(按钮保留)。""" + [▶ 播放][⬇ 下载] 回调按钮;Markdown 失败回退纯文本(按钮保留)。"""
title = f"{song.name} - {song.singer}".strip(" -") title = f"{song.name} - {song.singer}".strip(" -")
lines = [f"## {title}"] lines = [f"## {title}"]
if song.img:
# QQ 后台会代理并缓存图片到客户端(不落库),宽高按 #宽px #高px 指定
lines.append(f"![封面 #240px #240px]({song.img})")
details = [f"来源:{_SOURCE_NAMES.get(song.source, song.source)}"] details = [f"来源:{_SOURCE_NAMES.get(song.source, song.source)}"]
if song.album: if song.album:
details.append(f"专辑:{song.album}") details.append(f"专辑:{song.album}")
@@ -603,6 +611,8 @@ async def handle_button(
裸 uuid 会被 QQ 判为无效(40034025)。""" 裸 uuid 会被 QQ 判为无效(40034025)。"""
parts = data.split("|") parts = data.split("|")
action, args = (parts[1] if len(parts) > 1 else ""), parts[2:] 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: try:
if action == "page" and len(args) == 2: if action == "page" and len(args) == 2:
keyword, page = unquote(args[0]), int(args[1]) keyword, page = unquote(args[0]), int(args[1])
@@ -610,12 +620,12 @@ async def handle_button(
api, http, config, group_openid, keyword, page, api, http, config, group_openid, keyword, page,
event_id=event_id, event_id=event_id,
) )
elif action == "pick" and len(args) == 4: elif action == "pick" and is_song:
await send_card( await send_card(
api, http, config, group_openid, _song_of_data(args), api, http, config, group_openid, _song_of_data(args),
event_id=event_id, 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) song = _song_of_data(args)
try: try:
await send_song( await send_song(
+83 -11
View File
@@ -3,6 +3,7 @@
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from dataclasses import replace from dataclasses import replace
from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import quote from urllib.parse import quote
@@ -123,6 +124,7 @@ class FakeMusicServer:
"singer": "歌手T", "singer": "歌手T",
"albumName": "专辑T", "albumName": "专辑T",
"interval": "04:00", "interval": "04:00",
"img": "https://img.test/cover.jpg",
}, },
{"source": "kw", "songmid": "S1", "name": name, "singer": "歌手A"}, {"source": "kw", "songmid": "S1", "name": name, "singer": "歌手A"},
] ]
@@ -181,10 +183,13 @@ def _reset_menu_state():
music._seen_messages.clear() music._seen_messages.clear()
# 回调按钮 data:音乐|动作|源|song_id|歌名|歌手(歌名/歌手 % 转义) # 回调按钮 data:音乐|动作|源|song_id|歌名|歌手|封面(歌名/歌手/封面 % 转义)
PICK_1 = "音乐|pick|tx|T1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BT" IMG_URL = "https://img.test/cover.jpg"
PLAY_TX = "音乐|play|tx|T1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BT" PICK_1 = music._song_data("pick", music.Song("tx", "T1", "稻香", "歌手T", img=IMG_URL))
DL_TX = "音乐|dl|tx|T1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BT" 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]: 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): async def test_menu_only_selects_songs(env):
"""搜索菜单只负责选歌:编号回调按钮,不放播放/下载、也不放任何链接。""" """搜索菜单只负责选歌:编号回调按钮,不放播放/下载、不放链接、不带音源。"""
http, config = env http, config = env
api, calls = make_api() api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "点歌 稻香") 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 assert body["msg_type"] == 2
md = body["markdown"]["content"] md = body["markdown"]["content"]
assert "## 点歌:稻香" in md assert "## 点歌:稻香" in md
assert "1. 稻香 - 歌手T(QQ音乐)" in md assert "1. 稻香 - 歌手T" in md
assert "(QQ音乐)" in md and "(酷我)" in md assert "QQ音乐" not in md and "酷我" not in md # 每首歌后面不再标音源
assert "http" not in md # 菜单里没有任何链接 assert "http" not in md # 菜单里没有任何链接
# 编号回调按钮(type=1):点一下弹卡片,data 自包含歌曲 # 编号回调按钮(type=1):点一下弹卡片,data 自包含歌曲(含封面)
rows = buttons(body) rows = buttons(body)
assert [b["render_data"]["label"] for b in rows[0]["buttons"]] == ["1", "2"] 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] 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): async def test_pick_button_sends_card(env):
"""点编号 → 弹歌曲卡片:歌名/来源 + [▶播放][⬇下载] 回调按钮。 """点编号 → 弹歌曲卡片:封面图 + 歌名/来源 + [▶播放][⬇下载] 回调按钮。
卡片由回调 data 还原(自包含 4 字段),故不含专辑/时长等菜单外信息。""" 卡片由回调 data 还原(自包含 song 字段),故不含专辑/时长等菜单外信息。"""
http, config = env http, config = env
api, calls = make_api() api, calls = make_api()
await handle_button( await handle_button(
@@ -299,7 +304,8 @@ async def test_pick_button_sends_card(env):
assert card["msg_type"] == 2 assert card["msg_type"] == 2
md = card["markdown"]["content"] md = card["markdown"]["content"]
assert "## 稻香 - 歌手T" in md 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 labels(card) == [["▶ 播放", "⬇ 下载"]]
assert [b["action"]["type"] for b in buttons(card)[0]["buttons"]] == [1, 1] assert [b["action"]["type"] for b in buttons(card)[0]["buttons"]] == [1, 1]
assert [b["action"]["data"] for b in buttons(card)[0]["buttons"]] == [ 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") 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): async def test_card_play_button_sends_voice(env):
"""卡片上的 [▶ 播放] 是回调按钮:点一下直接发语音。""" """卡片上的 [▶ 播放] 是回调按钮:点一下直接发语音。"""
http, config = env http, config = env
@@ -591,6 +618,51 @@ def test_config_send_mode(monkeypatch):
assert BotConfig.load().send_mode == "link" # 非法值不报错,按默认处理 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: def make_emitter(api, http, config) -> EventEmitter: