Перейти до основного вмісту
Інструкції

MarkdownV2 у Telegram: чому пости падають мовчки і як це полагодити

Також доступно мовами:ENRUUK
Опубліковано December 12, 202513 хв читання241 переглядів
MarkdownV2 у Telegram: чому пости падають мовчки і як це полагодити

MarkdownV2 у Telegram: чому пости падають мовчки і як це полагодити

Коротко. Парс-режим MarkdownV2 у Telegram резервує 18 символів, які треба екранувати у звичайному тексті — _ * [ ] ( ) ~ ` > # + - = | { } . ! — плюс жорсткіші правила всередині блоків коду й URL інлайн-посилань. Пропустіть один — і sendMessage поверне HTTP 400 з ok: false. У продакшені цю помилку проковтує обгортка, лог їде у місце, яке ніхто не читає, і канал просто замовкає. Ця стаття дає повну таблицю, бойову escape-функцію на Python і JavaScript, чотири edge-кейси, що ламають наївні ескейпери, і фікстуру, яку можна прогнати по своєму пайплайну до наступного деплою.

Хочете керованого Telegram-постера, який сам екранує MarkdownV2 і показує кожне падіння? Спробуйте Autogram безкоштовно.

Передумови

Photo by Markus Spiske on Pexels

Перш ніж викочувати фікс:

  • Telegram-бот і чат, де він може писати. Створений через @BotFather, доданий адміном до тестового каналу — інакше sendMessage поверне Forbidden.
  • Робочий виклик sendMessage. Через сирий HTTP (curl -X POST), офіційний Bot API або обгортку — python-telegram-bot, aiogram, telegraf, grammY.
  • Видимість відповіді API. Якщо у вашому коді є except Exception без перевірки response.json()['ok'] чи спеціального BadRequest / TelegramBadRequest — спершу полагодьте це. Усі кроки нижче припускають, що ви бачите, коли відправлення впало.
  • Тестовий чат у BOT_TEST_CHAT_ID. Приватний канал або власний «Збережене», який ви контролюєте. Фікстура з Кроку 5 ганяє через нього всі edge-кейси — не запускайте її на реальному каналі з підписниками.

Якщо у вас лише два-три місця, де код шле повідомлення, ймовірно, ви просто підключите хелпер з Кроку 2 і поїдете далі. Пайплайнам із кількома продюсерами (cron, Celery, RSS-воркери, AI-перепис) потрібні всі кроки.

Крок 1 — Знайте 18 зарезервованих символів (і де яке правило працює)

Специфікація MarkdownV2 резервує три різні набори символів — залежно від того, де стоїть символ:

КонтекстСимволи, які треба екранувати через \Примітки
Звичайний текст_ * [ ] ( ) ~ ` > # + - = | { } . ! (18 загалом)Включно з крапкою і знаком оклику — два найчастіше пропущені
Усередині блоків pre і code` і \ (2 загалом)Усі інші зарезервовані символи літеральні
Усередині URL у [text](url) і [text](tg://user?id=…)) і \ (2 загалом)Закриваюча дужка і сам символ екранування

Три пастки видно одразу:

  • Крапка і знак оклику зарезервовані. Вони майже не зустрічалися у старіших Markdown-специфікаціях, тому наївний whitelist через re.escape про них забуває. URL із доменом верхнього рівня (.com, .io) і емоційний копірайтинг (Розпродаж!) ламають це з кожним релізом.
  • У код-блоках правило інвертоване. *виділення* тут літеральне — але випадковий ` достроково закриває блок. Якщо ви вкладаєте користувацький ввід всередину коду, екрануйте лише ` і \, і нічого більше.
  • В URL інлайн-посилання екрануйте ), не (. Посилання на статтю Вікіпедії типу https://en.wikipedia.org/wiki/Telegram_(software) має літеральну ) усередині шляху, яка передчасно закриває markdown-посилання. Заекрануйте її як \) усередині дужок, ( залиште як є.

Повний список — прямо зі специфікації Bot API. Цитата без змін:

In all other places characters _, *, [, ], (, ), ~, `, >, #, +, -, =, |, {, }, ., ! must be escaped with the preceding character \. (core.telegram.org/bots/api#markdownv2-style)

