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 图片语法 `![封面 #240px #240px](img)` 显示封面(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"![封面 #240px #240px]({song.img})") 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 "![封面 #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: