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)