MarkdownV2 в Telegram-боте: 18 спецсимволов и линтер, который спас мне сотни отправок

MarkdownV2 в Telegram-боте: 18 спецсимволов и линтер, который спас мне сотни отправок

Я в открытую строю @futur_e_news_bot — двуязычную ленту новостей. Однажды бот молча перестал слать сообщения: один непроэкранированный символ, и Telegram отклонял всю отправку. В MarkdownV2 надо экранировать 18 спецсимволов, и одна ошибка роняет сообщение целиком. Ниже разберу модель, которая убирает путаницу, и линтер на ~80 строк, который ловит это в тестах. Код можно забрать.

Одна точка, и ноль доставленных

Собираешь сообщение: жирный заголовок, ссылка, короткая цитата. Локально нормально, в проде тишина. В логах:

Bad Request: can't parse entities: Character '.' is reserved and must be escaped with the preceding '\'

Один «.» в дате. Или «!» в «Привет!». Telegram отклоняет не символ, а всё сообщение целиком. Пользователь не получает ничего.

Экранировать в MarkdownV2 надо 18 спецсимволов:

_ * [ ] ( ) ~ ` > # + - = | { } . !

Среди них «.», «-», «!», «=» — то, что есть в каждом втором тексте: проценты, даты, обычные предложения. Наивное «поставлю звёздочки для жирного» ломается на первой живой новости.

Два контекста, и всё встаёт на место

Кажется, что надо экранировать весь текст. Но тогда сломается сама разметка: «*» для жирного тоже спецсимвол.

Разгадка простая. В сообщении два контекста. Разметку («*жирный*», «[текст](ссылка)», «>цитата») ты пишешь сам, её не трогаешь. Свободный текст внутри (заголовки, имена, числа) экранируешь целиком. Не «экранируй строку», а «экранируй динамику, разметку авторь руками». Весь хаос свёлся к одной функции:

_MD2_ESCAPE = set("\\_*[]()~`>#+-=|{}.!") def md2(text: object) -> str: return "".join("\\" + ch if ch in _MD2_ESCAPE else ch for ch in str(text))

Любое динамическое значение гоню через md2(), разметку собираю поверх. Два места, где легко влететь:

  • У ссылок свои правила. Внутри «(url)» экранируется только «)» и «\», а не все 18. Прогонишь URL через обычный md2(), и поломаешь сам URL.
  • Метки кнопок — это не MarkdownV2. Их Telegram рендерит как обычный текст. Я на автомате проэкранировал дефис в метке «Real-time», и в кнопке отобразилось «Real\-time».

Линтер, который ловит это до Telegram

Беда MarkdownV2 в том, что обратная связь приходит из прода. Хотелось ловить ошибку в тестах. Поэтому написал маленький линтер: он парсит строку так же, как Telegram, и возвращает список проблем. Пусто — значит, отправится.

def validate_md2(s: str) -> list[str]: """Список проблем (пусто = распарсится).""" # уважает \-экранирование, понимает > как цитату в начале строки, # входит в код-контекст на `, пропускает (url) в ссылках, # балансирует * _ ~ || как сущности, # в конце ругается на голые спецсимволы и несбалансированные сущности. ...

Полностью это ~80 строк без зависимостей, забирай из репозитория. Дальше самое полезное: каждую строку, которую видит пользователь, проверяю в тесте.

self.assertEqual(validate_md2(t("ru", "welcome")), [])

За одну итерацию продукта я добавил десятки строк: приветствие, дайджесты, встроенный дашборд статистики с кнопками. Линтер прогнал каждую в CI. can't parse entities в проде больше не прилетал.

Забрать с собой

  • Два контекста: динамику через md2(), разметку руками.
  • Ссылки и кнопки — отдельные правила (в кнопках MarkdownV2 вообще нет).
  • Заведи оффлайн-линтер и прогоняй им каждую строку в тестах.

80 строк тестируемого кода экономят не красоту, а работоспособность фичи.

Из серии про то, как я в открытую строю @futur_e_news_bot: двуязычную ленту, которая приносит только важное и спокойное и живёт на sqlite-vec за пару долларов в месяц. Прошлые серии: запуск, как бот дважды умирал незаметно, 231 мёртвая душа.