Перейти к содержанию

Фильтрация и конечные автоматы

Всем привет! Нет, нет, нет, я не бросил работу над моим первым самостоятельным приложением – Telegram ботом по поиску вакансий для людей с инвалидностью в России. Более того работа над ботом подходит к концу. Осталось совсем немного, и буду переходить к контейнеризации и деплою.

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

Прежде чем начну, напоминаю, что код бота доступен в репозитории на GitHub. Если есть вопросы, критические замечания или предложения, пишите мне в Telegram, в Вконтакте или на почту (bks2408@mail.ru).

Содержание публикации:

Описание задачи

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

  • федеральный округ;
  • название региона;
  • наименование населенного пункта.

Как я уже писал ранее, федеральный округ и регион пользователь выбирает из списка, который представляет собой inline клавиатуры. Текст каждой кнопки – это название, а в качестве callback_data выступает номер округа или региона.

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

Кроме того,  при взаимодействии с ботом пользователю доступны ряд inline кнопок, которые делают работу с ботом интуитивно понятной и удобной. К таким кнопкам относятся кнопки:

  • «Подробнее» — отвечает за показ пользователю подробной информации о вакансии;
  • «Свернуть» — скрывает подробную информацию о вакансии;
  • «Добавить в избранное» — добавление понравившейся вакансии в избранное, сохранение данных о вакансии в БД;
  • «Удалить из избранного» — удаление вакансии из избранного, удаление записи о вакансии из БД.

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

Ну и в заключении этой части следует сказать, что есть и достаточно простые кейсы, которые требуют внимания – это перехват текстовых команд, вроде /start, /help, /cancel, /favorites, /Feedback. Отдельно на них я останавливаться не буду, тут все достаточно тривиально. Перехват этих команд осуществляется в хендлере с помощью класса Command.

Фильтрация

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

Aiogram имеет встроенные фильтры. К таким фильтрам относится, в частности, Commands, которые я использовал для перехвата текстовых команд, например, /help.

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

Если в качестве собственного фильтра используется класс, наследуемый от BaseFilter, то обязательно нужно переопределить его метод __call__().

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

Фильтр для федеральных округов

За перехват выбранного пользователем федерального округа в Work for everyone отвечает фильтр FederalDistrictFilter. Фильтр работает в два этапа.

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

Данный фильтр возвращает булево значение, то есть либо True, либо False.

class FederalDistrictFilter(BaseFilter):
    """Фильтр для перехвата номера федерального округа."""

    async def __call__(self, callback: CallbackQuery) -> bool:
        codes =  for code in ButtonData.federal_districts]
        if int(callback.data) in codes:
            return True
        return False

Фильтр для регионов

RegionFilter – отвечает за перехват региона, который выбрал пользователь.

Логика его работы такая. Для того чтобы успешно пройти фильтр, необходимо выполнить два условия:

  1. Номер полученного региона должен совпадать с номером регионов, которые записаны в соответствующей таблице в БД.
  2. Выбранный пользователем регион должен быть из того федерального округа, который пользователь выбрал на предыдущем шаге.

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

В своем боте я использую finite-state machine (FSM, конечный автомат) в ходе сбора данных, переключая состояния от одного к другому. Собственно, как пример использования FSM в документации к Aiogram указан случай диалога с пользователем в ходе сбора данных. 

Кроме хранения конкретного состояния FSM позволяет хранить и определенные данные в виде ключ – значение. Есть два варианта его использования: MemoryStorage и RedisStorage.

RedisStorage – это именно то, что нужно. Технология хранения данных, основанная на Redis, резидентной системе управления базами данных класса NoSQL. По сути, это своеобразная флешка, которая позволяет обеспечить временное сохранение данных пользователя. И только после того, как пользователь введет все необходимые данные и подтвердит, что они верны, я осуществляю их запись в БД.

Но и тут все просто не сработало. Первоначально я инициализировал соединение с Redis в конфигурационном файле в функции load_config(). Понятно, что в такой конфигурации из модуля Filters до хранилища мне не достучаться. Решение пришло быстро. Инициализацию соединения с Redis я перенес в модуль, отвечающий за работу с базой данных. Теперь, хранилище мне доступно из любого модуля.

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

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

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

class RegionFilter(BaseFilter):
    """
    Фильтр для перехвата номеров регионов
    в выбранном федеральном округе.
    """

    async def __call__(
        self, callback: CallbackQuery
    ) -> Union[bool, dict[str, str]]:
        data_redis = await redis.get(
            f'fsm:{str(callback.from_user.id)}:'
            f'{str(callback.from_user.id)}:data'
        )
        fd_code = json.loads(data_redis).get('fd_code')
        regions = get_data_regions(fd_code)
        for region in regions:
            if int(callback.data) in region:
                return {'region_name': region[0]}
        return False

Фильтр для населенного пункта

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

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

Фильтр начинается с проверки, не ввел ли пользователь в качестве названия населенного пункта город Москву или Санкт-Петербург. Данные города являются городами федерального значения. По сути, Москва и Питер – это регионы. Для них хендлер работает иначе. Если все же пользователь введет эти два города, то фильтр не будет пройден.

Идем дальше. Населенные пункты в России могут состоять из двух слов, а также в названии некоторых локаций может быть использован союз, а также дефис. Например, Нижний Новгород, Ростов-на-Дону.

А раз есть возможность использования в названии дефиса, то и разбивать методом split() введенное пользователем название нужно по-разному. В одном случае, разделяем строку по дефису, в другом – по пробелу. Кроме того, в зависимости от того, есть дефис или нет, устанавливаем соответствующее значение переменной (флагу) hyphen (либо True, либо False).

На следующем шаге идет блок if с основными условиями проверки. Что проверяю:

  • длину списка, который был получен в результате работы метода split(). Она не должна превышать 3 элементов. Вероятность того, что в названии населенного пункта может быть использовано больше 3 слов, крайне мала.
  • строковыми методами isspace() и isalpha() проверяю, что в названии населенного пункта нет цифр и других специальных символов. Полученный текст должен состоять только из пробелов и букв.
  • в заключении, с помощью регулярного выражения делаю проверку, что при вводе наименования населенного пункта пользователь использовал только кириллицу.

Если введенное пользователем название будет соответствовать всем перечисленным выше условиям, то фильтр будет считаться пройденным.

Фильтр возвращает в хендлер наименование населенного пункта. Методом join() обратно генерируем строку. Если в названии был дефис, благодаря ранее установленному флагу, в возвращаемом названии дефис также будет. И еще, пользователь мог ввести наименование населенного пункта с маленькой буквы, поэтому исправим это методом capitalize().

Вот и все. Возможно, немного мудрено, но работает!

class NameLocalityFilter(BaseFilter):
    """
    Фильтр для обработки названия ннаселенного пункта,
    введенного пользователем.
    """

    async def __call__(self, message: Message) -> Union[bool, dict[str, str]]:
        if message.text.lower() in ['москва', 'санкт-Петербург']:
            return False
        if '-' in message.text:
            split_message = message.text.split('-')
            hyphen = True
        else:
            split_message = message.text.split()
            hyphen = False
        if (
            len(split_message) <= 3
            and all(
                symbol.isspace() or symbol.isalpha()
                for symbol in (part_name for part_name in split_message)
            )
            and re.search(r'^[А-Яа-яЁё\s\-]+\Z', message.text)
        ):
            if hyphen:
                return {
                    'locality_name': '-'.join(
                        el_str.capitalize() for el_str in split_message
                    ),
                }
            return {
                'locality_name': ' '.join(
                    el_str.capitalize() for el_str in split_message
                ),
            }
        return False

Фильтры для кнопок «Подробнее» и «Свернуть»

Два фильтра, задача которых перехватить нажатие кнопок «Подробнее» и «Свернуть», очень похожи, поэтому рассмотрим лишь один из них.

Inline кнопка «Подробнее» отвечает за показ пользователю подробной информации о вакансии. В качестве callback_data для этой кнопки используется id вакансии и текст details. Например:

