Lumen / lumen_formatting.py
SilverElixir
Convert newlines to br in rich path so lists stop collapsing
aa3af88
Raw History Blame Contribute Delete
50.9 kB
"""
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-путь шлёт настоящий <table>."""
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-пути (<tg-math>), а это —
# второй рубеж для обычного пути: не полагаемся только на послушание модели,
# страхуем детерминированной пост-обработкой в юникод.
_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"(?<!\d)(\d{1,3})\. ")
def _split_inline_numbered(text: str) -> 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 <blockquote> ───────────────────────
# Тот же принцип, что и у _normalize_bullet_markers выше: "> " — обычный
# GFM-синтаксис цитаты, модели он известен без единого слова в system_prompt.py
# (там про цитаты вообще ничего не сказано — как и про списки, см. комментарий
# у _normalize_bullet_markers). Раньше строка "> текст" просто уходила в
# Telegram буквально с ">" в начале. Строится ДО HTML-экранирования (Phase 2),
# как и остальные построчные нормализации этой секции — сама обёртка
# <blockquote> добавляется тут же, а не как markdown-маркер для Phase 3, чтобы
# не путать её с обычным ">" внутри текста (например, "5 > 3").
_BLOCKQUOTE_LINE_RE = re.compile(r"^> ?(.*)$")
# \x00-сентинелы вместо буквальных <blockquote>/</blockquote> — та же причина,
# что и у плейсхолдеров код-блоков в _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-тегов (<b>/<i>/<code>/<pre> и битые self-closing) в
markdown-эквивалент — ДО экранирования (шаг 2), иначе легитимные теги от
модели превратились бы в видимый мусор "&lt;b&gt;".
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) — сентинелы, не сразу <blockquote>, т.к. 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) → настоящий <blockquote> — после Phase 3,
чтобы markdown внутри цитаты успел стать HTML до финализации обёртки.
4. Код-блоки/спаны восстанавливаются из плейсхолдеров с собственным
экранированием — самыми последними, чтобы шаги 2-3 их не затронули.
"""
if not text:
return ""
# ── Phase 0: сырой HTML модели в markdown до escape, иначе "&lt;b&gt;" видно.
# <br> в сентинел, <blockquote> не трогаем.
text = re.sub(r"<br\s*/?>", "\x00BR\x00", text, flags=re.IGNORECASE)
text = re.sub(r"</?(?:b|strong|i|em|u|s|code|pre)\s*/>", "", text, flags=re.IGNORECASE)
text = re.sub(r"<(?:b|strong)>(.*?)</(?:b|strong)>", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<(?:i|em)>(.*?)</(?:i|em)>", r"*\1*", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<u>(.*?)</u>", r"\1", text, flags=re.IGNORECASE | re.DOTALL)
# spoiler — тот же трюк, что и <u> выше: system_prompt.py не просит модель их
# использовать, поэтому это чисто защитная сетка на случай, если модель всё же
# напишет буквальный тег. Раньше он не ловился здесь вообще и долетал до Phase 2
# экранирования — показывался пользователю как видимый мусор "&lt;tg-spoiler&gt;".
text = re.sub(r"<tg-spoiler>(.*?)</tg-spoiler>", r"\1", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r'<span\s+class=["\']tg-spoiler["\']>(.*?)</span>', r"\1", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<s>(.*?)</s>", r"~~\1~~", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<pre>(.*?)</pre>", lambda m: f"```\n{m.group(1)}\n```", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<code>(.*?)</code>", 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-экранирования, т.к. решение "это
# строка цитаты" принимается по буквальному "> " в начале строки; сама
# обёртка <blockquote> подставляется позже (после 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# ── Фаза 3: применяем markdown ───────────────────────────────────────────────
text = re.sub(r"(\*\*|__)(.*?)\1", r"<b>\2</b>", text, flags=re.DOTALL)
text = re.sub(r"(\*|_)(.*?)\1", r"<i>\2</i>", text)
text = re.sub(r"~~(.*?)~~", r"<s>\1</s>", text)
# ── Phase 3.5: blockquote-сентинелы → настоящие <blockquote> ─────────────
# После Phase 3, чтобы markdown внутри цитаты (например "> **важно**")
# успел превратиться в HTML до того, как обёртка станет реальным тегом.
text = text.replace(_BLOCKQUOTE_START, "<blockquote>").replace(_BLOCKQUOTE_END, "</blockquote>")
# ── Фаза 4: возвращаем код с экранированием ────────────────────
# Ссылки первыми: метка ссылки может содержать плейсхолдер кода
# ([`code` text](url) — код вырезается раньше ссылки), поздняя замена кода
# находит его уже внутри вставленного <a> и протечки \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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
url = url.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
text = text.replace(key, f'<a href="{url}">{label}</a>')
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# language — атрибут entity "pre" в Telegram (даёт подсветку синтаксиса
# в клиентах, которые её поддерживают); раньше язык из ```python вырезался
# регэкспом при сохранении, но никогда не доходил до вывода.
replacement = f'<pre><code class="language-{lang}">{inner}</code></pre>' if lang else f"<pre>{inner}</pre>"
else:
inner = orig[1:-1]
inner = inner.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
replacement = f"<code>{inner}</code>"
text = text.replace(key, replacement)
# Сентинел <br> (см. Phase 0): в обычном HTML-пути — перенос строки.
# Оговорка: <br> ВНУТРИ код-блоков тоже превратится в перенос (Phase 0
# идёт до экстракции кода) — тот же класс компромисса, что уже есть у
# <b>/<i> в коде выше; код-примеры с буквальным <br> редки.
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
text = re.sub(r"(\*\*|__)(.*?)\1", r"<b>\2</b>", text, flags=re.DOTALL)
text = re.sub(r"(\*|_)(.*?)\1", r"<i>\2</i>", text)
text = re.sub(r"~~(.*?)~~", r"<s>\1</s>", text)
return text
def _build_rich_table(header: list[str], rows: list[list[str]]) -> str:
"""Строит <table bordered> из распарсенных ячеек (см. _split_table_cells).
Ячейки идут через _rich_inline — внутри работают **bold**/*italic*/`код`
(кодовые плейсхолдеры раскрываются общим финалом позже)."""
parts = ["<table bordered>"]
parts.append("<tr>" + "".join(f"<th>{_rich_inline(h)}</th>" for h in header) + "</tr>")
for row in rows:
cells = [row[i] if i < len(row) else "" for i in range(len(header))]
parts.append("<tr>" + "".join(f"<td>{_rich_inline(c)}</td>" for c in cells) + "</tr>")
parts.append("</table>")
return "".join(parts)
def _md_to_rich_html(text: str) -> str:
"""Вариант _md_to_html для Rich Messages (Bot API 10.1+, sendRichMessage):
сервер Telegram сам рендерит структурные блоки, поэтому здесь НЕ действуют
три запрета обычного пути — markdown-таблицы идут настоящим <table>,
#-заголовки — <h2>/<h3>/<h4>, а LaTeX ($...$/$$...$$) — сырым текстом в
<tg-math>/<tg-math-block> (НЕ юникод-заменой, как в _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 ""
# Тот же сентинел <br>, что в _md_to_html выше, но раскрывается в <br/>
# (валидный рич-тег переноса, в т.ч. внутри ячеек <td>).
text = re.sub(r"<br\s*/?>", "\x00BR\x00", text, flags=re.IGNORECASE)
text = re.sub(r"</?(?:b|strong|i|em|u|s|code|pre)\s*/>", "", text, flags=re.IGNORECASE)
text = re.sub(r"<(?:b|strong)>(.*?)</(?:b|strong)>", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<(?:i|em)>(.*?)</(?:i|em)>", r"*\1*", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<u>(.*?)</u>", r"\1", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<tg-spoiler>(.*?)</tg-spoiler>", r"\1", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r'<span\s+class=["\']tg-spoiler["\']>(.*?)</span>', r"\1", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<s>(.*?)</s>", r"~~\1~~", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<pre>(.*?)</pre>", lambda m: f"```\n{m.group(1)}\n```", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"<code>(.*?)</code>", 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"(?<!\$)\$(?!\$)([^\s$][^$\n]*?)(?<!\s)\$(?!\$)", lambda m: _take(_math, "MM", (m.group(1), False)), text)
# Скобочные формы LaTeX (реальный кейс nemotron — \[ S = \pi r^{2} \]): тот же
# смысл, что $/$$ выше, извлекаются раньше них, чтобы $ внутри не разобрали.
text = re.sub(r"\\\[([\s\S]+?)\\\]", lambda m: _take(_math, "MM", (m.group(1), True)), text)
text = re.sub(r"\\\((.+?)\\\)", lambda m: _take(_math, "MM", (m.group(1), False)), text)
def _take_heading(m: re.Match) -> 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" в <h3> вместо α
# (враждебное ревью 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
text = re.sub(r"(\*\*|__)(.*?)\1", r"<b>\2</b>", text, flags=re.DOTALL)
text = re.sub(r"(\*|_)(.*?)\1", r"<i>\2</i>", text)
text = re.sub(r"~~(.*?)~~", r"<s>\1</s>", text)
text = text.replace(_BLOCKQUOTE_START, "<blockquote>").replace(_BLOCKQUOTE_END, "</blockquote>")
# Прод 07.10.2026: в rich-сообщении голый перенос строки НЕ разрыв — Telegram
# склеивает абзацы, и разнесённый список приходил одной кашей (в обычном
# HTML-пути \n работает, поэтому баг был виден только в финале). Переносы
# заменяем на <br/> — ровно так их показывают доки для <blockquote>.
# Момент: код/ссылки/таблицы/заголовки ещё в плейсхолдерах (без \n внутри),
# поэтому многострочные <pre> не пострадают.
text = text.replace("\n", "<br/>")
for key, (tag_level, inner) in _heads.items():
text = text.replace(key, f"<h{tag_level}>{_rich_inline(inner)}</h{tag_level}>")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
tag = "tg-math-block" if block else "tg-math"
text = text.replace(key, f"<{tag}>{latex}</{tag}>")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
url = url.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
text = text.replace(key, f'<a href="{url}">{label}</a>')
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
replacement = f'<pre><code class="language-{lang}">{inner}</code></pre>' if lang else f"<pre>{inner}</pre>"
else:
inner = orig[1:-1]
inner = inner.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
replacement = f"<code>{inner}</code>"
text = text.replace(key, replacement)
text = text.replace("\x00BR\x00", "<br/>")
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"(?<!\w)_([^_\n]+?)_(?!\w)", r"\1", text)
text = re.sub(r"\*(.*?)\*", r"\1", text)
text = re.sub(r"~~(.*?)~~", r"\1", text)
text = re.sub(r"`([^`\n]+)`", r"\1", text)
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
for key, url in _urls.items():
text = text.replace(key, url)
return text
def _tg_len(text: str) -> 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 можно угодить в середину <b>/ссылки — 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] + "…"