From 655de4743cbf616eb013cc6f705b9f7aaf04d482 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Mon, 23 Feb 2026 16:59:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v2.0=20-=20=D0=9C=D1=83=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D0=B8-=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B0=20=D1=81=20SSH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новые возможности: - Класс 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 --- .env.example | 23 ++ bot.py | 597 +++++++++++++++++++++++++++++++++++------------ requirements.txt | 1 + 3 files changed, 478 insertions(+), 143 deletions(-) diff --git a/.env.example b/.env.example index 8b54b37..75f4239 100644 --- a/.env.example +++ b/.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 diff --git a/bot.py b/bot.py index 3bd11e1..91b88cd 100644 --- a/bot.py +++ b/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() # --- Проверка прав доступа --- @@ -190,14 +329,23 @@ def check_access(func): # --- Инициализация меню --- 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 = [ @@ -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") @@ -425,7 +579,64 @@ async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): parse_mode="Markdown", 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, @@ -548,33 +773,14 @@ async def execute_cli_command(query, 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 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( "❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд и была прервана.", @@ -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,10 +880,12 @@ 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 - меняем директорию пользователя # Работает только с простыми командами cd, не с составными cmd_stripped = command.strip() @@ -620,18 +893,19 @@ async def execute_cli_command_from_message(update: Update, command: str): parts = cmd_stripped.split(maxsplit=1) if len(parts) == 2: target_dir = parts[1] - + # Обработка ~ и относительных путей if target_dir.startswith("~"): target_dir = str(Path.home()) + target_dir[1:] elif not target_dir.startswith("/"): target_dir = str(Path(working_dir) / target_dir) - + # Проверка существования директории 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: @@ -640,76 +914,97 @@ async def execute_cli_command_from_message(update: Update, command: str): parse_mode="Markdown" ) 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" + 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 + + # Обычное выполнение + 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 - 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: + 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 Path(final_dir).is_dir(): - state.working_directory = final_dir - # Убираем pwd из вывода + # Простая проверка - если начинается с / + if final_dir.startswith('/'): + state_manager.get(update.effective_user.id).working_directory = final_dir 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" - ) - return - - logger.info(f"Выполнение команды: {command} в директории: {working_dir}") - + + 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): @@ -783,7 +1091,10 @@ def main(): print(" export TELEGRAM_BOT_TOKEN='your_token_here'") print("\nИли запустите ./run.sh для интерактивной настройки") sys.exit(1) - + + # Загрузка серверов из env + server_manager.load_from_env() + # Инициализация меню init_menus() diff --git a/requirements.txt b/requirements.txt index a52f235..0f6e89b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ python-telegram-bot==21.0 pyyaml==6.0.1 python-dotenv==1.0.1 +asyncssh==2.16.0