539a3f15-8dc7-11ee-b068-cb26dff57dd7_details

Идентификатор вакансии нужен, чтобы знать, о какой вакансии были запрошены подробные сведения.

Тут стоит остановиться вот на каком моменте. Кнопки «Подробнее» и «Свернуть» доступны в двух режимах. Первый режим – режим показа вакансий при их поиске. Второй режим – показ вакансий в разделе «Избранное». Фильтр должен обрабатывать оба варианта. Для этого при работе бота  в режиме «Избранное» callback_data inline кнопки формируется немного по-другому. Взамен текста details используется текст details.fav. Пример:

539a3f15-8dc7-11ee-b068-cb26dff57dd7_details.fav

Первая проверка в фильтре DetailsFilter заключается в том, что я проверяю, есть ли в полученном callback_data текст details или details.fav. Если такого текста нет, но фильтр не проходит. Если встречается то или другое, значит, идем дальше.

Дальше смотрим, из какого режима были нажаты соответствующие кнопки. Для этого в самом начале фильтра с помощью метода split() callback_data был разделен по знаку «_» на список из двух элементов. Элемент с индексом 0 – это id вакансии, а элемент с индексом 1 – указание на режим, в котором находится пользователь.

Если кнопки нажаты пользователем из режима просмотра вакансий при поиске, то фильтр:

  • переменной mode присваивает значение details;
  • получает из списка по индексу id вакансии;
  • делает запрос к БД (таблица вакансий, найденных для пользователя).

Если пользователь обратился к нам из режима «Избранное», то фильтр делает аналогичные операции, за исключением того, что в качестве режима устанавливается режим details.fav, а данные о вакансии запрашиваются из таблицы, которая отвечает за сохранение вакансий, добавленных пользователями в «Избранное».

Фильтр передает в хендлер словарь, в котором есть указание на режим, в котором находится пользователь, и словарь с подробными данными о вакансии.

class DetailsFilter(BaseFilter):
    """
    Фильтр для перехвата нажатия кнопки 'Подробнее'.
    """

    async def __call__(self, callback: CallbackQuery) -> Union[bool, dict]:
        data = callback.data.split('_')
        if not list(set(data) & set(['details', 'details.fav'])):
            return False
        if data[1] == 'details':
            mode = 'details'
            vacancy_id = data[0]
            vacancy = get_vacancy(vacancy_id, callback.from_user.id)
        elif data[1] == 'details.fav':
            mode = 'details.fav'
            vacancy_id = data[0]
            vacancy = get_vacancy_from_favorites(
                vacancy_id, callback.from_user.id
            )
        if not vacancy:
            return False
        return {'vacancy': vacancy.__dict__.get('__data__'), 'mode': mode}

Аналогичным образом работает фильтр, который отвечает за обработку inline кнопки «Свернуть». Только вместо details и details.fav используется collapse и collapse.fav соответственно.

Фильтр для пагинации

В соответствии с ТЗ для Telegram bot Work for everyone, если найденных вакансий в указанной пользователем локации больше 10, то вакансии показываются по 10 за один раз, как это выглядело бы на странице сайта. Согласитесь, что показать пользователю сразу 200 вакансий было бы неразумно.

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

Как это работает показываю на скриншотах ниже.

Демонстрация работы кнопок пагинации

Как и остальные кнопки в боте, кнопки, отвечающие за пагинацию, являются inline кнопками. Как показала практика, inline кнопки в отличии от «стандартных» предоставляют больше возможностей в реализации различных идей, направленных на создание интуитивно понятного интерфейса взаимодействия пользователя с Telegram bot.

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

