Skip to main content

tg-tree-wizard

PyPI Python Tests License: MIT

Лёгкий движок древовидных inline-опросников и меню для aiogram 3. Дерево описывается данными — не отдельным хендлером на каждый уровень. «Назад», переходы, защита от лимита callback_data — всё уже внутри библиотеки и покрыто тестами.

Quick start

pip install tg-tree-wizard

Опрос (wizard)

from aiogram import Bot, Dispatcher
from aiogram.filters import Command
from aiogram.fsm.storage.memory import MemoryStorage
from tg_tree_wizard import linear_wizard, TreeWizard

TREE, ROOT = linear_wizard([
    ("size", "Выберите размер:", ["Маленькая", "Средняя", "Большая"]),
    ("topping", "Начинка:", [
        ("Пепперони", "pepperoni", "spicy"),   # ветвление: доп. вопрос
        ("Маргарита", "margherita"),           # остальные — сразу в финал
    ]),
    ("spicy", "Поострее?", ["Да", "Нет"]),
])

async def on_finish(call, state, answers):
    await call.message.edit_text("Заказ: " + " → ".join(a.label for a in answers))

wizard = TreeWizard(TREE, root=ROOT, on_finish=on_finish)

dp = Dispatcher(storage=MemoryStorage())
dp.include_router(wizard.router)

@dp.message(Command("order"))
async def cmd_order(message, state):
    await wizard.start(message, state)

Меню (menu)

from tg_tree_wizard import Node, MenuOption, TreeMenu

TREE = {
    "main_menu": Node(
        text="Главное меню:",
        options=(
            MenuOption("📋 Заказы", "orders"),
            MenuOption("👤 Профиль", "profile"),
            MenuOption("💬 Поддержка", "support"),
        ),
    ),
    "orders": Node(
        text="Ваши заказы:",
        options=(
            MenuOption("📦 Активные", "active"),
            MenuOption("✅ Завершённые", "completed"),
            MenuOption("⬅️ В главное меню", "back", "main_menu"),
        ),
    ),
}

menu = TreeMenu(TREE, root="main_menu")
dp.include_router(menu.router)

@dp.message(Command("start"))
async def cmd_start(message, state):
    await menu.start(message, state)

Зачем это нужно

Многошаговый inline-опрос или меню в Telegram-боте на чистом aiogram — это отдельный хендлер на каждый уровень: собрать клавиатуру, распарсить callback_data, вручную добавить кнопку «Назад». С ростом дерева дублирование растёт линейно, а кнопка «Назад» и лимит Telegram в 64 байта на callback_data почти неизбежно всплывают как баги.

tg-tree-wizard сводит это к декларативному описанию дерева и двум общим хендлерам — работающим для дерева любой формы и глубины, будь то опрос с ответами или навигационное меню без сохранения состояния выбора.

Новые возможности:

  • Динамический текст узлов через callable Node.text(state) для персонализации меню
  • Сохранение пользовательских данных между wizard и menu через FSMContext
  • Пример полного бота с регистрацией и динамическим меню: examples/pizza_bot_text_input.py

Сколько кода экономит

Замер на дереве из 6 уровней (язык → формат → цель → индивидуально/группа → возраст → уровень владения), с 2–7 вариантами ответа на каждом — навигационная часть кода, без учёта бизнес-логики финального шага:

Строк кода
Вручную (клавиатура + парсинг callback_data + кнопка «Назад» на каждый уровень) ~169
tg-tree-wizard (linear_wizard + подключение роутера) ~26

Разница держится линейно — каждое новое дерево в проекте стоит фиксированные ~26 строк вместо ~169, потому что логика навигации уже написана и протестирована один раз внутри библиотеки.

TreeWizard — древовидные опросы

Короткий способ: linear_wizard

Подходит для большинства случаев — вопросы идут по порядку, ветвление лишь изредка:

from tg_tree_wizard import linear_wizard, TreeWizard

TREE, ROOT = linear_wizard([
    ("lang", "Выберите язык:", ["Английский", "Немецкий"]),
    ("delivery", "Формат:", [("Очно", "offline"), ("Онлайн", "online")]),
    ("topping", "Начинка:", [
        ("Пепперони", "pepperoni", "spicy"),   # явный переход = ветвление
        ("Маргарита", "margherita"),           # без 3-го элемента = следующий шаг по порядку
    ]),
    ("spicy", "Поострее?", ["Да", "Нет"]),
])

wizard = TreeWizard(TREE, root=ROOT, on_finish=my_finish_callback)
dp.include_router(wizard.router)

Правила для варианта ответа:

  • "Текст" — метка и значение совпадают, переход на следующий шаг по списку;
  • ("Текст", "значение") — то же самое, но значение отдельно от текста кнопки;
  • ("Текст", "значение", "id_узла") — явный переход, для ветвления или перехода не на следующий, а на произвольный узел.

