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блоки інвертують правило. Усередині фенс-блоку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. - Забути, що фенс-блоки інвертують правила. Повторне екранування всіх зарезервованих символів усередині сніпета шле видимі бекслеші. Усередині
`і```спеціальні лише`і\. - Довіра транспортному статусу (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 написаний під регекс-граматику, а не під 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 — робоча відправна точка.
Підсумок
Екранування 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 до свого каналу та отримувати зірки від підписників — без жодного рядка коду.

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

Повернення Telegram Stars: як насправді працює refundStarPayment
Коли повертати Telegram Stars, як викликати refundStarPayment, чому немає часткових повернень, обробка /paysupport і чиста звірка в обліку.
Підпишіться на нашу розсилку
Отримуйте найновіші поради з розвитку Telegram, стратегії автоматизації та оновлення платформи на вашу пошту.
Або підпишіться на наш Telegram-канал