Files
HeXi/hexi/config_standard.py
T
sansenhoshiandClaude b61d09f09f 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>
2026-09-01 13:13:40 +08:00

489 lines
17 KiB
Python

# -*- coding: utf-8 -*-
"""统一配置标准辅助:把插件 pydantic Config 模型一键接入 hexi.web_config。
这个模块是对 hexi.web_config 的薄封装,目的是把「插件配置标准」统一起来:
- 插件只需声明一份字段列表(或交给字段推断),就能在 Web 管理台可读可设置。
- 提供 getter(回填当前生效值)与 apply(保存后热更新 pydantic 对象)。
约定:
- plugin_id 必须是 NoneBot 插件模块名(与 plugin_control.list_plugins 一致),
这样 Web 的 /api/plugins/<id>/config 才能命中。
- 字段 key 就是 Config 模型里的属性名;env 名默认等于 key.upper()。
"""
from __future__ import annotations
import inspect
import json
from types import UnionType
from typing import Any, Callable, Iterable, Literal, Optional, Union, get_args, get_origin
from pydantic import BaseModel
from hexi.web_config import register_plugin_config
# 常见「密码/密钥」类字段名,前端会自动脱敏
_SECRET_HINTS = ("password", "secret", "token", "key", "cookie", "apikey", "api_key")
def _is_secret(key: str) -> bool:
kl = key.lower()
return any(h in kl for h in _SECRET_HINTS)
def _unwrap_optional(annotation: Any) -> tuple[Any, bool]:
"""剥掉 Optional/Union[..., None] 包装,返回 (真实类型, 是否可选)。"""
is_optional = False
origin = get_origin(annotation)
if origin is Union or origin is UnionType:
args = get_args(annotation)
non_none = [a for a in args if a is not type(None)]
if len(non_none) != len(args):
is_optional = True
if non_none:
return non_none[0], is_optional
return annotation, is_optional
def _type_label(annotation: Any) -> str:
"""把 pydantic 字段类型映射到 web_config 支持的 schema type。"""
annotation, _ = _unwrap_optional(annotation)
if annotation is bool:
return "bool"
if annotation is int:
return "int"
if annotation is float:
return "float"
if annotation is str:
return "string"
try:
origin = get_origin(annotation)
except Exception: # noqa: BLE001
origin = None
if origin is Literal:
return "enum"
if origin in (list, set, tuple):
args = get_args(annotation)
if args and args[0] is str:
return "text"
return "text"
return "string"
def _enum_options(annotation: Any) -> list[dict[str, str]]:
annotation, _ = _unwrap_optional(annotation)
try:
origin = get_origin(annotation)
args = get_args(annotation)
except Exception: # noqa: BLE001
return []
if origin is Literal:
return [{"value": str(o), "label": str(o)} for o in args]
return []
def _text_item_type(annotation: Any) -> str:
"""List[int]/List[float]/List[str] 的元素类型,用于 text 字段的取值转换。"""
annotation, _ = _unwrap_optional(annotation)
try:
origin = get_origin(annotation)
args = get_args(annotation)
except Exception: # noqa: BLE001
return "str"
if origin in (list, set, tuple) and args:
el, _ = _unwrap_optional(args[0])
if el is int:
return "int"
if el is float:
return "float"
if el is bool:
return "bool"
return "str"
def _coerce_value(value: Any, typ: str, item_type: str = "str") -> Any:
"""按 schema type 把前端字符串/值转成目标 python 值。"""
if value is None:
return None
if typ == "bool":
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "on"}
if typ == "int":
try:
return int(value)
except (TypeError, ValueError):
return None
if typ == "float":
try:
return float(value)
except (TypeError, ValueError):
return None
if typ == "text":
if isinstance(value, (list, tuple, set)):
items = list(value)
else:
# TextArea 多行/逗号分隔都接受
import re as _re
parts = _re.split(r"[,\n]+", str(value))
items = [p.strip() for p in parts if p.strip()]
if item_type == "int":
out = []
for it in items:
try:
out.append(int(it))
except (TypeError, ValueError):
continue
return out
if item_type == "float":
out = []
for it in items:
try:
out.append(float(it))
except (TypeError, ValueError):
continue
return out
return items
if typ == "json":
# 前端提交的是 JSON 字符串,解析成 dict/list;已解析则原样返回
if isinstance(value, str):
try:
return json.loads(value)
except ValueError:
return None
return value
if typ == "object_set":
# 前端提交的要么是字符串 JSON,要么已是 list[dict]
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, list) else []
except ValueError:
return []
if isinstance(value, list):
return value
return []
return "" if value is None else str(value)
def _stringify(value: Any, typ: str) -> Any:
"""把 python 值转成 Web 表单能显示的值。"""
if typ == "text":
if value is None:
return ""
if isinstance(value, (list, tuple, set)):
return "\n".join(str(v) for v in value)
return str(value)
if typ == "json":
if value is None:
return ""
if isinstance(value, (dict, list, tuple)):
return json.dumps(value, ensure_ascii=False, indent=2)
return str(value)
# object_set 保持结构化(list[dict])原样返回,前端用专用表格组件渲染
return value
def register_model_config(
plugin_id: str,
config_instance: BaseModel,
fields: Optional[Iterable[str]] = None,
labels: Optional[dict[str, str]] = None,
descriptions: Optional[dict[str, str]] = None,
options: Optional[dict[str, list[dict[str, str]]]] = None,
types: Optional[dict[str, str]] = None,
apply_extra: Optional[Callable[[dict[str, Any], BaseModel], None]] = None,
getter_extra: Optional[Callable[[BaseModel], dict[str, Any]]] = None,
) -> None:
"""把 pydantic Config 模型注册进统一 Web 配置标准。
Args:
plugin_id: NoneBot 插件模块名。
config_instance: 插件模块级 pydantic Config 实例(如 `.config.config`)。
fields: 需要暴露的字段名。缺省=全部非私有字段(自动推断)。
labels/descriptions/options: 可选的中文透出/提示/枚举覆盖。
apply_extra: 可选的额外热应用回调(如需要重注册定时任务)。
getter_extra: 可选的额外取值(如会额外返回非模型字段)。
"""
try:
hints = inspect.get_annotations(type(config_instance), eval_str=True)
except Exception: # noqa: BLE001
try:
hints = {
k: f.annotation
for k, f in config_instance.__class__.model_fields.items()
}
except Exception: # noqa: BLE001
hints = {}
try:
defaults = {
k: f.default
for k, f in config_instance.__class__.model_fields.items()
}
except Exception: # noqa: BLE001
defaults = {}
field_names = list(fields) if fields else [k for k in hints if not k.startswith("_")]
schema_fields: list[dict[str, Any]] = []
for key in field_names:
annotation = hints.get(key, str)
typ = (types or {}).get(key) or _type_label(annotation)
default = defaults.get(key)
field: dict[str, Any] = {
"key": key,
"label": (labels or {}).get(key, key),
"type": typ,
"default": _stringify(default, typ),
"description": (descriptions or {}).get(key, ""),
"secret": _is_secret(key),
}
if typ == "enum":
field["options"] = (options or {}).get(key) or _enum_options(annotation)
if typ == "text":
field["item_type"] = _text_item_type(annotation)
schema_fields.append(field)
schema = {"fields": schema_fields}
def _getter() -> dict[str, Any]:
values: dict[str, Any] = {}
for field in schema_fields:
key = field["key"]
try:
raw = getattr(config_instance, key)
except Exception: # noqa: BLE001
raw = defaults.get(key)
values[key] = _stringify(raw, field["type"])
if getter_extra:
values.update(getter_extra(config_instance))
return values
def _apply(values: dict[str, Any]) -> None:
for field in schema_fields:
key = field["key"]
if key not in values:
continue
coerced = _coerce_value(values[key], field["type"], field.get("item_type", "str"))
try:
setattr(config_instance, key, coerced)
except Exception: # noqa: BLE001
pass
if apply_extra:
apply_extra(values, config_instance)
register_plugin_config(
plugin_id,
schema,
apply=_apply,
getter=_getter,
)
def register_config_items(
plugin_id: str,
items: list[dict[str, Any]],
store: Optional[Any] = None,
apply_extra: Optional[Callable[[dict[str, Any], Any], None]] = None,
getter_extra: Optional[Callable[[Any], dict[str, Any]]] = None,
) -> None:
"""注册「来源无关」的任意配置项(schema + getter/setter),供 Web 读/改。
与 register_model_config 的区别:这里每个配置项可以来自任意来源:
- 模块常量(module attr)
- pydantic Config(字段)
- 配置文件(dict / JSON / YAML)
- 数据库/运行态状态
Args:
plugin_id: NoneBot 插件模块名。
items: 配置项列表,每个 dict 需含 key/label/type,可选:
default/description/secret/options/item_type/env,以及 getter/setter。
若某 item 未提供 getter/setter,则读写 store:
- store 是 dict -> store[key]
- store 是 ModuleType -> getattr/setattr(store, key)
store: 缺省 getter/setter 时的回退存储(dict 或模块)。
apply_extra: 保存后用额外回调做持久化/热加载等。
getter_extra: getter 额外返回的非 items 值。
"""
from types import ModuleType
def _item_getter(item: dict[str, Any]) -> Any:
getter = item.get("getter")
if getter is not None:
try:
return getter()
except Exception: # noqa: BLE001
return item.get("default")
if store is not None:
if isinstance(store, dict):
return store.get(item["key"], item.get("default"))
if isinstance(store, ModuleType):
try:
return getattr(store, item["key"])
except AttributeError:
return item.get("default")
return item.get("default")
def _item_setter(item: dict[str, Any], value: Any) -> None:
setter = item.get("setter")
if setter is not None:
try:
setter(value)
except Exception: # noqa: BLE001
pass
return
if store is not None:
if isinstance(store, dict):
store[item["key"]] = value
elif isinstance(store, ModuleType):
try:
setattr(store, item["key"], value)
except Exception: # noqa: BLE001
pass
schema_fields: list[dict[str, Any]] = []
for item in items:
field: dict[str, Any] = {
"key": item["key"],
"label": item.get("label", item["key"]),
"type": item.get("type", "string"),
"default": _stringify(item.get("default"), item.get("type", "string")),
"description": item.get("description", ""),
"secret": item.get("secret", _is_secret(item["key"])),
}
if item.get("options"):
field["options"] = item["options"]
if item.get("type") == "text":
field["item_type"] = item.get("item_type", "str")
if item.get("type") == "object_set" and item.get("item_schema"):
field["item_schema"] = item["item_schema"]
if item.get("key_field"):
field["key_field"] = item["key_field"]
if item.get("nosave"):
# 文件/外部源配置:值不写进 plugin_config.json(避免与命令修改互相覆盖为陈旧值),
# 但仍调用 apply(写入外部源)。
field["nosave"] = True
schema_fields.append(field)
schema = {"fields": schema_fields}
def _getter() -> dict[str, Any]:
values: dict[str, Any] = {}
for item in items:
raw = _item_getter(item)
values[item["key"]] = _stringify(raw, item.get("type", "string"))
if getter_extra:
values.update(getter_extra(store) if store is not None else getter_extra())
return values
def _apply(values: dict[str, Any]) -> None:
for item in items:
key = item["key"]
if key not in values:
continue
typ = item.get("type", "string")
coerced = _coerce_value(values[key], typ, item.get("item_type", "str"))
_item_setter(item, coerced)
if apply_extra:
apply_extra(values, store)
register_plugin_config(
plugin_id,
schema,
apply=_apply,
getter=_getter,
)
def register_object_set(
plugin_id: str,
set_key: str,
item_schema_fields: list[dict[str, Any]],
key_field: str,
getter: Callable[[], list[dict[str, Any]]],
setter: Callable[[list[dict[str, Any]]], None],
*,
label: Optional[str] = None,
description: str = "",
nosave: bool = False,
options_by_key: Optional[dict[str, list[dict[str, str]]]] = None,
) -> None:
"""注册一个「通用对象集」配置项(type=object_set)。
适用场景:插件要暴露一组结构化条目(如群分组、测速站点、服务器列表、
白/黑名单成员等),Web 端用通用表格渲染,支持增删改。
Args:
plugin_id: NoneBot 插件模块名。
set_key: 该对象集的配置 key。
item_schema_fields: 子字段 schema(同标准字段;不必含 key),每条含
key/label/type/default/options/item_type/secret 等。
key_field: 子字段主键名(用于定位唯一行/去重)。
getter: () -> list[dict] 取当前对象集。
setter: (list[dict]) -> None 持久化整个对象集(应自行校验/写回来源)。
label/description: Web 显示。
nosave: True 时不写入 plugin_config.json(权威源在插件自身,如文件/DB)。
options_by_key: 按子字段 key 覆盖 enum options。
"""
sub_fields: list[dict[str, Any]] = []
for sf in item_schema_fields:
field: dict[str, Any] = {
"key": sf["key"],
"label": sf.get("label", sf["key"]),
"type": sf.get("type", "string"),
"default": sf.get("default"),
"description": sf.get("description", ""),
"secret": sf.get("secret", _is_secret(sf["key"])),
}
if sf.get("type") == "text":
field["item_type"] = sf.get("item_type", "str")
opts = (options_by_key or {}).get(sf["key"]) or sf.get("options")
if opts:
field["options"] = opts
sub_fields.append(field)
def _coerce_setter(rows: list[dict[str, Any]]) -> None:
"""按 item_schema 把每行子字段转成目标类型,并按 key_field 去重后交给 setter。"""
if not isinstance(rows, list):
rows = []
coerced: list[dict[str, Any]] = []
seen: set[Any] = set()
for row in rows:
if not isinstance(row, dict):
continue
nrow: dict[str, Any] = {}
for sf in item_schema_fields:
key = sf["key"]
if key not in row:
if "default" in sf and key not in nrow:
nrow[key] = _coerce_value(sf["default"], sf.get("type", "string"), sf.get("item_type", "str"))
continue
nrow[key] = _coerce_value(
row[key], sf.get("type", "string"), sf.get("item_type", "str")
)
key_val = nrow.get(key_field)
if key_val is None or key_val == "":
continue # 缺主键的行丢弃
if key_val in seen:
continue # 主键重复只保留首个
seen.add(key_val)
coerced.append(nrow)
setter(coerced)
register_config_items(
plugin_id,
[
{
"key": set_key,
"label": label or set_key,
"type": "object_set",
"item_schema": {"fields": sub_fields},
"key_field": key_field,
"description": description,
"getter": getter,
"setter": _coerce_setter,
"nosave": nosave,
}
],
)