Перейти к основному содержимому
Инструкции

MarkdownV2 в Telegram: почему посты падают молча и как это починить

Также доступно на языках:ENRUUK
Опубликовано December 13, 202513 мин. чтения286 просмотров
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 блоки инвертируют правило. Внутри fence-блока 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.
  • Забыть, что fence-блоки инвертируют правила. Повторное экранирование всех зарезервированных символов внутри сниппета шлёт видимые бэкслеши. Внутри ` и ``` специальные только ` и \.
  • Доверие транспортному статусу (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 написан под regex-грамматику, а не под 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"] и бросающая исключение с preview-подстрокой, (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-канал