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,142 @@
"""NoneBot 版插件配置加载。
配置持久化在数据目录 config.json,键值结构与原 AstrBot 配置分组一致,
这样可以直接喂给 core 的 ConfigManager(它本质只读嵌套 dict)。
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from .core.shared.constants import PLUGIN_NAME
from .core.utils.paths import get_data_dir
class ConfigDict(dict):
"""兼容原 ConfigManager 的 dict;save_config 会把改动持久化到 config.json。"""
def save_config(self) -> None:
try:
config_file().write_text(
json.dumps(dict(self), ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
except OSError:
pass
def _build_from_items(items: dict) -> dict:
"""从 schema 的 items 递归生成嵌套默认配置。"""
out: dict = {}
for key, item in items.items():
if not isinstance(item, dict):
continue
if item.get("type") == "object" and isinstance(item.get("items"), dict):
out[key] = _build_from_items(item["items"])
elif "default" in item:
out[key] = item["default"]
return out
def _schema_defaults() -> dict:
"""从插件的 _conf_schema.json 读取完整默认配置。"""
schema_path = Path(__file__).resolve().parents[0] / "_conf_schema.json"
try:
schema = json.loads(schema_path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError):
return {}
defaults: dict = {}
for group, spec in schema.items():
if isinstance(spec, dict) and isinstance(spec.get("items"), dict):
defaults[group] = _build_from_items(spec["items"])
return defaults
def _read_config_value(*names: str, default: str = "") -> str:
"""依次从 shell 环境变量与 NoneBot driver.config(.env) 读取值。
NoneBot 的 .env 是通过 pydantic 加载的,不会写入 os.environ,
因此必须能从 get_driver().config 的小写属性读取。优先环境变量。
"""
for n in names:
v = os.environ.get(n)
if v:
return str(v)
try:
from nonebot import get_driver
drv_cfg = get_driver().config
except Exception:
return default
for n in names:
attr = n.lower()
try:
v = getattr(drv_cfg, attr, None)
except Exception:
v = None
if v:
return str(v)
return default
def _llm_env_values() -> dict:
"""用户必填的 LLM 端点/密钥/模型;AstrBot 版是 Provider 体系,插件本身不存这些。"""
return {
"llm_api_base": _read_config_value("HEXI_LLM_API_BASE"),
"llm_api_key": _read_config_value("HEXI_LLM_API_KEY"),
"llm_model": _read_config_value("HEXI_LLM_MODEL"),
}
def _default_config() -> dict:
cfg = _schema_defaults()
cfg.setdefault("llm", {}).update(_llm_env_values())
cfg.setdefault("basic", {}).setdefault("output_format", ["image"])
cfg.setdefault("basic", {}).setdefault("enable_base64_image", True)
cfg.setdefault("basic", {}).setdefault("analysis_days", 1)
cfg.setdefault("basic", {}).setdefault("max_messages", 1000)
cfg.setdefault("basic", {}).setdefault("min_messages_threshold", 50)
cfg.setdefault("basic", {}).setdefault("filter_bot_messages", True)
cfg.setdefault("analysis_features", {}).setdefault("chat_quality_analysis_enabled", False)
cfg.setdefault("incremental", {}).setdefault("incremental_enabled", False)
return cfg
def config_file() -> Path:
return get_data_dir(PLUGIN_NAME) / "config.json"
def load_config() -> ConfigDict:
cfg = ConfigDict(_default_config())
f = config_file()
if f.exists():
try:
user = json.loads(f.read_text(encoding="utf-8"))
for group, values in user.items():
if isinstance(values, dict):
cfg.setdefault(group, {}).update(values)
else:
cfg[group] = values
except (OSError, json.JSONDecodeError) as e:
_log_warn(f"读取配置文件失败: {e}")
# 环境变量/NoneBot .env 永远优先于持久化 config.json,避免被空值覆盖
cfg.setdefault("llm", {}).update(_llm_env_values())
return cfg
def save_config(cfg: dict) -> None:
try:
config_file().write_text(
json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8"
)
except OSError as e:
_log_warn(f"保存配置文件失败: {e}")
def _log_warn(msg: str) -> None:
try:
from .core.utils.logger import logger
logger.warning(msg)
except Exception:
pass