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 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
View File

@ -14,6 +14,8 @@ from typing import Optional, Callable, Dict, Any, List
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import wraps from functools import wraps
import asyncssh
from dotenv import load_dotenv from dotenv import load_dotenv
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, BotCommand from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, BotCommand
from telegram.ext import ( from telegram.ext import (
@ -69,6 +71,141 @@ class BotConfig:
return len(self.allowed_users) > 0 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 @dataclass
class UserState: class UserState:
@ -79,6 +216,7 @@ class UserState:
parent_menu: Optional[str] = None parent_menu: Optional[str] = None
context: Dict[str, Any] = field(default_factory=dict) context: Dict[str, Any] = field(default_factory=dict)
working_directory: Optional[str] = None working_directory: Optional[str] = None
current_server: str = "local" # Имя текущего сервера
class StateManager: class StateManager:
@ -160,6 +298,7 @@ config = BotConfig()
state_manager = StateManager() state_manager = StateManager()
menu_builder = MenuBuilder() menu_builder = MenuBuilder()
command_registry = CommandRegistry() command_registry = CommandRegistry()
server_manager = ServerManager()
# --- Проверка прав доступа --- # --- Проверка прав доступа ---
@ -193,12 +332,21 @@ def init_menus():
# Главное меню # Главное меню
main_menu = [ main_menu = [
MenuItem("🖥️ Выбор сервера", "server_menu", icon="🖥️"),
MenuItem("📋 Предустановленные команды", "preset_menu", icon="📋"), MenuItem("📋 Предустановленные команды", "preset_menu", icon="📋"),
MenuItem("⚙️ Настройки бота", "settings_menu", icon="⚙️"), MenuItem("⚙️ Настройки бота", "settings_menu", icon="⚙️"),
MenuItem(" О боте", "about", icon=""), MenuItem(" О боте", "about", icon=""),
] ]
menu_builder.add_menu("main", main_menu) 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 = [ preset_menu = [
MenuItem("📁 Файловая система", "fs_menu", icon="📁"), MenuItem("📁 Файловая система", "fs_menu", icon="📁"),
@ -279,17 +427,20 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
state_manager.reset(user.id) state_manager.reset(user.id)
# Показать текущую директорию # Показать текущую директорию и сервер
working_dir = config.working_directory working_dir = config.working_directory
server = server_manager.get("local")
server_desc = server.description if server else "localhost"
await update.message.reply_text( await update.message.reply_text(
f"👋 Привет, {user.first_name}!\n\n" f"👋 Привет, {user.first_name}!\n\n"
f"{config.icon} *{config.name}*\n" f"{config.icon} *{config.name}*\n"
f"_{config.description}_\n\n" f"_{config.description}_\n\n"
f"*Просто отправьте CLI команду в чат* — я её выполню!\n\n" f"*Просто отправьте CLI команду в чат* — я её выполню!\n\n"
f"📁 Рабочая директория: `{working_dir}`\n\n" f"🖥️ *Текущий сервер:* `{server_desc}`\n"
f"📁 *Рабочая директория:* `{working_dir}`\n\n"
f"Используйте `cd путь` для смены директории.\n" f"Используйте `cd путь` для смены директории.\n"
f"Или используйте кнопки меню для быстрых команд.\n" f"Или выберите сервер в меню.\n"
f"Команда /help покажет справку.", f"Команда /help покажет справку.",
parse_mode="Markdown", parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("main") 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_manager.reset(user.id)
state.current_menu = "main" state.current_menu = "main"
# Показать текущую директорию # Показать текущую директорию и сервер
working_dir = state.working_directory or config.working_directory 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( await update.message.reply_text(
f"🏠 *Главное меню*\n\n" f"🏠 *Главное меню*\n\n"
f"📁 Текущая директория: `{working_dir}`\n\n" f"🖥️ *Сервер:* `{server_desc}`\n"
f"📁 *Директория:* `{working_dir}`\n\n"
f"Выберите действие:", f"Выберите действие:",
parse_mode="Markdown", parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("main") 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") 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": elif callback == "settings_menu":
state.current_menu = "settings" state.current_menu = "settings"
await query.edit_message_text( await query.edit_message_text(
@ -530,17 +741,31 @@ async def execute_cli_command(query, command: str):
"""Выполнение CLI команды из кнопки меню.""" """Выполнение CLI команды из кнопки меню."""
user_id = query.from_user.id user_id = query.from_user.id
state = state_manager.get(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 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( await query.edit_message_text(
f"⏳ *Выполнение...*\n\n`{command}`", f"⏳ *Выполнение...*\n"
f"🖥️ `{server_name}`\n"
f"```\n{command}\n```",
parse_mode="Markdown" 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: try:
process = await asyncio.create_subprocess_shell( process = await asyncio.create_subprocess_shell(
command, command,
@ -554,26 +779,7 @@ async def execute_cli_command(query, command: str):
timeout=30 timeout=30
) )
output = stdout.decode("utf-8", errors="replace") await _show_result(query, command, stdout, stderr, process.returncode)
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")
except asyncio.TimeoutError: except asyncio.TimeoutError:
await query.edit_message_text( 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 @check_access
async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE): async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка текстовых сообщений как CLI команд.""" """Обработка текстовых сообщений как CLI команд."""
@ -609,8 +880,10 @@ async def execute_cli_command_from_message(update: Update, command: str):
"""Выполнение CLI команды из сообщения.""" """Выполнение CLI команды из сообщения."""
user_id = update.effective_user.id user_id = update.effective_user.id
state = state_manager.get(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 working_dir = state.working_directory or config.working_directory
# Обработка команды cd - меняем директорию пользователя # Обработка команды cd - меняем директорию пользователя
@ -631,7 +904,8 @@ async def execute_cli_command_from_message(update: Update, command: str):
if Path(target_dir).is_dir(): if Path(target_dir).is_dir():
state.working_directory = target_dir state.working_directory = target_dir
await update.message.reply_text( await update.message.reply_text(
f"📁 *Директория изменена:*\n`{target_dir}`", f"📁 *Директория изменена:*\n`{target_dir}`\n"
f"🖥️ Сервер: `{server_name}`",
parse_mode="Markdown" parse_mode="Markdown"
) )
else: else:
@ -641,75 +915,96 @@ async def execute_cli_command_from_message(update: Update, command: str):
) )
return return
# Проверка на составную команду с cd - выполняем и сохраняем конечную директорию # Для составных команд с cd — выполняем через SSH или локально
if "cd " in cmd_stripped and ("&&" in cmd_stripped or ";" in cmd_stripped): if "cd " in cmd_stripped and ("&&" in cmd_stripped or ";" in cmd_stripped):
# Добавляем pwd в конец для получения конечной директории if server_name == "local" or server is None:
command_with_pwd = f"{cmd_stripped} && pwd" await _execute_composite_command_local(update, cmd_stripped, working_dir)
logger.info(f"Выполнение составной команды с cd: {command_with_pwd} в директории: {working_dir}") else:
await _execute_composite_command_ssh(update, cmd_stripped, server, 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"
)
return 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: try:
process = await asyncio.create_subprocess_shell( process = await asyncio.create_subprocess_shell(
command, command,
@ -717,43 +1012,56 @@ async def execute_cli_command_from_message(update: Update, command: str):
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=working_dir cwd=working_dir
) )
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
stdout, stderr = await asyncio.wait_for( await _show_result_message(update, command, stdout.decode(), stderr.decode(), process.returncode)
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")
except asyncio.TimeoutError: except asyncio.TimeoutError:
await update.message.reply_text( await update.message.reply_text("❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд.", parse_mode="Markdown")
"❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд и была прервана.",
parse_mode="Markdown"
)
except Exception as e: except Exception as e:
logger.error(f"Ошибка выполнения команды: {e}") logger.error(f"Ошибка: {e}")
await update.message.reply_text( await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown")
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): async def post_init(application: Application):
@ -784,6 +1092,9 @@ def main():
print("\nИли запустите ./run.sh для интерактивной настройки") print("\nИли запустите ./run.sh для интерактивной настройки")
sys.exit(1) sys.exit(1)
# Загрузка серверов из env
server_manager.load_from_env()
# Инициализация меню # Инициализация меню
init_menus() init_menus()

View File

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