fix silent addgamer drop on restart and periodic-check backlog buildup
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 13s

Persist user_data (PicklePersistence) so an in-flight /addgamer username
prompt survives a bot restart instead of being silently swallowed by
handle_username when the in-memory awaiting flag is gone.

Collapse periodic-check backlog into a single request spanning the whole
missed gap instead of replaying it one period_minutes window at a time —
with enough tracked gamers sharing one RequestQueue, per-window replay
could never catch up and the checkpoint fell further behind indefinitely.
Notification period label now reflects the actual queried span (minutes/
hours/days) instead of the configured interval, so a weeks-old catch-up
no longer gets mislabeled as "for 15 minutes".
This commit is contained in:
vrubelroman 2026-07-04 18:33:45 +00:00
parent 62cdd750f8
commit b0173950b8
3 changed files with 62 additions and 21 deletions

View file

@ -9,13 +9,15 @@ from pathlib import Path
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ( from telegram.ext import (
Application, CommandHandler, CallbackQueryHandler, Application, CommandHandler, CallbackQueryHandler,
MessageHandler, filters, ContextTypes, ConversationHandler MessageHandler, filters, ContextTypes, ConversationHandler,
PicklePersistence
) )
from config import ( from config import (
TELEGRAM_BOT_TOKEN, PERIOD_OPTIONS, POLL_INTERVAL, TELEGRAM_BOT_TOKEN, PERIOD_OPTIONS, POLL_INTERVAL,
POLL_TIMEOUT, DROP_PENDING_UPDATES, ALLOWED_UPDATES, POLL_TIMEOUT, DROP_PENDING_UPDATES, ALLOWED_UPDATES,
LICHESS_STATS_API_BASE_URL, ADMINPANEL_TELEGRAM_BOT_TOKEN LICHESS_STATS_API_BASE_URL, ADMINPANEL_TELEGRAM_BOT_TOKEN,
DATABASE_PATH
) )
from version import BOT_VERSION from version import BOT_VERSION
from database import Database from database import Database
@ -1593,12 +1595,26 @@ class LichessBot:
wait_seconds = (next_period_start - now).total_seconds() 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}") logger.info(f"⏳ Waiting {wait_seconds:.1f} seconds until next period start ({next_period_start}) for {username}")
await asyncio.sleep(wait_seconds) await asyncio.sleep(wait_seconds)
# Используем сохраненное время как начало периода # Используем сохраненное время как начало периода
since_time = last_check_time since_time = last_check_time
# Конец периода - это момент, когда должен был начаться следующий период # Конец периода - это момент, когда должен был начаться следующий период
period_end_approx = next_period_start period_end_approx = next_period_start
logger.info(f"📌 Using saved period: from {since_time} to {period_end_approx}") logger.info(f"📌 Using saved period: from {since_time} to {period_end_approx}")
else:
# Мы отстаём от расписания (бэклог): если догонять по period_minutes
# за раз, при большом отставании (недели простоя, много отслеживаемых
# игроков через один общий RequestQueue) чекпоинт никогда не догонит
# текущее время — отставание только растёт. Вместо этого закрываем
# весь пропущенный промежуток одним запросом и сразу прыгаем к "сейчас".
since_time = last_check_time
period_end_approx = now
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: else:
# Первая проверка - ждем period_minutes минут от момента запуска # Первая проверка - ждем period_minutes минут от момента запуска
logger.info(f"⏳ First check: waiting {period_minutes} minutes before first check for {username}") logger.info(f"⏳ First check: waiting {period_minutes} minutes before first check for {username}")
@ -1729,8 +1745,13 @@ class LichessBot:
if has_games or has_puzzles: if has_games or has_puzzles:
logger.info(f"📊 Activity detected for {gamer['username']}, preparing notification...") logger.info(f"📊 Activity detected for {gamer['username']}, preparing notification...")
user_lang = self.db.get_user_language(user_id) 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( notification = StatsFormatter.format_period_notification(
gamer['username'], games_data, puzzles_data, period_minutes, lang=user_lang gamer['username'], games_data, puzzles_data, actual_span_minutes, lang=user_lang
) )
if not self.application: if not self.application:
@ -1841,8 +1862,14 @@ def main():
bot = LichessBot() 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 # Create application with Long Polling configuration
application = Application.builder().token(TELEGRAM_BOT_TOKEN).build() application = Application.builder().token(TELEGRAM_BOT_TOKEN).persistence(persistence).build()
# Setup handlers # Setup handlers
bot.setup_handlers(application) bot.setup_handlers(application)

