""" lumen_formatting.py — markdown Lumen в Telegram HTML. Вынесен из bot.py: чистые функции над строками без I/O и состояния. """ from __future__ import annotations import re _TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$") # Регекс квадратичен на длинных пробельных хвостах: строку режем до match, # разделитель шире капа в живых сообщениях не встречается. _TABLE_SEP_MAX_LEN = 500 def _is_table_separator(line: str) -> bool: """Строка-разделитель markdown-таблицы с bounded match.""" return _TABLE_SEP_RE.match(line[:_TABLE_SEP_MAX_LEN]) is not None def _split_table_cells(line: str) -> list[str]: s = line.strip() if s.startswith("|"): s = s[1:] if s.endswith("|"): s = s[:-1] return [c.strip() for c in s.split("|")] def _convert_markdown_tables_to_lists(text: str) -> str: """Telegram не рендерит таблицы: блок "заголовок + дефисы + строки" разворачиваем в "• Заголовок: значение". Rich-путь шлёт настоящий .""" if "|" not in text or "-" not in text: return text lines = text.split("\n") out: list[str] = [] i = 0 n = len(lines) while i < n: line = lines[i] if "|" in line and i + 1 < n and "-" in lines[i + 1] and _is_table_separator(lines[i + 1]): header_cells = _split_table_cells(line) if len(header_cells) >= 2: data_rows = [] j = i + 2 while j < n and "|" in lines[j] and lines[j].strip(): data_rows.append(_split_table_cells(lines[j])) j += 1 if data_rows: for row in data_rows: parts = [] for h_idx, header in enumerate(header_cells): val = row[h_idx] if h_idx < len(row) else "" if not val: continue parts.append(f"**{header}:** {val}" if header else val) if parts: out.append("• " + "; ".join(parts)) i = j continue out.append(line) i += 1 return "\n".join(out) def _table_block_line_indexes(lines: list[str]) -> set[int]: """Индексы строк markdown-таблиц (шапка + разделитель + тело) — теми же условиями, что _convert_markdown_tables_to_lists выше. Разносчикам списков эти строки трогать нельзя: в обычном пути таблицы разворачиваются ПОСЛЕ разноса, и строка без ведущего `|` (валидная таблица без внешних пайпов) иначе рвалась до детекта таблицы — стрим и финал расходились.""" idx: set[int] = set() i, n = 0, len(lines) while i < n: if "|" in lines[i] and i + 1 < n and "-" in lines[i + 1] and _is_table_separator(lines[i + 1]): if len(_split_table_cells(lines[i])) >= 2: j = i + 2 data = 0 while j < n and "|" in lines[j] and lines[j].strip(): data += 1 j += 1 if data: idx.update(range(i, j)) i = j continue i += 1 return idx # ── Защитная сетка от сырого LaTeX ────────────────────────────────────────── # Реальный найденный при калибровке случай: nemotron-3-nano-30b-a3b:free выдала # "\[ S = \pi r^{2}, \]" и "\(x^{2}+y^{2}=r^{2}\)" вместо юникода в ответе про # площадь круга. Промпт разрешает LaTeX для rich-пути (), а это — # второй рубеж для обычного пути: не полагаемся только на послушание модели, # страхуем детерминированной пост-обработкой в юникод. _LATEX_SUPERSCRIPT_MAP = {"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", "+": "⁺", "-": "⁻", "n": "ⁿ"} _LATEX_SUBSCRIPT_MAP = {"0": "₀", "1": "₁", "2": "₂", "3": "₃", "4": "₄", "5": "₅", "6": "₆", "7": "₇", "8": "₈", "9": "₉"} # Порядок важен: многобуквенные команды (\times, \infty...) должны замениться # ДО одиночного \t/\i и т.п., иначе оставшийся общий "\команда -> без бэкслеша" # в конце срежет их раньше времени. dict сохраняет порядок вставки в Python 3.7+. _LATEX_SYMBOL_MAP: dict[str, str] = { r"\times": "×", r"\cdot": "·", r"\approx": "≈", r"\infty": "∞", r"\leq": "≤", r"\le": "≤", r"\geq": "≥", r"\ge": "≥", r"\neq": "≠", r"\ne": "≠", r"\rightarrow": "→", r"\Rightarrow": "⇒", r"\to": "→", r"\forall": "∀", r"\exists": "∃", r"\emptyset": "∅", r"\cup": "∪", r"\cap": "∩", r"\int": "∫", # ДО \in: иначе "\int" съедался как "\in" и давал "∈t" (враждебное ревью 27.09.2026) r"\in": "∈", r"\pi": "π", r"\pm": "±", r"\mp": "∓", r"\sum": "∑", r"\prod": "∏", r"\alpha": "α", r"\beta": "β", r"\gamma": "γ", r"\Gamma": "Γ", r"\theta": "θ", r"\lambda": "λ", r"\mu": "μ", r"\sigma": "σ", r"\Sigma": "Σ", r"\delta": "δ", r"\Delta": "Δ", r"\phi": "φ", r"\omega": "ω", r"\Omega": "Ω", } def _latex_superscript(m: re.Match) -> str: return "".join(_LATEX_SUPERSCRIPT_MAP.get(ch, ch) for ch in m.group(1)) def _latex_subscript(m: re.Match) -> str: return "".join(_LATEX_SUBSCRIPT_MAP.get(ch, ch) for ch in m.group(1)) def _scrub_latex(text: str) -> str: """Сырой LaTeX в юникод. Вызывать только после вырезки кода, иначе портятся слэши в regex/путях Windows.""" if "\\" not in text and "$" not in text: return text # Снимаем только $$/\[..\]/\(..\): одиночный $ не трогаем, иначе сумма "$100" спарится с формулой и испортит оба. text = re.sub(r"\\\[(.*?)\\\]", r"\1", text, flags=re.DOTALL) text = re.sub(r"\\\((.*?)\\\)", r"\1", text, flags=re.DOTALL) text = re.sub(r"\$\$(.*?)\$\$", r"\1", text, flags=re.DOTALL) # \frac{a}{b} -> a/b (одноуровневая вложенность, самый частый случай) text = re.sub(r"\\d?frac\{([^{}]*)\}\{([^{}]*)\}", r"\1/\2", text) # \sqrt{x} -> √x, \sqrt[n]{x} -> ⁿ√x text = re.sub(r"\\sqrt\[([^\]]*)\]\{([^{}]*)\}", r"\1√\2", text) text = re.sub(r"\\sqrt\{([^{}]*)\}", r"√\1", text) for cmd, repl in _LATEX_SYMBOL_MAP.items(): text = text.replace(cmd, repl) # x^{2} / x^2 -> x², x_{2} / x_2 -> x₂ — только короткие индексы/степени, # чтобы случайно не тронуть код вида a^b в языках, где это не степень. # Осознанный компромисс: голый x^2 вне кода считаем степенью, редкий XOR в прозе без бэктиков сломается. text = re.sub(r"\^\{([0-9n+\-]{1,3})\}", _latex_superscript, text) text = re.sub(r"\^([0-9n])(?![0-9])", _latex_superscript, text) text = re.sub(r"_\{([0-9]{1,3})\}", _latex_subscript, text) text = re.sub(r"_([0-9])(?![0-9])", _latex_subscript, text) # Оставшиеся одиночные \command без известного юникод-эквивалента — просто # снимаем бэкслеш, чтобы пользователь не видел сырое "\int"/"\mathbb" и т.п. text = re.sub(r"\\([a-zA-Z]+)", r"\1", text) return text # ── Маркеры списков "- текст" / "* текст" в начале строки → "• текст" ─────── # Маркеры "-/* " в начале строки в "• ": иначе литеральные "-" видны в Telegram и "*" ломает italic дальше. _BULLET_MARKER_RE = re.compile(r"^([ \t]*)[-*][ \t]+", re.MULTILINE) def _normalize_bullet_markers(text: str) -> str: return _BULLET_MARKER_RE.sub(lambda m: m.group(1) + "• ", text) # Слипшиеся в один абзац буллеты ("• A ... • B ... • C", прод 22.09.2026: модель # написала весь список сравнения в одну строку) — разносим по строкам. Порог — # 2+ разделителя (т.е. уже 3 пункта: прод 26.09.2026 показал, что ровно # трёхпунктовые списки — самый частый случай, а с порогом 3 они не ловились). # Короткие "чай • кофе" (1 разделитель) и короткие абзацы — обычная проза, не трогаем. # Прод 07.10.2026: строки "Telegram: • ..." (~140 символов) не ловились, порог 200→120. # Сепаратор с необязательными пробелами ловит склейку без пробелов (прод 05.10.2026). _INLINE_BULLETS_MIN_SEPS = 2 _INLINE_BULLETS_MIN_LEN = 120 _INLINE_BULLET_SEP_RE = re.compile(r"\s*[•·]\s*") _INLINE_BULLET_START_RE = re.compile(r"\s*[•·]") def _split_inline_bullets(text: str) -> str: lines = text.split("\n") skip = _table_block_line_indexes(lines) out = [] for idx, line in enumerate(lines): chunks: list[str] = [] if ( idx not in skip and len(line) >= _INLINE_BULLETS_MIN_LEN and len(_INLINE_BULLET_SEP_RE.findall(line)) >= _INLINE_BULLETS_MIN_SEPS and not _is_structural_line(line) ): chunks = [c.strip() for c in _INLINE_BULLET_SEP_RE.split(line)] chunks = [c for c in chunks if c] if len(chunks) >= 2: if _INLINE_BULLET_START_RE.match(line): # Строка начинается с маркера — вводной фразы нет, все куски пункты. out.extend("• " + chunk for chunk in chunks) else: # Первый кусок — вводная фраза ("Вот моменты:"), дальше — пункты. out.append(chunks[0]) out.extend("• " + chunk for chunk in chunks[1:]) else: out.append(line) return "\n".join(out) # Строки служебной разметки: разносчик не должен ни есть заголовок, ни вытаскивать # пункты из цитаты, ни рвать строку markdown-таблицы (враждебное ревью 27.09.2026). # Проверка нужна в ОБОИХ разносчиках и в обоих путях рендера. _STRUCT_LINE_RE = re.compile(r"^\s*(?:#{1,6}\s|>|\|)") def _is_structural_line(line: str) -> bool: return bool(_STRUCT_LINE_RE.match(line)) # Слипшиеся в один абзац нумерованные пункты ("1. ... 2. ... 3. ...", прод # 25.09.2026: модель написала все 3 причины голубого неба одной строкой) — # разносим по строкам. Строго: последовательность с 1, 3+ пункта, каждый # содержательный — иначе дробим прозу вида "смотри пункты 1. и 2. ниже". _INLINE_NUMBERED_MIN_ITEMS = 3 _INLINE_NUMBERED_MIN_ITEM_LEN = 12 _NUMBERED_MARKER_RE = re.compile(r"(? str: lines = text.split("\n") skip = _table_block_line_indexes(lines) out = [] for idx, line in enumerate(lines): markers = [(m.start(), int(m.group(1))) for m in _NUMBERED_MARKER_RE.finditer(line)] if ( idx not in skip and len(markers) >= _INLINE_NUMBERED_MIN_ITEMS and [num for _, num in markers] == list(range(1, len(markers) + 1)) and not _is_structural_line(line) ): bounds = [pos for pos, _ in markers] + [len(line)] items = [line[bounds[i]:bounds[i + 1]].strip() for i in range(len(markers))] if all(len(it) >= _INLINE_NUMBERED_MIN_ITEM_LEN for it in items): # Вводная фраза до "1." ("Причины:") — отдельной строкой, как у буллетов. head = line[:bounds[0]].rstrip() if head: out.append(head) out.extend(items) continue out.append(line) return "\n".join(out) # ── Markdown-заголовки "#"/"##"/"###" → **жирный текст** ──────────────────── # Регрессия 18.08.2026: модели пишут ### вопреки промпту. Режем ATX по CommonMark (решётки + пробел), C#/#tag не трогаем. _HEADER_MARKER_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+(.*)$", re.MULTILINE) def _normalize_headers(text: str) -> str: def _repl(m: re.Match) -> str: content = m.group(1).rstrip() if not content: # Голая строка из одних "#" без текста — нечего выделять жирным, # просто убираем маркер целиком, а не оставляем пустую "****". return "" if content.startswith("**") and content.endswith("**") and len(content) > 4: # Модель сама уже обернула текст заголовка в **bold** (нередкий # случай — "### **Важно**") — оборачивать ЕЩЁ раз дало бы "****Важно****" # и сломало бы парность звёздочек в Phase 3 ниже. Раз обёртка уже # есть, только снимаем сам маркер "#", остальное не трогаем. return content return f"**{content}**" return _HEADER_MARKER_RE.sub(_repl, text) # ── Markdown-цитаты "> текст" → Telegram
─────────────────────── # Тот же принцип, что и у _normalize_bullet_markers выше: "> " — обычный # GFM-синтаксис цитаты, модели он известен без единого слова в system_prompt.py # (там про цитаты вообще ничего не сказано — как и про списки, см. комментарий # у _normalize_bullet_markers). Раньше строка "> текст" просто уходила в # Telegram буквально с ">" в начале. Строится ДО HTML-экранирования (Phase 2), # как и остальные построчные нормализации этой секции — сама обёртка #
добавляется тут же, а не как markdown-маркер для Phase 3, чтобы # не путать её с обычным ">" внутри текста (например, "5 > 3"). _BLOCKQUOTE_LINE_RE = re.compile(r"^> ?(.*)$") # \x00-сентинелы вместо буквальных
/
— та же причина, # что и у плейсхолдеров код-блоков в _md_to_html (см. Phase 1 там): если вставить # реальный HTML-тег здесь, Phase 2 (HTML-escape) его же и экранирует. Сентинелы # невидимы для escape (тот трогает только &/) и заменяются на настоящие теги # уже ПОСЛЕ Phase 3 — так текст внутри цитаты всё ещё проходит обычные # escape/markdown-фазы (например, "> **важно**" корректно станет цитатой с # жирным текстом внутри), меняется только сама обёртка. _BLOCKQUOTE_START = "\x00BQS\x00" _BLOCKQUOTE_END = "\x00BQE\x00" def _convert_blockquotes(text: str) -> str: lines = text.split("\n") out: list[str] = [] quote_buf: list[str] = [] def _flush(): if quote_buf: out.append(_BLOCKQUOTE_START + "\n".join(quote_buf) + _BLOCKQUOTE_END) quote_buf.clear() for line in lines: m = _BLOCKQUOTE_LINE_RE.match(line) if m: quote_buf.append(m.group(1)) else: _flush() out.append(line) _flush() return "\n".join(out) def _md_to_html(text: str) -> str: """Конвертирует markdown-подобный текст в Telegram HTML. Контракт: независимые regex-проходы, порядок критичен. Не переписывать в парсер: ~20 тестов ловят межфазовые конфликты на прод-трафике. Обязательный порядок фаз (нарушение порядка ломает уже отлаженные edge-case'ы): 0. Нормализация сырых HTML-тегов (///
 и битые self-closing) в
         markdown-эквивалент — ДО экранирования (шаг 2), иначе легитимные теги от
         модели превратились бы в видимый мусор "<b>".
      1. Код-блоки/спаны (```...```/`...`) вырезаются и заменяются плейсхолдерами —
         ДО LaTeX/таблиц/списков/markdown, иначе обратные слэши и "|"/"-" внутри
         реального кода (regex, пути Windows, побитовое ИЛИ) были бы испорчены.
      1.3. LaTeX → юникод (_scrub_latex) — код уже вынесен шагом 1.
       1.4. Маркеры списков "-"/"* " → "•" (_normalize_bullet_markers) — ДО таблиц,
            чтобы строка-разделитель таблицы ("|---|---|") успела обработаться первой
            и не была принята за маркер списка.
       1.405. Слипшиеся "• A • B" в одном абзаце → по строкам (_split_inline_bullets).
       1.406. Слипшиеся "1. A 2. B 3. C" в одном абзаце → по строкам (_split_inline_numbered).
      1.45. Markdown-цитаты "> " → сентинелы \x00BQS\x00/\x00BQE\x00 (_convert_
            blockquotes) — сентинелы, не сразу 
, т.к. Phase 2 экранировал бы буквальный тег; настоящий тег подставляется после Phase 3 (см. ниже). 1.5. Markdown-таблицы → список пунктов (_convert_markdown_tables_to_lists) — код и списки уже обработаны/вырезаны шагами 1/1.4. 2. HTML-экранирование остального текста (&/). 3. Markdown (**bold**/*italic*/~~strike~~/[текст](url)) → HTML-теги — ПОСЛЕ экранирования, иначе символы разметки сами могли бы быть экранированы раньше времени. Ссылки [текст](url) — последними в этой фазе (после bold/ italic), чтобы regex-проходы italic/bold не залезли внутрь href, если URL содержит "_" (см. комментарий в коде). 3.5. Сентинелы цитаты (шаг 1.45) → настоящий
— после Phase 3, чтобы markdown внутри цитаты успел стать HTML до финализации обёртки. 4. Код-блоки/спаны восстанавливаются из плейсхолдеров с собственным экранированием — самыми последними, чтобы шаги 2-3 их не затронули. """ if not text: return "" # ── Phase 0: сырой HTML модели в markdown до escape, иначе "<b>" видно. #
в сентинел,
не трогаем. text = re.sub(r"", "\x00BR\x00", text, flags=re.IGNORECASE) text = re.sub(r"", "", text, flags=re.IGNORECASE) text = re.sub(r"<(?:b|strong)>(.*?)", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"<(?:i|em)>(.*?)", r"*\1*", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"(.*?)", r"\1", text, flags=re.IGNORECASE | re.DOTALL) # spoiler — тот же трюк, что и выше: system_prompt.py не просит модель их # использовать, поэтому это чисто защитная сетка на случай, если модель всё же # напишет буквальный тег. Раньше он не ловился здесь вообще и долетал до Phase 2 # экранирования — показывался пользователю как видимый мусор "<tg-spoiler>". text = re.sub(r"(.*?)", r"\1", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r'(.*?)', r"\1", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"(.*?)", r"~~\1~~", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"
(.*?)
", lambda m: f"```\n{m.group(1)}\n```", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"(.*?)", r"`\1`", text, flags=re.IGNORECASE | re.DOTALL) # ── Фаза 1: вырезаем код до обработки ──────────────── _saved: dict[str, str] = {} _counter = [0] def _save_block(m: re.Match) -> str: key = f"\x00CB{_counter[0]}\x00" _counter[0] += 1 _saved[key] = m.group(0) return key text = re.sub(r"```[a-zA-Z0-9]*\n.*?\n```", _save_block, text, flags=re.DOTALL) text = re.sub(r"`[^`\n]+`", _save_block, text) # Ссылки вырезаем как код: "_" в URL ломает italic/bold. Markdown внутри label не поддерживаем. text = re.sub(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", _save_block, text) # ── Phase 1.3: сырой LaTeX → юникод (см. _scrub_latex выше) — код уже # вынесен на предыдущем шаге, поэтому обратные слэши в реальном коде # (regex, пути Windows и т.п.) не затрагиваются. text = _scrub_latex(text) # ── Phase 1.4: маркеры списков "- "/"* " → "• " (см. _normalize_bullet_ # markers выше) — ДО таблиц и ДО Phase 3, чтобы не путаться с "**bold**" и # чтобы строка-разделитель таблицы ("|---|---|") успела обработаться первой. text = _normalize_bullet_markers(text) # ── Phase 1.405: слипшиеся "• A • B" в одном абзаце → по строкам (см. # _split_inline_bullets выше) — после нормализации маркеров, до таблиц/escape. text = _split_inline_bullets(text) # ── Phase 1.406: слипшиеся "1. A 2. B 3. C" в одном абзаце → по строкам # (см. _split_inline_numbered выше) — код уже вынесен шагом 1, нумерация # версий/дат ("3.5", "1995.") под правило не попадает (нужна цепочка с 1). text = _split_inline_numbered(text) # ── Phase 1.41: markdown-заголовки "#"/"##"/"###" → **жирный текст** (см. # _normalize_headers выше) — после списков, до HTML-экранирования и до Phase 3 # (получившийся "**...**" обрабатывается обычным bold-регэкспом на общих # основаниях, отдельная ветка не нужна). text = _normalize_headers(text) # ── Phase 1.45: markdown-цитаты "> " → сентинелы blockquote (см. # _convert_blockquotes выше) — ДО HTML-экранирования, т.к. решение "это # строка цитаты" принимается по буквальному "> " в начале строки; сама # обёртка
подставляется позже (после Phase 3), сентинелы же # (\x00BQS\x00/\x00BQE\x00) escape в Phase 2 не трогает. text = _convert_blockquotes(text) # ── Phase 1.5: markdown-таблицы → список пунктов (см. _convert_markdown_ # tables_to_lists выше) — код уже вынесен на предыдущем шаге, поэтому "|" # внутри кода (например, битовое ИЛИ в Rust/C) сюда не попадёт. text = _convert_markdown_tables_to_lists(text) # ── Фаза 2: экранируем остальное в HTML ──────────────────────────────────────── text = text.replace("&", "&").replace("<", "<").replace(">", ">") # ── Фаза 3: применяем markdown ─────────────────────────────────────────────── text = re.sub(r"(\*\*|__)(.*?)\1", r"\2", text, flags=re.DOTALL) text = re.sub(r"(\*|_)(.*?)\1", r"\2", text) text = re.sub(r"~~(.*?)~~", r"\1", text) # ── Phase 3.5: blockquote-сентинелы → настоящие
───────────── # После Phase 3, чтобы markdown внутри цитаты (например "> **важно**") # успел превратиться в HTML до того, как обёртка станет реальным тегом. text = text.replace(_BLOCKQUOTE_START, "
").replace(_BLOCKQUOTE_END, "
") # ── Фаза 4: возвращаем код с экранированием ──────────────────── # Ссылки первыми: метка ссылки может содержать плейсхолдер кода # ([`code` text](url) — код вырезается раньше ссылки), поздняя замена кода # находит его уже внутри вставленного и протечки \x00CB0\x00 нет. for key, orig in _saved.items(): if not orig.startswith("["): continue m = re.match(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", orig, re.DOTALL) label, url = m.group(1), m.group(2) label = label.replace("&", "&").replace("<", "<").replace(">", ">") url = url.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) text = text.replace(key, f'{label}') for key, orig in _saved.items(): if orig.startswith("["): continue if orig.startswith("```"): m = re.match(r"```([a-zA-Z0-9]*)\n(.*)\n```", orig, re.DOTALL) lang, inner = (m.group(1), m.group(2)) if m else ("", orig[3:-3]) inner = inner.replace("&", "&").replace("<", "<").replace(">", ">") # language — атрибут entity "pre" в Telegram (даёт подсветку синтаксиса # в клиентах, которые её поддерживают); раньше язык из ```python вырезался # регэкспом при сохранении, но никогда не доходил до вывода. replacement = f'
{inner}
' if lang else f"
{inner}
" else: inner = orig[1:-1] inner = inner.replace("&", "&").replace("<", "<").replace(">", ">") replacement = f"{inner}" text = text.replace(key, replacement) # Сентинел
(см. Phase 0): в обычном HTML-пути — перенос строки. # Оговорка:
ВНУТРИ код-блоков тоже превратится в перенос (Phase 0 # идёт до экстракции кода) — тот же класс компромисса, что уже есть у # / в коде выше; код-примеры с буквальным
редки. text = text.replace("\x00BR\x00", "\n") return text def _rich_inline(text: str) -> str: """Инлайн-разметка для внутренностей рич-блоков (заголовков, ячеек таблиц): тот же escape + **bold**/*italic*/~~strike~~, что Phase 2–3 в _md_to_html. Плейсхолдеры кода/ссылок (символы \x00) намеренно не трогаются — они восстанавливаются позже общим финалом, как и в _md_to_html.""" text = text.replace("&", "&").replace("<", "<").replace(">", ">") text = re.sub(r"(\*\*|__)(.*?)\1", r"\2", text, flags=re.DOTALL) text = re.sub(r"(\*|_)(.*?)\1", r"\2", text) text = re.sub(r"~~(.*?)~~", r"\1", text) return text def _build_rich_table(header: list[str], rows: list[list[str]]) -> str: """Строит
из распарсенных ячеек (см. _split_table_cells). Ячейки идут через _rich_inline — внутри работают **bold**/*italic*/`код` (кодовые плейсхолдеры раскрываются общим финалом позже).""" parts = ["
"] parts.append("" + "".join(f"" for h in header) + "") for row in rows: cells = [row[i] if i < len(row) else "" for i in range(len(header))] parts.append("" + "".join(f"" for c in cells) + "") parts.append("
{_rich_inline(h)}
{_rich_inline(c)}
") return "".join(parts) def _md_to_rich_html(text: str) -> str: """Вариант _md_to_html для Rich Messages (Bot API 10.1+, sendRichMessage): сервер Telegram сам рендерит структурные блоки, поэтому здесь НЕ действуют три запрета обычного пути — markdown-таблицы идут настоящим , #-заголовки —

