Continue documentation and reducing public API

This commit is contained in:
Lonami Exo 2023-10-08 15:07:18 +02:00
parent 25a2b53d3f
commit 7fabf7da0a
35 changed files with 887 additions and 735 deletions

View File

@ -1,22 +1,3 @@
Code generation: # Developing
```sh See [Contributing](./client/doc/developing/contributing.rst).
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]`).

View File

@ -352,7 +352,8 @@ For the most part, it's a 1-to-1 translation and the result is idiomatic Teletho
Migrating from aiogram Migrating from aiogram
`````````````````````` ^^^^^^^^^^^^^^^^^^^^^^
Using one of the examples from their v3 documentation with logging and comments removed: Using one of the examples from their v3 documentation with logging and comments removed:
.. code-block:: python .. code-block:: python

View File

@ -55,7 +55,7 @@ To check for a concrete type, you can use :func:`isinstance`:
if isinstance(invite, tl.types.ChatInviteAlready): if isinstance(invite, tl.types.ChatInviteAlready):
print(invite.chat) 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: Instead, there are multiple alternatives:
* Use Telethon's separate site to search in the `Telethon Raw API <https://tl.telethon.dev/>`_. * Use Telethon's separate site to search in the `Telethon Raw API <https://tl.telethon.dev/>`_.

View File

@ -66,3 +66,5 @@ Glossary
Type Language Type Language
File format used by Telegram to define all the types and requests available in a :term:`layer`. 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 <https://core.telegram.org/mtproto/TL>`_. Telegram's site has an `Overview of the TL language <https://core.telegram.org/mtproto/TL>`_.
.. seealso:: :ref:`Type Language brief <tl-brief>`.

View File

@ -15,6 +15,8 @@ Messages
Messages are at the heart of a messaging platform. Messages are at the heart of a messaging platform.
In Telethon, you will be using the :class:`~types.Message` class to interact with them. In Telethon, you will be using the :class:`~types.Message` class to interact with them.
.. _formatting:
Formatting messages Formatting messages
------------------- -------------------

View File

@ -23,6 +23,7 @@ extensions = [
"sphinx.ext.autodoc", "sphinx.ext.autodoc",
"sphinx.ext.intersphinx", "sphinx.ext.intersphinx",
"sphinx.ext.graphviz", "sphinx.ext.graphviz",
"sphinx.ext.coverage",
"roles.tl", "roles.tl",
] ]
@ -31,7 +32,9 @@ tl_ref_url = "https://tl.telethon.dev"
autodoc_default_options = { autodoc_default_options = {
"members": True, "members": True,
"undoc-members": True, "undoc-members": True,
"show-inheritance": True,
} }
autodoc_typehints = "description"
modindex_common_prefix = ["telethon."] modindex_common_prefix = ["telethon."]
graphviz_output_format = "svg" graphviz_output_format = "svg"

View File

@ -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 <http://www.diveintopython3.net/>`_.
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.

View File

@ -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 <http://www.diveintopython3.net/>`_.
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 .. currentmodule:: telethon
@ -7,29 +52,61 @@ The repository contains several folders, each with their own "package".
benches/ benches/
-------- ^^^^^^^^
This folder contains different benchmarks. This folder contains different benchmarks.
Pretty straightforward. Pretty straightforward.
stubs/ stubs/
------ ^^^^^^
If a dependency doesn't support typing, files here must work around that. If a dependency doesn't support typing, files here must work around that.
.. _tools:
tools/ tools/
------ ^^^^^^
Various utility scripts. Various utility scripts.
Each script should have a "comment" at the top explaining what they are for. 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 <https://www.sphinx-doc.org>`_ and `graphviz <https://www.graphviz.org>`_'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/ generator/
---------- ^^^^^^^^^^
A package that should not be published and is only used when developing the library. 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. 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. 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. 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/ client/
------- ^^^^^^^
The Telethon client library and documentation lives here. The Telethon client library and documentation lives here.
This is the package that gets published. This is the package that gets published.

View File

