- 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>
489 lines
17 KiB
Python
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,
|
|
}
|
|
],
|
|
)
|