Введение
Однажды меня попросили провести ревью и рефакторинг одного 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):
...
Концепции
В примерах выше мы пришли к следующим концепциям, помогающим структурировать переходы между меню бота:
Разделение реакции на события и генерации сообщения
Хранение состояний в виде стека
Изолированные контексты для разных частей UI
Центральный менеджер управляющий переходами состояния
Переиспользуемые компоненты ("виджеты"), группирующиеся в "окна" описывающие внешний вид сообщения
Все эти концепции уже реализованы в aiogram_dialog
Создание бота на aiogram-dialog
Установка
Устанавливаем стандартным для Python способом, например с помощью pip.
pip install aiogram-dialog==2.*
Окна и виджеты
Выше мы предполагали, что для описания внешнего вида сообщения будет использоваться набор виджетов, скомпонованный в один объект. Первое сообщение может состоять из таких частей:
Динамический текст, куда будут подставлены данные из текущего контекста или события.
from aiogram_dialog.widgets.text import Format text = Format( "Hello, {event.from_user.username}. \n\n" "Extended mode is {extended_str}.\n" )
Фиксированный текст, который показывается только если включен расширенный режим. Для управления видимостью у виджетов есть атрибут
when
, через который задается название опции влияющей на отображение.from aiogram_dialog.widgets.text import Const additional = Const( "Here is some additional text, which is visible only in extended mode", when="extended", )
Чекбокс, который меняет текст по клику. Оба варианта текста фиксированы.
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, )
Кнопка перехода в настройки, которая пока ничего не делает.
from aiogram_dialog.widgets.text import Const from aiogram_dialog.widgets.kbd import Button button_next = Button(Const("Settings"), id="settings")
Для того чтобы чекбокс и кнопка перехода в настройки находились в одном ряду клавиатуры, добавим 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-режиме.
К сожалению, вынужден признать, что даже в документации сейчас не описана часть доступной функциональности. Буду рад новым участникам проекта, который помогут решить эту проблему.