View file

@ -283,13 +283,23 @@ class StatsFormatter:
"""Format notification for periodic checks""" """Format notification for periodic checks"""
from datetime import datetime from datetime import datetime
# Format period text # Format period text. period_minutes here is the actual queried span, which
# can be much larger than the configured check interval after a backlog
# collapse (see periodic_check in bot.py) — fall back to hours/days so a
# weeks-old catch-up doesn't get mislabeled as "for 15 minutes".
if period_minutes < 60:
if period_minutes == 1: if period_minutes == 1:
period_text = t('period_1_minute', lang) period_text = t('period_1_minute', lang)
elif period_minutes in [2, 3, 4]: elif period_minutes in [2, 3, 4]:
period_text = t('period_2_3_4_minutes', lang, period=period_minutes) period_text = t('period_2_3_4_minutes', lang, period=period_minutes)
else: else:
period_text = t('period_minutes_text', lang, period=period_minutes) period_text = t('period_minutes_text', lang, period=period_minutes)
elif period_minutes < 1440:
hours = round(period_minutes / 60)
period_text = t('period_hours_text', lang, hours=hours)
else:
days = round(period_minutes / 1440)
period_text = t('period_days_text', lang, days=days)
result = t('period_notification_title', lang, username=username, period_text=period_text) result = t('period_notification_title', lang, username=username, period_text=period_text)

View file

@ -113,6 +113,8 @@ TRANSLATIONS = {
'period_1_minute': "for 1 minute", 'period_1_minute': "for 1 minute",
'period_2_3_4_minutes': "for {period} minutes", 'period_2_3_4_minutes': "for {period} minutes",
'period_minutes_text': "for {period} minutes", 'period_minutes_text': "for {period} minutes",
'period_hours_text': "for {hours}h",
'period_days_text': "for {days}d",
'period_notification_title': "📊 Statistics {username}{period_text}\n\n", 'period_notification_title': "📊 Statistics {username}{period_text}\n\n",
'period_puzzles_section': "🧩 Puzzles: {total} (✅ {solved} - ❌ {failed})\n\n", 'period_puzzles_section': "🧩 Puzzles: {total} (✅ {solved} - ❌ {failed})\n\n",
'no_activity': "📭 No activity for this period", 'no_activity': "📭 No activity for this period",
@ -254,6 +256,8 @@ TRANSLATIONS = {
'period_1_minute': "за 1 минуту", 'period_1_minute': "за 1 минуту",
'period_2_3_4_minutes': "за {period} минуты", 'period_2_3_4_minutes': "за {period} минуты",
'period_minutes_text': "за {period} минут", 'period_minutes_text': "за {period} минут",
'period_hours_text': "за {hours}ч",
'period_days_text': "за {days}д",
'period_notification_title': "📊 Статистика {username}{period_text}\n\n", 'period_notification_title': "📊 Статистика {username}{period_text}\n\n",
'period_puzzles_section': "🧩 Пазлы: {total} (✅ {solved} - ❌ {failed})\n\n", 'period_puzzles_section': "🧩 Пазлы: {total} (✅ {solved} - ❌ {failed})\n\n",
'no_activity': "📭 Нет активности за этот период", 'no_activity': "📭 Нет активности за этот период",