Я заколебался искать запятую в коде бота — и написал библиотеку, чтобы диалоги жили в YAML

от автора

Дисклеймер: статья будет про 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(), всё круто, но порог входа для нового разраба — три дня.

  • А у четвёртых вообще «зачем нам это, у нас и так норм».

И тут я подумал: окей, а как бы я в идеале хотел?

Мне нужно:

  1. Один файл, где живут тексты + кнопки + картинки для каждой реплики бота. Не два. Не семь. Один.

  2. Чтобы маркетолог мог в этот файл залезть, поправить запятую, и не упасть в кому от вида f-строк.

  3. Чтобы можно было переиспользовать куски: ссылка на поддержку — это одно место, а не пятнадцать.

  4. Чтобы можно было сказать «вот эта кнопка показывается только премиум-юзерам», но в одном файле, без if-ов в трёх хендлерах.

  5. Чтобы поддерживались все методы Telegram — не только send_message, а и фотки, и видео, и опросы, и геолокации.

  6. Чтобы оно умело не положить бота при рассылках — 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” # подтянулась вся клавиатура целиком

Зачем два разных синтаксиса? Потому что они решают разные задачи:

{name}

@name

вставляется в строку посередине

да

нет

подставляет сложный объект целиком

нет

да

str.format-совместим

да

нет

можно вложить ссылку на ссылку

нет

да (рекурсия)

Если ты когда-нибудь пытался запихнуть 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_limitasyncio.sleep на точную дельту, чтобы попасть в окно. Без time.sleep, без блокировок, без CPU-burn.

  • Воркер не умирает на ошибках. Если конкретная отправка упала — вызывается твой on_error(task, exc), очередь едет дальше. Хочешь ретраи — пиши логику в on_error, библиотека намеренно их не делает за тебя (бо «правильного» ретрая в Telegram нет, всё зависит от твоего сценария).

И самое важное: очередь — не часть ядра. Не нужна — не импортируй. Импортировал — но в этом конкретном месте не нужна — передаёшь immediately=True. Импортировал и нужна почти везде, но иногда надо мимо — без проблем. Три уровня отказа от неё:

хочется

что инстанцируешь

только резолвить YAML, слать руками

DialogProvider

резолвить и сразу слать, без рейта

DialogApplier(provider, bot)

резолвить + очередь + рейт + приоритеты

DialogApplier(provider, bot, queue=...)

Адаптер: «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 для всего остального копирайта. Я так и делаю.

Резюме «до и после»

ДО

ПОСЛЕ

🗄 Где живёт текст

payment_handler.py, success_paywall.py, notifications/*, admin_commands.py, utils/broadcast.py, texts.py6 файлов

dialogs.yaml1 файл

⌨️ Где живут клавиатуры

копипастятся в каждом хендлере, 7 разных способов сборки

в секции constants: того же YAML, 1 способ

🔍 «Поправь запятую»

grep по проекту, найти 5–7 мест, не забыть ни одно

поменять одну строку в YAML

🌐 Бренды / локали

отдельный кодбейз, git cherry-pick и слёзы

дополнительный YAML-слой через DialogProvider([base, overlay])

👑 Премиум-кнопка

if user.is_premium: в трёх хендлерах

if: "@is_premium" прямо в YAML

🚦 Рассылка на 10к юзеров

TelegramRetryAfter, половина не получает

MessageQueue(rate_limit=30), всё едет

Запятая теперь правится в одном месте за десять секунд. Маркетолог сам залазит и правит, не присылая в личку «слушай, можешь…». Бренд меняется добавлением одного YAML-слоя. Кнопки скрываются/показываются прямо из YAML. Очередь держит Telegram-лимиты, премиум-кнопка показывается только премиум-юзерам, и я наконец могу спать.

Где брать

Лицензия — MIT, делайте что хотите. Issues и PR приветствуются — особенно если у тебя есть свои «кейсы из жизни», которые сейчас в YAML-DSL не закрываются. У меня сейчас 0.1.0, я открыт ко всему.

P.S.

Если ты дочитал до сюда — спасибо. Если у тебя есть свой Telegram-бот и ты тоже задолбался искать запятую — попробуй, мне будет интересен фидбек. Если ты пилишь что-то другое, и эта статья тебе не пригодилась — ну, по крайней мере, ты теперь знаешь, что про Telegram-API-лимиты надо думать до того, как делаешь рассылку на 10к юзеров.

Не делайте ботам больно. Особенно в пятницу вечером.

ссылка на оригинал статьи https://habr.com/ru/articles/1035714/