diff --git a/bot/utils/formatters.py b/bot/utils/formatters.py index d2202f8..bfc1fac 100644 --- a/bot/utils/formatters.py +++ b/bot/utils/formatters.py @@ -3,13 +3,15 @@ import asyncio import logging -from typing import List +import re +from typing import List, Tuple from telegram import Update logger = logging.getLogger(__name__) # Лимиты Telegram MAX_MESSAGE_LENGTH = 4096 # Максимальная длина сообщения +RESERVED_FOR_HEADER = 20 # Резервируем место для "(N/N) " def escape_markdown(text: str) -> str: @@ -20,53 +22,136 @@ def escape_markdown(text: str) -> str: return text -def split_message(text: str, max_length: int = MAX_MESSAGE_LENGTH) -> List[str]: +def find_code_blocks(text: str) -> List[Tuple[int, int]]: """ - Разбить длинный текст на сообщения <= max_length символов. - Старается разбивать по границам строк или блоков кода. + Найти все блоки кода (```) в тексте. + Возвращает список кортежей (start, end) для каждого блока. + """ + blocks = [] + pattern = re.compile(r'```') + matches = list(pattern.finditer(text)) + + # Пары start-end для каждого блока + i = 0 + while i < len(matches) - 1: + start = matches[i].start() + end = matches[i + 1].end() + blocks.append((start, end)) + i += 2 + + return blocks + + +def split_message(text: str, max_length: int = MAX_MESSAGE_LENGTH) -> List[Tuple[str, bool]]: + """ + Умно разбить длинный текст на сообщения <= max_length символов. + + Возвращает список кортежей (text, has_markdown): + - text: текст сообщения + - has_markdown: True если сообщение содержит блоки кода + + Алгоритм: + 1. Находим все блоки кода + 2. Стараемся не разрывать блоки кода + 3. Если блок кода не влезает — отправляем отдельным сообщением без Markdown """ if len(text) <= max_length: - return [text] - + return [(text, '```' in text)] + parts = [] + code_blocks = find_code_blocks(text) + + # Текущая позиция в тексте + pos = 0 current = "" - - for line in text.split('\n'): - # Если добавление строки превысит лимит - if len(current) + len(line) + 1 > max_length: + current_has_code = False + + for block_start, block_end in code_blocks: + # Текст до блока кода + before_code = text[pos:block_start] + + # Сам блок кода (включая ```) + code_block = text[block_start:block_end] + + # Обрабатываем текст до блока + for line in before_code.split('\n'): + if len(current) + len(line) + 1 > max_length - RESERVED_FOR_HEADER: + if current: + parts.append((current, current_has_code)) + current = line + current_has_code = False + else: + current += ('\n' if current else '') + line + + # Проверяем влезет ли блок кода + if len(current) + len(code_block) + 1 > max_length - RESERVED_FOR_HEADER: + # Отправляем что накопилось if current: - parts.append(current) - # Если строка сама по себе длиннее лимита — режем её - while len(line) > max_length: - parts.append(line[:max_length]) - line = line[max_length:] - current = line + parts.append((current, current_has_code)) + + # Если блок кода слишком длинный — режем его на части + if len(code_block) > max_length - RESERVED_FOR_HEADER: + # Отправляем блок кода частями без Markdown + for i in range(0, len(code_block), max_length - RESERVED_FOR_HEADER): + chunk = code_block[i:i + max_length - RESERVED_FOR_HEADER] + parts.append((chunk, False)) # Без Markdown! + current = "" + current_has_code = False + else: + # Блок влезает в следующее сообщение + current = code_block + current_has_code = True else: - current += ('\n' if current else '') + line - + # Блок кода влезает в текущее сообщение + current += ('\n' if current else '') + code_block + current_has_code = True + + pos = block_end + + # Обрабатываем остаток текста после последнего блока + if pos < len(text): + remaining = text[pos:] + for line in remaining.split('\n'): + if len(current) + len(line) + 1 > max_length - RESERVED_FOR_HEADER: + if current: + parts.append((current, current_has_code)) + current = line + current_has_code = False + else: + current += ('\n' if current else '') + line + if current: - parts.append(current) - + parts.append((current, current_has_code)) + return parts async def send_long_message(update: Update, text: str, parse_mode: str = None): """ Отправить длинный текст, разбив на несколько сообщений. - Если parse_mode="Markdown" и текст содержит блоки кода — отправляет без разметки. + + Умная разбивка: + - Блоки кода не разрываются между сообщениями + - Если блок кода не влезает — отправляется без Markdown + - Нумерация (1/3), (2/3) только если сообщений > 1 """ parts = split_message(text) + total = len(parts) - for i, part in enumerate(parts): + for i, (part, has_markdown) in enumerate(parts): # Добавляем номер части если их несколько - if len(parts) > 1: - header = f"({i+1}/{len(parts)}) " + if total > 1: + header = f"({i+1}/{total}) " if len(header) + len(part) <= MAX_MESSAGE_LENGTH: part = header + part - # Если это не первая часть и был Markdown — убираем parse_mode - # чтобы не было проблем с разорванной разметкой - actual_parse_mode = parse_mode if i == 0 else None + # Определяем parse_mode для этого сообщения + # Если у сообщения есть блоки кода — используем Markdown + # Если нет — отправляем без разметки (безопаснее) + if has_markdown and parse_mode: + actual_parse_mode = parse_mode + else: + actual_parse_mode = None try: await update.message.reply_text(part, parse_mode=actual_parse_mode)