From a08fc8c9626dd672940302a39029578f5b008f42 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Sun, 26 Oct 2025 20:23:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B5=D0=B4=D0=B8=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20Lichess=20Statistics=20E?= =?UTF-8?q?cosystem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Объединены три проекта в один репозиторий - LichessWebServices - REST API для статистики - LichessClientTG_bot - Telegram бот с поддержкой множества пользователей - LichessWebView - Веб-интерфейс для просмотра пользователей и игроков - Добавлен общий docker-compose.yml для запуска всех сервисов - Добавлен скрипт start.sh для удобного запуска - Добавлен README с полным описанием проекта --- .gitignore | 57 ++ LichessClientTG_bot/.gitignore | 54 ++ LichessClientTG_bot/Dockerfile | 31 + LichessClientTG_bot/README.md | 116 ++++ LichessClientTG_bot/bot.py | 520 +++++++++++++++++ LichessClientTG_bot/config.py | 23 + LichessClientTG_bot/database.py | 233 ++++++++ LichessClientTG_bot/docker-compose.yml | 22 + LichessClientTG_bot/formatters.py | 155 +++++ LichessClientTG_bot/lichess_api.py | 135 +++++ LichessClientTG_bot/requirements.txt | 4 + LichessClientTG_bot/run.sh | 41 ++ LichessClientTG_bot/view_db.py | 79 +++ LichessWebServices/.gitignore | 58 ++ LichessWebServices/API_DOCUMENTATION.md | 388 +++++++++++++ LichessWebServices/Dockerfile | 26 + LichessWebServices/README.md | 121 ++++ LichessWebServices/docker-compose.yml | 30 + LichessWebServices/lichess_client.py | 250 ++++++++ LichessWebServices/main.py | 705 +++++++++++++++++++++++ LichessWebServices/models.py | 236 ++++++++ LichessWebServices/requirements.txt | 20 + LichessWebServices/stats_service.py | 733 ++++++++++++++++++++++++ LichessWebView/.gitignore | 25 + LichessWebView/Dockerfile | 13 + LichessWebView/app.py | 116 ++++ LichessWebView/docker-compose.yml | 12 + LichessWebView/requirements.txt | 3 + LichessWebView/templates/index.html | 440 ++++++++++++++ README.md | 240 ++++++++ docker-compose.yml | 54 ++ start.sh | 50 ++ 32 files changed, 4990 insertions(+) create mode 100644 .gitignore create mode 100644 LichessClientTG_bot/.gitignore create mode 100644 LichessClientTG_bot/Dockerfile create mode 100644 LichessClientTG_bot/README.md create mode 100644 LichessClientTG_bot/bot.py create mode 100644 LichessClientTG_bot/config.py create mode 100644 LichessClientTG_bot/database.py create mode 100644 LichessClientTG_bot/docker-compose.yml create mode 100644 LichessClientTG_bot/formatters.py create mode 100644 LichessClientTG_bot/lichess_api.py create mode 100644 LichessClientTG_bot/requirements.txt create mode 100755 LichessClientTG_bot/run.sh create mode 100644 LichessClientTG_bot/view_db.py create mode 100644 LichessWebServices/.gitignore create mode 100644 LichessWebServices/API_DOCUMENTATION.md create mode 100644 LichessWebServices/Dockerfile create mode 100644 LichessWebServices/README.md create mode 100644 LichessWebServices/docker-compose.yml create mode 100644 LichessWebServices/lichess_client.py create mode 100644 LichessWebServices/main.py create mode 100644 LichessWebServices/models.py create mode 100644 LichessWebServices/requirements.txt create mode 100644 LichessWebServices/stats_service.py create mode 100644 LichessWebView/.gitignore create mode 100644 LichessWebView/Dockerfile create mode 100644 LichessWebView/app.py create mode 100644 LichessWebView/docker-compose.yml create mode 100644 LichessWebView/requirements.txt create mode 100644 LichessWebView/templates/index.html create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100755 start.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fa9852 --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +.pytest_cache/ + +# Virtual environments +venv/ +env/ +ENV/ +*.egg + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Databases +*.db +*.sqlite +*.sqlite3 +data/ + +# Logs +*.log +logs/ + +# Docker +.dockerignore + +# Environment +.env +.env.local + +# Node modules (если будут использоваться) +node_modules/ +npm-debug.log + +# Временные файлы +*.tmp +*.bak +*.cache + +# Файлы кеша +.cache/ + diff --git a/LichessClientTG_bot/.gitignore b/LichessClientTG_bot/.gitignore new file mode 100644 index 0000000..33c1d0c --- /dev/null +++ b/LichessClientTG_bot/.gitignore @@ -0,0 +1,54 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Database (commented out to include in git) +# *.db +# *.sqlite +# *.sqlite3 + +# Logs +*.log +logs/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker +.dockerignore diff --git a/LichessClientTG_bot/Dockerfile b/LichessClientTG_bot/Dockerfile new file mode 100644 index 0000000..14ce784 --- /dev/null +++ b/LichessClientTG_bot/Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create directory for database +RUN mkdir -p /app/data + +# Set environment variables +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +# Expose port (if needed for health checks) +EXPOSE 8000 + +# Run the bot +CMD ["python", "bot.py"] diff --git a/LichessClientTG_bot/README.md b/LichessClientTG_bot/README.md new file mode 100644 index 0000000..2d35e2b --- /dev/null +++ b/LichessClientTG_bot/README.md @@ -0,0 +1,116 @@ +# Lichess Telegram Bot + +Телеграм бот для получения статистики игроков Lichess с использованием вашего веб-сервиса. + +## Возможности + +- **Добавление пользователей**: Добавление игроков Lichess с токеном или без +- **Выбор активного игрока**: Глобальный выбор активного игрока для всех чатов +- **Статистика**: Получение статистики за сегодня, вчера и неделю +- **Периодические уведомления**: Настройка автоматических уведомлений о активности игрока + +## Команды + +- `/start` - Начать работу с ботом +- `/adduser` - Добавить нового игрока Lichess +- `/getgamers` - Выбрать активного игрока +- `/today` - Статистика за сегодня +- `/yesterday` - Статистика за вчера +- `/week` - Статистика за неделю +- `/setperiod` - Настроить периодические уведомления + +## Установка и запуск + +### С помощью Docker (рекомендуется) + +1. Убедитесь, что ваш Lichess API сервис запущен на `http://localhost:8001` + +2. Запустите бота: +```bash +docker-compose up -d +``` + +3. Проверьте логи: +```bash +docker-compose logs -f lichess-bot +``` + +### Локальная установка + +1. Установите зависимости: +```bash +pip install -r requirements.txt +``` + +2. Скопируйте файл конфигурации: +```bash +cp .env.example .env +``` + +3. Запустите бота: +```bash +python bot.py +``` + +## Конфигурация + +Основные настройки находятся в файле `config.py`: + +- `TELEGRAM_BOT_TOKEN` - Токен вашего телеграм бота +- `LICHESS_STATS_API_BASE_URL` - URL вашего веб-сервиса (по умолчанию `http://localhost:8001`) +- `PERIOD_OPTIONS` - Доступные периоды для уведомлений +- `POLL_INTERVAL` - Интервал опроса Telegram API (1.0 секунда) +- `POLL_TIMEOUT` - Таймаут для Long Polling (30 секунд) +- `DROP_PENDING_UPDATES` - Игнорировать накопившиеся обновления при запуске +- `ALLOWED_UPDATES` - Типы обновлений для обработки + +## Структура проекта + +``` +├── bot.py # Основной файл бота +├── database.py # Работа с базой данных SQLite +├── lichess_api.py # API клиент для Lichess и вашего сервиса +├── formatters.py # Форматирование ответов +├── config.py # Конфигурация +├── requirements.txt # Python зависимости +├── Dockerfile # Docker образ +├── docker-compose.yml # Docker Compose конфигурация +└── README.md # Документация +``` + +## API Endpoints + +Бот использует следующие endpoints вашего сервиса: + +- `GET /today/{username}` - Статистика за сегодня +- `GET /yesterday/{username}` - Статистика за вчера +- `GET /week/{username}` - Статистика за неделю +- `GET /games/{username}/period` - Игры за период +- `GET /puzzle/period` - Задачи за период (требует токен) + +## База данных + +Используется SQLite база данных с таблицами: + +- `gamers` - Игроки Lichess +- `chat_active_gamers` - Активные игроки по чатам (не используется для глобального режима) + +## Логирование + +Бот ведет подробные логи всех операций. В Docker контейнере логи можно просмотреть командой: + +```bash +docker-compose logs -f lichess-bot +``` + +## Мониторинг + +Docker Compose включает health checks для мониторинга состояния сервисов. + +## Поддержка + +При возникновении проблем проверьте: + +1. Запущен ли ваш Lichess API сервис на порту 8001 +2. Правильность токена телеграм бота +3. Логи контейнера на наличие ошибок diff --git a/LichessClientTG_bot/bot.py b/LichessClientTG_bot/bot.py new file mode 100644 index 0000000..b71da97 --- /dev/null +++ b/LichessClientTG_bot/bot.py @@ -0,0 +1,520 @@ +import asyncio +import logging +from datetime import datetime, timedelta +from typing import Dict, Any, Optional + +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ( + Application, CommandHandler, CallbackQueryHandler, + MessageHandler, filters, ContextTypes, ConversationHandler +) + +from config import ( + TELEGRAM_BOT_TOKEN, PERIOD_OPTIONS, POLL_INTERVAL, + POLL_TIMEOUT, DROP_PENDING_UPDATES, ALLOWED_UPDATES, + LICHESS_STATS_API_BASE_URL +) +from database import Database +from lichess_api import LichessAPI +from formatters import StatsFormatter + +# Configure logging +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.INFO +) +logger = logging.getLogger(__name__) + +# Conversation states +WAITING_FOR_TOKEN, WAITING_FOR_USERNAME = range(2) + +class LichessBot: + def __init__(self): + self.db = Database() + self.lichess_api = LichessAPI() + self.periodic_tasks = {} # Store periodic tasks + self.period_start_times = {} # Store start times for each gamer + self.application = None # Will be set when application is created + + async def start_existing_periodic_tasks(self): + """Start periodic tasks for all user-gamer pairs that have periods set""" + try: + gamers_with_periods = self.db.get_all_gamers_with_periods() + logger.info(f"Found {len(gamers_with_periods)} user-gamer pairs with periodic notifications") + + for gamer in gamers_with_periods: + if gamer['period_minutes'] > 0: + user_id = gamer['user_id'] + # Start periodic task with user_id and gamer + await self.start_periodic_task(gamer, user_id, gamer['period_minutes']) + logger.info(f"Started periodic task for {gamer['username']} (user {user_id}) with period {gamer['period_minutes']} minutes") + + except Exception as e: + logger.error(f"Error starting existing periodic tasks: {e}") + + async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Start command handler""" + # Register user in database + user = update.effective_user + self.db.add_or_get_telegram_user( + user_id=user.id, + username=user.username, + first_name=user.first_name, + last_name=user.last_name + ) + + await update.message.reply_text( + "🎯 Добро пожаловать в Lichess Statistics Bot!\n\n" + "Доступные команды:\n" + "/adduser - Добавить игрока Lichess для отслеживания\n" + "/getgamers - Выбрать активного игрока\n" + "/today - Статистика за сегодня\n" + "/yesterday - Статистика за вчера\n" + "/week - Статистика за неделю\n" + "/setperiod - Настроить периодические уведомления" + ) + + async def adduser_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Start adduser command""" + await update.message.reply_text( + "🔑 Введите ваш Lichess API token.\n" + "Если у вас нет токена, введите 0" + ) + return WAITING_FOR_TOKEN + + async def handle_token(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle token input""" + token = update.message.text.strip() + user_id = update.effective_user.id + + if token == "0": + await update.message.reply_text("👤 Введите ваш Lichess username:") + return WAITING_FOR_USERNAME + else: + # Get username from token + profile = await self.lichess_api.get_user_profile(token) + if profile: + username = profile.get('username') + if username: + # Add gamer to database + gamer_id = self.db.add_gamer(username, token) + # Link user to gamer + self.db.add_user_gamer(user_id, gamer_id) + + # If this is the first gamer for this user, make it active + user_gamers = self.db.get_user_gamers(user_id) + if len(user_gamers) == 1: + self.db.set_user_active_gamer(user_id, gamer_id) + + await update.message.reply_text( + f"✅ Игрок {username} успешно добавлен!" + ) + return ConversationHandler.END + else: + await update.message.reply_text( + "❌ Не удалось получить username из токена. Попробуйте еще раз или введите 0 для ввода username вручную." + ) + return WAITING_FOR_TOKEN + else: + await update.message.reply_text( + "❌ Неверный токен. Попробуйте еще раз или введите 0 для ввода username вручную." + ) + return WAITING_FOR_TOKEN + + async def handle_username(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle username input""" + username = update.message.text.strip() + user_id = update.effective_user.id + + if username: + # Add gamer to database + gamer_id = self.db.add_gamer(username) + # Link user to gamer + self.db.add_user_gamer(user_id, gamer_id) + + # If this is the first gamer for this user, make it active + user_gamers = self.db.get_user_gamers(user_id) + if len(user_gamers) == 1: + self.db.set_user_active_gamer(user_id, gamer_id) + + await update.message.reply_text( + f"✅ Игрок {username} успешно добавлен!" + ) + else: + await update.message.reply_text( + "❌ Username не может быть пустым. Попробуйте еще раз." + ) + return WAITING_FOR_USERNAME + + return ConversationHandler.END + + async def getgamers(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Get all gamers for the current user and allow selection""" + user_id = update.effective_user.id + gamers = self.db.get_user_gamers(user_id) + + if not gamers: + await update.message.reply_text("📭 В базе нет игроков. Используйте /adduser для добавления.") + return + + # Show loading message + loading_msg = await update.message.reply_text("🔄 Загружаем рейтинги игроков...") + + # Prepare data for each gamer + gamers_data = [] + for gamer in gamers: + status = "🟢" if gamer['is_active'] else "⚪" + username = gamer['username'] + + # Get user ratings from Lichess API + ratings_data = await self.lichess_api.get_user_ratings(username) + + if ratings_data and 'perfs' in ratings_data: + perfs = ratings_data['perfs'] + bullet_rating = perfs.get('bullet', {}).get('rating', 'N/A') + blitz_rating = perfs.get('blitz', {}).get('rating', 'N/A') + rapid_rating = perfs.get('rapid', {}).get('rating', 'N/A') + else: + bullet_rating = blitz_rating = rapid_rating = 'N/A' + + # Add period information if period > 0 + period_minutes = gamer.get('period_minutes', 0) + period_text = f" · {period_minutes}м" if period_minutes > 0 else "" + + gamers_data.append({ + 'id': gamer['id'], + 'status': status, + 'username': username, + 'bullet': bullet_rating, + 'blitz': blitz_rating, + 'rapid': rapid_rating, + 'period': period_text + }) + + # Create text message with stats + text_lines = [] + for gamer in gamers_data: + text_lines.append( + f"{gamer['status']} {gamer['username']} " + f"⚡ {gamer['bullet']} 🔥 {gamer['blitz']} 🐇 {gamer['rapid']}{gamer['period']}" + ) + + gamers_text = "👥 Выберите активного игрока:\n\n" + "\n".join(text_lines) + + # Create simple keyboard with just usernames + keyboard = [] + for gamer in gamers_data: + keyboard.append([InlineKeyboardButton( + text=f"{gamer['status']} {gamer['username']}", + callback_data=f"select_{gamer['id']}" + )]) + + reply_markup = InlineKeyboardMarkup(keyboard) + + # Edit the loading message with the results + await loading_msg.edit_text( + gamers_text, + parse_mode='HTML', + reply_markup=reply_markup + ) + + async def select_gamer(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle gamer selection""" + query = update.callback_query + await query.answer() + + user_id = query.from_user.id + gamer_id = int(query.data.split('_')[1]) + + # Get user gamers to find the selected one + gamers = self.db.get_user_gamers(user_id) + selected_gamer = next((g for g in gamers if g['id'] == gamer_id), None) + + if selected_gamer: + # Set active gamer for this user + self.db.set_user_active_gamer(user_id, gamer_id) + await query.edit_message_text( + f"✅ Активный игрок: {selected_gamer['username']}" + ) + else: + await query.edit_message_text("❌ Игрок не найден") + + async def get_stats(self, update: Update, context: ContextTypes.DEFAULT_TYPE, period: str): + """Get statistics for a period""" + user_id = update.effective_user.id + + # Get active gamer for this user + active_gamer = self.db.get_user_active_gamer(user_id) + + if not active_gamer: + await update.message.reply_text( + "❌ Нет активного игрока. Используйте /getgamers для выбора." + ) + return + + username = active_gamer['username'] + + # Get stats based on period + if period == "today": + data = await self.lichess_api.get_today_stats(username) + elif period == "yesterday": + data = await self.lichess_api.get_yesterday_stats(username) + elif period == "week": + data = await self.lichess_api.get_week_stats(username) + else: + await update.message.reply_text("❌ Неизвестный период") + return + + # Format and send response + formatted_response = StatsFormatter.format_stats_response(data, username, period) + await update.message.reply_text(formatted_response) + + async def today(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Today command""" + await self.get_stats(update, context, "today") + + async def yesterday(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Yesterday command""" + await self.get_stats(update, context, "yesterday") + + async def week(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Week command""" + await self.get_stats(update, context, "week") + + async def setperiod(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Set period command""" + user_id = update.effective_user.id + + # Get active gamer for this user + active_gamer = self.db.get_user_active_gamer(user_id) + + if not active_gamer: + await update.message.reply_text( + "❌ Нет активного игрока. Используйте /getgamers для выбора." + ) + return + + keyboard = [] + for period in PERIOD_OPTIONS: + if period == 0: + button_text = "❌ Отключить уведомления" + else: + button_text = f"⏰ {period} минут" + keyboard.append([InlineKeyboardButton(button_text, callback_data=f"period_{period}")]) + + reply_markup = InlineKeyboardMarkup(keyboard) + await update.message.reply_text( + f"⏱️ Выберите период для игрока {active_gamer['username']}:\n" + f"📱 Уведомления будут приходить в личные сообщения", + reply_markup=reply_markup + ) + + async def select_period(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle period selection""" + query = update.callback_query + await query.answer() + + user_id = query.from_user.id + period = int(query.data.split('_')[1]) + + # Get active gamer for this user + active_gamer = self.db.get_user_active_gamer(user_id) + + if active_gamer: + # Set period for this user-gamer pair + self.db.set_user_gamer_period(user_id, active_gamer['id'], period) + + if period == 0: + await query.edit_message_text( + f"✅ Уведомления для {active_gamer['username']} отключены" + ) + else: + await query.edit_message_text( + f"✅ Период {period} минут установлен для {active_gamer['username']}\n" + f"📱 Уведомления будут приходить в личные сообщения" + ) + + # Start periodic task for this gamer (send to user's personal messages) + await self.start_periodic_task(active_gamer, user_id, period) + + async def start_periodic_task(self, gamer: Dict[str, Any], user_id: int, period_minutes: int): + """Start periodic task for a gamer""" + task_key = f"{gamer['id']}_{user_id}" + + # Cancel existing task if any + if task_key in self.periodic_tasks: + self.periodic_tasks[task_key].cancel() + logger.info(f"Cancelled existing periodic task for {gamer['username']}") + + # Remove old start time + if task_key in self.period_start_times: + del self.period_start_times[task_key] + + # Create new periodic task + task = asyncio.create_task( + self.periodic_check(gamer, user_id, period_minutes) + ) + self.periodic_tasks[task_key] = task + + async def periodic_check(self, gamer: Dict[str, Any], user_id: int, period_minutes: int): + """Periodic check for gamer activity""" + task_key = f"{gamer['id']}_{user_id}" + + # Запоминаем время начала отслеживания + start_time = datetime.now() + self.period_start_times[task_key] = start_time + logger.info(f"Started periodic monitoring for {gamer['username']} with {period_minutes} minute intervals") + + while True: + try: + # Ждем заданное количество минут + await asyncio.sleep(period_minutes * 60) + + # Получаем время начала периода + period_start = self.period_start_times.get(task_key, start_time) + now = datetime.now() + + # Рассчитываем timestamps в миллисекундах + since_timestamp = int(period_start.timestamp() * 1000) + until_timestamp = int(now.timestamp() * 1000) + + logger.info(f"Checking period for {gamer['username']}: {period_start} to {now}") + logger.info(f"Unix timestamps: since={since_timestamp}, until={until_timestamp}") + + # Делаем запросы к API + games_url = f"{LICHESS_STATS_API_BASE_URL}/games/{gamer['username']}/period?since={since_timestamp}&until={until_timestamp}" + logger.info(f"🎮 GAMES API REQUEST: {games_url}") + + games_data = await self.lichess_api.get_games_period( + gamer['username'], since_timestamp, until_timestamp + ) + logger.info(f"Games API response: {games_data}") + + puzzles_data = None + if gamer['token']: + puzzles_url = f"{LICHESS_STATS_API_BASE_URL}/puzzle/period?since={since_timestamp}&until={until_timestamp}&max=150" + logger.info(f"🧩 PUZZLES API REQUEST: {puzzles_url}") + + puzzles_data = await self.lichess_api.get_puzzles_period( + gamer['token'], since_timestamp, until_timestamp, max_puzzles=150 + ) + logger.info(f"Puzzles API response: {puzzles_data}") + else: + logger.info(f"No token for {gamer['username']}, skipping puzzles API call") + + # Проверяем наличие реальной активности + has_games = False + total_games = 0 + total_losses = 0 + if games_data and games_data.get('data'): + total_games = games_data.get('data', {}).get('total', {}).get('games_played', 0) + total_losses = games_data.get('data', {}).get('total', {}).get('losses', 0) + has_games = total_games > 0 + + has_puzzles = False + if puzzles_data and puzzles_data.get('data'): + total_puzzles = puzzles_data.get('data', {}).get('total_attempts', 0) + has_puzzles = total_puzzles > 0 + + # Детальное логирование для отладки + logger.info(f"Activity check for {gamer['username']}: has_games={has_games}, has_puzzles={has_puzzles}") + if games_data and games_data.get('data'): + total_games = games_data.get('data', {}).get('total', {}).get('games_played', 0) + total_losses = games_data.get('data', {}).get('total', {}).get('losses', 0) + logger.info(f"Games data: total_games={total_games}, total_losses={total_losses}") + if puzzles_data and puzzles_data.get('data'): + total_puzzles = puzzles_data.get('data', {}).get('total_attempts', 0) + logger.info(f"Puzzles data: total_attempts={total_puzzles}") + + # Отправляем уведомление только если есть реальная активность + if has_games or has_puzzles: + try: + notification = StatsFormatter.format_period_notification( + gamer['username'], games_data, puzzles_data, period_minutes + ) + + if self.application: + try: + await self.application.bot.send_message( + chat_id=user_id, + text=notification + ) + logger.info(f"Sent periodic notification for {gamer['username']} to user {user_id}") + # Обновляем время начала только после успешной отправки уведомления + self.period_start_times[task_key] = now + except Exception as e: + logger.error(f"Failed to send notification to user {user_id}: {e}") + # Не обновляем время начала при ошибке отправки + except Exception as e: + logger.error(f"Error formatting notification for {gamer['username']}: {e}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + # Не обновляем время начала при ошибке форматирования + else: + logger.info(f"No activity found for {gamer['username']} in the last {period_minutes} minutes") + # Обновляем время начала даже если нет активности, чтобы не зацикливаться + self.period_start_times[task_key] = now + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error in periodic check: {e}") + import traceback + logger.error(f"Full traceback: {traceback.format_exc()}") + + def setup_handlers(self, application: Application): + """Setup all handlers""" + self.application = application # Store application reference + # Conversation handler for adduser + adduser_conv = ConversationHandler( + entry_points=[CommandHandler("adduser", self.adduser_start)], + states={ + WAITING_FOR_TOKEN: [MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_token)], + WAITING_FOR_USERNAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_username)], + }, + fallbacks=[CommandHandler("cancel", lambda u, c: ConversationHandler.END)] + ) + + # Add all handlers + application.add_handler(CommandHandler("start", self.start)) + application.add_handler(adduser_conv) + application.add_handler(CommandHandler("getgamers", self.getgamers)) + application.add_handler(CommandHandler("today", self.today)) + application.add_handler(CommandHandler("yesterday", self.yesterday)) + application.add_handler(CommandHandler("week", self.week)) + application.add_handler(CommandHandler("setperiod", self.setperiod)) + + # Callback handlers + application.add_handler(CallbackQueryHandler(self.select_gamer, pattern="^select_")) + application.add_handler(CallbackQueryHandler(self.select_period, pattern="^period_")) + +def main(): + """Main function""" + bot = LichessBot() + + # Create application with Long Polling configuration + application = Application.builder().token(TELEGRAM_BOT_TOKEN).build() + + # Setup handlers + bot.setup_handlers(application) + + # Set application reference for periodic tasks + bot.application = application + + # Start periodic tasks for existing gamers (will be called after application starts) + async def post_init(app): + await bot.start_existing_periodic_tasks() + + application.post_init = post_init + + # Start the bot with Long Polling + logger.info("Starting Lichess Statistics Bot with Long Polling...") + application.run_polling( + poll_interval=POLL_INTERVAL, + timeout=POLL_TIMEOUT, + drop_pending_updates=DROP_PENDING_UPDATES, + allowed_updates=ALLOWED_UPDATES + ) + +if __name__ == '__main__': + main() diff --git a/LichessClientTG_bot/config.py b/LichessClientTG_bot/config.py new file mode 100644 index 0000000..30e971f --- /dev/null +++ b/LichessClientTG_bot/config.py @@ -0,0 +1,23 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +# Telegram Bot Configuration +TELEGRAM_BOT_TOKEN = "8241474807:AAGcsLiaE9El63ARmUgrWASgkhcBv8QB1c8" + +# Lichess API Configuration +LICHESS_API_BASE_URL = "https://lichess.org/api" +LICHESS_STATS_API_BASE_URL = "http://localhost:8001" # For Docker container access + +# Database Configuration +DATABASE_PATH = "/app/data/lichess_bot.db" + +# Period options for /setperiod command +PERIOD_OPTIONS = [0, 1, 2, 3, 5, 10, 15, 30, 60, 120, 180] # minutes + +# Telegram Bot Long Polling Configuration +POLL_INTERVAL = 1.0 # seconds +POLL_TIMEOUT = 30 # seconds +DROP_PENDING_UPDATES = True +ALLOWED_UPDATES = ["message", "callback_query"] diff --git a/LichessClientTG_bot/database.py b/LichessClientTG_bot/database.py new file mode 100644 index 0000000..fc200e1 --- /dev/null +++ b/LichessClientTG_bot/database.py @@ -0,0 +1,233 @@ +import sqlite3 +import logging +from typing import Optional, List, Dict, Any +from config import DATABASE_PATH + +logger = logging.getLogger(__name__) + +class Database: + def __init__(self, db_path: str = DATABASE_PATH): + self.db_path = db_path + self.init_database() + + def init_database(self): + """Initialize database tables""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + # Create telegram_users table (Telegram bot users) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS telegram_users ( + user_id INTEGER PRIMARY KEY, + username TEXT, + first_name TEXT, + last_name TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Create gamers table (Lichess players only) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS gamers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + token TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Create user_gamers table (relationship between users and gamers) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS user_gamers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + gamer_id INTEGER NOT NULL, + is_active BOOLEAN DEFAULT FALSE, + period_minutes INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES telegram_users(user_id), + FOREIGN KEY (gamer_id) REFERENCES gamers(id), + UNIQUE(user_id, gamer_id) + ) + ''') + + conn.commit() + logger.info("Database initialized successfully") + + def add_or_get_telegram_user(self, user_id: int, username: Optional[str] = None, + first_name: Optional[str] = None, last_name: Optional[str] = None) -> bool: + """Add or update Telegram user""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + cursor.execute("SELECT user_id FROM telegram_users WHERE user_id = ?", (user_id,)) + existing = cursor.fetchone() + + if not existing: + cursor.execute( + "INSERT INTO telegram_users (user_id, username, first_name, last_name) VALUES (?, ?, ?, ?)", + (user_id, username, first_name, last_name) + ) + conn.commit() + return True + return False + + def add_gamer(self, username: str, token: Optional[str] = None) -> int: + """Add a new gamer to the database (return gamer_id)""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + # Check if gamer already exists + cursor.execute("SELECT id FROM gamers WHERE username = ?", (username,)) + existing = cursor.fetchone() + + if existing: + # Update existing gamer token if provided + if token: + cursor.execute("UPDATE gamers SET token = ? WHERE username = ?", (token, username)) + gamer_id = existing[0] + else: + # Add new gamer + cursor.execute( + "INSERT INTO gamers (username, token) VALUES (?, ?)", + (username, token) + ) + gamer_id = cursor.lastrowid + + conn.commit() + return gamer_id + + def add_user_gamer(self, user_id: int, gamer_id: int) -> bool: + """Add relationship between user and gamer""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + try: + cursor.execute( + "INSERT INTO user_gamers (user_id, gamer_id) VALUES (?, ?)", + (user_id, gamer_id) + ) + conn.commit() + return True + except sqlite3.IntegrityError: + # Already exists + return False + + def get_user_gamers(self, user_id: int) -> List[Dict[str, Any]]: + """Get all gamers for a specific user""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT g.id, g.username, g.token, ug.is_active, ug.period_minutes + FROM user_gamers ug + JOIN gamers g ON ug.gamer_id = g.id + WHERE ug.user_id = ? + ORDER BY ug.id + ''', (user_id,)) + + gamers = [] + for row in cursor.fetchall(): + gamers.append({ + 'id': row[0], + 'username': row[1], + 'token': row[2], + 'is_active': bool(row[3]), + 'period_minutes': row[4] + }) + + return gamers + + def get_user_active_gamer(self, user_id: int) -> Optional[Dict[str, Any]]: + """Get the active gamer for a specific user""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT g.id, g.username, g.token + FROM user_gamers ug + JOIN gamers g ON ug.gamer_id = g.id + WHERE ug.user_id = ? AND ug.is_active = TRUE + LIMIT 1 + ''', (user_id,)) + + row = cursor.fetchone() + if row: + return { + 'id': row[0], + 'username': row[1], + 'token': row[2] + } + return None + + def set_user_active_gamer(self, user_id: int, gamer_id: int): + """Set active gamer for a specific user""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + # Deactivate all gamers for this user + cursor.execute( + "UPDATE user_gamers SET is_active = FALSE WHERE user_id = ?", + (user_id,) + ) + + # Activate the selected gamer + cursor.execute( + "UPDATE user_gamers SET is_active = TRUE WHERE user_id = ? AND gamer_id = ?", + (user_id, gamer_id) + ) + + conn.commit() + + def set_user_gamer_period(self, user_id: int, gamer_id: int, period_minutes: int): + """Set period for a gamer for a specific user""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE user_gamers SET period_minutes = ? WHERE user_id = ? AND gamer_id = ?", + (period_minutes, user_id, gamer_id) + ) + conn.commit() + + def get_user_gamers_with_periods(self, user_id: int) -> List[Dict[str, Any]]: + """Get all gamers for a user that have periods set""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT g.id, g.username, g.token, ug.period_minutes + FROM user_gamers ug + JOIN gamers g ON ug.gamer_id = g.id + WHERE ug.user_id = ? AND ug.period_minutes > 0 + ''', (user_id,)) + + gamers = [] + for row in cursor.fetchall(): + gamers.append({ + 'id': row[0], + 'username': row[1], + 'token': row[2], + 'period_minutes': row[3] + }) + + return gamers + + def get_all_gamers_with_periods(self) -> List[Dict[str, Any]]: + """Get all user-gamer pairs that have periods set (for periodic checks across all users)""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT ug.user_id, g.id, g.username, g.token, ug.period_minutes + FROM user_gamers ug + JOIN gamers g ON ug.gamer_id = g.id + WHERE ug.period_minutes > 0 + ''') + + gamers = [] + for row in cursor.fetchall(): + gamers.append({ + 'user_id': row[0], + 'id': row[1], + 'username': row[2], + 'token': row[3], + 'period_minutes': row[4] + }) + + return gamers diff --git a/LichessClientTG_bot/docker-compose.yml b/LichessClientTG_bot/docker-compose.yml new file mode 100644 index 0000000..66cde32 --- /dev/null +++ b/LichessClientTG_bot/docker-compose.yml @@ -0,0 +1,22 @@ +services: + lichess-bot: + build: . + container_name: lichess-telegram-bot + restart: unless-stopped + volumes: + - ./data:/app/data + - ./lichess_bot.db:/app/lichess_bot.db + environment: + - PYTHONPATH=/app + - PYTHONUNBUFFERED=1 + healthcheck: + test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8001/docs', timeout=5)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + network_mode: "host" + +volumes: + bot-data: + driver: local diff --git a/LichessClientTG_bot/formatters.py b/LichessClientTG_bot/formatters.py new file mode 100644 index 0000000..872f32b --- /dev/null +++ b/LichessClientTG_bot/formatters.py @@ -0,0 +1,155 @@ +from typing import Dict, Any, Optional +from datetime import datetime + +class StatsFormatter: + @staticmethod + def _format_rating_change(rating_change: int) -> str: + """Format rating change with colored circles""" + if rating_change > 0: + return f"🟢 +{rating_change}" + elif rating_change < 0: + return f"🔴 {rating_change}" + else: + return f"0" + + @staticmethod + def format_stats_response(data: Dict[str, Any], username: str, period: str) -> str: + """Format statistics response according to the template""" + if not data or data.get('data') is None: + message = data.get('message', 'Нет данных') if data else 'Нет данных' + return f"📭 {message}" + + # Extract data from API response + api_data = data.get('data', {}) + tasks = api_data.get('tasks', {}) + games = api_data.get('games', {}) + + # Format date range + date_range = StatsFormatter._get_date_range(period) + + # Format tasks section + task_text = "" + if tasks and tasks.get('total', 0) > 0: + total_tasks = tasks.get('total', 0) + solved = tasks.get('solved', 0) + unsolved = tasks.get('unsolved', 0) + task_text = f"🧩 Задачи: {total_tasks} (✅ {solved} - ❌ {unsolved})\n\n" + + # Format games section + games_text = "" + if games: + for game_type, game_data in games.items(): + if not game_data or game_data.get('games_played', 0) == 0: + continue + + # Get game type emoji + emoji = StatsFormatter._get_game_type_emoji(game_type) + + games_count = game_data.get('games_played', 0) + rating_change = game_data.get('rating_change', 0) + rating = game_data.get('final_rating', 0) + wins = game_data.get('wins', 0) + losses = game_data.get('losses', 0) + draws = game_data.get('draws', 0) + + # Format rating change + rating_change_str = StatsFormatter._format_rating_change(rating_change) + + games_text += f"{emoji} {game_type.title()} — {games_count} игр • {rating_change_str}\n" + games_text += f"Рейтинг: {rating}\n" + games_text += f"✅ Победы: {wins}\n" + games_text += f"❌ Поражения: {losses}\n" + games_text += f"🤝 Ничьи: {draws}\n\n" + + # Combine all parts + result = f"📊 Статистика {username} • {date_range}\n\n" + result += task_text + result += games_text.rstrip() + + return result + + @staticmethod + def _get_date_range(period: str) -> str: + """Get date range string for the period""" + from datetime import datetime, timedelta + + today = datetime.now() + + if period == "today": + return today.strftime("%d.%m.%Y") + elif period == "yesterday": + yesterday = today - timedelta(days=1) + return yesterday.strftime("%d.%m.%Y") + elif period == "week": + week_ago = today - timedelta(days=7) + return f"{week_ago.strftime('%d.%m.%Y')}–{today.strftime('%d.%m.%Y')}" + else: + return today.strftime("%d.%m.%Y") + + @staticmethod + def _get_game_type_emoji(game_type: str) -> str: + """Get emoji for game type""" + emoji_map = { + 'bullet': '⚡️', + 'blitz': '🔥', + 'rapid': '🐇', + 'classical': '♟️', + 'correspondence': '📮' + } + return emoji_map.get(game_type.lower(), '🎯') + + @staticmethod + def format_period_notification(username: str, games_data: Optional[Dict], puzzles_data: Optional[Dict], period_minutes: int) -> str: + """Format notification for periodic checks""" + from datetime import datetime + + # Format period text + if period_minutes == 1: + period_text = "за 1 минуту" + elif period_minutes in [2, 3, 4]: + period_text = f"за {period_minutes} минуты" + else: + period_text = f"за {period_minutes} минут" + + result = f"📊 Статистика {username} • {period_text}\n\n" + + # Format puzzles first (if available and there's actual activity) + if puzzles_data and puzzles_data.get('data'): + puzzles_info = puzzles_data['data'] + total_puzzles = puzzles_info.get('total_attempts', 0) + solved = puzzles_info.get('solved', 0) + failed = puzzles_info.get('failed', 0) + + # Only show tasks section if there's actual activity (not all zeros) + if total_puzzles > 0 or solved > 0 or failed > 0: + result += f"🧩 Задачи: {total_puzzles} (✅ {solved} - ❌ {failed})\n\n" + + # Format games + if games_data and games_data.get('data'): + games_info = games_data['data'] + total_games = games_info.get('total', {}).get('games_played', 0) + + # Show details for each game type if there were games + if total_games > 0: + for game_type, game_data in games_info.items(): + if game_type != 'total' and game_data and game_data.get('games_played', 0) > 0: + emoji = StatsFormatter._get_game_type_emoji(game_type) + games_count = game_data.get('games_played', 0) + rating_change = game_data.get('rating_change', 0) + rating = game_data.get('rating', 0) + wins = game_data.get('wins', 0) + losses = game_data.get('losses', 0) + draws = game_data.get('draws', 0) + + rating_change_str = StatsFormatter._format_rating_change(rating_change) + result += f"{emoji} {game_type.title()} — {games_count} игр • {rating_change_str}\n" + result += f"Рейтинг: {rating}\n" + result += f"✅ Победы: {wins}\n" + result += f"❌ Поражения: {losses}\n" + result += f"🤝 Ничьи: {draws}\n\n" + + # If no activity + if not (games_data and games_data.get('data')) and not (puzzles_data and puzzles_data.get('data')): + result += "📭 Нет активности за этот период" + + return result.rstrip() diff --git a/LichessClientTG_bot/lichess_api.py b/LichessClientTG_bot/lichess_api.py new file mode 100644 index 0000000..3bd0d91 --- /dev/null +++ b/LichessClientTG_bot/lichess_api.py @@ -0,0 +1,135 @@ +import aiohttp +import logging +from typing import Optional, Dict, Any +from config import LICHESS_API_BASE_URL, LICHESS_STATS_API_BASE_URL + +logger = logging.getLogger(__name__) + +class LichessAPI: + def __init__(self): + self.lichess_base_url = LICHESS_API_BASE_URL + self.stats_base_url = LICHESS_STATS_API_BASE_URL + + async def get_user_profile(self, token: str) -> Optional[Dict[str, Any]]: + """Get user profile from Lichess API using token""" + headers = {"Authorization": f"Bearer {token}"} + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.lichess_base_url}/account", + headers=headers + ) as response: + if response.status == 200: + return await response.json() + else: + logger.error(f"Failed to get user profile: {response.status}") + return None + except Exception as e: + logger.error(f"Error getting user profile: {e}") + return None + + async def get_today_stats(self, username: str) -> Optional[Dict[str, Any]]: + """Get today's statistics from our stats API""" + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.stats_base_url}/stats/{username}/today" + ) as response: + if response.status == 200: + return await response.json() + else: + logger.error(f"Failed to get today stats: {response.status}") + return None + except Exception as e: + logger.error(f"Error getting today stats: {e}") + return None + + async def get_yesterday_stats(self, username: str) -> Optional[Dict[str, Any]]: + """Get yesterday's statistics from our stats API""" + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.stats_base_url}/stats/{username}/yesterday" + ) as response: + if response.status == 200: + return await response.json() + else: + logger.error(f"Failed to get yesterday stats: {response.status}") + return None + except Exception as e: + logger.error(f"Error getting yesterday stats: {e}") + return None + + async def get_week_stats(self, username: str) -> Optional[Dict[str, Any]]: + """Get week's statistics from our stats API""" + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.stats_base_url}/stats/{username}/week" + ) as response: + if response.status == 200: + return await response.json() + else: + logger.error(f"Failed to get week stats: {response.status}") + return None + except Exception as e: + logger.error(f"Error getting week stats: {e}") + return None + + async def get_games_period(self, username: str, since: int, until: int) -> Optional[Dict[str, Any]]: + """Get games for a specific period""" + try: + url = f"{self.stats_base_url}/games/{username}/period" + params = {"since": since, "until": until} + logger.info(f"🔍 LichessAPI.get_games_period: URL={url}, params={params}") + + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params) as response: + logger.info(f"🔍 LichessAPI.get_games_period: response.status={response.status}") + if response.status == 200: + result = await response.json() + logger.info(f"🔍 LichessAPI.get_games_period: result={result}") + return result + else: + logger.error(f"Failed to get games period: {response.status}") + return None + except Exception as e: + logger.error(f"Error getting games period: {e}") + return None + + async def get_puzzles_period(self, token: str, since: int, until: int, max_puzzles: int = 150) -> Optional[Dict[str, Any]]: + """Get puzzles for a specific period""" + headers = {"Authorization": f"Bearer {token}"} + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.stats_base_url}/puzzle/period", + headers=headers, + params={"since": since, "until": until, "max": max_puzzles} + ) as response: + if response.status == 200: + return await response.json() + else: + logger.error(f"Failed to get puzzles period: {response.status}") + return None + except Exception as e: + logger.error(f"Error getting puzzles period: {e}") + return None + + async def get_user_ratings(self, username: str) -> Optional[Dict[str, Any]]: + """Get user ratings from Lichess API""" + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.lichess_base_url}/user/{username}" + ) as response: + if response.status == 200: + return await response.json() + else: + logger.error(f"Failed to get user ratings: {response.status}") + return None + except Exception as e: + logger.error(f"Error getting user ratings: {e}") + return None \ No newline at end of file diff --git a/LichessClientTG_bot/requirements.txt b/LichessClientTG_bot/requirements.txt new file mode 100644 index 0000000..3f9281a --- /dev/null +++ b/LichessClientTG_bot/requirements.txt @@ -0,0 +1,4 @@ +python-telegram-bot==20.7 +requests==2.31.0 +aiohttp==3.9.1 +python-dotenv==1.0.0 diff --git a/LichessClientTG_bot/run.sh b/LichessClientTG_bot/run.sh new file mode 100755 index 0000000..7c54d2d --- /dev/null +++ b/LichessClientTG_bot/run.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Lichess Telegram Bot Runner Script + +echo "🎯 Lichess Telegram Bot" +echo "======================" + +# Check if Docker is running +if ! docker info > /dev/null 2>&1; then + echo "❌ Docker is not running. Please start Docker first." + exit 1 +fi + +# Check if the API service is running +echo "🔍 Checking Lichess API service..." +if curl -s http://localhost:8001/docs > /dev/null 2>&1; then + echo "✅ Lichess API service is running on http://localhost:8001" +else + echo "⚠️ Warning: Lichess API service is not accessible at http://localhost:8001" + echo " Make sure your API service is running before starting the bot." +fi + +echo "" +echo "🚀 Starting Lichess Telegram Bot..." + +# Build and start the bot +docker-compose up --build -d + +echo "" +echo "✅ Bot started successfully!" +echo "" +echo "📊 To view logs:" +echo " docker-compose logs -f lichess-bot" +echo "" +echo "🛑 To stop the bot:" +echo " docker-compose down" +echo "" +echo "🔧 To restart the bot:" +echo " docker-compose restart lichess-bot" +echo "" +echo "📱 Your bot is now running! Find it in Telegram and start chatting." diff --git a/LichessClientTG_bot/view_db.py b/LichessClientTG_bot/view_db.py new file mode 100644 index 0000000..34b9a92 --- /dev/null +++ b/LichessClientTG_bot/view_db.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +import sqlite3 +import json +from datetime import datetime + +def view_database(): + """View database contents""" + db_path = "data/lichess_bot.db" + + try: + with sqlite3.connect(db_path) as conn: + cursor = conn.cursor() + + print("🗄️ СОДЕРЖИМОЕ БАЗЫ ДАННЫХ LICHESS BOT") + print("=" * 50) + + # Show tables + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables = cursor.fetchall() + print(f"\n📋 Таблицы в базе: {[table[0] for table in tables]}") + + # Show gamers table + print("\n👥 ТАБЛИЦА GAMERS:") + print("-" * 30) + cursor.execute("SELECT * FROM gamers") + gamers = cursor.fetchall() + + if gamers: + print("ID | Username | Token | Active | Period | User ID | Created") + print("-" * 70) + for gamer in gamers: + if len(gamer) == 6: # Old format without user_id + gamer_id, username, token, is_active, period_minutes, created_at = gamer + user_id = "None" + else: # New format with user_id + gamer_id, username, token, is_active, period_minutes, user_id, created_at = gamer + + token_display = "***" if token else "None" + active_display = "✅" if is_active else "❌" + user_id_display = str(user_id) if user_id else "None" + print(f"{gamer_id:2} | {username:10} | {token_display:6} | {active_display:6} | {period_minutes:6} | {user_id_display:7} | {created_at}") + else: + print("Таблица пуста") + + # Show chat_active_gamers table + print("\n💬 ТАБЛИЦА CHAT_ACTIVE_GAMERS:") + print("-" * 35) + cursor.execute("SELECT * FROM chat_active_gamers") + chat_gamers = cursor.fetchall() + + if chat_gamers: + print("Chat ID | Gamer ID") + print("-" * 20) + for chat_gamer in chat_gamers: + chat_id, gamer_id = chat_gamer + print(f"{chat_id:7} | {gamer_id:8}") + else: + print("Таблица пуста") + + # Show database info + print(f"\n📊 ИНФОРМАЦИЯ О БАЗЕ:") + print("-" * 25) + cursor.execute("SELECT COUNT(*) FROM gamers") + total_gamers = cursor.fetchone()[0] + print(f"Всего игроков: {total_gamers}") + + cursor.execute("SELECT COUNT(*) FROM gamers WHERE is_active = TRUE") + active_gamers = cursor.fetchone()[0] + print(f"Активных игроков: {active_gamers}") + + cursor.execute("SELECT COUNT(*) FROM gamers WHERE period_minutes > 0") + monitored_gamers = cursor.fetchone()[0] + print(f"Игроков с периодическими уведомлениями: {monitored_gamers}") + + except Exception as e: + print(f"❌ Ошибка при чтении базы данных: {e}") + +if __name__ == "__main__": + view_database() diff --git a/LichessWebServices/.gitignore b/LichessWebServices/.gitignore new file mode 100644 index 0000000..efee0b1 --- /dev/null +++ b/LichessWebServices/.gitignore @@ -0,0 +1,58 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +*.log +logs/ + +# Docker +.dockerignore + +# Temporary files +*.tmp +*.temp diff --git a/LichessWebServices/API_DOCUMENTATION.md b/LichessWebServices/API_DOCUMENTATION.md new file mode 100644 index 0000000..754ba64 --- /dev/null +++ b/LichessWebServices/API_DOCUMENTATION.md @@ -0,0 +1,388 @@ +# Lichess Statistics API - Документация + +## Описание + +Lichess Statistics API предоставляет REST API для получения детальной статистики игроков платформы Lichess. API позволяет получать информацию о играх, рейтингах и решении задач (пазлов) за различные периоды времени. + +## Возможности + +- 📊 **Статистика игр** по режимам (Bullet, Blitz, Rapid) +- 🧩 **Статистика решения задач** (пазлов) +- 📅 **Статистика за разные периоды** (сегодня, вчера, неделя) +- 🎯 **Отслеживание изменений рейтинга** +- 📈 **Подробная аналитика результатов игр** + +## Режимы игр + +- **Bullet**: Быстрые игры (1-3 минуты) +- **Blitz**: Блиц игры (3-10 минут) +- **Rapid**: Рапид игры (10+ минут) + +## Базовый URL + +``` +http://localhost:8001 +``` + +## Эндпоинты + +### 1. Информация об API + +#### GET / +Возвращает основную информацию об API и доступных эндпоинтах. + +**Ответ:** +```json +{ + "message": "Lichess Statistics API", + "version": "1.0.0", + "description": "REST API для получения статистики игроков Lichess", + "endpoints": { + "today": "/stats/{username}/today", + "yesterday": "/stats/{username}/yesterday", + "week": "/stats/{username}/week" + }, + "documentation": "/docs", + "openapi_schema": "/openapi.json" +} +``` + +### 2. Health Check + +#### GET /health +Проверка состояния сервиса. + +**Ответ:** +```json +{ + "status": "healthy", + "timestamp": "2024-01-15T10:30:00Z", + "service": "Lichess Statistics API" +} +``` + +### 3. Статистика за сегодня + +#### GET /stats/{username}/today +Получает статистику игрока за сегодняшний день. + +**Параметры:** +- `username` (string, обязательный) - имя пользователя на Lichess + +**Пример запроса:** +```bash +curl http://localhost:8001/stats/magnus/today +``` + +**Пример ответа:** +```json +{ + "message": "Статистика за сегодняшний день", + "data": { + "username": "magnus", + "tasks": { + "total": 15, + "solved": 12, + "unsolved": 3 + }, + "games": { + "bullet": { + "games_played": 8, + "rating_change": 15, + "final_rating": 2850, + "wins": 5, + "losses": 2, + "draws": 1 + }, + "blitz": { + "games_played": 3, + "rating_change": -5, + "final_rating": 2750, + "wins": 1, + "losses": 2, + "draws": 0 + }, + "rapid": { + "games_played": 0, + "rating_change": 0, + "final_rating": 0, + "wins": 0, + "losses": 0, + "draws": 0 + } + } + } +} +``` + +### 4. Статистика за вчера + +#### GET /stats/{username}/yesterday +Получает статистику игрока за вчерашний день. + +**Параметры:** +- `username` (string, обязательный) - имя пользователя на Lichess + +**Пример запроса:** +```bash +curl http://localhost:8001/stats/magnus/yesterday +``` + +### 5. Статистика за неделю + +#### GET /stats/{username}/week +Получает агрегированную статистику игрока за последние 7 дней. + +**Параметры:** +- `username` (string, обязательный) - имя пользователя на Lichess + +**Пример запроса:** +```bash +curl http://localhost:8001/stats/magnus/week +``` + +### 6. Статистика игр за период + +#### GET /games/{username}/period +Получает детальную статистику игр пользователя за указанный период времени. + +**Параметры:** +- `username` (string, обязательный) - имя пользователя на Lichess +- `since` (integer, обязательный) - начало периода (Unix timestamp в миллисекундах) +- `until` (integer, обязательный) - конец периода (Unix timestamp в миллисекундах) +- `rated_only` (boolean, опциональный) - только рейтинговые игры (по умолчанию true - рекомендуется) + +**Пример запроса:** +```bash +# Статистика за последние 7 дней +curl "http://localhost:8001/games/magnus/period?since=1640995200000&until=1641081600000" + +# Только рейтинговые игры +curl "http://localhost:8001/games/magnus/period?since=1640995200000&until=1641081600000&rated_only=true" + +# Все игры (включая нерейтинговые) +curl "http://localhost:8001/games/magnus/period?since=1640995200000&until=1641081600000&rated_only=false" +``` + +**Пример ответа:** +```json +{ + "message": "Статистика игр за период", + "username": "magnus", + "period_start": 1640995200000, + "period_end": 1641081600000, + "games_count": 25, + "data": { + "bullet": { + "games_played": 10, + "wins": 6, + "losses": 3, + "draws": 1, + "rating_change": 15 + }, + "blitz": { + "games_played": 8, + "wins": 5, + "losses": 2, + "draws": 1, + "rating_change": 12 + }, + "rapid": { + "games_played": 5, + "wins": 3, + "losses": 1, + "draws": 1, + "rating_change": 8 + }, + "classical": { + "games_played": 2, + "wins": 1, + "losses": 1, + "draws": 0, + "rating_change": 0 + }, + "correspondence": { + "games_played": 0, + "wins": 0, + "losses": 0, + "draws": 0, + "rating_change": 0 + }, + "total": { + "games_played": 25, + "wins": 15, + "losses": 7, + "draws": 3, + "rating_change": 35 + } + } +} +``` + +### 7. Статистика решения задач за период + +#### GET /puzzle/period +Получает статистику решения задач (пазлов) за указанный период времени. Требует авторизации через Bearer токен. + +**Параметры:** +- `since` (integer, обязательный) - начало периода (Unix timestamp в миллисекундах) +- `until` (integer, обязательный) - конец периода (Unix timestamp в миллисекундах) +- `max` (integer, опциональный) - максимальное количество задач для получения (по умолчанию 50, максимум 1000) +- `Authorization` (header, обязательный) - Bearer токен авторизации + +**Важно:** Параметр `max` ограничивает количество активностей, получаемых от Lichess API. Если в указанном периоде было больше задач, чем указано в `max`, то будут показаны только последние N активностей. Для получения полной статистики рекомендуется увеличить значение `max` или использовать значение по умолчанию (50). + +**Пример запроса:** +```bash +# Статистика за последние 7 дней +curl -H "Authorization: Bearer your_token_here" \ + "http://localhost:8001/puzzle/period?since=1640995200000&until=1641081600000" + +# Больше задач +curl -H "Authorization: Bearer your_token_here" \ + "http://localhost:8001/puzzle/period?since=1640995200000&until=1641081600000&max=100" +``` + +**Пример ответа:** +```json +{ + "message": "Статистика решения задач за период", + "period_start": 1640995200000, + "period_end": 1641081600000, + "max_puzzles": 50, + "puzzles_in_period": 15, + "data": { + "total_attempts": 15, + "solved": 12, + "failed": 3, + "success_rate": 80.0 + } +} +``` + +**Получение токена:** +1. Зайдите на https://lichess.org/account/oauth/token/create +2. Создайте новый токен с правами на чтение активности +3. Используйте токен в заголовке Authorization + +## Модели данных + +### TaskStats +Статистика решения задач (пазлов): +```json +{ + "total": 15, // Общее количество решенных задач + "solved": 12, // Количество правильно решенных задач + "unsolved": 3 // Количество нерешенных или неправильно решенных задач +} +``` + +### GameModeStats +Статистика игр для конкретного режима: +```json +{ + "games_played": 8, // Общее количество сыгранных игр + "rating_change": 15, // Изменение рейтинга (может быть отрицательным) + "final_rating": 2850, // Текущий рейтинг игрока + "wins": 5, // Количество побед + "losses": 2, // Количество поражений + "draws": 1 // Количество ничьих +} +``` + +### GamesStats +Статистика игр по всем режимам: +```json +{ + "bullet": { /* GameModeStats */ }, + "blitz": { /* GameModeStats */ }, + "rapid": { /* GameModeStats */ } +} +``` + +### UserStats +Полная статистика пользователя: +```json +{ + "username": "magnus", + "tasks": { /* TaskStats */ }, + "games": { /* GamesStats */ } +} +``` + +### ActivityResponse +Ответ API с результатами запроса: +```json +{ + "message": "Статистика за сегодняшний день", + "data": { /* UserStats или null */ } +} +``` + +## Коды ошибок + +- **200** - Успешный запрос +- **404** - Пользователь не найден или неактивен +- **500** - Внутренняя ошибка сервера + +## Примеры ошибок + +### Пользователь не найден +```json +{ + "message": "Пользователь magnus не найден или неактивен" +} +``` + +### Внутренняя ошибка сервера +```json +{ + "detail": "Внутренняя ошибка сервера: Connection timeout" +} +``` + +## Swagger UI + +Интерактивная документация доступна по адресу: +``` +http://localhost:8001/docs +``` + +## OpenAPI Schema + +Схема OpenAPI доступна по адресу: +``` +http://localhost:8001/openapi.json +``` + +## Запуск в Docker + +```bash +# Сборка и запуск +docker compose up --build -d + +# Проверка статуса +docker compose ps + +# Просмотр логов +docker compose logs + +# Остановка +docker compose down +``` + +## Разработка + +### Установка зависимостей +```bash +pip install -r requirements.txt +``` + +### Запуск в режиме разработки +```bash +uvicorn main:app --host 0.0.0.0 --port 8001 --reload +``` + +## Лицензия + +MIT License diff --git a/LichessWebServices/Dockerfile b/LichessWebServices/Dockerfile new file mode 100644 index 0000000..fd07d4f --- /dev/null +++ b/LichessWebServices/Dockerfile @@ -0,0 +1,26 @@ +# Lichess Statistics API - Dockerfile +# +# Этот Dockerfile создает образ для запуска Lichess Statistics API +# в контейнере Docker. + +# Используем официальный Python образ на базе Debian slim +FROM python:3.11-slim + +# Устанавливаем рабочую директорию в контейнере +WORKDIR /app + +# Копируем файл зависимостей и устанавливаем пакеты +# Делаем это отдельно для кэширования слоев Docker +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем весь код приложения +COPY . . + +# Открываем порт 8000 для HTTP запросов +EXPOSE 8000 + +# Запускаем FastAPI приложение через Uvicorn +# --host 0.0.0.0 позволяет принимать соединения с любого IP +# --port 8000 указывает порт для прослушивания +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/LichessWebServices/README.md b/LichessWebServices/README.md new file mode 100644 index 0000000..35046f2 --- /dev/null +++ b/LichessWebServices/README.md @@ -0,0 +1,121 @@ +# Lichess Statistics API + +REST API сервис для получения статистики игроков Lichess.org по играм и решению задач. + +## Возможности + +- Получение статистики за сегодняшний день +- Получение статистики за вчерашний день +- Получение статистики за последние 7 дней +- Поддержка режимов игры: Bullet, Blitz, Rapid +- Статистика по решению задач (пазлов) +- Расчет изменения рейтинга +- Подсчет побед, поражений и ничьих + +## Запуск с Docker + +### 1. Сборка и запуск контейнера + +```bash +# Сборка образа +docker-compose build + +# Запуск сервиса +docker-compose up -d +``` + +### 2. Проверка работы + +Сервис будет доступен по адресу: http://localhost:8000 + +- Документация API: http://localhost:8000/docs +- Проверка здоровья: http://localhost:8000/health + +## API Endpoints + +### Получить статистику за сегодня +``` +GET /stats/{username}/today +``` + +### Получить статистику за вчера +``` +GET /stats/{username}/yesterday +``` + +### Получить статистику за неделю +``` +GET /stats/{username}/week +``` + +## Примеры использования + +### Статистика за сегодня +```bash +curl http://localhost:8000/stats/vrubelroman/today +``` + +### Статистика за вчера +```bash +curl http://localhost:8000/stats/vrubelroman/yesterday +``` + +### Статистика за неделю +```bash +curl http://localhost:8000/stats/vrubelroman/week +``` + +## Формат ответа + +```json +{ + "message": "Статистика за сегодняшний день", + "data": { + "username": "vrubelroman", + "tasks": { + "total": 28, + "solved": 25, + "unsolved": 3 + }, + "games": { + "bullet": { + "games_played": 7, + "rating_change": 30, + "final_rating": 2320, + "wins": 5, + "losses": 0, + "draws": 0 + }, + "blitz": { + "games_played": 5, + "rating_change": 32, + "final_rating": 2224, + "wins": 5, + "losses": 0, + "draws": 0 + }, + "rapid": { + "games_played": 2, + "rating_change": -10, + "final_rating": 2210, + "wins": 1, + "losses": 1, + "draws": 0 + } + } + } +} +``` + +## Остановка сервиса + +```bash +docker-compose down +``` + +## Логи + +Для просмотра логов: +```bash +docker-compose logs -f +``` diff --git a/LichessWebServices/docker-compose.yml b/LichessWebServices/docker-compose.yml new file mode 100644 index 0000000..10c141b --- /dev/null +++ b/LichessWebServices/docker-compose.yml @@ -0,0 +1,30 @@ +# Lichess Statistics API - Docker Compose +# +# Этот файл определяет сервисы для запуска Lichess Statistics API +# в контейнере Docker с помощью Docker Compose. + +services: + # Основной сервис API + lichess-api: + # Собираем образ из Dockerfile в текущей директории + build: . + + # Маппинг портов: хост:контейнер + # 8001 на хосте -> 8000 в контейнере + # Изменено с 8000:8000 из-за конфликта портов + ports: + - "8001:8000" + + # Переменные окружения + environment: + # Отключаем буферизацию Python для корректного вывода логов + - PYTHONUNBUFFERED=1 + + # Монтируем текущую директорию в контейнер для разработки + # Это позволяет видеть изменения кода без пересборки образа + volumes: + - .:/app + + # Политика перезапуска: перезапускать контейнер при сбое + # (кроме случаев ручной остановки) + restart: unless-stopped diff --git a/LichessWebServices/lichess_client.py b/LichessWebServices/lichess_client.py new file mode 100644 index 0000000..425b505 --- /dev/null +++ b/LichessWebServices/lichess_client.py @@ -0,0 +1,250 @@ +""" +Lichess Statistics API - Клиент для работы с Lichess API + +Этот модуль содержит класс LichessClient для взаимодействия с официальным API Lichess. +Обеспечивает: +- Получение активности пользователей +- Получение игр за период +- Получение активности по решению задач (пазлов) +- Обработку ошибок и таймаутов +- Парсинг NDJSON формата + +Автор: Lichess Web Services Team +Версия: 1.0.0 +""" + +import httpx +from typing import List, Dict, Any, Optional +from datetime import datetime, timedelta +import logging +import json + +# Настройка логирования для модуля +logger = logging.getLogger(__name__) + +class LichessClient: + """ + Клиент для взаимодействия с Lichess API. + + Предоставляет методы для получения различных данных от Lichess: + - Активность пользователей + - Игры за период + - Статистика решения задач + + Все методы асинхронные и используют httpx для HTTP запросов. + """ + + def __init__(self): + """ + Инициализация клиента Lichess API. + + Создает HTTP клиент с таймаутом 30 секунд для всех запросов. + """ + self.base_url = "https://lichess.org/api" # Базовый URL Lichess API + self.client = httpx.AsyncClient(timeout=30.0) # HTTP клиент с таймаутом + + async def get_user_activity(self, username: str) -> Optional[List[Dict[str, Any]]]: + """ + Получает активность пользователя за последние 7 активных дней. + + Args: + username: Имя пользователя на Lichess + + Returns: + Список активностей пользователя или None, если пользователь не найден + + Raises: + httpx.HTTPStatusError: При ошибках HTTP (кроме 404) + Exception: При других ошибках + """ + try: + # Формируем URL для получения активности пользователя + url = f"{self.base_url}/user/{username}/activity" + logger.info(f"Запрос активности пользователя {username}") + + # Выполняем HTTP GET запрос + response = await self.client.get(url) + response.raise_for_status() # Проверяем статус ответа + + # Возвращаем JSON данные + return response.json() + + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + # Пользователь не найден - это нормальная ситуация + logger.warning(f"Пользователь {username} не найден") + return None + else: + # Другие HTTP ошибки - логируем и пробрасываем + logger.error(f"HTTP ошибка при получении активности пользователя {username}: {e}") + raise + except Exception as e: + # Обрабатываем все остальные ошибки + logger.error(f"Ошибка при получении активности пользователя {username}: {e}") + raise + + async def get_games_of_period(self, username: str, since_ms: int, until_ms: int, rated_only: bool = True) -> Optional[List[Dict[str, Any]]]: + """ + Получает игры пользователя за определенный период. + + Lichess API возвращает игры в формате NDJSON (Newline Delimited JSON), + где каждая строка содержит JSON объект с информацией об игре. + + Args: + username: Имя пользователя на Lichess + since_ms: Начало периода в миллисекундах (Unix timestamp * 1000) + until_ms: Конец периода в миллисекундах (Unix timestamp * 1000) + rated_only: Только рейтинговые игры (по умолчанию True) + + Returns: + Список игр в формате JSON или None при ошибке + + Raises: + httpx.HTTPStatusError: При ошибках HTTP + Exception: При других ошибках + """ + try: + # Формируем URL для получения игр пользователя + url = f"{self.base_url}/games/user/{username}" + + # Параметры запроса + params = { + 'since': since_ms, # Начало периода + 'until': until_ms, # Конец периода + 'max': 1000 # Максимум игр за запрос (лимит Lichess API) + } + + # Добавляем фильтр по рейтинговым играм, если нужно + if rated_only: + params['rated'] = 'true' + + # Заголовки для получения NDJSON формата + headers = { + 'Accept': 'application/x-ndjson' # Запрашиваем NDJSON формат + } + + logger.info(f"Запрос игр для {username} с {since_ms} по {until_ms}") + + # Выполняем HTTP GET запрос + response = await self.client.get(url, params=params, headers=headers) + response.raise_for_status() # Проверяем статус ответа + + # Парсим NDJSON (Newline Delimited JSON) + # Каждая строка содержит отдельный JSON объект + games = [] + content = response.text.strip() + + if content: + for line in content.split('\n'): + if line.strip(): + try: + # Парсим каждую строку как отдельный JSON объект + game = json.loads(line) + games.append(game) + except json.JSONDecodeError as e: + # Логируем ошибки парсинга, но продолжаем обработку + logger.warning(f"Ошибка парсинга JSON строки: {e}") + continue + + logger.info(f"Получено {len(games)} игр для пользователя {username}") + return games + + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + # Пользователь не найден - это нормальная ситуация + logger.warning(f"Пользователь {username} не найден") + return None + else: + # Другие HTTP ошибки - логируем и пробрасываем + logger.error(f"HTTP ошибка при получении игр пользователя {username}: {e}") + raise + except Exception as e: + # Обрабатываем все остальные ошибки + logger.error(f"Ошибка при получении игр пользователя {username}: {e}") + raise + + async def get_puzzle_activity(self, token: str, max_puzzles: int = 50) -> Optional[List[Dict[str, Any]]]: + """ + Получает активность пользователя по решению задач (пазлов). + + Требует авторизации через Bearer токен. Lichess API возвращает данные + в формате NDJSON (Newline Delimited JSON). + + Args: + token: Bearer токен авторизации от Lichess + max_puzzles: Максимальное количество задач для получения (по умолчанию 50) + + Returns: + Список активностей по задачам в формате JSON или None при ошибке + + Raises: + httpx.HTTPStatusError: При ошибках HTTP + Exception: При других ошибках + """ + try: + # Формируем URL для получения активности по задачам + url = f"{self.base_url}/puzzle/activity" + + # Параметры запроса + params = { + 'max': max_puzzles # Максимальное количество задач + } + + # Заголовки с авторизацией и форматом данных + headers = { + 'Authorization': f'Bearer {token}', # Bearer токен авторизации + 'Accept': 'application/x-ndjson' # Запрашиваем NDJSON формат + } + + logger.info(f"Запрос активности по задачам, max={max_puzzles}") + + # Выполняем HTTP GET запрос + response = await self.client.get(url, params=params, headers=headers) + response.raise_for_status() # Проверяем статус ответа + + # Парсим NDJSON (Newline Delimited JSON) + # Каждая строка содержит отдельный JSON объект с активностью + activities = [] + content = response.text.strip() + + if content: + for line in content.split('\n'): + if line.strip(): + try: + # Парсим каждую строку как отдельный JSON объект + activity = json.loads(line) + activities.append(activity) + except json.JSONDecodeError as e: + # Логируем ошибки парсинга, но продолжаем обработку + logger.warning(f"Ошибка парсинга JSON строки: {e}") + continue + + logger.info(f"Получено {len(activities)} активностей по задачам") + return activities + + except httpx.HTTPStatusError as e: + if e.response.status_code == 401: + # Неверный токен авторизации + logger.warning("Неверный токен авторизации") + return None + elif e.response.status_code == 403: + # Доступ запрещен (недостаточно прав) + logger.warning("Доступ запрещен") + return None + else: + # Другие HTTP ошибки - логируем и пробрасываем + logger.error(f"HTTP ошибка при получении активности по задачам: {e}") + raise + except Exception as e: + # Обрабатываем все остальные ошибки + logger.error(f"Ошибка при получении активности по задачам: {e}") + raise + + async def close(self): + """ + Закрывает HTTP клиент. + + Освобождает ресурсы и корректно закрывает соединения. + Должен вызываться при завершении работы с клиентом. + """ + await self.client.aclose() diff --git a/LichessWebServices/main.py b/LichessWebServices/main.py new file mode 100644 index 0000000..f6f941d --- /dev/null +++ b/LichessWebServices/main.py @@ -0,0 +1,705 @@ +""" +Lichess Statistics API - Основной модуль FastAPI приложения + +Этот модуль содержит все API эндпоинты для получения статистики игроков Lichess. +Включает в себя: +- Статистику игр за разные периоды (сегодня, вчера, неделя) +- Детальную статистику игр за произвольный период +- Статистику решения задач (пазлов) за период +- Health check и информационные эндпоинты + +Автор: Lichess Web Services Team +Версия: 1.0.0 +""" + +from fastapi import FastAPI, HTTPException, Path, Query, Header +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +import logging +from datetime import datetime +from stats_service import StatsService +from models import ActivityResponse, ErrorResponse, HealthResponse, GamesOfPeriodResponse, PuzzleOfPeriodResponse + +# ============================================================================= +# НАСТРОЙКА ЛОГИРОВАНИЯ +# ============================================================================= +# Настройка базового логирования для всего приложения +# Уровень INFO позволяет видеть все важные события и ошибки +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# ============================================================================= +# ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ +# ============================================================================= +# Глобальный экземпляр сервиса статистики +# Инициализируется при запуске приложения и используется во всех эндпоинтах +stats_service = None + +# ============================================================================= +# УПРАВЛЕНИЕ ЖИЗНЕННЫМ ЦИКЛОМ ПРИЛОЖЕНИЯ +# ============================================================================= +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Контекстный менеджер для управления жизненным циклом FastAPI приложения. + + Выполняется при запуске и остановке приложения: + - При запуске: инициализирует сервис статистики + - При остановке: корректно закрывает все соединения + + Args: + app: Экземпляр FastAPI приложения + """ + # ========== STARTUP (Запуск приложения) ========== + global stats_service + # Создаем экземпляр сервиса статистики, который будет использоваться во всех эндпоинтах + stats_service = StatsService() + logger.info("Lichess API сервис запущен") + + # Передаем управление приложению + yield + + # ========== SHUTDOWN (Остановка приложения) ========== + # Корректно закрываем все соединения и освобождаем ресурсы + if stats_service: + await stats_service.close() + logger.info("Lichess API сервис остановлен") + +# ============================================================================= +# СОЗДАНИЕ FASTAPI ПРИЛОЖЕНИЯ +# ============================================================================= +app = FastAPI( + title="Lichess Statistics API", + description=""" + ## Lichess Statistics API + + REST API для получения детальной статистики игроков платформы Lichess. + + ### Возможности: + * 📊 Получение статистики игр по режимам (Bullet, Blitz, Rapid) + * 🧩 Статистика решения задач (пазлов) + * 📅 Статистика за разные периоды (сегодня, вчера, неделя) + * 🎯 Отслеживание изменений рейтинга + * 📈 Подробная аналитика результатов игр + + ### Режимы игр: + - **Bullet**: Быстрые игры (1-3 минуты) + - **Blitz**: Блиц игры (3-10 минут) + - **Rapid**: Рапид игры (10+ минут) + + ### Примеры использования: + - Получить статистику за сегодня: `GET /stats/{username}/today` + - Получить статистику за вчера: `GET /stats/{username}/yesterday` + - Получить статистику за неделю: `GET /stats/{username}/week` + """, + version="1.0.0", + contact={ + "name": "Lichess Statistics API Support", + "url": "https://github.com/vrubelroman/LichessWebServices", + }, + license_info={ + "name": "MIT", + }, + lifespan=lifespan, + openapi_tags=[ + { + "name": "health", + "description": "Проверка состояния сервиса" + }, + { + "name": "statistics", + "description": "Получение статистики игроков Lichess" + }, + { + "name": "info", + "description": "Информация об API" + } + ] +) + +# ============================================================================= +# НАСТРОЙКА CORS (Cross-Origin Resource Sharing) +# ============================================================================= +# CORS middleware позволяет веб-приложениям делать запросы к API с других доменов +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # В продакшене следует ограничить домены для безопасности + allow_credentials=True, # Разрешаем отправку cookies и авторизационных заголовков + allow_methods=["*"], # Разрешаем все HTTP методы (GET, POST, PUT, DELETE и т.д.) + allow_headers=["*"], # Разрешаем все заголовки +) + +# ============================================================================= +# API ЭНДПОИНТЫ +# ============================================================================= + +@app.get("/", tags=["info"]) +async def root(): + """ + ## Корневой endpoint + + Возвращает основную информацию об API и доступных эндпоинтах. + Используется для получения базовой информации о сервисе. + + ### Возвращает: + - Название API + - Версию + - Список доступных эндпоинтов + - Ссылки на документацию + """ + return { + "message": "Lichess Statistics API", + "version": "1.0.0", + "description": "REST API для получения статистики игроков Lichess", + "endpoints": { + "today": "/stats/{username}/today", # Статистика за сегодня + "yesterday": "/stats/{username}/yesterday", # Статистика за вчера + "week": "/stats/{username}/week", # Статистика за неделю + "games_period": "/games/{username}/period?since={timestamp}&until={timestamp}", # Статистика игр за период + "puzzle_period": "/puzzle/period?since={timestamp}&until={timestamp}&max={max}" # Статистика задач за период + }, + "documentation": "/docs", # Swagger UI документация + "openapi_schema": "/openapi.json" # OpenAPI схема + } + +@app.get("/health", + response_model=HealthResponse, + tags=["health"], + summary="Health Check", + description="Проверка состояния сервиса") +async def health_check(): + """ + ## Проверка здоровья сервиса + + Простой endpoint для проверки работоспособности API. + Используется для мониторинга и health checks в production среде. + + ### Возвращает: + - Статус сервиса (healthy/unhealthy) + - Время проверки в ISO формате + - Название сервиса + + ### Использование: + - Мониторинг системы + - Load balancer health checks + - Kubernetes liveness/readiness probes + """ + return HealthResponse( + status="healthy", # Всегда возвращает healthy, если сервис запущен + timestamp=datetime.now().isoformat(), # Текущее время в ISO формате + service="Lichess Statistics API" + ) + +@app.get("/stats/{username}/today", + response_model=ActivityResponse, + tags=["statistics"], + summary="Статистика за сегодня", + description="Получает детальную статистику игрока за сегодняшний день", + responses={ + 200: { + "description": "Статистика успешно получена", + "content": { + "application/json": { + "example": { + "message": "Статистика за сегодняшний день", + "data": { + "username": "magnus", + "tasks": { + "total": 15, + "solved": 12, + "unsolved": 3 + }, + "games": { + "bullet": { + "games_played": 8, + "rating_change": 15, + "final_rating": 2850, + "wins": 5, + "losses": 2, + "draws": 1 + }, + "blitz": { + "games_played": 3, + "rating_change": -5, + "final_rating": 2750, + "wins": 1, + "losses": 2, + "draws": 0 + }, + "rapid": { + "games_played": 0, + "rating_change": 0, + "final_rating": 0, + "wins": 0, + "losses": 0, + "draws": 0 + } + } + } + } + } + } + }, + 404: { + "description": "Пользователь не найден", + "content": { + "application/json": { + "example": { + "message": "Пользователь magnus не найден или неактивен" + } + } + } + }, + 500: { + "description": "Внутренняя ошибка сервера", + "content": { + "application/json": { + "example": { + "detail": "Внутренняя ошибка сервера: Connection timeout" + } + } + } + } + }) +async def get_today_stats( + username: str = Path(..., + description="Имя пользователя на Lichess", + example="magnus", + min_length=1, + max_length=50) +): + """ + ## Статистика за сегодняшний день + + Получает детальную статистику игрока за сегодняшний день, включая: + + ### Статистика игр: + - **Bullet**: Быстрые игры (1-3 минуты) + - **Blitz**: Блиц игры (3-10 минут) + - **Rapid**: Рапид игры (10+ минут) + + ### Для каждого режима: + - Количество сыгранных игр + - Изменение рейтинга + - Текущий рейтинг + - Количество побед, поражений, ничьих + + ### Статистика задач: + - Общее количество решенных задач + - Количество решенных задач + - Количество нерешенных задач + + ### Параметры: + - **username**: Имя пользователя на Lichess (обязательно) + + ### Возможные ошибки: + - **404**: Пользователь не найден или неактивен + - **500**: Внутренняя ошибка сервера + """ + # Проверяем, что сервис статистики инициализирован + if not stats_service: + raise HTTPException(status_code=500, detail="Сервис не инициализирован") + + try: + # Получаем статистику за сегодняшний день через сервис + result = await stats_service.get_today_stats(username) + return result + except Exception as e: + # Логируем ошибку и возвращаем HTTP 500 + logger.error(f"Ошибка в endpoint get_today_stats: {e}") + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + +@app.get("/stats/{username}/yesterday", + response_model=ActivityResponse, + tags=["statistics"], + summary="Статистика за вчера", + description="Получает детальную статистику игрока за вчерашний день") +async def get_yesterday_stats( + username: str = Path(..., + description="Имя пользователя на Lichess", + example="magnus", + min_length=1, + max_length=50) +): + """ + ## Статистика за вчерашний день + + Получает детальную статистику игрока за вчерашний день. + + ### Возвращает: + - Статистику игр по всем режимам (Bullet, Blitz, Rapid) + - Статистику решения задач (пазлов) + - Изменения рейтинга + - Результаты игр (победы, поражения, ничьи) + + ### Параметры: + - **username**: Имя пользователя на Lichess (обязательно) + """ + if not stats_service: + raise HTTPException(status_code=500, detail="Сервис не инициализирован") + + try: + result = await stats_service.get_yesterday_stats(username) + return result + except Exception as e: + logger.error(f"Ошибка в endpoint get_yesterday_stats: {e}") + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + +@app.get("/stats/{username}/week", + response_model=ActivityResponse, + tags=["statistics"], + summary="Статистика за неделю", + description="Получает агрегированную статистику игрока за последние 7 дней") +async def get_week_stats( + username: str = Path(..., + description="Имя пользователя на Lichess", + example="magnus", + min_length=1, + max_length=50) +): + """ + ## Статистика за последние 7 дней + + Получает агрегированную статистику игрока за последние 7 дней. + + ### Особенности: + - Суммирует все игры и задачи за неделю + - Показывает общее изменение рейтинга + - Отображает финальный рейтинг на конец периода + + ### Возвращает: + - Общую статистику игр по всем режимам + - Суммарную статистику решения задач + - Агрегированные изменения рейтинга + + ### Параметры: + - **username**: Имя пользователя на Lichess (обязательно) + """ + if not stats_service: + raise HTTPException(status_code=500, detail="Сервис не инициализирован") + + try: + result = await stats_service.get_week_stats(username) + return result + except Exception as e: + logger.error(f"Ошибка в endpoint get_week_stats: {e}") + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + +@app.get("/games/{username}/period", + response_model=GamesOfPeriodResponse, + tags=["statistics"], + summary="Статистика игр за период", + description="Получает детальную статистику игр пользователя за указанный период", + responses={ + 200: { + "description": "Статистика игр успешно получена", + "content": { + "application/json": { + "example": { + "message": "Статистика игр за период", + "username": "magnus", + "period_start": 1640995200000, + "period_end": 1641081600000, + "games_count": 25, + "data": { + "bullet": { + "games_played": 10, + "wins": 6, + "losses": 3, + "draws": 1, + "rating_change": 15, + "rating": 2850 + }, + "blitz": { + "games_played": 8, + "wins": 5, + "losses": 2, + "draws": 1, + "rating_change": 12, + "rating": 2750 + }, + "rapid": { + "games_played": 5, + "wins": 3, + "losses": 1, + "draws": 1, + "rating_change": 8, + "rating": 2600 + }, + "classical": { + "games_played": 2, + "wins": 1, + "losses": 1, + "draws": 0, + "rating_change": 0, + "rating": 2400 + }, + "correspondence": { + "games_played": 0, + "wins": 0, + "losses": 0, + "draws": 0, + "rating_change": 0, + "rating": None + }, + "total": { + "games_played": 25, + "wins": 15, + "losses": 7, + "draws": 3, + "rating_change": 35, + "rating": 2850 + } + } + } + } + } + }, + 400: { + "description": "Некорректные параметры запроса", + "content": { + "application/json": { + "example": { + "detail": "Параметр 'since' должен быть меньше 'until'" + } + } + } + }, + 404: { + "description": "Пользователь не найден", + "content": { + "application/json": { + "example": { + "message": "Пользователь magnus не найден" + } + } + } + }, + 500: { + "description": "Внутренняя ошибка сервера", + "content": { + "application/json": { + "example": { + "detail": "Внутренняя ошибка сервера: Connection timeout" + } + } + } + } + }) +async def get_games_of_period( + username: str = Path(..., + description="Имя пользователя на Lichess", + example="magnus", + min_length=1, + max_length=50), + since: int = Query(..., + description="Начало периода (Unix timestamp в миллисекундах)", + example=1640995200000), + until: int = Query(..., + description="Конец периода (Unix timestamp в миллисекундах)", + example=1641081600000), + rated_only: bool = Query(True, + description="Только рейтинговые игры (по умолчанию true - рекомендуется)", + example=True) +): + """ + ## Статистика игр за период + + Получает детальную статистику игр пользователя за указанный период времени. + Этот эндпоинт позволяет получить подробную аналитику по играм за любой период. + + ### Возможности: + - **Фильтрация по времени**: точный период с Unix timestamp + - **Типы игр**: Bullet, Blitz, Rapid, Classical, Correspondence + - **Статистика результатов**: победы, поражения, ничьи + - **Рейтинговые изменения**: суммарные изменения рейтинга + - **Итоговый рейтинг**: рейтинг после последней игры в каждом режиме + - **Фильтр рейтинговых игр**: только рейтинговые или все игры + + ### Параметры: + - **username**: Имя пользователя на Lichess (обязательно) + - **since**: Начало периода в Unix timestamp (миллисекунды) (обязательно) + - **until**: Конец периода в Unix timestamp (миллисекунды) (обязательно) + - **rated_only**: Только рейтинговые игры (по умолчанию true - рекомендуется) + + ### Примеры использования: + - Статистика за последние 7 дней: `since=1640995200000&until=1641081600000` (по умолчанию только рейтинговые) + - Только рейтинговые игры: `rated_only=true` (рекомендуется) + - Все игры: `rated_only=false` + + ### Возможные ошибки: + - **400**: Некорректные параметры (since >= until) + - **404**: Пользователь не найден + - **500**: Внутренняя ошибка сервера + """ + # Проверяем, что сервис статистики инициализирован + if not stats_service: + raise HTTPException(status_code=500, detail="Сервис не инициализирован") + + # Валидация параметров времени + if since >= until: + raise HTTPException( + status_code=400, + detail="Параметр 'since' должен быть меньше 'until'" + ) + + # Проверяем разумность периода (не более 1 года в миллисекундах) + if until - since > 365 * 24 * 3600 * 1000: + raise HTTPException( + status_code=400, + detail="Период не может превышать 1 год" + ) + + try: + # Конвертируем миллисекунды в секунды для внутренней логики + since_seconds = since // 1000 + until_seconds = until // 1000 + result = await stats_service.get_games_of_period(username, since_seconds, until_seconds, rated_only) + return result + except Exception as e: + logger.error(f"Ошибка в endpoint get_games_of_period: {e}") + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + +@app.get("/puzzle/period", + response_model=PuzzleOfPeriodResponse, + tags=["statistics"], + summary="Статистика решения задач за период", + description="Получает статистику решения задач (пазлов) за указанный период времени. Требует авторизации через Bearer токен.", + responses={ + 200: { + "description": "Статистика решения задач успешно получена", + "content": { + "application/json": { + "example": { + "message": "Статистика решения задач за период", + "period_start": 1640995200000, + "period_end": 1641081600000, + "max_puzzles": 50, + "puzzles_in_period": 15, + "data": { + "total_attempts": 15, + "solved": 12, + "failed": 3, + "success_rate": 80.0 + } + } + } + } + }, + 400: { + "description": "Некорректные параметры запроса", + "content": { + "application/json": { + "example": { + "detail": "Параметр 'since' должен быть меньше 'until'" + } + } + } + }, + 401: { + "description": "Неверный токен авторизации", + "content": { + "application/json": { + "example": { + "message": "Неверный токен авторизации или доступ запрещен" + } + } + } + }, + 500: { + "description": "Внутренняя ошибка сервера", + "content": { + "application/json": { + "example": { + "detail": "Внутренняя ошибка сервера: Connection timeout" + } + } + } + } + }) +async def get_puzzle_of_period( + since: int = Query(..., + description="Начало периода (Unix timestamp в миллисекундах)", + example=1640995200000), + until: int = Query(..., + description="Конец периода (Unix timestamp в миллисекундах)", + example=1641081600000), + max: int = Query(50, + description="Максимальное количество задач для получения от Lichess API. Внимание: если в периоде было больше задач, чем указано в max, будут показаны только последние N активностей", + example=50, + ge=1, + le=1000), + authorization: str = Header(..., + description="Bearer токен авторизации", + example="Bearer your_token_here") +): + """ + ## Статистика решения задач за период + + Получает статистику решения задач (пазлов) за указанный период времени. + Требует авторизации через Bearer токен от Lichess. + + ### Возможности: + - **Фильтрация по времени**: точный период с Unix timestamp в миллисекундах + - **Статистика решений**: количество решенных и нерешенных задач + - **Процент успеха**: автоматический расчет процента успешных решений + - **Настраиваемый лимит**: максимальное количество задач для анализа + + ### Параметры: + - **since**: Начало периода в Unix timestamp (миллисекунды) (обязательно) + - **until**: Конец периода в Unix timestamp (миллисекунды) (обязательно) + - **max**: Максимальное количество задач (по умолчанию 50, максимум 1000) + - **Authorization**: Bearer токен в заголовке (обязательно) + + ### Примеры использования: + - Статистика за последние 7 дней: `since=1640995200000&until=1641081600000` + - Больше задач: `max=100` + - Заголовок: `Authorization: Bearer your_token_here` + + ### Получение токена: + 1. Зайдите на https://lichess.org/account/oauth/token/create + 2. Создайте новый токен с правами на чтение активности + 3. Используйте токен в заголовке Authorization + + ### Возможные ошибки: + - **400**: Некорректные параметры (since >= until, неверный max) + - **401**: Неверный токен авторизации + - **500**: Внутренняя ошибка сервера + """ + # Проверяем, что сервис статистики инициализирован + if not stats_service: + raise HTTPException(status_code=500, detail="Сервис не инициализирован") + + # Валидация параметров + if since >= until: + raise HTTPException( + status_code=400, + detail="Параметр 'since' должен быть меньше 'until'" + ) + + # Проверяем разумность периода (не более 1 года в миллисекундах) + if until - since > 365 * 24 * 3600 * 1000: + raise HTTPException( + status_code=400, + detail="Период не может превышать 1 год" + ) + + # Извлекаем токен из заголовка Authorization + if not authorization.startswith("Bearer "): + raise HTTPException( + status_code=401, + detail="Неверный формат токена. Используйте 'Bearer your_token'" + ) + + token = authorization[7:] # Убираем "Bearer " из начала + + try: + result = await stats_service.get_puzzle_of_period(token, since, until, max) + return result + except Exception as e: + logger.error(f"Ошибка в endpoint get_puzzle_of_period: {e}") + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/LichessWebServices/models.py b/LichessWebServices/models.py new file mode 100644 index 0000000..253fcb1 --- /dev/null +++ b/LichessWebServices/models.py @@ -0,0 +1,236 @@ +""" +Lichess Statistics API - Модели данных + +Этот модуль содержит все Pydantic модели для валидации и сериализации данных. +Модели используются для: +- Валидации входных параметров API +- Сериализации ответов API +- Документации в Swagger UI +- Типизации данных в коде + +Автор: Lichess Web Services Team +Версия: 1.0.0 +""" + +from pydantic import BaseModel, Field +from typing import Dict, Optional, Any + +# ============================================================================= +# МОДЕЛИ СТАТИСТИКИ ЗАДАЧ (ПАЗЛОВ) +# ============================================================================= + +class TaskStats(BaseModel): + """ + Статистика решения задач (пазлов) пользователя. + + Содержит информацию о том, сколько задач пользователь решил, + сколько решил правильно и сколько не решил или решил неправильно. + """ + total: int = Field(..., description="Общее количество решенных задач", example=15) + solved: int = Field(..., description="Количество правильно решенных задач", example=12) + unsolved: int = Field(..., description="Количество нерешенных или неправильно решенных задач", example=3) + +# ============================================================================= +# МОДЕЛИ СТАТИСТИКИ ИГР +# ============================================================================= + +class GameModeStats(BaseModel): + """ + Статистика игр для конкретного режима (Bullet, Blitz, Rapid и т.д.). + + Содержит полную статистику по играм в определенном временном формате: + - Количество сыгранных игр + - Изменение рейтинга за период + - Текущий рейтинг + - Результаты игр (победы, поражения, ничьи) + """ + games_played: int = Field(..., description="Общее количество сыгранных игр", example=8) + rating_change: int = Field(..., description="Изменение рейтинга (может быть отрицательным)", example=15) + final_rating: int = Field(..., description="Текущий рейтинг игрока", example=2850) + wins: int = Field(..., description="Количество побед", example=5) + losses: int = Field(..., description="Количество поражений", example=2) + draws: int = Field(..., description="Количество ничьих", example=1) + +class GamesStats(BaseModel): + """ + Статистика игр по всем режимам. + + Агрегирует статистику игр по всем временным форматам: + - Bullet: быстрые игры (1-3 минуты) + - Blitz: блиц игры (3-10 минут) + - Rapid: рапид игры (10+ минут) + """ + bullet: GameModeStats = Field(..., description="Статистика Bullet игр (1-3 минуты)") + blitz: GameModeStats = Field(..., description="Статистика Blitz игр (3-10 минут)") + rapid: GameModeStats = Field(..., description="Статистика Rapid игр (10+ минут)") + +class UserStats(BaseModel): + """ + Полная статистика пользователя. + + Содержит всю доступную статистику по пользователю: + - Статистику решения задач (пазлов) + - Статистику игр по всем режимам + """ + username: str = Field(..., description="Имя пользователя на Lichess", example="magnus") + tasks: TaskStats = Field(..., description="Статистика решения задач") + games: GamesStats = Field(..., description="Статистика игр по всем режимам") + +# ============================================================================= +# МОДЕЛИ ОТВЕТОВ API +# ============================================================================= + +class ActivityResponse(BaseModel): + """ + Стандартный ответ API с результатами запроса статистики. + + Используется для всех эндпоинтов статистики (сегодня, вчера, неделя). + Содержит сообщение о результате и данные статистики пользователя. + """ + message: str = Field(..., description="Сообщение о результате запроса", example="Статистика за сегодняшний день") + data: Optional[UserStats] = Field(None, description="Данные статистики пользователя (null если пользователь не найден или неактивен)") + +class ErrorResponse(BaseModel): + """ + Модель для стандартизированных ошибок API. + + Используется для возврата структурированных ошибок с дополнительной информацией. + """ + detail: str = Field(..., description="Описание ошибки", example="Пользователь не найден") + error_code: Optional[str] = Field(None, description="Код ошибки", example="USER_NOT_FOUND") + timestamp: Optional[str] = Field(None, description="Время возникновения ошибки", example="2024-01-15T10:30:00Z") + +class HealthResponse(BaseModel): + """ + Ответ для health check эндпоинта. + + Используется для мониторинга состояния сервиса и проверки его работоспособности. + """ + status: str = Field(..., description="Статус сервиса", example="healthy") + timestamp: str = Field(..., description="Время проверки", example="2024-01-15T10:30:00Z") + service: str = Field(..., description="Название сервиса", example="Lichess Statistics API") + +# ============================================================================= +# МОДЕЛИ ДЛЯ ЭНДПОИНТА СТАТИСТИКИ ИГР ЗА ПЕРИОД +# ============================================================================= + +class GamePlayer(BaseModel): + """ + Информация об игроке в партии. + + Содержит данные о пользователе, его рейтинге и изменении рейтинга в конкретной игре. + """ + user: Optional[Dict[str, Any]] = Field(None, description="Информация о пользователе") + rating: Optional[int] = Field(None, description="Рейтинг игрока") + ratingDiff: Optional[int] = Field(None, description="Изменение рейтинга") + +class Game(BaseModel): + """ + Модель игры из Lichess API. + + Содержит полную информацию об игре, включая: + - Метаданные игры (ID, время создания, статус) + - Информацию об игроках и их рейтингах + - Результат игры и ходы в PGN формате + """ + id: str = Field(..., description="ID игры") + rated: bool = Field(..., description="Рейтинговая ли игра") + variant: str = Field(..., description="Вариант игры") + speed: str = Field(..., description="Скорость игры (bullet, blitz, rapid, classical, correspondence)") + perf: str = Field(..., description="Тип производительности") + createdAt: int = Field(..., description="Время создания игры (timestamp)") + lastMoveAt: int = Field(..., description="Время последнего хода (timestamp)") + status: str = Field(..., description="Статус игры") + players: Dict[str, GamePlayer] = Field(..., description="Игроки (white, black)") + winner: Optional[str] = Field(None, description="Победитель (white, black или null)") + moves: str = Field(..., description="Ходы игры в PGN формате") + +class GameStats(BaseModel): + """ + Статистика игр по конкретному типу (Bullet, Blitz, Rapid и т.д.). + + Содержит агрегированную статистику по играм определенного типа: + - Количество сыгранных игр + - Результаты игр (победы, поражения, ничьи) + - Общее изменение рейтинга + - Итоговый рейтинг после последней игры + """ + games_played: int = Field(..., description="Общее количество сыгранных игр", example=10) + wins: int = Field(..., description="Количество побед", example=6) + losses: int = Field(..., description="Количество поражений", example=3) + draws: int = Field(..., description="Количество ничьих", example=1) + rating_change: int = Field(..., description="Общее изменение рейтинга", example=15) + rating: Optional[int] = Field(None, description="Итоговый рейтинг после последней игры (только если games_played > 0)", example=2850) + +class GamesOfPeriodStats(BaseModel): + """ + Статистика игр за период по всем типам. + + Агрегирует статистику игр по всем временным форматам: + - Bullet, Blitz, Rapid, Classical, Correspondence + - Общая статистика по всем типам + """ + bullet: GameStats = Field(..., description="Статистика Bullet игр") + blitz: GameStats = Field(..., description="Статистика Blitz игр") + rapid: GameStats = Field(..., description="Статистика Rapid игр") + classical: GameStats = Field(..., description="Статистика Classical игр") + correspondence: GameStats = Field(..., description="Статистика Correspondence игр") + total: GameStats = Field(..., description="Общая статистика по всем типам") + +class GamesOfPeriodResponse(BaseModel): + """ + Ответ API с результатами запроса статистики игр за период. + + Содержит метаинформацию о запросе и агрегированную статистику игр. + """ + message: str = Field(..., description="Сообщение о результате запроса", example="Статистика игр за период") + username: str = Field(..., description="Имя пользователя", example="magnus") + period_start: int = Field(..., description="Начало периода (Unix timestamp)", example=1640995200) + period_end: int = Field(..., description="Конец периода (Unix timestamp)", example=1641081600) + games_count: int = Field(..., description="Общее количество игр", example=25) + data: Optional[GamesOfPeriodStats] = Field(None, description="Данные статистики игр") + +# ============================================================================= +# МОДЕЛИ ДЛЯ ЭНДПОИНТА СТАТИСТИКИ ЗАДАЧ ЗА ПЕРИОД +# ============================================================================= + +class PuzzleActivity(BaseModel): + """ + Активность по решению задачи (пазла) из Lichess API. + + Содержит информацию о попытке решения задачи пользователем: + - ID задачи и время решения + - Результат решения (решена/не решена) + - Дополнительная информация о задаче + """ + id: str = Field(..., description="ID задачи", example="abc123") + createdAt: int = Field(..., description="Время создания активности (timestamp в миллисекундах)", example=1640995200000) + win: bool = Field(..., description="Решена ли задача правильно", example=True) + puzzle: Dict[str, Any] = Field(..., description="Информация о задаче") + +class PuzzleStats(BaseModel): + """ + Агрегированная статистика решения задач за период. + + Содержит сводную информацию о решении задач: + - Общее количество попыток + - Количество успешных и неуспешных решений + - Процент успешности + """ + total_attempts: int = Field(..., description="Общее количество попыток решения", example=25) + solved: int = Field(..., description="Количество решенных задач", example=18) + failed: int = Field(..., description="Количество нерешенных задач", example=7) + success_rate: float = Field(..., description="Процент успешных решений", example=72.0) + +class PuzzleOfPeriodResponse(BaseModel): + """ + Ответ API с результатами запроса статистики решения задач за период. + + Содержит метаинформацию о запросе и агрегированную статистику решения задач. + """ + message: str = Field(..., description="Сообщение о результате запроса", example="Статистика решения задач за период") + period_start: int = Field(..., description="Начало периода (Unix timestamp в миллисекундах)", example=1640995200000) + period_end: int = Field(..., description="Конец периода (Unix timestamp в миллисекундах)", example=1641081600000) + max_puzzles: int = Field(..., description="Максимальное количество задач для получения", example=50) + puzzles_in_period: int = Field(..., description="Количество задач в указанном периоде", example=15) + data: Optional[PuzzleStats] = Field(None, description="Данные статистики решения задач") diff --git a/LichessWebServices/requirements.txt b/LichessWebServices/requirements.txt new file mode 100644 index 0000000..56b01de --- /dev/null +++ b/LichessWebServices/requirements.txt @@ -0,0 +1,20 @@ +# Lichess Statistics API - Зависимости Python +# +# Этот файл содержит все необходимые Python пакеты для работы API. +# Установка: pip install -r requirements.txt + +# FastAPI - современный веб-фреймворк для создания API +fastapi==0.104.1 + +# Uvicorn - ASGI сервер для запуска FastAPI приложения +# [standard] включает дополнительные зависимости для production +uvicorn[standard]==0.24.0 + +# HTTPX - асинхронный HTTP клиент для запросов к Lichess API +httpx==0.25.2 + +# Pydantic - библиотека для валидации данных и сериализации +pydantic==2.5.0 + +# Python-multipart - поддержка multipart/form-data для загрузки файлов +python-multipart==0.0.6 diff --git a/LichessWebServices/stats_service.py b/LichessWebServices/stats_service.py new file mode 100644 index 0000000..b5698d9 --- /dev/null +++ b/LichessWebServices/stats_service.py @@ -0,0 +1,733 @@ +""" +Lichess Statistics API - Сервис обработки статистики + +Этот модуль содержит класс StatsService для обработки и агрегации данных +от Lichess API. Включает в себя: +- Парсинг и обработку активности пользователей +- Агрегацию статистики игр по режимам +- Обработку статистики решения задач (пазлов) +- Фильтрацию данных по временным периодам +- Расчет рейтинговых изменений + +Автор: Lichess Web Services Team +Версия: 1.0.0 +""" + +from typing import List, Dict, Any, Optional +from datetime import datetime, timedelta, date +from lichess_client import LichessClient +from models import UserStats, TaskStats, GameModeStats, GamesStats, ActivityResponse, GameStats, GamesOfPeriodStats, GamesOfPeriodResponse, PuzzleStats, PuzzleOfPeriodResponse +import logging + +# Настройка логирования для модуля +logger = logging.getLogger(__name__) + +class StatsService: + """ + Сервис для обработки и агрегации статистики Lichess. + + Предоставляет методы для: + - Получения статистики за разные периоды (сегодня, вчера, неделя) + - Обработки игр за произвольный период + - Анализа активности по решению задач + - Агрегации данных по режимам игр + """ + + def __init__(self): + """ + Инициализация сервиса статистики. + + Создает экземпляр LichessClient для взаимодействия с API. + """ + self.lichess_client = LichessClient() + + # ============================================================================= + # ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ДЛЯ ОБРАБОТКИ ДАННЫХ + # ============================================================================= + + def _parse_lichess_interval(self, interval: Dict[str, int]) -> date: + """ + Парсит дату из временного интервала Lichess. + + Lichess API возвращает временные интервалы в миллисекундах, + этот метод конвертирует их в объект date. + + Args: + interval: Словарь с ключом 'start' содержащим timestamp в миллисекундах + + Returns: + Объект date с датой активности + """ + # Lichess использует миллисекунды, конвертируем в секунды + timestamp = interval['start'] / 1000 + return datetime.fromtimestamp(timestamp).date() + + def _is_date_in_range(self, target_date: date, activity_date: date, days_back: int) -> bool: + """ + Проверяет, попадает ли дата активности в нужный диапазон. + + Args: + target_date: Целевая дата (обычно сегодня) + activity_date: Дата активности из Lichess + days_back: Количество дней назад для проверки + + Returns: + True, если дата активности попадает в диапазон + """ + today = date.today() + start_date = today - timedelta(days=days_back-1) + return start_date <= activity_date <= today + + def _calculate_rating_change(self, mode_data: Dict[str, Any]) -> int: + """ + Вычисляет изменение рейтинга для режима игры. + + Args: + mode_data: Данные режима игры из Lichess API + + Returns: + Изменение рейтинга (может быть отрицательным) + """ + rp = mode_data.get('rp', {}) + before = rp.get('before', 0) # Рейтинг до периода + after = rp.get('after', 0) # Рейтинг после периода + return after - before + + def _get_final_rating(self, mode_data: Dict[str, Any]) -> int: + """ + Получает финальный рейтинг для режима игры. + + Args: + mode_data: Данные режима игры из Lichess API + + Returns: + Финальный рейтинг игрока в данном режиме + """ + rp = mode_data.get('rp', {}) + return rp.get('after', 0) + + def _count_game_results(self, mode_data: Dict[str, Any]) -> Dict[str, int]: + """ + Подсчитывает результаты игр для режима (победы, поражения, ничьи). + + Args: + mode_data: Данные режима игры из Lichess API + + Returns: + Словарь с количеством побед, поражений и ничьих + """ + wins = mode_data.get('win', 0) # Количество побед + losses = mode_data.get('loss', 0) # Количество поражений + draws = mode_data.get('draw', 0) # Количество ничьих + + return {"wins": wins, "losses": losses, "draws": draws} + + def _process_games_by_mode(self, games_data: Dict[str, Any]) -> Dict[str, GameModeStats]: + """ + Обрабатывает игры по режимам (bullet, blitz, rapid). + + Преобразует сырые данные от Lichess API в структурированную статистику + по каждому режиму игры. + + Args: + games_data: Сырые данные игр от Lichess API + + Returns: + Словарь с статистикой по каждому режиму игры + """ + result = {} + + # Инициализируем все режимы нулевыми значениями + # Это гарантирует, что все режимы будут присутствовать в результате + for mode in ["bullet", "blitz", "rapid"]: + result[mode] = GameModeStats( + games_played=0, + rating_change=0, + final_rating=0, + wins=0, + losses=0, + draws=0 + ) + + # Обрабатываем данные по режимам + for mode_name, mode_data in games_data.items(): + if mode_name in result: + # Извлекаем результаты игр + wins = mode_data.get('win', 0) + losses = mode_data.get('loss', 0) + draws = mode_data.get('draw', 0) + games_played = wins + losses + draws + + # Для недельной статистики используем предвычисленные значения + if 'rating_change' in mode_data: + rating_change = mode_data['rating_change'] + final_rating = mode_data['final_rating'] + else: + # Для дневной статистики вычисляем как обычно + rating_change = self._calculate_rating_change(mode_data) + final_rating = self._get_final_rating(mode_data) + + # Создаем объект статистики для режима + result[mode_name] = GameModeStats( + games_played=games_played, + rating_change=rating_change, + final_rating=final_rating, + wins=wins, + losses=losses, + draws=draws + ) + + return result + + def _process_tasks(self, puzzles_data: Dict[str, Any]) -> TaskStats: + """Обрабатывает статистику задач (пазлов)""" + score = puzzles_data.get('score', {}) + wins = score.get('win', 0) + losses = score.get('loss', 0) + draws = score.get('draw', 0) + + total = wins + losses + draws + solved = wins + unsolved = losses + draws + + return TaskStats( + total=total, + solved=solved, + unsolved=unsolved + ) + + # ============================================================================= + # ПУБЛИЧНЫЕ МЕТОДЫ ДЛЯ ПОЛУЧЕНИЯ СТАТИСТИКИ + # ============================================================================= + + async def get_today_stats(self, username: str) -> ActivityResponse: + """ + Получает статистику за сегодняшний день. + + Анализирует активность пользователя и возвращает статистику игр и задач + за сегодняшний день. + + Args: + username: Имя пользователя на Lichess + + Returns: + ActivityResponse с данными статистики или сообщением об ошибке + """ + try: + activity_data = await self.lichess_client.get_user_activity(username) + if not activity_data: + return ActivityResponse( + message=f"Пользователь {username} не найден или неактивен" + ) + + today = date.today() + + # Ищем активность за сегодня + today_activity = None + for activity in activity_data: + activity_date = self._parse_lichess_interval(activity['interval']) + if activity_date == today: + today_activity = activity + break + + if not today_activity: + return ActivityResponse( + message=f"Активности за сегодняшний день ({today}) не было" + ) + + # Обрабатываем данные + games_stats = self._process_games_by_mode(today_activity.get('games', {})) + tasks_stats = self._process_tasks(today_activity.get('puzzles', {})) + + user_stats = UserStats( + username=username, + tasks=tasks_stats, + games=GamesStats(**games_stats) + ) + + return ActivityResponse( + message="Статистика за сегодняшний день", + data=user_stats + ) + + except Exception as e: + logger.error(f"Ошибка при получении статистики за сегодня: {e}") + return ActivityResponse( + message=f"Ошибка при получении статистики: {str(e)}" + ) + + async def get_yesterday_stats(self, username: str) -> ActivityResponse: + """ + Получает статистику за вчерашний день. + + Анализирует активность пользователя и возвращает статистику игр и задач + за вчерашний день. + + Args: + username: Имя пользователя на Lichess + + Returns: + ActivityResponse с данными статистики или сообщением об ошибке + """ + try: + activity_data = await self.lichess_client.get_user_activity(username) + if not activity_data: + return ActivityResponse( + message=f"Пользователь {username} не найден или неактивен" + ) + + yesterday = date.today() - timedelta(days=1) + + # Ищем активность за вчера + yesterday_activity = None + for activity in activity_data: + activity_date = self._parse_lichess_interval(activity['interval']) + if activity_date == yesterday: + yesterday_activity = activity + break + + if not yesterday_activity: + return ActivityResponse( + message=f"Активности за вчерашний день ({yesterday}) не было" + ) + + # Обрабатываем данные + games_stats = self._process_games_by_mode(yesterday_activity.get('games', {})) + tasks_stats = self._process_tasks(yesterday_activity.get('puzzles', {})) + + user_stats = UserStats( + username=username, + tasks=tasks_stats, + games=GamesStats(**games_stats) + ) + + return ActivityResponse( + message="Статистика за вчерашний день", + data=user_stats + ) + + except Exception as e: + logger.error(f"Ошибка при получении статистики за вчера: {e}") + return ActivityResponse( + message=f"Ошибка при получении статистики: {str(e)}" + ) + + async def get_week_stats(self, username: str) -> ActivityResponse: + """ + Получает статистику за последние 7 дней. + + Анализирует активность пользователя и возвращает агрегированную статистику + игр и задач за последние 7 дней. + + Args: + username: Имя пользователя на Lichess + + Returns: + ActivityResponse с данными статистики или сообщением об ошибке + """ + try: + activity_data = await self.lichess_client.get_user_activity(username) + if not activity_data: + return ActivityResponse( + message=f"Пользователь {username} не найден или неактивен" + ) + + today = date.today() + week_activities = [] + + # Фильтруем активности за последние 7 дней + for activity in activity_data: + activity_date = self._parse_lichess_interval(activity['interval']) + if self._is_date_in_range(activity_date, activity_date, 7): + week_activities.append(activity) + + if not week_activities: + return ActivityResponse( + message="Активности за последние 7 дней не было" + ) + + # Объединяем все игры и задачи за неделю + combined_games = {} + combined_puzzles = {} + + for activity in week_activities: + # Суммируем игры по режимам + for mode, mode_data in activity.get('games', {}).items(): + if mode not in combined_games: + combined_games[mode] = { + 'win': 0, 'loss': 0, 'draw': 0, + 'rating_change': 0, # Суммируем изменения рейтинга + 'final_rating': 0 # Берем последний рейтинг + } + + combined_games[mode]['win'] += mode_data.get('win', 0) + combined_games[mode]['loss'] += mode_data.get('loss', 0) + combined_games[mode]['draw'] += mode_data.get('draw', 0) + + # Суммируем изменения рейтинга (delta = after - before) + rp = mode_data.get('rp', {}) + before = rp.get('before', 0) + after = rp.get('after', 0) + delta = after - before + combined_games[mode]['rating_change'] += delta + + # Для финального рейтинга берем последнее значение + combined_games[mode]['final_rating'] = after + + # Суммируем задачи + puzzles_score = activity.get('puzzles', {}).get('score', {}) + if not combined_puzzles: + combined_puzzles = {'score': {'win': 0, 'loss': 0, 'draw': 0}} + + combined_puzzles['score']['win'] += puzzles_score.get('win', 0) + combined_puzzles['score']['loss'] += puzzles_score.get('loss', 0) + combined_puzzles['score']['draw'] += puzzles_score.get('draw', 0) + + # Обрабатываем данные + games_stats = self._process_games_by_mode(combined_games) + tasks_stats = self._process_tasks(combined_puzzles) + + user_stats = UserStats( + username=username, + tasks=tasks_stats, + games=GamesStats(**games_stats) + ) + + return ActivityResponse( + message="Статистика за последние 7 дней", + data=user_stats + ) + + except Exception as e: + logger.error(f"Ошибка при получении статистики за неделю: {e}") + return ActivityResponse( + message=f"Ошибка при получении статистики: {str(e)}" + ) + + def _determine_game_result(self, game: Dict[str, Any], username: str) -> str: + """ + Определяет результат игры для указанного пользователя + + Returns: + 'win', 'loss', 'draw' или 'unknown' + """ + winner = game.get('winner') + players = game.get('players', {}) + + # Определяем цвет игрока + user_color = None + if players.get('white', {}).get('user', {}).get('name') == username: + user_color = 'white' + elif players.get('black', {}).get('user', {}).get('name') == username: + user_color = 'black' + + if user_color is None: + return 'unknown' + + # Определяем результат + if winner is None: + return 'draw' + elif winner == user_color: + return 'win' + else: + return 'loss' + + def _get_rating_change(self, game: Dict[str, Any], username: str) -> int: + """ + Получает изменение рейтинга для указанного пользователя + """ + players = game.get('players', {}) + + # Определяем цвет игрока + user_color = None + if players.get('white', {}).get('user', {}).get('name') == username: + user_color = 'white' + elif players.get('black', {}).get('user', {}).get('name') == username: + user_color = 'black' + + if user_color is None: + return 0 + + # Получаем изменение рейтинга + rating_diff = players.get(user_color, {}).get('ratingDiff') + return rating_diff if rating_diff is not None else 0 + + def _get_rating_info(self, game: Dict[str, Any], username: str) -> tuple[int, int]: + """ + Получает изменение рейтинга и итоговый рейтинг для указанного пользователя + + Returns: + tuple: (rating_change, final_rating) + """ + players = game.get('players', {}) + + # Определяем цвет игрока (без учета регистра) + user_color = None + white_user = players.get('white', {}).get('user', {}) + black_user = players.get('black', {}).get('user', {}) + + if white_user.get('name', '').lower() == username.lower(): + user_color = 'white' + elif black_user.get('name', '').lower() == username.lower(): + user_color = 'black' + + if user_color is None: + return 0, 0 + + # Получаем рейтинг до партии и изменение рейтинга + player_data = players.get(user_color, {}) + rating_before = player_data.get('rating', 0) + rating_diff = player_data.get('ratingDiff', 0) + + # Вычисляем итоговый рейтинг: rating + ratingDiff + final_rating = rating_before + rating_diff + + return rating_diff, final_rating + + def _process_games_of_period(self, games: List[Dict[str, Any]], username: str) -> GamesOfPeriodStats: + """ + Обрабатывает игры за период и возвращает статистику + """ + # Инициализируем статистику для всех типов игр + stats = { + 'bullet': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None}, + 'blitz': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None}, + 'rapid': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None}, + 'classical': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None}, + 'correspondence': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None}, + 'total': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None} + } + + # Сортируем игры по времени создания (от старых к новым) для правильного вычисления итогового рейтинга + sorted_games = sorted(games, key=lambda x: x.get('createdAt', 0)) + + for game in sorted_games: + speed = game.get('speed', 'unknown') + + # Пропускаем неизвестные типы игр + if speed not in stats: + continue + + # Определяем результат игры + result = self._determine_game_result(game, username) + if result == 'unknown': + continue + + # Получаем изменение рейтинга и итоговый рейтинг + rating_change, final_rating = self._get_rating_info(game, username) + + # Обновляем статистику для конкретного типа + stats[speed]['games_played'] += 1 + if result == 'win': + stats[speed]['wins'] += 1 + elif result == 'loss': + stats[speed]['losses'] += 1 + elif result == 'draw': + stats[speed]['draws'] += 1 + stats[speed]['rating_change'] += rating_change + # Сохраняем итоговый рейтинг после последней игры + if final_rating is not None: + stats[speed]['rating'] = final_rating + + # Обновляем общую статистику + stats['total']['games_played'] += 1 + if result == 'win': + stats['total']['wins'] += 1 + elif result == 'loss': + stats['total']['losses'] += 1 + elif result == 'draw': + stats['total']['draws'] += 1 + stats['total']['rating_change'] += rating_change + # Для общей статистики берем рейтинг из последней игры (любого типа) + if final_rating is not None: + stats['total']['rating'] = final_rating + + # Создаем объекты GameStats, устанавливая rating только для режимов с играми + def create_game_stats(mode_stats): + # Устанавливаем rating только если были игры + if mode_stats['games_played'] > 0 and mode_stats['rating'] is not None: + return GameStats(**mode_stats) + else: + # Убираем rating для режимов без игр + mode_stats_copy = mode_stats.copy() + mode_stats_copy['rating'] = None + return GameStats(**mode_stats_copy) + + return GamesOfPeriodStats( + bullet=create_game_stats(stats['bullet']), + blitz=create_game_stats(stats['blitz']), + rapid=create_game_stats(stats['rapid']), + classical=create_game_stats(stats['classical']), + correspondence=create_game_stats(stats['correspondence']), + total=create_game_stats(stats['total']) + ) + + async def get_games_of_period(self, username: str, since_timestamp: int, until_timestamp: int, rated_only: bool = True) -> GamesOfPeriodResponse: + """ + Получает статистику игр пользователя за определенный период. + + Получает игры от Lichess API за указанный период, обрабатывает их + и возвращает агрегированную статистику по режимам игр. + + Args: + username: Имя пользователя на Lichess + since_timestamp: Начало периода (Unix timestamp в секундах) + until_timestamp: Конец периода (Unix timestamp в секундах) + rated_only: Только рейтинговые игры (по умолчанию True) + + Returns: + GamesOfPeriodResponse с статистикой игр + """ + try: + # Конвертируем timestamp в миллисекунды для API Lichess + since_ms = since_timestamp * 1000 + until_ms = until_timestamp * 1000 + + # Получаем игры + games = await self.lichess_client.get_games_of_period(username, since_ms, until_ms, rated_only) + + if games is None: + return GamesOfPeriodResponse( + message=f"Пользователь {username} не найден", + username=username, + period_start=since_timestamp, + period_end=until_timestamp, + games_count=0 + ) + + if not games: + return GamesOfPeriodResponse( + message=f"Игры за указанный период не найдены", + username=username, + period_start=since_timestamp, + period_end=until_timestamp, + games_count=0 + ) + + # Обрабатываем игры + games_stats = self._process_games_of_period(games, username) + + return GamesOfPeriodResponse( + message="Статистика игр за период", + username=username, + period_start=since_timestamp, + period_end=until_timestamp, + games_count=len(games), + data=games_stats + ) + + except Exception as e: + logger.error(f"Ошибка при получении статистики игр за период: {e}") + return GamesOfPeriodResponse( + message=f"Ошибка при получении статистики: {str(e)}", + username=username, + period_start=since_timestamp, + period_end=until_timestamp, + games_count=0 + ) + + def _process_puzzle_activities(self, activities: List[Dict[str, Any]], since_ms: int, until_ms: int) -> PuzzleStats: + """ + Обрабатывает активности по задачам и возвращает статистику за период + """ + puzzles_in_period = [] + + for i, activity in enumerate(activities): + # Lichess API использует поле 'date' вместо 'createdAt' + created_at = activity.get('date') + if created_at is None: + if i < 3: # Логируем только первые 3 + logger.warning(f"Активность {i} не имеет date: {list(activity.keys())}") + continue + + # Логируем первую активность для отладки + if i == 0: + logger.info(f"Первая активность: date={created_at}, since={since_ms}, until={until_ms}") + + # Фильтруем по периоду [since_ms, until_ms) + if since_ms <= created_at < until_ms: + puzzles_in_period.append(activity) + + logger.info(f"Найдено {len(puzzles_in_period)} активностей в периоде из {len(activities)}") + + # Подсчитываем статистику + total_attempts = len(puzzles_in_period) + solved = sum(1 for activity in puzzles_in_period if activity.get('win', False)) + failed = total_attempts - solved + success_rate = (solved / total_attempts * 100) if total_attempts > 0 else 0.0 + + return PuzzleStats( + total_attempts=total_attempts, + solved=solved, + failed=failed, + success_rate=round(success_rate, 2) + ) + + async def get_puzzle_of_period(self, token: str, since_ms: int, until_ms: int, max_puzzles: int = 50) -> PuzzleOfPeriodResponse: + """ + Получает статистику решения задач за определенный период. + + Получает активность по решению задач от Lichess API, фильтрует по периоду + и возвращает агрегированную статистику решения задач. + + Args: + token: Bearer токен авторизации от Lichess + since_ms: Начало периода (Unix timestamp в миллисекундах) + until_ms: Конец периода (Unix timestamp в миллисекундах) + max_puzzles: Максимальное количество задач для получения (по умолчанию 50) + + Returns: + PuzzleOfPeriodResponse с статистикой решения задач + """ + try: + # Получаем активности по задачам + activities = await self.lichess_client.get_puzzle_activity(token, max_puzzles) + + if activities is None: + return PuzzleOfPeriodResponse( + message="Неверный токен авторизации или доступ запрещен", + period_start=since_ms, + period_end=until_ms, + max_puzzles=max_puzzles, + puzzles_in_period=0 + ) + + if not activities: + return PuzzleOfPeriodResponse( + message="Активности по задачам не найдены", + period_start=since_ms, + period_end=until_ms, + max_puzzles=max_puzzles, + puzzles_in_period=0 + ) + + # Обрабатываем активности + puzzle_stats = self._process_puzzle_activities(activities, since_ms, until_ms) + + return PuzzleOfPeriodResponse( + message="Статистика решения задач за период", + period_start=since_ms, + period_end=until_ms, + max_puzzles=max_puzzles, + puzzles_in_period=puzzle_stats.total_attempts, + data=puzzle_stats + ) + + except Exception as e: + logger.error(f"Ошибка при получении статистики решения задач за период: {e}") + return PuzzleOfPeriodResponse( + message=f"Ошибка при получении статистики: {str(e)}", + period_start=since_ms, + period_end=until_ms, + max_puzzles=max_puzzles, + puzzles_in_period=0 + ) + + async def close(self): + """ + Закрывает сервис статистики. + + Освобождает ресурсы и корректно закрывает HTTP клиент. + Должен вызываться при завершении работы с сервисом. + """ + await self.lichess_client.close() diff --git a/LichessWebView/.gitignore b/LichessWebView/.gitignore new file mode 100644 index 0000000..feb6dc1 --- /dev/null +++ b/LichessWebView/.gitignore @@ -0,0 +1,25 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.DS_Store + diff --git a/LichessWebView/Dockerfile b/LichessWebView/Dockerfile new file mode 100644 index 0000000..23a2103 --- /dev/null +++ b/LichessWebView/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["python", "app.py"] + diff --git a/LichessWebView/app.py b/LichessWebView/app.py new file mode 100644 index 0000000..53c5b65 --- /dev/null +++ b/LichessWebView/app.py @@ -0,0 +1,116 @@ +from flask import Flask, jsonify, render_template +from flask_cors import CORS +import sqlite3 +from datetime import datetime + +app = Flask(__name__) +CORS(app) + +# Путь к базе данных бота +DB_PATH = "/app/data/lichess_bot.db" + +@app.route('/') +def index(): + """Главная страница""" + return render_template('index.html') + +@app.route('/api/users') +def get_users(): + """Получить всех пользователей""" + try: + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + + # Получаем всех пользователей + cursor.execute(''' + SELECT + tu.user_id, + tu.username, + tu.first_name, + tu.last_name, + tu.created_at, + COUNT(ug.id) as gamer_count, + SUM(CASE WHEN ug.is_active = 1 THEN 1 ELSE 0 END) as active_gamers, + SUM(CASE WHEN ug.period_minutes > 0 THEN 1 ELSE 0 END) as monitored_gamers + FROM telegram_users tu + LEFT JOIN user_gamers ug ON tu.user_id = ug.user_id + GROUP BY tu.user_id, tu.username, tu.first_name, tu.last_name, tu.created_at + ORDER BY COALESCE(tu.first_name, tu.username, '') + ''') + + rows = cursor.fetchall() + users = [] + + for row in rows: + users.append({ + 'user_id': row[0], + 'username': row[1] or '-', + 'first_name': row[2] or '-', + 'last_name': row[3], + 'created_at': row[4], + 'gamer_count': row[5], + 'active_gamers': row[6], + 'monitored_gamers': row[7] + }) + + return jsonify({ + 'success': True, + 'users': users, + 'total': len(users) + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + +@app.route('/api/users//gamers') +def get_user_gamers(user_id): + """Получить игроков конкретного пользователя""" + try: + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + + # Получаем игроков пользователя + cursor.execute(''' + SELECT + g.id, + g.username, + g.token, + ug.is_active, + ug.period_minutes, + ug.created_at + FROM user_gamers ug + JOIN gamers g ON ug.gamer_id = g.id + WHERE ug.user_id = ? + ORDER BY g.username + ''', (user_id,)) + + rows = cursor.fetchall() + gamers = [] + + for row in rows: + gamers.append({ + 'id': row[0], + 'username': row[1], + 'has_token': bool(row[2]), + 'is_active': bool(row[3]), + 'period_minutes': row[4], + 'created_at': row[5] + }) + + return jsonify({ + 'success': True, + 'gamers': gamers + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) + diff --git a/LichessWebView/docker-compose.yml b/LichessWebView/docker-compose.yml new file mode 100644 index 0000000..6bd2503 --- /dev/null +++ b/LichessWebView/docker-compose.yml @@ -0,0 +1,12 @@ +services: + web-view: + build: . + container_name: lichess-web-view + ports: + - "5000:5000" + volumes: + - ../LichessClientTG_bot/data:/app/data:ro + restart: unless-stopped + environment: + - FLASK_ENV=production + diff --git a/LichessWebView/requirements.txt b/LichessWebView/requirements.txt new file mode 100644 index 0000000..8c7ee03 --- /dev/null +++ b/LichessWebView/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.0 +flask-cors==4.0.0 + diff --git a/LichessWebView/templates/index.html b/LichessWebView/templates/index.html new file mode 100644 index 0000000..34a6d0e --- /dev/null +++ b/LichessWebView/templates/index.html @@ -0,0 +1,440 @@ + + + + + + Lichess Bot Users Monitor + + + +
+ +
+

