telegram-cli-bot/bot.py

2366 lines
101 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Telegram CLI Bot - бот для выполнения CLI команд с многоуровневым меню.
Легкое добавление новых команд через регистрацию хендлеров.
Версия: 0.5.0 (рефакторинг)
"""
import os
import sys
import asyncio
import subprocess
import logging
import pty
import select
import fcntl
from pathlib import Path
from typing import Optional, Callable, Dict, Any, List, Tuple
from datetime import datetime, timedelta
import pexpect
import asyncssh
from qwen_integration import qwen_manager, QwenSessionState
# Подавляем логи sentence-transformers и huggingface
logging.getLogger("sentence_transformers").setLevel(logging.WARNING)
logging.getLogger("huggingface_hub").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
from vector_memory import (
hybrid_memory_manager,
save_message,
get_context,
get_profile,
get_memory_stats
)
from dotenv import load_dotenv
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, BotCommand
from telegram.ext import (
Application,
CommandHandler,
CallbackQueryHandler,
MessageHandler,
ContextTypes,
filters,
)
# Загрузка переменных окружения из .env
load_dotenv()
# --- Конфигурация ---
BASE_DIR = Path(__file__).parent
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
handlers=[
logging.FileHandler(BASE_DIR / "bot.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# ============================================================================
# ИМПОРТЫ ИЗ bot/ - новая модульная структура
# ============================================================================
from bot.config import config, state_manager, menu_builder, command_registry, server_manager
from bot.models.server import Server
from bot.models.session import SSHSession, SSHSessionManager, LocalSession, LocalSessionManager, INPUT_PATTERNS
from bot.utils.cleaners import clean_ansi_codes, normalize_output
from bot.utils.formatters import escape_markdown, split_message, send_long_message, format_long_output, MAX_MESSAGE_LENGTH
from bot.utils.ssh_readers import detect_input_type, read_ssh_output, read_pty_output
from bot.utils.decorators import check_access
from bot.keyboards.menus import MenuItem, init_menus
# Глобальные менеджеры сессий
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
text = update.message.text.strip()
state = state_manager.get(user_id)
logger.info(f"handle_text_message: user_id={user_id}, ai_chat_mode={state.ai_chat_mode}, text={text[:50]}")
# Проверка: не в режиме ввода данных сервера ли мы
if state.waiting_for_input:
await handle_server_input(update, text)
return
# Проверка: не активная ли SSH-сессия ожидает ввода
ssh_session = ssh_session_manager.get_session(user_id)
if ssh_session and ssh_session.waiting_for_input:
await handle_ssh_session_input(update, text, ssh_session)
return
# Проверка: не активная ли локальная сессия ожидает ввода
local_session = local_session_manager.get_session(user_id)
if local_session and local_session.waiting_for_input:
await handle_local_session_input(update, text, local_session)
return
# ПРОВЕРКА: режим чата с ИИ агентом
if state.ai_chat_mode:
logger.info(f"Пользователь {user_id} отправил задачу ИИ: {text}")
await handle_ai_task(update, text)
return
# Любое текстовое сообщение = CLI команда
logger.info(f"Пользователь {user_id} отправил команду: {text}")
await execute_cli_command_from_message(update, text)
async def handle_ai_task(update: Update, text: str):
"""Обработка задачи для ИИ агента с использованием системы памяти."""
user_id = update.effective_user.id
state = state_manager.get(user_id)
# Сохраняем сообщение пользователя в памяти
save_message(user_id, "user", text)
# Добавляем сообщение пользователя в историю сессии
state.ai_chat_history.append(f"User: {text}")
# Ограничиваем историю последними 20 сообщениями
if len(state.ai_chat_history) > 20:
state.ai_chat_history = state.ai_chat_history[-20:]
# Отправляем статус
status_msg = await update.message.reply_text("⏳ 🤖 Думаю...")
output_buffer = []
def on_output(text: str):
output_buffer.append(text)
def on_oauth_url(url: str):
pass # OAuth обрабатывается автоматически
# Формируем контекст с историей + памятью
history_context = "\n".join(state.ai_chat_history)
# Получаем контекст из системы памяти (профиль + релевантные факты)
memory_context = get_context(user_id, query=text)
# Считаем токены в контексте (примерно: 1 слово ≈ 1.3 токена)
context_words = len((memory_context + "\n" + history_context).split())
context_tokens = int(context_words * 1.3)
# Максимальный контекст модели (Qwen поддерживает до 256K токенов)
# Для безопасности берём 200K
MAX_CONTEXT_TOKENS = 200_000
context_percent = round((context_tokens / MAX_CONTEXT_TOKENS) * 100, 1)
# Собираем полный промпт
full_task = (
f"{memory_context}\n\n"
f"Previous conversation:\n{history_context}\n\n"
f"Current request: {text}"
)
# Выполняем задачу
result = await qwen_manager.run_task(user_id, full_task, on_output, on_oauth_url)
# Показываем результат
full_output = "".join(output_buffer).strip()
if not full_output:
full_output = result
# Добавляем ответ ИИ в историю и память
if full_output:
state.ai_chat_history.append(f"Assistant: {full_output[:500]}")
save_message(user_id, "assistant", full_output)
# Обрезаем если слишком длинный (с запасом на контекст)
if len(full_output) > 3500:
full_output = full_output[:3500] + "\n... (вывод обрезан)"
# Автоматическое извлечение фактов каждые 5 сообщений
state.messages_since_fact_extract += 1
if state.messages_since_fact_extract >= 5:
logger.info(f"Запуск извлечения фактов через ИИ для пользователя {user_id}")
dialog_context = "\n".join(state.ai_chat_history[-10:]) # Последние 10 сообщений
asyncio.create_task(hybrid_memory_manager.extract_facts_with_ai(user_id, dialog_context))
state.messages_since_fact_extract = 0
# Формируем сообщение с информацией о контексте (как в qwen-code)
context_info = f"📊 Контекст: {context_percent}%"
response_text = f"{full_output}\n\n_{context_info}_"
# Отправляем ответ с разбивкой на части если нужно
await send_long_message(update, response_text, parse_mode="Markdown")
async def handle_ssh_session_input(update: Update, text: str, session: SSHSession):
"""Обработка ввода пользователя в активную SSH-сессию."""
user_id = update.effective_user.id
input_type = session.input_type
logger.info(f"Пользователь {user_id} ввёл '{text}' в SSH-сессию (тип: {input_type})")
try:
# Отправляем ввод в SSH-процесс
if input_type == "password":
# Пароль отправляем с newline
session.process.stdin.write(text + "\n")
elif input_type == "confirm":
# Подтверждение - y или n
answer = "y" if text.lower() in ("y", "yes", "да", "д") else "n"
session.process.stdin.write(answer + "\n")
else:
# Обычный ввод
session.process.stdin.write(text + "\n")
await session.process.stdin.drain()
session.last_activity = datetime.now()
# Читаем ответ
output, is_done = await read_ssh_output(session.process, timeout=3.0)
session.output_buffer += output
# Проверяем тип ввода
new_input_type = detect_input_type(output)
if new_input_type == "password":
session.waiting_for_input = True
session.input_type = "password"
await update.message.reply_text(
f"⏳ *Требуется ввод*\n\n"
f"🔐 *Запрошен пароль*\n\n"
f"```\n{output.strip()[-200:] if output else 'Ожидание...'}\n```\n\n"
f"Отправьте пароль в чат:",
parse_mode="Markdown"
)
return
elif new_input_type == "confirm":
session.waiting_for_input = True
session.input_type = "confirm"
await update.message.reply_text(
f"⏳ *Требуется ввод*\n\n"
f"❓ *Требуется подтверждение*\n\n"
f"```\n{output.strip()[-200:] if output else 'Ожидание...'}\n```\n\n"
f"Отправьте `y` (да) или `n` (нет):",
parse_mode="Markdown"
)
return
elif is_done or new_input_type == "prompt":
# Команда завершена
await update.message.reply_text(
f"✅ *Результат:*\n\n"
f"```\n{session.command}\n```\n\n"
f"```\n{session.output_buffer.strip()[-4000:]}\n```",
parse_mode="Markdown"
)
ssh_session_manager.close_session(user_id)
return
else:
# Команда ещё выполняется
await update.message.reply_text(
f"⏳ *Выполнение...*\n\n"
f"```\n{output.strip()[-500:] if output else 'Выполняется...'}\n```",
parse_mode="Markdown"
)
# Читаем остаток
while not is_done:
more_output, is_done = await read_ssh_output(session.process, timeout=5.0)
output += more_output
session.output_buffer += output
session.last_activity = datetime.now()
new_input_type = detect_input_type(output)
if new_input_type in ("password", "confirm"):
session.waiting_for_input = True
session.input_type = new_input_type
await update.message.reply_text(
f"⏳ *Требуется ввод*\n\n"
f"{'🔐 *Запрошен пароль*' if new_input_type == 'password' else '❓ *Требуется подтверждение'}\n\n"
f"```\n{output.strip()[-200:]}\n```\n\n"
f"{'Отправьте пароль в чат:' if new_input_type == 'password' else 'Отправьте `y` (да) или `n` (нет):'}",
parse_mode="Markdown"
)
return
# Завершено
await update.message.reply_text(
f"✅ *Результат:*\n\n"
f"```\n{session.command}\n```\n\n"
f"```\n{session.output_buffer.strip()[-4000:]}\n```",
parse_mode="Markdown"
)
ssh_session_manager.close_session(user_id)
except Exception as e:
logger.error(f"Ошибка ввода в SSH-сессию: {e}")
ssh_session_manager.close_session(user_id)
await update.message.reply_text(
f"❌ *Ошибка:*\n```\n{str(e)}\n```",
parse_mode="Markdown"
)
async def handle_local_session_input(update: Update, text: str, session: LocalSession):
"""Обработка ввода пользователя в локальную сессию."""
user_id = update.effective_user.id
input_type = session.input_type
logger.info(f"Пользователь {user_id} ввёл '{text}' в локальную сессию (тип: {input_type})")
try:
child = session.context.get('child')
if not child:
raise Exception("Сессия не содержит child объект")
# Отправляем ввод
if input_type == "password":
child.sendline(text)
elif input_type == "confirm":
answer = "y" if text.lower() in ("y", "yes", "да", "д") else "n"
child.sendline(answer)
else:
child.sendline(text)
session.last_activity = datetime.now()
# Читаем ответ
logger.info("Чтение ответа...")
output = ""
try:
while True:
line = child.read_nonblocking(size=4096, timeout=5.0)
if not line:
break
output += line
logger.debug(f"Прочитано: {len(line)} символов")
# Проверяем запрос ввода
if detect_input_type(output):
break
except pexpect.TIMEOUT:
pass
except pexpect.EOF:
pass
logger.info(f"После ввода прочитано: {len(output)} символов")
session.output_buffer += output
# Проверяем тип ввода
new_input_type = detect_input_type(output)
if new_input_type == "password":
session.waiting_for_input = True
session.input_type = "password"
await update.message.reply_text(
f"⏳ *Требуется ввод*\n\n"
f"🔐 *Запрошен пароль*\n\n"
f"```\n{output.strip()[-200:] if output else 'Ожидание...'}\n```\n\n"
f"Отправьте пароль в чат:",
parse_mode="Markdown"
)
return
elif new_input_type == "confirm":
session.waiting_for_input = True
session.input_type = "confirm"
await update.message.reply_text(
f"⏳ *Требуется ввод*\n\n"
f"❓ *Требуется подтверждение*\n\n"
f"```\n{output.strip()[-200:] if output else 'Ожидание...'}\n```\n\n"
f"Отправьте `y` (да) или `n` (нет):",
parse_mode="Markdown"
)
return
else:
# Команда завершена
# Очищаем ANSI-коды и нормализуем вывод
cleaned_output = clean_ansi_codes(session.output_buffer)
cleaned_output = normalize_output(cleaned_output)
# Форматируем длинный вывод: первые 5 и последние 10 строк
formatted_output = format_long_output(cleaned_output.strip(), max_lines=15, head_lines=5, tail_lines=10)
if len(formatted_output) > 4000:
formatted_output = formatted_output[:4000] + "\n... (вывод обрезан)"
await update.message.reply_text(
f"✅ *Результат:*\n\n"
f"```\n{session.command}\n```\n\n"
f"```\n{formatted_output}\n```",
parse_mode="Markdown"
)
local_session_manager.close_session(user_id)
except Exception as e:
logger.error(f"Ошибка ввода в локальную сессию: {e}")
local_session_manager.close_session(user_id)
await update.message.reply_text(
f"❌ *Ошибка:*\n```\n{str(e)}\n```",
parse_mode="Markdown"
)
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_password"
await update.message.reply_text(
f"✅ User: `{text}`\n\n"
"Введите *SSH пароль* (или нажмите Пропустить для подключения только по ключу):\n"
"⚠️ Пароль будет сохранён в .env файл в открытом виде!",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("⏭️ Пропустить", callback_data="srv_skip_password")],
[InlineKeyboardButton("❌ Отмена", callback_data="server_menu")]
])
)
elif input_type == "add_server_password":
state.context["new_server"]["password"] = text
state.input_type = "add_server_tags"
await update.message.reply_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,
password=new_server.get("password", "")
):
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"
f"Password: {'установлен' if new_server.get('password') else 'не установлен'}\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"
)
elif text == "5":
state.input_type = "edit_server_password"
await update.message.reply_text(
"Введите новый *password* (или оставьте пустым для подключения только по ключу):\n"
"⚠️ Пароль будет сохранён в .env файл в открытом виде!",
parse_mode="Markdown"
)
else:
await update.message.reply_text(
"❌ Введите номер поля (1-5):",
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)
elif input_type == "edit_server_password":
server_manager.update_server(state.editing_server, password=text)
await finish_edit_server(update, state)
else:
# Неизвестный тип ввода - выполняем команду
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
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
# Обработка команды cd - меняем директорию пользователя
# Работает только с простыми командами cd, не с составными
cmd_stripped = command.strip()
if cmd_stripped.startswith("cd ") and "&&" not in cmd_stripped and ";" not in cmd_stripped and "|" not in cmd_stripped:
parts = cmd_stripped.split(maxsplit=1)
if len(parts) == 2:
target_dir = parts[1]
# Обработка ~ и относительных путей
if target_dir.startswith("~"):
target_dir = str(Path.home()) + target_dir[1:]
elif not target_dir.startswith("/"):
target_dir = str(Path(working_dir) / target_dir)
# Проверка существования директории
if Path(target_dir).is_dir():
state.working_directory = target_dir
await update.message.reply_text(
f"📁 *Директория изменена:*\n`{target_dir}`\n"
f"🖥️ Сервер: `{server_name}`",
parse_mode="Markdown"
)
else:
await update.message.reply_text(
f"❌ *Директория не найдена:*\n`{target_dir}`",
parse_mode="Markdown"
)
return
# Для составных команд с cd — выполняем через SSH или локально
if "cd " in cmd_stripped and ("&&" in cmd_stripped or ";" in cmd_stripped):
if server_name == "local" or server is None:
await _execute_composite_command_local(update, cmd_stripped, working_dir)
else:
await _execute_composite_command_ssh(update, cmd_stripped, server, working_dir)
return
# Обычное выполнение
if server_name == "local" or server is None:
await _execute_local_command_message(update, cmd_stripped, working_dir)
else:
await _execute_ssh_command_message(update, cmd_stripped, server, working_dir)
async def _execute_composite_command_local(update: Update, command: str, working_dir: str):
"""Выполнение составной команды локально."""
command_with_pwd = f"{command} && pwd"
logger.info(f"Выполнение составной команды с cd: {command_with_pwd} в директории: {working_dir}")
try:
process = await asyncio.create_subprocess_shell(
command_with_pwd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=working_dir
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
output = stdout.decode("utf-8", errors="replace").strip()
error = stderr.decode("utf-8", errors="replace")
# Последняя строка - это pwd
if output and process.returncode == 0:
lines = output.split('\n')
final_dir = lines[-1].strip()
if Path(final_dir).is_dir():
state_manager.get(update.effective_user.id).working_directory = final_dir
output = '\n'.join(lines[:-1])
await _show_result_message(update, command, output, error, process.returncode)
except asyncio.TimeoutError:
await update.message.reply_text("❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд.", parse_mode="Markdown")
except Exception as e:
logger.error(f"Ошибка: {e}")
await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown")
async def _execute_composite_command_ssh(update: Update, command: str, server: Server, working_dir: str):
"""Выполнение составной команды через SSH с интерактивной сессией."""
user_id = update.effective_user.id
command_with_pwd = f"{command} && pwd"
try:
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_with_pwd}" if working_dir else command_with_pwd
# Создаем интерактивный процесс с 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 update.message.reply_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 update.message.reply_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:
# Обработка pwd для смены директории
if output:
lines = output.strip().split('\n')
final_dir = lines[-1].strip()
if final_dir.startswith('/'):
state_manager.get(user_id).working_directory = final_dir
output = '\n'.join(lines[:-1])
ssh_session_manager.close_session(user_id)
await _show_result_message(update, command, output, "", 0)
return
except asyncssh.Error as e:
logger.error(f"SSH ошибка: {e}")
ssh_session_manager.close_session(user_id)
await update.message.reply_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 update.message.reply_text("❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд.", parse_mode="Markdown")
except Exception as e:
logger.error(f"Ошибка: {e}")
ssh_session_manager.close_session(user_id)
await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown")
async def _execute_local_command_message(update: Update, command: str, working_dir: str):
"""Выполнение локальной команды из сообщения через pexpect."""
user_id = update.effective_user.id
try:
logger.info(f"Запуск команды через pexpect: {command}")
# Создаём интерактивный процесс
child = pexpect.spawn(
'/bin/bash',
['-c', command],
cwd=working_dir,
encoding='utf-8',
codec_errors='replace',
echo=False,
timeout=30
)
# Создаём сессию (используем child вместо master_fd)
session = local_session_manager.create_session(
user_id=user_id,
command=command,
master_fd=child.child_fd,
pid=child.pid
)
session.context = {'child': child} # Сохраняем child объект
# Читаем начальный вывод
logger.info("Чтение вывода...")
output = ""
try:
# Пробуем прочитать с таймаутом
while True:
line = child.read_nonblocking(size=4096, timeout=2.0)
if not line:
break
output += line
logger.debug(f"Прочитано: {len(line)} символов")
# Проверяем запрос ввода
if detect_input_type(output):
break
except pexpect.TIMEOUT:
pass
except pexpect.EOF:
pass
logger.info(f"Прочитано: {len(output)} символов")
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 update.message.reply_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 update.message.reply_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:
# Команда завершена
local_session_manager.close_session(user_id)
await _show_result_message(update, command, output, "", 0)
except Exception as e:
logger.error(f"Ошибка выполнения команды: {e}")
local_session_manager.close_session(user_id)
await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown")
async def _execute_ssh_command_message(update: Update, command: str, server: Server, working_dir: str):
"""Выполнение команды через SSH из сообщения с интерактивной сессией."""
user_id = update.effective_user.id
try:
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 update.message.reply_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 update.message.reply_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_message(update, command, output, "", 0)
return
except asyncssh.Error as e:
logger.error(f"SSH ошибка: {e}")
ssh_session_manager.close_session(user_id)
await update.message.reply_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 update.message.reply_text("❌ *Таймаут*\n\nКоманда выполнялась дольше 30 секунд.", parse_mode="Markdown")
except Exception as e:
logger.error(f"Ошибка: {e}")
ssh_session_manager.close_session(user_id)
await update.message.reply_text(f"❌ *Ошибка:*\n```\n{str(e)}\n```", parse_mode="Markdown")
async def _show_result_message(update: Update, command: str, output: str, error: str, returncode: int):
"""Показ результата выполнения команды."""
# Очистка ANSI-кодов и нормализация
output = normalize_output(clean_ansi_codes(output)) if output else ""
error = clean_ansi_codes(error) if error else ""
result = f"✅ *Результат:*\n\n"
if output:
# Форматируем длинный вывод: первые 5 и последние 10 строк
output = format_long_output(output, max_lines=15, head_lines=5, tail_lines=10)
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(update, result, parse_mode="Markdown")
async def post_init(application: Application):
"""Инициализация после запуска бота."""
# Установка команд бота
commands = [
BotCommand("start", "Запустить бота"),
BotCommand("menu", "Главное меню с кнопками"),
BotCommand("help", "Справка"),
BotCommand("settings", "Настройки"),
BotCommand("stop", "Прервать SSH-сессию"),
BotCommand("ai", "Задача для Qwen Code AI"),
BotCommand("memory", "Статистика памяти ИИ"),
BotCommand("facts", "Показать сохранённые факты"),
BotCommand("forget", "Удалить факт по номеру"),
]
await application.bot.set_my_commands(commands)
logger.info("Бот инициализирован")
@check_access
async def stop_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка команды /stop - прерывание активной SSH-сессии."""
user_id = update.effective_user.id
session = ssh_session_manager.get_session(user_id)
if session:
ssh_session_manager.close_session(user_id)
await update.message.reply_text(
"❌ *SSH-сессия прервана*\n\n"
f"Команда `{session.command}` была остановлена.",
parse_mode="Markdown"
)
else:
await update.message.reply_text(
" *Нет активных SSH-сессий*\n\n"
"У вас нет выполняющихся команд.",
parse_mode="Markdown"
)
# ============================================
# КОМАНДЫ ДЛЯ РАБОТЫ С QWEN CODE (ИИ)
# ============================================
@check_access
async def ai_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка команды /ai - выполнение задачи через Qwen Code."""
user_id = update.effective_user.id
task = " ".join(context.args).strip()
if not task:
await update.message.reply_text(
"🤖 *Qwen Code AI*\n\n"
"Использование:\n"
"`/ai <задача>`\n\n"
"Примеры:\n"
"`/ai создай функцию Python для сортировки списка`\n"
"`/ai объясни код в файле main.py`\n"
"`/ai найди баги в этом коде`\n\n"
"Команды:\n"
"`/ai status` — статус сессии\n"
"`/ai stop` — завершить сессию\n"
"`/ai clear` — очистить историю диалога",
parse_mode="Markdown"
)
return
# Специальные команды
if task == "status":
session = qwen_manager.get_session(user_id)
if session:
await update.message.reply_text(
f"🤖 *Статус сессии Qwen Code*\n\n"
f"Состояние: `{session.state.value}`\n"
f"Последняя активность: {session.last_activity.strftime('%H:%M:%S')}\n"
f"Задача: `{session.pending_task or 'Нет'}`",
parse_mode="Markdown"
)
else:
await update.message.reply_text(" Активных сессий нет")
return
if task == "stop":
qwen_manager.close_session(user_id)
await update.message.reply_text("✅ Сессия Qwen Code завершена")
return
if task == "clear":
state = state_manager.get(user_id)
state.ai_chat_history.clear()
await update.message.reply_text("✅ История диалога с ИИ очищена")
return
# Отправляем задачу в ИИ
status_msg = await update.message.reply_text("⏳ 🤖 Думаю...", parse_mode="Markdown")
output_buffer = []
def on_output(text: str):
output_buffer.append(text)
def on_oauth_url(url: str):
pass # OAuth обрабатывается автоматически при первом запуске
# Выполняем задачу
result = await qwen_manager.run_task(user_id, task, on_output, on_oauth_url)
# Показываем результат
full_output = "".join(output_buffer).strip()
if not full_output:
full_output = result
if len(full_output) > 4000:
full_output = full_output[:4000] + "\n... (вывод обрезан)"
await status_msg.edit_text(
f"🤖 *Результат:*\n\n"
f"```\n{full_output}\n```",
parse_mode="Markdown"
)
@check_access
async def memory_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка команды /memory — статистика памяти ИИ."""
user_id = update.effective_user.id
stats = get_memory_stats(user_id)
if not stats:
await update.message.reply_text(" Память не инициализирована")
return
# Форматируем статистику
total_messages = stats.get("total_messages", 0)
total_facts = stats.get("total_facts", 0)
total_sessions = stats.get("total_sessions", 0)
vector_docs = stats.get("vector_documents", "N/A")
vector_model = stats.get("vector_model", "N/A")
hybrid_mode = stats.get("hybrid_mode", False)
text = (
"🧠 *Статистика памяти:*\n\n"
f"📊 Сообщений: `{total_messages}`\n"
f"📌 Фактов: `{total_facts}`\n"
f"📁 Сессий: `{total_sessions}`\n"
)
if hybrid_mode:
text += (
f"\n🔮 *Векторная память:*\n"
f" Документы: `{vector_docs}`\n"
f" Модель: `{vector_model}`\n"
)
text += "\nамять использует SQLite + ChromaDB с семантическим поиском._"
await update.message.reply_text(text, parse_mode="Markdown")
@check_access
async def facts_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка команды /facts — показать сохранённые факты."""
user_id = update.effective_user.id
# Получаем факты из SQLite
from memory_system import memory_manager
facts = memory_manager.storage.get_facts(user_id)
if not facts:
await update.message.reply_text(
"📋 *Ваши факты*\n\n"
"Пока нет сохранённых фактов.\n"
"Общайтесь с ИИ в чате — он автоматически запомнит важное!",
parse_mode="Markdown"
)
return
# Группируем по типам
from memory_system import FactType
grouped = {}
for fact in facts:
type_name = fact.fact_type.value
if type_name not in grouped:
grouped[type_name] = []
grouped[type_name].append(fact)
# Формируем сообщение
type_names_ru = {
"personal": "👤 Личное",
"technical": "💻 Технологии",
"project": "📁 Проекты",
"preference": "⭐ Предпочтения",
"other": "📌 Другое"
}
text = "📋 *Ваши сохранённые факты:*\n\n"
for type_name, type_facts in grouped.items():
type_title = type_names_ru.get(type_name, type_name)
text += f"*{type_title}* ({len(type_facts)}):\n"
for i, fact in enumerate(type_facts, 1):
# Обрезаем длинные факты
content = fact.content
if len(content) > 100:
content = content[:100] + "..."
text += f" {i}. {content}\n"
text += "\n"
text += f"_Всего: {len(facts)} фактов_\n"
text += "_Для удаления факта используйте `/forget <номер>`_"
await update.message.reply_text(text, parse_mode="Markdown")
@check_access
async def forget_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка команды /forget — удалить факт."""
user_id = update.effective_user.id
if not context.args or not context.args[0].isdigit():
await update.message.reply_text(
"❌ *Использование:*\n"
"`/forget <номер>`\n\n"
"Сначала вызовите `/facts` чтобы увидеть список.",
parse_mode="Markdown"
)
return
# Получаем факты
from memory_system import memory_manager
facts = memory_manager.storage.get_facts(user_id)
fact_index = int(context.args[0]) - 1
if fact_index < 0 or fact_index >= len(facts):
await update.message.reply_text(
f"❌ Факт с номером {fact_index + 1} не найден.\n"
f"Всего фактов: {len(facts)}",
parse_mode="Markdown"
)
return
# Удаляем факт
fact_to_delete = facts[fact_index]
memory_manager.storage.update_fact(fact_to_delete.id, is_active=False)
await update.message.reply_text(
f"✅ Факт удалён:\n_{fact_to_delete.content}_",
parse_mode="Markdown"
)
def main():
"""Точка входа."""
# Чтение токена только из переменной окружения
token = os.getenv("TELEGRAM_BOT_TOKEN")
if not token:
print("❌ Ошибка: не установлен TELEGRAM_BOT_TOKEN")
print("\nСпособы установки токена:")
print(" 1. Создайте файл .env по примеру .env.example")
print(" 2. Или задайте переменную окружения:")
print(" export TELEGRAM_BOT_TOKEN='your_token_here'")
print("\nИли запустите ./run.sh для интерактивной настройки")
sys.exit(1)
# Проверка настроек прокси
use_proxy = os.getenv("USE_PROXY", "false").lower() == "true"
proxy_url = None
if use_proxy:
proxy_host = os.getenv("PROXY_HOST", "127.0.0.1")
proxy_port = os.getenv("PROXY_PORT", "1080")
proxy_username = os.getenv("PROXY_USERNAME", "")
proxy_password = os.getenv("PROXY_PASSWORD", "")
# Формируем URL прокси: socks5://user:pass@host:port
if proxy_username and proxy_password:
proxy_url = f"socks5://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
else:
proxy_url = f"socks5://{proxy_host}:{proxy_port}"
print(f"✅ Прокси включён: {proxy_url.split('@')[0]}@{proxy_host}:{proxy_port}")
# Загрузка серверов из env
server_manager.load_from_env()
# Инициализация меню
init_menus()
# Создание приложения с таймаутами и прокси
builder = (
Application.builder()
.token(token)
.post_init(post_init)
.read_timeout(30)
.write_timeout(30)
.connect_timeout(30)
.pool_timeout(30)
)
# Добавляем прокси если включён
if use_proxy and proxy_url:
builder = builder.proxy_url(proxy_url)
logger.info(f"Используется SOCKS5 прокси: {proxy_host}:{proxy_port}")
application = builder.build()
# Регистрация хендлеров
application.add_handler(CommandHandler("start", start_command))
application.add_handler(CommandHandler("help", help_command))
application.add_handler(CommandHandler("settings", settings_command))
application.add_handler(CommandHandler("menu", menu_command))
application.add_handler(CommandHandler("stop", stop_command))
application.add_handler(CommandHandler("memory", memory_command))
application.add_handler(CommandHandler("facts", facts_command))
application.add_handler(CommandHandler("forget", forget_command))
application.add_handler(CallbackQueryHandler(menu_callback))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text_message))
application.add_handler(CommandHandler("ai", ai_command))
# Запуск
logger.info("Запуск бота...")
print(f"🤖 {config.name} запущен!")
print(f"📝 Описание: {config.description}")
print(f"🎨 Иконка: {config.icon}")
print("\nОстановка: Ctrl+C")
application.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()