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:
parent
4888508795
commit
7a110e6974
455
bot.py
455
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,6 +1080,11 @@ 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}")
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue