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

MarkdownV2 в Telegram: почему посты падают молча и как это починить
Коротко. Парс-режим MarkdownV2 в Telegram резервирует 18 символов, которые надо экранировать в обычном тексте —
_ * [ ] ( ) ~`> # + - = | { } . !— плюс более жёсткие правила внутри блоков кода и URL инлайн-ссылок. Пропустите один — иsendMessageвернёт HTTP 400 сok: false. В продакшене эту ошибку проглатывает обёртка, лог уходит в место, которое никто не читает, и канал просто замолкает. Эта статья даёт полную таблицу, боевую escape-функцию на Python и JavaScript, четыре edge-кейса, ломающих наивные эскейперы, и фикстуру, которую можно прогнать по своему пайплайну до следующего деплоя.
Предварительные требования
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%, которые больно кусают в продакшене — это всё, что не похоже на обычную прозу. Закройте все четыре, прежде чем сказать «готово».
-
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 экранирована,(остаётся как есть. -
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)Оборачивайте результат одинарным бэктиком для инлайн-кода или тройным — для блока.
-
Заэкранированная «намеренная» разметка. Когда нужно настоящее жирное слово в плоском тексте, экранируйте сегмент, потом добавляйте разметку.
f"*{escape_markdown_v2(headline)}*"— правильно;escape_markdown_v2("*headline*")даст\*headline\*— литеральные звёздочки в канале. -
Границы сущностей внутри форматированного текста. 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 и поднимают его через исключение).
Читать дальше
- Лимиты Telegram Bot API на масштабе — что ломается и как этого избежать — компаньон-статья про другой класс молчаливых падений Bot API: ответы
RetryAfter, которые воркеры ack-ают до прочтения. - Массовая рассылка в Telegram: гайд для опытных — операционные паттерны (очереди, ретраи, DLQ), ловящие регрессии экранирования до того, как они каскадно ломают канал.
- Автопостинг RSS в Telegram: пошаговая настройка 2026 — RSS-айтемы — самый плотный источник неэкранированных зарезервированных символов; хелпер из Шага 2 ставится между парсером фида и
sendMessage. - Автопостинг в Telegram без метки «Переслано» — когда вы шлёте «нативные» посты (без шапки forward), качество форматирования — единственный сигнал, что пост ваш, поэтому MarkdownV2 не может быть небрежным.
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 — рабочая отправная точка.
Итог
Экранирование MarkdownV2 — не загадка: 18 символов в обычном тексте, по два внутри код-блоков и URL ссылок, и одна проверка body["ok"] между вами и молчаливыми падениями. Добавьте хелпер из Шага 2, response-aware обёртку из Шага 4 и фикстуру из Шага 5 в пайплайн — и следующий пейджер «канал замолк в 03:00» не позвонит. Если хотите обойтись вообще без воркера, Autogram сам экранирует и показывает все ошибки Bot API человеческим языком — вставили текст, выбрали канал, поехали.
Авторство изображений
- Hero: Photo by Markus Spiske on Pexels.
- Inline #1: Photo by Christina Morillo on Pexels.
- Inline #2: Photo by Anastasiya Badun on Pexels.
Похожие посты

Как добавить Telegram Mini App в канал: монетизация без кода в 2026
Пошаговое руководство: как подключить Telegram Mini App к каналу и принимать Stars-оплату от подписчиков — без единой строки кода.

TON в фиат для владельцев каналов: вывод через Fragment
Пошаговое руководство по выводу дохода Telegram-канала — TON с рекламы и звёзд — через Fragment, конвертации на бирже и зачислению на банковский счёт.

Возврат Telegram Stars: как на самом деле работает refundStarPayment
Когда возвращать Telegram Stars, как вызывать refundStarPayment, почему нет частичных возвратов, обработка /paysupport и чистая сверка в учёте.
Подпишитесь на рассылку
Получайте последние советы по росту Telegram, стратегии автоматизации и обновления платформы прямо на почту.
Или подпишитесь на наш Telegram-канал