Каждый, кто делал 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

Комментарии (0)