Введение

Однажды меня попросили провести ревью и рефакторинг одного telegram-бота. Увидев файл размером 2000 строк, рассчитанный только на обработку разных меню я понял, что это требует унификации и общих подходов. Так родилась библиотека aiogram-dialog.

В этой статье я бы хотел обратить внимание на некоторые проблемы, которые мы встречаем при создании таких меню, предложить варианты их решения. А во второй половине статьи показать как это решается с помощью aiogram-dialog.

Мы не будем рассматривать архитектуру всего приложения, об этом вы можете прочитать у Фаулера или Мартина. Мы поговорим только про определенную часть UI ботов. Так же это не будет введением в разработку telegram-ботов с нуля. Я предполагаю, что читатель знаком с питоном, ООП и слышал о такой вещи как DRY. В коде примеров я использую aiogram v3.0 и надеюсь, что читатель уже использовал встроенную в библиотеку машину состояний.

Примеры выбраны так, чтобы проще было показать определенные проблемы, но это не единственные сценарии приводящие к ним.

Постановка проблемы

Шаг первый. Меню

Давайте рассмотрим небольшого бота, взаимодействующего с пользователем через сообщение с inline-клавиатурой.

Пусть сообщение содержит имя пользователя, а клавиатура содержит кнопку, включающую расширенный режим. При нажатии на кнопку с галочкой мы будем обновлять сообщение, а не посылать новое, скрывая или показывая в нем дополнительный текст. Так же заложим на будущее кнопку вызова настроек.

Для реализации такого бота нам пока потребуется 3 обработчика событий телеграм:

  • событие для команды /start, отправляющее сообщение

  • обработчик устанавливающий галочку

  • обработчик снимающий галочку

Пример кода:

import asyncio
import os

from aiogram import Router, F, Bot, Dispatcher
from aiogram.filters import CommandStart
from aiogram.types import (
    CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton, Message,
)

router = Router()

STEP1_EXTEND_CB = "extend"
STEP1_COLLAPSE_CB = "collapse"
STEP1_SETTINGS_CB = "settings"

ADDITIONAL_TEXT = "Here is some additional text, which is visible only in extended mode"


@router.message(CommandStart())
async def step1(message: Message):
    keyboard = InlineKeyboardMarkup(inline_keyboard=[[
        InlineKeyboardButton(text="[ ] Extended mode",
                             callback_data=STEP1_EXTEND_CB),
        InlineKeyboardButton(text="Settings", callback_data=STEP1_SETTINGS_CB),
    ]])
    await message.answer(
        f"Hello, {message.from_user.username}. \n\n"
        "Extended mode is off.",
        reply_markup=keyboard,
    )


@router.callback_query(F.data == STEP1_EXTEND_CB)
async def step1_check(callback: CallbackQuery):
    keyboard = InlineKeyboardMarkup(inline_keyboard=[[
        InlineKeyboardButton(text="[x] Extended mode",
                             callback_data=STEP1_COLLAPSE_CB),
        InlineKeyboardButton(text="Settings", callback_data=STEP1_SETTINGS_CB),
    ]])
    await callback.message.edit_text(
        f"Hello, {callback.from_user.username}. \n\n"
        "Extended mode is on.\n\n" + ADDITIONAL_TEXT,
        reply_markup=keyboard,
    )


@router.callback_query(F.data == STEP1_COLLAPSE_CB)
async def step1_uncheck(callback: CallbackQuery):
    keyboard = InlineKeyboardMarkup(inline_keyboard=[[
        InlineKeyboardButton(text="[ ] Extended mode",
                             callback_data=STEP1_EXTEND_CB),
        InlineKeyboardButton(text="Settings", callback_data=STEP1_SETTINGS_CB),
    ]])
    await callback.message.edit_text(
        f"Hello, {callback.from_user.username}. \n\n"
        "Extended mode is off.",
        reply_markup=keyboard,
    )


async def main():
    bot = Bot(token=os.getenv("BOT_TOKEN"))
    dp = Dispatcher()
    dp.include_router(router)
    await dp.start_polling(bot)


