Compare commits
10 commits
6ed6ab866e
...
a5e3e6903d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5e3e6903d | ||
|
|
0e3a0bfc48 | ||
|
|
63e28c279c | ||
|
|
3ffcf97c3f | ||
|
|
20f96d0e0a | ||
|
|
0de0deb14f | ||
|
|
2ac38440da | ||
|
|
b3b6a54e2d | ||
|
|
6d51cf135d | ||
|
|
977787c858 |
15 changed files with 331 additions and 83 deletions
|
|
@ -48,6 +48,15 @@ class LichessBot:
|
||||||
self.counters = MessageCounters() # Message counters
|
self.counters = MessageCounters() # Message counters
|
||||||
self.request_queue = get_request_queue() # Request queue for rate limiting
|
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):
|
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)."""
|
"""Notify admin about newly linked player (always try to send)."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -558,7 +567,8 @@ class LichessBot:
|
||||||
user_exists = await self.lichess_api.check_user_exists(username)
|
user_exists = await self.lichess_api.check_user_exists(username)
|
||||||
if not user_exists:
|
if not user_exists:
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
t('user_not_found', lang, username=username)
|
t('user_not_found', lang, username=username) + '\n\n' + t('addgamer_prompt', lang),
|
||||||
|
parse_mode='HTML'
|
||||||
)
|
)
|
||||||
return WAITING_FOR_USERNAME
|
return WAITING_FOR_USERNAME
|
||||||
|
|
||||||
|
|
@ -569,13 +579,10 @@ class LichessBot:
|
||||||
if existing_gamer:
|
if existing_gamer:
|
||||||
# Player is already being tracked by this user
|
# Player is already being tracked by this user
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
t('gamer_already_added', lang, username=username)
|
t('gamer_already_added', lang, username=username) + '\n\n' + t('addgamer_prompt', lang),
|
||||||
|
parse_mode='HTML'
|
||||||
)
|
)
|
||||||
# Clear awaiting flag
|
# Keep awaiting flag - don't clear it, so user can try again
|
||||||
try:
|
|
||||||
context.user_data['awaiting_addgamer_username'] = False
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Add gamer to database (without token)
|
# Add gamer to database (without token)
|
||||||
|
|
@ -594,13 +601,10 @@ class LichessBot:
|
||||||
# If add_user_gamer returned False, it means the pair already exists (shouldn't happen after our check, but just in case)
|
# 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:
|
if not added:
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
t('gamer_already_added', lang, username=username)
|
t('gamer_already_added', lang, username=username) + '\n\n' + t('addgamer_prompt', lang),
|
||||||
|
parse_mode='HTML'
|
||||||
)
|
)
|
||||||
# Clear awaiting flag
|
# Keep awaiting flag - don't clear it, so user can try again
|
||||||
try:
|
|
||||||
context.user_data['awaiting_addgamer_username'] = False
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Set default period to 1 hour (60 minutes) for new gamer
|
# Set default period to 1 hour (60 minutes) for new gamer
|
||||||
|
|
@ -948,12 +952,6 @@ class LichessBot:
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Send initial message about processing
|
|
||||||
try:
|
|
||||||
await update.message.reply_text(t('stats_processing', lang), parse_mode='HTML')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Process each gamer
|
# Process each gamer
|
||||||
has_any_activity = False
|
has_any_activity = False
|
||||||
for i, gamer in enumerate(gamers):
|
for i, gamer in enumerate(gamers):
|
||||||
|
|
@ -1047,9 +1045,6 @@ class LichessBot:
|
||||||
# If no activity for any player
|
# If no activity for any player
|
||||||
if not has_any_activity:
|
if not has_any_activity:
|
||||||
await update.message.reply_text(t('no_activity', lang))
|
await update.message.reply_text(t('no_activity', lang))
|
||||||
else:
|
|
||||||
# Send final message that all is done
|
|
||||||
await update.message.reply_text(t('stats_all_done', lang))
|
|
||||||
|
|
||||||
# Increment counter for the period command
|
# Increment counter for the period command
|
||||||
if period == "today":
|
if period == "today":
|
||||||
|
|
@ -1092,12 +1087,6 @@ class LichessBot:
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Send initial message about processing
|
|
||||||
try:
|
|
||||||
await update.message.reply_text(t('last_year_1000_processing', lang), parse_mode='HTML')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
now_ms = int(time.time() * 1000)
|
now_ms = int(time.time() * 1000)
|
||||||
year_ms = 365 * 24 * 3600 * 1000
|
year_ms = 365 * 24 * 3600 * 1000
|
||||||
since_ms = now_ms - year_ms
|
since_ms = now_ms - year_ms
|
||||||
|
|
@ -1146,9 +1135,6 @@ class LichessBot:
|
||||||
# If no activity for any player
|
# If no activity for any player
|
||||||
if not has_any_activity:
|
if not has_any_activity:
|
||||||
await update.message.reply_text(t('no_activity', lang))
|
await update.message.reply_text(t('no_activity', lang))
|
||||||
else:
|
|
||||||
# Send final message that all is done
|
|
||||||
await update.message.reply_text(t('stats_all_done', lang))
|
|
||||||
|
|
||||||
self.counters.increment('last_year_1000')
|
self.counters.increment('last_year_1000')
|
||||||
|
|
||||||
|
|
@ -1298,8 +1284,10 @@ class LichessBot:
|
||||||
|
|
||||||
# Set period for this user-gamer pair
|
# Set period for this user-gamer pair
|
||||||
self.db.set_user_gamer_period(user_id, gamer_id, period)
|
self.db.set_user_gamer_period(user_id, gamer_id, period)
|
||||||
|
self.db.clear_period_checkpoint(user_id, gamer_id)
|
||||||
|
|
||||||
if period == 0:
|
if period == 0:
|
||||||
|
await self.stop_periodic_task(gamer_id, user_id)
|
||||||
await query.edit_message_text(
|
await query.edit_message_text(
|
||||||
t('notifications_disabled', lang, username=selected_gamer['username'])
|
t('notifications_disabled', lang, username=selected_gamer['username'])
|
||||||
)
|
)
|
||||||
|
|
@ -1463,14 +1451,17 @@ class LichessBot:
|
||||||
task_key = f"{gamer['id']}_{user_id}"
|
task_key = f"{gamer['id']}_{user_id}"
|
||||||
username = gamer['username']
|
username = gamer['username']
|
||||||
|
|
||||||
# Инициализируем время начала отслеживания как текущее время
|
checkpoint_ts = self.db.get_period_checkpoint(user_id, gamer['id'])
|
||||||
# Первая проверка произойдет через period_minutes минут
|
if checkpoint_ts is not None:
|
||||||
start_time = datetime.now()
|
restored_time = datetime.fromtimestamp(checkpoint_ts)
|
||||||
self.period_start_times[task_key] = start_time
|
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")
|
logger.info(f"🔄 Started periodic monitoring for {username} (user {user_id}) with {period_minutes} minute intervals")
|
||||||
|
|
||||||
consecutive_errors = 0
|
consecutive_errors = 0
|
||||||
max_consecutive_errors = 5
|
max_consecutive_errors = 5
|
||||||
|
is_first_check = True # Флаг для первой проверки
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
@ -1498,21 +1489,48 @@ class LichessBot:
|
||||||
logger.info(f"Period changed for {gamer['username']} from {period_minutes} to {current_period} minutes")
|
logger.info(f"Period changed for {gamer['username']} from {period_minutes} to {current_period} minutes")
|
||||||
period_minutes = current_period
|
period_minutes = current_period
|
||||||
|
|
||||||
# Ждем заданное количество минут перед следующей проверкой
|
# Получаем сохраненное время последней проверки для расчета следующего периода
|
||||||
logger.info(f"⏳ Waiting {period_minutes} minutes before next check for {username}")
|
# Используем флаг is_first_check для первой проверки вместо проверки наличия ключа
|
||||||
await asyncio.sleep(period_minutes * 60)
|
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:
|
||||||
now = datetime.now()
|
# Уже была хотя бы одна проверка
|
||||||
|
# Рассчитываем, когда должен начаться следующий период
|
||||||
|
next_period_start = last_check_time + timedelta(minutes=period_minutes)
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
# Если следующий период еще не наступил, ждем
|
||||||
|
if next_period_start > now:
|
||||||
|
wait_seconds = (next_period_start - now).total_seconds()
|
||||||
|
logger.info(f"⏳ Waiting {wait_seconds:.1f} seconds until next period start ({next_period_start}) for {username}")
|
||||||
|
await asyncio.sleep(wait_seconds)
|
||||||
|
|
||||||
|
# Используем сохраненное время как начало периода
|
||||||
|
since_time = last_check_time
|
||||||
|
# Конец периода - это момент, когда должен был начаться следующий период
|
||||||
|
period_end_approx = next_period_start
|
||||||
|
logger.info(f"📌 Using saved period: from {since_time} to {period_end_approx}")
|
||||||
|
else:
|
||||||
|
# Первая проверка - ждем period_minutes минут от момента запуска
|
||||||
|
logger.info(f"⏳ First check: waiting {period_minutes} minutes before first check for {username}")
|
||||||
|
await asyncio.sleep(period_minutes * 60)
|
||||||
|
|
||||||
|
# Получаем текущее время
|
||||||
|
period_end_approx = datetime.now()
|
||||||
|
# Начало периода - текущее время минус period_minutes
|
||||||
|
since_time = period_end_approx - timedelta(minutes=period_minutes)
|
||||||
|
logger.info(f"📌 First check: period from {since_time} to {period_end_approx}")
|
||||||
|
|
||||||
# Рассчитываем период: от (текущее время - период) до текущего времени
|
|
||||||
# Это гарантирует, что мы проверяем последний период активности
|
|
||||||
since_time = now - timedelta(minutes=period_minutes)
|
|
||||||
since_timestamp = int(since_time.timestamp() * 1000)
|
since_timestamp = int(since_time.timestamp() * 1000)
|
||||||
until_timestamp = int(now.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 {now} (last {period_minutes} minutes)")
|
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={until_timestamp}")
|
logger.info(f"📅 Unix timestamps: since={since_timestamp}, until_approx={until_timestamp_approx}")
|
||||||
|
|
||||||
# Делаем запросы к API через очередь с обработкой ошибок
|
# Делаем запросы к API через очередь с обработкой ошибок
|
||||||
games_data = None
|
games_data = None
|
||||||
|
|
@ -1523,18 +1541,21 @@ class LichessBot:
|
||||||
logger.info(f"📥 Adding games request to queue for {gamer['username']}")
|
logger.info(f"📥 Adding games request to queue for {gamer['username']}")
|
||||||
games_data = await self.request_queue.add_request(
|
games_data = await self.request_queue.add_request(
|
||||||
self.lichess_api.get_games_period,
|
self.lichess_api.get_games_period,
|
||||||
gamer['username'], since_timestamp, until_timestamp
|
gamer['username'], since_timestamp, until_timestamp_approx
|
||||||
)
|
)
|
||||||
logger.info(f"✅ Games API response received for {gamer['username']}")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error getting games data for {gamer['username']}: {e}")
|
logger.error(f"❌ Error getting games data for {gamer['username']}: {e}")
|
||||||
consecutive_errors += 1
|
consecutive_errors += 1
|
||||||
if consecutive_errors >= max_consecutive_errors:
|
if consecutive_errors >= max_consecutive_errors:
|
||||||
logger.error(f"Too many consecutive errors for {gamer['username']}, stopping periodic check")
|
logger.error(f"Too many consecutive errors for {gamer['username']}, stopping periodic check")
|
||||||
break
|
break
|
||||||
# Продолжаем с обновлением времени начала, чтобы не зацикливаться
|
logger.warning(f"⚠️ Games data unavailable for {gamer['username']}; retrying the same period in 60 seconds")
|
||||||
now = datetime.now()
|
await asyncio.sleep(60)
|
||||||
self.period_start_times[task_key] = now
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if gamer.get('token'):
|
if gamer.get('token'):
|
||||||
|
|
@ -1543,9 +1564,11 @@ class LichessBot:
|
||||||
logger.info(f"📥 Adding puzzles request to queue for {gamer['username']}")
|
logger.info(f"📥 Adding puzzles request to queue for {gamer['username']}")
|
||||||
puzzles_data = await self.request_queue.add_request(
|
puzzles_data = await self.request_queue.add_request(
|
||||||
self.lichess_api.get_puzzles_period,
|
self.lichess_api.get_puzzles_period,
|
||||||
gamer['token'], since_timestamp, until_timestamp, 150
|
gamer['token'], since_timestamp, until_timestamp_approx, 150
|
||||||
)
|
)
|
||||||
logger.info(f"✅ Puzzles API response received for {gamer['username']}")
|
# Обновляем фактическое время после получения ответа по пазлам
|
||||||
|
request_end_time = datetime.now()
|
||||||
|
logger.info(f"✅ Puzzles API response received for {gamer['username']} at {request_end_time}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ Error getting puzzles data for {gamer['username']}: {e}")
|
logger.warning(f"⚠️ Error getting puzzles data for {gamer['username']}: {e}")
|
||||||
# Продолжаем без данных по пазлам
|
# Продолжаем без данных по пазлам
|
||||||
|
|
@ -1561,15 +1584,29 @@ class LichessBot:
|
||||||
logger.info(f"📊 Games data structure for {username}: {games_data}")
|
logger.info(f"📊 Games data structure for {username}: {games_data}")
|
||||||
|
|
||||||
# Проверяем games_count на верхнем уровне (приоритет)
|
# Проверяем games_count на верхнем уровне (приоритет)
|
||||||
if games_data.get('games_count', 0) > 0:
|
top_level_count = games_data.get('games_count', 0)
|
||||||
total_games = 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
|
has_games = True
|
||||||
logger.info(f"✅ Found {total_games} games via games_count field")
|
logger.info(f"✅ Found {total_games} games via games_count field")
|
||||||
# Также проверяем data.total.games_played
|
else:
|
||||||
elif games_data.get('data') and games_data.get('data', {}).get('total', {}).get('games_played', 0) > 0:
|
# Также проверяем data.total.games_played
|
||||||
total_games = games_data.get('data', {}).get('total', {}).get('games_played', 0)
|
games_data_dict = games_data.get('data')
|
||||||
has_games = True
|
if games_data_dict:
|
||||||
logger.info(f"✅ Found {total_games} games via data.total.games_played field")
|
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:
|
else:
|
||||||
logger.warning(f"⚠️ No games_data returned for {username}")
|
logger.warning(f"⚠️ No games_data returned for {username}")
|
||||||
|
|
||||||
|
|
@ -1623,8 +1660,23 @@ class LichessBot:
|
||||||
else:
|
else:
|
||||||
logger.debug(f"⏭️ No activity found for {gamer['username']} in the last {period_minutes} minutes")
|
logger.debug(f"⏭️ No activity found for {gamer['username']} in the last {period_minutes} minutes")
|
||||||
|
|
||||||
# Всегда обновляем время начала на текущее время после проверки (независимо от наличия активности)
|
# Обновляем время начала следующего периода на ПЛАНИРУЕМОЕ время окончания текущего периода
|
||||||
self.period_start_times[task_key] = now
|
# (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:
|
except asyncio.CancelledError:
|
||||||
logger.info(f"Periodic check cancelled for {gamer['username']}")
|
logger.info(f"Periodic check cancelled for {gamer['username']}")
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
IS_PROD = False
|
IS_PROD = True
|
||||||
|
|
||||||
# Telegram Bot Configuration (Production)
|
# Telegram Bot Configuration (Production)
|
||||||
TELEGRAM_BOT_TOKEN_PROD = "8241474807:AAH684LTY93aXRou4-LtqU5-p8LuEjzYn8U"
|
TELEGRAM_BOT_TOKEN_PROD = "8241474807:AAH684LTY93aXRou4-LtqU5-p8LuEjzYn8U"
|
||||||
|
|
@ -26,7 +26,9 @@ else:
|
||||||
|
|
||||||
# Lichess API Configuration
|
# Lichess API Configuration
|
||||||
LICHESS_API_BASE_URL = "https://lichess.org/api"
|
LICHESS_API_BASE_URL = "https://lichess.org/api"
|
||||||
LICHESS_STATS_API_BASE_URL = "http://localhost:8001" # For Docker container access
|
LICHESS_STATS_API_BASE_URL = "http://localhost:8002" # Host port for stats API when bot runs with host networking
|
||||||
|
# Минимальная задержка (сек) между запросами к Lichess в очереди мониторинга (избежание бана)
|
||||||
|
LICHESS_REQUEST_QUEUE_MIN_DELAY = 4.0
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
def _resolve_database_path() -> str:
|
def _resolve_database_path() -> str:
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ class Database:
|
||||||
token TEXT,
|
token TEXT,
|
||||||
is_active BOOLEAN DEFAULT FALSE,
|
is_active BOOLEAN DEFAULT FALSE,
|
||||||
period_minutes INTEGER DEFAULT 0,
|
period_minutes INTEGER DEFAULT 0,
|
||||||
|
last_period_end_ts INTEGER,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES telegram_users(user_id),
|
FOREIGN KEY (user_id) REFERENCES telegram_users(user_id),
|
||||||
FOREIGN KEY (gamer_id) REFERENCES gamers(id),
|
FOREIGN KEY (gamer_id) REFERENCES gamers(id),
|
||||||
|
|
@ -78,6 +79,13 @@ class Database:
|
||||||
# Column already exists
|
# Column already exists
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Add last_period_end_ts column to persist periodic checkpoint across restarts
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE user_gamers ADD COLUMN last_period_end_ts INTEGER")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# Column already exists
|
||||||
|
pass
|
||||||
|
|
||||||
# Create admin_settings table for admin bot configuration
|
# Create admin_settings table for admin bot configuration
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS admin_settings (
|
CREATE TABLE IF NOT EXISTS admin_settings (
|
||||||
|
|
@ -327,6 +335,39 @@ class Database:
|
||||||
)
|
)
|
||||||
conn.commit()
|
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:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT last_period_end_ts FROM user_gamers WHERE user_id = ? AND gamer_id = ?",
|
||||||
|
(user_id, gamer_id)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row or row[0] is None:
|
||||||
|
return None
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
def set_period_checkpoint(self, user_id: int, gamer_id: int, checkpoint_ts: int):
|
||||||
|
"""Persist period checkpoint timestamp (seconds since epoch)."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE user_gamers SET last_period_end_ts = ? WHERE user_id = ? AND gamer_id = ?",
|
||||||
|
(checkpoint_ts, user_id, gamer_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def clear_period_checkpoint(self, user_id: int, gamer_id: int):
|
||||||
|
"""Clear persisted period checkpoint."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE user_gamers SET last_period_end_ts = NULL WHERE user_id = ? AND gamer_id = ?",
|
||||||
|
(user_id, gamer_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
def remove_user_gamer(self, user_id: int, gamer_id: int) -> bool:
|
def remove_user_gamer(self, user_id: int, gamer_id: int) -> bool:
|
||||||
"""Remove gamer from user's tracked list"""
|
"""Remove gamer from user's tracked list"""
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@ class StatsFormatter:
|
||||||
emoji = StatsFormatter._get_game_type_emoji(game_type)
|
emoji = StatsFormatter._get_game_type_emoji(game_type)
|
||||||
games_count = game_data.get('games_played', 0)
|
games_count = game_data.get('games_played', 0)
|
||||||
rating_change = game_data.get('rating_change', 0)
|
rating_change = game_data.get('rating_change', 0)
|
||||||
rating = game_data.get('rating', 0)
|
rating = game_data.get('final_rating', game_data.get('rating', 0))
|
||||||
wins = game_data.get('wins', 0)
|
wins = game_data.get('wins', 0)
|
||||||
losses = game_data.get('losses', 0)
|
losses = game_data.get('losses', 0)
|
||||||
draws = game_data.get('draws', 0)
|
draws = game_data.get('draws', 0)
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 36 KiB |
|
|
@ -7,6 +7,8 @@ import logging
|
||||||
from typing import Callable, Any, Optional, Dict
|
from typing import Callable, Any, Optional, Dict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class RequestQueue:
|
class RequestQueue:
|
||||||
|
|
@ -139,6 +141,6 @@ def get_request_queue() -> RequestQueue:
|
||||||
"""Get the global request queue instance"""
|
"""Get the global request queue instance"""
|
||||||
global _request_queue
|
global _request_queue
|
||||||
if _request_queue is None:
|
if _request_queue is None:
|
||||||
_request_queue = RequestQueue(min_delay=7.0)
|
_request_queue = RequestQueue(min_delay=config.LICHESS_REQUEST_QUEUE_MIN_DELAY)
|
||||||
return _request_queue
|
return _request_queue
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ fi
|
||||||
|
|
||||||
# Check if the API service is running
|
# Check if the API service is running
|
||||||
echo "🔍 Checking Lichess API service..."
|
echo "🔍 Checking Lichess API service..."
|
||||||
if curl -s http://localhost:8001/docs > /dev/null 2>&1; then
|
if curl -s http://localhost:8002/docs > /dev/null 2>&1; then
|
||||||
echo "✅ Lichess API service is running on http://localhost:8001"
|
echo "✅ Lichess API service is running on http://localhost:8002"
|
||||||
else
|
else
|
||||||
echo "⚠️ Warning: Lichess API service is not accessible at http://localhost:8001"
|
echo "⚠️ Warning: Lichess API service is not accessible at http://localhost:8002"
|
||||||
echo " Make sure your API service is running before starting the bot."
|
echo " Make sure your API service is running before starting the bot."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,17 +121,15 @@ class LichessClient:
|
||||||
# Формируем URL для получения игр пользователя
|
# Формируем URL для получения игр пользователя
|
||||||
url = f"{self.base_url}/games/user/{username}"
|
url = f"{self.base_url}/games/user/{username}"
|
||||||
|
|
||||||
# Параметры запроса
|
# Параметры запроса. Параметр 'rated' в API Lichess не передаём:
|
||||||
|
# при rated=true API часто возвращает 0 игр даже для рейтинговых партий.
|
||||||
|
# Фильтрация по рейтинговости делается в stats_service после получения списка.
|
||||||
params = {
|
params = {
|
||||||
'since': since_ms, # Начало периода
|
'since': since_ms, # Начало периода
|
||||||
'until': until_ms, # Конец периода
|
'until': until_ms, # Конец периода
|
||||||
'max': 1000 # Максимум игр за запрос (лимит Lichess API)
|
'max': 1000 # Максимум игр за запрос (лимит Lichess API)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Добавляем фильтр по рейтинговым играм, если нужно
|
|
||||||
if rated_only:
|
|
||||||
params['rated'] = 'true'
|
|
||||||
|
|
||||||
# Заголовки для получения NDJSON формата
|
# Заголовки для получения NDJSON формата
|
||||||
headers = {
|
headers = {
|
||||||
'Accept': 'application/x-ndjson' # Запрашиваем NDJSON формат
|
'Accept': 'application/x-ndjson' # Запрашиваем NDJSON формат
|
||||||
|
|
|
||||||
|
|
@ -648,8 +648,8 @@ class StatsService:
|
||||||
since_ms = since_timestamp * 1000
|
since_ms = since_timestamp * 1000
|
||||||
until_ms = until_timestamp * 1000
|
until_ms = until_timestamp * 1000
|
||||||
|
|
||||||
# Получаем игры
|
# Получаем игры (без фильтра rated в запросе к Lichess — см. lichess_client)
|
||||||
games = await self.lichess_client.get_games_of_period(username, since_ms, until_ms, rated_only)
|
games = await self.lichess_client.get_games_of_period(username, since_ms, until_ms, rated_only=False)
|
||||||
|
|
||||||
if games is None:
|
if games is None:
|
||||||
return GamesOfPeriodResponse(
|
return GamesOfPeriodResponse(
|
||||||
|
|
@ -660,6 +660,10 @@ class StatsService:
|
||||||
games_count=0
|
games_count=0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Фильтр по рейтинговости на нашей стороне (API Lichess с param rated даёт неверный результат)
|
||||||
|
if rated_only:
|
||||||
|
games = [g for g in games if g.get('rated') is True]
|
||||||
|
|
||||||
if not games:
|
if not games:
|
||||||
return GamesOfPeriodResponse(
|
return GamesOfPeriodResponse(
|
||||||
message=f"Игры за указанный период не найдены",
|
message=f"Игры за указанный период не найдены",
|
||||||
|
|
|
||||||
144
check_recent_games.py
Normal file
144
check_recent_games.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Скрипт для проверки недавних игр пользователя через Lichess API"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def get_recent_games(username: str, minutes: int = 30):
|
||||||
|
"""Получить игры за последние N минут"""
|
||||||
|
|
||||||
|
# Вычисляем временные метки
|
||||||
|
now = datetime.now()
|
||||||
|
since_time = now - timedelta(minutes=minutes)
|
||||||
|
|
||||||
|
since_ms = int(since_time.timestamp() * 1000)
|
||||||
|
until_ms = int(now.timestamp() * 1000)
|
||||||
|
|
||||||
|
print(f"🔍 Проверяем игры для {username}")
|
||||||
|
print(f"⏰ Период: с {since_time.strftime('%Y-%m-%d %H:%M:%S')} до {now.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print(f"📅 Timestamps: since={since_ms}, until={until_ms}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Делаем запрос к Lichess API
|
||||||
|
url = f"https://lichess.org/api/games/user/{username}"
|
||||||
|
params = {
|
||||||
|
'since': since_ms,
|
||||||
|
'until': until_ms,
|
||||||
|
'max': 1000,
|
||||||
|
'rated': 'true' # Только рейтинговые
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Accept': 'application/x-ndjson'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, params=params, headers=headers, timeout=30)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
print(f"❌ Пользователь {username} не найден (404)")
|
||||||
|
return []
|
||||||
|
elif response.status_code != 200:
|
||||||
|
print(f"❌ Ошибка API: {response.status_code} - {response.text[:200]}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Парсим NDJSON
|
||||||
|
games = []
|
||||||
|
content = response.text.strip()
|
||||||
|
|
||||||
|
if content:
|
||||||
|
for line in content.split('\n'):
|
||||||
|
if line.strip():
|
||||||
|
try:
|
||||||
|
game = json.loads(line)
|
||||||
|
games.append(game)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"⚠️ Ошибка парсинга JSON: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return games
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка при запросе: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def format_game_time(created_at_ms: int) -> str:
|
||||||
|
"""Форматирует время создания игры"""
|
||||||
|
dt = datetime.fromtimestamp(created_at_ms / 1000)
|
||||||
|
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
def main():
|
||||||
|
username = sys.argv[1] if len(sys.argv) > 1 else "vrubelroman"
|
||||||
|
minutes = int(sys.argv[2]) if len(sys.argv) > 2 else 40
|
||||||
|
|
||||||
|
games = get_recent_games(username, minutes)
|
||||||
|
|
||||||
|
if not games:
|
||||||
|
print("❌ Игры не найдены")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"✅ Найдено игр: {len(games)}")
|
||||||
|
print()
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
for i, game in enumerate(games, 1):
|
||||||
|
game_id = game.get('id', 'N/A')
|
||||||
|
created_at_ms = game.get('createdAt', 0)
|
||||||
|
speed = game.get('speed', 'unknown')
|
||||||
|
rated = game.get('rated', False)
|
||||||
|
|
||||||
|
# Результат
|
||||||
|
white = game.get('players', {}).get('white', {})
|
||||||
|
black = game.get('players', {}).get('black', {})
|
||||||
|
white_user = white.get('user', {}).get('name', 'N/A')
|
||||||
|
black_user = black.get('user', {}).get('name', 'N/A')
|
||||||
|
winner = game.get('winner')
|
||||||
|
|
||||||
|
# Определяем результат для пользователя
|
||||||
|
if white_user == username:
|
||||||
|
result = "Победа" if winner == "white" else ("Поражение" if winner == "black" else "Ничья")
|
||||||
|
opponent = black_user
|
||||||
|
elif black_user == username:
|
||||||
|
result = "Победа" if winner == "black" else ("Поражение" if winner == "white" else "Ничья")
|
||||||
|
opponent = white_user
|
||||||
|
else:
|
||||||
|
result = "N/A"
|
||||||
|
opponent = "N/A"
|
||||||
|
|
||||||
|
game_time = format_game_time(created_at_ms)
|
||||||
|
time_ago = now - datetime.fromtimestamp(created_at_ms / 1000)
|
||||||
|
minutes_ago = int(time_ago.total_seconds() / 60)
|
||||||
|
|
||||||
|
print(f"\n🎮 Игра #{i}")
|
||||||
|
print(f" ID: {game_id}")
|
||||||
|
print(f" Время: {game_time} ({minutes_ago} минут назад)")
|
||||||
|
print(f" Скорость: {speed}")
|
||||||
|
print(f" Рейтинговая: {'Да' if rated else 'Нет'}")
|
||||||
|
print(f" Соперник: {opponent}")
|
||||||
|
print(f" Результат: {result}")
|
||||||
|
|
||||||
|
# Рейтинг
|
||||||
|
if white_user == username:
|
||||||
|
rating_change = white.get('ratingDiff', 0)
|
||||||
|
final_rating = white.get('rating', 0)
|
||||||
|
elif black_user == username:
|
||||||
|
rating_change = black.get('ratingDiff', 0)
|
||||||
|
final_rating = black.get('rating', 0)
|
||||||
|
else:
|
||||||
|
rating_change = 0
|
||||||
|
final_rating = 0
|
||||||
|
|
||||||
|
if rating_change != 0 or final_rating != 0:
|
||||||
|
print(f" Рейтинг: {final_rating} ({rating_change:+d})")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"\n📊 Всего игр за последние {minutes} минут: {len(games)}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
@ -4,17 +4,18 @@ services:
|
||||||
build: ./LichessWebServices
|
build: ./LichessWebServices
|
||||||
container_name: lichess-api
|
container_name: lichess-api
|
||||||
ports:
|
ports:
|
||||||
- "8001:8000"
|
- "8002:8000"
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
volumes:
|
volumes:
|
||||||
- ./LichessWebServices:/app
|
- ./LichessWebServices:/app
|
||||||
restart: always
|
restart: always
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
# Telegram Bot
|
# Telegram Bot
|
||||||
lichess-bot:
|
lichess-bot:
|
||||||
|
|
@ -31,7 +32,7 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- lichess-api
|
- lichess-api
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8001/health', timeout=5)"]
|
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8002/health', timeout=5)"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
@ -73,4 +74,3 @@ networks:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
0
export_db.sh
Normal file → Executable file
0
export_db.sh
Normal file → Executable file
6
logs.sh
Executable file
6
logs.sh
Executable file
|
|
@ -0,0 +1,6 @@
|
||||||
|
GNU nano 6.2 logchecker.sh
|
||||||
|
# Последние 500 строк, отфильтрованные по периодическим проверкам
|
||||||
|
docker logs --tail=1000 lichess-telegram-bot 2>&1 | grep -E "periodic|Checking activity|queue|Activity detected"
|
||||||
|
# Смотреть логи периодических проверок в реальном времени
|
||||||
|
#docker logs -f lichess-telegram-bot 2>&1 | grep --line-buffered -E "🔄|⏳|🔍|📥|✅|📊|periodic|queue|Activity"
|
||||||
|
docker logs -f lichess-telegram-bot 2>&1 | grep --line-buffered -E "Checking activity for|Games data structure for"
|
||||||
3
start.sh
3
start.sh
|
|
@ -53,7 +53,7 @@ echo ""
|
||||||
echo "✅ Все сервисы запущены!"
|
echo "✅ Все сервисы запущены!"
|
||||||
echo ""
|
echo ""
|
||||||
echo "🌐 Доступные сервисы:"
|
echo "🌐 Доступные сервисы:"
|
||||||
echo " - API документация: http://localhost:8001/docs"
|
echo " - API документация: http://localhost:8002/docs"
|
||||||
echo " - Веб-интерфейс: http://localhost:5000"
|
echo " - Веб-интерфейс: http://localhost:5000"
|
||||||
echo ""
|
echo ""
|
||||||
echo "📋 Для просмотра логов используйте:"
|
echo "📋 Для просмотра логов используйте:"
|
||||||
|
|
@ -64,4 +64,3 @@ echo " ${COMPOSE_CMD_DISPLAY} down"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue