686 lines
24 KiB
Python
686 lines
24 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""统一 Web 管理系统(/hub):自动聚合所有注册过的 Web 插件。
|
||
|
||
- 插件在自己 __init__ 里调用 hexi.web_hub.web_plugin_registry.register_web_plugin(...) 注册
|
||
- hub 启动时自动挂载每个插件 API 到 /api/<id>,无需手动配置
|
||
- /hub/api/plugins 由注册中心聚合返回
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
import asyncio
|
||
import json
|
||
import logging as _logging
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
|
||
from fastapi import Depends, FastAPI, HTTPException, status
|
||
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
||
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 .web_auth import (
|
||
authenticate,
|
||
change_password,
|
||
get_current_user,
|
||
get_user_by_token,
|
||
issue_token,
|
||
oauth2_scheme,
|
||
revoke_token,
|
||
sync_admin,
|
||
)
|
||
from .web_plugin_registry import get_web_plugin_builder, list_web_plugins
|
||
|
||
from .dashboard import collect_dashboard
|
||
|
||
# hexi/web_hub 为机器人核心模块(统一 Web 管理台),非 NoneBot 插件
|
||
basic_path = Path(__file__).resolve().parent
|
||
# hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web
|
||
WEB_DIST = Path(__file__).resolve().parents[1] / "web" / "dist"
|
||
|
||
def _hub_version() -> str:
|
||
"""管理台版本号:以 hexi/web/package.json 为唯一来源(前端构建也读它)。"""
|
||
try:
|
||
pkg = WEB_DIST.parent / "package.json"
|
||
return str(json.loads(pkg.read_text(encoding="utf-8")).get("version", "0.1.0"))
|
||
except Exception: # noqa: BLE001
|
||
return "0.1.0"
|
||
|
||
# 仓库根目录(bot.py 所在)与 bot 端口
|
||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
_BOT_PORT = 39697
|
||
|
||
# —— 日志清理策略(可用 .env 覆盖)——
|
||
# 单次运行时 _bot_run.log 超过该大小即直接清空(不归档),避免长期运行无限膨胀。
|
||
# 阈值读取优先级:HEXI_LOG_MAX_BYTES(字节) > HEXI_LOG_MAX_MB(MB) > 默认 20MB。
|
||
# 后台清理检查周期(秒)
|
||
_LOG_WATCH_INTERVAL = float(os.environ.get("HEXI_LOG_WATCH_INTERVAL", "60"))
|
||
# 清空互斥锁(防止后台线程与其它路径同时清空)
|
||
_LOG_LOCK = threading.Lock()
|
||
|
||
|
||
def _env_get(name: str, default: str = "") -> str:
|
||
"""读取配置:os.environ > .env 文件 > default。NoneBot 不会把 .env 注入 os.environ,故手动读取。"""
|
||
val = os.getenv(name)
|
||
if val:
|
||
return val
|
||
try:
|
||
env_path = _REPO_ROOT / ".env"
|
||
if env_path.exists():
|
||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||
line = line.strip()
|
||
if line.startswith(name + "=") or line.startswith(name + " ="):
|
||
return line.split("=", 1)[1].strip().split(" #", maxsplit=1)[0].strip()
|
||
except OSError:
|
||
pass
|
||
return default
|
||
|
||
|
||
def _log_max_bytes() -> int:
|
||
"""日志清理阈值(字节)。优先级:HEXI_LOG_MAX_BYTES > HEXI_LOG_MAX_MB > 默认 20MB。"""
|
||
raw = _env_get("HEXI_LOG_MAX_BYTES")
|
||
if raw:
|
||
try:
|
||
return max(1, int(raw))
|
||
except ValueError:
|
||
pass
|
||
mb = _env_get("HEXI_LOG_MAX_MB")
|
||
if mb:
|
||
try:
|
||
return max(1, int(float(mb)) * 1024 * 1024)
|
||
except ValueError:
|
||
pass
|
||
return 20 * 1024 * 1024
|
||
|
||
|
||
def _spawn_bot() -> None:
|
||
"""以分离进程方式拉起 bot.py:等旧实例释放端口后接管,日志追加到 _bot_run.log。"""
|
||
try:
|
||
log_fp = open(_REPO_ROOT / "_bot_run.log", "ab", buffering=0)
|
||
except OSError:
|
||
log_fp = None
|
||
# 子进程 bootstrap:等端口空闲 -> 直接以 __main__ 运行 bot.py(该进程即 bot,不残留 wrapper)
|
||
bootstrap = (
|
||
"import socket,time,sys,runpy\n"
|
||
"port=%d\n" % _BOT_PORT
|
||
+ "for _ in range(120):\n"
|
||
" s=socket.socket()\n"
|
||
" try:\n"
|
||
" s.bind(('0.0.0.0',port)); s.close(); break\n"
|
||
" except OSError:\n"
|
||
" s.close(); time.sleep(0.5)\n"
|
||
"else:\n"
|
||
" sys.exit(2)\n"
|
||
"runpy.run_path('bot.py', run_name='__main__')\n"
|
||
)
|
||
flags = 0
|
||
if hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
||
flags |= subprocess.CREATE_NEW_PROCESS_GROUP
|
||
if hasattr(subprocess, "DETACHED_PROCESS"):
|
||
flags |= subprocess.DETACHED_PROCESS
|
||
env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
|
||
subprocess.Popen(
|
||
[sys.executable, "-c", bootstrap],
|
||
cwd=str(_REPO_ROOT),
|
||
stdout=log_fp,
|
||
stderr=subprocess.STDOUT,
|
||
creationflags=flags,
|
||
env=env,
|
||
)
|
||
|
||
|
||
def _purge_legacy_archives() -> None:
|
||
"""删除历史遗留的 bot_run_*.log 归档(策略改为不归档,仅保留当前 _bot_run.log)。"""
|
||
logs_dir = _REPO_ROOT / "hexi" / "logs"
|
||
try:
|
||
for p in logs_dir.glob("bot_run_*.log"):
|
||
try:
|
||
p.unlink()
|
||
except OSError:
|
||
pass
|
||
except OSError as e: # noqa: BLE001
|
||
logger.warning(f"清理历史归档日志失败: {type(e).__name__}: {e}")
|
||
|
||
|
||
def _clear_log() -> None:
|
||
"""把 _bot_run.log 截断清空(不归档)。写端均为 append 模式,截断后下次写入从头续写。"""
|
||
with _LOG_LOCK:
|
||
log = _REPO_ROOT / "_bot_run.log"
|
||
if not log.exists():
|
||
return
|
||
try:
|
||
open(log, "wb").close()
|
||
logger.info("_bot_run.log 超过阈值,已清空(不归档)。")
|
||
except OSError as e: # noqa: BLE001
|
||
logger.warning(f"清空日志失败: {type(e).__name__}: {e}")
|
||
|
||
|
||
def _rotate_log_if_needed() -> None:
|
||
"""当 _bot_run.log 超过阈值时直接清空(不归档),避免长期运行无限膨胀。"""
|
||
try:
|
||
log = _REPO_ROOT / "_bot_run.log"
|
||
if log.exists() and log.stat().st_size >= _log_max_bytes():
|
||
_clear_log()
|
||
except OSError as e: # noqa: BLE001
|
||
logger.warning(f"日志轮转检查失败: {type(e).__name__}: {e}")
|
||
|
||
|
||
def _start_log_watchdog() -> None:
|
||
"""后台线程按周期检查日志大小,超阈值直接清空(不归档),避免长期运行无限膨胀。"""
|
||
global _log_watchdog_started
|
||
if _log_watchdog_started:
|
||
return
|
||
_log_watchdog_started = True
|
||
_purge_legacy_archives() # 顺带清掉历史遗留的归档文件
|
||
|
||
def _loop() -> None:
|
||
while True:
|
||
time.sleep(_LOG_WATCH_INTERVAL)
|
||
_rotate_log_if_needed()
|
||
|
||
threading.Thread(target=_loop, name="hexi-log-watchdog", daemon=True).start()
|
||
|
||
|
||
_log_watchdog_started = False
|
||
|
||
|
||
def _tail_log_lines(
|
||
path: Path, limit: int, before: int | None
|
||
) -> tuple[list[str], int, int]:
|
||
"""从日志文件读取最近 limit 行(或 before 字节之前最近的 limit 行)。
|
||
|
||
返回 (lines, window_start_offset, file_size)。window_start_offset 可作为
|
||
下一批更早日志的 before;为 0 表示已到文件头。仅按 8KB 步进向后寻找行边界,
|
||
避免大日志全量 read_bytes。逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与
|
||
切换为 UTF-8 后的新日志混存(保留 ANSI 颜色码,交给前端渲染颜色)。
|
||
"""
|
||
size = path.stat().st_size
|
||
end = size if before is None or before <= 0 or before > size else int(before)
|
||
if end <= 0:
|
||
return [], 0, size
|
||
start = 0
|
||
with open(path, "rb") as f:
|
||
pos = end
|
||
newline_count = 0
|
||
while pos > 0 and newline_count <= limit:
|
||
step = min(pos, 8192)
|
||
f.seek(pos - step)
|
||
chunk = f.read(step)
|
||
idx = chunk.rfind(b"\n")
|
||
if idx == -1:
|
||
pos -= step
|
||
continue
|
||
nl_abs = pos - step + idx
|
||
newline_count += 1
|
||
if newline_count > limit:
|
||
start = nl_abs + 1
|
||
break
|
||
pos = nl_abs
|
||
f.seek(start)
|
||
raw = f.read(end - start)
|
||
raw_lines = raw.split(b"\n")
|
||
if raw_lines and raw_lines[-1] == b"":
|
||
raw_lines.pop()
|
||
lines = []
|
||
for raw_line in raw_lines:
|
||
try:
|
||
line = raw_line.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
line = raw_line.decode("gbk", errors="replace")
|
||
lines.append(line.rstrip("\r"))
|
||
return lines, start, size
|
||
|
||
|
||
def _count_log_lines(path: Path) -> int:
|
||
"""分块统计全文件行数(仅换行计数,不全量载入)。"""
|
||
count = 0
|
||
with open(path, "rb") as f:
|
||
while True:
|
||
chunk = f.read(1 << 20)
|
||
if not chunk:
|
||
break
|
||
count += chunk.count(b"\n")
|
||
return count
|
||
|
||
|
||
def _schedule_bot_action(action: str) -> None:
|
||
"""1 秒后执行:stop 直接退出;restart 先拉起新进程再退出旧进程。"""
|
||
|
||
def _work() -> None:
|
||
time.sleep(1.0)
|
||
if action == "restart":
|
||
_spawn_bot()
|
||
time.sleep(1.0)
|
||
os._exit(0)
|
||
|
||
threading.Thread(target=_work, daemon=True).start()
|
||
|
||
|
||
def _uvicorn_access_debug(record: _logging.LogRecord) -> bool:
|
||
"""把 uvicorn 访问日志级别改为 DEBUG:默认 INFO 下不显示,开启 DEBUG 才可见。"""
|
||
if record.name == "uvicorn.access":
|
||
record.levelno = _logging.DEBUG
|
||
record.levelname = "DEBUG"
|
||
return True
|
||
|
||
|
||
class PasswordModel(BaseModel):
|
||
old_password: str
|
||
new_password: str
|
||
|
||
|
||
class PluginControlModel(BaseModel):
|
||
enabled: bool | None = None
|
||
chat: list[str] | None = None
|
||
|
||
|
||
class BotConfigModel(BaseModel):
|
||
superusers: list[str] | None = None
|
||
nickname: list[str] | None = None
|
||
debug: bool | None = None
|
||
log_level: str | None = None
|
||
log_max_mb: int | 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/library 插件(含带配置项的 library)+ 控制面状态 + 是否带 Web 页面。"""
|
||
from hexi.core.plugin_control import list_plugins
|
||
from .web_config import has_schema
|
||
from .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.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.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.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 .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 .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.get("/api/system/bot_config")
|
||
async def bot_config_get(_: dict = Depends(get_current_user)):
|
||
"""读取 bot 全局配置(SUPERUSERS / NICKNAME / DEBUG / LOG_LEVEL)。"""
|
||
from .bot_settings import current_values
|
||
|
||
return {"ok": True, **current_values()}
|
||
|
||
@app.post("/api/system/bot_config")
|
||
async def bot_config_set(
|
||
payload: BotConfigModel,
|
||
_: dict = Depends(get_current_user),
|
||
):
|
||
"""保存 bot 全局配置并尽量热应用到运行态。"""
|
||
from .bot_settings import save_bot_settings
|
||
|
||
try:
|
||
result = save_bot_settings(payload.dict(exclude_none=True))
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||
return result
|
||
|
||
@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": "密码已修改"}
|
||
|
||
@app.get("/api/logs")
|
||
async def logs(
|
||
limit: int = 500,
|
||
before: int | None = None,
|
||
_: dict = Depends(get_current_user),
|
||
):
|
||
"""读取 bot 运行日志(优先 _bot_run.log)。
|
||
|
||
- before 不传:返回最近 limit 行,offset/end 为文件尾(SSE 以 end 续读)
|
||
- before 传字节偏移:返回该偏移之前最近 limit 行,offset 为这些行的起点,
|
||
继续把 offset 传给 before 即可向前翻页;offset==0 表示已到文件头。
|
||
"""
|
||
root = Path(__file__).resolve().parents[2] # 仓库根目录
|
||
candidates = [
|
||
root / "_bot_run.log",
|
||
root / "hexi" / "logs" / "bot.log",
|
||
]
|
||
path = next((p for p in candidates if p.exists()), None)
|
||
if path is None:
|
||
return {"ok": False, "msg": "未找到日志文件", "lines": [], "size": 0, "total": 0}
|
||
try:
|
||
lines, start, size = _tail_log_lines(path, max(1, int(limit)), before)
|
||
total = _count_log_lines(path)
|
||
except OSError as e: # noqa: BLE001
|
||
return {"ok": False, "msg": f"读取日志失败: {e}", "lines": [], "size": 0, "total": 0}
|
||
return {
|
||
"ok": True,
|
||
"file": path.name,
|
||
"size": size,
|
||
"end": size,
|
||
"offset": start,
|
||
"has_more": start > 0,
|
||
"lines": lines,
|
||
"total": total,
|
||
}
|
||
|
||
@app.get("/api/dashboard/stream")
|
||
async def dashboard_stream(
|
||
_: dict = Depends(get_current_user),
|
||
):
|
||
"""Dashboard 状态 SSE 实时推送(每 2 秒采集一次)。"""
|
||
|
||
async def gen():
|
||
while True:
|
||
try:
|
||
data = await collect_dashboard()
|
||
payload = {"ok": True, **data}
|
||
except Exception as e: # noqa: BLE001
|
||
payload = {"ok": False, "msg": f"采集失败: {e}"}
|
||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||
await asyncio.sleep(2)
|
||
|
||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||
|
||
@app.get("/api/logs/stream")
|
||
async def logs_stream(
|
||
since: int = 0,
|
||
_: dict = Depends(get_current_user),
|
||
):
|
||
"""SSE 实时日志流:从 since(字节偏移)增量推送新日志行。"""
|
||
root = Path(__file__).resolve().parents[2] # 仓库根目录
|
||
candidates = [
|
||
root / "_bot_run.log",
|
||
root / "hexi" / "logs" / "bot.log",
|
||
]
|
||
path = next((p for p in candidates if p.exists()), None)
|
||
if path is None:
|
||
raise HTTPException(status_code=404, detail="未找到日志文件")
|
||
|
||
async def gen():
|
||
current = max(0, since)
|
||
beat = 0
|
||
await asyncio.sleep(0)
|
||
while True:
|
||
try:
|
||
size = path.stat().st_size
|
||
except OSError:
|
||
await asyncio.sleep(1.0)
|
||
continue
|
||
if current > size:
|
||
current = 0 # 日志被轮转/截断
|
||
if current < size:
|
||
try:
|
||
with open(path, "rb") as f:
|
||
f.seek(current)
|
||
raw = f.read()
|
||
except OSError:
|
||
await asyncio.sleep(1.0)
|
||
continue
|
||
last_nl = raw.rfind(b"\n")
|
||
if last_nl != -1:
|
||
# 只处理完整行,结尾半行留给下次;保留 ANSI 颜色码供前端渲染
|
||
data = raw[: last_nl + 1]
|
||
raw_lines = data.split(b"\n")
|
||
if raw_lines and raw_lines[-1] == b"":
|
||
raw_lines = raw_lines[:-1]
|
||
current = current + last_nl + 1
|
||
for raw_line in raw_lines:
|
||
try:
|
||
line = raw_line.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
line = raw_line.decode("gbk", errors="replace")
|
||
payload = {"line": line.rstrip("\r"), "offset": current}
|
||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||
await asyncio.sleep(0.5)
|
||
beat += 1
|
||
if beat >= 30: # 每 ~15s 一次心跳保活
|
||
beat = 0
|
||
yield ": ping\n\n"
|
||
|
||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||
|
||
@app.post("/api/system/bot")
|
||
async def bot_action(
|
||
payload: dict,
|
||
_: dict = Depends(get_current_user),
|
||
):
|
||
"""停止/重启 bot 进程(stop 直接退出;restart 先拉起新进程再退出旧进程)。"""
|
||
action = (payload or {}).get("action", "")
|
||
if action not in {"stop", "restart"}:
|
||
raise HTTPException(status_code=400, detail="action 必须是 stop 或 restart")
|
||
_schedule_bot_action(action)
|
||
label = "停止" if action == "stop" else "重启"
|
||
return {"ok": True, "msg": f"已请求{label} bot,约 1 秒后执行"}
|
||
|
||
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()
|
||
_start_log_watchdog()
|
||
|
||
|
||
@get_driver().on_startup
|
||
async def _startup_mount_web_plugins() -> None:
|
||
"""等所有插件 import 完成后,注册表就绪,再统一挂载各插件 API。"""
|
||
_mount_all_web_plugins()
|
||
|
||
|
||
@get_driver().on_startup
|
||
async def _downgrade_uvicorn_access_logs() -> None:
|
||
"""把 uvicorn.access 日志降级为 DEBUG(经 loguru 后按日志等级过滤)。"""
|
||
_logging.getLogger("uvicorn.access").addFilter(_uvicorn_access_debug) |