# -*- 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//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): return "array" 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 in {"text", "array"}: if isinstance(value, (list, tuple, set)): items = list(value) else: # 数组字段的字符串输入:多行/逗号分隔都接受 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 == "array": # 数组字段直接回传数组,前端用行编辑器(增删行)渲染; # 兼容旧数据:换行/逗号分隔的字符串按行拆成数组 if value is None: return [] if isinstance(value, (list, tuple, set)): return [str(v) for v in value] import re as _re parts = _re.split(r"[,\n]+", str(value)) return [p.strip() for p in parts if p.strip()] 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 in {"text", "array"}: 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") in {"text", "array"}: 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") in {"text", "array"}: 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, } ], )