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:
2026-09-01 13:13:40 +08:00
co-authored by Claude
parent 1783c60afa
commit b61d09f09f
3201 changed files with 160436 additions and 171 deletions
@@ -0,0 +1,99 @@
"""前端管理页面的构建与静态挂载。
方案: 不再由 bot 拉起 vite dev server, 而是把构建产物用 FastAPI StaticFiles
挂载到 bot 端口 /learning_chat 路径, 与 API 同源, 无孤儿进程、无 CORS 问题。
前端开发时手动 `npm run dev` 即可(API 已配置 dev proxy)。
"""
import asyncio
from pathlib import Path
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from nonebot import logger
BASE_DIR = Path(__file__).resolve().parent
ADMIN_DIR = BASE_DIR / "web" / "learning-chat-admin"
DIST_DIR = ADMIN_DIR / "dist"
# 参与构建新鲜度检查的源文件(不在 src/ 下的)
SRC_FILES = ("vite.config.ts", "index.html", "package.json")
def _dist_is_stale() -> bool:
"""dist/index.html 不存在, 或比任何源码旧时视为需要重建"""
dist_index = DIST_DIR / "index.html"
if not dist_index.exists():
return True
dist_mtime = dist_index.stat().st_mtime
for src_file in (ADMIN_DIR / "src").rglob("*"):
if src_file.is_file() and src_file.stat().st_mtime > dist_mtime:
return True
return any(
(ADMIN_DIR / name).stat().st_mtime > dist_mtime
for name in SRC_FILES
if (ADMIN_DIR / name).exists()
)
async def build_frontend() -> bool:
"""执行 vite 构建, 返回是否成功"""
logger.info("群聊学习 | 前端源码已更新, 开始重新构建(约10-40秒)...")
proc = await asyncio.create_subprocess_exec(
"npx.cmd",
"vite",
"build",
cwd=str(ADMIN_DIR),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
output, _ = await proc.communicate()
if proc.returncode != 0:
logger.error(
"群聊学习 | 前端构建失败(%s):\n%s",
proc.returncode,
output.decode(errors="ignore")[-3000:],
)
return False
logger.info("群聊学习 | 前端构建完成")
return True
async def ensure_frontend_ready() -> bool:
"""确保构建产物是最新的, 返回是否可用(最新或已成功重建)"""
if not _dist_is_stale():
return True
return await build_frontend()
def mount_frontend(app) -> None:
"""将构建产物挂载到 bot 端口的 /learning_chat 路径(需在 API 路由注册后调用)。
history 模式 SPA 需要回退: assets/ 静态挂载 + 真实文件直接返回,
其余路径(如 /login /config 等前端路由)一律回退到 index.html。
"""
if not DIST_DIR.exists():
logger.error("群聊学习 | 前端构建产物不存在, 管理页面不可用")
return
assets_dir = DIST_DIR / "assets"
if assets_dir.exists():
app.mount(
"/learning_chat/assets",
StaticFiles(directory=str(assets_dir)),
name="learning_chat_assets",
)
dist_resolved = DIST_DIR.resolve()
@app.get("/learning_chat/{full_path:path}")
async def learning_chat_spa(full_path: str):
target = (DIST_DIR / full_path).resolve()
if full_path and target.is_file() and target.is_relative_to(dist_resolved):
return FileResponse(target)
# SPA 回退: 前端路由(如 /login /config)全部交给 index.html
return FileResponse(DIST_DIR / "index.html")
@app.get("/learning_chat")
async def learning_chat_root():
return FileResponse(DIST_DIR / "index.html")
logger.info("群聊学习 | 管理页面已挂载: http://<host>:<port>/learning_chat/")