Files
HeXi/hexi/web_hub/__init__.py
T
sansenhoshiandClaude Code 51b08ccb68 feat: 群分析本地消息库 / 处理中占位图 / Web 日志窗口重构
- 群分析: 新增 history_store 只读本地消息源(读 learning_chat 落库,含 uninfo
  昵称补齐/去重/截断判定),适配器优先读本地库、失败回退 OneBot 分页;分页
  锚点字段回退链修复 NapCat 传 message_seq 翻页断裂;新增「本地记录」开关
- core/message_utils: 新增 common_proc_reply 占位图通用回复(引用消息 + 处理中
  动图,支持后台任务显式指定 target),群分析/战况/倒放改用
- web_hub + web: 日志页改固定窗口滚动 + 翻页锚定 + 自动换行,SSE 日志轮转发
  reset 帧,入口 HTML no-cache,行数统计增量缓存,CPU 改非阻塞采样,登录信息缓存
- 插件内 CLAUDE.md / DESIGN.md 不入库(.gitignore),galgame_card 两份文档取消
  跟踪(文件保留在磁盘)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:59:01 +08:00

729 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""统一 Web 管理系统(/hub):自动聚合所有注册过的 Web 插件。
- 插件在自己 __init__ 里调用 hexi.web_hub.web_plugin_registry.register_web_plugin(...) 注册
- hub 启动时自动挂载每个插件 API 到 /api/<id>,无需手动配置
- /hub/api/plugins 由注册中心聚合返回
"""
from __future__ import annotations
from pathlib import Path
import asyncio
import json
import logging as _logging
import os
import subprocess
import sys
import threading
import time
from fastapi import Depends, FastAPI, HTTPException, status
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 .web_auth import (
authenticate,
change_password,
get_current_user,
get_user_by_token,
issue_token,
oauth2_scheme,
revoke_token,
sync_admin,
)
from .web_plugin_registry import get_web_plugin_builder, list_web_plugins
from .dashboard import collect_dashboard
# hexi/web_hub 为机器人核心模块(统一 Web 管理台),非 NoneBot 插件
basic_path = Path(__file__).resolve().parent
# hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web
WEB_DIST = Path(__file__).resolve().parents[1] / "web" / "dist"
# 入口 HTML 的响应头:必须每次回源校验。否则重新构建后浏览器会沿用缓存的
# 旧 index.html(进而加载已被删除的旧 hashed bundle),Web 端一直跑旧代码。
_NO_CACHE = {"Cache-Control": "no-cache"}
def _hub_version() -> str:
"""管理台版本号:以 hexi/web/package.json 为唯一来源(前端构建也读它)。"""
try:
pkg = WEB_DIST.parent / "package.json"
return str(json.loads(pkg.read_text(encoding="utf-8")).get("version", "0.1.0"))
except Exception: # noqa: BLE001
return "0.1.0"
# 仓库根目录(bot.py 所在)与 bot 端口
_REPO_ROOT = Path(__file__).resolve().parents[2]
_BOT_PORT = 39697
# —— 日志清理策略(可用 .env 覆盖)——
# 单次运行时 _bot_run.log 超过该大小即直接清空(不归档),避免长期运行无限膨胀。
# 阈值读取优先级:HEXI_LOG_MAX_BYTES(字节) > HEXI_LOG_MAX_MB(MB) > 默认 20MB。
# 后台清理检查周期(秒)
_LOG_WATCH_INTERVAL = float(os.environ.get("HEXI_LOG_WATCH_INTERVAL", "60"))
# 清空互斥锁(防止后台线程与其它路径同时清空)
_LOG_LOCK = threading.Lock()
def _env_get(name: str, default: str = "") -> str:
"""读取配置:os.environ > .env 文件 > default。NoneBot 不会把 .env 注入 os.environ,故手动读取。"""
val = os.getenv(name)
if val:
return val
try:
env_path = _REPO_ROOT / ".env"
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()
except OSError:
pass
return default
def _log_max_bytes() -> int:
"""日志清理阈值(字节)。优先级:HEXI_LOG_MAX_BYTES > HEXI_LOG_MAX_MB > 默认 20MB。"""
raw = _env_get("HEXI_LOG_MAX_BYTES")
if raw:
try:
return max(1, int(raw))
except ValueError:
pass
mb = _env_get("HEXI_LOG_MAX_MB")
if mb:
try:
return max(1, int(float(mb)) * 1024 * 1024)
except ValueError:
pass
return 20 * 1024 * 1024
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 _purge_legacy_archives() -> None:
"""删除历史遗留的 bot_run_*.log 归档(策略改为不归档,仅保留当前 _bot_run.log)。"""
logs_dir = _REPO_ROOT / "hexi" / "logs"
try:
for p in logs_dir.glob("bot_run_*.log"):
try:
p.unlink()
except OSError:
pass
except OSError as e: # noqa: BLE001
logger.warning(f"清理历史归档日志失败: {type(e).__name__}: {e}")
def _clear_log() -> None:
"""把 _bot_run.log 截断清空(不归档)。写端均为 append 模式,截断后下次写入从头续写。"""
with _LOG_LOCK:
log = _REPO_ROOT / "_bot_run.log"
if not log.exists():
return
try:
open(log, "wb").close()
logger.info("_bot_run.log 超过阈值,已清空(不归档)。")
except OSError as e: # noqa: BLE001
logger.warning(f"清空日志失败: {type(e).__name__}: {e}")
def _rotate_log_if_needed() -> None:
"""当 _bot_run.log 超过阈值时直接清空(不归档),避免长期运行无限膨胀。"""
try:
log = _REPO_ROOT / "_bot_run.log"
if log.exists() and log.stat().st_size >= _log_max_bytes():
_clear_log()
except OSError as e: # noqa: BLE001
logger.warning(f"日志轮转检查失败: {type(e).__name__}: {e}")
def _start_log_watchdog() -> None:
"""后台线程按周期检查日志大小,超阈值直接清空(不归档),避免长期运行无限膨胀。"""
global _log_watchdog_started
if _log_watchdog_started:
return
_log_watchdog_started = True
_purge_legacy_archives() # 顺带清掉历史遗留的归档文件
def _loop() -> None:
while True:
time.sleep(_LOG_WATCH_INTERVAL)
_rotate_log_if_needed()
threading.Thread(target=_loop, name="hexi-log-watchdog", daemon=True).start()
_log_watchdog_started = False
def _tail_log_lines(
path: Path, limit: int, before: int | None
) -> tuple[list[str], int, int]:
"""从日志文件读取最近 limit 行(或 before 字节之前最近的 limit 行)。
返回 (lines, window_start_offset, file_size)。window_start_offset 可作为
下一批更早日志的 before;为 0 表示已到文件头。仅按 8KB 步进向后寻找行边界,
避免大日志全量 read_bytes。逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与
切换为 UTF-8 后的新日志混存(保留 ANSI 颜色码,交给前端渲染颜色)。
"""
size = path.stat().st_size
end = size if before is None or before <= 0 or before > size else int(before)
if end <= 0:
return [], 0, size
start = 0
with open(path, "rb") as f:
pos = end
newline_count = 0
while pos > 0 and newline_count <= limit:
step = min(pos, 8192)
f.seek(pos - step)
chunk = f.read(step)
idx = chunk.rfind(b"\n")
if idx == -1:
pos -= step
continue
nl_abs = pos - step + idx
newline_count += 1
if newline_count > limit:
start = nl_abs + 1
break
pos = nl_abs
f.seek(start)
raw = f.read(end - start)
raw_lines = raw.split(b"\n")
if raw_lines and raw_lines[-1] == b"":
raw_lines.pop()
lines = []
for raw_line in raw_lines:
try:
line = raw_line.decode("utf-8")
except UnicodeDecodeError:
line = raw_line.decode("gbk", errors="replace")
lines.append(line.rstrip("\r"))
return lines, start, size
# 行数统计缓存:路径 -> (已统计到的字节偏移, 行数)。日志只追加不重写,
# 因此每次只需扫「自上次统计以来新增的字节」,不必重扫整个文件。
_COUNT_CACHE: dict[str, tuple[int, int]] = {}
# SSE 单轮最多读取的字节数:避免日志突发时一次性把大量行灌给前端
_LOG_STREAM_CHUNK = 1 << 20
def _count_log_lines(path: Path) -> int:
"""统计全文件行数(仅换行计数,不全量载入)。
首次或检测到文件被截断时全量扫描;之后按增量累加——否则每次翻页
都要在事件循环里把整个日志(可达 20MB)重扫一遍。
"""
key = str(path)
size = path.stat().st_size
cached = _COUNT_CACHE.get(key)
if cached is not None:
done, count = cached
if done == size:
return count
if 0 < done < size:
with open(path, "rb") as f:
f.seek(done)
chunk = f.read()
count += chunk.count(b"\n")
_COUNT_CACHE[key] = (done + len(chunk), count)
return count
count = 0
with open(path, "rb") as f:
while True:
chunk = f.read(1 << 20)
if not chunk:
break
count += chunk.count(b"\n")
_COUNT_CACHE[key] = (size, count)
return count
def _schedule_bot_action(action: str) -> None:
"""1 秒后执行:stop 直接退出;restart 先拉起新进程再退出旧进程。"""
def _work() -> None:
time.sleep(1.0)
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
new_password: str
class PluginControlModel(BaseModel):
enabled: bool | None = None
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
log_max_mb: int | None = None
async def _first_bot():
"""取第一个 OneBot V11 bot,无则 None。"""
try:
from nonebot import get_adapter
bots = get_adapter(Adapter).bots
return next(iter(bots.values()), None)
except Exception: # noqa: BLE001
return None
def build_hub_app() -> FastAPI:
sync_admin()
app = FastAPI(title="HeXi Web Hub")
@app.post("/api/auth/token")
async def token_endpoint(form: OAuth2PasswordRequestForm = Depends()):
user_id = authenticate(form.username, form.password)
if user_id is None:
raise HTTPException(status_code=400, detail="用户名或密码错误")
token, expires_in = issue_token(user_id)
return {"access_token": token, "token_type": "bearer", "expires_in": expires_in}
@app.get("/api/auth/me")
async def me(user: dict = Depends(get_current_user)):
return {"ok": True, "username": user["username"]}
@app.post("/api/auth/logout")
async def logout(token: str = Depends(oauth2_scheme)):
if token and get_user_by_token(token):
revoke_token(token)
return {"ok": True}
@app.get("/api/plugins")
async def plugins(_: dict = Depends(get_current_user)):
return {"plugins": list_web_plugins()}
@app.get("/api/settings")
async def settings(user: dict = Depends(get_current_user)):
return {
"ok": True,
"user": {
"id": user["id"],
"username": user["username"],
"created_at": user.get("created_at", ""),
},
"plugins": list_web_plugins(),
"version": _hub_version(),
}
@app.get("/api/dashboard")
async def dashboard(_: dict = Depends(get_current_user)):
try:
data = await collect_dashboard()
except Exception as e: # noqa: BLE001
logger.warning(f"Dashboard 采集失败: {type(e).__name__}: {e}")
return {"ok": False, "msg": f"采集失败: {e}"}
return {"ok": True, **data}
@app.get("/api/plugins/catalog")
async def plugins_catalog(_: dict = Depends(get_current_user)):
"""全部 application/library 插件(含带配置项的 library)+ 控制面状态 + 是否带 Web 页面。"""
from hexi.core.plugin_control import list_plugins
from .web_config import has_schema
from .web_plugin_registry import get_web_plugin_by_module
items = list_plugins()
for it in items:
web = get_web_plugin_by_module(it["id"])
it["has_web"] = bool(web)
it["web_path"] = "/hub/" + web["id"] if web else None
it["has_config"] = has_schema(it["id"])
return {"plugins": items}
@app.get("/api/groups")
async def groups(_: dict = Depends(get_current_user)):
"""OneBot 当前群列表(分群控制用)。"""
bot = await _first_bot()
if bot is None:
return {"items": []}
try:
gl = await bot.get_group_list()
except Exception as e: # noqa: BLE001
logger.warning(f"获取群列表失败: {type(e).__name__}: {e}")
return {"items": []}
return {
"items": [
{"group_id": int(g["group_id"]), "group_name": g.get("group_name", "")}
for g in gl
]
}
@app.post("/api/plugins/{plugin_id}/global")
async def plugin_global(
plugin_id: str,
payload: PluginControlModel,
_: dict = Depends(get_current_user),
):
from hexi.core.plugin_control import set_global
ctl = set_global(plugin_id, enabled=payload.enabled, chat=payload.chat)
return {"ok": True, "control": ctl}
@app.post("/api/plugins/{plugin_id}/groups/{group_id}")
async def plugin_group(
plugin_id: str,
group_id: str,
payload: PluginControlModel,
_: dict = Depends(get_current_user),
):
from hexi.core.plugin_control import set_group
ctl = set_group(
plugin_id, group_id, enabled=payload.enabled, chat=payload.chat
)
return {"ok": True, "control": ctl}
@app.delete("/api/plugins/{plugin_id}/groups/{group_id}")
async def plugin_group_remove(
plugin_id: str,
group_id: str,
_: dict = Depends(get_current_user),
):
from hexi.core.plugin_control import remove_group
ctl = remove_group(plugin_id, group_id)
return {"ok": True, "control": ctl}
@app.get("/api/plugins/{plugin_id}/config")
async def plugin_config_get(
plugin_id: str, _: dict = Depends(get_current_user)
):
from .web_config import get_config
cfg = get_config(plugin_id)
if cfg is None:
return {"ok": False, "msg": "该插件未注册配置 schema"}
return {"ok": True, **cfg}
@app.post("/api/plugins/{plugin_id}/config")
async def plugin_config_set(
plugin_id: str,
payload: dict,
_: dict = Depends(get_current_user),
):
from .web_config import save_config
payload = payload or {}
if "revision" not in payload:
raise HTTPException(
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
detail="缺少配置 revision,请先读取最新配置",
)
values = payload.get("values") or {}
try:
cfg = save_config(
plugin_id, values, expected_revision=int(payload["revision"])
)
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
except (TypeError, ValueError) as e:
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 .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 .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,
token: str = Depends(oauth2_scheme),
user: dict = Depends(get_current_user),
):
if len(payload.new_password) < 6:
raise HTTPException(status_code=400, detail="新密码长度不能少于 6 位")
if not change_password(
user["id"], payload.old_password, payload.new_password, keep_token=token
):
raise HTTPException(status_code=400, detail="原密码错误")
return {"ok": True, "msg": "密码已修改"}
@app.get("/api/logs")
async def logs(
limit: int = 500,
before: int | None = None,
_: dict = Depends(get_current_user),
):
"""读取 bot 运行日志(优先 _bot_run.log)。
- before 不传:返回最近 limit 行,offset/end 为文件尾(SSE 以 end 续读)
- before 传字节偏移:返回该偏移之前最近 limit 行,offset 为这些行的起点,
继续把 offset 传给 before 即可向前翻页;offset==0 表示已到文件头。
"""
root = Path(__file__).resolve().parents[2] # 仓库根目录
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:
lines, start, size = _tail_log_lines(path, max(1, int(limit)), before)
total = _count_log_lines(path)
except OSError as e: # noqa: BLE001
return {"ok": False, "msg": f"读取日志失败: {e}", "lines": [], "size": 0, "total": 0}
return {
"ok": True,
"file": path.name,
"size": size,
"end": size,
"offset": start,
"has_more": start > 0,
"lines": lines,
"total": total,
}
@app.get("/api/dashboard/stream")
async def dashboard_stream(
_: dict = Depends(get_current_user),
):
"""Dashboard 状态 SSE 实时推送(每 5 秒采集一次)。"""
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(5)
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(字节偏移)增量推送新日志行。
检测到日志被轮转/清空(since 超出当前文件大小)时发一帧 {"reset": true}
让前端丢掉已失效的旧窗口,然后从新文件末尾继续——不能回退到 0,
否则重连的客户端会把整个文件重放一遍(20MB ≈ 十万行)。
"""
root = Path(__file__).resolve().parents[2] # 仓库根目录
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 = size
yield 'data: {"reset": true}\n\n'
if current < size:
try:
with open(path, "rb") as f:
f.seek(current)
raw = f.read(_LOG_STREAM_CHUNK)
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"
elif len(raw) >= _LOG_STREAM_CHUNK:
# 单行超过单轮读取上限:整块丢弃,否则游标永远不前进
current += len(raw)
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")
@app.get("/")
async def index():
if (WEB_DIST / "index.html").exists():
# no-cache:入口 HTML 必须每次回源校验,否则重新构建后浏览器会继续
# 用缓存的旧 index.html 去加载已被替换掉的旧 hashed bundle(dist/assets)。
return FileResponse(WEB_DIST / "index.html", headers=_NO_CACHE)
return HTMLResponse(
"<h1>HeXi Web Hub</h1><p>前端未构建,请在 <code>hexi/web</code> 执行 "
"<code>npm run build</code>。</p>"
)
@app.get("/{path:path}")
async def spa(path: str):
target = (WEB_DIST / path).resolve()
if path and target.is_file() and target.is_relative_to(WEB_DIST.resolve()):
return FileResponse(target)
if (WEB_DIST / "index.html").exists():
return FileResponse(WEB_DIST / "index.html", headers=_NO_CACHE)
raise HTTPException(status_code=404, detail="页面不存在")
return app
def _mount_all_web_plugins() -> None:
"""启动时挂载所有已注册插件的 API 到 /api/<id>。"""
try:
from nonebot import get_app
for plugin in list_web_plugins():
builder = get_web_plugin_builder(plugin["id"])
if not builder:
continue
try:
sub_app = builder()
if sub_app is not None:
get_app().mount("/api/" + plugin["id"], sub_app)
logger.info(f"Web 插件 API 已挂载: /api/{plugin['id']}")
except Exception as e: # noqa: BLE001
logger.warning(f"Web 插件 {plugin['id']} 挂载失败: {type(e).__name__}: {e}")
except Exception as e: # noqa: BLE001
logger.warning(f"Web 插件自动挂载失败: {type(e).__name__}: {e}")
def mount_hub() -> None:
try:
from nonebot import get_app
get_app().mount("/hub", build_hub_app())
logger.info("统一 Web 管理系统已挂载: /hub")
except Exception as e: # noqa: BLE001
logger.warning(f"统一 Web 管理系统挂载失败: {type(e).__name__}: {e}")
mount_hub()
_start_log_watchdog()
@get_driver().on_startup
async def _startup_mount_web_plugins() -> None:
"""等所有插件 import 完成后,注册表就绪,再统一挂载各插件 API。"""
_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)