/

/

, а LaTeX ($...$/$$...$$) — сырым текстом в / (НЕ юникод-заменой, как в _md_to_html). Остальное 1:1 как в _md_to_html: тот же Phase 0 (сырой HTML модели), те же плейсхолдеры кода/ссылок, те же цитаты, списки и escape, тот же порядок восстановления (код — последним). Держать в синхроне с _md_to_html при правках escape/Phase 0: расхождение даст разный рендер стрима (HTML) и финала (rich) одного и того же ответа. Обычный (не-rich) путь НЕ тронут: стрим-правки идут через _md_to_html, а sendRichMessage при ошибке API откатывается на него же (см. bot.py).""" if not text: return "" # Тот же сентинел
, что в _md_to_html выше, но раскрывается в
# (валидный рич-тег переноса, в т.ч. внутри ячеек

). text = re.sub(r"", "\x00BR\x00", text, flags=re.IGNORECASE) text = re.sub(r"", "", text, flags=re.IGNORECASE) text = re.sub(r"<(?:b|strong)>(.*?)", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"<(?:i|em)>(.*?)", r"*\1*", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"(.*?)", r"\1", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"(.*?)", r"\1", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r'(.*?)', r"\1", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"(.*?)", r"~~\1~~", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"
(.*?)
", lambda m: f"```\n{m.group(1)}\n```", text, flags=re.IGNORECASE | re.DOTALL) text = re.sub(r"(.*?)", r"`\1`", text, flags=re.IGNORECASE | re.DOTALL) _code: dict[str, str] = {} _links: dict[str, str] = {} _math: dict[str, tuple[str, bool]] = {} _tables: dict[str, tuple[list[str], list[list[str]]]] = {} _heads: dict[str, tuple[int, str]] = {} _counter = [0] def _take(store: dict, prefix: str, value) -> str: key = f"\x00{prefix}{_counter[0]}\x00" _counter[0] += 1 store[key] = value return key text = re.sub(r"```[a-zA-Z0-9]*\n.*?\n```", lambda m: _take(_code, "CB", m.group(0)), text, flags=re.DOTALL) text = re.sub(r"`[^`\n]+`", lambda m: _take(_code, "CB", m.group(0)), text) text = re.sub(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", lambda m: _take(_links, "CB", m.group(0)), text) text = re.sub(r"\$\$([\s\S]+?)\$\$", lambda m: _take(_math, "MM", (m.group(1), True)), text) # Инлайн-форма требует содержимого без пробелов по краям (как в GFM): иначе # цены вида "$50 до $100" превратились бы в "формулу". Одиночные "$80 000" # без закрывающего знака и так не матчатся. text = re.sub(r"(? str: # Тот же допуск, что у легаси _HEADER_MARKER_RE (ведущие пробелы, до 6 # решёток); масштаб под чат: # → h2, ## → h3, остальное → h4. level = len(m.group(1)) tag_level = 2 if level == 1 else (3 if level == 2 else 4) # Остаточный LaTeX в заголовке скрабим сразу: заголовок уходит в плейсхолдер # ДО общего _scrub_latex ниже, и юзер видел "\alpha" в

вместо α # (враждебное ревью 27.09.2026). return _take(_heads, "HD", (tag_level, _scrub_latex(m.group(2).strip()))) text = re.sub(r"^[ \t]*(#{1,6})[ \t]+(.+)$", _take_heading, text, flags=re.MULTILINE) lines = text.split("\n") out: list[str] = [] i = 0 n = len(lines) while i < n: line = lines[i] if "|" in line and i + 1 < n and "-" in lines[i + 1] and _is_table_separator(lines[i + 1]): header_cells = _split_table_cells(line) if len(header_cells) >= 2: data_rows: list[list[str]] = [] j = i + 2 while j < n and "|" in lines[j] and lines[j].strip(): data_rows.append(_split_table_cells(lines[j])) j += 1 if data_rows: out.append(_take(_tables, "TB", (header_cells, data_rows))) i = j continue out.append(line) i += 1 text = "\n".join(out) # Остаточный LaTex ВНЕ разделителей ($…$, \[…\]) → юникод, как в обычном пути. # Раньше rich-путь его не трогал, и пользователь видел "\frac{1}{2}" и "\alpha\times\beta" # в финальном сообщении, тогда как стрим/HTML-фолбэк показывали 1/2 и α×β (аудит 26.09.2026). # Плейсхолдеры кода/математики/таблиц безопасны: в них нет ни слэшей, ни "^"/"_". text = _scrub_latex(text) text = _normalize_bullet_markers(text) # Та же разноска слипшихся списков, что в обычном пути (Phase 1.405/1.406): # иначе стрим (HTML) и финал (rich) одного ответа выглядят по-разному. text = _split_inline_bullets(text) text = _split_inline_numbered(text) text = _convert_blockquotes(text) text = text.replace("&", "&").replace("<", "<").replace(">", ">") text = re.sub(r"(\*\*|__)(.*?)\1", r"\2", text, flags=re.DOTALL) text = re.sub(r"(\*|_)(.*?)\1", r"\2", text) text = re.sub(r"~~(.*?)~~", r"\1", text) text = text.replace(_BLOCKQUOTE_START, "
").replace(_BLOCKQUOTE_END, "
") # Прод 07.10.2026: в rich-сообщении голый перенос строки НЕ разрыв — Telegram # склеивает абзацы, и разнесённый список приходил одной кашей (в обычном # HTML-пути \n работает, поэтому баг был виден только в финале). Переносы # заменяем на
— ровно так их показывают доки для
. # Момент: код/ссылки/таблицы/заголовки ещё в плейсхолдерах (без \n внутри), # поэтому многострочные
 не пострадают.
    text = text.replace("\n", "
") for key, (tag_level, inner) in _heads.items(): text = text.replace(key, f"{_rich_inline(inner)}") for key, (header, rows) in _tables.items(): text = text.replace(key, _build_rich_table(header, rows)) for key, (latex, block) in _math.items(): latex = latex.replace("&", "&").replace("<", "<").replace(">", ">") tag = "tg-math-block" if block else "tg-math" text = text.replace(key, f"<{tag}>{latex}") for key, orig in _links.items(): m = re.match(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", orig, re.DOTALL) label, url = m.group(1), m.group(2) label = label.replace("&", "&").replace("<", "<").replace(">", ">") url = url.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) text = text.replace(key, f'{label}') for key, orig in _code.items(): if orig.startswith("```"): m = re.match(r"```([a-zA-Z0-9]*)\n(.*)\n```", orig, re.DOTALL) lang, inner = (m.group(1), m.group(2)) if m else ("", orig[3:-3]) inner = inner.replace("&", "&").replace("<", "<").replace(">", ">") replacement = f'
{inner}
' if lang else f"
{inner}
" else: inner = orig[1:-1] inner = inner.replace("&", "&").replace("<", "<").replace(">", ">") replacement = f"{inner}" text = text.replace(key, replacement) text = text.replace("\x00BR\x00", "
") return text def _strip_markdown(text: str) -> str: """Сносит markdown-разметку в голый текст для последнего рубежа отправки (plain-text без parse_mode): иначе при отказе HTML/рич в чат уходят сырые `**`, backtick-и и `[label](url)` (прод-кейс 17.09.2026 — ответ с `**` прилетел как есть). Чистит только синтаксис, слова не трогает.""" if not text: return "" # Голые URL прячем до чистки: "_" внутри них не выделение. _urls: dict[str, str] = {} def _save_url(m: re.Match) -> str: key = f"\x00U{len(_urls)}\x00" _urls[key] = m.group(0) return key text = re.sub(r"```[a-zA-Z0-9]*\n(.*?)\n```", r"\1", text, flags=re.DOTALL) text = re.sub(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", r"\1", text) text = re.sub(r"https?://[^\s()<>]+", _save_url, text) text = re.sub(r"(\*\*|__)(.*?)\1", r"\2", text, flags=re.DOTALL) # Одиночное "_" только вне слова: my_file_name и имена файлов не трогаем. text = re.sub(r"(? int: """Длина глазами Telegram: UTF-16 code units, не codepoints (эмодзи вне BMP стоят 2 единицы, 3000 штук уже переполнение при "коротких" 3000 символах).""" return len(text.encode("utf-16-le")) // 2 def _take_prefix_by_units(text: str, max_units: int) -> str: """Самый длинный префикс не длиннее max_units (кодпоинт не рвём).""" if _tg_len(text) <= max_units: return text lo, hi = 0, len(text) while lo < hi: mid = (lo + hi + 1) // 2 if _tg_len(text[:mid]) <= max_units: lo = mid else: hi = mid - 1 return text[:lo] _FENCE_BLOCK_RE = re.compile(r"```[a-zA-Z0-9]*\n.*?\n```", re.DOTALL) _FENCE_UNWRAP_RE = re.compile(r"\A```([a-zA-Z0-9]*)\n(.*)\n```\Z", re.DOTALL) # Дальше этого пол не дробим: патологический синтаксис теоретически раздувает # HTML без края, вечный бисект хуже одного негабаритного чанка с фолбэком. _SPLIT_FLOOR_UNITS = 64 def _chunk_plain(span: str, max_len: int) -> list[str]: """Кусочки plain-сегмента по границам абзацев/строк/предложений (в units).""" if _tg_len(span) <= max_len: return [span] chunks: list[str] = [] remaining = span while _tg_len(remaining) > max_len: window = _take_prefix_by_units(remaining, max_len) cut = -1 for sep in ("\n\n", "\n", ". ", " "): idx = window.rfind(sep) if idx > len(window) * 0.5: cut = idx + len(sep) break if cut <= 0: cut = len(window) chunks.append(remaining[:cut].rstrip()) remaining = remaining[cut:].lstrip() if remaining: chunks.append(remaining) return chunks def _chunk_fence_block(block: str, max_len: int) -> list[str]: """Забор целиком, если влезает; иначе построчно с переоткрытием забора.""" if _tg_len(_md_to_html(block)) <= max_len: return [block] m = _FENCE_UNWRAP_RE.match(block) if not m: return _chunk_plain(block, max_len) lang, inner = m.group(1), m.group(2) wrap_overhead = _tg_len(f"```{lang}\n") + _tg_len("\n```") inner_chunks = _chunk_plain(inner, max(64, max_len - wrap_overhead)) or [inner] return [f"```{lang}\n{part}\n```" for part in inner_chunks] def _split_to_fit_html(chunk_md: str, max_len: int) -> list[str]: """Дробление markdown-куска, пока его итоговый HTML не влезет в лимит.""" if _tg_len(_md_to_html(chunk_md)) <= max_len: return [chunk_md] m = _FENCE_UNWRAP_RE.match(chunk_md) wrap = (m.group(1), True) if m else None inner = m.group(2) if m else chunk_md if not inner.strip(): return [chunk_md] lines = inner.split("\n") if len(lines) > 1: mid = len(lines) // 2 parts = ["\n".join(lines[:mid]), "\n".join(lines[mid:])] else: half = _take_prefix_by_units(inner, _tg_len(inner) // 2) or inner[:1] rest = inner[len(half):] parts = [half, rest] if rest else [half] out: list[str] = [] for part in parts: piece = f"```{wrap[0]}\n{part}\n```" if wrap else part if _tg_len(part) <= _SPLIT_FLOOR_UNITS: out.append(piece) else: out.extend(_split_to_fit_html(piece, max_len)) return out def _merge_short_chunks(chunks: list[str], max_len: int) -> list[str]: """Склейка соседних кусков обратно, пока влезают: сегментация по заборам иначе дробила бы каждое короткое сообщение с кодом на три.""" merged: list[str] = [] acc = "" for chunk in chunks: candidate = chunk if not acc else acc + chunk if _tg_len(_md_to_html(candidate)) <= max_len: acc = candidate else: if acc: merged.append(acc) acc = chunk if acc or not merged: merged.append(acc) return merged def _split_text_chunks(text: str, max_len: int = 4096) -> list[str]: """Разбивает длинный текст на части не длиннее max_len, стараясь резать по границам абзацев/строк/предложений, а не посреди слова. Раньше сообщения длиннее лимита Telegram (4096 симв.) просто не отправлялись — пользователь не видел вообще ничего. Вынесено из bot.py (срез монолита, сентябрь 2026): чистая функция над строками без единой зависимости от Telegram/рантайма — тот же класс, что и _md_to_html выше. Дефолт 4096 дублирует TG_MAX_LEN из bot.py буквально (импортировать константу оттуда нельзя — циклический импорт): оба места про лимит Telegram, меняются только вместе с ним. Лимит соблюдается дважды: сам markdown режется в units (эмодзи вне BMP стоят 2), затем каждый кусок проверяется по длине итогового HTML — разметка раздувает текст, и чанк "4095 символов" иначе давал 400 от Telegram. Заборы не рвутся: длинные делятся построчно с переоткрытием.""" if not text: return [text] pieces: list[tuple[bool, str]] = [] pos = 0 for m in _FENCE_BLOCK_RE.finditer(text): if m.start() > pos: pieces.append((False, text[pos:m.start()])) pieces.append((True, m.group(0))) pos = m.end() if pos < len(text): pieces.append((False, text[pos:])) if not pieces: return [text] md_chunks: list[str] = [] for is_fence, seg in pieces: if is_fence: md_chunks.extend(_chunk_fence_block(seg, max_len)) else: # Слипшиеся буллеты разносим ДО резки: иначе чанкер рвёт список # по ". " и куски с 1 маркером мимо порога сплиттера (прод 07.10.2026). seg = _split_inline_numbered(_split_inline_bullets(seg)) md_chunks.extend(_chunk_plain(seg, max_len)) out: list[str] = [] for chunk in md_chunks: out.extend(_split_to_fit_html(chunk, max_len)) return _merge_short_chunks(out, max_len) or [text] def _truncate_html_to_fit(md_text: str, limit: int) -> str: """HTML по границе исходника, а не по границе тегов: резать готовый HTML по codepoint можно угодить в середину /ссылки — Telegram ответит "can't parse entities" (AUD-J-002). Бинарным поиском ищем самый длинный префикс исходника, чей HTML влезает в лимит. Вынесено из bot.py (гостевые ответы — одиночный InlineQueryResultArticle без фолбэка на plain-текст).""" full = _md_to_html(md_text) if len(full) <= limit: return full lo, hi = 0, len(md_text) while lo < hi: mid = (lo + hi + 1) // 2 if len(_md_to_html(md_text[:mid])) <= limit - 1: lo = mid else: hi = mid - 1 return _md_to_html(md_text[:lo])[:limit - 1] + "…"