ChessCalcNextTour/bots/client_bot.py
Roman Vrubel 566daf5bb4 Убрал таблицу, заменил на компактный список (влезает на телефон)
Каждая пара — одна строка: номер, белые (очки) — чёрные (очки).
Убрал мёртвый код: _display_width, _make_table_* и др.
2026-06-14 20:51:24 +00:00

232 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Клиентский Telegram-бот chessCalc.
Принимает ссылку на турнир chess-results.com, рассчитывает жеребьёвку
следующего тура и выводит пары в таблице (MarkdownV2).
"""
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+')
START_MSG = (
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\)\.'
)
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)
def format_pairings(pairings_data: dict, tournament_name: str) -> str:
pairings = pairings_data['pairings']
rnd = pairings_data['round']
lines = [
f'*{_md_escape(tournament_name)}*',
f'📋 *Тур {rnd}* — пары',
'',
]
board = 1
for pairing in pairings:
if len(pairing) != 3:
continue
p1, p2, color = pairing
if color == 'bye':
lines.append(f'{board}\\. {_md_escape(p1.name)} — BYE')
board += 1
continue
if color == 'w':
white, black = p1, p2
else:
white, black = p2, p1
lines.append(
f'{board}\\. {_md_escape(white.name)} '
f'\\({white.points:.1f}\\) — '
f'{_md_escape(black.name)} \\({black.points:.1f}\\)'
)
board += 1
source = pairings_data.get('source', 'algorithm')
if source == 'bbp_pairings_fide_2025':
lines.append('\n_FIDE 2025 \(bbpPairings\)_')
elif source == 'swiss_algorithm_simplified':
lines.append('\n_Упрощённый Swiss \(bbpPairings недоступен\)_')
return '\n'.join(lines)
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 '')
await update.message.reply_text(START_MSG, 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 '')
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`',
parse_mode=ParseMode.MARKDOWN_V2)
return
url = match.group(0)
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)
record_request(user.id, url, '', False, str(e))
return
if tournament['current_round'] >= tournament['num_rounds']:
await msg.edit_text('🏁 Турнир уже завершён. Жеребьёвки не будет.',
parse_mode=ParseMode.MARKDOWN_V2)
record_request(user.id, url, tournament.get('name', ''), True)
return
status_parts = [
f'✅ *{_md_escape(tournament["name"])}*',
f'📅 Сыграно туров: {tournament["current_round"]} из {tournament["num_rounds"]}',
f'👥 Участников: {len(tournament["standings"])}',
]
await msg.edit_text('\n'.join(status_parts), parse_mode=ParseMode.MARKDOWN_V2)
await asyncio.sleep(0.5)
# 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(
f'⚠️ Тур {next_rd} уже сыгран — на сайте есть результаты\\.',
parse_mode=ParseMode.MARKDOWN_V2)
record_request(user.id, url, tournament.get('name', ''), True)
return
if rd_games:
await update.message.reply_text(
f'⚠️ Жеребьёвка тура {next_rd} уже опубликована на chess\\-results\\.com\\.\n'
'Я всё равно посчитаю, но пары могут незначительно отличаться '
'\\(разные редакции правил FIDE\\)\\.',
parse_mode=ParseMode.MARKDOWN_V2)
except Exception:
pass
# Calculate next round
calc_msg = await update.message.reply_text(
f'🧮 Считаю жеребьёвку тура {next_rd}\\.\\.\\.',
parse_mode=ParseMode.MARKDOWN_V2)
try:
result = calculate_next_round(tournament)
except Exception as e:
await calc_msg.edit_text(f'❌ Ошибка расчёта: {_md_escape(str(e))}',
parse_mode=ParseMode.MARKDOWN_V2)
record_request(user.id, url, tournament.get('name', ''), False, str(e))
return
output = format_pairings(result, tournament['name'])
# Telegram has 4096 char limit
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)
record_request(user.id, url, tournament.get('name', ''), True)
def _chunk_output(text: str, size: int) -> list:
"""Split output at ``` boundaries: keep code blocks intact."""
chunks = []
in_code = False
current = []
current_len = 0
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:
chunks.append('\n'.join(current))
current = [line]
current_len = len(line)
else:
current.append(line)
current_len += len(line) + 1
if is_fence:
in_code = not in_code
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()