Files
HeXi/hexi/plugins/nonebot_plugin_galgame_card/web.py
T

267 lines
10 KiB
Python
Raw Normal View History

"""Web 管理后台:群开关 / 参与者 / 数据浏览(挂在 bot 端口 /galgame_card 路径)
仿 nonebot_plugin_learning_chat 的网页模式:JWT 登录 + /galgame_card/api/* 路由 + 静态页。
"""
import datetime
from pathlib import Path
from typing import Optional
from fastapi import Depends, Header, HTTPException
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from jose import jwt
from nonebot import get_adapter, get_app, get_driver, logger
from nonebot.adapters.onebot.v11 import Adapter, Bot
from pydantic import BaseModel
from . import repository
from .config import config
API_PREFIX = "/galgame_card/api"
WEB_DIR = Path(__file__).parent / "web"
driver = get_driver()
def authentication():
def inner(token: Optional[str] = Header(None, alias="Authorization")):
try:
payload = jwt.decode(token, config.web_secret_key, algorithms="HS256")
if not payload.get("username") or payload["username"] != config.web_username:
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
except (jwt.JWTError, jwt.ExpiredSignatureError, AttributeError):
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
return Depends(inner)
class LoginModel(BaseModel):
username: str
password: str
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() -> Optional[Bot]:
bots = get_adapter(Adapter).bots
return next(iter(bots.values()), None)
@driver.on_startup
async def init_web():
if not config.web_enable:
return
app = get_app()
@app.post(f"{API_PREFIX}/login")
async def login(user: LoginModel):
if user.username != config.web_username or user.password != config.web_password:
return _err(-100, "登录失败,请确认账号密码无误")
token = jwt.encode(
{
"username": user.username,
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12),
},
config.web_secret_key,
algorithm="HS256",
)
return _ok({"token": token}, "登录成功")
@app.get(f"{API_PREFIX}/groups", dependencies=[authentication()])
async def groups():
"""管理中的群:数据库直读,不依赖 OneBot"""
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(f"{API_PREFIX}/groups/discover", dependencies=[authentication()])
async def discover_groups():
"""OneBot 全部群("添加群"选择器用),顺带回填已管理群名称"""
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(f"{API_PREFIX}/groups", dependencies=[authentication()])
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(f"{API_PREFIX}/groups/{{group_id}}/enabled", dependencies=[authentication()])
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(f"{API_PREFIX}/groups/{{group_id}}/participants", dependencies=[authentication()])
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(f"{API_PREFIX}/groups/{{group_id}}/participants", dependencies=[authentication()])
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(f"{API_PREFIX}/groups/{{group_id}}/participants/{{user_id}}", dependencies=[authentication()])
async def remove_participant(group_id: int, user_id: int):
await repository.leave(user_id, group_id)
return _ok(msg="已移除")
@app.get(f"{API_PREFIX}/groups/{{group_id}}/logs", dependencies=[authentication()])
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(f"{API_PREFIX}/groups/{{group_id}}/impressions", dependencies=[authentication()])
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(f"{API_PREFIX}/groups/{{group_id}}/summaries", dependencies=[authentication()])
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(f"{API_PREFIX}/groups/{{group_id}}/logs", dependencies=[authentication()])
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(f"{API_PREFIX}/groups/{{group_id}}/data", dependencies=[authentication()])
async def clear_group(group_id: int):
await repository.clear_group_data(group_id)
return _ok(msg="已清空该群全部数据")
@app.delete(f"{API_PREFIX}/groups/{{group_id}}", dependencies=[authentication()])
async def remove_group(group_id: int):
await repository.remove_group(group_id)
return _ok(msg="已移出管理列表(数据保留,重新加入可续上)")
@app.get(f"{API_PREFIX}/settings", dependencies=[authentication()])
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,
},
}
)
# React 管理后台(web/persona-admin 构建产物)
dist_dir = WEB_DIR / "persona-admin" / "dist"
if (dist_dir / "index.html").exists():
app.mount(
"/galgame_card/assets",
StaticFiles(directory=dist_dir / "assets"),
name="galgame_card_assets",
)
@app.get("/galgame_card")
@app.get("/galgame_card/")
async def admin_page():
return FileResponse(dist_dir / "index.html")
else:
# 构建产物缺失时回退到旧版单文件页面
@app.get("/galgame_card")
@app.get("/galgame_card/")
async def admin_page():
html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
return HTMLResponse(html)
logger.info("群聊人设卡 | Web 管理后台已挂载: /galgame_card")