# -*- coding: utf-8 -*- """统一 Web 管理系统(/hub):自动聚合所有注册过的 Web 插件。 - 插件在自己 __init__ 里调用 hexi.web_plugin_registry.register_web_plugin(...) 注册 - hub 启动时自动挂载每个插件 API 到 /api/,无需手动配置 - /hub/api/plugins 由注册中心聚合返回 """ from __future__ import annotations from pathlib import Path from fastapi import Depends, FastAPI, HTTPException, status from fastapi.responses import FileResponse, HTMLResponse 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 hexi.web_auth import ( authenticate, change_password, get_current_user, get_user_by_token, issue_token, oauth2_scheme, revoke_token, sync_admin, ) from hexi.web_plugin_registry import get_web_plugin_builder, list_web_plugins from .dashboard import collect_dashboard basic_path = Path(__file__).resolve().parent # hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist" # 管理台自身版本号(展示在设置页「关于」处) HUB_VERSION = "0.1.0" class PasswordModel(BaseModel): old_password: str new_password: str class PluginControlModel(BaseModel): enabled: bool | None = None chat: list[str] | 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 插件 + 控制面状态 + 是否带 Web 页面。""" from hexi.plugins.nonebot_plugin_hexi_core.plugin_control import list_plugins from hexi.web_config import has_schema from hexi.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.plugins.nonebot_plugin_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.plugins.nonebot_plugin_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.plugins.nonebot_plugin_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 hexi.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 hexi.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.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": "密码已修改"} 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( "

HeXi Web Hub

前端未构建,请在 hexi/web 执行 " "npm run build。

" ) @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/。""" 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() @get_driver().on_startup async def _startup_mount_web_plugins() -> None: """等所有插件 import 完成后,注册表就绪,再统一挂载各插件 API。""" _mount_all_web_plugins()