2026-06-14 20:39:19 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Клиентский Telegram-бот chessCalc.
|
|
|
|
|
|
Принимает ссылку на турнир chess-results.com, рассчитывает жеребьёвку
|
2026-06-14 20:48:02 +00:00
|
|
|
|
следующего тура и выводит пары в таблице (MarkdownV2).
|
2026-06-14 20:39:19 +00:00
|
|
|
|
"""
|
|
|
|
|
|
import os
|
|
|
|
|
|
import re
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import asyncio
|
2026-06-20 23:04:32 +00:00
|
|
|
|
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
|
|
|
|
|
from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ContextTypes
|
2026-06-14 20:39:19 +00:00
|
|
|
|
from telegram.constants import ParseMode
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-06-19 18:35:12 +00:00
|
|
|
|
from bots import tracker
|
2026-06-14 20:39:19 +00:00
|
|
|
|
|
|
|
|
|
|
TOKEN = os.environ.get('CLIENT_BOT_TOKEN', '')
|
|
|
|
|
|
|
|
|
|
|
|
URL_PATTERN = re.compile(r'https?://(?:[\w-]+\.)?chess-results\.com/\S+')
|
2026-06-14 20:48:02 +00:00
|
|
|
|
|
2026-06-20 23:04:32 +00:00
|
|
|
|
START_MSG = {
|
|
|
|
|
|
'ru': (
|
|
|
|
|
|
'♟️ *chessCalc* — жеребьёвка следующего тура\n\n'
|
|
|
|
|
|
'Рассчитываю пары _до официальной публикации_ на chess\-results\.com\.\n'
|
|
|
|
|
|
'Слежу за игроками: как только тур завершён — присылаю результат и жеребьёвку следующего\.\n\n'
|
|
|
|
|
|
'*Команды:*\n'
|
|
|
|
|
|
'/addplayer — подписаться на игрока по FIDE ID\n'
|
|
|
|
|
|
'/myplayers — список отслеживаемых игроков\n'
|
|
|
|
|
|
'/removeplayer — отписаться от игрока\n\n'
|
|
|
|
|
|
'*Разовый расчёт:*\n'
|
|
|
|
|
|
'Пришли ссылку на турнир — получи жеребьёвку прямо сейчас\n'
|
|
|
|
|
|
'`https://chess\-results\.com/tnr1393124\.aspx?lan=11`\n\n'
|
|
|
|
|
|
'_Движок: FIDE Dutch System \(bbpPairings\), точность 100% на большинстве турниров_'
|
|
|
|
|
|
),
|
|
|
|
|
|
'en': (
|
|
|
|
|
|
'♟️ *chessCalc* — next round pairings\n\n'
|
|
|
|
|
|
'I calculate pairings _before official publication_ on chess\-results\.com\.\n'
|
|
|
|
|
|
'I also track players: when a round finishes — I send the result and next round pairings\.\n\n'
|
|
|
|
|
|
'*Commands:*\n'
|
|
|
|
|
|
'/addplayer — subscribe to a player by FIDE ID\n'
|
|
|
|
|
|
'/myplayers — list of tracked players\n'
|
|
|
|
|
|
'/removeplayer — unsubscribe from a player\n\n'
|
|
|
|
|
|
'*One\-time calculation:*\n'
|
|
|
|
|
|
'Send a tournament link — get pairings right now\n'
|
|
|
|
|
|
'`https://chess\-results\.com/tnr1393124\.aspx?lan=11`\n\n'
|
|
|
|
|
|
'_Engine: FIDE Dutch System \(bbpPairings\), 100% accuracy on most tournaments_'
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
2026-06-14 20:39:19 +00:00
|
|
|
|
|
2026-06-19 16:30:55 +00:00
|
|
|
|
LOCALE = {
|
|
|
|
|
|
'ru': {
|
|
|
|
|
|
'player': 'Игрок',
|
|
|
|
|
|
'rating': 'Рейт',
|
|
|
|
|
|
'points': 'Оч',
|
|
|
|
|
|
'tb1': 'Доп1',
|
|
|
|
|
|
'title': '📋 *Тур {rnd}* — пары',
|
|
|
|
|
|
'bye': '— BYE —',
|
|
|
|
|
|
'footer_bbp': '\n_FIDE 2025 \(bbpPairings\)_',
|
|
|
|
|
|
'footer_fallback': '\n_Упрощённый Swiss \(bbpPairings недоступен\)_',
|
2026-06-20 23:04:32 +00:00
|
|
|
|
'no_players': 'У вас нет отслеживаемых игроков\.\n/addplayer — добавить игрока',
|
|
|
|
|
|
'my_players_title': 'Ваши игроки',
|
|
|
|
|
|
'waiting': '⏳ _Ожидаю турнир_',
|
|
|
|
|
|
'round_label': 'тур',
|
|
|
|
|
|
'points_label': 'очк\\.',
|
2026-06-19 16:30:55 +00:00
|
|
|
|
},
|
|
|
|
|
|
'en': {
|
|
|
|
|
|
'player': 'Player',
|
|
|
|
|
|
'rating': 'Rat',
|
|
|
|
|
|
'points': 'Pts',
|
|
|
|
|
|
'tb1': 'TB1',
|
|
|
|
|
|
'title': '📋 *Round {rnd}* — pairings',
|
|
|
|
|
|
'bye': '— BYE —',
|
|
|
|
|
|
'footer_bbp': '\n_FIDE 2025 \(bbpPairings\)_',
|
|
|
|
|
|
'footer_fallback': '\n_Simplified Swiss \(bbpPairings unavailable\)_',
|
2026-06-20 23:04:32 +00:00
|
|
|
|
'no_players': 'You have no tracked players\.\n/addplayer — add a player',
|
|
|
|
|
|
'my_players_title': 'Your players',
|
|
|
|
|
|
'waiting': '⏳ _Waiting for a tournament_',
|
|
|
|
|
|
'round_label': 'round',
|
|
|
|
|
|
'points_label': 'pts',
|
2026-06-19 16:30:55 +00:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_locale(lang_code: str) -> dict:
|
|
|
|
|
|
if lang_code and lang_code.startswith('ru'):
|
|
|
|
|
|
return LOCALE['ru']
|
|
|
|
|
|
return LOCALE['en']
|
|
|
|
|
|
|
2026-06-14 20:39:19 +00:00
|
|
|
|
|
2026-06-14 20:48:02 +00:00
|
|
|
|
def _md_escape(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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 21:03:59 +00:00
|
|
|
|
def _display_width(s: str) -> int:
|
|
|
|
|
|
w = 0
|
|
|
|
|
|
for ch in s:
|
|
|
|
|
|
cp = ord(ch)
|
2026-06-15 17:15:19 +00:00
|
|
|
|
if (0x2E80 <= cp <= 0x9FFF or 0xF900 <= cp <= 0xFAFF or
|
|
|
|
|
|
0xFE30 <= cp <= 0xFE4F):
|
2026-06-14 21:03:59 +00:00
|
|
|
|
w += 2
|
|
|
|
|
|
else:
|
|
|
|
|
|
w += 1
|
|
|
|
|
|
return w
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pad_col(s: str, width: int) -> str:
|
|
|
|
|
|
cur = _display_width(s)
|
2026-06-14 21:13:48 +00:00
|
|
|
|
if cur > width:
|
2026-06-14 21:16:54 +00:00
|
|
|
|
# Truncate to fit, then pad to exactly width
|
2026-06-14 21:13:48 +00:00
|
|
|
|
result = []
|
|
|
|
|
|
w = 0
|
|
|
|
|
|
for ch in s:
|
|
|
|
|
|
cw = 2 if _display_width(ch) == 2 else 1
|
2026-06-15 17:44:38 +00:00
|
|
|
|
if w + cw + 3 > width: # +3 for "..."
|
2026-06-14 21:13:48 +00:00
|
|
|
|
break
|
|
|
|
|
|
result.append(ch)
|
|
|
|
|
|
w += cw
|
2026-06-15 17:44:38 +00:00
|
|
|
|
truncated = ''.join(result) + '...'
|
2026-06-14 21:16:54 +00:00
|
|
|
|
pad_to = width - _display_width(truncated)
|
|
|
|
|
|
return truncated + ' ' * max(0, pad_to)
|
2026-06-14 21:03:59 +00:00
|
|
|
|
return s + ' ' * (width - cur)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-20 07:53:43 +00:00
|
|
|
|
def format_pairings(pairings_data: dict, tournament_name: str, lang: str = '',
|
2026-06-20 08:55:09 +00:00
|
|
|
|
player_name: str = '', player_sno: int = 0) -> str:
|
2026-06-19 16:30:55 +00:00
|
|
|
|
locale = _get_locale(lang)
|
2026-06-14 20:48:02 +00:00
|
|
|
|
pairings = pairings_data['pairings']
|
|
|
|
|
|
rnd = pairings_data['round']
|
2026-06-14 21:08:04 +00:00
|
|
|
|
|
2026-06-20 07:53:43 +00:00
|
|
|
|
# If highlighting a player, reduce name width to compensate for ✅ emoji (visually 2 chars wide)
|
2026-06-20 08:55:09 +00:00
|
|
|
|
w_name = 15 if player_sno else 17
|
2026-06-20 07:53:43 +00:00
|
|
|
|
w_board, w_rating, w_pts, w_tb1 = 2, 4, 4, 5
|
2026-06-19 14:51:15 +00:00
|
|
|
|
border = '+'.join('-' * w for w in [w_board, w_name, w_rating, w_pts, w_tb1])
|
2026-06-15 17:25:59 +00:00
|
|
|
|
|
2026-06-19 16:30:55 +00:00
|
|
|
|
header = (f'{_pad_col("#", w_board)}|{_pad_col(locale["player"], w_name)}|'
|
|
|
|
|
|
f'{_pad_col(locale["rating"], w_rating)}|{_pad_col(locale["points"], w_pts)}|'
|
|
|
|
|
|
f'{_pad_col(locale["tb1"], w_tb1)}')
|
2026-06-14 21:10:31 +00:00
|
|
|
|
table_header = f'```\n{header}\n{border}\n'
|
|
|
|
|
|
|
|
|
|
|
|
source = pairings_data.get('source', 'algorithm')
|
2026-06-19 16:30:55 +00:00
|
|
|
|
footer = locale['footer_bbp'] if source == 'bbp_pairings_fide_2025' else \
|
|
|
|
|
|
locale['footer_fallback'] if source == 'swiss_algorithm_simplified' else ''
|
2026-06-14 21:10:31 +00:00
|
|
|
|
|
2026-06-20 07:53:43 +00:00
|
|
|
|
safe_tour = _md_escape(tournament_name)
|
|
|
|
|
|
title = f'📋 *{safe_tour}*\n*{locale["title"].format(rnd=rnd)}*'
|
|
|
|
|
|
|
2026-06-14 21:10:31 +00:00
|
|
|
|
return {
|
2026-06-20 07:53:43 +00:00
|
|
|
|
'title': title,
|
2026-06-19 16:30:55 +00:00
|
|
|
|
'rows': _build_table_rows(pairings, w_board, w_name, w_rating, w_pts, w_tb1, border,
|
2026-06-20 08:55:09 +00:00
|
|
|
|
locale['bye'], player_name, player_sno),
|
2026-06-14 21:10:31 +00:00
|
|
|
|
'table_header': table_header,
|
|
|
|
|
|
'footer': footer,
|
|
|
|
|
|
}
|
2026-06-14 20:48:02 +00:00
|
|
|
|
|
2026-06-14 21:10:31 +00:00
|
|
|
|
|
2026-06-19 14:51:15 +00:00
|
|
|
|
def _build_table_rows(pairings: list, w_board: int, w_name: int, w_rating: int,
|
2026-06-20 07:53:43 +00:00
|
|
|
|
w_pts: int, w_tb1: int, border: str, bye_label: str = '— BYE —',
|
2026-06-20 08:55:09 +00:00
|
|
|
|
player_name: str = '', player_sno: int = 0) -> list:
|
2026-06-19 14:51:15 +00:00
|
|
|
|
def _rating_str(p):
|
|
|
|
|
|
return str(p.rating) if p.rating else ''
|
|
|
|
|
|
|
|
|
|
|
|
def _tb1_str(p):
|
|
|
|
|
|
return f"{p.tb[0]:.1f}" if p.tb else ''
|
|
|
|
|
|
|
2026-06-20 08:55:09 +00:00
|
|
|
|
# Find the board where the player plays.
|
|
|
|
|
|
# Primary: match by SNo (reliable across Latin/Cyrillic name variations).
|
|
|
|
|
|
# Fallback: match by normalized name.
|
2026-06-20 07:53:43 +00:00
|
|
|
|
def _n(s: str) -> str:
|
|
|
|
|
|
return s.replace(',', '').replace(' ', ' ').lower()
|
|
|
|
|
|
|
|
|
|
|
|
player_board = 0
|
|
|
|
|
|
board_idx = 1
|
|
|
|
|
|
player_norm = _n(player_name) if player_name else ''
|
|
|
|
|
|
for pairing in pairings:
|
|
|
|
|
|
if len(pairing) != 3:
|
|
|
|
|
|
continue
|
|
|
|
|
|
p1, p2, _color = pairing
|
2026-06-20 08:55:09 +00:00
|
|
|
|
if player_sno and (p1.sno == player_sno or p2.sno == player_sno):
|
|
|
|
|
|
player_board = board_idx
|
|
|
|
|
|
player_name = p1.name if p1.sno == player_sno else p2.name
|
|
|
|
|
|
break
|
2026-06-20 07:53:43 +00:00
|
|
|
|
if player_norm and (player_norm == _n(p1.name) or player_norm == _n(p2.name)):
|
|
|
|
|
|
player_board = board_idx
|
2026-06-20 08:55:09 +00:00
|
|
|
|
player_name = p1.name if player_norm == _n(p1.name) else p2.name
|
2026-06-20 07:53:43 +00:00
|
|
|
|
break
|
|
|
|
|
|
board_idx += 1
|
|
|
|
|
|
|
|
|
|
|
|
# If we have a player, show ±5 boards around them
|
|
|
|
|
|
if player_board > 0:
|
|
|
|
|
|
lo = max(1, player_board - 5)
|
|
|
|
|
|
hi = player_board + 5
|
|
|
|
|
|
else:
|
|
|
|
|
|
lo, hi = 1, 999999
|
|
|
|
|
|
|
|
|
|
|
|
def _name_cell(name: str) -> str:
|
|
|
|
|
|
if player_name and name == player_name:
|
|
|
|
|
|
return f'✅ {name}'
|
|
|
|
|
|
return name
|
|
|
|
|
|
|
2026-06-14 21:10:31 +00:00
|
|
|
|
rows = []
|
2026-06-14 20:48:02 +00:00
|
|
|
|
board = 1
|
2026-06-14 21:13:48 +00:00
|
|
|
|
for pi, pairing in enumerate(pairings):
|
2026-06-14 20:48:02 +00:00
|
|
|
|
if len(pairing) != 3:
|
|
|
|
|
|
continue
|
|
|
|
|
|
p1, p2, color = pairing
|
|
|
|
|
|
|
2026-06-20 07:53:43 +00:00
|
|
|
|
if board < lo:
|
|
|
|
|
|
board += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
if board > hi:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
2026-06-14 20:48:02 +00:00
|
|
|
|
if color == 'bye':
|
2026-06-14 21:10:31 +00:00
|
|
|
|
rows.append(
|
2026-06-15 17:44:38 +00:00
|
|
|
|
f'{_pad_col(str(board), w_board)}|'
|
2026-06-20 07:53:43 +00:00
|
|
|
|
f'{_pad_col(_name_cell(p1.name), w_name)}|'
|
2026-06-19 14:51:15 +00:00
|
|
|
|
f'{_pad_col(_rating_str(p1), w_rating)}|'
|
|
|
|
|
|
f'{_pad_col(f"{p1.points:.1f}", w_pts)}|'
|
|
|
|
|
|
f'{_pad_col(_tb1_str(p1), w_tb1)}'
|
2026-06-14 21:03:59 +00:00
|
|
|
|
)
|
2026-06-14 21:10:31 +00:00
|
|
|
|
rows.append(
|
2026-06-15 17:44:38 +00:00
|
|
|
|
f'{_pad_col("", w_board)}|'
|
2026-06-19 16:30:55 +00:00
|
|
|
|
f'{_pad_col(bye_label, w_name)}|'
|
2026-06-19 14:51:15 +00:00
|
|
|
|
f'{_pad_col("", w_rating)}|'
|
|
|
|
|
|
f'{_pad_col("", w_pts)}|'
|
|
|
|
|
|
f'{_pad_col("", w_tb1)}'
|
2026-06-14 21:03:59 +00:00
|
|
|
|
)
|
2026-06-14 20:48:02 +00:00
|
|
|
|
board += 1
|
2026-06-20 07:53:43 +00:00
|
|
|
|
if pi + 1 < len(pairings) and board <= hi:
|
2026-06-14 21:13:48 +00:00
|
|
|
|
rows.append(border)
|
2026-06-14 20:48:02 +00:00
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if color == 'w':
|
|
|
|
|
|
white, black = p1, p2
|
|
|
|
|
|
else:
|
|
|
|
|
|
white, black = p2, p1
|
|
|
|
|
|
|
2026-06-14 21:10:31 +00:00
|
|
|
|
rows.append(
|
2026-06-15 17:44:38 +00:00
|
|
|
|
f'{_pad_col(str(board), w_board)}|'
|
2026-06-20 07:53:43 +00:00
|
|
|
|
f'{_pad_col(_name_cell(white.name), w_name)}|'
|
2026-06-19 14:51:15 +00:00
|
|
|
|
f'{_pad_col(_rating_str(white), w_rating)}|'
|
|
|
|
|
|
f'{_pad_col(f"{white.points:.1f}", w_pts)}|'
|
|
|
|
|
|
f'{_pad_col(_tb1_str(white), w_tb1)}'
|
2026-06-14 21:01:10 +00:00
|
|
|
|
)
|
2026-06-14 21:10:31 +00:00
|
|
|
|
rows.append(
|
2026-06-15 17:44:38 +00:00
|
|
|
|
f'{_pad_col("", w_board)}|'
|
2026-06-20 07:53:43 +00:00
|
|
|
|
f'{_pad_col(_name_cell(black.name), w_name)}|'
|
2026-06-19 14:51:15 +00:00
|
|
|
|
f'{_pad_col(_rating_str(black), w_rating)}|'
|
|
|
|
|
|
f'{_pad_col(f"{black.points:.1f}", w_pts)}|'
|
|
|
|
|
|
f'{_pad_col(_tb1_str(black), w_tb1)}'
|
2026-06-14 20:51:24 +00:00
|
|
|
|
)
|
2026-06-14 20:48:02 +00:00
|
|
|
|
board += 1
|
|
|
|
|
|
|
2026-06-20 07:53:43 +00:00
|
|
|
|
if pi + 1 < len(pairings) and board <= hi:
|
2026-06-14 21:13:48 +00:00
|
|
|
|
rows.append(border)
|
|
|
|
|
|
|
2026-06-14 21:10:31 +00:00
|
|
|
|
return rows
|
2026-06-14 21:08:04 +00:00
|
|
|
|
|
2026-06-14 21:03:59 +00:00
|
|
|
|
|
2026-06-14 21:10:31 +00:00
|
|
|
|
def _render_chunks(fmt: dict) -> list:
|
|
|
|
|
|
header = fmt['table_header']
|
|
|
|
|
|
footer = fmt['footer']
|
|
|
|
|
|
rows = fmt['rows']
|
2026-06-14 20:48:02 +00:00
|
|
|
|
|
2026-06-14 21:10:31 +00:00
|
|
|
|
chunks = []
|
|
|
|
|
|
title_line = fmt['title'] + '\n'
|
|
|
|
|
|
|
2026-06-14 21:13:48 +00:00
|
|
|
|
# Estimate overhead per chunk
|
2026-06-14 21:10:31 +00:00
|
|
|
|
overhead = len(title_line) + len(header) + len(footer) + 10
|
|
|
|
|
|
available = 3800 - overhead
|
2026-06-14 21:13:48 +00:00
|
|
|
|
rows_per_chunk = max(15, available // 60)
|
|
|
|
|
|
rows_per_chunk = (rows_per_chunk // 3) * 3 # 3 rows/pair (white, black, border)
|
2026-06-14 21:10:31 +00:00
|
|
|
|
|
|
|
|
|
|
for i in range(0, len(rows), rows_per_chunk):
|
|
|
|
|
|
batch = rows[i:i + rows_per_chunk]
|
|
|
|
|
|
parts = []
|
|
|
|
|
|
if i == 0:
|
|
|
|
|
|
parts.append(title_line)
|
|
|
|
|
|
parts.append(header)
|
|
|
|
|
|
parts.extend(batch)
|
|
|
|
|
|
parts.append('```')
|
|
|
|
|
|
if i + rows_per_chunk >= len(rows):
|
|
|
|
|
|
parts.append(footer)
|
|
|
|
|
|
chunks.append('\n'.join(parts))
|
|
|
|
|
|
|
|
|
|
|
|
return chunks
|
2026-06-14 20:48:02 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 20:39:19 +00:00
|
|
|
|
async def start(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 '')
|
2026-06-20 23:04:32 +00:00
|
|
|
|
lang = 'ru' if (user.language_code or '').startswith('ru') else 'en'
|
|
|
|
|
|
await update.message.reply_text(START_MSG[lang], parse_mode=ParseMode.MARKDOWN_V2)
|
2026-06-14 20:39:19 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 18:35:12 +00:00
|
|
|
|
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)
|
2026-06-20 22:54:01 +00:00
|
|
|
|
if had_fide:
|
2026-06-19 18:35:12 +00:00
|
|
|
|
await update.message.reply_text('❎ Подписка отменена\.')
|
|
|
|
|
|
else:
|
|
|
|
|
|
await update.message.reply_text('Нет активного процесса подписки\.')
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-20 23:04:32 +00:00
|
|
|
|
def _player_card(p: dict, loc: dict) -> tuple[str, InlineKeyboardMarkup]:
|
|
|
|
|
|
safe_name = _md_escape(p['player_name'])
|
|
|
|
|
|
fed = p['fed'] or ''
|
|
|
|
|
|
rating = p['rating'] or '—'
|
|
|
|
|
|
fide_id = p['fide_id']
|
|
|
|
|
|
|
2026-06-20 23:35:49 +00:00
|
|
|
|
rapid = p.get('rapid') or 0
|
|
|
|
|
|
blitz = p.get('blitz') or 0
|
|
|
|
|
|
ratings = str(rating)
|
|
|
|
|
|
if rapid:
|
|
|
|
|
|
ratings += f' 📍{rapid}'
|
|
|
|
|
|
if blitz:
|
|
|
|
|
|
ratings += f' ⚡{blitz}'
|
|
|
|
|
|
|
2026-06-20 23:04:32 +00:00
|
|
|
|
lines = [f'👤 *{safe_name}*']
|
2026-06-20 23:35:49 +00:00
|
|
|
|
lines.append(f'🏳 {_md_escape(fed)} · {_md_escape(ratings)}')
|
2026-06-20 23:04:32 +00:00
|
|
|
|
|
|
|
|
|
|
tours = p['tournaments']
|
|
|
|
|
|
waiting = all(t['name'] is None for t in tours)
|
|
|
|
|
|
if waiting:
|
|
|
|
|
|
lines.append(loc['waiting'])
|
|
|
|
|
|
else:
|
|
|
|
|
|
for t in tours:
|
|
|
|
|
|
if t['name'] is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
pts = _md_escape(f'{t["points"]:.1f}')
|
|
|
|
|
|
rd = t['round_done']
|
2026-06-20 23:35:49 +00:00
|
|
|
|
if t.get('url'):
|
|
|
|
|
|
tour_link = f'[{_md_escape(t["name"])}]({t["url"]})'
|
|
|
|
|
|
else:
|
|
|
|
|
|
tour_link = _md_escape(t['name'])
|
|
|
|
|
|
lines.append(f'📌 {tour_link}')
|
2026-06-20 23:04:32 +00:00
|
|
|
|
lines.append(f' {pts} {loc["points_label"]}, {loc["round_label"]} {rd}')
|
|
|
|
|
|
|
|
|
|
|
|
keyboard = InlineKeyboardMarkup([[
|
|
|
|
|
|
InlineKeyboardButton('🗑 Удалить', callback_data=f'remove:{fide_id}')
|
|
|
|
|
|
]])
|
|
|
|
|
|
return '\n'.join(lines), keyboard
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 18:35:12 +00:00
|
|
|
|
async def my_players(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
|
|
|
|
user = update.effective_user
|
2026-06-20 23:04:32 +00:00
|
|
|
|
loc = _get_locale(user.language_code or '')
|
2026-06-20 22:54:01 +00:00
|
|
|
|
players = tracker.get_user_subs(user.id)
|
|
|
|
|
|
if not players:
|
2026-06-20 23:04:32 +00:00
|
|
|
|
await update.message.reply_text(loc['no_players'],
|
2026-06-20 22:54:01 +00:00
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2)
|
2026-06-19 18:35:12 +00:00
|
|
|
|
return
|
2026-06-20 22:54:01 +00:00
|
|
|
|
|
2026-06-20 23:04:32 +00:00
|
|
|
|
header = f'*{_md_escape(loc["my_players_title"])} \\({len(players)}\\):*'
|
|
|
|
|
|
await update.message.reply_text(header, parse_mode=ParseMode.MARKDOWN_V2)
|
2026-06-20 22:54:01 +00:00
|
|
|
|
for p in players:
|
2026-06-20 23:04:32 +00:00
|
|
|
|
text, keyboard = _player_card(p, loc)
|
2026-06-20 23:35:49 +00:00
|
|
|
|
try:
|
|
|
|
|
|
await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN_V2,
|
|
|
|
|
|
reply_markup=keyboard,
|
|
|
|
|
|
disable_web_page_preview=True)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
import sys
|
|
|
|
|
|
print(f'my_players card error: {e}\ntext={text!r}', file=sys.stderr)
|
|
|
|
|
|
# Fallback: send as plain text without formatting
|
|
|
|
|
|
plain = text.replace('\\', '').replace('*', '').replace('_', '')
|
|
|
|
|
|
await update.message.reply_text(plain, reply_markup=keyboard,
|
|
|
|
|
|
disable_web_page_preview=True)
|
2026-06-20 22:54:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-20 23:04:32 +00:00
|
|
|
|
async def remove_player_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
|
|
|
|
query = update.callback_query
|
|
|
|
|
|
await query.answer()
|
|
|
|
|
|
user = query.from_user
|
|
|
|
|
|
loc = _get_locale(user.language_code or '')
|
2026-06-19 18:35:12 +00:00
|
|
|
|
|
2026-06-20 23:04:32 +00:00
|
|
|
|
fide_id = int(query.data.split(':')[1])
|
2026-06-20 22:54:01 +00:00
|
|
|
|
ok = tracker.remove_player_subscriptions(fide_id, user.id)
|
2026-06-20 23:04:32 +00:00
|
|
|
|
|
2026-06-19 18:35:12 +00:00
|
|
|
|
if ok:
|
2026-06-20 23:04:32 +00:00
|
|
|
|
# Strike through the card text and remove the button
|
|
|
|
|
|
struck = query.message.text.replace('👤', '❌')
|
|
|
|
|
|
await query.edit_message_text(struck, reply_markup=None)
|
2026-06-19 18:35:12 +00:00
|
|
|
|
else:
|
2026-06-20 23:04:32 +00:00
|
|
|
|
await query.edit_message_text(query.message.text + '\n\n_уже удалён_',
|
|
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
|
|
|
|
reply_markup=None)
|
|
|
|
|
|
|
2026-06-19 18:35:12 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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'])
|
2026-06-20 22:54:01 +00:00
|
|
|
|
title = player.get('title') or ''
|
|
|
|
|
|
title_str = f', {_md_escape(title)}' if title and title != 'None' else ''
|
|
|
|
|
|
fed = player.get('fed') or ''
|
2026-06-20 23:35:49 +00:00
|
|
|
|
ratings_parts = [f'Рейтинг: {player["rating"]}']
|
|
|
|
|
|
if player.get('rapid'):
|
|
|
|
|
|
ratings_parts.append(f'📍{player["rapid"]}')
|
|
|
|
|
|
if player.get('blitz'):
|
|
|
|
|
|
ratings_parts.append(f'⚡{player["blitz"]}')
|
|
|
|
|
|
ratings_str = _md_escape(' '.join(ratings_parts))
|
2026-06-20 07:15:16 +00:00
|
|
|
|
await update.message.reply_text(
|
2026-06-19 18:35:12 +00:00
|
|
|
|
f'✅ *{safe_name}*{title_str}\n'
|
2026-06-20 23:35:49 +00:00
|
|
|
|
f'{"🇫 " if fed else ""}{_md_escape(fed)} \\| {ratings_str}\n\n'
|
2026-06-20 07:15:16 +00:00
|
|
|
|
'🔍 Ищу турниры на chess\\-results\\.com\\.\\.',
|
|
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2)
|
|
|
|
|
|
|
2026-06-20 23:35:49 +00:00
|
|
|
|
# SQL-only cache search (instant, no HTTP). Background jobs handle HTTP discovery.
|
|
|
|
|
|
tournaments = tracker.find_player_in_cache(player['fide_id'])
|
2026-06-20 07:15:16 +00:00
|
|
|
|
|
|
|
|
|
|
if tournaments:
|
|
|
|
|
|
# Auto-subscribe to all found tournaments
|
|
|
|
|
|
lines = [f'✅ *Найдено турниров: {len(tournaments)}*']
|
|
|
|
|
|
for t in tournaments:
|
|
|
|
|
|
tracker.add_subscription(
|
|
|
|
|
|
user.id, player, t['url'], t['name'], t['sno'],
|
|
|
|
|
|
0, 0.0, lang_code, t['start_date'], t['end_date'])
|
|
|
|
|
|
dates = ''
|
|
|
|
|
|
if t['start_date'] and t['end_date']:
|
2026-06-20 22:54:01 +00:00
|
|
|
|
dates = f' \\({_md_escape(t["start_date"])} — {_md_escape(t["end_date"])}\\)'
|
2026-06-20 23:35:49 +00:00
|
|
|
|
tour_link = f'[{_md_escape(t["name"])}]({t["url"]})'
|
|
|
|
|
|
lines.append(f' • {tour_link}{dates}')
|
2026-06-20 07:15:16 +00:00
|
|
|
|
lines.append('\nЯ буду присылать результаты и жеребьёвку\.\n'
|
2026-06-20 07:53:43 +00:00
|
|
|
|
'/myplayers \\- список подписок')
|
2026-06-20 07:15:16 +00:00
|
|
|
|
await update.message.reply_text('\n'.join(lines),
|
|
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2)
|
|
|
|
|
|
else:
|
2026-06-20 22:54:01 +00:00
|
|
|
|
# No active tournament found — watch for future ones
|
|
|
|
|
|
tracker.add_player_watch(user.id, player, lang_code)
|
|
|
|
|
|
safe_name = _md_escape(player['name'])
|
2026-06-20 07:15:16 +00:00
|
|
|
|
await update.message.reply_text(
|
2026-06-20 22:54:01 +00:00
|
|
|
|
f'👁 *{safe_name}* поставлен на отслеживание\.\n\n'
|
|
|
|
|
|
'Активного турнира сейчас не найдено\\. '
|
|
|
|
|
|
'Как только игрок появится в новом турнире — сразу подпишу и пришлю жеребьёвку\\.\n\n'
|
|
|
|
|
|
'/myplayers \\- список подписок',
|
2026-06-20 07:15:16 +00:00
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2)
|
2026-06-19 18:35:12 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 10:47:18 +00:00
|
|
|
|
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')}
|
|
|
|
|
|
name_to_standing = {
|
|
|
|
|
|
re.sub(r'\s+', '', s['name'].lower()): s
|
|
|
|
|
|
for s in standings
|
|
|
|
|
|
}
|
|
|
|
|
|
name_parts_to_standing: dict[str, list] = {}
|
|
|
|
|
|
for s in standings:
|
|
|
|
|
|
parts = re.sub(r'\s+', '', s['name'].lower()).split()
|
|
|
|
|
|
for p in parts:
|
|
|
|
|
|
name_parts_to_standing.setdefault(p, []).append(s)
|
|
|
|
|
|
|
|
|
|
|
|
def _find_player(sno, name_str):
|
|
|
|
|
|
p = sno_to_standing.get(sno)
|
|
|
|
|
|
if p:
|
|
|
|
|
|
return p
|
|
|
|
|
|
if name_str:
|
|
|
|
|
|
norm = re.sub(r'\s+', '', name_str.lower())
|
|
|
|
|
|
p = name_to_standing.get(norm)
|
|
|
|
|
|
if p:
|
|
|
|
|
|
return p
|
|
|
|
|
|
parts = norm.split()
|
|
|
|
|
|
for part in parts:
|
|
|
|
|
|
candidates = name_parts_to_standing.get(part, [])
|
|
|
|
|
|
if len(candidates) == 1:
|
|
|
|
|
|
return candidates[0]
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
for g in games:
|
|
|
|
|
|
if g is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
ws, bs = g['white_sno'], g['black_sno']
|
|
|
|
|
|
ws_result = g.get('white_score')
|
|
|
|
|
|
bs_result = g.get('black_score')
|
|
|
|
|
|
|
|
|
|
|
|
if ws_result is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
ws_val = float(ws_result)
|
|
|
|
|
|
bs_val = float(bs_result)
|
|
|
|
|
|
|
|
|
|
|
|
wp = _find_player(ws, g.get('white_name', ''))
|
|
|
|
|
|
bp = _find_player(bs, g.get('black_name', ''))
|
|
|
|
|
|
|
|
|
|
|
|
if wp:
|
|
|
|
|
|
opp_sno = bs if bs != 0 else (bp.get('starting_sno') if bp else 0)
|
|
|
|
|
|
existing = [r for r in wp.get('results', []) if r.get('round') != rd]
|
|
|
|
|
|
existing.append({'opponent': opp_sno, 'color': 'w', 'score': ws_val, 'round': rd})
|
|
|
|
|
|
wp['results'] = existing
|
|
|
|
|
|
|
|
|
|
|
|
if bp:
|
|
|
|
|
|
opp_sno = ws if ws != 0 else (wp.get('starting_sno') if wp else 0)
|
|
|
|
|
|
existing = [r for r in bp.get('results', []) if r.get('round') != rd]
|
|
|
|
|
|
existing.append({'opponent': opp_sno, 'color': 'b', 'score': bs_val, 'round': rd})
|
|
|
|
|
|
bp['results'] = existing
|
|
|
|
|
|
|
|
|
|
|
|
for s in standings:
|
|
|
|
|
|
sno = s.get('starting_sno')
|
|
|
|
|
|
if sno and len(s.get('results', [])) < rd:
|
|
|
|
|
|
orig_results = s.get('_orig_results', [])
|
|
|
|
|
|
if len(orig_results) >= rd:
|
|
|
|
|
|
res = orig_results[rd - 1]
|
|
|
|
|
|
res['round'] = rd
|
|
|
|
|
|
s['results'] = list(s.get('results', []))
|
|
|
|
|
|
s['results'].append(res)
|
|
|
|
|
|
|
2026-06-15 16:53:57 +00:00
|
|
|
|
for s in standings:
|
|
|
|
|
|
s['points'] = sum(r.get('score', 0) for r in s.get('results', []))
|
|
|
|
|
|
|
2026-06-15 10:47:18 +00:00
|
|
|
|
tournament['current_round'] = rd
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 18:35:12 +00:00
|
|
|
|
|
2026-06-14 20:39:19 +00:00
|
|
|
|
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 '')
|
|
|
|
|
|
|
2026-06-19 18:35:12 +00:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-06-20 22:54:01 +00:00
|
|
|
|
# Step 2: regular pairings request
|
2026-06-14 20:39:19 +00:00
|
|
|
|
match = URL_PATTERN.search(text)
|
|
|
|
|
|
if not match:
|
|
|
|
|
|
await update.message.reply_text(
|
2026-06-14 20:48:02 +00:00
|
|
|
|
'❌ Не нашёл ссылку на chess\\-results\\.com в сообщении\\.\n\n'
|
2026-06-14 20:39:19 +00:00
|
|
|
|
'Пришли ссылку вида:\n'
|
2026-06-14 20:48:02 +00:00
|
|
|
|
'`https://chess\\-results\\.com/tnr1393124\\.aspx?lan=11`',
|
|
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2)
|
2026-06-14 20:39:19 +00:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
url = match.group(0)
|
2026-06-14 20:48:02 +00:00
|
|
|
|
msg = await update.message.reply_text('⏳ Загружаю турнир\.\.\.',
|
|
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2)
|
2026-06-14 20:39:19 +00:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
tournament = fetch_tournament(url)
|
|
|
|
|
|
except Exception as e:
|
2026-06-14 20:48:02 +00:00
|
|
|
|
await msg.edit_text(f'❌ Ошибка загрузки: {_md_escape(str(e))}',
|
|
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2)
|
2026-06-14 20:39:19 +00:00
|
|
|
|
record_request(user.id, url, '', False, str(e))
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if tournament['current_round'] >= tournament['num_rounds']:
|
2026-06-14 21:01:10 +00:00
|
|
|
|
await msg.edit_text('🏁 Турнир уже завершён\\. Жеребьёвки не будет\\.',
|
2026-06-14 20:48:02 +00:00
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2)
|
|
|
|
|
|
record_request(user.id, url, tournament.get('name', ''), True)
|
2026-06-14 20:39:19 +00:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
status_parts = [
|
2026-06-14 20:48:02 +00:00
|
|
|
|
f'✅ *{_md_escape(tournament["name"])}*',
|
2026-06-14 20:39:19 +00:00
|
|
|
|
f'📅 Сыграно туров: {tournament["current_round"]} из {tournament["num_rounds"]}',
|
|
|
|
|
|
f'👥 Участников: {len(tournament["standings"])}',
|
|
|
|
|
|
]
|
2026-06-14 20:48:02 +00:00
|
|
|
|
await msg.edit_text('\n'.join(status_parts), parse_mode=ParseMode.MARKDOWN_V2)
|
2026-06-14 20:39:19 +00:00
|
|
|
|
|
2026-06-14 20:48:02 +00:00
|
|
|
|
await asyncio.sleep(0.5)
|
2026-06-14 20:39:19 +00:00
|
|
|
|
|
|
|
|
|
|
# Check if next round pairings already published on site
|
|
|
|
|
|
base_url = re.sub(r'[&?]art=\d+', '', url)
|
|
|
|
|
|
base_url = re.sub(r'[&?]rd=\d+', '', base_url)
|
|
|
|
|
|
base_url = re.sub(r'[&?]turdet=\w+', '', base_url)
|
|
|
|
|
|
base_url = re.sub(r'[&?]SNode=\w+', '', base_url)
|
|
|
|
|
|
next_rd = tournament['current_round'] + 1
|
2026-06-15 10:47:18 +00:00
|
|
|
|
initial_rd = next_rd
|
2026-06-14 20:39:19 +00:00
|
|
|
|
|
|
|
|
|
|
from swiss_calc.parser import fetch_url as _fetch_url, parse_round_pairings
|
|
|
|
|
|
try:
|
2026-06-15 10:47:18 +00:00
|
|
|
|
while next_rd <= tournament['num_rounds']:
|
|
|
|
|
|
rd_html = _fetch_url(f'{base_url}&art=2&rd={next_rd}&turdet=YES')
|
|
|
|
|
|
rd_games = parse_round_pairings(rd_html, next_rd)
|
|
|
|
|
|
played = any(g.get('white_score') is not None for g in rd_games if g)
|
|
|
|
|
|
if played:
|
|
|
|
|
|
_apply_round_results(tournament, rd_games, next_rd)
|
|
|
|
|
|
next_rd += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
if rd_games:
|
|
|
|
|
|
await update.message.reply_text(
|
|
|
|
|
|
f'⚠️ Жеребьёвка тура {next_rd} уже опубликована на chess\\-results\\.com\\.\n'
|
|
|
|
|
|
'Я всё равно посчитаю, но пары могут незначительно отличаться '
|
|
|
|
|
|
'\\(разные редакции правил FIDE\\)\\.',
|
|
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2)
|
|
|
|
|
|
break
|
|
|
|
|
|
else:
|
|
|
|
|
|
await msg.edit_text('🏁 Турнир уже завершён\\. Жеребьёвки не будет\\.',
|
|
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2)
|
2026-06-14 20:39:19 +00:00
|
|
|
|
record_request(user.id, url, tournament.get('name', ''), True)
|
|
|
|
|
|
return
|
2026-06-15 10:47:18 +00:00
|
|
|
|
|
|
|
|
|
|
if next_rd > initial_rd:
|
|
|
|
|
|
await msg.edit_text(
|
|
|
|
|
|
f'✅ *{_md_escape(tournament["name"])}*\n'
|
|
|
|
|
|
f'📅 Сыграно туров: {tournament["current_round"]} из {tournament["num_rounds"]}\n'
|
|
|
|
|
|
f'👥 Участников: {len(tournament["standings"])}',
|
2026-06-14 20:48:02 +00:00
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2)
|
2026-06-14 20:39:19 +00:00
|
|
|
|
except Exception:
|
2026-06-14 20:48:02 +00:00
|
|
|
|
pass
|
2026-06-14 20:39:19 +00:00
|
|
|
|
|
|
|
|
|
|
# Calculate next round
|
|
|
|
|
|
calc_msg = await update.message.reply_text(
|
2026-06-14 20:48:02 +00:00
|
|
|
|
f'🧮 Считаю жеребьёвку тура {next_rd}\\.\\.\\.',
|
|
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2)
|
2026-06-14 20:39:19 +00:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = calculate_next_round(tournament)
|
|
|
|
|
|
except Exception as e:
|
2026-06-14 20:48:02 +00:00
|
|
|
|
await calc_msg.edit_text(f'❌ Ошибка расчёта: {_md_escape(str(e))}',
|
|
|
|
|
|
parse_mode=ParseMode.MARKDOWN_V2)
|
2026-06-14 20:39:19 +00:00
|
|
|
|
record_request(user.id, url, tournament.get('name', ''), False, str(e))
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-06-19 16:30:55 +00:00
|
|
|
|
lang_code = user.language_code or ''
|
2026-06-20 07:53:43 +00:00
|
|
|
|
|
|
|
|
|
|
# If user has a subscription for this tournament, highlight their player
|
|
|
|
|
|
player_name = ''
|
|
|
|
|
|
try:
|
|
|
|
|
|
subs = tracker.get_user_subs(user.id)
|
|
|
|
|
|
for s in subs:
|
|
|
|
|
|
sub_tour = s.get('tournament_name', '')
|
|
|
|
|
|
if sub_tour and sub_tour == tournament.get('name', ''):
|
|
|
|
|
|
player_name = s.get('player_name', '')
|
|
|
|
|
|
break
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
fmt = format_pairings(result, tournament['name'], lang_code, player_name)
|
2026-06-14 21:10:31 +00:00
|
|
|
|
chunks = _render_chunks(fmt)
|
2026-06-14 20:39:19 +00:00
|
|
|
|
|
2026-06-14 21:01:10 +00:00
|
|
|
|
try:
|
2026-06-14 21:10:31 +00:00
|
|
|
|
await calc_msg.edit_text(chunks[0], parse_mode=ParseMode.MARKDOWN_V2,
|
|
|
|
|
|
disable_web_page_preview=True)
|
|
|
|
|
|
for chunk in chunks[1:]:
|
|
|
|
|
|
await update.message.reply_text(chunk, parse_mode=ParseMode.MARKDOWN_V2,
|
|
|
|
|
|
disable_web_page_preview=True)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
# Fallback: plain text without MarkdownV2
|
|
|
|
|
|
plain_chunks = [c.replace('*', '').replace('_', '').replace('\\', '') for c in chunks]
|
|
|
|
|
|
await calc_msg.edit_text(plain_chunks[0])
|
|
|
|
|
|
for chunk in plain_chunks[1:]:
|
|
|
|
|
|
await update.message.reply_text(chunk)
|
2026-06-14 20:39:19 +00:00
|
|
|
|
|
|
|
|
|
|
record_request(user.id, url, tournament.get('name', ''), True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _chunk_output(text: str, size: int) -> list:
|
2026-06-14 21:10:31 +00:00
|
|
|
|
lines = text.split('\n')
|
2026-06-14 20:39:19 +00:00
|
|
|
|
chunks = []
|
|
|
|
|
|
current = []
|
|
|
|
|
|
current_len = 0
|
2026-06-14 21:10:31 +00:00
|
|
|
|
for line in lines:
|
|
|
|
|
|
line_len = len(line) + 1 # +1 for newline
|
|
|
|
|
|
if current_len + line_len > size and current:
|
2026-06-14 20:39:19 +00:00
|
|
|
|
chunks.append('\n'.join(current))
|
2026-06-14 21:10:31 +00:00
|
|
|
|
current = []
|
|
|
|
|
|
current_len = 0
|
|
|
|
|
|
current.append(line)
|
|
|
|
|
|
current_len += line_len
|
2026-06-14 20:39:19 +00:00
|
|
|
|
if current:
|
|
|
|
|
|
chunks.append('\n'.join(current))
|
|
|
|
|
|
return chunks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
|
if not TOKEN:
|
|
|
|
|
|
print('CLIENT_BOT_TOKEN not set', file=sys.stderr)
|
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
app = Application.builder().token(TOKEN).build()
|
|
|
|
|
|
app.add_handler(CommandHandler('start', start))
|
2026-06-19 18:35:12 +00:00
|
|
|
|
app.add_handler(CommandHandler('addplayer', add_player))
|
|
|
|
|
|
app.add_handler(CommandHandler('cancel', cancel_sub))
|
|
|
|
|
|
app.add_handler(CommandHandler('myplayers', my_players))
|
2026-06-20 23:04:32 +00:00
|
|
|
|
app.add_handler(CallbackQueryHandler(remove_player_callback, pattern=r'^remove:\d+$'))
|
2026-06-14 20:39:19 +00:00
|
|
|
|
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_url))
|
|
|
|
|
|
|
2026-06-19 18:35:12 +00:00
|
|
|
|
app.job_queue.run_repeating(
|
2026-06-21 07:47:38 +00:00
|
|
|
|
tracker.check_all_subscriptions, interval=300, first=10)
|
2026-06-20 07:15:16 +00:00
|
|
|
|
app.job_queue.run_repeating(
|
|
|
|
|
|
tracker.rescan_new_tournaments, interval=3600, first=60)
|
2026-06-20 22:09:48 +00:00
|
|
|
|
app.job_queue.run_repeating(
|
|
|
|
|
|
tracker.rescan_existing_tournaments, interval=7200, first=300)
|
2026-06-20 07:15:16 +00:00
|
|
|
|
app.job_queue.run_repeating(
|
|
|
|
|
|
tracker.warmup_cache, interval=21600, first=10)
|
|
|
|
|
|
|
|
|
|
|
|
print('Client bot started', file=sys.stderr)
|
2026-06-19 18:35:12 +00:00
|
|
|
|
|
2026-06-14 20:39:19 +00:00
|
|
|
|
print('Client bot started', file=sys.stderr)
|
|
|
|
|
|
app.run_polling()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
|
main()
|