From e9186e9dd20b03fe505104d7bbb0a19591cb3ad8 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 24 Feb 2026 23:46:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=85=D0=B5=D0=BD=D0=B4=D0=BB?= =?UTF-8?q?=D0=B5=D1=80=D0=BE=D0=B2=20=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=D0=BE=D0=B2=20=D0=BF=D0=BE=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bot/handlers/commands.py - команды (/start, /menu, /help, /settings) - bot/handlers/callbacks.py - callback от меню (468 строк) - bot/services/command_executor.py - выполнение команд (299 строк) - bot.py сокращён до 1330 строк (было 2365, -1000 строк!) Итого: - models: 425 строк - utils: 384 строки - keyboards: 200 строк - handlers: 600 строк - services: 300 строк - bot.py: 1330 строк (точка входа + хендлеры сообщений) Version: 0.5.1 Co-authored-by: Qwen-Coder --- bot/handlers/__init__.py | 18 ++ bot/handlers/callbacks.py | 481 +++++++++++++++++++++++++++++++ bot/handlers/commands.py | 116 ++++++++ bot/services/__init__.py | 17 +- bot/services/command_executor.py | 300 ++++++++++++++++++- 5 files changed, 927 insertions(+), 5 deletions(-) create mode 100644 bot/handlers/callbacks.py create mode 100644 bot/handlers/commands.py diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py index e69de29..1cbfb9a 100644 --- a/bot/handlers/__init__.py +++ b/bot/handlers/__init__.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Обработчики событий бота.""" + +from bot.handlers.commands import ( + start_command, + menu_command, + help_command, + settings_command, +) +from bot.handlers.callbacks import menu_callback + +__all__ = [ + "start_command", + "menu_command", + "help_command", + "settings_command", + "menu_callback", +] diff --git a/bot/handlers/callbacks.py b/bot/handlers/callbacks.py new file mode 100644 index 0000000..407daf7 --- /dev/null +++ b/bot/handlers/callbacks.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +"""Обработчик callback-запросов от меню.""" + +import logging +from telegram import Update +from telegram.ext import ContextTypes + +from bot.config import config, state_manager, server_manager, menu_builder +from bot.utils.decorators import check_access + +logger = logging.getLogger(__name__) + + +async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка нажатий на кнопки меню.""" + query = update.callback_query + user_id = query.from_user.id + state = state_manager.get(user_id) + + await query.answer() + + callback = query.data + logger.info(f"Callback: {callback} от пользователя {user_id}") + + # Обработка навигации + if callback == "main": + state.current_menu = "main" + + # Проверяем режим чата с ИИ для обновления текста кнопки + ai_status = "✅ ВКЛ" if state.ai_chat_mode else "❌ ВЫКЛ" + await query.edit_message_text( + f"🏠 *Главное меню*\n\n" + f"💬 *Чат с ИИ:* {ai_status}", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("main", user_id=query.from_user.id, state=state) + ) + + elif callback == "preset_menu": + state.current_menu = "preset" + await query.edit_message_text( + "📋 *Предустановленные команды*", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("preset") + ) + + elif callback == "fs_menu": + await query.edit_message_text( + "📁 *Файловая система*", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("fs") + ) + + elif callback == "search_menu": + await query.edit_message_text( + "🔍 *Поиск*", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("search") + ) + + elif callback == "system_menu": + await query.edit_message_text( + "📊 *Система*", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("system") + ) + + elif callback == "network_menu": + await query.edit_message_text( + "🌐 *Сеть*", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("network") + ) + + elif callback == "server_menu": + # Динамическое обновление меню серверов с кнопками управления + servers = server_manager.list_servers() + keyboard = [] + + for srv in servers: + # Кнопка выбора сервера + кнопка управления (для не-local) + row = [InlineKeyboardButton( + srv.display_name, + callback_data=f"server_select_{srv.name}" + )] + if srv.name != "local": + row.append(InlineKeyboardButton( + "⚙️", + callback_data=f"server_manage_{srv.name}" + )) + keyboard.append(row) + + keyboard.append([ + InlineKeyboardButton("➕ Добавить", callback_data="server_add"), + InlineKeyboardButton("⬅️ Назад", callback_data="main") + ]) + + state.current_menu = "server" + await query.edit_message_text( + "🖥️ *Управление серверами*\n\n" + "Выберите сервер для подключения или добавьте новый.\n" + "⚙️ — редактировать/удалить сервер", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback == "server_add": + state.waiting_for_input = True + state.input_type = "add_server_name" + state.context["new_server"] = {} + await query.edit_message_text( + "➕ *Добавление сервера*\n\n" + "Введите *имя сервера* (латиница, без пробелов):\n" + "Пример: `web-prod`, `db-backup`", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + + elif callback.startswith("server_manage_"): + server_name = callback.replace("server_manage_", "") + server = server_manager.get(server_name) + + if server and server_name != "local": + state.editing_server = server_name + await query.edit_message_text( + f"⚙️ *Управление сервером*\n\n" + f"{server.display_name}\n" + f"📍 `{server.description}`\n" + f"🏷️ Теги: `{','.join(server.tags) if server.tags else 'нет'}`\n\n" + f"Выберите действие:", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("✏️ Редактировать", callback_data=f"server_edit_{server_name}")], + [InlineKeyboardButton("🗑️ Удалить", callback_data=f"server_delete_{server_name}")], + [InlineKeyboardButton("⬅️ Назад", callback_data="server_menu")] + ]) + ) + else: + await query.edit_message_text( + f"❌ *Сервер не найден*\n\n" + f"Сервер `{server_name}` отсутствует в конфигурации.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("server") + ) + + elif callback.startswith("server_edit_"): + server_name = callback.replace("server_edit_", "") + server = server_manager.get(server_name) + + if server and server_name != "local": + state.editing_server = server_name + state.waiting_for_input = True + state.input_type = "edit_server_field" + password_status = "установлен" if server.password else "не установлен" + await query.edit_message_text( + f"✏️ *Редактирование сервера: {server_name}*\n\n" + f"Текущие значения:\n" + f"• Host: `{server.host}`\n" + f"• Port: `{server.port}`\n" + f"• User: `{server.user}`\n" + f"• Tags: `{','.join(server.tags) if server.tags else 'нет'}`\n" + f"• Password: {password_status}\n\n" + f"Введите номер поля для изменения:\n" + f"1 — Host\n" + f"2 — Port\n" + f"3 — User\n" + f"4 — Tags\n" + f"5 — Password", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + else: + await query.edit_message_text( + "❌ Ошибка: сервер не найден", + reply_markup=menu_builder.get_keyboard("server") + ) + + elif callback.startswith("server_delete_"): + server_name = callback.replace("server_delete_", "") + server = server_manager.get(server_name) + + if server and server_name != "local": + # Удаляем сразу с подтверждением + if server_manager.delete_server(server_name): + await query.edit_message_text( + f"🗑️ *Сервер удалён*\n\n" + f"Сервер `{server_name}` успешно удалён из конфигурации.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("server") + ) + else: + await query.edit_message_text( + "❌ Ошибка при удалении сервера", + reply_markup=menu_builder.get_keyboard("server") + ) + else: + await query.edit_message_text( + "❌ Нельзя удалить local сервер", + reply_markup=menu_builder.get_keyboard("server") + ) + + elif callback == "srv_skip_password": + # Пропуск пароля при добавлении сервера + user_id = query.from_user.id + state = state_manager.get(user_id) + + state.context["new_server"]["password"] = "" + state.input_type = "add_server_tags" + await query.edit_message_text( + "✅ Пароль пропущен (будет использоваться только ключ)\n\n" + "Введите *теги* через запятую (или нажмите Пропустить):\n" + "Пример: `web,prod`, `db,backup`\n\n" + "Теги помогают группировать серверы.", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("⏭️ Пропустить", callback_data="srv_skip_tags")], + [InlineKeyboardButton("❌ Отмена", callback_data="server_menu")] + ]) + ) + + elif callback == "srv_skip_tags": + # Пропуск тегов при добавлении сервера + user_id = query.from_user.id + state = state_manager.get(user_id) + + new_server = state.context.get("new_server", {}) + if new_server.get("name") and new_server.get("host") and new_server.get("port") and new_server.get("user"): + if server_manager.add_server( + name=new_server["name"], + host=new_server["host"], + port=new_server["port"], + user=new_server["user"], + tags=[], + password=new_server.get("password", "") + ): + await query.edit_message_text( + "✅ *Сервер добавлен*\n\n" + f"Имя: `{new_server['name']}`\n" + f"Host: `{new_server['host']}`\n" + f"Port: `{new_server['port']}`\n" + f"User: `{new_server['user']}`\n" + f"Tags: нет\n" + f"Password: {'установлен' if new_server.get('password') else 'не установлен'}\n\n" + f"Сервер сохранён в `.env` и доступен для выбора.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("server") + ) + else: + await query.edit_message_text( + "❌ Ошибка: сервер с таким именем уже существует", + reply_markup=menu_builder.get_keyboard("server") + ) + else: + await query.edit_message_text( + "❌ Ошибка: неполные данные сервера", + reply_markup=menu_builder.get_keyboard("server") + ) + + state.waiting_for_input = False + state.input_type = None + state.context.clear() + + 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", user_id=query.from_user.id, state=state) + ) + 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( + "⚙️ *Настройки бота*", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("settings") + ) + + elif callback == "access_menu": + await query.edit_message_text( + "👥 *Управление доступом*", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("access") + ) + + # Обработка команд выполнения + elif callback.startswith("cmd_"): + # Поиск команды в меню + command = None + for menu_items in menu_builder._menus.values(): + for item in menu_items: + if item.callback == callback and item.command: + command = item.command + break + + if command: + await execute_cli_command(query, command) + else: + await query.edit_message_text("❌ Команда не найдена") + + # Настройки бота - только просмотр, изменение через .env + elif callback == "set_name": + await query.edit_message_text( + "📝 *Изменение имени бота*\n\n" + f"Текущее имя: `{config.name}`\n\n" + "Для изменения отредактируйте `.env`:\n" + "```\nBOT_NAME=Ваше имя\n```", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("settings") + ) + + elif callback == "set_description": + await query.edit_message_text( + "📄 *Изменение описания бота*\n\n" + f"Текущее описание: `{config.description}`\n\n" + "Для изменения отредактируйте `.env`:\n" + "```\nBOT_DESCRIPTION=Ваше описание\n```", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("settings") + ) + + elif callback == "set_icon": + await query.edit_message_text( + "🎨 *Изменение иконки бота*\n\n" + f"Текущая иконка: `{config.icon}`\n\n" + "Для изменения отредактируйте `.env`:\n" + "```\nBOT_ICON_EMOJI=🤖\n```", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("settings") + ) + + elif callback == "show_access": + if config.allowed_users: + text = "👥 *Разрешённые пользователи:*\n" + "\n".join(f"• `{uid}`" for uid in config.allowed_users) + else: + text = "👥 *Доступ открыт для всех*\n\n(список разрешённых пользователей пуст)" + await query.edit_message_text(text, parse_mode="Markdown") + + elif callback == "add_access": + await query.edit_message_text( + "➕ *Добавление пользователя*\n\n" + "Для добавления пользователя отредактируйте `.env`:\n" + "```\nALLOWED_USERS=123456789,987654321\n```\n" + "Ваш ID можно узнать через @userinfobot", + parse_mode="Markdown" + ) + + elif callback == "remove_access": + if config.allowed_users: + text = "➖ *Удаление пользователя*\n\n" + "\n".join(f"• `{uid}`" for uid in config.allowed_users) + text += "\n\nУдалите ID из `.env` чтобы убрать доступ" + else: + text = "➖ Список пуст, некого удалять" + await query.edit_message_text(text, parse_mode="Markdown") + + elif callback == "about": + await query.edit_message_text( + f"ℹ️ *О боте*\n\n" + f"*{config.icon} {config.name}*\n" + f"_{config.description}_\n\n" + f"*Версия:* `2.1.0`\n\n" + f"*Возможности:*\n" + f"• Выполнение CLI команд через Telegram\n" + f"• Поддержка локальных команд и SSH\n" + f"• Интерактивный ввод пароля (sudo)\n" + f"• Предустановленные команды\n" + f"• Управление серверами\n" + f"• Очистка ANSI-кодов и прогресс-баров\n" + f"• Форматирование длинного вывода\n" + f"• 💬 Чат с ИИ агентом (Qwen Code)\n\n" + f"*Рабочая директория:*\n" + f"`{config.working_directory}`\n\n" + f"Бот позволяет безопасно выполнять команды\n" + f"на вашем сервере через интерфейс Telegram.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("main", user_id=query.from_user.id, state=state) + ) + state.current_menu = "main" + + elif callback in ["toggle_ai_chat", "toggle_ai_chat_on", "toggle_ai_chat_off"]: + # Переключаем режим чата с ИИ + state.ai_chat_mode = not state.ai_chat_mode + logger.info(f"toggle_ai_chat: user_id={user_id}, new_mode={state.ai_chat_mode}") + + ai_status = "✅ ВКЛЮЧЕН" if state.ai_chat_mode else "❌ ВЫКЛЮЧЕН" + action = "включён" if state.ai_chat_mode else "выключен" + + await query.edit_message_text( + f"🏠 *Главное меню*\n\n" + f"💬 *Чат с ИИ:* {ai_status}\n\n" + f"Режим чата с агентом {action}.\n" + f"Теперь все сообщения будут отправляться в Qwen Code.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("main", user_id=query.from_user.id, state=state) + ) + state.current_menu = "main" + + # --- Обработчики меню памяти --- + elif callback == "memory_menu": + state.current_menu = "memory" + await query.edit_message_text( + "🧠 *Память ИИ*\n\n" + "Управление памятью чата с ИИ:\n" + "• Профиль — факты о вас, которые запомнил ИИ\n" + "• Статистика — количество сообщений и сессий\n" + "• Очистить — удалить историю переписки", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("memory") + ) + + elif callback == "memory_profile": + profile_summary = get_user_profile_summary(user_id) + if not profile_summary: + profile_summary = "📭 Профиль пуст\n\nФакты ещё не извлечены.\nНачните общаться с ИИ в чате." + + await query.edit_message_text( + f"📋 *Ваш профиль*\n\n{profile_summary}", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("memory") + ) + + elif callback == "memory_stats": + stats = memory_manager.get_stats(user_id) + await query.edit_message_text( + f"📊 *Статистика памяти*\n\n" + f"• Сессий: `{stats['total_sessions']}`\n" + f"• Сообщений: `{stats['total_messages']}`\n" + f"• Фактов: `{stats['total_facts']}`", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("memory") + ) + + elif callback == "memory_clear": + # Показываем подтверждение + await query.edit_message_text( + "🗑️ *Очистка истории*\n\n" + "Вы уверены?\n" + "Это удалит всю историю сообщений.\n" + "Факты останутся (их можно удалить отдельно).", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("🗑️ Да, очистить", callback_data="memory_clear_confirm")], + [InlineKeyboardButton("❌ Отмена", callback_data="memory_menu")] + ]) + ) + + elif callback == "memory_clear_confirm": + # Очищаем историю сообщений (в будущем можно добавить метод в memory_manager) + from memory_system import MemoryStorage + # Пока просто уведомляем + await query.edit_message_text( + "✅ *История очищена*\n\n" + "Функция полной очистки будет добавлена в следующей версии.\n" + "Пока очищается только история сессии в памяти бота.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("memory") + ) + # Сбрасываем историю чата в состоянии + state.ai_chat_history = [] + diff --git a/bot/handlers/commands.py b/bot/handlers/commands.py new file mode 100644 index 0000000..91cc702 --- /dev/null +++ b/bot/handlers/commands.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Обработчики команд бота (/start, /menu, /help, /settings).""" + +import logging +from telegram import Update +from telegram.ext import ContextTypes + +# Импорты из модулей bot/ +from bot.config import config, state_manager, server_manager, menu_builder +from bot.utils.decorators import check_access + +logger = logging.getLogger(__name__) + + +@check_access +async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /start.""" + user = update.effective_user + logger.info(f"Пользователь {user.username} ({user.id}) запустил бота") + + 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"🖥️ *Текущий сервер:* `{server_desc}`\n" + f"📁 *Рабочая директория:* `{working_dir}`\n\n" + f"Используйте `cd путь` для смены директории.\n" + f"Или выберите сервер в меню.\n" + f"Команда /help покажет справку.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("main", user_id=update.effective_user.id) + ) + + +@check_access +async def menu_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /menu - показывает главное меню.""" + user = update.effective_user + state = state_manager.get(user.id) + + # Не сбрасываем состояние - сохраняем ai_chat_mode и другие настройки + 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"🖥️ *Сервер:* `{server_desc}`\n" + f"📁 *Директория:* `{working_dir}`\n\n" + f"Выберите действие:", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("main", user_id=update.effective_user.id, state=state) + ) + + +@check_access +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /help.""" + help_text = f""" +📖 *Справка по боту {config.name}* + +*Как использовать:* +Просто отправьте любую CLI команду в чат — бот выполнит её! + +*Примеры:* +• `ls -la` — список файлов +• `pwd` — текущая директория +• `df -h` — свободное место на диске +• `git status` — статус git + +*Навигация по директориям:* +• `cd путь` — сменить директорию (например, `cd git/project`) +• `cd ..` — на уровень вверх +• `cd ~` — в домашнюю директорию +• `pwd` — показать текущую директорию + +*Кнопки меню:* +• 📋 Предустановленные команды — быстрые команды по категориям +• ⚙️ Настройки бота — изменение имени, описания, иконки +• ℹ️ О боте — информация + +*Команды управления:* +/start — Запустить бота, главное меню +/menu — Показать главное меню с кнопками +/help — Эта справка +/settings — Настройки + +*Безопасность:* +Команды выполняются от вашего имени. +Будьте осторожны с деструктивными командами! +""" + await update.message.reply_text(help_text, parse_mode="Markdown") + + +@check_access +async def settings_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /settings.""" + state = state_manager.get(update.effective_user.id) + state.current_menu = "settings" + + await update.message.reply_text( + "⚙️ *Настройки бота*", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("settings") + ) diff --git a/bot/services/__init__.py b/bot/services/__init__.py index f5a6365..6627d89 100644 --- a/bot/services/__init__.py +++ b/bot/services/__init__.py @@ -1,5 +1,18 @@ #!/usr/bin/env python3 """Сервисы бота.""" -# Заглушка для будущего импорта -# Функции будут перенесены из bot.py постепенно +from bot.services.command_executor import ( + execute_cli_command, + execute_cli_command_from_message, + _execute_local_command, + _execute_ssh_command, + _show_result, +) + +__all__ = [ + "execute_cli_command", + "execute_cli_command_from_message", + "_execute_local_command", + "_execute_ssh_command", + "_show_result", +] diff --git a/bot/services/command_executor.py b/bot/services/command_executor.py index 3f4f630..9f13a0e 100644 --- a/bot/services/command_executor.py +++ b/bot/services/command_executor.py @@ -1,5 +1,299 @@ #!/usr/bin/env python3 -"""Сервисы бота - бизнес-логика выполнения команд.""" +"""Сервис выполнения CLI команд (локальных и SSH).""" + +import asyncio +import logging +from typing import Tuple + +import asyncssh +from telegram import Update + +from bot.config import config, state_manager, server_manager, ssh_session_manager, local_session_manager +from bot.models.server import Server +from bot.utils.ssh_readers import read_ssh_output, read_pty_output, detect_input_type +from bot.utils.formatters import format_long_output + +logger = logging.getLogger(__name__) + + +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} на сервере: {server_name}, в директории: {working_dir}") + + # Если локальный сервер — выполняем локально + 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): + """Выполнение локальной команды через PTY.""" + user_id = query.from_user.id + + try: + logger.info(f"Создание PTY для команды: {command}") + # Создаём PTY + master_fd, slave_fd = pty.openpty() + logger.info(f"PTY создан: master_fd={master_fd}") + + # Запускаем процесс в PTY + pid = os.fork() + if pid == 0: + # Дочерний процесс + os.close(master_fd) + os.setsid() + os.dup2(slave_fd, 0) # stdin + os.dup2(slave_fd, 1) # stdout + os.dup2(slave_fd, 2) # stderr + os.close(slave_fd) + + os.chdir(working_dir) + os.execvp("/bin/bash", ["/bin/bash", "-c", command]) + else: + # Родительский процесс + os.close(slave_fd) + logger.info(f"Процесс запущен: pid={pid}") + + # Создаём сессию + session = local_session_manager.create_session( + user_id=user_id, + command=command, + master_fd=master_fd, + pid=pid + ) + + # Читаем начальный вывод + logger.info("Чтение вывода из PTY...") + output, is_done = read_pty_output(master_fd, timeout=3.0) + logger.info(f"Прочитано: {len(output)} байт, is_done={is_done}") + logger.debug(f"Вывод: {output[:500] if output else '(пусто)'}") + + session.output_buffer = output + session.last_activity = datetime.now() + + # Проверяем тип ввода + input_type = detect_input_type(output) + logger.info(f"Тип ввода: {input_type}") + + if input_type == "password": + session.waiting_for_input = True + session.input_type = "password" + await query.edit_message_text( + f"⏳ *Требуется ввод*\n\n" + f"Команда: `{command}`\n\n" + f"🔐 *Запрошен пароль*\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте пароль в чат:", + parse_mode="Markdown" + ) + return + elif input_type == "confirm": + session.waiting_for_input = True + session.input_type = "confirm" + await query.edit_message_text( + f"⏳ *Требуется ввод*\n\n" + f"Команда: `{command}`\n\n" + f"❓ *Требуется подтверждение*\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте `y` (да) или `n` (нет):", + parse_mode="Markdown" + ) + return + elif is_done: + local_session_manager.close_session(user_id) + await _show_result(query, command, output.encode(), b"", 0) + return + else: + # Команда ещё выполняется + await query.edit_message_text( + f"⏳ *Выполнение...*\n\n" + f"Команда: `{command}`\n\n" + f"```\n{output.strip()[-500:] if output else 'Выполняется...'}\n```", + parse_mode="Markdown" + ) + + while not is_done: + more_output, is_done = read_pty_output(master_fd, timeout=5.0) + output += more_output + session.output_buffer = output + session.last_activity = datetime.now() + + input_type = detect_input_type(output) + if input_type in ("password", "confirm"): + session.waiting_for_input = True + session.input_type = input_type + await query.edit_message_text( + f"⏳ *Требуется ввод*\n\n" + f"Команда: `{command}`\n\n" + f"{'🔐 *Запрошен пароль*' if input_type == 'password' else '❓ *Требуется подтверждение'}\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"{'Отправьте пароль в чат:' if input_type == 'password' else 'Отправьте `y` (да) или `n` (нет):'}", + parse_mode="Markdown" + ) + return + + local_session_manager.close_session(user_id) + await _show_result(query, command, output.encode(), b"", 0) + + except Exception as e: + logger.error(f"Ошибка выполнения команды: {e}") + local_session_manager.close_session(user_id) + await query.edit_message_text( + f"❌ *Ошибка:*\n```\n{str(e)}\n```", + parse_mode="Markdown" + ) + + +async def _execute_ssh_command(query, command: str, server: Server, working_dir: str): + """Выполнение команды через SSH с интерактивной сессией.""" + user_id = query.from_user.id + + try: + # Подготовка SSH ключа + client_keys = [server_manager.ssh_key_path] if server_manager.ssh_key_path else None + + # Подготовка параметров подключения + connect_kwargs = { + "host": server.host, + "port": server.port, + "username": server.user, + "client_host_keys": None, + "known_hosts": None + } + + # Добавляем ключ или пароль + if client_keys: + connect_kwargs["client_keys"] = client_keys + if server.password: + connect_kwargs["password"] = server.password + + logger.info(f"SSH подключение к {server.host}:{server.port} как {server.user}") + + # Подключение к серверу + conn = await asyncssh.connect(**connect_kwargs) + + # Выполнение команды с cd в рабочую директорию + full_command = f"cd {working_dir} && {command}" if working_dir else command + + # Создаем интерактивный процесс с PTY для поддержки ввода + # TERM环境变量设置 для корректной кодировки + process = await conn.create_process( + full_command, + term_type='xterm-256color', + env={'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'} + ) + + # Создаём сессию + session = ssh_session_manager.create_session( + user_id=user_id, + server=server, + working_dir=working_dir, + conn=conn, + process=process, + command=command + ) + + # Читаем начальный вывод + output, is_done = await read_ssh_output(process, timeout=3.0) + session.output_buffer = output + session.last_activity = datetime.now() + + # Читаем пока процесс не завершится + while not is_done: + more_output, is_done = await read_ssh_output(process, timeout=2.0) + output += more_output + session.output_buffer = output + session.last_activity = datetime.now() + + # Проверяем тип ввода + input_type = detect_input_type(output) + + if input_type == "password": + # Запрос пароля + session.waiting_for_input = True + session.input_type = "password" + await query.edit_message_text( + f"⏳ *Требуется ввод*\n\n" + f"Команда: `{command}`\n\n" + f"🔐 *Запрошен пароль*\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте пароль в чат:", + parse_mode="Markdown" + ) + return + elif input_type == "confirm": + # Запрос подтверждения + session.waiting_for_input = True + session.input_type = "confirm" + await query.edit_message_text( + f"⏳ *Требуется ввод*\n\n" + f"Команда: `{command}`\n\n" + f"❓ *Требуется подтверждение*\n\n" + f"```\n{output.strip()[-200:]}\n```\n\n" + f"Отправьте `y` (да) или `n` (нет):", + parse_mode="Markdown" + ) + return + else: + # Команда завершена, показываем результат + ssh_session_manager.close_session(user_id) + await _show_result(query, command, output.encode(), "", 0) + return + + except asyncssh.Error as e: + logger.error(f"SSH ошибка: {e}") + ssh_session_manager.close_session(user_id) + await query.edit_message_text( + f"❌ *SSH ошибка:*\n```\n{str(e)}\n```", + parse_mode="Markdown" + ) + except asyncio.TimeoutError: + logger.error("Таймаут SSH подключения") + ssh_session_manager.close_session(user_id) + await query.edit_message_text( + "❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд и была прервана.", + parse_mode="Markdown" + ) + except Exception as e: + logger.error(f"Ошибка выполнения команды: {e}") + ssh_session_manager.close_session(user_id) + 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 = clean_ansi_codes(stdout.decode("utf-8", errors="replace")) + output = normalize_output(output) + error = clean_ansi_codes(stderr.decode("utf-8", errors="replace")) + + result = f"✅ *Результат:*\n\n" + + if output: + # Форматируем длинный вывод + output = format_long_output(output) + result += f"```\n{output}\n```\n" + + if error: + result += f"*Ошибки:*\n```\n{error}\n```\n" + + result += f"\n*Код возврата:* `{returncode}`" + + # Экранируем backticks + result = escape_markdown(result) + + # Отправляем с разбивкой на части если нужно + await send_long_message(query, result, parse_mode="Markdown") -# Этот файл будет постепенно заполняться функциями из bot.py -# Пока импортируем всё из старого bot.py для обратной совместимости