From 4df1f4537befedb8ed1de7c60b30d80aa9cc8b73 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 1 Oct 2023 13:37:28 +0200 Subject: [PATCH] Complete migration guide from other bot libraries --- README.rst | 8 +- client/doc/basic/signing-in.rst | 9 + client/doc/concepts/botapi-vs-mtproto.rst | 322 +++++++++++++++++- client/doc/concepts/chats.rst | 6 +- client/doc/concepts/glossary.rst | 1 + client/doc/concepts/updates.rst | 38 ++- client/doc/developing/migration-guide.rst | 14 + .../src/telethon/_impl/client/client/auth.py | 41 ++- .../telethon/_impl/client/client/client.py | 31 +- .../telethon/_impl/client/client/updates.py | 31 +- .../src/telethon/_impl/client/events/event.py | 7 + .../_impl/client/events/filters/__init__.py | 4 +- .../_impl/client/events/filters/messages.py | 52 ++- .../telethon/_impl/client/parsers/strings.py | 2 +- client/src/telethon/events/filters.py | 4 + 15 files changed, 521 insertions(+), 49 deletions(-) diff --git a/README.rst b/README.rst index f9ce068d..ef2cdfe9 100755 --- a/README.rst +++ b/README.rst @@ -3,7 +3,7 @@ Telethon .. epigraph:: - ⭐️ Thanks **everyone** who has starred the project, it means a lot! + ⭐️ Thanks **everyone** who has starred the project, it means a lot! |logo| **Telethon** is an asyncio_ **Python 3** MTProto_ library to interact with Telegram_'s API @@ -31,7 +31,7 @@ Installing .. code-block:: sh - pip install telethon + pip install telethon Creating a client @@ -47,7 +47,7 @@ Creating a client api_hash = '0123456789abcdef0123456789abcdef' async with TelegramClient('session_name', api_id, api_hash) as client: - ... + await client.interactive_login() Doing stuff @@ -58,7 +58,7 @@ Doing stuff print(await client.get_me()) await client.send_message('username', 'Hello! Talking to you from Telethon') - await client.send_message('username', photo='/home/myself/Pictures/holidays.jpg') + await client.send_photo('username', '/home/myself/Pictures/holidays.jpg') async for message in client.get_messages('username', 1): path = await message.download_media() diff --git a/client/doc/basic/signing-in.rst b/client/doc/basic/signing-in.rst index 67e03983..ff0757dd 100644 --- a/client/doc/basic/signing-in.rst +++ b/client/doc/basic/signing-in.rst @@ -97,6 +97,15 @@ Do as the prompts say on the terminal, and you will have successfully logged-in! Once you're done, make sure to :meth:`~Client.disconnect` for a graceful shutdown. +To summarize: + +.. code-block:: python + + from telethon import Client + client = Client('name', 12345, '0123456789abcdef0123456789abcdef') + await client.connect() + await client.interactive_login() + Manual login ------------ diff --git a/client/doc/concepts/botapi-vs-mtproto.rst b/client/doc/concepts/botapi-vs-mtproto.rst index da8b12dd..3f9e2237 100644 --- a/client/doc/concepts/botapi-vs-mtproto.rst +++ b/client/doc/concepts/botapi-vs-mtproto.rst @@ -17,13 +17,13 @@ Quoting their main page: .. epigraph:: - The Bot API is an HTTP-based interface created for developers keen on building bots for Telegram. + The :term:`Bot API` is an HTTP-based interface created for developers keen on building bots for Telegram. To learn how to create and set up a bot, please consult our `Introduction to Bots `_ and `Bot FAQ `_. -Bot API is simply an HTTP endpoint offering a custom HTTP API. +:term:`Bot API` is simply an HTTP endpoint offering a custom HTTP API. Underneath, it uses `tdlib `_ to talk to Telegram's servers. You can configure your bot details via `@BotFather `_. @@ -51,6 +51,19 @@ This name was chosen because it gives you "raw" access to the MTProto API. Telethon's :class:`Client` and other custom types are implemented using the :term:`Raw API`. +Why is an API ID and hash needed for bots with MTProto? +------------------------------------------------------- + +When talking to Telegram's API directly, you need an API ID and hash to sign in to their servers. +API access is forbidden without an API ID, and the sign in can only be done with the API hash. + +When using the :term:`Bot API`, that layer talks to the MTProto API underneath. +To do so, it uses its own private API ID and hash. + +When you cut on the intermediary, you need to provide your own. +In a similar manner, the authorization key which remembers that you logged-in must be kept locally. + + Advantages of MTProto over Bot API ---------------------------------- @@ -109,9 +122,306 @@ and you were making API requests manually, or if you used a wrapper library like or `pyTelegramBotAPI `. You will surely be pleased with Telethon! -If you were using an asynchronous library like `aiohttp `_ -or a wrapper like `aiogram `_, the switch will be even easier. +If you were using an asynchronous library like `aiohttp `_ +or a wrapper like `aiogram `_, the switch will be even easier. -Migrating from TODO -^^^^^^^^^^^^^^^^^^^ +Migrating from PTB v13.x +^^^^^^^^^^^^^^^^^^^^^^^^ + +Using one of the examples from their v13 wiki with the ``.ext`` module: + +.. code-block:: python + + from telegram import Update + from telegram.ext import Updater, CallbackContext, CommandHandler + + updater = Updater(token='TOKEN', use_context=True) + dispatcher = updater.dispatcher + + def start(update: Update, context: CallbackContext): + context.bot.send_message(chat_id=update.effective_chat.id, text="I'm a bot, please talk to me!") + + start_handler = CommandHandler('start', start) + dispatcher.add_handler(start_handler) + + updater.start_polling() + +The code creates an ``Updater`` instance. +This will take care of polling updates for the bot associated with the given token. +Then, a ``CommandHandler`` using our ``start`` function is added to the dispatcher. +At the end, we block, telling the updater to do its job. + +In Telethon: + +.. code-block:: python + + import asyncio + from telethon import Client + from telethon.events import NewMessage, filters + + updater = Client('bot', api_id, api_hash) + + async def start(update: NewMessage): + await update.client.send_message(chat=update.chat.id, text="I'm a bot, please talk to me!") + + start_filter = filters.Command('/start') + updater.add_event_handler(start, NewMessage, start_filter) + + async def main(): + async with updater: + await updater.interactive_login('TOKEN') + await updater.run_until_disconnected() + + asyncio.run(main()) + +Key differences: + +* Telethon only has a :class:`~telethon.Client`, not separate ``Bot`` or ``Updater`` classes. +* There is no separate dispatcher. The :class:`~telethon.Client` is capable of dispatching updates. +* Telethon handlers only have one parameter, the event. +* There is no context, but the :attr:`~telethon.events.Event.client` property exists in all events. +* Handler types are :mod:`~telethon.events.filters` and don't have a ``Handler`` suffix. +* Telethon must define the update type (:class:`~telethon.events.NewMessage`) and filter. +* The setup to run the client (and dispatch updates) is a bit more involved with :mod:`asyncio`. + +Here's the above code in idiomatic Telethon: + +.. code-block:: python + + import asyncio + from telethon import Client, events + from telethon.events import filters + + client = Client('bot', api_id, api_hash) + + @client.on(events.NewMessage, filters.Command('/start')) + async def start(event): + await event.respond("I'm a bot, please talk to me!") + + async def main(): + async with client: + await client.interactive_login('TOKEN') + await client.run_until_disconnected() + + asyncio.run(main()) + +Events can be added using decorators and methods such as :meth:`types.Message.respond` help reduce the verbosity. + + +Migrating from PTB v20.x +^^^^^^^^^^^^^^^^^^^^^^^^ + +Using one of the examples from their v13 wiki with the ``.ext`` module: + +.. code-block:: python + + from telegram import Update + from telegram.ext import ApplicationBuilder, ContextTypes, CommandHandler + + async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): + await context.bot.send_message(chat_id=update.effective_chat.id, text="I'm a bot, please talk to me!") + + if __name__ == '__main__': + application = ApplicationBuilder().token('TOKEN').build() + + start_handler = CommandHandler('start', start) + application.add_handler(start_handler) + + application.run_polling() + +No need to import the :mod:`asyncio` module directly! +Now instead there are builders to help set stuff up. + +In Telethon: + +.. code-block:: python + + import asyncio + from telethon import Client + from telethon.events import NewMessage, filters + + async def start(update: NewMessage): + await update.client.send_message(chat=update.chat.id, text="I'm a bot, please talk to me!") + + async def main(): + application = Client('bot', api_id, api_hash) + + start_filter = filters.Command('/start') + application.add_event_handler(start, NewMessage, start_filter) + + async with application: + await application.interactive_login('TOKEN') + await application.run_until_disconnected() + + asyncio.run(main()) + +Key differences: + +* No builders. Telethon tries to get out of your way on how you structure your code. +* The client must be connected before it can run, hence the ``async with``. + +Here's the above code in idiomatic Telethon: + +.. code-block:: python + + import asyncio + from telethon import Client, events + from telethon.events import filters + + @client.on(events.NewMessage, filters.Command('/start')) + async def start(event): + await event.respond("I'm a bot, please talk to me!") + + async def main(): + async with Client('bot', api_id, api_hash) as client: + await client.interactive_login('TOKEN') + client.add_event_handler(start, NewMessage, filters.Command('/start')) + await client.run_until_disconnected() + + asyncio.run(main()) + +Note how the client can be created and started in the same line. +This makes it easy to have clean disconnections once the script exits. + + +Migrating from asynchronous TeleBot +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Using one of the examples from their v4 pyTelegramBotAPI documentation: + +.. code-block:: python + + from telebot.async_telebot import AsyncTeleBot + bot = AsyncTeleBot('TOKEN') + + # Handle '/start' and '/help' + @bot.message_handler(commands=['help', 'start']) + async def send_welcome(message): + await bot.reply_to(message, """\ + Hi there, I am EchoBot. + I am here to echo your kind words back to you. Just say anything nice and I'll say the exact same thing to you!\ + """) + + # Handle all other messages with content_type 'text' (content_types defaults to ['text']) + @bot.message_handler(func=lambda message: True) + async def echo_message(message): + await bot.reply_to(message, message.text) + + import asyncio + asyncio.run(bot.polling()) + +This showcases a command handler and a catch-all echo handler, both added with decorators. + +In Telethon: + +.. code-block:: python + + from telethon import Client, events + from telethon.events.filters import Any, Command, TextOnly + bot = Client('bot', api_id, api_hash) + + # Handle '/start' and '/help' + @bot.on(events.NewMessage, Any(Command('/help'), Command('/start'))) + async def send_welcome(message: NewMessage): + await message.reply("""\ + Hi there, I am EchoBot. + I am here to echo your kind words back to you. Just say anything nice and I'll say the exact same thing to you!\ + """) + + # Handle all other messages with only 'text' + @bot.on(events.NewMessage, TextOnly()) + async def echo_message(message: NewMessage): + await message.reply(message.text) + + import asyncio + async def main(): + async with bot: + await bot.interactive_login('TOKEN') + await bot.run_until_disconnected() + asyncio.run(main()) + +Key differences: + +* The handler type is defined using the event type instead of being a specific method in the client. +* Filters are also separate instances instead of being tied to specific event types. +* The ``reply_to`` helper is in the message, not the client instance. +* Setup is a bit more involved because the connection is not implicit. + +For the most part, it's a 1-to-1 translation and the result is idiomatic Telethon. + + +Migrating from aiogram +`````````````````````` +Using one of the examples from their v3 documentation with logging and comments removed: + +.. code-block:: python + import asyncio + + from aiogram import Bot, Dispatcher, types + from aiogram.enums import ParseMode + from aiogram.filters import CommandStart + from aiogram.types import Message + from aiogram.utils.markdown import hbold + + dp = Dispatcher() + + @dp.message(CommandStart()) + async def command_start_handler(message: Message) -> None: + await message.answer(f"Hello, {hbold(message.from_user.full_name)}!") + + @dp.message() + async def echo_handler(message: types.Message) -> None: + try: + await message.send_copy(chat_id=message.chat.id) + except TypeError: + await message.answer("Nice try!") + + async def main() -> None: + bot = Bot(TOKEN, parse_mode=ParseMode.HTML) + await dp.start_polling(bot) + + if __name__ == "__main__": + asyncio.run(main()) + +We can see a specific handler for the ``/start`` command and a catch-all echo handler: + +In Telethon: + +.. code-block:: python + import asyncio, html + + from telethon import Client, RpcError, types, events + from telethon.events.filters import Command + from telethon.types import Message + + client = Client("bot", api_id, api_hash) + + @client.on(events.NewMessage, Command("/start")) + async def command_start_handler(message: Message) -> None: + await message.respond(html=f"Hello, {html.escape(message.sender.full_name)}!") + + @dp.message() + async def echo_handler(message: types.Message) -> None: + try: + await message.respond(message) + except RpcError: + await message.respond("Nice try!") + + async def main() -> None: + async with bot: + await bot.interactive_login(TOKEN) + await bot.run_until_disconnected() + + if __name__ == "__main__": + asyncio.run(main()) + +Key differences: + +* There is no separate dispatcher. Handlers are added to the client. +* There is no specific handler for the ``/start`` command. +* The ``answer`` method is for callback queries. Messages have :meth:`~types.Message.respond`. +* Telethon doesn't have functions to format messages. Instead, markdown or HTML are used. +* Telethon cannot have a default parse mode. Instead, it should be specified when responding. +* Telethon doesn't have ``send_copy``. Instead, :meth:`Client.send_message` accepts :class:`~types.Message`. +* If sending a message fails, the error will be :class:`RpcError`, because it comes from Telegram. diff --git a/client/doc/concepts/chats.rst b/client/doc/concepts/chats.rst index cabd1204..ecef4372 100644 --- a/client/doc/concepts/chats.rst +++ b/client/doc/concepts/chats.rst @@ -43,10 +43,10 @@ In the schema definitions, there are two boxed types, :tl:`User` and :tl:`Chat`. A boxed :tl:`User` can only be the bare :tl:`user`, but the boxed :tl:`Chat` can be either a bare :tl:`chat` or a bare :tl:`channel`. A bare :tl:`chat` always refers to small groups. -A bare :tl:`channel` can have either the ``broadcast`` or the ``megagroup`` flag set to ``True``. +A bare :tl:`channel` can have either the ``broadcast`` or the ``megagroup`` flag set to :data:`True`. -A bare :tl:`channel` with the ``broadcast`` flag set to ``True`` is known as a broadcast channel. -A bare :tl:`channel` with the ``megagroup`` flag set to ``True`` is known as a supergroup. +A bare :tl:`channel` with the ``broadcast`` flag set to :data:`True` is known as a broadcast channel. +A bare :tl:`channel` with the ``megagroup`` flag set to :data:`True` is known as a supergroup. A bare :tl:`chat` with has less features than a bare :tl:`channel` ``megagroup``. Official clients are very good at hiding this difference. diff --git a/client/doc/concepts/glossary.rst b/client/doc/concepts/glossary.rst index a1d8e6de..784ad047 100644 --- a/client/doc/concepts/glossary.rst +++ b/client/doc/concepts/glossary.rst @@ -44,6 +44,7 @@ Glossary .. seealso:: The :doc:`../concepts/botapi-vs-mtproto` concept. + Bot API HTTP Bot API Telegram's simplified HTTP API to control bot accounts only. diff --git a/client/doc/concepts/updates.rst b/client/doc/concepts/updates.rst index 4bd34701..a01a6fd6 100644 --- a/client/doc/concepts/updates.rst +++ b/client/doc/concepts/updates.rst @@ -45,7 +45,7 @@ For this reason, filters cannot be asynchronous. This reduces the chance a filter will do slow IO and potentially fail. A filter is simply a callable function that takes an event as input and returns a boolean. -If the filter returns ``True``, the handler will be called. +If the filter returns :data:`True`, the handler will be called. Using this knowledge, you can create custom filters too. If you need state, you can use a class with a ``__call__`` method defined: @@ -74,3 +74,39 @@ You can use :func:`isinstance` if your filter can only deal with certain types o If you need to perform asynchronous operations, you can't use a filter. Instead, manually check for those conditions inside your handler. + + +Setting priority on handlers +---------------------------- + +There is no explicit way to define a different priority for different handlers. + +Instead, the library will call all your handlers in the order you added them. +This means that, if you want a "catch-all" handler, it should be registered last. + +By default, the library will stop calling the rest of handlers after one is called: + +.. code-block:: python + + @client.on(events.NewMessage) + async def first(event): + print('This is always called on new messages!') + + @client.on(events.NewMessage) + async def second(event): + print('This will never be called, because "first" already ran.') + +This is often the desired behaviour if you're using filters. + +If you have more complicated filters executed *inside* the handler, +Telethon believes your handler completed and will stop calling the rest. +If that's the case, you can instruct Telethon to check all your handlers: + +.. code-block:: python + + client = Client(..., check_all_handlers=True) + # ^^^^^^^^^^^^^^^^^^^^^^^ + # Now the code above will call both handlers + +If you need a more complicated setup, consider sorting all your handlers beforehand. +Then, use :meth:`Client.add_event_handler` on all of them to ensure the correct order. diff --git a/client/doc/developing/migration-guide.rst b/client/doc/developing/migration-guide.rst index 84a252c3..b477f1b6 100644 --- a/client/doc/developing/migration-guide.rst +++ b/client/doc/developing/migration-guide.rst @@ -306,6 +306,20 @@ This means getting those identifiers is up to you, and you can handle it in a wa Behaviour changes in events --------------------------- +Events produced by the client itself will now also be received as updates. +This means, for example, that your :class:`events.NewMessage` handlers will run when you use :meth:`Client.send_message`. +This is needed to properly handle updates. + +In v1, there was a backwards-compatibility hack that flagged results from the client as their "own". +But in some rare cases, it was possible to still receive messages sent by the client itself in v1. +The hack has been removed so now the library will consistently deliver all updates. + +``events.StopPropagation`` no longer exists. +In v1, all handlers were always called. +Now handlers are called in order until the filter for one returns :data:`True`. +The default behaviour is that handlers after that one are not called. +This behaviour can be changed with the ``check_all_handlers`` flag in :class:`Client` constructor. + :class:`events.CallbackQuery` no longer also handles "inline bot callback queries". This was a hacky workaround. diff --git a/client/src/telethon/_impl/client/client/auth.py b/client/src/telethon/_impl/client/client/auth.py index 8c6ce071..915352fa 100644 --- a/client/src/telethon/_impl/client/client/auth.py +++ b/client/src/telethon/_impl/client/client/auth.py @@ -125,14 +125,20 @@ async def sign_in( return await complete_login(self, result) -async def interactive_login(self: Client) -> User: +async def interactive_login( + self: Client, + phone_or_token: Optional[str] = None, + *, + password: Optional[str] = None, +) -> User: if me := await self.get_me(): return me - phone_or_token = "" - while not re.match(r"\+?[\s()]*\d", phone_or_token): - print("Please enter your phone (+1 23...) or bot token (12:abcd...)") - phone_or_token = input(": ").strip() + if not phone_or_token: + phone_or_token = "" + while not re.match(r"\+?[\s()]*\d", phone_or_token): + print("Please enter your phone (+1 23...) or bot token (12:abcd...)") + phone_or_token = input(": ").strip() # Bot flow if re.match(r"\d+:", phone_or_token): @@ -160,16 +166,21 @@ async def interactive_login(self: Client) -> User: break if isinstance(user_or_token, PasswordToken): - while True: - print("Please enter your password (prompt is hidden; type and press enter)") - password = getpass.getpass(": ") - try: - user = await self.check_password(user_or_token, password) - except RpcError as e: - if e.name.startswith("PASSWORD"): - print("Invalid password:", e) - else: - raise + if password: + user = await self.check_password(user_or_token, password) + else: + while True: + print( + "Please enter your password (prompt is hidden; type and press enter)" + ) + password = getpass.getpass(": ") + try: + user = await self.check_password(user_or_token, password) + except RpcError as e: + if e.name.startswith("PASSWORD"): + print("Invalid password:", e) + else: + raise else: user = user_or_token diff --git a/client/src/telethon/_impl/client/client/client.py b/client/src/telethon/_impl/client/client/client.py index 10499747..fe49d0c6 100644 --- a/client/src/telethon/_impl/client/client/client.py +++ b/client/src/telethon/_impl/client/client/client.py @@ -168,6 +168,14 @@ class Client: :param update_queue_limit: Maximum amount of updates to keep in memory before dropping them. + + :param check_all_handlers: + Whether to always check all event handlers or stop early. + + The library will call event handlers in the order they were added. + By default, the library stops checking handlers as soon as a filter returns :data:`True`. + + By setting ``check_all_handlers=True``, the library will keep calling handlers after the first match. """ def __init__( @@ -175,6 +183,7 @@ class Client: session: Optional[Union[str, Path, Storage]], api_id: int, api_hash: Optional[str] = None, + check_all_handlers: bool = False, ) -> None: self._sender: Optional[Sender] = None self._sender_lock = asyncio.Lock() @@ -190,6 +199,7 @@ class Client: api_id=api_id, api_hash=api_hash or "", ) + self._message_box = MessageBox() self._chat_hashes = ChatHashCache(None) self._last_update_limit_warn: Optional[float] = None @@ -197,10 +207,10 @@ class Client: Tuple[abcs.Update, Dict[int, Chat]] ] = asyncio.Queue(maxsize=self._config.update_queue_limit or 0) self._dispatcher: Optional[asyncio.Task[None]] = None - self._downloader_map = object() self._handlers: Dict[ Type[Event], List[Tuple[Callable[[Any], Awaitable[Any]], Optional[Filter]]] ] = {} + self._shortcircuit_handlers = not check_all_handlers if self_user := self._config.session.user: self._dc_id = self_user.dc @@ -795,11 +805,19 @@ class Client: """ return await inline_query(self, bot, query, chat=chat) - async def interactive_login(self) -> User: + async def interactive_login( + self, phone_or_token: Optional[str] = None, *, password: Optional[str] = None + ) -> User: """ Begin an interactive login if needed. If the account was already logged-in, this method simply returns :term:`yourself`. + :param phone_or_token: + Bypass the phone number or bot token prompt, and use this value instead. + + :param password: + Bypass the 2FA password prompt, and use this value instead. + :return: The user corresponding to :term:`yourself`. .. rubric:: Example @@ -809,11 +827,14 @@ class Client: me = await client.interactive_login() print('Logged in as:', me.full_name) + # or, to make sure you're logged-in as a bot + await client.interactive_login('1234:ab56cd78ef90) + .. seealso:: In-depth explanation for :doc:`/basic/signing-in`. """ - return await interactive_login(self) + return await interactive_login(self, phone_or_token, password=password) async def is_authorized(self) -> bool: """ @@ -1210,7 +1231,7 @@ class Client: async def send_message( self, chat: ChatLike, - text: Optional[str] = None, + text: Optional[Union[str, Message]] = None, *, markdown: Optional[str] = None, html: Optional[str] = None, @@ -1225,6 +1246,8 @@ class Client: :param text: Message text, with no formatting. + When given a :class:`Message` instance, a copy of the message will be sent. + :param text_markdown: Message text, parsed as CommonMark. diff --git a/client/src/telethon/_impl/client/client/updates.py b/client/src/telethon/_impl/client/client/updates.py index 82233e72..db44170d 100644 --- a/client/src/telethon/_impl/client/client/updates.py +++ b/client/src/telethon/_impl/client/client/updates.py @@ -124,16 +124,21 @@ def extend_update_queue( async def dispatcher(client: Client) -> None: while client.connected: - update, chat_map = await client._updates.get() - for event_cls, handlers in client._handlers.items(): - if event := event_cls._try_from_update(client, update, chat_map): - for handler, filter in handlers: - if not filter or filter(event): - try: - await handler(event) - except asyncio.CancelledError: - raise - except Exception: - # TODO proper logger - name = getattr(handler, "__name__", repr(handler)) - logging.exception("Unhandled exception on %s", name) + try: + await dispatch_next(client) + except asyncio.CancelledError: + raise + except Exception: + # TODO proper logger + logging.exception("Unhandled exception in event handler") + + +async def dispatch_next(client: Client) -> None: + update, chat_map = await client._updates.get() + for event_cls, handlers in client._handlers.items(): + if event := event_cls._try_from_update(client, update, chat_map): + for handler, filter in handlers: + if not filter or filter(event): + await handler(event) + if client._shortcircuit_handlers: + return diff --git a/client/src/telethon/_impl/client/events/event.py b/client/src/telethon/_impl/client/events/event.py index 6294ce15..07e01c5e 100644 --- a/client/src/telethon/_impl/client/events/event.py +++ b/client/src/telethon/_impl/client/events/event.py @@ -15,6 +15,13 @@ class Event(metaclass=NoPublicConstructor): The base type of all events. """ + @property + def client(self) -> Client: + """ + The :class:`~telethon.Client` that received this update. + """ + return self._client + @classmethod @abc.abstractmethod def _try_from_update( diff --git a/client/src/telethon/_impl/client/events/filters/__init__.py b/client/src/telethon/_impl/client/events/filters/__init__.py index 249e2b96..b86065f9 100644 --- a/client/src/telethon/_impl/client/events/filters/__init__.py +++ b/client/src/telethon/_impl/client/events/filters/__init__.py @@ -1,6 +1,6 @@ from .combinators import All, Any, Not from .common import Chats, Filter, Senders -from .messages import Command, Forward, Incoming, Outgoing, Reply, Text +from .messages import Command, Forward, Incoming, Media, Outgoing, Reply, Text, TextOnly __all__ = [ "All", @@ -12,7 +12,9 @@ __all__ = [ "Command", "Forward", "Incoming", + "Media", "Outgoing", "Reply", "Text", + "TextOnly", ] diff --git a/client/src/telethon/_impl/client/events/filters/messages.py b/client/src/telethon/_impl/client/events/filters/messages.py index 824ac96c..54638e6e 100644 --- a/client/src/telethon/_impl/client/events/filters/messages.py +++ b/client/src/telethon/_impl/client/events/filters/messages.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Literal, Optional, Union from ..event import Event @@ -18,6 +18,9 @@ class Text: The match, if any, is discarded. If you need to access captured groups, you need to manually perform the check inside the handler instead. + + Note that the caption text in messages with media is also searched. + If you want to filter based on media, use :class:`TextOnly` or :class:`Media`. """ __slots__ = ("_pattern",) @@ -131,3 +134,50 @@ class Reply: def __call__(self, event: Event) -> bool: return getattr(event, "reply", None) is not None + + +class TextOnly: + """ + Filter by messages with some text and no media. + + Note that link previews are only considered media if they have a photo or document. + """ + + +MediaTypes = Union[Literal["photo"], Literal["audio"], Literal["video"]] + + +class Media: + """ + Filter by the media type in the message. + + By default, this filter will pass if the message has any media. + + Note that link previews are only considered media if they have a photo or document. + + When you specify one or more media types, *only* those types will be considered. + + You can use literal strings or the constants defined by the filter. + """ + + PHOTO = "photo" + AUDIO = "audio" + VIDEO = "video" + + __slots__ = "_types" + + def __init__(self, types: Optional[MediaTypes] = None) -> None: + self._types = types + + @property + def types(self) -> MediaTypes: + """ + The media types being checked. + """ + return self._types + + def __call__(self, event: Event) -> bool: + if self._types is None: + return getattr(event, "file", None) is not None + else: + return any(getattr(event, ty, None) is not None for ty in self._types) diff --git a/client/src/telethon/_impl/client/parsers/strings.py b/client/src/telethon/_impl/client/parsers/strings.py index 1d7f8fb3..650dc07d 100644 --- a/client/src/telethon/_impl/client/parsers/strings.py +++ b/client/src/telethon/_impl/client/parsers/strings.py @@ -21,7 +21,7 @@ def del_surrogate(text: str) -> str: def within_surrogate(text: str, index: int, *, length: Optional[int] = None) -> bool: """ - `True` if ``index`` is within a surrogate (before and after it, not at!). + :data:`True` if ``index`` is within a surrogate (before and after it, not at!). """ if length is None: length = len(text) diff --git a/client/src/telethon/events/filters.py b/client/src/telethon/events/filters.py index 0dbbf805..a4726d9a 100644 --- a/client/src/telethon/events/filters.py +++ b/client/src/telethon/events/filters.py @@ -15,11 +15,13 @@ from .._impl.client.events.filters import ( Filter, Forward, Incoming, + Media, Not, Outgoing, Reply, Senders, Text, + TextOnly, ) __all__ = [ @@ -30,9 +32,11 @@ __all__ = [ "Filter", "Forward", "Incoming", + "Media", "Not", "Outgoing", "Reply", "Senders", "Text", + "TextOnly", ]