diff --git a/.env.example b/.env.example index ee6974c..219759f 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,7 @@ SEND_MODE=link # 日志同时输出到控制台与 LOG_DIR/musicbot.log(按天轮转、保留 7 天) LOG_LEVEL=INFO LOG_DIR=logs + +# 热重载(开发用):on 时监控源码与 intents.toml/.env,变更后自动重启 bot。 +# 默认 off;也可用命令行 `musicbot --reload` 开启(start.bat 已带该参数)。 +RELOAD=off diff --git a/.gitignore b/.gitignore index 8ba214b..30d62ee 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ __pycache__/ # 编辑器 .vscode/ .zcode/ + +# 研究/调试临时产物 +_*.html diff --git a/README.md b/README.md index 3c37a97..33a5384 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两 - **直链候选源回退**:tx 等源直链常受 CDN 风控抓不动(bot 抓不动时 QQ 服务器侧上传同样报 40093007),上传前先探测直链可抓性,不可抓则按 kw/wy/mg 逐个搜索换源 - **自动撤回**(`WITHDRAW`,默认开):翻页撤旧菜单、选歌后撤菜单、下载/播放点击后撤卡片(无论发送成败都撤,卡片是一次性 UI);关闭后所有消息都保留 - **日志**:控制台 + `LOG_DIR/musicbot.log`(按天轮转、保留 7 天),级别由 `LOG_LEVEL` 控制;目录不可写时自动退化为仅控制台 +- **热重载(开发用)**:监控 `musicbot/` 下的 `.py` 与 `intents.toml`/`.env`,变更后重启 bot 子进程(重连一次 websocket)。默认关,`uv run musicbot --reload`(`start.bat` 已带)或 `RELOAD=on` 开启 - 按钮回调需 `intents.toml` 开启 `INTERACTION`(本仓库已开启) ## 配置 @@ -26,6 +27,7 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两 | `SEND_MODE` | `link` | 下载按钮:`link` 直接发直链 / `file` 先传群文件(需群主/管理员) | | `LOG_LEVEL` | `INFO` | 日志级别 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL | | `LOG_DIR` | `logs` | 日志目录(自动按天轮转、保留 7 天) | +| `RELOAD` | `off` | 热重载;`on` 等价于命令行 `--reload`(`start.bat` 已带) | ### 不 @ 机器人也能点歌 @@ -38,9 +40,12 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两 ```bash uv sync -uv run musicbot +uv run musicbot # 正常启动 +uv run musicbot --reload # 开发:改源码/配置自动重启 ``` +Windows 上双击 `start.bat` 即以热重载模式启动。 + ## 开发 ```bash uv run pytest diff --git a/musicbot/config.py b/musicbot/config.py index 8bca2d4..46919c8 100644 --- a/musicbot/config.py +++ b/musicbot/config.py @@ -38,6 +38,7 @@ class BotConfig: send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链 log_level: str = "INFO" # 控制台与文件日志级别 log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台 + reload: bool = False # 热重载(开发用;--reload 亦可开启) @classmethod def load(cls) -> "BotConfig": @@ -51,6 +52,7 @@ class BotConfig: send_mode=_send_mode(), log_level=_log_level(), log_dir=Path(os.getenv("LOG_DIR", "logs")), + reload=_flag("RELOAD", False), ) diff --git a/musicbot/main.py b/musicbot/main.py index 005b5ac..f916a01 100644 --- a/musicbot/main.py +++ b/musicbot/main.py @@ -1,12 +1,15 @@ -"""装配入口:配置日志、注册命令并运行 bot。""" +"""装配入口:配置日志、注册命令并运行 bot(可选热重载)。""" +import argparse import asyncio import sys +from pathlib import Path from aiohttp import ClientSession from loguru import logger from qqbotsdk import EventEmitter, run_loop +from . import reload from .config import BotConfig from .music import register @@ -52,7 +55,35 @@ async def amain() -> None: await run_loop(ee) -def main() -> None: +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(prog="musicbot", description="QQ 群点歌 bot") + parser.add_argument( + "--reload", + action="store_true", + help="开发用:监控源码与配置,变更后自动重启(等价于 RELOAD=on)", + ) + return parser.parse_args(argv) + + +def _project_root() -> Path: + """项目根:包目录的上一级(flat 布局)。找不到 pyproject.toml 时退回当前 + 工作目录,避免安装成非 editable 包时监视到 site-packages。""" + root = Path(__file__).resolve().parent.parent + return root if (root / "pyproject.toml").is_file() else Path.cwd() + + +def main(argv: list[str] | None = None) -> None: + args = _parse_args(argv) + config = BotConfig.load() + # 子进程只跑 bot,绝不再套一层监督(否则 --reload 会无限自我复制) + reload_on = (args.reload or config.reload) and not reload.is_child() + if reload_on: + # 监控与子进程 cwd 都用项目根:intents.toml/.env 都在那里, + # 保证「监控的树」与「实际加载的配置」一致 + root = _project_root() + # 监督者不配置日志:子进程自己会配,避免两边抢写同一个日志文件 + code = reload.run_supervisor([sys.executable, "-m", "musicbot"], root) + sys.exit(code) try: asyncio.run(amain()) except KeyboardInterrupt: diff --git a/musicbot/reload.py b/musicbot/reload.py new file mode 100644 index 0000000..680ab40 --- /dev/null +++ b/musicbot/reload.py @@ -0,0 +1,122 @@ +"""开发用热重载:监控源码与配置文件,变更后重启 bot 子进程。 + +进程级重启(而非进程内模块重载):bot 是长连接 + 模块级状态 + handler +闭包,就地重载要清理 pyee 监听器与旧闭包,容易留下幽灵 handler;重启 +子进程则状态与闭包都是干净的,代价是重连一次 websocket。 + +监督进程只负责拉起/杀掉子进程与轮询文件改动,自身不跑 bot;子进程由 +`_MUSICBOT_RELOAD_CHILD` 环境变量标记,不会再套一层监督(`is_child`)。 +""" + +import os +import subprocess +import sys +import time +from collections.abc import Iterable +from pathlib import Path + +from loguru import logger + +# 子进程标记:带上它启动的 musicbot 只跑 bot,不再做文件监督 +CHILD_ENV = "_MUSICBOT_RELOAD_CHILD" + +# 轮询间隔(秒):够快地感知保存动作,又不至于空耗 CPU +_POLL_INTERVAL = 1.0 + +# 监控的文件类型(源码与订阅/环境配置——两者都在启动时读取,改了需重启) +_WATCH_SUFFIXES = (".py", ".toml") + +# 不监控的目录:日志、缓存与虚拟环境 +_SKIP_DIRS = frozenset( + {".venv", "__pycache__", ".git", ".pytest_cache", ".ruff_cache", "logs"} +) + + +def is_child() -> bool: + """当前进程是否是监督者拉起的 bot 子进程。""" + return os.environ.get(CHILD_ENV) == "1" + + +def iter_watched_files(root: Path) -> Iterable[Path]: + """产出 root 下需要监控的文件:.py/.toml 源码配置,外加 .env。 + + `.env` 无后缀,按文件名单独匹配;跳过日志/缓存/虚拟环境目录。 + """ + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + if any(part in _SKIP_DIRS for part in path.parts): + continue + if path.suffix in _WATCH_SUFFIXES or path.name == ".env": + yield path + + +def snapshot(root: Path) -> dict[Path, float]: + """取被监控文件的 (路径 → mtime) 快照;读不到时以 0 标记。""" + snap: dict[Path, float] = {} + for path in iter_watched_files(root): + try: + snap[path] = path.stat().st_mtime + except OSError: + snap[path] = 0.0 + return snap + + +def changed_files(before: dict[Path, float], after: dict[Path, float]) -> list[Path]: + """两次快照间的变更(新增/删除/修改)文件列表。""" + changed = {p for p in set(before) | set(after) if before.get(p) != after.get(p)} + return sorted(changed) + + +def _spawn(cmd: list[str], root: Path) -> subprocess.Popen: + return subprocess.Popen(cmd, cwd=root, env={**os.environ, CHILD_ENV: "1"}) + + +def _terminate(proc: subprocess.Popen) -> None: + """结束子进程及其后代(bot 可能再拉起 Python 子进程)。""" + if proc.poll() is not None: + return + if sys.platform == "win32": + # terminate() 只结束直接子进程,/T 连整棵进程树一起收掉 + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(proc.pid)], + capture_output=True, + check=False, + ) + else: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + logger.warning("子进程未在 10s 内退出,强制结束") + proc.kill() + + +def run_supervisor(cmd: list[str], root: Path) -> int: + """跑 bot 并在被监控文件变化时重启它,返回子进程退出码。 + + cmd 为子进程启动命令(无需 --reload,由 CHILD_ENV 标记取代)。 + """ + watched = len(list(iter_watched_files(root))) + logger.info(f"热重载已开启,监控 {watched} 个文件(源码 + intents.toml/.env)") + + while True: + logger.info("启动 bot 子进程") + proc = _spawn(cmd, root) + try: + before = snapshot(root) + while proc.poll() is None: + time.sleep(_POLL_INTERVAL) + after = snapshot(root) + touched = changed_files(before, after) + if touched: + names = ", ".join(str(p.relative_to(root)) for p in touched[:5]) + tail = " 等" if len(touched) > 5 else "" + logger.info(f"检测到变更({names}{tail}),重启") + break + else: + # 子进程自行退出(含 Ctrl-C 透传):不重启,原样返回退出码 + return proc.returncode or 0 + finally: + # 正常退出时是空操作;重启与 Ctrl-C 中断时回收子进程,避免孤儿 + _terminate(proc) diff --git a/start.bat b/start.bat index a5b7e2b..d89332a 100644 --- a/start.bat +++ b/start.bat @@ -1 +1 @@ -uv run musicbot \ No newline at end of file +uv run musicbot --reload \ No newline at end of file diff --git a/tests/test_music.py b/tests/test_music.py index b04234c..adb490b 100644 --- a/tests/test_music.py +++ b/tests/test_music.py @@ -663,6 +663,101 @@ def test_setup_logging_writes_file(tmp_path: Path): lg.remove() +def test_config_reload_flag(monkeypatch): + """RELOAD 默认关(生产不受影响),on 可开启,非法值按关处理。""" + from musicbot.config import BotConfig + + monkeypatch.delenv("RELOAD", raising=False) + assert BotConfig.load().reload is False + + monkeypatch.setenv("RELOAD", "on") + assert BotConfig.load().reload is True + + monkeypatch.setenv("RELOAD", "随便写") + assert BotConfig.load().reload is False + + +# ---- 热重载:文件监听与进程监督 ---- + +def test_watched_files_and_skip_rules(tmp_path: Path): + """监控 .py/.toml 与 .env;跳过 logs/、__pycache__/、.venv/ 与无关文件。""" + from musicbot import reload + + (tmp_path / "musicbot").mkdir() + (tmp_path / "musicbot" / "music.py").write_text("", encoding="utf-8") + (tmp_path / "tests").mkdir() + (tmp_path / "tests" / "test_x.py").write_text("", encoding="utf-8") + (tmp_path / "intents.toml").write_text("", encoding="utf-8") + (tmp_path / ".env").write_text("", encoding="utf-8") + (tmp_path / "README.md").write_text("", encoding="utf-8") # 无关后缀 + (tmp_path / "logs").mkdir() + (tmp_path / "logs" / "musicbot.log").write_text("", encoding="utf-8") + (tmp_path / "musicbot" / "__pycache__").mkdir() + (tmp_path / "musicbot" / "__pycache__" / "x.py").write_text("", encoding="utf-8") + + watched = reload.iter_watched_files(tmp_path) + names = {p.relative_to(tmp_path).as_posix() for p in watched} + assert names == {"musicbot/music.py", "tests/test_x.py", "intents.toml", ".env"} + + +def test_changed_files_detects_edit_add_remove(tmp_path: Path): + """快照比对:改动/新增/删除都能识别,未变则返回空。""" + from musicbot import reload + + f = tmp_path / "musicbot" + f.mkdir() + mod = f / "music.py" + mod.write_text("a", encoding="utf-8") + + before = reload.snapshot(tmp_path) + assert reload.changed_files(before, reload.snapshot(tmp_path)) == [] + + mod.write_text("bb", encoding="utf-8") + assert reload.changed_files(before, reload.snapshot(tmp_path)) == [mod] + + +def test_is_child_guard(monkeypatch): + """子进程标记生效:带标记的进程不再做监督,避免 --reload 无限自我复制。""" + from musicbot import reload + + monkeypatch.delenv(reload.CHILD_ENV, raising=False) + assert reload.is_child() is False + monkeypatch.setenv(reload.CHILD_ENV, "1") + assert reload.is_child() is True + + +def test_main_child_does_not_supervise(monkeypatch): + """子进程即使带 --reload / RELOAD=on 也只跑 bot,不起监督 + (否则 --reload 会无限自我复制)。""" + from musicbot import main as mb_main + from musicbot import reload + + called: dict[str, bool] = {} + + async def fake_amain() -> None: + called["bot"] = True + + monkeypatch.setattr(reload, "is_child", lambda: True) + monkeypatch.setattr(mb_main, "amain", fake_amain) + monkeypatch.setattr( + reload, "run_supervisor", + lambda *a, **k: called.setdefault("supervisor", True), + ) + monkeypatch.setattr(mb_main.BotConfig, "load", staticmethod( + lambda: replace( + BotConfig( + music_server_url="http://127.0.0.1:1", + music_quality="128k", + music_source="tx", + ), + reload=True, + ) + )) + + mb_main.main(["--reload"]) + assert called == {"bot": True} # 跑的是 bot,没有起监督 + + # ---- 全量模式(不 @ 也能用)与去重 ---- def make_emitter(api, http, config) -> EventEmitter: