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:
@@ -0,0 +1,488 @@
|
||||
# -*- 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,
|
||||
}
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user