LichessStatTgWeb/LichessClientTG_bot/bot.py

705 lines
33 KiB
Python
Raw Normal View History

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.DEBUG
)
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.\n"
"Он показывает рейтинги, дневную, вчерашнюю и недельную активность.\n"
"При указании user_token (с правом чтения задач) можно также получать данные по задачам.\n"
"Бот поддерживает автоматические проверки с заданным интервалом и отправляет отчёт, если за это время была активность.\n\n"
"Пример за неделю:\n"
"🧩 Задачи: 114 (✅ 81 - ❌ 33)\n\n"
"🔥 Blitz — 5 игр • 🔴 -10\n"
"Рейтинг: 2245\n"
"✅ Победы: 1\n"
"❌ Поражения: 3\n"
"🤝 Ничьи: 1\n\n"
"🐇 Rapid — 19 игр • 🟢 +20\n"
"Рейтинг: 2248\n"
"✅ Победы: 8\n"
"❌ Поражения: 4\n"
"🤝 Ничьи: 7\n\n"
"📋 Доступные команды:\n"
"/addgamer - Добавить игрока Lichess для отслеживания (только username)\n"
"/addtoken - Добавить игрока с токеном для получения данных по задачам\n"
"/getgamers - Выбрать активного игрока (для которого будут работать команды /today и тд)\n"
"/delgamer - Удалить игрока из списка отслеживаемых\n"
"/today - Статистика за сегодня\n"
"/yesterday - Статистика за вчера\n"
"/week - Статистика за неделю\n"
"/setperiod - Настроить периодические уведомления активного игрока\n"
"(активный игрок меняется в меню команды /getgamers)"
)
async def addgamer_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Start addgamer command - simple username only"""
await update.message.reply_text("👤 Введите Lichess username игрока для отслеживания:")
return WAITING_FOR_USERNAME
async def addtoken_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Start addtoken command - token required"""
await update.message.reply_text(
"🔑 Введите Lichess API токен, чтобы получать данные по задачам.\n"
"Токен создаётся в настройках профиля — дайте ему только право puzzle:read.\n"
"После этого будет добавлен игрок, соответствующий этому токену."
)
return WAITING_FOR_TOKEN
async def handle_token(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle token input for /addtoken"""
token = update.message.text.strip()
user_id = update.effective_user.id
# Get username from token
profile = await self.lichess_api.get_user_profile(token)
if profile:
username = profile.get('username')
if username:
# Check if this gamer is already tracked by this user
user_gamers = self.db.get_user_gamers(user_id)
existing_gamer = next((g for g in user_gamers if g['username'] == username), None)
if existing_gamer:
# Update token for existing gamer
self.db.add_user_gamer(user_id, existing_gamer['id'], token)
await update.message.reply_text(
f"✅ Токен добавлен для игрока {username}!"
)
else:
# Add new gamer and link with token
gamer_id = self.db.add_gamer(username)
self.db.add_user_gamer(user_id, gamer_id, token)
# 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 из токена. Попробуйте еще раз."
)
return WAITING_FOR_TOKEN
else:
await update.message.reply_text(
"❌ Неверный токен. Попробуйте еще раз."
)
return WAITING_FOR_TOKEN
async def handle_username(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle username input for /addgamer"""
username = update.message.text.strip()
user_id = update.effective_user.id
if username:
# Add gamer to database (without token)
gamer_id = self.db.add_gamer(username)
# Link user to gamer (without token)
self.db.add_user_gamer(user_id, gamer_id, None)
# 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)
logger.info(f"getgamers: user_id={user_id}, found {len(gamers)} gamers")
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 i, gamer in enumerate(gamers):
try:
logger.info(f"Processing gamer {i+1}/{len(gamers)}: {gamer['username']} (ID: {gamer['id']})")
status = "🟢" if gamer['is_active'] else ""
username = gamer['username']
# Get user ratings from Lichess API
logger.debug(f"Requesting ratings for {username}")
ratings_data = await self.lichess_api.get_user_ratings(username)
logger.debug(f"Received ratings for {username}: {ratings_data is not None}")
2025-10-29 00:59:36 +03:00
# Add delay between requests to avoid rate limiting
if i < len(gamers) - 1: # Don't sleep after the last one
await asyncio.sleep(0.5) # 500ms delay between requests
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
})
logger.info(f"Successfully added gamer {username} to gamers_data (total: {len(gamers_data)})")
except Exception as e:
logger.error(f"Error processing gamer {gamer.get('username', 'unknown')}: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
# Still add the gamer with N/A ratings
gamers_data.append({
'id': gamer['id'],
'status': "🟢" if gamer['is_active'] else "",
'username': gamer['username'],
'bullet': 'N/A',
'blitz': 'N/A',
'rapid': 'N/A',
'period': f" · {gamer.get('period_minutes', 0)}м" if gamer.get('period_minutes', 0) > 0 else ""
})
logger.info(f"Added gamer {gamer['username']} with N/A ratings due to error")
# 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']}"
)
logger.info(f"getgamers: prepared {len(gamers_data)} gamers for display")
gamers_text = "👥 <b>Выберите активного игрока:</b>\n\n" + "\n".join(text_lines)
logger.info(f"getgamers: message length: {len(gamers_text)} characters")
# 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']}"
)])
logger.debug(f"Added button for {gamer['username']} (ID: {gamer['id']})")
logger.info(f"getgamers: created {len(keyboard)} buttons in keyboard")
reply_markup = InlineKeyboardMarkup(keyboard)
# Edit the loading message with the results
try:
await loading_msg.edit_text(
gamers_text,
parse_mode='HTML',
reply_markup=reply_markup
)
except Exception as e:
logger.error(f"Error editing message: {e}")
# If edit fails, delete the loading message and send a new one
try:
await loading_msg.delete()
except:
pass
await update.message.reply_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 delgamer(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Show gamers list for deletion"""
user_id = update.effective_user.id
gamers = self.db.get_user_gamers(user_id)
if not gamers:
await update.message.reply_text("📭 У вас нет отслеживаемых игроков.")
return
# Show loading message
loading_msg = await update.message.reply_text("🔄 Загружаем список игроков...")
# Create text message with stats
text_lines = []
keyboard = []
2025-10-29 00:59:36 +03:00
for i, gamer in enumerate(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)
2025-10-29 00:59:36 +03:00
# Add delay between requests to avoid rate limiting
if i < len(gamers) - 1: # Don't sleep after the last one
await asyncio.sleep(0.5) # 500ms delay between requests
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'
period_minutes = gamer.get('period_minutes', 0)
period_text = f" · {period_minutes}м" if period_minutes > 0 else ""
text_lines.append(
f"{status} <b>{username}</b> "
f"{bullet_rating} 🔥 {blitz_rating} 🐇 {rapid_rating}{period_text}"
)
# Add delete button
keyboard.append([InlineKeyboardButton(
text=f"🗑️ {username}",
callback_data=f"delete_{gamer['id']}"
)])
gamers_text = "🗑️ <b>Выберите игрока для удаления:</b>\n\n" + "\n".join(text_lines)
reply_markup = InlineKeyboardMarkup(keyboard)
# Edit the loading message with the results
try:
await loading_msg.edit_text(
gamers_text,
parse_mode='HTML',
reply_markup=reply_markup
)
except Exception as e:
logger.error(f"Error editing message: {e}")
try:
await loading_msg.delete()
except:
pass
await update.message.reply_text(
gamers_text,
parse_mode='HTML',
reply_markup=reply_markup
)
async def handle_delete_gamer(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle gamer deletion"""
query = update.callback_query
await query.answer()
user_id = query.from_user.id
gamer_id = int(query.data.split('_')[1])
# Get gamer info before deletion
gamers = self.db.get_user_gamers(user_id)
gamer_to_delete = next((g for g in gamers if g['id'] == gamer_id), None)
if gamer_to_delete:
username = gamer_to_delete['username']
deleted = self.db.remove_user_gamer(user_id, gamer_id)
if deleted:
await query.edit_message_text(
f"✅ Игрок <b>{username}</b> удален из списка отслеживаемых.",
parse_mode='HTML'
)
else:
await query.edit_message_text("Не удалось удалить игрока")
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 addgamer (simple username only)
addgamer_conv = ConversationHandler(
entry_points=[CommandHandler("addgamer", self.addgamer_start)],
states={
WAITING_FOR_USERNAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_username)],
},
fallbacks=[CommandHandler("cancel", lambda u, c: ConversationHandler.END)]
)
# Conversation handler for addtoken (token required)
addtoken_conv = ConversationHandler(
entry_points=[CommandHandler("addtoken", self.addtoken_start)],
states={
WAITING_FOR_TOKEN: [MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_token)],
},
fallbacks=[CommandHandler("cancel", lambda u, c: ConversationHandler.END)]
)
# Add all handlers
application.add_handler(CommandHandler("start", self.start))
application.add_handler(addgamer_conv)
application.add_handler(addtoken_conv)
application.add_handler(CommandHandler("getgamers", self.getgamers))
application.add_handler(CommandHandler("delgamer", self.delgamer))
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.handle_delete_gamer, pattern="^delete_"))
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()