LichessStatTgWeb/LichessClientTG_bot/bot.py
2026-03-21 22:58:47 +03:00

1795 lines
84 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import logging
import sqlite3
import os
from datetime import datetime, timedelta
from typing import Dict, Any, Optional
from pathlib import Path
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, ADMINPANEL_TELEGRAM_BOT_TOKEN
)
from version import BOT_VERSION
from database import Database
from lichess_api import LichessAPI
from formatters import StatsFormatter
from i18n import t
from admin_bot import get_admin_bot, init_admin_bot
from message_counters import MessageCounters
from request_queue import get_request_queue
import time
import aiohttp
# 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
self.counters = MessageCounters() # Message counters
self.request_queue = get_request_queue() # Request queue for rate limiting
async def stop_periodic_task(self, gamer_id: int, user_id: int):
"""Stop periodic task for a user-gamer pair."""
task_key = f"{gamer_id}_{user_id}"
task = self.periodic_tasks.pop(task_key, None)
if task:
task.cancel()
logger.info(f"Cancelled periodic task for gamer_id={gamer_id}, user_id={user_id}")
self.period_start_times.pop(task_key, None)
async def _notify_admin_new_player(self, player_username: str, added_by_user_id: int, added_by_username: Optional[str], is_new_gamer: bool = False):
"""Notify admin about newly linked player (always try to send)."""
try:
admin_bot = get_admin_bot()
if admin_bot:
logger.info("Sending admin notification via admin_bot instance")
await admin_bot.notify_new_player(
player_username=player_username,
added_by_user_id=added_by_user_id,
added_by_username=added_by_username,
is_new_gamer=is_new_gamer
)
return
except Exception as e:
logger.warning(f"notify_new_player via admin_bot failed: {e}")
# Fallback: direct API call using admin bot token and chat id from DB
try:
admin_chat_id = self.db.get_admin_chat_id()
if not admin_chat_id:
logger.warning("Admin chat id is not set; cannot send admin notification.")
return
url = f"https://api.telegram.org/bot{ADMINPANEL_TELEGRAM_BOT_TOKEN}/sendMessage"
added_by_text = f"@{added_by_username}" if added_by_username else f"ID: {added_by_user_id}"
lichess_url = f"https://lichess.org/@/{player_username}"
message = (
f"🎮 <b>Добавлен новый игрок для отслеживания</b>\n\n"
f"Игрок: <a href=\"{lichess_url}\">{player_username}</a>\n"
f"Добавил: {added_by_text}"
)
logger.info(f"Sending admin notification via direct API to chat_id={admin_chat_id}")
async with aiohttp.ClientSession() as session:
async with session.post(url, json={
"chat_id": admin_chat_id,
"text": message,
"parse_mode": "HTML"
}) as response:
if response.status != 200:
error_text = await response.text()
logger.error(f"Failed to send admin notification (fallback): {response.status} - {error_text}")
else:
logger.info("Admin notification sent successfully via direct API")
except Exception as e:
logger.error(f"Fallback admin notification failed: {e}")
async def _notify_admin_new_user(self, user_id: int, username: Optional[str], first_name: Optional[str]):
"""Notify admin about new Telegram user (fallback method)."""
try:
admin_chat_id = self.db.get_admin_chat_id()
if not admin_chat_id:
logger.warning("Admin chat id is not set; cannot send admin notification.")
return
url = f"https://api.telegram.org/bot{ADMINPANEL_TELEGRAM_BOT_TOKEN}/sendMessage"
username_text = f"@{username}" if username else "без username"
name_text = first_name if first_name else "без имени"
message = (
f"🆕 <b>Новый пользователь Telegram</b>\n\n"
f"ID: {user_id}\n"
f"Username: {username_text}\n"
f"Имя: {name_text}"
)
logger.info(f"Sending admin notification via direct API to chat_id={admin_chat_id}")
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.post(url, json={
"chat_id": admin_chat_id,
"text": message,
"parse_mode": "HTML"
}) as response:
if response.status == 200:
logger.info(f"Admin notification sent successfully via direct API")
else:
error_text = await response.text()
logger.error(f"Failed to send admin notification: {response.status} - {error_text}")
except Exception as e:
logger.error(f"Failed to send admin notification via API: {e}")
import traceback
logger.error(traceback.format_exc())
async def test_admin_notify(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Manual test to verify admin notifications delivery path."""
user = update.effective_user
try:
await self._notify_admin_new_player("test_player_notify", user.id, user.username if user else None)
await update.message.reply_text("✅ Admin notification test triggered.")
except Exception as e:
logger.error(f"test_admin_notify failed: {e}")
await update.message.reply_text(f"❌ Failed to trigger admin notification: {e}")
def get_user_language_from_update(self, update: Update) -> str:
"""Get user's selected bot language from database"""
user = update.effective_user
if user:
# Update user info in database (this will auto-detect language for new users)
self.db.add_or_get_telegram_user(
user_id=user.id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
language_code=user.language_code
)
# Get user's selected bot language from database
return self.db.get_user_language(user.id)
return 'en'
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()
# Get statistics
import sqlite3
with sqlite3.connect(self.db.db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM telegram_users")
total_users = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(DISTINCT username) FROM gamers")
total_gamers = cursor.fetchone()[0]
logger.info(f"📊 Statistics: {total_users} users, {total_gamers} tracked gamers")
logger.info(f"🔔 Found {len(gamers_with_periods)} user-gamer pairs with periodic notifications enabled")
if len(gamers_with_periods) == 0:
logger.warning("⚠️ No periodic notifications configured! Users need to set periods using /setperiod")
# Start request queue processor
self.request_queue._start_processor()
logger.info("✅ Request queue processor started")
for gamer in gamers_with_periods:
if gamer['period_minutes'] > 0:
user_id = gamer['user_id']
username = gamer['username']
period = gamer['period_minutes']
# Start periodic task with user_id and gamer
await self.start_periodic_task(gamer, user_id, period)
logger.info(f"✅ Started periodic task for {username} (user {user_id}) with period {period} minutes")
logger.info(f"✅ All periodic tasks started. Total: {len([g for g in gamers_with_periods if g['period_minutes'] > 0])}")
# Start daily counter reset task
asyncio.create_task(self.daily_counter_reset_task())
logger.info("✅ Started daily counter reset task")
except Exception as e:
logger.error(f"❌ Error starting existing periodic tasks: {e}")
import traceback
logger.error(traceback.format_exc())
async def daily_counter_reset_task(self):
"""Background task to reset daily counters at midnight"""
while True:
try:
# Calculate seconds until next midnight
now = datetime.now()
next_midnight = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
seconds_until_midnight = (next_midnight - now).total_seconds()
logger.info(f"Daily counter reset task: waiting {seconds_until_midnight} seconds until next midnight")
await asyncio.sleep(seconds_until_midnight)
# Reset daily counters
self.counters._reset_daily_counters_if_needed()
logger.info("Daily counters reset at midnight")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in daily counter reset task: {e}")
import traceback
logger.error(traceback.format_exc())
# Wait 1 hour before retrying
await asyncio.sleep(3600)
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Start command handler"""
logger.info(f"📝 start() method called for user {update.effective_user.id}")
# Register user in database
user = update.effective_user
lang_code = user.language_code if user else None
logger.info(f"User info: id={user.id}, username={user.username}, lang_code={lang_code}")
is_new_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,
language_code=lang_code
)
# Notify admin bot about new user
if is_new_user:
try:
admin_bot = get_admin_bot()
if admin_bot:
await admin_bot.notify_new_user(
user_id=user.id,
username=user.username,
first_name=user.first_name
)
else:
# Fallback: direct API call
await self._notify_admin_new_user(user.id, user.username, user.first_name)
except Exception as e:
logger.error(f"Failed to notify admin about new user: {e}")
import traceback
logger.error(traceback.format_exc())
# Try fallback
try:
await self._notify_admin_new_user(user.id, user.username, user.first_name)
except Exception as e2:
logger.error(f"Fallback notification also failed: {e2}")
lang = self.get_user_language_from_update(update)
start_msg = t('start_message', lang)
await update.message.reply_text(start_msg)
self.counters.increment('start')
async def help_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Help command handler"""
lang = self.get_user_language_from_update(update)
help_msg = t('help_message', lang)
await update.message.reply_text(help_msg)
async def start_and_addgamer(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Start command that shows welcome message and starts addgamer conversation"""
try:
# Clear any existing conversation state
if context and hasattr(context, "user_data"):
context.user_data.clear()
# Run the regular start command
await self.start(update, context)
# Start addgamer conversation
await self.addgamer_start(update, context)
except Exception as e:
logger.error(f"Error in start_and_addgamer: {e}")
import traceback
logger.error(traceback.format_exc())
try:
await update.message.reply_text(f"Error: {e}")
except:
pass
async def addgamer_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Start addgamer command - show menu with options"""
user_id = update.effective_user.id
logger.info(f"addgamer_start called for user {user_id}")
lang = self.get_user_language_from_update(update)
try:
keyboard = [
[
InlineKeyboardButton(
text=t('addgamer_btn_add', lang),
callback_data="addgamer_add"
)
],
[
InlineKeyboardButton(
text=t('addgamer_btn_how', lang),
callback_data="addgamer_how"
)
],
]
reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text(
t('addgamer_menu', lang),
reply_markup=reply_markup
)
logger.info(f"Addgamer menu sent to user {user_id}")
except Exception as e:
logger.error(f"Error sending addgamer menu: {e}")
import traceback
logger.error(traceback.format_exc())
# No conversation state returned; handler-based flow
return
async def addgamer_show_prompt(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Callback: show username prompt after user presses 'Add player'"""
query = update.callback_query
await query.answer()
user_id = query.from_user.id
logger.info(f"addgamer_show_prompt called for user {user_id}")
# Clear previous state and mark that we're waiting for username
if context and hasattr(context, "user_data"):
context.user_data.clear()
context.user_data['awaiting_addgamer_username'] = True
# Language from DB (for callbacks)
if update.effective_user:
self.db.add_or_get_telegram_user(
user_id=update.effective_user.id,
username=update.effective_user.username,
first_name=update.effective_user.first_name,
last_name=update.effective_user.last_name,
language_code=update.effective_user.language_code
)
lang = self.db.get_user_language(user_id)
await query.message.reply_text(
t('addgamer_prompt', lang),
parse_mode='HTML'
)
logger.info(f"Addgamer prompt (from button) sent to user {user_id}")
# Count real start of username input flow
self.counters.increment('addgamer')
async def addgamer_show_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Callback: show how to find username on Lichess (with images)"""
query = update.callback_query
if not query or not query.message:
logger.error("addgamer_show_help: Invalid query or message")
return
await query.answer()
user_id = query.from_user.id
logger.info(f"addgamer_show_help called for user {user_id}")
# Language from DB (for callbacks)
if update.effective_user:
self.db.add_or_get_telegram_user(
user_id=update.effective_user.id,
username=update.effective_user.username,
first_name=update.effective_user.first_name,
last_name=update.effective_user.last_name,
language_code=update.effective_user.language_code
)
lang = self.db.get_user_language(user_id)
# Определяем имя файла картинки в зависимости от языка
# Для русской локализации - helpRU.jpeg, для английской - helpEN.jpeg
image_filename = "helpRU.jpeg" if lang == 'ru' else "helpEN.jpeg"
# Определяем путь к картинке (она находится в той же папке, что и bot.py)
bot_dir = Path(__file__).resolve().parent
image_path = bot_dir / image_filename
# Пробуем альтернативные пути, если основной не найден
possible_paths = [
image_path, # В папке бота
bot_dir.parent / image_filename, # В корне проекта
Path("/home/vrubel/PROJECTS/LichessStatTgWeb/LichessClientTG_bot") / image_filename, # Абсолютный путь
]
# Ищем существующий файл
found_path = None
for candidate in possible_paths:
if candidate and candidate.exists():
found_path = candidate
logger.info(f"✅ Found {image_filename} at: {found_path}")
break
if not found_path or not found_path.exists():
error_msg = f"❌ Error: Could not find {image_filename}\n\nChecked paths:\n"
for path in possible_paths:
exists = path.exists() if path else False
error_msg += f"{path} (exists: {exists})\n"
logger.error(error_msg)
await query.message.reply_text(f"❌ Error: Could not find image file. Please check bot logs.")
return
try:
logger.info(f"Sending help image: {found_path} (language: {lang})")
with open(found_path, "rb") as img:
await query.message.reply_photo(photo=img)
logger.info(f"Successfully sent help image to user {user_id}")
# Устанавливаем флаг ожидания username, чтобы пользователь мог ввести его после просмотра картинки
if context and hasattr(context, "user_data"):
context.user_data['awaiting_addgamer_username'] = True
# Отправляем текст с запросом username
await query.message.reply_text(
t('addgamer_prompt', lang),
parse_mode='HTML'
)
# Считаем это началом процесса добавления игрока
self.counters.increment('addgamer')
except Exception as e:
logger.error(f"Error sending lichess help images: {e}")
import traceback
logger.error(traceback.format_exc())
try:
await query.message.reply_text(f"❌ Error sending images: {e}")
except Exception as e2:
logger.error(f"Failed to send error message: {e2}")
async def addtoken_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Start addtoken command - token required"""
# Reset any existing conversation state
if context and hasattr(context, "user_data"):
context.user_data.clear()
lang = self.get_user_language_from_update(update)
await update.message.reply_text(t('addtoken_prompt', lang))
self.counters.increment('addtoken')
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
logger.info(f"Processing token for user {user_id}, token prefix: {token[:10]}...")
# Get username from token
profile = await self.lichess_api.get_user_profile(token)
logger.info(f"Profile response: {profile is not None}")
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)
lang = self.get_user_language_from_update(update)
if existing_gamer:
# Update token for existing gamer
self.db.add_user_gamer(user_id, existing_gamer['id'], token)
await update.message.reply_text(
t('token_added', lang, username=username)
)
# Always notify admin about link/added player
try:
user_obj = update.effective_user
await self._notify_admin_new_player(username, user_id, user_obj.username if user_obj else None)
except Exception as e:
logger.error(f"Admin notify failed after token update: {e}")
else:
# Add new gamer and link with token
# Check if gamer already exists
import sqlite3
with sqlite3.connect(self.db.db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT id FROM gamers WHERE username = ?", (username,))
existing_gamer = cursor.fetchone()
is_new_gamer = existing_gamer is None
gamer_id = self.db.add_gamer(username)
self.db.add_user_gamer(user_id, gamer_id, token)
# Set default period to 1 hour (60 minutes) for new gamer
self.db.set_user_gamer_period(user_id, gamer_id, 60)
# 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)
# Start periodic task for this gamer (60 minutes period)
try:
gamer_data = {
'id': gamer_id,
'username': username,
'token': token,
'period_minutes': 60
}
await self.start_periodic_task(gamer_data, user_id, 60)
logger.info(f"Started periodic task for {username} (user {user_id}) with period 60 minutes")
except Exception as e:
logger.error(f"Failed to start periodic task for {username}: {e}")
import traceback
logger.error(traceback.format_exc())
# Notify admin bot about new player (always notify on link)
try:
user_obj = update.effective_user
await self._notify_admin_new_player(
username, user_id, user_obj.username if user_obj else None, is_new_gamer
)
except Exception as e:
logger.error(f"Admin notify failed after adding gamer with token: {e}")
await update.message.reply_text(
t('gamer_added_with_token', lang, username=username)
)
return ConversationHandler.END
else:
lang = self.get_user_language_from_update(update)
await update.message.reply_text(
t('token_username_error', lang)
)
return WAITING_FOR_TOKEN
else:
lang = self.get_user_language_from_update(update)
await update.message.reply_text(
t('invalid_token', lang)
)
return WAITING_FOR_TOKEN
async def handle_username(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle username input for /addgamer"""
# Only handle if we are awaiting an addgamer username
if not (context and hasattr(context, "user_data") and context.user_data.get('awaiting_addgamer_username')):
return
username = update.message.text.strip()
user_id = update.effective_user.id
lang = self.get_user_language_from_update(update)
if not username:
await update.message.reply_text(
t('empty_username', lang)
)
return
# Check if user exists on Lichess
user_exists = await self.lichess_api.check_user_exists(username)
if not user_exists:
await update.message.reply_text(
t('user_not_found', lang, username=username) + '\n\n' + t('addgamer_prompt', lang),
parse_mode='HTML'
)
return WAITING_FOR_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'].lower() == username.lower()), None)
if existing_gamer:
# Player is already being tracked by this user
await update.message.reply_text(
t('gamer_already_added', lang, username=username) + '\n\n' + t('addgamer_prompt', lang),
parse_mode='HTML'
)
# Keep awaiting flag - don't clear it, so user can try again
return
# Add gamer to database (without token)
# Check if gamer already exists in global gamers table
import sqlite3
with sqlite3.connect(self.db.db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT id FROM gamers WHERE username = ?", (username,))
existing_gamer_row = cursor.fetchone()
is_new_gamer = existing_gamer_row is None
gamer_id = self.db.add_gamer(username)
# Link user to gamer (without token)
added = self.db.add_user_gamer(user_id, gamer_id, None)
# If add_user_gamer returned False, it means the pair already exists (shouldn't happen after our check, but just in case)
if not added:
await update.message.reply_text(
t('gamer_already_added', lang, username=username) + '\n\n' + t('addgamer_prompt', lang),
parse_mode='HTML'
)
# Keep awaiting flag - don't clear it, so user can try again
return
# Set default period to 1 hour (60 minutes) for new gamer
self.db.set_user_gamer_period(user_id, gamer_id, 60)
# 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)
# Start periodic task for this gamer (60 minutes period)
try:
gamer_data = {
'id': gamer_id,
'username': username,
'token': None,
'period_minutes': 60
}
await self.start_periodic_task(gamer_data, user_id, 60)
logger.info(f"Started periodic task for {username} (user {user_id}) with period 60 minutes")
except Exception as e:
logger.error(f"Failed to start periodic task for {username}: {e}")
import traceback
logger.error(traceback.format_exc())
# Notify admin bot about player link (always notify)
try:
user_obj = update.effective_user
await self._notify_admin_new_player(
username, user_id, user_obj.username if user_obj else None, is_new_gamer
)
logger.info(f"Admin notification processed for player {username}")
except Exception as e:
logger.error(f"Failed to notify admin about new player link: {e}")
lang = self.get_user_language_from_update(update)
await update.message.reply_text(
t('gamer_added', lang, username=username),
parse_mode='HTML'
)
# Clear awaiting flag
try:
context.user_data['awaiting_addgamer_username'] = False
except Exception:
pass
return
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")
lang = self.get_user_language_from_update(update)
if not gamers:
logger.info(f"getgamers: No gamers found for user {user_id}, sending no_gamers message")
await update.message.reply_text(t('no_gamers', lang))
self.counters.increment('getgamers')
return
logger.info(f"getgamers: Proceeding with {len(gamers)} gamers for user {user_id}")
self.counters.increment('getgamers')
# Show loading message
loading_msg = await update.message.reply_text(t('loading_ratings', lang))
# 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']})")
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}")
# 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_suffix = t('period_minutes_suffix', lang)
period_text = f" · {period_minutes}{period_suffix}" if period_minutes > 0 else ""
gamers_data.append({
'id': gamer['id'],
'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
period_minutes = gamer.get('period_minutes', 0)
period_suffix = t('period_minutes_suffix', lang)
period_text = f" · {period_minutes}{period_suffix}" if period_minutes > 0 else ""
gamers_data.append({
'id': gamer['id'],
'username': gamer['username'],
'bullet': 'N/A',
'blitz': 'N/A',
'rapid': 'N/A',
'period': period_text
})
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"<b>{gamer['username']}</b> "
f"{gamer['bullet']} 🔥 {gamer['blitz']} 🐇 {gamer['rapid']}{gamer['period']}"
)
logger.info(f"getgamers: prepared {len(gamers_data)} gamers for display")
# Check if we have any gamers to display
if not gamers_data:
logger.warning(f"getgamers: No gamers data prepared, but gamers list was not empty. This should not happen.")
try:
await loading_msg.delete()
except:
pass
await update.message.reply_text(t('no_gamers', lang))
return
gamers_text = t('select_active_gamer', lang) + "\n".join(text_lines)
logger.info(f"getgamers: message length: {len(gamers_text)} characters")
# Edit the loading message with the results (no keyboard)
try:
await loading_msg.edit_text(
gamers_text,
parse_mode='HTML'
)
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'
)
logger.info(f"getgamers: Completed successfully for user {user_id}, displayed {len(gamers_data)} gamers")
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)
# Для callback query обновляем язык пользователя если есть в update
if update.effective_user:
self.db.add_or_get_telegram_user(
user_id=update.effective_user.id,
username=update.effective_user.username,
first_name=update.effective_user.first_name,
last_name=update.effective_user.last_name,
language_code=update.effective_user.language_code
)
lang = self.db.get_user_language(user_id)
if selected_gamer:
# Set active gamer for this user
self.db.set_user_active_gamer(user_id, gamer_id)
await query.edit_message_text(
t('active_gamer_set', lang, username=selected_gamer['username'])
)
else:
await query.edit_message_text(t('gamer_not_found', lang))
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)
lang = self.get_user_language_from_update(update)
if not gamers:
await update.message.reply_text(t('no_gamers_to_delete', lang))
self.counters.increment('delgamer')
return
self.counters.increment('delgamer')
# Show loading message
loading_msg = await update.message.reply_text(t('loading_gamers', lang))
# Create text message with stats
text_lines = []
keyboard = []
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)
# 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_suffix = t('period_minutes_suffix', lang)
period_text = f" · {period_minutes}{period_suffix}" 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 = t('select_gamer_to_delete', lang) + "\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)
# Для callback query получаем язык из БД
if update.effective_user:
self.db.add_or_get_telegram_user(
user_id=update.effective_user.id,
username=update.effective_user.username,
first_name=update.effective_user.first_name,
last_name=update.effective_user.last_name,
language_code=update.effective_user.language_code
)
lang = self.db.get_user_language(user_id)
if gamer_to_delete:
username = gamer_to_delete['username']
was_active = gamer_to_delete.get('is_active', False)
total_gamers_before = len(gamers)
deleted = self.db.remove_user_gamer(user_id, gamer_id)
if deleted:
# Check how many gamers remain after deletion
remaining_gamers = self.db.get_user_gamers(user_id)
remaining_count = len(remaining_gamers)
# Determine which message to show
if remaining_count == 0:
# Last gamer deleted
message = t('last_gamer_deleted', lang, username=username)
elif was_active:
# Active gamer deleted but there are other gamers
message = t('active_gamer_deleted', lang, username=username)
else:
# Regular deletion
message = t('gamer_deleted', lang, username=username)
await query.edit_message_text(
message,
parse_mode='HTML'
)
else:
await query.edit_message_text(t('delete_failed', lang))
else:
await query.edit_message_text(t('gamer_not_found', lang))
async def get_stats(self, update: Update, context: ContextTypes.DEFAULT_TYPE, period: str):
"""Get statistics for a period - shows stats for all players with activity"""
user_id = update.effective_user.id
logger.info(f"🔍 get_stats called: user_id={user_id}, period={period}")
# Get all gamers for this user
gamers = self.db.get_user_gamers(user_id)
logger.info(f"🔍 Found {len(gamers)} gamers for user {user_id}: {[g['username'] for g in gamers]}")
lang = self.get_user_language_from_update(update)
if not gamers:
await update.message.reply_text(
t('no_gamers', lang)
)
return
# Process each gamer
has_any_activity = False
for i, gamer in enumerate(gamers):
username = gamer['username']
logger.info(f"🔍 Processing gamer {i+1}/{len(gamers)}: {username} for period {period}")
# Send message about processing this player
processing_msg = None
try:
processing_msg = await update.message.reply_text(t('stats_player_processing', lang, username=username), parse_mode='HTML')
except Exception:
pass
# Get stats based on period
try:
logger.info(f"🔍 Making API request for {username}, period={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(t('unknown_period', lang))
return
logger.info(f"🔍 API response for {username}: data={data is not None}, type={type(data)}")
if data:
logger.info(f"🔍 API response keys: {data.keys() if isinstance(data, dict) else 'not a dict'}")
if isinstance(data, dict) and 'message' in data:
logger.info(f"🔍 API message: {data.get('message')}")
except Exception as e:
logger.error(f"❌ Error getting stats for {username}: {e}")
import traceback
logger.error(traceback.format_exc())
data = None
# Delete processing message
if processing_msg:
try:
await processing_msg.delete()
except Exception:
pass
# Check if there's activity
has_activity = False
if data:
if data.get('data'):
api_data = data.get('data', {})
tasks = api_data.get('tasks', {})
games = api_data.get('games', {})
logger.info(f"🔍 Activity check for {username}: tasks={tasks}, games={games}")
# Check for puzzles activity
if tasks and tasks.get('total', 0) > 0:
has_activity = True
logger.info(f"{username} has puzzles activity: {tasks.get('total')}")
# Check for games activity
if games:
for game_type, game_data in games.items():
if game_data and game_data.get('games_played', 0) > 0:
has_activity = True
logger.info(f"{username} has {game_type} activity: {game_data.get('games_played')} games")
break
else:
# API вернул ответ, но без данных (нет активности)
message = data.get('message', 'No message')
# Filter out old "No active player" messages - this functionality is deprecated
if 'No active player' in message or 'Нет активного игрока' in message or 'active player' in message.lower() or 'активного игрока' in message.lower():
logger.info(f" API response for {username}: filtered out deprecated 'No active player' message")
message = None
else:
logger.info(f" API response for {username}: {message} (no activity data)")
else:
logger.warning(f"⚠️ No response data for {username}: data is None")
# Only send response if there's activity
if has_activity:
formatted_response = StatsFormatter.format_stats_response(data, username, period, lang)
await update.message.reply_text(formatted_response)
has_any_activity = True
else:
logger.info(f" No activity found for {username}, skipping response")
# Add delay between requests to avoid rate limiting
if i < len(gamers) - 1:
await asyncio.sleep(1.0)
# If no activity for any player
if not has_any_activity:
await update.message.reply_text(t('no_activity', lang))
# Increment counter for the period command
if period == "today":
self.counters.increment('today')
elif period == "yesterday":
self.counters.increment('yesterday')
elif period == "week":
self.counters.increment('week')
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 support(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Support command - show contact information"""
lang = self.get_user_language_from_update(update)
support_msg = t('support_message', lang, version=BOT_VERSION)
await update.message.reply_text(support_msg, parse_mode='HTML')
self.counters.increment('support')
async def last_year_or_1000games(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Get last year stats or last 1000 rated games for all players with activity"""
user_id = update.effective_user.id
# Get all gamers for this user
gamers = self.db.get_user_gamers(user_id)
lang = self.get_user_language_from_update(update)
if not gamers:
await update.message.reply_text(
t('no_gamers', lang)
)
return
now_ms = int(time.time() * 1000)
year_ms = 365 * 24 * 3600 * 1000
since_ms = now_ms - year_ms
has_any_activity = False
# Process each gamer sequentially
for i, gamer in enumerate(gamers):
username = gamer['username']
try:
# Send message about processing this player
processing_msg = None
try:
processing_msg = await update.message.reply_text(t('last_year_1000_player_processing', lang, username=username), parse_mode='HTML')
except Exception:
pass
# Get data for this player
data = await self.lichess_api.get_games_period(username, since_ms, now_ms, rated_only=True)
# Delete processing message
if processing_msg:
try:
await processing_msg.delete()
except Exception:
pass
if data:
# Check if there's activity (games_count > 0)
games_count = data.get('games_count', 0)
if games_count > 0:
# Format and send immediately
text = StatsFormatter.format_last_year_or_1000(data, username, lang)
await update.message.reply_text(text)
has_any_activity = True
# Wait 3 seconds before next request (except after the last one)
if i < len(gamers) - 1:
await asyncio.sleep(3.0)
except Exception as e:
logger.error(f"/lastYear_or_1000games error for {username}: {e}")
await update.message.reply_text(f"Error for {username}: {e}")
# If no activity for any player
if not has_any_activity:
await update.message.reply_text(t('no_activity', lang))
self.counters.increment('last_year_1000')
async def setperiod(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Set period command - first select gamer, then select period"""
user_id = update.effective_user.id
# Get all gamers for this user
gamers = self.db.get_user_gamers(user_id)
lang = self.get_user_language_from_update(update)
if not gamers:
await update.message.reply_text(t('no_gamers', lang))
self.counters.increment('setperiod')
return
# Create keyboard with gamers and their periods
keyboard = []
for gamer in gamers:
username = gamer['username']
period_minutes = gamer.get('period_minutes', 0)
# Format period text
if period_minutes == 0:
period_text = ""
elif period_minutes < 60:
period_text = f"{period_minutes}m"
elif period_minutes == 60:
period_text = "1h"
else:
hours = period_minutes // 60
period_text = f"{hours}h"
keyboard.append([InlineKeyboardButton(
text=f"{username} · {period_text}",
callback_data=f"select_gamer_period_{gamer['id']}"
)])
reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text(
"⏱️ Select player to set notification period:",
reply_markup=reply_markup
)
self.counters.increment('setperiod')
async def select_gamer_for_period(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle gamer selection for period setting"""
query = update.callback_query
await query.answer()
logger.info(f"select_gamer_for_period called with callback_data: {query.data}")
user_id = query.from_user.id
# Parse callback_data: select_gamer_period_{gamer_id}
try:
gamer_id = int(query.data.split('_')[-1])
logger.info(f"Parsed gamer_id: {gamer_id}")
except (ValueError, IndexError) as e:
logger.error(f"Error parsing gamer_id from callback_data '{query.data}': {e}")
await query.edit_message_text("❌ Error: Invalid player selection")
return
# Get gamer info
gamers = self.db.get_user_gamers(user_id)
selected_gamer = None
for gamer in gamers:
if gamer['id'] == gamer_id:
selected_gamer = gamer
break
if not selected_gamer:
await query.edit_message_text("❌ Player not found")
return
# Для callback query получаем язык из БД
if update.effective_user:
self.db.add_or_get_telegram_user(
user_id=update.effective_user.id,
username=update.effective_user.username,
first_name=update.effective_user.first_name,
last_name=update.effective_user.last_name,
language_code=update.effective_user.language_code
)
lang = self.db.get_user_language(user_id)
# Show period options for selected gamer
keyboard = []
for period in PERIOD_OPTIONS:
if period == 0:
button_text = t('disable_notifications', lang)
else:
# Format period text: minutes for < 60, hours for >= 60
if period < 60:
button_text = f"{period} minutes"
elif period == 60:
button_text = "⏰ 1 hour"
else:
hours = period // 60
button_text = f"{hours} hours"
keyboard.append([InlineKeyboardButton(
button_text,
callback_data=f"period_{gamer_id}_{period}"
)])
reply_markup = InlineKeyboardMarkup(keyboard)
await query.edit_message_text(
t('select_period', lang, username=selected_gamer['username']),
reply_markup=reply_markup,
parse_mode='HTML'
)
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
# Parse callback data: period_{gamer_id}_{period}
parts = query.data.split('_')
gamer_id = int(parts[1])
period = int(parts[2])
# Get gamer info
gamers = self.db.get_user_gamers(user_id)
selected_gamer = None
for gamer in gamers:
if gamer['id'] == gamer_id:
selected_gamer = gamer
break
if not selected_gamer:
await query.edit_message_text("❌ Player not found")
return
# Для callback query получаем язык из БД
if update.effective_user:
self.db.add_or_get_telegram_user(
user_id=update.effective_user.id,
username=update.effective_user.username,
first_name=update.effective_user.first_name,
last_name=update.effective_user.last_name,
language_code=update.effective_user.language_code
)
lang = self.db.get_user_language(user_id)
# Set period for this user-gamer pair
self.db.set_user_gamer_period(user_id, gamer_id, period)
self.db.clear_period_checkpoint(user_id, gamer_id)
if period == 0:
await self.stop_periodic_task(gamer_id, user_id)
await query.edit_message_text(
t('notifications_disabled', lang, username=selected_gamer['username'])
)
else:
# Format period text for confirmation message
if period < 60:
period_text = f"{period} minutes"
elif period == 60:
period_text = "1 hour"
else:
hours = period // 60
period_text = f"{hours} hours"
await query.edit_message_text(
f"✅ Period {period_text} set for {selected_gamer['username']}\n📱 Notifications will be sent to personal messages"
)
# Start periodic task for this gamer (send to user's personal messages)
await self.start_periodic_task(selected_gamer, user_id, period)
async def set_lang(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Show language selection menu"""
user = update.effective_user
if not user:
await update.message.reply_text("❌ Failed to get user information")
return
# Get current language to show menu in user's language
lang = self.get_user_language_from_update(update)
# Create keyboard with language buttons
keyboard = [
[
InlineKeyboardButton("🇬🇧 English", callback_data="lang_en"),
InlineKeyboardButton("🇷🇺 Русский", callback_data="lang_ru")
]
]
reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text(
t('select_language', lang),
reply_markup=reply_markup,
parse_mode='HTML'
)
async def handle_language_selection(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle language selection callback"""
query = update.callback_query
await query.answer()
user = query.from_user
user_id = user.id
selected_lang = query.data.split('_')[1] # 'en' or 'ru'
# Update user info in database
self.db.add_or_get_telegram_user(
user_id=user_id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
language_code=user.language_code
)
# Save selected language to database
success = self.db.set_user_language(user_id, selected_lang)
if success:
# Get message in selected language
message = t('language_set_ru', selected_lang) if selected_lang == 'ru' else t('language_set_en', selected_lang)
await query.edit_message_text(message)
else:
# Use current language for error message
lang = self.db.get_user_language(user_id)
error_msg = "Не удалось установить язык" if lang == 'ru' else "❌ Failed to set language"
await query.edit_message_text(error_msg)
async def profile(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Show list of players to view their profiles"""
user_id = update.effective_user.id
gamers = self.db.get_user_gamers(user_id)
lang = self.get_user_language_from_update(update)
if not gamers:
await update.message.reply_text(t('no_gamers', lang))
return
# Create keyboard with player buttons
keyboard = []
for gamer in gamers:
username = gamer['username']
keyboard.append([
InlineKeyboardButton(
username,
callback_data=f"profile_{gamer['id']}"
)
])
reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text(
t('select_player_profile', lang),
reply_markup=reply_markup,
parse_mode='HTML'
)
async def handle_profile_selection(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle profile selection callback - send profile link"""
query = update.callback_query
await query.answer()
user_id = query.from_user.id
gamer_id = int(query.data.split('_')[1])
# Get gamer info
gamers = self.db.get_user_gamers(user_id)
selected_gamer = next((g for g in gamers if g['id'] == gamer_id), None)
if not selected_gamer:
lang = self.db.get_user_language(user_id)
await query.edit_message_text(t('gamer_not_found', lang))
return
username = selected_gamer['username']
profile_url = f"https://lichess.org/@/{username}"
lang = self.db.get_user_language(user_id)
# Send profile link
await query.message.reply_text(
f"🔗 <a href=\"{profile_url}\">{profile_url}</a>",
parse_mode='HTML'
)
# Edit original message to show confirmation
await query.edit_message_text(
t('profile_link_sent', lang),
parse_mode='HTML'
)
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}"
username = gamer['username']
checkpoint_ts = self.db.get_period_checkpoint(user_id, gamer['id'])
if checkpoint_ts is not None:
restored_time = datetime.fromtimestamp(checkpoint_ts)
self.period_start_times[task_key] = restored_time
logger.info(f"♻️ Restored periodic checkpoint for {username} (user {user_id}) at {restored_time}")
logger.info(f"🔄 Started periodic monitoring for {username} (user {user_id}) with {period_minutes} minute intervals")
consecutive_errors = 0
max_consecutive_errors = 5
is_first_check = True # Флаг для первой проверки
while True:
try:
# Проверяем, что период все еще установлен в БД
current_gamers = self.db.get_user_gamers(user_id)
gamer_still_exists = False
current_period = 0
for g in current_gamers:
if g['id'] == gamer['id']:
gamer_still_exists = True
current_period = g.get('period_minutes', 0)
break
# Если игрок удален или период отключен, прекращаем мониторинг
if not gamer_still_exists or current_period == 0:
logger.info(f"Periodic monitoring stopped for {gamer['username']}: gamer removed or period disabled")
if task_key in self.periodic_tasks:
del self.periodic_tasks[task_key]
if task_key in self.period_start_times:
del self.period_start_times[task_key]
break
# Обновляем период на случай, если он был изменен
if current_period != period_minutes:
logger.info(f"Period changed for {gamer['username']} from {period_minutes} to {current_period} minutes")
period_minutes = current_period
# Получаем сохраненное время последней проверки для расчета следующего периода
# Используем флаг is_first_check для первой проверки вместо проверки наличия ключа
last_check_time = self.period_start_times.get(task_key)
if is_first_check and checkpoint_ts is None:
last_check_time = None # Принудительно делаем первую проверку
is_first_check = False
if last_check_time:
# Уже была хотя бы одна проверка
# Рассчитываем, когда должен начаться следующий период
next_period_start = last_check_time + timedelta(minutes=period_minutes)
now = datetime.now()
# Если следующий период еще не наступил, ждем
if next_period_start > now:
wait_seconds = (next_period_start - now).total_seconds()
logger.info(f"⏳ Waiting {wait_seconds:.1f} seconds until next period start ({next_period_start}) for {username}")
await asyncio.sleep(wait_seconds)
# Используем сохраненное время как начало периода
since_time = last_check_time
# Конец периода - это момент, когда должен был начаться следующий период
period_end_approx = next_period_start
logger.info(f"📌 Using saved period: from {since_time} to {period_end_approx}")
else:
# Первая проверка - ждем period_minutes минут от момента запуска
logger.info(f"⏳ First check: waiting {period_minutes} minutes before first check for {username}")
await asyncio.sleep(period_minutes * 60)
# Получаем текущее время
period_end_approx = datetime.now()
# Начало периода - текущее время минус period_minutes
since_time = period_end_approx - timedelta(minutes=period_minutes)
logger.info(f"📌 First check: period from {since_time} to {period_end_approx}")
since_timestamp = int(since_time.timestamp() * 1000)
# Используем приблизительное время как until_timestamp
# После получения ответа пересчитаем фактическое время
until_timestamp_approx = int(period_end_approx.timestamp() * 1000)
logger.info(f"🔍 Checking activity for {username} (user {user_id}): period from {since_time} to {period_end_approx} (approx, last {period_minutes} minutes)")
logger.info(f"📅 Unix timestamps: since={since_timestamp}, until_approx={until_timestamp_approx}")
# Делаем запросы к API через очередь с обработкой ошибок
games_data = None
puzzles_data = None
try:
# Добавляем запрос в очередь (будет выполнен с задержкой 7 секунд)
logger.info(f"📥 Adding games request to queue for {gamer['username']}")
games_data = await self.request_queue.add_request(
self.lichess_api.get_games_period,
gamer['username'], since_timestamp, until_timestamp_approx
)
if games_data is None:
raise RuntimeError("Games period API returned no data")
# Фиксируем фактическое время получения ответа
request_end_time = datetime.now()
logger.info(f"✅ Games API response received for {gamer['username']} at {request_end_time}")
except Exception as e:
logger.error(f"❌ Error getting games data for {gamer['username']}: {e}")
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
logger.error(f"Too many consecutive errors for {gamer['username']}, stopping periodic check")
break
logger.warning(f"⚠️ Games data unavailable for {gamer['username']}; retrying the same period in 60 seconds")
await asyncio.sleep(60)
continue
if gamer.get('token'):
try:
# Добавляем запрос в очередь (будет выполнен с задержкой 7 секунд)
logger.info(f"📥 Adding puzzles request to queue for {gamer['username']}")
puzzles_data = await self.request_queue.add_request(
self.lichess_api.get_puzzles_period,
gamer['token'], since_timestamp, until_timestamp_approx, 150
)
# Обновляем фактическое время после получения ответа по пазлам
request_end_time = datetime.now()
logger.info(f"✅ Puzzles API response received for {gamer['username']} at {request_end_time}")
except Exception as e:
logger.warning(f"⚠️ Error getting puzzles data for {gamer['username']}: {e}")
# Продолжаем без данных по пазлам
# Сбрасываем счетчик ошибок при успешном запросе
consecutive_errors = 0
# Проверяем наличие реальной активности
has_games = False
total_games = 0
if games_data:
# Логируем структуру ответа для отладки
logger.info(f"📊 Games data structure for {username}: {games_data}")
# Проверяем games_count на верхнем уровне (приоритет)
top_level_count = games_data.get('games_count', 0)
logger.debug(f"🔍 Top-level games_count: {top_level_count}")
if top_level_count > 0:
total_games = top_level_count
has_games = True
logger.info(f"✅ Found {total_games} games via games_count field")
else:
# Также проверяем data.total.games_played
games_data_dict = games_data.get('data')
if games_data_dict:
data_total = games_data_dict.get('total', {})
total_games_played = data_total.get('games_played', 0) if data_total else 0
else:
total_games_played = 0
logger.debug(f"🔍 data.total.games_played: {total_games_played}")
if total_games_played > 0:
total_games = total_games_played
has_games = True
logger.info(f"✅ Found {total_games} games via data.total.games_played field")
else:
logger.warning(f"⚠️ No games found: games_count={top_level_count}, data.total.games_played={total_games_played}")
else:
logger.warning(f"⚠️ No games_data returned for {username}")
has_puzzles = False
total_puzzles = 0
if puzzles_data:
logger.info(f"📊 Puzzles data structure for {username}: {puzzles_data}")
# Проверяем puzzles_in_period на верхнем уровне (приоритет)
if puzzles_data.get('puzzles_in_period', 0) > 0:
total_puzzles = puzzles_data.get('puzzles_in_period', 0)
has_puzzles = True
logger.info(f"✅ Found {total_puzzles} puzzles via puzzles_in_period field")
# Также проверяем data.total_attempts
elif puzzles_data.get('data') and puzzles_data.get('data', {}).get('total_attempts', 0) > 0:
total_puzzles = puzzles_data.get('data', {}).get('total_attempts', 0)
has_puzzles = True
logger.info(f"✅ Found {total_puzzles} puzzles via data.total_attempts field")
logger.info(f"🔍 Activity check result for {username}: has_games={has_games} (total={total_games}), has_puzzles={has_puzzles} (total={total_puzzles})")
# Отправляем уведомление только если есть реальная активность
if has_games or has_puzzles:
logger.info(f"📊 Activity detected for {gamer['username']}, preparing notification...")
try:
# Get user language from database
user_lang = self.db.get_user_language(user_id)
notification = StatsFormatter.format_period_notification(
gamer['username'], games_data, puzzles_data, period_minutes, lang=user_lang
)
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}")
# Increment periodic notification counter
self.counters.increment('periodic_notification')
except Exception as e:
logger.error(f"❌ Failed to send notification to user {user_id}: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
else:
logger.error(f"❌ Application not initialized, cannot send notification for {gamer['username']} to user {user_id}")
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.debug(f"⏭️ No activity found for {gamer['username']} in the last {period_minutes} minutes")
# Обновляем время начала следующего периода на ПЛАНИРУЕМОЕ время окончания текущего периода
# (period_end_approx), а не на фактическое время завершения запроса (request_end_time).
# Это гарантирует непрерывность периодов без пропусков:
# - Проверяем период A-B
# - Следующая проверка будет периода B-C
# - Без пропусков между A-B и B-C
#
# Использование request_end_time приведет к пропуску диапазона между period_end_approx и request_end_time
#
# period_end_approx уже установлено в начале итерации
self.period_start_times[task_key] = period_end_approx
self.db.set_period_checkpoint(user_id, gamer['id'], int(period_end_approx.timestamp()))
logger.info(f"📌 Updated period_start_time for {username} to {period_end_approx} (planned period end, next period will start from here)")
if 'request_end_time' in locals():
delay = (request_end_time - period_end_approx).total_seconds()
if delay > 0:
logger.info(f"⏱️ Request completed with {delay:.1f}s delay after planned period end")
except asyncio.CancelledError:
logger.info(f"Periodic check cancelled for {gamer['username']}")
break
except Exception as e:
consecutive_errors += 1
logger.error(f"Error in periodic check for {gamer['username']}: {e}")
import traceback
logger.error(f"Full traceback: {traceback.format_exc()}")
if consecutive_errors >= max_consecutive_errors:
logger.error(f"Too many consecutive errors for {gamer['username']}, stopping periodic check")
if task_key in self.periodic_tasks:
del self.periodic_tasks[task_key]
if task_key in self.period_start_times:
del self.period_start_times[task_key]
break
# Ждем перед повторной попыткой при ошибке
await asyncio.sleep(60) # 1 minute delay before retry
def setup_handlers(self, application: Application):
"""Setup all handlers"""
self.application = application # Store application reference
# Conversation handler for addtoken (token required)
# Must be added BEFORE general MessageHandler to avoid conflicts
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),
CommandHandler("addtoken", self.addtoken_start) # Allow restarting conversation
],
per_chat=True,
per_user=True
)
# Add handlers - ConversationHandler must be before general MessageHandler
application.add_handler(CommandHandler("start", self.start_and_addgamer))
application.add_handler(CommandHandler("help", self.help_command))
application.add_handler(CommandHandler("addgamer", self.addgamer_start))
application.add_handler(addtoken_conv) # Add before general MessageHandler
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_username))
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("lastYear_or_1000games", self.last_year_or_1000games))
application.add_handler(CommandHandler("support", self.support))
application.add_handler(CommandHandler("setperiod", self.setperiod))
application.add_handler(CommandHandler("set_lang", self.set_lang))
application.add_handler(CommandHandler("profile", self.profile))
application.add_handler(CommandHandler("test_admin_notify", self.test_admin_notify))
# Callback handlers (order matters - more specific patterns first)
application.add_handler(CallbackQueryHandler(self.addgamer_show_prompt, pattern="^addgamer_add$"))
application.add_handler(CallbackQueryHandler(self.addgamer_show_help, pattern="^addgamer_how$"))
application.add_handler(CallbackQueryHandler(self.handle_language_selection, pattern="^lang_"))
application.add_handler(CallbackQueryHandler(self.handle_profile_selection, pattern="^profile_"))
application.add_handler(CallbackQueryHandler(self.select_gamer_for_period, pattern="^select_gamer_period_"))
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"""
# Initialize admin bot for notifications (admin bot runs as separate service)
init_admin_bot()
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
# Add error handler
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Log the error and send a telegram message to notify the developer."""
logger.error(f"Exception while handling an update: {context.error}")
import traceback
logger.error(traceback.format_exc())
application.add_error_handler(error_handler)
# Start the bot with Long Polling
logger.info("Starting Lichess Statistics Bot with Long Polling...")
try:
application.run_polling(
poll_interval=POLL_INTERVAL,
timeout=POLL_TIMEOUT,
drop_pending_updates=DROP_PENDING_UPDATES,
allowed_updates=ALLOWED_UPDATES
)
except Exception as e:
logger.error(f"Fatal error in run_polling: {e}")
import traceback
logger.error(traceback.format_exc())
raise
if __name__ == '__main__':
main()