Последний шаг в списке — финальный по умолчанию (после него опрос завершается), если явно не переопределить переход у его вариантов.

Полный способ: Node / Option напрямую

Нужен для сложных ветвящихся графов, нескольких независимых корней и т.п.:

from tg_tree_wizard import Node, Option, TreeWizard

TREE = {
    "start": Node(
        text="Первый вопрос:",
        options=(
            Option("Вариант A", "a", "next_node_id"),
            Option("Вариант B", "b", None),  # None = опрос завершается
        ),
    ),
    "next_node_id": Node(...),
}

wizard = TreeWizard(TREE, root="start", on_finish=my_finish_callback)

TreeWizard(...) сам вызывает validate_tree и check_callback_data_limits при создании — если где-то опечатались в id узла или id получился слишком длинным, бот упадёт при старте с понятной ошибкой.

Кнопка «Назад» и отмена

Кнопка «Назад» добавляется автоматически на каждом шаге (кроме корневого). Для кнопки отмены:

wizard = TreeWizard(
    TREE, root="start",
    cancel_button=True,
    cancel_callback_data="cancel",
)

Middleware — перехват событий

Middleware-хуки позволяют логировать, валидировать или модифицировать события:

from tg_tree_wizard import MiddlewareData, AbortWizard

def logging_hook(data: MiddlewareData):
    print(f"[{data.event_type}] узел={data.node_id}")

wizard = TreeWizard(TREE, root="start", middleware=[logging_hook])

Типы событий: "start", "choice", "back", "cancel". Чтобы прервать опрос из middleware — бросьте AbortWizard.

Динамические варианты (DynamicOption)

Генерирует клавиатуру на лету, используя данные из состояния пользователя:

from tg_tree_wizard import DynamicOption

def languages_factory(state: WizardState) -> list[tuple[str, str]]:
    user_lang = state.answers.get("lang", "en")
    if user_lang == "ru":
        return [("Русский", "ru"), ("Украинский", "uk")]
    return [("English", "en"), ("German", "de")]

TREE = {
    "lang": Node(text="Язык:", options=(
        DynamicOption(label_factory=languages_factory, value="dyn_lang", next_node="format"),
    )),
}

Кнопки с URL и Switch Inline Query

from tg_tree_wizard import URLOption, SwitchOption

TREE = {
    "menu": Node(text="Меню:", options=(
        Option("В каталог", "catalog"),
        URLOption(label="🌐 Наш сайт", value="url", url="https://example.com"),
        SwitchOption(label="🔍 Поиск по чату", value="search", switch_inline_query="поиск..."),
    )),
}

Динамический текст узлов

Node.text теперь может быть не только строкой, но и callable, принимающим состояние:

from tg_tree_wizard import Node, MenuOption, TreeMenu

MENU_TREE = {
    "main": Node(
        text=lambda state: f"Привет, {state.data.get('user', 'гость')}!",
        options=(...),
    ),
}

Поддерживается в TreeWizard и TreeMenu. Полный пример с регистрацией и динамическим меню: examples/pizza_bot_text_input.py.

TreeMenu — навигационные меню

Простые линейные меню: TreeWizard + MenuOption

Если нужно простое линейное меню (как опрос, но без сохранения ответов), используйте TreeWizard с MenuOption:

from tg_tree_wizard import Node, MenuOption, TreeWizard

TREE = {
    "main_menu": Node(
        text="Главное меню:",
        options=(
            MenuOption("⚙️ Настройки", "settings", "settings_menu"),
            MenuOption("👤 Профиль", "profile", "profile_page"),
            MenuOption("📖 Помощь", "help", "help_page"),
        ),
    ),
}

wizard = TreeWizard(TREE, root="main_menu")

MenuOption полностью совместим со всеми возможностями библиотеки: middleware, FSMContext, динамические опции и т.д.

Нелинейная навигация: TreeMenu

Для меню с произвольной навигацией (пользователь может переходить между разделами без линейного порядка) используется TreeMenu:

from tg_tree_wizard import Node, MenuOption, TreeMenu

TREE = {
    "main_menu": Node(
        text="Главное меню:",
        options=(
            MenuOption("📋 Заказы", "orders"),
            MenuOption("👤 Профиль", "profile"),
            MenuOption("💬 Поддержка", "support"),
        ),
    ),
    "orders": Node(
        text="Ваши заказы:",
        options=(
            MenuOption("📦 Активные", "active"),
            MenuOption("✅ Завершённые", "completed"),
            MenuOption("⬅️ В главное меню", "back", "main_menu"),
        ),
    ),
}

menu = TreeMenu(TREE, root="main_menu")
dp.include_router(menu.router)

