From 7a110e6974ffb0d21655dac622157b6de538e8f9 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Mon, 23 Feb 2026 17:25:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CRUD=20=D0=B4=D0=BB=D1=8F=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BE=D0=B2=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20Telegram=20=D0=BC=D0=B5=D0=BD=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Возможности: - Добавление сервера через пошаговую форму (имя, host, port, user, tags) - Редактирование сервера (выбор поля для изменения) - Удаление сервера (кроме local) - Сохранение изменений в .env автоматически UI: - Меню серверов с кнопками управления (⚙️) - Пошаговый ввод с валидацией - Кнопки отмены и пропуска тегов - Индикация текущего состояния Серверы: - add_server(name, host, port, user, tags) - update_server(name, host, port, user, tags) - delete_server(name) - save_to_env() - сохраняет в .env файл Валидация: - Имя: только латиница, дефисы, подчёркивания - Порт: число 1-65535 - Теги: список через запятую Co-authored-by: Qwen-Coder --- bot.py | 457 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 443 insertions(+), 14 deletions(-) diff --git a/bot.py b/bot.py index 2a8c3ab..da36c29 100644 --- a/bot.py +++ b/bot.py @@ -211,6 +211,76 @@ class ServerManager: keyboard.append([button]) return InlineKeyboardMarkup(keyboard) + def add_server(self, name: str, host: str, port: int, user: str, tags: List[str] = None) -> bool: + """Добавить сервер.""" + if name in self._servers: + return False + self._servers[name] = Server(name=name, host=host, port=port, user=user, tags=tags or []) + self.save_to_env() + return True + + def update_server(self, name: str, host: str = None, port: int = None, + user: str = None, tags: List[str] = None) -> bool: + """Обновить сервер.""" + if name not in self._servers or name == "local": + return False + server = self._servers[name] + if host: + server.host = host + if port: + server.port = port + if user: + server.user = user + if tags is not None: + server.tags = tags + self.save_to_env() + return True + + def delete_server(self, name: str) -> bool: + """Удалить сервер.""" + if name not in self._servers or name == "local": + return False + del self._servers[name] + self.save_to_env() + return True + + def save_to_env(self): + """Сохранить серверы в .env файл.""" + env_file = Path(__file__).parent / ".env" + + # Читаем существующий файл + lines = [] + if env_file.exists(): + with open(env_file, "r", encoding="utf-8") as f: + lines = f.readlines() + + # Формируем строку серверов + server_parts = [] + for server in self._servers.values(): + if server.name == "local": + continue + tags_str = ",".join(server.tags) if server.tags else "" + server_parts.append(f"{server.name}|{server.host}|{server.port}|{server.user}|{tags_str}") + + servers_line = f"SERVERS={','.join(server_parts)}\n" + + # Ищем и обновляем или добавляем строку SERVERS + found = False + for i, line in enumerate(lines): + if line.startswith("SERVERS="): + lines[i] = servers_line + found = True + break + + if not found: + lines.append("\n" + servers_line) + + # Записываем обратно + with open(env_file, "w", encoding="utf-8") as f: + f.writelines(lines) + + logger.info(f"Серверы сохранены в {env_file}") + # --- Хранилище состояний пользователя --- @dataclass @@ -218,11 +288,12 @@ class UserState: """Состояние пользователя в диалоге.""" current_menu: str = "main" waiting_for_input: bool = False - input_type: Optional[str] = None + input_type: Optional[str] = None # "name", "host", "port", "user", "tags", "server_action" parent_menu: Optional[str] = None context: Dict[str, Any] = field(default_factory=dict) working_directory: Optional[str] = None current_server: str = "local" # Имя текущего сервера + editing_server: Optional[str] = None # Имя сервера, который редактируем class StateManager: @@ -587,36 +658,173 @@ async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): ) elif callback == "server_menu": - # Динамическое обновление меню серверов + # Динамическое обновление меню серверов с кнопками управления servers = server_manager.list_servers() keyboard = [] + for srv in servers: - keyboard.append([InlineKeyboardButton( + # Кнопка выбора сервера + кнопка управления (для не-local) + row = [InlineKeyboardButton( srv.display_name, callback_data=f"server_select_{srv.name}" - )]) - keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data="main")]) + )] + 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" - "Команды будут выполняться на выбранном сервере через SSH.", + "🖥️ *Управление серверами*\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" - "Для добавления сервера отредактируйте `.env`:\n" - "```\nSERVERS=name|host|port|user|tags\n```\n" - "Пример:\n" - "```\nSERVERS=web-prod|192.168.1.10|22|root|web,prod\n```\n" - "После изменения перезапустите бота.", + "Введите *имя сервера* (латиница, без пробелов):\n" + "Пример: `web-prod`, `db-backup`", parse_mode="Markdown", - reply_markup=menu_builder.get_keyboard("server") + 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" + 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\n" + f"Введите номер поля для изменения:\n" + f"1 — Host\n" + f"2 — Port\n" + f"3 — User\n" + f"4 — Tags", + 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_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=[] + ): + 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\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) @@ -872,9 +1080,14 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE text = update.message.text.strip() state = state_manager.get(user_id) + # Проверка: не в режиме ввода данных сервера ли мы + if state.waiting_for_input: + await handle_server_input(update, text) + return + # Любое текстовое сообщение = CLI команда logger.info(f"Пользователь {user_id} отправил команду: {text}") - + await update.message.reply_text( f"⏳ *Выполнение...*\n\n`{text}`", parse_mode="Markdown" @@ -882,6 +1095,222 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE await execute_cli_command_from_message(update, text) +async def handle_server_input(update: Update, text: str): + """Обработка ввода данных для CRUD операций с серверами.""" + user_id = update.effective_user.id + state = state_manager.get(user_id) + input_type = state.input_type + + if input_type == "add_server_name": + # Проверка имени + if not text.replace("-", "").replace("_", "").isalnum(): + await update.message.reply_text( + "❌ Неверный формат имени.\n\n" + "Используйте только латиницу, дефисы и подчёркивания.\n" + "Пример: `web-prod`, `db_backup`", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + return + + state.context["new_server"]["name"] = text + state.input_type = "add_server_host" + await update.message.reply_text( + f"✅ Имя: `{text}`\n\n" + "Введите *host* (IP или домен):\n" + "Пример: `192.168.1.10`, `example.com`", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + + elif input_type == "add_server_host": + state.context["new_server"]["host"] = text + state.input_type = "add_server_port" + await update.message.reply_text( + f"✅ Host: `{text}`\n\n" + "Введите *SSH порт* (обычно 22):", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + + elif input_type == "add_server_port": + try: + port = int(text) + if port < 1 or port > 65535: + raise ValueError() + state.context["new_server"]["port"] = port + state.input_type = "add_server_user" + await update.message.reply_text( + f"✅ Port: `{port}`\n\n" + "Введите *SSH пользователя*:\n" + "Пример: `root`, `admin`, `ubuntu`", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + except ValueError: + await update.message.reply_text( + "❌ Неверный формат порта.\n\n" + "Введите число от 1 до 65535:", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + + elif input_type == "add_server_user": + state.context["new_server"]["user"] = text + state.input_type = "add_server_tags" + await update.message.reply_text( + f"✅ User: `{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 input_type == "add_server_tags": + # Обработка ввода тегов (если пользователь ввёл текстом, а не нажал кнопку) + tags = [t.strip() for t in text.split(",") if t.strip()] + state.context["new_server"]["tags"] = tags + + # Завершение добавления + new_server = state.context.get("new_server", {}) + if server_manager.add_server( + name=new_server["name"], + host=new_server["host"], + port=new_server["port"], + user=new_server["user"], + tags=tags + ): + await update.message.reply_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: `{','.join(tags)}`\n\n" + f"Сервер сохранён в `.env` и доступен для выбора.", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("server") + ) + else: + await update.message.reply_text( + "❌ Ошибка: сервер с таким именем уже существует", + reply_markup=menu_builder.get_keyboard("server") + ) + + state.waiting_for_input = False + state.input_type = None + state.context.clear() + + elif input_type == "edit_server_field": + # Выбор поля для редактирования + if text == "1": + state.input_type = "edit_server_host" + await update.message.reply_text( + "Введите новый *host*:", + parse_mode="Markdown" + ) + elif text == "2": + state.input_type = "edit_server_port" + await update.message.reply_text( + "Введите новый *port*:", + parse_mode="Markdown" + ) + elif text == "3": + state.input_type = "edit_server_user" + await update.message.reply_text( + "Введите нового *user*:", + parse_mode="Markdown" + ) + elif text == "4": + state.input_type = "edit_server_tags" + await update.message.reply_text( + "Введите новые *теги* через запятую:", + parse_mode="Markdown" + ) + else: + await update.message.reply_text( + "❌ Введите номер поля (1-4):", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ Отмена", callback_data="server_menu") + ]]) + ) + return + + elif input_type == "edit_server_host": + server_manager.update_server(state.editing_server, host=text) + await finish_edit_server(update, state) + + elif input_type == "edit_server_port": + try: + port = int(text) + server_manager.update_server(state.editing_server, port=port) + await finish_edit_server(update, state) + except ValueError: + await update.message.reply_text("❌ Неверный формат порта") + return + + elif input_type == "edit_server_user": + server_manager.update_server(state.editing_server, user=text) + await finish_edit_server(update, state) + + elif input_type == "edit_server_tags": + tags = [t.strip() for t in text.split(",") if t.strip()] + server_manager.update_server(state.editing_server, tags=tags) + await finish_edit_server(update, state) + + else: + # Неизвестный тип ввода - выполняем команду + await update.message.reply_text( + f"⏳ *Выполнение...*\n\n`{text}`", + parse_mode="Markdown" + ) + await execute_cli_command_from_message(update, text) + return + + # Сброс состояния после завершения + if not state.waiting_for_input or input_type.startswith("add_server_tags"): + state.waiting_for_input = False + state.input_type = None + state.context.clear() + + +async def finish_edit_server(update: Update, state): + """Завершение редактирования сервера.""" + server_name = state.editing_server + state.waiting_for_input = False + state.input_type = None + state.editing_server = None + + server = server_manager.get(server_name) + if server: + await update.message.reply_text( + "✅ *Сервер обновлён*\n\n" + f"{server.display_name}\n" + f"📍 `{server.description}`", + parse_mode="Markdown", + reply_markup=menu_builder.get_keyboard("server") + ) + else: + await update.message.reply_text( + "❌ Ошибка при обновлении сервера", + reply_markup=menu_builder.get_keyboard("server") + ) + + async def execute_cli_command_from_message(update: Update, command: str): """Выполнение CLI команды из сообщения.""" user_id = update.effective_user.id