asyncio.run(main())

Проблема 1:

В обработчике разных событий есть одинаковый код генерации текста и клавиатуры. При добавлении новых кнопок или переходов между меню он снова будет дублироваться

Решение проблемы 1:

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

Пример кода:

import asyncio
import os

from aiogram import Bot, Router, F, Dispatcher
from aiogram.filters import CommandStart
from aiogram.types import (
    Message, InlineKeyboardMarkup, InlineKeyboardButton, User, Chat,
    CallbackQuery,
)

router = Router()

STEP1_EXTEND_CB = "extend"
STEP1_COLLAPSE_CB = "collapse"
STEP1_SETTINGS_CB = "settings"

ADDITIONAL_TEXT = "Here is some additional text, which is visible only in extended mode"


def step1_text(user: User, is_extended: bool) -> str:
    if is_extended:
        status = "on"
        suffix = "\n\n" + ADDITIONAL_TEXT
    else:
        status = "off"
        suffix = ""
    return (
        f"Hello, {user.username}. \n\n"
        f"Extended mode is {status}."
        f"{suffix}"
    )


def step1_keyboard(is_checked: bool) -> InlineKeyboardMarkup:
    if is_checked:
        checkbox = InlineKeyboardButton(
            text="[x] Extended mode", callback_data=STEP1_COLLAPSE_CB,
        )
    else:
        checkbox = InlineKeyboardButton(
            text="[ ] Extended mode", callback_data=STEP1_EXTEND_CB,
        )
    return InlineKeyboardMarkup(inline_keyboard=[[
        checkbox,
        InlineKeyboardButton(text="Settings", callback_data=STEP1_SETTINGS_CB),
    ]])


@router.message(CommandStart())
async def step1(message: Message):
    await message.answer(
        text=step1_text(user=message.from_user, is_extended=False),
        reply_markup=step1_keyboard(is_checked=False)
    )


@router.callback_query(F.data == STEP1_EXTEND_CB)
async def step1_check(callback: CallbackQuery):
    await callback.message.edit_text(
        text=step1_text(user=callback.from_user, is_extended=True),
        reply_markup=step1_keyboard(is_checked=True)
    )


@router.callback_query(F.data == STEP1_COLLAPSE_CB)
async def step1_uncheck(callback: CallbackQuery):
    await callback.message.edit_text(
        text=step1_text(user=callback.from_user, is_extended=False),
        reply_markup=step1_keyboard(is_checked=False)
    )


async def main():
    bot = Bot(token=os.getenv("BOT_TOKEN"))
    dp = Dispatcher()
    dp.include_router(router)
    await dp.start_polling(bot)


asyncio.run(main())

Шаг второй. Ввод текста

Добавим к обработке нажатий возможность обработки входящих сообщений. В ответ мы хотим посылать новое сообщение, содержащие актуальную информацию, не теряя при этом состояния чекбокса. В старом сообщении же мы будем скрывать клавиатуру, чтобы не иметь много похожих кнопок, на все из которых юзер может попытаться нажать.

Проблема 2:

  • При входящем сообщении мы не знаем какое старое сообщение редактировать, как это было в CallbackQuery

  • При входящем сообщении мы не знаем состояние чекбокса в старом сообщении

Решение проблемы 2:

Необходимо запоминать где-то состояние чата: нажата ли галочка и id последнего сообщения.

Пример кода:

import asyncio
import os

from aiogram import Bot, Router, F, Dispatcher
from aiogram.filters import CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import (
    Message, InlineKeyboardMarkup, InlineKeyboardButton, User, Chat,
    CallbackQuery,
)

router = Router()

STEP1_EXTEND_CB = "extend"
STEP1_COLLAPSE_CB = "collapse"
STEP1_SETTINGS_CB = "settings"

IS_EXTENDED_KEY = "extended"
LAST_MSG_ID_KEY = "last_message_id"

ADDITIONAL_TEXT = "Here is some additional text, which is visible only in extended mode"


def step1_text(user: User, is_extended: bool) -> str:
    if is_extended:
        status = "on"
        suffix = "\n\n" + ADDITIONAL_TEXT
    else:
        status = "off"
        suffix = ""
    return (
        f"Hello, {user.username}. \n\n"
        f"Extended mode is {status}."
        f"{suffix}"
    )


def step1_keyboard(is_checked: bool) -> InlineKeyboardMarkup:
    if is_checked:
        checkbox = InlineKeyboardButton(
            text="[x] Extended mode", callback_data=STEP1_COLLAPSE_CB,
        )
    else:
        checkbox = InlineKeyboardButton(
            text="[ ] Extended mode", callback_data=STEP1_EXTEND_CB,
        )
    return InlineKeyboardMarkup(inline_keyboard=[[
        checkbox,
        InlineKeyboardButton(text="Settings", callback_data=STEP1_SETTINGS_CB),
    ]])


@router.message(CommandStart())
async def step1(message: Message, state: FSMContext):
    message = await message.answer(
        text=step1_text(user=message.from_user, is_extended=False),
        reply_markup=step1_keyboard(is_checked=False)
    )
    await state.set_data({
        IS_EXTENDED_KEY: False,
        LAST_MSG_ID_KEY: message.message_id,
    })


@router.callback_query(F.data == STEP1_EXTEND_CB)
async def step1_check(callback: CallbackQuery, state: FSMContext):
    await state.update_data({IS_EXTENDED_KEY: True})
    await callback.message.edit_text(
        text=step1_text(user=callback.from_user, is_extended=True),
        reply_markup=step1_keyboard(is_checked=True)
    )


@router.callback_query(F.data == STEP1_COLLAPSE_CB)
async def step1_uncheck(callback: CallbackQuery, state: FSMContext):
    await state.update_data({IS_EXTENDED_KEY: False})
    await callback.message.edit_text(
        text=step1_text(user=callback.from_user, is_extended=False),
        reply_markup=step1_keyboard(is_checked=False)
    )


@router.message()
async def step1_nothing(message: Message, bot: Bot, state: FSMContext):
    data = await state.get_data()
    await bot.edit_message_reply_markup(
        chat_id=message.chat.id, message_id=data[LAST_MSG_ID_KEY],
    )
    message = await message.answer(
        text=step1_text(
            user=message.from_user, is_extended=data[IS_EXTENDED_KEY],
        ),
        reply_markup=step1_keyboard(is_checked=data[IS_EXTENDED_KEY])
    )
    data[LAST_MSG_ID_KEY] = message.message_id
    await state.set_data(data)


async def main():
    bot = Bot(token=os.getenv("BOT_TOKEN"))
    dp = Dispatcher()
    dp.include_router(router)
    await dp.start_polling(bot)


asyncio.run(main())

Шаг третий и далее

Добавим к боту вторую клавиатуру, которая появляется по нажатию кнопки "Settings". Пусть это будет экран настроек, содержащий ещё пару чекбоксов и кнопки "сохранить" и "отменить". По Нажатию "сохранить" мы сохраняем настройки в БД и возвращаемся в прошлое меню. А по нажатию "отменить" тоже возвращаемся, но без сохранения.

Проблема 3:

Обработчик сообщения не знает что мы перешли в настройки и отправляет нам меню 1.

Решение проблемы 3:

Необходимо запоминать в каком меню мы находимся по аналогии с другими данными чата. Можно использоваться для этого State из aiogram

Проблема 4:

  • В разных меню могут быть похожие по смыслу данные, необходимо следить, чтобы они не перетирали друг друга.

  • Необходимо удалять временные данные настроек при выходе, так как потом они будут сброшены. При этом другие данные не должны быть затронуты. Проблема кажется несущественной пока это касается только одного меню с небольшим количеством данных - мы всегда можем перечислить их ключ. Но подход будет повторяться

Решение проблемы 4:

Заведем для каждого меню (то есть состояния или группы состояний) зафиксируем ключ, под которым его данные будут храниться в общем словаре data. При выходе из меню мы можем удалять целиком ключ

То есть вместо

{
    "last_message_id": 1,
    "extended": true,
    "settings_option1": false,
    "settings_option2": true
}

Мы сделаем

{
    "last_message_id": 1,
    "step1": {
        "extended": true
    },
    "settings": {
      "option1": false,
      "option2": true
    }
}

Проблема 5:

Повторяющиеся паттерны обработки. В разных частях программы могут повторяться чекбоксы, кнопки выбора из нескольких вариантов, календарь, переходы вперед/назад. Приходится дублировать хэндлеры, делающие одну и ту же работу:

  • генерация кнопок

  • сохранение своего состояния

  • вызов показа нового текста и клавиатуры после нажатия

Решение проблемы 5:

  • выносим каждый паттерн в отдельный класс

  • добавляем ему id для генерации callback_data и хранения данных

  • параметризуем экземпляр колбэк-функциями для вызова прикладной логики, не относящейся к обновлению меню

Например, класс Checkbox может выглядеть так:


class Checkbox():
    def __init__(
            self,
            checked_text: str,
            unchecked_text: str,
            id: str,
            on_click: Optional[OnStateChanged] = None,
    ):
        ...

    def is_checked(self, state: FMSContext) -> bool:
        ...

    async def render_keyboard(
                self, state: FMSContext,
        ) -> List[List[InlineKeyboardButton]]:
        ...
    
    async def process_callback(
            self,
            callback: CallbackQuery,
            state: FMSContext,
    ) -> bool:
        ...

Имея набор таких виджетов (примитивов над клавиатурой, обработкой ввода или генерацией текста), логичным становится объединение их в один объект, описывающий состояние сообщения, который надо показать (далее Окно).

Проблема 6:

В коде имеющем несколько меню возможны переходы по нескольким направлениям. Часто необходимо реализовать переход назад или в главное меню.

  • В некоторые меню можно попасть разными способами и переход назад должен возвращать пользователя правильно

  • Для отрисовки главного меню требуется его импортировать в другие меню, и наоборот из него мы косвенно импортируем их. Возможны циклические импорты

Решение проблемы 6:

  • Заводим стек состояний. При переходе в новое меню мы не просто сохраняем его стейт, а добавляем его в стек.

  • Заводим отдельный класс менеджер, следящий за состоянием стека и вызывающий отрисовку исходя из текущего состояния, очистку при возврате в главное меню

  • Все переходы делается по State, а конкретные объекты окон привязываются к стейтам и регистрируются в менеджере.

Примерный вид класса менеджера стека:

class Manager:
    def __init__(self, windows: Dict[State, Window]):
        ...
        
    def refresh(self, context: FMSContext):
        ...
    
    def switch_into(self, state: State, fsm_context: FSMContext):
        ...
    
    def switch_up(self, fsm_context: FSMContext):
        ...

    def reset_stack(self, state: State, fsm_context: FSMContext):
        ...

Концепции

В примерах выше мы пришли к следующим концепциям, помогающим структурировать переходы между меню бота:

  1. Разделение реакции на события и генерации сообщения

  2. Хранение состояний в виде стека

  3. Изолированные контексты для разных частей UI

  4. Центральный менеджер управляющий переходами состояния

  5. Переиспользуемые компоненты ("виджеты"), группирующиеся в "окна" описывающие внешний вид сообщения

Все эти концепции уже реализованы в aiogram_dialog

Создание бота на aiogram-dialog

Установка

Устанавливаем стандартным для Python способом, например с помощью pip.

pip install aiogram-dialog==2.*

Окна и виджеты

Выше мы предполагали, что для описания внешнего вида сообщения будет использоваться набор виджетов, скомпонованный в один объект. Первое сообщение может состоять из таких частей:

  1. Динамический текст, куда будут подставлены данные из текущего контекста или события.

    from aiogram_dialog.widgets.text import Format
    
    text = Format(
         "Hello, {event.from_user.username}. \n\n"
         "Extended mode is {extended_str}.\n"
     )
    
  2. Фиксированный текст, который показывается только если включен расширенный режим. Для управления видимостью у виджетов есть атрибут when, через который задается название опции влияющей на отображение.

     from aiogram_dialog.widgets.text import Const
    
     additional = Const(
          "Here is some additional text, which is visible only in extended mode",
          when="extended",
     )
    
  3. Чекбокс, который меняет текст по клику. Оба варианта текста фиксированы. id виджета используется одновременно для формирования callback_data, хранения данных в контексте и поиска виджета в обработчиках.

    from aiogram_dialog.widgets.text import Const
    from aiogram_dialog.widgets.kbd import Checkbox
    
    EXTEND_BTN_ID = "extend"
    
    checkbox = Checkbox(
        checked_text=Const("[x] Extended mode"),
        unchecked_text=Const("[ ] Extended mode"),
        id=EXTEND_BTN_ID,
    )
    
  4. Кнопка перехода в настройки, которая пока ничего не делает.

    from aiogram_dialog.widgets.text import Const
    from aiogram_dialog.widgets.kbd import Button
    
    button_next = Button(Const("Settings"), id="settings")
    
  5. Для того чтобы чекбокс и кнопка перехода в настройки находились в одном ряду клавиатуры, добавим Row-виджет. В библиотеке предусмотрены также другие варианты расположения: в колонку, по несколько штук в ряд или с пагинацией при превышении определенного числа.

    from aiogram_dialog.widgets.kbd import Row
    row = Row(checkbox, button_next)
    

Теперь объединим это всё одно окно. Так же нам потребуется создать State, для того чтобы мы могли переключиться на это окно позднее. Библиотека требует чтобы все стейты были созданы в StatesGroup, таким образом мы достигаем большей гибкости в изоляции контекстов.

from aiogram.fsm.state import State, StatesGroup


from aiogram_dialog import Window
from aiogram_dialog.widgets.text import Format, Const
from aiogram_dialog.widgets.kbd import Checkbox, Button, Row


class MainMenu(StatesGroup):
    START = State()


EXTEND_BTN_ID = "extend"

window = Window(
    Format(
        "Hello, {event.from_user.username}. \n\n"
        "Extended mode is {extended_str}.\n"
    ),
    Const(
        "Here is some additional text, which is visible only in extended mode",
        when="extended",
    ),
    Row(
        Checkbox(
            checked_text=Const("[x] Extended mode"),
            unchecked_text=Const("[ ] Extended mode"),
            id=EXTEND_BTN_ID,
        ),
        Button(Const("Settings"), id="settings"),
    ),
    state=MainMenu.START
)

В процессе рендеринга данного окна туда в дальнейшем будет передан текущий контекст, откуда Checkbox прочитает своё состояние и сможет выбрать какой из двух вариантов текста использовать. Так же будет использовано текущее обрабатываемое событие (Message или CallbackQuery) чтобы подставить имя пользователя. Однако мы не указали пока что подставить в качестве {check} в текст.

Для внедрения дополнительных данных в процессе рендеринга сообщения в окно добавляется функция-getter. Она возвращает словарь, к которому в дальнейшем можно обращаться из Format, использовать внутри виджетов, влиять на их видимость и т.п. В частности, через него делается создание кнопок для выбора из списка.

В данном случае функция-геттер должна вернуть по ключу extended_str строку "on" если галочка снята и "off" в противном случае. А по ключу extended - само булево значение опции.

В качестве параметров она получает всё, что прилетает из middleware. Пока проигнорируем это, поставив **kwargs.

async def getter(**kwargs) -> Dict[str, Any]:
    if True:  # here will be some condition
        return {
            "extended_str": "on",
            "extended": True,
        }
    else:
        return {
            "extended_str": "off",
            "extended": False,
        }

Менеджер и ограничение контекста

Чтобы избежать конфликтов и автоматически очищать данные, aiogram-dialog ограничивает работу с контекстом не одним State, а одной StatesGroup. Таким образом мы можем иметь более одного окна с общим контекстом, что упрощает реализацию некоторых сценариев.

Так же как стейты объединяются в StatesGroup, окна объединяются в объект Dialog.

from aiogram_dialog import Dialog

main_menu = Dialog(window)

Если быть более точным, новый контекст создается каждый раз, когда вы добавляете что-то в стек переходов, но вы может сохранять контекст меняя текущий State в стеке в пределах одной StatesGroup.

Для управления переходами и контекстом, используется класс DialogManager. Он прилетает в getter и во все обработчики, обычно под именем dialog_manager.

Модифицируем наш геттер так, чтобы он выбирал текст исходя из состояния чекбокса. С dialog_manager мы найдем состояние виджета по его айди и проверим, есть ли там галочка.

from aiogram_dialog import DialogManager

async def getter(dialog_manager: DialogManager, **kwargs) -> Dict[str, Any]:
    if dialog_manager.find(EXTEND_BTN_ID).is_checked():
        return {
            "extended_str": "on",
            "extended": True,
        }
    else:
        return {
            "extended_str": "off",
            "extended": False,
        }

Прежде чем запускать бота нам необходимо реализовать переход к нашему диалогу. Делается это с помощью DialogManager.start

router = Router()


@router.message(CommandStart())
async def start(message: Message, dialog_manager: DialogManager):
    await dialog_manager.start(MainMenu.START)

Остался последний подготовительный подключить конкретные диалоги к боту и настроить сам Dispatcher на работу с библиотекой:

dp = Dispatcher()
dp.include_router(main_menu)
dp.include_router(router)
setup_dialogs(dp)

Таким образом, целиком всё будет выглядеть так:

import asyncio
import os
from typing import Dict, Any

from aiogram.filters import CommandStart
from aiogram.fsm.state import State, StatesGroup

from aiogram import Router, F, Bot, Dispatcher
from aiogram.types import Message

from aiogram_dialog import Dialog, Window, setup_dialogs, DialogManager
from aiogram_dialog.widgets.text import Format, Const
from aiogram_dialog.widgets.kbd import Checkbox, Button, Row


class Step1(StatesGroup):
    START = State()


EXTEND_BTN_ID = "extend"


async def getter(dialog_manager: DialogManager, **kwargs) -> Dict[str, Any]:
    if dialog_manager.find(EXTEND_BTN_ID).is_checked():
        return {
            "extended_str": "on",
            "extended": True,
        }
    else:
        return {
            "extended_str": "off",
            "extended": False,
        }


main_menu = Dialog(
    Window(
        Format(
            "Hello, {event.from_user.username}. \n\n"
            "Extended mode is {extended_str}.\n"
        ),
        Const(
            "Here is some additional text, which is visible only in extended mode",
            when="extended",
        ),
        Row(
            Checkbox(
                checked_text=Const("[x] Extended mode"),
                unchecked_text=Const("[ ] Extended mode"),
                id=EXTEND_BTN_ID,
            ),
            Button(Const("Settings"), id="settings"),
        ),
        getter=getter,
        state=Step1.START
    )
)

router = Router()


@router.message(CommandStart())
async def start(message: Message, dialog_manager: DialogManager):
    await dialog_manager.start(Step1.START)


async def main():
    bot = Bot(token=os.getenv("BOT_TOKEN"))
    dp = Dispatcher()
    dp.include_router(main_menu)
    dp.include_router(router)
    setup_dialogs(dp)

    await dp.start_polling(bot)


asyncio.run(main())

Второй диалог

Второй диалог добавляется полностью аналогично первому. Мы воспользуемся новым виджетом Cancel(), который возвращает нас в предыдущее меню (с очисткой контекста, естественно).

class Settings(StatesGroup):
    START = State()
    
NOTIFICATIONS_BTN_ID = "notify"
ADULT_BTN_ID = "adult"

settings = Dialog(
    Window(
        Const("Setting"),
        Checkbox(
            checked_text=Const("[x] Send notifications"),
            unchecked_text=Const("[ ] Send notifications"),
            id=NOTIFICATIONS_BTN_ID,
        ),
        Checkbox(
            checked_text=Const("[x] Adult mode"),
            unchecked_text=Const("[ ] Adult mode"),
            id=ADULT_BTN_ID,
        ),
        Row(
            Cancel(),
            Cancel(text=Const("Save"), id="save"),
        ),
        state=Settings.START,
    )
)

Переход ко второму диалогу из первого мы можем организовать так же вызвав dialog_manager.start из обработчика кнопки next, либо заменить её на специальный виджет

Start(Const("Settings"), id="settings", state=Settings.START)

Целиком код:

import asyncio
import os
from typing import Dict, Any

from aiogram.filters import CommandStart
from aiogram.fsm.state import State, StatesGroup

from aiogram import Router, F, Bot, Dispatcher
from aiogram.types import Message

from aiogram_dialog import Dialog, Window, setup_dialogs, DialogManager
from aiogram_dialog.widgets.text import Format, Const
from aiogram_dialog.widgets.kbd import Checkbox, Button, Row, Cancel, Start


class MainMenu(StatesGroup):
    START = State()


class Settings(StatesGroup):
    START = State()


EXTEND_BTN_ID = "extend"


async def getter(dialog_manager: DialogManager, **kwargs) -> Dict[str, Any]:
    if dialog_manager.find(EXTEND_BTN_ID).is_checked():
        return {
            "extended_str": "on",
            "extended": True,
        }
    else:
        return {
            "extended_str": "off",
            "extended": False,
        }


main_menu = Dialog(
    Window(
        Format(
            "Hello, {event.from_user.username}. \n\n"
            "Extended mode is {extended_str}.\n"
        ),
        Const(
            "Here is some additional text, which is visible only in extended mode",
            when="extended",
        ),
        Row(
            Checkbox(
                checked_text=Const("[x] Extended mode"),
                unchecked_text=Const("[ ] Extended mode"),
                id=EXTEND_BTN_ID,
            ),
            Start(Const("Settings"), id="settings", state=Settings.START),
        ),
        getter=getter,
        state=MainMenu.START
    )
)

NOTIFICATIONS_BTN_ID = "notify"
ADULT_BTN_ID = "adult"

settings = Dialog(
    Window(
        Const("Settings"),
        Checkbox(
            checked_text=Const("[x] Send notifications"),
            unchecked_text=Const("[ ] Send notifications"),
            id=NOTIFICATIONS_BTN_ID,
        ),
        Checkbox(
            checked_text=Const("[x] Adult mode"),
            unchecked_text=Const("[ ] Adult mode"),
            id=ADULT_BTN_ID,
        ),
        Row(
            Cancel(),
            Cancel(text=Const("Save"), id="save"),
        ),
        state=Settings.START,
    )
)

router = Router()


@router.message(CommandStart())
async def start(message: Message, dialog_manager: DialogManager):
    await dialog_manager.start(MainMenu.START)


async def main():
    bot = Bot(token=os.getenv("BOT_TOKEN"))
    dp = Dispatcher()
    dp.include_router(main_menu)
    dp.include_router(settings)
    dp.include_router(router)
    setup_dialogs(dp)

    await dp.start_polling(bot)


asyncio.run(main())

Заключение

Почему-то про разработку телеграмм-ботов в основном пишут статьи, рассчитанные на изучающих языки программирования, да и фреймворки почти не предлагают высокоуровневых подходов к реализации интерфейса бота. Между тем, тут можно провести параллели и с веб-разработкой и созданием мобильных приложений, перенимая концепции и паттерны (VIPER, HTTP Session, Widget, Back stack). Грамотно подходя к организации кода мы можем вложить свои силы в разработку бизнес-логики или проектирование UX вместо того, чтобы тратить их на очередное повторение реализации чекбокса в новом разделе.

Если вас заинтересовал проект, приглашаю ознакомиться с документацией и github проекта. Так же на гитхабе доступны примеры, показывающие как использовать те или иные возможности и разные виджеты.

Чем хороша библиотека:

  • готовые паттерны обновления сообщений с меню

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

  • возможность разделения реакции на события и логики отображения

  • возможность писать переиспользуемые меню

  • сокращение времени разработки

При этом вы можете совмещать код, написанный с использованием "диалогов", с обычным кодом на aiogram.

В данной статье отражена ничтожная часть возможностей, это введение показывающее подходы и готовую их реализацию. Так же упущены некоторые детали настройки в production-режиме.

К сожалению, вынужден признать, что даже в документации сейчас не описана часть доступной функциональности. Буду рад новым участникам проекта, который помогут решить эту проблему.

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


  1. bomzheg
    04.09.2023 14:36
    +1

    Использую aiogram-dioalog для довольно большого бота (26 диалогов, 58 окон, 25к строк). Очень удобно, наконец-то разделена обработка события и подготовка и отрисовка следующего меню. Да ещё и в комплекте куча батареек.

    Хочу рассказать про мой любимый виджет Jinja

    Типичная проблема - мы используем везде html-форматирование, тогда пользовательский ввод надо экранировать. Весь код превращается в кашу из смеси текстовых шаблонов с html.escape. А если вывод сложный, то там ещё и длинная вермишель из for и if.

    В случае использования Format в aiogram-dialog в геттере приходится заниматься склейкой каких-то строк в циклах, попутно не забывая экранировать.

    Вместо этого можно взять Jinja и использовать любимый шаблонизатор на полную: экранирование при подстановке, циклы, условия, jinja-фильтры, в том числе пользовательские.

    Живой пример из моего бота. Задача здесь - отрисовать уровни на каких сейчас находятся команды:

    Jinja(
        "{% for level_time in stat %}"
        "{% if level_time.is_finished %}"
        "????<b>{{ level_time.team.name }}</b> - финишировала в "
        "{% else %}"
        "????<b>{{ level_time.team.name }}</b> - уровень {{ level_time.level_number + 1 }} начат "
        "{% endif %}"
        "{{ level_time.start_at|user_timezone }}\n"
        "{% endfor %}",
    ),

    Получаем примерно такой вывод:

    ????another team - уровень 1 начат 13.05.23 00:05
    ????like a team - уровень 3 начат 13.05.23 01:01
    ????Майтим - уровень 3 начат 13.05.23 00:05
    ????Победители по жизни - финишировала в 13.05.23 05:06

    Удобно, что мы так полностью отдели логику представления от логики подготовки данных для представления. Всё то, чем пользуются классические web-приложения теперь доступно и при разработке ботов.


  1. Meamone
    04.09.2023 14:36

    Очень хороший фреймворк, уже продолжительное время использую, в особенности для больших ботов.
    С развитым функционалом, активным комьюнити и регулярными апдейтами.
    Если кого-то заинтересуют примеры его использования на крупных проектах, можете посмотреть:
    - orders_bot
    - cost_confirmation_bot
    - Shvatka


  1. mikegordan
    04.09.2023 14:36

    Я извиняюсь , а как это масштабируется ? Я правильно понимаю никак? Если 1 миллион пользователей одновременно нажмут одну кнопку , они все будут последовательно по очереди обрабатываться?


    1. Tishka17 Автор
      04.09.2023 14:36

      Ну тут вопрос для каких сценариев вы хотите это использовать. Очевидно, что если у вас группа на миллион участников и все могут нажать кнопку, то странно иметь сложное меню с состоянием и переходами - это будет затрагивать всех пользователей. Скорее всего тут будут более простые сценарии

      Если же мы говорим о небольших группах или личной переписке - тут более вероятны сложные менюшки. Но тут и нет проблем с масштабированием - разные чаты мы можем обрабатывать параллельно без каких либо проблем.