Якщо ви прочитали це як «використай re.escape» — ви прочитали неправильно. re.escape екранує значно більше символів і дає на виході рядок, який Telegram теж відкине, тільки з іншою помилкою. Беріть явний набір; ніколи не виводьте його з regex-хелпера стандартної бібліотеки.

Крок 2 — Напишіть безпечну escape-функцію на Python і JavaScript

Photo by Christina Morillo on Pexels

Функція нижче покриває кейс звичайного тексту. Вона навмисне «нудна»: кожен рядок прямо мапиться на речення зі специфікації. Збережіть один раз і використовуйте всюди, де рядок іде у Telegram.

import re

# За https://core.telegram.org/bots/api#markdownv2-style
_MD2_RESERVED = r"_*[]()~`>#+-=|{}.!"
_MD2_RE = re.compile(f"([{re.escape(_MD2_RESERVED)}])")

def escape_markdown_v2(text: str) -> str:
    """Екранує всі зарезервовані символи MarkdownV2 у `text`.

    Викликати лише на простих сегментах користувацького тексту. НЕ
    застосовувати на рядку, який вже містить навмисну MarkdownV2-розмітку
    (наприклад, на руках сформоване *жирне* слово) — спершу екрануйте
    сегменти, потім обгорніть їх розміткою.
    """
    return _MD2_RE.sub(r"\\\1", text)

До й після на реальному заголовку RSS:

>>> raw = "Postgres 18.0 ships skip-scan indexes (50% smaller plans!)"
>>> escape_markdown_v2(raw)
'Postgres 18\\.0 ships skip\\-scan indexes \\(50% smaller plans\\!\\)'

Знак % залишився як є — він не зарезервований у MarkdownV2. Дві дужки, !, . і - усі заекрановані. Відправте отриманий рядок з parse_mode="MarkdownV2" — і отримаєте чисто рендерений пост.

Аналог на JavaScript (Node, Deno, браузер — будь-де):

