/ и битые 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"?(?: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"(.*?)", 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"| {_rich_inline(h)} | " 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"| {_rich_inline(c)} | " for c in cells) + "
")
parts.append("
")
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"?(?: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"(.*?)", 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}{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("&", "&").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] + "…"
|