refactor: restructure plugins per MTSS standard, consolidate assets to res/
Per docs/plugin-audit-report.md (plugins normalized to Trigger(handlers) → Service(services) → Model(repository/models) + utils): - Split monolithic __init__.py into handlers/services/utils across dailywife, deer_pipe, dice, galgame_card, helldivers_tools, huoziyinshua, learning_chat, makeaquote, mc_server_status, ncm_saying, picfinder_take, picstatus, random_jm_code, regif, steam_info, video_analysis, group_tools - Move static assets under res/: deer_pipe font/img, makeaquote font, helldivers img/templates, huoziyinshua HuoZiYinShua - Add config.py + register_config_items to ncm_saying, random_jm_code, group_tools; learning_chat unified config bridge - Remove deprecated: voice_trans plugin, bf_bot/test.py, dead code in dailywife/deer_pipe, empty dirs, debug scripts under helldivers temp - Disable brash_general_supercredits_tools (stub comment only) - bot.py: optional stdout/stderr redirect to log file for Web log viewer, force ANSI colorize on non-TTY sinks - Move runtime data (jm_code.json) out of plugin dir into hexi/data - Docs: plugin-audit-report.md; README reflects removed plugins Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,15 +9,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import json
|
||||
import logging as _logging
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, status
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from nonebot import get_driver, logger
|
||||
from nonebot.adapters.onebot.v11 import Adapter
|
||||
from nonebot.plugin import PluginMetadata
|
||||
|
||||
from hexi.web_auth import (
|
||||
authenticate,
|
||||
@@ -33,6 +43,13 @@ from hexi.web_plugin_registry import get_web_plugin_builder, list_web_plugins
|
||||
|
||||
from .dashboard import collect_dashboard
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="HeXi Web Hub",
|
||||
description="统一 Web 管理系统(/hub):自动聚合所有注册过的 Web 插件",
|
||||
usage="/hub 进入管理台",
|
||||
type="application",
|
||||
)
|
||||
|
||||
basic_path = Path(__file__).resolve().parent
|
||||
# hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web
|
||||
WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist"
|
||||
@@ -40,6 +57,88 @@ WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist"
|
||||
# 管理台自身版本号(展示在设置页「关于」处)
|
||||
HUB_VERSION = "0.1.0"
|
||||
|
||||
# 仓库根目录(bot.py 所在)与 bot 端口
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
_BOT_PORT = 39697
|
||||
|
||||
|
||||
def _spawn_bot() -> None:
|
||||
"""以分离进程方式拉起 bot.py:等旧实例释放端口后接管,日志追加到 _bot_run.log。"""
|
||||
try:
|
||||
log_fp = open(_REPO_ROOT / "_bot_run.log", "ab", buffering=0)
|
||||
except OSError:
|
||||
log_fp = None
|
||||
# 子进程 bootstrap:等端口空闲 -> 直接以 __main__ 运行 bot.py(该进程即 bot,不残留 wrapper)
|
||||
bootstrap = (
|
||||
"import socket,time,sys,runpy\n"
|
||||
"port=%d\n" % _BOT_PORT
|
||||
+ "for _ in range(120):\n"
|
||||
" s=socket.socket()\n"
|
||||
" try:\n"
|
||||
" s.bind(('0.0.0.0',port)); s.close(); break\n"
|
||||
" except OSError:\n"
|
||||
" s.close(); time.sleep(0.5)\n"
|
||||
"else:\n"
|
||||
" sys.exit(2)\n"
|
||||
"runpy.run_path('bot.py', run_name='__main__')\n"
|
||||
)
|
||||
flags = 0
|
||||
if hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
||||
flags |= subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
if hasattr(subprocess, "DETACHED_PROCESS"):
|
||||
flags |= subprocess.DETACHED_PROCESS
|
||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-c", bootstrap],
|
||||
cwd=str(_REPO_ROOT),
|
||||
stdout=log_fp,
|
||||
stderr=subprocess.STDOUT,
|
||||
creationflags=flags,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _archive_log() -> None:
|
||||
"""把当前 _bot_run.log 归档到 hexi/logs/bot_run_<时间戳>.log,并把原文件清空(供新运行)。"""
|
||||
log = _REPO_ROOT / "_bot_run.log"
|
||||
if not log.exists():
|
||||
return
|
||||
logs_dir = _REPO_ROOT / "hexi" / "logs"
|
||||
try:
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
data = log.read_bytes()
|
||||
if data:
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
target = logs_dir / f"bot_run_{ts}.log"
|
||||
target.write_bytes(data)
|
||||
logger.info(f"已归档旧日志 -> {target.name}")
|
||||
# 清空原日志,新 run 从空文件开始
|
||||
open(log, "wb").close()
|
||||
except OSError as e: # noqa: BLE001
|
||||
logger.warning(f"归档日志失败: {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
def _schedule_bot_action(action: str) -> None:
|
||||
"""1 秒后执行:stop 直接退出;restart 先拉起新进程再退出旧进程。"""
|
||||
|
||||
def _work() -> None:
|
||||
time.sleep(1.0)
|
||||
_archive_log()
|
||||
if action == "restart":
|
||||
_spawn_bot()
|
||||
time.sleep(1.0)
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_work, daemon=True).start()
|
||||
|
||||
|
||||
def _uvicorn_access_debug(record: _logging.LogRecord) -> bool:
|
||||
"""把 uvicorn 访问日志级别改为 DEBUG:默认 INFO 下不显示,开启 DEBUG 才可见。"""
|
||||
if record.name == "uvicorn.access":
|
||||
record.levelno = _logging.DEBUG
|
||||
record.levelname = "DEBUG"
|
||||
return True
|
||||
|
||||
|
||||
class PasswordModel(BaseModel):
|
||||
old_password: str
|
||||
@@ -51,6 +150,13 @@ class PluginControlModel(BaseModel):
|
||||
chat: list[str] | None = None
|
||||
|
||||
|
||||
class BotConfigModel(BaseModel):
|
||||
superusers: list[str] | None = None
|
||||
nickname: list[str] | None = None
|
||||
debug: bool | None = None
|
||||
log_level: str | None = None
|
||||
|
||||
|
||||
async def _first_bot():
|
||||
"""取第一个 OneBot V11 bot,无则 None。"""
|
||||
try:
|
||||
@@ -216,6 +322,27 @@ def build_hub_app() -> FastAPI:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) from e
|
||||
return {"ok": True, **cfg}
|
||||
|
||||
@app.get("/api/system/bot_config")
|
||||
async def bot_config_get(_: dict = Depends(get_current_user)):
|
||||
"""读取 bot 全局配置(SUPERUSERS / NICKNAME / DEBUG / LOG_LEVEL)。"""
|
||||
from hexi.plugins.nonebot_plugin_web_hub.bot_settings import current_values
|
||||
|
||||
return {"ok": True, **current_values()}
|
||||
|
||||
@app.post("/api/system/bot_config")
|
||||
async def bot_config_set(
|
||||
payload: BotConfigModel,
|
||||
_: dict = Depends(get_current_user),
|
||||
):
|
||||
"""保存 bot 全局配置并尽量热应用到运行态。"""
|
||||
from hexi.plugins.nonebot_plugin_web_hub.bot_settings import save_bot_settings
|
||||
|
||||
try:
|
||||
result = save_bot_settings(payload.dict(exclude_none=True))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
return result
|
||||
|
||||
@app.post("/api/settings/password")
|
||||
async def settings_password(
|
||||
payload: PasswordModel,
|
||||
@@ -230,6 +357,125 @@ def build_hub_app() -> FastAPI:
|
||||
raise HTTPException(status_code=400, detail="原密码错误")
|
||||
return {"ok": True, "msg": "密码已修改"}
|
||||
|
||||
@app.get("/api/logs")
|
||||
async def logs(
|
||||
limit: int = 500,
|
||||
_: dict = Depends(get_current_user),
|
||||
):
|
||||
"""实时读取 bot 运行日志(优先 _bot_run.log),返回最近 limit 行,供前端轮询。"""
|
||||
root = Path(__file__).resolve().parents[3] # 仓库根目录
|
||||
candidates = [
|
||||
root / "_bot_run.log",
|
||||
root / "hexi" / "logs" / "bot.log",
|
||||
]
|
||||
path = next((p for p in candidates if p.exists()), None)
|
||||
if path is None:
|
||||
return {"ok": False, "msg": "未找到日志文件", "lines": [], "size": 0, "total": 0}
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as e: # noqa: BLE001
|
||||
return {"ok": False, "msg": f"读取日志失败: {e}", "lines": [], "size": 0, "total": 0}
|
||||
# 逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与切换为 UTF-8 后的新日志混存
|
||||
# 保留 ANSI 颜色码,交给前端渲染颜色
|
||||
lines = []
|
||||
for raw_line in raw.split(b"\n"):
|
||||
try:
|
||||
line = raw_line.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
line = raw_line.decode("gbk", errors="replace")
|
||||
lines.append(line.rstrip("\r"))
|
||||
tail = lines[-max(1, int(limit)) :]
|
||||
return {"ok": True, "file": path.name, "size": len(raw), "offset": len(raw), "lines": tail, "total": len(lines)}
|
||||
|
||||
@app.get("/api/dashboard/stream")
|
||||
async def dashboard_stream(
|
||||
_: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Dashboard 状态 SSE 实时推送(每 2 秒采集一次)。"""
|
||||
|
||||
async def gen():
|
||||
while True:
|
||||
try:
|
||||
data = await collect_dashboard()
|
||||
payload = {"ok": True, **data}
|
||||
except Exception as e: # noqa: BLE001
|
||||
payload = {"ok": False, "msg": f"采集失败: {e}"}
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
await asyncio.sleep(2)
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
@app.get("/api/logs/stream")
|
||||
async def logs_stream(
|
||||
since: int = 0,
|
||||
_: dict = Depends(get_current_user),
|
||||
):
|
||||
"""SSE 实时日志流:从 since(字节偏移)增量推送新日志行。"""
|
||||
root = Path(__file__).resolve().parents[3] # 仓库根目录
|
||||
candidates = [
|
||||
root / "_bot_run.log",
|
||||
root / "hexi" / "logs" / "bot.log",
|
||||
]
|
||||
path = next((p for p in candidates if p.exists()), None)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="未找到日志文件")
|
||||
|
||||
async def gen():
|
||||
current = max(0, since)
|
||||
beat = 0
|
||||
await asyncio.sleep(0)
|
||||
while True:
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
if current > size:
|
||||
current = 0 # 日志被轮转/截断
|
||||
if current < size:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
f.seek(current)
|
||||
raw = f.read()
|
||||
except OSError:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
last_nl = raw.rfind(b"\n")
|
||||
if last_nl != -1:
|
||||
# 只处理完整行,结尾半行留给下次;保留 ANSI 颜色码供前端渲染
|
||||
data = raw[: last_nl + 1]
|
||||
raw_lines = data.split(b"\n")
|
||||
if raw_lines and raw_lines[-1] == b"":
|
||||
raw_lines = raw_lines[:-1]
|
||||
current = current + last_nl + 1
|
||||
for raw_line in raw_lines:
|
||||
try:
|
||||
line = raw_line.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
line = raw_line.decode("gbk", errors="replace")
|
||||
payload = {"line": line.rstrip("\r"), "offset": current}
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
await asyncio.sleep(0.5)
|
||||
beat += 1
|
||||
if beat >= 30: # 每 ~15s 一次心跳保活
|
||||
beat = 0
|
||||
yield ": ping\n\n"
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
@app.post("/api/system/bot")
|
||||
async def bot_action(
|
||||
payload: dict,
|
||||
_: dict = Depends(get_current_user),
|
||||
):
|
||||
"""停止/重启 bot 进程(stop 直接退出;restart 先拉起新进程再退出旧进程)。"""
|
||||
action = (payload or {}).get("action", "")
|
||||
if action not in {"stop", "restart"}:
|
||||
raise HTTPException(status_code=400, detail="action 必须是 stop 或 restart")
|
||||
_schedule_bot_action(action)
|
||||
label = "停止" if action == "stop" else "重启"
|
||||
return {"ok": True, "msg": f"已请求{label} bot,约 1 秒后执行"}
|
||||
|
||||
assets = WEB_DIST / "assets"
|
||||
if assets.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(assets)), name="hub_assets")
|
||||
@@ -291,4 +537,10 @@ mount_hub()
|
||||
@get_driver().on_startup
|
||||
async def _startup_mount_web_plugins() -> None:
|
||||
"""等所有插件 import 完成后,注册表就绪,再统一挂载各插件 API。"""
|
||||
_mount_all_web_plugins()
|
||||
_mount_all_web_plugins()
|
||||
|
||||
|
||||
@get_driver().on_startup
|
||||
async def _downgrade_uvicorn_access_logs() -> None:
|
||||
"""把 uvicorn.access 日志降级为 DEBUG(经 loguru 后按日志等级过滤)。"""
|
||||
_logging.getLogger("uvicorn.access").addFilter(_uvicorn_access_debug)
|
||||
@@ -0,0 +1,200 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Bot 全局配置的读取 / 写入(SUPERUSERS / NICKNAME / DEBUG / LOG_LEVEL)。
|
||||
|
||||
- 读取:优先取 NoneBot driver.config 的当前生效值,退化到 .env / os.environ。
|
||||
- 写入:落盘到 .env(保留 JSON 列表格式),写 os.environ(即时可读),
|
||||
并在可能范围内热应用到 driver.config 与 loguru(LOG_LEVEL)。
|
||||
- 注意:个别插件在 import 时把 SUPERUSERS/NICKNAME 拷贝成了常量,
|
||||
想让所有插件完全生效建议保存后重启 bot(前端会给出提示)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nonebot import get_driver, logger
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
# 仓库根目录的 .env(NoneBot 启动时自动加载)
|
||||
_ENV_PATH = _REPO_ROOT / ".env"
|
||||
|
||||
# 表单键 -> 环境变量名
|
||||
_KEY_ENV: dict[str, str] = {
|
||||
"superusers": "SUPERUSERS",
|
||||
"nickname": "NICKNAME",
|
||||
"debug": "DEBUG",
|
||||
"log_level": "LOG_LEVEL",
|
||||
}
|
||||
|
||||
# LOG_LEVEL 可选项(写入时建议大写)
|
||||
LOG_LEVEL_OPTIONS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
|
||||
|
||||
def _env_get(name: str, default: str = "") -> str:
|
||||
"""os.environ > .env 文件 > default。"""
|
||||
val = os.getenv(name)
|
||||
if val:
|
||||
return val
|
||||
if _ENV_PATH.exists():
|
||||
for line in _ENV_PATH.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith(name + "=") or line.startswith(name + " ="):
|
||||
return line.split("=", 1)[1].strip().split(" #", maxsplit=1)[0].strip()
|
||||
return default
|
||||
|
||||
|
||||
def _parse_list(value: str) -> list[str]:
|
||||
"""把 env 里可能是 JSON 数组或逗号/空白分隔的列表解析成 list[str]。"""
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, list):
|
||||
return [str(x) for x in parsed]
|
||||
return [str(parsed)]
|
||||
except Exception:
|
||||
# 兼容逗号/空格分隔、带引号的形式
|
||||
return [
|
||||
part.strip().strip("\"'").strip()
|
||||
for part in value.replace(" ", ",").split(",")
|
||||
if part.strip()
|
||||
]
|
||||
|
||||
|
||||
def _serialize_list(items: list[str]) -> str:
|
||||
"""序列化成 .env 使用的 JSON 数组字符串。"""
|
||||
return json.dumps([str(x) for x in items], ensure_ascii=False)
|
||||
|
||||
|
||||
def _normalize_log_level(value: Any) -> str:
|
||||
"""把 log_level 统一成大写字符串。"""
|
||||
if isinstance(value, int):
|
||||
# loguru 数字等级:20=DEBUG 30=INFO 40=WARNING 50=ERROR
|
||||
mapping = {10: "TRACE", 20: "DEBUG", 30: "INFO", 40: "WARNING", 50: "ERROR", 60: "CRITICAL"}
|
||||
return mapping.get(value, "INFO")
|
||||
return str(value).upper()
|
||||
|
||||
|
||||
def current_values() -> dict[str, Any]:
|
||||
"""返回当前生效值(读取优先级:driver.config > .env)。"""
|
||||
try:
|
||||
cfg = get_driver().config
|
||||
except Exception:
|
||||
cfg = None
|
||||
|
||||
if cfg is not None:
|
||||
superusers = sorted(str(x) for x in getattr(cfg, "superusers", []) or [])
|
||||
nickname = sorted(str(x) for x in getattr(cfg, "nickname", []) or [])
|
||||
debug = bool(getattr(cfg, "debug", False))
|
||||
log_level = _normalize_log_level(getattr(cfg, "log_level", "INFO"))
|
||||
else:
|
||||
superusers = _parse_list(_env_get("SUPERUSERS"))
|
||||
nickname = _parse_list(_env_get("NICKNAME"))
|
||||
debug = _env_get("DEBUG", "").strip().lower() in ("1", "true", "yes", "on")
|
||||
log_level = _normalize_log_level(_env_get("LOG_LEVEL", "INFO"))
|
||||
|
||||
return {
|
||||
"superusers": superusers,
|
||||
"nickname": nickname,
|
||||
"debug": bool(debug),
|
||||
"log_level": log_level,
|
||||
"log_level_options": LOG_LEVEL_OPTIONS,
|
||||
"source": "config" if cfg is not None else "env",
|
||||
}
|
||||
|
||||
|
||||
def _set_env_line(name: str, value: str) -> None:
|
||||
"""替换 .env 中已有的 name= 行(保留其它行),不存在则追加。"""
|
||||
try:
|
||||
if _ENV_PATH.exists():
|
||||
lines = _ENV_PATH.read_text(encoding="utf-8").splitlines()
|
||||
else:
|
||||
lines = []
|
||||
replaced = False
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(name + "=") or stripped.startswith(name + " ="):
|
||||
if not replaced:
|
||||
out.append(f"{name}={value}")
|
||||
replaced = True
|
||||
# 重复行跳过
|
||||
else:
|
||||
out.append(line)
|
||||
if not replaced:
|
||||
out.append(f"{name}={value}")
|
||||
out_text = "\n".join(out).rstrip("\n") + "\n"
|
||||
_ENV_PATH.write_text(out_text, encoding="utf-8")
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"写入 .env({name}) 失败: {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
def _apply_log_level(log_level: str) -> None:
|
||||
"""把 LOG_LEVEL 热应用到 loguru 的默认过滤配置。"""
|
||||
try:
|
||||
from nonebot.log import logger as nb_logger
|
||||
|
||||
nb_logger.configure(extra={"nonebot_log_level": log_level})
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"热应用 LOG_LEVEL 失败: {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""保存并尽量热应用。返回(新的当前值 + 提示)。"""
|
||||
# 规范化输入
|
||||
superusers = [str(x) for x in (values.get("superusers") or []) if str(x).strip()]
|
||||
nickname = [str(x) for x in (values.get("nickname") or []) if str(x).strip()]
|
||||
debug = bool(values.get("debug", False))
|
||||
log_level = _normalize_log_level(values.get("log_level", "INFO"))
|
||||
if log_level not in LOG_LEVEL_OPTIONS:
|
||||
raise ValueError(f"LOG_LEVEL 必须是 {'/'.join(LOG_LEVEL_OPTIONS)} 之一")
|
||||
|
||||
# 1. 写 os.environ(即时可读,进程内 getenv 立刻生效)
|
||||
os.environ["SUPERUSERS"] = _serialize_list(superusers)
|
||||
os.environ["NICKNAME"] = _serialize_list(nickname)
|
||||
os.environ["DEBUG"] = "true" if debug else "false"
|
||||
os.environ["LOG_LEVEL"] = log_level
|
||||
|
||||
# 2. 落盘 .env(重启后依然生效)
|
||||
_set_env_line("SUPERUSERS", _serialize_list(superusers))
|
||||
_set_env_line("NICKNAME", _serialize_list(nickname))
|
||||
_set_env_line("DEBUG", "true" if debug else "false")
|
||||
_set_env_line("LOG_LEVEL", log_level)
|
||||
|
||||
# 3. 热应用到 driver.config
|
||||
applied: list[str] = []
|
||||
try:
|
||||
cfg = get_driver().config
|
||||
if hasattr(cfg, "superusers"):
|
||||
cfg.superusers = set(superusers)
|
||||
applied.append("SUPERUSERS")
|
||||
if hasattr(cfg, "nickname"):
|
||||
cfg.nickname = set(nickname)
|
||||
applied.append("NICKNAME")
|
||||
# DEBUG 在 NoneBot Config 中属于 extra 字段,直接透传
|
||||
try:
|
||||
setattr(cfg, "debug", debug)
|
||||
applied.append("DEBUG")
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(cfg, "log_level"):
|
||||
cfg.log_level = log_level
|
||||
applied.append("LOG_LEVEL")
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"热应用 driver.config 失败: {type(e).__name__}: {e}")
|
||||
|
||||
# 4. LOG_LEVEL 应用到 loguru
|
||||
_apply_log_level(log_level)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
**current_values(),
|
||||
"applied": applied,
|
||||
"restart_required": True,
|
||||
"notice": "已保存到 .env 并尽量热应用;部分插件在 import 时缓存了 SUPERUSERS/NICKNAME,"
|
||||
"如需完全生效,请在下方执行「重启 bot」。",
|
||||
}
|
||||
@@ -23,6 +23,9 @@ except Exception: # pragma: no cover
|
||||
|
||||
_MODULE_START = time.time()
|
||||
|
||||
# 网络速率采样:记录上一次累计计数与时间,用于计算实时 bytes/s 用量
|
||||
_NET_LAST: dict = {"time": None, "sent": None, "recv": None}
|
||||
|
||||
|
||||
async def _collect_cpu() -> dict:
|
||||
try:
|
||||
@@ -91,6 +94,95 @@ async def _collect_process() -> dict:
|
||||
return info
|
||||
|
||||
|
||||
async def _collect_network() -> dict:
|
||||
"""采集网络流量累计值 + 实时速率(bytes/s)。"""
|
||||
global _NET_LAST
|
||||
now = time.time()
|
||||
try:
|
||||
io = psutil.net_io_counters(pernic=False)
|
||||
except Exception:
|
||||
return {
|
||||
"sent": 0,
|
||||
"recv": 0,
|
||||
"sent_rate": 0,
|
||||
"recv_rate": 0,
|
||||
"packets_sent": 0,
|
||||
"packets_recv": 0,
|
||||
"interfaces": [],
|
||||
}
|
||||
sent = int(io.bytes_sent)
|
||||
recv = int(io.bytes_recv)
|
||||
sent_rate = recv_rate = 0.0
|
||||
last = _NET_LAST
|
||||
if last["time"] is not None and last["sent"] is not None:
|
||||
dt = max(0.001, now - float(last["time"]))
|
||||
sent_rate = max(0.0, (sent - int(last["sent"])) / dt)
|
||||
recv_rate = max(0.0, (recv - int(last["recv"])) / dt)
|
||||
_NET_LAST = {"time": now, "sent": sent, "recv": recv}
|
||||
|
||||
# 逐网卡明细(含 IP / 友好名称)
|
||||
interfaces: list[dict] = []
|
||||
try:
|
||||
pernic = psutil.net_io_counters(pernic=True)
|
||||
addrs = psutil.net_if_addrs()
|
||||
stats = psutil.net_if_stats()
|
||||
for name, cnt in pernic.items():
|
||||
if not cnt.bytes_sent and not cnt.bytes_recv:
|
||||
continue
|
||||
ips = []
|
||||
for addr in addrs.get(name, []):
|
||||
ip = (addr.address or "").strip()
|
||||
# 只保留 IPv4(带点分十进制),忽略 MAC / IPv6
|
||||
if ip and ":" not in ip and ip.count(".") >= 3:
|
||||
ips.append(ip)
|
||||
up = bool(stats.get(name).isup) if name in stats else True
|
||||
interfaces.append({
|
||||
"name": name,
|
||||
"up": up,
|
||||
"sent": int(cnt.bytes_sent),
|
||||
"recv": int(cnt.bytes_recv),
|
||||
"ip": ", ".join(ips) if ips else "",
|
||||
})
|
||||
except Exception:
|
||||
interfaces = []
|
||||
|
||||
return {
|
||||
"sent": sent,
|
||||
"recv": recv,
|
||||
"sent_rate": round(sent_rate, 1),
|
||||
"recv_rate": round(recv_rate, 1),
|
||||
"packets_sent": int(io.packets_sent),
|
||||
"packets_recv": int(io.packets_recv),
|
||||
"interfaces": interfaces,
|
||||
}
|
||||
|
||||
|
||||
async def _collect_backend() -> dict:
|
||||
"""BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数。"""
|
||||
from nonebot import get_loaded_plugins
|
||||
|
||||
try:
|
||||
plugin_count = len(get_loaded_plugins())
|
||||
except Exception:
|
||||
plugin_count = 0
|
||||
info: dict = {
|
||||
"nonebot_version": _nonebot_version(),
|
||||
"plugin_count": plugin_count,
|
||||
"python": f"{platform.python_implementation()} {platform.python_version()}",
|
||||
"driver_type": "",
|
||||
"driver": "",
|
||||
}
|
||||
try:
|
||||
from nonebot import get_driver
|
||||
|
||||
driver = get_driver()
|
||||
info["driver_type"] = getattr(driver, "type", "") or ""
|
||||
info["driver"] = getattr(driver.config, "driver", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
return info
|
||||
|
||||
|
||||
async def _collect_system() -> dict:
|
||||
boot = psutil.boot_time()
|
||||
now = time.time()
|
||||
@@ -180,13 +272,15 @@ async def _collect_bots() -> list:
|
||||
|
||||
async def collect_dashboard() -> dict:
|
||||
"""聚合采集,失败字段降级,不阻塞整体返回。"""
|
||||
cpu, memory, process, system, disk, bots = await asyncio.gather(
|
||||
cpu, memory, process, system, disk, bots, network, backend = await asyncio.gather(
|
||||
_collect_cpu(),
|
||||
_collect_memory(),
|
||||
_collect_process(),
|
||||
_collect_system(),
|
||||
_collect_disk(),
|
||||
_collect_bots(),
|
||||
_collect_network(),
|
||||
_collect_backend(),
|
||||
)
|
||||
return {
|
||||
"bots": bots,
|
||||
@@ -195,4 +289,6 @@ async def collect_dashboard() -> dict:
|
||||
"memory": memory,
|
||||
"process": process,
|
||||
"disk": disk,
|
||||
"network": network,
|
||||
"backend": backend,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user