fix: player tracking subscription flow and /myplayers display
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 4s

- scan_for_player: remove hardcoded TNR range (1434k-1450k), search full cache
- discover_max_tnr: start from cache MAX(tnr) instead of stuck-at-500k value
- addplayer: if no tournament found, add fide_only watch instead of asking for URL
- rescan_new_tournaments: deactivate fide_only entry when real tournament found
- /myplayers: card layout grouped by player, removal by FIDE ID
- /removeplayer: now accepts FIDE ID, removes all subscriptions for that player
- fix MarkdownV2 escaping: dates (hyphens), <> in messages, title='None'
- check_all_subscriptions: skip fide_only entries; interval 5min → 1min

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
vrubel 2026-06-20 22:54:01 +00:00
parent 282c651821
commit 293c1d90c8
2 changed files with 131 additions and 106 deletions

View file

@ -291,8 +291,7 @@ async def add_player(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def cancel_sub(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_fide = context.user_data.pop('awaiting_fide_id', None)
had_pending = context.user_data.pop('pending_fide', None) if had_fide:
if had_fide or had_pending:
await update.message.reply_text('❎ Подписка отменена\.') await update.message.reply_text('❎ Подписка отменена\.')
else: else:
await update.message.reply_text('Нет активного процесса подписки\.') 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): async def my_players(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.effective_user user = update.effective_user
subs = tracker.get_user_subs(user.id) players = tracker.get_user_subs(user.id)
if not subs: if not players:
await update.message.reply_text('У вас нет активных подписок\.\n' await update.message.reply_text('У вас нет активных подписок\.\n'
'/addplayer — добавить игрока') '/addplayer — добавить игрока',
parse_mode=ParseMode.MARKDOWN_V2)
return return
lines = ['*Ваши подписки:*']
for s in subs: sep = '' * 22
safe_name = _md_escape(s['player_name']) lines = [f'*Ваши игроки \\({len(players)}\\):*']
safe_tour = _md_escape(s['tournament_name'] or '') for p in players:
last_pts_str = _md_escape(f'{s["last_points"]:.1f}') safe_name = _md_escape(p['player_name'])
lines.append( fed = p['fed'] or ''
f' {s["id"]}\\. {safe_name} \({s["rating"]}, {s["fed"]}\) ' rating = p['rating'] or ''
f'{safe_tour}{last_pts_str} очк\\.') fide_id = p['fide_id']
lines.append('\n/removeplayer <номер> — удалить подписку')
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), await update.message.reply_text('\n'.join(lines),
parse_mode=ParseMode.MARKDOWN_V2) 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() parts = update.message.text.strip().split()
if len(parts) < 2 or not parts[1].isdigit(): if len(parts) < 2 or not parts[1].isdigit():
await update.message.reply_text( await update.message.reply_text(
'Использование: `/removeplayer <номер>`\n' 'Использование: `/removeplayer FIDE_ID`\n'
'/myplayers — посмотреть список с номерами', '/myplayers — посмотреть список',
parse_mode=ParseMode.MARKDOWN_V2) parse_mode=ParseMode.MARKDOWN_V2)
return return
sub_id = int(parts[1]) fide_id = int(parts[1])
ok = tracker.remove_subscription(sub_id, user.id) ok = tracker.remove_player_subscriptions(fide_id, user.id)
if ok: if ok:
await update.message.reply_text('✅ Подписка удалена\.') await update.message.reply_text('✅ Игрок удалён из подписок\.',
parse_mode=ParseMode.MARKDOWN_V2)
else: 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: def _extract_fide_id(text: str) -> int | None:
@ -362,10 +385,12 @@ async def _lookup_and_confirm(update: Update, context, fide_id: int):
return return
safe_name = _md_escape(player['name']) 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( await update.message.reply_text(
f'✅ *{safe_name}*{title_str}\n' 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\\.\\.', '🔍 Ищу турниры на chess\\-results\\.com\\.\\.',
parse_mode=ParseMode.MARKDOWN_V2) 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']) 0, 0.0, lang_code, t['start_date'], t['end_date'])
dates = '' dates = ''
if t['start_date'] and t['end_date']: 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']) safe_tour = _md_escape(t['name'])
lines.append(f'{safe_tour}{dates}') lines.append(f'{safe_tour}{dates}')
lines.append('\nЯ буду присылать результаты и жеребьёвку\.\n' 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), await update.message.reply_text('\n'.join(lines),
parse_mode=ParseMode.MARKDOWN_V2) parse_mode=ParseMode.MARKDOWN_V2)
else: else:
# No tournaments found — fallback to manual URL # No active tournament found — watch for future ones
context.user_data['pending_fide'] = { tracker.add_player_watch(user.id, player, lang_code)
'player': player, safe_name = _md_escape(player['name'])
'lang': lang_code,
}
await update.message.reply_text( await update.message.reply_text(
'❌ Турниров не найдено автоматически\\.\n\n' f'👁 *{safe_name}* поставлен на отслеживание\.\n\n'
'Отправь ссылку на турнир chess\\-results\\.com, ' 'Активного турнира сейчас не найдено\\. '
'где играет этот игрок\\.\n' 'Как только игрок появится в новом турнире — сразу подпишу и пришлю жеребьёвку\\.\n\n'
'/cancel — отмена', '/myplayers \\- список подписок',
parse_mode=ParseMode.MARKDOWN_V2) parse_mode=ParseMode.MARKDOWN_V2)
@ -479,50 +502,6 @@ def _apply_round_results(tournament, games, rd):
tournament['current_round'] = 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): async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.effective_user 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 '', record_user(user.id, user.username or '', user.first_name or '',
user.last_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 # Step 1: user just called /addplayer — waiting for FIDE ID
if context.user_data.pop('awaiting_fide_id', None): if context.user_data.pop('awaiting_fide_id', None):
fide_id = _extract_fide_id(text) 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) await _lookup_and_confirm(update, context, fide_id)
return return
# Step 2: player confirmed — waiting for tournament URL # Step 2: regular pairings request
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) match = URL_PATTERN.search(text)
if not match: if not match:
await update.message.reply_text( await update.message.reply_text(
@ -718,7 +678,7 @@ def main():
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_url)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_url))
app.job_queue.run_repeating( 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( app.job_queue.run_repeating(
tracker.rescan_new_tournaments, interval=3600, first=60) tracker.rescan_new_tournaments, interval=3600, first=60)
app.job_queue.run_repeating( app.job_queue.run_repeating(

View file

@ -148,6 +148,25 @@ def fetch_fide_player(fide_id: int) -> dict:
# ═══ Subscription CRUD ═══ # ═══ 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, def add_subscription(user_id: int, player: dict, tournament_url: str,
tournament_name: str, player_sno: int, tournament_name: str, player_sno: int,
last_results_count: int, last_points: float, 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: def get_user_subs(user_id: int) -> list:
"""Return subscriptions grouped by player (fide_id)."""
conn = _get_conn() conn = _get_conn()
rows = conn.execute( rows = conn.execute(
'SELECT id, player_name, rating, fed, tournament_name, ' 'SELECT fide_id, player_name, rating, fed, tournament_name, '
'last_points, last_round_done, active FROM subscriptions ' 'last_points, last_round_done, tournament_url FROM subscriptions '
'WHERE user_id = ? AND active = 1 ORDER BY id', 'WHERE user_id = ? AND active = 1 ORDER BY fide_id, id',
(user_id,)).fetchall() (user_id,)).fetchall()
conn.close() conn.close()
return [{'id': r[0], 'player_name': r[1], 'rating': r[2], 'fed': r[3],
'tournament_name': r[4], 'last_points': r[5], players: dict = {}
'last_round_done': r[6], 'active': r[7]} for r in rows] 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: def get_active_subs() -> list:
@ -205,6 +241,18 @@ def remove_subscription(sub_id: int, user_id: int) -> bool:
return deleted 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): def update_result(sub_id: int, results_count: int, points: float):
conn = _get_conn() conn = _get_conn()
conn.execute( conn.execute(
@ -508,6 +556,13 @@ def discover_max_tnr() -> int:
saved = get_tnr_state('max_tnr_seen', str(DEFAULT_MAX_TNR)) saved = get_tnr_state('max_tnr_seen', str(DEFAULT_MAX_TNR))
tnr = int(saved) 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]: for step in [10000, 1000, 100, 50, 20, 10, 5, 1]:
while True: while True:
parsed = _fetch_and_parse_art0(tnr + step) parsed = _fetch_and_parse_art0(tnr + step)
@ -535,14 +590,14 @@ def scan_for_player(fide_id: int, max_tnr: int = None,
results = [] results = []
seen_tnrs = set() 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() conn = _get_conn()
pattern = f'%"{fide_id}"%' pattern = f'%"{fide_id}"%'
rows = conn.execute( rows = conn.execute(
'SELECT tnr, name, start_date, end_date, players_json FROM tnr_cache ' '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', 'ORDER BY tnr DESC',
(1434000, 1450000, pattern)).fetchall() (pattern,)).fetchall()
conn.close() conn.close()
for row in rows: for row in rows:
tnr, tname, tstart, tend, players_json = row tnr, tname, tstart, tend, players_json = row
@ -769,6 +824,8 @@ async def check_all_subscriptions(context):
today_str = date.today().isoformat() today_str = date.today().isoformat()
for sub in subs: for sub in subs:
if sub['tournament_url'] == 'fide_only':
continue
try: try:
tournament = fetch_tournament(sub['tournament_url']) tournament = fetch_tournament(sub['tournament_url'])
except Exception: except Exception:
@ -846,6 +903,14 @@ async def rescan_new_tournaments(context):
add_subscription( add_subscription(
user_id, player, t['url'], t['name'], t['sno'], user_id, player, t['url'], t['name'], t['sno'],
0, 0.0, lang, t['start_date'], t['end_date']) 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: try:
safe_name = _md_escape_tracker(t['player_name']) safe_name = _md_escape_tracker(t['player_name'])
safe_tour = _md_escape_tracker(t['name']) safe_tour = _md_escape_tracker(t['name'])