@ -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``. ``.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 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). 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 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 <https://pypi.org/project/jsonpickle/>`_. If you need to serialize the session data to a string, you can use something like `jsonpickle <https://pypi.org/project/jsonpickle/>`_.
Or even the built-in :mod:`pickle` followed by :mod:`base64` or just :meth:`bytes.hex`. 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`. 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.

View File

@ -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.

View File

@ -123,6 +123,4 @@ Tips and tricks to develop both with the library and for the library.
developing/changelog developing/changelog
developing/migration-guide developing/migration-guide
developing/faq developing/faq
developing/philosophy.rst developing/contributing
developing/coding-style.rst
developing/project-structure.rst

View File

@ -1,4 +1 @@
Client
======
.. autoclass:: telethon.Client .. autoclass:: telethon.Client

View File

@ -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

View File

@ -1,8 +1,10 @@
"""
The main package for the Telethon library.
"""
from ._impl import tl as _tl 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.client.errors import errors
from ._impl.mtproto import RpcError from ._impl.mtproto import RpcError
from ._impl.session import Session
from .version import __version__ from .version import __version__
__all__ = ["_tl", "Client", "Config", "errors", "RpcError", "Session"] __all__ = ["_tl", "Client", "errors", "RpcError"]

View File

@ -9,7 +9,7 @@ from ...mtproto import RpcError
from ...session import User as SessionUser from ...session import User as SessionUser
from ...tl import abcs, functions, types from ...tl import abcs, functions, types
from ..types import LoginToken, PasswordToken, User from ..types import LoginToken, PasswordToken, User
from .net import connect_sender from .net import connect_sender, datacenter_for_id
if TYPE_CHECKING: if TYPE_CHECKING:
from .client import Client 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: async def complete_login(client: Client, auth: abcs.auth.Authorization) -> User:
assert client._sender
assert isinstance(auth, types.auth.Authorization) assert isinstance(auth, types.auth.Authorization)
assert isinstance(auth.user, types.User) assert isinstance(auth.user, types.User)
user = User._from_raw(auth.user) user = User._from_raw(auth.user)
client._config.session.user = SessionUser( client._session.user = SessionUser(
id=user.id, dc=client._dc_id, bot=user.bot, username=user.username id=user.id, dc=client._sender.dc_id, bot=user.bot, username=user.username
) )
packed = user.pack() 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: async def handle_migrate(client: Client, dc_id: Optional[int]) -> None:
assert dc_id is not 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: async with client._sender_lock:
client._sender = sender client._sender = sender
client._dc_id = dc_id
async def bot_sign_in(self: Client, token: str) -> User: async def bot_sign_in(self: Client, token: str) -> User:

View File

@ -1,9 +1,9 @@
from __future__ import annotations 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 ...tl import abcs, functions, types
from ..types import ChatLike, Message, NoPublicConstructor from ..types import ChatLike, InlineResult, NoPublicConstructor
from ..utils import generate_random_id from ..utils import generate_random_id
if TYPE_CHECKING: if TYPE_CHECKING:
@ -64,64 +64,6 @@ class InlineResults(metaclass=NoPublicConstructor):
return self._buffer.pop() 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( async def inline_query(
self: Client, bot: ChatLike, query: str = "", *, chat: Optional[ChatLike] = None self: Client, bot: ChatLike, query: str = "", *, chat: Optional[ChatLike] = None
) -> AsyncIterator[InlineResult]: ) -> AsyncIterator[InlineResult]:

View File

@ -18,6 +18,9 @@ from typing import (
Union, Union,
) )
from telethon._impl.session.session import DataCenter
from ....version import __version__ as default_version
from ...mtsender import Sender from ...mtsender import Sender
from ...session import ( from ...session import (
ChatHashCache, ChatHashCache,
@ -93,6 +96,8 @@ from .net import (
Config, Config,
connect, connect,
connected, connected,
default_device_model,
default_system_version,
disconnect, disconnect,
invoke_request, invoke_request,
run_until_disconnected, run_until_disconnected,
@ -137,9 +142,13 @@ class Client:
:param api_id: :param api_id:
The API ID. See :doc:`/basic/signing-in` to learn how to obtain it. The API ID. See :doc:`/basic/signing-in` to learn how to obtain it.
This is required to initialize the connection.
:param api_hash: :param api_hash:
The API hash. See :doc:`/basic/signing-in` to learn how to obtain it. 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: :param device_model:
Device model. Device model.
@ -158,8 +167,8 @@ class Client:
:param catch_up: :param catch_up:
Whether to "catch up" on updates that occured while the client was not connected. Whether to "catch up" on updates that occured while the client was not connected.
:param server_addr: :param datacenter:
Override the server address ``'ip:port'`` pair to connect to. Override the datacenter to connect to.
Useful to connect to one of Telegram's test servers. Useful to connect to one of Telegram's test servers.
:param flood_sleep_threshold: :param flood_sleep_threshold:
@ -183,23 +192,45 @@ class Client:
session: Optional[Union[str, Path, Storage]], session: Optional[Union[str, Path, Storage]],
api_id: int, api_id: int,
api_hash: Optional[str] = None, 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, check_all_handlers: bool = False,
) -> None: ) -> None:
self._sender: Optional[Sender] = None self._sender: Optional[Sender] = None
self._sender_lock = asyncio.Lock() self._sender_lock = asyncio.Lock()
self._dc_id = DEFAULT_DC
if isinstance(session, Storage): if isinstance(session, Storage):
self._storage = session self._storage = session
elif session is None: elif session is None:
self._storage = MemorySession() self._storage = MemorySession()
else: else:
self._storage = SqliteSession(session) self._storage = SqliteSession(session)
self._config = Config( self._config = Config(
session=Session(),
api_id=api_id, api_id=api_id,
api_hash=api_hash or "", 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._message_box = MessageBox()
self._chat_hashes = ChatHashCache(None) self._chat_hashes = ChatHashCache(None)
self._last_update_limit_warn: Optional[float] = None self._last_update_limit_warn: Optional[float] = None
@ -212,10 +243,8 @@ class Client:
] = {} ] = {}
self._shortcircuit_handlers = not check_all_handlers self._shortcircuit_handlers = not check_all_handlers
if self_user := self._config.session.user: if self._session.user and self._config.catch_up and self._session.state:
self._dc_id = self_user.dc self._message_box.load(self._session.state)
if self._config.catch_up and self._config.session.state:
self._message_box.load(self._config.session.state)
# Begin partially @generated # Begin partially @generated
@ -468,7 +497,10 @@ class Client:
:param message_id: :param message_id:
The identifier of the message to edit. 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. :return: The edited message.
@ -728,6 +760,27 @@ class Client:
def get_messages_with_ids( def get_messages_with_ids(
self, chat: ChatLike, message_ids: List[int] self, chat: ChatLike, message_ids: List[int]
) -> AsyncList[Message]: ) -> 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) return get_messages_with_ids(self, chat, message_ids)
def get_participants(self, chat: ChatLike) -> AsyncList[Participant]: 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. It is very likely that you will not be able to fetch all the members.
There is no way to bypass this. There is no way to bypass this.
:param chat:
The :term:`chat` to fetch participants from.
:return: The participants. :return: The participants.
.. rubric:: Example .. rubric:: Example
@ -753,6 +809,9 @@ class Client:
""" """
Get the profile pictures set in a chat, or user avatars. 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. :return: The photo files.
.. rubric:: Example .. rubric:: Example
@ -990,9 +1049,32 @@ class Client:
return await resolve_to_packed(self, chat) return await resolve_to_packed(self, chat)
async def resolve_username(self, username: str) -> 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) return await resolve_username(self, username)
async def run_until_disconnected(self) -> None: 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) await run_until_disconnected(self)
def search_all_messages( def search_all_messages(
@ -1084,10 +1166,8 @@ class Client:
async def send_audio( async def send_audio(
self, self,
chat: ChatLike, 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, size: Optional[int] = None,
name: Optional[str] = None, name: Optional[str] = None,
duration: Optional[float] = None, duration: Optional[float] = None,
@ -1105,12 +1185,18 @@ class Client:
duration, title and performer if they are not provided. duration, title and performer if they are not provided.
:param chat: :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: :param file: See :meth:`send_file`.
A local file path or :class:`~telethon.types.File` to send. :param size: See :meth:`send_file`.
:param name: See :meth:`send_file`.
The rest of parameters behave the same as they do in :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 .. rubric:: Example
@ -1121,9 +1207,7 @@ class Client:
return await send_audio( return await send_audio(
self, self,
chat, chat,
path, file,
url=url,
file=file,
size=size, size=size,
name=name, name=name,
duration=duration, duration=duration,
@ -1138,10 +1222,8 @@ class Client:
async def send_file( async def send_file(
self, self,
chat: ChatLike, 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, size: Optional[int] = None,
name: Optional[str] = None, name: Optional[str] = None,
mime_type: Optional[str] = None, mime_type: Optional[str] = None,
@ -1152,7 +1234,6 @@ class Client:
title: Optional[str] = None, title: Optional[str] = None,
performer: Optional[str] = None, performer: Optional[str] = None,
emoji: Optional[str] = None, emoji: Optional[str] = None,
emoji_sticker: Optional[str] = None,
width: Optional[int] = None, width: Optional[int] = None,
height: Optional[int] = None, height: Optional[int] = None,
round: bool = False, 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: 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`. `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: :param chat:
The :term:`chat` where the message will be sent to. The :term:`chat` where the message will be sent to.
@ -1177,36 +1258,121 @@ class Client:
:param path: :param path:
A local file path or :class:`~telethon.types.File` to send. A local file path or :class:`~telethon.types.File` to send.
:param caption: :param file:
Caption text to display under the media, with no formatting. The file to send.
:param caption_markdown: This can be a path, relative or absolute, to a local file, as either a :class:`str` or :class:`pathlib.Path`.
Caption text to display under the media, parsed as markdown.
:param caption_html: It can also be a file opened for reading in binary mode, with its ``read`` method optionally being ``async``.
Caption text to display under the media, parsed as HTML. 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 you wrote to an in-memory file, you probably want to ``file.seek(0)`` first.
if *path* isn't a :class:`~telethon.types.File`. If you want to send :class:`bytes`, wrap them in :class:`io.BytesIO` first.
See the documentation of :meth:`~telethon.types.File.new` to learn what they do.
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 .. rubric:: Example
.. code-block:: python .. code-block:: python
login_token = await client.request_login_code('+1 23 456...') await client.send_file(chat, 'picture.jpg')
print(login_token.timeout, 'seconds before code expires')
# 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( return await send_file(
self, self,
chat, chat,
path, file,
url=url,
file=file,
size=size, size=size,
name=name, name=name,
mime_type=mime_type, mime_type=mime_type,
@ -1217,7 +1383,6 @@ class Client:
title=title, title=title,
performer=performer, performer=performer,
emoji=emoji, emoji=emoji,
emoji_sticker=emoji_sticker,
width=width, width=width,
height=height, height=height,
round=round, round=round,
@ -1244,16 +1409,9 @@ class Client:
:param chat: :param chat:
The :term:`chat` where the message will be sent to. The :term:`chat` where the message will be sent to.
:param text: :param text: See :ref:`formatting`.
Message text, with no formatting. :param markdown: See :ref:`formatting`.
:param html: See :ref:`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 link_preview: :param link_preview:
Whether the link preview is allowed. Whether the link preview is allowed.
@ -1266,10 +1424,6 @@ class Client:
:param reply_to: :param reply_to:
The message identifier of the message to 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 .. rubric:: Example
.. code-block:: python .. code-block:: python
@ -1289,10 +1443,8 @@ class Client:
async def send_photo( async def send_photo(
self, self,
chat: ChatLike, 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, size: Optional[int] = None,
name: Optional[str] = None, name: Optional[str] = None,
compress: bool = True, compress: bool = True,
@ -1309,16 +1461,21 @@ class Client:
Only compressed images can be displayed as photos in applications. 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. 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. width and height if they are not provided.
:param chat: :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: :param file: See :meth:`send_file`.
A local file path or :class:`~telethon.types.File` to send. :param size: See :meth:`send_file`.
:param name: See :meth:`send_file`.
The rest of parameters behave the same as they do in :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 .. rubric:: Example
@ -1329,9 +1486,7 @@ class Client:
return await send_photo( return await send_photo(
self, self,
chat, chat,
path, file,
url=url,
file=file,
size=size, size=size,
name=name, name=name,
compress=compress, compress=compress,
@ -1345,10 +1500,8 @@ class Client:
async def send_video( async def send_video(
self, self,
chat: ChatLike, 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, size: Optional[int] = None,
name: Optional[str] = None, name: Optional[str] = None,
duration: Optional[float] = None, duration: Optional[float] = None,
@ -1363,16 +1516,23 @@ class Client:
""" """
Send a video file. 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. duration, width and height if they are not provided.
:param chat: :param chat:
The :term:`chat` where the message will be sent to. The :term:`chat` where the message will be sent to.
:param path: :param file: See :meth:`send_file`.
A local file path or :class:`~telethon.types.File` to send. :param size: See :meth:`send_file`.
:param name: See :meth:`send_file`.
The rest of parameters behave the same as they do in :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 .. rubric:: Example
@ -1383,9 +1543,7 @@ class Client:
return await send_video( return await send_video(
self, self,
chat, chat,
path, file,
url=url,
file=file,
size=size, size=size,
name=name, name=name,
duration=duration, duration=duration,
@ -1442,6 +1600,10 @@ class Client:
:param token: :param token:
The login token returned from :meth:`request_login_code`. 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: :return:
The user corresponding to :term:`yourself`, or a password token if the account has 2FA enabled. The user corresponding to :term:`yourself`, or a password token if the account has 2FA enabled.

View File

@ -1,9 +1,12 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import math
import mimetypes
import urllib.parse
from inspect import isawaitable from inspect import isawaitable
from pathlib import Path 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 ...tl import abcs, functions, types
from ..types import ( from ..types import (
@ -28,14 +31,15 @@ MAX_CHUNK_SIZE = 512 * 1024
FILE_MIGRATE_ERROR = 303 FILE_MIGRATE_ERROR = 303
BIG_FILE_SIZE = 10 * 1024 * 1024 BIG_FILE_SIZE = 10 * 1024 * 1024
# ``round`` parameter would make this more annoying to access otherwise.
math_round = round
async def send_photo( async def send_photo(
self: Client, self: Client,
chat: ChatLike, 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, size: Optional[int] = None,
name: Optional[str] = None, name: Optional[str] = None,
compress: bool = True, compress: bool = True,
@ -48,9 +52,7 @@ async def send_photo(
return await send_file( return await send_file(
self, self,
chat, chat,
path, file,
url=url,
file=file,
size=size, size=size,
name=name, name=name,
compress=compress, compress=compress,
@ -65,10 +67,8 @@ async def send_photo(
async def send_audio( async def send_audio(
self: Client, self: Client,
chat: ChatLike, 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, size: Optional[int] = None,
name: Optional[str] = None, name: Optional[str] = None,
duration: Optional[float] = None, duration: Optional[float] = None,
@ -82,9 +82,7 @@ async def send_audio(
return await send_file( return await send_file(
self, self,
chat, chat,
path, file,
url=url,
file=file,
size=size, size=size,
name=name, name=name,
duration=duration, duration=duration,
@ -100,10 +98,8 @@ async def send_audio(
async def send_video( async def send_video(
self: Client, self: Client,
chat: ChatLike, 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, size: Optional[int] = None,
name: Optional[str] = None, name: Optional[str] = None,
duration: Optional[float] = None, duration: Optional[float] = None,
@ -118,9 +114,7 @@ async def send_video(
return await send_file( return await send_file(
self, self,
chat, chat,
path, file,
url=url,
file=file,
size=size, size=size,
name=name, name=name,
duration=duration, 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( async def send_file(
self: Client, self: Client,
chat: ChatLike, 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, size: Optional[int] = None,
name: Optional[str] = None, name: Optional[str] = None,
mime_type: Optional[str] = None, mime_type: Optional[str] = None,
@ -161,61 +162,127 @@ async def send_file(
caption_markdown: Optional[str] = None, caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None, caption_html: Optional[str] = None,
) -> Message: ) -> 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( message, entities = parse_message(
text=caption, markdown=caption_markdown, html=caption_html, allow_empty=True text=caption, markdown=caption_markdown, html=caption_html, allow_empty=True
) )
assert isinstance(message, str) 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: # URLs are handled early as they can't use any other attributes either.
if file_info._input_file is None: input_media: abcs.InputMedia
file_info._input_file = await upload(self, file_info) if (url_path := try_get_url_path(file)) is not None:
file_info._input_media = ( assert isinstance(file, str)
types.InputMediaUploadedPhoto( 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
)
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, spoiler=False,
file=file_info._input_file, file=input_file,
stickers=None, stickers=None,
ttl_seconds=None, ttl_seconds=None,
) )
if file_info._photo
else types.InputMediaUploadedDocument( # Only bother to calculate attributes when sending documents.
nosound_video=file_info._muted, 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, force_file=False,
spoiler=False, spoiler=False,
file=file_info._input_file, file=input_file,
thumb=None, thumb=None,
mime_type=file_info._mime, mime_type=mime_type,
attributes=file_info._attributes, attributes=attributes,
stickers=None, stickers=None,
ttl_seconds=None, ttl_seconds=None,
) )
)
random_id = generate_random_id()
return self._build_message_map( return await do_send_file(self, chat, input_media, message, entities)
await self(
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( functions.messages.send_media(
silent=False, silent=False,
background=False, background=False,
@ -224,7 +291,7 @@ async def send_file(
update_stickersets_order=False, update_stickersets_order=False,
peer=peer, peer=peer,
reply_to=None, reply_to=None,
media=file_info._input_media, media=input_media,
message=message, message=message,
random_id=random_id, random_id=random_id,
reply_markup=None, reply_markup=None,
@ -239,26 +306,28 @@ async def send_file(
async def upload( async def upload(
client: Client, client: Client,
file: File, fd: InFileLike,
size: int,
name: str,
) -> abcs.InputFile: ) -> abcs.InputFile:
file_id = generate_random_id() file_id = generate_random_id()
uploaded = 0 uploaded = 0
part = 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() buffer = bytearray()
to_store: Union[bytearray, bytes] = b"" to_store: Union[bytearray, bytes] = b""
hash_md5 = hashlib.md5() hash_md5 = hashlib.md5()
is_big = file._size > BIG_FILE_SIZE is_big = size > BIG_FILE_SIZE
fd = file._open() while uploaded != size:
try: ret = fd.read(MAX_CHUNK_SIZE - len(buffer))
while uploaded != file._size: chunk = await ret if isawaitable(ret) else ret
chunk = await fd.read(MAX_CHUNK_SIZE - len(buffer)) assert isinstance(chunk, bytes)
if not chunk: if not chunk:
raise ValueError("unexpected end-of-file") raise ValueError("unexpected end-of-file")
if len(chunk) == MAX_CHUNK_SIZE or uploaded + len(chunk) == file._size: if len(chunk) == MAX_CHUNK_SIZE or uploaded + len(chunk) == size:
to_store = chunk to_store = chunk
else: else:
buffer += chunk buffer += chunk
@ -286,20 +355,18 @@ async def upload(
buffer.clear() buffer.clear()
part += 1 part += 1
finally:
fd.close()
if file._size > BIG_FILE_SIZE: if is_big:
return types.InputFileBig( return types.InputFileBig(
id=file_id, id=file_id,
parts=total_parts, parts=total_parts,
name=file._name, name=name,
) )
else: else:
return types.InputFile( return types.InputFile(
id=file_id, id=file_id,
parts=total_parts, parts=total_parts,
name=file._name, name=name,
md5_checksum=hash_md5.hexdigest(), md5_checksum=hash_md5.hexdigest(),
) )

View File

@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import itertools
import platform import platform
import re import re
from dataclasses import dataclass, field 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 ....version import __version__
from ...mtproto import Full, RpcError from ...mtproto import Full, RpcError
@ -13,7 +14,7 @@ from ...mtsender import connect as connect_without_auth
from ...mtsender import connect_with_auth from ...mtsender import connect_with_auth
from ...session import DataCenter, Session from ...session import DataCenter, Session
from ...session import User as SessionUser 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 ..errors import adapt_rpc
from .updates import dispatcher, process_socket_updates from .updates import dispatcher, process_socket_updates
@ -41,13 +42,6 @@ def default_system_version() -> str:
@dataclass @dataclass
class Config: 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_id: int
api_hash: str api_hash: str
device_model: str = field(default_factory=default_device_model) device_model: str = field(default_factory=default_device_model)
@ -56,54 +50,33 @@ class Config:
system_lang_code: str = "en" system_lang_code: str = "en"
lang_code: str = "en" lang_code: str = "en"
catch_up: bool = False catch_up: bool = False
server_addr: Optional[str] = None datacenter: Optional[DataCenter] = None
flood_sleep_threshold: Optional[int] = 60 flood_sleep_threshold: Optional[int] = 60
update_queue_limit: Optional[int] = None update_queue_limit: Optional[int] = None
# dc_id to IPv4 and port pair KNOWN_DC = [
DC_ADDRESSES = [ DataCenter(id=1, addr="149.154.175.53:443", auth=None),
"0.0.0.0:0", DataCenter(id=2, addr="149.154.167.51:443", auth=None),
"149.154.175.53:443", DataCenter(id=3, addr="149.154.175.100:443", auth=None),
"149.154.167.51:443", DataCenter(id=4, addr="149.154.167.92:443", auth=None),
"149.154.175.100:443", DataCenter(id=5, addr="91.108.56.190:443", auth=None),
"149.154.167.92:443",
"91.108.56.190:443",
] ]
DEFAULT_DC = 2 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() transport = Full()
if config.server_addr:
addr = config.server_addr
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: if dc.auth:
auth_key = dc.auth sender = await connect_with_auth(transport, dc.id, dc.addr, dc.auth)
break
if auth_key:
sender = await connect_with_auth(transport, addr, auth_key)
else: else:
sender = await connect_without_auth(transport, addr) sender = await connect_without_auth(transport, dc.id, dc.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)
)
# TODO handle -404 (we had a previously-valid authkey, but server no longer knows about it) # 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( remote_config = await sender.invoke(
functions.invoke_with_layer( functions.invoke_with_layer(
layer=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: async def connect(self: Client) -> None:
@ -131,32 +124,44 @@ async def connect(self: Client) -> None:
return return
if session := await self._storage.load(): if session := await self._storage.load():
self._config.session = session self._session = session
if user := self._config.session.user: if dc := self._config.datacenter:
self._dc_id = user.dc # 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: else:
for dc in self._config.session.dcs: # Reuse the session's datacenter, falling back to defaults if not found.
if dc.auth: datacenter = datacenter_for_id(
self._dc_id = dc.id self, self._session.user.dc if self._session.user else DEFAULT_DC
break )
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: try:
await self(functions.updates.get_state()) await self(functions.updates.get_state())
except RpcError as e: except RpcError as e:
if e.code == 401: if e.code == 401:
self._config.session.user = None self._session.user = None
except Exception as e: except Exception as e:
pass pass
else: else:
if not self._config.session.user: if not self._session.user:
me = await self.get_me() me = await self.get_me()
assert me is not None assert me is not None
self._config.session.user = SessionUser( self._session.user = SessionUser(
id=me.id, dc=self._dc_id, bot=me.bot, username=me.username id=me.id, dc=self._sender.dc_id, bot=me.bot, username=me.username
) )
packed = me.pack() packed = me.pack()
assert packed is not None assert packed is not None
@ -165,6 +170,17 @@ async def connect(self: Client) -> None:
self._dispatcher = asyncio.create_task(dispatcher(self)) 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: async def disconnect(self: Client) -> None:
if not self._sender: if not self._sender:
return return
@ -173,8 +189,6 @@ async def disconnect(self: Client) -> None:
self._dispatcher.cancel() self._dispatcher.cancel()
try: try:
await self._dispatcher await self._dispatcher
except asyncio.CancelledError:
pass
except Exception: except Exception:
pass # TODO log pass # TODO log
finally: finally:
@ -187,8 +201,8 @@ async def disconnect(self: Client) -> None:
finally: finally:
self._sender = None self._sender = None
self._config.session.state = self._message_box.session_state() self._session.state = self._message_box.session_state()
await self._storage.save(self._config.session) await self._storage.save(self._session)
async def invoke_request( async def invoke_request(

View File

@ -123,14 +123,24 @@ def extend_update_queue(
async def dispatcher(client: Client) -> None: async def dispatcher(client: Client) -> None:
loop = asyncio.get_running_loop()
while client.connected: while client.connected:
try: try:
await dispatch_next(client) await dispatch_next(client)
except asyncio.CancelledError: except asyncio.CancelledError:
raise return
except Exception: 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 # TODO proper logger
logging.exception("Unhandled exception in event handler") logging.exception("Unhandled exception in event handler")
raise
async def dispatch_next(client: Client) -> None: async def dispatch_next(client: Client) -> None:

View File

@ -76,10 +76,10 @@ async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat:
if isinstance(chat, types.InputPeerEmpty): if isinstance(chat, types.InputPeerEmpty):
raise ValueError("Cannot resolve chat") raise ValueError("Cannot resolve chat")
elif isinstance(chat, types.InputPeerSelf): elif isinstance(chat, types.InputPeerSelf):
if not self._config.session.user: if not self._session.user:
raise ValueError("Cannot resolve chat") raise ValueError("Cannot resolve chat")
return PackedChat( 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, id=self._chat_hashes.self_id,
access_hash=0, # TODO get hash access_hash=0, # TODO get hash
) )
@ -112,7 +112,7 @@ async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat:
if chat.startswith("+"): if chat.startswith("+"):
resolved = await resolve_phone(self, chat) resolved = await resolve_phone(self, chat)
elif chat == "me": elif chat == "me":
if me := self._config.session.user: if me := self._session.user:
return PackedChat( return PackedChat(
ty=PackedType.BOT if me.bot else PackedType.USER, ty=PackedType.BOT if me.bot else PackedType.USER,
id=me.id, id=me.id,

View File

@ -72,7 +72,7 @@ class Command:
self._username = "" self._username = ""
client: Optional[Client] client: Optional[Client]
if (client := getattr(event, "_client", None)) is not None: if (client := getattr(event, "_client", None)) is not None:
user = client._config.session.user user = client._session.user
if user and user.username: if user and user.username:
self._username = user.username self._username = user.username

View File

@ -2,12 +2,13 @@ from .async_list import AsyncList
from .chat import Channel, Chat, ChatLike, Group, RestrictionReason, User from .chat import Channel, Chat, ChatLike, Group, RestrictionReason, User
from .dialog import Dialog from .dialog import Dialog
from .draft import Draft 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 .login_token import LoginToken
from .message import Message from .message import Message
from .meta import NoPublicConstructor from .meta import NoPublicConstructor
from .participant import Participant from .participant import Participant
from .password_token import PasswordToken from .password_token import PasswordToken
from .inline_result import InlineResult
from .recent_action import RecentAction from .recent_action import RecentAction
__all__ = [ __all__ = [
@ -22,9 +23,9 @@ __all__ = [
"Draft", "Draft",
"File", "File",
"InFileLike", "InFileLike",
"InWrapper",
"OutFileLike", "OutFileLike",
"OutWrapper", "OutWrapper",
"InlineResult",
"LoginToken", "LoginToken",
"Message", "Message",
"NoPublicConstructor", "NoPublicConstructor",

View File

@ -44,7 +44,7 @@ stripped_size_header = bytes.fromhex(
stripped_size_footer = bytes.fromhex("FFD9") 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 = bytearray(stripped_size_header)
header[164] = data[1] header[164] = data[1]
header[166] = data[2] header[166] = data[2]
@ -77,7 +77,12 @@ class InFileLike(Protocol):
""" """
def read(self, n: int) -> Union[bytes, Coroutine[Any, Any, bytes]]: 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): class OutFileLike(Protocol):
@ -87,33 +92,12 @@ class OutFileLike(Protocol):
""" """
def write(self, data: bytes) -> Union[Any, Coroutine[Any, Any, Any]]: def write(self, data: bytes) -> Union[Any, Coroutine[Any, Any, Any]]:
pass """
Write all the data into the file or buffer.
:param data:
class InWrapper: Data that must be written to the buffer entirely.
__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()
class OutWrapper: class OutWrapper:
@ -143,30 +127,24 @@ class OutWrapper:
class File(metaclass=NoPublicConstructor): class File(metaclass=NoPublicConstructor):
""" """
File information of uploaded media. File information of media sent to Telegram that can be downloaded.
It is used both when sending files or accessing media in a `Message`.
""" """
def __init__( def __init__(
self, self,
*, *,
path: Optional[Path],
file: Optional[InFileLike],
attributes: List[abcs.DocumentAttribute], attributes: List[abcs.DocumentAttribute],
size: int, size: int,
name: str, name: str,
mime: str, mime: str,
photo: bool, photo: bool,
muted: bool, muted: bool,
input_media: Optional[abcs.InputMedia], input_media: abcs.InputMedia,
thumb: Optional[abcs.PhotoSize], thumb: Optional[abcs.PhotoSize],
thumbs: Optional[List[abcs.PhotoSize]], thumbs: Optional[List[abcs.PhotoSize]],
raw: Optional[Union[abcs.MessageMedia, abcs.Photo, abcs.Document]], raw: Optional[Union[abcs.MessageMedia, abcs.Photo, abcs.Document]],
client: Optional[Client], client: Optional[Client],
): ):
self._path = path
self._file = file
self._attributes = attributes self._attributes = attributes
self._size = size self._size = size
self._name = name self._name = name
@ -226,8 +204,6 @@ class File(metaclass=NoPublicConstructor):
) -> Optional[Self]: ) -> Optional[Self]:
if isinstance(raw, types.Document): if isinstance(raw, types.Document):
return cls._create( return cls._create(
path=None,
file=None,
attributes=raw.attributes, attributes=raw.attributes,
size=raw.size, size=raw.size,
name=next( name=next(
@ -279,8 +255,6 @@ class File(metaclass=NoPublicConstructor):
if isinstance(raw, types.Photo): if isinstance(raw, types.Photo):
largest_thumb = max(raw.sizes, key=photo_size_byte_count) largest_thumb = max(raw.sizes, key=photo_size_byte_count)
return cls._create( return cls._create(
path=None,
file=None,
attributes=[], attributes=[],
size=photo_size_byte_count(largest_thumb), size=photo_size_byte_count(largest_thumb),
name="", name="",
@ -304,165 +278,29 @@ class File(metaclass=NoPublicConstructor):
return None return None
@classmethod @property
def new( def name(self) -> Optional[str]:
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:
""" """
Create file information that can later be sent as media. The file name, if known.
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.
""" """
if isinstance(path, cls): for attr in self._attributes:
return path if isinstance(attr, types.DocumentAttributeFilename):
assert not isinstance(path, File) return attr.file_name
attributes: List[abcs.DocumentAttribute] = [] return None
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,
)
@property @property
def ext(self) -> str: def ext(self) -> str:
""" """
The file extension, including the leading dot ``.``. 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. This makes it safe to always append this property to a file name.
""" """
if self._path: if name := self._name:
return self._path.suffix return Path(name).suffix
else: else:
return mimetypes.guess_extension(self._mime) or "" return mimetypes.guess_extension(self._mime) or ""
@ -477,8 +315,6 @@ class File(metaclass=NoPublicConstructor):
""" """
return [ return [
File._create( File._create(
path=None,
file=None,
attributes=[], attributes=[],
size=photo_size_byte_count(t), size=photo_size_byte_count(t),
name="", name="",
@ -530,22 +366,13 @@ class File(metaclass=NoPublicConstructor):
""" """
Alias for :meth:`telethon.Client.download`. Alias for :meth:`telethon.Client.download`.
The file must have been obtained from Telegram to be downloadable. :param file: See :meth:`~telethon.Client.download`.
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.
""" """
if not self._client: if not self._client:
raise ValueError("only files from Telegram can be downloaded") raise ValueError("only files from Telegram can be downloaded")
await self._client.download(self, file) 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: def _input_location(self) -> abcs.InputFileLocation:
thumb_types = ( thumb_types = (
types.PhotoSizeEmpty, types.PhotoSizeEmpty,

View File

@ -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)

View File

@ -186,7 +186,10 @@ class Message(metaclass=NoPublicConstructor):
""" """
Alias for :meth:`telethon.Client.send_message`. 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( return await self._client.send_message(
self.chat, text, markdown=markdown, html=html, link_preview=link_preview 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. 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( return await self._client.send_message(
self.chat, self.chat,
@ -218,7 +224,7 @@ class Message(metaclass=NoPublicConstructor):
""" """
Alias for :meth:`telethon.Client.delete_messages`. 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) 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`. 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( return await self._client.edit_message(
self.chat, self.chat,
@ -247,17 +256,23 @@ class Message(metaclass=NoPublicConstructor):
""" """
Alias for :meth:`telethon.Client.forward_messages`. 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] return (await self._client.forward_messages(target, [self.id], self.chat))[0]
async def mark_read(self) -> None: async def mark_read(self) -> None:
pass 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 pass
async def unpin(self) -> None: async def unpin(self) -> None:
"""
Alias for :meth:`telethon.Client.unpin_message`.
"""
pass pass
# --- # ---

View File

@ -76,6 +76,8 @@ class Request(Generic[Return]):
@dataclass @dataclass
class Sender: class Sender:
dc_id: int
addr: str
_reader: StreamReader _reader: StreamReader
_writer: StreamWriter _writer: StreamWriter
_transport: Transport _transport: Transport
@ -88,10 +90,14 @@ class Sender:
_write_drain_pending: bool _write_drain_pending: bool
@classmethod @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(":")) reader, writer = await asyncio.open_connection(*addr.split(":"))
return cls( return cls(
dc_id=dc_id,
addr=addr,
_reader=reader, _reader=reader,
_writer=writer, _writer=writer,
_transport=transport, _transport=transport,
@ -271,8 +277,8 @@ class Sender:
return None return None
async def connect(transport: Transport, addr: str) -> Sender: async def connect(transport: Transport, dc_id: int, addr: str) -> Sender:
sender = await Sender.connect(transport, Plain(), addr) sender = await Sender.connect(transport, Plain(), dc_id, addr)
return await generate_auth_key(sender) return await generate_auth_key(sender)
@ -289,6 +295,8 @@ async def generate_auth_key(sender: Sender) -> Sender:
first_salt = finished.first_salt first_salt = finished.first_salt
return Sender( return Sender(
dc_id=sender.dc_id,
addr=sender.addr,
_reader=sender._reader, _reader=sender._reader,
_writer=sender._writer, _writer=sender._writer,
_transport=sender._transport, _transport=sender._transport,
@ -304,9 +312,10 @@ async def generate_auth_key(sender: Sender) -> Sender:
async def connect_with_auth( async def connect_with_auth(
transport: Transport, transport: Transport,
dc_id: int,
addr: str, addr: str,
auth_key: bytes, auth_key: bytes,
) -> Sender: ) -> Sender:
return await Sender.connect( return await Sender.connect(
transport, Encrypted(AuthKey.from_bytes(auth_key)), addr transport, Encrypted(AuthKey.from_bytes(auth_key)), dc_id, addr
) )

View File

@ -67,6 +67,9 @@ class PackedChat:
""" """
Convenience method to convert hexadecimal numbers into bytes then passed to :meth:`from_bytes`: Convenience method to convert hexadecimal numbers into bytes then passed to :meth:`from_bytes`:
:param hex:
Hexadecimal numbers to convert from.
.. code-block:: .. code-block::
assert PackedChat.from_hex(packed.hex) == packed assert PackedChat.from_hex(packed.hex) == packed

View File

@ -6,62 +6,71 @@ class DataCenter:
""" """
Data-center information. Data-center information.
:var id: The DC identifier. :param id: See below.
:var addr: The server address of the DC, in ``'ip:port'`` format. :param addr: See below.
:var auth: Authentication key to encrypt communication with. :param auth: See below.
""" """
__slots__ = ("id", "addr", "auth") __slots__ = ("id", "addr", "auth")
def __init__(self, *, id: int, addr: str, auth: Optional[bytes]) -> None: def __init__(self, *, id: int, addr: str, auth: Optional[bytes]) -> None:
self.id = id self.id = id
"The DC identifier."
self.addr = addr self.addr = addr
"The server address of the DC, in ``'ip:port'`` format."
self.auth = auth self.auth = auth
"Authentication key to encrypt communication with."
class User: class User:
""" """
Information about the logged-in user. Information about the logged-in user.
:var id: User identifier. :param id: See below.
:var dc: Data-center identifier of the user's "home" DC. :param dc: See below.
:var bot: :data:`True` if the user is from a bot account. :param bot: See below.
:var username: User's primary username. :param username: See below.
""" """
__slots__ = ("id", "dc", "bot", "username") __slots__ = ("id", "dc", "bot", "username")
def __init__(self, *, id: int, dc: int, bot: bool, username: Optional[str]) -> None: def __init__(self, *, id: int, dc: int, bot: bool, username: Optional[str]) -> None:
self.id = id self.id = id
"User identifier."
self.dc = dc self.dc = dc
'Data-center identifier of the user\'s "home" DC.'
self.bot = bot self.bot = bot
":data:`True` if the user is from a bot account."
self.username = username self.username = username
"User's primary username."
class ChannelState: class ChannelState:
""" """
Update state for a channel. Update state for a channel.
:var id: The channel identifier. :param id: See below.
:var pts: The channel's partial sequence number. :param pts: See below.
""" """
__slots__ = ("id", "pts") __slots__ = ("id", "pts")
def __init__(self, *, id: int, pts: int) -> None: def __init__(self, *, id: int, pts: int) -> None:
self.id = id self.id = id
"The channel identifier."
self.pts = pts self.pts = pts
"The channel's partial sequence number."
class UpdateState: class UpdateState:
""" """
Update state for an account. Update state for an account.
:var pts: The primary partial sequence number. :param pts: See below.
:var qts: The secondary partial sequence number. :param qts: See below.
:var date: Date of the latest update sequence. :param date: See below.
:var seq: The sequence number. :param seq: See below.
:var channels: Update state for channels. :param channels: See below.
""" """
__slots__ = ( __slots__ = (
@ -82,10 +91,15 @@ class UpdateState:
channels: List[ChannelState], channels: List[ChannelState],
) -> None: ) -> None:
self.pts = pts self.pts = pts
"The primary partial sequence number."
self.qts = qts self.qts = qts
"The secondary partial sequence number."
self.date = date self.date = date
"Date of the latest update sequence."
self.seq = seq self.seq = seq
"The sequence number."
self.channels = channels self.channels = channels
"Update state for channels."
class Session: class Session:
@ -102,9 +116,18 @@ class Session:
If you think the session has been compromised, immediately terminate all If you think the session has been compromised, immediately terminate all
sessions through an official Telegram client to revoke the authorization. sessions through an official Telegram client to revoke the authorization.
:param dcs: See below.
:param user: See below.
:param state: See below.
""" """
VERSION = 1 VERSION = 1
"""
Current version.
Will be incremented if new fields are added.
"""
__slots__ = ("dcs", "user", "state") __slots__ = ("dcs", "user", "state")
@ -116,81 +139,8 @@ class Session:
state: Optional[UpdateState] = None, state: Optional[UpdateState] = None,
): ):
self.dcs = dcs or [] self.dcs = dcs or []
"List of known data-centers."
self.user = user self.user = user
"Information about the logged-in user."
self.state = state self.state = state
"Update 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,
)

View File

@ -12,17 +12,22 @@ class Storage(abc.ABC):
@abc.abstractmethod @abc.abstractmethod
async def load(self) -> Optional[Session]: 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`. This method is called by the library prior to `connect`.
:return: The previously-saved session.
""" """
@abc.abstractmethod @abc.abstractmethod
async def save(self, session: Session) -> None: 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`. This method is called by the library post `disconnect`.
:param session:
The session information that should be persisted.
""" """
@abc.abstractmethod @abc.abstractmethod

View File

@ -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 ( from .._impl.client.events import (
CallbackQuery, CallbackQuery,
Event, Event,

View File

@ -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 ( from ._impl.session import (
ChannelState, ChannelState,
DataCenter, DataCenter,

View File

@ -1,3 +1,6 @@
"""
Classes for the various objects the library returns.
"""
from ._impl.client.client import Config, InlineResult from ._impl.client.client import Config, InlineResult
from ._impl.client.types import ( from ._impl.client.types import (
AsyncList, AsyncList,

View File

@ -2,7 +2,7 @@ import os
import random import random
from pytest import mark from pytest import mark
from telethon import Client, Config, Session from telethon import Client
from telethon import _tl as tl 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)) pong = await client(tl.mtproto.functions.ping(ping_id=ping_id))
assert isinstance(pong, tl.mtproto.types.Pong) assert isinstance(pong, tl.mtproto.types.Pong)
assert pong.ping_id == ping_id assert pong.ping_id == ping_id
await client.disconnect()

View File

@ -4,11 +4,10 @@ import logging
from pytest import LogCaptureFixture, mark from pytest import LogCaptureFixture, mark
from telethon._impl.mtproto import Full from telethon._impl.mtproto import Full
from telethon._impl.mtsender import connect from telethon._impl.mtsender import connect
from telethon._impl.session import DataCenter
from telethon._impl.tl import LAYER, abcs, functions, types from telethon._impl.tl import LAYER, abcs, functions, types
TELEGRAM_TEST_DC_2 = "149.154.167.40:443" TELEGRAM_TEST_DC = 2, "149.154.167.40:443"
TELEGRAM_DEFAULT_TEST_DC = TELEGRAM_TEST_DC_2
TEST_TIMEOUT = 10000 TEST_TIMEOUT = 10000
@ -22,9 +21,7 @@ async def test_invoke_encrypted_method(caplog: LogCaptureFixture) -> None:
def timeout() -> float: def timeout() -> float:
return deadline - asyncio.get_running_loop().time() return deadline - asyncio.get_running_loop().time()
sender = await asyncio.wait_for( sender = await asyncio.wait_for(connect(Full(), *TELEGRAM_TEST_DC), timeout())
connect(Full(), TELEGRAM_DEFAULT_TEST_DC), timeout()
)
rx = sender.enqueue( rx = sender.enqueue(
functions.invoke_with_layer( functions.invoke_with_layer(