From 33263e66309da695b06cf11cd7532e9d9942238c Mon Sep 17 00:00:00 2001 From: mirivlad Date: Wed, 25 Feb 2026 00:07:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=BE=D0=BA=D0=BE=D0=BD=D1=87?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20bot.py=20=D0=BE=D1=82=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bot.py: 2365 → 1411 строк (-954 строки, -40%) - Удалены дублирующиеся функции которые теперь в модулях: - start_command, menu_command, help_command, settings_command → bot/handlers/commands.py - menu_callback → bot/handlers/callbacks.py - execute_cli_command, _execute_local_command, _execute_ssh_command, _show_result → bot/services/command_executor.py - clean_ansi_codes, normalize_output, split_message, send_long_message → bot/utils/ - init_menus → bot/keyboards/menus.py - Оставлены только: - handle_text_message, handle_ai_task - handle_ssh_session_input, handle_local_session_input, handle_server_input - finish_edit_server, stop_command, ai_command, memory_command, facts_command, forget_command - post_init, main - Исправлены импорты в decorators.py, command_executor.py - Добавлены ssh_session_manager, local_session_manager в bot/models/session.py Version: 0.5.4 Co-authored-by: Qwen-Coder --- bot.py | 970 +------------------------------ bot/models/session.py | 4 + bot/services/__init__.py | 2 - bot/services/command_executor.py | 3 +- 4 files changed, 14 insertions(+), 965 deletions(-) diff --git a/bot.py b/bot.py index bf9e4e9..fffc71a 100644 --- a/bot.py +++ b/bot.py @@ -1,9 +1,7 @@ #!/usr/bin/env python3 """ Telegram CLI Bot - бот для выполнения CLI команд с многоуровневым меню. -Легкое добавление новых команд через регистрацию хендлеров. - -Версия: 0.5.0 (рефакторинг) +Версия: 0.5.3 (модульная структура) """ import os @@ -64,7 +62,7 @@ logger = logging.getLogger(__name__) # ============================================================================ -# ИМПОРТЫ ИЗ bot/ - новая модульная структура +# ИМПОРТЫ ИЗ bot/ - модульная структура # ============================================================================ from bot.config import config, state_manager, menu_builder, command_registry, server_manager from bot.models.server import Server @@ -75,968 +73,16 @@ from bot.utils.ssh_readers import detect_input_type, read_ssh_output, read_pty_o from bot.utils.decorators import check_access from bot.keyboards.menus import MenuItem, init_menus +# Импорты хендлеров из модулей +from bot.handlers.commands import start_command, menu_command, help_command, settings_command +from bot.handlers.callbacks import menu_callback +from bot.services.command_executor import execute_cli_command + # Глобальные менеджеры сессий ssh_session_manager = SSHSessionManager() local_session_manager = LocalSessionManager() -def init_menus(): - """Инициализация структуры меню.""" - # Главное меню - main_menu = [ - MenuItem("🖥️ Выбор сервера", "server_menu", icon="🖥️"), - MenuItem("📋 Предустановленные команды", "preset_menu", icon="📋"), - MenuItem("💬 Чат с ИИ агентом", "toggle_ai_chat", 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 = [ - MenuItem("📁 Файловая система", "fs_menu", icon="📁"), - MenuItem("🔍 Поиск", "search_menu", icon="🔍"), - MenuItem("📊 Система", "system_menu", icon="📊"), - MenuItem("🌐 Сеть", "network_menu", icon="🌐"), - MenuItem("⬅️ Назад", "main", icon="⬅️"), - ] - menu_builder.add_menu("preset", preset_menu) - - # Файловая система - fs_menu = [ - MenuItem("ls -la", "cmd_ls_la", command="ls -la", icon="📄"), - MenuItem("pwd", "cmd_pwd", command="pwd", icon="📍"), - MenuItem("df -h", "cmd_df", command="df -h", icon="💾"), - MenuItem("du -sh *", "cmd_du", command="du -sh * 2>/dev/null | sort -hr | head -20", icon="📊"), - MenuItem("⬅️ Назад", "preset", icon="⬅️"), - ] - menu_builder.add_menu("fs", fs_menu) - - # Поиск - search_menu = [ - MenuItem("find . -name", "cmd_find_name", command="find . -maxdepth 3 -name '*.txt' 2>/dev/null", icon="🔎"), - MenuItem("grep пример", "cmd_grep", command="grep -r 'example' . 2>/dev/null | head -20", icon="🔍"), - MenuItem("which command", "cmd_which", command="which python3 bash git", icon="📍"), - MenuItem("⬅️ Назад", "preset", icon="⬅️"), - ] - menu_builder.add_menu("search", search_menu) - - # Система - system_menu = [ - MenuItem("top -n 1", "cmd_top", command="top -bn1 | head -20", icon="📈"), - MenuItem("ps aux", "cmd_ps", command="ps aux | head -20", icon="🔄"), - MenuItem("free -h", "cmd_free", command="free -h", icon="💾"), - MenuItem("uname -a", "cmd_uname", command="uname -a", icon="ℹ️"), - MenuItem("uptime", "cmd_uptime", command="uptime", icon="⏱️"), - MenuItem("⬅️ Назад", "preset", icon="⬅️"), - ] - menu_builder.add_menu("system", system_menu) - - # Сеть - network_menu = [ - MenuItem("ip addr", "cmd_ip", command="ip addr 2>/dev/null || ifconfig 2>/dev/null", icon="🌐"), - MenuItem("ping google", "cmd_ping", command="ping -c 4 google.com 2>&1 | head -10", icon="📡"), - MenuItem("netstat", "cmd_netstat", command="ss -tuln 2>/dev/null || netstat -tuln 2>/dev/null | head -20", icon="🔌"), - MenuItem("curl ifconfig.me", "cmd_curl_ip", command="curl -s ifconfig.me 2>&1", icon="📍"), - MenuItem("⬅️ Назад", "preset", icon="⬅️"), - ] - menu_builder.add_menu("network", network_menu) - - # Настройки - settings_menu = [ - MenuItem("📝 Изменить имя бота", "set_name", icon="📝"), - MenuItem("📄 Изменить описание", "set_description", icon="📄"), - MenuItem("🎨 Изменить иконку", "set_icon", icon="🎨"), - MenuItem("👥 Управление доступом", "access_menu", icon="👥"), - MenuItem("🧠 Память ИИ", "memory_menu", icon="🧠"), - MenuItem("⬅️ Назад", "main", icon="⬅️"), - ] - menu_builder.add_menu("settings", settings_menu) - - # Память ИИ - memory_menu = [ - MenuItem("📋 Мой профиль", "memory_profile", icon="📋"), - MenuItem("📊 Статистика", "memory_stats", icon="📊"), - MenuItem("🗑️ Очистить историю", "memory_clear", icon="🗑️"), - MenuItem("⬅️ Назад", "settings", icon="⬅️"), - ] - menu_builder.add_menu("memory", memory_menu) - - # Доступ - access_menu = [ - MenuItem("📋 Показать разрешённых", "show_access", icon="📋"), - MenuItem("➕ Добавить пользователя", "add_access", icon="➕"), - MenuItem("➖ Удалить пользователя", "remove_access", icon="➖"), - MenuItem("⬅️ Назад", "settings", icon="⬅️"), - ] - menu_builder.add_menu("access", access_menu) - - -# --- Хендлеры --- - -@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, state=state) - ) - - -@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") - ) - - -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 = [] - - -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") - - -@check_access async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработка текстовых сообщений как CLI команд.""" user_id = update.effective_user.id @@ -2318,7 +1364,7 @@ def main(): server_manager.load_from_env() # Инициализация меню - init_menus() + init_menus(menu_builder) # Создание приложения с таймаутами и прокси builder = ( diff --git a/bot/models/session.py b/bot/models/session.py index c5ab027..035a4c9 100644 --- a/bot/models/session.py +++ b/bot/models/session.py @@ -183,3 +183,7 @@ INPUT_PATTERNS = { r"[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+:.*[$#]\s*$", ], } + +# Глобальные менеджеры сессий +ssh_session_manager = SSHSessionManager() +local_session_manager = LocalSessionManager() diff --git a/bot/services/__init__.py b/bot/services/__init__.py index 6627d89..bc83a98 100644 --- a/bot/services/__init__.py +++ b/bot/services/__init__.py @@ -3,7 +3,6 @@ from bot.services.command_executor import ( execute_cli_command, - execute_cli_command_from_message, _execute_local_command, _execute_ssh_command, _show_result, @@ -11,7 +10,6 @@ from bot.services.command_executor import ( __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 9f13a0e..dc81511 100644 --- a/bot/services/command_executor.py +++ b/bot/services/command_executor.py @@ -8,8 +8,9 @@ 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.config import config, state_manager, server_manager from bot.models.server import Server +from bot.models.session import ssh_session_manager, local_session_manager from bot.utils.ssh_readers import read_ssh_output, read_pty_output, detect_input_type from bot.utils.formatters import format_long_output