Каждый, кто делал Telegram-бота с анкетой длиннее трёх вопросов, знает это чувство. Сначала всё просто: спросил имя, спросил email, записал в базу. Потом продакт говорит: «а давай если пользователь выбрал тариф Pro — спрашиваем ИНН, а если Free — пропускаем». Потом: «а ещё нужна кнопка Назад, и чтобы счётчик шагов учитывал скрытые». И вот ты сидишь в пятницу вечером, распутывая спагетти из вложенных if-ов, match-ей и глобальных state, и думаешь: наверняка кто-то уже решил эту задачу нормально.
Мы тоже так думали. Не нашли — и написали свою библиотеку. Называется dialog-engine: описываешь шаги диалога в JSON или YAML, а движок сам разруливает навигацию, условную видимость и прогресс. Без привязки к Telegram — работает где угодно: бот, веб-форма, CLI. Но для aiogram 3 есть готовые inline-клавиатуры из коробки.
Расскажу, как до этого дошло, как устроен движок изнутри и как подключить его к реальному боту за полчаса.
Предыстория: 40 шагов и один роутер
Мы делали SaaS-продукт с Telegram-ботом, в котором пользователь проходит многошаговую анкету. Сначала было 8 шагов — терпимо. Потом шагов стало 25, появились условия: если выбрал «юрлицо» — спроси реквизиты, если «физлицо» — пропусти. Если загрузил фото паспорта — не спрашивай серию и номер вручную. И так далее.
Код роутера превратился в 800-строчный файл с вложенными if current_step == "step_17", ручным пересчётом «шаг N из M» и багами при навигации назад через скрытые шаги. Каждое новое бизнес-требование — это рефакторинг всего файла.
Проблема не в Telegram и не в aiogram. Проблема в том, что логика «какие шаги показывать и в каком порядке» размазана по коду. А должна быть в конфиге.
Идея: диалог — это данные, а не код
Суть dialog-engine укладывается в три предложения:
Вы описываете шаги в JSON-файле: id, тип, текст вопроса и условия видимости. Приложение хранит контекст — обычный dict с ответами пользователя. Движок по контексту определяет, какие шаги видны, и вычисляет навигацию — без единого if в вашем коде для каждого сценария.
Вот минимальный пример. JSON-файл wizard.json:
{ "steps": [ { "id": "name", "type": "text", "text": "Как вас зовут?" }, { "id": "plan", "type": "choice", "text": "Выберите тариф", "choices": { "free": "Бесплатный", "pro": "Профессиональный" } }, { "id": "company_inn", "type": "text", "text": "Введите ИНН компании", "show_when": { "field": "plan", "equals": "pro" } }, { "id": "confirm", "type": "text", "text": "Всё верно? Отправляем!" } ] }
Третий шаг (company_inn) появится только если пользователь выбрал тариф pro. Для free движок его пропустит — и при навигации вперёд, и при подсчёте «шаг 2 из 3» (а не «из 4»).
Установка
Python 3.10+. Минимальная установка — только ядро, ноль зависимостей:
pip install dialog-engine
Дополнительные возможности подключаются через extras:
# Валидация структуры JSON через Pydantic pip install dialog-engine[validation] # Загрузка из YAML pip install dialog-engine[yaml] # Inline-клавиатуры для aiogram 3 pip install dialog-engine[aiogram] # Всё сразу pip install dialog-engine[validation,yaml,aiogram]
Репозиторий: github.com/ShyDamn/dialog-engine. PyPI: pypi.org/project/dialog-engine.
Формат конфига: что можно описать
Корень файла — объект с полем steps (или просто массив шагов). У каждого шага три обязательных поля: id, type и text. Остальное — опционально.
type — произвольная строка. Библиотека не интерпретирует типы: text, choice, photo — это соглашение для вашего UI. Хотите тип location или date — пожалуйста, движку всё равно.
required — по умолчанию true. Если false, в aiogram-интеграции автоматически появляется кнопка «Пропустить».
choices — для шагов с выбором. Объект вида "значение": "подпись". Подписи могут быть i18n-ключами — библиотека их не переводит, а передаёт в вашу translate-функцию.
min / max — для шагов с вложениями (например, количество фотографий).
А теперь самое интересное — условная видимость.
skip_when и show_when: условия без if-ов
Каждый шаг может иметь skip_when (пропустить, если условие выполняется) и show_when (показать, только если условие выполняется). Логика простая: если не задано ни то, ни другое — шаг виден всегда.
Листовое условие — объект с полем field (ключ в контексте) и оператором сравнения:
{ "field": "plan", "equals": "pro" } { "field": "age", "gte": 18 } { "field": "email", "exists": true } { "field": "role", "in": ["admin", "manager"] } { "field": "status", "not_in": ["blocked", "deleted"] }
Операторы: equals, in, not_in, exists, lt, gt, lte, gte. Сравнения работают через стандартные операторы Python, так что строки и числа сравниваются как ожидается.
Для сложной логики есть составные правила:
{ "skip_when": { "any_of": [ { "field": "plan", "equals": "free" }, { "field": "age", "lt": 18 } ] } }
any_of — логическое ИЛИ, all_of — логическое И. Список условий на верхнем уровне тоже работает как И. Правила можно вкладывать друг в друга.
Реальный пример из нашего проекта: не показывать шаг загрузки фото банковской карты, если владелец — третье лицо:
{ "id": "bank_card_photo", "type": "photo", "text": "upload-card", "required": false, "min": 1, "max": 1, "skip_when": { "field": "card_owner", "equals": "third" } }
Python API: навигация в пять строк
Загрузка:
from dialog_engine import DialogEngine engine = DialogEngine.from_file("wizard.json") # или: engine = DialogEngine.from_json_string('{"steps": [...]}') # или: engine = DialogEngine.from_list([{...}, {...}])
Навигация — контекст-зависимая. Передаёте dict с ответами пользователя, движок пропускает скрытые шаги:
ctx = {"plan": "free"} # Следующий видимый шаг после текущего (индекс 1) next_idx = engine.next_index(1, ctx) # Предыдущий видимый шаг prev_idx = engine.previous_index(3, ctx) # «Шаг 2 из 3» — с учётом скрытых pos = engine.effective_position(next_idx, ctx) # 1-based total = engine.effective_total(ctx) # Последний ли это видимый шаг? if engine.is_last_visible(next_idx, ctx): # Диалог закончен — показываем подтверждение ...
Для сохранения сессии в БД есть DialogSessionState:
from dialog_engine import DialogSessionState state = DialogSessionState(index=2, context={"name": "Анна"}) raw = state.to_json() # строка JSON restored = DialogSessionState.from_json(raw)
Интеграция с aiogram 3: клавиатуры из коробки
Ядро не привязано к Telegram, но для aiogram 3 есть готовые хелперы. После pip install dialog-engine[aiogram] доступны функции для генерации inline-клавиатур.
Для шагов с выбором — клавиатура с вариантами, текущий выбор помечается галочкой:
from dialog_engine.integrations.aiogram import ( build_step_keyboard, build_photo_keyboard, parse_choice_callback, is_named_callback, KeyboardCallbacks, ) def translate(key: str) -> str: """Ваша функция перевода.""" return translations.get(key, key) step = engine.get_step(current_index) kb = build_step_keyboard( step, translate, show_back=True, current_value=ctx.get(step.id), ) await message.answer(step.text, reply_markup=kb)
Для шагов с фотографиями — отдельная клавиатура с кнопками «Готово», «Очистить», «Пропустить»:
kb = build_photo_keyboard( translate, show_done=True, show_clear=True, show_skip=not step.required, )
Разбор нажатий тоже типизирован:
@router.callback_query() async def on_callback(callback: CallbackQuery): cb = KeyboardCallbacks() pair = parse_choice_callback(callback.data, cb) if pair: step_id, choice_key = pair ctx[step_id] = choice_key # Переходим к следующему шагу next_idx = engine.next_index(current_index, ctx) ... if is_named_callback(callback.data, cb.skip): next_idx = engine.next_index(current_index, ctx) ... if is_named_callback(callback.data, cb.back): prev_idx = engine.previous_index(current_index, ctx) ...
callback_data по умолчанию используют префиксы dialog_choice:step_id:key, dialog_skip, dialog_back, dialog_keep. Если нужны свои — переопределите через KeyboardCallbacks.
CLI: валидация и визуализация
После установки пакета доступна команда dialog-engine с подкомандами.
Проверка конфига (дубликаты id, пустой список шагов):
dialog-engine validate wizard.json
Строгая проверка через Pydantic (нужен extra validation):
dialog-engine validate --strict wizard.json
Генерация Mermaid-диаграммы — удобно для документации:
dialog-engine mermaid wizard.json
Вывод JSON Schema (для автодополнения в IDE):
dialog-engine schema > dialog.schema.json
Шаблон нового диалога:
dialog-engine init -o my-dialog.json
Валидацию удобно запускать в CI — чтобы кривой конфиг не доехал до продакшена.
Как встроить в реальный проект: чеклист
Вот пошаговый алгоритм интеграции, который мы используем сами:
Загрузите диалог один раз при старте приложения через DialogEngine.from_file. В состоянии сессии пользователя храните два значения: текущий индекс шага и контекст (словарь с ответами). После каждого ответа обновляйте контекст: ctx[step.id] = value. Для перехода вперёд вызывайте next_index(current, ctx) — если вернулось None, диалог завершён. Для «Назад» — previous_index. Для индикатора прогресса — effective_position и effective_total.
Типы шагов — ваша ответственность. Библиотека не отправляет сообщения и не скачивает файлы. Она только управляет порядком шагов и их видимостью. Ввод-вывод реализуете вы — под свой фреймворк и свою платформу.
Зачем JSON, а не код
Резонный вопрос: почему не описать шаги обычным списком в Python? Несколько причин.
Конфиг могут редактировать не-программисты. Продакт-менеджер может добавить шаг или поменять условие видимости, не трогая код бота. JSON-файл можно хранить отдельно и подгружать динамически — например, разные анкеты для разных стран.
Валидация отделена от логики. dialog-engine validate в CI ловит ошибки в конфиге до деплоя. JSON Schema даёт автодополнение в VS Code и WebStorm.
Диалог становится переносимым. Один и тот же wizard.json работает в Telegram-боте, в веб-форме на React и в CLI-утилите. Меняется только слой отображения.
Лицензия и ограничения
Исходный код открыт для просмотра, но это не open source в классическом смысле. Вы можете устанавливать пакет и использовать его как библиотеку в своих проектах. Нельзя модифицировать исходники и распространять изменённые версии. Подробности — в файле LICENSE в репозитории.
Требования: Python 3.10+. Ядро не имеет зависимостей — extras подтягивают Pydantic, PyYAML и aiogram по необходимости.
Что дальше
Сейчас dialog-engine — версия 0.1.1. Мы используем его в продакшене в нескольких проектах и постепенно обрастаем фичами по реальным потребностям. В планах — поддержка ветвлений (не только линейный визард, но и деревья), генерация сводки по пройденным шагам и расширение интеграций за пределы aiogram.
Если у вас есть сценарии, которые не покрывает текущий формат — пишите в issues на GitHub. Баг-репорты и предложения приветствуются.
Репозиторий: github.com/ShyDamn/dialog-engine PyPI: pypi.org/project/dialog-engine