Telegram Боты на Aiogram 3.x: Магия фильтров

Привет, друзья!

Благодарю вас за позитивный отклик на мои статьи и за подписки — это бесценно. Продолжим.

К этому моменту мы с вами уже научились многому:

  • Запустили своего бота на aiogram 3

  • Поговорили про магические и встроенные фильтры

  • Научились профессионально писать текстовые клавиатуры и узнали все про специальные текстовые кнопки (статья про текстовые клавиатуры)

  • Разобрались с инлайн кнопками и CallData (статья про инлайн кнопки)

  • Научились создавать инлайн кнопки-ссылки и инлайн кнопки с CallData

  • Рассмотрели тему командного меню

  • Познакомились с моим видением каркаса бота

  • Научились писать хендлеры для текстовых сообщений и обработки CallData

  • Даже сделали так, чтобы наш бот имитировал набор текста, и многое другое

Казалось бы, что еще может быть интересного в aiogram 3? Но поверьте, интересного еще на множество статей. Здесь, на Хабре, я планирую научить каждого писать телеграмм-ботов на уровне профессионалов, чтоб вас не пугала никакая задача от клиента.

Как вы уже поняли из названия статьи, сегодня мы поговорим о фильтрах в aiogram 3. В этой статье мы обсудим:

  • Встроенные командные фильтры (освежим знания, так как уже обсуждали это)

  • Магические фильтры (касались этой темы неоднократно, но сегодня акцентируем на этом внимание и узнаем новые трюки)

  • Создание собственных фильтров через классы (если не знакомы с ООП не страшно — просто за мной повторяйте)

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

Фильтры в aiogram нужны для того, чтобы бот понимал, как реагировать на то или иное событие (действие, сообщение или тип сообщения). Приведу несколько примеров.

Фильтры по типу сообщения

На более высоком уровне мы указываем фильтры в декораторе перед функцией после роутера или диспетчера. Из наиболее часто используемых:

.message

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

.callback_query

Срабатывает на сообщения, содержащие callback дату (подробно обсуждали в прошлой статье).

.channel_post

Срабатывает на сообщения в канале, который администрирует бот (планирую написать отдельную статью или серию про бота на aiogram 3, который будет выступать в роли администратора канала).

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

Встроенные командные фильтры.

Мы уже рассматривали их, но сейчас освежим знания и я дам вам новую информацию по этой теме. Существует два встроенных командных фильтра: CommandStart и Command.

from aiogram.filters import CommandStart, Command

CommandStart реагирует на команду /start и записывается таким образом:

@start_router.message(CommandStart())

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

Command реагирует на команду, которую вы в него передали. Запись имеет такой вид:

@start_router.message(Command(«test»))

Это указывает боту, что данный хендлер должен реагировать на команду /test (пример работы с этим фильтром мы подробно рассматривали в другой статье).

Кроме одиночного аргумента, данный фильтр может принимать список команд, на которые должен реагировать бот. Это полезная функция в связке с CommandObject (рассматривали что это в другой статье).

Запись в таком случае будет выглядеть так:

@start_router.message(Command(commands=["settings", "config"]))

Давайте ещё немного освежим свои знания и напишем хендлер, который будет реагировать на метку в команде (подробный разбор в другой статье). Как вы помните, для этого нам нужно будет использовать CommandObject.

Импорт будет выглядеть так:

from aiogram.filters import CommandStart, Command, CommandObject

Пишем хендлер, который будет реагировать на команды /settings и /about:

@start_router.message(Command(commands=["settings", "about"]))
async def univers_cmd_handler(message: Message, command: CommandObject):
    command_args: str = command.args
    command_name = 'settings' if 'settings' in message.text else 'about'
    response = f'Была вызвана команда /{command_name}'
    if command_args:
        response += f' с меткой {command_args}'
    else:
        response += ' без метки'
    await message.answer(response)

Вот так мы изящно описали ситуацию, когда нужно обработать сразу несколько команд. Такое бывает полезно в админ панели. Например, если нужно быстро забанить пользователя (/ban user_id) или выполнить другую команду без прописывания большого сценария через FSM или без создания кнопок.

Давайте разберем код.

@start_router.message(Command(commands=["settings", "about"]))

