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:
2026-09-03 00:44:38 +08:00
co-authored by Claude
parent 25bc30611f
commit 9371a28e35
2389 changed files with 5100 additions and 10127 deletions
+254 -2
View File
@@ -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,
}