Однажды меня попросили провести ревью и рефакторинг одного telegram-бота. Увидев файл размером 2000 строк, рассчитанный только на обработку разных меню я понял, что это требует унификации и общих подходов. Так родилась библиотека aiogram-dialog. В этой статье я бы хотел обратить внимание на некоторые проблемы, которые мы встречаем при создании таких меню, предложить варианты их решения. А во второй половине статьи показать как это решается с помощью aiogram-dialog. Мы не будем рассматривать архитектуру всего приложения, об этом вы можете прочитать у Фаулера или Мартина. Мы поговорим только про определенную часть UI ботов. Так же это не будет введением в разработку telegram-ботов с нуля. Я предполагаю, что читатель знаком с питоном, ООП и слышал о такой вещи как DRY. В коде примеров я использую aiogram v3.0 и надеюсь, что читатель уже использовал встроенную в библиотеку машину состояний. Примеры выбраны так, чтобы проще было показать определенные проблемы, но это не единственные сценарии приводящие к ним. Давайте рассмотрим небольшого бота, взаимодействующего с пользователем через сообщение с inline-клавиатурой. Пусть сообщение содержит имя пользователя, а клавиатура содержит кнопку, включающую расширенный режим. При нажатии на кнопку с галочкой мы будем обновлять сообщение, а не посылать новое, скрывая или показывая в нем дополнительный текст. Так же заложим на будущее кнопку вызова настроек. Для реализации такого бота нам пока потребуется 3 обработчика событий телеграм: событие для команды /start, отправляющее сообщение обработчик устанавливающий галочку обработчик снимающий галочку Пример кода: Проблема 1: В обработчике разных событий есть одинаковый код генерации текста и клавиатуры. При добавлении новых кнопок или переходов между меню он снова будет дублироваться Решение проблемы 1: Необходимо отделить код генерации представления и код обработки действий. То есть вынести формирование текста и клавиатуры в отдельные функции, которые мы будем везде вызывать. Пример кода: Добавим к обработке нажатий возможность обработки входящих сообщений. В ответ мы хотим посылать новое сообщение, содержащие актуальную информацию, не теряя при этом состояния чекбокса. В старом сообщении же мы будем скрывать клавиатуру, чтобы не иметь много похожих кнопок, на все из которых юзер может попытаться нажать. Проблема 2: При входящем сообщении мы не знаем какое старое сообщение редактировать, как это было в CallbackQuery При входящем сообщении мы не знаем состояние чекбокса в старом сообщении Решение проблемы 2: Необходимо запоминать где-то состояние чата: нажата ли галочка и id последнего сообщения. Пример кода: Добавим к боту вторую клавиатуру, которая появляется по нажатию кнопки "Settings". Пусть это будет экран настроек, содержащий ещё пару чекбоксов и кнопки "сохранить" и "отменить". По Нажатию "сохранить" мы сохраняем настройки в БД и возвращаемся в прошлое меню. А по нажатию "отменить" тоже возвращаемся, но без сохранения. Проблема 3: Обработчик сообщения не знает что мы перешли в настройки и отправляет нам меню 1. Решение проблемы 3: Необходимо запоминать в каком меню мы находимся по аналогии с другими данными чата. Можно использоваться для этого State из aiogram Проблема 4: В разных меню могут быть похожие по смыслу данные, необходимо следить, чтобы они не перетирали друг друга. Необходимо удалять временные данные настроек при выходе, так как потом они будут сброшены. При этом другие данные не должны быть затронуты. Проблема кажется несущественной пока это касается только одного меню с небольшим количеством данных - мы всегда можем перечислить их ключ. Но подход будет повторяться Решение проблемы 4: Заведем для каждого меню (то есть состояния или группы состояний) зафиксируем ключ, под которым его данные будут храниться в общем словаре data. При выходе из меню мы можем удалять целиком ключ То есть вместо Мы сделаем Проблема 5: Повторяющиеся паттерны обработки. В разных частях программы могут повторяться чекбоксы, кнопки выбора из нескольких вариантов, календарь, переходы вперед/назад. Приходится дублировать хэндлеры, делающие одну и ту же работу: генерация кнопок сохранение своего состояния вызов показа нового текста и клавиатуры после нажатия Решение проблемы 5: выносим каждый паттерн в отдельный класс добавляем ему id для генерации callback_data и хранения данных параметризуем экземпляр колбэк-функциями для вызова прикладной логики, не относящейся к обновлению меню Например, класс Checkbox может выглядеть так: Имея набор таких виджетов (примитивов над клавиатурой, обработкой ввода или генерацией текста), логичным становится объединение их в один объект, описывающий состояние сообщения, который надо показать (далее Окно). Проблема 6: В коде имеющем несколько меню возможны переходы по нескольким направлениям. Часто необходимо реализовать переход назад или в главное меню. В некоторые меню можно попасть разными способами и переход назад должен возвращать пользователя правильно Для отрисовки главного меню требуется его импортировать в другие меню, и наоборот из него мы косвенно импортируем их. Возможны циклические импорты Решение проблемы 6: Заводим стек состояний. При переходе в новое меню мы не просто сохраняем его стейт, а добавляем его в стек. Заводим отдельный класс менеджер, следящий за состоянием стека и вызывающий отрисовку исходя из текущего состояния, очистку при возврате в главное меню Все переходы делается по State, а конкретные объекты окон привязываются к стейтам и регистрируются в менеджере. Примерный вид класса менеджера стека: В примерах выше мы пришли к следующим концепциям, помогающим структурировать переходы между меню бота: Разделение реакции на события и генерации сообщения Хранение состояний в виде стека Изолированные контексты для разных частей UI Центральный менеджер управляющий переходами состояния Переиспользуемые компоненты ("виджеты"), группирующиеся в "окна" описывающие внешний вид сообщения Все эти концепции уже реализованы в aiogram_dialog Устанавливаем стандартным для Python способом, например с помощью pip. Выше мы предполагали, что для описания внешнего вида сообщения будет использоваться набор виджетов, скомпонованный в один объект. Первое сообщение может состоять из таких частей: Динамический текст, куда будут подставлены данные из текущего контекста или события. Фиксированный текст, который показывается только если включен расширенный режим. Для управления видимостью у виджетов есть атрибут when, через который задается название опции влияющей на отображение. Чекбокс, который меняет текст по клику. Оба варианта текста фиксированы. id виджета используется одновременно для формирования callback_data, хранения данных в контексте и поиска виджета в обработчиках. Кнопка перехода в настройки, которая пока ничего не делает. Для того чтобы чекбокс и кнопка перехода в настройки находились в одном ряду клавиатуры, добавим Row-виджет. В библиотеке предусмотрены также другие варианты расположения: в колонку, по несколько штук в ряд или с пагинацией при превышении определенного числа. Теперь объединим это всё одно окно. Так же нам потребуется создать State, для того чтобы мы могли переключиться на это окно позднее. Библиотека требует чтобы все стейты были созданы в StatesGroup, таким образом мы достигаем большей гибкости в изоляции контекстов. В процессе рендеринга данного окна туда в дальнейшем будет передан текущий контекст, откуда Checkbox прочитает своё состояние и сможет выбрать какой из двух вариантов текста использовать. Так же будет использовано текущее обрабатываемое событие (Message или CallbackQuery) чтобы подставить имя пользователя. Однако мы не указали пока что подставить в качестве {check} в текст. Для внедрения дополнительных данных в процессе рендеринга сообщения в окно добавляется функция-getter. Она возвращает словарь, к которому в дальнейшем можно обращаться из Format, использовать внутри виджетов, влиять на их видимость и т.п. В частности, через него делается создание кнопок для выбора из списка. В данном случае функция-геттер должна вернуть по ключу extended_str строку "on" если галочка снята и "off" в противном случае. А по ключу extended - само булево значение опции. В качестве параметров она получает всё, что прилетает из middleware. Пока проигнорируем это, поставив **kwargs. Чтобы избежать конфликтов и автоматически очищать данные, aiogram-dialog ограничивает работу с контекстом не одним State, а одной StatesGroup. Таким образом мы можем иметь более одного окна с общим контекстом, что упрощает реализацию некоторых сценариев. Так же как стейты объединяются в StatesGroup, окна объединяются в объект Dialog. Если быть более точным, новый контекст создается каждый раз, когда вы добавляете что-то в стек переходов, но вы может сохранять контекст меняя текущий State в стеке в пределах одной StatesGroup. Для управления переходами и контекстом, используется класс DialogManager. Он прилетает в getter и во все обработчики, обычно под именем dialog_manager. Модифицируем наш геттер так, чтобы он выбирал текст исходя из состояния чекбокса. С dialog_manager мы найдем состояние виджета по его айди и проверим, есть ли там галочка. Прежде чем запускать бота нам необходимо реализовать переход к нашему диалогу. Делается это с помощью DialogManager.start Остался последний подготовительный подключить конкретные диалоги к боту и настроить сам Dispatcher на работу с библиотекой: Таким образом, целиком всё будет выглядеть так: Второй диалог добавляется полностью аналогично первому. Мы воспользуемся новым виджетом Cancel(), который возвращает нас в предыдущее меню (с очисткой контекста, естественно). Переход ко второму диалогу из первого мы можем организовать так же вызвав dialog_manager.start из обработчика кнопки next, либо заменить её на специальный виджет Целиком код: Почему-то про разработку телеграмм-ботов в основном пишут статьи, рассчитанные на изучающих языки программирования, да и фреймворки почти не предлагают высокоуровневых подходов к реализации интерфейса бота. Между тем, тут можно провести параллели и с веб-разработкой и созданием мобильных приложений, перенимая концепции и паттерны (VIPER, HTTP Session, Widget, Back stack). Грамотно подходя к организации кода мы можем вложить свои силы в разработку бизнес-логики или проектирование UX вместо того, чтобы тратить их на очередное повторение реализации чекбокса в новом разделе. Если вас заинтересовал проект, приглашаю ознакомиться с документацией и github проекта. Так же на гитхабе доступны примеры, показывающие как использовать те или иные возможности и разные виджеты. Чем хороша библиотека: готовые паттерны обновления сообщений с меню готовые заменяемые виджеты для различных моделей поведения: чекбоксы, радио кнопки, календарь, пагинаторы, форматирование текста и многие другие возможность разделения реакции на события и логики отображения возможность писать переиспользуемые меню сокращение времени разработки При этом вы можете совмещать код, написанный с использованием "диалогов", с обычным кодом на aiogram. В данной статье отражена ничтожная часть возможностей, это введение показывающее подходы и готовую их реализацию. Так же упущены некоторые детали настройки в production-режиме. К сожалению, вынужден признать, что даже в документации сейчас не описана часть доступной функциональности. Буду рад новым участникам проекта, который помогут решить эту проблему.