Здесь мы прописали хендлер для сообщений, который использует фильтр Command. В него мы передали аргумент commands со списком команд, на которые должен реагировать хендлер.

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

command_args: str = command.args

Этой строкой мы достаем аргументы из команды (подробно тему разбирали в другой статье), а затем прописываем простое условие, на основании которого генерируем ответное сообщение:

command_name = 'settings' if 'settings' in message.text else 'about'
response = f'Была вызвана команда /{command_name}'
if command_args:
    response += f' с меткой {command_args}'
else:
    response += ' без метки'

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

Такой результат

Такой результат

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

Больше сказать по командным фильтрам особо нечего, а значит, можем переходить к магическим фильтрам.

Магические фильтры

Магические фильтры, как по мне, это самое глобальное и самое крутое обновление которое предложила своим пользователям библиотека aiogram 3 за долгое время. Грамотное использование этого нововведения позволяет разработчикам сокращать код, на фоне двойки, в полтора-два раза и это без преувеличений!

В прошлых статьях мы уже касались магических фильтров в таких реализациях:

  • F.text == "ПРИВЕТ” (тем самым мы говорили что бот должен реагировать на сообщение «ПРИВЕТ»)

  • F.data == "back_home” (тут мы говорили боту что он должен реагировать на CallData, которая равна «back_home»)

  • F.data.startswith('qst_') (тут мы говорили боту что он должен реагировать на CallData, которая начинается с 'qst_').

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

Магические фильтры отвечающие по типу контента.

Да, друзья, в тройке больше не работает запись content_type=ContentType.TEXT, к примеру, тут эта запись сократилась, тем самым облегчив жизнь разработчику.

Вот как теперь записывается фильтры на тип сообщения:

  • F.text — обычное текстовое сообщение (уже такое делали)

  • F.photo — cообщение с фото

  • F.video — сообщение с видео

  • F.animation — сообщение с анимацией (гифки)

  • F.contact — сообщение с отправкой контактных данных (очень полезно для FSM)

  • F.document — сообщение с файлом (тут может быть и фото, если оно отправлено документом)

  • F.data — сообщение с CallData (в прошлой статье такое обрабатывали).

Теме работы с разным типом сообщения я посвящу отдельную статью ещё. На данный момент вы должны понимать, что достаточно указать, например, F.video, чтоб бот понимал что сейчас ему нужно будет произвести какое-то действие с видео.

Важно отметить. Те кто писал на двойке знают, что раньше, по умолчанию, если мы ничего не указывали в качестве аргументов декоратора хендлеров, бот воспринимал этот хендлер, как тип контента — TEXT. В тройке же этот расклад изменился и теперь по умолчанию тип контента ANY, так что будьте осторожны.

С каждым типом сообщений можно прямо в декораторе прокручивать всякие трюки. Давайте рассмотрим трюки на примере F.text (для остальных типов сообщений они не будут особо отличаться, сейчас главное чтоб вы уловили общую идею).

F.text == 'Привет'

Текст сообщения равен 'Привет'

F.text != 'Пока!'

Текст сообщения не равен 'Пока!'

F.text.contains('Привет')

Текст сообщения содержит слово 'Привет'

F.text.lower().contains('привет')

Текст сообщения в малом регистре содержит слово 'привет'.

F.text.startswith('Привет')

Текст сообщения начинается со слова 'Привет'

F.text.endswith('дружище')

Текст сообщения заканчивается словом 'дружище'

~F.text
~F.text.startswith('spam')

Это означает инвертирование результата операции с помощью побитового отрицания ~.

~F.text означает, что фильтр F.text будет инвертирован, что приведет к тому, что он будет противоположен исходному результату. Например, если F.text возвращает True (истина), то ~F.text вернет False (ложь), и наоборот.

~F.text.startswith ('spam') означает, что результат операции F.text.startswith ('spam') будет инвертирован. Это означает, что если сообщение начинается с «spam», то результат будет True, и инвертированный результат, возвращенный ~, будет False, что означает, что сообщение не начинается с «spam». Если результат операции F.text.startswith ('spam') равен False (ложь), то инвертированный результат будет True, что означает, что сообщение начинается с «spam».

F.text.upper().in_({'ПРИВЕТ', 'ПОКА'})

F.text.upper().in_(['ПРИВЕТ', 'ПОКА'])