def generating_pagination_kb(
    page_number: int, count_pages: int
) -> KeyboardBuilder[InlineKeyboardButton]:
    """
    Клавиатура для перемещения между страницами с вакансиями.
    """
    if page_number == 1:
        return InlineKeyboardBuilder().row(
            InlineKeyboardButton(
                text='Вперёд',
                callback_data=f'next_{page_number + 1}',
            )
        )

    if page_number == count_pages:
        return InlineKeyboardBuilder().row(
            InlineKeyboardButton(
                text='Назад',
                callback_data=f'back_{page_number - 1}',
            ),
            InlineKeyboardButton(
                text='Перейти в избранное',
                callback_data='favorites',
            ),
            InlineKeyboardButton(
                text='Ввести данные заново',
                callback_data='re_enter_data',
            ),
            width=2,
        )
    return InlineKeyboardBuilder().row(
        InlineKeyboardButton(
            text='Назад',
            callback_data=f'back_{page_number - 1}',
        ),
        InlineKeyboardButton(
            text='Вперёд',
            callback_data=f'next_{page_number + 1}',
        ),
        InlineKeyboardButton(
            text='Перейти в избранное',
            callback_data='favorites',
        ),
        width=2,
    )

Функция generating_pagination_kb() принимает на вход номер страницы и общее количество страниц, рассчитанное исходя из количества найденных вакансий.

Задача функции в зависимости от номера страницы (первая, промежуточная, последняя) сгенерировать три варианта inline клавиатур:

  • с одной кнопкой «Вперед»;
  • с тремя кнопками «Назад», «Вперед» и «Перейти в избранное»;
  • с тремя кнопками «Назад», «Перейти в избранное» и «Ввести данные заново».

Итак, если функция получила на вход page_number со значением 1, то есть первая страница, то мы должны показать клавиатуру с одной кнопкой «Вперед». callback_data для такой кнопки будет:

f'next_{page_number + 1}'

Если функция получает page_number, равный count_pages, то есть речь идет о последней странице списка найденных вакансий, то callback_data для кнопки «Назад» будет формироваться вот так:

f'back_{page_number - 1}'

Логично, что для страниц, которые находятся между первой и последней, формирование кнопок «Назад» и «Вперед» будет осуществляться по таким же правилам. Если кнопка должна показать пользователю следующую страницу, то значение page_number увеличивается на единицу в пределах максимального количества страниц. Если пользователь захотел вернуться назад, значение page_number уменьшается на единицу, что соответствует номеру предшествующей страницы.

А теперь о том, что касается работы фильтра ShowManyVacanciesFilter, который отвечает за перехват нажатия кнопок пагинации. На первом этапе после разделения полученной в качестве callback строки на отдельные элементы в списке с помощью метода split() с параметром ‘_’ я проверяю, есть ли в таком списке next, back. Если есть, то мы на верном пути и работа фильтра продолжается.

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

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


class ShowManyVacanciesFilterr(BaseFilter):
    """
    Фильтр для перехвата нажатия кнопок пагинации'.
    """

    async def __call__(
        self, callback: CallbackQuery
    ) -> Union[bool, dict[str, int]]:
        data = callback.data.split('_')
        if not list(set(data) & set(['many', 'next', 'back'])):
            return False

        data_redis = await redis.get(
            f'fsm:{str(callback.from_user.id)}:'
            f'{str(callback.from_user.id)}:data'
        )
        count_pages = ceil(json.loads(data_redis).get('count_vacancies') / 10)
        page_number = int(data[-1])
        if 1 > page_number > count_pages:
            return False
        return {'page_number': page_number}

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

Про конечные автоматы

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

Особо долго я тут останавливаться не буду, опишу только те состояния, которые я использую в процессе работы бота.

  • federal_district_choice – пользователь должен выбрать федеральный округ;
  • region_name_choice пользователь выбирает регион;
  • local_name_input — бот ожидает от пользователя ввод наименования населенного пункта;
  • verification_data – пользователь должен проверить корректность введенных данных;
  • show_vacancies_mode – режим показа пользователю найденных вакансий.

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

Заключение

Вот и все на сегодня. Статья получилась длинной, но лучше так, как говориться, все в одном.

Если есть, что сказать по фильтрам и в целом по коду бота, пишите мне в Telegram, в Вконтакте или на почту (bks2408@mail.ru).

Код бота в репозитории на GitHub.