Создание единого проекта Lichess Statistics Ecosystem

- Объединены три проекта в один репозиторий
- LichessWebServices - REST API для статистики
- LichessClientTG_bot - Telegram бот с поддержкой множества пользователей
- LichessWebView - Веб-интерфейс для просмотра пользователей и игроков
- Добавлен общий docker-compose.yml для запуска всех сервисов
- Добавлен скрипт start.sh для удобного запуска
- Добавлен README с полным описанием проекта
This commit is contained in:
vrubelroman 2025-10-26 20:23:26 +03:00
commit a08fc8c962
32 changed files with 4990 additions and 0 deletions

54
LichessClientTG_bot/.gitignore vendored Normal file
View file

@ -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

View file

@ -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"]

View file

@ -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. Логи контейнера на наличие ошибок

520
LichessClientTG_bot/bot.py Normal file
View file

@ -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']} <b>{gamer['username']}</b> "
f"{gamer['bullet']} 🔥 {gamer['blitz']} 🐇 {gamer['rapid']}{gamer['period']}"
)
gamers_text = "👥 <b>Выберите активного игрока:</b>\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()

View file

@ -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"]

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -0,0 +1,4 @@
python-telegram-bot==20.7
requests==2.31.0
aiohttp==3.9.1
python-dotenv==1.0.0

41
LichessClientTG_bot/run.sh Executable file
View file

@ -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."

View file

@ -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()