From 35fb54a887d2e3aea98101ada80e29f479a7a476 Mon Sep 17 00:00:00 2001 From: Roman Vrubel Date: Sun, 14 Jun 2026 20:48:02 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D1=91=D0=BB=20Te?= =?UTF-8?q?legram-=D0=B1=D0=BE=D1=82=D0=BE=D0=B2=20=D0=BD=D0=B0=20Markdown?= =?UTF-8?q?V2=20=D1=81=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=B2=20code-=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client_bot.py: вывод пар в моноширинной таблице () с учётом ширины кириллицы и обрезкой длинных имён - admin_bot.py: /stat в MarkdownV2 - Оба бота: все сообщения в ParseMode.MARKDOWN_V2 --- bots/admin_bot.py | 20 ++-- bots/client_bot.py | 249 +++++++++++++++++++++++++++++---------------- 2 files changed, 169 insertions(+), 100 deletions(-) diff --git a/bots/admin_bot.py b/bots/admin_bot.py index e9e39d5..1a0677a 100644 --- a/bots/admin_bot.py +++ b/bots/admin_bot.py @@ -13,10 +13,10 @@ from bots.stats import get_stats, record_user TOKEN = os.environ.get('ADMIN_BOT_TOKEN', '') START_MSG = ( - '🔧 chessCalc — админский бот\n\n' - 'Доступные команды:\n' - '/stat — статистика использования клиентского бота\n' - '/start — эта справка' + r'*🔧 chessCalc — админский бот*\n\n' + r'Доступные команды:\n' + r'/stat — статистика использования клиентского бота\n' + r'/start — эта справка' ) @@ -24,7 +24,7 @@ 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.HTML) + await update.message.reply_text(START_MSG, parse_mode=ParseMode.MARKDOWN_V2) async def stat(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -33,12 +33,12 @@ async def stat(update: Update, context: ContextTypes.DEFAULT_TYPE): user.last_name or '') stats = get_stats() text = ( - '📊 Статистика chessCalc\n\n' - f'👤 Уникальных пользователей: {stats["unique_users"]}\n' - f'🔢 Всего запросов: {stats["total_requests"]}\n' - f'📅 За сегодня: {stats["today_requests"]}' + r'*📊 Статистика chessCalc*\n\n' + rf'👤 Уникальных пользователей: *{stats["unique_users"]}*\n' + rf'🔢 Всего запросов: *{stats["total_requests"]}*\n' + rf'📅 За сегодня: *{stats["today_requests"]}*' ) - await update.message.reply_text(text, parse_mode=ParseMode.HTML) + await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN_V2) def main(): diff --git a/bots/client_bot.py b/bots/client_bot.py index 4622154..1b85da6 100644 --- a/bots/client_bot.py +++ b/bots/client_bot.py @@ -1,7 +1,7 @@ """ Клиентский Telegram-бот chessCalc. Принимает ссылку на турнир chess-results.com, рассчитывает жеребьёвку -следующего тура и выводит пары. +следующего тура и выводит пары в таблице (MarkdownV2). """ import os import re @@ -15,29 +15,142 @@ 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 swiss_calc.display import format_pairings_table 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 = ( - '♟️ chessCalc — жеребьёвка следующего тура\n\n' - 'Я рассчитываю пары следующего тура шахматного турнира ' - 'до официальной публикации на chess-results.com.\n\n' - 'Просто пришли мне ссылку на турнир — и я покажу, ' - 'кто с кем играет в следующем туре.\n\n' - 'Пример ссылки:\n' - 'https://chess-results.com/tnr1393124.aspx?lan=11\n\n' - 'Работает для швейцарских турниров. Движок: FIDE Dutch System (bbpPairings).' + 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) + # Cyrillic, CJK, etc — typically 2-wide in monospace + if (0x0400 <= cp <= 0x04FF or 0x0500 <= cp <= 0x052F or + 0x2E80 <= cp <= 0x9FFF or 0xF900 <= cp <= 0xFAFF or + 0xFE30 <= cp <= 0xFE4F or cp == 0x2116): + w += 2 + else: + w += 1 + return w + + +def _pad_to_width(s: str, width: int) -> str: + cur = _display_width(s) + if cur >= width: + return s + return s + ' ' * (width - cur) + + +def _trim_to_width(s: str, width: int) -> str: + result = [] + cur = 0 + for ch in s: + cw = 2 if _display_width(ch) == 2 else 1 + if cur + cw > width: + break + result.append(ch) + cur += cw + return ''.join(result) + + +def _make_table_header(cols: list, widths: list) -> str: + parts = [_pad_to_width(c, w) for c, w in zip(cols, widths)] + line = ' │ '.join(parts) + sep = '─┼─'.join('─' * w for w in widths) + return line + '\n' + sep + + +def _make_table_row(cols: list, widths: list) -> str: + parts = [_pad_to_width(c, w) for c, w in zip(cols, widths)] + return ' │ '.join(parts) + + +def format_pairings(pairings_data: dict, tournament_name: str) -> str: + pairings = pairings_data['pairings'] + rnd = pairings_data['round'] + + max_name = 28 + cols = ['#', 'Белые', 'О', 'Чёрные', 'О'] + widths = [5, max_name, 5, max_name, 5] + + header_line = f'*{_md_escape(tournament_name)}*\n📋 *Тур {rnd}* — пары\n' + + table_lines = [] + table_lines.append('```') + table_lines.append(_make_table_header(cols, widths)) + + board = 1 + for pairing in pairings: + if len(pairing) != 3: + continue + p1, p2, color = pairing + + if color == 'bye': + bye_name = _trim_to_width(p1.name, max_name) + table_lines.append( + _make_table_row( + [str(board), bye_name, str(p1.points), '— BYE —', ''], + widths)) + board += 1 + continue + + if color == 'w': + white, black = p1, p2 + else: + white, black = p2, p1 + + w_name = _trim_to_width(white.name, max_name) + b_name = _trim_to_width(black.name, max_name) + w_pts = f'{white.points:.1f}' + b_pts = f'{black.points:.1f}' + + table_lines.append( + _make_table_row( + [str(board), w_name, w_pts, b_name, b_pts], + widths)) + board += 1 + + table_lines.append('```') + + footer = '' + source = pairings_data.get('source', 'algorithm') + if source == 'bbp_pairings_fide_2025': + footer = '\n_FIDE 2025 \(bbpPairings\)_' + elif source == 'swiss_algorithm_simplified': + footer = '\n_Упрощённый Swiss \(bbpPairings недоступен\)_' + + return header_line + '\n'.join(table_lines) + footer + + 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.HTML) + await update.message.reply_text(START_MSG, parse_mode=ParseMode.MARKDOWN_V2) async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -49,36 +162,38 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE): match = URL_PATTERN.search(text) if not match: await update.message.reply_text( - '❌ Не нашёл ссылку на chess-results.com в сообщении.\n\n' + '❌ Не нашёл ссылку на chess\\-results\\.com в сообщении\\.\n\n' 'Пришли ссылку вида:\n' - 'https://chess-results.com/tnr1393124.aspx?lan=11', - parse_mode=ParseMode.HTML) + '`https://chess\\-results\\.com/tnr1393124\\.aspx?lan=11`', + parse_mode=ParseMode.MARKDOWN_V2) return url = match.group(0) - msg = await update.message.reply_text('⏳ Загружаю турнир...') + 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'❌ Ошибка загрузки: {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('🏁 Турнир уже завершён. Жеребьёвки не будет.') - record_request(user.id, url, tournament.get('name', ''), - True) + await msg.edit_text('🏁 Турнир уже завершён. Жеребьёвки не будет.', + parse_mode=ParseMode.MARKDOWN_V2) + record_request(user.id, url, tournament.get('name', ''), True) return status_parts = [ - f'✅ {tournament["name"]}', + 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.HTML) + await msg.edit_text('\n'.join(status_parts), parse_mode=ParseMode.MARKDOWN_V2) - await asyncio.sleep(0.5) # дать прочитать + await asyncio.sleep(0.5) # Check if next round pairings already published on site base_url = re.sub(r'[&?]art=\d+', '', url) @@ -91,32 +206,32 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE): try: rd_html = _fetch_url(f'{base_url}&art=2&rd={next_rd}&turdet=YES') rd_games = parse_round_pairings(rd_html, next_rd) - # Check if any game has a result (means round already played) 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.HTML) + f'⚠️ Тур {next_rd} уже сыгран — на сайте есть результаты\\.', + parse_mode=ParseMode.MARKDOWN_V2) record_request(user.id, url, tournament.get('name', ''), True) return - # Check if pairings are published (unplayed games exist) if rd_games: await update.message.reply_text( - f'⚠️ Жеребьёвка тура {next_rd} уже опубликована на chess-results.com.\n' + f'⚠️ Жеребьёвка тура {next_rd} уже опубликована на chess\\-results\\.com\\.\n' 'Я всё равно посчитаю, но пары могут незначительно отличаться ' - '(разные редакции правил FIDE).', - parse_mode=ParseMode.HTML) + '\\(разные редакции правил FIDE\\)\\.', + parse_mode=ParseMode.MARKDOWN_V2) except Exception: - pass # art=2 page not accessible — pairings likely not published yet + pass # Calculate next round calc_msg = await update.message.reply_text( - f'🧮 Считаю жеребьёвку тура {next_rd}...') + f'🧮 Считаю жеребьёвку тура {next_rd}\\.\\.\\.', + parse_mode=ParseMode.MARKDOWN_V2) try: result = calculate_next_round(tournament) except Exception as e: - await calc_msg.edit_text(f'❌ Ошибка расчёта: {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 @@ -125,82 +240,36 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE): # Telegram has 4096 char limit if len(output) > 4000: chunks = _chunk_output(output, 3800) - await calc_msg.edit_text(chunks[0], parse_mode=ParseMode.HTML, + 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.HTML, + 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.HTML, + 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 format_pairings(pairings_data: dict, tournament_name: str) -> str: - pairings = pairings_data['pairings'] - rnd = pairings_data['round'] - - lines = [ - f'{_escape_html(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}. {_escape_html(p1.name)} — BYE') - board += 1 - continue - - if color == 'w': - white, black = p1, p2 - else: - white, black = p2, p1 - - w_name = _escape_html(white.name) - b_name = _escape_html(black.name) - w_pts = white.points - b_pts = black.points - - lines.append( - f' {board}. {w_name} — {b_name} ' - f'({w_pts}/{b_pts})' - ) - board += 1 - - lines.append('') - source = pairings_data.get('source', 'algorithm') - if source == 'bbp_pairings_fide_2025': - lines.append('FIDE 2025 (bbpPairings)') - elif source == 'swiss_algorithm_simplified': - lines.append('Упрощённый Swiss (bbpPairings недоступен)') - - return '\n'.join(lines) - - -def _escape_html(text: str) -> str: - return text.replace('&', '&').replace('<', '<').replace('>', '>') - - def _chunk_output(text: str, size: int) -> list: - lines = text.split('\n') + """Split output at ``` boundaries: keep code blocks intact.""" chunks = [] + in_code = False current = [] current_len = 0 - for line in lines: - if current_len + len(line) > size and current: + 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) + current_len += len(line) + 1 + if is_fence: + in_code = not in_code if current: chunks.append('\n'.join(current)) return chunks