Files
SenSu/services/plugin_service.py
T
AskaEth f5800cc6c3 Debug: fix headless mode, plugin compat, psutil optional, module imports
- headless: TUI skip now works correctly
- example_plugin: keyword args compat (plugin_name=, config=, bridge=)
- sysmon: psutil made optional (graceful degrade)
- tui_service: SysMonWidget optional
- All 22 modules import clean, 23 tests pass, integration verified
2026-06-10 19:26:41 +08:00

476 lines
19 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
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_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
)
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)}")