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:
mirivlad 2026-02-23 16:59:52 +08:00
parent 96d2577415
commit 655de4743c
3 changed files with 478 additions and 143 deletions

View File

@ -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

597
bot.py
View File

@ -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()

View File

@ -1,3 +1,4 @@
python-telegram-bot==21.0
pyyaml==6.0.1
python-dotenv==1.0.1
asyncssh==2.16.0