Дисклеймер: статья будет про Telegram-бота на Python и aiogram. Если ты пишешь ботов на чём-то ещё — листай, тут тебя расстроят. Если не пишешь вообще — оставайся, иногда полезно посмотреть, как другие страдают.
Привет. Я три года пилю Telegram-бот для одного B2C-продукта по подписке. Бот, как полагается всякому продуктовому боту, оброс примерно 800 строками диалогов: онбординги, оплаты, инструкции на iOS/Android/винде, тех-поддержка, рассылки, ошибки, благодарности, апселл премиума, реферальная программа, A/B-тесты копирайтинга, и три варианта одной и той же кнопки «Подключиться», потому что маркетолог каждый квартал хочет «ну вот тут чуть-чуть подвинуть».
И всё это, как у всех нормальных людей, лежало в Python-коде.
Не «в коде» в смысле «у нас красивые тексты в одном файле texts.py». А в коде в смысле — в хендлерах, в утилитах, в сервисах, кусочно, копипастно, с f-строками, с условиями if user.is_premium:, с тремя разными способами собрать одну и ту же inline-клавиатуру, и с пятым по счёту «ой, не забудьте parse_mode='HTML', иначе у нас бот выплёвывает голый <b> пользователю».
И вот в один прекрасный пятничный вечер маркетолог пишет:
«Слушай, в тексте после оплаты можно убрать лишнюю запятую? Ну там «Поздравляем, ваша подписка активна» — две запятые, не очень.»
А я такой:
-
🔎 grep’аю по проекту «Поздравляем»
-
нахожу семь мест: в
payment_handler.py, вsuccess_paywall.py, вnotifications/payment_success.py, в админ-команде «выдать промо-доступ», в утилите рассылки, в тестовом сценарии, и ещё в одном месте, где это вообще «Поздравлеям» — кто-то когда-то опечатался, и эта ветка просто никогда не тригерилась -
начинаю править
-
в одном месте текст склеен через
+из трёх кусков, потому что там вставляется дата окончания подписки -
в другом месте текст шаблонизирован через
.format(), но по-другому -
в третьем месте текст лежит в YAML-конфиге (ха-ха, единственный приличный человек в команде когда-то так и сделал)
Я потратил двадцать минут на одну запятую.
И тут меня переклинило.
«Нужно что-то менять»
Ну правда: я инженер, я могу 10 раз в день деплоить, я умею в CI/CD, я знаю про feature flags. Но текст в боте — это, оказывается, нифига не «деплоить десять раз в день». Это «помолись, чтобы ты нашёл правильное место, не зацепил ничего лишнего, и чтобы прод не лёг, потому что ты случайно вместо <b> написал <bb>».
Самое смешное, что боль не моя личная. Я начал общаться с другими ребятами, кто пилит ботов:
-
У одних тексты в Python, копипаст клавиатур на 12 файлов, любое изменение брендинга — это PR на 200 строк диффа.
-
У других всё в i18n-файлах (
messages.ru.json), но клавиатуры всё равно собираются в Python — и теперь у тебя «текст в одном месте, кнопки в другом», и менять их синхронно — отдельный квест. -
У третьих — святой Грааль — реализован свой DSL поверх Python, тексты живут в
dataclass’ах с методомto_markup(), всё круто, но порог входа для нового разраба — три дня. -
А у четвёртых вообще «зачем нам это, у нас и так норм».
И тут я подумал: окей, а как бы я в идеале хотел?
Мне нужно:
-
Один файл, где живут тексты + кнопки + картинки для каждой реплики бота. Не два. Не семь. Один.
-
Чтобы маркетолог мог в этот файл залезть, поправить запятую, и не упасть в кому от вида f-строк.
-
Чтобы можно было переиспользовать куски: ссылка на поддержку — это одно место, а не пятнадцать.
-
Чтобы можно было сказать «вот эта кнопка показывается только премиум-юзерам», но в одном файле, без
if-ов в трёх хендлерах. -
Чтобы поддерживались все методы Telegram — не только
send_message, а и фотки, и видео, и опросы, и геолокации. -
Чтобы оно умело не положить бота при рассылках — Telegram ставит лимит 30 msg/s на бота, и при превышении огребаешь
429 Too Many Requests.
И вот тут я понял: я хочу YAML. Прямо как Kubernetes, Docker Compose, GitHub Actions — все же научились декларативно описывать систему, почему бы и боту — нельзя?
Знакомьтесь, aiogram-dialog-yaml
Это библиотека. Питоновская. Лежит на PyPI, исходники — на GitHub. Ставится одной строчкой:
pip install aiogram-dialog-yaml[aiogram]
В двух словах: ты пишешь диалоги в YAML, библиотека резолвит их в готовые словари, которые ты скармливаешь aiogram’у. Тексты, кнопки, медиа, условия — всё в одном файле. Логику можно вставлять прямо в YAML через функции-билдеры. Сверху — опциональная очередь с rate-лимитом, чтобы ты не вылетал с RetryAfter.
Звучит абстрактно. Сейчас на пальцах.
Хеллоу-ворлд
Вот так выглядит самый базовый диалог:
# dialogs.yaml constants: service_name: «Acme Bot» support_link: «https://t.me/acme_support»
welcome_text: | 👋 Привет, {user_name}! Добро пожаловать в {service_name}. Что-то пошло не так? Напиши в {support_link}.
welcome_keyboard: func: static_inline_keyboard data: buttons: — { line: 1, text: “🚀 Начать”, callback: “start” } — { line: 2, text: “💬 Поддержка”, url: “@support_link” }
dialogs: welcome: send_message: text: “@welcome_text” parse_mode: html reply_markup: “@welcome_keyboard”
А вот так — Python:
import asyncio from aiogram import Bot
from aiogram_dialog_yaml import DialogProvider, FunctionRegistry, default_functions from aiogram_dialog_yaml.functions import aiogram_functions
registry = FunctionRegistry(default_functions()) registry.register_many(aiogram_functions())
provider = DialogProvider(“dialogs.yaml”, functions=registry)
async def main() -> None: bot = Bot(token=“…”) section = await provider.get(“welcome”, user_name=“Иван”) await bot.send_message(chat_id=12345, **section[“send_message”])
asyncio.run(main())
Всё. Никакой магии, никаких метаклассов, никаких декораторов. section — это обычный питоновский словарь, который ты прямо в bot.send_message(**section["send_message"]) и пихаешь. Клавиатура — настоящая InlineKeyboardMarkup уже на этом этапе, не строка, не словарь, не «потом сконвертим».
«Окей, — скажешь ты, — это просто шаблонизатор поверх PyYAML. Чё там удивительного?» А вот сейчас будет.
@references и {placeholders} — две стороны одной медали
В YAML есть два способа подставить значение в текст, и они дополняют друг друга.
{name} — это обычный str.format. Куда втыкаешь — туда и подставляется. Простые скаляры: имя юзера, цена, дата.
text: «Привет, {user_name}! Твоя подписка истекает {expires_at}.»
@name — это референс на константу или на параметр. Подставляется что угодно — строка, словарь, список, целый сложный объект клавиатуры. И рекурсивно — если значение само содержит {} или @, они тоже разрешатся.
constants: cta_button: func: static_inline_keyboard data: buttons: — { text: «Оплатить ⭐», url: «@payment_link» }
dialogs: paywall: send_message: text: “Подписка истекла, {user_name}. Продли за 199₽.” reply_markup: “@cta_button” # подтянулась вся клавиатура целиком
Зачем два разных синтаксиса? Потому что они решают разные задачи:
|
|
|
|
|---|---|---|
|
вставляется в строку посередине |
да |
нет |
|
подставляет сложный объект целиком |
нет |
да |
|
|
да |
нет |
|
можно вложить ссылку на ссылку |
нет |
да (рекурсия) |
Если ты когда-нибудь пытался запихнуть InlineKeyboardMarkup через .format() — ты знаешь, почему пришлось разделить.
Бонус: если переменной нет — не падаем
Самая раздражающая штука у обычного str.format — это что он падает с KeyError, если ты забыл прокинуть параметр. В библиотеке стоит lenient formatter: незнакомый {user_name} просто остаётся в тексте как есть, известные вокруг — резолвятся.
greeting: «Привет, {user_name}! Подписка от {service_name}.» await provider.get(«greeting», service_name=»Acme») # → «Привет, {user_name}! Подписка от Acme.»
Не падает. Тебе сразу видно, что забыл прокинуть. Без stacktrace’а на 40 строк.
Кейс №1: «Поменяй кнопку, маркетолог сегодня агрессивный»
Раньше:
# payment_handler.py keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=»Оплатить», url=PAY_URL)], [InlineKeyboardButton(text=»❓ Помощь», callback_data=»help»)], ]) # success_paywall.py — другое место, другой импорт keyboard = InlineKeyboardMarkup(…) # ещё семь строк # notifications/renew_reminder.py — третье место, тоже клавиатура keyboard = InlineKeyboardMarkup(…) # ещё семь строк
Маркетолог пишет: «давай эмодзи у «Оплатить» поменяем на ⭐». Я лезу искать. Нахожу пять мест. Меняю четыре, забываю пятое. Пятое — это сценарий, который триггерится раз в две недели у юзеров, у которых истекает подписка по реферальной программе. В понедельник пишут: «у меня кнопка без эмодзи, у друга с эмодзи, чё за дискриминация».
Теперь:
constants: pay_keyboard: func: static_inline_keyboard data: buttons: — { line: 1, text: «⭐ Оплатить», url: «@payment_link» } — { line: 2, text: «❓ Помощь», callback: «help» }
И в любом диалоге, где раньше клавиатура копипастилась — теперь просто reply_markup: "@pay_keyboard". Поменял один раз — поменялось везде.
И, что важно, маркетолог теперь сам может это сделать. YAML — это не Python. Он не упадёт от того, что забыли запятую (упадёт, но при загрузке, с понятной ошибкой про строку и колонку, а не на третий день в проде).
Кейс №2: «А если у нас A/B на разные бренды?»
Реальный сценарий из моей жизни: один и тот же продукт, два бренда — основной (@product_bot) и бюджетный (@product_lite_bot). Разное название сервиса, разные тарифы, разные приветственные тексты, но 80% диалогов идентичны.
Раньше: два бота, два кодбейза, синхронизация через git cherry-pick и слёзы.
Сейчас: одна кодовая база, два YAML-слоя:
provider_main = DialogProvider([«base.yaml», «brand_main.yaml»]) provider_lite = DialogProvider([«base.yaml», «brand_lite.yaml»])
Каждый последующий слой переопределяет предыдущий на уровне секций. Базовые тексты — в base.yaml, бренд-специфика — в brand_main.yaml / brand_lite.yaml. Никакой магии deep-merge, никаких сюрпризов: ты явно видишь, какие секции переопределены.
То же самое работает для локализации: base.ru.yaml + base.en.yaml. Не хочу спорить, какая i18n-схема лучше — это рабочий минимум, который покрывает 95% случаев.
Кейс №3: «Покажи кнопку только премиум-юзерам»
Декларативно прямо в YAML:
main_menu_keyboard: func: static_inline_keyboard data: buttons: — { line: 1, text: «📺 Подключения», callback: «configs» } — { line: 2, text: «💳 Оплата», callback: «pay» } — { line: 3, text: «🎁 Промокод», callback: «promo», if: «@show_promo_button» } — { line: 4, text: «👑 Премиум», callback: «premium_only», if: «@is_premium» } section = await provider.get( «main_menu», show_promo_button=True, is_premium=user.is_premium, )
if: false → кнопка просто не отрендерится. Никаких if’ов в питоне, никаких трёх версий клавиатуры. Это всё та же штука: UI-логика живёт рядом с UI, а не размазана по хендлерам.
А если хочется ветку посложнее — выбрать целую секцию в зависимости от флага — есть встроенный билдер if_else:
welcome: func: if_else data: if: «@show_promo_picture» then: send_photo: photo: «@promo_image» caption: «@welcome_text» reply_markup: «@welcome_keyboard» else: send_message: text: «@welcome_text» reply_markup: «@welcome_keyboard»
То есть: «если у нас сейчас активна промо-картинка — шли с фоткой, иначе — обычным сообщением». Логика в одном месте, оба варианта рядом. Через две недели маркетолог захочет третий вариант — добавляешь, не выкатывая релиз бекенда.
А что если мне нужно что-то совсем кастомное?
Очевидный вопрос: «А если у меня в тексте надо вывести список из БД?» Ну, типа: «Твои активные подписки: Подписка №1 (до 12.06), Подписка №2 (до 30.07), Premium (до конца времён)».
Регистрируешь свою функцию:
async def render_configs(data): configs = data[«configs»] return «\n».join( f»{c.name} — до {c.expires:%d.%m.%Y}» for c in configs )
registry.register(“render_configs”, render_configs)
И вызываешь её из YAML:
dialogs: status: send_message: text: func: render_configs data: configs: «@user_configs» parse_mode: html section = await provider.get(«status», user_configs=user.configs)
Функция может быть sync или async — библиотеке плевать, она проверит inspect.iscoroutinefunction и сделает правильное.
И вот теперь главное:
Это и есть тот самый «стандарт», к которому всё движется. Декларативно описать что нужно сделать, и иметь дырки для как — Python-функции, которые делают грязную работу. Kubernetes ровно так и устроен: манифесты — YAML, операторы — Go. GitHub Actions — то же самое: workflow в YAML, actions — TypeScript/Docker. Terraform — HCL, провайдеры — Go. Декларативное снаружи, императивное внутри. Это эталон. И в боте я могу так же.
Очередь сообщений и лимиты Telegram
Окей, диалоги — это полдела. Вторая половина — доставка.
Если ты когда-нибудь делал рассылку из бота на 10к юзеров, ты знаешь:
|
лимит |
значение |
|---|---|
|
глобальный исходящий рейт на одного бота |
~30 сообщений / сек |
|
сообщения в один групповой чат |
~20 / минута |
|
сообщения в один личный чат |
~1 / сек (soft, бёрсты ок) |
Превысил — получил 429 Too Many Requests с заголовком Retry-After: N. aiogram это превращает в TelegramRetryAfter. Если ты этого не обработал — у тебя половина юзеров не получила сообщения, и теперь иди объясняй CTO, что «ну вот мы упёрлись в лимит Telegram». Эпично.
В библиотеке этим занимается MessageQueue — опциональная (!) штука, которую ты вкомпиливаешь, когда созрел до production:
from aiogram_dialog_yaml.delivery import DialogApplier, MessageQueue
queue = MessageQueue(bot, rate_limit=30) applier = DialogApplier(provider, bot, queue=queue)
await queue.start()
Обычная отправка — едет через очередь, дренируется со скоростью ≤30/сек.
await applier.apply(chat_id=123, section_key=“welcome”, user_name=“Иван”)
Срочное — приоритет 0 пролезает вперёд всех priority-1.
await applier.apply(chat_id=123, section_key=“urgent_alert”, priority=0)
Per-call escape hatch: эту одну отправку — мимо очереди, в обход.
await applier.apply(chat_id=123, section_key=“status”, immediately=True)
await queue.stop(drain=True) # на завершении ждём, пока очередь сольётся
Что внутри:
-
asyncio.PriorityQueue, ключ —(priority, seq).seq— это монотонный счётчик черезitertools.count, чтобы при равных приоритетах сохранять FIFO. -
Sliding window из таймстемпов за последнюю секунду. Превысили
rate_limit—asyncio.sleepна точную дельту, чтобы попасть в окно. Безtime.sleep, без блокировок, без CPU-burn. -
Воркер не умирает на ошибках. Если конкретная отправка упала — вызывается твой
on_error(task, exc), очередь едет дальше. Хочешь ретраи — пиши логику вon_error, библиотека намеренно их не делает за тебя (бо «правильного» ретрая в Telegram нет, всё зависит от твоего сценария).
И самое важное: очередь — не часть ядра. Не нужна — не импортируй. Импортировал — но в этом конкретном месте не нужна — передаёшь immediately=True. Импортировал и нужна почти везде, но иногда надо мимо — без проблем. Три уровня отказа от неё:
|
хочется |
что инстанцируешь |
|---|---|
|
только резолвить YAML, слать руками |
|
|
резолвить и сразу слать, без рейта |
|
|
резолвить + очередь + рейт + приоритеты |
|
Адаптер: «aiogram хочет красивые типы, YAML хочет строки»
Telegram-API имеет пару капризных моментов:
-
send_video_noteне принимает URL — надо реально загрузить файл. В aiogram это решается черезURLInputFile. -
send_pollв aiogram ≥3.7 хочетInputPollOption, а не строки.
Не пихать же это в YAML, правда? В библиотеке есть params_adapter — функция, которая запускается прямо перед bot.<method>(...). Ты в неё описываешь нужные трансформации один раз:
def params_adapter(method, params): if method == «send_video_note» and isinstance(params.get(«video_note»), str): params[«video_note»] = URLInputFile(params[«video_note»], «vn.mp4») if method == «send_poll»: params[«options»] = [InputPollOption(text=o) for o in params.get(«options», [])] return params
queue = MessageQueue(bot, rate_limit=30, params_adapter=params_adapter)
YAML остаётся чистым. Бот работает. Все довольны.
Поток выполнения, на одной схеме
Каждый слой можно выкинуть. Хочешь только провайдер — окей, выкинул всё остальное, разруливай отправку сам. Хочешь applier без очереди — окей, шлёт напрямую. Хочешь всё — пожалуйста.
Полный демо-бот, который реально живёт
Чтобы это не звучало как «ну там в теории всё круто», в репозитории лежит examples/full_bot/ — настоящий aiogram-бот, который покрывает все практически полезные методы отправки:
send_message, send_photo, send_video, send_audio, send_document, send_animation, send_voice, send_video_note, send_media_group, send_location, send_venue, send_contact, send_poll (обычный и квиз), send_dice, send_sticker, send_chat_action.
Всё это — один dialogs.yaml и сто строк bot.py, бóльшая часть которых — это lifecycle очереди и регистрация хендлеров. Никаких сообщений руками в Python.
Запускается так:
git clone https://github.com/notwizzard/aiogram-dialog-yaml cd aiogram-dialog-yaml pip install -e «.[aiogram]» python-dotenv cp examples/full_bot/.env.example examples/full_bot/.env # вставь токен от @BotFather в .env python examples/full_bot/bot.py
И в Telegram пишешь боту /start — открывается меню из 17 кнопок, и по каждой кнопке прилетает соответствующий тип сообщения. Гифки от Giphy, картинки от Lorem Picsum, видео — сэмпл от Google. Всё бесплатно, ничего регистрировать не надо.
Что под капотом, если совсем коротко
-
Чистое ядро: только
PyYAML, ничего больше. Хочешь использовать механизм рендера YAML в чём-то, не связанном с Telegram — пожалуйста. -
Опциональный aiogram-extra:
pip install aiogram-dialog-yaml[aiogram]подтягивает aiogram и включает билдеры клавиатур и медиа. -
Опциональный delivery-слой:
MessageQueueиDialogApplier. Импортируешь только если нужно. -
Type hints +
py.typed: всё типизировано, mypy довольный. -
19 pytest-кейсов: приоритеты, FIFO, rate-limit, error handling, layered configs, lenient placeholders, async builders — всё покрыто.
-
CI на GitHub Actions для Python 3.9–3.13.
-
Publish через OIDC Trusted Publishing: я ничего не храню — релиз пушится прямо из GitHub-релиза, PyPI верит мне по подписи.
А зачем вообще ещё одна либа, когда есть aiogram-dialog?
Хороший вопрос, я сам его себе задавал. Есть aiogram-dialog от Tishka17 — мощная штука для FSM-сценариев с окнами и состояниями. Но её фокус — многошаговые диалоги с переходами (типа онбординг-флоу или wizard). А моя боль была про single-shot сообщения: пользователь нажал кнопку — мы ему шлём заранее заготовленный текст с картинкой и клавиатурой. Без состояний. Просто «вытащи мне эту реплику из YAML и отправь».
То есть это дополняющая история, не конкурирующая. У них разные сценарии использования. Можно использовать aiogram-dialog для FSM-флоу и aiogram-dialog-yaml для всего остального копирайта. Я так и делаю.
Резюме «до и после»
|
|
ДО |
ПОСЛЕ |
|---|---|---|
|
🗄 Где живёт текст |
|
|
|
⌨️ Где живут клавиатуры |
копипастятся в каждом хендлере, 7 разных способов сборки |
в секции |
|
🔍 «Поправь запятую» |
grep по проекту, найти 5–7 мест, не забыть ни одно |
поменять одну строку в YAML |
|
🌐 Бренды / локали |
отдельный кодбейз, |
дополнительный YAML-слой через |
|
👑 Премиум-кнопка |
|
|
|
🚦 Рассылка на 10к юзеров |
|
|
Запятая теперь правится в одном месте за десять секунд. Маркетолог сам залазит и правит, не присылая в личку «слушай, можешь…». Бренд меняется добавлением одного YAML-слоя. Кнопки скрываются/показываются прямо из YAML. Очередь держит Telegram-лимиты, премиум-кнопка показывается только премиум-юзерам, и я наконец могу спать.
Где брать
-
Полный README с API-референсом, миграционной таблицей для тех, кто хочет переехать с самописного решения, и рабочим бот-примером.
Лицензия — MIT, делайте что хотите. Issues и PR приветствуются — особенно если у тебя есть свои «кейсы из жизни», которые сейчас в YAML-DSL не закрываются. У меня сейчас 0.1.0, я открыт ко всему.
P.S.
Если ты дочитал до сюда — спасибо. Если у тебя есть свой Telegram-бот и ты тоже задолбался искать запятую — попробуй, мне будет интересен фидбек. Если ты пилишь что-то другое, и эта статья тебе не пригодилась — ну, по крайней мере, ты теперь знаешь, что про Telegram-API-лимиты надо думать до того, как делаешь рассылку на 10к юзеров.
Не делайте ботам больно. Особенно в пятницу вечером.
ссылка на оригинал статьи https://habr.com/ru/articles/1035714/