From 7fabf7da0a072f256197c7bf6cd2c8a058b35b49 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 8 Oct 2023 15:07:18 +0200 Subject: [PATCH] Continue documentation and reducing public API --- DEVELOPING.md | 23 +- client/doc/concepts/botapi-vs-mtproto.rst | 3 +- client/doc/concepts/full-api.rst | 2 +- client/doc/concepts/glossary.rst | 2 + client/doc/concepts/messages.rst | 2 + client/doc/conf.py | 3 + client/doc/developing/coding-style.rst | 17 - ...project-structure.rst => contributing.rst} | 95 +++++- client/doc/developing/migration-guide.rst | 71 ++-- client/doc/developing/philosophy.rst | 10 - client/doc/index.rst | 4 +- client/doc/modules/client.rst | 3 - client/doc/modules/sessions.rst | 22 +- client/src/telethon/__init__.py | 8 +- .../src/telethon/_impl/client/client/auth.py | 12 +- .../src/telethon/_impl/client/client/bots.py | 62 +--- .../telethon/_impl/client/client/client.py | 320 +++++++++++++----- .../src/telethon/_impl/client/client/files.py | 277 +++++++++------ .../src/telethon/_impl/client/client/net.py | 138 ++++---- .../telethon/_impl/client/client/updates.py | 18 +- .../src/telethon/_impl/client/client/users.py | 6 +- .../_impl/client/events/filters/messages.py | 2 +- .../telethon/_impl/client/types/__init__.py | 5 +- .../src/telethon/_impl/client/types/file.py | 225 ++---------- .../_impl/client/types/inline_result.py | 80 +++++ .../telethon/_impl/client/types/message.py | 27 +- client/src/telethon/_impl/mtsender/sender.py | 17 +- .../src/telethon/_impl/session/chat/packed.py | 3 + client/src/telethon/_impl/session/session.py | 130 +++---- .../telethon/_impl/session/storage/storage.py | 9 +- client/src/telethon/events/__init__.py | 5 + client/src/telethon/session.py | 5 + client/src/telethon/types.py | 3 + client/tests/client_test.py | 4 +- client/tests/mtsender_test.py | 9 +- 35 files changed, 887 insertions(+), 735 deletions(-) delete mode 100644 client/doc/developing/coding-style.rst rename client/doc/developing/{project-structure.rst => contributing.rst} (62%) delete mode 100644 client/doc/developing/philosophy.rst create mode 100644 client/src/telethon/_impl/client/types/inline_result.py diff --git a/DEVELOPING.md b/DEVELOPING.md index 43778762..56239b3a 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -1,22 +1,3 @@ -Code generation: +# Developing -```sh -pip install -e generator/ -python tools/codegen.py -``` - -Formatting, type-checking and testing: - -```sh -pip install -e client/[dev] -python tools/check.py -``` - -Documentation (requires [sphinx](https://www.sphinx-doc.org) and [graphviz](https://www.graphviz.org)'s `dot`): - -```sh -pip install -e client/[doc] -python tools/docgen.py -``` - -Note that multiple optional dependency sets can be specified by separating them with a comma (`[dev,doc]`). +See [Contributing](./client/doc/developing/contributing.rst). diff --git a/client/doc/concepts/botapi-vs-mtproto.rst b/client/doc/concepts/botapi-vs-mtproto.rst index f72c71d1..e8459c3f 100644 --- a/client/doc/concepts/botapi-vs-mtproto.rst +++ b/client/doc/concepts/botapi-vs-mtproto.rst @@ -352,7 +352,8 @@ For the most part, it's a 1-to-1 translation and the result is idiomatic Teletho Migrating from aiogram -`````````````````````` +^^^^^^^^^^^^^^^^^^^^^^ + Using one of the examples from their v3 documentation with logging and comments removed: .. code-block:: python diff --git a/client/doc/concepts/full-api.rst b/client/doc/concepts/full-api.rst index 0bfd75c8..572a5a16 100644 --- a/client/doc/concepts/full-api.rst +++ b/client/doc/concepts/full-api.rst @@ -55,7 +55,7 @@ To check for a concrete type, you can use :func:`isinstance`: if isinstance(invite, tl.types.ChatInviteAlready): print(invite.chat) -The ``telethon._tl`` module is not documented here because it would result in tens of megabytes. +The ``telethon._tl`` module is not documented here because it would greatly bloat the documentation and make search harder. Instead, there are multiple alternatives: * Use Telethon's separate site to search in the `Telethon Raw API `_. diff --git a/client/doc/concepts/glossary.rst b/client/doc/concepts/glossary.rst index 784ad047..e5b5904d 100644 --- a/client/doc/concepts/glossary.rst +++ b/client/doc/concepts/glossary.rst @@ -66,3 +66,5 @@ Glossary Type Language File format used by Telegram to define all the types and requests available in a :term:`layer`. Telegram's site has an `Overview of the TL language `_. + + .. seealso:: :ref:`Type Language brief `. diff --git a/client/doc/concepts/messages.rst b/client/doc/concepts/messages.rst index c0539136..76b2177d 100644 --- a/client/doc/concepts/messages.rst +++ b/client/doc/concepts/messages.rst @@ -15,6 +15,8 @@ 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. +.. _formatting: + Formatting messages ------------------- diff --git a/client/doc/conf.py b/client/doc/conf.py index 38bd3a59..c289a6ef 100644 --- a/client/doc/conf.py +++ b/client/doc/conf.py @@ -23,6 +23,7 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.graphviz", + "sphinx.ext.coverage", "roles.tl", ] @@ -31,7 +32,9 @@ tl_ref_url = "https://tl.telethon.dev" autodoc_default_options = { "members": True, "undoc-members": True, + "show-inheritance": True, } +autodoc_typehints = "description" modindex_common_prefix = ["telethon."] graphviz_output_format = "svg" diff --git a/client/doc/developing/coding-style.rst b/client/doc/developing/coding-style.rst deleted file mode 100644 index fd9fce04..00000000 --- a/client/doc/developing/coding-style.rst +++ /dev/null @@ -1,17 +0,0 @@ -Coding style -============ - -Knowledge of Python is a obviously a must to develop a Python library. -A good online resource is `Dive Into Python 3 `_. - -Telethon uses multiple tools to automatically format the code and check for linting rules. -This means you can simply ignore formatting and let the tools handle it for you. -You can find these tools under the ``tools/`` folder. - -The documentation is written with mostly a newline after every period. -This is not a hard rule. -Lines can be cut earlier if they become too long to be comfortable. - -Commit messages should be short and descriptive. -They should start with an action in the present ("Fix" and not "Fixed"). -This saves a few characters and represents what the commit will "do" after applied. diff --git a/client/doc/developing/project-structure.rst b/client/doc/developing/contributing.rst similarity index 62% rename from client/doc/developing/project-structure.rst rename to client/doc/developing/contributing.rst index 1821ddd5..d78b26b8 100644 --- a/client/doc/developing/project-structure.rst +++ b/client/doc/developing/contributing.rst @@ -1,5 +1,50 @@ -Project Structure -================= +Contributing +============ + +Telethon welcomes all new contributions, whether it's reporting bugs or sending code patches. + +Please keep both the philosophy and coding style below in mind. + +Be mindful when adding new features. +Every new feature must be understood by the maintainer, or otherwise it will probably rot. +The *usefulness : maintenance-cost* ratio must be high enough to warrant being built-in. +Consider whether your new features could be a separate add-on project entirely. + + +Philosophy +---------- + +* Dependencies should only be added when absolutely necessary. +* Dependencies written in anything other than Python cannot be mandatory. +* The library must work correctly with no system dependencies other than Python 3. +* Strict type-checking is required to pass everywhere in the library to make upgrades easier. +* The code structure must make use of hard and clear boundaries to keep the different parts decoupled. +* The API should cover only the most commonly used features to avoid bloat and reduce maintenance costs. +* Documentation must be a pleasure to use and contain plenty of code examples. + + +Coding style +------------ + +Knowledge of Python is a obviously a must to develop a Python library. +A good online resource is `Dive Into Python 3 `_. + +Telethon uses multiple tools to automatically format the code and check for linting rules. +This means you can simply ignore formatting and let the tools handle it for you. +You can find these tools under the ``tools/`` folder. +See :ref:`tools` below for an explanation. + +The documentation is written with mostly a newline after every period. +This is not a hard rule. +Lines can be cut earlier if they become too long to be comfortable. + +Commit messages should be short and descriptive. +They should start with an action in the present ("Fix" and not "Fixed"). +This saves a few characters and represents what the commit will "do" after applied. + + +Project structure +----------------- .. currentmodule:: telethon @@ -7,29 +52,61 @@ The repository contains several folders, each with their own "package". benches/ --------- +^^^^^^^^ This folder contains different benchmarks. Pretty straightforward. stubs/ ------- +^^^^^^ If a dependency doesn't support typing, files here must work around that. +.. _tools: tools/ ------- +^^^^^^ Various utility scripts. Each script should have a "comment" at the top explaining what they are for. -See ``DEVELOPING.md`` in the repository root to learn how to use some of the tools. +Code generation +""""""""""""""" +This will take ``api.tl`` and ``mtproto.tl`` files and generate ``client/_impl/tl``. + +.. code-block:: sh + + pip install -e generator/ + python tools/codegen.py + +Linting +""""""" + +This includes format checks, type-checking and testing. + +.. code-block:: sh + + pip install -e client/[dev] + python tools/check.py + +Documentation +""""""""""""" + +Requires `sphinx `_ and `graphviz `_'s ``dot``. + +.. code-block:: sh + + pip install -e client/[doc] + python tools/docgen.py + +Note that multiple optional dependency sets can be specified by separating them with a comma (``[dev,doc]``). + +.. _tl-brief: generator/ ----------- +^^^^^^^^^^ A package that should not be published and is only used when developing the library. The implementation is private and exists under the ``src/*/_impl/`` folder. @@ -72,11 +149,11 @@ An in-memory "filesystem" structure is kept before writing all files to disk. This makes it possible to execute most of the process in a sans-io manner. Once the code generation finishes, all files are written to disk at once. -See ``DEVELOPING.md`` in the repository root to learn how to generate code. +See :ref:`tools` above to learn how to generate code. client/ -------- +^^^^^^^ The Telethon client library and documentation lives here. This is the package that gets published. diff --git a/client/doc/developing/migration-guide.rst b/client/doc/developing/migration-guide.rst index 4ad28959..557cd6a5 100644 --- a/client/doc/developing/migration-guide.rst +++ b/client/doc/developing/migration-guide.rst @@ -64,6 +64,21 @@ This was also a good opportunity to remove a lot of modules that were not suppos ``.crypto``, ``.extensions``, ``.network``, ``.custom``, ``.functions``, ``.helpers``, ``.hints``, ``.password``, ``.requestiter``, ``.sync``, ``.types``, ``.utils``. +TelegramClient renamed to Client +-------------------------------- + +You can rename it with :keyword:`as` during import if you want to use the old name. + +Python allows using namespaces via packages and modules. +Therefore, the full name :class:`telethon.Client` already indicates it's from ``telethon``, so the old ``Telegram`` prefix was redundant. + + +No telethon.sync hack +--------------------- + +You can no longer ``import telethon.sync`` to have most calls wrapped in :meth:`asyncio.loop.run_until_complete` for you. + + Raw API is now private ---------------------- @@ -140,6 +155,27 @@ Functions no longer have an asynchronous ``.resolve()``. This used to let you pass usernames and have them be resolved to :tl:`InputPeer` automatically (unless it was nested). +Changes to start and client context-manager +------------------------------------------- + +You can no longer ``start()`` the client. + +Instead, you will need to first :meth:`~Client.connect` and then start the :meth:`~Client.interactive_login`. + +In v1, the when using the client as a context-manager, ``start()`` was called. +Since that method no longer exists, it now instead only :meth:`~Client.connect` and :meth:`~Client.disconnect`. + +This means you won't get annoying prompts in your terminal if the session was not authorized. +It also means you can now use the context manager even with custom login flows. + +The old ``sign_in()`` method also sent the code, which was rather confusing. +Instead, you must now :meth:`~Client.request_login_code` as a separate operation. + +The old ``log_out()`` was also renamed to :meth:`~Client.sign_out` for consistency with :meth:`~Client.sign_in`. + +The old ``is_user_authorized()`` was renamed to :meth:`~Client.is_authorized` since it works for bot accounts too. + + Unified client iter and get methods ----------------------------------- @@ -451,38 +487,3 @@ StringSession no longer exists If you need to serialize the session data to a string, you can use something like `jsonpickle `_. Or even the built-in :mod:`pickle` followed by :mod:`base64` or just :meth:`bytes.hex`. But be aware that these approaches probably will not be compatible with additions to the :class:`~session.Session`. - - -TelegramClient renamed to Client --------------------------------- - -You can rename it with :keyword:`as` during import if you want to use the old name. - -Python allows using namespaces via packages and modules. -Therefore, the full name :class:`telethon.Client` already indicates it's from ``telethon``, so the old ``Telegram`` prefix was redundant. - - -Changes to start and client context-manager -------------------------------------------- - -You can no longer ``start()`` the client. - -Instead, you will need to first :meth:`~Client.connect` and then start the :meth:`~Client.interactive_login`. - -In v1, the when using the client as a context-manager, ``start()`` was called. -Since that method no longer exists, it now instead only :meth:`~Client.connect` and :meth:`~Client.disconnect`. - -This means you won't get annoying prompts in your terminal if the session was not authorized. -It also means you can now use the context manager even with custom login flows. - -The old ``sign_in()`` method also sent the code, which was rather confusing. -Instead, you must now :meth:`~Client.request_login_code` as a separate operation. - -The old ``log_out()`` was also renamed to :meth:`~Client.sign_out` for consistency with :meth:`~Client.sign_in`. - -The old ``is_user_authorized()`` was renamed to :meth:`~Client.is_authorized` since it works for bot accounts too. - -No telethon.sync hack ---------------------- - -You can no longer ``import telethon.sync`` to have most calls wrapped in :meth:`asyncio.loop.run_until_complete` for you. diff --git a/client/doc/developing/philosophy.rst b/client/doc/developing/philosophy.rst deleted file mode 100644 index 435d6150..00000000 --- a/client/doc/developing/philosophy.rst +++ /dev/null @@ -1,10 +0,0 @@ -Philosophy -========== - -* Dependencies should only be added when absolutely necessary. -* Dependencies written in anything other than Python cannot be mandatory. -* The library must work correctly with no system dependencies other than Python 3. -* Strict type-checking is required to pass everywhere in the library to make upgrades easier. -* The code structure must make use of hard and clear boundaries to keep the different parts decoupled. -* The API should cover only the most commonly used features to avoid bloat and reduce maintenance costs. -* Documentation must be a pleasure to use and contain plenty of code examples. diff --git a/client/doc/index.rst b/client/doc/index.rst index a3d7dc2e..e1b32122 100644 --- a/client/doc/index.rst +++ b/client/doc/index.rst @@ -123,6 +123,4 @@ Tips and tricks to develop both with the library and for the library. developing/changelog developing/migration-guide developing/faq - developing/philosophy.rst - developing/coding-style.rst - developing/project-structure.rst + developing/contributing diff --git a/client/doc/modules/client.rst b/client/doc/modules/client.rst index ac028519..7df92f57 100644 --- a/client/doc/modules/client.rst +++ b/client/doc/modules/client.rst @@ -1,4 +1 @@ -Client -====== - .. autoclass:: telethon.Client diff --git a/client/doc/modules/sessions.rst b/client/doc/modules/sessions.rst index 6426e19a..aedfcd80 100644 --- a/client/doc/modules/sessions.rst +++ b/client/doc/modules/sessions.rst @@ -1,4 +1,20 @@ -Session storages -================ +Sessions +======== -.. automodule:: telethon.session +.. currentmodule:: telethon.session + +Storages +-------- + +.. autoclass:: Storage +.. autoclass:: SqliteSession +.. autoclass:: MemorySession + +Types +----- + +.. autoclass:: Session +.. autoclass:: DataCenter +.. autoclass:: User +.. autoclass:: UpdateState +.. autoclass:: ChannelState diff --git a/client/src/telethon/__init__.py b/client/src/telethon/__init__.py index fb1528e9..2ad99319 100644 --- a/client/src/telethon/__init__.py +++ b/client/src/telethon/__init__.py @@ -1,8 +1,10 @@ +""" +The main package for the Telethon library. +""" from ._impl import tl as _tl -from ._impl.client import Client, Config +from ._impl.client import Client from ._impl.client.errors import errors from ._impl.mtproto import RpcError -from ._impl.session import Session from .version import __version__ -__all__ = ["_tl", "Client", "Config", "errors", "RpcError", "Session"] +__all__ = ["_tl", "Client", "errors", "RpcError"] diff --git a/client/src/telethon/_impl/client/client/auth.py b/client/src/telethon/_impl/client/client/auth.py index 915352fa..baf4a927 100644 --- a/client/src/telethon/_impl/client/client/auth.py +++ b/client/src/telethon/_impl/client/client/auth.py @@ -9,7 +9,7 @@ from ...mtproto import RpcError from ...session import User as SessionUser from ...tl import abcs, functions, types from ..types import LoginToken, PasswordToken, User -from .net import connect_sender +from .net import connect_sender, datacenter_for_id if TYPE_CHECKING: from .client import Client @@ -26,11 +26,12 @@ async def is_authorized(self: Client) -> bool: async def complete_login(client: Client, auth: abcs.auth.Authorization) -> User: + assert client._sender assert isinstance(auth, types.auth.Authorization) assert isinstance(auth.user, types.User) user = User._from_raw(auth.user) - client._config.session.user = SessionUser( - id=user.id, dc=client._dc_id, bot=user.bot, username=user.username + client._session.user = SessionUser( + id=user.id, dc=client._sender.dc_id, bot=user.bot, username=user.username ) packed = user.pack() @@ -48,10 +49,11 @@ async def complete_login(client: Client, auth: abcs.auth.Authorization) -> User: async def handle_migrate(client: Client, dc_id: Optional[int]) -> None: assert dc_id is not None - sender = await connect_sender(dc_id, client._config) + sender, client._session.dcs = await connect_sender( + client._config, datacenter_for_id(client, dc_id) + ) async with client._sender_lock: client._sender = sender - client._dc_id = dc_id async def bot_sign_in(self: Client, token: str) -> User: diff --git a/client/src/telethon/_impl/client/client/bots.py b/client/src/telethon/_impl/client/client/bots.py index 42271483..8b47ab30 100644 --- a/client/src/telethon/_impl/client/client/bots.py +++ b/client/src/telethon/_impl/client/client/bots.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, AsyncIterator, List, Optional, Self, Union +from typing import TYPE_CHECKING, AsyncIterator, List, Optional, Self from ...tl import abcs, functions, types -from ..types import ChatLike, Message, NoPublicConstructor +from ..types import ChatLike, InlineResult, NoPublicConstructor from ..utils import generate_random_id if TYPE_CHECKING: @@ -64,64 +64,6 @@ class InlineResults(metaclass=NoPublicConstructor): return self._buffer.pop() -class InlineResult(metaclass=NoPublicConstructor): - def __init__( - self, - client: Client, - results: types.messages.BotResults, - result: Union[types.BotInlineMediaResult, types.BotInlineResult], - default_peer: abcs.InputPeer, - ): - self._client = client - self._raw_results = results - self._raw = result - self._default_peer = default_peer - - @property - def type(self) -> str: - return self._raw.type - - @property - def title(self) -> str: - return self._raw.title or "" - - @property - def description(self) -> Optional[str]: - return self._raw.description - - async def send( - self, - chat: Optional[ChatLike], - ) -> Message: - if chat is None and isinstance(self._default_peer, types.InputPeerEmpty): - raise ValueError("no target chat was specified") - - if chat is not None: - peer = (await self._client._resolve_to_packed(chat))._to_input_peer() - else: - peer = self._default_peer - - random_id = generate_random_id() - return self._client._build_message_map( - await self._client( - functions.messages.send_inline_bot_result( - silent=False, - background=False, - clear_draft=False, - hide_via=False, - peer=peer, - reply_to=None, - random_id=random_id, - query_id=self._raw_results.query_id, - id=self._raw.id, - schedule_date=None, - send_as=None, - ) - ), - peer, - ).with_random_id(random_id) - - async def inline_query( self: Client, bot: ChatLike, query: str = "", *, chat: Optional[ChatLike] = None ) -> AsyncIterator[InlineResult]: diff --git a/client/src/telethon/_impl/client/client/client.py b/client/src/telethon/_impl/client/client/client.py index 1de75c9b..54a4c111 100644 --- a/client/src/telethon/_impl/client/client/client.py +++ b/client/src/telethon/_impl/client/client/client.py @@ -18,6 +18,9 @@ from typing import ( Union, ) +from telethon._impl.session.session import DataCenter + +from ....version import __version__ as default_version from ...mtsender import Sender from ...session import ( ChatHashCache, @@ -93,6 +96,8 @@ from .net import ( Config, connect, connected, + default_device_model, + default_system_version, disconnect, invoke_request, run_until_disconnected, @@ -137,9 +142,13 @@ class Client: :param api_id: The API ID. See :doc:`/basic/signing-in` to learn how to obtain it. + This is required to initialize the connection. + :param api_hash: The API hash. See :doc:`/basic/signing-in` to learn how to obtain it. + This is required to sign in, and can be omitted otherwise. + :param device_model: Device model. @@ -158,8 +167,8 @@ class Client: :param catch_up: Whether to "catch up" on updates that occured while the client was not connected. - :param server_addr: - Override the server address ``'ip:port'`` pair to connect to. + :param datacenter: + Override the datacenter to connect to. Useful to connect to one of Telegram's test servers. :param flood_sleep_threshold: @@ -183,23 +192,45 @@ class Client: session: Optional[Union[str, Path, Storage]], api_id: int, api_hash: Optional[str] = None, + *, + device_model: Optional[str] = None, + system_version: Optional[str] = None, + app_version: Optional[str] = None, + system_lang_code: Optional[str] = None, + lang_code: Optional[str] = None, + catch_up: Optional[bool] = None, + datacenter: Optional[DataCenter] = None, + flood_sleep_threshold: Optional[int] = None, + update_queue_limit: Optional[int] = None, check_all_handlers: bool = False, ) -> None: self._sender: Optional[Sender] = None self._sender_lock = asyncio.Lock() - self._dc_id = DEFAULT_DC if isinstance(session, Storage): self._storage = session elif session is None: self._storage = MemorySession() else: self._storage = SqliteSession(session) + self._config = Config( - session=Session(), api_id=api_id, api_hash=api_hash or "", + device_model=device_model or default_device_model(), + system_version=system_version or default_system_version(), + app_version=app_version or default_version, + system_lang_code=system_lang_code or "en", + lang_code=lang_code or "en", + catch_up=catch_up or False, + datacenter=datacenter, + flood_sleep_threshold=60 + if flood_sleep_threshold is None + else flood_sleep_threshold, + update_queue_limit=update_queue_limit, ) + self._session = Session() + self._message_box = MessageBox() self._chat_hashes = ChatHashCache(None) self._last_update_limit_warn: Optional[float] = None @@ -212,10 +243,8 @@ class Client: ] = {} self._shortcircuit_handlers = not check_all_handlers - if self_user := self._config.session.user: - self._dc_id = self_user.dc - if self._config.catch_up and self._config.session.state: - self._message_box.load(self._config.session.state) + if self._session.user and self._config.catch_up and self._session.state: + self._message_box.load(self._session.state) # Begin partially @generated @@ -468,7 +497,10 @@ class Client: :param message_id: The identifier of the message to edit. - The rest of parameters behave the same as they do in `send_message` or `send_file`. + :param text: See :ref:`formatting `. + :param markdown: See :ref:`formatting `. + :param html: See :ref:`formatting `. + :param link_preview: See :ref:`formatting `. :return: The edited message. @@ -728,6 +760,27 @@ class Client: def get_messages_with_ids( self, chat: ChatLike, message_ids: List[int] ) -> AsyncList[Message]: + """ + Get the full message objects from the corresponding message identifiers. + + :param chat: + The :term:`chat` where the message to fetch is. + + :param message_ids: + The message identifiers of the messages to fetch. + + :return: + The matching messages. + The order of the returned messages is *not* guaranteed to match the input. + The method may return less messages than requested when some are missing. + + .. rubric:: Example + + .. code-block:: python + + # Get the first message (after "Channel created") of the chat + first_message = (await client.get_messages_with_ids(chat, [2]))[0] + """ return get_messages_with_ids(self, chat, message_ids) def get_participants(self, chat: ChatLike) -> AsyncList[Participant]: @@ -738,6 +791,9 @@ class Client: 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. + :return: The participants. .. rubric:: Example @@ -753,6 +809,9 @@ class Client: """ Get the profile pictures set in a chat, or user avatars. + :param chat: + The :term:`chat` to fetch the profile photo files from. + :return: The photo files. .. rubric:: Example @@ -990,9 +1049,32 @@ class Client: return await resolve_to_packed(self, chat) async def resolve_username(self, username: str) -> Chat: + """ + 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. + The packed chat can then be used (and re-fetched) more cheaply. + + :param username: + The public "@username" to resolve. + + :return: The matching chat. + + .. rubric:: Example + + .. code-block:: python + + print(await client.resolve_username('@cat')) + """ return await resolve_username(self, username) async def run_until_disconnected(self) -> None: + """ + Keep running the library until a disconnection occurs. + + Connection errors will be raised from this method if they occur. + """ await run_until_disconnected(self) def search_all_messages( @@ -1084,10 +1166,8 @@ class Client: async def send_audio( self, chat: ChatLike, - path: Optional[Union[str, Path, File]] = None, + file: Union[str, Path, InFileLike, File], *, - url: Optional[str] = None, - file: Optional[InFileLike] = None, size: Optional[int] = None, name: Optional[str] = None, duration: Optional[float] = None, @@ -1105,12 +1185,18 @@ class Client: duration, title and performer if they are not provided. :param chat: - The :term:`chat` where the message will be sent to. + The :term:`chat` where the audio media will be sent to. - :param path: - A local file path or :class:`~telethon.types.File` to send. - - The rest of parameters behave the same as they do in :meth:`send_file`. + :param file: See :meth:`send_file`. + :param size: See :meth:`send_file`. + :param name: See :meth:`send_file`. + :param duration: See :meth:`send_file`. + :param voice: See :meth:`send_file`. + :param title: See :meth:`send_file`. + :param performer: See :meth:`send_file`. + :param caption: See :ref:`formatting`. + :param caption_markdown: See :ref:`formatting`. + :param caption_html: See :ref:`formatting`. .. rubric:: Example @@ -1121,9 +1207,7 @@ class Client: return await send_audio( self, chat, - path, - url=url, - file=file, + file, size=size, name=name, duration=duration, @@ -1138,10 +1222,8 @@ class Client: async def send_file( self, chat: ChatLike, - path: Optional[Union[str, Path, File]] = None, + file: Union[str, Path, InFileLike, File], *, - url: Optional[str] = None, - file: Optional[InFileLike] = None, size: Optional[int] = None, name: Optional[str] = None, mime_type: Optional[str] = None, @@ -1152,7 +1234,6 @@ class Client: title: Optional[str] = None, performer: Optional[str] = None, emoji: Optional[str] = None, - emoji_sticker: Optional[str] = None, width: Optional[int] = None, height: Optional[int] = None, round: bool = False, @@ -1169,7 +1250,7 @@ class Client: If you want to let the library attempt to guess the file metadata, use the type-specific methods to send media: `send_photo`, `send_audio` or `send_file`. - Unlike `send_photo`, image files will be sent as documents by default. + Unlike :meth:`send_photo`, image files will be sent as documents by default. :param chat: The :term:`chat` where the message will be sent to. @@ -1177,36 +1258,121 @@ class Client: :param path: A local file path or :class:`~telethon.types.File` to send. - :param caption: - Caption text to display under the media, with no formatting. + :param file: + The file to send. - :param caption_markdown: - Caption text to display under the media, parsed as markdown. + This can be a path, relative or absolute, to a local file, as either a :class:`str` or :class:`pathlib.Path`. - :param caption_html: - Caption text to display under the media, parsed as HTML. + It can also be a file opened for reading in binary mode, with its ``read`` method optionally being ``async``. + Note that the file descriptor will *not* be seeked back to the start before sending it. - The rest of parameters are passed to :meth:`telethon.types.File.new` - if *path* isn't a :class:`~telethon.types.File`. - See the documentation of :meth:`~telethon.types.File.new` to learn what they do. + If you wrote to an in-memory file, you probably want to ``file.seek(0)`` first. + If you want to send :class:`bytes`, wrap them in :class:`io.BytesIO` first. - See the section on :doc:`/concepts/messages` to learn about message formatting. + You can also pass any :class:`~telethon.types.File` that was previously sent in Telegram to send a copy. + This will not download and re-upload the file, but will instead reuse the original without forwarding it. - Note that only one *caption* parameter can be provided. + Last, a URL can also be specified. + For the library to detect it as a URL, the string *must* start with either ``http://` or ``https://``. + Telethon will *not* download and upload the file, but will instead pass the URL to Telegram. + If Telegram is unable to access the media, is too large, or is invalid, the method will fail. + + When using URLs, it is recommended to explicitly pass either a name or define the mime-type. + To make sure the URL is interpreted as an image, use `send_photo`. + + :param size: + The size of the local file to send. + + This parameter **must** be specified when sending a previously-opened or in-memory files. + The library will not ``seek`` the file to attempt to determine the size. + + This can be less than the real file size, in which case only ``size`` bytes will be sent. + This can be useful if you have a single buffer with multiple files. + + :param name: + Override for the default file name. + + When given a string or path, the :attr:`pathlib.Path.name` will be used by default only if this parameter is omitted. + + This parameter **must** be specified when sending a previously-opened or in-memory files. + The library will not attempt to read any ``name`` attributes the object may have. + + :param mime_type: + Override for the default mime-type. + + By default, the library will use :func:`mimetypes.guess_type` on the name. + + If no mime-type is registered for the name's extension, ``application/octet-stream`` will be used. + + :param compress: + Whether the image file is allowed to be compressed by Telegram. + + If not, image files will be sent as document. + + :param animated: + Whether the sticker is animated (not a static image). + + :param duration: + Duration, in seconds, of the audio or video. + + This field should be specified when sending audios or videos from local files. + + The floating-point value will be rounded to an integer. + + :param voice: + Whether the audio is a live recording, often recorded right before sending it. + + :param title: + Title of the song in the audio file. + + :param performer: + Artist or main performer of the song in the audio file. + + :param emoji: + Alternative text for the sticker. + + :param width: + Width, in pixels, of the image or video. + + This field should be specified when sending images or videos from local files. + + :param height: + Height, in pixels, of the image or video. + + This field should be specified when sending images or videos from local files. + + :param round: + Whether the video should be displayed as a round video. + + :param supports_streaming: + Whether clients are allowed to stream the video having to wait for a full download. + + Note that the file format of the video must have streaming support. + + :param muted: + Whether the sound of the video is or should be missing. + + This is often used for short animations or "GIFs". + + :param caption: See :ref:`formatting`. + :param caption_markdown: See :ref:`formatting`. + :param caption_html: See :ref:`formatting`. .. rubric:: Example .. code-block:: python - login_token = await client.request_login_code('+1 23 456...') - print(login_token.timeout, 'seconds before code expires') + await client.send_file(chat, 'picture.jpg') + + # Sending in-memory bytes + import io + data = b'my in-memory document' + cawait client.send_file(chat, io.BytesIO(data), size=len(data), name='doc.txt') """ return await send_file( self, chat, - path, - url=url, - file=file, + file, size=size, name=name, mime_type=mime_type, @@ -1217,7 +1383,6 @@ class Client: title=title, performer=performer, emoji=emoji, - emoji_sticker=emoji_sticker, width=width, height=height, round=round, @@ -1244,16 +1409,9 @@ class Client: :param chat: The :term:`chat` where the message will be sent to. - :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. - - :param text_html: - Message text, parsed as HTML. + :param text: See :ref:`formatting`. + :param markdown: See :ref:`formatting`. + :param html: See :ref:`formatting`. :param link_preview: Whether the link preview is allowed. @@ -1266,10 +1424,6 @@ class Client: :param reply_to: The message identifier of the message to reply to. - Note that exactly one *text* parameter must be provided. - - See the section on :doc:`/concepts/messages` to learn about message formatting. - .. rubric:: Example .. code-block:: python @@ -1289,10 +1443,8 @@ class Client: async def send_photo( self, chat: ChatLike, - path: Optional[Union[str, Path, File]] = None, + file: Union[str, Path, InFileLike, File], *, - url: Optional[str] = None, - file: Optional[InFileLike] = None, size: Optional[int] = None, name: Optional[str] = None, compress: bool = True, @@ -1309,16 +1461,21 @@ class Client: Only compressed images can be displayed as photos in applications. If *compress* is set to :data:`False`, the image will be sent as a file document. - Unlike `send_file`, this method will attempt to guess the values for + Unlike :meth:`send_file`, this method will attempt to guess the values for width and height if they are not provided. :param chat: - The :term:`chat` where the message will be sent to. + The :term:`chat` where the photo media will be sent to. - :param path: - A local file path or :class:`~telethon.types.File` to send. - - The rest of parameters behave the same as they do in :meth:`send_file`. + :param file: See :meth:`send_file`. + :param size: See :meth:`send_file`. + :param name: See :meth:`send_file`. + :param compress: See :meth:`send_file`. + :param width: See :meth:`send_file`. + :param height: See :meth:`send_file`. + :param caption: See :ref:`formatting`. + :param caption_markdown: See :ref:`formatting`. + :param caption_html: See :ref:`formatting`. .. rubric:: Example @@ -1329,9 +1486,7 @@ class Client: return await send_photo( self, chat, - path, - url=url, - file=file, + file, size=size, name=name, compress=compress, @@ -1345,10 +1500,8 @@ class Client: async def send_video( self, chat: ChatLike, - path: Optional[Union[str, Path, File]] = None, + file: Union[str, Path, InFileLike, File], *, - url: Optional[str] = None, - file: Optional[InFileLike] = None, size: Optional[int] = None, name: Optional[str] = None, duration: Optional[float] = None, @@ -1363,16 +1516,23 @@ class Client: """ Send a video file. - Unlike `send_file`, this method will attempt to guess the values for + Unlike :meth:`send_file`, this method will attempt to guess the values for duration, width and height if they are not provided. :param chat: The :term:`chat` where the message will be sent to. - :param path: - A local file path or :class:`~telethon.types.File` to send. - - The rest of parameters behave the same as they do in :meth:`send_file`. + :param file: See :meth:`send_file`. + :param size: See :meth:`send_file`. + :param name: See :meth:`send_file`. + :param duration: See :meth:`send_file`. + :param width: See :meth:`send_file`. + :param height: See :meth:`send_file`. + :param round: See :meth:`send_file`. + :param supports_streaming: See :meth:`send_file`. + :param caption: See :ref:`formatting`. + :param caption_markdown: See :ref:`formatting`. + :param caption_html: See :ref:`formatting`. .. rubric:: Example @@ -1383,9 +1543,7 @@ class Client: return await send_video( self, chat, - path, - url=url, - file=file, + file, size=size, name=name, duration=duration, @@ -1442,6 +1600,10 @@ class Client: :param token: The login token returned from :meth:`request_login_code`. + :param code: + The login code sent by Telegram to a previously-authorized device. + This should be a short string of digits. + :return: The user corresponding to :term:`yourself`, or a password token if the account has 2FA enabled. diff --git a/client/src/telethon/_impl/client/client/files.py b/client/src/telethon/_impl/client/client/files.py index fd9e30a4..da2eafc0 100644 --- a/client/src/telethon/_impl/client/client/files.py +++ b/client/src/telethon/_impl/client/client/files.py @@ -1,9 +1,12 @@ from __future__ import annotations import hashlib +import math +import mimetypes +import urllib.parse from inspect import isawaitable from pathlib import Path -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, List, Optional, Union from ...tl import abcs, functions, types from ..types import ( @@ -28,14 +31,15 @@ MAX_CHUNK_SIZE = 512 * 1024 FILE_MIGRATE_ERROR = 303 BIG_FILE_SIZE = 10 * 1024 * 1024 +# ``round`` parameter would make this more annoying to access otherwise. +math_round = round + async def send_photo( self: Client, chat: ChatLike, - path: Optional[Union[str, Path, File]] = None, + file: Union[str, Path, InFileLike, File], *, - url: Optional[str] = None, - file: Optional[InFileLike] = None, size: Optional[int] = None, name: Optional[str] = None, compress: bool = True, @@ -48,9 +52,7 @@ async def send_photo( return await send_file( self, chat, - path, - url=url, - file=file, + file, size=size, name=name, compress=compress, @@ -65,10 +67,8 @@ async def send_photo( async def send_audio( self: Client, chat: ChatLike, - path: Optional[Union[str, Path, File]] = None, + file: Union[str, Path, InFileLike, File], *, - url: Optional[str] = None, - file: Optional[InFileLike] = None, size: Optional[int] = None, name: Optional[str] = None, duration: Optional[float] = None, @@ -82,9 +82,7 @@ async def send_audio( return await send_file( self, chat, - path, - url=url, - file=file, + file, size=size, name=name, duration=duration, @@ -100,10 +98,8 @@ async def send_audio( async def send_video( self: Client, chat: ChatLike, - path: Optional[Union[str, Path, File]] = None, + file: Union[str, Path, InFileLike, File], *, - url: Optional[str] = None, - file: Optional[InFileLike] = None, size: Optional[int] = None, name: Optional[str] = None, duration: Optional[float] = None, @@ -118,9 +114,7 @@ async def send_video( return await send_file( self, chat, - path, - url=url, - file=file, + file, size=size, name=name, duration=duration, @@ -134,13 +128,20 @@ async def send_video( ) +def try_get_url_path(maybe_url: Union[str, Path, InFileLike]) -> Optional[str]: + if not isinstance(maybe_url, str): + return None + lowercase = maybe_url.lower() + if lowercase.startswith("http://") or lowercase.startswith("https://"): + return urllib.parse.urlparse(maybe_url).path + return None + + async def send_file( self: Client, chat: ChatLike, - path: Optional[Union[str, Path, File]] = None, + file: Union[str, Path, InFileLike, File], *, - url: Optional[str] = None, - file: Optional[InFileLike] = None, size: Optional[int] = None, name: Optional[str] = None, mime_type: Optional[str] = None, @@ -161,61 +162,127 @@ async def send_file( caption_markdown: Optional[str] = None, caption_html: Optional[str] = None, ) -> Message: - file_info = File.new( - path, - url=url, - file=file, - size=size, - name=name, - mime_type=mime_type, - compress=compress, - animated=animated, - duration=duration, - voice=voice, - title=title, - performer=performer, - emoji=emoji, - emoji_sticker=emoji_sticker, - width=width, - height=height, - round=round, - supports_streaming=supports_streaming, - muted=muted, - ) message, entities = parse_message( text=caption, markdown=caption_markdown, html=caption_html, allow_empty=True ) assert isinstance(message, str) - peer = (await self._resolve_to_packed(chat))._to_input_peer() + # Re-send existing file. + if isinstance(file, File): + return await do_send_file(self, chat, file._input_media, message, entities) - if file_info._input_media is None: - if file_info._input_file is None: - file_info._input_file = await upload(self, file_info) - file_info._input_media = ( - types.InputMediaUploadedPhoto( - spoiler=False, - file=file_info._input_file, - stickers=None, - ttl_seconds=None, + # URLs are handled early as they can't use any other attributes either. + input_media: abcs.InputMedia + if (url_path := try_get_url_path(file)) is not None: + assert isinstance(file, str) + if compress: + if mime_type is None: + if name is None: + name = Path(url_path).name + mime_type, _ = mimetypes.guess_type(name, strict=False) + as_photo = mime_type and mime_type.startswith("image/") + else: + as_photo = False + if as_photo: + input_media = types.InputMediaPhotoExternal( + spoiler=False, url=file, ttl_seconds=None ) - if file_info._photo - else types.InputMediaUploadedDocument( - nosound_video=file_info._muted, - force_file=False, - spoiler=False, - file=file_info._input_file, - thumb=None, - mime_type=file_info._mime, - attributes=file_info._attributes, - stickers=None, - ttl_seconds=None, + else: + input_media = types.InputMediaDocumentExternal( + spoiler=False, url=file, ttl_seconds=None ) + return await do_send_file(self, chat, input_media, message, entities) + + # Paths are opened and closed by us. Anything else is *only* read, not closed. + if isinstance(file, (str, Path)): + path = Path(file) if isinstance(file, str) else file + if size is None: + size = path.stat().st_size + if name is None: + name = path.name + with path.open("rb") as fd: + input_file = await upload(self, fd, size, name) + else: + if size is None: + raise ValueError("size must be set when sending file-like objects") + if name is None: + raise ValueError("name must be set when sending file-like objects") + input_file = await upload(self, file, size, name) + + # Mime is mandatory for documents, but we also use it to determine whether to send as photo. + if mime_type is None: + mime_type, _ = mimetypes.guess_type(name, strict=False) + if mime_type is None: + mime_type = "application/octet-stream" + + as_photo = compress and mime_type.startswith("image/") + if as_photo: + input_media = types.InputMediaUploadedPhoto( + spoiler=False, + file=input_file, + stickers=None, + ttl_seconds=None, ) - random_id = generate_random_id() - return self._build_message_map( - await self( + # Only bother to calculate attributes when sending documents. + else: + attributes: List[abcs.DocumentAttribute] = [] + attributes.append(types.DocumentAttributeFilename(file_name=name)) + + if mime_type.startswith("image/"): + if width is not None and height is not None: + attributes.append(types.DocumentAttributeImageSize(w=width, h=height)) + elif mime_type.startswith("audio/"): + if duration is not None: + attributes.append( + types.DocumentAttributeAudio( + voice=voice, + duration=int(math_round(duration)), + title=title, + performer=performer, + waveform=None, + ) + ) + elif mime_type.startswith("video/"): + if duration is not None and width is not None and height is not None: + attributes.append( + types.DocumentAttributeVideo( + round_message=round, + supports_streaming=supports_streaming, + nosound=muted, + duration=int(math_round(duration)), + w=width, + h=height, + preload_prefix_size=None, + ) + ) + + input_media = types.InputMediaUploadedDocument( + nosound_video=muted, + force_file=False, + spoiler=False, + file=input_file, + thumb=None, + mime_type=mime_type, + attributes=attributes, + stickers=None, + ttl_seconds=None, + ) + + return await do_send_file(self, chat, input_media, message, entities) + + +async def do_send_file( + client: Client, + chat: ChatLike, + input_media: abcs.InputMedia, + message: str, + entities: Optional[List[abcs.MessageEntity]], +) -> Message: + peer = (await client._resolve_to_packed(chat))._to_input_peer() + random_id = generate_random_id() + return client._build_message_map( + await client( functions.messages.send_media( silent=False, background=False, @@ -224,7 +291,7 @@ async def send_file( update_stickersets_order=False, peer=peer, reply_to=None, - media=file_info._input_media, + media=input_media, message=message, random_id=random_id, reply_markup=None, @@ -239,67 +306,67 @@ async def send_file( async def upload( client: Client, - file: File, + fd: InFileLike, + size: int, + name: str, ) -> abcs.InputFile: file_id = generate_random_id() uploaded = 0 part = 0 - total_parts = (file._size + MAX_CHUNK_SIZE - 1) // MAX_CHUNK_SIZE + total_parts = (size + MAX_CHUNK_SIZE - 1) // MAX_CHUNK_SIZE buffer = bytearray() to_store: Union[bytearray, bytes] = b"" hash_md5 = hashlib.md5() - is_big = file._size > BIG_FILE_SIZE + is_big = size > BIG_FILE_SIZE - fd = file._open() - try: - while uploaded != file._size: - chunk = await fd.read(MAX_CHUNK_SIZE - len(buffer)) - if not chunk: - raise ValueError("unexpected end-of-file") + while uploaded != size: + ret = fd.read(MAX_CHUNK_SIZE - len(buffer)) + chunk = await ret if isawaitable(ret) else ret + assert isinstance(chunk, bytes) + if not chunk: + raise ValueError("unexpected end-of-file") - if len(chunk) == MAX_CHUNK_SIZE or uploaded + len(chunk) == file._size: - to_store = chunk + if len(chunk) == MAX_CHUNK_SIZE or uploaded + len(chunk) == size: + to_store = chunk + else: + buffer += chunk + if len(buffer) == MAX_CHUNK_SIZE: + to_store = buffer else: - buffer += chunk - if len(buffer) == MAX_CHUNK_SIZE: - to_store = buffer - else: - continue + continue - if is_big: - await client( - functions.upload.save_big_file_part( - file_id=file_id, - file_part=part, - file_total_parts=part, - bytes=to_store, - ) + if is_big: + await client( + functions.upload.save_big_file_part( + file_id=file_id, + file_part=part, + file_total_parts=part, + bytes=to_store, ) - else: - await client( - functions.upload.save_file_part( - file_id=file_id, file_part=total_parts, bytes=to_store - ) + ) + else: + await client( + functions.upload.save_file_part( + file_id=file_id, file_part=total_parts, bytes=to_store ) - hash_md5.update(to_store) + ) + hash_md5.update(to_store) - buffer.clear() - part += 1 - finally: - fd.close() + buffer.clear() + part += 1 - if file._size > BIG_FILE_SIZE: + if is_big: return types.InputFileBig( id=file_id, parts=total_parts, - name=file._name, + name=name, ) else: return types.InputFile( id=file_id, parts=total_parts, - name=file._name, + name=name, md5_checksum=hash_md5.hexdigest(), ) diff --git a/client/src/telethon/_impl/client/client/net.py b/client/src/telethon/_impl/client/client/net.py index 86911deb..b22a9aee 100644 --- a/client/src/telethon/_impl/client/client/net.py +++ b/client/src/telethon/_impl/client/client/net.py @@ -1,10 +1,11 @@ from __future__ import annotations import asyncio +import itertools import platform import re from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Optional, TypeVar +from typing import TYPE_CHECKING, List, Optional, Tuple, TypeVar from ....version import __version__ from ...mtproto import Full, RpcError @@ -13,7 +14,7 @@ from ...mtsender import connect as connect_without_auth from ...mtsender import connect_with_auth from ...session import DataCenter, Session from ...session import User as SessionUser -from ...tl import LAYER, Request, functions +from ...tl import LAYER, Request, functions, types from ..errors import adapt_rpc from .updates import dispatcher, process_socket_updates @@ -41,13 +42,6 @@ def default_system_version() -> str: @dataclass class Config: - """ - Configuration used by the :class:`telethon.Client`. - - See the parameters of :class:`~telethon.Client` for an explanation of the fields. - """ - - session: Session api_id: int api_hash: str device_model: str = field(default_factory=default_device_model) @@ -56,54 +50,33 @@ class Config: system_lang_code: str = "en" lang_code: str = "en" catch_up: bool = False - server_addr: Optional[str] = None + datacenter: Optional[DataCenter] = None flood_sleep_threshold: Optional[int] = 60 update_queue_limit: Optional[int] = None -# dc_id to IPv4 and port pair -DC_ADDRESSES = [ - "0.0.0.0:0", - "149.154.175.53:443", - "149.154.167.51:443", - "149.154.175.100:443", - "149.154.167.92:443", - "91.108.56.190:443", +KNOWN_DC = [ + DataCenter(id=1, addr="149.154.175.53:443", auth=None), + DataCenter(id=2, addr="149.154.167.51:443", auth=None), + DataCenter(id=3, addr="149.154.175.100:443", auth=None), + DataCenter(id=4, addr="149.154.167.92:443", auth=None), + DataCenter(id=5, addr="91.108.56.190:443", auth=None), ] DEFAULT_DC = 2 -async def connect_sender(dc_id: int, config: Config) -> Sender: +async def connect_sender( + config: Config, dc: DataCenter +) -> Tuple[Sender, List[DataCenter]]: transport = Full() - if config.server_addr: - addr = config.server_addr + if dc.auth: + sender = await connect_with_auth(transport, dc.id, dc.addr, dc.auth) else: - addr = DC_ADDRESSES[dc_id] - - auth_key: Optional[bytes] = None - for dc in config.session.dcs: - if dc.id == dc_id: - if dc.auth: - auth_key = dc.auth - break - - if auth_key: - sender = await connect_with_auth(transport, addr, auth_key) - else: - sender = await connect_without_auth(transport, addr) - for dc in config.session.dcs: - if dc.id == dc_id: - dc.auth = sender.auth_key - break - else: - config.session.dcs.append( - DataCenter(id=dc_id, addr=addr, auth=sender.auth_key) - ) + sender = await connect_without_auth(transport, dc.id, dc.addr) # TODO handle -404 (we had a previously-valid authkey, but server no longer knows about it) - # TODO all up-to-date server addresses should be stored in the session for future initial connections remote_config = await sender.invoke( functions.invoke_with_layer( layer=LAYER, @@ -121,9 +94,29 @@ async def connect_sender(dc_id: int, config: Config) -> Sender: ), ) ) - remote_config - return sender + latest_dcs = [] + append_current = True + for opt in types.Config.from_bytes(remote_config).dc_options: + assert isinstance(opt, types.DcOption) + latest_dcs.append( + DataCenter( + id=opt.id, + addr=opt.ip_address, + auth=sender.auth_key if sender.dc_id == opt.id else None, + ) + ) + if sender.dc_id == opt.id: + append_current = False + + if append_current: + # Current config has no DC with current ID. + # Append it to preserve the authorization key. + latest_dcs.append( + DataCenter(id=sender.dc_id, addr=sender.addr, auth=sender.auth_key) + ) + + return sender, latest_dcs async def connect(self: Client) -> None: @@ -131,32 +124,44 @@ async def connect(self: Client) -> None: return if session := await self._storage.load(): - self._config.session = session + self._session = session - if user := self._config.session.user: - self._dc_id = user.dc + if dc := self._config.datacenter: + # Datacenter override, reusing the session's auth-key unless already present. + datacenter = ( + dc + if dc.auth + else DataCenter( + id=dc.id, + addr=dc.addr, + auth=next( + (d.auth for d in self._session.dcs if d.id == dc.id and d.auth), + None, + ), + ) + ) else: - for dc in self._config.session.dcs: - if dc.auth: - self._dc_id = dc.id - break + # Reuse the session's datacenter, falling back to defaults if not found. + datacenter = datacenter_for_id( + self, self._session.user.dc if self._session.user else DEFAULT_DC + ) - self._sender = await connect_sender(self._dc_id, self._config) + self._sender, self._session.dcs = await connect_sender(self._config, datacenter) - if self._message_box.is_empty() and self._config.session.user: + if self._message_box.is_empty() and self._session.user: try: await self(functions.updates.get_state()) except RpcError as e: if e.code == 401: - self._config.session.user = None + self._session.user = None except Exception as e: pass else: - if not self._config.session.user: + if not self._session.user: me = await self.get_me() assert me is not None - self._config.session.user = SessionUser( - id=me.id, dc=self._dc_id, bot=me.bot, username=me.username + self._session.user = SessionUser( + id=me.id, dc=self._sender.dc_id, bot=me.bot, username=me.username ) packed = me.pack() assert packed is not None @@ -165,6 +170,17 @@ async def connect(self: Client) -> None: self._dispatcher = asyncio.create_task(dispatcher(self)) +def datacenter_for_id(client: Client, dc_id: int) -> DataCenter: + try: + return next( + dc + for dc in itertools.chain(client._session.dcs, KNOWN_DC) + if dc.id == dc_id + ) + except StopIteration: + raise ValueError(f"no datacenter found for id: {dc_id}") from None + + async def disconnect(self: Client) -> None: if not self._sender: return @@ -173,8 +189,6 @@ async def disconnect(self: Client) -> None: self._dispatcher.cancel() try: await self._dispatcher - except asyncio.CancelledError: - pass except Exception: pass # TODO log finally: @@ -187,8 +201,8 @@ async def disconnect(self: Client) -> None: finally: self._sender = None - self._config.session.state = self._message_box.session_state() - await self._storage.save(self._config.session) + self._session.state = self._message_box.session_state() + await self._storage.save(self._session) async def invoke_request( diff --git a/client/src/telethon/_impl/client/client/updates.py b/client/src/telethon/_impl/client/client/updates.py index db44170d..0a506374 100644 --- a/client/src/telethon/_impl/client/client/updates.py +++ b/client/src/telethon/_impl/client/client/updates.py @@ -123,14 +123,24 @@ def extend_update_queue( async def dispatcher(client: Client) -> None: + loop = asyncio.get_running_loop() while client.connected: try: await dispatch_next(client) except asyncio.CancelledError: - raise - except Exception: - # TODO proper logger - logging.exception("Unhandled exception in event handler") + return + except Exception as e: + if isinstance(e, RuntimeError) and loop.is_closed: + # User probably forgot to call disconnect. + logging.warning( + "client was not closed cleanly, make sure to call client.disconnect()! %s", + e, + ) + return + else: + # TODO proper logger + logging.exception("Unhandled exception in event handler") + raise async def dispatch_next(client: Client) -> None: diff --git a/client/src/telethon/_impl/client/client/users.py b/client/src/telethon/_impl/client/client/users.py index 3b23e4a4..33c64733 100644 --- a/client/src/telethon/_impl/client/client/users.py +++ b/client/src/telethon/_impl/client/client/users.py @@ -76,10 +76,10 @@ async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat: if isinstance(chat, types.InputPeerEmpty): raise ValueError("Cannot resolve chat") elif isinstance(chat, types.InputPeerSelf): - if not self._config.session.user: + if not self._session.user: raise ValueError("Cannot resolve chat") return PackedChat( - ty=PackedType.BOT if self._config.session.user.bot else PackedType.USER, + ty=PackedType.BOT if self._session.user.bot else PackedType.USER, id=self._chat_hashes.self_id, access_hash=0, # TODO get hash ) @@ -112,7 +112,7 @@ async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat: if chat.startswith("+"): resolved = await resolve_phone(self, chat) elif chat == "me": - if me := self._config.session.user: + if me := self._session.user: return PackedChat( ty=PackedType.BOT if me.bot else PackedType.USER, id=me.id, diff --git a/client/src/telethon/_impl/client/events/filters/messages.py b/client/src/telethon/_impl/client/events/filters/messages.py index 288b36b7..c5a3b398 100644 --- a/client/src/telethon/_impl/client/events/filters/messages.py +++ b/client/src/telethon/_impl/client/events/filters/messages.py @@ -72,7 +72,7 @@ class Command: self._username = "" client: Optional[Client] if (client := getattr(event, "_client", None)) is not None: - user = client._config.session.user + user = client._session.user if user and user.username: self._username = user.username diff --git a/client/src/telethon/_impl/client/types/__init__.py b/client/src/telethon/_impl/client/types/__init__.py index d1cd7826..b1f7452d 100644 --- a/client/src/telethon/_impl/client/types/__init__.py +++ b/client/src/telethon/_impl/client/types/__init__.py @@ -2,12 +2,13 @@ from .async_list import AsyncList from .chat import Channel, Chat, ChatLike, Group, RestrictionReason, User from .dialog import Dialog from .draft import Draft -from .file import File, InFileLike, InWrapper, OutFileLike, OutWrapper +from .file import File, InFileLike, OutFileLike, OutWrapper from .login_token import LoginToken from .message import Message from .meta import NoPublicConstructor from .participant import Participant from .password_token import PasswordToken +from .inline_result import InlineResult from .recent_action import RecentAction __all__ = [ @@ -22,9 +23,9 @@ __all__ = [ "Draft", "File", "InFileLike", - "InWrapper", "OutFileLike", "OutWrapper", + "InlineResult", "LoginToken", "Message", "NoPublicConstructor", diff --git a/client/src/telethon/_impl/client/types/file.py b/client/src/telethon/_impl/client/types/file.py index 1e38a2fc..95baa911 100644 --- a/client/src/telethon/_impl/client/types/file.py +++ b/client/src/telethon/_impl/client/types/file.py @@ -44,7 +44,7 @@ stripped_size_header = bytes.fromhex( stripped_size_footer = bytes.fromhex("FFD9") -def expand_stripped_size(data: bytes) -> bytearray: +def expand_stripped_size(data: bytes) -> bytes: header = bytearray(stripped_size_header) header[164] = data[1] header[166] = data[2] @@ -77,7 +77,12 @@ class InFileLike(Protocol): """ def read(self, n: int) -> Union[bytes, Coroutine[Any, Any, bytes]]: - pass + """ + Read from the file or buffer. + + :param n: + Maximum amount of bytes that should be returned. + """ class OutFileLike(Protocol): @@ -87,33 +92,12 @@ class OutFileLike(Protocol): """ def write(self, data: bytes) -> Union[Any, Coroutine[Any, Any, Any]]: - pass + """ + Write all the data into the file or buffer. - -class InWrapper: - __slots__ = ("_fd", "_owned") - - def __init__(self, file: Union[str, Path, InFileLike]): - if isinstance(file, str): - file = Path(file) - - if isinstance(file, Path): - self._fd: Union[InFileLike, BufferedReader] = file.open("rb") - self._owned = True - else: - self._fd = file - self._owned = False - - async def read(self, n: int) -> bytes: - ret = self._fd.read(n) - chunk = await ret if isawaitable(ret) else ret - assert isinstance(chunk, bytes) - return chunk - - def close(self) -> None: - if self._owned: - assert hasattr(self._fd, "close") - self._fd.close() + :param data: + Data that must be written to the buffer entirely. + """ class OutWrapper: @@ -143,30 +127,24 @@ class OutWrapper: class File(metaclass=NoPublicConstructor): """ - File information of uploaded media. - - It is used both when sending files or accessing media in a `Message`. + File information of media sent to Telegram that can be downloaded. """ def __init__( self, *, - path: Optional[Path], - file: Optional[InFileLike], attributes: List[abcs.DocumentAttribute], size: int, name: str, mime: str, photo: bool, muted: bool, - input_media: Optional[abcs.InputMedia], + input_media: abcs.InputMedia, thumb: Optional[abcs.PhotoSize], thumbs: Optional[List[abcs.PhotoSize]], raw: Optional[Union[abcs.MessageMedia, abcs.Photo, abcs.Document]], client: Optional[Client], ): - self._path = path - self._file = file self._attributes = attributes self._size = size self._name = name @@ -226,8 +204,6 @@ class File(metaclass=NoPublicConstructor): ) -> Optional[Self]: if isinstance(raw, types.Document): return cls._create( - path=None, - file=None, attributes=raw.attributes, size=raw.size, name=next( @@ -279,8 +255,6 @@ class File(metaclass=NoPublicConstructor): if isinstance(raw, types.Photo): largest_thumb = max(raw.sizes, key=photo_size_byte_count) return cls._create( - path=None, - file=None, attributes=[], size=photo_size_byte_count(largest_thumb), name="", @@ -304,165 +278,29 @@ class File(metaclass=NoPublicConstructor): return None - @classmethod - def new( - cls, - path: Optional[Union[str, Path, Self]] = None, - *, - url: Optional[str] = None, - file: Optional[InFileLike] = None, - size: Optional[int] = None, - name: Optional[str] = None, - mime_type: Optional[str] = None, - compress: bool = False, - animated: bool = False, - duration: Optional[float] = None, - voice: bool = False, - title: Optional[str] = None, - performer: Optional[str] = None, - emoji: Optional[str] = None, - emoji_sticker: Optional[str] = None, - width: Optional[int] = None, - height: Optional[int] = None, - round: bool = False, - supports_streaming: bool = False, - muted: bool = False, - ) -> Self: + @property + def name(self) -> Optional[str]: """ - Create file information that can later be sent as media. - - If the path is a `File`, the rest of parameters are ignored, and - this existing instance is returned instead (the method is a no-op). - - Only one of path, url or file must be specified. - - If a local file path is not given, size and name must be specified. - - The mime_type will be inferred from the name if it is omitted. - - The rest of parameters are only used depending on the mime_type: - - * For image/: - * width (required), in pixels, of the media. - * height (required), in pixels, of the media. - * For audio/: - * duration (required), in seconds, of the media. This will be rounded. - * voice, if it's a live recording. - * title, of the song. - * performer, with the name of the artist. - * For video/: - * duration (required), in seconds, of the media. This will be rounded. - * width (required), in pixels, of the media. - * height (required), in pixels, of the media. - * round, if it should be displayed as a round video. - * supports_streaming, if clients are able to stream the video. - * muted, if the sound from the video is or should be missing. - * For sticker: - * animated, if it's not a static image. - * emoji, as the alternative text for the sticker. - * stickerset, to which the sticker belongs. - - If any of the required fields are missing, the attribute will not be sent. + The file name, if known. """ - if isinstance(path, cls): - return path - assert not isinstance(path, File) + for attr in self._attributes: + if isinstance(attr, types.DocumentAttributeFilename): + return attr.file_name - attributes: List[abcs.DocumentAttribute] = [] - - if sum((path is not None, url is not None, file is not None)) != 1: - raise ValueError("must specify exactly one of path, markdown or html") - - if path is not None: - size = os.path.getsize(path) - name = os.path.basename(path) - - if size is None: - raise ValueError("must specify size") - if name is None: - raise ValueError("must specify name") - - if mime_type is None: - mime_type, _ = guess_type(name, strict=False) - if mime_type is None: - raise ValueError("must specify mime_type") - - if sum((path is not None, url is not None, file is not None)) != 1: - raise ValueError("must specify exactly one of path, markdown or html") - - attributes.append(types.DocumentAttributeFilename(file_name=name)) - - if mime_type.startswith("image/"): - if width is not None and height is not None: - attributes.append(types.DocumentAttributeImageSize(w=width, h=height)) - elif mime_type.startswith("audio/"): - if duration is not None: - attributes.append( - types.DocumentAttributeAudio( - voice=voice, - duration=int(math_round(duration)), - title=title, - performer=performer, - waveform=None, - ) - ) - elif mime_type.startswith("video/"): - if duration is not None and width is not None and height is not None: - attributes.append( - types.DocumentAttributeVideo( - round_message=round, - supports_streaming=supports_streaming, - nosound=muted, - duration=int(math_round(duration)), - w=width, - h=height, - preload_prefix_size=None, - ) - ) - - photo = compress and mime_type.startswith("image/") - - input_media: Optional[abcs.InputMedia] - if url is not None: - if photo: - input_media = types.InputMediaPhotoExternal( - spoiler=False, url=url, ttl_seconds=None - ) - else: - input_media = types.InputMediaDocumentExternal( - spoiler=False, url=url, ttl_seconds=None - ) - else: - input_media = None - - return cls._create( - path=Path(path) if path is not None else None, - file=file, - attributes=attributes, - size=size, - name=name, - mime=mime_type, - photo=photo, - muted=muted, - input_media=input_media, - thumb=None, - thumbs=None, - raw=None, - client=None, - ) + return None @property def ext(self) -> str: """ The file extension, including the leading dot ``.``. - If the file does not represent and local file, the mimetype is used in :meth:`mimetypes.guess_extension`. + If the name is not known, the mime-type is used in :meth:`mimetypes.guess_extension`. - If no extension is known for the mimetype, the empty string will be returned. + If no extension is known for the mime-type, the empty string will be returned. This makes it safe to always append this property to a file name. """ - if self._path: - return self._path.suffix + if name := self._name: + return Path(name).suffix else: return mimetypes.guess_extension(self._mime) or "" @@ -477,8 +315,6 @@ class File(metaclass=NoPublicConstructor): """ return [ File._create( - path=None, - file=None, attributes=[], size=photo_size_byte_count(t), name="", @@ -530,22 +366,13 @@ class File(metaclass=NoPublicConstructor): """ Alias for :meth:`telethon.Client.download`. - The file must have been obtained from Telegram to be downloadable. - This means you cannot create local files, or files with an URL, and download them. - - See the documentation of :meth:`~telethon.Client.download` for an explanation of the parameters. + :param file: See :meth:`~telethon.Client.download`. """ if not self._client: raise ValueError("only files from Telegram can be downloaded") await self._client.download(self, file) - def _open(self) -> InWrapper: - file = self._file or self._path - if file is None: - raise TypeError(f"cannot use file for uploading: {self}") - return InWrapper(file) - def _input_location(self) -> abcs.InputFileLocation: thumb_types = ( types.PhotoSizeEmpty, diff --git a/client/src/telethon/_impl/client/types/inline_result.py b/client/src/telethon/_impl/client/types/inline_result.py new file mode 100644 index 00000000..e5d908e0 --- /dev/null +++ b/client/src/telethon/_impl/client/types/inline_result.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional, Union + +from ..utils import generate_random_id +from ...tl import abcs, types, functions +from .chat import ChatLike +from .meta import NoPublicConstructor +from .message import Message + +if TYPE_CHECKING: + from ..client import Client + + +class InlineResult(metaclass=NoPublicConstructor): + def __init__( + self, + client: Client, + results: types.messages.BotResults, + result: Union[types.BotInlineMediaResult, types.BotInlineResult], + default_peer: abcs.InputPeer, + ): + self._client = client + self._raw_results = results + self._raw = result + self._default_peer = default_peer + + @property + def type(self) -> str: + return self._raw.type + + @property + def title(self) -> str: + return self._raw.title or "" + + @property + def description(self) -> Optional[str]: + return self._raw.description + + async def send( + self, + chat: Optional[ChatLike] = None, + ) -> Message: + """ + Send the inline result to the desired chat. + + :param chat: + The chat where the inline result should be sent to. + + This can be omitted if a chat was previously specified in the :meth:`~Client.inline_query`. + + :return: The sent message. + """ + if chat is None and isinstance(self._default_peer, types.InputPeerEmpty): + raise ValueError("no target chat was specified") + + if chat is not None: + peer = (await self._client._resolve_to_packed(chat))._to_input_peer() + else: + peer = self._default_peer + + random_id = generate_random_id() + return self._client._build_message_map( + await self._client( + functions.messages.send_inline_bot_result( + silent=False, + background=False, + clear_draft=False, + hide_via=False, + peer=peer, + reply_to=None, + random_id=random_id, + query_id=self._raw_results.query_id, + id=self._raw.id, + schedule_date=None, + send_as=None, + ) + ), + peer, + ).with_random_id(random_id) diff --git a/client/src/telethon/_impl/client/types/message.py b/client/src/telethon/_impl/client/types/message.py index bf60d3d0..299f5df6 100644 --- a/client/src/telethon/_impl/client/types/message.py +++ b/client/src/telethon/_impl/client/types/message.py @@ -186,7 +186,10 @@ class Message(metaclass=NoPublicConstructor): """ Alias for :meth:`telethon.Client.send_message`. - See the documentation of :meth:`~telethon.Client.send_message` for an explanation of the parameters. + :param text: See :ref:`formatting`. + :param markdown: See :ref:`formatting`. + :param html: See :ref:`formatting`. + :param link_preview: See :meth:`~telethon.Client.send_message`. """ return await self._client.send_message( self.chat, text, markdown=markdown, html=html, link_preview=link_preview @@ -203,7 +206,10 @@ class Message(metaclass=NoPublicConstructor): """ Alias for :meth:`telethon.Client.send_message` with the ``reply_to`` parameter set to this message. - See the documentation of :meth:`~telethon.Client.send_message` for an explanation of the parameters. + :param text: See :ref:`formatting`. + :param markdown: See :ref:`formatting`. + :param html: See :ref:`formatting`. + :param link_preview: See :meth:`~telethon.Client.send_message`. """ return await self._client.send_message( self.chat, @@ -218,7 +224,7 @@ class Message(metaclass=NoPublicConstructor): """ Alias for :meth:`telethon.Client.delete_messages`. - See the documentation of :meth:`~telethon.Client.delete_messages` for an explanation of the parameters. + :param revoke: See :meth:`~telethon.Client.delete_messages`. """ await self._client.delete_messages(self.chat, [self.id], revoke=revoke) @@ -232,7 +238,10 @@ class Message(metaclass=NoPublicConstructor): """ Alias for :meth:`telethon.Client.edit_message`. - See the documentation of :meth:`~telethon.Client.edit_message` for an explanation of the parameters. + :param text: See :ref:`formatting`. + :param markdown: See :ref:`formatting`. + :param html: See :ref:`formatting`. + :param link_preview: See :meth:`~telethon.Client.send_message`. """ return await self._client.edit_message( self.chat, @@ -247,17 +256,23 @@ class Message(metaclass=NoPublicConstructor): """ Alias for :meth:`telethon.Client.forward_messages`. - See the documentation of :meth:`~telethon.Client.forward_messages` for an explanation of the parameters. + :param target: See :meth:`~telethon.Client.forward_messages`. """ return (await self._client.forward_messages(target, [self.id], self.chat))[0] async def mark_read(self) -> None: pass - async def pin(self, *, notify: bool = False, pm_oneside: bool = False) -> None: + async def pin(self) -> None: + """ + Alias for :meth:`telethon.Client.pin_message`. + """ pass async def unpin(self) -> None: + """ + Alias for :meth:`telethon.Client.unpin_message`. + """ pass # --- diff --git a/client/src/telethon/_impl/mtsender/sender.py b/client/src/telethon/_impl/mtsender/sender.py index 2b0d3857..ba8cc76f 100644 --- a/client/src/telethon/_impl/mtsender/sender.py +++ b/client/src/telethon/_impl/mtsender/sender.py @@ -76,6 +76,8 @@ class Request(Generic[Return]): @dataclass class Sender: + dc_id: int + addr: str _reader: StreamReader _writer: StreamWriter _transport: Transport @@ -88,10 +90,14 @@ class Sender: _write_drain_pending: bool @classmethod - async def connect(cls, transport: Transport, mtp: Mtp, addr: str) -> Self: + async def connect( + cls, transport: Transport, mtp: Mtp, dc_id: int, addr: str + ) -> Self: reader, writer = await asyncio.open_connection(*addr.split(":")) return cls( + dc_id=dc_id, + addr=addr, _reader=reader, _writer=writer, _transport=transport, @@ -271,8 +277,8 @@ class Sender: return None -async def connect(transport: Transport, addr: str) -> Sender: - sender = await Sender.connect(transport, Plain(), addr) +async def connect(transport: Transport, dc_id: int, addr: str) -> Sender: + sender = await Sender.connect(transport, Plain(), dc_id, addr) return await generate_auth_key(sender) @@ -289,6 +295,8 @@ async def generate_auth_key(sender: Sender) -> Sender: first_salt = finished.first_salt return Sender( + dc_id=sender.dc_id, + addr=sender.addr, _reader=sender._reader, _writer=sender._writer, _transport=sender._transport, @@ -304,9 +312,10 @@ async def generate_auth_key(sender: Sender) -> Sender: async def connect_with_auth( transport: Transport, + dc_id: int, addr: str, auth_key: bytes, ) -> Sender: return await Sender.connect( - transport, Encrypted(AuthKey.from_bytes(auth_key)), addr + transport, Encrypted(AuthKey.from_bytes(auth_key)), dc_id, addr ) diff --git a/client/src/telethon/_impl/session/chat/packed.py b/client/src/telethon/_impl/session/chat/packed.py index 3e6d2738..da31c139 100644 --- a/client/src/telethon/_impl/session/chat/packed.py +++ b/client/src/telethon/_impl/session/chat/packed.py @@ -67,6 +67,9 @@ class PackedChat: """ Convenience method to convert hexadecimal numbers into bytes then passed to :meth:`from_bytes`: + :param hex: + Hexadecimal numbers to convert from. + .. code-block:: assert PackedChat.from_hex(packed.hex) == packed diff --git a/client/src/telethon/_impl/session/session.py b/client/src/telethon/_impl/session/session.py index a4091a69..d97599f3 100644 --- a/client/src/telethon/_impl/session/session.py +++ b/client/src/telethon/_impl/session/session.py @@ -6,62 +6,71 @@ class DataCenter: """ Data-center information. - :var id: The DC identifier. - :var addr: The server address of the DC, in ``'ip:port'`` format. - :var auth: Authentication key to encrypt communication with. + :param id: See below. + :param addr: See below. + :param auth: See below. """ __slots__ = ("id", "addr", "auth") def __init__(self, *, id: int, addr: str, auth: Optional[bytes]) -> None: self.id = id + "The DC identifier." self.addr = addr + "The server address of the DC, in ``'ip:port'`` format." self.auth = auth + "Authentication key to encrypt communication with." class User: """ Information about the logged-in user. - :var id: User identifier. - :var dc: Data-center identifier of the user's "home" DC. - :var bot: :data:`True` if the user is from a bot account. - :var username: User's primary username. + :param id: See below. + :param dc: See below. + :param bot: See below. + :param username: See below. """ __slots__ = ("id", "dc", "bot", "username") def __init__(self, *, id: int, dc: int, bot: bool, username: Optional[str]) -> None: self.id = id + "User identifier." self.dc = dc + 'Data-center identifier of the user\'s "home" DC.' self.bot = bot + ":data:`True` if the user is from a bot account." self.username = username + "User's primary username." class ChannelState: """ Update state for a channel. - :var id: The channel identifier. - :var pts: The channel's partial sequence number. + :param id: See below. + :param pts: See below. """ __slots__ = ("id", "pts") def __init__(self, *, id: int, pts: int) -> None: self.id = id + "The channel identifier." self.pts = pts + "The channel's partial sequence number." class UpdateState: """ Update state for an account. - :var pts: The primary partial sequence number. - :var qts: The secondary partial sequence number. - :var date: Date of the latest update sequence. - :var seq: The sequence number. - :var channels: Update state for channels. + :param pts: See below. + :param qts: See below. + :param date: See below. + :param seq: See below. + :param channels: See below. """ __slots__ = ( @@ -82,10 +91,15 @@ class UpdateState: channels: List[ChannelState], ) -> None: self.pts = pts + "The primary partial sequence number." self.qts = qts + "The secondary partial sequence number." self.date = date + "Date of the latest update sequence." self.seq = seq + "The sequence number." self.channels = channels + "Update state for channels." class Session: @@ -102,9 +116,18 @@ class Session: If you think the session has been compromised, immediately terminate all sessions through an official Telegram client to revoke the authorization. + + :param dcs: See below. + :param user: See below. + :param state: See below. """ VERSION = 1 + """ + Current version. + + Will be incremented if new fields are added. + """ __slots__ = ("dcs", "user", "state") @@ -116,81 +139,8 @@ class Session: state: Optional[UpdateState] = None, ): self.dcs = dcs or [] + "List of known data-centers." self.user = user + "Information about the logged-in user." self.state = state - - def to_dict(self) -> Dict[str, Any]: - return { - "v": self.VERSION, - "dcs": [ - { - "id": dc.id, - "addr": dc.addr, - "auth": base64.b64encode(dc.auth).decode("ascii") - if dc.auth - else None, - } - for dc in self.dcs - ], - "user": { - "id": self.user.id, - "dc": self.user.dc, - "bot": self.user.bot, - "username": self.user.username, - } - if self.user - else None, - "state": { - "pts": self.state.pts, - "qts": self.state.qts, - "date": self.state.date, - "seq": self.state.seq, - "channels": [ - {"id": channel.id, "pts": channel.pts} - for channel in self.state.channels - ], - } - if self.state - else None, - } - - @classmethod - def from_dict(cls, dict: Dict[str, Any]) -> Self: - version = dict["v"] - if version != cls.VERSION: - raise ValueError( - f"cannot parse session format version {version} (expected {cls.VERSION})" - ) - - return cls( - dcs=[ - DataCenter( - id=dc["id"], - addr=dc["addr"], - auth=base64.b64decode(dc["auth"]) - if dc["auth"] is not None - else None, - ) - for dc in dict["dcs"] - ], - user=User( - id=dict["user"]["id"], - dc=dict["user"]["dc"], - bot=dict["user"]["bot"], - username=dict["user"]["username"], - ) - if dict["user"] - else None, - state=UpdateState( - pts=dict["state"]["pts"], - qts=dict["state"]["qts"], - date=dict["state"]["date"], - seq=dict["state"]["seq"], - channels=[ - ChannelState(id=channel["id"], pts=channel["pts"]) - for channel in dict["state"]["channels"] - ], - ) - if dict["state"] - else None, - ) + "Update state." diff --git a/client/src/telethon/_impl/session/storage/storage.py b/client/src/telethon/_impl/session/storage/storage.py index 0ab1a3c4..fcf56f47 100644 --- a/client/src/telethon/_impl/session/storage/storage.py +++ b/client/src/telethon/_impl/session/storage/storage.py @@ -12,17 +12,22 @@ class Storage(abc.ABC): @abc.abstractmethod async def load(self) -> Optional[Session]: """ - Load the `Session` instance, if any. + Load the :class:`Session` instance, if any. This method is called by the library prior to `connect`. + + :return: The previously-saved session. """ @abc.abstractmethod async def save(self, session: Session) -> None: """ - Save the `Session` instance to persistent storage. + Save the :class:`Session` instance to persistent storage. This method is called by the library post `disconnect`. + + :param session: + The session information that should be persisted. """ @abc.abstractmethod diff --git a/client/src/telethon/events/__init__.py b/client/src/telethon/events/__init__.py index b5acf921..220eabb2 100644 --- a/client/src/telethon/events/__init__.py +++ b/client/src/telethon/events/__init__.py @@ -1,3 +1,8 @@ +""" +Classes related to the different event types that wrap incoming Telegram updates. + +See the :doc:`/concepts/updates` concept for more details. +""" from .._impl.client.events import ( CallbackQuery, Event, diff --git a/client/src/telethon/session.py b/client/src/telethon/session.py index eda49dd2..a87159ae 100644 --- a/client/src/telethon/session.py +++ b/client/src/telethon/session.py @@ -1,3 +1,8 @@ +""" +Classes related to session data and session storages. + +See the :doc:`/concepts/sessions` concept for more details. +""" from ._impl.session import ( ChannelState, DataCenter, diff --git a/client/src/telethon/types.py b/client/src/telethon/types.py index 6612ed84..1fea0edc 100644 --- a/client/src/telethon/types.py +++ b/client/src/telethon/types.py @@ -1,3 +1,6 @@ +""" +Classes for the various objects the library returns. +""" from ._impl.client.client import Config, InlineResult from ._impl.client.types import ( AsyncList, diff --git a/client/tests/client_test.py b/client/tests/client_test.py index 4401a65b..3d08ce4d 100644 --- a/client/tests/client_test.py +++ b/client/tests/client_test.py @@ -2,7 +2,7 @@ import os import random from pytest import mark -from telethon import Client, Config, Session +from telethon import Client from telethon import _tl as tl @@ -22,3 +22,5 @@ async def test_ping_pong() -> None: pong = await client(tl.mtproto.functions.ping(ping_id=ping_id)) assert isinstance(pong, tl.mtproto.types.Pong) assert pong.ping_id == ping_id + + await client.disconnect() diff --git a/client/tests/mtsender_test.py b/client/tests/mtsender_test.py index 78279935..49f5599a 100644 --- a/client/tests/mtsender_test.py +++ b/client/tests/mtsender_test.py @@ -4,11 +4,10 @@ import logging from pytest import LogCaptureFixture, mark from telethon._impl.mtproto import Full from telethon._impl.mtsender import connect +from telethon._impl.session import DataCenter from telethon._impl.tl import LAYER, abcs, functions, types -TELEGRAM_TEST_DC_2 = "149.154.167.40:443" - -TELEGRAM_DEFAULT_TEST_DC = TELEGRAM_TEST_DC_2 +TELEGRAM_TEST_DC = 2, "149.154.167.40:443" TEST_TIMEOUT = 10000 @@ -22,9 +21,7 @@ async def test_invoke_encrypted_method(caplog: LogCaptureFixture) -> None: def timeout() -> float: return deadline - asyncio.get_running_loop().time() - sender = await asyncio.wait_for( - connect(Full(), TELEGRAM_DEFAULT_TEST_DC), timeout() - ) + sender = await asyncio.wait_for(connect(Full(), *TELEGRAM_TEST_DC), timeout()) rx = sender.enqueue( functions.invoke_with_layer(