TreeMenu отличается от TreeWizard:

  • Использует MenuState — хранит текущий узел и историю переходов;
  • Поддерживает произвольную навигацию между разделами меню;
  • Middleware получает новые типы событий: "menu_choice", "menu_back".

WizardManager — управление состоянием сессий

from tg_tree_wizard import WizardManager

manager = WizardManager()
wizard = TreeWizard(TREE, root="start")
dp.include_router(wizard.router)

active_count = manager.active_count()  # количество активных опросов

Тестирование дерева без aiogram

simulate_wizard позволяет прогнать дерево через asyncio.run() без запуска бота:

from tg_tree_wizard.testing import simulate_wizard, simulate_wizard_with_state
import asyncio

tree = {
    "a": Node(text="Шаг A", options=(Option("A1", "v1", "b"), Option("A2", "v2", None))),
    "b": Node(text="Шаг B", options=(Option("B1", "v3", None),)),
}

answers, final_node = asyncio.run(simulate_wizard(tree, root="a", choices=[("a", 0), ("b", 0)]))
print(final_node)  # "b"

Почему не aiogram_dialog?

aiogram_dialog — более мощный и зрелый фреймворк для сложных сценариев (виджеты, множественные диалоги, кастомный рендеринг). tg-tree-wizard — не замена ему, а более лёгкая альтернатива для конкретно одного случая: линейный или слабо ветвящийся опрос / меню с кнопкой «Назад», без необходимости осваивать Window/State-модель aiogram_dialog. Если нужен полноценный UI-фреймворк поверх aiogram — берите aiogram_dialog; если нужен просто быстрый древовидный визард или меню — этого достаточно.

Структура пакета

tg_tree_wizard/
├── pyproject.toml              # метаданные пакета
├── src/tg_tree_wizard/
│   ├── __init__.py             # публичный API
│   ├── core.py                 # чистая логика дерева, БЕЗ aiogram
│   ├── aiogram_adapter.py      # клавиатуры + хендлеры aiogram поверх core.py
│   ├── middleware.py            # middleware-система для перехвата событий
│   ├── manager.py              # WizardManager — управление состоянием сессий
│   └── testing.py              # simulate_wizard — прогон дерева без aiogram
├── tests/                      # тесты ядра и адаптера, без сети и без Telegram
└── examples/
    ├── simulate_flow.py        # прогон через настоящий aiogram Dispatcher, без сети
    ├── language_school_bot.py  # бот с опросом
    ├── pizza_bot.py            # бот с ветвлением
    ├── pizza_bot_text_input.py # бот с регистрацией через ввод текста и динамическим меню
    └── menu_bot.py             # пример меню с TreeMenu и MenuOption

Почему core.py отдельно от aiogram_adapter.py: ядро ничего не знает про Telegram — значит, его логику (переходы, «Назад», ошибки) можно проверить обычным pytest за доли секунды, без сети и без живого бота. Адаптер — тонкий слой, который только рендерит клавиатуры и дёргает ядро.

Разработка и контрибьютинг

Если вы обнаружили уязвимость безопасности, пожалуйста, прочитайте SECURITY.md перед тем как открыть issue.

Лицензия

MIT

Metadata

Release files for tg-tree-wizard 2.0.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tg-tree-wizard 2.0.3
File Size Uploaded
tg_tree_wizard-2.0.3.tar.gz 40.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tg-tree-wizard 2.0.3
File Interpreter ABI Platform
tg_tree_wizard-2.0.3-py3-none-any.whl Python 3 none any Details

Total release size: 68.4 kB

Release files / tg_tree_wizard-2.0.3.tar.gz

Download URL tg_tree_wizard-2.0.3.tar.gz
Size 40.8 kB
Tags Source
SHA-256 checksum
How to use checksums
fa5e61beb439affe5c546662b0466aad88aa6e1ce9e45f416e36ce4d9f28af41
BLAKE2b-256 checksum
How to use checksums
b5f97b80220ad69ea7caee5f68c976a233ed4d91e02df91f382eaa562daf8cb9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.7

Release files / tg_tree_wizard-2.0.3-py3-none-any.whl

Download URL tg_tree_wizard-2.0.3-py3-none-any.whl
Size 27.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e4df96c96e8b4c439a8be9d08d9832853dd83c9ffbd18188f2d643bb1b903896
BLAKE2b-256 checksum
How to use checksums
adfc0fa784427d61c5e1a9a9db1219a58280f5e17acdd7683ae0029559393c2b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.7

Release history Release notifications | RSS feed

This release

2.0.3 This release

2 release files

2.0.2

1 release file

2.0.1

2 release files

1.1.1

2 release files

1.1.0

2 release files

1.0.2

2 release files

1.0.1

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page