Files
Mofox-Core/src/plugin_system/base/plugin_base.py
minecraft1024a 444c458986 refactor(core): 统一消息对象类型并增强代码健壮性
本次提交对多个核心模块进行了重构和修复,主要目标是统一内部消息对象的类型为 `DatabaseMessages`,并增加多处空值检查和类型注解,以提升代码的健壮性和可维护性。

- **统一消息类型**: 在 `action_manager` 中,将 `action_message` 和 `target_message` 的类型注解和处理逻辑统一为 `DatabaseMessages`,消除了对 `dict` 类型的兼容代码,使逻辑更清晰。
- **增强健壮性**:
    - 在 `permission_api` 中,为所有对外方法增加了对 `_permission_manager` 未初始化时的空值检查,防止在管理器未就绪时调用引发异常。
    - 在 `chat_api` 和 `cross_context_api` 中,增加了对 `stream.user_info` 的存在性检查,避免在私聊场景下 `user_info` 为空时导致 `AttributeError`。
- **类型修复**: 修正了 `action_modifier` 和 `plugin_base` 中的类型注解错误,并解决了 `action_modifier` 中因 `chat_stream` 未初始化可能导致的潜在问题。
- **代码简化**: 移除了 `action_manager` 中因兼容 `dict` 类型而产生的冗余代码分支,使逻辑更直接。
2025-11-01 16:51:04 +08:00

