Files
HeXi/hexi/web_hub/bot_settings.py
T
sansenhoshiandClaude Code 968b3d1cfe feat(web): 任务中心全局 Provider / 设置·布局·路由调整 / 视频解析页适配
- tasks.tsx:顶栏任务中心全局 Provider(SSE 任务进度,供各插件页共享)
- 设置页与 AppLayout / client / router 调整,BF2042 插件页路由注册
- web_hub:dashboard / bot_settings 同步更新
- 视频解析页配合后端(缓存 tab / 任务槽进度)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:27 +08:00

281 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Bot 全局配置的读取 / 写入(SUPERUSERS / NICKNAME / DEBUG / LOG_LEVEL)。
- 读取:优先取 NoneBot driver.config 的当前生效值,退化到 .env / os.environ。
- 写入:落盘到 .env(保留 JSON 列表格式),写 os.environ(即时可读),
并在可能范围内热应用到 driver.config 与 loguru(LOG_LEVEL)。
- 注意:个别插件在 import 时把 SUPERUSERS/NICKNAME 拷贝成了常量,
想让所有插件完全生效建议保存后重启 bot(前端会给出提示)。
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from nonebot import get_driver, logger
_REPO_ROOT = Path(__file__).resolve().parents[2]
# 仓库根目录的 .env(NoneBot 启动时自动加载)
_ENV_PATH = _REPO_ROOT / ".env"
# 表单键 -> 环境变量名
_KEY_ENV: dict[str, str] = {
"superusers": "SUPERUSERS",
"nickname": "NICKNAME",
"debug": "DEBUG",
"log_level": "LOG_LEVEL",
}
# LOG_LEVEL 可选项(写入时建议大写)
LOG_LEVEL_OPTIONS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
# 资源发送方式(写入 .env 的 HEXI_MEDIA_MODE;语义见 hexi/core/outbound_media._mode)
MEDIA_MODE_OPTIONS = ["http", "base64", "file"]
_MEDIA_MODE_ENV = "HEXI_MEDIA_MODE"
# 日志清理大小(MB)默认值(未在 .env 配置时)
_LOG_MAX_MB_DEFAULT = 20
def _env_get(name: str, default: str = "") -> str:
"""os.environ > .env 文件 > default。"""
val = os.getenv(name)
if val:
return val
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()
return default
def _parse_list(value: str) -> list[str]:
"""把 env 里可能是 JSON 数组或逗号/空白分隔的列表解析成 list[str]。"""
value = (value or "").strip()
if not value:
return []
try:
parsed = json.loads(value)
if isinstance(parsed, list):
return [str(x) for x in parsed]
return [str(parsed)]
except Exception:
# 兼容逗号/空格分隔、带引号的形式
return [
part.strip().strip("\"'").strip()
for part in value.replace(" ", ",").split(",")
if part.strip()
]
def _serialize_list(items: list[str]) -> str:
"""序列化成 .env 使用的 JSON 数组字符串。"""
return json.dumps([str(x) for x in items], ensure_ascii=False)
def _normalize_log_level(value: Any) -> str:
"""把 log_level 统一成大写字符串。"""
if isinstance(value, int):
# loguru 数字等级:20=DEBUG 30=INFO 40=WARNING 50=ERROR
mapping = {10: "TRACE", 20: "DEBUG", 30: "INFO", 40: "WARNING", 50: "ERROR", 60: "CRITICAL"}
return mapping.get(value, "INFO")
return str(value).upper()
def _current_log_max_mb() -> int:
"""当前日志清理大小(MB)。优先级:HEXI_LOG_MAX_MB > HEXI_LOG_MAX_BYTES(换算) > 默认。"""
raw = _env_get("HEXI_LOG_MAX_MB").strip()
if raw:
try:
val = float(raw)
if val > 0:
return int(val)
except ValueError:
pass
raw_bytes = _env_get("HEXI_LOG_MAX_BYTES").strip()
if raw_bytes:
try:
b = int(raw_bytes)
if b > 0:
return max(1, b // (1024 * 1024))
except ValueError:
pass
return _LOG_MAX_MB_DEFAULT
def _current_media_mode() -> str:
"""当前资源发送方式(HEXI_MEDIA_MODE > 旧的两个开关 > 默认 http)。
推断规则必须与 `hexi.core.outbound_media._mode` 一致(那边运行时读
os.environ,这边多读一层 .env 文件,好在手改 .env 未重启时也能显示正确)。
"""
raw = _env_get(_MEDIA_MODE_ENV).strip().lower()
if raw in MEDIA_MODE_OPTIONS:
return raw
if _env_get("HEXI_INLINE_MEDIA").strip().lower() in ("0", "false", "no", "off"):
return "file"
if _env_get("HEXI_INLINE_MEDIA_URL").strip().lower() in ("0", "false", "no", "off"):
return "base64"
return "http"
def current_values() -> dict[str, Any]:
"""返回当前生效值(读取优先级:driver.config > .env)。"""
try:
cfg = get_driver().config
except Exception:
cfg = None
if cfg is not None:
superusers = sorted(str(x) for x in getattr(cfg, "superusers", []) or [])
nickname = sorted(str(x) for x in getattr(cfg, "nickname", []) or [])
debug = bool(getattr(cfg, "debug", False))
log_level = _normalize_log_level(getattr(cfg, "log_level", "INFO"))
else:
superusers = _parse_list(_env_get("SUPERUSERS"))
nickname = _parse_list(_env_get("NICKNAME"))
debug = _env_get("DEBUG", "").strip().lower() in ("1", "true", "yes", "on")
log_level = _normalize_log_level(_env_get("LOG_LEVEL", "INFO"))
return {
"superusers": superusers,
"nickname": nickname,
"debug": bool(debug),
"log_level": log_level,
"log_level_options": LOG_LEVEL_OPTIONS,
"log_max_mb": _current_log_max_mb(),
"log_max_mb_default": _LOG_MAX_MB_DEFAULT,
"media_mode": _current_media_mode(),
"media_mode_options": MEDIA_MODE_OPTIONS,
"source": "config" if cfg is not None else "env",
}
def _set_env_line(name: str, value: str) -> None:
"""替换 .env 中已有的 name= 行(保留其它行),不存在则追加。"""
try:
if _ENV_PATH.exists():
lines = _ENV_PATH.read_text(encoding="utf-8").splitlines()
else:
lines = []
replaced = False
out: list[str] = []
for line in lines:
stripped = line.strip()
if stripped.startswith(name + "=") or stripped.startswith(name + " ="):
if not replaced:
out.append(f"{name}={value}")
replaced = True
# 重复行跳过
else:
out.append(line)
if not replaced:
out.append(f"{name}={value}")
out_text = "\n".join(out).rstrip("\n") + "\n"
_ENV_PATH.write_text(out_text, encoding="utf-8")
except Exception as e: # noqa: BLE001
logger.warning(f"写入 .env({name}) 失败: {type(e).__name__}: {e}")
def _apply_log_level(log_level: str) -> None:
"""把 LOG_LEVEL 热应用到 loguru 的默认过滤配置。"""
try:
from nonebot.log import logger as nb_logger
nb_logger.configure(extra={"nonebot_log_level": log_level})
except Exception as e: # noqa: BLE001
logger.warning(f"热应用 LOG_LEVEL 失败: {type(e).__name__}: {e}")
def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]:
"""保存并尽量热应用。返回(新的当前值 + 提示)。"""
# 规范化输入
superusers = [str(x) for x in (values.get("superusers") or []) if str(x).strip()]
nickname = [str(x) for x in (values.get("nickname") or []) if str(x).strip()]
debug = bool(values.get("debug", False))
log_level = _normalize_log_level(values.get("log_level", "INFO"))
if log_level not in LOG_LEVEL_OPTIONS:
raise ValueError(f"LOG_LEVEL 必须是 {'/'.join(LOG_LEVEL_OPTIONS)} 之一")
# 日志清理大小(MB,写入 .env 的 HEXI_LOG_MAX_MB)
log_max_mb: int | None = None
_log_max_mb_raw = values.get("log_max_mb")
if _log_max_mb_raw is not None and str(_log_max_mb_raw).strip() != "":
try:
log_max_mb = int(float(_log_max_mb_raw))
except (TypeError, ValueError):
raise ValueError("日志清理大小必须是正整数(MB)")
if log_max_mb <= 0:
raise ValueError("日志清理大小必须是正整数(MB)")
# 资源发送方式(http/base64/file,写入 HEXI_MEDIA_MODE)
media_mode: str | None = None
_media_mode_raw = values.get("media_mode")
if _media_mode_raw is not None and str(_media_mode_raw).strip() != "":
media_mode = str(_media_mode_raw).strip().lower()
if media_mode not in MEDIA_MODE_OPTIONS:
raise ValueError(f"资源发送方式必须是 {'/'.join(MEDIA_MODE_OPTIONS)} 之一")
# 1. 写 os.environ(即时可读,进程内 getenv 立刻生效)
os.environ["SUPERUSERS"] = _serialize_list(superusers)
os.environ["NICKNAME"] = _serialize_list(nickname)
os.environ["DEBUG"] = "true" if debug else "false"
os.environ["LOG_LEVEL"] = log_level
if log_max_mb is not None:
os.environ["HEXI_LOG_MAX_MB"] = str(log_max_mb)
if media_mode is not None:
# outbound_media._mode() 每次发送现读 os.environ → 保存即生效,无需重启
os.environ[_MEDIA_MODE_ENV] = media_mode
# 2. 落盘 .env(重启后依然生效)
_set_env_line("SUPERUSERS", _serialize_list(superusers))
_set_env_line("NICKNAME", _serialize_list(nickname))
_set_env_line("DEBUG", "true" if debug else "false")
_set_env_line("LOG_LEVEL", log_level)
if log_max_mb is not None:
_set_env_line("HEXI_LOG_MAX_MB", str(log_max_mb))
if media_mode is not None:
_set_env_line(_MEDIA_MODE_ENV, media_mode)
# 3. 热应用到 driver.config
applied: list[str] = []
try:
cfg = get_driver().config
if hasattr(cfg, "superusers"):
cfg.superusers = set(superusers)
applied.append("SUPERUSERS")
if hasattr(cfg, "nickname"):
cfg.nickname = set(nickname)
applied.append("NICKNAME")
# DEBUG 在 NoneBot Config 中属于 extra 字段,直接透传
try:
setattr(cfg, "debug", debug)
applied.append("DEBUG")
except Exception:
pass
if hasattr(cfg, "log_level"):
cfg.log_level = log_level
applied.append("LOG_LEVEL")
except Exception as e: # noqa: BLE001
logger.warning(f"热应用 driver.config 失败: {type(e).__name__}: {e}")
# 4. LOG_LEVEL 应用到 loguru
_apply_log_level(log_level)
if log_max_mb is not None:
applied.append("HEXI_LOG_MAX_MB")
if media_mode is not None:
applied.append(_MEDIA_MODE_ENV)
return {
"ok": True,
**current_values(),
"applied": applied,
"restart_required": True,
"notice": "已保存到 .env 并尽量热应用;部分插件在 import 时缓存了 SUPERUSERS/NICKNAME,"
"如需完全生效,请在下方执行「重启 bot」。",
}