ChessCalcNextTour/bots/client_bot.py
Roman Vrubel c6d799e82f Исправлено: обрезка длинных имён + разделители между парами
- _pad_col теперь обрезает имена длиннее колонки (с многоточием)
- Разделительная линия (────┼────┼────) после каждой пары
- chunk_size кратен 3 (white + black + border)
- Очки выровнены по самой длинной фамилии
2026-06-14 21:13:48 +00:00

325 lines
12 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 _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:
# Truncate to fit: cut chars until it fits, add "…"
result = []
w = 0
for ch in s:
cw = 2 if _display_width(ch) == 2 else 1
if w + cw + 2 > width: # +2 for "…"
break
result.append(ch)
w += cw
return ''.join(result) + ''
return s + ' ' * (width - cur)
def format_pairings(pairings_data: dict, tournament_name: str) -> str:
pairings = pairings_data['pairings']
rnd = pairings_data['round']
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])
header = f'{_pad_col("#", w_board)}{_pad_col("Игрок", max_name_w)}{_pad_col("О", w_pts)}'
table_header = f'```\n{header}\n{border}\n'
source = pairings_data.get('source', 'algorithm')
footer = '\n_FIDE 2025 \(bbpPairings\)_' if source == 'bbp_pairings_fide_2025' else \
'\n_Упрощённый Swiss \(bbpPairings недоступен\)_' if source == 'swiss_algorithm_simplified' else ''
return {
'title': f'*{_md_escape(tournament_name)}*\n📋 *Тур {rnd}* — пары',
'rows': _build_table_rows(pairings, w_board, max_name_w, w_pts, border),
'table_header': table_header,
'footer': footer,
}
def _build_table_rows(pairings: list, w_board: int, w_name: int, w_pts: int,
border: str) -> list:
rows = []
board = 1
for pi, pairing in enumerate(pairings):
if len(pairing) != 3:
continue
p1, p2, color = pairing
if color == 'bye':
rows.append(
f'{_pad_col(str(board), w_board)}'
f'{_pad_col(p1.name, w_name)}'
f'{_pad_col(str(p1.points), w_pts)}'
)
rows.append(
f'{_pad_col("", w_board)}'
f'{_pad_col("— BYE —", w_name)}'
f'{_pad_col("", w_pts)}'
)
board += 1
if pi + 1 < len(pairings):
rows.append(border)
continue
if color == 'w':
white, black = p1, p2
else:
white, black = p2, p1
rows.append(
f'{_pad_col(str(board), w_board)}'
f'{_pad_col(white.name, w_name)}'
f'{_pad_col(f"{white.points:.1f}", w_pts)}'
)
rows.append(
f'{_pad_col("", w_board)}'
f'{_pad_col(black.name, w_name)}'
f'{_pad_col(f"{black.points:.1f}", w_pts)}'
)
board += 1
# Separator between pairs
if pi + 1 < len(pairings):
rows.append(border)
return rows
def _render_chunks(fmt: dict) -> list:
header = fmt['table_header']
footer = fmt['footer']
rows = fmt['rows']
chunks = []
title_line = fmt['title'] + '\n'
# Estimate overhead per chunk
overhead = len(title_line) + len(header) + len(footer) + 10
available = 3800 - overhead
rows_per_chunk = max(15, available // 60)
rows_per_chunk = (rows_per_chunk // 3) * 3 # 3 rows/pair (white, black, border)
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
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
fmt = format_pairings(result, tournament['name'])
chunks = _render_chunks(fmt)
try:
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)
record_request(user.id, url, tournament.get('name', ''), True)
def _chunk_output(text: str, size: int) -> list:
lines = text.split('\n')
chunks = []
current = []
current_len = 0
for line in lines:
line_len = len(line) + 1 # +1 for newline
if current_len + line_len > size and current:
chunks.append('\n'.join(current))
current = []
current_len = 0
current.append(line)
current_len += line_len
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()