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 сводит это к декларативному описанию дерева и двум общим хендлерам — работающим для дерева любой формы и глубины, будь то опрос с ответами или навигационное меню без сохранения состояния выбора.

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

Замер на дереве из 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="поиск..."),
    )),
}

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            # реальный бот с ветвлением
    └── menu_bot.py             # пример меню с TreeMenu и MenuOption

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

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

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

Лицензия

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tg_tree_wizard-2.0.1.tar.gz (39.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tg_tree_wizard-2.0.1-py3-none-any.whl (26.7 kB view details)

Uploaded Python 3

File details

Details for the file tg_tree_wizard-2.0.1.tar.gz.

File metadata

  • Download URL: tg_tree_wizard-2.0.1.tar.gz
  • Upload date:
  • Size: 39.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.5

File hashes

Hashes for tg_tree_wizard-2.0.1.tar.gz
Algorithm Hash digest
SHA256 13b67f8a147149ca50c0ad04c2a51da437942c2f0141738d6178968c634ac6d3
MD5 645647c09097a395fcb46559f3fb83b3
BLAKE2b-256 7a464e96e7ea6dd47c0b20446246258a9d51a4d25165a91bff9e2ed582993dab

See more details on using hashes here.

File details

Details for the file tg_tree_wizard-2.0.1-py3-none-any.whl.

File metadata

  • Download URL: tg_tree_wizard-2.0.1-py3-none-any.whl
  • Upload date:
  • Size: 26.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.5

File hashes

Hashes for tg_tree_wizard-2.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 078bec96583831d18b795a3486ff8f831cdc7d5938578632e2ce683c0195ccd4
MD5 7a462a724507781bdcffe3199181c639
BLAKE2b-256 d801d27cc26a0427470cb991fbf2586a726dd068a9949d795bff3c9063982e76

See more details on using hashes here.

Release history Release notifications | RSS feed

2.0.2

1 file

This release

2.0.1 This release

2 files

1.1.1

2 files

1.1.0

2 files

1.0.2

2 files

1.0.1

2 files

1.0.0

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page