From 2def0a169c9522edea17a36ff0f7869e0c6987bb Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 2 Nov 2023 00:45:01 +0100 Subject: [PATCH] Continue documentation and matching documented behaviour --- client/doc/basic/installation.rst | 6 +- client/doc/basic/signing-in.rst | 9 +- client/doc/concepts/botapi-vs-mtproto.rst | 6 +- client/doc/concepts/chats.rst | 17 ++- client/doc/concepts/datacenters.rst | 125 ++++++++++++++++++ client/doc/concepts/errors.rst | 16 +-- client/doc/concepts/full-api.rst | 2 +- client/doc/concepts/messages.rst | 37 ++++++ client/doc/concepts/sessions.rst | 5 +- client/doc/concepts/updates.rst | 82 +++++++++++- client/doc/index.rst | 1 + client/doc/modules/client.rst | 2 +- .../telethon/_impl/client/client/client.py | 118 +++++++++++------ .../telethon/_impl/client/client/messages.py | 69 ++++++---- .../telethon/_impl/client/client/updates.py | 5 +- .../telethon/_impl/client/events/__init__.py | 3 +- .../src/telethon/_impl/client/events/event.py | 32 +++++ .../telethon/_impl/client/events/messages.py | 2 +- .../telethon/_impl/client/parsers/markdown.py | 2 +- client/src/telethon/_impl/mtsender/sender.py | 4 + client/src/telethon/events/__init__.py | 2 + 21 files changed, 450 insertions(+), 95 deletions(-) create mode 100644 client/doc/concepts/datacenters.rst diff --git a/client/doc/basic/installation.rst b/client/doc/basic/installation.rst index 890c5c8d..c2a43799 100644 --- a/client/doc/basic/installation.rst +++ b/client/doc/basic/installation.rst @@ -29,10 +29,10 @@ Once you have a working Python 3 installation, you can install or upgrade the `` .. code-block:: shell - python -m pip install --upgrade telethon + python -m pip install --upgrade "telethon~=2.0" Be sure to use lock-files if your project! -The above is just a quick way to get started and install Telethon globally. +The above is just a quick way to get started and install a `v2-compatible `_ Telethon globally. Installing development versions @@ -47,7 +47,7 @@ If you want the *latest* unreleased changes, you can run the following command i .. note:: The development version may have bugs and is not recommended for production use. - However, when you are `reporting a library bug `, + However, when you are `reporting a library bug `_, you must reproduce the issue in this version before reporting the problem. diff --git a/client/doc/basic/signing-in.rst b/client/doc/basic/signing-in.rst index 8e099324..70458b14 100644 --- a/client/doc/basic/signing-in.rst +++ b/client/doc/basic/signing-in.rst @@ -50,6 +50,8 @@ If the issue persists, you may try contacting them, using a proxy or using a VPN Be aware that some phone numbers are not eligible to register applications with. +.. _interactive login: + Interactive login ----------------- @@ -131,6 +133,11 @@ If you want to automatically login as a bot when needed, you can do so without a Manual login ------------ +.. tip:: + + You can safely skip to :doc:`next-steps` if you've already completed the :ref:`interactive login`. + This section is only of interest if you want more control over how to manually login. + We've talked about the second and third parameters of the :class:`Client` constructor, but not the first: .. code-block:: python @@ -143,7 +150,7 @@ The session path can contain directory separators and live anywhere in the file Telethon will automatically append the ``.session`` extension if you don't provide any. Briefly, the session contains some of the information needed to connect to Telegram. -This includes the datacenter belonging to the account logged-in, and the authorization key used for encryption, among other things. +This includes the data center belonging to the account logged-in, and the authorization key used for encryption, among other things. .. important:: diff --git a/client/doc/concepts/botapi-vs-mtproto.rst b/client/doc/concepts/botapi-vs-mtproto.rst index 37859dfe..cff0b700 100644 --- a/client/doc/concepts/botapi-vs-mtproto.rst +++ b/client/doc/concepts/botapi-vs-mtproto.rst @@ -72,7 +72,7 @@ There is no HTTP connection, no "polling", and no "web hooks". We can compare the two visually: .. graphviz:: - :caption: Communication between a Client and the Bot API + :caption: Communication between a Client and the HTTP Bot API digraph botapi { rankdir=LR; @@ -86,7 +86,7 @@ We can compare the two visually: } .. graphviz:: - :caption: Communication between a Client and the MTProto API + :caption: Communication between a Client and Telegram's API via MTProto digraph botapi { rankdir=LR; @@ -119,7 +119,7 @@ If the above points convinced you to switch to Telethon, the following short gui It doesn't matter if you wrote your bot with `requests `_ and you were making API requests manually, or if you used a wrapper library like `python-telegram-bot `_ -or `pyTelegramBotAPI `. +or `pyTelegramBotAPI `_. You will surely be pleased with Telethon! If you were using an asynchronous library like `aiohttp `_ diff --git a/client/doc/concepts/chats.rst b/client/doc/concepts/chats.rst index ecef4372..39c2b019 100644 --- a/client/doc/concepts/chats.rst +++ b/client/doc/concepts/chats.rst @@ -39,7 +39,7 @@ Telegram Chat The Telegram API is very confusing when it comes to the word "chat". You only need to know about this if you plan to use the :term:`Raw API`. -In the schema definitions, there are two boxed types, :tl:`User` and :tl:`Chat`. +In the :term:`TL` 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. @@ -48,7 +48,7 @@ A bare :tl:`channel` can have either the ``broadcast`` or the ``megagroup`` flag 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``. +A bare :tl:`chat` has less features available than a bare :tl:`channel` ``megagroup``. Official clients are very good at hiding this difference. They will implicitly convert bare :tl:`chat` to bare :tl:`channel` ``megagroup`` when doing certain operations. Doing things like setting a username is actually a two-step process (migration followed by updating the username). @@ -70,11 +70,21 @@ The Bot API follows a certain convention when it comes to identifiers: * User IDs are positive. * Chat IDs are negative. -* Channel IDs are prefixed with ``-100``. +* Channel IDs are *also* negative, but are prefixed by ``-100``. Telethon encourages the use of :class:`~types.PackedChat` instead of naked identifiers. As a reminder, negative identifiers are not supported in Telethon's chat-like parameters. +If you got an Bot API-style ID from somewhere else, you will need to explicitly say what type it is: + +.. code-block:: python + + # If -1001234 is your ID... + from telethon.types import PackedChat, PackedType + chat = PackedChat(PackedType.BROADCAST, 1234, None) + # ...you need to explicitly create a PackedChat with id=1234 and set the corresponding type (a channel). + # The access hash (see below) will be ``None``, which may or may not work. + Encountering chats ------------------ @@ -88,6 +98,7 @@ If you: * …know the username of the user, group, or channel, you can :meth:`~Client.resolve_username`. * …are a bot responding to users, you will be able to access the :attr:`types.Message.sender`. + Chats access hash ----------------- diff --git a/client/doc/concepts/datacenters.rst b/client/doc/concepts/datacenters.rst new file mode 100644 index 00000000..315cd50e --- /dev/null +++ b/client/doc/concepts/datacenters.rst @@ -0,0 +1,125 @@ +Data centers +============ + +.. currentmodule:: telethon + +Telegram has multiple servers, known as *data centers* or MTProto servers, all over the globe. +This makes it possible to have reasonably low latency when sending messages. + +When an account is created, Telegram chooses the most appropriated data center for you. +This means you *cannot* change what your "home data center" is. +However, `Telegram may change it after prolongued use from other locations `_. + + +Connecting behind a proxy +------------------------- + +You can change the way Telethon opens a connection to Telegram's data center by setting a different :class:`~telethon._impl.mtsender.sender.Connector`. + +A connector is a function returning an asynchronous reader-writer pair. +The default connector is :func:`asyncio.open_connection`, defined as: + +.. code-block:: python + + def default_connector(ip: str, port: int): + return asyncio.open_connection(ip, port) + +While proxies are not directly supported in Telethon, you can change the connector to use a proxy. +Any proxy library that supports :mod:`asyncio`, such as `python-socks[asyncio] `_, can be used: + +.. code-block:: python + + import asyncio + from functools import partial + from python_socks.async_.asyncio import Proxy + from telethon import Client + + async def my_proxy_connector(ip, port, *, proxy_url): + # Refer to python-socks for an up-to-date way to define and use proxies. + # This is just an example of a custom connector. + proxy = Proxy.from_url(proxy_url) + sock = await proxy.connect(dest_host='example.com', dest_port=443) + return await asyncio.open_connection( + host=ip, + port=port, + sock=sock, + ssl=ssl.create_default_context(), + server_hostname='example.com', + ) + + client = Client(..., connector=partial( + my_proxy_connector, + proxy_url='socks5://user:password@127.0.0.1:1080' + )) + +.. important:: + + Proxies can be used with Telethon, but they are not directly supported. + Any connection errors you encounter while using a proxy are therefore very unlikely to be errors in Telethon. + Connection errors when using custom connectors will *not* be considered bugs in the Telethon. + +.. note:: + + Some proxies only support HTTP traffic. + Telethon by default does not transmit HTTP-encoded packets. + This means some HTTP-only proxies may not work. + + +Test servers +------------ + +While you cannot change the production data center assigned to your account, you can tell Telethon to connect to a different server. + +This is most useful to connect to the official Telegram test servers or `even your own `_. + +You need to import and define the :class:`session.DataCenter` to connect to when creating the :class:`Client`: + +.. code-block:: python + + from telethon import Client + from telethon.session import DataCenter + + client = Client(..., datacenter=DataCenter(id=2, ipv4_addr='149.154.167.40:443')) + +This will override the value coming from the :class:`~session.Session`. +You can get the test address for your account from `My Telegram `_. + +.. note:: + + Make sure the :doc:`sessions` you use for this client had not been created for the production servers before. + The library will attempt to use the existing authorization key saved based on the data center identifier. + This will most likely fail if you mix production and test servers. + + +There are public phone numbers anyone can use, with the following format: + +.. code-block:: + :caption: 99966XYYYY test phone number, X being the datacenter identifier and YYYY random digits + + 99966 X YYYY + \___/ \_/ \__/ + | | `- random number + | `- datacenter identifier + `- fixed digits + +For example, the test phone number 1234 for the datacenter 2 would be 9996621234. + +The confirmation code to complete the login is the datacenter identifier repeated five times, in this case, 22222. + +Therefore, it is possible to automate the login procedure, assuming the account exists and there is no 2-factor authentication: + +.. code-block:: python + + from random import randrange + from telethon import Client + from telethon.session import DataCenter + + datacenter = DataCenter(id=2, ipv4_addr='149.154.167.40:443') + phone = f'{randrange(1, 9999):04}' + login_code = str(datacenter.id) * 5 + client = Client(..., datacenter=datacenter) + + async with client: + if not await client.is_authorized(): + login_token = await client.request_login_code(phone_or_token) + await client.sign_in(login_token, login_code) diff --git a/client/doc/concepts/errors.rst b/client/doc/concepts/errors.rst index 10a2487a..67ea339a 100644 --- a/client/doc/concepts/errors.rst +++ b/client/doc/concepts/errors.rst @@ -9,7 +9,8 @@ In Telethon, a :term:`RPC error` corresponds to the :class:`RpcError` class. Telethon will only ever raise :class:`RpcError` when the result to a :term:`RPC` is an error. If the error is raised, you know it comes from Telegram. -Consequently, when using :term:`Raw API`, if a :class:`RpcError` occurs, it is never a bug in the library. +Consequently, when using :term:`Raw API` directly, if a :class:`RpcError` occurs, it is *extremely unlikely* to be a bug in the library. +When :class:`RpcError`\ s are raised using the :term:`Raw API`, Telegram is the one that decided an error should occur. :term:`RPC error` consist of an integer :attr:`~RpcError.code` and a string :attr:`~RpcError.name`. The :attr:`RpcError.code` is roughly the same as `HTTP status codes `_. @@ -25,16 +26,15 @@ It occurs when you have attempted to use a request too many times during a certa .. code-block:: python import asyncio - from telethon import RpcError + from telethon import errors try: await client.send_message('me', 'Spam') - except RpcError as e: - # If we get a flood error, sleep. Else, propagate the error. - if e.name == 'FLOOD_WAIT': - await asyncio.sleep(e.value) - else: - raise + except errors.FloodWait as e: + # A flood error; sleep. + await asyncio.sleep(e.value) Note that the library can automatically handle and retry on ``FLOOD_WAIT`` for you. Refer to the ``flood_sleep_threshold`` of the :class:`Client` to learn how. + +Refer to the documentation of the :data:`telethon.errors` pseudo-module for more details. diff --git a/client/doc/concepts/full-api.rst b/client/doc/concepts/full-api.rst index 572a5a16..ddbddc53 100644 --- a/client/doc/concepts/full-api.rst +++ b/client/doc/concepts/full-api.rst @@ -10,7 +10,7 @@ Telethon concedes to this fact and implements only commonly-used features to kee Access to the entirity of Telegram's API via Telethon's :term:`Raw API` is a necessary evil. The ``telethon._tl`` module has a leading underscore to signal that it is private. -It is not covered by the semver guarantees of the library, but you may need to use it regardless. +It is not covered by the `semver `_ guarantees of the library, but you may need to use it regardless. If the :class:`Client` doesn't offer a method for what you need, using the :term:`Raw API` is inevitable. diff --git a/client/doc/concepts/messages.rst b/client/doc/concepts/messages.rst index e1d8279e..bdfaf5f1 100644 --- a/client/doc/concepts/messages.rst +++ b/client/doc/concepts/messages.rst @@ -15,6 +15,43 @@ Messages Messages are at the heart of a messaging platform. In Telethon, you will be using the :class:`~types.Message` class to interact with them. + +Fetching messages +----------------- + +The most common way to actively fetch messages using the :meth:`Client.get_messages` method: + +.. code-block:: python + + # Get the last message in a chat (by setting the limit to 1). + last_message = (await client.get_messages(chat, 1))[0] + + # Iterate over all messages in a chat, starting from the oldest message (by using reversed). + async for message in reversed(client.get_messages(chat)): + print(message.sender.name, message.text_html) + +You can also perform a fuzzy text search with the :meth:`Client.search_messages` method. +The search will be performed server-side by Telegram, so the rules for how it works are also fuzzy. + +If you want to search for messages in all the chats you're part of, you can use :meth:`Client.search_all_messages`. + +Lastly, :meth:`Client.send_message` *also* returns the :class:`~types.Message` that you just sent. + +The most common way to passively listen to incoming messages is using the :class:`~events.NewMessage` event: + +.. code-block:: python + + from telethon import events + + @client.on(events.NewMessage) + async def first(event): + print(event.chat.name, ':', event.text) + +.. seealso:: + + The :doc:`updates` concept for an in-depth explanation on using events. + + .. _formatting: Formatting messages diff --git a/client/doc/concepts/sessions.rst b/client/doc/concepts/sessions.rst index 1ea37615..78f0a20d 100644 --- a/client/doc/concepts/sessions.rst +++ b/client/doc/concepts/sessions.rst @@ -4,7 +4,7 @@ Sessions .. currentmodule:: telethon In Telethon, the word :term:`session` is used to refer to the set of data needed to connect to Telegram. -This includes the server address of your home datacenter, as well as the authorization key bound to an account. +This includes the server address of your home data center, as well as the authorization key bound to an account. When you first connect to Telegram, an authorization key is generated to encrypt all communication. After login, Telegram remembers this authorization key as logged-in, so you don't need to login again. @@ -48,6 +48,9 @@ Telethon comes with two built-in storages: It's useful when you don't have file-system access. If you would like to store the session state in a different way, you can subclass :class:`session.Storage`. +You may also find `custom third-party session storages in Telethon's wiki `_. +Be careful with any third-party code you install, as they could steal the login credentials. +Only use session storages you trust, and pin the specific versions you have audited. Some Python installations do not have the ``sqlite3`` module. In this case, attempting to use the default :class:`~session.SqliteSession` will fail. diff --git a/client/doc/concepts/updates.rst b/client/doc/concepts/updates.rst index a01a6fd6..549d06e7 100644 --- a/client/doc/concepts/updates.rst +++ b/client/doc/concepts/updates.rst @@ -23,6 +23,55 @@ Telethon abstracts away Telegram updates with :mod:`~telethon.events`. With the above, you will see all warnings and errors and when they happened. +Listening to updates +-------------------- + +You can define and register your own functions to be called when certain :mod:`telethon.events` occur. + +The most common way is using the :meth:`Client.on` decorator to register your callback functions, often referred to as *handlers*: + +.. code-block:: python + + from telethon import Client, events + from telethon.events import filters + + bot = Client(...) + + @bot.on(events.NewMessage, filters.Command('/start')) + async def handler(event: events.NewMessage): + await event.respond('Beep boop!') + +The first parameter is the :class:`type` of one of the :mod:`telethon.events`, not an instance, so make sure you don't write parenthesis after it. + +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. + +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. +You don't need to explicitly set the type hint, but you can do so if you want your IDE to assist in autocompletion. + +If you cannot use decorators, you can use the :meth:`Client.add_event_handler` method instead. +The above code is equivalent to the following: + +.. code-block:: python + + from telethon import Client, events + from telethon.events import filters + + async def handler(event: events.NewMessage): + await event.respond('Beep boop!') + + bot = Client(...) + bot.add_event_handler(handler, events.NewMessage, filters.Command('/start')) + + +Note how the above lets you defined the :class:`Client` instance *after* your handlers. +In other words, you can define your handlers without the :class:`Client` instance. +This may make it easier to place them in a separate file. + + Filtering events ---------------- @@ -51,6 +100,12 @@ If you need state, you can use a class with a ``__call__`` method defined: .. code-block:: python + # Anonymous filter which only handles messages with ID = 1000 + client.add_event_handler(handler, events.NewMessage, lambda e: e.id == 1000) + # this parameter is the filter ^--------------------^ + + # ... + def only_odd_messages(event): "A filter that only handles messages when their ID is divisible by 2" return event.id % 2 == 0 @@ -75,6 +130,16 @@ 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. +The filters work all the same when using :meth:`Client.on`. +This makes it very convenient to write custom filters using the :keyword:`lambda` syntax: + +.. code-block:: python + + @client.on(events.NewMessage, lambda e: e.id == 1000) + async def handler(event): + ... + + Setting priority on handlers ---------------------------- @@ -100,13 +165,26 @@ 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: +If that's the case, you can :keyword:`return` :class:`events.Continue`: + +.. code-block:: python + + @client.on(events.NewMessage) + async def first(event): + print('This is always called on new messages!') + return events.Continue + + @client.on(events.NewMessage) + async def second(event): + print('Now this one runs as well!') + +Alternatively, if this is *always* the behaviour you want, you can configure it in the :class:`Client`: .. code-block:: python client = Client(..., check_all_handlers=True) # ^^^^^^^^^^^^^^^^^^^^^^^ - # Now the code above will call both handlers + # Now the code above will call both handlers, even without returning events.Continue 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/index.rst b/client/doc/index.rst index 2da0f545..e660f4e7 100644 --- a/client/doc/index.rst +++ b/client/doc/index.rst @@ -91,6 +91,7 @@ A more in-depth explanation of some of the concepts and words used in Telethon. concepts/errors concepts/botapi-vs-mtproto concepts/full-api + concepts/datacenters concepts/glossary diff --git a/client/doc/modules/client.rst b/client/doc/modules/client.rst index cf44d0cc..36fe8622 100644 --- a/client/doc/modules/client.rst +++ b/client/doc/modules/client.rst @@ -7,6 +7,6 @@ The :class:`Client` class is the "entry point" of the library. Most client methods have an alias in the respective types. For example, :meth:`Client.forward_messages` can also be invoked from :meth:`types.Message.forward`. -With a few exceptions, "client.verb_object" methods also exist as "object.verb". +With a few exceptions, *client.verb_object* methods also exist as *object.verb*. .. autoclass:: Client diff --git a/client/src/telethon/_impl/client/client/client.py b/client/src/telethon/_impl/client/client/client.py index 521e7181..63e091d2 100644 --- a/client/src/telethon/_impl/client/client/client.py +++ b/client/src/telethon/_impl/client/client/client.py @@ -152,6 +152,8 @@ class Client: :param catch_up: Whether to "catch up" on updates that occured while the client was not connected. + If :data:`True`, all updates that occured while the client was offline will trigger your :doc:`event handlers `. + :param check_all_handlers: Whether to always check all event handlers or stop early. @@ -159,21 +161,24 @@ class Client: 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. + Use :class:`telethon.events.Continue` instead if you only want this behaviour sometimes. :param flood_sleep_threshold: Maximum amount of time, in seconds, to automatically sleep before retrying a request. - This sleeping occurs when ``FLOOD_WAIT`` :class:`~telethon.RpcError` is raised by Telegram. + This sleeping occurs when ``FLOOD_WAIT`` (and similar) :class:`~telethon.RpcError`\ s are raised by Telegram. :param logger: Logger for the client. Any dependency of the client will use :meth:`logging.Logger.getChild`. This effectively makes the parameter the root logger. - The default will get the logger for the package name from the root. + The default will get the logger for the package name from the root (usually *telethon*). :param update_queue_limit: Maximum amount of updates to keep in memory before dropping them. + A warning will be logged on a cooldown if this limit is reached. + :param device_model: Device model. @@ -184,19 +189,19 @@ class Client: Application version. :param system_lang_code: - ISO 639-1 language code of the system's language. + `ISO 639-1 `_ language code of the system's language. :param lang_code: - ISO 639-1 language code of the application's language. + `ISO 639-1 `_ language code of the application's language. :param datacenter: - Override the datacenter to connect to. + Override the :doc:`data center ` to connect to. Useful to connect to one of Telegram's test servers. :param connector: Asynchronous function called to connect to a remote address. By default, this is :func:`asyncio.open_connection`. - In order to use proxies, you can set a custom connector. + In order to :doc:`use proxies `, you can set a custom connector. See :class:`~telethon._impl.mtsender.sender.Connector` for more details. """ @@ -330,7 +335,13 @@ class Client: .. code-block:: python - await client.bot_sign_in('12345:abc67DEF89ghi') + user = await client.bot_sign_in('12345:abc67DEF89ghi') + print('Signed in to bot account:', user.name) + + .. caution:: + + Be sure to check :meth:`is_authorized` before calling this function. + Signing in often when you don't need to will lead to :doc:`/concepts/errors`. .. seealso:: @@ -364,6 +375,7 @@ class Client: assert isinstance(password_token, PasswordToken) user = await client.check_password(password_token, '1-L0V3+T3l3th0n') + print('Signed in to 2FA-protected account:', user.name) .. seealso:: @@ -390,9 +402,9 @@ class Client: This lets you leave a group, unsubscribe from a channel, or delete a one-to-one private conversation. - Note that the group or channel will not be deleted. + Note that the group or channel will not be deleted (other users will remain in it). - Note that bot accounts do not have dialogs, so this method will fail. + Note that bot accounts do not have dialogs, so this method will fail when used in a bot account. :param chat: The :term:`chat` representing the dialog to delete. @@ -420,8 +432,8 @@ class Client: .. warning:: When deleting messages from private conversations or small groups, - this parameter is ignored. This means the *message_ids* may delete - messages in different chats. + this parameter is currently ignored. + This means the *message_ids* may delete messages in different chats. :param message_ids: The list of message identifiers to delete. @@ -437,11 +449,12 @@ class Client: .. code-block:: python # Delete two messages from chat for yourself - await client.delete_messages( + delete_count = await client.delete_messages( chat, [187481, 187482], revoke=False, ) + print('Deleted', delete_count, 'message(s)') .. seealso:: @@ -477,19 +490,19 @@ class Client: Note that the extension is not automatically added to the path. You can get the file extension with :attr:`telethon.types.File.ext`. - .. warning:: + .. caution:: - If the file already exists, it will be overwritten! + If the file already exists, it will be overwritten. .. rubric:: Example .. code-block:: python if photo := message.photo: - await client.download(photo, 'picture.jpg') + await client.download(photo, f'picture{photo.ext}') if video := message.video: - with open('video.mp4, 'wb') as file: + with open(f'video{video.ext}', 'wb') as file: await client.download(video, file) .. seealso:: @@ -530,15 +543,21 @@ class Client: .. code-block:: python - # Edit message to have text without formatting - await client.edit_message(chat, msg_id, text='New text') + # Set a draft with no formatting and print the date Telegram registered + draft = await client.edit_draft(chat, 'New text') + print('Set current draft on', draft.date) - # Remove the link preview without changing the text - await client.edit_message(chat, msg_id, link_preview=False) + # Set a draft using HTML formatting, with a reply, and enabling the link preview + await client.edit_draft( + chat, + html='Draft with reply an URL https://example.com', + reply_to=message_id, + link_preview=True + ) .. seealso:: - :meth:`telethon.types.Message.edit` + :meth:`telethon.types.Draft.edit` """ return await edit_draft( self, @@ -622,11 +641,12 @@ class Client: .. code-block:: python # Forward two messages from chat to the destination - await client.forward_messages( + messages = await client.forward_messages( destination, [187481, 187482], chat, ) + print('Forwarded', len(messages), 'message(s)') .. seealso:: @@ -704,6 +724,7 @@ class Client: .. code-block:: python + # Clear all drafts async for draft in client.get_drafts(): await draft.delete() """ @@ -794,10 +815,12 @@ class Client: offset_date: Optional[datetime.datetime] = None, ) -> AsyncList[Message]: """ - Get the message history from a :term:`chat`. + Get the message history from a :term:`chat`, from the newest message to the oldest. + + The returned iterator can be :func:`reversed` to fetch from the first to the last instead. :param chat: - The :term:`chat` where the message to edit is. + The :term:`chat` where the messages should be fetched from. :param limit: How many messages to fetch at most. @@ -818,12 +841,17 @@ class Client: # Get the last message in a chat last_message = (await client.get_messages(chat, 1))[0] + print(message.sender.name, last_message.text) # Print all messages before 2023 as HTML from datetime import datetime async for message in client.get_messages(chat, offset_date=datetime(2023, 1, 1)): print(message.sender.name, ':', message.html_text) + + # Print the first 10 messages in a chat as markdown + async for message in reversed(client.get_messages(chat)): + print(message.sender.name, ':', message.markdown_text) """ return get_messages( self, chat, limit, offset_id=offset_id, offset_date=offset_date @@ -859,9 +887,11 @@ class Client: """ Get the participants in a group or channel, along with their permissions. - Note that Telegram is rather strict when it comes to fetching members. - It is very likely that you will not be able to fetch all the members. - There is no way to bypass this. + .. note:: + + Telegram is rather strict when it comes to fetching members. + It is very likely that you will not be able to fetch all the members. + There is no way to bypass this. :param chat: The :term:`chat` to fetch participants from. @@ -955,11 +985,12 @@ class Client: .. code-block:: python + # Interactive login from the terminal me = await client.interactive_login() print('Logged in as:', me.name) - # or, to make sure you're logged-in as a bot - await client.interactive_login('1234:ab56cd78ef90) + # Automatic login to a bot account + await client.interactive_login('54321:hJrIQtVBab0M2Yqg4HL1K-EubfY_v2fEVR') .. seealso:: @@ -979,6 +1010,11 @@ class Client: if not await client.is_authorized(): ... # need to sign in + + .. seealso:: + + :meth:`get_me` can be used to fetch up-to-date information about :term:`yourself` + and check if you're logged-in at the same time. """ return await is_authorized(self) @@ -1075,8 +1111,7 @@ class Client: .. code-block:: python # Mark all messages as read - message = await client.read_message(chat, 'all') - await message.delete() + await client.read_message(chat, 'all') """ await read_message(self, chat, message_id) @@ -1100,18 +1135,12 @@ class Client: client.remove_event_handler(my_handler) else: print('still going!') - - .. seealso:: - - :meth:`add_event_handler`, used to register existing functions as event handlers. """ remove_event_handler(self, handler) async def request_login_code(self, phone: str) -> LoginToken: """ Request Telegram to send a login code to the provided phone number. - This is simply the opposite of :meth:`add_event_handler`. - Does nothing if the handler was not actually registered. :param phone: The phone number string, in international format. @@ -1126,6 +1155,11 @@ class Client: login_token = await client.request_login_code('+1 23 456...') print(login_token.timeout, 'seconds before code expires') + .. caution:: + + Be sure to check :meth:`is_authorized` before calling this function. + Signing in often when you don't need to will lead to :doc:`/concepts/errors`. + .. seealso:: :meth:`sign_in`, to complete the login procedure. @@ -1159,7 +1193,7 @@ class Client: Resolve a username into a :term:`chat`. This method is rather expensive to call. - It is recommended to use it once and then ``chat.pack()`` the result. + It is recommended to use it once and then :meth:`types.Chat.pack` the result. The packed chat can then be used (and re-fetched) more cheaply. :param username: @@ -1773,6 +1807,14 @@ class Client: @property def connected(self) -> bool: + """ + :data:`True` if :meth:`connect` has been called previously. + + This property will be set back to :data:`False` after calling :meth:`disconnect`. + + This property does *not* check whether the connection is alive. + The only way to check if the connection still works is to make a request. + """ return connected(self) def _build_message_map( diff --git a/client/src/telethon/_impl/client/client/messages.py b/client/src/telethon/_impl/client/client/messages.py index ca104c24..0602b2ca 100644 --- a/client/src/telethon/_impl/client/client/messages.py +++ b/client/src/telethon/_impl/client/client/messages.py @@ -2,7 +2,7 @@ from __future__ import annotations import datetime import sys -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Self, Tuple, Union from ...session import PackedChat from ...tl import abcs, functions, types @@ -250,37 +250,36 @@ async def forward_messages( class MessageList(AsyncList[Message]): + def __init__(self) -> None: + super().__init__() + self._reversed = False + def _extend_buffer( self, client: Client, messages: abcs.messages.Messages ) -> Dict[int, Chat]: - if isinstance(messages, types.messages.Messages): - chat_map = build_chat_map(messages.users, messages.chats) - self._buffer.extend( - Message._from_raw(client, m, chat_map) for m in messages.messages - ) - self._total = len(messages.messages) - self._done = True - return chat_map - elif isinstance(messages, types.messages.MessagesSlice): - chat_map = build_chat_map(messages.users, messages.chats) - self._buffer.extend( - Message._from_raw(client, m, chat_map) for m in messages.messages - ) - self._total = messages.count - return chat_map - elif isinstance(messages, types.messages.ChannelMessages): - chat_map = build_chat_map(messages.users, messages.chats) - self._buffer.extend( - Message._from_raw(client, m, chat_map) for m in messages.messages - ) - self._total = messages.count - return chat_map - elif isinstance(messages, types.messages.MessagesNotModified): + if isinstance(messages, types.messages.MessagesNotModified): self._total = messages.count return {} + + if isinstance(messages, types.messages.Messages): + self._total = len(messages.messages) + self._done = True + elif isinstance( + messages, (types.messages.MessagesSlice, types.messages.ChannelMessages) + ): + self._total = messages.count else: raise RuntimeError("unexpected case") + chat_map = build_chat_map(messages.users, messages.chats) + self._buffer.extend( + Message._from_raw(client, m, chat_map) + for m in ( + reversed(messages.messages) if self._reversed else messages.messages + ) + ) + return chat_map + def _last_non_empty_message( self, ) -> Union[types.Message, types.MessageService, types.MessageEmpty]: @@ -320,13 +319,14 @@ class HistoryList(MessageList): await self._client._resolve_to_packed(self._chat) )._to_input_peer() + limit = min(max(self._limit, 1), 100) result = await self._client( functions.messages.get_history( peer=self._peer, offset_id=self._offset_id, offset_date=self._offset_date, - add_offset=0, - limit=min(max(self._limit, 1), 100), + add_offset=-limit if self._reversed else 0, + limit=limit, max_id=0, min_id=0, hash=0, @@ -338,9 +338,20 @@ class HistoryList(MessageList): self._done |= not self._limit if self._buffer and not self._done: last = self._last_non_empty_message() - self._offset_id = self._buffer[-1].id - if (date := getattr(last, "date", None)) is not None: - self._offset_date = date + self._offset_id = last.id + (1 if self._reversed else 0) + self._offset_date = 0 + + def __reversed__(self) -> Self: + new = self.__class__( + self._client, + self._chat, + self._limit, + offset_id=1 if self._offset_id == 0 else self._offset_id, + offset_date=self._offset_date, + ) + new._peer = self._peer + new._reversed = not self._reversed + return new def get_messages( diff --git a/client/src/telethon/_impl/client/client/updates.py b/client/src/telethon/_impl/client/client/updates.py index b2422327..0534baef 100644 --- a/client/src/telethon/_impl/client/client/updates.py +++ b/client/src/telethon/_impl/client/client/updates.py @@ -14,6 +14,7 @@ from typing import ( from ...session import Gap from ...tl import abcs +from ..events import Continue from ..events import Event as EventBase from ..events.filters import Filter from ..types import build_chat_map @@ -152,6 +153,6 @@ async def dispatch_next(client: Client) -> None: 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: + ret = await handler(event) + if ret is Continue or client._shortcircuit_handlers: return diff --git a/client/src/telethon/_impl/client/events/__init__.py b/client/src/telethon/_impl/client/events/__init__.py index eda5c327..e386f413 100644 --- a/client/src/telethon/_impl/client/events/__init__.py +++ b/client/src/telethon/_impl/client/events/__init__.py @@ -1,8 +1,9 @@ -from .event import Event +from .event import Continue, Event from .messages import MessageDeleted, MessageEdited, MessageRead, NewMessage from .queries import ButtonCallback, InlineQuery __all__ = [ + "Continue", "Event", "MessageDeleted", "MessageEdited", diff --git a/client/src/telethon/_impl/client/events/event.py b/client/src/telethon/_impl/client/events/event.py index 9b0e25ce..40ee4d3c 100644 --- a/client/src/telethon/_impl/client/events/event.py +++ b/client/src/telethon/_impl/client/events/event.py @@ -28,3 +28,35 @@ class Event(metaclass=NoPublicConstructor): cls, client: Client, update: abcs.Update, chat_map: Dict[int, Chat] ) -> Optional[Self]: pass + + +class Continue: + """ + This is **not** an event type you can listen to. + + This is a sentinel value used to signal that the library should *Continue* calling other handlers. + + You can :keyword:`return` this from your handlers if you want handlers registered after to also run. + + The primary use case is having asynchronous filters inside your handler: + + .. code-block:: python + + from telethon import events + + @client.on(events.NewMessage) + async def admin_only_handler(event): + allowed = await database.is_user_admin(event.sender.id) + if not allowed: + # this user is not allowed, fall-through the handlers + return events.Continue + + @client.on(events.NewMessage) + async def everyone_else_handler(event): + ... # runs if admin_only_handler was not allowed + """ + + def __init__(self) -> None: + raise TypeError( + f"Can't instantiate {self.__class__.__name__} class (the type is the sentinel value; remove the parenthesis)" + ) diff --git a/client/src/telethon/_impl/client/events/messages.py b/client/src/telethon/_impl/client/events/messages.py index 027c73d2..f5139209 100644 --- a/client/src/telethon/_impl/client/events/messages.py +++ b/client/src/telethon/_impl/client/events/messages.py @@ -14,7 +14,7 @@ class NewMessage(Event, Message): """ Occurs when a new message is sent or received. - .. warning:: + .. caution:: Messages sent with the :class:`~telethon.Client` are also caught, so be careful not to enter infinite loops! diff --git a/client/src/telethon/_impl/client/parsers/markdown.py b/client/src/telethon/_impl/client/parsers/markdown.py index 4701d531..8466d847 100644 --- a/client/src/telethon/_impl/client/parsers/markdown.py +++ b/client/src/telethon/_impl/client/parsers/markdown.py @@ -134,7 +134,7 @@ def parse(message: str) -> Tuple[str, List[MessageEntity]]: elif token.type in ("s_close", "s_open"): push(MessageEntityStrike) elif token.type == "softbreak": - message += " " + message += "\n" elif token.type in ("strong_close", "strong_open"): push(MessageEntityBold) elif token.type == "text": diff --git a/client/src/telethon/_impl/mtsender/sender.py b/client/src/telethon/_impl/mtsender/sender.py index c2ee7e8e..f0e20755 100644 --- a/client/src/telethon/_impl/mtsender/sender.py +++ b/client/src/telethon/_impl/mtsender/sender.py @@ -105,6 +105,10 @@ class Connector(Protocol): default_connector = lambda ip, port: asyncio.open_connection(ip, port) If your connector needs additional parameters, you can use either the :keyword:`lambda` syntax or :func:`functools.partial`. + + .. seealso:: + + The :doc:`/concepts/datacenters` concept has examples on how to combine proxy libraries with Telethon. """ async def __call__(self, ip: str, port: int) -> Tuple[AsyncReader, AsyncWriter]: diff --git a/client/src/telethon/events/__init__.py b/client/src/telethon/events/__init__.py index a2ec3224..c8c04910 100644 --- a/client/src/telethon/events/__init__.py +++ b/client/src/telethon/events/__init__.py @@ -7,6 +7,7 @@ Classes related to the different event types that wrap incoming Telegram updates """ from .._impl.client.events import ( ButtonCallback, + Continue, Event, InlineQuery, MessageDeleted, @@ -17,6 +18,7 @@ from .._impl.client.events import ( __all__ = [ "ButtonCallback", + "Continue", "Event", "InlineQuery", "MessageDeleted",