e6875f0b4b
- 13-service async plugin framework - Textual TUI with CLI fallback - Plugin hot-reload + permission system - Web management panel (aiohttp) - Bridge-based inter-module communication - 10 regression tests Fixes applied: - PBKDF2-SHA256 auth (was plain SHA256) - Auth bypass removed (was allow-all on fail) - Bare excepts replaced with logged errors - CatFramework/DreamSu -> SenSu naming unified - ServiceManager: health checks + startup_order - Env var credentials (SENSU_ADMIN_PASSWORD etc)
605 lines
24 KiB
Python
605 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
import logging
|
|
import asyncio
|
|
import shlex
|
|
from typing import Dict, List, Callable, Any
|
|
from dataclasses import dataclass
|
|
import os
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
@dataclass
|
|
class Command:
|
|
"""命令数据类"""
|
|
name: str
|
|
handler: Callable
|
|
description: str
|
|
permissions: List[str]
|
|
source: str = "internal"
|
|
|
|
class CommandService:
|
|
"""指令服务"""
|
|
|
|
def __init__(self, service_manager=None):
|
|
self.commands: Dict[str, Command] = {}
|
|
self.command_history: List[Dict] = []
|
|
self.max_history_size = 100
|
|
self.service_manager = service_manager # 添加服务管理器引用
|
|
logger.debug("CommandService初始化开始")
|
|
|
|
|
|
def register_command(self, name: str, handler: Callable, description: str = "",
|
|
permissions: List[str] = None, source: str = "plugins"):
|
|
"""注册命令"""
|
|
# 来源如 internal plugins system 等
|
|
try:
|
|
if name in self.commands:
|
|
logger.warning(f"命令 {name} 已存在,将被覆盖")
|
|
|
|
self.commands[name] = Command(
|
|
name=name,
|
|
handler=handler,
|
|
description=description or f"命令: {name}",
|
|
permissions=permissions or [],
|
|
source=source
|
|
)
|
|
logger.debug(f"注册命令: {name} (来源: {source})")
|
|
|
|
except Exception as e:
|
|
logger.error(f"注册命令 {name} 时出错: {str(e)}", exc_info=True)
|
|
raise
|
|
|
|
async def _handle_permission_command(self, command: str, args: List[str], source: str) -> str:
|
|
"""处理权限相关命令"""
|
|
try:
|
|
permission_service = self.service_manager.get_service("permission")
|
|
if not permission_service:
|
|
return "❌ 权限服务不可用"
|
|
|
|
return await permission_service.process_permission_command(command, args)
|
|
|
|
except Exception as e:
|
|
logger.error(f"处理权限命令时出错: {str(e)}", exc_info=True)
|
|
return f"❌ 处理权限命令时出错: {str(e)}"
|
|
|
|
async def process_command(self, command_string: str, source: str = "unknown") -> Any:
|
|
"""处理命令"""
|
|
try:
|
|
logger.debug(f"处理命令: '{command_string}' (来源: {source})")
|
|
|
|
# 解析命令
|
|
parts = shlex.split(command_string.strip())
|
|
if not parts:
|
|
logger.warning("空命令")
|
|
return "空命令"
|
|
|
|
command_name = parts[0]
|
|
args = parts[1:]
|
|
|
|
# 记录命令历史
|
|
self._add_to_history(command_string, source)
|
|
|
|
# 检查权限命令
|
|
permission_commands = ['pmallow', 'pmdeny', 'pmignore', 'permissions',
|
|
'pmpending', 'pmrequests', 'pm_plugin_status', 'pmhelp', 'pmtest']
|
|
|
|
if command_name in permission_commands:
|
|
permission_service = self.service_manager.get_service("permission")
|
|
if not permission_service:
|
|
return "❌ 权限服务不可用"
|
|
|
|
# 直接调用权限服务处理命令
|
|
return await permission_service.process_permission_command(command_name, args)
|
|
|
|
# 查找其他命令
|
|
if command_name not in self.commands:
|
|
logger.warning(f"未知命令: {command_name}")
|
|
return f"未知命令: {command_name}"
|
|
|
|
command = self.commands[command_name]
|
|
|
|
# 执行命令
|
|
try:
|
|
result = await self._execute_command(command, args, source)
|
|
logger.debug(f"命令执行成功: {command_name}")
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"命令执行失败 {command_name}: {str(e)}", exc_info=True)
|
|
return f"命令执行错误: {str(e)}"
|
|
|
|
except Exception as e:
|
|
logger.error(f"处理命令时出错: {str(e)}", exc_info=True)
|
|
return f"命令处理错误: {str(e)}"
|
|
|
|
async def _execute_command(self, command: Command, args: List[str], source: str) -> Any:
|
|
"""执行命令"""
|
|
try:
|
|
# 检查处理器类型
|
|
if asyncio.iscoroutinefunction(command.handler):
|
|
result = await command.handler(*args)
|
|
else:
|
|
result = command.handler(*args)
|
|
|
|
logger.debug(f"命令 {command.name} 执行完成")
|
|
return result
|
|
|
|
except TypeError as e:
|
|
logger.error(f"命令参数错误 {command.name}: {str(e)}", exc_info=True)
|
|
raise ValueError(f"参数错误: {str(e)}")
|
|
except Exception as e:
|
|
logger.error(f"命令执行异常 {command.name}: {str(e)}", exc_info=True)
|
|
raise
|
|
|
|
def _add_to_history(self, command: str, source: str):
|
|
"""添加到命令历史"""
|
|
try:
|
|
history_entry = {
|
|
"command": command,
|
|
"source": source,
|
|
"timestamp": asyncio.get_event_loop().time()
|
|
}
|
|
self.command_history.append(history_entry)
|
|
|
|
# 限制历史记录大小
|
|
if len(self.command_history) > self.max_history_size:
|
|
self.command_history.pop(0)
|
|
|
|
logger.debug(f"命令历史记录添加,当前大小: {len(self.command_history)}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"添加命令历史时出错: {str(e)}", exc_info=True)
|
|
|
|
def get_command_list(self) -> List[Dict]:
|
|
"""获取命令列表"""
|
|
try:
|
|
command_list = []
|
|
for name, cmd in self.commands.items():
|
|
command_list.append({
|
|
"name": name,
|
|
"description": cmd.description,
|
|
"permissions": cmd.permissions,
|
|
"source": cmd.source
|
|
})
|
|
|
|
logger.debug(f"获取命令列表,共 {len(command_list)} 个命令")
|
|
return command_list
|
|
|
|
except Exception as e:
|
|
logger.error(f"获取命令列表时出错: {str(e)}", exc_info=True)
|
|
return []
|
|
|
|
def get_command_history(self, limit: int = 10) -> List[Dict]:
|
|
"""获取命令历史"""
|
|
try:
|
|
history = self.command_history[-limit:]
|
|
logger.debug(f"获取命令历史,返回 {len(history)} 条记录")
|
|
return history
|
|
|
|
except Exception as e:
|
|
logger.error(f"获取命令历史时出错: {str(e)}", exc_info=True)
|
|
return []
|
|
|
|
def register_builtin_commands(self):
|
|
"""注册内置命令"""
|
|
try:
|
|
logger.debug("开始注册内置命令")
|
|
|
|
# 帮助命令
|
|
self.register_command(
|
|
name="help",
|
|
handler=self._cmd_help,
|
|
description="显示帮助信息",
|
|
permissions=["framework.command.help.read"],
|
|
source="internal"
|
|
)
|
|
|
|
# 测试日志命令
|
|
self.register_command(
|
|
name="testlog",
|
|
handler=self._cmd_test_log,
|
|
description="生成测试日志",
|
|
permissions=["framework.command.test"],
|
|
source="internal"
|
|
)
|
|
|
|
# 状态命令
|
|
self.register_command(
|
|
name="status",
|
|
handler=self._cmd_status,
|
|
description="显示框架状态",
|
|
permissions=["framework.status.read"],
|
|
source="internal"
|
|
)
|
|
|
|
# 历史命令
|
|
self.register_command(
|
|
name="history",
|
|
handler=self._cmd_history,
|
|
description="显示命令历史",
|
|
permissions=["framework.command.history.read"],
|
|
source="internal"
|
|
)
|
|
|
|
# 网络诊断
|
|
self.register_command(
|
|
name="netdiag",
|
|
handler=self._cmd_netdiag,
|
|
description="网络服务诊断",
|
|
permissions=["framework.network.diagnose"],
|
|
source="internal"
|
|
)
|
|
|
|
# 权限管理命令组 - 保留注册但不使用(在process_command中直接处理)
|
|
# 这些注册是为了在help命令中显示
|
|
permission_commands = [
|
|
("pmallow", "权限管理: 同意权限请求"),
|
|
("pmdeny", "权限管理: 拒绝权限请求"),
|
|
("pmignore", "权限管理: 暂时忽略权限请求"),
|
|
("permissions", "权限管理: 显示权限状态"),
|
|
("pmpending", "权限管理: 查看待授权请求列表"),
|
|
("pmrequests", "权限管理: 查看待授权请求列表(别名)"),
|
|
("pm_plugin_status", "权限管理: 查看插件权限状态"),
|
|
("pmtest", "权限管理: 测试权限配置文件"),
|
|
("pmhelp", "权限管理: 显示权限命令帮助")
|
|
]
|
|
|
|
for cmd_name, description in permission_commands:
|
|
self.register_command(
|
|
name=cmd_name,
|
|
handler=self._cmd_permission, # 使用统一的备用处理器
|
|
description=description,
|
|
permissions=["framework.permission.read"],
|
|
source="internal"
|
|
)
|
|
|
|
# 滚动控制命令组
|
|
scroll_commands = [
|
|
("scroll", "滚动控制: 手动滚动到底部"),
|
|
("autoscroll", "滚动控制: 切换自动滚动")
|
|
]
|
|
|
|
# 脚手架命令
|
|
self.register_command(
|
|
name="create-plugin",
|
|
handler=self._cmd_create_plugin,
|
|
description="创建新插件脚手架",
|
|
permissions=["framework.scaffold.plugin"],
|
|
source="internal"
|
|
)
|
|
|
|
logger.info(f"内置命令注册完成,共注册 {len(self.commands)} 个命令")
|
|
|
|
|
|
for cmd_name, description in scroll_commands:
|
|
self.register_command(
|
|
name=cmd_name,
|
|
handler=self._cmd_scroll_control,
|
|
description=description,
|
|
permissions=["framework.tui.control"],
|
|
source="internal"
|
|
)
|
|
|
|
|
|
|
|
logger.info(f"内置命令注册完成,共注册 {len(self.commands)} 个命令")
|
|
|
|
except Exception as e:
|
|
logger.error(f"注册内置命令时出错: {str(e)}", exc_info=True)
|
|
raise
|
|
|
|
async def _cmd_netdiag(self, *args) -> str:
|
|
"""网络诊断命令"""
|
|
try:
|
|
internet_service = self.service_manager.get_service("internet")
|
|
|
|
result = ["🔧 **网络服务诊断报告**"]
|
|
result.append("=" * 50)
|
|
|
|
if not internet_service:
|
|
result.append("❌ 网络服务未注册")
|
|
result.append("\n💡 **可能的原因:**")
|
|
result.append(" 1. 网络服务启动失败")
|
|
result.append(" 2. 依赖包缺失 (aiohttp)")
|
|
result.append(" 3. 端口被占用")
|
|
result.append(" 4. 权限不足")
|
|
result.append("\n🔧 **解决方案:**")
|
|
result.append(" - 检查上方日志中的错误信息")
|
|
result.append(" - 运行: pip install aiohttp")
|
|
result.append(" - 尝试更换端口号")
|
|
result.append(" - 使用 sudo (如果需要)")
|
|
return "\n".join(result)
|
|
|
|
# 获取健康信息
|
|
health_info = await internet_service.check_service_health()
|
|
|
|
result.append(f"🔄 服务运行: {'✅ 是' if health_info.get('is_running') else '❌ 否'}")
|
|
result.append(f"🔌 HTTP端口: {health_info.get('http_port', 'N/A')}")
|
|
result.append(f"📡 WebSocket端口: {health_info.get('websocket_port', 'N/A')}")
|
|
result.append(f"🌐 HTTP活跃: {'✅ 是' if health_info.get('http_active') else '❌ 否'}")
|
|
result.append(f"📦 依赖状态: {'✅ 正常' if health_info.get('dependencies_available') else '❌ 缺失'}")
|
|
|
|
if health_info.get('error'):
|
|
result.append(f"❌ 错误信息: {health_info['error']}")
|
|
|
|
# 端口占用检查
|
|
if not health_info.get('http_active') and health_info.get('is_running'):
|
|
result.append("\n⚠️ **端口问题检测:**")
|
|
result.append(" HTTP服务已启动但端口未响应")
|
|
result.append(" 可能被防火墙阻止或配置错误")
|
|
|
|
# 路由信息
|
|
routes = internet_service.get_plugin_routes()
|
|
total_routes = sum(len(plugin_routes) for plugin_routes in routes.values())
|
|
result.append(f"\n🛣️ 注册路由: {total_routes} 个")
|
|
|
|
for plugin_name, plugin_routes in routes.items():
|
|
result.append(f" 📍 {plugin_name}: {len(plugin_routes)} 个路由")
|
|
|
|
return "\n".join(result)
|
|
|
|
except Exception as e:
|
|
logger.error(f"网络诊断命令执行失败: {str(e)}")
|
|
return f"❌ 网络诊断失败: {str(e)}"
|
|
|
|
async def _cmd_scroll_control(self, *args) -> str:
|
|
"""处理滚动控制命令"""
|
|
try:
|
|
tui_service = self.service_manager.get_service("tui")
|
|
if not tui_service:
|
|
return "❌ TUI服务不可用"
|
|
|
|
if not args:
|
|
return "🔧 滚动控制命令\n💡 使用: scroll [log|message|all]\n💡 使用: autoscroll [on|off|toggle] [log|message|all]"
|
|
|
|
command = args[0].lower()
|
|
|
|
if command == "scroll":
|
|
target = args[1] if len(args) > 1 else "all"
|
|
if target not in ["log", "message", "all"]:
|
|
return "❌ 无效的目标,请使用: log, message, all"
|
|
return tui_service.scroll_to_bottom(target)
|
|
|
|
elif command == "autoscroll":
|
|
if len(args) < 2:
|
|
return "❌ 请指定操作: on, off, toggle"
|
|
|
|
action = args[1].lower()
|
|
target = args[2] if len(args) > 2 else "all"
|
|
|
|
if target not in ["log", "message", "all"]:
|
|
return "❌ 无效的目标,请使用: log, message, all"
|
|
|
|
if action == "on":
|
|
return tui_service.toggle_auto_scroll(target, True)
|
|
elif action == "off":
|
|
return tui_service.toggle_auto_scroll(target, False)
|
|
elif action == "toggle":
|
|
return tui_service.toggle_auto_scroll(target, None)
|
|
else:
|
|
return "❌ 无效的操作,请使用: on, off, toggle"
|
|
|
|
else:
|
|
return "❌ 未知滚动命令\n💡 可用命令: scroll, autoscroll"
|
|
|
|
except Exception as e:
|
|
logger.error(f"处理滚动命令时出错: {str(e)}")
|
|
return f"❌ 滚动命令错误: {str(e)}"
|
|
|
|
|
|
async def _cmd_permission(self, *args) -> str:
|
|
"""处理权限相关命令 - 备用处理器"""
|
|
try:
|
|
permission_service = self.service_manager.get_service("permission")
|
|
if not permission_service:
|
|
return "❌ 权限服务不可用"
|
|
|
|
# 如果没有参数,显示通用帮助
|
|
if not args:
|
|
return "🔐 权限管理命令\n💡 使用 pmhelp 查看详细帮助"
|
|
|
|
# 否则直接转发到权限服务
|
|
command_name = str(args[0]).lower()
|
|
permission_args = [str(arg) for arg in args[1:]] if len(args) > 1 else []
|
|
|
|
return await permission_service.process_permission_command(command_name, permission_args)
|
|
|
|
except Exception as e:
|
|
logger.error(f"处理权限命令时出错: {str(e)}", exc_info=True)
|
|
return f"❌ 权限命令错误: {str(e)}"
|
|
|
|
|
|
async def _cmd_help(self, *args) -> str:
|
|
"""帮助命令处理器"""
|
|
try:
|
|
commands = self.get_command_list()
|
|
if not commands:
|
|
return "❌ 没有可用的命令"
|
|
|
|
help_text = ["📋 **可用命令:**", ""]
|
|
|
|
# 按来源分组显示命令
|
|
commands_by_source = {}
|
|
for cmd in commands:
|
|
source = cmd['source']
|
|
if source not in commands_by_source:
|
|
commands_by_source[source] = []
|
|
commands_by_source[source].append(cmd)
|
|
|
|
# 显示内置命令
|
|
if 'internal' in commands_by_source:
|
|
help_text.append("🔧 **内置命令:**")
|
|
for cmd in commands_by_source['internal']:
|
|
help_text.append(f" 🟢 {cmd['name']:15} - {cmd['description']}")
|
|
help_text.append("")
|
|
|
|
# 显示插件命令
|
|
if 'plugin' in commands_by_source:
|
|
help_text.append("🔌 **插件命令:**")
|
|
for cmd in commands_by_source['plugin']:
|
|
help_text.append(f" 🟡 {cmd['name']:15} - {cmd['description']}")
|
|
help_text.append("")
|
|
|
|
# 显示系统命令
|
|
if 'system' in commands_by_source:
|
|
help_text.append("⚙️ **系统命令:**")
|
|
for cmd in commands_by_source['system']:
|
|
help_text.append(f" 🔵 {cmd['name']:15} - {cmd['description']}")
|
|
|
|
# 添加使用提示
|
|
help_text.extend([
|
|
"",
|
|
"💡 **使用提示:**",
|
|
" - 输入命令名称执行命令",
|
|
" - 使用 'status' 查看框架状态",
|
|
" - 使用 'history' 查看命令历史",
|
|
" - 使用 'permissions' 管理插件权限"
|
|
])
|
|
|
|
return "\n".join(help_text)
|
|
|
|
except Exception as e:
|
|
logger.error(f"处理help命令时出错: {str(e)}", exc_info=True)
|
|
return f"❌ 帮助命令错误: {str(e)}"
|
|
|
|
async def _cmd_status(self, *args) -> str:
|
|
"""状态命令处理器"""
|
|
try:
|
|
status_info = [
|
|
f"命令服务状态:",
|
|
f" 注册命令数: {len(self.commands)}",
|
|
f" 历史记录数: {len(self.command_history)}",
|
|
f" 最大历史大小: {self.max_history_size}"
|
|
]
|
|
return "\n".join(status_info)
|
|
|
|
except Exception as e:
|
|
logger.error(f"处理status命令时出错: {str(e)}", exc_info=True)
|
|
return f"状态命令错误: {str(e)}"
|
|
|
|
async def _cmd_history(self, *args) -> str:
|
|
"""历史命令处理器"""
|
|
try:
|
|
limit = 10
|
|
if args and args[0].isdigit():
|
|
limit = min(int(args[0]), 50) # 限制最大50条
|
|
|
|
history = self.get_command_history(limit)
|
|
if not history:
|
|
return "没有命令历史"
|
|
|
|
history_text = [f"最近 {len(history)} 条命令历史:"]
|
|
for i, entry in enumerate(reversed(history), 1):
|
|
history_text.append(f" {i}. [{entry['source']}] {entry['command']}")
|
|
|
|
return "\n".join(history_text)
|
|
|
|
except Exception as e:
|
|
logger.error(f"处理history命令时出错: {str(e)}", exc_info=True)
|
|
return f"历史命令错误: {str(e)}"
|
|
|
|
async def _cmd_test_log(self, *args) -> str:
|
|
"""测试日志命令"""
|
|
try:
|
|
logger.debug("这是一条DEBUG测试日志")
|
|
logger.info("这是一条INFO测试日志")
|
|
logger.warning("这是一条WARNING测试日志")
|
|
logger.error("这是一条ERROR测试日志")
|
|
return "✅ 测试日志已生成,请检查TUI显示"
|
|
except Exception as e:
|
|
return f"❌ 测试日志生成失败: {str(e)}"
|
|
|
|
async def _cmd_create_plugin(self, *args) -> str:
|
|
"""创建新插件脚手架"""
|
|
try:
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
from string import Template
|
|
|
|
# 1. 参数解析
|
|
if not args:
|
|
return "❌ 用法: create-plugin <插件名> [--author <作者>] [--desc <描述>]\n💡 插件名需为小写字母/数字/下划线,如: my_cool_plugin"
|
|
|
|
plugin_name = args[0]
|
|
author = "Unknown"
|
|
description = "暂无描述"
|
|
|
|
# 解析可选参数
|
|
i = 1
|
|
while i < len(args):
|
|
if args[i] == "--author" and i + 1 < len(args):
|
|
author = args[i+1]
|
|
i += 2
|
|
elif args[i] == "--desc" and i + 1 < len(args):
|
|
description = args[i+1]
|
|
i += 2
|
|
else:
|
|
i += 1
|
|
|
|
# 2. 命名校验
|
|
if not re.match(r'^[a-z][a-z0-9_]*$', plugin_name):
|
|
return "❌ 插件名格式错误。请使用小写字母开头,仅包含小写字母、数字和下划线(如: data_sync)"
|
|
|
|
plugin_dir = Path("plugins") / plugin_name
|
|
if plugin_dir.exists():
|
|
return f"❌ 插件目录已存在: {plugin_dir}"
|
|
|
|
# 3. 模板路径
|
|
template_dir = Path(os.getenv("SENSU_CODE_DIR", ".")) / "templates" / "plugin"
|
|
|
|
if not template_dir.exists():
|
|
return "❌ 模板目录不存在: templates/plugin/"
|
|
|
|
# 4. 创建目录与渲染文件
|
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
context = {
|
|
"plugin_name": plugin_name,
|
|
"author": author,
|
|
"description": description
|
|
}
|
|
|
|
for template_file in template_dir.iterdir():
|
|
if template_file.is_file() and template_file.name.endswith(".template"):
|
|
with open(template_file, 'r', encoding='utf-8') as f:
|
|
tpl = Template(f.read())
|
|
content = tpl.safe_substitute(context)
|
|
|
|
target_name = template_file.stem
|
|
target_path = plugin_dir / target_name
|
|
|
|
with open(target_path, 'w', encoding='utf-8') as f:
|
|
f.write(content)
|
|
logger.debug(f"脚手架文件生成: {target_path}")
|
|
|
|
return (
|
|
f"✅ 插件脚手架创建成功!\n"
|
|
f"📁 路径: {plugin_dir}\n"
|
|
f"👤 作者: {author}\n"
|
|
f"📝 描述: {description}\n\n"
|
|
f"🔧 下一步:\n"
|
|
f" 1. 编辑 {plugin_dir}/__init__.py 实现业务逻辑\n"
|
|
f" 2. 运行框架自动加载插件\n"
|
|
f" 3. 使用 `help` 查看可用命令"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"创建插件脚手架失败: {str(e)}", exc_info=True)
|
|
return f"❌ 创建失败: {str(e)}"
|
|
|
|
|
|
|
|
|
|
|
|
def shutdown(self):
|
|
"""关闭指令服务"""
|
|
try:
|
|
logger.info("关闭指令服务")
|
|
self.commands.clear()
|
|
self.command_history.clear()
|
|
logger.debug("指令服务关闭完成")
|
|
except Exception as e:
|
|
logger.error(f"关闭指令服务时出错: {str(e)}", exc_info=True)
|