结构调整

视频解析多图/多媒体结构 消息体适配
This commit is contained in:
2026-09-08 14:25:32 +08:00
parent 30899688a7
commit 131b92b319
72 changed files with 433 additions and 186 deletions
+686
View File
@@ -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)
+243
View File
@@ -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」。",
}
+495
View File
@@ -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,
}
],
)
+310
View File
@@ -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
+307
View File
@@ -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)
+409
View File
@@ -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()
+12
View File
@@ -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,
)
+64
View File
@@ -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