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
|
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
|
||||||
|
|
|
||||||
501
bot.py
501
bot.py
|
|
@ -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,16 +915,25 @@ 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)
|
||||||
|
return
|
||||||
|
|
||||||
await update.message.reply_text(
|
# Обычное выполнение
|
||||||
f"⏳ *Выполнение...*\n\n`{cmd_stripped}`",
|
if server_name == "local" or server is None:
|
||||||
parse_mode="Markdown"
|
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:
|
try:
|
||||||
process = await asyncio.create_subprocess_shell(
|
process = await asyncio.create_subprocess_shell(
|
||||||
|
|
@ -660,56 +943,68 @@ async def execute_cli_command_from_message(update: Update, command: str):
|
||||||
cwd=working_dir
|
cwd=working_dir
|
||||||
)
|
)
|
||||||
|
|
||||||
stdout, stderr = await asyncio.wait_for(
|
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
|
||||||
process.communicate(),
|
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
|
|
||||||
output = stdout.decode("utf-8", errors="replace").strip()
|
output = stdout.decode("utf-8", errors="replace").strip()
|
||||||
error = stderr.decode("utf-8", errors="replace")
|
error = stderr.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
# Последняя строка - это pwd, сохраняем её
|
# Последняя строка - это pwd
|
||||||
if output and process.returncode == 0:
|
if output and process.returncode == 0:
|
||||||
lines = output.split('\n')
|
lines = output.split('\n')
|
||||||
final_dir = lines[-1].strip()
|
final_dir = lines[-1].strip()
|
||||||
# Проверяем, что это действительно путь
|
|
||||||
if Path(final_dir).is_dir():
|
if Path(final_dir).is_dir():
|
||||||
state.working_directory = final_dir
|
state_manager.get(update.effective_user.id).working_directory = final_dir
|
||||||
# Убираем pwd из вывода
|
|
||||||
output = '\n'.join(lines[:-1])
|
output = '\n'.join(lines[:-1])
|
||||||
|
|
||||||
result = f"✅ *Результат:*\n\n"
|
await _show_result_message(update, command, output, error, process.returncode)
|
||||||
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:
|
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"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"Выполнение команды: {command} в директории: {working_dir}")
|
|
||||||
|
|
||||||
|
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,17 +1012,42 @@ 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)
|
||||||
|
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")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка: {e}")
|
||||||
|
await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown")
|
||||||
|
|
||||||
stdout, stderr = await asyncio.wait_for(
|
|
||||||
process.communicate(),
|
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
|
|
||||||
output = stdout.decode("utf-8", errors="replace")
|
async def _execute_ssh_command_message(update: Update, command: str, server: Server, working_dir: str):
|
||||||
error = stderr.decode("utf-8", errors="replace")
|
"""Выполнение команды через SSH из сообщения."""
|
||||||
|
try:
|
||||||
|
client_keys = [server_manager.ssh_key_path] if server_manager.ssh_key_path else None
|
||||||
|
|
||||||
result = f"✅ *Результат:*\n\n"
|
async with asyncssh.connect(
|
||||||
result += f"```\n{command}\n```\n\n"
|
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 output:
|
||||||
if len(output) > 4000:
|
if len(output) > 4000:
|
||||||
|
|
@ -739,22 +1059,10 @@ async def execute_cli_command_from_message(update: Update, command: str):
|
||||||
error = error[:4000] + "\n... (вывод обрезан)"
|
error = error[:4000] + "\n... (вывод обрезан)"
|
||||||
result += f"*Ошибки:*\n```\n{error}\n```\n"
|
result += f"*Ошибки:*\n```\n{error}\n```\n"
|
||||||
|
|
||||||
result += f"\n*Код возврата:* `{process.returncode}`"
|
result += f"\n*Код возврата:* `{returncode}`"
|
||||||
|
|
||||||
await update.message.reply_text(result, parse_mode="Markdown")
|
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue