T

tg_tree_wizard

Lightweight tree-shaped inline keyboard wizard for aiogram 3

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