diff --git a/bots/client_bot.py b/bots/client_bot.py index 066eec2..2e0b184 100644 --- a/bots/client_bot.py +++ b/bots/client_bot.py @@ -68,7 +68,6 @@ def format_pairings(pairings_data: dict, tournament_name: str) -> str: pairings = pairings_data['pairings'] rnd = pairings_data['round'] - # Find the longest player name + 3 spaces padding max_name_w = 20 for pairing in pairings: if len(pairing) == 3: @@ -81,31 +80,38 @@ def format_pairings(pairings_data: dict, tournament_name: str) -> str: w_board, w_pts = 4, 5 border = '─┼─'.join('─' * w for w in [w_board, max_name_w, w_pts]) - lines = [ - f'*{_md_escape(tournament_name)}*', - f'📋 *Тур {rnd}* — пары', - '', - '```', - f'{_pad_col("#", w_board)} │ {_pad_col("Игрок", max_name_w)} │ ' - f'{_pad_col("О", w_pts)}', - border, - ] + 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), + 'table_header': table_header, + 'footer': footer, + } + + +def _build_table_rows(pairings: list, w_board: int, w_name: int, w_pts: int) -> list: + rows = [] board = 1 - for pi, pairing in enumerate(pairings): + for pairing in pairings: if len(pairing) != 3: continue p1, p2, color = pairing if color == 'bye': - lines.append( + rows.append( f'{_pad_col(str(board), w_board)} │ ' - f'{_pad_col(p1.name, max_name_w)} │ ' + f'{_pad_col(p1.name, w_name)} │ ' f'{_pad_col(str(p1.points), w_pts)}' ) - lines.append( + rows.append( f'{_pad_col("", w_board)} │ ' - f'{_pad_col("— BYE —", max_name_w)} │ ' + f'{_pad_col("— BYE —", w_name)} │ ' f'{_pad_col("", w_pts)}' ) board += 1 @@ -116,33 +122,48 @@ def format_pairings(pairings_data: dict, tournament_name: str) -> str: else: white, black = p2, p1 - lines.append( + rows.append( f'{_pad_col(str(board), w_board)} │ ' - f'{_pad_col(white.name, max_name_w)} │ ' + f'{_pad_col(white.name, w_name)} │ ' f'{_pad_col(f"{white.points:.1f}", w_pts)}' ) - lines.append( + rows.append( f'{_pad_col("", w_board)} │ ' - f'{_pad_col(black.name, max_name_w)} │ ' + f'{_pad_col(black.name, w_name)} │ ' f'{_pad_col(f"{black.points:.1f}", w_pts)}' ) board += 1 - # 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) + return rows - lines.append('```') - 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 недоступен\)_') +def _render_chunks(fmt: dict) -> list: + header = fmt['table_header'] + footer = fmt['footer'] + rows = fmt['rows'] - return '\n'.join(lines) + chunks = [] + title_line = fmt['title'] + '\n' + + # Estimate overhead per chunk: title (if first) + header + footer + ``` + overhead = len(title_line) + len(header) + len(footer) + 10 + available = 3800 - overhead + rows_per_chunk = max(10, available // 60) # ~60 chars per row + rows_per_chunk = (rows_per_chunk // 2) * 2 # keep pairs together + + 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): @@ -234,52 +255,38 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE): record_request(user.id, url, tournament.get('name', ''), False, str(e)) return - output = format_pairings(result, tournament['name']) + fmt = format_pairings(result, tournament['name']) + chunks = _render_chunks(fmt) - # Telegram has 4096 char limit 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) + 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: - """Split output at ``` boundaries: keep code blocks intact.""" + lines = text.split('\n') 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: + 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 = [line] - current_len = len(line) - else: - current.append(line) - current_len += len(line) + 1 - if is_fence: - in_code = not in_code + current = [] + current_len = 0 + current.append(line) + current_len += line_len if current: chunks.append('\n'.join(current)) return chunks