feat: hot reload for config/templates and GitPython-based hot update with pre-update notification
This commit is contained in:
1 parent
8775c022bd
commit
67776b0092
7 files changed
+164
-5
No files matched your search
+13
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -21,3 +21,13 @@ top_n = 5
|
|||||||
# 提醒目标
|
# 提醒目标
|
||||||
groups = [123456789]
|
groups = [123456789]
|
||||||
friends = []
|
friends = []
|
||||||
|
|
||||||
|
[update]
|
||||||
|
# Git 热更新:定期检查远端是否有新版本,更新前先推送通知,再拉取并自动重启
|
||||||
|
enabled = true
|
||||||
|
# 检查间隔(秒)
|
||||||
|
interval = 300
|
||||||
|
# 更新通知发送到哪些群/好友(留空则不发送通知,只更新重启)
|
||||||
|
groups = []
|
||||||
|
friends = []
|
||||||
|
|
||||||
+2
-1
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user