feat: CRUD для серверов через Telegram меню

Возможности:
- Добавление сервера через пошаговую форму (имя, 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 <qwen-coder@alibabacloud.com>
This commit is contained in:
mirivlad 2026-02-23 17:25:32 +08:00
parent 4888508795
commit 7a110e6974
1 changed files with 443 additions and 14 deletions

453
bot.py
View File

@ -211,6 +211,76 @@ class ServerManager:
keyboard.append([button]) keyboard.append([button])
return InlineKeyboardMarkup(keyboard) 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 @dataclass
@ -218,11 +288,12 @@ class UserState:
"""Состояние пользователя в диалоге.""" """Состояние пользователя в диалоге."""
current_menu: str = "main" current_menu: str = "main"
waiting_for_input: bool = False 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 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" # Имя текущего сервера current_server: str = "local" # Имя текущего сервера
editing_server: Optional[str] = None # Имя сервера, который редактируем
class StateManager: class StateManager:
@ -587,36 +658,173 @@ async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
) )
elif callback == "server_menu": elif callback == "server_menu":
# Динамическое обновление меню серверов # Динамическое обновление меню серверов с кнопками управления
servers = server_manager.list_servers() servers = server_manager.list_servers()
keyboard = [] keyboard = []
for srv in servers: for srv in servers:
keyboard.append([InlineKeyboardButton( # Кнопка выбора сервера + кнопка управления (для не-local)
row = [InlineKeyboardButton(
srv.display_name, srv.display_name,
callback_data=f"server_select_{srv.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" state.current_menu = "server"
await query.edit_message_text( await query.edit_message_text(
"🖥️ *Выберите сервер:*\n\n" "🖥️ *Управление серверами*\n\n"
"Команды будут выполняться на выбранном сервере через SSH.", "Выберите сервер для подключения или добавьте новый.\n"
"⚙️ — редактировать/удалить сервер",
parse_mode="Markdown", parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard) reply_markup=InlineKeyboardMarkup(keyboard)
) )
elif callback == "server_add": elif callback == "server_add":
state.waiting_for_input = True
state.input_type = "add_server_name"
state.context["new_server"] = {}
await query.edit_message_text( await query.edit_message_text(
" *Добавление сервера*\n\n" " *Добавление сервера*\n\n"
"Для добавления сервера отредактируйте `.env`:\n" "Введите *имя сервера* (латиница, без пробелов):\n"
"```\nSERVERS=name|host|port|user|tags\n```\n" "Пример: `web-prod`, `db-backup`",
"Пример:\n" parse_mode="Markdown",
"```\nSERVERS=web-prod|192.168.1.10|22|root|web,prod\n```\n" 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", parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("server") 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_"): elif callback.startswith("server_select_"):
server_name = callback.replace("server_select_", "") server_name = callback.replace("server_select_", "")
server = server_manager.get(server_name) server = server_manager.get(server_name)
@ -872,6 +1080,11 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
text = update.message.text.strip() text = update.message.text.strip()
state = state_manager.get(user_id) state = state_manager.get(user_id)
# Проверка: не в режиме ввода данных сервера ли мы
if state.waiting_for_input:
await handle_server_input(update, text)
return
# Любое текстовое сообщение = CLI команда # Любое текстовое сообщение = CLI команда
logger.info(f"Пользователь {user_id} отправил команду: {text}") logger.info(f"Пользователь {user_id} отправил команду: {text}")
@ -882,6 +1095,222 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
await execute_cli_command_from_message(update, text) 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): 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