const MD2_RESERVED = /[_*[\]()~`>#+\-=|{}.!]/g;

function escapeMarkdownV2(text) {
  return text.replace(MD2_RESERVED, "\\$&");
}

// Використання:
const raw = "Розпродаж до п'ятниці — знижка 30%!";
const safe = escapeMarkdownV2(raw);
// → "Розпродаж до п'ятниці — знижка 30%\\!" — апостроф і тире не резервовані, тільки !

Кілька деталей перед тим, як викочувати хелпер:

  • Екрануйте рівно один раз. Обгорніть фінальне тіло один раз — ніколи не викликайте хелпер двічі на тому самому рядку. Подвійне екранування рендерить бекслеші літерально у каналі, що виглядає ще аматорськіше за сам 400.
  • Застосовуйте посегментно, а не на всю помилку. Якщо потрібне справжнє жирне слово — заекрануйте середину *Привіт {name}!* (частину {name}!), але *…* навколо лишіть. Патерн «обгортка плюс заекранований сегмент» — єдиний, який спецификація реально підтримує.
  • Сприймайте escape-функцію як єдине джерело істини. Кожне місце, що шле в Telegram, імпортує один і той самий хелпер. Скопійований regex у дванадцятьох файлах — найшвидший шлях до того, що одинадцять із них тихо «розійдуться».

Бібліотеки-обгортки мають свої хелпери — telegram.helpers.escape_markdown(text, version=2) у python-telegram-bot, markdownv2.escape в aiogram, Markup.escape у telegraf. Можете брати їх — але звіртеся з таблицею з Кроку 1: кілька старіших версій бібліотек забували = чи > і шлють у продакшен ледь зламаний текст.

Крок 3 — Чотири edge-кейси, які ламають наївні ескейпери

90% випадків покриває хелпер вище. 10%, які боляче кусають у продакшені — це все, що не схоже на звичайну прозу. Закрийте всі чотири, перш ніж казати «готово».

  1. URL усередині [text](url)-дужок. Спецификація звужує набір екранування всередині URL посилання до ) і \. Якщо ви прогоните повний хелпер для звичайного тексту по URL, отримаєте https://example\.com/wiki/Telegram\_\(software\) — і Telegram відкине запит з Bad Request: can't parse entities: Character '\' is reserved and must be escaped with the preceding '\'. Робіть так:

    _MD2_LINK_RE = re.compile(r"([\\)])")
    
    def escape_markdown_v2_link(url: str) -> str:
        """Екранує URL для (...) частини інлайн-посилання."""
        return _MD2_LINK_RE.sub(r"\\\1", url)
    
    def link(text: str, url: str) -> str:
        return f"[{escape_markdown_v2(text)}]({escape_markdown_v2_link(url)})"
    

    Тепер link("Telegram (software)", "https://en.wikipedia.org/wiki/Telegram_(software)") дає робоче MarkdownV2-посилання — дужки в тексті заекрановані, ) в URL заекранована, ( лишається як є.

  2. pre і code блоки інвертують правило. Усередині фенс-блоку code екрануються лише ` і \; усе інше — літеральне. Те саме для інлайн `code`. Якщо ви прогоните по сніпету хелпер для звичайного тексту, користувач побачить def\ foo\(\) з бекслешами скрізь.

    _MD2_CODE_RE = re.compile(r"([\\`])")
    
    def escape_markdown_v2_code(code: str) -> str:
        """Екранує рядок для вкладення в `pre` чи `code` блоки."""
        return _MD2_CODE_RE.sub(r"\\\1", code)
    

    Обгортайте результат одинарним бектиком для інлайн-коду або потрійним — для блоку.

  3. Заекранована «навмисна» розмітка. Коли потрібне справжнє жирне слово в плоскому тексті, екрануйте сегмент, потім додавайте розмітку. f"*{escape_markdown_v2(headline)}*" — правильно; escape_markdown_v2("*headline*") дасть \*headline\* — літеральні зірочки в каналі.

  4. Межі сутностей всередині форматованого тексту. Telegram парсить MarkdownV2 зліва направо, і одна неспарована _ чи * ламає все, що йде далі. Якщо у користувацькому вводі є зірочка, а ви її не заекранували, парсер бачить «гулящий» italic-bold маркер і кидає на всьому повідомленні. Лік — той самий, екрануйте раніше — але failure mode варто впізнавати в продакшені, бо помилка читається як Can't find end of italic entity чи подібний оманливий текст. Telegram не каже, що саме зламано; він каже, що робив парсер, коли здався. Реальний фікс лежить вище за течією.

Якщо хочете формальної граматики, секції Bot API formatting options і API entities documentation описують точні регіони і набори екранування для кожного.

Крок 4 — Ловіть «мовчазні» падіння — завжди парсте відповідь

Photo by Anastasiya Badun on Pexels

Слово «мовчазний» у мовчазних падіннях — оманливе. Telegram не мовчить — він повертає HTTP 400 з точною ok: false-обгорткою:

{
  "ok": false,
  "error_code": 400,
  "description": "Bad Request: can't parse entities: Character '!' is reserved and must be escaped with the preceding '\\'"
}

Мовчите ви. У продакшені це ховають три патерни:

  • Виключення бібліотеки спіймані і викинуті. Воркер, який обгортає await bot.send_message(...) у try: ... except Exception: log.error(...); return, проходить код-рев'ю на ура — і пізніше з'їдає годинами. Ловіть конкретний BadRequest / TelegramBadRequest, логуйте структуровано (chat id, проблемний підрядок, суфікс помилки парсера), і кидайте далі.
  • Прямі API-виклики без перевірки ok. curl чи requests.post до api.telegram.org поверне 400 з JSON-тілом. Якщо код ігнорує і response.status_code, і response.json()["ok"], бо «запит же пішов» — падіння стає невидимим.
  • Черги, що ack-ають до перевірки. Celery, RQ, Sidekiq, які підтверджують задачу до читання відповіді бота, тихо роняють падіння у DLQ — або ще гірше, без DLQ. Переносьте ack після успішної відповіді Telegram, не після return функції.

Робочий патерн:

import httpx

async def send_md2(chat_id: int, text: str, *, token: str) -> int:
    """Повертає id повідомлення Telegram або кидає зі структурованими полями."""
    payload = {
        "chat_id": chat_id,
        "text": escape_markdown_v2(text),
        "parse_mode": "MarkdownV2",
    }
    async with httpx.AsyncClient(timeout=10) as client:
        r = await client.post(
            f"https://api.telegram.org/bot{token}/sendMessage",
            json=payload,
        )
    body = r.json()
    if not body.get("ok"):
        raise RuntimeError(
            f"telegram sendMessage failed: "
            f"http={r.status_code} code={body.get('error_code')} "
            f"desc={body.get('description')!r} preview={text[:80]!r}"
        )
    return body["result"]["message_id"]

Три властивості важливі:

  • Завжди дивиться на body["ok"]. Транспортний статус сам по собі не є істиною.
  • Включає preview поганого тексту в повідомлення помилки, щоб черговому інженеру не довелося прокручувати фейлну джобу заново.
  • Прокидає падіння нагору. Викликач сам вирішує — ретрай, dead-letter чи alert. Тиша — більше не дефолт.

Прив'яжіть це до assert ok is True у юніт-тестах — і регресії екранування ловитимуться на git push, а не на наступному «канал замовк». Більше про продакшен-обробку помилок Bot API — RetryAfter, глобальний кап 30 msg/sec і повільне 1 msg/sec на чат — у гайді по лімітах Telegram Bot API.

Крок 5 — Прогоніть фікстуру по пайплайну до релізу

Ручне тестування escape-кейсів — це як шиплять регресії «чотири підряд». Запікайте незручні рядки в тест, який постить у тестовий чат і перевіряє, що кожне повідомлення дійшло.

import os, asyncio

FIXTURES = [
    ("plain text",                "Привіт світ"),
    ("trailing exclamation",      "Великий розпродаж у п'ятницю!"),
    ("dotted version",            "Postgres 18.0 виходить наступного тижня"),
    ("dashed list",               "Три пункти: - молоко - хліб - яйця"),
    ("paren in copy",             "Telegram (застосунок) додав заплановані пости"),
    ("paren in URL",              "Прочитати https://en.wikipedia.org/wiki/Telegram_(software)"),
    ("code-fence inversion",      "Беріть ```re.compile(r'\\d+')``` для цифр"),
    ("contraction (apostrophe)",  "Це крапка вас вб'є, а не апостроф"),
    ("inline link with parens",   "[Telegram (software)](https://en.wikipedia.org/wiki/Telegram_(software))"),
    ("emoji",                     "Сьогодні релізимо 🚀!"),
    ("plus-and-equals",           "C++ vs C#=Pascal? Обговорюємо"),
    ("braces and pipes",          "Беріть {{template}} | output | filter"),
]

async def main() -> None:
    chat_id = int(os.environ["BOT_TEST_CHAT_ID"])
    token = os.environ["BOT_TOKEN"]
    for name, text in FIXTURES:
        try:
            mid = await send_md2(chat_id, text, token=token)
            print(f"OK   {name!r:35} → message_id={mid}")
        except Exception as exc:
            print(f"FAIL {name!r:35} → {exc}")

asyncio.run(main())

Прокручуйте її при кожному апгрейді бібліотеки-обгортки і кожному рефакторі escape-хелпера. Дванадцять секунд CI дешевші за один пропущений анонс запуску.

Для пайплайнів, які шлють багато повідомлень за хвилину — RSS-форвардери, AI-дайджест-боти, bulk-reply воркери — та сама фікстура працює як smoke-тест на rate-limit. Дивіться гайд по масовій розсилці в Telegram на тему черг і ретраїв, які стримують баги екранування від каскаду до rate-limit штормів.

Поширені помилки

  • re.escape замість явної таблиці. re.escape екранує те, що MarkdownV2 не резервує (@, :, / тощо), і дає рядки, які Telegram відкидає з іншою, але такою ж заплутаною 400.
  • except Exception навколо send-виклику. Ховає всі типи помилок — екранування, rate-limit, мережа — за одним лог-рядком. Ловіть конкретний exception обгортки, логуйте структуровано, кидайте далі.
  • Виклик хелпера двічі. Кожен виклик подвоює бекслеші. Підписники бачать літеральні \. і \! у каналі, а черговий інженер чухає голову.
  • Екранування URL у [text](url) хелпером для звичайного тексту. Дає https:\/\/example\.com і 400. Екрануйте URL посилань хелпером, який знає про (), з Кроку 3.
  • Забути, що фенс-блоки інвертують правила. Повторне екранування всіх зарезервованих символів усередині сніпета шле видимі бекслеші. Усередині ` і ``` спеціальні лише ` і \.
  • Довіра транспортному статусу (200) без перевірки body["ok"]. 400 з ok: false виглядає як 200 для скрипта, який дивиться лише response.status_code через прошарок (деякі бібліотеки ковтають 400 і піднімають його через виключення).

Читати далі

FAQ

Які символи треба екранувати у Telegram MarkdownV2?

Вісімнадцять символів у звичайному тексті: _, *, [, ], (, ), ~, `, >, #, +, -, =, |, {, }, ., !. Усередині `` і ``` блоків коду треба екранувати лише ` і \; усередині URL інлайн-посилання [text](url) — лише ) і \. Список опубліковано дослівно у документації форматування Bot API.

Чому мій Telegram-бот нічого не постить — ні помилки, ні повідомлення?

Telegram повертає HTTP 400 ok: false з точним полем description, але ваш код його ковтає. Три типові підозрювані: блок try: ... except Exception, який ловить BadRequest обгортки; прямий requests.post, який ігнорує response.json()["ok"]; черга-воркер, який ack-ає задачу до перевірки відповіді Telegram. Додайте перевірку body["ok"] і структурований лог-рядок — тиша зникає.

Чи можна просто взяти re.escape з Python для MarkdownV2?

Ні. re.escape написаний під регекс-граматику, а не під MarkdownV2 — він екранує @, :, /, ^, $ та інше, що MarkdownV2 не чіпає. Вихід re.escape доходить до Telegram з зайвими бекслешами і тригерить інший 400 (Character '\\' is reserved and must be escaped). Беріть явний 18-символьний regex з Кроку 2.

Як заекранувати URL усередині інлайн-посилання MarkdownV2?

Усередині дужок [text](url) екрануйте лише два символи: ) і \. Сегмент text зовні дужок — звичайний текст і підпорядкований повному 18-символьному правилу. URL https://en.wikipedia.org/wiki/Telegram_(software) усередині дужок стає https://en.wikipedia.org/wiki/Telegram_(software\) — заекрановано лише закриваючу дужку, підкреслення і відкриваюча дужка лишаються літеральними.

