refactor: разделение хендлеров и сервисов по модулям
- 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 <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
d1592c7b38
commit
e9186e9dd2
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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 = []
|
||||||
|
|
||||||
|
|
@ -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")
|
||||||
|
)
|
||||||
|
|
@ -1,5 +1,18 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Сервисы бота."""
|
"""Сервисы бота."""
|
||||||
|
|
||||||
# Заглушка для будущего импорта
|
from bot.services.command_executor import (
|
||||||
# Функции будут перенесены из bot.py постепенно
|
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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,299 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 для обратной совместимости
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue