From 2992a8e212fa968e132167897decd66aac790546 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 2 Nov 2023 01:30:40 +0100 Subject: [PATCH] Make filters combinable --- client/doc/concepts/updates.rst | 9 +- client/doc/developing/migration-guide.rst | 1 + client/doc/modules/types.rst | 4 + .../_impl/client/events/filters/__init__.py | 6 +- .../client/events/filters/combinators.py | 104 +++++++++++++++++- .../_impl/client/events/filters/common.py | 11 +- .../_impl/client/events/filters/messages.py | 17 +-- 7 files changed, 126 insertions(+), 26 deletions(-) diff --git a/client/doc/concepts/updates.rst b/client/doc/concepts/updates.rst index 549d06e7..a4668c98 100644 --- a/client/doc/concepts/updates.rst +++ b/client/doc/concepts/updates.rst @@ -37,7 +37,7 @@ The most common way is using the :meth:`Client.on` decorator to register your ca bot = Client(...) - @bot.on(events.NewMessage, filters.Command('/start')) + @bot.on(events.NewMessage, filters.Command('/start') | filters.Command('/help')) async def handler(event: events.NewMessage): await event.respond('Beep boop!') @@ -46,7 +46,11 @@ The first parameter is the :class:`type` of one of the :mod:`telethon.events`, n The second parameter is optional. If provided, it must be a callable function that returns :data:`True` if the handler should run. Built-in filter functions are available in the :mod:`~telethon.events.filters` module. -In this example, :class:`~events.filters.Command` means the handler will be called when the user sends */start* to the bot. +In this example, :class:`~events.filters.Command` means the handler will be called when the user sends */start* or */help* to the bot. + +Built-in filter functions are also :class:`~telethon._impl.client.events.filters.combinators.Combinable`. +This means you can use ``|``, ``&`` and the unary ``~`` to combine filters with *or*, *and*, and negate them, respectively. +These operators correspond to :class:`events.filters.Any`, :class:`events.filters.All` and :class:`events.filters.Not`. When your ``handler`` function is called, it will receive a single parameter, the event. The event type is the same as the one you defined in the decorator when registering your handler. @@ -140,7 +144,6 @@ This makes it very convenient to write custom filters using the :keyword:`lambda ... - Setting priority on handlers ---------------------------- diff --git a/client/doc/developing/migration-guide.rst b/client/doc/developing/migration-guide.rst index f6bd6129..6040fb8d 100644 --- a/client/doc/developing/migration-guide.rst +++ b/client/doc/developing/migration-guide.rst @@ -345,6 +345,7 @@ There is no longer nested ``class Event`` inside them either. Instead, the event type itself is what the handler will actually be called with. Because filters are separate, there is no longer a need for v1 ``@events.register`` either. +It also means you can combine filters with ``&``, ``|`` and ``~``. Filters are now normal functions that work with any event. Of course, this doesn't mean all filters make sense for all events. diff --git a/client/doc/modules/types.rst b/client/doc/modules/types.rst index 18ac0c3e..74278734 100644 --- a/client/doc/modules/types.rst +++ b/client/doc/modules/types.rst @@ -102,6 +102,10 @@ Private definitions Generic parameter used by :class:`AsyncList`. +.. currentmodule:: telethon._impl.client.events.filters.combinators + +.. autoclass:: Combinable + .. currentmodule:: telethon._impl.client.types.file .. autoclass:: InFileLike diff --git a/client/src/telethon/_impl/client/events/filters/__init__.py b/client/src/telethon/_impl/client/events/filters/__init__.py index b86065f9..d3a1c97a 100644 --- a/client/src/telethon/_impl/client/events/filters/__init__.py +++ b/client/src/telethon/_impl/client/events/filters/__init__.py @@ -1,13 +1,13 @@ -from .combinators import All, Any, Not -from .common import Chats, Filter, Senders +from .combinators import All, Any, Filter, Not +from .common import Chats, Senders from .messages import Command, Forward, Incoming, Media, Outgoing, Reply, Text, TextOnly __all__ = [ "All", "Any", + "Filter", "Not", "Chats", - "Filter", "Senders", "Command", "Forward", diff --git a/client/src/telethon/_impl/client/events/filters/combinators.py b/client/src/telethon/_impl/client/events/filters/combinators.py index 54505cc4..b8214eb6 100644 --- a/client/src/telethon/_impl/client/events/filters/combinators.py +++ b/client/src/telethon/_impl/client/events/filters/combinators.py @@ -1,12 +1,70 @@ -from typing import Tuple +import abc +import typing +from typing import Callable, Tuple from ..event import Event -from .common import Filter + +Filter = Callable[[Event], bool] -class Any: +class Combinable(abc.ABC): + """ + Subclass that enables filters to be combined. + + * The :func:`bitwise or ` operator ``|`` can be used to combine filters with :class:`Any`. + * The :func:`bitwise and ` operator ``&`` can be used to combine filters with :class:`All`. + * The :func:`bitwise invert ` operator ``~`` can be used to negate a filter with :class:`Not`. + + Filters combined this way will be merged. + This means multiple ``|`` or ``&`` will lead to a single :class:`Any` or :class:`All` being used. + Multiple ``~`` will toggle between using :class:`Not` and not using it. + """ + + def __or__(self, other: typing.Any) -> Filter: + if not callable(other): + return NotImplemented + + lhs = self.filters if isinstance(self, Any) else (self,) + rhs = other.filters if isinstance(other, Any) else (other,) + return Any(*lhs, *rhs) # type: ignore [arg-type] + + def __and__(self, other: typing.Any) -> Filter: + if not callable(other): + return NotImplemented + + lhs = self.filters if isinstance(self, All) else (self,) + rhs = other.filters if isinstance(other, All) else (other,) + return All(*lhs, *rhs) # type: ignore [arg-type] + + def __invert__(self) -> Filter: + return self.filter if isinstance(self, Not) else Not(self) # type: ignore [return-value] + + @abc.abstractmethod + def __call__(self, event: Event) -> bool: + pass + + +class Any(Combinable): """ Combine multiple filters, returning :data:`True` if any of the filters pass. + + When either filter is *combinable*, you can use the ``|`` operator instead. + + .. code-block:: python + + from telethon.filters import Any, Command + + @bot.on(events.NewMessage, Any(Command('/start'), Command('/help'))) + async def handler(event): ... + + # equivalent to: + + @bot.on(events.NewMessage, Command('/start') | Command('/help')) + async def handler(event): ... + + :param filter1: The first filter to check. + :param filter2: The second filter to check if the first one failed. + :param filters: The rest of filters to check if the first and second one failed. """ __slots__ = ("_filters",) @@ -25,9 +83,27 @@ class Any: return any(f(event) for f in self._filters) -class All: +class All(Combinable): """ Combine multiple filters, returning :data:`True` if all of the filters pass. + + When either filter is *combinable*, you can use the ``&`` operator instead. + + .. code-block:: python + + from telethon.filters import All, Command, Text + + @bot.on(events.NewMessage, All(Command('/start'), Text(r'\bdata:\w+'))) + async def handler(event): ... + + # equivalent to: + + @bot.on(events.NewMessage, Command('/start') & Text(r'\bdata:\w+')) + async def handler(event): ... + + :param filter1: The first filter to check. + :param filter2: The second filter to check. + :param filters: The rest of filters to check. """ __slots__ = ("_filters",) @@ -46,9 +122,25 @@ class All: return all(f(event) for f in self._filters) -class Not: +class Not(Combinable): """ Negate the output of a single filter, returning :data:`True` if the nested filter does *not* pass. + + When the filter is *combinable*, you can use the ``~`` operator instead. + + .. code-block:: python + + from telethon.filters import All, Command + + @bot.on(events.NewMessage, Not(Command('/start')) + async def handler(event): ... + + # equivalent to: + + @bot.on(events.NewMessage, ~Command('/start')) + async def handler(event): ... + + :param filter: The filter to negate. """ __slots__ = ("_filter",) @@ -59,7 +151,7 @@ class Not: @property def filter(self) -> Filter: """ - The filters being negated. + The filter being negated. """ return self._filter diff --git a/client/src/telethon/_impl/client/events/filters/common.py b/client/src/telethon/_impl/client/events/filters/common.py index 874edb23..8e4b4ca5 100644 --- a/client/src/telethon/_impl/client/events/filters/common.py +++ b/client/src/telethon/_impl/client/events/filters/common.py @@ -1,12 +1,11 @@ -from typing import Callable, Literal, Sequence, Tuple, Type, Union +from typing import Literal, Sequence, Tuple, Type, Union from ...types import Channel, Group, User from ..event import Event - -Filter = Callable[[Event], bool] +from .combinators import Combinable -class Chats: +class Chats(Combinable): """ Filter by ``event.chat.id``, if the event has a chat. """ @@ -30,7 +29,7 @@ class Chats: return id in self._chats -class Senders: +class Senders(Combinable): """ Filter by ``event.sender.id``, if the event has a sender. """ @@ -54,7 +53,7 @@ class Senders: return id in self._senders -class ChatType: +class ChatType(Combinable): """ Filter by chat type, either ``'user'``, ``'group'`` or ``'broadcast'``. """ diff --git a/client/src/telethon/_impl/client/events/filters/messages.py b/client/src/telethon/_impl/client/events/filters/messages.py index 50d30576..9045e190 100644 --- a/client/src/telethon/_impl/client/events/filters/messages.py +++ b/client/src/telethon/_impl/client/events/filters/messages.py @@ -4,12 +4,13 @@ import re from typing import TYPE_CHECKING, Literal, Optional, Tuple, Union from ..event import Event +from .combinators import Combinable if TYPE_CHECKING: from ...client.client import Client -class Text: +class Text(Combinable): """ Filter by ``event.text`` using a *regular expression* pattern. @@ -33,7 +34,7 @@ class Text: return re.search(self._pattern, text) is not None if text is not None else False -class Command: +class Command(Combinable): """ Filter by ``event.text`` to make sure the first word matches the command or the command + '@' + username, using the username of the logged-in account. @@ -84,7 +85,7 @@ class Command: return False -class Incoming: +class Incoming(Combinable): """ Filter by ``event.incoming``, that is, messages sent from others to the logged-in account. @@ -99,7 +100,7 @@ class Incoming: return getattr(event, "incoming", False) -class Outgoing: +class Outgoing(Combinable): """ Filter by ``event.outgoing``, that is, messages sent from others to the logged-in account. @@ -114,7 +115,7 @@ class Outgoing: return getattr(event, "outgoing", False) -class Forward: +class Forward(Combinable): """ Filter by ``event.forward``. """ @@ -125,7 +126,7 @@ class Forward: return getattr(event, "forward", None) is not None -class Reply: +class Reply(Combinable): """ Filter by ``event.reply``. """ @@ -136,7 +137,7 @@ class Reply: return getattr(event, "reply", None) is not None -class TextOnly: +class TextOnly(Combinable): """ Filter by messages with some text and no media. @@ -144,7 +145,7 @@ class TextOnly: """ -class Media: +class Media(Combinable): """ Filter by the media type in the message.