Чи код-блоки слідують тим самим правилам екранування?

Ні. Усередині `inline` чи fenced блоку коду зарезервовані лише бектик ` і бекслеш \. Усе інше — включно з 16 іншими символами правила для звичайного тексту — літеральне. Якщо прогоните хелпер для звичайного тексту по код-блоку, підписники побачать def\ foo\(\) з бекслешами і ваш сніпет виглядатиме зламаним.

Який найбезпечніший спосіб слати MarkdownV2-повідомлення у продакшені?

Три прошарки: (1) хелпер escape_markdown_v2(text), імпортований раз і застосований до кожного користувацького сегмента, (2) HTTP-обгортка, яка завжди дивиться на response.json()["ok"] і кидає виключення з proviewed-підрядком, (3) набір фікстур із незручних рядків (крапки, оклики, дужки в URL, код-блоки, емодзі), що ганяється у CI на кожній зміні escape-хелпера чи бібліотеки. Фікстура з Кроку 5 — робоча відправна точка.

Підсумок

Публікації в Telegram в Autogram. Підключіть перший канал і отримайте 7 днів доступу до Solo з AI-бюджетом $1. Банківська картка не потрібна.

Авторство зображень

#telegram markdownv2 екранування#telegram markdown помилка парсингу#bot api parse_mode markdownv2#екранування telegram#telegram bot api#markdownv2 python

Підпишіться на нашу розсилку

Отримуйте найновіші поради з розвитку Telegram, стратегії автоматизації та оновлення платформи на вашу пошту.

Або підпишіться на наш Telegram-канал