From 67776b00929675db00cdbe4389e0df799874b364 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:59:39 +0800 Subject: [PATCH] feat: hot reload for config/templates and GitPython-based hot update with pre-update notification --- alerter.py | 13 +++++++++ bot.py | 27 ++++++++++++++++++ config.example.toml | 10 +++++++ pyproject.toml | 3 +- templates.py | 12 +++++--- updater.py | 69 +++++++++++++++++++++++++++++++++++++++++++++ uv.lock | 35 +++++++++++++++++++++++ 7 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 updater.py diff --git a/alerter.py b/alerter.py index f584845..59df141 100644 --- a/alerter.py +++ b/alerter.py @@ -26,6 +26,19 @@ class Alerter: self._last_alert_ts = 0.0 self._task = None + def apply_config(self, cfg: dict): + """热重载告警参数(保留告警状态)。""" + self.interval = cfg.get("interval", self.interval) + new_threshold = cfg.get("cpu_threshold", self.threshold) + if new_threshold != self.threshold: + log.info("告警阈值变更: %.0f%% -> %.0f%%", self.threshold, new_threshold) + self.threshold = new_threshold + self.consecutive_need = cfg.get("consecutive", self.consecutive_need) + self.cooldown = cfg.get("cooldown", self.cooldown) + self.top_n = cfg.get("top_n", self.top_n) + self.groups = cfg.get("groups", self.groups) or [] + self.friends = cfg.get("friends", self.friends) or [] + def start(self): self._task = asyncio.get_running_loop().create_task(self._loop()) diff --git a/bot.py b/bot.py index 2d477f2..ba971f6 100644 --- a/bot.py +++ b/bot.py @@ -2,11 +2,13 @@ import asyncio import logging import sys +from pathlib import Path import monitor import templates from alerter import Alerter from onebot import OneBotServer +from updater import Updater logging.basicConfig( level=logging.INFO, @@ -203,15 +205,40 @@ class Bot: await self.ob.start(self.on_event) alerter = Alerter(self.ob, self.cfg.get("alert", {})) alerter.start() + updater = Updater(self.ob, self.cfg.get("update", {})) + if self.cfg.get("update", {}).get("enabled", False): + updater.start() + watcher = asyncio.get_running_loop().create_task(self._watch_config(alerter)) log.info("机器人就绪,等待 OneBot 实现端连接...") try: await asyncio.Event().wait() except (KeyboardInterrupt, asyncio.CancelledError): pass finally: + watcher.cancel() + await updater.stop() await alerter.stop() await self.ob.stop() + async def _watch_config(self, alerter: Alerter, interval: float = 5.0): + """监测 config.toml 修改并热重载(端口/token 等需重启的项不生效)。""" + path = Path("config.toml") + last = path.stat().st_mtime + while True: + await asyncio.sleep(interval) + try: + mtime = path.stat().st_mtime + if mtime == last: + continue + last = mtime + cfg = load_config() + alerter.apply_config(cfg.get("alert", {})) + log.info("配置已热重载 (端口/access_token 变更需重启生效)") + except FileNotFoundError: + continue + except Exception: + log.exception("配置热重载失败") + def main(): if sys.platform == "win32": diff --git a/config.example.toml b/config.example.toml index 5c0492d..b9ad1d0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -21,3 +21,13 @@ top_n = 5 # 提醒目标 groups = [123456789] friends = [] + +[update] +# Git 热更新:定期检查远端是否有新版本,更新前先推送通知,再拉取并自动重启 +enabled = true +# 检查间隔(秒) +interval = 300 +# 更新通知发送到哪些群/好友(留空则不发送通知,只更新重启) +groups = [] +friends = [] + diff --git a/pyproject.toml b/pyproject.toml index dbf951b..004c1e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ authors = [ requires-python = ">=3.12" dependencies = [ "aiohttp>=3.14.3", + "gitpython>=3.1.62", "psutil>=7.2.2", ] @@ -26,4 +27,4 @@ requires = ["setuptools>=75"] build-backend = "setuptools.build_meta" [tool.setuptools] -py-modules = ["bot", "onebot", "monitor", "alerter", "templates"] +py-modules = ["bot", "onebot", "monitor", "alerter", "templates", "updater"] diff --git a/templates.py b/templates.py index 4eadc11..4e95083 100644 --- a/templates.py +++ b/templates.py @@ -1,11 +1,12 @@ """模板渲染:从 templates/ 目录加载 .txt 模板并用 str.format 填充。 查找顺序:本模块所在目录、当前工作目录。 +文件按修改时间自动热重载,改动后无需重启。 """ from pathlib import Path _CANDIDATE_DIRS = [Path(__file__).parent / "templates", Path("templates")] -_cache: dict[str, str] = {} +_cache: dict[str, tuple[float, str]] = {} # name -> (mtime, content) def _dir() -> Path: @@ -16,6 +17,9 @@ def _dir() -> Path: def render(name: str, **kwargs) -> str: - if name not in _cache: - _cache[name] = (_dir() / f"{name}.txt").read_text(encoding="utf-8") - return _cache[name].format(**kwargs) + path = _dir() / f"{name}.txt" + mtime = path.stat().st_mtime + cached = _cache.get(name) + if cached is None or cached[0] != mtime: + _cache[name] = (mtime, path.read_text(encoding="utf-8")) + return _cache[name][1].format(**kwargs) diff --git a/updater.py b/updater.py new file mode 100644 index 0000000..72c7338 --- /dev/null +++ b/updater.py @@ -0,0 +1,69 @@ +"""Git 热更新:定期检查远端新版本,更新前先推送通知,再拉取并重启进程。 + +基于 GitPython。 +""" +import asyncio +import logging +import os +import sys + +import git + +log = logging.getLogger("updater") + + +class Updater: + def __init__(self, ob, cfg: dict): + self.ob = ob + self.interval = cfg.get("interval", 300) + self.groups = cfg.get("groups", []) or [] + self.friends = cfg.get("friends", []) or [] + self._updating = False + try: + self.repo = git.Repo(".") + except (git.InvalidGitRepositoryError, git.NoSuchPathError): + self.repo = None + + def start(self): + if self.repo is None: + log.info("非 git 仓库,热更新已禁用") + return + self._task = asyncio.get_running_loop().create_task(self._loop()) + + async def stop(self): + if hasattr(self, "_task"): + self._task.cancel() + + async def _loop(self): + log.info("热更新已启动: 每 %ds 检查一次远端", self.interval) + while True: + try: + await asyncio.sleep(self.interval) + if self.ob.connected and not self._updating: + await self.check_once() + except asyncio.CancelledError: + break + except Exception: + log.exception("热更新检查出错") + + async def check_once(self): + """检查远端;有更新则通知、拉取并重启进程。""" + self._updating = True + try: + await asyncio.to_thread(self.repo.remotes.origin.fetch) + refs = self.repo.remotes.origin.refs + remote = getattr(refs, "HEAD", None) or (refs[0] if refs else None) + if remote is None or remote.commit == self.repo.head.commit: + return + log.info("发现新版本: %s -> %s", + self.repo.head.commit.hexsha[:8], remote.commit.hexsha[:8]) + if self.ob.connected and (self.groups or self.friends): + await self.ob.broadcast( + "[更新] 检测到新版本,正在更新并重启机器人...", + self.groups, self.friends) + await asyncio.to_thread(self.repo.remotes.origin.pull, "--ff-only") + log.warning("更新完成,重启进程...") + await asyncio.sleep(1) + os.execv(sys.executable, [sys.executable] + sys.argv) + finally: + self._updating = False diff --git a/uv.lock b/uv.lock index 22eeb2f..fd45ee9 100644 --- a/uv.lock +++ b/uv.lock @@ -231,6 +231,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.62" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/db/3ca813cbacb23ab6fe46ff38a9b5ef8e73e970c8051f2ce903aacafe0446/gitpython-3.1.62.tar.gz", hash = "sha256:1791de66309bc0c7cfca40bf8d2e3de7ca091cbf94e6051be1ad0722c61062af", size = 231728, upload-time = "2026-09-07T02:57:21.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/0b/29d7965215f8ef830a7ca1f42997fe13e5693d85e9edb18f938d063ef5f2/gitpython-3.1.62-py3-none-any.whl", hash = "sha256:7002251225e10e29d2e1f49e6532613fe5d5d9f0b6f1f02997a52b38fe56899e", size = 222753, upload-time = "2026-09-07T02:57:19.762Z" }, +] + [[package]] name = "idna" version = "3.19" @@ -255,6 +279,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, + { name = "gitpython" }, { name = "psutil" }, ] @@ -267,6 +292,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.14.3" }, + { name = "gitpython", specifier = ">=3.1.62" }, { name = "psutil", specifier = ">=7.2.2" }, ] @@ -592,6 +618,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"