Spaces:
Running
Running
File size: 50,882 Bytes
9a08963 5f80ef1 9a08963 e89951a 9a08963 0b65b92 9a08963 e89951a 9a08963 3d3384a e89951a 3d3384a 9a08963 e9b6d6a 9a08963 62c1832 9a08963 0b65b92 9a08963 0b65b92 9a08963 0b65b92 9a08963 0b65b92 9a08963 693519a 76aeacc d926362 76aeacc d926362 281f95c 693519a 3d3384a 693519a 3d3384a 281f95c 62c1832 3d3384a 281f95c 62c1832 281f95c 693519a 62c1832 d59ebea 3d3384a d59ebea 3d3384a d59ebea 3d3384a d59ebea 62c1832 d59ebea cc503f9 0b65b92 cc503f9 9a08963 0b65b92 9a08963 693519a d59ebea 9a08963 0b65b92 9a08963 0b65b92 9d7cca0 9a08963 0b65b92 9a08963 0b65b92 9a08963 693519a d59ebea cc503f9 9a08963 0b65b92 9a08963 0b65b92 9a08963 0b65b92 1190087 9a08963 1190087 9a08963 9d7cca0 9a08963 00b6f48 339b914 9d7cca0 339b914 62c1832 339b914 e89951a 339b914 5a68db4 339b914 d59ebea 339b914 aa3af88 339b914 9d7cca0 1190087 9d7cca0 1190087 9d7cca0 1190087 9d7cca0 1190087 339b914 1190087 00b6f48 1190087 00b6f48 1190087 00b6f48 1190087 00b6f48 1190087 00b6f48 f40e9a4 1190087 2939738 1190087 1dfad36 1190087 2939738 1190087 f40e9a4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 | """
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), иначе легитимные теги от
модели превратились бы в видимый мусор "<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) — сентинелы, не сразу <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, иначе "<b>" видно.
# <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
# экранирования — показывался пользователю как видимый мусор "<tg-spoiler>".
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("&", "&").replace("<", "<").replace(">", ">")
# ── Фаза 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("&", "&").replace("<", "<").replace(">", ">")
url = url.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
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("&", "&").replace("<", "<").replace(">", ">")
# 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("&", "&").replace("<", "<").replace(">", ">")
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("&", "&").replace("<", "<").replace(">", ">")
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("&", "&").replace("<", "<").replace(">", ">")
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("&", "&").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'<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("&", "&").replace("<", "<").replace(">", ">")
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("&", "&").replace("<", "<").replace(">", ">")
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] + "…"
|