diff --git a/LichessClientTG_bot/bot.py b/LichessClientTG_bot/bot.py index a8699b6..4425e7f 100644 --- a/LichessClientTG_bot/bot.py +++ b/LichessClientTG_bot/bot.py @@ -23,7 +23,7 @@ from config import ( ) from version import BOT_VERSION from database import Database -from lichess_api import LichessAPI +from lichess_api import LichessAPI, InvalidTokenError from formatters import StatsFormatter from i18n import t from admin_bot import get_admin_bot, init_admin_bot @@ -1713,19 +1713,38 @@ class LichessBot: continue if gamer.get('token'): - # Ошибки получения пазлов обрабатываются так же, как ошибки игр: + # Транзитные ошибки получения пазлов обрабатываются так же, как ошибки игр: # исключение всплывает во внешний обработчик, чекпоинт не продвигается, # то же окно ретраится на следующей итерации (не считаем "пазлов не было"). + # InvalidTokenError — отдельный случай: Lichess отклонил токен насовсем + # (401), ретраить бессмысленно — это раньше приводило к вечному циклу + # ретраев без продвижения чекпоинта (см. историю с Dor1zz). Вместо этого + # отключаем пазлы для этой пары и продолжаем отслеживать партии как обычно. 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}") + try: + puzzles_data = await self.request_queue.add_request( + self.lichess_api.get_puzzles_period, + gamer['token'], since_timestamp, until_timestamp_approx, 150 + ) + except InvalidTokenError: + logger.warning(f"🔑 Token rejected by Lichess for {gamer['username']} (user {user_id}); disabling puzzle tracking for this pair") + self.db.clear_user_gamer_token(user_id, gamer['id']) + gamer['token'] = None + puzzles_data = None + try: + user_lang = self.db.get_user_language(user_id) + await self.application.bot.send_message( + chat_id=user_id, + text=t('token_rejected_notice', user_lang, username=gamer['username']) + ) + except Exception as notify_err: + logger.error(f"Failed to notify user {user_id} about rejected token: {notify_err}") + else: + 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 diff --git a/LichessClientTG_bot/database.py b/LichessClientTG_bot/database.py index b60db2a..161b29d 100644 --- a/LichessClientTG_bot/database.py +++ b/LichessClientTG_bot/database.py @@ -335,6 +335,16 @@ class Database: ) conn.commit() + def clear_user_gamer_token(self, user_id: int, gamer_id: int): + """Clear a rejected/expired token so periodic checks stop sending it (games-only monitoring continues).""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE user_gamers SET token = NULL WHERE user_id = ? AND gamer_id = ?", + (user_id, gamer_id) + ) + conn.commit() + def get_period_checkpoint(self, user_id: int, gamer_id: int) -> Optional[int]: """Get persisted period checkpoint timestamp (seconds since epoch).""" with sqlite3.connect(self.db_path) as conn: diff --git a/LichessClientTG_bot/i18n.py b/LichessClientTG_bot/i18n.py index c0cc1da..f31da60 100644 --- a/LichessClientTG_bot/i18n.py +++ b/LichessClientTG_bot/i18n.py @@ -72,7 +72,8 @@ TRANSLATIONS = { 'user_not_found': "❌ Player {username} not found on Lichess. Check the spelling of the name.", 'lichess_temporarily_unavailable': "⚠️ Lichess is temporarily unavailable. Please try again in a minute.", 'gamer_already_added': "ℹ️ Player {username} is already being tracked.\n\nTo add another player, use /addgamer", - + 'token_rejected_notice': "⚠️ Lichess rejected the saved token for {username} — puzzle stats have been turned off for this player, game tracking continues as normal. Use /addtoken to reconnect a fresh token if you want puzzle stats back.", + # Get gamers 'no_gamers': "📭 No players in database. Use /addgamer to add.", 'loading_ratings': "🔄 Loading player ratings...", @@ -215,7 +216,8 @@ TRANSLATIONS = { 'user_not_found': "❌ Игрок {username} не найден на Lichess. Проверьте правильность написания имени.", 'lichess_temporarily_unavailable': "⚠️ Lichess временно недоступен. Попробуйте, пожалуйста, через минуту.", 'gamer_already_added': "ℹ️ Игрок {username} уже отслеживается.\n\nДля добавления следующего игрока воспользуйтесь /addgamer", - + 'token_rejected_notice': "⚠️ Lichess отклонил сохранённый токен для {username} — статистика по пазлам для этого игрока отключена, отслеживание партий продолжается как обычно. Используйте /addtoken, чтобы подключить новый токен и вернуть статистику по пазлам.", + # Get gamers 'no_gamers': "📭 Нет игроков в базе данных. Используйте /addgamer для добавления.", 'loading_ratings': "🔄 Загрузка рейтингов игроков...", diff --git a/LichessClientTG_bot/lichess_api.py b/LichessClientTG_bot/lichess_api.py index 7c9c522..c21dad5 100644 --- a/LichessClientTG_bot/lichess_api.py +++ b/LichessClientTG_bot/lichess_api.py @@ -6,6 +6,12 @@ from rate_limiter import get_rate_limiter logger = logging.getLogger(__name__) + +class InvalidTokenError(Exception): + """Raised when Lichess rejects a stored token (401) — permanent, not worth retrying.""" + pass + + class LichessAPI: def __init__(self): self.lichess_base_url = LICHESS_API_BASE_URL @@ -125,10 +131,17 @@ class LichessAPI: return None async def get_puzzles_period(self, token: str, since: int, until: int, max_puzzles: int = 150) -> Optional[Dict[str, Any]]: - """Get puzzles for a specific period""" + """ + Get puzzles for a specific period. + + Raises InvalidTokenError on a 401 (Lichess rejected the token — permanent, + the caller should stop retrying with this token) instead of returning None, + so callers can tell that apart from a transient failure (network error, + Lichess hiccup) where retrying the same window later makes sense. + """ await self.rate_limiter.wait_if_needed() headers = {"Authorization": f"Bearer {token}"} - + try: async with aiohttp.ClientSession() as session: async with session.get( @@ -138,9 +151,14 @@ class LichessAPI: ) as response: if response.status == 200: return await response.json() + elif response.status == 401: + logger.warning(f"Puzzles period: token rejected by Lichess (401)") + raise InvalidTokenError("Lichess rejected the token") else: logger.error(f"Failed to get puzzles period: {response.status}") return None + except InvalidTokenError: + raise except Exception as e: logger.error(f"Error getting puzzles period: {e}") return None diff --git a/LichessWebServices/main.py b/LichessWebServices/main.py index 50d3d60..7e96c83 100644 --- a/LichessWebServices/main.py +++ b/LichessWebServices/main.py @@ -704,7 +704,10 @@ async def get_puzzle_of_period( try: result = await stats_service.get_puzzle_of_period(token, since, until, max) if not result.success: - # Реальная ошибка при обращении к Lichess — не маскируем её под "0 пазлов" + if result.auth_failed: + # Токен отклонён Lichess (401/403) — permanent, ретраить бессмысленно + raise HTTPException(status_code=401, detail=result.message) + # Прочая (транзитная) ошибка при обращении к Lichess — не маскируем её под "0 пазлов" raise HTTPException(status_code=502, detail=result.message) return result except HTTPException: diff --git a/LichessWebServices/models.py b/LichessWebServices/models.py index d352001..870fd6e 100644 --- a/LichessWebServices/models.py +++ b/LichessWebServices/models.py @@ -253,6 +253,7 @@ class PuzzleOfPeriodResponse(BaseModel): """ message: str = Field(..., description="Сообщение о результате запроса", example="Статистика решения задач за период") success: bool = Field(True, description="False, если запрос к Lichess завершился ошибкой (а не легитимным нулевым результатом)", example=True) + auth_failed: bool = Field(False, description="True, если Lichess отклонил токен (401/403) — permanent, не стоит ретраить; отличается от транзитных ошибок") period_start: int = Field(..., description="Начало периода (Unix timestamp в миллисекундах)", example=1640995200000) period_end: int = Field(..., description="Конец периода (Unix timestamp в миллисекундах)", example=1641081600000) max_puzzles: int = Field(..., description="Максимальное количество задач для получения", example=50) diff --git a/LichessWebServices/stats_service.py b/LichessWebServices/stats_service.py index 53b3822..50b3fb6 100644 --- a/LichessWebServices/stats_service.py +++ b/LichessWebServices/stats_service.py @@ -839,6 +839,7 @@ class StatsService: return PuzzleOfPeriodResponse( message="Неверный токен авторизации или доступ запрещен", success=False, + auth_failed=True, period_start=since_ms, period_end=until_ms, max_puzzles=max_puzzles,