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
|
|
|
|
|
|
from telegram import Update
|
|
|
|
|
|
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
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-14 20:39:19 +00:00
|
|
|
|
START_MSG = (
|
2026-06-14 20:48:02 +00:00
|
|
|
|
r'*♟️ chessCalc — жеребьёвка следующего тура*\n\n'
|
|
|
|
|
|
r'Я рассчитываю пары следующего тура шахматного турнира '
|
|
|
|
|
|
r'_до официальной публикации_ на chess\-results\.com\.\n\n'
|
|
|
|
|
|
r'Просто пришли мне ссылку на турнир — и я покажу, '
|
|
|
|
|
|
r'кто с кем играет в следующем туре\.\n\n'
|
|
|
|
|
|
r'Пример ссылки:\n'
|
|
|
|
|
|
r'`https://chess\-results\.com/tnr1393124\.aspx?lan=11`\n\n'
|
|
|
|
|
|
r'Работает для швейцарских турниров\. Движок: FIDE Dutch System \(bbpPairings\)\.'
|
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)
|
|
|
|
|
|
if (0x0400 <= cp <= 0x04FF or 0x0500 <= cp <= 0x052F or
|
|
|
|
|
|
0x2E80 <= cp <= 0x9FFF or 0xF900 <= cp <= 0xFAFF or
|
|
|
|
|
|
0xFE30 <= cp <= 0xFE4F):
|
|
|
|
|
|
w += 2
|
|
|
|
|
|
else:
|
|
|
|
|
|
w += 1
|
|
|
|
|
|
return w
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pad_col(s: str, width: int) -> str:
|
|
|
|
|
|
cur = _display_width(s)
|
|
|
|
|
|
if cur >= width:
|
|
|
|
|
|
return s
|
|
|
|
|
|
return s + ' ' * (width - cur)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 20:48:02 +00:00
|
|
|
|
def format_pairings(pairings_data: dict, tournament_name: str) -> str:
|
|
|
|
|
|
pairings = pairings_data['pairings']
|
|
|
|
|
|
rnd = pairings_data['round']
|
2026-06-14 21:08:04 +00:00
|
|
|
|
|
|
|
|
|
|
# Find the longest player name + 3 spaces padding
|
|
|
|
|
|
max_name_w = 20
|
|
|
|
|
|
for pairing in pairings:
|
|
|
|
|
|
if len(pairing) == 3:
|
|
|
|
|
|
for p in (pairing[0], pairing[1]):
|
|
|
|
|
|
w = _display_width(p.name)
|
|
|
|
|
|
if w > max_name_w:
|
|
|
|
|
|
max_name_w = w
|
|
|
|
|
|
max_name_w = min(max_name_w + 3, 42)
|
|
|
|
|
|
|
|
|
|
|
|
w_board, w_pts = 4, 5
|
|
|
|
|
|
border = '─┼─'.join('─' * w for w in [w_board, max_name_w, w_pts])
|
2026-06-14 20:48:02 +00:00
|
|
|
|
|
2026-06-14 20:51:24 +00:00
|
|
|
|
lines = [
|
|
|
|
|
|
f'*{_md_escape(tournament_name)}*',
|
|
|
|
|
|
f'📋 *Тур {rnd}* — пары',
|
|
|
|
|
|
'',
|
2026-06-14 21:03:59 +00:00
|
|
|
|
'```',
|
2026-06-14 21:08:04 +00:00
|
|
|
|
f'{_pad_col("#", w_board)} │ {_pad_col("Игрок", max_name_w)} │ '
|
|
|
|
|
|
f'{_pad_col("О", w_pts)}',
|
2026-06-14 21:03:59 +00:00
|
|
|
|
border,
|
2026-06-14 20:51:24 +00:00
|
|
|
|
]
|
2026-06-14 20:48:02 +00:00
|
|
|
|
|
|
|
|
|
|
board = 1
|
2026-06-14 21:08:04 +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
|
|
|
|
|
|
|
|
|
|
|
|
if color == 'bye':
|
2026-06-14 21:03:59 +00:00
|
|
|
|
lines.append(
|
2026-06-14 21:08:04 +00:00
|
|
|
|
f'{_pad_col(str(board), w_board)} │ '
|
|
|
|
|
|
f'{_pad_col(p1.name, max_name_w)} │ '
|
2026-06-14 21:03:59 +00:00
|
|
|
|
f'{_pad_col(str(p1.points), w_pts)}'
|
|
|
|
|
|
)
|
|
|
|
|
|
lines.append(
|
2026-06-14 21:08:04 +00:00
|
|
|
|
f'{_pad_col("", w_board)} │ '
|
|
|
|
|
|
f'{_pad_col("— BYE —", max_name_w)} │ '
|
2026-06-14 21:03:59 +00:00
|
|
|
|
f'{_pad_col("", w_pts)}'
|
|
|
|
|
|
)
|
2026-06-14 20:48:02 +00:00
|
|
|
|
board += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if color == 'w':
|
|
|
|
|
|
white, black = p1, p2
|
|
|
|
|
|
else:
|
|
|
|
|
|
white, black = p2, p1
|
|
|
|
|
|
|
2026-06-14 20:51:24 +00:00
|
|
|
|
lines.append(
|
2026-06-14 21:08:04 +00:00
|
|
|
|
f'{_pad_col(str(board), w_board)} │ '
|
|
|
|
|
|
f'{_pad_col(white.name, max_name_w)} │ '
|
2026-06-14 21:03:59 +00:00
|
|
|
|
f'{_pad_col(f"{white.points:.1f}", w_pts)}'
|
2026-06-14 21:01:10 +00:00
|
|
|
|
)
|
|
|
|
|
|
lines.append(
|
2026-06-14 21:08:04 +00:00
|
|
|
|
f'{_pad_col("", w_board)} │ '
|
|
|
|
|
|
f'{_pad_col(black.name, max_name_w)} │ '
|
2026-06-14 21:03:59 +00:00
|
|
|
|
f'{_pad_col(f"{black.points:.1f}", w_pts)}'
|
2026-06-14 20:51:24 +00:00
|
|
|
|
)
|
2026-06-14 20:48:02 +00:00
|
|
|
|
board += 1
|
|
|
|
|
|
|
2026-06-14 21:08:04 +00:00
|
|
|
|
# Separator between pairs
|
|
|
|
|
|
if pi < len(pairings) - 1:
|
|
|
|
|
|
next_p = pairings[pi + 1]
|
|
|
|
|
|
if len(next_p) == 3 and next_p[2] != 'bye':
|
|
|
|
|
|
lines.append(border)
|
|
|
|
|
|
|
2026-06-14 21:03:59 +00:00
|
|
|
|
lines.append('```')
|
|
|
|
|
|
|
2026-06-14 20:48:02 +00:00
|
|
|
|
source = pairings_data.get('source', 'algorithm')
|
|
|
|
|
|
if source == 'bbp_pairings_fide_2025':
|
2026-06-14 20:51:24 +00:00
|
|
|
|
lines.append('\n_FIDE 2025 \(bbpPairings\)_')
|
2026-06-14 20:48:02 +00:00
|
|
|
|
elif source == 'swiss_algorithm_simplified':
|
2026-06-14 20:51:24 +00:00
|
|
|
|
lines.append('\n_Упрощённый Swiss \(bbpPairings недоступен\)_')
|
2026-06-14 20:48:02 +00:00
|
|
|
|
|
2026-06-14 20:51:24 +00:00
|
|
|
|
return '\n'.join(lines)
|
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-14 20:48:02 +00:00
|
|
|
|
await update.message.reply_text(START_MSG, parse_mode=ParseMode.MARKDOWN_V2)
|
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 '')
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
from swiss_calc.parser import fetch_url as _fetch_url, parse_round_pairings
|
|
|
|
|
|
try:
|
|
|
|
|
|
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:
|
|
|
|
|
|
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
|
|
|
|
record_request(user.id, url, tournament.get('name', ''), True)
|
|
|
|
|
|
return
|
|
|
|
|
|
if rd_games:
|
|
|
|
|
|
await update.message.reply_text(
|
2026-06-14 20:48:02 +00:00
|
|
|
|
f'⚠️ Жеребьёвка тура {next_rd} уже опубликована на chess\\-results\\.com\\.\n'
|
2026-06-14 20:39:19 +00:00
|
|
|
|
'Я всё равно посчитаю, но пары могут незначительно отличаться '
|
2026-06-14 20:48:02 +00:00
|
|
|
|
'\\(разные редакции правил FIDE\\)\\.',
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
output = format_pairings(result, tournament['name'])
|
|
|
|
|
|
|
|
|
|
|
|
# Telegram has 4096 char limit
|
2026-06-14 21:01:10 +00:00
|
|
|
|
try:
|
|
|
|
|
|
if len(output) > 4000:
|
|
|
|
|
|
chunks = _chunk_output(output, 3800)
|
|
|
|
|
|
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)
|
|
|
|
|
|
else:
|
|
|
|
|
|
await calc_msg.edit_text(output, parse_mode=ParseMode.MARKDOWN_V2,
|
|
|
|
|
|
disable_web_page_preview=True)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# MarkdownV2 parse failed — fall back to plain text
|
|
|
|
|
|
plain = output.replace('*', '').replace('_', '').replace('\\', '')
|
|
|
|
|
|
if len(plain) > 4000:
|
|
|
|
|
|
chunks = _chunk_output(plain, 3800)
|
|
|
|
|
|
await calc_msg.edit_text(chunks[0])
|
|
|
|
|
|
for chunk in chunks[1:]:
|
|
|
|
|
|
await update.message.reply_text(chunk)
|
|
|
|
|
|
else:
|
|
|
|
|
|
await calc_msg.edit_text(plain)
|
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 20:48:02 +00:00
|
|
|
|
"""Split output at ``` boundaries: keep code blocks intact."""
|
2026-06-14 20:39:19 +00:00
|
|
|
|
chunks = []
|
2026-06-14 20:48:02 +00:00
|
|
|
|
in_code = False
|
2026-06-14 20:39:19 +00:00
|
|
|
|
current = []
|
|
|
|
|
|
current_len = 0
|
2026-06-14 20:48:02 +00:00
|
|
|
|
for line in text.split('\n'):
|
|
|
|
|
|
is_fence = line.strip().startswith('```')
|
|
|
|
|
|
new_len = current_len + len(line) + 1
|
|
|
|
|
|
if new_len > size and current and not in_code:
|
2026-06-14 20:39:19 +00:00
|
|
|
|
chunks.append('\n'.join(current))
|
|
|
|
|
|
current = [line]
|
|
|
|
|
|
current_len = len(line)
|
|
|
|
|
|
else:
|
|
|
|
|
|
current.append(line)
|
2026-06-14 20:48:02 +00:00
|
|
|
|
current_len += len(line) + 1
|
|
|
|
|
|
if is_fence:
|
|
|
|
|
|
in_code = not in_code
|
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))
|
|
|
|
|
|
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_url))
|
|
|
|
|
|
|
|
|
|
|
|
print('Client bot started', file=sys.stderr)
|
|
|
|
|
|
app.run_polling()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
|
main()
|