402 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import datetime
import os
import shutil
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, ClassVar
import toml
from src.common.logger import get_logger
from src.config.config import CONFIG_DIR
from src.plugin_system.base.component_types import (
PermissionNodeField,
PluginInfo,
)
from src.plugin_system.base.config_types import ConfigField
from src.plugin_system.base.plugin_metadata import PluginMetadata
logger = get_logger("plugin_base")
class PluginBase(ABC):
"""插件总基类
所有衍生插件基类都应该继承自此类,这个类定义了插件的基本结构和行为。
"""
# 插件基本信息(子类必须定义)
plugin_name: str
config_file_name: str
enable_plugin: bool = True
config_schema: ClassVar[dict[str, dict[str, ConfigField] | str] ] = {}
permission_nodes: ClassVar[list["PermissionNodeField"] ] = []
config_section_descriptions: ClassVar[dict[str, str] ] = {}
def __init__(self, plugin_dir: str, metadata: PluginMetadata):
"""初始化插件
Args:
plugin_dir: 插件目录路径,由插件管理器传递
metadata: 插件元数据对象
"""
self.config: dict[str, Any] = {} # 插件配置
self.plugin_dir = plugin_dir # 插件目录路径
self.plugin_meta = metadata # 插件元数据
self.log_prefix = f"[Plugin:{self.plugin_name}]"
self._is_enabled = self.enable_plugin # 从插件定义中获取默认启用状态
# 验证插件信息
self._validate_plugin_info()
# 加载插件配置
self._load_plugin_config()
# 从元数据获取显示信息
self.display_name = self.plugin_meta.name
self.plugin_version = self.plugin_meta.version
self.plugin_description = self.plugin_meta.description
self.plugin_author = self.plugin_meta.author
# 创建插件信息对象
self.plugin_info = PluginInfo(
name=self.plugin_name,
display_name=self.display_name,
description=self.plugin_description,
version=self.plugin_version,
author=self.plugin_author,
enabled=self._is_enabled,
is_built_in=False,
config_file=self.config_file_name or "",
dependencies=self.plugin_meta.dependencies.copy(),
python_dependencies=self.plugin_meta.python_dependencies.copy(),
)
logger.debug(f"{self.log_prefix} 插件基类初始化完成")
def _validate_plugin_info(self):
"""验证插件基本信息"""
if not self.plugin_name:
raise ValueError(f"插件类 {self.__class__.__name__} 必须定义 plugin_name")
if not self.plugin_meta.name:
raise ValueError(f"插件 {self.plugin_name} 的元数据中缺少 name 字段")
if not self.plugin_meta.description:
raise ValueError(f"插件 {self.plugin_name} 的元数据中缺少 description 字段")
def _generate_and_save_default_config(self, config_file_path: str):
"""根据插件的Schema生成并保存默认配置文件"""
if not self.config_schema:
logger.info(f"{self.log_prefix} 插件未定义config_schema不生成配置文件")
return
toml_str = f"# {self.plugin_name} - 自动生成的配置文件\n"
plugin_description = self.plugin_meta.description or "插件配置文件"
toml_str += f"# {plugin_description}\n\n"
# 遍历每个配置节
for section, fields in self.config_schema.items():
# 添加节描述
if section in self.config_section_descriptions:
toml_str += f"# {self.config_section_descriptions[section]}\n"
toml_str += f"[{section}]\n\n"
# 遍历节内的字段
if isinstance(fields, dict):
for field_name, field in fields.items():
if isinstance(field, ConfigField):
# 添加字段描述
toml_str += f"# {field.description}"
if field.required:
toml_str += " (必需)"
toml_str += "\n"
# 如果有示例值,添加示例
if field.example:
toml_str += f"# 示例: {field.example}\n"
# 如果有可选值,添加说明
if field.choices:
choices_str = ", ".join(map(str, field.choices))
toml_str += f"# 可选值: {choices_str}\n"
# 添加字段值
value = field.default
if isinstance(value, str):
toml_str += f'{field_name} = "{value}"\n'
elif isinstance(value, bool):
toml_str += f"{field_name} = {str(value).lower()}\n"
else:
toml_str += f"{field_name} = {value}\n"
toml_str += "\n"
toml_str += "\n"
try:
with open(config_file_path, "w", encoding="utf-8") as f:
f.write(toml_str)
logger.info(f"{self.log_prefix} 已生成默认配置文件: {config_file_path}")
except OSError as e:
logger.error(f"{self.log_prefix} 保存默认配置文件失败: {e}", exc_info=True)
def _backup_config_file(self, config_file_path: str) -> str:
"""备份配置文件到指定的 backup 子目录"""
try:
config_path = Path(config_file_path)
backup_dir = config_path.parent / "backup"
backup_dir.mkdir(exist_ok=True)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_filename = f"{config_path.name}.backup_{timestamp}"
backup_path = backup_dir / backup_filename
shutil.copy2(config_file_path, backup_path)
logger.info(f"{self.log_prefix} 配置文件已备份到: {backup_path}")
return str(backup_path)
except Exception as e:
logger.error(f"{self.log_prefix} 备份配置文件失败: {e}", exc_info=True)
return ""
def _synchronize_config(
self, schema_config: dict[str, Any], user_config: dict[str, Any]
) -> tuple[dict[str, Any], bool]:
"""递归地将用户配置与 schema 同步,返回同步后的配置和是否发生变化的标志"""
changed = False
# 内部递归函数
def _sync_dicts(schema_dict: dict[str, Any], user_dict: dict[str, Any], parent_key: str = "") -> dict[str, Any]:
nonlocal changed
synced_dict = schema_dict.copy()
# 检查并记录用户配置中多余的、在 schema 中不存在的键
for key in user_dict:
if key not in schema_dict:
logger.warning(f"{self.log_prefix} 发现废弃配置项 '{parent_key}{key}',将被移除。")
changed = True
# 以 schema 为基准进行遍历,保留用户的值,补全缺失的项
for key, schema_value in schema_dict.items():
full_key = f"{parent_key}{key}"
if key in user_dict:
user_value = user_dict[key]
if isinstance(schema_value, dict) and isinstance(user_value, dict):
# 递归同步嵌套的字典
synced_dict[key] = _sync_dicts(schema_value, user_value, f"{full_key}.")
else:
# 键存在,保留用户的值
synced_dict[key] = user_value
else:
# 键在用户配置中缺失,补全
logger.info(f"{self.log_prefix} 补全缺失的配置项: '{full_key}' = {schema_value}")
changed = True
# synced_dict[key] 已经包含了来自 schema_dict.copy() 的默认值
return synced_dict
final_config = _sync_dicts(schema_config, user_config)
return final_config, changed
def _generate_config_from_schema(self) -> dict[str, Any]:
# sourcery skip: dict-comprehension
"""根据schema生成配置数据结构不写入文件"""
if not self.config_schema:
return {}
config_data = {}
# 遍历每个配置节
for section, fields in self.config_schema.items():
if isinstance(fields, dict):
section_data = {}
# 遍历节内的字段
for field_name, field in fields.items():
if isinstance(field, ConfigField):
section_data[field_name] = field.default
config_data[section] = section_data
return config_data
def _save_config_to_file(self, config_data: dict[str, Any], config_file_path: str):
"""将配置数据保存为TOML文件包含注释"""
if not self.config_schema:
logger.debug(f"{self.log_prefix} 插件未定义config_schema不生成配置文件")
return
toml_str = f"# {self.plugin_name} - 配置文件\n"
plugin_description = self.plugin_meta.description or "插件配置文件"
toml_str += f"# {plugin_description}\n\n"
# 遍历每个配置节
for section, fields in self.config_schema.items():
# 添加节描述
if section in self.config_section_descriptions:
toml_str += f"# {self.config_section_descriptions[section]}\n"
toml_str += f"[{section}]\n\n"
# 遍历节内的字段
if isinstance(fields, dict) and section in config_data:
section_data = config_data[section]
for field_name, field in fields.items():
if isinstance(field, ConfigField):
# 添加字段描述
toml_str += f"# {field.description}"
if field.required:
toml_str += " (必需)"
toml_str += "\n"
# 如果有示例值,添加示例
if field.example:
toml_str += f"# 示例: {field.example}\n"
# 如果有可选值,添加说明
if field.choices:
choices_str = ", ".join(map(str, field.choices))
toml_str += f"# 可选值: {choices_str}\n"
# 添加字段值(使用迁移后的值)
value = section_data.get(field_name, field.default)
if isinstance(value, str):
toml_str += f'{field_name} = "{value}"\n'
elif isinstance(value, bool):
toml_str += f"{field_name} = {str(value).lower()}\n"
elif isinstance(value, list):
# 格式化列表
if all(isinstance(item, str) for item in value):
formatted_list = "[" + ", ".join(f'"{item}"' for item in value) + "]"
else:
formatted_list = str(value)
toml_str += f"{field_name} = {formatted_list}\n"
else:
toml_str += f"{field_name} = {value}\n"
toml_str += "\n"
toml_str += "\n"
try:
with open(config_file_path, "w", encoding="utf-8") as f:
f.write(toml_str)
logger.info(f"{self.log_prefix} 配置文件已保存: {config_file_path}")
except OSError as e:
logger.error(f"{self.log_prefix} 保存配置文件失败: {e}", exc_info=True)
def _load_plugin_config(self): # sourcery skip: extract-method
"""
加载并同步插件配置文件。
处理逻辑:
1. 确定用户配置文件路径和插件自带的配置文件路径。
2. 如果用户配置文件不存在,尝试从插件目录迁移(移动)一份。
3. 如果迁移后(或原本)用户配置文件仍不存在,则根据 schema 生成一份。
4. 加载用户配置文件。
5. 以 schema 为基准,与用户配置进行同步,补全缺失项并移除废弃项。
6. 如果同步过程发现不一致,则先备份原始文件,然后将同步后的完整配置写回用户目录。
7. 将最终同步后的配置加载到 self.config。
"""
if not self.config_file_name:
logger.debug(f"{self.log_prefix} 未指定配置文件,跳过加载")
return
user_config_path = os.path.join(CONFIG_DIR, "plugins", self.plugin_name, self.config_file_name)
plugin_config_path = os.path.join(self.plugin_dir, self.config_file_name)
os.makedirs(os.path.dirname(user_config_path), exist_ok=True)
# 首次加载迁移:如果用户配置不存在,但插件目录中存在,则移动过来
if not os.path.exists(user_config_path) and os.path.exists(plugin_config_path):
try:
shutil.move(plugin_config_path, user_config_path)
logger.info(f"{self.log_prefix} 已将配置文件从 {plugin_config_path} 迁移到 {user_config_path}")
except OSError as e:
logger.error(f"{self.log_prefix} 迁移配置文件失败: {e}", exc_info=True)
# 如果用户配置文件仍然不存在,生成默认的
if not os.path.exists(user_config_path):
logger.info(f"{self.log_prefix} 用户配置文件 {user_config_path} 不存在,将生成默认配置。")
self._generate_and_save_default_config(user_config_path)
if not os.path.exists(user_config_path):
if not self.config_schema:
logger.debug(f"{self.log_prefix} 插件未定义 config_schema使用空配置。")
self.config = {}
else:
logger.warning(f"{self.log_prefix} 用户配置文件 {user_config_path} 不存在且无法创建。")
return
try:
with open(user_config_path, encoding="utf-8") as f:
user_config: dict[str, Any] = toml.load(f) or {}
except Exception as e:
logger.error(f"{self.log_prefix} 加载用户配置文件 {user_config_path} 失败: {e}", exc_info=True)
self.config = self._generate_config_from_schema() # 加载失败时使用默认 schema
return
# 生成基于 schema 的理想配置结构
schema_config = self._generate_config_from_schema()
# 将用户配置与 schema 同步
synced_config, was_changed = self._synchronize_config(schema_config, user_config)
# 如果配置发生了变化(补全或移除),则备份并重写配置文件
if was_changed:
logger.info(f"{self.log_prefix} 检测到配置结构不匹配,将自动同步并更新配置文件。")
self._backup_config_file(user_config_path)
self._save_config_to_file(synced_config, user_config_path)
logger.info(f"{self.log_prefix} 配置文件已同步更新。")
self.config = synced_config
logger.debug(f"{self.log_prefix} 配置已从 {user_config_path} 加载并同步。")
# 从最终配置中更新插件启用状态
if "plugin" in self.config and "enabled" in self.config["plugin"]:
self._is_enabled = self.config["plugin"]["enabled"]
logger.info(f"{self.log_prefix} 从配置更新插件启用状态: {self._is_enabled}")
def get_config(self, key: str, default: Any = None) -> Any:
"""获取插件配置值,支持嵌套键访问
Args:
key: 配置键名,支持嵌套访问如 "section.subsection.key"
default: 默认值
Returns:
Any: 配置值或默认值
"""
# 支持嵌套键访问
keys = key.split(".")
current = self.config
for k in keys:
if isinstance(current, dict) and k in current:
current = current[k]
else:
return default
return current
@abstractmethod
def register_plugin(self) -> bool:
"""
注册插件到插件管理器
子类必须实现此方法,返回注册是否成功
Returns:
bool: 是否成功注册插件
"""
raise NotImplementedError("Subclasses must implement this method")
async def on_plugin_loaded(self):
"""插件加载完成后的钩子函数"""
pass
def on_unload(self):
"""插件卸载时的钩子函数"""
pass