Починен краш Message_too_long: умная разбивка на чанки с code-блоками

- format_pairings возвращает структуру (title/rows/header/footer)
- _render_chunks разбивает пары на группы по ~30 пар
- Каждый чанк — свой code-блок с заголовком
- Убраны разделители между парами (экономия ~50% размера)
- Fallback plain text при любой ошибке MarkdownV2
This commit is contained in:
Roman Vrubel 2026-06-14 21:10:31 +00:00
parent 6bb1d238aa
commit bd8cb7d9fc

View file

@ -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:]:
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)
else:
await calc_msg.edit_text(plain)
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 = []
current_len = 0
current.append(line)
current_len += len(line) + 1
if is_fence:
in_code = not in_code
current_len += line_len
if current:
chunks.append('\n'.join(current))
return chunks