2432 lines
107 KiB
Python
2432 lines
107 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Telegram CLI Bot - бот для выполнения CLI команд с многоуровневым меню.
|
||
Версия: 0.7.4 (Универсальный интерфейс AI-провайдеров с поддержкой инструментов)
|
||
"""
|
||
|
||
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, wait_and_read_ssh
|
||
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()
|
||
|
||
# ПРОВЕРКА: игнорируем сообщения от самого бота (защита от зацикливания)
|
||
if update.effective_user.is_bot:
|
||
logger.debug(f"Игнорируем сообщение от бота: {text[:50]}")
|
||
return
|
||
|
||
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_output_control:
|
||
logger.info(f"Пользователь {user_id} отправил команду во время ожидания кнопки — прерываем вывод")
|
||
state.waiting_for_output_control = False
|
||
state.continue_output = False # Отменяем текущий вывод
|
||
# Не отвечаем на старое сообщение с кнопками — просто продолжаем обработку команды
|
||
|
||
# Проверка: не в режиме ввода данных сервера ли мы
|
||
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.waiting_for_restart_password:
|
||
await handle_restart_password(update, text)
|
||
return
|
||
|
||
# Проверка: не ждём ли завершения OAuth авторизации Qwen
|
||
if state.waiting_for_qwen_oauth:
|
||
await handle_qwen_oauth_completion(update, text)
|
||
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)
|
||
|
||
# === ПРОВЕРКА: AI-пресет ===
|
||
ai_preset = state.ai_preset
|
||
|
||
# Если ИИ отключен — пропускаем обработку
|
||
if ai_preset == "off":
|
||
logger.info(f"Пользователь {user_id}: ИИ отключен, пропускаем обработку")
|
||
return
|
||
|
||
# === ПРОВЕРКА: Авторизация Qwen ===
|
||
from bot.utils.qwen_oauth import is_authorized, get_authorization_url
|
||
|
||
if not await is_authorized():
|
||
logger.info(f"Пользователь {user_id} не авторизован в Qwen, получаем OAuth URL")
|
||
oauth_url = await get_authorization_url()
|
||
|
||
if oauth_url:
|
||
# Устанавливаем флаг ожидания
|
||
state.waiting_for_qwen_oauth = True
|
||
|
||
await update.message.reply_text(
|
||
"🔐 **Требуется авторизация Qwen Code**\n\n"
|
||
"Для работы с Qwen Code необходимо авторизоваться.\n\n"
|
||
f"🔗 **[Открыть ссылку для авторизации]({oauth_url})**\n\n"
|
||
"Или скопируй:\n"
|
||
f"`{oauth_url}`\n\n"
|
||
"📋 **Инструкция:**\n"
|
||
"1. Нажми на ссылку выше или скопируй её в браузер\n"
|
||
"2. Войди через Google или GitHub\n"
|
||
"3. Разрешите доступ\n"
|
||
"4. Вернись в Telegram и отправь любое сообщение\n\n"
|
||
"_Бот автоматически продолжит работу после авторизации._",
|
||
parse_mode="Markdown",
|
||
disable_web_page_preview=True
|
||
)
|
||
else:
|
||
await update.message.reply_text(
|
||
"❌ **Ошибка получения OAuth URL**\n\n"
|
||
"Не удалось получить ссылку для авторизации. Попробуйте позже или используйте /qwen_auth",
|
||
parse_mode="Markdown"
|
||
)
|
||
return
|
||
|
||
# === ПРОВЕРКА: Нужна ли компактификация? ===
|
||
# Проверяем порог заполненности контекста
|
||
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
|
||
|
||
async def on_oauth_url(url: str):
|
||
"""Callback для OAuth URL — отправляем ссылку в чат."""
|
||
try:
|
||
await update.message.reply_text(
|
||
"🔐 **Требуется авторизация Qwen Code**\n\n"
|
||
"Для работы с Qwen Code необходимо авторизоваться.\n\n"
|
||
"🔗 **Инструкция:**\n"
|
||
"1. Откройте ссылку: `{url}`\n"
|
||
"2. Войдите через Google/GitHub\n"
|
||
"3. После авторизации отправьте любое сообщение в чат\n\n"
|
||
"_Бот автоматически продолжит работу после авторизации._".format(url=url),
|
||
parse_mode="Markdown"
|
||
)
|
||
logger.info(f"Отправлен OAuth URL пользователю {user_id}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки OAuth URL: {e}")
|
||
|
||
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 добавляем ТОЛЬКО если это не статус инструмента
|
||
# Статусы инструментов содержат "🔧 Использую инструмент:"
|
||
is_tool_status = "🔧 Использую инструмент:" in chunk_text
|
||
if not is_tool_status:
|
||
result_buffer.append(chunk_text)
|
||
|
||
logger.debug(f"output_buffer: {len(output_buffer)}, result_buffer: {len(result_buffer)}, is_tool_status: {is_tool_status}")
|
||
|
||
# Если сообщение ещё не создано - создаём
|
||
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:
|
||
# НЕ используем escape_markdown — это вызывает двойное экранирование
|
||
# Отправляем как plain text без parse_mode
|
||
await stream_message.edit_text(
|
||
f"⏳ {current_status}\n\n{current_output}"
|
||
)
|
||
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-пресет
|
||
ai_preset = state.ai_preset
|
||
|
||
# Определяем провайдера и модель на основе пресета
|
||
from bot.models.user_state import (
|
||
AI_PRESET_OFF,
|
||
AI_PRESET_QWEN,
|
||
AI_PRESET_GIGA_AUTO,
|
||
AI_PRESET_GIGA_LITE,
|
||
AI_PRESET_GIGA_PRO,
|
||
)
|
||
|
||
if ai_preset == AI_PRESET_OFF:
|
||
# ИИ отключен - не должны были сюда попасть
|
||
logger.warning(f"Попытка обработки AI-запроса при отключенном ИИ (пресет={ai_preset})")
|
||
return
|
||
|
||
if ai_preset == AI_PRESET_QWEN:
|
||
current_provider = "qwen"
|
||
provider_display = "Qwen Code"
|
||
elif ai_preset in [AI_PRESET_GIGA_AUTO, AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO]:
|
||
current_provider = "gigachat"
|
||
# Для GigaChat пресетов устанавливаем нужную модель
|
||
from bot.tools.gigachat_tool import GigaChatConfig
|
||
if ai_preset == AI_PRESET_GIGA_LITE:
|
||
# Принудительно Lite модель
|
||
GigaChatConfig.model = GigaChatConfig.model_lite
|
||
elif ai_preset == AI_PRESET_GIGA_PRO:
|
||
# Принудительно Pro модель
|
||
GigaChatConfig.model = GigaChatConfig.model_pro
|
||
# ai_preset == AI_PRESET_GIGA_AUTO использует авто-переключение в gigachat_tool.py
|
||
provider_display = f"GigaChat ({ai_preset})"
|
||
else:
|
||
# По умолчанию Qwen
|
||
current_provider = "qwen"
|
||
provider_display = "Qwen Code"
|
||
|
||
logger.info(f"AI-пресет: {ai_preset}, провайдер: {current_provider}")
|
||
|
||
# Получаем менеджера провайдеров
|
||
from bot.ai_provider_manager import get_ai_provider_manager
|
||
provider_manager = get_ai_provider_manager()
|
||
|
||
# Собираем полный промпт с системным промптом
|
||
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}")
|
||
|
||
# Формируем контекст для GigaChat из памяти и истории
|
||
# Это обеспечивает ту же функциональность что и для Qwen
|
||
context_messages = []
|
||
|
||
# Добавляем summary если есть
|
||
if summary:
|
||
context_messages.append({
|
||
"role": "system",
|
||
"content": f"=== SUMMARY ДИАЛОГА ===\n{summary}"
|
||
})
|
||
|
||
# Добавляем контекст памяти
|
||
if memory_context:
|
||
context_messages.append({
|
||
"role": "system",
|
||
"content": f"=== КОНТЕКСТ ПАМЯТИ ===\n{memory_context}"
|
||
})
|
||
|
||
# Добавляем историю диалога
|
||
if history_context:
|
||
for line in history_context.split("\n"):
|
||
if line.startswith("user:"):
|
||
context_messages.append({"role": "user", "content": line[5:].strip()})
|
||
elif line.startswith("assistant:"):
|
||
context_messages.append({"role": "assistant", "content": line[10:].strip()})
|
||
|
||
result = await provider_manager.execute_request(
|
||
provider_id=current_provider,
|
||
user_id=user_id,
|
||
prompt=text, # Только запрос пользователя
|
||
system_prompt=system_prompt, # System prompt отдельно
|
||
context=context_messages, # Контекст из памяти и истории
|
||
on_chunk=None # GigaChat не поддерживает потоковый вывод
|
||
)
|
||
|
||
if result.get("success"):
|
||
full_output = result.get("content", "")
|
||
# Получаем информацию о модели
|
||
model_name = result.get("metadata", {}).get("model")
|
||
if model_name:
|
||
provider_name = f"GigaChat ({model_name})"
|
||
else:
|
||
provider_name = "GigaChat"
|
||
else:
|
||
full_output = f"❌ **Ошибка {provider_manager.get_provider_info(current_provider).name}:**\n{result.get('error', 'Неизвестная ошибка')}"
|
||
provider_name = "GigaChat"
|
||
|
||
# Добавляем ответ ИИ в историю и память
|
||
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 тоже т.к. он содержит % символ
|
||
context_info = f"📊 Контекст: {context_percent}\\%\n🤖 AI: {provider_name}"
|
||
|
||
# НЕ используем escape_markdown — это вызывает двойное экранирование
|
||
# Вместо этого отправляем ответ ИИ как plain text, а context_info с Markdown
|
||
# Отправляем результат ОТДЕЛЬНЫМ сообщением
|
||
response_text = f"{full_output}\n\n*{context_info}*"
|
||
|
||
# Отправляем новое сообщение с результатом
|
||
# parse_mode=None для full_output (plain text), но Markdown для context_info
|
||
# Telegram не поддерживает смешанный parse_mode, поэтому используем Markdown
|
||
# и полагаемся на то что ИИ генерирует корректный текст
|
||
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_tool':
|
||
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
|
||
)
|
||
|
||
# Читаем вывод с ожиданием завершения процесса
|
||
# wait_and_read_ssh решает проблему с returncode, который доступен только после завершения
|
||
output, error_output, returncode = await wait_and_read_ssh(process, timeout=30.0)
|
||
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, error_output, returncode)
|
||
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):
|
||
"""Выполнение локальной команды из сообщения."""
|
||
user_id = update.effective_user.id
|
||
|
||
# Для простых команд используем subprocess (быстро и надёжно)
|
||
# Для интерактивных команд (sudo, ssh и т.д.) нужен pexpect
|
||
logger.info(f"Выполнение локальной команды: {command} в {working_dir}")
|
||
|
||
# Проверяем, нужна ли интерактивность
|
||
needs_interactive = any(cmd in command for cmd in ['sudo', 'ssh', 'su ', 'passwd', 'login'])
|
||
|
||
if needs_interactive:
|
||
logger.info("Команда требует интерактивного ввода, используем pexpect")
|
||
await _execute_local_command_interactive(update, command, working_dir)
|
||
else:
|
||
logger.info("Выполняю команду через subprocess")
|
||
await _execute_local_command_subprocess(update, command, working_dir)
|
||
|
||
|
||
async def _execute_local_command_subprocess(update: Update, command: str, working_dir: str):
|
||
"""Выполнение локальной команды через subprocess (без интерактивности)."""
|
||
try:
|
||
logger.info(f"Создаю subprocess: {command}")
|
||
process = await asyncio.create_subprocess_shell(
|
||
command,
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE,
|
||
cwd=working_dir
|
||
)
|
||
logger.info(f"Process PID: {process.pid}, жду выполнения...")
|
||
|
||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
|
||
logger.info(f"Process завершен с кодом: {process.returncode}")
|
||
|
||
output = stdout.decode("utf-8", errors="replace").strip()
|
||
error = stderr.decode("utf-8", errors="replace").strip()
|
||
|
||
logger.info(f"Output length: {len(output)}, Error length: {len(error)}")
|
||
|
||
await _show_result_message(update, command, output, error, process.returncode)
|
||
|
||
except asyncio.TimeoutError:
|
||
logger.error("Таймаут выполнения команды")
|
||
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_local_command_interactive(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
|
||
)
|
||
|
||
# Создаём сессию
|
||
session = local_session_manager.create_session(
|
||
user_id=user_id,
|
||
command=command,
|
||
master_fd=child.child_fd,
|
||
pid=child.pid
|
||
)
|
||
session.context = {'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
|
||
)
|
||
|
||
# Читаем вывод с ожиданием завершения процесса
|
||
# wait_and_read_ssh решает проблему с returncode, который доступен только после завершения
|
||
output, error_output, returncode = await wait_and_read_ssh(process, timeout=30.0)
|
||
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, error_output, returncode)
|
||
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):
|
||
"""Показ результата выполнения команды."""
|
||
logger.info(f"_show_result_message: output_len={len(output)}, error_len={len(error)}")
|
||
|
||
# Очистка ANSI-кодов и нормализация
|
||
if output:
|
||
output = clean_ansi_codes(output)
|
||
logger.info(f"После clean_ansi_codes: output_len={len(output)}")
|
||
output = normalize_output(output)
|
||
logger.info(f"После normalize_output: output_len={len(output)}")
|
||
else:
|
||
output = ""
|
||
|
||
error = clean_ansi_codes(error) if error else ""
|
||
|
||
result = f"✅ *Результат:*\n\n"
|
||
|
||
if output:
|
||
# Показываем ВЕСЬ вывод, разбивая на сообщения если нужно
|
||
# НЕ экранируем backticks — send_long_message сам разобьёт на блоки
|
||
result += f"```\n{output}\n```\n"
|
||
logger.info(f"Добавлен output в результат, длина result={len(result)}")
|
||
else:
|
||
logger.warning("output пустой после обработки!")
|
||
|
||
if error:
|
||
# НЕ экранируем backticks
|
||
result += f"*Ошибки:*\n```\n{error}\n```\n"
|
||
|
||
result += f"\n*Код возврата:* `{returncode}`"
|
||
|
||
# Экранируем специальные символы Markdown ТОЛЬКО вне блоков кода
|
||
result = smart_escape_markdown(result)
|
||
logger.info(f"Отправляю сообщение, длина={len(result)}")
|
||
await send_long_message(update, result, parse_mode="Markdown", pause_every=3)
|
||
logger.info("Сообщение отправлено")
|
||
|
||
|
||
def smart_escape_markdown(text: str) -> str:
|
||
"""
|
||
Умное экранирование Markdown — только вне блоков кода.
|
||
Не трогает уже существующую разметку (*жирный*, _курсив_, `код`).
|
||
"""
|
||
# Разбиваем на части: внутри ``` и снаружи
|
||
parts = text.split("```")
|
||
escaped_parts = []
|
||
|
||
for i, part in enumerate(parts):
|
||
if i % 2 == 0:
|
||
# Вне блоков кода — экранируем ТОЛЬКО одиночные спецсимволы
|
||
# Не трогаем: *текст*, _текст_, `код`, [текст](url)
|
||
escaped = escape_unescaped_special_chars(part)
|
||
escaped_parts.append(escaped)
|
||
else:
|
||
# Внутри блоков кода — не трогаем
|
||
escaped_parts.append(part)
|
||
|
||
return "```".join(escaped_parts)
|
||
|
||
|
||
def escape_unescaped_special_chars(text: str) -> str:
|
||
"""
|
||
Экранирует спецсимволы Markdown которые ещё не экранированы.
|
||
Не трогает уже размеченный текст.
|
||
"""
|
||
# Сначала экранируем обратные слэши
|
||
text = text.replace('\\', '\\\\')
|
||
|
||
# Экранируем * _ [ ] ( ) которые не являются частью разметки
|
||
# Простая эвристика: экранируем если символ не окружён буквами/цифрами
|
||
import re
|
||
|
||
# Экранируем * если это не *текст*
|
||
# text = re.sub(r'(?<!\w)\*(?!\w)', r'\*', text) # Оставляем *текст*
|
||
|
||
# Для простоты — экранируем только одиночные символы в начале строки или после пробела
|
||
# Это предотвращает экранирование *Результат:* но экранирует случайные * в выводе
|
||
|
||
# На самом деле, для вывода команд лучше вообще не экранировать * и _
|
||
# Экранируем только [ ] ( ) которые могут сломать ссылки
|
||
text = text.replace('[', '\\[')
|
||
text = text.replace(']', '\\]')
|
||
|
||
return text
|
||
|
||
|
||
async def post_init(application: Application):
|
||
"""Инициализация после запуска бота."""
|
||
# Установка команд бота
|
||
commands = [
|
||
BotCommand("start", "Запустить бота"),
|
||
BotCommand("menu", "Главное меню с кнопками"),
|
||
BotCommand("help", "Справка"),
|
||
BotCommand("settings", "Настройки"),
|
||
BotCommand("cron", "Управление задачами"),
|
||
BotCommand("stop", "Прервать SSH-сессию"),
|
||
BotCommand("restart_bot", "Перезапустить бота"),
|
||
BotCommand("qwen_auth", "Авторизовать Qwen Code"),
|
||
BotCommand("ai_presets", "🎛️ Выбор AI-провайдера"),
|
||
BotCommand("ai_off", "⌨️ ИИ Отключен (CLI режим)"),
|
||
BotCommand("ai_qwen", "💻 Qwen Code (бесплатно)"),
|
||
BotCommand("ai_giga_auto", "🧠 GigaChat Авто (Lite/Pro)"),
|
||
BotCommand("ai_giga_lite", "🚀 GigaChat Lite (дешево)"),
|
||
BotCommand("ai_giga_pro", "👑 GigaChat Pro (максимум)"),
|
||
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("Бот инициализирован")
|
||
|
||
# Проверяем, был ли запрошен перезапуск пользователем
|
||
await check_restart_and_notify(application)
|
||
|
||
|
||
async def check_restart_and_notify(application):
|
||
"""Проверить файл перезапуска и отправить уведомление пользователю."""
|
||
import os
|
||
import json
|
||
|
||
restart_file = "/tmp/telegram_bot_restart.json"
|
||
|
||
try:
|
||
if os.path.exists(restart_file):
|
||
with open(restart_file, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
|
||
user_id = data.get('user_id')
|
||
|
||
if user_id:
|
||
logger.info(f"📢 Отправка уведомления о запуске пользователю {user_id}")
|
||
|
||
# Получаем состояние пользователя
|
||
state = state_manager.get(user_id)
|
||
|
||
# Показываем текущую директорию и сервер
|
||
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 application.bot.send_message(
|
||
chat_id=user_id,
|
||
text=f"✅ Бот перезапущен!\n\n🖥️ Сервер: {server_desc}\n📁 Директория: {working_dir}\n\nГотов к работе! Отправьте команду или выберите действие в меню:",
|
||
reply_markup=menu_builder.get_keyboard("main", user_id=user_id, state=state)
|
||
)
|
||
logger.info(f"✅ Уведомление отправлено пользователю {user_id}")
|
||
|
||
# Удаляем файл
|
||
os.remove(restart_file)
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Ошибка при отправке уведомления о перезапуске: {e}")
|
||
|
||
|
||
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"
|
||
)
|
||
|
||
|
||
|
||
|
||
# ============================================
|
||
# КОМАНДА ПЕРЕЗАПУСКА БОТА
|
||
# ============================================
|
||
|
||
@check_access
|
||
async def restart_bot_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Обработка команды /restart_bot - перезапуск бота через systemctl."""
|
||
user_id = update.effective_user.id
|
||
state = state_manager.get(user_id)
|
||
|
||
# Устанавливаем флаг ожидания пароля
|
||
state.waiting_for_restart_password = True
|
||
|
||
# Отключаем ИИ на время ввода пароля
|
||
state.ai_chat_mode = False
|
||
|
||
await update.message.reply_text(
|
||
"🔄 **Перезапуск бота**\n\n"
|
||
"Для перезапуска требуется ввести пароль `sudo`.\n\n"
|
||
"🔐 *Отправьте пароль в чат:*\n\n"
|
||
"_После ввода пароль будет использован для команды:_\n"
|
||
"`sudo systemctl restart telegram-bot`\n\n"
|
||
"⚠️ *Бот будет перезапущен, соединение прервётся.*",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
|
||
# ============================================
|
||
# QWEN OAUTH АВТОРИЗАЦИЯ
|
||
# ============================================
|
||
|
||
@check_access
|
||
async def qwen_auth_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Обработка команды /qwen_auth - авторизация Qwen Code."""
|
||
user_id = update.effective_user.id
|
||
state = state_manager.get(user_id)
|
||
|
||
from bot.utils.qwen_oauth import get_authorization_url, is_authorized
|
||
|
||
# Проверяем есть ли уже валидный токен
|
||
if await is_authorized():
|
||
await update.message.reply_text(
|
||
"✅ **Qwen уже авторизован!**\n\n"
|
||
"Токен действителен и готов к использованию.",
|
||
parse_mode="Markdown"
|
||
)
|
||
return
|
||
|
||
# Получаем OAuth URL
|
||
oauth_url = await get_authorization_url()
|
||
|
||
if not oauth_url:
|
||
await update.message.reply_text(
|
||
"❌ **Ошибка получения OAuth URL**\n\n"
|
||
"Не удалось получить ссылку для авторизации. Попробуйте позже.",
|
||
parse_mode="Markdown"
|
||
)
|
||
return
|
||
|
||
# Устанавливаем флаг ожидания
|
||
state.waiting_for_qwen_oauth = True
|
||
|
||
await update.message.reply_text(
|
||
"🔐 **Авторизация Qwen Code**\n\n"
|
||
"Для работы с Qwen Code необходимо авторизоваться.\n\n"
|
||
"🔗 **Откройте ссылку:**\n"
|
||
f"`{oauth_url}`\n\n"
|
||
"📋 **Инструкция:**\n"
|
||
"1. Нажмите на ссылку или скопируйте её в браузер\n"
|
||
"2. Войдите через Google или GitHub\n"
|
||
"3. Разрешите доступ\n"
|
||
"4. Вернитесь в Telegram и отправьте любое сообщение\n\n"
|
||
"_Бот автоматически проверит завершение авторизации._",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
|
||
async def handle_qwen_oauth_completion(update: Update, text: str):
|
||
"""Проверка завершения OAuth авторизации."""
|
||
user_id = update.effective_user.id
|
||
state = state_manager.get(user_id)
|
||
|
||
from bot.utils.qwen_oauth import check_authorization_complete, is_authorized
|
||
|
||
# Проверяем завершение авторизации
|
||
if await check_authorization_complete():
|
||
state.waiting_for_qwen_oauth = False
|
||
|
||
if await is_authorized():
|
||
await update.message.reply_text(
|
||
"✅ **Авторизация успешна!**\n\n"
|
||
"Qwen Code готов к работе. Отправьте задачу.",
|
||
parse_mode="Markdown"
|
||
)
|
||
return
|
||
|
||
# Если авторизация ещё не завершена — показываем статус
|
||
await update.message.reply_text(
|
||
"⏳ **Проверка авторизации...**\n\n"
|
||
"Если вы уже авторизовались, бот продолжит работу.\n"
|
||
"Если нет — откройте ссылку для авторизации.",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
|
||
async def handle_restart_password(update: Update, text: str):
|
||
"""Обработка пароля для перезапуска бота."""
|
||
user_id = update.effective_user.id
|
||
state = state_manager.get(user_id)
|
||
password = text.strip()
|
||
|
||
logger.info(f"Пользователь {user_id} ввёл пароль для перезапуска бота")
|
||
|
||
# Сбрасываем флаг
|
||
state.waiting_for_restart_password = False
|
||
|
||
try:
|
||
# Сохраняем user_id в файл для уведомления после перезапуска
|
||
import json
|
||
import os
|
||
restart_file = "/tmp/telegram_bot_restart.json"
|
||
with open(restart_file, 'w', encoding='utf-8') as f:
|
||
json.dump({'user_id': user_id}, f, ensure_ascii=False)
|
||
|
||
# Отправляем сообщение о начале перезапуска
|
||
await update.message.reply_text(
|
||
"⏳ *Выполнение перезапуска...*\n\n"
|
||
f"Пароль принят, выполняю команду.\n\n"
|
||
"_Бот будет недоступен несколько секунд._",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
# Создаём временный скрипт с паролем
|
||
import tempfile
|
||
script_file = tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False)
|
||
# Используем script для создания псевдо-терминала
|
||
script_file.write(f"""#!/bin/bash
|
||
printf '%s\\n' '{password}' | sudo -S systemctl restart telegram-bot
|
||
""")
|
||
script_file.close()
|
||
os.chmod(script_file.name, 0o755)
|
||
|
||
# Запускаем через script для PTY
|
||
process = await asyncio.create_subprocess_exec(
|
||
'script', '-q', '-c', f'/bin/bash {script_file.name}', '/dev/null',
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE
|
||
)
|
||
|
||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15)
|
||
|
||
# Удаляем скрипт
|
||
os.remove(script_file.name)
|
||
|
||
if process.returncode == 0:
|
||
logger.info(f"Бот успешно перезапущен пользователем {user_id}")
|
||
else:
|
||
error_msg = stderr.decode('utf-8', errors='replace').strip()
|
||
logger.error(f"Ошибка перезапуска: {error_msg}")
|
||
# Удаляем файл если ошибка
|
||
if os.path.exists(restart_file):
|
||
os.remove(restart_file)
|
||
await update.message.reply_text(
|
||
f"❌ *Ошибка перезапуска:*\n```\n{error_msg}\n```",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
except asyncio.TimeoutError:
|
||
logger.error("Таймаут при перезапуске бота")
|
||
await update.message.reply_text(
|
||
"❌ *Ошибка*\n\n"
|
||
"Таймаут выполнения команды перезапуска.",
|
||
parse_mode="Markdown"
|
||
)
|
||
if os.path.exists("/tmp/telegram_bot_restart.json"):
|
||
os.remove("/tmp/telegram_bot_restart.json")
|
||
except Exception as e:
|
||
logger.exception(f"Ошибка при перезапуске бота: {e}")
|
||
await update.message.reply_text(
|
||
"❌ *Ошибка*\n\n"
|
||
f"```\n{str(e)}\n```",
|
||
parse_mode="Markdown"
|
||
)
|
||
if os.path.exists("/tmp/telegram_bot_restart.json"):
|
||
os.remove("/tmp/telegram_bot_restart.json")
|
||
|
||
|
||
# ============================================
|
||
# КОМАНДЫ ДЛЯ РАБОТЫ С 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
|
||
from bot.tools import tools_registry
|
||
init_ai_provider_manager(qwen_manager, tools_registry)
|
||
|
||
# Создание приложения с таймаутами и прокси
|
||
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("rss", rss_command))
|
||
application.add_handler(CommandHandler("menu", menu_command))
|
||
application.add_handler(CommandHandler("stop", stop_command))
|
||
application.add_handler(CommandHandler("restart_bot", restart_bot_command))
|
||
application.add_handler(CommandHandler("qwen_auth", qwen_auth_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("ai", ai_command))
|
||
|
||
# AI-пресеты
|
||
from bot.handlers.ai_presets import register_ai_preset_handlers
|
||
register_ai_preset_handlers(application)
|
||
|
||
application.add_handler(CallbackQueryHandler(menu_callback))
|
||
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text_message))
|
||
|
||
# Запуск
|
||
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()
|