MarkdownV2 в Telegram-боте: 18 спецсимволов и линтер, который спас мне сотни отправок
Я в открытую строю @futur_e_news_bot — двуязычную ленту новостей. Однажды бот молча перестал слать сообщения: один непроэкранированный символ, и Telegram отклонял всю отправку. В MarkdownV2 надо экранировать 18 спецсимволов, и одна ошибка роняет сообщение целиком. Ниже разберу модель, которая убирает путаницу, и линтер на ~80 строк, который ловит это в тестах. Код можно забрать.
Одна точка, и ноль доставленных
Собираешь сообщение: жирный заголовок, ссылка, короткая цитата. Локально нормально, в проде тишина. В логах:
Один «.» в дате. Или «!» в «Привет!». Telegram отклоняет не символ, а всё сообщение целиком. Пользователь не получает ничего.
Экранировать в MarkdownV2 надо 18 спецсимволов:
Среди них «.», «-», «!», «=» — то, что есть в каждом втором тексте: проценты, даты, обычные предложения. Наивное «поставлю звёздочки для жирного» ломается на первой живой новости.
Два контекста, и всё встаёт на место
Кажется, что надо экранировать весь текст. Но тогда сломается сама разметка: «*» для жирного тоже спецсимвол.
Разгадка простая. В сообщении два контекста. Разметку («*жирный*», «[текст](ссылка)», «>цитата») ты пишешь сам, её не трогаешь. Свободный текст внутри (заголовки, имена, числа) экранируешь целиком. Не «экранируй строку», а «экранируй динамику, разметку авторь руками». Весь хаос свёлся к одной функции:
Любое динамическое значение гоню через md2(), разметку собираю поверх. Два места, где легко влететь:
- У ссылок свои правила. Внутри «(url)» экранируется только «)» и «\», а не все 18. Прогонишь URL через обычный md2(), и поломаешь сам URL.
- Метки кнопок — это не MarkdownV2. Их Telegram рендерит как обычный текст. Я на автомате проэкранировал дефис в метке «Real-time», и в кнопке отобразилось «Real\-time».
Линтер, который ловит это до Telegram
Беда MarkdownV2 в том, что обратная связь приходит из прода. Хотелось ловить ошибку в тестах. Поэтому написал маленький линтер: он парсит строку так же, как Telegram, и возвращает список проблем. Пусто — значит, отправится.
Полностью это ~80 строк без зависимостей, забирай из репозитория. Дальше самое полезное: каждую строку, которую видит пользователь, проверяю в тесте.
За одну итерацию продукта я добавил десятки строк: приветствие, дайджесты, встроенный дашборд статистики с кнопками. Линтер прогнал каждую в CI. can't parse entities в проде больше не прилетал.
Забрать с собой
- Два контекста: динамику через md2(), разметку руками.
- Ссылки и кнопки — отдельные правила (в кнопках MarkdownV2 вообще нет).
- Заведи оффлайн-линтер и прогоняй им каждую строку в тестах.
80 строк тестируемого кода экономят не красоту, а работоспособность фичи.
Из серии про то, как я в открытую строю @futur_e_news_bot: двуязычную ленту, которая приносит только важное и спокойное и живёт на sqlite-vec за пару долларов в месяц. Прошлые серии: запуск, как бот дважды умирал незаметно, 231 мёртвая душа.