结构调整
视频解析多图/多媒体结构 消息体适配
This commit is contained in:
@@ -0,0 +1,686 @@
|
||||
# -*- 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"
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _count_log_lines(path: Path) -> int:
|
||||
"""分块统计全文件行数(仅换行计数,不全量载入)。"""
|
||||
count = 0
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
count += chunk.count(b"\n")
|
||||
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 实时推送(每 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[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 = 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")
|
||||
|
||||
@app.get("/")
|
||||
async def index():
|
||||
if (WEB_DIST / "index.html").exists():
|
||||
return FileResponse(WEB_DIST / "index.html")
|
||||
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")
|
||||
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)
|
||||
@@ -0,0 +1,243 @@
|
||||
# -*- 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[2]
|
||||
# 仓库根目录的 .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"]
|
||||
|
||||
# 日志清理大小(MB)默认值(未在 .env 配置时)
|
||||
_LOG_MAX_MB_DEFAULT = 20
|
||||
|
||||
|
||||
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_log_max_mb() -> int:
|
||||
"""当前日志清理大小(MB)。优先级:HEXI_LOG_MAX_MB > HEXI_LOG_MAX_BYTES(换算) > 默认。"""
|
||||
raw = _env_get("HEXI_LOG_MAX_MB").strip()
|
||||
if raw:
|
||||
try:
|
||||
val = float(raw)
|
||||
if val > 0:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
pass
|
||||
raw_bytes = _env_get("HEXI_LOG_MAX_BYTES").strip()
|
||||
if raw_bytes:
|
||||
try:
|
||||
b = int(raw_bytes)
|
||||
if b > 0:
|
||||
return max(1, b // (1024 * 1024))
|
||||
except ValueError:
|
||||
pass
|
||||
return _LOG_MAX_MB_DEFAULT
|
||||
|
||||
|
||||
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,
|
||||
"log_max_mb": _current_log_max_mb(),
|
||||
"log_max_mb_default": _LOG_MAX_MB_DEFAULT,
|
||||
"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)} 之一")
|
||||
|
||||
# 日志清理大小(MB,写入 .env 的 HEXI_LOG_MAX_MB)
|
||||
log_max_mb: int | None = None
|
||||
_log_max_mb_raw = values.get("log_max_mb")
|
||||
if _log_max_mb_raw is not None and str(_log_max_mb_raw).strip() != "":
|
||||
try:
|
||||
log_max_mb = int(float(_log_max_mb_raw))
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("日志清理大小必须是正整数(MB)")
|
||||
if log_max_mb <= 0:
|
||||
raise ValueError("日志清理大小必须是正整数(MB)")
|
||||
|
||||
# 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
|
||||
if log_max_mb is not None:
|
||||
os.environ["HEXI_LOG_MAX_MB"] = str(log_max_mb)
|
||||
|
||||
# 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)
|
||||
if log_max_mb is not None:
|
||||
_set_env_line("HEXI_LOG_MAX_MB", str(log_max_mb))
|
||||
|
||||
# 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)
|
||||
if log_max_mb is not None:
|
||||
applied.append("HEXI_LOG_MAX_MB")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
**current_values(),
|
||||
"applied": applied,
|
||||
"restart_required": True,
|
||||
"notice": "已保存到 .env 并尽量热应用;部分插件在 import 时缓存了 SUPERUSERS/NICKNAME,"
|
||||
"如需完全生效,请在下方执行「重启 bot」。",
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""统一配置标准辅助:把插件 pydantic Config 模型一键接入 hexi.web_hub.web_config。
|
||||
|
||||
这个模块是对 hexi.web_hub.web_config 的薄封装,目的是把「插件配置标准」统一起来:
|
||||
- 插件只需声明一份字段列表(或交给字段推断),就能在 Web 管理台可读可设置。
|
||||
- 提供 getter(回填当前生效值)与 apply(保存后热更新 pydantic 对象)。
|
||||
|
||||
约定:
|
||||
- plugin_id 必须是 NoneBot 插件模块名(与 plugin_control.list_plugins 一致),
|
||||
这样 Web 的 /api/plugins/<id>/config 才能命中。
|
||||
- 字段 key 就是 Config 模型里的属性名;env 名默认等于 key.upper()。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
from types import UnionType
|
||||
from typing import Any, Callable, Iterable, Literal, Optional, Union, get_args, get_origin
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .web_config import register_plugin_config
|
||||
|
||||
# 常见「密码/密钥」类字段名,前端会自动脱敏
|
||||
_SECRET_HINTS = ("password", "secret", "token", "key", "cookie", "apikey", "api_key")
|
||||
|
||||
|
||||
def _is_secret(key: str) -> bool:
|
||||
kl = key.lower()
|
||||
return any(h in kl for h in _SECRET_HINTS)
|
||||
|
||||
|
||||
def _unwrap_optional(annotation: Any) -> tuple[Any, bool]:
|
||||
"""剥掉 Optional/Union[..., None] 包装,返回 (真实类型, 是否可选)。"""
|
||||
is_optional = False
|
||||
origin = get_origin(annotation)
|
||||
if origin is Union or origin is UnionType:
|
||||
args = get_args(annotation)
|
||||
non_none = [a for a in args if a is not type(None)]
|
||||
if len(non_none) != len(args):
|
||||
is_optional = True
|
||||
if non_none:
|
||||
return non_none[0], is_optional
|
||||
return annotation, is_optional
|
||||
|
||||
|
||||
def _type_label(annotation: Any) -> str:
|
||||
"""把 pydantic 字段类型映射到 web_config 支持的 schema type。"""
|
||||
annotation, _ = _unwrap_optional(annotation)
|
||||
if annotation is bool:
|
||||
return "bool"
|
||||
if annotation is int:
|
||||
return "int"
|
||||
if annotation is float:
|
||||
return "float"
|
||||
if annotation is str:
|
||||
return "string"
|
||||
try:
|
||||
origin = get_origin(annotation)
|
||||
except Exception: # noqa: BLE001
|
||||
origin = None
|
||||
if origin is Literal:
|
||||
return "enum"
|
||||
if origin in (list, set, tuple):
|
||||
return "array"
|
||||
return "string"
|
||||
|
||||
|
||||
def _enum_options(annotation: Any) -> list[dict[str, str]]:
|
||||
annotation, _ = _unwrap_optional(annotation)
|
||||
try:
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
if origin is Literal:
|
||||
return [{"value": str(o), "label": str(o)} for o in args]
|
||||
return []
|
||||
|
||||
|
||||
def _text_item_type(annotation: Any) -> str:
|
||||
"""List[int]/List[float]/List[str] 的元素类型,用于 text 字段的取值转换。"""
|
||||
annotation, _ = _unwrap_optional(annotation)
|
||||
try:
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
except Exception: # noqa: BLE001
|
||||
return "str"
|
||||
if origin in (list, set, tuple) and args:
|
||||
el, _ = _unwrap_optional(args[0])
|
||||
if el is int:
|
||||
return "int"
|
||||
if el is float:
|
||||
return "float"
|
||||
if el is bool:
|
||||
return "bool"
|
||||
return "str"
|
||||
|
||||
|
||||
def _coerce_value(value: Any, typ: str, item_type: str = "str") -> Any:
|
||||
"""按 schema type 把前端字符串/值转成目标 python 值。"""
|
||||
if value is None:
|
||||
return None
|
||||
if typ == "bool":
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
if typ == "int":
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if typ == "float":
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if typ in {"text", "array"}:
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
items = list(value)
|
||||
else:
|
||||
# 数组字段的字符串输入:多行/逗号分隔都接受
|
||||
import re as _re
|
||||
parts = _re.split(r"[,\n]+", str(value))
|
||||
items = [p.strip() for p in parts if p.strip()]
|
||||
if item_type == "int":
|
||||
out = []
|
||||
for it in items:
|
||||
try:
|
||||
out.append(int(it))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
if item_type == "float":
|
||||
out = []
|
||||
for it in items:
|
||||
try:
|
||||
out.append(float(it))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
return items
|
||||
if typ == "json":
|
||||
# 前端提交的是 JSON 字符串,解析成 dict/list;已解析则原样返回
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return value
|
||||
if typ == "object_set":
|
||||
# 前端提交的要么是字符串 JSON,要么已是 list[dict]
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except ValueError:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return []
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _stringify(value: Any, typ: str) -> Any:
|
||||
"""把 python 值转成 Web 表单能显示的值。"""
|
||||
if typ == "array":
|
||||
# 数组字段直接回传数组,前端用行编辑器(增删行)渲染;
|
||||
# 兼容旧数据:换行/逗号分隔的字符串按行拆成数组
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [str(v) for v in value]
|
||||
import re as _re
|
||||
parts = _re.split(r"[,\n]+", str(value))
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
if typ == "text":
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return "\n".join(str(v) for v in value)
|
||||
return str(value)
|
||||
if typ == "json":
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (dict, list, tuple)):
|
||||
return json.dumps(value, ensure_ascii=False, indent=2)
|
||||
return str(value)
|
||||
# object_set 保持结构化(list[dict])原样返回,前端用专用表格组件渲染
|
||||
return value
|
||||
|
||||
|
||||
def register_model_config(
|
||||
plugin_id: str,
|
||||
config_instance: BaseModel,
|
||||
fields: Optional[Iterable[str]] = None,
|
||||
labels: Optional[dict[str, str]] = None,
|
||||
descriptions: Optional[dict[str, str]] = None,
|
||||
options: Optional[dict[str, list[dict[str, str]]]] = None,
|
||||
types: Optional[dict[str, str]] = None,
|
||||
apply_extra: Optional[Callable[[dict[str, Any], BaseModel], None]] = None,
|
||||
getter_extra: Optional[Callable[[BaseModel], dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""把 pydantic Config 模型注册进统一 Web 配置标准。
|
||||
|
||||
Args:
|
||||
plugin_id: NoneBot 插件模块名。
|
||||
config_instance: 插件模块级 pydantic Config 实例(如 `.config.config`)。
|
||||
fields: 需要暴露的字段名。缺省=全部非私有字段(自动推断)。
|
||||
labels/descriptions/options: 可选的中文透出/提示/枚举覆盖。
|
||||
apply_extra: 可选的额外热应用回调(如需要重注册定时任务)。
|
||||
getter_extra: 可选的额外取值(如会额外返回非模型字段)。
|
||||
"""
|
||||
try:
|
||||
hints = inspect.get_annotations(type(config_instance), eval_str=True)
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
hints = {
|
||||
k: f.annotation
|
||||
for k, f in config_instance.__class__.model_fields.items()
|
||||
}
|
||||
except Exception: # noqa: BLE001
|
||||
hints = {}
|
||||
|
||||
try:
|
||||
defaults = {
|
||||
k: f.default
|
||||
for k, f in config_instance.__class__.model_fields.items()
|
||||
}
|
||||
except Exception: # noqa: BLE001
|
||||
defaults = {}
|
||||
|
||||
field_names = list(fields) if fields else [k for k in hints if not k.startswith("_")]
|
||||
schema_fields: list[dict[str, Any]] = []
|
||||
for key in field_names:
|
||||
annotation = hints.get(key, str)
|
||||
typ = (types or {}).get(key) or _type_label(annotation)
|
||||
default = defaults.get(key)
|
||||
field: dict[str, Any] = {
|
||||
"key": key,
|
||||
"label": (labels or {}).get(key, key),
|
||||
"type": typ,
|
||||
"default": _stringify(default, typ),
|
||||
"description": (descriptions or {}).get(key, ""),
|
||||
"secret": _is_secret(key),
|
||||
}
|
||||
if typ == "enum":
|
||||
field["options"] = (options or {}).get(key) or _enum_options(annotation)
|
||||
if typ in {"text", "array"}:
|
||||
field["item_type"] = _text_item_type(annotation)
|
||||
schema_fields.append(field)
|
||||
|
||||
schema = {"fields": schema_fields}
|
||||
|
||||
def _getter() -> dict[str, Any]:
|
||||
values: dict[str, Any] = {}
|
||||
for field in schema_fields:
|
||||
key = field["key"]
|
||||
try:
|
||||
raw = getattr(config_instance, key)
|
||||
except Exception: # noqa: BLE001
|
||||
raw = defaults.get(key)
|
||||
values[key] = _stringify(raw, field["type"])
|
||||
if getter_extra:
|
||||
values.update(getter_extra(config_instance))
|
||||
return values
|
||||
|
||||
def _apply(values: dict[str, Any]) -> None:
|
||||
for field in schema_fields:
|
||||
key = field["key"]
|
||||
if key not in values:
|
||||
continue
|
||||
coerced = _coerce_value(values[key], field["type"], field.get("item_type", "str"))
|
||||
try:
|
||||
setattr(config_instance, key, coerced)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if apply_extra:
|
||||
apply_extra(values, config_instance)
|
||||
|
||||
register_plugin_config(
|
||||
plugin_id,
|
||||
schema,
|
||||
apply=_apply,
|
||||
getter=_getter,
|
||||
)
|
||||
|
||||
def register_config_items(
|
||||
plugin_id: str,
|
||||
items: list[dict[str, Any]],
|
||||
store: Optional[Any] = None,
|
||||
apply_extra: Optional[Callable[[dict[str, Any], Any], None]] = None,
|
||||
getter_extra: Optional[Callable[[Any], dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""注册「来源无关」的任意配置项(schema + getter/setter),供 Web 读/改。
|
||||
|
||||
与 register_model_config 的区别:这里每个配置项可以来自任意来源:
|
||||
- 模块常量(module attr)
|
||||
- pydantic Config(字段)
|
||||
- 配置文件(dict / JSON / YAML)
|
||||
- 数据库/运行态状态
|
||||
|
||||
Args:
|
||||
plugin_id: NoneBot 插件模块名。
|
||||
items: 配置项列表,每个 dict 需含 key/label/type,可选:
|
||||
default/description/secret/options/item_type/env,以及 getter/setter。
|
||||
若某 item 未提供 getter/setter,则读写 store:
|
||||
- store 是 dict -> store[key]
|
||||
- store 是 ModuleType -> getattr/setattr(store, key)
|
||||
store: 缺省 getter/setter 时的回退存储(dict 或模块)。
|
||||
apply_extra: 保存后用额外回调做持久化/热加载等。
|
||||
getter_extra: getter 额外返回的非 items 值。
|
||||
"""
|
||||
from types import ModuleType
|
||||
|
||||
def _item_getter(item: dict[str, Any]) -> Any:
|
||||
getter = item.get("getter")
|
||||
if getter is not None:
|
||||
try:
|
||||
return getter()
|
||||
except Exception: # noqa: BLE001
|
||||
return item.get("default")
|
||||
if store is not None:
|
||||
if isinstance(store, dict):
|
||||
return store.get(item["key"], item.get("default"))
|
||||
if isinstance(store, ModuleType):
|
||||
try:
|
||||
return getattr(store, item["key"])
|
||||
except AttributeError:
|
||||
return item.get("default")
|
||||
return item.get("default")
|
||||
|
||||
def _item_setter(item: dict[str, Any], value: Any) -> None:
|
||||
setter = item.get("setter")
|
||||
if setter is not None:
|
||||
try:
|
||||
setter(value)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return
|
||||
if store is not None:
|
||||
if isinstance(store, dict):
|
||||
store[item["key"]] = value
|
||||
elif isinstance(store, ModuleType):
|
||||
try:
|
||||
setattr(store, item["key"], value)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
schema_fields: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
field: dict[str, Any] = {
|
||||
"key": item["key"],
|
||||
"label": item.get("label", item["key"]),
|
||||
"type": item.get("type", "string"),
|
||||
"default": _stringify(item.get("default"), item.get("type", "string")),
|
||||
"description": item.get("description", ""),
|
||||
"secret": item.get("secret", _is_secret(item["key"])),
|
||||
}
|
||||
if item.get("options"):
|
||||
field["options"] = item["options"]
|
||||
if item.get("type") in {"text", "array"}:
|
||||
field["item_type"] = item.get("item_type", "str")
|
||||
if item.get("type") == "object_set" and item.get("item_schema"):
|
||||
field["item_schema"] = item["item_schema"]
|
||||
if item.get("key_field"):
|
||||
field["key_field"] = item["key_field"]
|
||||
if item.get("nosave"):
|
||||
# 文件/外部源配置:值不写进 plugin_config.json(避免与命令修改互相覆盖为陈旧值),
|
||||
# 但仍调用 apply(写入外部源)。
|
||||
field["nosave"] = True
|
||||
schema_fields.append(field)
|
||||
|
||||
schema = {"fields": schema_fields}
|
||||
|
||||
def _getter() -> dict[str, Any]:
|
||||
values: dict[str, Any] = {}
|
||||
for item in items:
|
||||
raw = _item_getter(item)
|
||||
values[item["key"]] = _stringify(raw, item.get("type", "string"))
|
||||
if getter_extra:
|
||||
values.update(getter_extra(store) if store is not None else getter_extra())
|
||||
return values
|
||||
|
||||
def _apply(values: dict[str, Any]) -> None:
|
||||
for item in items:
|
||||
key = item["key"]
|
||||
if key not in values:
|
||||
continue
|
||||
typ = item.get("type", "string")
|
||||
coerced = _coerce_value(values[key], typ, item.get("item_type", "str"))
|
||||
_item_setter(item, coerced)
|
||||
if apply_extra:
|
||||
apply_extra(values, store)
|
||||
|
||||
register_plugin_config(
|
||||
plugin_id,
|
||||
schema,
|
||||
apply=_apply,
|
||||
getter=_getter,
|
||||
)
|
||||
|
||||
|
||||
def register_object_set(
|
||||
plugin_id: str,
|
||||
set_key: str,
|
||||
item_schema_fields: list[dict[str, Any]],
|
||||
key_field: str,
|
||||
getter: Callable[[], list[dict[str, Any]]],
|
||||
setter: Callable[[list[dict[str, Any]]], None],
|
||||
*,
|
||||
label: Optional[str] = None,
|
||||
description: str = "",
|
||||
nosave: bool = False,
|
||||
options_by_key: Optional[dict[str, list[dict[str, str]]]] = None,
|
||||
) -> None:
|
||||
"""注册一个「通用对象集」配置项(type=object_set)。
|
||||
|
||||
适用场景:插件要暴露一组结构化条目(如群分组、测速站点、服务器列表、
|
||||
白/黑名单成员等),Web 端用通用表格渲染,支持增删改。
|
||||
|
||||
Args:
|
||||
plugin_id: NoneBot 插件模块名。
|
||||
set_key: 该对象集的配置 key。
|
||||
item_schema_fields: 子字段 schema(同标准字段;不必含 key),每条含
|
||||
key/label/type/default/options/item_type/secret 等。
|
||||
key_field: 子字段主键名(用于定位唯一行/去重)。
|
||||
getter: () -> list[dict] 取当前对象集。
|
||||
setter: (list[dict]) -> None 持久化整个对象集(应自行校验/写回来源)。
|
||||
label/description: Web 显示。
|
||||
nosave: True 时不写入 plugin_config.json(权威源在插件自身,如文件/DB)。
|
||||
options_by_key: 按子字段 key 覆盖 enum options。
|
||||
"""
|
||||
sub_fields: list[dict[str, Any]] = []
|
||||
for sf in item_schema_fields:
|
||||
field: dict[str, Any] = {
|
||||
"key": sf["key"],
|
||||
"label": sf.get("label", sf["key"]),
|
||||
"type": sf.get("type", "string"),
|
||||
"default": sf.get("default"),
|
||||
"description": sf.get("description", ""),
|
||||
"secret": sf.get("secret", _is_secret(sf["key"])),
|
||||
}
|
||||
if sf.get("type") in {"text", "array"}:
|
||||
field["item_type"] = sf.get("item_type", "str")
|
||||
opts = (options_by_key or {}).get(sf["key"]) or sf.get("options")
|
||||
if opts:
|
||||
field["options"] = opts
|
||||
sub_fields.append(field)
|
||||
|
||||
def _coerce_setter(rows: list[dict[str, Any]]) -> None:
|
||||
"""按 item_schema 把每行子字段转成目标类型,并按 key_field 去重后交给 setter。"""
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
coerced: list[dict[str, Any]] = []
|
||||
seen: set[Any] = set()
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
nrow: dict[str, Any] = {}
|
||||
for sf in item_schema_fields:
|
||||
key = sf["key"]
|
||||
if key not in row:
|
||||
if "default" in sf and key not in nrow:
|
||||
nrow[key] = _coerce_value(sf["default"], sf.get("type", "string"), sf.get("item_type", "str"))
|
||||
continue
|
||||
nrow[key] = _coerce_value(
|
||||
row[key], sf.get("type", "string"), sf.get("item_type", "str")
|
||||
)
|
||||
key_val = nrow.get(key_field)
|
||||
if key_val is None or key_val == "":
|
||||
continue # 缺主键的行丢弃
|
||||
if key_val in seen:
|
||||
continue # 主键重复只保留首个
|
||||
seen.add(key_val)
|
||||
coerced.append(nrow)
|
||||
setter(coerced)
|
||||
|
||||
register_config_items(
|
||||
plugin_id,
|
||||
[
|
||||
{
|
||||
"key": set_key,
|
||||
"label": label or set_key,
|
||||
"type": "object_set",
|
||||
"item_schema": {"fields": sub_fields},
|
||||
"key_field": key_field,
|
||||
"description": description,
|
||||
"getter": getter,
|
||||
"setter": _coerce_setter,
|
||||
"nosave": nosave,
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,310 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""统一 Web 管理台 Dashboard 数据采集。
|
||||
|
||||
数据来源与 nonebot_plugin_picstatus 类似:
|
||||
- bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取
|
||||
- host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本
|
||||
仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。
|
||||
同步采集(psutil 等可能阻塞的调用)整体放入线程池执行,避免阻塞事件循环;
|
||||
结果做 1 秒短缓存,多个前端标签页(含 SSE 循环)不至于各自重复采集。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import platform
|
||||
import time
|
||||
|
||||
import psutil
|
||||
from nonebot import get_bots, logger
|
||||
|
||||
try:
|
||||
from cpuinfo import get_cpu_info
|
||||
except Exception: # pragma: no cover
|
||||
get_cpu_info = None
|
||||
|
||||
_MODULE_START = time.time()
|
||||
|
||||
# 网络速率采样:记录上一次累计计数与时间,用于计算实时 bytes/s 用量
|
||||
_NET_LAST: dict = {"time": None, "sent": None, "recv": None}
|
||||
|
||||
# 采集结果短缓存(多标签页 / SSE 每 2s 循环共用一份结果)
|
||||
_DASH_CACHE: dict = {"time": 0.0, "data": None}
|
||||
_DASH_CACHE_TTL = 1.0
|
||||
|
||||
|
||||
def _cpu_sync() -> dict:
|
||||
try:
|
||||
per_core = psutil.cpu_percent(0.3, True)
|
||||
except TypeError:
|
||||
# 兼容部分平台不接受 percpu 参数
|
||||
per_core = []
|
||||
if per_core:
|
||||
percent = sum(per_core) / len(per_core)
|
||||
else:
|
||||
percent = psutil.cpu_percent(0.3)
|
||||
info: dict = {
|
||||
"percent": round(float(percent), 1),
|
||||
"per_core": [round(float(x), 1) for x in (per_core or [])],
|
||||
"logical": psutil.cpu_count(),
|
||||
"physical": psutil.cpu_count(logical=False),
|
||||
"brand": "",
|
||||
}
|
||||
if get_cpu_info:
|
||||
try:
|
||||
brand = get_cpu_info().get("brand_raw", "")
|
||||
info["brand"] = brand.split("@", maxsplit=1)[0].strip()
|
||||
except Exception:
|
||||
info["brand"] = ""
|
||||
try:
|
||||
info["load_avg"] = [round(float(x), 2) for x in psutil.getloadavg()]
|
||||
except Exception:
|
||||
info["load_avg"] = None
|
||||
return info
|
||||
|
||||
|
||||
def _memory_sync() -> dict:
|
||||
mem = psutil.virtual_memory()
|
||||
try:
|
||||
proc = psutil.Process()
|
||||
rss = proc.memory_info().rss
|
||||
proc_mem_percent = proc.memory_percent()
|
||||
except Exception:
|
||||
rss = 0
|
||||
proc_mem_percent = 0.0
|
||||
return {
|
||||
"percent": round(float(mem.percent), 1),
|
||||
"used": int(mem.used),
|
||||
"total": int(mem.total),
|
||||
"available": int(mem.available),
|
||||
"process_rss": int(rss),
|
||||
"process_percent": round(float(proc_mem_percent), 1),
|
||||
}
|
||||
|
||||
|
||||
def _process_sync() -> dict:
|
||||
try:
|
||||
proc = psutil.Process()
|
||||
cpu = proc.cpu_percent(0.2)
|
||||
info = {
|
||||
"pid": proc.pid,
|
||||
"name": proc.name(),
|
||||
"rss": int(proc.memory_info().rss),
|
||||
"cpu_percent": round(float(cpu), 1),
|
||||
"memory_percent": round(float(proc.memory_percent()), 1),
|
||||
"create_time": int(proc.create_time()),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"采集进程信息失败: {type(e).__name__}: {e}")
|
||||
info = {}
|
||||
return info
|
||||
|
||||
|
||||
def _network_sync() -> 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,
|
||||
}
|
||||
|
||||
|
||||
def _backend_sync() -> 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
|
||||
|
||||
|
||||
def _system_sync() -> dict:
|
||||
boot = psutil.boot_time()
|
||||
now = time.time()
|
||||
return {
|
||||
"hostname": platform.node(),
|
||||
"os": platform.platform(),
|
||||
"python": f"{platform.python_implementation()} {platform.python_version()}",
|
||||
"nonebot": _nonebot_version(),
|
||||
"boot_time": int(boot),
|
||||
"system_uptime_seconds": int(now - boot),
|
||||
"bot_uptime_seconds": int(now - _MODULE_START),
|
||||
}
|
||||
|
||||
|
||||
def _nonebot_version() -> str:
|
||||
try:
|
||||
import nonebot
|
||||
|
||||
return nonebot.__version__
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _disk_sync() -> list:
|
||||
parts = []
|
||||
for part in psutil.disk_partitions():
|
||||
try:
|
||||
usage = psutil.disk_usage(part.mountpoint)
|
||||
except Exception:
|
||||
continue
|
||||
parts.append(
|
||||
{
|
||||
"mount": part.mountpoint,
|
||||
"device": part.device,
|
||||
"percent": round(float(usage.percent), 1),
|
||||
"used": int(usage.used),
|
||||
"total": int(usage.total),
|
||||
}
|
||||
)
|
||||
return parts
|
||||
|
||||
|
||||
async def _collect_bots() -> list:
|
||||
try:
|
||||
bots = get_bots().values()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"获取 bot 列表失败: {type(e).__name__}: {e}")
|
||||
return []
|
||||
result = []
|
||||
for bot in bots:
|
||||
adapter = getattr(bot, "adapter", None)
|
||||
connections = getattr(adapter, "connections", {}) or {}
|
||||
ws_connected = bool(connections.get(bot.self_id))
|
||||
item = {
|
||||
"self_id": bot.self_id,
|
||||
"adapter": adapter.get_name() if adapter else "",
|
||||
"nick": bot.self_id,
|
||||
"avatar": f"https://q1.qlogo.cn/g?b=qq&nk={bot.self_id}&s=640",
|
||||
"ws_connected": ws_connected,
|
||||
"online": None,
|
||||
"msg_recv": None,
|
||||
"msg_sent": None,
|
||||
}
|
||||
if ws_connected:
|
||||
try:
|
||||
login = await bot.get_login_info()
|
||||
item["nick"] = login.get("nickname") or item["nick"]
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(
|
||||
f"获取登录信息失败({bot.self_id}): {type(e).__name__}: {e}"
|
||||
)
|
||||
try:
|
||||
status = await bot.get_status()
|
||||
item["online"] = status.get("online")
|
||||
stat = status.get("stat") or {}
|
||||
item["msg_recv"] = stat.get("message_received") or stat.get(
|
||||
"MessageReceived"
|
||||
)
|
||||
item["msg_sent"] = stat.get("message_sent") or stat.get("MessageSent")
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(
|
||||
f"获取连接状态失败({bot.self_id}): {type(e).__name__}: {e}"
|
||||
)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
async def collect_dashboard() -> dict:
|
||||
"""聚合采集,失败字段降级,不阻塞整体返回。
|
||||
|
||||
同步采集整体放线程池(psutil 的 disk_usage 等可能秒级阻塞),
|
||||
结果带 1 秒短缓存(SSE 每 2s 循环与多标签页共用)。
|
||||
"""
|
||||
now = time.time()
|
||||
if _DASH_CACHE["data"] is not None and now - _DASH_CACHE["time"] < _DASH_CACHE_TTL:
|
||||
return _DASH_CACHE["data"]
|
||||
cpu, memory, process, system, disk, network, backend, bots = await asyncio.gather(
|
||||
asyncio.to_thread(_cpu_sync),
|
||||
asyncio.to_thread(_memory_sync),
|
||||
asyncio.to_thread(_process_sync),
|
||||
asyncio.to_thread(_system_sync),
|
||||
asyncio.to_thread(_disk_sync),
|
||||
asyncio.to_thread(_network_sync),
|
||||
asyncio.to_thread(_backend_sync),
|
||||
_collect_bots(), # 需要走 bot API,必须留在事件循环
|
||||
)
|
||||
data = {
|
||||
"bots": bots,
|
||||
"system": system,
|
||||
"cpu": cpu,
|
||||
"memory": memory,
|
||||
"process": process,
|
||||
"disk": disk,
|
||||
"network": network,
|
||||
"backend": backend,
|
||||
}
|
||||
_DASH_CACHE["time"] = now
|
||||
_DASH_CACHE["data"] = data
|
||||
return data
|
||||
@@ -0,0 +1,307 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""统一 Web 鉴权模块:OAuth2 Password + SQLite 存储。
|
||||
|
||||
- 用户/访问令牌存 SQLite:hexi/data/web_auth.db
|
||||
- 提供 OAuth2 密码流(tokenUrl 指向 /hub/api/auth/token)
|
||||
- 供统一 Web /hub 与各插件 API 共用(/api/<plugin>/...)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
basic_path = Path(__file__).resolve().parents[1] # hexi/
|
||||
DATA_DIR = basic_path / "data"
|
||||
DB_PATH = DATA_DIR / "web_auth.db"
|
||||
|
||||
TOKEN_TTL_SECONDS = 60 * 60 * 24 # 24 小时
|
||||
PBKDF2_ITERATIONS = 100_000
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(
|
||||
tokenUrl="/hub/api/auth/token",
|
||||
auto_error=False,
|
||||
)
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _read_env(key: str, default: str) -> str:
|
||||
"""取值优先级:os.environ > .env 文件 > default(NoneBot 不一定把 .env 全塞进 env)。"""
|
||||
val = os.getenv(key)
|
||||
if val:
|
||||
return val
|
||||
env_path = basic_path.parent / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith(key + "=") or line.startswith(key + " ="):
|
||||
value = line.split("=", 1)[1].strip()
|
||||
return value.strip('"').strip("'")
|
||||
return default
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt.encode("utf-8"), PBKDF2_ITERATIONS
|
||||
).hex()
|
||||
return f"{salt}${digest}"
|
||||
|
||||
|
||||
def _verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
salt, digest = stored.split("$", 1)
|
||||
except ValueError:
|
||||
return False
|
||||
calc = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt.encode("utf-8"), PBKDF2_ITERATIONS
|
||||
).hex()
|
||||
return secrets.compare_digest(digest, calc)
|
||||
|
||||
|
||||
def init_auth_db() -> None:
|
||||
"""只建表(轻量,可每次调用);用户播种/同步由 sync_admin() 在启动时做一次。"""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS web_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS web_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at REAL NOT NULL,
|
||||
created_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _env_has_password_key() -> bool:
|
||||
""".env 中是否显式配置了 hexi_web_password(区别于代码默认值 admin)。"""
|
||||
env_path = basic_path.parent / ".env"
|
||||
if not env_path.exists():
|
||||
return False
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("hexi_web_password=") or line.startswith(
|
||||
"hexi_web_password ="
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _write_env_password(password: str) -> None:
|
||||
"""把 Web 端修改后的密码回写 .env,保持 .env 永远是权威源(重启后不还原)。"""
|
||||
env_path = basic_path.parent / ".env"
|
||||
try:
|
||||
lines = (
|
||||
env_path.read_text(encoding="utf-8").splitlines()
|
||||
if env_path.exists()
|
||||
else []
|
||||
)
|
||||
replaced = False
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("hexi_web_password=") or stripped.startswith(
|
||||
"hexi_web_password ="
|
||||
):
|
||||
if not replaced:
|
||||
# JSON 字符串写法可安全转义 # 等特殊字符,dotenv 解析后与裸值一致
|
||||
out.append("hexi_web_password=" + json.dumps(password))
|
||||
replaced = True
|
||||
else:
|
||||
out.append(line)
|
||||
if not replaced:
|
||||
out.append("hexi_web_password=" + json.dumps(password))
|
||||
env_path.write_text(
|
||||
"\n".join(out).rstrip("\n") + "\n", encoding="utf-8"
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _seed_admin_if_missing(username: str, password: str) -> None:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM web_users WHERE username=?", (username,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"INSERT INTO web_users(username,password_hash,is_active,created_at) VALUES(?,?,1,?)",
|
||||
(username, _hash_password(password), _now()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def sync_admin() -> None:
|
||||
"""启动时调用:以 .env 为准创建/同步超级管理员。
|
||||
|
||||
仅当 .env 显式配置了 hexi_web_password 时才会在每次启动同步密码
|
||||
(此时 .env 是权威源,重启始终以 .env 覆盖);若 .env 未显式配置,
|
||||
则只在用户不存在时播种默认账号,保证 Web 端「修改密码」能持久生效、
|
||||
不被每次启动静默还原。
|
||||
"""
|
||||
init_auth_db()
|
||||
username = _read_env("hexi_web_username", "admin")
|
||||
password = _read_env("hexi_web_password", "admin")
|
||||
if not _env_has_password_key():
|
||||
_seed_admin_if_missing(username, password)
|
||||
return
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM web_users WHERE username=?", (username,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"INSERT INTO web_users(username,password_hash,is_active,created_at) VALUES(?,?,1,?)",
|
||||
(username, _hash_password(password), _now()),
|
||||
)
|
||||
else:
|
||||
# 以 .env 为准同步超级管理员密码:解决改 .env 后库里还是旧密码的问题
|
||||
conn.execute(
|
||||
"UPDATE web_users SET password_hash=?, is_active=1 WHERE id=?",
|
||||
(_hash_password(password), row["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def create_user(username: str, password: str) -> int:
|
||||
init_auth_db()
|
||||
with _conn() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO web_users(username,password_hash,is_active,created_at) VALUES(?,?,1,?)",
|
||||
(username, _hash_password(password), _now()),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def revoke_user_tokens(user_id: int, except_token: Optional[str] = None) -> None:
|
||||
"""吊销某用户访问令牌(可保留当前令牌)。改密码后调用,旧登录态全部失效。"""
|
||||
init_auth_db()
|
||||
with _conn() as conn:
|
||||
if except_token:
|
||||
conn.execute(
|
||||
"DELETE FROM web_tokens WHERE user_id=? AND token<>?",
|
||||
(user_id, except_token),
|
||||
)
|
||||
else:
|
||||
conn.execute("DELETE FROM web_tokens WHERE user_id=?", (user_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def change_password(
|
||||
user_id: int, old_password: str, new_password: str, keep_token: Optional[str] = None
|
||||
) -> bool:
|
||||
"""校验旧密码后改新密码,并吊销旧令牌(保留当前令牌可选)。成功返回 True。
|
||||
|
||||
成功后会回写 .env(hexi_web_password),保证重启后仍是新密码:
|
||||
否则 sync_admin 会按 .env 默认值把密码还原。
|
||||
"""
|
||||
if not new_password:
|
||||
return False
|
||||
init_auth_db()
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM web_users WHERE id=? AND is_active=1", (user_id,)
|
||||
).fetchone()
|
||||
if not row or not _verify_password(old_password, row["password_hash"]):
|
||||
return False
|
||||
conn.execute(
|
||||
"UPDATE web_users SET password_hash=? WHERE id=?",
|
||||
(_hash_password(new_password), user_id),
|
||||
)
|
||||
conn.commit()
|
||||
_write_env_password(new_password)
|
||||
revoke_user_tokens(user_id, except_token=keep_token)
|
||||
return True
|
||||
|
||||
|
||||
def authenticate(username: str, password: str) -> Optional[int]:
|
||||
"""校验成功返回 user_id,否则 None。"""
|
||||
init_auth_db()
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM web_users WHERE username=? AND is_active=1", (username,)
|
||||
).fetchone()
|
||||
if row and _verify_password(password, row["password_hash"]):
|
||||
return int(row["id"])
|
||||
return None
|
||||
|
||||
|
||||
def issue_token(user_id: int) -> tuple[str, int]:
|
||||
"""签发不透明访问令牌(存库可吊销),返回 (token, expires_in)。"""
|
||||
init_auth_db()
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = time.time() + TOKEN_TTL_SECONDS
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO web_tokens(token,user_id,expires_at,created_at) VALUES(?,?,?,?)",
|
||||
(token, user_id, expires_at, _now()),
|
||||
)
|
||||
conn.commit()
|
||||
return token, TOKEN_TTL_SECONDS
|
||||
|
||||
|
||||
def revoke_token(token: str) -> None:
|
||||
init_auth_db()
|
||||
with _conn() as conn:
|
||||
conn.execute("DELETE FROM web_tokens WHERE token=?", (token,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_user_by_token(token: str) -> Optional[dict]:
|
||||
init_auth_db()
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT u.id, u.username, u.created_at
|
||||
FROM web_tokens t JOIN web_users u ON u.id = t.user_id
|
||||
WHERE t.token=? AND t.expires_at > ?
|
||||
""",
|
||||
(token, time.time()),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def get_current_user(token: Optional[str] = Depends(oauth2_scheme)) -> dict:
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
user = get_user_by_token(token)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="登录态无效或已过期")
|
||||
return user
|
||||
|
||||
|
||||
# 各插件 API 用的统一鉴权依赖
|
||||
require_admin = Depends(get_current_user)
|
||||
@@ -0,0 +1,409 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""插件配置统一标准:schema 注册 + 存储 + 保存热刷新。
|
||||
|
||||
标准 schema 格式(每个插件注册一份):
|
||||
{
|
||||
"fields": [
|
||||
{
|
||||
"key": "web_password", # 配置字段名
|
||||
"label": "Web 密码", # 表单显示名
|
||||
"type": "string|text|password|int|float|bool|enum", # 类型
|
||||
"default": ..., # 默认值
|
||||
"description": "...", # 提示
|
||||
"env": "WEB_PASSWORD", # 环境变量名(缺省=key.upper())
|
||||
"secret": true, # 是否敏感(前端默认掩码显示,回传明文)
|
||||
"options": [...], # enum 可选项
|
||||
"required": false,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
存储:hexi/config/plugin_config.json(Web 读写源)。
|
||||
热刷新:
|
||||
- 若注册时提供了 apply(values) 回调,保存后调用它(插件自定义热应用,推荐)。
|
||||
- 否则写环境变量(os.environ + .env,保证 get_plugin_config 能读到),再按需 hot_reload 插件。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from nonebot import logger
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1] # hexi/
|
||||
DATA_DIR = _ROOT / "config"
|
||||
STORE_PATH = DATA_DIR / "plugin_config.json"
|
||||
ENV_PATH = _ROOT.parent / ".env" # 仓库根 .env
|
||||
|
||||
_TYPES = {"string", "text", "password", "int", "float", "bool", "enum", "json", "object", "path", "object_set", "array"}
|
||||
|
||||
# plugin_id -> schema
|
||||
_schemas: dict[str, dict[str, Any]] = {}
|
||||
# plugin_id -> apply(values) 回调
|
||||
_appliers: dict[str, Callable[[dict[str, Any]], Any]] = {}
|
||||
# plugin_id -> getter() 回调(返回当前生效值,Web 表单回填用)
|
||||
_getters: dict[str, Callable[[], dict[str, Any]]] = {}
|
||||
# plugin_id -> {key: value}
|
||||
_values: dict[str, dict[str, Any]] = {}
|
||||
_revisions: dict[str, int] = {}
|
||||
_config_lock = threading.RLock()
|
||||
MASKED_SECRET = "****"
|
||||
|
||||
|
||||
def _load() -> None:
|
||||
global _values
|
||||
if STORE_PATH.exists():
|
||||
try:
|
||||
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict) and isinstance(data.get("plugins"), dict):
|
||||
_values = {}
|
||||
for pid, entry in data["plugins"].items():
|
||||
if isinstance(entry, dict) and isinstance(entry.get("values", {}), dict):
|
||||
_values[pid] = entry["values"]
|
||||
_revisions[pid] = int(entry.get("revision", 0))
|
||||
else:
|
||||
_values = data if isinstance(data, dict) else {}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"加载插件配置存储失败: {type(e).__name__}: {e}")
|
||||
_values = {}
|
||||
else:
|
||||
_values = {}
|
||||
|
||||
|
||||
def _save() -> None:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"version": 1,
|
||||
"plugins": {
|
||||
pid: {"revision": _revisions.get(pid, 0), "values": values}
|
||||
for pid, values in _values.items()
|
||||
},
|
||||
}
|
||||
tmp = STORE_PATH.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, STORE_PATH)
|
||||
|
||||
|
||||
def _field_env(field: dict[str, Any]) -> str:
|
||||
return str(field.get("env") or str(field["key"]).upper())
|
||||
|
||||
|
||||
def register_plugin_config(
|
||||
plugin_id: str,
|
||||
schema: dict[str, Any],
|
||||
apply: Optional[Callable[[dict[str, Any]], Any]] = None,
|
||||
getter: Optional[Callable[[], dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""注册某插件的标准配置 schema;apply 可选,用于保存后热刷新;
|
||||
getter 可选,返回插件当前生效值,供 Web 表单回填(否则回填 schema 默认值)。
|
||||
|
||||
同一 plugin_id 可多次调用:字段按 key 合并到一个 schema,
|
||||
getter/apply 会被组合成「合并取值 / 依次应用」,避免后注册覆盖前注册。
|
||||
"""
|
||||
fields = schema.get("fields", [])
|
||||
for f in fields:
|
||||
typ = f.get("type", "string")
|
||||
if typ not in _TYPES:
|
||||
raise ValueError(f"插件 {plugin_id} 字段 {f.get('key')} 类型 {typ} 不受支持")
|
||||
if "key" not in f:
|
||||
raise ValueError(f"插件 {plugin_id} 存在缺 key 的字段")
|
||||
f.setdefault("label", f["key"])
|
||||
f.setdefault("default", None)
|
||||
f.setdefault("secret", False)
|
||||
|
||||
existing = _schemas.get(plugin_id)
|
||||
if existing is not None:
|
||||
existing_keys = {f["key"] for f in existing.get("fields", [])}
|
||||
for f in fields:
|
||||
if f["key"] not in existing_keys:
|
||||
existing["fields"].append(f)
|
||||
existing_keys.add(f["key"])
|
||||
schema = existing
|
||||
_schemas[plugin_id] = schema
|
||||
|
||||
if apply is not None:
|
||||
prev = _appliers.get(plugin_id)
|
||||
if prev is None:
|
||||
_appliers[plugin_id] = apply
|
||||
else:
|
||||
def _combo_apply(values, _prev=prev, _new=apply):
|
||||
try:
|
||||
_prev(values)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning(f"插件配置 apply 前段失败({plugin_id}): …")
|
||||
_new(values)
|
||||
_appliers[plugin_id] = _combo_apply
|
||||
|
||||
if getter is not None:
|
||||
prevg = _getters.get(plugin_id)
|
||||
if prevg is None:
|
||||
_getters[plugin_id] = getter
|
||||
else:
|
||||
def _combo_getter(_prev=prevg, _new=getter):
|
||||
out: dict[str, Any] = {}
|
||||
try:
|
||||
out.update(_prev())
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
out.update(_new())
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return out
|
||||
_getters[plugin_id] = _combo_getter
|
||||
|
||||
|
||||
def has_schema(plugin_id: str) -> bool:
|
||||
return plugin_id in _schemas
|
||||
|
||||
|
||||
def get_effective_value(plugin_id: str, key: str, default: Any = None) -> Any:
|
||||
"""统一读取「插件配置生效值」(供插件运行期调用,来源无关)。
|
||||
|
||||
优先级:
|
||||
1. 用户已保存的值(plugin_config.json)
|
||||
2. 插件 getter 返回的当前生效值
|
||||
3. default
|
||||
|
||||
适合模块常量/配置文件/DB 内的配置项:插件在用到某配置时调用
|
||||
get_effective_value(plugin_id, key, default) 读取,即可立刻反映 Web 修改。
|
||||
"""
|
||||
stored = _values.get(plugin_id, {})
|
||||
if key in stored and stored[key] is not None:
|
||||
return stored[key]
|
||||
getter = _getters.get(plugin_id)
|
||||
if getter:
|
||||
try:
|
||||
live = getter()
|
||||
except Exception: # noqa: BLE001
|
||||
live = None
|
||||
if isinstance(live, dict) and key in live and live[key] is not None:
|
||||
return live[key]
|
||||
return default
|
||||
|
||||
|
||||
def get_schema(plugin_id: str) -> Optional[dict[str, Any]]:
|
||||
return _schemas.get(plugin_id)
|
||||
|
||||
|
||||
def _coerce(field: dict[str, Any], raw: Any) -> Any:
|
||||
typ = field.get("type", "string")
|
||||
if typ == "bool":
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
normalized = str(raw).strip().lower()
|
||||
if normalized not in {"0", "1", "true", "false", "yes", "no", "on", "off"}:
|
||||
raise ValueError("布尔值必须是 true/false")
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
if typ == "int":
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError(f"字段 {field['key']} 必须是整数") from e
|
||||
if typ == "float":
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError(f"字段 {field['key']} 必须是数字") from e
|
||||
if typ == "array":
|
||||
item_type = str(field.get("item_type") or "str")
|
||||
if isinstance(raw, str):
|
||||
# 防御:兼容旧前端提交的多行/逗号分隔文本
|
||||
raw = [p.strip() for p in re.split(r"[,\n]+", raw) if p.strip()]
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError(f"字段 {field['key']} 必须是数组")
|
||||
out = []
|
||||
for item in raw:
|
||||
if item_type in {"int", "float"}:
|
||||
try:
|
||||
out.append(int(item) if item_type == "int" else float(item))
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError(f"字段 {field['key']} 含非法数字: {item}") from e
|
||||
else:
|
||||
out.append(str(item))
|
||||
return out
|
||||
if typ in {"string", "text", "password"}:
|
||||
return "" if raw is None else str(raw)
|
||||
if typ == "enum":
|
||||
return "" if raw is None else str(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def get_config(plugin_id: str) -> Optional[dict[str, Any]]:
|
||||
"""返回 {schema, values};未注册 schema 返回 None。"""
|
||||
schema = _schemas.get(plugin_id)
|
||||
if schema is None:
|
||||
return None
|
||||
stored = _values.get(plugin_id, {})
|
||||
live: dict[str, Any] = {}
|
||||
getter = _getters.get(plugin_id)
|
||||
if getter:
|
||||
try:
|
||||
live = getter()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"插件配置 getter 回填失败({plugin_id}): {type(e).__name__}: {e}")
|
||||
values: dict[str, Any] = {}
|
||||
for f in schema.get("fields", []):
|
||||
key = f["key"]
|
||||
if key in stored:
|
||||
values[key] = stored[key]
|
||||
elif key in live:
|
||||
values[key] = live[key]
|
||||
elif "default" in f:
|
||||
values[key] = f["default"]
|
||||
else:
|
||||
values[key] = None
|
||||
# 密钥类字段回传明文:该管理台仅本地局域网/本机使用,无外泄面;
|
||||
# 明文由前端默认以掩码(小圆点)形式展示,用户点「眼睛」才可查看。
|
||||
# array 字段兼容旧存储:text 时期保存的是换行/逗号分隔字符串,归一化为数组
|
||||
for f in schema.get("fields", []):
|
||||
key = f["key"]
|
||||
if f.get("type") == "array" and isinstance(values.get(key), str):
|
||||
try:
|
||||
values[key] = _coerce(f, values[key])
|
||||
except ValueError:
|
||||
values[key] = []
|
||||
return {"schema": schema, "values": values, "revision": _revisions.get(plugin_id, 0)}
|
||||
|
||||
|
||||
def _validate_field(field: dict[str, Any], value: Any) -> None:
|
||||
key = field["key"]
|
||||
if value in (None, ""):
|
||||
if field.get("required"):
|
||||
raise ValueError(f"字段 {key} 不能为空")
|
||||
return
|
||||
typ = field.get("type", "string")
|
||||
if typ == "enum":
|
||||
options = {str(o.get("value") if isinstance(o, dict) else o) for o in field.get("options", [])}
|
||||
if str(value) not in options:
|
||||
raise ValueError(f"字段 {key} 不是有效选项")
|
||||
if typ in {"int", "float"}:
|
||||
number = float(value)
|
||||
if field.get("min") is not None and number < field["min"]:
|
||||
raise ValueError(f"字段 {key} 小于最小值")
|
||||
if field.get("max") is not None and number > field["max"]:
|
||||
raise ValueError(f"字段 {key} 大于最大值")
|
||||
|
||||
|
||||
def _update_env(plugin_id: str, values: dict[str, Any]) -> None:
|
||||
"""写 os.environ(即时生效)+ 写 .env(重启保留)。"""
|
||||
fields = _schemas.get(plugin_id, {}).get("fields", [])
|
||||
for f in fields:
|
||||
key = f["key"]
|
||||
if key not in values:
|
||||
continue
|
||||
# text/json/object/path/array 这类非标量不适合写进 .env(重启解析会出错),
|
||||
# 这类字段仅在运行期热更新,不落盘 .env(见 config_standard 说明)。
|
||||
if f.get("type") in {"text", "json", "object", "path", "array"}:
|
||||
continue
|
||||
env_name = _field_env(f)
|
||||
raw = values[key]
|
||||
text = "" if raw is None else str(raw)
|
||||
os.environ[env_name] = text
|
||||
_append_env(env_name, text)
|
||||
|
||||
|
||||
def _append_env(env_name: str, value: str) -> None:
|
||||
"""把 KEY=VALUE 写进 .env(已存在则在原行更新,否则追加)。"""
|
||||
try:
|
||||
lines: list[str] = []
|
||||
if ENV_PATH.exists():
|
||||
lines = ENV_PATH.read_text(encoding="utf-8").splitlines()
|
||||
replaced = False
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(env_name + "="):
|
||||
if not replaced:
|
||||
out.append(f"{env_name}={value}")
|
||||
replaced = True
|
||||
# 重复行跳过
|
||||
else:
|
||||
out.append(line)
|
||||
if not replaced:
|
||||
out.append(f"{env_name}={value}")
|
||||
ENV_PATH.write_text("\n".join(out).rstrip("\n") + "\n", encoding="utf-8")
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"写入 .env({env_name}) 失败: {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
def _hot_reload(plugin_id: str) -> None:
|
||||
try:
|
||||
from hexi.core.plugin_manager import hot_reload
|
||||
|
||||
ok = hot_reload(plugin_id)
|
||||
if ok:
|
||||
logger.info(f"插件配置热刷新: hot_reload({plugin_id}) 成功")
|
||||
else:
|
||||
logger.warning(
|
||||
f"插件配置热刷新跳过 {plugin_id}: 非 application(library/未声明)插件不可热重载,"
|
||||
"请检查是否已提供 apply 回调或直接重启"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"插件配置热刷新失败({plugin_id}): {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
def save_config(
|
||||
plugin_id: str,
|
||||
values: dict[str, Any],
|
||||
reload: bool = True,
|
||||
expected_revision: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""保存配置并触发热刷新。返回最新 {schema, values}。"""
|
||||
schema = _schemas.get(plugin_id)
|
||||
if schema is None:
|
||||
raise ValueError(f"插件 {plugin_id} 未注册配置 schema")
|
||||
|
||||
fields = schema.get("fields", [])
|
||||
with _config_lock:
|
||||
current_revision = _revisions.get(plugin_id, 0)
|
||||
if expected_revision is not None and expected_revision != current_revision:
|
||||
raise RuntimeError(f"配置已被其他请求修改,当前版本为 {current_revision}")
|
||||
merged = dict(_values.get(plugin_id, {}))
|
||||
persist_merged = dict(_values.get(plugin_id, {}))
|
||||
for f in fields:
|
||||
key = f["key"]
|
||||
if key not in values:
|
||||
continue
|
||||
raw = values[key]
|
||||
# 兼容旧版本前端回传的掩码占位:不覆盖已保存的明文
|
||||
if f.get("secret") and raw == MASKED_SECRET:
|
||||
continue
|
||||
if raw in (None, ""):
|
||||
val: Any = None if raw is None else ""
|
||||
else:
|
||||
val = _coerce(f, raw)
|
||||
_validate_field(f, val)
|
||||
merged[key] = val
|
||||
# nosave fields apply to their authority source but skip the shared value store.
|
||||
if not f.get("nosave"):
|
||||
persist_merged[key] = val
|
||||
_values[plugin_id] = persist_merged
|
||||
_revisions[plugin_id] = current_revision + 1
|
||||
_save()
|
||||
|
||||
# 统一落盘到 .env,保证重启后依然生效(与 NoneBot get_plugin_config 约定一致)
|
||||
_update_env(plugin_id, merged)
|
||||
|
||||
applier = _appliers.get(plugin_id)
|
||||
if applier is not None:
|
||||
# apply 负责把值热应用到插件运行态对象(env 变更不会自动刷新 NoneBot driver.config)
|
||||
try:
|
||||
applier(dict(merged))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"插件配置 apply 回调失败({plugin_id}): {type(e).__name__}: {e}")
|
||||
else:
|
||||
if reload:
|
||||
_hot_reload(plugin_id)
|
||||
|
||||
result = get_config(plugin_id)
|
||||
return result
|
||||
|
||||
|
||||
# 导入时加载存储
|
||||
_load()
|
||||
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""兼容 shim:统一鉴权已迁移到 hexi.web_hub.web_auth(OAuth2 + SQLite)。"""
|
||||
from .web_auth import ( # noqa: F401
|
||||
authenticate,
|
||||
create_user,
|
||||
get_user_by_token,
|
||||
init_auth_db,
|
||||
issue_token,
|
||||
oauth2_scheme,
|
||||
require_admin,
|
||||
revoke_token,
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""统一 Web 插件注册中心。
|
||||
|
||||
带 Web 控制台的插件只需在自身 __init__.py 里调用 register_web_plugin(...),
|
||||
hub 启动时会自动聚合注册表、自动挂载 /api/<id>,无需手动改 hub 或配置环境变量。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
_plugins: dict[str, dict[str, Any]] = {}
|
||||
# NoneBot 插件模块名 -> web plugin id(用于插件目录合表)
|
||||
_module_to_id: dict[str, str] = {}
|
||||
|
||||
|
||||
def register_web_plugin(
|
||||
plugin_id: str,
|
||||
name: str,
|
||||
icon: str,
|
||||
builder: Callable[[], Any],
|
||||
module_name: str | None = None,
|
||||
) -> None:
|
||||
"""注册一个 Web 插件。builder 返回一个 FastAPI 子应用(或 None 表示不挂载)。
|
||||
|
||||
- plugin_id 会决定前端路由 /hub/<id> 与后端 API 前缀 /api/<id>
|
||||
- name 显示在侧边栏
|
||||
- icon 为 Gravity 图标名(前端按需映射)
|
||||
- module_name 为 NoneBot 插件模块名,用于把「有 Web」的插件和插件目录合表
|
||||
"""
|
||||
if builder is None:
|
||||
builder = lambda: None # noqa: E731
|
||||
_plugins[plugin_id] = {
|
||||
"id": plugin_id,
|
||||
"name": name,
|
||||
"icon": icon,
|
||||
"builder": builder,
|
||||
"module_name": module_name,
|
||||
}
|
||||
if module_name:
|
||||
_module_to_id[module_name] = plugin_id
|
||||
|
||||
|
||||
def list_web_plugins() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": p["id"],
|
||||
"name": p["name"],
|
||||
"icon": p["icon"],
|
||||
"module_name": p.get("module_name") or "",
|
||||
}
|
||||
for p in _plugins.values()
|
||||
]
|
||||
|
||||
|
||||
def get_web_plugin_by_module(module_name: str) -> dict[str, Any] | None:
|
||||
"""按 NoneBot 模块名查 web 插件记录(无则 None)。"""
|
||||
pid = _module_to_id.get(module_name)
|
||||
return _plugins.get(pid) if pid else None
|
||||
|
||||
|
||||
def get_web_plugin_builder(plugin_id: str) -> Callable[[], Any] | None:
|
||||
p = _plugins.get(plugin_id)
|
||||
return p["builder"] if p else None
|
||||
Reference in New Issue
Block a user