Continue implementation and documentation

This commit is contained in:
Lonami Exo 2023-09-30 17:13:24 +02:00
parent 6a880f1ff1
commit 18895748c4
23 changed files with 913 additions and 88 deletions

20
client/doc/_static/custom.css vendored Normal file
View File

@ -0,0 +1,20 @@
.underline {
text-decoration: underline;
}
.strikethrough {
text-decoration: line-through;
}
.spoiler {
border-radius: 5%;
padding: 0.1em;
background-color: gray;
color: gray;
transition: all 200ms;
}
.spoiler:hover {
background-color: unset;
color: unset;
}

View File

@ -56,8 +56,8 @@ Interactive login
The library offers a method for "quick and dirty" scripts known as :meth:`~Client.interactive_login`.
This method will first check whether the account was previously logged-in, and if not, ask for a phone number to be input.
You can write the code in a file (such as ``hello.py``) and then run it, or use the built-in ``asyncio``-enabled REPL.
For this tutorial, we'll be using the ``asyncio`` REPL:
You can write the code in a file (such as ``hello.py``) and then run it, or use the built-in :mod:`asyncio`-enabled REPL.
For this tutorial, we'll be using the :mod:`asyncio` REPL:
.. code-block:: shell
@ -209,3 +209,6 @@ Otherwise, you might run into errors such as tasks being destroyed while pending
Once a :class:`Client` instance has been connected, you cannot change the :mod:`asyncio` event loop.
Methods like :func:`asyncio.run` setup and tear-down a new event loop every time.
If the loop changes, the client is likely to be "stuck" because its loop cannot advance.
If you want to learn how :mod:`asyncio` works under the hood or need an introduction,
you can read my own blog post `An Introduction to Asyncio <https://lonami.dev/blog/asyncio/>`_.

View File

@ -44,6 +44,11 @@ Glossary
.. seealso:: The :doc:`../concepts/botapi-vs-mtproto` concept.
HTTP Bot API
Telegram's simplified HTTP API to control bot accounts only.
.. seealso:: The :doc:`../concepts/botapi-vs-mtproto` concept.
login
Used to refer to the login process as a whole, as opposed to the action to :term:`sign in`.
The "login code" or "login token" get their name because they belong to the login process.

View File

