#!/usr/bin/env python3 """ Telegram CLI Bot - бот для выполнения CLI команд с многоуровневым меню. Версия: 0.5.3 (модульная структура) """ 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) logging.getLogger("httpcore").setLevel(logging.WARNING) logging.getLogger("telegram.ext").setLevel(logging.WARNING) from vector_memory import ( hybrid_memory_manager, save_message, get_context, get_profile, get_memory_stats ) # Импорты компактификации from bot.compaction import init_compactor, get_compactor, DialogueCompactor 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.DEBUG, 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 # Импорты хендлеров из модулей from bot.handlers.commands import start_command, menu_command, help_command, settings_command, cron_command, rss_command, ai_command from bot.handlers.callbacks import menu_callback from bot.services.command_executor import execute_cli_command # Импорты инструментов и AI агента from bot.ai_agent import ai_agent from bot.tools import tools_registry from bot.services.cron_scheduler import init_scheduler, get_scheduler from bot.ai_provider_manager import init_ai_provider_manager, get_ai_provider_manager # Глобальные менеджеры сессий ssh_session_manager = SSHSessionManager() local_session_manager = LocalSessionManager() compactor: Optional[DialogueCompactor] = None 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) # === ПРОВЕРКА: Нужна ли компактификация? === # Проверяем порог заполненности контекста if compactor.check_compaction_needed(): logger.info("Запуск компактификации истории диалога...") status_msg = await update.message.reply_text("🔄 **Запуск компактификации истории...**\n\n_Это может занять несколько секунд._", parse_mode="Markdown") result = await compactor.compact() await status_msg.delete() if result.success: await update.message.reply_text( f"✅ **Компактификация завершена!**\n\n" f"📊 Сжато сообщений: {result.messages_compressed}\n" f"📝 Длина summary: {result.summary_length} символов\n" f"💾 Экономия токенов: ~{result.tokens_saved}", parse_mode="Markdown" ) else: logger.error(f"Компактификация не удалась: {result.error}") await update.message.reply_text(f"⚠️ **Ошибка компактификации:** {result.error}", parse_mode="Markdown") # Сохраняем сообщение пользователя в памяти 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("⏳ 🤖 Думаю...") # === ПРОВЕРКА: Решение AI агента об использовании инструментов === agent_decision = await ai_agent.decide(text, context={'user_id': user_id}) if agent_decision.should_use_tool: logger.info(f"AI агент решил использовать инструмент: {agent_decision.tool_name} (confidence={agent_decision.confidence})") # Выполняем инструмент tool_result = await ai_agent.execute_tool( agent_decision.tool_name, **agent_decision.tool_args ) if tool_result.success: # Формируем ответ с результатами инструмента full_output = await format_tool_result(agent_decision.tool_name, tool_result) # Добавляем в историю 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... (вывод обрезан)" await send_long_message(update, full_output, parse_mode="Markdown") await status_msg.delete() return else: logger.warning(f"Инструмент {agent_decision.tool_name} вернул ошибку: {tool_result.error}") # Продолжаем с обычным ИИ-ответом если инструмент не сработал # === ОБЫЧНЫЙ ИИ-ОТВЕТ через Qwen === output_buffer = [] # Буфер для потокового отображения result_buffer = [] # Буфер для финального результата (без статусов) stream_message = None # Сообщение для потокового вывода (статусы) result_message = None # Финальное сообщение с результатом current_status = "🤖 Думаю..." # Текущий статус для отображения is_tool_output = False # Флаг: идёт ли вывод инструмента def on_output(text: str): """Callback для накопления полного вывода (не используется для streaming).""" pass def on_oauth_url(url: str): pass def on_event(event): """Обработка событий stream-json для обновления статуса.""" nonlocal current_status, is_tool_output from qwen_integration import QwenEventType if event.event_type == QwenEventType.SYSTEM: if event.subtype == 'session_start': current_status = "🤖 Запуск сессии..." elif event.event_type == QwenEventType.ASSISTANT: message = event.message or {} content_list = message.get('content', []) for content_item in content_list: if isinstance(content_item, dict) and content_item.get('type') == 'tool_use': tool_name = content_item.get('name', 'инструмент') current_status = f"🔧 Использую {tool_name}..." is_tool_output = True # Начинается вывод инструмента break elif event.event_type == QwenEventType.RESULT: if event.is_error: current_status = "❌ Ошибка" else: current_status = "✅ Готово" logger.debug(f"Событие Qwen: {event.event_type.value}, статус: {current_status}") async def on_chunk(chunk: str): """Потоковая отправка chunks в Telegram.""" nonlocal stream_message, current_status, is_tool_output chunk_text = chunk if not chunk_text or not chunk_text.strip(): return # Логируем для отладки logger.debug(f"on_chunk: {repr(chunk_text[:50])}...") # Добавляем в потоковый буфер (всё) output_buffer.append(chunk_text) # В result_buffer добавляем ТОЛЬКО если это не статус инструмента # Статусы инструментов начинаются с "\n🔧" if not chunk_text.strip().startswith("🔧"): result_buffer.append(chunk_text) logger.debug(f"output_buffer: {len(output_buffer)}, result_buffer: {len(result_buffer)}") # Если сообщение ещё не создано - создаём if stream_message is None: stream_message = await update.message.reply_text( f"⏳ {current_status}", parse_mode="Markdown" ) await asyncio.sleep(0.1) # Формируем текущий вывод current_output = "".join(output_buffer) # Обрезаем до безопасного размера if len(current_output) > 3500: current_output = current_output[-3500:] # Обновляем сообщение try: await stream_message.edit_text( f"⏳ {current_status}\n\n{current_output}", parse_mode="Markdown" ) except Exception as e: logger.debug(f"Ошибка редактирования: {e}") await asyncio.sleep(0.3) # Формируем контекст с историей + памятью + summary # Получаем summary и последние сообщения из ChromaDB summary = None try: summary, recent_messages = compactor.get_context_with_summary(limit=20) # Формируем историю из последних сообщений history_context = "\n".join([f"{msg['role']}: {msg['content']}" for msg in recent_messages]) except Exception as e: logger.error(f"Ошибка загрузки summary: {e}") # Fallback на старую логику 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) # Получаем текущего AI-провайдера from bot.ai_provider_manager import get_ai_provider_manager provider_manager = get_ai_provider_manager() current_provider = provider_manager.get_current_provider(state) logger.info(f"Обработка AI-запроса через провайдер: {current_provider}") # Собираем полный промпт с системным промптом system_prompt = qwen_manager.load_system_prompt() # Формируем полный промпт с summary (если есть) if summary: full_task = ( f"{system_prompt}\n\n" f"=== SUMMARY ДИАЛОГА (контекст) ===\n" f"{summary}\n\n" f"=== КОНТЕКСТ ПАМЯТИ ===\n" f"{memory_context}\n\n" f"=== ИСТОРИЯ ДИАЛОГА (последние 20 сообщений) ===\n" f"{history_context}\n\n" f"=== ЗАПРОС ПОЛЬЗОВАТЕЛЯ ===\n" f"{text}" ) else: full_task = ( f"{system_prompt}\n\n" f"=== КОНТЕКСТ ПАМЯТИ ===\n" f"{memory_context}\n\n" f"=== ИСТОРИЯ ДИАЛОГА ===\n" f"{history_context}\n\n" f"=== ЗАПРОС ПОЛЬЗОВАТЕЛЯ ===\n" f"{text}" ) # Выполняем задачу через текущего провайдера if current_provider == "qwen": # Qwen Code - потоковый вывод result = await qwen_manager.run_task( user_id, full_task, on_output, on_oauth_url, use_system_prompt=False, on_chunk=on_chunk, on_event=on_event ) # Формируем финальный результат ИЗ result_buffer (без статусов инструментов) full_output = "".join(result_buffer).strip() # Если result_buffer пустой — пробуем извлечь текст из result if not full_output: logger.warning("result_buffer пустой, пробуем извлечь текст из result") import re text_matches = re.findall(r'"text":"([^"]+)"', result) if text_matches: full_output = " ".join(text_matches).replace("\\n", "\n") else: full_output = "⚠️ Не удалось получить ответ ИИ" logger.error(f"Result: {result[:500]}...") provider_name = "Qwen Code" elif current_provider == "gigachat": # GigaChat - ответ целиком (не потоковый) # Обновляем статусное сообщение try: await status_msg.edit_text( "⏳ 🤖 **GigaChat думает...**", parse_mode="Markdown" ) except Exception as e: logger.debug(f"Ошибка обновления статуса для GigaChat: {e}") result = await provider_manager.execute_request( provider_id=current_provider, user_id=user_id, prompt=full_task, system_prompt=system_prompt, on_chunk=None # GigaChat не поддерживает потоковый вывод ) if result.get("success"): full_output = result.get("content", "") else: full_output = f"❌ **Ошибка {provider_manager.get_provider_info(current_provider).name}:**\n{result.get('error', 'Неизвестная ошибка')}" provider_name = "GigaChat" else: full_output = f"❌ Неизвестный провайдер: {current_provider}" provider_name = "Unknown" # Добавляем ответ ИИ в историю и память if full_output and 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:]) asyncio.create_task(hybrid_memory_manager.extract_facts_with_ai(user_id, dialog_context)) state.messages_since_fact_extract = 0 # Формируем сообщение с информацией о контексте и провайдере context_info = f"📊 Контекст: {context_percent}%\n🤖 AI: {provider_name}" # Экранируем специальные символы Markdown в ответе ИИ escaped_output = escape_markdown(full_output) # Отправляем результат ОТДЕЛЬНЫМ сообщением response_text = f"{escaped_output}\n\n*{context_info}*" # Отправляем новое сообщение с результатом await update.message.reply_text( response_text, parse_mode="Markdown" ) # Обновляем потоковое сообщение на финальный статус if stream_message: try: await stream_message.edit_text( f"✅ {current_status}", parse_mode="Markdown" ) except Exception as e: logger.debug(f"Ошибка обновления статусного сообщения: {e}") async def translate_title(title: str, max_length: int = 100) -> str: """ Краткий перевод заголовка на русский через ИИ. Если перевод не удался — возвращает оригинал. """ try: # Быстрый промпт для перевода prompt = f"Переведи на русский язык этот заголовок новости (максимум {max_length} символов, без кавычек и пояснений):\n{title[:200]}" # Используем qwen_manager для перевода from qwen_integration import qwen_manager # Создаём временную сессию для перевода import hashlib temp_user_id = f"translator_{hashlib.md5(title.encode()).hexdigest()}" result = await qwen_manager.run_task(temp_user_id, prompt, on_output=lambda x: None, on_oauth_url=lambda x: None, use_system_prompt=False) # Извлекаем текст из результата import re text_matches = re.findall(r'"text":"([^"]+)"', result) if text_matches: translated = " ".join(text_matches).replace("\\n", " ").strip() # Убираем кавычки если есть translated = translated.strip('"\'') if translated and len(translated) > 3: return translated[:max_length] return title[:max_length] except Exception as e: logger.debug(f"Ошибка перевода заголовка: {e}") return title[:max_length] async def format_tool_result(tool_name: str, result: 'ToolResult') -> str: """Форматировать результат выполнения инструмента.""" from bot.tools import ToolResult if tool_name == 'ddgs_search': if not result.data: return "🔍 Ничего не найдено по вашему запросу." output = "🔍 **Результаты поиска:**\n\n" for i, item in enumerate(result.data[:5], 1): title = item.get('title', 'Без названия') href = item.get('href', '') body = item.get('body', '')[:200] output += f"{i}. **{title}**\n" if href: output += f" {href}\n" if body: output += f" {body}\n\n" return output elif tool_name in ('rss_reader', 'rss_tool'): action = result.metadata.get('action', 'list') if action == 'list' and result.data: # Помечаем новости как прочитанные (digest_flag=1) from bot.tools.rss_tool import RSSTool rss_tool_instance = RSSTool(db_path='rss.db') for item in result.data: news_id = item.get('id') if news_id: await rss_tool_instance.mark_digest(news_id) logger.debug(f"Новость {news_id} помечена как прочитанная") output = "📰 **Последние новости:**\n\n" # Берём не более 15 новостей для читаемости news_count = min(len(result.data), 15) for i in range(news_count): item = result.data[i] title = item.get('title', 'Без названия') pub_date = item.get('pub_date', '') link = item.get('link', '') # Переводим заголовок на русский translated_title = await translate_title(title, max_length=100) # Форматируем дату date_str = "" if pub_date: try: # Преобразуем дату в более читаемый формат dt = datetime.strptime(pub_date[:19], '%Y-%m-%d %H:%M:%S') date_str = dt.strftime('%d.%m.%Y %H:%M') except: date_str = pub_date[:16] # Обрезаем заголовок если слишком длинный if len(translated_title) > 120: translated_title = translated_title[:117] + "..." output += f"**{i+1}. {translated_title}**\n" if date_str: output += f" 📅 {date_str}\n" if link: # Обрезаем ссылку для читаемости short_link = link[:60] + "..." if len(link) > 63 else link output += f" 🔗 {short_link}\n" output += "\n" return output elif action == 'fetch': total = result.data.get('total_new_items', 0) if result.data else 0 return f"✅ Получено {total} новых элементов из лент." elif action == 'list_feeds' and result.data: output = "📑 **Ваши RSS ленты:**\n\n" for feed in result.data: title = feed.get('title', feed.get('url', 'Unknown')) url = feed.get('url', '') last_fetch = feed.get('last_fetched', '') output += f"• **{title}**\n" output += f" 🔗 {url}\n" if last_fetch: output += f" 🕐 Обновлено: {last_fetch[:16]}\n" output += "\n" return output return f"RSS: {result.data}" elif tool_name == 'ssh_executor': if not result.success: return f"❌ **Ошибка SSH:**\n```\n{result.error}\n```" data = result.data server = result.metadata.get('server', 'unknown') command = result.metadata.get('command', '') output = f"🖥️ **SSH: {server}**\n" output += f"**Команда:** `{command}`\n\n" if data.get('stdout'): output += f"**Вывод:**\n```\n{data['stdout']}\n```\n\n" if data.get('stderr'): output += f"**Ошибки:**\n```\n{data['stderr']}\n```\n\n" if data.get('returncode') == 0: output += "✅ **Успешно**" else: output += f"❌ **Код возврата:** {data.get('returncode', 'N/A')}" return output elif tool_name == 'cron_tool': action = result.metadata.get('action', 'list') if action == 'list' and result.data: output = "⏰ **Ваши задачи:**\n\n" for job in result.data: status = "✅" if job.get('enabled') else "❌" notify_icon = "🔔" if job.get('notify') else "🔕" log_icon = "📝" if job.get('log_results') else "🚫" output += f"{status} **{job.get('name', 'Без названия')}** (ID: {job.get('id')})\n" output += f" {notify_icon}{log_icon} Промпт: _{job.get('prompt', '')[:100]}_{'...' if len(job.get('prompt', '')) > 100 else ''}\n" output += f" Расписание: `{job.get('schedule', '')}`\n" if job.get('next_run'): output += f" Следующий запуск: {job.get('next_run')}\n" if job.get('last_run'): output += f" Последний запуск: {job.get('last_run')}\n" output += "\n" return output elif action == 'add' and result.success: data = result.data notify_status = "🔔 Уведомлять" if result.metadata.get('notify') else "🔕 Без уведомлений" log_status = "📝 Логировать" if result.metadata.get('log_results') else "🚫 Без логов" return f"✅ **Задача добавлена:**\n• ID: {data.get('id')}\n• Название: {data.get('name')}\n• Расписание: {data.get('schedule')}\n• {notify_status}, {log_status}\n• Следующий запуск: {data.get('next_run', 'N/A')}" elif action == 'remove' and result.success: return f"✅ **Задача удалена:** ID {result.data.get('id')}" elif action == 'run' and result.success: result_text = result.metadata.get('result_text', 'Задача выполнена') tool_used = result.data.get('tool_used', 'не указан') return f"✅ **Задача выполнена:**\n\n{result_text}\n\n🔧 Инструмент: {tool_used}" elif action == 'run' and not result.success: return f"❌ **Ошибка выполнения задачи:**\n{result.error}" return f"Cron: {result.data}" return f"Инструмент {tool_name}: {result.data}" 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("cron", "Управление задачами"), BotCommand("stop", "Прервать SSH-сессию"), BotCommand("ai", "Задача для Qwen Code AI"), BotCommand("memory", "Статистика памяти ИИ"), BotCommand("facts", "Показать сохранённые факты"), BotCommand("forget", "Удалить факт по номеру"), ] await application.bot.set_my_commands(commands) # Инициализация компактора диалогов from bot.compaction import init_compactor global compactor compactor = init_compactor(qwen_manager, hybrid_memory_manager.vector) logger.info("🔄 Компактор диалогов инициализирован") # Инициализация планировщика cron-задач cron_tool = tools_registry.get('cron_tool') if cron_tool: scheduler = init_scheduler(cron_tool, ai_agent, send_notification=send_cron_notification) await scheduler.start() logger.info("🕐 Планировщик cron-задач инициализирован") else: logger.warning("⚠️ Cron инструмент не найден, планировщик не запущен") logger.info("Бот инициализирован") async def send_cron_notification(user_id: int, message: str): """ Отправить уведомление пользователю о результате cron-задачи. Args: user_id: ID пользователя в Telegram message: Текст уведомления """ try: # Получаем application из контекста from telegram.ext import Application app = Application.get_instance() if app: await app.bot.send_message( chat_id=user_id, text=message, parse_mode="Markdown" ) logger.info(f"🔔 Уведомление отправлено пользователю {user_id}") else: logger.warning("Application не инициализирован, уведомление не отправлено") except Exception as e: logger.exception(f"Ошибка отправки уведомления: {e}") @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 compact_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработка команды /compact — ручная компактификация истории диалога.""" user_id = update.effective_user.id logger.info(f"Пользователь {user_id} запросил ручную компактификацию") status_msg = await update.message.reply_text( "🔄 **Запуск компактификации истории...**\n\n" "_Сжатие старой истории в структурированный summary._\n" "_Это может занять несколько секунд._", parse_mode="Markdown" ) result = await compactor.compact() await status_msg.delete() if result.success: if result.messages_compressed > 0: await update.message.reply_text( f"✅ **Компактификация завершена!**\n\n" f"📊 Сжато сообщений: `{result.messages_compressed}`\n" f"📝 Длина summary: `{result.summary_length}` символов\n" f"💾 Экономия токенов: ~`{result.tokens_saved}`\n\n" f"_Summary автоматически используется в контексте диалога._", parse_mode="Markdown" ) else: await update.message.reply_text( "ℹ️ **Компактификация не требуется**\n\n" "_Недостаточно сообщений для сжатия или summary уже актуален._", parse_mode="Markdown" ) else: logger.error(f"Компактификация не удалась: {result.error}") await update.message.reply_text( f"⚠️ **Ошибка компактификации:**\n`{result.error}`", 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(menu_builder) # Инициализация AIProviderManager from qwen_integration import qwen_manager, gigachat_provider init_ai_provider_manager(qwen_manager, gigachat_provider) # Создание приложения с таймаутами и прокси 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("cron", cron_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("compact", compact_command)) application.add_handler(CommandHandler("facts", facts_command)) application.add_handler(CommandHandler("forget", forget_command)) application.add_handler(CommandHandler("rss", rss_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()