feat: hot reload for config/templates and GitPython-based hot update with pre-update notification

This commit is contained in:
snowy-yang committed 2026-09-12 19:59:39 +08:00
1 parent 8775c022bd
commit 67776b0092
7 files changed
+164 -5

No files matched your search

+13
View File
@@ -26,6 +26,19 @@ class Alerter:
self._last_alert_ts = 0.0 self._last_alert_ts = 0.0
self._task = None 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): def start(self):
self._task = asyncio.get_running_loop().create_task(self._loop()) self._task = asyncio.get_running_loop().create_task(self._loop())
+27
View File
@@ -2,11 +2,13 @@
import asyncio import asyncio
import logging import logging
import sys import sys
from pathlib import Path
import monitor import monitor
import templates import templates
from alerter import Alerter from alerter import Alerter
from onebot import OneBotServer from onebot import OneBotServer
from updater import Updater
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -203,15 +205,40 @@ class Bot:
await self.ob.start(self.on_event) await self.ob.start(self.on_event)
alerter = Alerter(self.ob, self.cfg.get("alert", {})) alerter = Alerter(self.ob, self.cfg.get("alert", {}))
alerter.start() 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 实现端连接...") log.info("机器人就绪,等待 OneBot 实现端连接...")
try: try:
await asyncio.Event().wait() await asyncio.Event().wait()
except (KeyboardInterrupt, asyncio.CancelledError): except (KeyboardInterrupt, asyncio.CancelledError):
pass pass
finally: finally:
watcher.cancel()
await updater.stop()
await alerter.stop() await alerter.stop()
await self.ob.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(): def main():
if sys.platform == "win32": if sys.platform == "win32":
+10
View File
@@ -21,3 +21,13 @@ top_n = 5
# 提醒目标 # 提醒目标
groups = [123456789] groups = [123456789]
friends = [] friends = []
[update]
# Git 热更新:定期检查远端是否有新版本,更新前先推送通知,再拉取并自动重启
enabled = true
# 检查间隔(秒)
interval = 300
# 更新通知发送到哪些群/好友(留空则不发送通知,只更新重启)
groups = []
friends = []
+2 -1
View File
@@ -9,6 +9,7 @@ authors = [
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"aiohttp>=3.14.3", "aiohttp>=3.14.3",
"gitpython>=3.1.62",
"psutil>=7.2.2", "psutil>=7.2.2",
] ]
@@ -26,4 +27,4 @@ requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.setuptools] [tool.setuptools]
py-modules = ["bot", "onebot", "monitor", "alerter", "templates"] py-modules = ["bot", "onebot", "monitor", "alerter", "templates", "updater"]
+8 -4
View File
@@ -1,11 +1,12 @@
"""模板渲染:从 templates/ 目录加载 .txt 模板并用 str.format 填充。 """模板渲染:从 templates/ 目录加载 .txt 模板并用 str.format 填充。
查找顺序:本模块所在目录、当前工作目录。 查找顺序:本模块所在目录、当前工作目录。
文件按修改时间自动热重载,改动后无需重启。
""" """
from pathlib import Path from pathlib import Path
_CANDIDATE_DIRS = [Path(__file__).parent / "templates", Path("templates")] _CANDIDATE_DIRS = [Path(__file__).parent / "templates", Path("templates")]
_cache: dict[str, str] = {} _cache: dict[str, tuple[float, str]] = {} # name -> (mtime, content)
def _dir() -> Path: def _dir() -> Path:
@@ -16,6 +17,9 @@ def _dir() -> Path:
def render(name: str, **kwargs) -> str: def render(name: str, **kwargs) -> str:
if name not in _cache: path = _dir() / f"{name}.txt"
_cache[name] = (_dir() / f"{name}.txt").read_text(encoding="utf-8") mtime = path.stat().st_mtime
return _cache[name].format(**kwargs) 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)
+69
View File
@@ -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
Generated
+35
View File
@@ -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" }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.19" version = "3.19"
@@ -255,6 +279,7 @@ version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "gitpython" },
{ name = "psutil" }, { name = "psutil" },
] ]
@@ -267,6 +292,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiohttp", specifier = ">=3.14.3" }, { name = "aiohttp", specifier = ">=3.14.3" },
{ name = "gitpython", specifier = ">=3.1.62" },
{ name = "psutil", specifier = ">=7.2.2" }, { 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" }, { 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]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.16.0" version = "4.16.0"