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

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
``````````````````````
^^^^^^^^^^^^^^^^^^^^^^
Using one of the examples from their v3 documentation with logging and comments removed:
.. 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):
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 <https://tl.telethon.dev/>`_.

View File

@ -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 <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.
In Telethon, you will be using the :class:`~types.Message` class to interact with them.
.. _formatting:
Formatting messages
-------------------

View File

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

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

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``.
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 <https://pypi.org/project/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.

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/migration-guide
developing/faq
developing/philosophy.rst
developing/coding-style.rst
developing/project-structure.rst
developing/contributing

View File

@ -1,4 +1 @@
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.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"]

View File

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

View File

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

View File

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

View File

@ -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(
# 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
)
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=file_info._input_file,
file=input_file,
stickers=None,
ttl_seconds=None,
)
if file_info._photo
else types.InputMediaUploadedDocument(
nosound_video=file_info._muted,
# 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=file_info._input_file,
file=input_file,
thumb=None,
mime_type=file_info._mime,
attributes=file_info._attributes,
mime_type=mime_type,
attributes=attributes,
stickers=None,
ttl_seconds=None,
)
)
random_id = generate_random_id()
return self._build_message_map(
await self(
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,26 +306,28 @@ 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))
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:
if len(chunk) == MAX_CHUNK_SIZE or uploaded + len(chunk) == size:
to_store = chunk
else:
buffer += chunk
@ -286,20 +355,18 @@ async def upload(
buffer.clear()
part += 1
finally:
fd.close()
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(),
)

View File

@ -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
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)
sender = await connect_with_auth(transport, dc.id, dc.addr, dc.auth)
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(

View File

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

View File

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

View File

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

View File

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

View File

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

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`.
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
# ---

View File

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

View File

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

View File

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

View File

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

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 (
CallbackQuery,
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 (
ChannelState,
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.types import (
AsyncList,

View File

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

View File

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