v0.8.0: Исправление OAuth + память файлов + совместимость PTB 20.7+
Основные изменения: - Исправлена рекурсия в qwen_oauth.py (RecursionError при проверке токенов) - Добавлена проверка force=True в _load_credentials() - Бот теперь запоминает загруженные файлы в памяти ИИ (ai_chat_history + vector_memory) - Сохранение полного абсолютного пути к файлу для корректной работы ИИ - Исправлена совместимость с python-telegram-bot 20.7+: - MAX_FILE_SIZE_DOWNLOAD → локальная константа - filters.STICKER → filters.Sticker.ALL - Обновлена версия бота до 0.8.0 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
c767f3d50b
commit
09fb020745
10
bot.py
10
bot.py
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Telegram CLI Bot - бот для выполнения CLI команд с многоуровневым меню.
|
Telegram CLI Bot - бот для выполнения CLI команд с многоуровневым меню.
|
||||||
Версия: 0.7.4 (Универсальный интерфейс AI-провайдеров с поддержкой инструментов)
|
Версия: 0.8.0 (Исправление OAuth + память файлов + совместимость PTB 20.7+)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -2412,10 +2412,14 @@ def main():
|
||||||
# AI-пресеты
|
# AI-пресеты
|
||||||
from bot.handlers.ai_presets import register_ai_preset_handlers
|
from bot.handlers.ai_presets import register_ai_preset_handlers
|
||||||
register_ai_preset_handlers(application)
|
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(CallbackQueryHandler(menu_callback))
|
||||||
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text_message))
|
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text_message))
|
||||||
|
|
||||||
# Запуск
|
# Запуск
|
||||||
logger.info("Запуск бота...")
|
logger.info("Запуск бота...")
|
||||||
print(f"🤖 {config.name} запущен!")
|
print(f"🤖 {config.name} запущен!")
|
||||||
|
|
|
||||||
|
|
@ -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("Обработчики файлов зарегистрированы")
|
||||||
|
|
@ -70,27 +70,69 @@ class DeviceAuthorizationResponse:
|
||||||
|
|
||||||
class QwenOAuthClient:
|
class QwenOAuthClient:
|
||||||
"""Qwen OAuth 2.0 клиент."""
|
"""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):
|
def __init__(self, credentials_path: Optional[Path] = None):
|
||||||
self.credentials_path = credentials_path or QWEN_CREDENTIALS_FILE
|
self.credentials_path = credentials_path or QWEN_CREDENTIALS_FILE
|
||||||
self._credentials: Optional[QwenCredentials] = None
|
self._credentials: Optional[QwenCredentials] = None
|
||||||
|
self._file_mod_time: float = 0 # Время последней модификации файла
|
||||||
|
self._last_check_time: float = 0 # Время последней проверки
|
||||||
self._load_credentials()
|
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():
|
if self.credentials_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(self.credentials_path, 'r') as f:
|
with open(self.credentials_path, 'r') as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
self._credentials = QwenCredentials(**data)
|
self._credentials = QwenCredentials(**data)
|
||||||
|
self._file_mod_time = current_mod_time
|
||||||
logger.info(f"Токены загружены из {self.credentials_path}")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка загрузки токенов: {e}")
|
logger.error(f"Ошибка загрузки токенов: {e}")
|
||||||
self._credentials = None
|
self._credentials = None
|
||||||
|
self._file_mod_time = 0
|
||||||
else:
|
else:
|
||||||
logger.debug("Файл с токенами не найден")
|
logger.debug("Файл с токенами не найден")
|
||||||
self._credentials = None
|
self._credentials = None
|
||||||
|
self._file_mod_time = 0
|
||||||
|
|
||||||
# Загружаем code_verifier из device_code.json если есть
|
# Загружаем code_verifier из device_code.json если есть
|
||||||
device_code_file = QWEN_CONFIG_DIR / 'device_code.json'
|
device_code_file = QWEN_CONFIG_DIR / 'device_code.json'
|
||||||
if device_code_file.exists():
|
if device_code_file.exists():
|
||||||
|
|
@ -116,6 +158,9 @@ class QwenOAuthClient:
|
||||||
|
|
||||||
def has_valid_token(self) -> bool:
|
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()
|
return self._credentials is not None and not self._credentials.is_expired()
|
||||||
|
|
||||||
async def get_access_token(self) -> Optional[str]:
|
async def get_access_token(self) -> Optional[str]:
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
async def read_ssh_output(process: asyncssh.SSHClientProcess, timeout: float = 2.0, wait_for_completion: bool = False) -> Tuple[str, bool]:
|
||||||
"""
|
"""
|
||||||
Чтение вывода из SSH-процесса с таймаутом.
|
Чтение вывода из SSH-процесса с таймаутом.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
process: SSH процесс для чтения
|
process: SSH процесс для чтения
|
||||||
timeout: Таймаут для чтения данных (сек)
|
timeout: Таймаут для чтения данных (сек)
|
||||||
wait_for_completion: Если True, дождаться завершения процесса через process.wait()
|
wait_for_completion: Если True, дождаться завершения процесса через process.wait()
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(вывод, завершён_ли_процесс)
|
(вывод, завершён_ли_процесс)
|
||||||
"""
|
"""
|
||||||
|
|
@ -56,11 +56,11 @@ async def read_ssh_output(process: asyncssh.SSHClientProcess, timeout: float = 2
|
||||||
is_done = False
|
is_done = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Используем readany() для чтения доступных данных
|
# Используем read() для чтения доступных данных
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# readany() читает любые доступные данные
|
# read() читает данные до EOF
|
||||||
data = await asyncio.wait_for(process.stdout.readany(), timeout=timeout)
|
data = await asyncio.wait_for(process.stdout.read(), timeout=timeout)
|
||||||
if data:
|
if data:
|
||||||
if isinstance(data, bytes):
|
if isinstance(data, bytes):
|
||||||
output += data.decode('utf-8', errors='replace')
|
output += data.decode('utf-8', errors='replace')
|
||||||
|
|
@ -92,7 +92,7 @@ async def read_ssh_output(process: asyncssh.SSHClientProcess, timeout: float = 2
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
try:
|
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 data:
|
||||||
if isinstance(data, bytes):
|
if isinstance(data, bytes):
|
||||||
error_output += data.decode('utf-8', errors='replace')
|
error_output += data.decode('utf-8', errors='replace')
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,17 @@ from datetime import datetime, timedelta
|
||||||
from typing import Optional, Dict, Callable, Any, List, Union
|
from typing import Optional, Dict, Callable, Any, List, Union
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
# Импортируем OAuth модуль
|
# Импортируем OAuth модуль и константы
|
||||||
from bot.utils.qwen_oauth import (
|
from bot.utils.qwen_oauth import (
|
||||||
get_authorization_url,
|
get_authorization_url,
|
||||||
check_authorization_complete,
|
check_authorization_complete,
|
||||||
is_authorized,
|
is_authorized,
|
||||||
get_access_token,
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -352,6 +356,15 @@ class QwenCodeManager:
|
||||||
session.output_buffer = ""
|
session.output_buffer = ""
|
||||||
|
|
||||||
try:
|
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 = os.environ.copy()
|
||||||
env["FORCE_COLOR"] = "0"
|
env["FORCE_COLOR"] = "0"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue