diff --git a/bots/client_bot.py b/bots/client_bot.py index 8901318..c18584d 100644 --- a/bots/client_bot.py +++ b/bots/client_bot.py @@ -16,6 +16,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from swiss_calc.parser import fetch_tournament from swiss_calc.swiss import calculate_next_round from bots.stats import record_user, record_request +from bots import tracker TOKEN = os.environ.get('CLIENT_BOT_TOKEN', '') @@ -228,6 +229,107 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text(START_MSG, parse_mode=ParseMode.MARKDOWN_V2) +async def add_player(update: Update, context: ContextTypes.DEFAULT_TYPE): + user = update.effective_user + record_user(user.id, user.username or '', user.first_name or '', + user.last_name or '') + context.user_data['awaiting_fide_id'] = True + await update.message.reply_text( + 'Пришли мне FIDE ID игрока \(число\) или ссылку на профиль FIDE:\n' + r'`https://ratings\.fide\.com/profile/1503014`\n' + '\n' + '/cancel — отмена', + parse_mode=ParseMode.MARKDOWN_V2) + + +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: + await update.message.reply_text('❎ Подписка отменена\.') + else: + await update.message.reply_text('Нет активного процесса подписки\.') + + +async def my_players(update: Update, context: ContextTypes.DEFAULT_TYPE): + user = update.effective_user + subs = tracker.get_user_subs(user.id) + if not subs: + await update.message.reply_text('У вас нет активных подписок\.\n' + '/addplayer — добавить игрока') + return + lines = ['*Ваши подписки:*'] + for s in subs: + safe_name = _md_escape(s['player_name']) + safe_tour = _md_escape(s['tournament_name'] or '—') + lines.append( + f' {s["id"]}\\. {safe_name} \({s["rating"]}, {s["fed"]}\) ' + f'— {safe_tour} — {s["last_points"]:.1f} очк\\.') + lines.append('\n/removeplayer <номер> — удалить подписку') + await update.message.reply_text('\n'.join(lines), + parse_mode=ParseMode.MARKDOWN_V2) + + +async def remove_player(update: Update, context: ContextTypes.DEFAULT_TYPE): + user = update.effective_user + parts = update.message.text.strip().split() + if len(parts) < 2 or not parts[1].isdigit(): + await update.message.reply_text( + 'Использование: `/removeplayer <номер>`\n' + '/myplayers — посмотреть список с номерами', + parse_mode=ParseMode.MARKDOWN_V2) + return + sub_id = int(parts[1]) + ok = tracker.remove_subscription(sub_id, user.id) + if ok: + await update.message.reply_text('✅ Подписка удалена\.') + else: + await update.message.reply_text('Подписка не найдена\. /myplayers — список') + + +def _extract_fide_id(text: str) -> int | None: + """Extract FIDE ID from plain number or profile URL.""" + m = re.search(r'profile/(\d+)', text) + if m: + return int(m.group(1)) + # Try plain number (between 6-8 digits) + for token in text.split(): + if token.isdigit() and 100000 <= int(token) <= 9999999999: + return int(token) + return None + + +async def _lookup_and_confirm(update: Update, context, fide_id: int): + user = update.effective_user + lang_code = user.language_code or '' + + try: + player = tracker.fetch_fide_player(fide_id) + except Exception as e: + await update.message.reply_text( + f'❌ Не удалось найти игрока: {_md_escape(str(e))}\n' + 'Попробуй другой ID или /cancel', + parse_mode=ParseMode.MARKDOWN_V2) + context.user_data['awaiting_fide_id'] = True + return + + safe_name = _md_escape(player['name']) + title_str = f', {player["title"]}' if player.get('title') else '' + + msg = ( + f'✅ *{safe_name}*{title_str}\n' + f'{"🇫 " if player["fed"] else ""}{player["fed"]} \\| Рейтинг: {player["rating"]}\n\n' + 'Теперь отправь ссылку на турнир chess\\-results\\.com, ' + 'где играет этот игрок\.\n' + '/cancel — отмена' + ) + context.user_data['pending_fide'] = { + 'player': player, + 'lang': lang_code, + } + await update.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN_V2) + + def _apply_round_results(tournament, games, rd): standings = tournament['standings'] sno_to_standing = {s['starting_sno']: s for s in standings if s.get('starting_sno')} @@ -301,12 +403,91 @@ 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}, {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 text = update.message.text.strip() 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) + if fide_id is None: + await update.message.reply_text( + 'Не удалось распознать FIDE ID\. Отправь число или ссылку вида\n' + r'`https://ratings\.fide\.com/profile/1503014`' '\n' + '/cancel — отмена', + parse_mode=ParseMode.MARKDOWN_V2) + context.user_data['awaiting_fide_id'] = True + return + 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 match = URL_PATTERN.search(text) if not match: await update.message.reply_text( @@ -441,8 +622,15 @@ def main(): app = Application.builder().token(TOKEN).build() app.add_handler(CommandHandler('start', start)) + app.add_handler(CommandHandler('addplayer', add_player)) + app.add_handler(CommandHandler('cancel', cancel_sub)) + app.add_handler(CommandHandler('myplayers', my_players)) + app.add_handler(CommandHandler('removeplayer', remove_player)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_url)) + app.job_queue.run_repeating( + tracker.check_all_subscriptions, interval=300, first=10) + print('Client bot started', file=sys.stderr) app.run_polling() diff --git a/bots/tracker.py b/bots/tracker.py new file mode 100644 index 0000000..477fb78 --- /dev/null +++ b/bots/tracker.py @@ -0,0 +1,331 @@ +""" +Player tracking: FIDE profile lookup, subscription management, +background polling for new results and pairings. +""" +import re +import os +import sys +import sqlite3 +import requests +from pathlib import Path +from datetime import date + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +DB_PATH = os.environ.get('STATS_DB', '/app/data/tournaments.db') +FIDE_URL = 'https://ratings.fide.com/profile/{fide_id}' +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' +} + +RESULT_LOCALE = { + 'ru': { + 'win': 'победа', 'draw': 'ничья', 'loss': 'поражение', + 'white': 'белыми', 'black': 'чёрными', + 'points': 'Очков', 'round': 'тур', 'vs': 'против', + }, + 'en': { + 'win': 'win', 'draw': 'draw', 'loss': 'loss', + 'white': 'White', 'black': 'Black', + 'points': 'Points', 'round': 'round', 'vs': 'vs', + }, +} + + +def _get_conn(): + p = Path(DB_PATH) + try: + p.parent.mkdir(parents=True, exist_ok=True) + except PermissionError: + fallback = Path(__file__).parent.parent / 'data' / 'tournaments.db' + fallback.parent.mkdir(parents=True, exist_ok=True) + p = fallback + conn = sqlite3.connect(str(p)) + conn.execute('PRAGMA journal_mode=WAL') + conn.execute(''' + CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + fide_id INTEGER NOT NULL, + player_name TEXT NOT NULL, + rating INTEGER DEFAULT 0, + fed TEXT DEFAULT '', + lang TEXT DEFAULT 'en', + tournament_url TEXT NOT NULL, + tournament_name TEXT DEFAULT '', + player_sno INTEGER DEFAULT 0, + last_results_count INTEGER DEFAULT 0, + last_points REAL DEFAULT 0.0, + last_round_done INTEGER DEFAULT 0, + active INTEGER DEFAULT 1, + UNIQUE(user_id, fide_id, tournament_url) + ) + ''') + conn.commit() + return conn + + +# ═══ FIDE profile scraper ═══ + +def fetch_fide_player(fide_id: int) -> dict: + url = FIDE_URL.format(fide_id=fide_id) + resp = requests.get(url, headers=HEADERS, timeout=20) + if resp.status_code != 200: + raise RuntimeError(f'FIDE profile not found (HTTP {resp.status_code})') + html = resp.text + + # Check for "No record found" + if 'No record found' in html: + raise RuntimeError(f'Player with FIDE ID {fide_id} not found') + + # Name:

Carlsen, Magnus

+ name = '' + m = re.search(r']*>\s*(.*?)\s*', html) + if m: + name = m.group(1).strip() + name = re.sub(r'<[^>]+>', '', name).strip() + if not name: + raise RuntimeError('Could not extract player name from FIDE profile') + + # Federation: "National Rank NOR" + fed = '' + m = re.search(r'National Rank\s+([A-Z]{3})', html) + if m: + fed = m.group(1) + + # Rating (standard):

2841

STANDARD + rating = 0 + m = re.search(r'

(\d{3,4})

\s*]*>\s*STANDARD', html) + if m: + rating = int(m.group(1)) + + # Title:

Grandmaster

+ title = '' + m = re.search(r'\s*

(.*?)

', html) + if m: + title = m.group(1).strip() + # Some players have multiple titles in adjacent

— take only the first one + title = re.sub(r'<[^>]+>', '', title).strip() + + return { + 'fide_id': fide_id, 'name': name, 'rating': rating, + 'fed': fed, 'title': title, + } + + +# ═══ Subscription CRUD ═══ + +def add_subscription(user_id: int, player: dict, tournament_url: str, + tournament_name: str, player_sno: int, + last_results_count: int, last_points: float, + lang: str): + conn = _get_conn() + conn.execute(''' + INSERT OR REPLACE 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 1) + ''', (user_id, player['fide_id'], player['name'], player['rating'], + player.get('fed', ''), lang, tournament_url, tournament_name, + player_sno, last_results_count, last_points)) + conn.commit() + conn.close() + + +def get_user_subs(user_id: int) -> list: + 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', + (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] + + +def get_active_subs() -> list: + conn = _get_conn() + rows = conn.execute( + 'SELECT id, user_id, fide_id, player_name, rating, fed, lang, ' + 'tournament_url, tournament_name, player_sno, last_results_count, ' + 'last_points, last_round_done FROM subscriptions ' + 'WHERE active = 1').fetchall() + conn.close() + return [dict(zip( + ['id', 'user_id', 'fide_id', 'player_name', 'rating', 'fed', 'lang', + 'tournament_url', 'tournament_name', 'player_sno', + 'last_results_count', 'last_points', 'last_round_done'], r)) + for r in rows] + + +def remove_subscription(sub_id: int, user_id: int) -> bool: + conn = _get_conn() + cur = conn.execute( + 'UPDATE subscriptions SET active = 0 WHERE id = ? AND user_id = ?', + (sub_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( + 'UPDATE subscriptions SET last_results_count = ?, last_points = ? ' + 'WHERE id = ?', + (results_count, points, sub_id)) + conn.commit() + conn.close() + + +def update_round(sub_id: int, round_num: int): + conn = _get_conn() + conn.execute( + 'UPDATE subscriptions SET last_round_done = ? WHERE id = ?', + (round_num, sub_id)) + conn.commit() + conn.close() + + +# ═══ Tournament player lookup ═══ + +def _normalize(s: str) -> str: + return re.sub(r'\s+', ' ', s.strip()).lower() + + +def find_player(tournament: dict, name: str, sno: int = 0) -> dict | None: + standings = tournament.get('standings', []) + + # Try exact SNo match first + if sno: + for s in standings: + if s.get('starting_sno') == sno: + return s + + # Try exact name match (normalized) + norm_target = _normalize(name) + for s in standings: + if _normalize(s['name']) == norm_target: + return s + + # Fuzzy: split into parts, find best overlap + target_parts = set(norm_target.split()) + best, best_overlap = None, 0 + for s in standings: + s_parts = set(_normalize(s['name']).split()) + overlap = len(target_parts & s_parts) + if overlap > best_overlap: + best_overlap = overlap + best = s + + if best and best_overlap >= 1: + return best + + return None + + +def find_opponent_name(tournament: dict, opp_sno: int) -> str: + if not opp_sno: + return 'BYE' + standings = tournament.get('standings', []) + for s in standings: + if s.get('starting_sno') == opp_sno: + return s['name'] + return f'#{opp_sno}' + + +# ═══ Result formatting ═══ + +def _md_escape_tracker(text: str) -> str: + escape_chars = r'_*[]()~`>#+-=|{}.!' + result = [] + for ch in text: + if ch in escape_chars: + result.append('\\' + ch) + else: + result.append(ch) + return ''.join(result) + + +def format_result(player_name: str, result: dict, opponent_name: str, + points: float, lang: str) -> str: + loc = RESULT_LOCALE.get(lang, RESULT_LOCALE['en']) + score = result.get('score', 0.0) + color = result.get('color', '') + rd = result.get('round', '?') + + score_label = {1.0: loc['win'], 0.5: loc['draw'], 0.0: loc['loss']} + score_desc = score_label.get(score, f'{score}') + color_desc = loc['white'] if color == 'w' else loc['black'] + + safe_name = _md_escape_tracker(player_name) + safe_opp = _md_escape_tracker(opponent_name) + + return ( + f'♟ *{safe_name}* — {loc["round"]} {rd}\n' + f'{score_desc} \({color_desc}\) {loc["vs"]} {safe_opp}\n' + f'{loc["points"]}: {points:.1f}' + ) + + +# ═══ Background check ═══ + +async def check_all_subscriptions(context): + from swiss_calc.parser import fetch_tournament + from swiss_calc.swiss import calculate_next_round + from bots.client_bot import format_pairings, _render_chunks + from telegram.constants import ParseMode + + subs = get_active_subs() + if not subs: + return + + today_str = date.today().isoformat() + for sub in subs: + try: + tournament = fetch_tournament(sub['tournament_url']) + except Exception: + continue + + player = find_player(tournament, sub['player_name'], sub['player_sno']) + if player is None: + continue + + results_count = len(player.get('results', [])) + points = player.get('points', 0.0) + + # New individual result detected + if results_count > sub['last_results_count']: + new_r = player['results'][-1] + opp_name = find_opponent_name(tournament, new_r.get('opponent', 0)) + msg = format_result( + player['name'], new_r, opp_name, points, sub['lang']) + try: + await context.bot.send_message( + sub['user_id'], msg, parse_mode=ParseMode.MARKDOWN_V2) + except Exception: + pass + update_result(sub['id'], results_count, points) + + # Round fully completed → calculate next round pairings + current_rd = tournament.get('current_round', 0) + if current_rd > sub['last_round_done'] and current_rd < tournament.get('num_rounds', 0): + try: + result = calculate_next_round(tournament) + fmt = format_pairings(result, tournament.get('name', ''), sub['lang']) + chunks = _render_chunks(fmt) + for chunk in chunks: + try: + await context.bot.send_message( + sub['user_id'], chunk, + parse_mode=ParseMode.MARKDOWN_V2, + disable_web_page_preview=True) + except Exception: + break + except Exception: + pass + update_round(sub['id'], current_rd)