diff --git a/client/doc/_static/custom.css b/client/doc/_static/custom.css
new file mode 100644
index 00000000..c6094f81
--- /dev/null
+++ b/client/doc/_static/custom.css
@@ -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;
+}
diff --git a/client/doc/basic/signing-in.rst b/client/doc/basic/signing-in.rst
index 9adb91e4..67e03983 100644
--- a/client/doc/basic/signing-in.rst
+++ b/client/doc/basic/signing-in.rst
@@ -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 `_.
diff --git a/client/doc/concepts/glossary.rst b/client/doc/concepts/glossary.rst
index 0d254768..a1d8e6de 100644
--- a/client/doc/concepts/glossary.rst
+++ b/client/doc/concepts/glossary.rst
@@ -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.
diff --git a/client/doc/concepts/messages.rst b/client/doc/concepts/messages.rst
new file mode 100644
index 00000000..c0539136
--- /dev/null
+++ b/client/doc/concepts/messages.rst
@@ -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 `_ is not fully compatible with :term:`HTTP Bot API`'s
+`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 `_,
+and instead favours more standard `HTML elements `_:
+
+* ``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.
diff --git a/client/doc/conf.py b/client/doc/conf.py
index c4864dce..38bd3a59 100644
--- a/client/doc/conf.py
+++ b/client/doc/conf.py
@@ -40,3 +40,4 @@ graphviz_output_format = "svg"
html_theme = "sphinx_rtd_theme"
html_static_path = ["_static"]
+html_css_files = ["custom.css"]
diff --git a/client/doc/developing/faq.rst b/client/doc/developing/faq.rst
new file mode 100644
index 00000000..d738b9cd
--- /dev/null
+++ b/client/doc/developing/faq.rst
@@ -0,0 +1,2 @@
+Frequently Asked Questions
+==========================
diff --git a/client/doc/developing/migration-guide.rst b/client/doc/developing/migration-guide.rst
index 775c0cb7..84a252c3 100644
--- a/client/doc/developing/migration-guide.rst
+++ b/client/doc/developing/migration-guide.rst
@@ -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 `_.
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 `_.
+
+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 `_ 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
---------------------
diff --git a/client/doc/developing/project-structure.rst b/client/doc/developing/project-structure.rst
index f7ed064b..1821ddd5 100644
--- a/client/doc/developing/project-structure.rst
+++ b/client/doc/developing/project-structure.rst
@@ -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/``.
diff --git a/client/doc/index.rst b/client/doc/index.rst
index 7ffdbe55..a3d7dc2e 100644
--- a/client/doc/index.rst
+++ b/client/doc/index.rst
@@ -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
diff --git a/client/doc/modules/types.rst b/client/doc/modules/types.rst
index 75e3e33c..8ec7cf96 100644
--- a/client/doc/modules/types.rst
+++ b/client/doc/modules/types.rst
@@ -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
diff --git a/client/src/telethon/_impl/client/client/chats.py b/client/src/telethon/_impl/client/client/chats.py
index 5186731b..1f30e03c 100644
--- a/client/src/telethon/_impl/client/client/chats.py
+++ b/client/src/telethon/_impl/client/client/chats.py
@@ -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
diff --git a/client/src/telethon/_impl/client/client/client.py b/client/src/telethon/_impl/client/client/client.py
index 5cb7bc6f..10499747 100644
--- a/client/src/telethon/_impl/client/client/client.py
+++ b/client/src/telethon/_impl/client/client/client.py
@@ -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:
diff --git a/client/src/telethon/_impl/client/client/dialogs.py b/client/src/telethon/_impl/client/client/dialogs.py
index 7ab8ee1a..41a6c77c 100644
--- a/client/src/telethon/_impl/client/client/dialogs.py
+++ b/client/src/telethon/_impl/client/client/dialogs.py
@@ -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)
diff --git a/client/src/telethon/_impl/client/client/files.py b/client/src/telethon/_impl/client/client/files.py
index 3f1c5fa0..2c4db2f2 100644
--- a/client/src/telethon/_impl/client/client/files.py
+++ b/client/src/telethon/_impl/client/client/files.py
@@ -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,
)
diff --git a/client/src/telethon/_impl/client/client/messages.py b/client/src/telethon/_impl/client/client/messages.py
index 91cedbc1..515b9817 100644
--- a/client/src/telethon/_impl/client/client/messages.py
+++ b/client/src/telethon/_impl/client/client/messages.py
@@ -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,
diff --git a/client/src/telethon/_impl/client/client/updates.py b/client/src/telethon/_impl/client/client/updates.py
index 0eb4be0e..82233e72 100644
--- a/client/src/telethon/_impl/client/client/updates.py
+++ b/client/src/telethon/_impl/client/client/updates.py
@@ -7,12 +7,10 @@ from typing import (
Any,
Awaitable,
Callable,
- Dict,
List,
Optional,
Type,
TypeVar,
- Union,
)
from ...session import Gap
diff --git a/client/src/telethon/_impl/client/types/__init__.py b/client/src/telethon/_impl/client/types/__init__.py
index 406db0bc..d1cd7826 100644
--- a/client/src/telethon/_impl/client/types/__init__.py
+++ b/client/src/telethon/_impl/client/types/__init__.py
@@ -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",
]
diff --git a/client/src/telethon/_impl/client/types/draft.py b/client/src/telethon/_impl/client/types/draft.py
new file mode 100644
index 00000000..8cb80509
--- /dev/null
+++ b/client/src/telethon/_impl/client/types/draft.py
@@ -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
diff --git a/client/src/telethon/_impl/client/types/file.py b/client/src/telethon/_impl/client/types/file.py
index 1d073b54..22ae6086 100644
--- a/client/src/telethon/_impl/client/types/file.py
+++ b/client/src/telethon/_impl/client/types/file.py
@@ -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,
diff --git a/client/src/telethon/_impl/client/types/message.py b/client/src/telethon/_impl/client/types/message.py
index 1cb46188..30d52b28 100644
--- a/client/src/telethon/_impl/client/types/message.py
+++ b/client/src/telethon/_impl/client/types/message.py
@@ -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
)
diff --git a/client/src/telethon/_impl/client/types/recent_action.py b/client/src/telethon/_impl/client/types/recent_action.py
new file mode 100644
index 00000000..ffb1acc0
--- /dev/null
+++ b/client/src/telethon/_impl/client/types/recent_action.py
@@ -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
diff --git a/client/src/telethon/_impl/session/chat/packed.py b/client/src/telethon/_impl/session/chat/packed.py
index 350bbb2f..3e6d2738 100644
--- a/client/src/telethon/_impl/session/chat/packed.py
+++ b/client/src/telethon/_impl/session/chat/packed.py
@@ -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__):
diff --git a/tools/copy_client_signatures.py b/tools/copy_client_signatures.py
index 0c95cdbd..104f7618 100644
--- a/tools/copy_client_signatures.py
+++ b/tools/copy_client_signatures.py
@@ -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__":