diff --git a/bots/client_bot.py b/bots/client_bot.py index 58326d6..ccc220c 100644 --- a/bots/client_bot.py +++ b/bots/client_bot.py @@ -291,8 +291,7 @@ async def add_player(update: Update, context: ContextTypes.DEFAULT_TYPE): async def cancel_sub(update: Update, context: ContextTypes.DEFAULT_TYPE): had_fide = context.user_data.pop('awaiting_fide_id', None) - had_pending = context.user_data.pop('pending_fide', None) - if had_fide or had_pending: + if had_fide: await update.message.reply_text('❎ Подписка отменена\.') else: await update.message.reply_text('Нет активного процесса подписки\.') @@ -300,20 +299,42 @@ async def cancel_sub(update: Update, context: ContextTypes.DEFAULT_TYPE): async def my_players(update: Update, context: ContextTypes.DEFAULT_TYPE): user = update.effective_user - subs = tracker.get_user_subs(user.id) - if not subs: + players = tracker.get_user_subs(user.id) + if not players: await update.message.reply_text('У вас нет активных подписок\.\n' - '/addplayer — добавить игрока') + '/addplayer — добавить игрока', + parse_mode=ParseMode.MARKDOWN_V2) return - lines = ['*Ваши подписки:*'] - for s in subs: - safe_name = _md_escape(s['player_name']) - safe_tour = _md_escape(s['tournament_name'] or '—') - last_pts_str = _md_escape(f'{s["last_points"]:.1f}') - lines.append( - f' {s["id"]}\\. {safe_name} \({s["rating"]}, {s["fed"]}\) ' - f'— {safe_tour} — {last_pts_str} очк\\.') - lines.append('\n/removeplayer <номер> — удалить подписку') + + sep = '━' * 22 + lines = [f'*Ваши игроки \\({len(players)}\\):*'] + for p in players: + safe_name = _md_escape(p['player_name']) + fed = p['fed'] or '' + rating = p['rating'] or '—' + fide_id = p['fide_id'] + + lines.append(sep) + lines.append(f'👤 *{safe_name}*') + lines.append(f'🏳 {_md_escape(fed)} · {rating}') + + tours = p['tournaments'] + waiting = all(t['name'] is None for t in tours) + if waiting: + lines.append('⏳ _Ожидаю турнир_') + else: + for t in tours: + if t['name'] is None: + continue + safe_tour = _md_escape(t['name']) + pts = _md_escape(f'{t["points"]:.1f}') + rd = t['round_done'] + lines.append(f'📌 {safe_tour}') + lines.append(f' {pts} очк\\., тур {rd}') + + lines.append(f'🗑 /removeplayer {fide_id}') + + lines.append(sep) await update.message.reply_text('\n'.join(lines), parse_mode=ParseMode.MARKDOWN_V2) @@ -323,16 +344,18 @@ async def remove_player(update: Update, context: ContextTypes.DEFAULT_TYPE): parts = update.message.text.strip().split() if len(parts) < 2 or not parts[1].isdigit(): await update.message.reply_text( - 'Использование: `/removeplayer <номер>`\n' - '/myplayers — посмотреть список с номерами', + 'Использование: `/removeplayer FIDE_ID`\n' + '/myplayers — посмотреть список', parse_mode=ParseMode.MARKDOWN_V2) return - sub_id = int(parts[1]) - ok = tracker.remove_subscription(sub_id, user.id) + fide_id = int(parts[1]) + ok = tracker.remove_player_subscriptions(fide_id, user.id) if ok: - await update.message.reply_text('✅ Подписка удалена\.') + await update.message.reply_text('✅ Игрок удалён из подписок\.', + parse_mode=ParseMode.MARKDOWN_V2) else: - await update.message.reply_text('Подписка не найдена\. /myplayers — список') + await update.message.reply_text('Игрок не найден\. /myplayers — список', + parse_mode=ParseMode.MARKDOWN_V2) def _extract_fide_id(text: str) -> int | None: @@ -362,10 +385,12 @@ async def _lookup_and_confirm(update: Update, context, fide_id: int): return safe_name = _md_escape(player['name']) - title_str = f', {player["title"]}' if player.get('title') else '' + title = player.get('title') or '' + title_str = f', {_md_escape(title)}' if title and title != 'None' else '' + fed = player.get('fed') or '' await update.message.reply_text( f'✅ *{safe_name}*{title_str}\n' - f'{"🇫 " if player["fed"] else ""}{player["fed"]} \\| Рейтинг: {player["rating"]}\n\n' + f'{"🇫 " if fed else ""}{_md_escape(fed)} \\| Рейтинг: {player["rating"]}\n\n' '🔍 Ищу турниры на chess\\-results\\.com\\.\\.', parse_mode=ParseMode.MARKDOWN_V2) @@ -385,7 +410,7 @@ async def _lookup_and_confirm(update: Update, context, fide_id: int): 0, 0.0, lang_code, t['start_date'], t['end_date']) dates = '' if t['start_date'] and t['end_date']: - dates = f' \({t["start_date"]} — {t["end_date"]}\)' + dates = f' \\({_md_escape(t["start_date"])} — {_md_escape(t["end_date"])}\\)' safe_tour = _md_escape(t['name']) lines.append(f' • {safe_tour}{dates}') lines.append('\nЯ буду присылать результаты и жеребьёвку\.\n' @@ -393,16 +418,14 @@ async def _lookup_and_confirm(update: Update, context, fide_id: int): await update.message.reply_text('\n'.join(lines), parse_mode=ParseMode.MARKDOWN_V2) else: - # No tournaments found — fallback to manual URL - context.user_data['pending_fide'] = { - 'player': player, - 'lang': lang_code, - } + # No active tournament found — watch for future ones + tracker.add_player_watch(user.id, player, lang_code) + safe_name = _md_escape(player['name']) await update.message.reply_text( - '❌ Турниров не найдено автоматически\\.\n\n' - 'Отправь ссылку на турнир chess\\-results\\.com, ' - 'где играет этот игрок\\.\n' - '/cancel — отмена', + f'👁 *{safe_name}* поставлен на отслеживание\.\n\n' + 'Активного турнира сейчас не найдено\\. ' + 'Как только игрок появится в новом турнире — сразу подпишу и пришлю жеребьёвку\\.\n\n' + '/myplayers \\- список подписок', parse_mode=ParseMode.MARKDOWN_V2) @@ -479,50 +502,6 @@ def _apply_round_results(tournament, games, rd): tournament['current_round'] = rd -async def _subscribe_to_tournament(update: Update, context, url: str, pending: dict): - user = update.effective_user - player = pending['player'] - lang_code = pending['lang'] - - msg = await update.message.reply_text('⏳ Проверяю турнир\.\.\.', - parse_mode=ParseMode.MARKDOWN_V2) - - try: - tournament = fetch_tournament(url) - except Exception as e: - await msg.edit_text(f'❌ Ошибка загрузки турнира: {_md_escape(str(e))}', - parse_mode=ParseMode.MARKDOWN_V2) - context.user_data['pending_fide'] = pending - return - - p = tracker.find_player(tournament, player['name']) - if p is None: - safe_name = _md_escape(player['name']) - await msg.edit_text( - f'❌ *{safe_name}* не найден в этом турнире\\.\n' - 'Отправь другой URL или /cancel', - parse_mode=ParseMode.MARKDOWN_V2) - context.user_data['pending_fide'] = pending - return - - p_sno = p.get('starting_sno', 0) - results_count = len(p.get('results', [])) - points = p.get('points', 0.0) - tracker.add_subscription( - user.id, player, url, - tournament.get('name', ''), - p_sno, results_count, points, lang_code) - - safe_name = _md_escape(player['name']) - safe_tour = _md_escape(tournament.get('name', '')) - await msg.edit_text( - f'✅ *{safe_name}* найден в турнире\\.\n' - f'📅 *{safe_tour}*\n' - f'Ст\\.№ {p_sno}, {_md_escape(f"{points:.1f}")} очк\\. — отслеживаю\!\n\n' - 'Я буду присылать результаты игрока и жеребьёвку следующих туров\.\n' - '/myplayers — список подписок', - parse_mode=ParseMode.MARKDOWN_V2) - async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE): user = update.effective_user @@ -530,8 +509,6 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE): record_user(user.id, user.username or '', user.first_name or '', user.last_name or '') - # State machine: waiting for FIDE ID → tournament URL → regular pairings - # Step 1: user just called /addplayer — waiting for FIDE ID if context.user_data.pop('awaiting_fide_id', None): fide_id = _extract_fide_id(text) @@ -546,24 +523,7 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE): await _lookup_and_confirm(update, context, fide_id) return - # Step 2: player confirmed — waiting for tournament URL - pending = context.user_data.get('pending_fide') - if pending: - match = URL_PATTERN.search(text) - if not match: - await update.message.reply_text( - 'Не нашёл ссылку на chess\\-results\\.com в сообщении\\.\n\n' - 'Пришли ссылку вида:\n' - '`https://chess\\-results\\.com/tnr1393124\\.aspx?lan=11`\n\n' - '/cancel — отмена', - parse_mode=ParseMode.MARKDOWN_V2) - return - url = match.group(0) - context.user_data.pop('pending_fide', None) - await _subscribe_to_tournament(update, context, url, pending) - return - - # Step 3: regular pairings request + # Step 2: regular pairings request match = URL_PATTERN.search(text) if not match: await update.message.reply_text( @@ -718,7 +678,7 @@ def main(): app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_url)) app.job_queue.run_repeating( - tracker.check_all_subscriptions, interval=300, first=10) + tracker.check_all_subscriptions, interval=60, first=10) app.job_queue.run_repeating( tracker.rescan_new_tournaments, interval=3600, first=60) app.job_queue.run_repeating( diff --git a/bots/tracker.py b/bots/tracker.py index cbcab6c..47d3eca 100644 --- a/bots/tracker.py +++ b/bots/tracker.py @@ -148,6 +148,25 @@ def fetch_fide_player(fide_id: int) -> dict: # ═══ Subscription CRUD ═══ +def add_player_watch(user_id: int, player: dict, lang: str): + """Subscribe to a player with no active tournament yet. + + rescan_new_tournaments will auto-upgrade this to a real subscription + when a tournament appears. + """ + conn = _get_conn() + conn.execute(''' + INSERT OR IGNORE INTO subscriptions + (user_id, fide_id, player_name, rating, fed, lang, tournament_url, + tournament_name, player_sno, last_results_count, last_points, + last_round_done, active) + VALUES (?, ?, ?, ?, ?, ?, 'fide_only', '', 0, 0, 0.0, 0, 1) + ''', (user_id, player['fide_id'], player['name'], player.get('rating', 0), + player.get('fed', ''), lang)) + conn.commit() + conn.close() + + def add_subscription(user_id: int, player: dict, tournament_url: str, tournament_name: str, player_sno: int, last_results_count: int, last_points: float, @@ -167,16 +186,33 @@ def add_subscription(user_id: int, player: dict, tournament_url: str, def get_user_subs(user_id: int) -> list: + """Return subscriptions grouped by player (fide_id).""" conn = _get_conn() rows = conn.execute( - 'SELECT id, player_name, rating, fed, tournament_name, ' - 'last_points, last_round_done, active FROM subscriptions ' - 'WHERE user_id = ? AND active = 1 ORDER BY id', + 'SELECT fide_id, player_name, rating, fed, tournament_name, ' + 'last_points, last_round_done, tournament_url FROM subscriptions ' + 'WHERE user_id = ? AND active = 1 ORDER BY fide_id, id', (user_id,)).fetchall() conn.close() - return [{'id': r[0], 'player_name': r[1], 'rating': r[2], 'fed': r[3], - 'tournament_name': r[4], 'last_points': r[5], - 'last_round_done': r[6], 'active': r[7]} for r in rows] + + players: dict = {} + for r in rows: + fide_id, pname, rating, fed, tname, pts, rd, url = r + if fide_id not in players: + players[fide_id] = { + 'fide_id': fide_id, 'player_name': pname, + 'rating': rating, 'fed': fed or '', + 'tournaments': [], + } + if url == 'fide_only': + players[fide_id]['tournaments'].append({ + 'name': None, 'points': pts, 'round_done': rd, + }) + else: + players[fide_id]['tournaments'].append({ + 'name': tname or url, 'points': pts, 'round_done': rd, + }) + return list(players.values()) def get_active_subs() -> list: @@ -205,6 +241,18 @@ def remove_subscription(sub_id: int, user_id: int) -> bool: return deleted +def remove_player_subscriptions(fide_id: int, user_id: int) -> bool: + """Deactivate all subscriptions for a player (by FIDE ID).""" + conn = _get_conn() + cur = conn.execute( + 'UPDATE subscriptions SET active = 0 WHERE fide_id = ? AND user_id = ?', + (fide_id, user_id)) + conn.commit() + deleted = cur.rowcount > 0 + conn.close() + return deleted + + def update_result(sub_id: int, results_count: int, points: float): conn = _get_conn() conn.execute( @@ -508,6 +556,13 @@ def discover_max_tnr() -> int: saved = get_tnr_state('max_tnr_seen', str(DEFAULT_MAX_TNR)) tnr = int(saved) + # Use max cached TNR as a better starting point (avoids stuck-at-500K problem) + conn = _get_conn() + row = conn.execute('SELECT MAX(tnr) FROM tnr_cache').fetchone() + conn.close() + if row and row[0]: + tnr = max(tnr, row[0]) + for step in [10000, 1000, 100, 50, 20, 10, 5, 1]: while True: parsed = _fetch_and_parse_art0(tnr + step) @@ -535,14 +590,14 @@ def scan_for_player(fide_id: int, max_tnr: int = None, results = [] seen_tnrs = set() - # Phase 1: check tnr_cache via SQL (single query, instant). Return if found. + # Phase 1: check full tnr_cache via SQL (single query, instant). Return if found. conn = _get_conn() pattern = f'%"{fide_id}"%' rows = conn.execute( 'SELECT tnr, name, start_date, end_date, players_json FROM tnr_cache ' - 'WHERE tnr >= ? AND tnr <= ? AND players_json LIKE ? ' + 'WHERE players_json LIKE ? ' 'ORDER BY tnr DESC', - (1434000, 1450000, pattern)).fetchall() + (pattern,)).fetchall() conn.close() for row in rows: tnr, tname, tstart, tend, players_json = row @@ -769,6 +824,8 @@ async def check_all_subscriptions(context): today_str = date.today().isoformat() for sub in subs: + if sub['tournament_url'] == 'fide_only': + continue try: tournament = fetch_tournament(sub['tournament_url']) except Exception: @@ -846,6 +903,14 @@ async def rescan_new_tournaments(context): add_subscription( user_id, player, t['url'], t['name'], t['sno'], 0, 0.0, lang, t['start_date'], t['end_date']) + # Deactivate the fide_only watch entry now that we have a real tournament + conn = _get_conn() + conn.execute( + "UPDATE subscriptions SET active = 0 " + "WHERE user_id = ? AND fide_id = ? AND tournament_url = 'fide_only'", + (user_id, fide_id)) + conn.commit() + conn.close() try: safe_name = _md_escape_tracker(t['player_name']) safe_tour = _md_escape_tracker(t['name'])