Telegram Bots on Aiogram 3.x: The magic of filters

  • Built-in command filters (let's refresh our knowledge, since we already discussed this)

  • Magic filters (we’ve touched on this topic more than once, but today we’ll focus on this and learn new tricks)

  • Creating your own filters through classes (if you are not familiar with OOP, it’s okay – just repeat after me)

Before we start writing, let's figure out what filters are in aiogram 3 and why they are needed (in simple words and without scientific tediousness).

Filters in aiogram are needed so that the bot understands how to react to a particular event (action, message or message type). Let me give you a few examples.

Filters by message type

At a higher level, we specify filters in the decorator before the function after the router or dispatcher. Among the most frequently used:

.message

Triggers on messages in a personal chat with a bot or in groups (will not work in channels). Here text, photo, video messages and messages with documents are processed (we will discuss in detail below).

.callback_query

Triggers on messages containing a callback date (discussed in detail in the previous article).

.channel_post

Triggers on messages in a channel administered by a bot (I plan to write a separate article or series about the bot on aiogram 3, which will act as a channel administrator).

Let's continue studying filters and their applications to make your bots even more functional and flexible in their work.

Built-in command filters.

We have already looked at them, but now let’s refresh our knowledge and I will give you new information on this topic. There are two built-in command filters: CommandStart and Command.

from aiogram.filters import CommandStart, Command

CommandStart responds to the /start command and is written like this:

@start_router.message(CommandStart())

As you can see, the /start command is not explicitly specified here, but the bot will still respond to this command. There's not much else to say about this filter.

Command responds to the command you passed to it. The entry looks like this:

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

This indicates to the bot that this handler should respond to the /test command (we discussed an example of working with this filter in detail in another article).

In addition to a single argument, this filter can accept a list of commands that the bot should respond to. This is a useful feature in conjunction with CommandObject (We discussed what this is in another article).

The entry in this case will look like this:

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

Let's refresh our knowledge a little more and write a handler that will respond to a tag in a command (detailed analysis in another article). As you remember, for this we will need to use CommandObject.

The import will look like this:

from aiogram.filters import CommandStart, Command, CommandObject

We are writing a handler that will respond to the /settings and /about commands:

@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' с меткой <b>{command_args}</b>'
    else:
        response += ' без метки'
    await message.answer(response)

This is how we elegantly described the situation when you need to process several commands at once. This can be useful in the admin panel. For example, if you need to quickly ban a user (/ban user_id) or execute another command without writing a large script through FSM or without creating buttons.

Let's look at the code.

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

Here we have written a message handler that uses a filter Command. We passed the argument to it commands with a list of commands to which the handler must respond.

Next, we configured our function, indicating that it should process the message and that there will be arguments (at a minimum, it will try to receive them, otherwise it will assign the value None).

command_args: str = command.args

With this line we extract the arguments from the command (the topic was discussed in detail in another article), and then we write a simple condition on the basis of which we generate a response message:

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

We format the message in a simple way (we make the tag tag bold if there is one) and send it to the user.

This result

This result

It may not look very nice, but the implementation is quite functional and sometimes it is very convenient to use this approach. Of course, it’s better for the customer to draw the keyboards, but we, as experienced developers, understand that the main thing is the ease of calling the command, right?

There’s not much more to say about command filters, which means we can move on to magic filters.

Magic filters

Magic filters, as for me, are the most global and coolest update that the aiogram 3 library has offered its users in a long time. Proper use of this innovation allows developers to reduce the code by one and a half to two times, and this is without exaggeration!

In previous articles we have already touched on magic filters in the following implementations:

  • F.text == “ПРИВЕТ” (thus we said that the bot should respond to the message “HELLO”)

  • F.data == “back_home” (here we told the bot that it should respond to CallData, which is equal to “back_home”)

  • F.data.startswith('qst_') (here we told the bot that it should respond to CallData, which begins with 'qst_').

As you understand, this is just the tip of the iceberg, and the magic filters themselves are capable of much more, and further, using practical examples, we will look at all the capabilities of magic filters, after which you will understand that these filters did not receive such a name by chance.

Magic filters based on content type.

Yes, friends, the recording in the troika no longer works content_type=ContentType.TEXT, for example, here this entry has been shortened, thereby making life easier for the developer.

This is how filters are now written for a message type:

  • F.text – regular text message (this has already been done)

  • F.photo – message with photo

  • F.video – message with video

  • F.animation – message with animation (gifs)

  • F.contact – message sending contact details (very useful for FSM)

  • F.document – a message with a file (there may also be a photo if it is sent as a document)

  • F.data – message with CallData (this was processed in the previous article).

I will devote a separate article to the topic of working with different types of messages. At this point, you should understand that it is enough to indicate, for example, F.video, so that the bot understands that now it will need to perform some action with the video.

It is important to note. Those who wrote in two know that previously, by default, if we did not specify anything as arguments to the handler decorator, the bot perceived this handler as a content type – TEXT. In the three, this situation has changed and now the default content type is ANY, so be careful.

You can perform all sorts of tricks with each type of message directly in the decorator. Let's look at the tricks using F.text as an example (for other types of messages they will not be much different, now the main thing is that you get the general idea).

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

Message text is 'Hello'

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

The message text is not equal to 'Bye!'

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

The message text contains the word 'Hello'

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

The message text contains the word 'hello' in small case.

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

The text of the message begins with the word 'Hello'

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

The text of the message ends with the word 'buddy'

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

This means inverting the result of the operation using bitwise negation ~.

~F.text means that the F.text filter will be inverted, causing it to be the opposite of the original result. For example, if F.text returns True, then ~F.text will return False, and vice versa.

~F.text.startswith('spam') means that the result of the F.text.startswith('spam') operation will be inverted. This means that if the message starts with “spam”, then the result will be True, and the inverted result returned by ~ will be False, which means the message does not start with “spam”. If the result of F.text.startswith('spam') is False, then the inverted result will be True, which means the message starts with “spam”.

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

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

The text is equal to one of the message options. First, we convert the message itself to upper case. You can use it for checking as sets (this is more reliable) or a list.

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

Here are some more examples of the same filter.

F.text.len() == 5

The text length is 5.

I tried to explain in as much detail as possible. Let's write a simple handler that will respond to the word “subscribe” in a message (we will strengthen this handler in other examples).

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

I set the text to small case, and inside I already checked for the content of the word 'subscribe' in the text, if the entry would look like this F.text.lower().contains('Подписывайся')then the condition would never have been met, because we had previously converted the text to small register.

This notation allowed us to ignore case. We check:

Everything is working. As you understand, using magic filters is quite convenient and you can quickly delve into this topic.

It is possible to process regular expressions in these filters, but knowing the story about a developer who wanted to solve a problem using regular expressions and then got two problems, I try not to use regular expressions in bots, but I will still show how it works in the context of magic filters.

Let's consider a case where we want to check if a message starts with the Russian word “Hello” (case sensitive) and then contains any text. Here's how to do it:

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

In this regular:

  • (?i) enables case-ignoring, meaning the regular expression will match “Hello” regardless of whether it is capitalized or lowercase.

  • ^ denotes the beginning of a line. • Hello, looks for the word “Hello” and the comma after it.

  • .+ represents one or more of any characters after the word “Hello.”

And here is an example of such code:

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

Testing:

Everything works, but, I repeat. If you can avoid using regular expressions – do not use!

Now let's learn how to use several magic filters at once in one handler. Two operators will help us with this: & (analogous to and from the If Else loop) and | (analogous to or from the If Else loop). In addition, each condition must be placed in brackets. Here are examples:

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

Here we checked that the user ID is 1245555 and that he entered the text 'I want to join the admin panel!'.

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

Checks that the message begins with “Hello” or ends with “Bye”.

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

And this is a more complex example, but I’m sure you can figure it out. Here we checked whether the user's ID is in the ID set + the message begins with ! or that the message begins with “/” + the text contains the word 'ban'.

As you can see, the limit is only in your imagination, but there are also insatiable programmers for whom magical filters are not enough. Especially for them, aiogram 3 made it possible to make their own filters (yes, I know that this could be done in two, but there it was so inconvenient that they didn’t really use it, well, at least I did).

Custom filters via classes

If you are writing a bot according to the structure I proposed from previous articles, then you also have a filters package. Let's create a file there named is_admin.py and write a filter there that will check whether the user is an administrator (yes, I showed above how to do the same through magic filters, but this is a fairly simple example, understanding which you will understand the principles of generating any user filters).

Full filter code:

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

Let's start the analysis with imports:

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

From the typing library we imported List to correctly indicate that we can pass either a list or a single admin ID.

From the aiogram library we imported BaseFilter and Message, which are necessary to create a custom filter and work with messages.

Now let's move on to the filter itself:

The filter we created is called IsAdmin. This filter is designed to check whether the user who sent the message is an administrator.

Constructor init

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

Constructor init initializes an object of the IsAdmin class and accepts one user_ids parameter. This parameter can be either an integer (if we have only one administrator) or a list of integers (if we have multiple administrators). We store this parameter in the self.user_ids attribute.

Asynchronous method 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

Method call is required for custom filter classes. This method is called every time a filter needs to be applied to a message.

Self.user_ids type check:

  • If self.user_ids is an integer, then we have one administrator. In this case, we simply check if the id of the user who sent the message (message.from_user.id) matches self.user_ids.

  • If self.user_ids is a list (List[int]), which means we have several administrators. In this case, we check if the user ID is contained in this list.

It's worth noting that, whether custom filters or magic filters, their end goal is always to produce a boolean value, True or False. In other words, writing a filter class or magic filter construct comes down to checking a condition: if the condition is true, the action is performed; if not, the action is not performed.

Using a filter

This filter can be used in bot message handlers to restrict access to certain commands or actions to administrators only. Usage example:

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('В твоем сообщении было найдено слово "подписывайся", а у нас такое писать запрещено!')

Code Explanation

Import filter:

from filters.is_admin import IsAdmin

We import our custom IsAdmin filter from the filter package.

Handler for administrators:

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

Here we create a handler that responds to messages containing the word “subscribe”. This handler will only fire if the message was sent by an administrator whose IDs are listed in admins. If the condition is met, the bot responds: “Oh, admin, hello! And you can write “subscribe”.”

Handler for all other users:

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

This handler also responds to messages containing the word “subscribe”. But unlike the previous handler, it works for all users except administrators. If an ordinary user sends such a message, the bot responds: “The word 'subscribe' was found in your message, but we are prohibited from writing such a thing!”

Processing order

Here it is important to pay attention not only to the code itself, but also to the order in which it is executed. As a reminder, the Python interpreter reads code from top to bottom. In this regard, filters with more precise settings should be located higher.

Why is it important:

If the more general filter is higher up, it will intercept the message before the more specific filter can process it. In our case, if the handler for all users was higher, then the message with the text “subscribe” would be processed by this handler, and the administrators would never receive their special response.

Thus, by placing more specific filters higher, we ensure that messages are processed correctly and the necessary actions are performed depending on the conditions. The same applies to enabling routers in the main bot file. What has a higher priority (more precise setting) should always be turned on before the more general one.

Let me remind you that my list of administrators is written like this:

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

The data is pulled from the .env file and, using python-decouple, pulls this information from the .env file (the data itself in the start handler is pulled from the create_bot file).

from create_bot import admins

Conclusion

Friends, in this article I have provided you with basic information that will allow you to create filters of any complexity – both custom ones and using magic and built-in filters.

Due to time constraints, I was not able to consider all possible processing options. However, this is not required. Excessive theory can become ballast. Now it is important for you to understand the general principles of working with filters. Over time, in practice, you will encounter various special cases and will be able to deepen your knowledge.

Thank you for your support in the form of likes, subscriptions and nice comments. This motivates me to write more and more articles on the topic of aiogram 3. See you soon!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *