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,227 @@
# -*- coding: utf-8 -*-
"""群聊人设卡 Web API 子应用(挂载到 /api/galgame_card)。
统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
旧版独立后台 /galgame_card(JWT 自鉴权)仍保留可用,前端由 hub 渲染。
"""
from __future__ import annotations
from typing import Optional
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from hexi.web_auth import require_admin
from . import repository
from .config import config
API = require_admin
def _ok(data=None, msg: str = "ok") -> JSONResponse:
return JSONResponse({"status": 0, "msg": msg, "data": data})
def _err(code: int, msg: str) -> JSONResponse:
return JSONResponse({"status": code, "msg": msg})
async def _first_bot():
try:
from nonebot import get_adapter
from nonebot.adapters.onebot.v11 import Adapter
bots = get_adapter(Adapter).bots
return next(iter(bots.values()), None)
except Exception: # noqa: BLE001
return None
def build_admin_app() -> FastAPI | None:
"""构建群聊人设卡管理 API 子应用(挂载到 /api/galgame_card)。
在 hub 统一登录态下可用;未启用 Web 时返回 None。
"""
if not config.web_enable:
return None
app = FastAPI(title="Galgame Card API")
@app.get("/groups", dependencies=[API])
async def groups():
rows = await repository.list_groups()
corpus = await repository.corpus_counts_by_group()
impressions = await repository.impression_counts_by_group()
summaries = await repository.summary_counts_by_group()
participants = await repository.participant_counts_by_group()
result = []
for row in rows:
gid = row.group_id
result.append(
{
"group_id": gid,
"group_name": row.group_name,
"enabled": row.enabled,
"participants": participants.get(gid, 0),
"corpus_count": corpus.get(gid, 0),
"impression_count": impressions.get(gid, 0),
"summary_count": summaries.get(gid, 0),
}
)
return _ok(result)
@app.get("/groups/discover", dependencies=[API])
async def discover_groups():
bot = await _first_bot()
if bot is None:
return _err(-100, "未连接 OneBot,无法获取群列表")
group_list = await bot.get_group_list()
managed_ids = {row.group_id for row in await repository.list_groups()}
result = [
{
"group_id": int(g["group_id"]),
"group_name": g.get("group_name", ""),
"managed": int(g["group_id"]) in managed_ids,
}
for g in group_list
]
await repository.backfill_group_names(
{int(g["group_id"]): g.get("group_name", "") for g in group_list}
)
return _ok(result)
@app.post("/groups", dependencies=[API])
async def add_group(data: dict):
gid = int(data.get("group_id") or 0)
if gid <= 0:
return _err(400, "group_id 不合法")
await repository.add_group(gid, str(data.get("group_name") or "").strip())
return _ok(msg="已加入管理列表")
@app.post("/groups/{group_id}/enabled", dependencies=[API])
async def set_group_enabled(group_id: int, data: dict):
await repository.set_group_enabled(group_id, bool(data.get("enabled")))
return _ok(msg="已开启采集" if data.get("enabled") else "已关闭采集")
@app.get("/groups/{group_id}/participants", dependencies=[API])
async def participants(group_id: int):
members = await repository.joined_users(group_id)
stats = dict(await repository.chat_log_stats_by_user(group_id))
result = [
{"user_id": uid, "corpus_count": stats.get(uid, 0)} for uid in members
]
return _ok(result)
@app.post("/groups/{group_id}/participants", dependencies=[API])
async def add_participant(group_id: int, data: dict):
user_id = int(data.get("user_id") or 0)
if user_id <= 0:
return _err(400, "user_id 不合法")
if await repository.is_joined(user_id, group_id):
return _err(400, "已在人设名单中")
await repository.join(user_id, group_id)
return _ok(msg="已加入")
@app.delete("/groups/{group_id}/participants/{user_id}", dependencies=[API])
async def remove_participant(group_id: int, user_id: int):
await repository.leave(user_id, group_id)
return _ok(msg="已移除")
@app.get("/groups/{group_id}/logs", dependencies=[API])
async def logs(
group_id: int,
user_id: Optional[int] = None,
page: int = 1,
per_page: int = 50,
):
offset = max(0, (page - 1) * per_page)
items = await repository.list_logs(group_id, user_id, offset, per_page)
total = await repository.count_logs_filtered(group_id, user_id)
result = [
{
"id": row.id,
"user_id": row.user_id,
"nickname": row.nickname,
"content": row.content,
"target_user_id": row.target_user_id,
"target_inherited": row.target_inherited,
"follows_id": row.follows_id,
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
for row in items
]
return _ok({"items": result, "total": total})
@app.get("/groups/{group_id}/impressions", dependencies=[API])
async def impressions(group_id: int, page: int = 1, per_page: int = 50):
offset = max(0, (page - 1) * per_page)
items = await repository.list_impressions_group(group_id, offset, per_page)
result = [
{
"id": row.id,
"user_id": row.user_id,
"content": row.content,
"cover_from_id": row.cover_from_id,
"cover_to_id": row.cover_to_id,
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
for row in items
]
return _ok(
{
"items": result,
"total": await repository.count_impressions_group(group_id),
}
)
@app.get("/groups/{group_id}/summaries", dependencies=[API])
async def summaries(group_id: int):
items = await repository.list_summaries_group(group_id)
result = [
{
"version": row.version,
"card_text": row.card_text,
"corpus_count": row.corpus_count,
"impression_count": row.impression_count,
"model": row.model,
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
for row in items
]
return _ok(result)
@app.delete("/groups/{group_id}/logs", dependencies=[API])
async def delete_logs(group_id: int, user_id: Optional[int] = None):
count = await repository.delete_logs(group_id, user_id)
return _ok(msg=f"已删除 {count} 条语料")
@app.delete("/groups/{group_id}/data", dependencies=[API])
async def clear_group(group_id: int):
await repository.clear_group_data(group_id)
return _ok(msg="已清空该群全部数据")
@app.delete("/groups/{group_id}", dependencies=[API])
async def remove_group(group_id: int):
await repository.remove_group(group_id)
return _ok(msg="已移出管理列表(数据保留,重新加入可续上)")
@app.get("/settings", dependencies=[API])
async def settings():
from . import processor
return _ok(
{
"web_username": config.web_username,
"web_enable": config.web_enable,
"thresholds": {
"max_content_len": processor.MAX_CONTENT_LEN,
"repeat_window": processor.REPEAT_WINDOW,
"throttle_seconds": processor.THROTTLE_SECONDS,
"rolling_window": repository.ROLLING_WINDOW,
},
}
)
return app