Initial commit: SenSu Alpha 0.2.0

- 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)
This commit is contained in:
2026-06-10 12:27:14 +08:00
commit e6875f0b4b
78 changed files with 14843 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import logging
logger = logging.getLogger(__name__)
class ServiceManager:
"""服务管理器"""
def __init__(self):
self.services = {}
# 记录框架启动时间
self.start_time = time.time()
logger.debug("ServiceManager初始化完成")
def register_service(self, name: str, service_instance):
"""注册服务"""
try:
if name in self.services:
logger.warning(f"服务 {name} 已存在,将被覆盖")
self.services[name] = service_instance
logger.debug(f"服务 {name} 注册成功")
except Exception as e:
logger.error(f"注册服务 {name} 时出错: {str(e)}", exc_info=True)
raise
def get_service(self, name: str):
"""获取服务"""
try:
service = self.services.get(name)
if not service:
logger.error(f"服务 {name} 不存在")
raise ValueError(f"服务 {name} 未找到")
logger.debug(f"成功获取服务 {name}")
return service
except Exception as e:
logger.error(f"获取服务 {name} 时出错: {str(e)}", exc_info=True)
raise
def shutdown_all(self):
"""关闭所有服务"""
logger.info("开始关闭所有服务")
for name, service in self.services.items():
try:
if hasattr(service, 'shutdown'):
service.shutdown()
logger.debug(f"服务 {name} 关闭成功")
except Exception as e:
logger.error(f"关闭服务 {name} 时出错: {str(e)}", exc_info=True)
logger.info("所有服务关闭完成")
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import logging
import asyncio
from typing import Dict, List, Optional
from dataclasses import dataclass
from aiohttp import web
import json
logger = logging.getLogger(__name__)
@dataclass
class APIEndpoint:
"""API端点数据类"""
path: str
methods: List[str]
handler: callable
plugin_name: str
require_auth: bool
admin_only: bool
class APIService:
"""API服务 - 管理插件的API端点"""
def __init__(self, internet_service, auth_service, permission_service):
self.internet_service = internet_service
self.auth_service = auth_service
self.permission_service = permission_service
self.endpoints: Dict[str, APIEndpoint] = {}
self.plugin_endpoints: Dict[str, List[str]] = {}
logger.debug("APIService初始化开始")
async def register_endpoint(self, plugin_name: str, path: str, methods: List[str],
handler: callable, require_auth: bool = True,
admin_only: bool = False) -> bool:
"""注册API端点"""
try:
logger.debug(f"注册API端点: {plugin_name} -> {path} {methods}")
# 权限检查 - 只有admin插件可以操作接口
if not self.permission_service.check_plugin_permission(plugin_name, "admin"):
logger.error(f"插件 {plugin_name} 无权限注册API端点")
return False
# 创建端点键
endpoint_key = f"{plugin_name}:{path}"
# 检查端点是否已存在
if endpoint_key in self.endpoints:
logger.warning(f"API端点已存在: {endpoint_key}")
return False
# 创建端点实例
endpoint = APIEndpoint(
path=path,
methods=methods,
handler=handler,
plugin_name=plugin_name,
require_auth=require_auth,
admin_only=admin_only
)
# 注册到互联网服务
for method in methods:
internet_endpoint_key = f"{method}:{path}"
self.internet_service.endpoints[internet_endpoint_key] = endpoint
# 保存端点信息
self.endpoints[endpoint_key] = endpoint
# 更新插件端点映射
if plugin_name not in self.plugin_endpoints:
self.plugin_endpoints[plugin_name] = []
self.plugin_endpoints[plugin_name].append(endpoint_key)
logger.info(f"API端点注册成功: {endpoint_key}")
return True
except Exception as e:
logger.error(f"注册API端点时出错: {str(e)}", exc_info=True)
return False
async def unregister_endpoint(self, plugin_name: str, path: str) -> bool:
"""注销API端点"""
try:
logger.debug(f"注销API端点: {plugin_name} -> {path}")
endpoint_key = f"{plugin_name}:{path}"
if endpoint_key not in self.endpoints:
logger.warning(f"API端点不存在: {endpoint_key}")
return False
endpoint = self.endpoints[endpoint_key]
# 从互联网服务中移除
for method in endpoint.methods:
internet_endpoint_key = f"{method}:{path}"
if internet_endpoint_key in self.internet_service.endpoints:
del self.internet_service.endpoints[internet_endpoint_key]
# 从端点映射中移除
del self.endpoints[endpoint_key]
# 从插件端点列表中移除
if plugin_name in self.plugin_endpoints:
if endpoint_key in self.plugin_endpoints[plugin_name]:
self.plugin_endpoints[plugin_name].remove(endpoint_key)
logger.info(f"API端点注销成功: {endpoint_key}")
return True
except Exception as e:
logger.error(f"注销API端点时出错: {str(e)}", exc_info=True)
return False
async def unregister_all_plugin_endpoints(self, plugin_name: str) -> bool:
"""注销插件的所有API端点"""
try:
logger.debug(f"注销插件所有API端点: {plugin_name}")
if plugin_name not in self.plugin_endpoints:
logger.debug(f"插件无注册的API端点: {plugin_name}")
return True
endpoints_to_remove = self.plugin_endpoints[plugin_name][:]
success_count = 0
for endpoint_key in endpoints_to_remove:
# 从endpoint_key中提取path
parts = endpoint_key.split(':', 1)
if len(parts) == 2:
path = parts[1]
success = await self.unregister_endpoint(plugin_name, path)
if success:
success_count += 1
logger.info(f"插件API端点清理完成: {plugin_name} -> 成功 {success_count}/{len(endpoints_to_remove)}")
return success_count == len(endpoints_to_remove)
except Exception as e:
logger.error(f"注销插件所有API端点时出错: {str(e)}", exc_info=True)
return False
def get_plugin_endpoints(self, plugin_name: str) -> List[Dict]:
"""获取插件的API端点列表"""
try:
if plugin_name not in self.plugin_endpoints:
return []
endpoints_info = []
for endpoint_key in self.plugin_endpoints[plugin_name]:
if endpoint_key in self.endpoints:
endpoint = self.endpoints[endpoint_key]
endpoints_info.append({
'path': endpoint.path,
'methods': endpoint.methods,
'require_auth': endpoint.require_auth,
'admin_only': endpoint.admin_only
})
logger.debug(f"获取插件API端点列表: {plugin_name} -> {len(endpoints_info)}")
return endpoints_info
except Exception as e:
logger.error(f"获取插件API端点列表时出错: {str(e)}", exc_info=True)
return []
def get_all_endpoints(self) -> List[Dict]:
"""获取所有API端点"""
try:
all_endpoints = []
for endpoint_key, endpoint in self.endpoints.items():
all_endpoints.append({
'plugin': endpoint.plugin_name,
'path': endpoint.path,
'methods': endpoint.methods,
'require_auth': endpoint.require_auth,
'admin_only': endpoint.admin_only
})
logger.debug(f"获取所有API端点: {len(all_endpoints)}")
return all_endpoints
except Exception as e:
logger.error(f"获取所有API端点时出错: {str(e)}", exc_info=True)
return []
async def shutdown(self):
"""关闭API服务"""
try:
logger.info("关闭API服务")
# 注销所有端点
all_plugins = list(self.plugin_endpoints.keys())
for plugin_name in all_plugins:
await self.unregister_all_plugin_endpoints(plugin_name)
self.endpoints.clear()
self.plugin_endpoints.clear()
logger.debug("API服务关闭完成")
except Exception as e:
logger.error(f"关闭API服务时出错: {str(e)}", exc_info=True)
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import logging
import os
import hashlib
import secrets
from typing import Dict, List, Optional
from dataclasses import dataclass
import time
import json
logger = logging.getLogger(__name__)
@dataclass
class User:
"""用户数据类"""
username: str
password_hash: str
permissions: List[str]
is_active: bool = True
created_at: float = None
@dataclass
class Token:
"""令牌数据类"""
token: str
username: str
permissions: List[str]
created_at: float
expires_at: float
is_valid: bool = True
class AuthService:
"""认证服务 - 处理用户认证和权限验证"""
def __init__(self, config: Dict):
self.config = config
self.users: Dict[str, User] = {}
self.tokens: Dict[str, Token] = {}
self.token_expiry_hours = 24
self.secret_key = secrets.token_hex(32)
logger.debug("AuthService初始化开始")
# 初始化默认用户
self._init_default_users()
def _init_default_users(self):
"""初始化默认用户"""
try:
# 创建默认管理员用户
admin_password_hash = self._hash_password(os.environ.get("SENSU_ADMIN_PASSWORD","admin123"))
admin_user = User(
username="admin",
password_hash=admin_password_hash,
permissions=["admin"],
created_at=time.time()
)
self.users["admin"] = admin_user
# 创建默认API用户
api_password_hash = self._hash_password(os.environ.get("SENSU_API_PASSWORD","api123"))
api_user = User(
username="api",
password_hash=api_password_hash,
permissions=["framework.status.read", "plugin.info.read"],
created_at=time.time()
)
self.users["api"] = api_user
logger.debug("默认用户初始化完成")
except Exception as e:
logger.error(f"初始化默认用户时出错: {str(e)}", exc_info=True)
raise
def _hash_password(self, password: str) -> str:
"""哈希密码"""
try:
salt = "catframework_salt" # 实际应该使用随机盐
return hashlib.sha256((password + salt).encode()).hexdigest()
except Exception as e:
logger.error(f"哈希密码时出错: {str(e)}", exc_info=True)
raise
def authenticate_user(self, username: str, password: str) -> Optional[Token]:
"""用户认证"""
try:
logger.debug(f"用户认证尝试: {username}")
if username not in self.users:
logger.warning(f"用户不存在: {username}")
return None
user = self.users[username]
if not user.is_active:
logger.warning(f"用户已被禁用: {username}")
return None
password_hash = self._hash_password(password)
if user.password_hash != password_hash:
logger.warning(f"密码错误: {username}")
return None
# 创建令牌
token = self._create_token(user)
logger.debug(f"用户认证成功: {username}")
return token
except Exception as e:
logger.error(f"用户认证时出错: {str(e)}", exc_info=True)
return None
def _create_token(self, user: User) -> Token:
"""创建令牌"""
try:
token_str = secrets.token_hex(32)
created_at = time.time()
expires_at = created_at + (self.token_expiry_hours * 3600)
token = Token(
token=token_str,
username=user.username,
permissions=user.permissions,
created_at=created_at,
expires_at=expires_at
)
self.tokens[token_str] = token
logger.debug(f"创建令牌: {user.username}, 有效期: {self.token_expiry_hours}小时")
return token
except Exception as e:
logger.error(f"创建令牌时出错: {str(e)}", exc_info=True)
raise
def validate_token(self, token_str: str) -> Optional[Token]:
"""验证令牌"""
try:
if token_str not in self.tokens:
logger.debug("令牌不存在")
return None
token = self.tokens[token_str]
# 检查令牌是否有效
if not token.is_valid:
logger.debug("令牌已失效")
return None
# 检查令牌是否过期
if time.time() > token.expires_at:
logger.debug("令牌已过期")
token.is_valid = False
return None
logger.debug(f"令牌验证成功: {token.username}")
return token
except Exception as e:
logger.error(f"验证令牌时出错: {str(e)}", exc_info=True)
return None
def revoke_token(self, token_str: str) -> bool:
"""撤销令牌"""
try:
if token_str in self.tokens:
self.tokens[token_str].is_valid = False
logger.debug(f"令牌已撤销: {token_str}")
return True
else:
logger.warning(f"要撤销的令牌不存在: {token_str}")
return False
except Exception as e:
logger.error(f"撤销令牌时出错: {str(e)}", exc_info=True)
return False
def check_permission(self, token_str: str, permission: str) -> bool:
"""检查权限"""
try:
token = self.validate_token(token_str)
if not token:
return False
# 检查admin权限
if "admin" in token.permissions:
return True
# 检查具体权限
has_permission = permission in token.permissions
logger.debug(f"权限检查: {token.username} -> {permission} = {has_permission}")
return has_permission
except Exception as e:
logger.error(f"检查权限时出错: {str(e)}", exc_info=True)
return False
def create_user(self, username: str, password: str, permissions: List[str]) -> bool:
"""创建用户"""
try:
if username in self.users:
logger.warning(f"用户已存在: {username}")
return False
password_hash = self._hash_password(password)
user = User(
username=username,
password_hash=password_hash,
permissions=permissions,
created_at=time.time()
)
self.users[username] = user
logger.debug(f"用户创建成功: {username}, 权限: {permissions}")
return True
except Exception as e:
logger.error(f"创建用户时出错: {str(e)}", exc_info=True)
return False
def get_user_info(self, username: str) -> Optional[Dict]:
"""获取用户信息"""
try:
if username not in self.users:
return None
user = self.users[username]
return {
"username": user.username,
"permissions": user.permissions,
"is_active": user.is_active,
"created_at": user.created_at
}
except Exception as e:
logger.error(f"获取用户信息时出错: {str(e)}", exc_info=True)
return None
def cleanup_expired_tokens(self):
"""清理过期令牌"""
try:
current_time = time.time()
expired_tokens = []
for token_str, token in self.tokens.items():
if current_time > token.expires_at:
expired_tokens.append(token_str)
for token_str in expired_tokens:
del self.tokens[token_str]
if expired_tokens:
logger.debug(f"清理了 {len(expired_tokens)} 个过期令牌")
except Exception as e:
logger.error(f"清理过期令牌时出错: {str(e)}", exc_info=True)
def shutdown(self):
"""关闭认证服务"""
try:
logger.info("关闭认证服务")
self.cleanup_expired_tokens()
self.users.clear()
self.tokens.clear()
logger.debug("认证服务关闭完成")
except Exception as e:
logger.error(f"关闭认证服务时出错: {str(e)}", exc_info=True)
+604
View File
@@ -0,0 +1,604 @@
#!/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)
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import logging
import asyncio
from pathlib import Path
from typing import Dict, Any
import yaml
import importlib.util
import sys
import os
logger = logging.getLogger(__name__)
class InitService:
"""初始化服务"""
def __init__(self, config_path: str = "config/framework"):
self.config_path = Path(config_path)
self.configs: Dict[str, Any] = {}
self.fmfuncs_loaded = False
logger.debug("InitService初始化开始")
async def initialize_framework(self):
"""初始化框架"""
try:
logger.info("开始初始化框架")
# 1. 加载配置
await self._load_configs()
# 2. 创建必要目录
await self._create_directories()
# 3. 加载框架功能集
await self._load_fmfuncs()
# 4. 验证初始化状态
await self._validate_init()
logger.info("框架初始化完成")
return self.configs
except Exception as e:
logger.error(f"框架初始化失败: {str(e)}", exc_info=True)
raise
async def _load_configs(self):
"""加载配置文件"""
try:
logger.debug("开始加载配置文件")
if not self.config_path.exists():
logger.warning(f"配置路径不存在: {self.config_path},将创建默认配置")
self.config_path.mkdir(parents=True, exist_ok=True)
# 加载基础配置
base_config_file = self.config_path / "base_config.yaml"
if base_config_file.exists():
with open(base_config_file, 'r', encoding='utf-8') as f:
self.configs['base'] = yaml.safe_load(f)
logger.debug("基础配置加载成功")
else:
logger.warning("基础配置文件不存在,使用默认配置")
self.configs['base'] = self._get_default_base_config()
self._save_config(base_config_file, self.configs['base'])
# 加载权限规则
permission_file = self.config_path / "permission_rules.yaml"
if permission_file.exists():
with open(permission_file, 'r', encoding='utf-8') as f:
self.configs['permission_rules'] = yaml.safe_load(f)
logger.debug("权限规则配置加载成功")
else:
logger.warning("权限规则文件不存在,使用默认配置")
self.configs['permission_rules'] = self._get_default_permission_rules()
self._save_config(permission_file, self.configs['permission_rules'])
logger.debug(f"配置文件加载完成,共加载 {len(self.configs)} 个配置集")
except Exception as e:
logger.error(f"加载配置文件时出错: {str(e)}", exc_info=True)
raise
async def _create_directories(self):
"""创建必要目录"""
try:
logger.debug("开始创建必要目录")
directories = [
"config/plugins",
"config/services",
"config/permissions",
"logs/runtime",
"logs/debug",
"plugins",
"utils",
"fmfuncs"
]
for dir_path in directories:
path = Path(dir_path)
path.mkdir(parents=True, exist_ok=True)
logger.debug(f"创建目录: {dir_path}")
logger.debug("目录创建完成")
except Exception as e:
logger.error(f"创建目录时出错: {str(e)}", exc_info=True)
raise
async def _load_fmfuncs(self):
"""加载框架功能集"""
try:
logger.debug("开始加载框架功能集")
fmfuncs_path = Path(os.getenv("SENSU_CODE_DIR", ".")) / "fmfuncs"
if not fmfuncs_path.exists():
logger.warning("fmfuncs目录不存在,跳过加载")
return
# 动态加载所有Python文件
for py_file in fmfuncs_path.glob("*.py"):
if py_file.name == "__init__.py":
continue
try:
module_name = f"fmfuncs.{py_file.stem}"
spec = importlib.util.spec_from_file_location(module_name, py_file)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
logger.debug(f"加载框架功能: {module_name}")
except Exception as e:
logger.error(f"加载框架功能 {py_file} 时出错: {str(e)}", exc_info=True)
continue
self.fmfuncs_loaded = True
logger.debug("框架功能集加载完成")
except Exception as e:
logger.error(f"加载框架功能集时出错: {str(e)}", exc_info=True)
raise
async def _validate_init(self):
"""验证初始化状态"""
try:
logger.debug("开始验证初始化状态")
required_configs = ['base', 'permission_rules']
for config_name in required_configs:
if config_name not in self.configs:
logger.error(f"缺少必要配置: {config_name}")
raise ValueError(f"缺少必要配置: {config_name}")
required_dirs = ['config', 'logs', 'plugins']
for dir_name in required_dirs:
if not Path(dir_name).exists():
logger.error(f"必要目录不存在: {dir_name}")
raise ValueError(f"必要目录不存在: {dir_name}")
logger.debug("初始化状态验证通过")
except Exception as e:
logger.error(f"验证初始化状态时出错: {str(e)}", exc_info=True)
raise
def _get_default_base_config(self) -> Dict:
"""获取默认基础配置"""
return {
'framework': {
'name': 'SenSu',
'version': 'Alpha_0.2.0',
'debug': True
},
'logging': {
'level': 'INFO',
'debug_level_file': True,
'max_log_files': 20,
'max_file_size': '10MB'
},
'tui': {
'layout': {
'grid-rows': '4fr 5fr 1fr'
}
},
'services': {
'internet': {
'ws_port': 8765,
'api_port': 8000,
'enable_reverse_proxy': False
}
},
'plugins': {
'auto_load': True,
'hot_reload': True,
'max_retry_count': 3
}
}
def _get_default_permission_rules(self) -> Dict:
"""获取默认权限规则"""
return {
'permission_levels': ['read', 'write', 'execute', 'admin'],
'default_permissions': [
'framework.status.read',
'plugin.self.info.read'
],
'admin_permissions': [
'framework.*',
'plugin.*',
'service.*'
]
}
def _save_config(self, file_path: Path, config: Dict):
"""保存配置到文件"""
try:
with open(file_path, 'w', encoding='utf-8') as f:
yaml.dump(config, f, default_flow_style=False, allow_unicode=True)
logger.debug(f"配置保存到: {file_path}")
except Exception as e:
logger.error(f"保存配置到 {file_path} 时出错: {str(e)}", exc_info=True)
def get_config(self, config_name: str) -> Dict:
"""获取配置"""
try:
config = self.configs.get(config_name)
if not config:
logger.error(f"配置不存在: {config_name}")
raise ValueError(f"配置 {config_name} 不存在")
logger.debug(f"获取配置: {config_name}")
return config
except Exception as e:
logger.error(f"获取配置 {config_name} 时出错: {str(e)}", exc_info=True)
raise
def shutdown(self):
"""关闭初始化服务"""
try:
logger.info("关闭初始化服务")
self.configs.clear()
logger.debug("初始化服务关闭完成")
except Exception as e:
logger.error(f"关闭初始化服务时出错: {str(e)}", exc_info=True)
+464
View File
@@ -0,0 +1,464 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import logging
import asyncio
from typing import Dict, List, Callable, Any, Optional
from pathlib import Path
import aiohttp
from aiohttp import web
import json
import ssl
logger = logging.getLogger(__name__)
class InternetService:
"""网络服务 - 管理HTTP和WebSocket服务器"""
def __init__(self, config: Dict, service_manager):
self.config = config
self.service_manager = service_manager
self.http_app = web.Application()
self.http_runner = None
self.ws_connections: Dict[str, List] = {}
self.plugin_routes: Dict[str, List] = {}
self.is_running = False
# 从配置获取端口
internet_config = config.get('internet', {})
ws_config = internet_config.get('websocket', {})
self.ws_host = ws_config.get('host', '0.0.0.0')
self.ws_port = ws_config.get('port', 8765)
http_config = internet_config.get('http', {})
self.http_host = http_config.get('host', '0.0.0.0')
self.http_port = http_config.get('port', 8000)
logger.debug("InternetService初始化开始")
async def start(self):
"""启动网络服务 - 精准错误处理与端口复用版"""
try:
logger.info("启动网络服务")
self._setup_default_routes()
# 初始化 AppRunner
self.http_runner = web.AppRunner(self.http_app)
await self.http_runner.setup()
# 1. 启动 HTTP 站点
try:
self.site = web.TCPSite(
self.http_runner, self.http_host, self.http_port,
reuse_address=True, reuse_port=True
)
await self.site.start()
logger.info(f"✅ HTTP 服务已绑定: {self.http_host}:{self.http_port}")
except OSError as e:
logger.error(f"❌ HTTP 端口 {self.http_port} 绑定失败: {e}")
await self.http_runner.cleanup()
return False
# 2. 启动 WebSocket 站点 (独立端口)
try:
self.ws_site = web.TCPSite(
self.http_runner, self.ws_host, self.ws_port,
reuse_address=True, reuse_port=True
)
await self.ws_site.start()
logger.info(f"✅ WebSocket 服务已绑定: {self.ws_host}:{self.ws_port}")
except OSError as e:
logger.error(f"❌ WebSocket 端口 {self.ws_port} 绑定失败: {e}")
logger.warning("💡 WS端口可能处于 TIME_WAIT,请等待30秒或更换 config 中的 websocket.port")
await self.http_runner.cleanup() # 回滚已启动的 HTTP
return False
await self.save_network_config()
self.is_running = True
logger.info("🌐 网络服务启动完成")
return True
except Exception as e:
logger.error(f"❌ 启动网络服务异常: {str(e)}", exc_info=True)
return False
async def check_service_health(self) -> Dict[str, Any]:
"""检查服务健康状况"""
try:
import socket
health_info = {
"is_running": self.is_running,
"http_port": self.http_port,
"websocket_port": self.ws_port,
"http_active": False,
"dependencies_available": self._check_dependencies(),
"error": None
}
# 检查端口是否在监听
if self.is_running:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
result = sock.connect_ex(('localhost', self.http_port))
sock.close()
health_info["http_active"] = (result == 0)
except Exception as e:
health_info["error"] = f"端口检查失败: {str(e)}"
return health_info
except Exception as e:
return {
"is_running": False,
"error": f"健康检查失败: {str(e)}"
}
def _check_dependencies(self) -> bool:
"""检查必要的依赖包"""
try:
import aiohttp
import yaml
return True
except ImportError as e:
logger.error(f"❌ 缺少依赖包: {str(e)}")
return False
async def save_network_config(self):
"""保存网络配置"""
try:
config_path = Path("config") / "services" / "network_routes.yaml"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_data = {
"plugin_routes": self.get_plugin_routes(),
"last_updated": asyncio.get_event_loop().time(),
"http_port": self.http_port,
"websocket_port": self.ws_port
}
import yaml
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}")
except Exception as e:
logger.error(f"保存网络配置时出错: {str(e)}")
def _setup_default_routes(self):
"""设置默认路由"""
# 健康检查端点
self.http_app.router.add_get('/health', self._handle_health_check)
# 插件API端点
self.http_app.router.add_get('/api/plugins', self._handle_get_plugins)
self.http_app.router.add_get('/api/commands', self._handle_get_commands)
# 数据接收端点
self.http_app.router.add_post('/api/data', self._handle_data_receive)
logger.debug("默认路由设置完成")
async def register_plugin_route(self, plugin_name: str, route_path: str,
handler: Callable, methods: List[str] = ["GET"],
require_auth: bool = True):
"""为插件注册HTTP路由 - 修复冻结路由器问题"""
try:
# 规范化路径
if not route_path.startswith('/'):
route_path = '/' + route_path
full_path = f"/{plugin_name}{route_path}"
# 创建包装器处理权限验证
async def wrapped_handler(request):
try:
# 权限验证
if require_auth:
auth_result = await self._check_plugin_auth(plugin_name, request)
if not auth_result['allowed']:
return web.json_response(
{"error": "权限不足", "details": auth_result['reason']},
status=403
)
# 调用插件处理器
return await handler(request)
except Exception as e:
logger.error(f"插件路由处理出错 {full_path}: {str(e)}")
return web.json_response(
{"error": "内部服务器错误", "details": str(e)},
status=500
)
# 检查路由器是否已冻结
if hasattr(self.http_app.router, '_frozen') and self.http_app.router._frozen:
logger.warning(f"路由器已冻结,无法注册新路由: {full_path}")
logger.info("💡 建议: 在启动网络服务前注册所有插件路由")
return
# 注册路由
for method in methods:
self.http_app.router.add_route(method.upper(), full_path, wrapped_handler)
# 记录路由信息
if plugin_name not in self.plugin_routes:
self.plugin_routes[plugin_name] = []
self.plugin_routes[plugin_name].append({
'path': full_path,
'methods': methods,
'require_auth': require_auth
})
logger.info(f"注册插件路由: {plugin_name} -> {full_path} [{','.join(methods)}]")
except RuntimeError as e:
if "frozen router" in str(e):
logger.error(f"❌ 无法注册路由 {full_path}: 路由器已冻结")
logger.info("💡 解决方案: 在启动网络服务前注册插件路由")
else:
logger.error(f"注册插件路由时出错: {str(e)}", exc_info=True)
raise
except Exception as e:
logger.error(f"注册插件路由时出错: {str(e)}", exc_info=True)
raise
async def register_plugin_websocket(self, plugin_name: str, ws_path: str,
handler: Callable, require_auth: bool = True):
"""为插件注册WebSocket路由"""
try:
# 规范化路径
if not ws_path.startswith('/'):
ws_path = '/' + ws_path
full_path = f"/plugin/{plugin_name}/ws{ws_path}"
async def websocket_handler(request):
try:
# 权限验证
if require_auth:
auth_result = await self._check_plugin_auth(plugin_name, request)
if not auth_result['allowed']:
return web.json_response(
{"error": "WebSocket连接权限不足"},
status=403
)
# 建立WebSocket连接
ws = web.WebSocketResponse()
await ws.prepare(request)
# 记录连接
connection_id = f"{plugin_name}_{id(ws)}"
if plugin_name not in self.ws_connections:
self.ws_connections[plugin_name] = []
self.ws_connections[plugin_name].append(ws)
logger.debug(f"WebSocket连接建立: {connection_id}")
# 调用插件处理器
await handler(ws, request)
# 清理连接
self.ws_connections[plugin_name].remove(ws)
return ws
except Exception as e:
logger.error(f"WebSocket处理出错 {full_path}: {str(e)}")
return web.json_response(
{"error": "WebSocket连接失败"},
status=500
)
# 注册WebSocket路由
self.http_app.router.add_route('GET', full_path, websocket_handler)
# 记录路由信息
if plugin_name not in self.plugin_routes:
self.plugin_routes[plugin_name] = []
self.plugin_routes[plugin_name].append({
'path': full_path,
'methods': ['WEBSOCKET'],
'require_auth': require_auth
})
logger.info(f"注册插件WebSocket: {plugin_name} -> {full_path}")
except Exception as e:
logger.error(f"注册插件WebSocket时出错: {str(e)}", exc_info=True)
raise
async def _check_plugin_auth(self, plugin_name: str, request) -> Dict[str, Any]:
"""检查插件权限"""
try:
# 获取权限服务
permission_service = self.service_manager.get_service("permission")
if not permission_service:
return {"allowed": False, "reason": "权限服务不可用"}
# 检查插件是否有网络访问权限
if not permission_service.has_permission(plugin_name, "plugin.network.access"):
return {"allowed": False, "reason": "插件没有网络访问权限"}
# 检查API密钥(如果配置了)
api_key = request.headers.get('X-API-Key')
if api_key:
# 验证API密钥逻辑
valid_keys = self.config.get('api_keys', [])
if api_key not in valid_keys:
return {"allowed": False, "reason": "无效的API密钥"}
return {"allowed": True, "reason": "权限验证通过"}
except Exception as e:
logger.error(f"权限检查时出错: {str(e)}")
return {"allowed": False, "reason": "权限检查失败"}
async def broadcast_to_websockets(self, plugin_name: str, message: Dict):
"""向插件的所有WebSocket连接广播消息"""
try:
if plugin_name not in self.ws_connections:
return
message_json = json.dumps(message, ensure_ascii=False)
disconnected = []
for ws in self.ws_connections[plugin_name]:
try:
if not ws.closed:
await ws.send_str(message_json)
else:
disconnected.append(ws)
except Exception as e:
logger.error(f"WebSocket广播消息失败: {str(e)}")
disconnected.append(ws)
# 清理断开连接的WebSocket
for ws in disconnected:
self.ws_connections[plugin_name].remove(ws)
logger.debug(f"WebSocket广播完成: {plugin_name} -> {len(self.ws_connections[plugin_name])} 个连接")
except Exception as e:
logger.error(f"WebSocket广播时出错: {str(e)}", exc_info=True)
# 默认路由处理器
async def _handle_health_check(self, request):
"""健康检查端点"""
return web.json_response({
"status": "healthy",
"service": "internet",
"timestamp": asyncio.get_event_loop().time()
})
async def _handle_get_plugins(self, request):
"""获取插件列表"""
try:
plugin_service = self.service_manager.get_service("plugin")
if not plugin_service:
return web.json_response({"error": "插件服务不可用"}, status=503)
plugins_info = []
for name, info in plugin_service.plugin_info.items():
plugins_info.append({
"name": name,
"version": info.version,
"description": info.description,
"enabled": info.enabled,
"loaded": info.loaded
})
return web.json_response({
"plugins": plugins_info,
"count": len(plugins_info)
})
except Exception as e:
logger.error(f"获取插件列表时出错: {str(e)}")
return web.json_response({"error": "内部服务器错误"}, status=500)
async def _handle_get_commands(self, request):
"""获取命令列表"""
try:
command_service = self.service_manager.get_service("command")
if not command_service:
return web.json_response({"error": "命令服务不可用"}, status=503)
command_list = command_service.get_command_list()
return web.json_response({
"commands": command_list,
"count": len(command_list)
})
except Exception as e:
logger.error(f"获取命令列表时出错: {str(e)}")
return web.json_response({"error": "内部服务器错误"}, status=500)
async def _handle_data_receive(self, request):
"""处理跨端数据传输"""
try:
data = await request.json()
# 获取插件桥接服务
plugin_bridge = self.service_manager.get_service("plugin_bridge")
if plugin_bridge:
# 广播数据到所有插件
await plugin_bridge.broadcast_to_plugins(
"network.data.receive",
{
"source": request.remote,
"data": data,
"timestamp": asyncio.get_event_loop().time()
}
)
return web.json_response({
"status": "success",
"message": "数据接收成功",
"timestamp": asyncio.get_event_loop().time()
})
except Exception as e:
logger.error(f"处理跨端数据时出错: {str(e)}")
return web.json_response({
"error": "数据接收失败",
"details": str(e)
}, status=400)
def get_plugin_routes(self, plugin_name: str = None) -> Dict:
"""获取插件路由信息"""
if plugin_name:
return self.plugin_routes.get(plugin_name, [])
else:
return self.plugin_routes.copy()
async def shutdown(self):
"""关闭网络服务"""
try:
logger.info("关闭网络服务")
self.is_running = False
# 关闭所有WebSocket连接
for plugin_name, connections in self.ws_connections.items():
for ws in connections:
if not ws.closed:
await ws.close()
self.ws_connections[plugin_name].clear()
# 关闭HTTP服务器
if self.http_runner:
await self.http_runner.cleanup()
logger.debug("网络服务关闭完成")
except Exception as e:
logger.error(f"关闭网络服务时出错: {str(e)}", exc_info=True)
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import logging
import logging.handlers
import os
import asyncio
from pathlib import Path
from typing import Dict, List, Callable
import json
from datetime import datetime
logger = logging.getLogger(__name__)
class LogService:
"""日志服务"""
def __init__(self, config: Dict):
self.config = config
self.log_consumers: List[Callable] = []
self.log_buffer: List[Dict] = []
self.buffer_size = 100
self.log_dir = Path("logs")
self.is_initialized = False
self._in_emit = False
# 文件数量限制
self.max_log_files_per_folder = self.config['logging']['max_log_files'] # 每个文件夹文件数量上限
# 立即初始化日志系统
self._setup_logging_sync()
def _setup_logging_sync(self):
"""同步设置日志系统 - 增强版"""
try:
# 创建日志目录
self.log_dir.mkdir(exist_ok=True)
(self.log_dir / "debug").mkdir(exist_ok=True)
(self.log_dir / "runtime").mkdir(exist_ok=True)
# 清理旧日志文件(在创建新文件之前)
self._cleanup_old_log_files()
# 生成基于时间戳和会话ID的日志文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
session_id = os.urandom(4).hex() # 生成8位随机会话ID
runtime_log_file = f"framework_{timestamp}_{session_id}.log"
debug_log_file = f"debug_{timestamp}_{session_id}.log"
# 保存当前会话的日志文件名(用于后续引用)
self.current_session_logs = {
'runtime': runtime_log_file,
'debug': debug_log_file,
'timestamp': timestamp,
'session_id': session_id
}
# 配置根日志记录器
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
# 清除现有处理器
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# 创建过滤器实例
shared_filter = self.SafeLogFilter(self)
# 控制台处理器
console_handler = logging.StreamHandler()
console_handler.setLevel(getattr(logging, self.config['logging']['level'], logging.INFO))
console_formatter = logging.Formatter(
'%(asctime)s [%(levelname)-8s] %(name)s: %(message)s',
datefmt='%H:%M:%S'
)
console_handler.setFormatter(console_formatter)
console_handler.addFilter(shared_filter)
root_logger.addHandler(console_handler)
# 文件处理器
if self.config['logging'].get('enable_file_logging', True):
file_formatter = logging.Formatter(
'%(asctime)s [%(levelname)-8s] %(name)s:%(lineno)d - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# 运行时文件处理器
runtime_handler = logging.handlers.RotatingFileHandler(
self.log_dir / "runtime" / runtime_log_file,
maxBytes=self._parse_size(self.config['logging'].get('max_file_size', '10MB')),
backupCount=self.config['logging'].get('max_log_files', 3)
)
runtime_handler.setLevel(getattr(logging, self.config['logging']['level'], logging.INFO))
runtime_handler.setFormatter(file_formatter)
runtime_handler.addFilter(shared_filter)
root_logger.addHandler(runtime_handler)
# Debug文件处理器
if self.config['logging'].get('debug_level_file', True):
debug_handler = logging.handlers.RotatingFileHandler(
self.log_dir / "debug" / debug_log_file,
maxBytes=self._parse_size(self.config['logging'].get('max_file_size', '10MB')),
backupCount=self.config['logging'].get('max_log_files', 3)
)
debug_handler.setLevel(logging.DEBUG)
debug_handler.setFormatter(file_formatter)
debug_handler.addFilter(shared_filter)
root_logger.addHandler(debug_handler)
self.is_initialized = True
logger.info(f"✅ 日志系统初始化完成 - 会话ID: {session_id}")
logger.info(f"📝 运行时日志: logs/runtime/{runtime_log_file}")
logger.info(f"🐛 调试日志: logs/debug/{debug_log_file}")
except Exception as e:
logger.error(f"日志系统设置失败: {e}")
# 回退到基础配置
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
self.is_initialized = True
def _cleanup_old_log_files(self):
"""清理旧的日志文件,保持每个文件夹文件上限"""
try:
logger.debug("开始清理旧日志文件...")
# 清理 runtime 文件夹
runtime_dir = self.log_dir / "runtime"
if runtime_dir.exists():
runtime_files = list(runtime_dir.glob("*.log"))
self._remove_old_files(runtime_files, "runtime")
# 清理 debug 文件夹
debug_dir = self.log_dir / "debug"
if debug_dir.exists():
debug_files = list(debug_dir.glob("*.log"))
self._remove_old_files(debug_files, "debug")
except Exception as e:
logger.error(f"清理旧日志文件时出错: {e}")
def _remove_old_files(self, files: List[Path], folder_name: str):
"""删除最旧的文件,直到文件数量不超过限制"""
try:
if len(files) <= self.max_log_files_per_folder:
logger.debug(f"{folder_name} 文件夹文件数量正常: {len(files)}/{self.max_log_files_per_folder}")
return
# 按修改时间排序(最旧的在前)
files_sorted = sorted(files, key=lambda x: x.stat().st_mtime)
# 计算需要删除的文件数量
files_to_remove = len(files_sorted) - self.max_log_files_per_folder
if files_to_remove > 0:
logger.info(f"清理 {folder_name} 文件夹: 删除 {files_to_remove} 个旧日志文件")
for i in range(files_to_remove):
old_file = files_sorted[i]
try:
old_file.unlink()
logger.debug(f"删除旧日志文件: {old_file.name}")
except Exception as e:
logger.error(f"删除文件失败 {old_file}: {e}")
logger.info(f"{folder_name} 文件夹清理完成: {self.max_log_files_per_folder} 个文件")
except Exception as e:
logger.error(f"删除 {folder_name} 文件夹旧文件时出错: {e}")
def cleanup_log_files(self):
"""手动清理日志文件(可以定期调用)"""
try:
logger.info("开始手动清理日志文件...")
self._cleanup_old_log_files()
logger.info("日志文件清理完成")
except Exception as e:
logger.error(f"手动清理日志文件时出错: {e}")
def get_log_file_counts(self) -> Dict[str, int]:
"""获取当前日志文件数量统计"""
try:
runtime_count = len(list((self.log_dir / "runtime").glob("*.log")))
debug_count = len(list((self.log_dir / "debug").glob("*.log")))
return {
"runtime": runtime_count,
"debug": debug_count,
"max_limit": self.max_log_files_per_folder
}
except Exception as e:
logger.error(f"获取日志文件统计时出错: {e}")
return {"runtime": 0, "debug": 0, "max_limit": self.max_log_files_per_folder}
def get_current_session_info(self) -> Dict:
"""获取当前会话的日志信息"""
return getattr(self, 'current_session_logs', {})
def _parse_size(self, size_str: str) -> int:
"""解析文件大小字符串"""
try:
units = {'B': 1, 'KB': 1024, 'MB': 1024**2, 'GB': 1024**3}
number = ''.join(filter(str.isdigit, size_str))
unit = ''.join(filter(str.isalpha, size_str)).upper()
return int(number) * units.get(unit, 1)
except Exception:
return 10 * 1024 * 1024
def add_log_consumer(self, callback: Callable):
"""添加日志消费者"""
if callback not in self.log_consumers:
self.log_consumers.append(callback)
logger.debug(f"添加日志消费者,总数: {len(self.log_consumers)}")
def emit_log(self, log_record: Dict):
"""发射日志到消费者"""
if self._in_emit:
return
self._in_emit = True
try:
# 格式化日志记录
timestamp = log_record['timestamp']
level = log_record['level']
name = log_record['name']
message = log_record['message']
# 转换时间戳
if isinstance(timestamp, (int, float)):
timestamp_str = datetime.fromtimestamp(timestamp).strftime('%H:%M:%S')
else:
timestamp_str = str(timestamp)
formatted_record = {
'timestamp': timestamp,
'timestamp_str': timestamp_str,
'level': level,
'name': name,
'message': message,
'module': log_record.get('module', ''),
'line': log_record.get('line', 0),
'formatted_message': f"{timestamp_str} [{level:8}] {name}: {message}",
'simple_message': f"{timestamp_str} [{level:8}] {message}",
'original_message': message
}
# 添加到缓冲区
self.log_buffer.append(formatted_record)
if len(self.log_buffer) > self.buffer_size:
self.log_buffer.pop(0)
# 发送给消费者
for consumer in self.log_consumers:
try:
# 检查TUI级别过滤
if hasattr(consumer, '_tui_level_filter'):
tui_level = consumer._tui_level_filter
if self._should_display_for_tui(level, tui_level):
consumer(formatted_record)
else:
# 默认发送所有日志
consumer(formatted_record)
except Exception as e:
logger.error(f"日志消费者处理出错: {e}")
except Exception as e:
logger.error(f"发射日志时出错: {e}")
finally:
self._in_emit = False
def _should_display_for_tui(self, log_level: str, tui_level: str) -> bool:
"""检查日志是否应该显示在TUI中"""
level_priority = {
'DEBUG': 10, 'INFO': 20, 'WARNING': 30, 'ERROR': 40, 'CRITICAL': 50
}
log_priority = level_priority.get(log_level, 0)
tui_priority = level_priority.get(tui_level, 0)
return log_priority >= tui_priority
def add_tui_log_consumer(self, callback: Callable, tui_level: str = "INFO"):
"""专门为TUI添加日志消费者"""
callback._tui_level_filter = tui_level
if callback not in self.log_consumers:
self.log_consumers.append(callback)
logger.debug(f"添加TUI日志消费者,级别: {tui_level}")
def get_recent_logs(self, count: int = 50) -> List[Dict]:
"""获取最近的日志"""
return self.log_buffer[-count:]
def shutdown(self):
"""关闭日志服务"""
self.log_consumers.clear()
logging.shutdown()
logger.info("日志服务已关闭")
class SafeLogFilter(logging.Filter):
"""安全的日志过滤器"""
def __init__(self, log_service):
super().__init__()
self.log_service = log_service
self._in_filter = False
def filter(self, record):
"""过滤日志记录"""
if self._in_filter:
return True
self._in_filter = True
try:
# 创建日志记录
log_record = {
'timestamp': record.created,
'name': record.name,
'level': record.levelname,
'message': record.getMessage(),
'module': record.module,
'line': record.lineno
}
# 发射日志记录
self.log_service.emit_log(log_record)
except Exception:
pass
finally:
self._in_filter = False
return True
File diff suppressed because it is too large Load Diff
+476
View File
@@ -0,0 +1,476 @@
#!/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 fmfuncs.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)}")
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import logging
import asyncio
import signal
import sys
from typing import List, Callable
from enum import Enum
logger = logging.getLogger(__name__)
class ShutdownPriority(Enum):
"""关闭优先级枚举"""
HIGHEST = 0
HIGH = 1
NORMAL = 2
LOW = 3
LOWEST = 4
class ShutdownService:
"""关闭服务 - 管理框架的优雅关闭"""
def __init__(self, service_manager):
self.service_manager = service_manager
self.shutdown_handlers: List[Callable] = []
self.is_shutting_down = False
self.shutdown_timeout = 30 # 秒
logger.debug("ShutdownService初始化开始")
# 注册信号处理
self._register_signal_handlers()
def _register_signal_handlers(self):
"""注册信号处理"""
try:
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
logger.debug("信号处理器注册完成")
except Exception as e:
logger.error(f"注册信号处理器时出错: {str(e)}", exc_info=True)
def _signal_handler(self, signum, frame):
"""信号处理函数"""
try:
signal_name = signal.Signals(signum).name
logger.info(f"接收到信号: {signal_name}")
asyncio.create_task(self.initiate_shutdown())
except Exception as e:
logger.error(f"处理信号时出错: {str(e)}", exc_info=True)
sys.exit(1)
def register_shutdown_handler(self, handler: Callable, priority: ShutdownPriority = ShutdownPriority.NORMAL):
"""注册关闭处理器"""
try:
self.shutdown_handlers.append((priority.value, handler))
# 按优先级排序
self.shutdown_handlers.sort(key=lambda x: x[0])
logger.debug(f"注册关闭处理器,优先级: {priority.name}, 当前总数: {len(self.shutdown_handlers)}")
except Exception as e:
logger.error(f"注册关闭处理器时出错: {str(e)}", exc_info=True)
async def initiate_shutdown(self, reason: str = "正常关闭"):
"""发起关闭流程"""
try:
if self.is_shutting_down:
logger.warning("关闭流程已在进行中")
return
self.is_shutting_down = True
logger.info(f"开始框架关闭流程 - 原因: {reason}")
# 执行关闭处理器
await self._execute_shutdown_handlers()
# 关闭服务管理器
self.service_manager.shutdown_all()
logger.info("框架关闭完成")
# 退出程序
sys.exit(0)
except Exception as e:
logger.error(f"关闭流程出错: {str(e)}", exc_info=True)
sys.exit(1)
async def _execute_shutdown_handlers(self):
"""执行关闭处理器"""
try:
logger.debug(f"开始执行 {len(self.shutdown_handlers)} 个关闭处理器")
for priority, handler in self.shutdown_handlers:
try:
handler_name = handler.__name__ if hasattr(handler, '__name__') else str(handler)
logger.debug(f"执行关闭处理器: {handler_name} (优先级: {priority})")
if asyncio.iscoroutinefunction(handler):
await asyncio.wait_for(handler(), timeout=self.shutdown_timeout)
else:
# 在事件循环中运行同步函数
await asyncio.get_event_loop().run_in_executor(None, handler)
logger.debug(f"关闭处理器完成: {handler_name}")
except asyncio.TimeoutError:
logger.error(f"关闭处理器超时: {handler_name}")
except Exception as e:
logger.error(f"关闭处理器出错 {handler_name}: {str(e)}", exc_info=True)
logger.debug("所有关闭处理器执行完成")
except Exception as e:
logger.error(f"执行关闭处理器时出错: {str(e)}", exc_info=True)
def emergency_shutdown(self):
"""紧急关闭"""
try:
logger.critical("执行紧急关闭")
sys.exit(1)
except Exception as e:
logger.critical(f"紧急关闭时出错: {str(e)}")
os._exit(1)
def shutdown(self):
"""关闭关闭服务"""
try:
logger.info("关闭ShutdownService")
self.shutdown_handlers.clear()
logger.debug("ShutdownService关闭完成")
except Exception as e:
logger.error(f"关闭ShutdownService时出错: {str(e)}", exc_info=True)
+806
View File
@@ -0,0 +1,806 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import logging
import sys
import io
import time
from textual.app import App
from textual.containers import Container, ScrollableContainer
from textual.widgets import Static, Input, Header, Footer
from textual.reactive import reactive
from typing import List, Dict
import asyncio
from datetime import datetime
logger = logging.getLogger(__name__)
class SystemExitGraceful(Exception):
"""优雅的系统退出异常"""
pass
class LogDisplay(Static):
"""日志显示组件 - 直接捕获所有日志输出"""
def __init__(self):
super().__init__("日志显示区域 - 等待日志输入...")
self.log_lines: List[str] = []
self.max_lines = 500
self.auto_scroll_enabled = True # 启用自动滚动
# 保存原始的logging处理器和格式器
self.original_handlers = []
self.original_formatters = {}
logger.debug("LogDisplay初始化完成")
def start_capture(self):
"""开始捕获所有日志输出"""
try:
# 获取根日志记录器
root_logger = logging.getLogger()
# 保存原始处理器和它们的格式器
self.original_handlers = root_logger.handlers.copy()
for handler in self.original_handlers:
self.original_formatters[handler] = handler.formatter
# 清除所有现有处理器
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# 添加我们的自定义处理器
custom_handler = self.TUILogHandler(self)
custom_handler.setLevel(logging.DEBUG) # 捕获所有级别的日志
# 强制使用包含彩色级别的格式器
formatter = self.ColoredFormatter(
'%(asctime)s %(levelname_color)s %(name)s: %(message)s',
datefmt='%H:%M:%S'
)
custom_handler.setFormatter(formatter)
root_logger.addHandler(custom_handler)
# 同时重定向stdout和stderr作为备份
self.original_stdout = sys.stdout
self.original_stderr = sys.stderr
sys.stdout = self.TUIOutput(self)
sys.stderr = self.TUIOutput(self, is_error=True)
print("✅ TUI日志捕获已启动 - 捕获所有日志输出")
except Exception as e:
logger.error(f"启动日志捕获失败: {e}")
def stop_capture(self):
"""停止捕获输出"""
try:
# 恢复logging处理器
root_logger = logging.getLogger()
# 移除我们的处理器
for handler in root_logger.handlers[:]:
if hasattr(handler, 'log_display'):
root_logger.removeHandler(handler)
# 恢复原始处理器和格式器
for handler in self.original_handlers:
# 恢复格式器
if handler in self.original_formatters:
handler.setFormatter(self.original_formatters[handler])
root_logger.addHandler(handler)
# 恢复stdout和stderr
sys.stdout = self.original_stdout
sys.stderr = self.original_stderr
print("🛑 TUI日志捕获已停止")
except Exception as e:
logger.error(f"停止日志捕获失败: {e}")
def add_log_line(self, line: str):
"""添加日志行到TUI显示"""
try:
# 添加到缓冲区
self.log_lines.append(line)
if len(self.log_lines) > self.max_lines:
self.log_lines.pop(0)
# 更新显示
display_content = "\n".join(self.log_lines)
self.update(display_content)
# 自动滚动到底部
if self.auto_scroll_enabled:
self.scroll_to_bottom()
except Exception as e:
# 如果TUI更新失败,回退到原始输出
if hasattr(self, 'original_stdout'):
self.original_stdout.write(f"TUI日志显示错误: {e}\n")
def scroll_to_bottom(self):
"""滚动到底部"""
try:
# 获取父容器(ScrollableContainer
parent = self.parent
if parent and hasattr(parent, 'scroll_end'):
parent.scroll_end()
except Exception as e:
# 忽略滚动错误,不影响主要功能
pass
def toggle_auto_scroll(self, enabled: bool = None):
"""切换自动滚动状态"""
if enabled is None:
self.auto_scroll_enabled = not self.auto_scroll_enabled
else:
self.auto_scroll_enabled = enabled
logger.debug(f"日志自动滚动: {'启用' if self.auto_scroll_enabled else '禁用'}")
return self.auto_scroll_enabled
class ColoredFormatter(logging.Formatter):
"""带颜色的日志格式器 - 增强版"""
# ANSI颜色代码
COLORS = {
'DEBUG': '\033[36m', # 青色 - DEBUG信息
'INFO': '\033[32m', # 绿色 - 正常信息
'WARNING': '\033[33m', # 黄色 - 警告信息
'ERROR': '\033[31m', # 红色 - 错误信息
'CRITICAL': '\033[35m', # 紫色 - 严重错误
'RESET': '\033[0m' # 重置颜色
}
# 级别显示宽度
LEVEL_WIDTH = 8
def format(self, record):
"""格式化日志记录,为级别添加颜色"""
try:
# 为级别添加颜色和固定宽度
levelname = record.levelname
if levelname in self.COLORS:
# 添加颜色并保持固定宽度
colored_level = f"{self.COLORS[levelname]}[{levelname:<{self.LEVEL_WIDTH}}]{self.COLORS['RESET']}"
record.levelname_color = colored_level
else:
record.levelname_color = f"[{levelname:<{self.LEVEL_WIDTH}}]"
# 调用父类格式化方法
formatted_message = super().format(record)
return formatted_message
except Exception:
# 如果格式化失败,返回简单格式
return f"{record.asctime} [{record.levelname}] {record.name}: {record.getMessage()}"
class TUILogHandler(logging.Handler):
"""自定义logging处理器,同时输出到终端和TUI"""
def __init__(self, log_display):
super().__init__()
self.log_display = log_display
def emit(self, record):
"""处理日志记录"""
try:
# 格式化日志记录(使用我们的格式器)
formatted_message = self.format(record)
# 输出到原始终端(通过原始处理器,但使用我们的格式器)
for original_handler in self.log_display.original_handlers:
if original_handler.level <= record.levelno:
# 临时使用我们的格式器来确保级别显示一致
original_handler.setFormatter(self.formatter)
original_handler.emit(record)
# 恢复原始格式器
original_formatter = self.log_display.original_formatters.get(original_handler)
if original_formatter:
original_handler.setFormatter(original_formatter)
# 添加到TUI显示
self.log_display.add_log_line(formatted_message)
except Exception as e:
# 如果处理失败,使用简单格式
try:
simple_message = f"{datetime.now().strftime('%H:%M:%S')} [{record.levelname:8}] {record.name}: {record.getMessage()}"
self.log_display.add_log_line(simple_message)
except:
pass
class TUIOutput(io.TextIOBase):
"""自定义输出流,捕获print等输出"""
def __init__(self, log_display, is_error=False):
self.log_display = log_display
self.is_error = is_error
self.original_stream = sys.stderr if is_error else sys.stdout
# 颜色定义
self.COLORS = {
'INFO': '\033[32m', # 绿色
'ERROR': '\033[31m', # 红色
'RESET': '\033[0m' # 重置颜色
}
def write(self, text):
"""写入文本"""
try:
# 写入到原始终端
self.original_stream.write(text)
self.original_stream.flush()
# 如果文本不是空的,添加到TUI
if text.strip():
# 添加简单的时间戳和级别
timestamp = datetime.now().strftime('%H:%M:%S')
level = "ERROR" if self.is_error else "INFO"
# 添加颜色
if level in self.COLORS:
colored_level = f"{self.COLORS[level]}[{level}]{self.COLORS['RESET']}"
else:
colored_level = f"[{level}]"
# 分割多行文本
lines = text.split('\n')
for line in lines:
if line.strip(): # 忽略空行
log_line = f"{timestamp} {colored_level} {line.strip()}"
self.log_display.add_log_line(log_line)
return len(text)
except Exception:
# 如果TUI处理失败,只输出到终端
self.original_stream.write(text)
self.original_stream.flush()
return len(text)
def flush(self):
"""刷新缓冲区"""
self.original_stream.flush()
def close(self):
"""关闭流"""
pass
class MessageDisplay(Static):
"""消息显示组件 - 增强版"""
def __init__(self):
super().__init__("消息区域")
self.current_messages: List[Dict] = []
self.max_messages = 200 # 更大的消息容量
self.auto_scroll_enabled = True # 启用自动滚动
# ANSI颜色代码
self.COLORS = {
'INFO': '\033[37m', # 黑底白字
'DEBUG': '\033[36m', # 青色 - DEBUG信息
'SUCCESS': '\033[32m', # 绿色 - 正常信息
'WARNING': '\033[33m', # 黄色 - 警告信息
'ERROR': '\033[31m', # 红色 - 错误信息
'COMMAND': '\033[40;37m', # 灰底白字 - 命令信息
'RESET': '\033[0m' # 重置颜色
}
self.message_types = {
'info': {'icon': f"{self.COLORS['INFO']}[INFO ]{self.COLORS['RESET']}", 'color': 'white'},
'success': {'icon': f"{self.COLORS['SUCCESS']}[SUCCESS]{self.COLORS['RESET']}", 'color': 'green'},
'error': {'icon': f"{self.COLORS['ERROR']}[ERROR ]{self.COLORS['RESET']}", 'color': 'red'},
'warning': {'icon': f"{self.COLORS['WARNING']}[WARNING]{self.COLORS['RESET']}", 'color': 'yellow'},
'debug': {'icon': f"{self.COLORS['DEBUG']}[DEBUG ]{self.COLORS['RESET']}", 'color': 'cyan'},
'command': {'icon': f"{self.COLORS['COMMAND']}[COMMAND]{self.COLORS['RESET']}", 'color': 'meow'}
}
logger.debug("MessageDisplay初始化完成")
def add_message(self, message: str, msg_type: str = "info", persistent: bool = False):
"""添加消息 - 支持多行消息"""
try:
# 分割多行消息为单独的消息
lines = message.strip().split('\n')
for line in lines:
if line.strip(): # 忽略空行
message_data = {
"text": line.strip(),
"type": msg_type,
"persistent": persistent,
"timestamp": asyncio.get_event_loop().time(),
"display_time": datetime.now().strftime('%H:%M:%S')
}
self.current_messages.append(message_data)
# 智能消息管理
self._manage_messages()
self._update_display()
except Exception as e:
logger.error(f"添加消息时出错: {str(e)}")
def reset_display(self):
"""重置显示状态"""
try:
# 清空所有消息
self.current_messages.clear()
# 更新显示
self.update("消息区域已重置")
# 强制刷新
self.refresh()
except Exception as e:
logger.error(f"重置消息显示时出错: {str(e)}")
def _manage_messages(self):
"""智能管理消息数量"""
try:
# 计算非持久化消息的数量
non_persistent_messages = [msg for msg in self.current_messages if not msg['persistent']]
if len(non_persistent_messages) > self.max_messages:
# 移除最旧的非持久化消息
for i, msg in enumerate(self.current_messages):
if not msg['persistent']:
self.current_messages.pop(i)
break
except Exception as e:
logger.error(f"管理消息时出错: {str(e)}")
def _update_display(self):
"""更新显示 - 带时间戳的格式化消息"""
try:
if not self.current_messages:
display_text = "📭 暂无消息"
else:
display_text = []
for msg in self.current_messages:
# 获取消息类型配置
msg_config = self.message_types.get(msg['type'], self.message_types['info'])
icon = msg_config['icon']
# 构建显示行
persistent_mark = "🔒 " if msg['persistent'] else ""
time_stamp = f"[{msg['display_time']}] " if len(self.current_messages) > 1 else ""
display_line = f"{time_stamp}{persistent_mark}{icon} {msg['text']}"
display_text.append(display_line)
display_text = "\n".join(display_text)
self.update(display_text)
# 自动滚动到底部
if self.auto_scroll_enabled:
self.scroll_to_bottom()
except Exception as e:
logger.error(f"更新消息显示时出错: {str(e)}")
def scroll_to_bottom(self):
"""滚动到底部"""
try:
# 获取父容器(ScrollableContainer
parent = self.parent
if parent and hasattr(parent, 'scroll_end'):
parent.scroll_end()
except Exception as e:
# 忽略滚动错误,不影响主要功能
pass
def toggle_auto_scroll(self, enabled: bool = None):
"""切换自动滚动状态"""
if enabled is None:
self.auto_scroll_enabled = not self.auto_scroll_enabled
else:
self.auto_scroll_enabled = enabled
logger.debug(f"消息自动滚动: {'启用' if self.auto_scroll_enabled else '禁用'}")
return self.auto_scroll_enabled
class TUIFramework(App):
"""TUI框架应用"""
def __init__(self, config, log_service, command_service):
super().__init__()
self.config = config
self.log_service = log_service
self.command_service = command_service
self.log_display = LogDisplay()
self.message_display = MessageDisplay()
self.command_input = None
self.CSS = self._generate_css()
def _generate_css(self):
"""根据配置动态生成CSS - 增强版"""
try:
tui_config = self.config.get('tui', {})
layout_config = tui_config.get('layout', {})
styles_config = tui_config.get('styles', {})
# 获取布局配置,使用默认值
grid_rows = layout_config.get('grid_rows', '7fr 2fr 1fr')
# 获取样式配置,使用默认值
log_area_style = styles_config.get('log_area', 'border: solid green; overflow-y: auto;')
message_area_style = styles_config.get('message_area', 'border: solid yellow; overflow-y: auto;')
input_area_style = styles_config.get('input_area', 'border: solid red;')
css = f"""
Screen {{
layout: grid;
grid-size: 1 3;
grid-rows: {grid_rows};
}}
#log-area {{
{log_area_style}
overflow-y: auto;
scrollbar-size: 1 1;
}}
#message-area {{
{message_area_style}
overflow-y: auto;
scrollbar-size: 1 1;
}}
#input-area {{
{input_area_style}
}}
/* 自定义滚动条样式 */
ScrollableContainer {{
scrollbar-color: #666 #222;
scrollbar-color-hover: #888 #333;
overflow-y: auto;
}}
/* 确保内容正确换行 */
Static {{
width: 100%;
content-align: left middle;
overflow-y: auto;
}}
"""
logger.debug(f"生成的TUI CSS:\n{css}")
return css
except Exception as e:
logger.error(f"生成TUI CSS时出错: {str(e)}", exc_info=True)
# 返回默认CSS作为回退
return """
Screen {
layout: grid;
grid-size: 1 3;
grid-rows: 7fr 2fr 1fr;
}
#log-area {
border: solid green;
overflow-y: auto;
scrollbar-size: 1 1;
}
#message-area {
border: solid yellow;
overflow-y: auto;
scrollbar-size: 1 1;
}
#input-area {
border: solid red;
}
/* 自定义滚动条样式 */
ScrollableContainer {
scrollbar-color: #666 #222;
scrollbar-color-hover: #888 #333;
overflow-y: auto;
}
/* 确保内容正确换行 */
Static {
width: 100%;
content-align: left middle;
overflow-y: auto;
}
"""
def compose(self):
"""组合界面"""
yield Header()
yield ScrollableContainer(
self.log_display,
id="log-area"
)
yield ScrollableContainer(
self.message_display,
id="message-area"
)
self.command_input = Input(placeholder="输入指令...", id="command-input")
yield Container(
self.command_input,
id="input-area"
)
yield Footer()
async def on_mount(self):
"""挂载完成事件"""
try:
# 开始捕获所有输出
self.log_display.start_capture()
# 设置输入框焦点
if self.command_input:
self.command_input.focus()
# 显示欢迎消息
self.show_message("🐱 SenSu TUI 已就绪!输入 'help' 查看命令\n", "info")
print("✅ TUI已启动,开始捕获所有输出")
except Exception as e:
print(f"❌ TUI挂载时出错: {str(e)}")
async def on_input_submitted(self, event):
"""输入提交事件"""
try:
if hasattr(event, 'input') and event.input.id == "command-input":
command = event.value
event.input.value = "" # 清空输入框
if command.strip():
print(f"执行命令: {command}")
# 在消息区域显示正在处理
self.show_message(f"执行命令: {command}", "command")
# 发送到指令服务处理
result = await self.command_service.process_command(command, "tui")
# 显示命令结果
if result:
self.show_message(f"结果: {result}", "success")
else:
self.show_message("命令执行完成", "success")
except Exception as e:
print(f"❌ 指令处理错误: {str(e)}")
self.show_message(f"指令处理错误: {str(e)}", "error")
def show_message(self, message: str, msg_type: str = "info", persistent: bool = False):
"""显示消息"""
try:
self.message_display.add_message(message, msg_type, persistent)
except Exception as e:
print(f"❌ 显示TUI消息时出错: {str(e)}")
def clear_messages(self, clear_persistent: bool = False):
"""清空消息区域"""
try:
self.message_display.clear_messages(clear_persistent)
except Exception as e:
print(f"❌ 清空消息时出错: {str(e)}")
async def action_quit(self):
"""重写退出动作 - 最佳方案:优雅关闭"""
try:
logger.info("🐱 TUI接收到退出信号,开始关闭流程")
# 显示关闭消息
self.show_message("🐱 正在关闭框架...", "info", persistent=True)
# 停止捕获输出
self.log_display.stop_capture()
# 使用异步任务来优雅关闭,避免阻塞
asyncio.create_task(self._async_graceful_shutdown())
except Exception as e:
logger.error(f"TUI退出处理时出错: {str(e)}")
# 紧急退出
import os
os._exit(0)
async def _async_graceful_shutdown(self):
"""异步优雅关闭"""
try:
# 给一点时间显示消息
self.show_message("🐱 3...", "info", persistent=True)
await asyncio.sleep(1)
self.show_message("🐱 2..", "info", persistent=True)
await asyncio.sleep(1)
self.show_message("🐱 1.", "info", persistent=True)
await asyncio.sleep(1)
logger.info("🐱 执行异步关闭")
logger.debug("使用事件循环停止")
# 获取当前事件循环
loop = asyncio.get_event_loop()
# 停止所有运行中的任务(除了当前任务)
tasks = [t for t in asyncio.all_tasks(loop) if t is not asyncio.current_task()]
if tasks:
logger.debug(f"取消 {len(tasks)} 个运行中的任务")
for task in tasks:
task.cancel()
# 等待任务取消完成
await asyncio.gather(*tasks, return_exceptions=True)
# 停止事件循环
loop.stop()
logger.info("🐱 事件循环已停止,框架关闭完成")
except Exception as e:
logger.error(f"异步关闭失败: {str(e)}")
# 最后的手段
import os
os._exit(0)
def shutdown(self):
"""关闭TUI"""
try:
# 停止捕获输出
self.log_display.stop_capture()
self.action_quit()
self.exit()
print("🛑 TUI已关闭")
except Exception as e:
print(f"❌ 关闭TUI时出错: {str(e)}")
class TuiService:
"""TUI服务"""
def __init__(self, config: Dict, log_service, command_service):
self.config = config
self.log_service = log_service
self.command_service = command_service
self.tui_app = None
self._message_queue = asyncio.Queue()
self._message_processor_task = None
async def start(self):
"""启动TUI"""
try:
if not self.config.get('tui', {}).get('enabled', True):
print("TUI已禁用")
return
print("启动TUI服务")
self.tui_app = TUIFramework(self.config, self.log_service, self.command_service)
# 设置动态标题
self._setup_title()
# 启动消息处理任务
self._message_processor_task = asyncio.create_task(self._process_message_queue())
# 在后台运行TUI
asyncio.create_task(self._run_tui())
except Exception as e:
print(f"启动TUI服务时出错: {str(e)}")
raise
def toggle_auto_scroll(self, target: str = "all", enabled: bool = None):
"""切换自动滚动状态"""
try:
if not self.tui_app:
return "❌ TUI未启动"
result = []
if target in ["all", "log"]:
log_state = self.tui_app.log_display.toggle_auto_scroll(enabled)
result.append(f"📜 日志自动滚动: {'✅ 启用' if log_state else '❌ 禁用'}")
if target in ["all", "message"]:
msg_state = self.tui_app.message_display.toggle_auto_scroll(enabled)
result.append(f"💬 消息自动滚动: {'✅ 启用' if msg_state else '❌ 禁用'}")
return "\n".join(result)
except Exception as e:
return f"❌ 切换自动滚动失败: {str(e)}"
def scroll_to_bottom(self, target: str = "all"):
"""手动滚动到底部"""
try:
if not self.tui_app:
return "❌ TUI未启动"
result = []
if target in ["all", "log"]:
self.tui_app.log_display.scroll_to_bottom()
result.append("📜 日志区域已滚动到底部")
if target in ["all", "message"]:
self.tui_app.message_display.scroll_to_bottom()
result.append("💬 消息区域已滚动到底部")
return "\n".join(result)
except Exception as e:
return f"❌ 滚动到底部失败: {str(e)}"
async def _process_message_queue(self):
"""处理消息队列,避免消息过多导致界面卡顿"""
try:
while True:
# 从队列中获取消息
message_data = await self._message_queue.get()
if message_data is None: # 停止信号
break
message, msg_type, persistent = message_data
# 显示消息
if self.tui_app:
self.tui_app.show_message(message, msg_type, persistent)
# 小延迟避免消息过快
await asyncio.sleep(0.05)
except asyncio.CancelledError:
logger.debug("消息处理任务被取消")
except Exception as e:
logger.error(f"消息处理任务出错: {str(e)}")
def _setup_title(self):
"""设置TUI标题"""
try:
framework_config = self.config.get('framework', {})
name = framework_config.get('name', 'SenSu')
version = framework_config.get('version', 'Unknown')
debug_mode = framework_config.get('debug', False)
# 构建标题
title_parts = [f"🐱 {name} Ver.{version}"]
if debug_mode:
title_parts.append("[DEBUG]")
self.tui_app.title = " ".join(title_parts)
self.tui_app.sub_title = "Based DreamSu Framework"
logger.debug(f"设置TUI标题: {self.tui_app.title}")
logger.debug(f"设置TUI副标题: {self.tui_app.sub_title}")
except Exception as e:
logger.error(f"设置TUI标题时出错: {e}")
self.tui_app.title = "🐱 SenSu - Based DreamSu Framework" # 默认标题
async def _run_tui(self):
"""运行TUI"""
try:
await self.tui_app.run_async()
except Exception as e:
print(f"运行TUI时出错: {str(e)}")
def show_message(self, message: str, msg_type: str = "info", persistent: bool = False):
"""显示消息"""
try:
if self.tui_app:
# 将消息放入队列,由后台任务处理
self._message_queue.put_nowait((message, msg_type, persistent))
except Exception as e:
print(f"通过TUI服务显示消息时出错: {str(e)}")
def shutdown(self):
"""关闭TUI服务"""
try:
if self.tui_app:
self.tui_app.shutdown()
print("TUI服务已关闭")
except Exception as e:
print(f"关闭TUI服务时出错: {str(e)}")
+2
View File
@@ -0,0 +1,2 @@
from .manager import WebPanelManager
__all__ = ["WebPanelManager"]
+31
View File
@@ -0,0 +1,31 @@
import logging
logger = logging.getLogger(__name__)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import functools
from aiohttp import web
def panel_auth(handler):
"""面板专用鉴权装饰器(替代子应用中间件)"""
@functools.wraps(handler)
async def wrapper(request, *args, **kwargs):
token = request.cookies.get("panel_token")
if not token and request.headers.get("Authorization", "").startswith("Bearer "):
token = request.headers["Authorization"].split(" ", 1)[1]
auth_svc = request.app.get('auth_service')
is_valid = False
if token and auth_svc:
try:
v = await auth_svc.validate_token(token)
is_valid = bool(v)
except: pass
elif not auth_svc:
is_valid = False # 认证不可用时拒绝
if not is_valid:
return web.json_response({"error": "未认证或会话过期"}, status=401)
return await handler(request, *args, **kwargs)
return wrapper
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import logging
from pathlib import Path
from aiohttp import web
from .routes import auth, status, plugins, commands, logs
logger = logging.getLogger(__name__)
class WebPanelManager:
def __init__(self, config: dict, service_manager):
panel_cfg = config.get('panel', {}).get('entrance', {})
self.base_path = panel_cfg.get('path', '/panel')
self.panel_user = os.environ.get('SENSU_PANEL_USER', panel_cfg.get('username', 'admin'))
self.panel_pass = os.environ.get('SENSU_PANEL_PASS', panel_cfg.get('password', 'admin'))
self.base_path = f"/{self.base_path.strip('/')}"
self.sm = service_manager
self.project_root = Path(__file__).resolve().parent.parent.parent
async def start(self):
internet = self.sm.get_service("internet")
if not internet or not internet.http_app:
logger.error("❌ 网络服务未就绪,无法注册面板路由")
return False
app = internet.http_app
logger.info(f"🌐 向网络服务注册面板路由 (前缀: {self.base_path})...")
# 依赖注入
app['service_manager'] = self.sm
app['auth_service'] = self.sm.get_service("auth")
app['log_service'] = self.sm.get_service("log")
app['panel_config'] = {
'username': self.panel_user,
'password': self.panel_pass,
'index_path': self.project_root / "static" / "web_panel" / "index.html",
'home_path': self.project_root / "static" / "web_panel" / "home.html" # 🟢 新增
}
# 注册静态文件
# URL 前缀: /SenSu/static/ -> 物理路径: .../static/web_panel/
static_dir = self.project_root / "static" / "web_panel"
if static_dir.exists():
app.router.add_static(f'{self.base_path}/static/', path=str(static_dir))
logger.info(f"📂 静态资源已挂载: {self.base_path}/static/")
else:
logger.warning(f"⚠️ 静态目录缺失: {static_dir}")
# 注册首页 (登录页)
app.router.add_get(self.base_path, self._redirect_slash)
app.router.add_get(f'{self.base_path}/', self._serve_index)
# 🟢 新增: 注册面板主页 (/SenSu/home.html -> home.html)
app.router.add_get(f'{self.base_path}/home.html', self._serve_home)
# 注册 API 路由
auth.setup_routes(app, self.base_path)
status.setup_routes(app, self.base_path)
plugins.setup_routes(app, self.base_path)
commands.setup_routes(app, self.base_path)
logs.setup_routes(app, self.base_path)
# 注册日志广播
ls = self.sm.get_service("log")
if ls and hasattr(ls, 'add_log_consumer'):
ls.add_log_consumer(logs.broadcast_log)
logger.info("📡 日志广播已连接")
logger.info(f"✅ 面板路由注册完成 (复用原有网络服务路由器)")
return True
async def _redirect_slash(self, req):
return web.HTTPFound(f'{self.base_path}/')
async def _serve_index(self, req):
"""提供登录页"""
path = req.app['panel_config']['index_path']
if path.exists(): return web.FileResponse(path)
return web.Response(text=f"❌ 找不到 index.html\n路径: {path}", status=404)
async def _serve_home(self, req):
"""提供面板主页"""
path = req.app['panel_config']['home_path']
if path.exists(): return web.FileResponse(path)
return web.Response(text=f"❌ 找不到 home.html\n路径: {path}", status=404)
+39
View File
@@ -0,0 +1,39 @@
from aiohttp import web
from .utils.response import json_res
import logging
logger = logging.getLogger(__name__)
# 白名单 (相对于子应用的路径)
WHITE_LIST = {
"/api/login",
"/api/auth/status",
"/",
"/static/"
}
async def auth_middleware(app, handler):
async def mid(req):
path = req.path
# 检查白名单
if any(path.startswith(w) for w in WHITE_LIST):
return await handler(req)
# 提取 Token
token = req.cookies.get("panel_token")
if not token and req.headers.get("Authorization", "").startswith("Bearer "):
token = req.headers["Authorization"].split(" ", 1)[1]
valid, info = False, {}
# 验证 Token (简单内存验证,后期可接 Redis/DB)
session_store = app.get('session_store', {})
if token and token in session_store:
valid, info = True, session_store[token]
if valid:
req['user'] = info
return await handler(req)
return json_res({"error": "未认证"}, 401)
return mid
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import secrets
import logging
from aiohttp import web
from ..utils.auth import panel_auth
logger = logging.getLogger(__name__)
# 全局 Session 存储 (内存型)
# 格式: { "token_string": { "username": "...", "perms": [...] } }
PANEL_SESSION_STORE = {}
def setup_routes(app, prefix=''):
"""注册面板认证路由"""
# 🟢 关键:将 Session Store 挂载到 app,供拦截器读取
app['panel_session_store'] = PANEL_SESSION_STORE
# 路由注册
app.router.add_post(f'{prefix}/api/login', handle_login)
# 退出和状态检查都需要拦截
app.router.add_post(f'{prefix}/api/logout', panel_auth(handle_logout))
app.router.add_get(f'{prefix}/api/auth/status', panel_auth(handle_auth_status))
async def handle_login(req):
"""处理面板登录"""
try:
data = await req.json()
username = data.get('username')
password = data.get('password')
cfg = req.app.get('panel_config', {})
cfg_user = cfg.get('username', 'admin')
cfg_pass = cfg.get('password', 'admin')
# 校验配置中的账号密码
if username == cfg_user and password == cfg_pass:
# 登录成功:生成 Token
token = secrets.token_hex(16)
# 写入 Session Store
user_info = {
"username": username,
"perms": ["admin"],
"login_time": __import__('time').time()
}
PANEL_SESSION_STORE[token] = user_info
logger.info(f"✅ 面板登录成功: {username} (Session: {token[:4]}...)")
resp = web.json_response({"success": True, "username": username})
# 设置 Cookie
resp.set_cookie("panel_token", token, max_age=259200, httponly=True, samesite="Lax")
return resp
else:
logger.warning(f"❌ 面板登录失败: 用户 {username} 密码错误")
return web.json_response({"success": False, "msg": "用户名或密码错误"}, status=401)
except Exception as e:
logger.error(f"登录异常: {e}")
return web.json_response({"error": str(e)}, status=500)
async def handle_logout(req):
"""处理退出登录"""
token = req.cookies.get("panel_token")
if token and token in PANEL_SESSION_STORE:
del PANEL_SESSION_STORE[token]
logger.info(f"👋 用户退出登录")
resp = web.json_response({"success": True})
resp.del_cookie("panel_token")
return resp
async def handle_auth_status(req):
"""获取当前认证状态 (被 panel_auth 拦截,能进来说明已认证)"""
user = req.get('user', {})
return web.json_response({
"authenticated": True,
"username": user.get("username", "Unknown"),
"perms": user.get("perms", [])
})
+15
View File
@@ -0,0 +1,15 @@
from aiohttp import web
from ..utils.auth import panel_auth
def setup_routes(app, prefix=''):
app.router.add_post(f'{prefix}/api/command', panel_auth(exec_cmd))
async def exec_cmd(req):
d = await req.json()
cs = req.app.get('service_manager').get_service("command")
if not cs: return web.json_response({"error": "Missing"}, 503)
try:
res = await cs.execute_command(d.get('command',''))
return web.json_response({"success": True, "output": str(res)})
except Exception as e:
return web.json_response({"success": False, "error": str(e)})
+30
View File
@@ -0,0 +1,30 @@
import json, asyncio
from aiohttp import web
from ..utils.auth import panel_auth
active_ws = set()
def setup_routes(app, prefix=''):
app.router.add_get(f'{prefix}/api/logs/ws', panel_auth(ws_handler))
async def ws_handler(req):
ws = web.WebSocketResponse(heartbeat=30.0)
await ws.prepare(req)
active_ws.add(ws)
try:
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
d = json.loads(msg.data)
if d.get('action') == 'set_level':
ls = req.app.get('log_service')
if ls: ls.set_level(d.get('level','INFO'))
finally: active_ws.discard(ws)
return ws
def broadcast_log(log_record):
if not active_ws: return
payload = json.dumps({"type":"log", "level":log_record.get('level','INFO'),
"message":log_record.get('simple_message',''), "timestamp":log_record.get('timestamp',0)})
for ws in list(active_ws):
if not ws.closed: asyncio.ensure_future(ws.send_str(payload))
else: active_ws.discard(ws)
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import logging
from aiohttp import web
from ..utils.auth import panel_auth
logger = logging.getLogger(__name__)
def setup_routes(app, prefix=''):
app.router.add_get(f'{prefix}/api/plugins', panel_auth(list_plugins))
app.router.add_post(f'{prefix}/api/plugins/{{name}}/{{action}}', panel_auth(manage_plugin))
app.router.add_get(f'{prefix}/api/plugins/{{name}}/perms', panel_auth(get_perms))
app.router.add_post(f'{prefix}/api/plugins/{{name}}/perms', panel_auth(set_perms))
async def list_plugins(req):
sm = req.app.get('service_manager')
if not sm:
return web.json_response({"error": "Service Manager 未初始化"}, status=503)
ps = sm.get_service("plugin")
if not ps:
return web.json_response({"plugins": []})
data = []
for name, info in ps.plugin_info.items():
data.append({
"name": name,
"version": getattr(info, 'version', '?'),
"running": name in ps.plugins,
"enabled": True
})
return web.json_response({"plugins": data})
async def manage_plugin(req):
sm = req.app.get('service_manager')
if not sm: return web.json_response({"error": "SM Missing"}, 503)
name = req.match_info['name']
action = req.match_info['action']
ps = sm.get_service("plugin")
if not ps: return web.json_response({"error": "Plugin Service Missing"}, 503)
try:
if action in ('disable', 'unload'):
await ps.unload_plugin(name)
elif action == 'enable':
await ps.load_plugin(name)
elif action == 'reload':
await ps.unload_plugin(name)
await ps.load_plugin(name)
return web.json_response({"success": True, "msg": "操作成功"})
except Exception as e:
logger.error(f"插件操作失败: {e}")
return web.json_response({"success": False, "error": str(e)})
async def get_perms(req):
return web.json_response({"plugin": req.match_info['name'], "permissions": ["read", "write"]})
async def set_perms(req):
return web.json_response({"success": True})
+27
View File
@@ -0,0 +1,27 @@
import time
from aiohttp import web
from ..utils.auth import panel_auth
from ..utils.system_info import SystemInfoCollector
collector = SystemInfoCollector()
def setup_routes(app, prefix=''):
app.router.add_get(f'{prefix}/api/framework', panel_auth(get_framework))
app.router.add_get(f'{prefix}/api/system', panel_auth(get_system))
async def get_framework(req):
sm = req.app.get('service_manager')
if not sm: return web.json_response({"error": "Missing"}, 500)
ps = sm.get_service("plugin")
# 🟢 修复:使用 sm.start_time 属性
uptime = time.time() - getattr(sm, 'start_time', time.time())
return web.json_response({
"version": "Alpha_0.2.0",
"uptime": int(uptime), # 取整秒
"plugins": len(ps.plugins) if ps else 0
})
async def get_system(req):
return web.json_response(collector.get_all())
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import functools
from aiohttp import web
def panel_auth(handler):
"""面板专用鉴权装饰器:基于面板自有的 Session Store 验证"""
@functools.wraps(handler)
async def wrapper(request, *args, **kwargs):
# 1. 获取 Token
token = request.cookies.get("panel_token")
if not token and request.headers.get("Authorization", "").startswith("Bearer "):
token = request.headers["Authorization"].split(" ", 1)[1]
is_valid = False
# 2. 从面板 Session Store 验证
session_store = request.app.get('panel_session_store', {})
if token and token in session_store:
is_valid = True
# 验证通过,将用户信息注入 request 供后续使用
request['user'] = session_store[token]
# 3. 拦截逻辑 (不再依赖外部 AuthService,确保安全隔离)
if not is_valid:
# 返回 401 并附带提示,前端可据此判断状态
return web.json_response({
"error": "未认证或会话已过期",
"status": 401
}, status=401)
return await handler(request, *args, **kwargs)
return wrapper
+7
View File
@@ -0,0 +1,7 @@
from aiohttp import web
def json_res(data, status=200, cookie=None):
resp = web.json_response(data, status=status)
if cookie: resp.set_cookie(cookie["n"], cookie["v"], max_age=cookie.get("m", 86400), httponly=True)
return resp
def get_user(req):
return req.get('user')
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import time
import platform
import logging
from typing import Dict, Any
logger = logging.getLogger(__name__)
def _is_android() -> bool:
"""检测是否为 Android 环境 (Termux 等)"""
return (
'ANDROID_ROOT' in os.environ or
os.path.exists('/system/bin/getprop') or
platform.release().lower().find('android') != -1
)
class SystemInfoCollector:
def __init__(self):
self.is_android = 'ANDROID_ROOT' in os.environ or os.path.exists('/system/bin/getprop')
self.psutil = None
if not self.is_android:
try:
import psutil
self.psutil = psutil
except ImportError as e:
logger.debug(f"psutil not available: {e}")
else:
logger.info("🤖 Android 平台识别,启用原生采集")
def get_all(self):
return {
"platform": {
"system": platform.system(),
"machine": platform.machine(),
"python": platform.python_version()
},
"cpu": self._get_cpu(),
"memory": self._get_memory(),
"network": self._get_network()
}
def _get_cpu(self):
if self.psutil:
return {
"percent": self.psutil.cpu_percent(interval=0.1),
"cores": self.psutil.cpu_count(),
"load_avg": os.getloadavg() if hasattr(os, 'getloadavg') else [0,0,0]
}
# Android 估算:负载率 = (1分钟负载 / 核心数) * 100
try:
load = os.getloadavg()
cores = os.cpu_count() or 1
percent = min(100.0, (load[0] / cores) * 100)
return {"percent": round(percent, 1), "cores": cores, "load_avg": load}
except Exception as e:
logger.error(f"System info collection error: {e}", exc_info=True)
return {"percent": 0, "cores": 0, "load_avg": [0,0,0]}
def _get_memory(self):
if self.psutil:
m = self.psutil.virtual_memory()
return {"total_gb": round(m.total/1073741824, 1), "used_gb": round(m.used/1073741824, 1), "percent": m.percent}
try:
mem = {}
with open('/proc/meminfo') as f:
for line in f:
parts = line.split()
if len(parts) >= 2: mem[parts[0].rstrip(':')] = int(parts[1]) * 1024
t, a = mem.get('MemTotal', 1), mem.get('MemAvailable', mem.get('MemFree', 0))
return {"total_gb": round(t/1073741824, 1), "used_gb": round((t-a)/1073741824, 1), "percent": round(((t-a)/t)*100, 1)}
except Exception as e:
logger.warning(f"Memory info failed: {e}")
return {"total_gb": 0, "used_gb": 0, "percent": 0}
def _get_network(self):
if self.psutil:
io = self.psutil.net_io_counters()
return {"rx": round(io.bytes_recv/1048576, 1), "tx": round(io.bytes_sent/1048576, 1)}
# Android 解析 /proc/net/dev
try:
rx = 0
with open('/proc/net/dev', 'r') as f:
for line in f:
if ':' in line and 'lo' not in line: # 排除 lo 回环
parts = line.split(':')[1].split()
rx += int(parts[0]) # RX bytes
return {"rx": round(rx/1048576, 1), "tx": 0}
except Exception as e:
logger.warning(f"Network info failed: {e}")
return {"rx": 0, "tx": 0}