diff --git a/bot.py b/bot.py index 41cfcd8..b16e566 100644 --- a/bot.py +++ b/bot.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Telegram CLI Bot - бот для выполнения CLI команд с многоуровневым меню. -Версия: 0.7.4 (Универсальный интерфейс AI-провайдеров с поддержкой инструментов) +Версия: 0.8.0 (Исправление OAuth + память файлов + совместимость PTB 20.7+) """ import os @@ -2412,10 +2412,14 @@ def main(): # AI-пресеты from bot.handlers.ai_presets import register_ai_preset_handlers register_ai_preset_handlers(application) - + + # Обработчики файлов + from bot.handlers.files import register_file_handlers + register_file_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} запущен!") diff --git a/bot/handlers/files.py b/bot/handlers/files.py new file mode 100644 index 0000000..32173ab --- /dev/null +++ b/bot/handlers/files.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +"""Обработчики файлов для Telegram бота. + +Функционал: +- Прием файлов от пользователя с сохранением в uploads/YYYY-MM-DD/filename +- Команда /get filename — отправка файла пользователю +- Команда /files [date] — список файлов за дату (сегодня по умолчанию) +- Проверка размера файлов (max 20MB) +- Логирование операций в uploads/files.log +""" + +import os +import logging +from pathlib import Path +from datetime import datetime, timedelta +from typing import Optional + +from telegram import Update +from telegram.ext import CommandHandler, MessageHandler, filters, ContextTypes + +from bot.config import state_manager +from bot.utils.decorators import check_access +from vector_memory import save_message + +logger = logging.getLogger(__name__) + +# ============================================================================ +# КОНСТАНТЫ +# ============================================================================ + +# Максимальный размер файла: 20MB (ограничение Telegram) +MAX_FILE_SIZE_DOWNLOAD = 20 * 1024 * 1024 # 20 MB в байтах +MAX_FILE_SIZE = 20 * 1024 * 1024 # 20 MB в байтах + +# Базовая директория для загрузок +BASE_DIR = Path(__file__).parent.parent.parent +UPLOADS_DIR = BASE_DIR / "uploads" + +# Файл лога операций +LOG_FILE = UPLOADS_DIR / "files.log" + +# ============================================================================ +# ЛОГИРОВАНИЕ +# ============================================================================ + +def log_operation(operation: str, user_id: int, username: str, details: str = ""): + """Логирование операций с файлами.""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + log_entry = f"[{timestamp}] {operation} | user_id={user_id} | username={username}" + if details: + log_entry += f" | {details}" + + try: + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(log_entry + "\n") + except Exception as e: + logger.error(f"Ошибка записи в лог файлов: {e}") + + +def setup_file_logging(): + """Настройка отдельного логгера для операций с файлами.""" + UPLOADS_DIR.mkdir(parents=True, exist_ok=True) + + file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") + file_handler.setFormatter(logging.Formatter( + "[%(asctime)s] %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + )) + + file_logger = logging.getLogger("files") + file_logger.addHandler(file_handler) + file_logger.setLevel(logging.INFO) + + return file_logger + + +file_logger = setup_file_logging() + + +# ============================================================================ +# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ +# ============================================================================ + +def get_date_dir(date: Optional[datetime] = None) -> Path: + """Получить директорию для указанной даты.""" + if date is None: + date = datetime.now() + + date_str = date.strftime("%Y-%m-%d") + date_dir = UPLOADS_DIR / date_str + + # Создаем директорию если нет + date_dir.mkdir(parents=True, exist_ok=True) + + return date_dir + + +def get_file_path(filename: str, date: Optional[datetime] = None) -> Path: + """Получить полный путь к файлу.""" + date_dir = get_date_dir(date) + return date_dir / filename + + +def file_exists(filename: str, date: Optional[datetime] = None) -> bool: + """Проверить существование файла.""" + file_path = get_file_path(filename, date) + return file_path.exists() + + +def list_files_for_date(date: Optional[datetime] = None) -> list: + """Получить список файлов за указанную дату.""" + date_dir = get_date_dir(date) + + if not date_dir.exists(): + return [] + + files = [] + for item in date_dir.iterdir(): + if item.is_file() and item.name != "files.log": + stat = item.stat() + files.append({ + "name": item.name, + "size": stat.st_size, + "path": str(item), + "modified": datetime.fromtimestamp(stat.st_mtime) + }) + + # Сортируем по имени + files.sort(key=lambda x: x["name"]) + + return files + + +def format_file_size(size_bytes: int) -> str: + """Форматировать размер файла в человекочитаемом виде.""" + for unit in ["B", "KB", "MB", "GB"]: + if size_bytes < 1024: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024 + return f"{size_bytes:.1f} TB" + + +def parse_date_string(date_str: Optional[str]) -> Optional[datetime]: + """Распарсить строку даты.""" + if not date_str: + return None + + # Поддерживаемые форматы + formats = [ + "%Y-%m-%d", # 2024-01-15 + "%d.%m.%Y", # 15.01.2024 + "%d-%m-%Y", # 15-01-2024 + "%Y%m%d", # 20240115 + ] + + for fmt in formats: + try: + return datetime.strptime(date_str, fmt) + except ValueError: + continue + + # Относительные даты + if date_str.lower() in ["yesterday", "вчера"]: + return datetime.now() - timedelta(days=1) + elif date_str.lower() in ["today", "сегодня"]: + return datetime.now() + + return None + + +# ============================================================================ +# ОБРАБОТЧИК ФАЙЛОВ +# ============================================================================ + +@check_access +async def handle_file_message(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка входящих файлов от пользователя.""" + user_id = update.effective_user.id + username = update.effective_user.username or str(user_id) + + # Определяем тип файла и получаем файл + file = None + file_type = None + + if update.message.document: + file = update.message.document + file_type = "document" + elif update.message.photo: + # Берем фото в максимальном разрешении (последнее в списке) + file = update.message.photo[-1] + file_type = "photo" + elif update.message.audio: + file = update.message.audio + file_type = "audio" + elif update.message.voice: + file = update.message.voice + file_type = "voice" + elif update.message.video: + file = update.message.video + file_type = "video" + elif update.message.video_note: + file = update.message.video_note + file_type = "video_note" + elif update.message.sticker: + file = update.message.sticker + file_type = "sticker" + elif update.message.animation: + file = update.message.animation + file_type = "animation" + + if not file: + return + + # Получаем информацию о файле + file_name = file.file_name or f"file_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + file_size = file.file_size or 0 + + logger.info(f"Получен файл: {file_name}, размер: {file_size}, тип: {file_type}") + + # Проверка размера файла + if file_size > MAX_FILE_SIZE: + log_operation("REJECTED_SIZE", user_id, username, f"file={file_name}, size={file_size}") + await update.message.reply_text( + f"❌ **Файл слишком большой**\n\n" + f"Максимальный размер: {format_file_size(MAX_FILE_SIZE)}\n" + f"Размер файла: {format_file_size(file_size)}", + parse_mode="Markdown" + ) + return + + # Создаем директорию для сегодняшней даты + date_dir = get_date_dir() + file_path = date_dir / file_name + + # Обработка дубликатов имен + if file_path.exists(): + base, ext = os.path.splitext(file_name) + counter = 1 + while file_path.exists(): + new_name = f"{base}_{counter}{ext}" + file_path = date_dir / new_name + counter += 1 + file_name = file_path.name + + logger.info(f"Сохранение файла: {file_path}") + + # Отправляем статус + status_msg = await update.message.reply_text( + f"⏳ **Загрузка файла...**\n\n" + f"📁 {file_name}\n" + f"📊 Размер: {format_file_size(file_size)}", + parse_mode="Markdown" + ) + + try: + # Скачиваем файл + telegram_file = await context.bot.get_file(file.file_id) + await telegram_file.download_to_drive(file_path) + + # Логирование + log_operation("UPLOAD", user_id, username, f"file={file_name}, size={file_size}, path={file_path}") + file_logger.info(f"UPLOAD: user={username}, file={file_name}, size={file_size}") + + # === СОХРАНЕНИЕ В ПАМЯТЬ ИИ === + # Добавляем информацию о файле в историю диалога и векторную память + state = state_manager.get(user_id) + if state: + # Формируем сообщение о файле с ПОЛНЫМ абсолютным путём! + # Это важно чтобы ИИ правильно понимал где файл + absolute_path = str(file_path.absolute()) + file_info = f"Пользователь загрузил файл: {file_name} (тип: {file_type}, размер: {format_file_size(file_size)}, полный путь: {absolute_path})" + + # Добавляем в историю диалога + state.ai_chat_history.append(f"User: {file_info}") + + # Сохраняем в векторную память + save_message(user_id, "user", file_info) + + logger.info(f"Информация о файле сохранена в памяти ИИ: {file_name}, путь: {absolute_path}") + # =============================== + + # Обновляем статус + await status_msg.edit_text( + f"✅ **Файл сохранен!**\n\n" + f"📁 **Имя:** `{file_name}`\n" + f"📊 **Размер:** {format_file_size(file_size)}\n" + f"📂 **Директория:** `{date_dir.name}/`\n" + f"📅 **Дата:** {datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n" + f"Используйте `/files` для просмотра всех файлов за сегодня.", + parse_mode="Markdown" + ) + + except Exception as e: + logger.exception(f"Ошибка сохранения файла: {e}") + log_operation("UPLOAD_ERROR", user_id, username, f"file={file_name}, error={str(e)}") + + await status_msg.edit_text( + f"❌ **Ошибка сохранения файла**\n\n" + f"Файл: `{file_name}`\n" + f"Ошибка: `{str(e)}`", + parse_mode="Markdown" + ) + + +# ============================================================================ +# КОМАНДА /GET - ПОЛУЧЕНИЕ ФАЙЛА +# ============================================================================ + +@check_access +async def get_file_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Команда /get filename - отправка файла пользователю. + + Использование: + /get filename.txt - получить файл за сегодня + /get filename.txt 2024-01-15 - получить файл за указанную дату + """ + user_id = update.effective_user.id + username = update.effective_user.username or str(user_id) + + if not context.args: + await update.message.reply_text( + "❌ **Использование:**\n\n" + "`/get <имя_файла>` - получить файл за сегодня\n" + "`/get <имя_файла> <дата>` - получить файл за указанную дату\n\n" + "**Примеры:**\n" + "`/get document.pdf`\n" + "`/get photo.jpg 2024-01-15`\n" + "`/get data.csv вчера`", + parse_mode="Markdown" + ) + return + + # Парсим аргументы + filename = context.args[0] + date_arg = context.args[1] if len(context.args) > 1 else None + file_date = parse_date_string(date_arg) + + # Проверяем существование файла + file_path = get_file_path(filename, file_date) + + if not file_path.exists(): + # Ищем файл во всех директориях за сегодня + date_dir = get_date_dir(file_date) + await update.message.reply_text( + f"❌ **Файл не найден**\n\n" + f"Файл: `{filename}`\n" + f"Директория: `{date_dir.name}/`\n\n" + f"Используйте `/files` для просмотра доступных файлов.", + parse_mode="Markdown" + ) + log_operation("GET_NOT_FOUND", user_id, username, f"file={filename}, date={file_date}") + return + + # Проверка размера перед отправкой + file_size = file_path.stat().st_size + if file_size > MAX_FILE_SIZE: + await update.message.reply_text( + f"❌ **Файл слишком большой для отправки**\n\n" + f"Максимальный размер: {format_file_size(MAX_FILE_SIZE)}\n" + f"Размер файла: {format_file_size(file_size)}", + parse_mode="Markdown" + ) + log_operation("GET_TOO_LARGE", user_id, username, f"file={filename}, size={file_size}") + return + + # Отправляем статус + status_msg = await update.message.reply_text( + f"⏳ **Отправка файла...**\n\n" + f"📁 `{filename}`\n" + f"📊 {format_file_size(file_size)}", + parse_mode="Markdown" + ) + + try: + # Отправляем файл + with open(file_path, "rb") as f: + await update.message.reply_document( + document=f, + filename=filename, + caption=f"📁 **{filename}**\n📊 {format_file_size(file_size)}", + parse_mode="Markdown" + ) + + # Логирование + log_operation("GET", user_id, username, f"file={filename}, size={file_size}") + file_logger.info(f"GET: user={username}, file={filename}, size={file_size}") + + # Удаляем статус + await status_msg.delete() + + except Exception as e: + logger.exception(f"Ошибка отправки файла: {e}") + log_operation("GET_ERROR", user_id, username, f"file={filename}, error={str(e)}") + + await status_msg.edit_text( + f"❌ **Ошибка отправки файла**\n\n" + f"Файл: `{filename}`\n" + f"Ошибка: `{str(e)}`", + parse_mode="Markdown" + ) + + +# ============================================================================ +# КОМАНДА /FILES - СПИСОК ФАЙЛОВ +# ============================================================================ + +@check_access +async def list_files_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Команда /files [date] - список файлов за указанную дату. + + Использование: + /files - список файлов за сегодня + /files 2024-01-15 - список файлов за указанную дату + /files вчера - список файлов за вчера + """ + user_id = update.effective_user.id + username = update.effective_user.username or str(user_id) + + # Парсим дату из аргументов + date_arg = context.args[0] if context.args else None + file_date = parse_date_string(date_arg) + + if file_date is None and date_arg: + await update.message.reply_text( + f"❌ **Неверный формат даты:** `{date_arg}`\n\n" + f"**Поддерживаемые форматы:**\n" + f"• `2024-01-15` (YYYY-MM-DD)\n" + f"• `15.01.2024` (DD.MM.YYYY)\n" + f"• `сегодня` / `today`\n" + f"• `вчера` / `yesterday`", + parse_mode="Markdown" + ) + return + + # Получаем список файлов + files = list_files_for_date(file_date) + + date_str = (file_date or datetime.now()).strftime("%Y-%m-%d") + date_dir = get_date_dir(file_date) + + if not files: + await update.message.reply_text( + f"📭 **Нет файлов за {date_str}**\n\n" + f"Директория: `{date_dir.name}/`\n\n" + f"Отправьте файл в чат чтобы сохранить его.", + parse_mode="Markdown" + ) + log_operation("LIST_EMPTY", user_id, username, f"date={date_str}") + return + + # Формируем вывод + total_size = sum(f["size"] for f in files) + + output = f"📁 **Файлы за {date_str}**\n\n" + output += f"📂 Директория: `{date_dir.name}/`\n" + output += f"📊 Всего файлов: {len(files)}\n" + output += f"💾 Общий размер: {format_file_size(total_size)}\n\n" + + for i, file_info in enumerate(files, 1): + name = file_info["name"] + size = format_file_size(file_info["size"]) + modified = file_info["modified"].strftime("%H:%M") + + # Обрезаем длинные имена + if len(name) > 40: + display_name = name[:37] + "..." + else: + display_name = name + + output += f"{i}. `{display_name}` - {size} ({modified})\n" + + output += f"\n💡 **Использование:**\n" + output += f"`/get <имя_файла>` - скачать файл\n" + output += f"`/get <имя_файла> {date_str}` - скачать файл за дату" + + await update.message.reply_text(output, parse_mode="Markdown") + log_operation("LIST", user_id, username, f"date={date_str}, count={len(files)}") + + +# ============================================================================ +# РЕГИСТРАЦИЯ ХЕНДЛЕРОВ +# ============================================================================ + +def register_file_handlers(application): + """Регистрация обработчиков файлов в приложении.""" + # Обработчик входящих файлов (документы, фото, аудио, видео и т.д.) + application.add_handler(MessageHandler( + filters.Document.ALL | + filters.PHOTO | + filters.AUDIO | + filters.VOICE | + filters.VIDEO | + filters.VIDEO_NOTE | + filters.Sticker.ALL | + filters.ANIMATION, + handle_file_message + )) + + # Команда /get - получение файла + application.add_handler(CommandHandler("get", get_file_command)) + + # Команда /files - список файлов + application.add_handler(CommandHandler("files", list_files_command)) + + logger.info("Обработчики файлов зарегистрированы") diff --git a/bot/utils/qwen_oauth.py b/bot/utils/qwen_oauth.py index 832f173..34913ca 100644 --- a/bot/utils/qwen_oauth.py +++ b/bot/utils/qwen_oauth.py @@ -70,27 +70,69 @@ class DeviceAuthorizationResponse: class QwenOAuthClient: """Qwen OAuth 2.0 клиент.""" - + + # Как в qwen-code: 30 секунд буфер + 5 секунд интервал проверки + TOKEN_REFRESH_BUFFER_MS = 30 * 1000 + CACHE_CHECK_INTERVAL_MS = 5 * 1000 + def __init__(self, credentials_path: Optional[Path] = None): self.credentials_path = credentials_path or QWEN_CREDENTIALS_FILE self._credentials: Optional[QwenCredentials] = None + self._file_mod_time: float = 0 # Время последней модификации файла + self._last_check_time: float = 0 # Время последней проверки self._load_credentials() - - def _load_credentials(self) -> None: - """Загрузить токены из файла.""" + + def _get_file_mod_time(self) -> float: + """Получить время модификации файла токенов.""" + try: + if self.credentials_path.exists(): + return self.credentials_path.stat().st_mtime + except Exception: + pass + return 0 + + def _load_credentials(self, force: bool = False) -> None: + """ + Загрузить токены из файла. + + Args: + force: Принудительная перезагрузка даже если файл не изменился + """ + current_mod_time = self._get_file_mod_time() + now = time.time() + + # Если force=True — пропускаем все проверки и загружаем всегда + if not force: + # Проверяем не слишком ли часто проверяем (как в qwen-code) + if (now - self._last_check_time) < (self.CACHE_CHECK_INTERVAL_MS / 1000): + return + + # Проверяем изменился ли файл (как в qwen-code) + if current_mod_time <= self._file_mod_time: + # Файл не изменился — используем кэш + return + + self._last_check_time = now + + logger.debug(f"Загрузка токенов из {self.credentials_path} (mod_time={current_mod_time}, force={force})") if self.credentials_path.exists(): try: with open(self.credentials_path, 'r') as f: data = json.load(f) self._credentials = QwenCredentials(**data) + self._file_mod_time = current_mod_time logger.info(f"Токены загружены из {self.credentials_path}") + logger.debug(f"Access token: {self._credentials.access_token[:20] if self._credentials.access_token else 'None'}..., expiry: {self._credentials.expiry_date}") + # НЕ вызываем has_valid_token() здесь — это вызывает рекурсию! except Exception as e: logger.error(f"Ошибка загрузки токенов: {e}") self._credentials = None + self._file_mod_time = 0 else: logger.debug("Файл с токенами не найден") self._credentials = None - + self._file_mod_time = 0 + # Загружаем code_verifier из device_code.json если есть device_code_file = QWEN_CONFIG_DIR / 'device_code.json' if device_code_file.exists(): @@ -116,6 +158,9 @@ class QwenOAuthClient: def has_valid_token(self) -> bool: """Проверка наличия валидного токена.""" + # Принудительно перезагружаем токены из файла + # Это нужно потому что токены могут быть обновлены другим процессом (qwen-code CLI) + self._load_credentials(force=True) return self._credentials is not None and not self._credentials.is_expired() async def get_access_token(self) -> Optional[str]: diff --git a/bot/utils/ssh_readers.py b/bot/utils/ssh_readers.py index 82bf29f..e3a3f57 100644 --- a/bot/utils/ssh_readers.py +++ b/bot/utils/ssh_readers.py @@ -42,12 +42,12 @@ def detect_input_type(text: str) -> Optional[str]: async def read_ssh_output(process: asyncssh.SSHClientProcess, timeout: float = 2.0, wait_for_completion: bool = False) -> Tuple[str, bool]: """ Чтение вывода из SSH-процесса с таймаутом. - + Args: process: SSH процесс для чтения timeout: Таймаут для чтения данных (сек) wait_for_completion: Если True, дождаться завершения процесса через process.wait() - + Returns: (вывод, завершён_ли_процесс) """ @@ -56,11 +56,11 @@ async def read_ssh_output(process: asyncssh.SSHClientProcess, timeout: float = 2 is_done = False try: - # Используем readany() для чтения доступных данных + # Используем read() для чтения доступных данных while True: try: - # readany() читает любые доступные данные - data = await asyncio.wait_for(process.stdout.readany(), timeout=timeout) + # read() читает данные до EOF + data = await asyncio.wait_for(process.stdout.read(), timeout=timeout) if data: if isinstance(data, bytes): output += data.decode('utf-8', errors='replace') @@ -92,7 +92,7 @@ async def read_ssh_output(process: asyncssh.SSHClientProcess, timeout: float = 2 try: while True: try: - data = await asyncio.wait_for(process.stderr.readany(), timeout=0.5) + data = await asyncio.wait_for(process.stderr.read(), timeout=0.5) if data: if isinstance(data, bytes): error_output += data.decode('utf-8', errors='replace') diff --git a/qwen_integration.py b/qwen_integration.py index 26b1e59..f5d08cb 100644 --- a/qwen_integration.py +++ b/qwen_integration.py @@ -18,13 +18,17 @@ from datetime import datetime, timedelta from typing import Optional, Dict, Callable, Any, List, Union from enum import Enum -# Импортируем OAuth модуль +# Импортируем OAuth модуль и константы from bot.utils.qwen_oauth import ( get_authorization_url, check_authorization_complete, is_authorized, get_access_token, - clear_authorization + clear_authorization, + QWEN_OAUTH_CLIENT_ID, + QWEN_OAUTH_BASE_URL, + QWEN_OAUTH_DEVICE_CODE_ENDPOINT, + QWEN_OAUTH_TOKEN_ENDPOINT ) logger = logging.getLogger(__name__) @@ -352,6 +356,15 @@ class QwenCodeManager: session.output_buffer = "" try: + # ПРОВЕРКА: Проверяем авторизацию ПЕРЕД запуском qwen-code + # Это предотвращает ошибку "No auth type is selected" + if not is_authorized(): + logger.warning("Пользователь не авторизован в Qwen, получаем OAuth URL") + oauth_url = await get_authorization_url() + if oauth_url and session.on_oauth_url: + await session.on_oauth_url(oauth_url) + return "🔐 Требуется авторизация Qwen. Пожалуйста, пройдите по ссылке и вернитесь." + env = os.environ.copy() env["FORCE_COLOR"] = "0"