Add HeXi bot codebase: custom plugins, web frontends, tests
- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""统一 Web 管理系统(/hub):自动聚合所有注册过的 Web 插件。
|
||||
|
||||
- 插件在自己 __init__ 里调用 hexi.web_plugin_registry.register_web_plugin(...) 注册
|
||||
- hub 启动时自动挂载每个插件 API 到 /api/<id>,无需手动配置
|
||||
- /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(
|
||||
"<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()
|
||||
|
||||
|
||||
@get_driver().on_startup
|
||||
async def _startup_mount_web_plugins() -> None:
|
||||
"""等所有插件 import 完成后,注册表就绪,再统一挂载各插件 API。"""
|
||||
_mount_all_web_plugins()
|
||||
@@ -0,0 +1,198 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""统一 Web 管理台 Dashboard 数据采集。
|
||||
|
||||
数据来源与 nonebot_plugin_picstatus 类似:
|
||||
- bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取
|
||||
- host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本
|
||||
仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
|
||||
async def _collect_cpu() -> dict:
|
||||
try:
|
||||
per_core = await asyncio.to_thread(psutil.cpu_percent, 0.3, True)
|
||||
except TypeError:
|
||||
# 兼容部分平台不接受 percpu 参数
|
||||
per_core = []
|
||||
if per_core:
|
||||
percent = sum(per_core) / len(per_core)
|
||||
else:
|
||||
percent = await asyncio.to_thread(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
|
||||
|
||||
|
||||
async def _collect_memory() -> 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),
|
||||
}
|
||||
|
||||
|
||||
async def _collect_process() -> dict:
|
||||
try:
|
||||
proc = psutil.Process()
|
||||
cpu = await asyncio.to_thread(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
|
||||
|
||||
|
||||
async def _collect_system() -> 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 ""
|
||||
|
||||
|
||||
async def _collect_disk() -> 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:
|
||||
"""聚合采集,失败字段降级,不阻塞整体返回。"""
|
||||
cpu, memory, process, system, disk, bots = await asyncio.gather(
|
||||
_collect_cpu(),
|
||||
_collect_memory(),
|
||||
_collect_process(),
|
||||
_collect_system(),
|
||||
_collect_disk(),
|
||||
_collect_bots(),
|
||||
)
|
||||
return {
|
||||
"bots": bots,
|
||||
"system": system,
|
||||
"cpu": cpu,
|
||||
"memory": memory,
|
||||
"process": process,
|
||||
"disk": disk,
|
||||
}
|
||||
Reference in New Issue
Block a user