👥 Пользователи

+ +
+ Всего пользователей: 0 +
+ + + +
+
Загрузка...
+
+
+ + +
+

🎮 Отслеживаемые игроки

+ + + +
+
Выберите пользователя для просмотра его игроков
+
+
+
+ + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5964d50 --- /dev/null +++ b/README.md @@ -0,0 +1,240 @@ +# Lichess Statistics Ecosystem + +Полнофункциональная система для отслеживания статистики игроков Lichess.org с Telegram ботом и веб-интерфейсом. + +## 🎯 Описание проекта + +Система состоит из трех взаимосвязанных компонентов: + +1. **LichessWebServices** - REST API для получения статистики игроков Lichess +2. **LichessClientTG_bot** - Telegram бот для управления подписками и уведомлений +3. **LichessWebView** - Веб-интерфейс для просмотра пользователей и их игроков + +## 🏗️ Архитектура + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Telegram Users │ +│ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ LichessClientTG_bot │ │ +│ │ (Управление подписками, уведомления) │ │ +│ └────────┬───────────────────────────────────────────┘ │ +│ │ │ +│ ├──────────────────────┐ │ +│ │ │ │ +│ ┌────────▼──────────┐ ┌──────▼──────────────┐ │ +│ │ LichessWebServices │ │ LichessWebView │ │ +│ │ │ │ │ │ +│ │ REST API для │ │ Веб-интерфейс │ │ +│ │ получения │ │ для просмотра │ │ +│ │ статистики │ │ пользователей │ │ +│ └────────┬───────────┘ └────────────────────┘ │ +│ │ │ +└───────────┼──────────────────────────────────────────────┘ + │ + ▼ + ┌───────────────┐ + │ Lichess API │ + └───────────────┘ +``` + +## 🚀 Быстрый старт + +### Установка и запуск + +```bash +# Клонируем репозиторий +git clone https://github.com/vrubelroman/LichessStatTgWeb.git +cd LichessStatTgWeb + +# Запускаем все сервисы +./start.sh +``` + +Скрипт `start.sh` запустит все контейнеры в правильном порядке. + +### Доступные сервисы + +После запуска доступны: + +- **API документация**: http://localhost:8001/docs +- **Веб-интерфейс**: http://localhost:5000 +- **Telegram бот**: работает в фоне + +## 📦 Структура проекта + +``` +LichessStatTgWeb/ +├── LichessWebServices/ # REST API сервис +│ ├── main.py # FastAPI приложение +│ ├── stats_service.py # Логика обработки статистики +│ ├── lichess_client.py # Клиент для Lichess API +│ └── models.py # Pydantic модели +│ +├── LichessClientTG_bot/ # Telegram бот +│ ├── bot.py # Основная логика бота +│ ├── database.py # Работа с БД +│ ├── lichess_api.py # API клиент +│ ├── formatters.py # Форматирование ответов +│ └── config.py # Конфигурация +│ +├── LichessWebView/ # Веб-интерфейс +│ ├── app.py # Flask приложение +│ └── templates/ # HTML шаблоны +│ └── index.html # Главная страница +│ +├── docker-compose.yml # Общая конфигурация контейнеров +├── start.sh # Скрипт запуска всех сервисов +└── README.md # Этот файл +``` + +## 🔧 Компоненты + +### 1. LichessWebServices (API) + +REST API для получения статистики игроков Lichess. + +**Возможности:** +- Статистика за сегодня/вчера/неделю +- Статистика игр по режимам (Bullet, Blitz, Rapid) +- Статистика решения задач (puzzles) +- Получение игр за произвольный период + +**Endpoints:** +- `GET /stats/{username}/today` - статистика за сегодня +- `GET /stats/{username}/yesterday` - статистика за вчера +- `GET /stats/{username}/week` - статистика за неделю +- `GET /games/{username}/period` - игры за период +- `GET /puzzle/period` - задачи за период (требует токен) + +### 2. LichessClientTG_bot (Telegram бот) + +Telegram бот для управления отслеживанием игроков. + +**Возможности:** +- Добавление игроков для отслеживания +- Выбор активного игрока +- Получение статистики (сегодня/вчера/неделя) +- Настройка периодических уведомлений +- Каждый пользователь имеет свой набор игроков + +**Команды:** +- `/start` - начало работы с ботом +- `/adduser` - добавить игрока Lichess +- `/getgamers` - выбрать активного игрока +- `/today` - статистика за сегодня +- `/yesterday` - статистика за вчера +- `/week` - статистика за неделю +- `/setperiod` - настроить уведомления + +### 3. LichessWebView (Веб-интерфейс) + +Веб-интерфейс для просмотра пользователей и их игроков. + +**Возможности:** +- Просмотр всех пользователей бота +- Фильтрация пользователей по имени/никнейму +- Отображение игроков каждого пользователя +- Статистика по каждому пользователю +- Отображение активных игроков и периодов + +## 🗄️ База данных + +Система использует SQLite базу данных с таблицами: + +- `telegram_users` - пользователи Telegram +- `gamers` - игроки Lichess +- `user_gamers` - связь пользователей с игроками и настройки + +**Структура:** +- Каждый пользователь видит только своих игроков +- У каждого пользователя свой активный игрок +- Период отслеживания привязывается к паре пользователь-игрок + +## 🐳 Docker + +Все компоненты запускаются в Docker контейнерах: + +```bash +# Запуск всех сервисов +docker-compose up -d + +# Просмотр логов +docker-compose logs -f + +# Остановка всех сервисов +docker-compose down +``` + +## 🔑 Конфигурация + +### Telegram бот + +Токен бота настраивается в `LichessClientTG_bot/config.py`: +```python +TELEGRAM_BOT_TOKEN = "YOUR_TOKEN_HERE" +``` + +### Lichess API + +Для получения статистики по задачам нужен токен Lichess: +1. Зайдите на https://lichess.org/account/oauth/token/create +2. Создайте токен с правами на чтение +3. Используйте токен при добавлении игрока в боте + +## 📊 API документация + +Полная документация API доступна по адресу: +http://localhost:8001/docs + +Включает: +- Swagger UI для интерактивного тестирования +- Описание всех endpoints +- Примеры запросов и ответов + +## 🛠️ Разработка + +### Локальная разработка + +```bash +# API сервис +cd LichessWebServices +docker-compose up -d + +# Telegram бот +cd LichessClientTG_bot +docker-compose up -d + +# Веб-интерфейс +cd LichessWebView +docker-compose up -d +``` + +### Логи + +```bash +# Логи API +docker logs lichesswebservices_lichess-api_1 -f + +# Логи бота +docker logs lichess-telegram-bot -f + +# Логи веб-интерфейса +docker logs lichess-web-view -f +``` + +## 📝 Лицензия + +MIT + +## 👤 Автор + +Roman Vrubel + +## 🔗 Полезные ссылки + +- [Lichess API Documentation](https://lichess.org/api) +- [Telegram Bot API](https://core.telegram.org/bots/api) +- [FastAPI Documentation](https://fastapi.tiangolo.com/) + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a69c1ca --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +services: + # Lichess Web Services (API) + lichess-api: + build: ./LichessWebServices + container_name: lichess-api + ports: + - "8001:8000" + environment: + - PYTHONUNBUFFERED=1 + volumes: + - ./LichessWebServices:/app + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + # Telegram Bot + lichess-bot: + build: ./LichessClientTG_bot + container_name: lichess-telegram-bot + volumes: + - ./LichessClientTG_bot/data:/app/data + environment: + - PYTHONPATH=/app + - PYTHONUNBUFFERED=1 + network_mode: "host" + restart: unless-stopped + depends_on: + - lichess-api + healthcheck: + test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8001/health', timeout=5)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # Web View Interface + web-view: + build: ./LichessWebView + container_name: lichess-web-view + ports: + - "5000:5000" + volumes: + - ./LichessClientTG_bot/data:/app/data:ro + restart: unless-stopped + depends_on: + - lichess-bot + +networks: + default: + name: lichess-network + diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..c69db42 --- /dev/null +++ b/start.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +echo "🚀 Запуск Lichess Statistics Ecosystem..." +echo "" + +# Проверяем Docker +if ! command -v docker &> /dev/null; then + echo "❌ Docker не установлен. Установите Docker и попробуйте снова." + exit 1 +fi + +if ! command -v docker-compose &> /dev/null; then + echo "❌ Docker Compose не установлен. Установите Docker Compose и попробуйте снова." + exit 1 +fi + +# Останавливаем существующие контейнеры +echo "🛑 Остановка существующих контейнеров..." +docker-compose down + +# Пересобираем образы +echo "🔨 Пересборка Docker образов..." +docker-compose build + +# Запускаем контейнеры +echo "🚀 Запуск контейнеров..." +docker-compose up -d + +# Ждем запуска +echo "⏳ Ожидание запуска сервисов..." +sleep 5 + +# Проверяем статус +echo "" +echo "📊 Статус контейнеров:" +docker-compose ps + +echo "" +echo "✅ Все сервисы запущены!" +echo "" +echo "🌐 Доступные сервисы:" +echo " - API документация: http://localhost:8001/docs" +echo " - Веб-интерфейс: http://localhost:5000" +echo "" +echo "📋 Для просмотра логов используйте:" +echo " docker-compose logs -f" +echo "" +echo "🛑 Для остановки используйте:" +echo " docker-compose down" +