@ -0,0 +1,191 @@
Messages
========
.. currentmodule:: telethon
.. role:: underline
:class: underline
.. role:: strikethrough
:class: strikethrough
.. role:: spoiler
:class: spoiler
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 messages
-------------------
The library supports 3 formatting modes: no formatting, CommonMark, HTML.
Telegram does not natively support markdown or HTML.
Clients such as Telethon parse the text into a list of formatting :tl:`MessageEntity` at different offsets.
Note that `CommonMark's markdown <https://commonmark.org/>`_ is not fully compatible with :term:`HTTP Bot API`'s
`MarkdownV2 style <https://core.telegram.org/bots/api#markdownv2-style>`_, and does not support spoilers::
*italic* and _italic_
**bold** and __bold__
# headings are underlined
~~strikethrough~~
[inline URL](https://www.example.com/)
[inline mention](tg://user?id=ab1234cd6789)
custom emoji image with ![👍](tg://emoji?id=1234567890)
`inline code`
```python
multiline pre-formatted
block with optional language
```
HTML is also not fully compatible with :term:`HTTP Bot API`'s
`MarkdownV2 style <https://core.telegram.org/bots/api#markdownv2-style>`_,
and instead favours more standard `HTML elements <https://developer.mozilla.org/en-US/docs/Web/HTML/Element>`_:
* ``strong`` and ``b`` for **bold**.
* ``em`` and ``i`` for *italics*.
* ``u`` for :underline:`underlined text`.
* ``del`` and ``s`` for :strikethrough:`strikethrough`.
* ``blockquote`` for quotes.
* ``details`` for :spoiler:`hidden text` (spoiler).
* ``code`` for ``inline code``
* ``pre`` for multiple lines of code.
* ``a`` for links.
* ``img`` for inline images (only custom emoji).
Both markdown and HTML recognise the following special URLs using the ``tg:`` protocol:
* ``tg://user?id=ab1234cd6789`` for inline mentions.
To make sure the mention works, use :attr:`types.PackedChat.hex`.
You can also use :attr:`types.User.id`, but the mention will fail if the user is not in cache.
* ``tg://emoji?id=1234567890`` for custom emoji.
You must use the document identifier as the value.
The alt-text of the image **must** be a emoji such as 👍.
To obtain a message's text formatted, use :attr:`types.Message.text_markdown` or :attr:`types.Message.text_html`.
To send a message with formatted text, use the ``markdown`` or ``html`` parameters in :meth:`Client.send_message`.
When sending files, the format is appended to the name of the ``caption`` parameter, either ``caption_markdown`` or ``caption_html``.
Message identifiers
-------------------
This is an in-depth explanation for how the :attr:`types.Message.id` works.
.. note::
You can safely skip this section if you're not interested.
Every account, whether it's an user account or bot account, has its own message counter.
This counter starts at 1, and is incremented by 1 every time a new message is received.
In private conversations or small groups, each account will receive a copy each message.
The message identifier will be based on the message counter of the receiving account.
In megagroups and broadcast channels, the message counter instead belongs to the channel itself.
It also starts at 1 and is incremented by 1 for every message sent to the group or channel.
This means every account will see the same message identifier for a given mesasge in a group or channel.
This design has the following implications:
* The message identifier alone is enough to uniquely identify a message only if it's not from a megagroup or channel.
This is why :class:`events.MessageDeleted` does not need to (and doesn't always) include chat information.
* Messages cannot be deleted for one-side only in megagroups or channels.
Because every account shares the same identifier for the message, it cannot be deleted only for some.
* Links to messages only work for everyone inside megagroups or channels.
In private conversations and small groups, each account will have their own counter, and the identifiers won't match.
Let's look at a concrete example.
* You are logged in as User-A.
* Both User-B and User-C are your mutual contacts.
* You have share a small group called Group-S with User-B.
* You also share a megagroup called Group-M with User-C.
.. graphviz::
:caption: Demo scenario
digraph scenario {
"User A" [shape=trapezium];
"User B" [shape=box];
"User C" [shape=box];
"User A" -> "User B";
"User A" -> "User C";
"Group-S" -> "User A";
"Group-S" -> "User B";
"Group-M" -> "User A";
"Group-M" -> "User C";
}
Every account and channel has just been created.
This means everyone has a message counter of one.
First, User-A will sent a welcome message to both User-B and User-C::
User-A → User-B: Hey, welcome!
User-A → User-C: ¡Bienvenido!
* For User-A, "Hey, welcome!" will have the message identifier 1. The message with "¡Bienvenido!" will have an ID of 2.
* For User-B, "Hey, welcome" will have ID 1.
* For User-B, "¡Bienvenido!" will have ID 1.
.. csv-table:: Message identifiers
:header: "Message", "User-A", "User-B", "User-C", "Group-S", "Group-M"
"Hey, welcome!", 1, 1, "", "", ""
"¡Bienvenido!", 2, "", 1, "", ""
Next, User-B and User-C will respond to User-A::
User-B → User-A: Thanks!
User-C → User-A: Gracias :)
.. csv-table:: Message identifiers
:header: "Message", "User-A", "User-B", "User-C", "Group-S", "Group-M"
"Hey, welcome!", 1, 1, "", "", ""
"¡Bienvenido!", 2, "", 1, "", ""
"Thanks!", 3, 2, "", "", ""
"Gracias :)", 4, "", 2, "", ""
Notice how for each message, the counter goes up by one, and they are independent.
Let's see what happens when User-B sends a message to Group-S::
User-B → Group-S: Nice group
.. csv-table:: Message identifiers
:header: "Message", "User-A", "User-B", "User-C", "Group-S", "Group-M"
"Hey, welcome!", 1, 1, "", "", ""
"¡Bienvenido!", 2, "", 1, "", ""
"Thanks!", 3, 2, "", "", ""
"Gracias :)", 4, "", 2, "", ""
"Nice group", 5, 3, "", "", ""
While the message was sent to a different chat, the group itself doesn't have a counter.
The message identifiers are still unique for each account.
The chat where the message was sent can be completely ignored.
Megagroups behave differently::
User-C → Group-M: Buen grupo
.. csv-table:: Message identifiers
:header: "Message", "User-A", "User-B", "User-C", "Group-S", "Group-M"
"Hey, welcome!", 1, 1, "", "", ""
"¡Bienvenido!", 2, "", 1, "", ""
"Thanks!", 3, 2, "", "", ""
"Gracias :)", 4, "", 2, "", ""
"Nice group", 5, 3, "", "", ""
"Buen grupo", "", "", "", "", 1
The group has its own message counter.
Each user won't get a copy of the message with their own identifier, but rather everyone sees the same message.

View File

@ -40,3 +40,4 @@ graphviz_output_format = "svg"
html_theme = "sphinx_rtd_theme"
html_static_path = ["_static"]
html_css_files = ["custom.css"]

View File

@ -0,0 +1,2 @@
Frequently Asked Questions
==========================

View File

@ -37,6 +37,7 @@ I can guarantee you will into far less problems.
Without further ado, let's take a look at the biggest changes.
This list may not be exhaustive, but it should give you an idea on what to expect.
If you feel like a major change is missing, please `open an issue <https://github.com/LonamiWebs/Telethon/>`_.
Complete project restructure
@ -53,6 +54,12 @@ The public modules under the ``telethon`` now make actual sense.
It's actually a factory object returning new error types on demand.
This means you don't need to wait for new library versions to be released to catch them.
.. note::
Be sure to check the documentation for :data:`telethon.errors` to learn about error changes.
Notably, errors such as ``FloodWaitError`` no longer have a ``.seconds`` field.
Instead, every value for every error type is always ``.value``.
This was also a good opportunity to remove a lot of modules that were not supposed to public in their entirety:
``.crypto``, ``.extensions``, ``.network``, ``.custom``, ``.functions``, ``.helpers``, ``.hints``, ``.password``, ``.requestiter``, ``.sync``, ``.types``, ``.utils``.
@ -86,14 +93,49 @@ Each of them can have an additional namespace (as seen above with ``account.``).
* ``tl.functions`` contains every :term:`TL` definition treated as a function.
The naming convention now follows Python's, and are ``snake_case``.
They're no longer a class with attributes.
They serialize the request immediately.
* ``tl.abcs`` contains every abstract class, the "boxed" types from Telegram.
You can use these for your type-hinting needs.
* ``tl.types`` contains concrete instances, the "bare" types Telegram actually returns.
You'll probably use these with :func:`isinstance` a lot.
All types use :term:`__slots__` to save space.
This means you can't add extra fields to these at runtime unless you subclass.
Raw API has a reduced feature-set
---------------------------------
The string representation is now on :meth:`object.__repr__`, not :meth:`object.__str__`.
All types use :term:`__slots__` to save space.
This means you can't add extra fields to these at runtime unless you subclass.
The ``.stringify()`` methods on all TL types no longer exists.
Instead, you can use a library like `beauty-print <https://pypi.org/project/beauty-print/>`_.
The ``.to_dict()`` method on all TL types no longer exists.
The same is true for ``.to_json()``.
Instead, you can use a library like `json-pickle <https://pypi.org/project/jsonpickle/>`_ or write your own:
.. code-block:: python
def to_dict(obj):
if obj is None or isinstance(obj, (bool, int, bytes, str)): return obj
if isinstance(obj, list): return [to_dict(x) for x in obj]
if isinstance(obj, dict): return {k: to_dict(v) for k, v in obj.items()}
return {slot: to_dict(getattr(obj, slot)) for slot in obj.__slots__}
Lesser-known methods such as ``TLObject.pretty_format``, ``serialize_bytes``, ``serialize_datetime`` and ``from_reader`` are also gone.
The remaining methods are:
* ``Serializable.constructor_id()`` class-method, to get the integer identifier of the corresponding type constructor.
* ``Serializable.from_bytes()`` class-method, to convert serialized :class:`bytes` back into the class.
* :meth:`object.__bytes__` instance-method, to serialize the instance into :class:`bytes` the way Telegram expects.
Functions are no longer a class with attributes.
They serialize the request immediately.
This means you cannot create request instance and change it later.
Consider using :func:`functools.partial` if you want to reuse parts of a request instead.
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).
Unified client iter and get methods
@ -159,6 +201,66 @@ The simplest approach could be using a global ``states`` dictionary storing the
del states[event.sender.id]
.. rubric:: No ``client.kick_participant()`` method.
This is not a thing in Telegram.
It was implemented by restricting and then removing the restriction.
The old ``client.edit_permissions()`` was renamed to :meth:`Client.set_banned_rights`.
This defines the rights a restricted participant has (bans them from doing other things).
Revoking the right to view messages will kick them.
This rename should avoid confusion, as it is now clear this is not to promote users to admin status.
For administrators, ``client.edit_admin`` was renamed to :meth:`Client.set_admin_rights` for consistency.
Note that a new method, :meth:`Client.set_default_rights`, must now be used to set a chat's default rights.
.. rubric:: No ``client.download_profile_photo()`` method.
You can simply use :meth:`Client.download` now.
Note that :meth:`~Client.download` no longer supports downloading contacts as ``.vcard``.
.. rubric:: No ``client.set_proxy()`` method.
Proxy support is no longer built-in.
They were never officially maintained.
This doesn't mean you can't use them.
You're now free to choose your own proxy library and pass a different connector to the :class:`Client` constructor.
This should hopefully make it clear that most connection issues when using proxies do *not* come from Telethon.
.. rubric:: No ``client.set_receive_updates`` method.
It was not working as expected.
.. rubric:: No ``client.catch_up()`` method.
You can still configure it when creating the :class:`Client`, which was the only way to make it work anyway.
.. rubric:: No ``client.action()`` method.
.. rubric:: No ``client.takeout()`` method.
.. rubric:: No ``client.qr_login()`` method.
.. rubric:: No ``client.edit_2fa()`` method.
.. rubric:: No ``client.get_stats()`` method.
.. rubric:: No ``client.edit_folder()`` method.
.. rubric:: No ``client.build_reply_markup()`` method.
.. rubric:: No ``client.list_event_handlers()`` method.
These are out of scope for the time being.
They might be re-introduced in the future if there is a burning need for them and are not difficult to maintain.
This doesn't mean you can't do these things anymore though, since the :term:`Raw API` is still available.
Telethon v2 is committed to not exposing the raw API under any public API of the ``telethon`` package.
This means any method returning data from Telegram must have a custom wrapper object and be maintained too.
Because the standards are higher, the barrier of entry for new additions and features is higher too.
No message.raw_text or message.message
--------------------------------------
@ -201,6 +303,57 @@ This means getting those identifiers is up to you, and you can handle it in a wa
In-depth explanation for :doc:`/concepts/updates`.
Behaviour changes in events
---------------------------
:class:`events.CallbackQuery` no longer also handles "inline bot callback queries".
This was a hacky workaround.
:class:`events.MessageRead` no longer triggers when the *contents* of a message are read, such as voice notes being played.
Albums in Telegram are an illusion.
There is no "album media".
There is only separate messages pretending to be a single message.
``events.Album`` was a hack that waited for a small amount of time to group messages sharing the same grouped identifier.
If you want to wait for a full album, you will need to wait yourself:
.. code-block:: python
pending_albums = {} # global for simplicity
async def gather_album(event, handler):
if pending := pending_albums.get(event.grouped_id):
pending.append(event)
else:
pending_albums[event.grouped_id] = [event]
# Wait for other events to come in. Adjust delay to your needs.
# This will NOT work if sequential updates are enabled (spawn a task to do the rest instead).
await asyncio.sleep(1)
events = pending_albums.pop(grouped_id, [])
await handler(events)
@client.on(events.NewMessage)
async def handler(event):
if event.grouped_id:
await gather_album(event, handle_album)
else:
await handle_message(event)
async def handle_album(events):
... # do stuff with events
async def handle_message(event):
... # do stuff with event
Note that the above code is not foolproof and will not handle more than one client.
It might be possible for album events to be delayed for more than a second.
Note that messages that do **not** belong to an album can be received in-between an album.
Overall, it's probably better if you treat albums for what they really are:
separate messages sharing a :attr:`~types.Message.grouped_id`.
Streamlined chat, input_chat and chat_id
----------------------------------------
@ -232,6 +385,13 @@ Overall, dealing with users, groups and channels should feel a lot more natural.
In-depth explanation for :doc:`/concepts/chats`.
Other methods like ``client.get_peer_id``, ``client.get_input_entity`` and ``client.get_entity`` are gone too.
While not directly related, ``client.is_bot`` is gone as well.
You can use :meth:`Client.get_me` or read it from the session instead.
The ``telethon.utils`` package is gone entirely, so methods like ``utils.resolve_id`` no longer exist either.
Session cache no longer exists
------------------------------
@ -264,7 +424,7 @@ 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 name was redundant.
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
@ -283,8 +443,9 @@ 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 ``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
---------------------

View File

@ -41,6 +41,30 @@ The implementation consists of a parser and a code generator.
The parser is able to read parse ``.tl`` files (:term:`Type Language` definition files).
It doesn't do anything with the files other than to represent the content as Python objects.
.. admonition:: Type Language brief
TL-definitions are statements terminated with a semicolon ``;`` and often defined in a single line:
.. code-block::
geoPointEmpty#1117dd5f = GeoPoint;
geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radius:flags.0?int = GeoPoint;
The first word is the name, optionally followed by the hash sign ``#`` and an hexadecimal number.
Every definition can have a constructor identifier inferred based on its own text representation.
The hexadecimal number will override the constructor identifier used for the definition.
What follows up to the equals-sign ``=`` are the fields of the definition.
They have a name and a type, separated by the colon ``:``.
The type ``#`` represents a bitflag.
Other fields can be conditionally serialized by prefixing the type with ``flag_name.bit_index?``.
After the equal-sign comes the name for the "base class".
This representation is known as "boxed", and it contains the constructor identifier to discriminate a definition.
If the definition name appears on its own, it will be "bare" and will not have the constructor identifier prefix.
The code generator uses the parsed definitions to generate Python code.
Most of the code to serialize and deserialize objects lives under ``serde/``.

View File

@ -102,14 +102,15 @@ A more in-depth explanation of some of the concepts and words used in Telethon.
concepts/chats
concepts/updates
concepts/messages
concepts/sessions
concepts/errors
concepts/botapi-vs-mtproto
concepts/full-api
concepts/glossary
Developing
==========
Development resources
=====================
Tips and tricks to develop both with the library and for the library.
@ -117,10 +118,11 @@ Tips and tricks to develop both with the library and for the library.
.. toctree::
:hidden:
:caption: Developing
:caption: Development resources
developing/changelog
developing/migration-guide
developing/faq
developing/philosophy.rst
developing/coding-style.rst
developing/project-structure.rst

View File

@ -31,6 +31,7 @@ Errors
except errors.FloodWait as e:
await asyncio.sleep(e.value)
Note how the :attr:`RpcError.value` field is still accessible, as it's a subclass of :class:`RpcError`.
The code above is equivalent to the following:
.. code-block:: python

View File

@ -3,8 +3,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from ...tl import abcs, functions, types
from ..types import AsyncList, ChatLike, Participant
from ..types import AsyncList, ChatLike, File, Participant, RecentAction
from ..utils import build_chat_map
from .messages import SearchList
if TYPE_CHECKING:
from .client import Client
@ -81,3 +82,106 @@ class ParticipantList(AsyncList[Participant]):
def get_participants(self: Client, chat: ChatLike) -> AsyncList[Participant]:
return ParticipantList(self, chat)
class RecentActionList(AsyncList[RecentAction]):
def __init__(
self,
client: Client,
chat: ChatLike,
):
super().__init__()
self._client = client
self._chat = chat
self._peer: Optional[types.InputChannel] = None
self._offset = 0
async def _fetch_next(self) -> None:
if self._peer is None:
self._peer = (
await self._client._resolve_to_packed(self._chat)
)._to_input_channel()
result = await self._client(
functions.channels.get_admin_log(
channel=self._peer,
q="",
min_id=0,
max_id=self._offset,
limit=100,
events_filter=None,
admins=[],
)
)
assert isinstance(result, types.channels.AdminLogResults)
chat_map = build_chat_map(result.users, result.chats)
self._buffer.extend(RecentAction._create(e, chat_map) for e in result.events)
self._total += len(self._buffer)
if self._buffer:
self._offset = min(e.id for e in self._buffer)
def get_admin_log(self: Client, chat: ChatLike) -> AsyncList[RecentAction]:
return RecentActionList(self, chat)
class ProfilePhotoList(AsyncList[File]):
def __init__(
self,
client: Client,
chat: ChatLike,
):
super().__init__()
self._client = client
self._chat = chat
self._peer: Optional[abcs.InputPeer] = None
self._search_iter: Optional[SearchList] = None
async def _fetch_next(self) -> None:
if self._peer is None:
self._peer = (
await self._client._resolve_to_packed(self._chat)
)._to_input_peer()
if isinstance(self._peer, types.InputPeerUser):
result = await self._client(
functions.photos.get_user_photos(
user_id=types.InputUser(
user_id=self._peer.user_id, access_hash=self._peer.access_hash
),
offset=0,
max_id=0,
limit=0,
)
)
if isinstance(result, types.photos.Photos):
self._buffer.extend(
filter(None, (File._try_from_raw_photo(p) for p in result.photos))
)
self._total = len(result.photos)
elif isinstance(result, types.photos.PhotosSlice):
self._buffer.extend(
filter(None, (File._try_from_raw_photo(p) for p in result.photos))
)
self._total = result.count
else:
raise RuntimeError("unexpected case")
def get_profile_photos(self: Client, chat: ChatLike) -> AsyncList[File]:
return ProfilePhotoList(self, chat)
def set_banned_rights(self: Client, chat: ChatLike, user: ChatLike) -> None:
pass
def set_admin_rights(self: Client, chat: ChatLike, user: ChatLike) -> None:
pass
def set_default_rights(self: Client, chat: ChatLike, user: ChatLike) -> None:
pass

View File

@ -36,6 +36,7 @@ from ..types import (
Chat,
ChatLike,
Dialog,
Draft,
File,
InFileLike,
LoginToken,
@ -43,6 +44,7 @@ from ..types import (
OutFileLike,
Participant,
PasswordToken,
RecentAction,
User,
)
from .auth import (
@ -55,8 +57,15 @@ from .auth import (
sign_out,
)
from .bots import InlineResult, inline_query
from .chats import get_participants
from .dialogs import delete_dialog, get_dialogs
from .chats import (
get_admin_log,
get_participants,
get_profile_photos,
set_admin_rights,
set_banned_rights,
set_default_rights,
)
from .dialogs import delete_dialog, get_dialogs, get_drafts
from .files import (
download,
get_file_bytes,
@ -198,7 +207,7 @@ class Client:
if self._config.catch_up and self._config.session.state:
self._message_box.load(self._config.session.state)
# ---
# Begin partially @generated
def add_event_handler(
self,
@ -511,6 +520,29 @@ class Client:
"""
return await forward_messages(self, target, message_ids, source)
def get_admin_log(self, chat: ChatLike) -> AsyncList[RecentAction]:
"""
Get the recent actions from the administrator's log.
This method requires you to be an administrator in the :term:`chat`.
The returned actions are also known as "admin log events".
:param chat:
The :term:`chat` to fetch recent actions from.
:return: The recent actions.
.. rubric:: Example
.. code-block:: python
async for admin_log_event in client.get_admin_log(chat):
if message := admin_log_event.deleted_message:
print('Deleted:', message.text)
"""
return get_admin_log(self, chat)
def get_contacts(self) -> AsyncList[User]:
"""
Get the users in your contact list.
@ -548,6 +580,47 @@ class Client:
"""
return get_dialogs(self)
def get_drafts(self) -> AsyncList[Draft]:
"""
Get all message drafts saved in any dialog.
:return: The existing message drafts.
.. rubric:: Example
.. code-block:: python
async for draft in client.get_drafts():
await draft.delete()
"""
return get_drafts(self)
def get_file_bytes(self, media: File) -> AsyncList[bytes]:
"""
Get the contents of an uploaded media file as chunks of :class:`bytes`.
This lets you iterate over the chunks of a file and print progress while the download occurs.
If you just want to download a file to disk without printing progress, use :meth:`download` instead.
:param media:
The media file to download.
This will often come from :attr:`telethon.types.Message.file`.
.. rubric:: Example
.. code-block:: python
if file := message.file:
with open(f'media{file.ext}', 'wb') as fd:
downloaded = 0
async for chunk in client.get_file_bytes(file):
downloaded += len(chunk)
fd.write(chunk)
print(f'Downloaded {downloaded // 1024}/{file.size // 1024} KiB')
"""
return get_file_bytes(self, media)
def get_handler_filter(
self, handler: Callable[[Event], Awaitable[Any]]
) -> Optional[Filter]:
@ -666,6 +739,23 @@ class Client:
"""
return get_participants(self, chat)
def get_profile_photos(self, chat: ChatLike) -> AsyncList[File]:
"""
Get the profile pictures set in a chat, or user avatars.
:return: The photo files.
.. rubric:: Example
.. code-block:: python
i = 0
async for photo in client.get_profile_photos(chat):
await client.download(photo, f'{i}.jpg')
i += 1
"""
return get_profile_photos(self, chat)
async def inline_query(
self, bot: ChatLike, query: str = "", *, chat: Optional[ChatLike] = None
) -> AsyncIterator[InlineResult]:
@ -730,34 +820,15 @@ class Client:
Check whether the client instance is authorized (i.e. logged-in).
:return: :data:`True` if the client instance has signed-in.
"""
return await is_authorized(self)
def get_file_bytes(self, media: File) -> AsyncList[bytes]:
"""
Get the contents of an uploaded media file as chunks of :class:`bytes`.
This lets you iterate over the chunks of a file and print progress while the download occurs.
If you just want to download a file to disk without printing progress, use :meth:`download` instead.
:param media:
The media file to download.
This will often come from :attr:`telethon.types.Message.file`.
.. rubric:: Example
.. code-block:: python
if file := message.file:
with open(f'media{file.ext}', 'wb') as fd:
downloaded = 0
async for chunk in client.get_file_bytes(file):
downloaded += len(chunk)
fd.write(chunk)
print(f'Downloaded {downloaded // 1024}/{file.size // 1024} KiB')
if not await client.is_authorized():
... # need to sign in
"""
return get_file_bytes(self, media)
return await is_authorized(self)
def on(
self, event_cls: Type[Event], filter: Optional[Filter] = None
@ -934,6 +1005,13 @@ class Client:
This means only messages sent before *offset_date* will be fetched.
:return: The found messages.
.. rubric:: Example
.. code-block:: python
async for message in client.search_all_messages(query='hello'):
print(message.text)
"""
return search_all_messages(
self, limit, query=query, offset_id=offset_id, offset_date=offset_date
@ -970,6 +1048,13 @@ class Client:
This means only messages sent before *offset_date* will be fetched.
:return: The found messages.
.. rubric:: Example
.. code-block:: python
async for message in client.search_messages(chat, query='hello'):
print(message.text)
"""
return search_messages(
self, chat, limit, query=query, offset_id=offset_id, offset_date=offset_date
@ -988,6 +1073,9 @@ class Client:
voice: bool = False,
title: Optional[str] = None,
performer: Optional[str] = None,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
) -> Message:
"""
Send an audio file.
@ -1002,6 +1090,12 @@ class Client:
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`.
.. rubric:: Example
.. code-block:: python
await client.send_audio(chat, 'file.ogg', voice=True)
"""
return await send_audio(
self,
@ -1015,6 +1109,9 @@ class Client:
voice=voice,
title=title,
performer=performer,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
)
async def send_file(
@ -1072,7 +1169,16 @@ class Client:
if *path* isn't a :class:`~telethon.types.File`.
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.
Note that only one *caption* parameter can be provided.
.. rubric:: Example
.. code-block:: python
login_token = await client.request_login_code('+1 23 456...')
print(login_token.timeout, 'seconds before code expires')
"""
return await send_file(
self,
@ -1120,20 +1226,23 @@ class Client:
Message text, with no formatting.
:param text_markdown:
Message text, parsed as markdown.
Message text, parsed as CommonMark.
:param text_html:
Message text, parsed as HTML.
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
await client.send_message(chat, markdown='**Hello!**')
"""
return await send_message(
self,
chat,
text,
markdown=markdown,
html=html,
link_preview=link_preview,
self, chat, text, markdown=markdown, html=html, link_preview=link_preview
)
async def send_photo(
@ -1148,6 +1257,9 @@ class Client:
compress: bool = True,
width: Optional[int] = None,
height: Optional[int] = None,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
) -> Message:
"""
Send a photo file.
@ -1166,6 +1278,12 @@ class Client:
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`.
.. rubric:: Example
.. code-block:: python
await client.send_photo(chat, 'photo.jpg', caption='Check this out!')
"""
return await send_photo(
self,
@ -1178,6 +1296,9 @@ class Client:
compress=compress,
width=width,
height=height,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
)
async def send_video(
@ -1194,6 +1315,9 @@ class Client:
height: Optional[int] = None,
round: bool = False,
supports_streaming: bool = False,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
) -> Message:
"""
Send a video file.
@ -1208,6 +1332,12 @@ class Client:
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`.
.. rubric:: Example
.. code-block:: python
await client.send_video(chat, 'video.mp4', caption_markdown='*I cannot believe this just happened*')
"""
return await send_video(
self,
@ -1222,8 +1352,20 @@ class Client:
height=height,
round=round,
supports_streaming=supports_streaming,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
)
def set_admin_rights(self, chat: ChatLike, user: ChatLike) -> None:
set_admin_rights(self, chat, user)
def set_banned_rights(self, chat: ChatLike, user: ChatLike) -> None:
set_banned_rights(self, chat, user)
def set_default_rights(self, chat: ChatLike, user: ChatLike) -> None:
set_default_rights(self, chat, user)
def set_handler_filter(
self,
handler: Callable[[Event], Awaitable[Any]],
@ -1314,7 +1456,7 @@ class Client:
"""
await unpin_message(self, chat, message_id)
# ---
# End partially @generated
@property
def connected(self) -> bool:

View File

@ -2,8 +2,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ...tl import abcs, functions, types
from ..types import AsyncList, ChatLike, Dialog, User
from ...tl import functions, types
from ..types import AsyncList, ChatLike, Dialog, Draft
from ..utils import build_chat_map
if TYPE_CHECKING:
@ -78,3 +78,29 @@ async def delete_dialog(self: Client, chat: ChatLike) -> None:
max_date=None,
)
)
class DraftList(AsyncList[Draft]):
def __init__(self, client: Client):
super().__init__()
self._client = client
self._offset = 0
async def _fetch_next(self) -> None:
result = await self._client(functions.messages.get_all_drafts())
assert isinstance(result, types.Updates)
chat_map = build_chat_map(result.users, result.chats)
self._buffer.extend(
Draft._from_raw(u, chat_map)
for u in result.updates
if isinstance(u, types.UpdateDraftMessage)
)
self._total = len(result.updates)
self._done = True
def get_drafts(self: Client) -> AsyncList[Draft]:
return DraftList(self)

View File

@ -1,12 +1,9 @@
from __future__ import annotations
import asyncio
import hashlib
from functools import partial
from inspect import isawaitable
from io import BufferedWriter
from pathlib import Path
from typing import TYPE_CHECKING, Any, Coroutine, Optional, Tuple, Union
from typing import TYPE_CHECKING, Optional, Union
from ...tl import abcs, functions, types
from ..types import (
@ -43,6 +40,9 @@ async def send_photo(
compress: bool = True,
width: Optional[int] = None,
height: Optional[int] = None,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
) -> Message:
return await send_file(
self,
@ -55,6 +55,9 @@ async def send_photo(
compress=compress,
width=width,
height=height,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
)
@ -71,6 +74,9 @@ async def send_audio(
voice: bool = False,
title: Optional[str] = None,
performer: Optional[str] = None,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
) -> Message:
return await send_file(
self,
@ -84,6 +90,9 @@ async def send_audio(
voice=voice,
title=title,
performer=performer,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
)
@ -101,6 +110,9 @@ async def send_video(
height: Optional[int] = None,
round: bool = False,
supports_streaming: bool = False,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
) -> Message:
return await send_file(
self,
@ -115,6 +127,9 @@ async def send_video(
height=height,
round=round,
supports_streaming=supports_streaming,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
)

View File

@ -319,6 +319,7 @@ class SearchList(MessageList):
self._peer: Optional[abcs.InputPeer] = None
self._limit = limit
self._query = query
self._filter = types.InputMessagesFilterEmpty()
self._offset_id = offset_id
self._offset_date = offset_date
@ -334,7 +335,7 @@ class SearchList(MessageList):
q=self._query,
from_id=None,
top_msg_id=None,
filter=types.InputMessagesFilterEmpty(),
filter=self._filter,
min_date=0,
max_date=self._offset_date,
offset_id=self._offset_id,

View File

@ -7,12 +7,10 @@ from typing import (
Any,
Awaitable,
Callable,
Dict,
List,
Optional,
Type,
TypeVar,
Union,
)
from ...session import Gap

View File

@ -1,12 +1,14 @@
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 .login_token import LoginToken
from .message import Message
from .meta import NoPublicConstructor
from .participant import Participant
from .password_token import PasswordToken
from .recent_action import RecentAction
__all__ = [
"AsyncList",
@ -17,6 +19,7 @@ __all__ = [
"RestrictionReason",
"User",
"Dialog",
"Draft",
"File",
"InFileLike",
"InWrapper",
@ -27,4 +30,5 @@ __all__ = [
"NoPublicConstructor",
"Participant",
"PasswordToken",
"RecentAction",
]

View File

@ -0,0 +1,29 @@
from typing import Dict, List, Optional, Self
from ...session import PackedChat, PackedType
from ...tl import abcs, types
from .chat import Chat
from .meta import NoPublicConstructor
class Draft(metaclass=NoPublicConstructor):
"""
A draft message in a chat.
"""
__slots__ = ("_raw", "_chat_map")
def __init__(
self, raw: types.UpdateDraftMessage, chat_map: Dict[int, Chat]
) -> None:
self._raw = raw
self._chat_map = chat_map
@classmethod
def _from_raw(
cls, draft: types.UpdateDraftMessage, chat_map: Dict[int, Chat]
) -> Self:
return cls._create(draft, chat_map)
async def delete(self) -> None:
pass

View File

@ -137,7 +137,7 @@ class File(metaclass=NoPublicConstructor):
self._raw = raw
@classmethod
def _try_from_raw(cls, raw: abcs.MessageMedia) -> Optional[Self]:
def _try_from_raw_message_media(cls, raw: abcs.MessageMedia) -> Optional[Self]:
if isinstance(raw, types.MessageMediaDocument):
if isinstance(raw.document, types.Document):
return cls._create(
@ -176,30 +176,47 @@ class File(metaclass=NoPublicConstructor):
raw=raw,
)
elif isinstance(raw, types.MessageMediaPhoto):
if isinstance(raw.photo, types.Photo):
return cls._create(
path=None,
file=None,
attributes=[],
size=max(map(photo_size_byte_count, raw.photo.sizes)),
name="",
mime="image/jpeg",
photo=True,
muted=False,
input_media=types.InputMediaPhoto(
spoiler=raw.spoiler,
id=types.InputPhoto(
id=raw.photo.id,
access_hash=raw.photo.access_hash,
file_reference=raw.photo.file_reference,
),
ttl_seconds=raw.ttl_seconds,
),
raw=raw,
if raw.photo:
return cls._try_from_raw_photo(
raw.photo, spoiler=raw.spoiler, ttl_seconds=raw.ttl_seconds
)
return None
@classmethod
def _try_from_raw_photo(
cls,
raw: abcs.Photo,
*,
spoiler: bool = False,
ttl_seconds: Optional[int] = None,
) -> Optional[Self]:
if isinstance(raw, types.Photo):
return cls._create(
path=None,
file=None,
attributes=[],
size=max(map(photo_size_byte_count, raw.sizes)),
name="",
mime="image/jpeg",
photo=True,
muted=False,
input_media=types.InputMediaPhoto(
spoiler=spoiler,
id=types.InputPhoto(
id=raw.id,
access_hash=raw.access_hash,
file_reference=raw.file_reference,
),
ttl_seconds=ttl_seconds,
),
raw=types.MessageMediaPhoto(
spoiler=spoiler, photo=raw, ttl_seconds=ttl_seconds
),
)
return None
@classmethod
def new(
cls,

View File

@ -39,8 +39,26 @@ class Message(metaclass=NoPublicConstructor):
@property
def id(self) -> int:
"""
The message identifier.
.. seealso::
:doc:`/concepts/messages`, which contains an in-depth explanation of message counters.
"""
return self._raw.id
@property
def grouped_id(self) -> Optional[int]:
"""
If the message is grouped with others in an album, return the group identifier.
Messages with the same :attr:`grouped_id` will belong to the same album.
Note that there can be messages in-between that do not have a :attr:`grouped_id`.
"""
return getattr(self._raw, "grouped_id", None)
@property
def text(self) -> Optional[str]:
return getattr(self._raw, "message", None)
@ -91,7 +109,7 @@ class Message(metaclass=NoPublicConstructor):
def _file(self) -> Optional[File]:
return (
File._try_from_raw(self._raw.media)
File._try_from_raw_message_media(self._raw.media)
if isinstance(self._raw, types.Message) and self._raw.media
else None
)

View File

@ -0,0 +1,29 @@
from typing import Dict, List, Optional, Self, Union
from ...session import PackedChat, PackedType
from ...tl import abcs, types
from .chat import Chat
from .meta import NoPublicConstructor
class RecentAction(metaclass=NoPublicConstructor):
"""
A recent action in a chat, also known as an "admin log event action" or :tl:`ChannelAdminLogEvent`.
Only administrators of the chat can access these.
"""
__slots__ = ("_raw", "_chat_map")
def __init__(
self,
event: abcs.ChannelAdminLogEvent,
chat_map: Dict[int, Chat],
) -> None:
assert isinstance(event, types.ChannelAdminLogEvent)
self._raw = event
self._chat_map = chat_map
@property
def id(self) -> int:
return self._raw.id

View File

@ -1,11 +1,11 @@
import struct
from enum import Enum
from enum import IntFlag
from typing import Optional, Self
from ...tl import abcs, types
class PackedType(Enum):
class PackedType(IntFlag):
"""
The type of a :class:`PackedChat`.
"""
@ -52,6 +52,27 @@ class PackedChat:
ty = PackedType(ty_byte & 0b0011_1111)
return cls(ty, id, access_hash if has_hash else None)
@property
def hex(self) -> str:
"""
Convenience property to convert to bytes and represent them as hexadecimal numbers:
.. code-block::
assert packed.hex == bytes(packed).hex()
"""
return bytes(self).hex()
def from_hex(cls, hex: str) -> Self:
"""
Convenience method to convert hexadecimal numbers into bytes then passed to :meth:`from_bytes`:
.. code-block::
assert PackedChat.from_hex(packed.hex) == packed
"""
return cls.from_bytes(bytes.fromhex(hex))
def is_user(self) -> bool:
return self.ty in (PackedType.USER, PackedType.BOT)
@ -93,13 +114,13 @@ class PackedChat:
if self.is_user():
return types.InputUser(user_id=self.id, access_hash=self.access_hash or 0)
else:
raise ValueError("chat is not user")
raise TypeError("chat is not a user")
def _to_chat_id(self) -> int:
if self.is_chat():
return self.id
else:
raise ValueError("chat is not small group")
raise TypeError("chat is not a group")
def _to_input_channel(self) -> types.InputChannel:
if self.is_channel():
@ -107,7 +128,7 @@ class PackedChat:
channel_id=self.id, access_hash=self.access_hash or 0
)
else:
raise ValueError("chat is not channel")
raise TypeError("chat is not a channel")
def __eq__(self, other: object) -> bool:
if not isinstance(other, self.__class__):

View File

@ -11,6 +11,7 @@ Properties and private methods can use a different parameter name than `self`
to avoid being included.
"""
import ast
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Union
@ -62,6 +63,7 @@ class MethodVisitor(ast.NodeVisitor):
def main() -> None:
client_root = Path.cwd() / "client/src/telethon/_impl/client/client"
client_py = client_root / "client.py"
fm_visitor = FunctionMethodsVisitor()
m_visitor = MethodVisitor()
@ -74,7 +76,7 @@ def main() -> None:
fm_visitor.visit(ast.parse(contents))
with (client_root / "client.py").open(encoding="utf-8") as fd:
with client_py.open(encoding="utf-8") as fd:
contents = fd.read()
m_visitor.visit(ast.parse(contents))
@ -109,13 +111,22 @@ def main() -> None:
function.body.append(call)
class_body.append(function)
print(
ast.unparse(
ast.ClassDef(
name="Client", bases=[], keywords=[], body=class_body, decorator_list=[]
)
generated = ast.unparse(
ast.ClassDef(
name="Client", bases=[], keywords=[], body=class_body, decorator_list=[]
)
)
)[len("class Client:") :].strip()
start_idx = contents.index("\n", contents.index("# Begin partially @generated"))
end_idx = contents.index("# End partially @generated")
with client_py.open("w", encoding="utf-8") as fd:
fd.write(
f"{contents[:start_idx]}\n\n {generated}\n\n {contents[end_idx:]}"
)
print("written @generated")
exit(subprocess.run((sys.executable, "-m", "black", str(client_py))).returncode)
if __name__ == "__main__":