Текст равен одному из вариантов сообщений. Предварительно само сообщение перегоняем в верхний регистр. Можно для проверки использовать, как множества (это надежнее) или список.

F.chat.type.in_({"group", "supergroup"})
f.content_type.in_({'text', 'sticker', 'photo'})

Вот ещё несколько примеров этого же фильтра.

F.text.len() == 5

Длина текста равна 5.

Постарался максимально подробно объяснить. Давайте напишем простой хендлер, который будет реагировать на слово «подписывайся» в сообщении (на других примерах мы усилим этот хендлер).

@start_router.message(F.text.lower().contains('подписывайся'))
async def process_find_word(message: Message):
    await message.answer('В твоем сообщении было найдено слово "подписывайся", а у нас такое писать запрещено!')

Я установил малый регистр текста, а внутри уже провел проверку на содержание в тексте слова 'подписывайся', если запись выглядела бы так F.text.lower().contains('Подписывайся'), то условие никогда бы не было выполнено, ведь мы предварительно текст привели в малый регистр.

Такая запись позволила нам игнорировать регистр. Проверяем:

Все работает. Как вы понимаете использование магических фильтров достаточно удобное и можно быстро в эту тему вникнуть.

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

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

F.text.regexp(r'(?i)^Привет, .+')

В этой регулярке:

  • (? i) включает игнорирование регистра, то есть регулярное выражение будет искать «Привет» независимо от того, написано оно с заглавной или строчной буквы.

  • ^ обозначает начало строки. • Привет, ищет слово «Привет» и запятую после него.

  • .+ обозначает один или более любых символов после слова «Привет,».

А вот пример такого кода:

@start_router.message(F.text.regexp(r'(?i)^Привет, .+'))
async def process_find_reg(message: Message):
    await message.answer('И тебе здарова! Че нада?')

Тестируем:

79c55976489c05e081c6b49c33dbe9ea.jpg

Все работает, но, повторюсь. Если можно не использовать регулярки — не используйте!

А теперь давайте научимся использовать сразу несколько магических фильтров в одном хендлере. В этом нам помогут 2 оператора: & (аналог and из цикла If Else) и | (аналог or из цикла If Else). Кроме того каждое условие нужно брать в скобки. Вот примеры:

(F.from_user.id == 1245555) & (F.text == 'Хочу в админку!')

Тут мы сделали проверку, что айди пользователя равен 1245555 и что он ввел текст 'Хочу в админку!'.

F.text.startswith('Привет') | F.text.endswith('Пока')

Проверка на то, что сообщение начинается на «Привет» или заканчивается на «Пока».

(F.from_user.id.in_({42, 777, 911})) & (F.text.startswith('!') | F.text.startswith('/')) & F.text.contains('ban')

А это уже более сложный пример, но я уверен, что и с ним вы разберетесь. Тут мы сделали проверку на то находится ли айди пользователя в сете айдишников + сообщение начинается на ! или что сообщение начинается на »/» + текст содержит слово 'ban'.

Как вы видите — ограничение только в вашей фантазии, но бывают и такие ненасытные программисты, которым недостаточно магических фильтров. Специально для них aiogram 3 дал возможность делать собственные фильтры (да, я знаю что и в двойке это можно было делать, но там оно было настолько неудобно что это особо не юзали, ну я по крайней мере).

Пользовательские фильтры через классы

Если вы пишите бота по предложенной мною структуре из предидущих статей, то у вас так-же есть пакет filters. Давайте там создадим файл с именем is_admin.py и там пропишем фильтр, который будет проверять является ли пользователь администратором (да, я выше показал как сделать то же самое через магические фильтры, но это достаточно простой пример, поняв который вы поймете принципы генерации любых пользовательских фильтров).

Полный код фильтра:

from typing import List

from aiogram.filters import BaseFilter
from aiogram.types import Message


class IsAdmin(BaseFilter):
    def __init__(self, user_ids: int | List[int]) -> None:
        self.user_ids = user_ids

    async def __call__(self, message: Message) -> bool:
        if isinstance(self.user_ids, int):
            return message.from_user.id == self.user_ids
        return message.from_user.id in self.user_ids

Начнем разбор с импортов:

from typing import List
from aiogram.filters import BaseFilter
from aiogram.types import Message

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

Из библиотеки aiogram мы импортировали BaseFilter и Message, необходимые для создания пользовательского фильтра и работы с сообщениями.

Теперь перейдем к самому фильтру:

Фильтр, который мы создали, называется IsAdmin. Этот фильтр предназначен для проверки, является ли пользователь, отправивший сообщение, администратором.

Конструктор init

class IsAdmin(BaseFilter):
    def __init__(self, user_ids: int | List[int]) -> None:
        self.user_ids = user_ids

Конструктор init инициализирует объект класса IsAdmin и принимает один параметр user_ids. Этот параметр может быть либо целым числом (если у нас только один администратор), либо списком целых чисел (если у нас несколько администраторов). Мы сохраняем этот параметр в атрибуте self.user_ids.

Асинхронный метод call:

async def __call__(self, message: Message) -> bool:
        if isinstance(self.user_ids, int):
            return message.from_user.id == self.user_ids
        return message.from_user.id in self.user_ids

Метод call является обязательным для классов пользовательских фильтров. Этот метод вызывается каждый раз, когда необходимо применить фильтр к сообщению.

Проверка типа self.user_ids:

  • Если self.user_ids является целым числом (int), значит у нас один администратор. В этом случае мы просто проверяем, совпадает ли идентификатор пользователя, отправившего сообщение (message.from_user.id), с self.user_ids.

  • Если self.user_ids является списком (List[int]), значит у нас несколько администраторов. В этом случае мы проверяем, содержится ли идентификатор пользователя в этом списке.

Стоит отметить, что, будь то пользовательские фильтры или магические фильтры, их конечная цель всегда заключается в получении булевого значения True или False. Иными словами, написание класса фильтров или конструкции магического фильтра сводится к проверке условия: если условие истинно, действие выполняется; если нет — действие не выполняется.

Использование фильтра

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

from filters.is_admin import IsAdmin

@start_router.message(F.text.lower().contains('подписывайся'), IsAdmin(admins))
async def process_find_word(message: Message):
    await message.answer('О, админ, здарова! А тебе можно писать подписывайся.')


@start_router.message(F.text.lower().contains('подписывайся'))
async def process_find_word(message: Message):
    await message.answer('В твоем сообщении было найдено слово "подписывайся", а у нас такое писать запрещено!')

Объяснение кода

Импорт фильтра:

from filters.is_admin import IsAdmin

Мы импортируем наш пользовательский фильтр IsAdmin из пакета с фильтрами.

Обработчик для администраторов:

@start_router.message(F.text.lower().contains('подписывайся'), IsAdmin(admins))
async def process_find_word(message: Message):
    await message.answer('О, админ, здарова! А тебе можно писать "подписывайся".')

Здесь мы создаем обработчик, который реагирует на сообщения, содержащие слово «подписывайся». Этот обработчик будет срабатывать только в том случае, если сообщение отправлено администратором, чьи идентификаторы указаны в admins. Если условие выполняется, бот отвечает: «О, админ, здарова! А тебе можно писать «подписывайся».».

Обработчик для всех остальных пользователей:

@start_router.message(F.text.lower().contains('подписывайся'))
async def process_find_word(message: Message):
    await message.answer('В твоем сообщении было найдено слово "подписывайся", а у нас такое писать запрещено!')

Этот обработчик также реагирует на сообщения, содержащие слово «подписывайся». Но в отличие от предыдущего обработчика, он срабатывает для всех пользователей, кроме администраторов. Если обычный пользователь отправляет такое сообщение, бот отвечает: «В твоем сообщении было найдено слово 'подписывайся', а у нас такое писать запрещено!».

Порядок обработки

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

Почему это важно:

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

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

Напоминаю, что список администраторов у меня записан таким образом:

admins = [int(admin_id) for admin_id in config('ADMINS').split(',')]

Данные тянутся с файла .env и при помощи python-decouple вытягивает эту информацию с файла .env (сами данные в хендлер старт тянутся с файла create_bot).

from create_bot import admins

Заключение

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

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

Спасибо за вашу поддержку в виде лайков, подписок и приятных комментариев. Это мотивирует меня писать все новые статьи по теме aiogram 3. До скорого!

© Habrahabr.ru