All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 12s
The bot's request_queue.py 4s FIFO gate wasn't protecting against Lichess's rate limiter — that's already handled downstream in LichessWebServices/ rate_limiter.py (0.2s, shared across all callers of our stats service). The bot-side gate only paced calls to our own local service, and since it awaited each request to full completion before dequeuing the next, real dispatch gaps were max(4s, previous request's duration) — with 454 tracked gamer/user pairs, any burst (e.g. after a restart) piled into the queue and took 10-20+ minutes to drain. Replace it with a paced-dispatch + bounded-concurrency design: a hard 2s floor between dispatches (still never lets 2+ requests through in that window), decoupled from completion time, with up to 10 requests actually in flight at once via a semaphore. Doesn't touch the real Lichess-facing rate limit at all. Also add deterministic per-(user,gamer) checkpoint jitter: previously every pair sharing the same period_minutes re-locked onto the same wall-clock phase on every restart (backlog collapse snaps period_end_approx to `now` for everyone overdue at once), recreating the pileup each time. Jitter is stable across restarts (crc32-based, not Python's salted hash()) and capped well under the 2h stale-backlog threshold. Small startup stagger added too, purely cosmetic smoothing on top of the jitter fix.
1959 lines
96 KiB
Python
1959 lines
96 KiB
Python
import asyncio
|
||
import logging
|
||
import sqlite3
|
||
import os
|
||
import zlib
|
||
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,
|
||
PicklePersistence
|
||
)
|
||
|
||
import config
|
||
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,
|
||
DATABASE_PATH
|
||
)
|
||
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())
|
||
|
||
@staticmethod
|
||
def _estimate_backoff_elapsed_hours(consecutive_errors: int) -> float:
|
||
"""Оценивает, сколько времени прошло при данном числе подряд идущих ошибок,
|
||
исходя из формулы бэкоффа periodic_check: min(300, 60 * n) секунд на попытку."""
|
||
total_seconds = sum(min(300, 60 * i) for i in range(1, consecutive_errors + 1))
|
||
return total_seconds / 3600
|
||
|
||
async def _notify_admin_periodic_check_failing(self, gamer_username: str, user_id: int, consecutive_errors: int):
|
||
"""Notify admin that periodic check for a gamer has been failing for a long time."""
|
||
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
|
||
hours = self._estimate_backoff_elapsed_hours(consecutive_errors)
|
||
url = f"https://api.telegram.org/bot{ADMINPANEL_TELEGRAM_BOT_TOKEN}/sendMessage"
|
||
message = (
|
||
f"⚠️ <b>Мониторинг игрока не работает</b>\n\n"
|
||
f"Игрок: {gamer_username}\n"
|
||
f"Владелец: user_id={user_id}\n"
|
||
f"Подряд идущих ошибок: {consecutive_errors} (~{hours:.1f} ч)\n\n"
|
||
f"Проверьте токен игрока или доступность Lichess API."
|
||
)
|
||
logger.info(f"Sending admin 'periodic check failing' 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:
|
||
logger.info("Admin 'periodic check failing' notification sent successfully")
|
||
else:
|
||
error_text = await response.text()
|
||
logger.error(f"Failed to send admin 'periodic check failing' notification: {response.status} - {error_text}")
|
||
except Exception as e:
|
||
logger.error(f"Failed to send admin 'periodic check failing' notification: {e}")
|
||
|
||
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")
|
||
|
||
active_gamers = [g for g in gamers_with_periods if g['period_minutes'] > 0]
|
||
# Stagger task creation across a short startup window instead of firing all
|
||
# ~N tasks' first (blocking) sqlite checkpoint read in the same event loop
|
||
# tick. Small and one-off — the real anti-lockstep fix is the per-pair
|
||
# checkpoint jitter in periodic_check, this just smooths process boot.
|
||
stagger_step = config.PERIODIC_STARTUP_STAGGER_MAX_SECONDS / max(1, len(active_gamers))
|
||
|
||
for gamer in active_gamers:
|
||
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")
|
||
if stagger_step > 0:
|
||
await asyncio.sleep(stagger_step)
|
||
|
||
logger.info(f"✅ All periodic tasks started. Total: {len(active_gamers)}")
|
||
|
||
# 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)
|
||
# Mark that we're awaiting a username right away: the menu below is shown
|
||
# so a user typing the name directly (without tapping a button) is handled
|
||
# too, instead of being silently ignored by handle_username.
|
||
if context and hasattr(context, "user_data"):
|
||
context.user_data['awaiting_addgamer_username'] = True
|
||
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())
|
||
try:
|
||
await update.message.reply_text(t('addgamer_menu_error', lang))
|
||
except Exception as e2:
|
||
logger.error(f"Failed to send addgamer menu error message: {e2}")
|
||
# 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 user_exists is None:
|
||
# Transient API failure (rate limit, network error, unexpected status) —
|
||
# don't tell the user the name is wrong, it may well be valid.
|
||
await update.message.reply_text(
|
||
t('lichess_temporarily_unavailable', lang) + '\n\n' + t('addgamer_prompt', lang),
|
||
parse_mode='HTML'
|
||
)
|
||
return
|
||
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")
|
||
|
||
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_table = StatsFormatter.format_gamers_table(gamers_data)
|
||
gamers_text = t('select_active_gamer', lang) + "<pre>" + gamers_table + "</pre>"
|
||
|
||
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:
|
||
# Дополнительный, параллельный запрос за реальными партиями периода —
|
||
# только чтобы добавить точность (accuracy) и, для today/yesterday,
|
||
# построчный разбор партий. Шапку (данные из /activity) не трогаем:
|
||
# при любой ошибке здесь просто не покажем точность.
|
||
accuracy_extra = None
|
||
try:
|
||
now_dt = datetime.now()
|
||
if period == "today":
|
||
since_dt = now_dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
until_dt = now_dt
|
||
want_rows = True
|
||
elif period == "yesterday":
|
||
today_midnight = now_dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
since_dt = today_midnight - timedelta(days=1)
|
||
until_dt = today_midnight
|
||
want_rows = True
|
||
else: # week — то же окно (today-6 .. now), что использует _is_date_in_range(days_back=7)
|
||
since_dt = now_dt.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=6)
|
||
until_dt = now_dt
|
||
want_rows = False
|
||
|
||
accuracy_extra = await self.lichess_api.get_games_period(
|
||
username,
|
||
int(since_dt.timestamp() * 1000),
|
||
int(until_dt.timestamp() * 1000),
|
||
rated_only=True,
|
||
include_games=want_rows
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Supplementary accuracy fetch failed for {username}/{period}: {e}")
|
||
accuracy_extra = None
|
||
|
||
formatted_response = StatsFormatter.format_stats_response(data, username, period, lang, accuracy_extra=accuracy_extra)
|
||
await update.message.reply_text(formatted_response, parse_mode='Markdown')
|
||
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, parse_mode='Markdown')
|
||
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)
|
||
|
||
if period == 0:
|
||
# Отключаем мониторинг: чистим чекпоинт, чтобы при повторном включении
|
||
# не было catch-up на потенциально очень старое (устаревшее) окно.
|
||
self.db.clear_period_checkpoint(user_id, gamer_id)
|
||
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
|
||
|
||
# Оповещать админ-бота о затяжной серии ошибок каждые ADMIN_NOTIFY_ERROR_THRESHOLD
|
||
# подряд идущих ошибок (~2 часа при капнутом бэкоффе в 300с на попытку)
|
||
ADMIN_NOTIFY_ERROR_THRESHOLD = 25
|
||
|
||
@staticmethod
|
||
def _checkpoint_jitter_seconds(user_id: int, gamer_id: int, period_minutes: int) -> float:
|
||
"""
|
||
Deterministic per-(user, gamer) jitter applied whenever a checkpoint gets
|
||
pinned to "now" (backlog collapse / first check). Without this, every pair
|
||
sharing the same period_minutes re-locks onto the exact same wall-clock
|
||
phase on every bot restart, causing them all to become "due" in the same
|
||
instant forever after. Stable across restarts (unlike Python's salted
|
||
hash()), and capped well below STALE_BACKLOG_THRESHOLD so it never
|
||
interacts with backlog-notification suppression.
|
||
"""
|
||
jitter_cap = min(period_minutes * 60 * config.PERIODIC_CHECKPOINT_JITTER_FRACTION,
|
||
config.PERIODIC_CHECKPOINT_JITTER_MAX_SECONDS)
|
||
if jitter_cap <= 0:
|
||
return 0.0
|
||
digest = zlib.crc32(f"{user_id}:{gamer_id}".encode())
|
||
return digest % jitter_cap
|
||
|
||
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
|
||
# за раз, при большом отставании (недели простоя, много отслеживаемых
|
||
# игроков через один общий RequestQueue) чекпоинт никогда не догонит
|
||
# текущее время — отставание только растёт. Вместо этого закрываем
|
||
# весь пропущенный промежуток одним запросом и сразу прыгаем к "сейчас".
|
||
# Джиттер (см. _checkpoint_jitter_seconds) не даёт всем парам с
|
||
# одинаковым period_minutes зафиксироваться на один и тот же момент.
|
||
since_time = last_check_time
|
||
jitter = self._checkpoint_jitter_seconds(user_id, gamer['id'], period_minutes)
|
||
period_end_approx = now - timedelta(seconds=jitter)
|
||
if period_end_approx - next_period_start > timedelta(minutes=period_minutes):
|
||
logger.warning(
|
||
f"⏰ {username} is behind schedule by "
|
||
f"{(now - next_period_start).total_seconds():.0f}s; "
|
||
f"collapsing backlog into one request from {since_time} to {period_end_approx}"
|
||
)
|
||
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)
|
||
|
||
# Получаем текущее время (с джиттером, см. _checkpoint_jitter_seconds)
|
||
jitter = self._checkpoint_jitter_seconds(user_id, gamer['id'], period_minutes)
|
||
period_end_approx = datetime.now() - timedelta(seconds=jitter)
|
||
# Начало периода - текущее время минус period_minutes
|
||
since_time = period_end_approx - timedelta(minutes=period_minutes)
|
||
logger.info(f"📌 First check: period from {since_time} to {period_end_approx}")
|
||
|
||
# A collapsed backlog spanning way more than a normal check window isn't
|
||
# "recent activity" anymore — dumping a multi-week, game-by-game report
|
||
# on the user reads as spam, not a periodic update. Catch the checkpoint
|
||
# up silently in that case instead of sending the full notification.
|
||
STALE_BACKLOG_THRESHOLD = timedelta(hours=2)
|
||
is_stale_backlog = (period_end_approx - since_time) > STALE_BACKLOG_THRESHOLD
|
||
if is_stale_backlog:
|
||
logger.warning(
|
||
f"⏭️ {username}: collapsed window ({since_time} to {period_end_approx}) "
|
||
f"exceeds {STALE_BACKLOG_THRESHOLD}; catching up checkpoint silently, "
|
||
f"no notification will be sent for this stale backlog"
|
||
)
|
||
|
||
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,
|
||
include_games=True
|
||
)
|
||
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
|
||
backoff_seconds = min(300, 60 * consecutive_errors)
|
||
if consecutive_errors >= max_consecutive_errors:
|
||
logger.error(f"⚠️ {consecutive_errors} consecutive errors for {gamer['username']}; still retrying the same period (backoff {backoff_seconds}s)")
|
||
else:
|
||
logger.warning(f"⚠️ Games data unavailable for {gamer['username']}; retrying the same period in {backoff_seconds}s")
|
||
if consecutive_errors == self.ADMIN_NOTIFY_ERROR_THRESHOLD or (
|
||
consecutive_errors > self.ADMIN_NOTIFY_ERROR_THRESHOLD
|
||
and consecutive_errors % self.ADMIN_NOTIFY_ERROR_THRESHOLD == 0
|
||
):
|
||
await self._notify_admin_periodic_check_failing(gamer['username'], user_id, consecutive_errors)
|
||
await asyncio.sleep(backoff_seconds)
|
||
continue
|
||
|
||
if gamer.get('token'):
|
||
# Ошибки получения пазлов обрабатываются так же, как ошибки игр:
|
||
# исключение всплывает во внешний обработчик, чекпоинт не продвигается,
|
||
# то же окно ретраится на следующей итерации (не считаем "пазлов не было").
|
||
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
|
||
)
|
||
if puzzles_data is None:
|
||
raise RuntimeError("Puzzles period API returned no data")
|
||
# Обновляем фактическое время после получения ответа по пазлам
|
||
request_end_time = datetime.now()
|
||
logger.info(f"✅ Puzzles API response received for {gamer['username']} at {request_end_time}")
|
||
|
||
# Сбрасываем счетчик ошибок при успешном запросе
|
||
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})")
|
||
|
||
# Отправляем уведомление только если есть реальная активность и это не
|
||
# молчаливый догон устаревшего бэклога (is_stale_backlog, см. выше).
|
||
# Ошибки форматирования/отправки НЕ перехватываются здесь: они должны
|
||
# всплыть во внешний обработчик, чтобы чекпоинт не продвинулся и уже
|
||
# обнаруженная активность была отправлена повторно на следующей попытке,
|
||
# а не потеряна молча.
|
||
if (has_games or has_puzzles) and not is_stale_backlog:
|
||
logger.info(f"📊 Activity detected for {gamer['username']}, preparing notification...")
|
||
user_lang = self.db.get_user_language(user_id)
|
||
# Label the notification with the actual queried window, not the
|
||
# configured period: after a backlog collapse (see above) they can
|
||
# differ by days/weeks, and showing "for 15 minutes" on activity
|
||
# that's actually from weeks ago is misleading.
|
||
actual_span_minutes = max(1, round((period_end_approx - since_time).total_seconds() / 60))
|
||
notification = StatsFormatter.format_period_notification(
|
||
gamer['username'], games_data, puzzles_data, actual_span_minutes, lang=user_lang
|
||
)
|
||
|
||
if not self.application:
|
||
raise RuntimeError(f"Application not initialized, cannot send notification for {gamer['username']} to user {user_id}")
|
||
|
||
await self.application.bot.send_message(
|
||
chat_id=user_id,
|
||
text=notification,
|
||
parse_mode='Markdown'
|
||
)
|
||
logger.info(f"✅ Sent periodic notification for {gamer['username']} to user {user_id}")
|
||
# Increment periodic notification counter
|
||
self.counters.increment('periodic_notification')
|
||
elif is_stale_backlog and (has_games or has_puzzles):
|
||
logger.info(f"⏭️ Suppressed stale backlog notification for {gamer['username']} (had activity, but window too old)")
|
||
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()}")
|
||
|
||
# Не убиваем задачу навсегда: ретраим то же окно с растущим бэкоффом
|
||
# (капается на 300с), пока Lichess/Telegram не восстановятся.
|
||
backoff_seconds = min(300, 60 * consecutive_errors)
|
||
if consecutive_errors >= max_consecutive_errors:
|
||
logger.error(f"⚠️ {consecutive_errors} consecutive errors for {gamer['username']}; still retrying the same period (backoff {backoff_seconds}s)")
|
||
if consecutive_errors == self.ADMIN_NOTIFY_ERROR_THRESHOLD or (
|
||
consecutive_errors > self.ADMIN_NOTIFY_ERROR_THRESHOLD
|
||
and consecutive_errors % self.ADMIN_NOTIFY_ERROR_THRESHOLD == 0
|
||
):
|
||
await self._notify_admin_periodic_check_failing(gamer['username'], user_id, consecutive_errors)
|
||
await asyncio.sleep(backoff_seconds)
|
||
continue
|
||
|
||
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()
|
||
|
||
# Persist user_data (e.g. awaiting_addgamer_username) to disk so an in-flight
|
||
# /addgamer conversation survives a bot restart instead of silently dropping
|
||
# the next message the user sends.
|
||
persistence_path = os.path.join(os.path.dirname(DATABASE_PATH), "bot_persistence.pickle")
|
||
persistence = PicklePersistence(filepath=persistence_path)
|
||
|
||
# Create application with Long Polling configuration
|
||
application = Application.builder().token(TELEGRAM_BOT_TOKEN).persistence(persistence).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()
|