c73e569958
- --headless flag (skip TUI for SSH/systemd) - InitService auto-starts cyrene_debug_server.py - Fixed log WS URL (home.html/api/logs/ws -> api/logs/ws) - PluginService watchdog hot-reload (plugins/ dir) - example_plugin: 2 commands (echo, plugin_status) - Tests: 15/15 passing
480 lines
20 KiB
Python
480 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
import logging
|
|
import asyncio
|
|
import importlib.util
|
|
import sys
|
|
import inspect
|
|
from pathlib import Path
|
|
from typing import Dict, List, Any, Optional, Callable
|
|
from dataclasses import dataclass
|
|
import yaml
|
|
import traceback
|
|
from sdk.plugin_command_decorator import plugin_command, command
|
|
from sdk.plugin_status import PluginStatus
|
|
from sdk.plugin_error import PluginConfigError, PluginLoadError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
@dataclass
|
|
class PluginInfo:
|
|
"""插件信息数据类"""
|
|
name: str
|
|
version: str
|
|
description: str
|
|
author: str
|
|
enabled: bool
|
|
loaded: bool
|
|
error_count: int
|
|
permissions: List[str]
|
|
plugin_path: Path
|
|
commands: Dict[str, Dict] = None # 新增命令信息
|
|
|
|
class PluginService:
|
|
"""插件服务 - 管理插件的加载、卸载和运行"""
|
|
|
|
def __init__(self, config: Dict, permission_service, bridge_service, service_manager):
|
|
self.config = config
|
|
self.permission_service = permission_service
|
|
self.bridge_service = bridge_service
|
|
self.service_manager = service_manager # 新增服务管理器
|
|
self.bridge_service.service_manager = self.service_manager
|
|
self.plugins: Dict[str, Any] = {}
|
|
self.plugin_status: Dict[str, Any] = {}
|
|
self.plugin_info: Dict[str, PluginInfo] = {}
|
|
self.plugins_dir = Path("plugins")
|
|
self.is_running = False
|
|
logger.debug("PluginService初始化开始")
|
|
|
|
|
|
async def start(self):
|
|
"""启动插件服务"""
|
|
try:
|
|
logger.info("启动插件服务")
|
|
|
|
# 创建插件目录
|
|
self.plugins_dir.mkdir(exist_ok=True)
|
|
|
|
# 自动加载插件
|
|
if self.config['plugins']['auto_load']:
|
|
await self.load_all_plugins()
|
|
|
|
await self.save_command_config()
|
|
|
|
self.is_running = True
|
|
logger.info("插件服务启动完成")
|
|
|
|
except Exception as e:
|
|
logger.error(f"启动插件服务时出错: {str(e)}", exc_info=True)
|
|
raise
|
|
|
|
async def load_all_plugins(self):
|
|
"""加载所有插件"""
|
|
try:
|
|
logger.debug("开始加载所有插件")
|
|
|
|
if not self.plugins_dir.exists():
|
|
logger.warning("插件目录不存在,跳过加载")
|
|
return
|
|
|
|
loaded_count = 0
|
|
error_count = 0
|
|
|
|
# 遍历插件目录
|
|
for plugin_dir in self.plugins_dir.iterdir():
|
|
if plugin_dir.is_dir():
|
|
try:
|
|
success = await self.load_plugin(plugin_dir.name)
|
|
if success:
|
|
loaded_count += 1
|
|
else:
|
|
error_count += 1
|
|
except Exception as e:
|
|
logger.error(f"加载插件 {plugin_dir.name} 时出错: {str(e)}", exc_info=True)
|
|
error_count += 1
|
|
|
|
logger.info(f"插件加载完成: 成功 {loaded_count}, 失败 {error_count}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"加载所有插件时出错: {str(e)}", exc_info=True)
|
|
raise
|
|
|
|
async def load_plugin(self, plugin_name: str) -> bool:
|
|
"""加载单个插件 - 支持异步权限处理"""
|
|
try:
|
|
logger.debug(f"开始加载插件: {plugin_name}")
|
|
|
|
plugin_path = self.plugins_dir / plugin_name
|
|
|
|
if not plugin_path.exists():
|
|
logger.error(f"插件目录不存在: {plugin_path}")
|
|
return False
|
|
|
|
# 检查插件配置文件
|
|
config_file = plugin_path / "config.yaml"
|
|
if not config_file.exists():
|
|
logger.error(f"插件配置文件不存在: {config_file}")
|
|
return False
|
|
|
|
# 加载插件配置
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
plugin_config = yaml.safe_load(f)
|
|
|
|
# 检查权限文件
|
|
permission_file = plugin_path / "permissions.yaml"
|
|
if not permission_file.exists():
|
|
logger.error(f"插件权限文件不存在: {permission_file}")
|
|
return False
|
|
|
|
# 加载权限配置
|
|
with open(permission_file, 'r', encoding='utf-8') as f:
|
|
permission_config = yaml.safe_load(f)
|
|
|
|
# 验证插件信息
|
|
required_fields = ['name', 'version', 'description', 'author']
|
|
for field in required_fields:
|
|
if field not in plugin_config:
|
|
logger.error(f"插件配置缺少必要字段: {field}")
|
|
return False
|
|
|
|
# 检查主模块
|
|
main_module = plugin_path / "__init__.py"
|
|
if not main_module.exists():
|
|
logger.error(f"插件主模块不存在: {main_module}")
|
|
return False
|
|
|
|
# 动态加载插件模块
|
|
module_name = f"plugins.{plugin_name}"
|
|
spec = importlib.util.spec_from_file_location(module_name, main_module)
|
|
if not spec:
|
|
logger.error(f"无法创建模块规范: {module_name}")
|
|
return False
|
|
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[module_name] = module
|
|
|
|
try:
|
|
spec.loader.exec_module(module)
|
|
logger.debug(f"插件模块加载成功: {module_name}")
|
|
except Exception as e:
|
|
logger.error(f"执行插件模块时出错: {str(e)}", exc_info=True)
|
|
return False
|
|
|
|
# 获取插件类实例
|
|
if not hasattr(module, 'Plugin'):
|
|
logger.error(f"插件类 'Plugin' 不存在: {module_name}")
|
|
return False
|
|
|
|
# 权限申请和验证 - 非阻塞版本
|
|
permissions = permission_config.get('permissions', [])
|
|
if permissions:
|
|
# 非阻塞权限请求,立即返回True让插件继续加载
|
|
permission_result = await self.permission_service.request_permissions(plugin_name, permissions)
|
|
if not permission_result:
|
|
logger.warning(f"插件权限申请失败: {plugin_name}")
|
|
# 即使权限申请失败,也允许插件以受限模式运行
|
|
logger.info(f"插件 {plugin_name} 将以受限模式运行")
|
|
|
|
# 实例化插件
|
|
try:
|
|
plugin_instance = module.Plugin(
|
|
plugin_name=plugin_name,
|
|
config=plugin_config,
|
|
bridge=self.bridge_service
|
|
)
|
|
|
|
# 初始化插件
|
|
if hasattr(plugin_instance, 'initialize'):
|
|
if asyncio.iscoroutinefunction(plugin_instance.initialize):
|
|
await plugin_instance.initialize()
|
|
else:
|
|
plugin_instance.initialize()
|
|
|
|
# 扫描并注册插件命令
|
|
plugin_commands = await self._scan_and_register_commands(plugin_name, plugin_instance, plugin_config)
|
|
|
|
# 注册插件
|
|
self.plugins[plugin_name] = plugin_instance
|
|
|
|
# 保存插件信息
|
|
self.plugin_info[plugin_name] = PluginInfo(
|
|
name=plugin_config['name'],
|
|
version=plugin_config['version'],
|
|
description=plugin_config['description'],
|
|
author=plugin_config['author'],
|
|
enabled=True,
|
|
loaded=True,
|
|
error_count=0,
|
|
permissions=permissions,
|
|
plugin_path=plugin_path,
|
|
commands=plugin_commands
|
|
)
|
|
|
|
self.plugin_status[plugin_name] = PluginStatus.RUNNING
|
|
logger.info(f"插件加载成功: {plugin_name} v{plugin_config['version']}, 注册了 {len(plugin_commands)} 个命令")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"实例化插件时出错: {str(e)}", exc_info=True)
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"加载插件 {plugin_name} 时出错: {str(e)}", exc_info=True)
|
|
return False
|
|
|
|
async def _scan_and_register_commands(self, plugin_name: str, plugin_instance: Any, plugin_config: Dict) -> Dict[str, Dict]:
|
|
"""扫描并注册插件命令 - 修正版本"""
|
|
try:
|
|
logger.debug(f"扫描插件命令: {plugin_name}")
|
|
|
|
command_service = self.service_manager.get_service("command")
|
|
if not command_service:
|
|
logger.error("命令服务不可用,无法注册插件命令")
|
|
return {}
|
|
|
|
# 扫描插件中的命令方法
|
|
command_methods = {}
|
|
for name, method in inspect.getmembers(plugin_instance, predicate=inspect.ismethod):
|
|
# 检查方法是否有命令装饰器或符合命名约定
|
|
if (hasattr(method, '_is_plugin_command') or
|
|
name.startswith('cmd_') or
|
|
name.startswith('command_')):
|
|
|
|
command_name = self._get_command_name(name, method, plugin_config)
|
|
command_description = self._get_command_description(name, method, plugin_config)
|
|
command_permissions = self._get_command_permissions(name, method, plugin_config)
|
|
|
|
# 修正:使用正确的source格式
|
|
command_service.register_command(
|
|
name=command_name,
|
|
handler=method,
|
|
description=command_description,
|
|
permissions=command_permissions,
|
|
source=f"plugin.{plugin_name}" # 使用 plugin.插件名 格式
|
|
)
|
|
|
|
command_methods[command_name] = {
|
|
'method_name': name,
|
|
'description': command_description,
|
|
'permissions': command_permissions
|
|
}
|
|
|
|
logger.debug(f"注册插件命令: {command_name} -> {name}")
|
|
|
|
return command_methods
|
|
|
|
except Exception as e:
|
|
logger.error(f"扫描插件命令时出错: {str(e)}", exc_info=True)
|
|
return {}
|
|
|
|
def _get_command_name(self, method_name: str, method: Callable, plugin_config: Dict) -> str:
|
|
"""获取命令名称"""
|
|
try:
|
|
# 如果方法有装饰器指定的名称
|
|
if hasattr(method, '_command_name'):
|
|
return getattr(method, '_command_name')
|
|
|
|
# 从方法名提取命令名
|
|
if method_name.startswith('cmd_'):
|
|
return method_name[4:]
|
|
elif method_name.startswith('command_'):
|
|
return method_name[8:]
|
|
else:
|
|
return method_name
|
|
|
|
except Exception as e:
|
|
logger.error(f"获取命令名称时出错: {str(e)}")
|
|
return method_name
|
|
|
|
def _get_command_description(self, method_name: str, method: Callable, plugin_config: Dict) -> str:
|
|
"""获取命令描述"""
|
|
try:
|
|
# 如果方法有装饰器指定的描述
|
|
if hasattr(method, '_command_description'):
|
|
return getattr(method, '_command_description')
|
|
|
|
# 使用方法的文档字符串
|
|
if method.__doc__:
|
|
# 提取第一行作为描述
|
|
doc_lines = method.__doc__.strip().split('\n')
|
|
return doc_lines[0].strip()
|
|
|
|
# 默认描述
|
|
return f"插件命令: {method_name}"
|
|
|
|
except Exception as e:
|
|
logger.error(f"获取命令描述时出错: {str(e)}")
|
|
return f"插件命令: {method_name}"
|
|
|
|
def _get_command_permissions(self, method_name: str, method: Callable, plugin_config: Dict) -> List[str]:
|
|
"""获取命令权限"""
|
|
try:
|
|
# 如果方法有装饰器指定的权限
|
|
if hasattr(method, '_command_permissions'):
|
|
return getattr(method, '_command_permissions')
|
|
|
|
# 从插件配置中获取默认权限
|
|
default_permissions = plugin_config.get('default_command_permissions', [])
|
|
return default_permissions.copy()
|
|
|
|
except Exception as e:
|
|
logger.error(f"获取命令权限时出错: {str(e)}")
|
|
return []
|
|
|
|
async def unload_plugin(self, plugin_name: str) -> bool:
|
|
"""卸载插件"""
|
|
try:
|
|
logger.debug(f"开始卸载插件: {plugin_name}")
|
|
|
|
if plugin_name not in self.plugins:
|
|
logger.warning(f"插件未加载: {plugin_name}")
|
|
return False
|
|
|
|
plugin_instance = self.plugins[plugin_name]
|
|
plugin_info = self.plugin_info[plugin_name]
|
|
|
|
# 注销插件命令
|
|
await self._unregister_plugin_commands(plugin_name)
|
|
|
|
# 调用插件的清理方法
|
|
try:
|
|
if hasattr(plugin_instance, 'shutdown'):
|
|
if asyncio.iscoroutinefunction(plugin_instance.shutdown):
|
|
await plugin_instance.shutdown()
|
|
else:
|
|
plugin_instance.shutdown()
|
|
except Exception as e:
|
|
logger.error(f"插件清理时出错 {plugin_name}: {str(e)}", exc_info=True)
|
|
|
|
# 从模块缓存中移除
|
|
module_name = f"plugins.{plugin_name}"
|
|
if module_name in sys.modules:
|
|
del sys.modules[module_name]
|
|
|
|
# 移除插件实例和信息
|
|
del self.plugins[plugin_name]
|
|
plugin_info.loaded = False
|
|
plugin_info.enabled = False
|
|
|
|
logger.info(f"插件卸载成功: {plugin_name}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"卸载插件 {plugin_name} 时出错: {str(e)}", exc_info=True)
|
|
return False
|
|
|
|
async def _unregister_plugin_commands(self, plugin_name: str):
|
|
"""注销插件命令"""
|
|
try:
|
|
command_service = self.service_manager.get_service("command")
|
|
if not command_service:
|
|
return
|
|
|
|
# 从命令服务中移除该插件的所有命令
|
|
commands_to_remove = []
|
|
for cmd_name, cmd_info in command_service.commands.items():
|
|
if cmd_info.source.startswith(f"plugin.{plugin_name}"):
|
|
commands_to_remove.append(cmd_name)
|
|
|
|
for cmd_name in commands_to_remove:
|
|
del command_service.commands[cmd_name]
|
|
logger.debug(f"注销插件命令: {cmd_name}")
|
|
|
|
logger.info(f"已注销插件 {plugin_name} 的 {len(commands_to_remove)} 个命令")
|
|
|
|
except Exception as e:
|
|
logger.error(f"注销插件命令时出错: {str(e)}", exc_info=True)
|
|
|
|
async def save_command_config(self):
|
|
"""保存命令配置到文件"""
|
|
try:
|
|
command_service = self.service_manager.get_service("command")
|
|
if not command_service:
|
|
logger.error("命令服务不可用")
|
|
return False
|
|
|
|
command_list = command_service.get_command_list()
|
|
config_path = Path("config") / "plugins" / "commands.yaml"
|
|
|
|
# 确保目录存在
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
config_data = {
|
|
"commands": {},
|
|
"plugin_commands": {},
|
|
"last_updated": asyncio.get_event_loop().time(),
|
|
"total_commands": len(command_list)
|
|
}
|
|
|
|
# 按来源分组命令
|
|
for cmd in command_list:
|
|
cmd_info = {
|
|
"description": cmd['description'],
|
|
"permissions": cmd['permissions'],
|
|
"source": cmd['source']
|
|
}
|
|
|
|
config_data["commands"][cmd['name']] = cmd_info
|
|
|
|
# 按插件分组
|
|
if cmd['source'].startswith("plugin."):
|
|
plugin_name = cmd['source'].split('.', 1)[1]
|
|
if plugin_name not in config_data["plugin_commands"]:
|
|
config_data["plugin_commands"][plugin_name] = {}
|
|
config_data["plugin_commands"][plugin_name][cmd['name']] = cmd_info
|
|
|
|
with open(config_path, 'w', encoding='utf-8') as f:
|
|
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True, indent=2)
|
|
|
|
logger.info(f"命令配置已保存: {config_path}, 共 {len(command_list)} 个命令")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"保存命令配置时出错: {str(e)}", exc_info=True)
|
|
return False
|
|
|
|
async def register_delayed_routes(self, internet_service):
|
|
"""注册延迟的路由(在网络服务启动后)"""
|
|
try:
|
|
if not internet_service:
|
|
logger.warning("网络服务不可用,跳过延迟路由注册")
|
|
return
|
|
|
|
for plugin_name, plugin_instance in self.plugins.items():
|
|
try:
|
|
# 检查插件是否有延迟注册方法
|
|
if hasattr(plugin_instance, 'register_delayed_routes'):
|
|
await plugin_instance.register_delayed_routes(internet_service)
|
|
logger.info(f"延迟注册插件路由: {plugin_name}")
|
|
else:
|
|
# 如果插件没有延迟注册方法,尝试重新初始化网络功能
|
|
await self._reinitialize_plugin_network(plugin_instance, internet_service)
|
|
|
|
except Exception as e:
|
|
logger.error(f"延迟注册插件 {plugin_name} 路由时出错: {str(e)}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"注册延迟路由时出错: {str(e)}")
|
|
|
|
async def _reinitialize_plugin_network(self, plugin_instance, internet_service):
|
|
"""重新初始化插件的网络功能"""
|
|
try:
|
|
plugin_name = plugin_instance.plugin_name
|
|
|
|
# 检查插件是否有网络桥接
|
|
if hasattr(plugin_instance, 'network_bridge'):
|
|
# 重新创建网络桥接
|
|
plugin_instance.network_bridge = PluginNetworkBridge(
|
|
plugin_name, internet_service, plugin_instance.bridge
|
|
)
|
|
|
|
# 重新设置网络路由
|
|
if hasattr(plugin_instance, '_setup_network_routes'):
|
|
await plugin_instance._setup_network_routes()
|
|
|
|
logger.info(f"重新初始化插件网络功能: {plugin_name}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"重新初始化插件网络功能时出错: {str(e)}")
|
|
|