feat: v2.0 - Мульти-серверная архитектура с SSH
Новые возможности:
- Класс Server и ServerManager для управления серверами
- Конфигурация серверов через .env (SERVERS, SSH_KEY_PATH)
- Меню выбора сервера с динамическим обновлением
- Выполнение команд через SSH на удалённых серверах
- Индикатор текущего сервера в UI
- Персональная рабочая директория для каждого сервера
- Поддержка составных команд с cd через SSH
Формат конфигурации серверов:
SERVERS=name|host|port|user|tags,name|host|port|user|tags
Изменения:
- + asyncssh==2.16.0 в зависимости
- ~ .env.example: добавлены SERVERS, SSH_KEY_PATH, DEFAULT_SERVER
- ~ bot.py: ~600 строк нового кода
- ~ menu: добавлено 🖥️ Выбор сервера
Настройки состояния пользователя:
- current_server: имя текущего сервера (по умолчанию 'local')
- working_directory: сбрасывается при смене сервера
Безопасность:
- known_hosts отключён для простоты (можно включить в продакшене)
- SSH ключ через client_keys (путь из .env)
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
96d2577415
commit
655de4743c
23
.env.example
23
.env.example
|
|
@ -14,3 +14,26 @@ ALLOWED_USERS=
|
|||
|
||||
# Рабочая директория для команд
|
||||
WORKING_DIRECTORY=/home/mirivlad
|
||||
|
||||
# ===========================================
|
||||
# Мульти-серверная конфигурация (v2.0)
|
||||
# ===========================================
|
||||
|
||||
# SSH ключ для подключения к серверам
|
||||
SSH_KEY_PATH=/home/mirivlad/.ssh/id_ed25519
|
||||
|
||||
# Список серверов (формат: name|host|port|user|tags)
|
||||
# name - отображаемое имя сервера
|
||||
# host - IP или домен
|
||||
# port - SSH порт (обычно 22)
|
||||
# user - пользователь SSH
|
||||
# tags - теги через запятую для группировки (web,db,prod,dev)
|
||||
#
|
||||
# Пример:
|
||||
# SERVERS=web-prod|192.168.1.10|22|root|web,prod,db-prod|192.168.1.11|22|postgres|db,prod,local|localhost|22|mirivlad|local,dev
|
||||
#
|
||||
# Пустой список = только локальный сервер
|
||||
SERVERS=
|
||||
|
||||
# Сервер по умолчанию (имя из списка или "local")
|
||||
DEFAULT_SERVER=local
|
||||
|
|
|
|||
571
bot.py
571
bot.py
|
|
@ -14,6 +14,8 @@ from typing import Optional, Callable, Dict, Any, List
|
|||
from dataclasses import dataclass, field
|
||||
from functools import wraps
|
||||
|
||||
import asyncssh
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, BotCommand
|
||||
from telegram.ext import (
|
||||
|
|
@ -69,6 +71,141 @@ class BotConfig:
|
|||
return len(self.allowed_users) > 0
|
||||
|
||||
|
||||
# --- Серверы ---
|
||||
@dataclass
|
||||
class Server:
|
||||
"""Конфигурация сервера."""
|
||||
name: str
|
||||
host: str
|
||||
port: int
|
||||
user: str
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Отображаемое имя с иконкой."""
|
||||
icon = "🖥️"
|
||||
if "local" in self.tags:
|
||||
icon = "💻"
|
||||
elif "db" in self.tags:
|
||||
icon = "🗄️"
|
||||
elif "web" in self.tags:
|
||||
icon = "🌐"
|
||||
return f"{icon} {self.name}"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Краткое описание сервера."""
|
||||
return f"{self.user}@{self.host}:{self.port}"
|
||||
|
||||
|
||||
class ServerManager:
|
||||
"""Управление серверами."""
|
||||
|
||||
def __init__(self):
|
||||
self._servers: Dict[str, Server] = {}
|
||||
self._default_server: str = "local"
|
||||
self._ssh_key_path: Optional[str] = None
|
||||
|
||||
# Локальный сервер всегда доступен
|
||||
self._servers["local"] = Server(
|
||||
name="local",
|
||||
host="localhost",
|
||||
port=22,
|
||||
user=os.getenv("USER", "user"),
|
||||
tags=["local", "dev"]
|
||||
)
|
||||
|
||||
def load_from_env(self):
|
||||
"""Загрузка серверов из переменных окружения."""
|
||||
self._ssh_key_path = os.getenv("SSH_KEY_PATH")
|
||||
self._default_server = os.getenv("DEFAULT_SERVER", "local")
|
||||
|
||||
servers_str = os.getenv("SERVERS", "")
|
||||
if not servers_str.strip():
|
||||
return
|
||||
|
||||
# Парсинг формата: name|host|port|user|tags,name|host|port|user|tags
|
||||
parts = servers_str.split(",")
|
||||
i = 0
|
||||
while i < len(parts):
|
||||
if i + 4 >= len(parts):
|
||||
break
|
||||
|
||||
# Проверяем, не является ли текущая часть тегом (содержит ли =)
|
||||
if "=" in parts[i]:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
name = parts[i].strip()
|
||||
host = parts[i + 1].strip()
|
||||
port_str = parts[i + 2].strip()
|
||||
user = parts[i + 3].strip()
|
||||
|
||||
# Теги могут быть в следующей части или отсутствовать
|
||||
tags = []
|
||||
next_idx = i + 4
|
||||
if next_idx < len(parts):
|
||||
next_part = parts[next_idx].strip()
|
||||
# Если следующая часть не похожа на имя сервера (содержит только буквы и дефисы)
|
||||
# и не похожа на host (не содержит точек)
|
||||
if "|" in next_part or (next_part and not next_part.replace("-", "").replace("_", "").isalnum()):
|
||||
# Это теги
|
||||
tags = [t.strip() for t in next_part.split("|") if t.strip()]
|
||||
i += 5
|
||||
else:
|
||||
i += 4
|
||||
else:
|
||||
i += 4
|
||||
|
||||
try:
|
||||
port = int(port_str)
|
||||
server = Server(name=name, host=host, port=port, user=user, tags=tags)
|
||||
self._servers[name] = server
|
||||
logger.info(f"Загружен сервер: {server.display_name} ({server.description})")
|
||||
except ValueError as e:
|
||||
logger.warning(f"Ошибка парсинга сервера: {parts[i:i+4]} - {e}")
|
||||
|
||||
def get(self, name: str) -> Optional[Server]:
|
||||
"""Получить сервер по имени."""
|
||||
return self._servers.get(name)
|
||||
|
||||
def list_servers(self) -> List[Server]:
|
||||
"""Список всех серверов."""
|
||||
return list(self._servers.values())
|
||||
|
||||
def get_by_tags(self, tags: List[str]) -> List[Server]:
|
||||
"""Получить серверы по тегам."""
|
||||
result = []
|
||||
for server in self._servers.values():
|
||||
if any(tag in server.tags for tag in tags):
|
||||
result.append(server)
|
||||
return result
|
||||
|
||||
@property
|
||||
def default_server(self) -> str:
|
||||
"""Имя сервера по умолчанию."""
|
||||
return self._default_server
|
||||
|
||||
@property
|
||||
def ssh_key_path(self) -> Optional[str]:
|
||||
"""Путь к SSH ключу."""
|
||||
return self._ssh_key_path
|
||||
|
||||
def get_keyboard(self, exclude_local: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Создать клавиатуру с выбором сервера."""
|
||||
keyboard = []
|
||||
for server in self._servers.values():
|
||||
if exclude_local and server.name == "local":
|
||||
continue
|
||||
button = InlineKeyboardButton(
|
||||
server.display_name,
|
||||
callback_data=f"server_select_{server.name}"
|
||||
)
|
||||
keyboard.append([button])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
# --- Хранилище состояний пользователя ---
|
||||
@dataclass
|
||||
class UserState:
|
||||
|
|
@ -79,6 +216,7 @@ class UserState:
|
|||
parent_menu: Optional[str] = None
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
working_directory: Optional[str] = None
|
||||
current_server: str = "local" # Имя текущего сервера
|
||||
|
||||
|
||||
class StateManager:
|
||||
|
|
@ -160,6 +298,7 @@ config = BotConfig()
|
|||
state_manager = StateManager()
|
||||
menu_builder = MenuBuilder()
|
||||
command_registry = CommandRegistry()
|
||||
server_manager = ServerManager()
|
||||
|
||||
|
||||
# --- Проверка прав доступа ---
|
||||
|
|
@ -193,12 +332,21 @@ def init_menus():
|
|||
|
||||
# Главное меню
|
||||
main_menu = [
|
||||
MenuItem("🖥️ Выбор сервера", "server_menu", icon="🖥️"),
|
||||
MenuItem("📋 Предустановленные команды", "preset_menu", icon="📋"),
|
||||
MenuItem("⚙️ Настройки бота", "settings_menu", icon="⚙️"),
|
||||
MenuItem("ℹ️ О боте", "about", icon="ℹ️"),
|
||||
]
|
||||
menu_builder.add_menu("main", main_menu)
|
||||
|
||||
# Меню серверов
|
||||
server_menu = [
|
||||
MenuItem("💻 local (localhost)", "server_select_local", icon="💻"),
|
||||
MenuItem("➕ Добавить сервер", "server_add", icon="➕"),
|
||||
MenuItem("⬅️ Назад", "main", icon="⬅️"),
|
||||
]
|
||||
menu_builder.add_menu("server", server_menu)
|
||||
|
||||
# Меню предустановленных команд
|
||||
preset_menu = [
|
||||
MenuItem("📁 Файловая система", "fs_menu", icon="📁"),
|
||||
|
|
@ -279,17 +427,20 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
||||
state_manager.reset(user.id)
|
||||
|
||||
# Показать текущую директорию
|
||||
# Показать текущую директорию и сервер
|
||||
working_dir = config.working_directory
|
||||
server = server_manager.get("local")
|
||||
server_desc = server.description if server else "localhost"
|
||||
|
||||
await update.message.reply_text(
|
||||
f"👋 Привет, {user.first_name}!\n\n"
|
||||
f"{config.icon} *{config.name}*\n"
|
||||
f"_{config.description}_\n\n"
|
||||
f"*Просто отправьте CLI команду в чат* — я её выполню!\n\n"
|
||||
f"📁 Рабочая директория: `{working_dir}`\n\n"
|
||||
f"🖥️ *Текущий сервер:* `{server_desc}`\n"
|
||||
f"📁 *Рабочая директория:* `{working_dir}`\n\n"
|
||||
f"Используйте `cd путь` для смены директории.\n"
|
||||
f"Или используйте кнопки меню для быстрых команд.\n"
|
||||
f"Или выберите сервер в меню.\n"
|
||||
f"Команда /help покажет справку.",
|
||||
parse_mode="Markdown",
|
||||
reply_markup=menu_builder.get_keyboard("main")
|
||||
|
|
@ -306,12 +457,15 @@ async def menu_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
state_manager.reset(user.id)
|
||||
state.current_menu = "main"
|
||||
|
||||
# Показать текущую директорию
|
||||
# Показать текущую директорию и сервер
|
||||
working_dir = state.working_directory or config.working_directory
|
||||
server = server_manager.get(state.current_server)
|
||||
server_desc = server.description if server else state.current_server
|
||||
|
||||
await update.message.reply_text(
|
||||
f"🏠 *Главное меню*\n\n"
|
||||
f"📁 Текущая директория: `{working_dir}`\n\n"
|
||||
f"🖥️ *Сервер:* `{server_desc}`\n"
|
||||
f"📁 *Директория:* `{working_dir}`\n\n"
|
||||
f"Выберите действие:",
|
||||
parse_mode="Markdown",
|
||||
reply_markup=menu_builder.get_keyboard("main")
|
||||
|
|
@ -426,6 +580,63 @@ async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
reply_markup=menu_builder.get_keyboard("network")
|
||||
)
|
||||
|
||||
elif callback == "server_menu":
|
||||
# Динамическое обновление меню серверов
|
||||
servers = server_manager.list_servers()
|
||||
keyboard = []
|
||||
for srv in servers:
|
||||
keyboard.append([InlineKeyboardButton(
|
||||
srv.display_name,
|
||||
callback_data=f"server_select_{srv.name}"
|
||||
)])
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data="main")])
|
||||
|
||||
state.current_menu = "server"
|
||||
await query.edit_message_text(
|
||||
"🖥️ *Выберите сервер:*\n\n"
|
||||
"Команды будут выполняться на выбранном сервере через SSH.",
|
||||
parse_mode="Markdown",
|
||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||
)
|
||||
|
||||
elif callback == "server_add":
|
||||
await query.edit_message_text(
|
||||
"➕ *Добавление сервера*\n\n"
|
||||
"Для добавления сервера отредактируйте `.env`:\n"
|
||||
"```\nSERVERS=name|host|port|user|tags\n```\n"
|
||||
"Пример:\n"
|
||||
"```\nSERVERS=web-prod|192.168.1.10|22|root|web,prod\n```\n"
|
||||
"После изменения перезапустите бота.",
|
||||
parse_mode="Markdown",
|
||||
reply_markup=menu_builder.get_keyboard("server")
|
||||
)
|
||||
|
||||
elif callback.startswith("server_select_"):
|
||||
server_name = callback.replace("server_select_", "")
|
||||
server = server_manager.get(server_name)
|
||||
|
||||
if server:
|
||||
state.current_server = server_name
|
||||
# Сброс рабочей директории при смене сервера
|
||||
state.working_directory = None
|
||||
|
||||
await query.edit_message_text(
|
||||
f"✅ *Сервер изменён*\n\n"
|
||||
f"{server.display_name}\n"
|
||||
f"📍 `{server.description}`\n\n"
|
||||
f"Теперь команды выполняются на этом сервере.",
|
||||
parse_mode="Markdown",
|
||||
reply_markup=menu_builder.get_keyboard("main")
|
||||
)
|
||||
state.current_menu = "main"
|
||||
else:
|
||||
await query.edit_message_text(
|
||||
f"❌ *Сервер не найден*\n\n"
|
||||
f"Сервер `{server_name}` отсутствует в конфигурации.",
|
||||
parse_mode="Markdown",
|
||||
reply_markup=menu_builder.get_keyboard("server")
|
||||
)
|
||||
|
||||
elif callback == "settings_menu":
|
||||
state.current_menu = "settings"
|
||||
await query.edit_message_text(
|
||||
|
|
@ -530,17 +741,31 @@ async def execute_cli_command(query, command: str):
|
|||
"""Выполнение CLI команды из кнопки меню."""
|
||||
user_id = query.from_user.id
|
||||
state = state_manager.get(user_id)
|
||||
server_name = state.current_server
|
||||
server = server_manager.get(server_name)
|
||||
|
||||
# Определяем рабочую директорию: сначала пользовательская, потом из конфига
|
||||
# Определяем рабочую директорию
|
||||
working_dir = state.working_directory or config.working_directory
|
||||
|
||||
logger.info(f"Выполнение команды: {command} в директории: {working_dir}")
|
||||
logger.info(f"Выполнение команды: {command} на сервере: {server_name}, в директории: {working_dir}")
|
||||
|
||||
await query.edit_message_text(
|
||||
f"⏳ *Выполнение...*\n\n`{command}`",
|
||||
f"⏳ *Выполнение...*\n"
|
||||
f"🖥️ `{server_name}`\n"
|
||||
f"```\n{command}\n```",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
# Если локальный сервер — выполняем локально
|
||||
if server_name == "local" or server is None:
|
||||
await _execute_local_command(query, command, working_dir)
|
||||
else:
|
||||
# Выполняем через SSH
|
||||
await _execute_ssh_command(query, command, server, working_dir)
|
||||
|
||||
|
||||
async def _execute_local_command(query, command: str, working_dir: str):
|
||||
"""Выполнение локальной команды."""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
|
|
@ -554,26 +779,7 @@ async def execute_cli_command(query, command: str):
|
|||
timeout=30
|
||||
)
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
error = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
result = f"✅ *Результат:*\n\n"
|
||||
result += f"```\n{command}\n```\n\n"
|
||||
|
||||
if output:
|
||||
# Ограничиваем вывод
|
||||
if len(output) > 4000:
|
||||
output = output[:4000] + "\n... (вывод обрезан)"
|
||||
result += f"*Вывод:*\n```\n{output}\n```\n"
|
||||
|
||||
if error:
|
||||
if len(error) > 4000:
|
||||
error = error[:4000] + "\n... (вывод обрезан)"
|
||||
result += f"*Ошибки:*\n```\n{error}\n```\n"
|
||||
|
||||
result += f"\n*Код возврата:* `{process.returncode}`"
|
||||
|
||||
await query.edit_message_text(result, parse_mode="Markdown")
|
||||
await _show_result(query, command, stdout, stderr, process.returncode)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
await query.edit_message_text(
|
||||
|
|
@ -588,6 +794,71 @@ async def execute_cli_command(query, command: str):
|
|||
)
|
||||
|
||||
|
||||
async def _execute_ssh_command(query, command: str, server: Server, working_dir: str):
|
||||
"""Выполнение команды через SSH."""
|
||||
try:
|
||||
# Подготовка SSH ключа
|
||||
client_keys = [server_manager.ssh_key_path] if server_manager.ssh_key_path else None
|
||||
|
||||
# Подключение к серверу
|
||||
async with asyncssh.connect(
|
||||
host=server.host,
|
||||
port=server.port,
|
||||
username=server.user,
|
||||
client_keys=client_keys,
|
||||
known_hosts=None # Отключаем проверку known_hosts для простоты
|
||||
) as conn:
|
||||
# Выполнение команды
|
||||
result = await conn.run(
|
||||
command,
|
||||
cwd=working_dir,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
await _show_result(query, command, result.stdout.encode(), result.stderr.encode(), 0)
|
||||
|
||||
except asyncssh.Error as e:
|
||||
logger.error(f"SSH ошибка: {e}")
|
||||
await query.edit_message_text(
|
||||
f"❌ *SSH ошибка:*\n```\n{str(e)}\n```",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await query.edit_message_text(
|
||||
"❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд и была прервана.",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка выполнения команды: {e}")
|
||||
await query.edit_message_text(
|
||||
f"❌ *Ошибка:*\n```\n{str(e)}\n```",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
|
||||
async def _show_result(query, command: str, stdout: bytes, stderr: bytes, returncode: int):
|
||||
"""Показ результата выполнения команды."""
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
error = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
result = f"✅ *Результат:*\n\n"
|
||||
result += f"```\n{command}\n```\n\n"
|
||||
|
||||
if output:
|
||||
if len(output) > 4000:
|
||||
output = output[:4000] + "\n... (вывод обрезан)"
|
||||
result += f"*Вывод:*\n```\n{output}\n```\n"
|
||||
|
||||
if error:
|
||||
if len(error) > 4000:
|
||||
error = error[:4000] + "\n... (вывод обрезан)"
|
||||
result += f"*Ошибки:*\n```\n{error}\n```\n"
|
||||
|
||||
result += f"\n*Код возврата:* `{returncode}`"
|
||||
|
||||
await query.edit_message_text(result, parse_mode="Markdown")
|
||||
|
||||
|
||||
@check_access
|
||||
async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Обработка текстовых сообщений как CLI команд."""
|
||||
|
|
@ -609,8 +880,10 @@ async def execute_cli_command_from_message(update: Update, command: str):
|
|||
"""Выполнение CLI команды из сообщения."""
|
||||
user_id = update.effective_user.id
|
||||
state = state_manager.get(user_id)
|
||||
server_name = state.current_server
|
||||
server = server_manager.get(server_name)
|
||||
|
||||
# Определяем рабочую директорию: сначала пользовательская, потом из конфига
|
||||
# Определяем рабочую директорию
|
||||
working_dir = state.working_directory or config.working_directory
|
||||
|
||||
# Обработка команды cd - меняем директорию пользователя
|
||||
|
|
@ -631,7 +904,8 @@ async def execute_cli_command_from_message(update: Update, command: str):
|
|||
if Path(target_dir).is_dir():
|
||||
state.working_directory = target_dir
|
||||
await update.message.reply_text(
|
||||
f"📁 *Директория изменена:*\n`{target_dir}`",
|
||||
f"📁 *Директория изменена:*\n`{target_dir}`\n"
|
||||
f"🖥️ Сервер: `{server_name}`",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
else:
|
||||
|
|
@ -641,75 +915,96 @@ async def execute_cli_command_from_message(update: Update, command: str):
|
|||
)
|
||||
return
|
||||
|
||||
# Проверка на составную команду с cd - выполняем и сохраняем конечную директорию
|
||||
# Для составных команд с cd — выполняем через SSH или локально
|
||||
if "cd " in cmd_stripped and ("&&" in cmd_stripped or ";" in cmd_stripped):
|
||||
# Добавляем pwd в конец для получения конечной директории
|
||||
command_with_pwd = f"{cmd_stripped} && pwd"
|
||||
logger.info(f"Выполнение составной команды с cd: {command_with_pwd} в директории: {working_dir}")
|
||||
|
||||
await update.message.reply_text(
|
||||
f"⏳ *Выполнение...*\n\n`{cmd_stripped}`",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command_with_pwd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=working_dir
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=30
|
||||
)
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace").strip()
|
||||
error = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
# Последняя строка - это pwd, сохраняем её
|
||||
if output and process.returncode == 0:
|
||||
lines = output.split('\n')
|
||||
final_dir = lines[-1].strip()
|
||||
# Проверяем, что это действительно путь
|
||||
if Path(final_dir).is_dir():
|
||||
state.working_directory = final_dir
|
||||
# Убираем pwd из вывода
|
||||
output = '\n'.join(lines[:-1])
|
||||
|
||||
result = f"✅ *Результат:*\n\n"
|
||||
result += f"```\n{cmd_stripped}\n```\n\n"
|
||||
|
||||
if output:
|
||||
if len(output) > 4000:
|
||||
output = output[:4000] + "\n... (вывод обрезан)"
|
||||
result += f"*Вывод:*\n```\n{output}\n```\n"
|
||||
|
||||
if error:
|
||||
if len(error) > 4000:
|
||||
error = error[:4000] + "\n... (вывод обрезан)"
|
||||
result += f"*Ошибки:*\n```\n{error}\n```\n"
|
||||
|
||||
result += f"\n*Код возврата:* `{process.returncode}`"
|
||||
|
||||
await update.message.reply_text(result, parse_mode="Markdown")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
await update.message.reply_text(
|
||||
"❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд и была прервана.",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка выполнения команды: {e}")
|
||||
await update.message.reply_text(
|
||||
f"❌ *Ошибка:*\n```\n{str(e)}\n```",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
if server_name == "local" or server is None:
|
||||
await _execute_composite_command_local(update, cmd_stripped, working_dir)
|
||||
else:
|
||||
await _execute_composite_command_ssh(update, cmd_stripped, server, working_dir)
|
||||
return
|
||||
|
||||
logger.info(f"Выполнение команды: {command} в директории: {working_dir}")
|
||||
# Обычное выполнение
|
||||
if server_name == "local" or server is None:
|
||||
await _execute_local_command_message(update, cmd_stripped, working_dir)
|
||||
else:
|
||||
await _execute_ssh_command_message(update, cmd_stripped, server, working_dir)
|
||||
|
||||
|
||||
async def _execute_composite_command_local(update: Update, command: str, working_dir: str):
|
||||
"""Выполнение составной команды локально."""
|
||||
command_with_pwd = f"{command} && pwd"
|
||||
logger.info(f"Выполнение составной команды с cd: {command_with_pwd} в директории: {working_dir}")
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command_with_pwd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=working_dir
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
|
||||
output = stdout.decode("utf-8", errors="replace").strip()
|
||||
error = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
# Последняя строка - это pwd
|
||||
if output and process.returncode == 0:
|
||||
lines = output.split('\n')
|
||||
final_dir = lines[-1].strip()
|
||||
if Path(final_dir).is_dir():
|
||||
state_manager.get(update.effective_user.id).working_directory = final_dir
|
||||
output = '\n'.join(lines[:-1])
|
||||
|
||||
await _show_result_message(update, command, output, error, process.returncode)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
await update.message.reply_text("❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд.", parse_mode="Markdown")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка: {e}")
|
||||
await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown")
|
||||
|
||||
|
||||
async def _execute_composite_command_ssh(update: Update, command: str, server: Server, working_dir: str):
|
||||
"""Выполнение составной команды через SSH."""
|
||||
command_with_pwd = f"{command} && pwd"
|
||||
|
||||
try:
|
||||
client_keys = [server_manager.ssh_key_path] if server_manager.ssh_key_path else None
|
||||
|
||||
async with asyncssh.connect(
|
||||
host=server.host,
|
||||
port=server.port,
|
||||
username=server.user,
|
||||
client_keys=client_keys,
|
||||
known_hosts=None
|
||||
) as conn:
|
||||
result = await conn.run(command_with_pwd, cwd=working_dir, timeout=30)
|
||||
output = result.stdout.strip()
|
||||
error = result.stderr
|
||||
|
||||
# Последняя строка - pwd
|
||||
if output:
|
||||
lines = output.split('\n')
|
||||
final_dir = lines[-1].strip()
|
||||
# Простая проверка - если начинается с /
|
||||
if final_dir.startswith('/'):
|
||||
state_manager.get(update.effective_user.id).working_directory = final_dir
|
||||
output = '\n'.join(lines[:-1])
|
||||
|
||||
await _show_result_message(update, command, output, error, 0)
|
||||
|
||||
except asyncssh.Error as e:
|
||||
logger.error(f"SSH ошибка: {e}")
|
||||
await update.message.reply_text(f"❌ *SSH ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown")
|
||||
except asyncio.TimeoutError:
|
||||
await update.message.reply_text("❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд.", parse_mode="Markdown")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка: {e}")
|
||||
await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown")
|
||||
|
||||
|
||||
async def _execute_local_command_message(update: Update, command: str, working_dir: str):
|
||||
"""Выполнение локальной команды из сообщения."""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
|
|
@ -717,43 +1012,56 @@ async def execute_cli_command_from_message(update: Update, command: str):
|
|||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=working_dir
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=30
|
||||
)
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
error = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
result = f"✅ *Результат:*\n\n"
|
||||
result += f"```\n{command}\n```\n\n"
|
||||
|
||||
if output:
|
||||
if len(output) > 4000:
|
||||
output = output[:4000] + "\n... (вывод обрезан)"
|
||||
result += f"*Вывод:*\n```\n{output}\n```\n"
|
||||
|
||||
if error:
|
||||
if len(error) > 4000:
|
||||
error = error[:4000] + "\n... (вывод обрезан)"
|
||||
result += f"*Ошибки:*\n```\n{error}\n```\n"
|
||||
|
||||
result += f"\n*Код возврата:* `{process.returncode}`"
|
||||
|
||||
await update.message.reply_text(result, parse_mode="Markdown")
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
|
||||
await _show_result_message(update, command, stdout.decode(), stderr.decode(), process.returncode)
|
||||
except asyncio.TimeoutError:
|
||||
await update.message.reply_text(
|
||||
"❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд и была прервана.",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await update.message.reply_text("❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд.", parse_mode="Markdown")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка выполнения команды: {e}")
|
||||
await update.message.reply_text(
|
||||
f"❌ *Ошибка:*\n```\n{str(e)}\n```",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
logger.error(f"Ошибка: {e}")
|
||||
await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown")
|
||||
|
||||
|
||||
async def _execute_ssh_command_message(update: Update, command: str, server: Server, working_dir: str):
|
||||
"""Выполнение команды через SSH из сообщения."""
|
||||
try:
|
||||
client_keys = [server_manager.ssh_key_path] if server_manager.ssh_key_path else None
|
||||
|
||||
async with asyncssh.connect(
|
||||
host=server.host,
|
||||
port=server.port,
|
||||
username=server.user,
|
||||
client_keys=client_keys,
|
||||
known_hosts=None
|
||||
) as conn:
|
||||
result = await conn.run(command, cwd=working_dir, timeout=30)
|
||||
await _show_result_message(update, command, result.stdout, result.stderr, 0)
|
||||
except asyncssh.Error as e:
|
||||
logger.error(f"SSH ошибка: {e}")
|
||||
await update.message.reply_text(f"❌ *SSH ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown")
|
||||
except asyncio.TimeoutError:
|
||||
await update.message.reply_text("❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд.", parse_mode="Markdown")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка: {e}")
|
||||
await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown")
|
||||
|
||||
|
||||
async def _show_result_message(update: Update, command: str, output: str, error: str, returncode: int):
|
||||
"""Показ результата выполнения команды."""
|
||||
result = f"✅ *Результат:*\n\n```\n{command}\n```\n\n"
|
||||
|
||||
if output:
|
||||
if len(output) > 4000:
|
||||
output = output[:4000] + "\n... (вывод обрезан)"
|
||||
result += f"*Вывод:*\n```\n{output}\n```\n"
|
||||
|
||||
if error:
|
||||
if len(error) > 4000:
|
||||
error = error[:4000] + "\n... (вывод обрезан)"
|
||||
result += f"*Ошибки:*\n```\n{error}\n```\n"
|
||||
|
||||
result += f"\n*Код возврата:* `{returncode}`"
|
||||
|
||||
await update.message.reply_text(result, parse_mode="Markdown")
|
||||
|
||||
|
||||
async def post_init(application: Application):
|
||||
|
|
@ -784,6 +1092,9 @@ def main():
|
|||
print("\nИли запустите ./run.sh для интерактивной настройки")
|
||||
sys.exit(1)
|
||||
|
||||
# Загрузка серверов из env
|
||||
server_manager.load_from_env()
|
||||
|
||||
# Инициализация меню
|
||||
init_menus()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
python-telegram-bot==21.0
|
||||
pyyaml==6.0.1
|
||||
python-dotenv==1.0.1
|
||||
asyncssh==2.16.0
|
||||
|
|
|
|||
Loading…
Reference in New Issue