diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 275b5782..04016f8c 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -5,6 +5,8 @@ build:
os: ubuntu-22.04
tools:
python: "3.11"
+ apt_packages:
+ - graphviz
sphinx:
configuration: client/doc/conf.py
diff --git a/client/doc/concepts/botapi-vs-mtproto.rst b/client/doc/concepts/botapi-vs-mtproto.rst
index cff0b700..7bd9cdfb 100644
--- a/client/doc/concepts/botapi-vs-mtproto.rst
+++ b/client/doc/concepts/botapi-vs-mtproto.rst
@@ -318,7 +318,7 @@ In Telethon:
.. code-block:: python
from telethon import Client, events
- from telethon.events.filters import Any, Command, TextOnly
+ from telethon.events.filters import Any, Command, Media
bot = Client('bot', api_id, api_hash)
# Handle '/start' and '/help'
@@ -329,8 +329,8 @@ In Telethon:
I am here to echo your kind words back to you. Just say anything nice and I'll say the exact same thing to you!\
""")
- # Handle all other messages with only 'text'
- @bot.on(events.NewMessage, TextOnly())
+ # Handle all other messages without media (negating the filter using ~)
+ @bot.on(events.NewMessage, ~Media())
async def echo_message(message: NewMessage):
await message.reply(message.text)
diff --git a/client/doc/concepts/messages.rst b/client/doc/concepts/messages.rst
index bdfaf5f1..a3c23ffc 100644
--- a/client/doc/concepts/messages.rst
+++ b/client/doc/concepts/messages.rst
@@ -79,7 +79,7 @@ Note that `CommonMark's markdown `_ is not fully compat
```
HTML is also not fully compatible with :term:`HTTP Bot API`'s
-`MarkdownV2 style `_,
+`HTML style `_,
and instead favours more standard `HTML elements `_:
* ``strong`` and ``b`` for **bold**.
diff --git a/client/src/telethon/_impl/client/client/client.py b/client/src/telethon/_impl/client/client/client.py
index 7382c8d1..a4cf6acd 100644
--- a/client/src/telethon/_impl/client/client/client.py
+++ b/client/src/telethon/_impl/client/client/client.py
@@ -37,6 +37,7 @@ from ..events import Event
from ..events.filters import Filter
from ..types import (
AdminRight,
+ AlbumBuilder,
AsyncList,
Chat,
ChatLike,
@@ -53,8 +54,8 @@ from ..types import (
PasswordToken,
RecentAction,
User,
- buttons,
)
+from ..types import buttons as btns
from .auth import (
bot_sign_in,
check_password,
@@ -77,10 +78,12 @@ from .dialogs import delete_dialog, edit_draft, get_dialogs, get_drafts
from .files import (
download,
get_file_bytes,
+ prepare_album,
send_audio,
send_file,
send_photo,
send_video,
+ upload,
)
from .messages import (
MessageMap,
@@ -115,6 +118,7 @@ from .updates import (
set_handler_filter,
)
from .users import (
+ get_chats,
get_contacts,
get_me,
input_to_peer,
@@ -680,6 +684,40 @@ class Client:
"""
return get_admin_log(self, chat)
+ async def get_chats(self, chats: Sequence[ChatLike]) -> List[Chat]:
+ """
+ Get the latest basic information about the given chats.
+
+ This method is most commonly used to turn one or more :class:`~types.PackedChat` into the original :class:`~types.Chat`.
+ This includes users, groups and broadcast channels.
+
+ :param chats:
+ The users, groups or channels to fetch.
+
+ :return: The fetched chats.
+
+ .. rubric:: Example
+
+ .. code-block:: python
+
+ # Retrieve a PackedChat from somewhere
+ packed_user = my_database.get_packed_winner()
+
+ # Fetch it
+ users = await client.get_chats([packed_user])
+ user = users[0] # user will be a User if our packed_user was a user
+
+ # Notify the user they won, using their current full name in the message
+ await client.send_message(packed_user, f'Congratulations {user.name}, you won!')
+
+ .. caution::
+
+ This method supports being called with anything that looks like a chat, like every other method.
+ However, calling it with usernames or phone numbers will fetch the chats twice.
+ If that's the case, consider using :meth:`resolve_username` or :meth:`get_contacts` instead.
+ """
+ return await get_chats(self, chats)
+
def get_contacts(self) -> AsyncList[User]:
"""
Get the users in your contact list.
@@ -1085,6 +1123,34 @@ class Client:
"""
return await pin_message(self, chat, message_id)
+ def prepare_album(self) -> AlbumBuilder:
+ """
+ Prepare an album upload to send.
+
+ Albums are a way to send multiple photos or videos as separate messages with the same grouped identifier.
+
+ :return: A new album builder instance, with no media added to it yet.
+
+ .. rubric:: Example
+
+ .. code-block:: python
+
+ # Prepare a new album
+ album = await client.prepare_album()
+
+ # Add a bunch of photos
+ for photo in ('a.jpg', 'b.png'):
+ await album.add_photo(photo)
+ # A video in-between
+ await album.add_video('c.mp4')
+ # And another photo
+ await album.add_photo('d.jpeg')
+
+ # Album is ready to be sent to as many chats as needed
+ await album.send(chat)
+ """
+ return prepare_album(self)
+
async def read_message(
self, chat: ChatLike, message_id: Union[int, Literal["all"]]
) -> None:
@@ -1169,28 +1235,6 @@ class Client:
"""
return await request_login_code(self, phone)
- async def resolve_to_packed(self, chat: ChatLike) -> PackedChat:
- """
- Resolve a :term:`chat` and return a compact, reusable reference to it.
-
- :param chat:
- The :term:`chat` to resolve.
-
- :return: An efficient, reusable version of the input.
-
- .. rubric:: Example
-
- .. code-block:: python
-
- friend = await client.resolve_to_packed('@cat')
- # Now you can use `friend` to get or send messages, files...
-
- .. seealso::
-
- In-depth explanation for :doc:`/concepts/chats`.
- """
- return await resolve_to_packed(self, chat)
-
async def resolve_username(self, username: str) -> Chat:
"""
Resolve a username into a :term:`chat`.
@@ -1310,6 +1354,7 @@ class Client:
self,
chat: ChatLike,
file: Union[str, Path, InFileLike, File],
+ mime_type: Optional[str] = None,
*,
size: Optional[int] = None,
name: Optional[str] = None,
@@ -1320,6 +1365,8 @@ class Client:
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
+ reply_to: Optional[int] = None,
+ buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
"""
Send an audio file.
@@ -1333,6 +1380,7 @@ class Client:
:param file: See :meth:`send_file`.
:param size: See :meth:`send_file`.
:param name: See :meth:`send_file`.
+ :param mime_type: See :meth:`send_file`.
:param duration: See :meth:`send_file`.
:param voice: See :meth:`send_file`.
:param title: See :meth:`send_file`.
@@ -1351,6 +1399,7 @@ class Client:
self,
chat,
file,
+ mime_type,
size=size,
name=name,
duration=duration,
@@ -1360,6 +1409,8 @@ class Client:
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
+ reply_to=reply_to,
+ buttons=buttons,
)
async def send_file(
@@ -1386,6 +1437,8 @@ class Client:
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
+ reply_to: Optional[int] = None,
+ buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
"""
Send any type of file with any amount of attributes.
@@ -1438,8 +1491,10 @@ class Client:
When given a string or path, its :attr:`~pathlib.PurePath.name` will be used by default only if this parameter is omitted.
- This parameter **must** be specified when sending a previously-opened or in-memory files.
- The library will not attempt to read any ``name`` attributes the object may have.
+ When given a :term:`file-like object`, if it has a ``.name`` :class:`str` property, it will be used.
+ This is the case for files opened via :func:`open`.
+
+ This parameter **must** be specified when sending any other previously-opened or in-memory files.
:param mime_type:
Override for the default mime-type.
@@ -1536,6 +1591,8 @@ class Client:
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
+ reply_to=reply_to,
+ buttons=buttons,
)
async def send_message(
@@ -1547,9 +1604,7 @@ class Client:
html: Optional[str] = None,
link_preview: bool = False,
reply_to: Optional[int] = None,
- buttons: Optional[
- Union[List[buttons.Button], List[List[buttons.Button]]]
- ] = None,
+ buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
"""
Send a message.
@@ -1594,12 +1649,15 @@ class Client:
*,
size: Optional[int] = None,
name: Optional[str] = None,
+ mime_type: Optional[str] = None,
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,
+ reply_to: Optional[int] = None,
+ buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
"""
Send a photo file.
@@ -1617,6 +1675,7 @@ class Client:
:param file: See :meth:`send_file`.
:param size: See :meth:`send_file`.
:param name: See :meth:`send_file`.
+ :param mime_type: See :meth:`send_file`.
:param compress: See :meth:`send_file`.
:param width: See :meth:`send_file`.
:param height: See :meth:`send_file`.
@@ -1636,12 +1695,15 @@ class Client:
file,
size=size,
name=name,
+ mime_type=mime_type,
compress=compress,
width=width,
height=height,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
+ reply_to=reply_to,
+ buttons=buttons,
)
async def send_video(
@@ -1651,14 +1713,18 @@ class Client:
*,
size: Optional[int] = None,
name: Optional[str] = None,
+ mime_type: Optional[str] = None,
duration: Optional[float] = None,
width: Optional[int] = None,
height: Optional[int] = None,
round: bool = False,
supports_streaming: bool = False,
+ muted: bool = False,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
+ reply_to: Optional[int] = None,
+ buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
"""
Send a video file.
@@ -1672,6 +1738,7 @@ class Client:
:param file: See :meth:`send_file`.
:param size: See :meth:`send_file`.
:param name: See :meth:`send_file`.
+ :param mime_type: See :meth:`send_file`.
:param duration: See :meth:`send_file`.
:param width: See :meth:`send_file`.
:param height: See :meth:`send_file`.
@@ -1693,14 +1760,18 @@ class Client:
file,
size=size,
name=name,
+ mime_type=mime_type,
duration=duration,
width=width,
height=height,
round=round,
supports_streaming=supports_streaming,
+ muted=muted,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
+ reply_to=reply_to,
+ buttons=buttons,
)
async def set_chat_default_restrictions(
@@ -1961,6 +2032,11 @@ class Client:
def _input_to_peer(self, input: Optional[abcs.InputPeer]) -> Optional[abcs.Peer]:
return input_to_peer(self, input)
+ async def _upload(
+ self, fd: Union[str, Path, InFileLike], size: Optional[int], name: Optional[str]
+ ) -> Tuple[abcs.InputFile, str]:
+ return await upload(self, fd, size, name)
+
async def __call__(self, request: Request[Return]) -> Return:
if not self._sender:
raise ConnectionError("not connected")
diff --git a/client/src/telethon/_impl/client/client/dialogs.py b/client/src/telethon/_impl/client/client/dialogs.py
index 6fdabd74..d039c6d2 100644
--- a/client/src/telethon/_impl/client/client/dialogs.py
+++ b/client/src/telethon/_impl/client/client/dialogs.py
@@ -4,8 +4,15 @@ import time
from typing import TYPE_CHECKING, Optional
from ...tl import functions, types
-from ..types import AsyncList, ChatLike, Dialog, Draft, build_chat_map, build_msg_map
-from .messages import parse_message
+from ..types import (
+ AsyncList,
+ ChatLike,
+ Dialog,
+ Draft,
+ build_chat_map,
+ build_msg_map,
+ parse_message,
+)
if TYPE_CHECKING:
from .client import Client
@@ -125,7 +132,6 @@ async def edit_draft(
message, entities = parse_message(
text=text, markdown=markdown, html=html, allow_empty=False
)
- assert isinstance(message, str)
result = await self(
functions.messages.save_draft(
diff --git a/client/src/telethon/_impl/client/client/files.py b/client/src/telethon/_impl/client/client/files.py
index 1919d16a..49a89b75 100644
--- a/client/src/telethon/_impl/client/client/files.py
+++ b/client/src/telethon/_impl/client/client/files.py
@@ -2,13 +2,13 @@ from __future__ import annotations
import hashlib
import mimetypes
-import urllib.parse
from inspect import isawaitable
from pathlib import Path
-from typing import TYPE_CHECKING, List, Optional, Union
+from typing import TYPE_CHECKING, List, Optional, Tuple, Union
from ...tl import abcs, functions, types
from ..types import (
+ AlbumBuilder,
AsyncList,
ChatLike,
File,
@@ -16,10 +16,14 @@ from ..types import (
Message,
OutFileLike,
OutWrapper,
+)
+from ..types import buttons as btns
+from ..types import (
expand_stripped_size,
generate_random_id,
+ parse_message,
+ try_get_url_path,
)
-from .messages import parse_message
if TYPE_CHECKING:
from .client import Client
@@ -34,6 +38,10 @@ BIG_FILE_SIZE = 10 * 1024 * 1024
math_round = round
+def prepare_album(self: Client) -> AlbumBuilder:
+ return AlbumBuilder._create(client=self)
+
+
async def send_photo(
self: Client,
chat: ChatLike,
@@ -41,12 +49,15 @@ async def send_photo(
*,
size: Optional[int] = None,
name: Optional[str] = None,
+ mime_type: Optional[str] = None,
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,
+ reply_to: Optional[int] = None,
+ buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
return await send_file(
self,
@@ -54,12 +65,17 @@ async def send_photo(
file,
size=size,
name=name,
+ mime_type="image/jpeg" # specific mime doesn't matter, only that it's image
+ if compress
+ else mime_type,
compress=compress,
width=width,
height=height,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
+ reply_to=reply_to,
+ buttons=buttons,
)
@@ -67,6 +83,7 @@ async def send_audio(
self: Client,
chat: ChatLike,
file: Union[str, Path, InFileLike, File],
+ mime_type: Optional[str] = None,
*,
size: Optional[int] = None,
name: Optional[str] = None,
@@ -77,6 +94,8 @@ async def send_audio(
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
+ reply_to: Optional[int] = None,
+ buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
return await send_file(
self,
@@ -84,6 +103,7 @@ async def send_audio(
file,
size=size,
name=name,
+ mime_type=mime_type,
duration=duration,
voice=voice,
title=title,
@@ -91,6 +111,8 @@ async def send_audio(
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
+ reply_to=reply_to,
+ buttons=buttons,
)
@@ -101,41 +123,40 @@ async def send_video(
*,
size: Optional[int] = None,
name: Optional[str] = None,
+ mime_type: Optional[str] = None,
duration: Optional[float] = None,
width: Optional[int] = None,
height: Optional[int] = None,
round: bool = False,
supports_streaming: bool = False,
+ muted: bool = False,
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
+ reply_to: Optional[int] = None,
+ buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
return await send_file(
self,
chat,
file,
size=size,
+ mime_type=mime_type,
name=name,
duration=duration,
width=width,
height=height,
round=round,
supports_streaming=supports_streaming,
+ muted=muted,
caption=caption,
caption_markdown=caption_markdown,
caption_html=caption_html,
+ reply_to=reply_to,
+ buttons=buttons,
)
-def try_get_url_path(maybe_url: Union[str, Path, InFileLike]) -> Optional[str]:
- if not isinstance(maybe_url, str):
- return None
- lowercase = maybe_url.lower()
- if lowercase.startswith("http://") or lowercase.startswith("https://"):
- return urllib.parse.urlparse(maybe_url).path
- return None
-
-
async def send_file(
self: Client,
chat: ChatLike,
@@ -160,15 +181,18 @@ async def send_file(
caption: Optional[str] = None,
caption_markdown: Optional[str] = None,
caption_html: Optional[str] = None,
+ reply_to: Optional[int] = None,
+ buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
message, entities = parse_message(
text=caption, markdown=caption_markdown, html=caption_html, allow_empty=True
)
- assert isinstance(message, str)
# Re-send existing file.
if isinstance(file, File):
- return await do_send_file(self, chat, file._input_media, message, entities)
+ return await do_send_file(
+ self, chat, file._input_media, message, entities, reply_to, buttons
+ )
# URLs are handled early as they can't use any other attributes either.
input_media: abcs.InputMedia
@@ -190,23 +214,11 @@ async def send_file(
input_media = types.InputMediaDocumentExternal(
spoiler=False, url=file, ttl_seconds=None
)
- return await do_send_file(self, chat, input_media, message, entities)
+ return await do_send_file(
+ self, chat, input_media, message, entities, reply_to, buttons
+ )
- # Paths are opened and closed by us. Anything else is *only* read, not closed.
- if isinstance(file, (str, Path)):
- path = Path(file) if isinstance(file, str) else file
- if size is None:
- size = path.stat().st_size
- if name is None:
- name = path.name
- with path.open("rb") as fd:
- input_file = await upload(self, fd, size, name)
- else:
- if size is None:
- raise ValueError("size must be set when sending file-like objects")
- if name is None:
- raise ValueError("name must be set when sending file-like objects")
- input_file = await upload(self, file, size, name)
+ input_file, name = await upload(self, file, size, name)
# Mime is mandatory for documents, but we also use it to determine whether to send as photo.
if mime_type is None:
@@ -249,7 +261,7 @@ async def send_file(
round_message=round,
supports_streaming=supports_streaming,
nosound=muted,
- duration=int(math_round(duration)),
+ duration=duration,
w=width,
h=height,
preload_prefix_size=None,
@@ -268,7 +280,9 @@ async def send_file(
ttl_seconds=None,
)
- return await do_send_file(self, chat, input_media, message, entities)
+ return await do_send_file(
+ self, chat, input_media, message, entities, reply_to, buttons
+ )
async def do_send_file(
@@ -277,6 +291,8 @@ async def do_send_file(
input_media: abcs.InputMedia,
message: str,
entities: Optional[List[abcs.MessageEntity]],
+ reply_to: Optional[int],
+ buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]],
) -> Message:
peer = (await client._resolve_to_packed(chat))._to_input_peer()
random_id = generate_random_id()
@@ -289,11 +305,15 @@ async def do_send_file(
noforwards=False,
update_stickersets_order=False,
peer=peer,
- reply_to=None,
+ reply_to=types.InputReplyToMessage(
+ reply_to_msg_id=reply_to, top_msg_id=None
+ )
+ if reply_to
+ else None,
media=input_media,
message=message,
random_id=random_id,
- reply_markup=None,
+ reply_markup=btns.build_keyboard(buttons),
entities=entities,
schedule_date=None,
send_as=None,
@@ -304,6 +324,31 @@ async def do_send_file(
async def upload(
+ client: Client,
+ file: Union[str, Path, InFileLike],
+ size: Optional[int],
+ name: Optional[str],
+) -> Tuple[abcs.InputFile, str]:
+ # Paths are opened and closed by us. Anything else is *only* read, not closed.
+ if isinstance(file, (str, Path)):
+ path = Path(file) if isinstance(file, str) else file
+ if size is None:
+ size = path.stat().st_size
+ if name is None:
+ name = path.name
+ with path.open("rb") as fd:
+ return await do_upload(client, fd, size, name), name
+ else:
+ if size is None:
+ raise ValueError("size must be set when sending file-like objects")
+ if name is None:
+ name = getattr(file, "name", None)
+ if not isinstance(name, str):
+ raise ValueError("name must be set when sending file-like objects")
+ return await do_upload(client, file, size, name), name
+
+
+async def do_upload(
client: Client,
fd: InFileLike,
size: int,
diff --git a/client/src/telethon/_impl/client/client/messages.py b/client/src/telethon/_impl/client/client/messages.py
index 3f585be3..6288e25b 100644
--- a/client/src/telethon/_impl/client/client/messages.py
+++ b/client/src/telethon/_impl/client/client/messages.py
@@ -2,85 +2,18 @@ from __future__ import annotations
import datetime
import sys
-from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Self, Tuple, Union
+from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Self, Union
from ...session import PackedChat
from ...tl import abcs, functions, types
-from ..parsers import parse_html_message, parse_markdown_message
-from ..types import (
- AsyncList,
- Chat,
- ChatLike,
- Message,
- build_chat_map,
- buttons,
- generate_random_id,
- peer_id,
-)
+from ..types import AsyncList, Chat, ChatLike, Message, build_chat_map
+from ..types import buttons as btns
+from ..types import generate_random_id, parse_message, peer_id
if TYPE_CHECKING:
from .client import Client
-def parse_message(
- *,
- text: Optional[Union[str, Message]],
- markdown: Optional[str],
- html: Optional[str],
- allow_empty: bool,
-) -> Tuple[Union[str, Message], Optional[List[abcs.MessageEntity]]]:
- cnt = sum((text is not None, markdown is not None, html is not None))
- if cnt != 1:
- if cnt == 0 and allow_empty:
- return "", None
- raise ValueError("must specify exactly one of text, markdown or html")
-
- if text is not None:
- parsed, entities = text, None
- elif markdown is not None:
- parsed, entities = parse_markdown_message(markdown)
- elif html is not None:
- parsed, entities = parse_html_message(html)
- else:
- raise RuntimeError("unexpected case")
-
- return parsed, entities or None
-
-
-def build_keyboard(
- btns: Optional[Union[List[buttons.Button], List[List[buttons.Button]]]]
-) -> Optional[abcs.ReplyMarkup]:
- # list[button] -> list[list[button]]
- # This does allow for "invalid" inputs (mixing lists and non-lists), but that's acceptable.
- buttons_lists_iter = (
- button if isinstance(button, list) else [button] for button in (btns or [])
- )
- # Remove empty rows (also making it easy to check if all-empty).
- buttons_lists = [bs for bs in buttons_lists_iter if bs]
-
- if not buttons_lists:
- return None
-
- rows: List[abcs.KeyboardButtonRow] = [
- types.KeyboardButtonRow(buttons=[btn._raw for btn in btns])
- for btns in buttons_lists
- ]
-
- # Guaranteed to have at least one, first one used to check if it's inline.
- # If the user mixed inline with non-inline, Telegram will complain.
- if isinstance(buttons_lists[0][0], buttons.InlineButton):
- return types.ReplyInlineMarkup(rows=rows)
- else:
- return types.ReplyKeyboardMarkup(
- resize=False,
- single_use=False,
- selective=False,
- persistent=False,
- rows=rows,
- placeholder=None,
- )
-
-
async def send_message(
self: Client,
chat: ChatLike,
@@ -90,16 +23,39 @@ async def send_message(
html: Optional[str] = None,
link_preview: bool = False,
reply_to: Optional[int] = None,
- buttons: Optional[Union[List[buttons.Button], List[List[buttons.Button]]]] = None,
+ buttons: Optional[Union[List[btns.Button], List[List[btns.Button]]]] = None,
) -> Message:
packed = await self._resolve_to_packed(chat)
peer = packed._to_input_peer()
- message, entities = parse_message(
- text=text, markdown=markdown, html=html, allow_empty=False
- )
random_id = generate_random_id()
- result = await self(
- functions.messages.send_message(
+
+ if isinstance(text, Message):
+ message = text.text or ""
+ request = functions.messages.send_message(
+ no_webpage=not text.link_preview,
+ silent=text.silent,
+ background=False,
+ clear_draft=False,
+ noforwards=not text.can_forward,
+ update_stickersets_order=False,
+ peer=peer,
+ reply_to=types.InputReplyToMessage(
+ reply_to_msg_id=text.replied_message_id, top_msg_id=None
+ )
+ if text.replied_message_id
+ else None,
+ message=message,
+ random_id=random_id,
+ reply_markup=getattr(text._raw, "reply_markup", None),
+ entities=getattr(text._raw, "entities", None) or None,
+ schedule_date=None,
+ send_as=None,
+ )
+ else:
+ message, entities = parse_message(
+ text=text, markdown=markdown, html=html, allow_empty=False
+ )
+ request = functions.messages.send_message(
no_webpage=not link_preview,
silent=False,
background=False,
@@ -114,33 +70,13 @@ async def send_message(
else None,
message=message,
random_id=random_id,
- reply_markup=build_keyboard(buttons),
+ reply_markup=btns.build_keyboard(buttons),
entities=entities,
schedule_date=None,
send_as=None,
)
- if isinstance(message, str)
- else functions.messages.send_message(
- no_webpage=not message.link_preview,
- silent=message.silent,
- background=False,
- clear_draft=False,
- noforwards=not message.can_forward,
- update_stickersets_order=False,
- peer=peer,
- reply_to=types.InputReplyToMessage(
- reply_to_msg_id=message.replied_message_id, top_msg_id=None
- )
- if message.replied_message_id
- else None,
- message=message.text or "",
- random_id=random_id,
- reply_markup=getattr(message._raw, "reply_markup", None),
- entities=getattr(message._raw, "entities", None) or None,
- schedule_date=None,
- send_as=None,
- )
- )
+
+ result = await self(request)
if isinstance(result, types.UpdateShortSentMessage):
return Message._from_defaults(
self,
@@ -161,7 +97,7 @@ async def send_message(
if reply_to
else None,
date=result.date,
- message=message if isinstance(message, str) else (message.text or ""),
+ message=message,
media=result.media,
entities=result.entities,
ttl_period=result.ttl_period,
@@ -184,7 +120,6 @@ async def edit_message(
message, entities = parse_message(
text=text, markdown=markdown, html=html, allow_empty=False
)
- assert isinstance(message, str)
return self._build_message_map(
await self(
functions.messages.edit_message(
diff --git a/client/src/telethon/_impl/client/client/updates.py b/client/src/telethon/_impl/client/client/updates.py
index d0d8bb6d..486bdc92 100644
--- a/client/src/telethon/_impl/client/client/updates.py
+++ b/client/src/telethon/_impl/client/client/updates.py
@@ -154,5 +154,5 @@ async def dispatch_next(client: Client) -> None:
for handler, filter in handlers:
if not filter or filter(event):
ret = await handler(event)
- if ret is Continue or client._shortcircuit_handlers:
+ if ret is not Continue or client._shortcircuit_handlers:
return
diff --git a/client/src/telethon/_impl/client/client/users.py b/client/src/telethon/_impl/client/client/users.py
index 2345e405..d7b1739a 100644
--- a/client/src/telethon/_impl/client/client/users.py
+++ b/client/src/telethon/_impl/client/client/users.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Optional
+from typing import TYPE_CHECKING, List, Optional, Sequence
from ...mtproto import RpcError
from ...session import PackedChat, PackedType
@@ -13,6 +13,7 @@ from ..types import (
Group,
User,
build_chat_map,
+ expand_peer,
peer_id,
)
@@ -73,12 +74,51 @@ async def resolve_username(self: Client, username: str) -> Chat:
)
-async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat:
+async def get_chats(self: Client, chats: Sequence[ChatLike]) -> List[Chat]:
+ packed_chats: List[PackedChat] = []
+ input_users: List[types.InputUser] = []
+ input_chats: List[int] = []
+ input_channels: List[types.InputChannel] = []
+
+ for chat in chats:
+ packed = await resolve_to_packed(self, chat)
+ if packed.is_user():
+ input_users.append(packed._to_input_user())
+ elif packed.is_chat():
+ input_chats.append(packed.id)
+ else:
+ input_channels.append(packed._to_input_channel())
+
+ users = (
+ (await self(functions.users.get_users(id=input_users))) if input_users else []
+ )
+ groups = (
+ (await self(functions.messages.get_chats(id=input_chats)))
+ if input_chats
+ else []
+ )
+ assert isinstance(groups, types.messages.Chats)
+ channels = (
+ (await self(functions.channels.get_channels(id=input_channels)))
+ if input_channels
+ else []
+ )
+ assert isinstance(channels, types.messages.Chats)
+
+ chat_map = build_chat_map(self, users, groups.chats + channels.chats)
+ return [
+ chat_map.get(chat.id)
+ or expand_peer(self, chat._to_peer(), broadcast=chat.ty == PackedType.BROADCAST)
+ for chat in packed_chats
+ ]
+
+
+async def resolve_to_packed(client: Client, chat: ChatLike) -> PackedChat:
if isinstance(chat, PackedChat):
return chat
if isinstance(chat, (User, Group, Channel)):
- packed = chat.pack() or self._chat_hashes.get(chat.id)
+ packed = chat.pack() or client._chat_hashes.get(chat.id)
if packed is not None:
return packed
@@ -96,11 +136,11 @@ async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat:
if isinstance(chat, types.InputPeerEmpty):
raise ValueError("Cannot resolve chat")
elif isinstance(chat, types.InputPeerSelf):
- if not self._session.user:
+ if not client._session.user:
raise ValueError("Cannot resolve chat")
return PackedChat(
- ty=PackedType.BOT if self._session.user.bot else PackedType.USER,
- id=self._chat_hashes.self_id,
+ ty=PackedType.BOT if client._session.user.bot else PackedType.USER,
+ id=client._chat_hashes.self_id,
access_hash=0,
)
elif isinstance(chat, types.InputPeerChat):
@@ -130,9 +170,9 @@ async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat:
if isinstance(chat, str):
if chat.startswith("+"):
- resolved = await resolve_phone(self, chat)
+ resolved = await resolve_phone(client, chat)
elif chat == "me":
- if me := self._session.user:
+ if me := client._session.user:
return PackedChat(
ty=PackedType.BOT if me.bot else PackedType.USER,
id=me.id,
@@ -141,13 +181,13 @@ async def resolve_to_packed(self: Client, chat: ChatLike) -> PackedChat:
else:
resolved = None
else:
- resolved = await resolve_username(self, username=chat)
+ resolved = await resolve_username(client, username=chat)
if resolved and (packed := resolved.pack()) is not None:
return packed
if isinstance(chat, int):
- packed = self._chat_hashes.get(chat)
+ packed = client._chat_hashes.get(chat)
if packed is None:
raise ValueError("Cannot resolve chat")
return packed
diff --git a/client/src/telethon/_impl/client/events/filters/__init__.py b/client/src/telethon/_impl/client/events/filters/__init__.py
index d3a1c97a..740f1754 100644
--- a/client/src/telethon/_impl/client/events/filters/__init__.py
+++ b/client/src/telethon/_impl/client/events/filters/__init__.py
@@ -1,6 +1,6 @@
from .combinators import All, Any, Filter, Not
-from .common import Chats, Senders
-from .messages import Command, Forward, Incoming, Media, Outgoing, Reply, Text, TextOnly
+from .common import Chats, ChatType, Senders
+from .messages import Command, Forward, Incoming, Media, Outgoing, Reply, Text
__all__ = [
"All",
@@ -8,6 +8,7 @@ __all__ = [
"Filter",
"Not",
"Chats",
+ "ChatType",
"Senders",
"Command",
"Forward",
@@ -16,5 +17,4 @@ __all__ = [
"Outgoing",
"Reply",
"Text",
- "TextOnly",
]
diff --git a/client/src/telethon/_impl/client/events/filters/combinators.py b/client/src/telethon/_impl/client/events/filters/combinators.py
index b8214eb6..a98870e1 100644
--- a/client/src/telethon/_impl/client/events/filters/combinators.py
+++ b/client/src/telethon/_impl/client/events/filters/combinators.py
@@ -1,10 +1,10 @@
import abc
import typing
-from typing import Callable, Tuple
+from typing import Callable, Tuple, TypeAlias
from ..event import Event
-Filter = Callable[[Event], bool]
+Filter: TypeAlias = Callable[[Event], bool]
class Combinable(abc.ABC):
@@ -48,11 +48,12 @@ class Any(Combinable):
"""
Combine multiple filters, returning :data:`True` if any of the filters pass.
- When either filter is *combinable*, you can use the ``|`` operator instead.
+ When either filter is :class:`~telethon._impl.client.events.filters.combinators.Combinable`,
+ you can use the ``|`` operator instead.
.. code-block:: python
- from telethon.filters import Any, Command
+ from telethon.events.filters import Any, Command
@bot.on(events.NewMessage, Any(Command('/start'), Command('/help')))
async def handler(event): ...
@@ -87,11 +88,12 @@ class All(Combinable):
"""
Combine multiple filters, returning :data:`True` if all of the filters pass.
- When either filter is *combinable*, you can use the ``&`` operator instead.
+ When either filter is :class:`~telethon._impl.client.events.filters.combinators.Combinable`,
+ you can use the ``&`` operator instead.
.. code-block:: python
- from telethon.filters import All, Command, Text
+ from telethon.events.filters import All, Command, Text
@bot.on(events.NewMessage, All(Command('/start'), Text(r'\bdata:\w+')))
async def handler(event): ...
@@ -126,11 +128,12 @@ class Not(Combinable):
"""
Negate the output of a single filter, returning :data:`True` if the nested filter does *not* pass.
- When the filter is *combinable*, you can use the ``~`` operator instead.
+ When the filter is :class:`~telethon._impl.client.events.filters.combinators.Combinable`,
+ you can use the ``~`` operator instead.
.. code-block:: python
- from telethon.filters import All, Command
+ from telethon.events.filters import All, Command
@bot.on(events.NewMessage, Not(Command('/start'))
async def handler(event): ...
diff --git a/client/src/telethon/_impl/client/events/filters/common.py b/client/src/telethon/_impl/client/events/filters/common.py
index 8e4b4ca5..3e9d0944 100644
--- a/client/src/telethon/_impl/client/events/filters/common.py
+++ b/client/src/telethon/_impl/client/events/filters/common.py
@@ -1,4 +1,4 @@
-from typing import Literal, Sequence, Tuple, Type, Union
+from typing import Sequence, Set, Type, Union
from ...types import Channel, Group, User
from ..event import Event
@@ -8,20 +8,21 @@ from .combinators import Combinable
class Chats(Combinable):
"""
Filter by ``event.chat.id``, if the event has a chat.
+
+ :param chat_ids: The chat identifiers to filter on.
"""
__slots__ = ("_chats",)
- def __init__(self, chat_id: Union[int, Sequence[int]], *chat_ids: int) -> None:
- self._chats = {chat_id} if isinstance(chat_id, int) else set(chat_id)
- self._chats.update(chat_ids)
+ def __init__(self, chat_ids: Sequence[int]) -> None:
+ self._chats = set(chat_ids)
@property
- def chat_ids(self) -> Tuple[int, ...]:
+ def chat_ids(self) -> Set[int]:
"""
- The chat identifiers this filter is filtering on.
+ A copy of the set of chat identifiers this filter is filtering on.
"""
- return tuple(self._chats)
+ return set(self._chats)
def __call__(self, event: Event) -> bool:
chat = getattr(event, "chat", None)
@@ -32,20 +33,21 @@ class Chats(Combinable):
class Senders(Combinable):
"""
Filter by ``event.sender.id``, if the event has a sender.
+
+ :param sender_ids: The sender identifiers to filter on.
"""
__slots__ = ("_senders",)
- def __init__(self, sender_id: Union[int, Sequence[int]], *sender_ids: int) -> None:
- self._senders = {sender_id} if isinstance(sender_id, int) else set(sender_id)
- self._senders.update(sender_ids)
+ def __init__(self, sender_ids: Sequence[int]) -> None:
+ self._senders = set(sender_ids)
@property
- def sender_ids(self) -> Tuple[int, ...]:
+ def sender_ids(self) -> Set[int]:
"""
- The sender identifiers this filter is filtering on.
+ A copy of the set of sender identifiers this filter is filtering on.
"""
- return tuple(self._senders)
+ return set(self._senders)
def __call__(self, event: Event) -> bool:
sender = getattr(event, "sender", None)
@@ -55,37 +57,38 @@ class Senders(Combinable):
class ChatType(Combinable):
"""
- Filter by chat type, either ``'user'``, ``'group'`` or ``'broadcast'``.
+ Filter by chat type using :func:`isinstance`.
+
+ :param type: The chat type to filter on.
+
+ .. rubric:: Example
+
+ .. code-block:: python
+
+ from telethon import events
+ from telethon.events import filters
+ from telethon.types import Channel
+
+ # Handle only messages from broadcast channels
+ @client.on(events.NewMessage, filters.ChatType(Channel))
+ async def handler(event):
+ print(event.text)
"""
__slots__ = ("_type",)
def __init__(
self,
- type: Union[Literal["user"], Literal["group"], Literal["broadcast"]],
+ type: Type[Union[User, Group, Channel]],
) -> None:
- if type == "user":
- self._type: Union[Type[User], Type[Group], Type[Channel]] = User
- elif type == "group":
- self._type = Group
- elif type == "broadcast":
- self._type = Channel
- else:
- raise TypeError(f"unrecognised chat type: {type}")
+ self._type = type
@property
- def type(self) -> Union[Literal["user"], Literal["group"], Literal["broadcast"]]:
+ def type(self) -> Type[Union[User, Group, Channel]]:
"""
The chat type this filter is filtering on.
"""
- if self._type == User:
- return "user"
- elif self._type == Group:
- return "group"
- elif self._type == Channel:
- return "broadcast"
- else:
- raise RuntimeError("unexpected case")
+ return self._type
def __call__(self, event: Event) -> bool:
sender = getattr(event, "chat", None)
diff --git a/client/src/telethon/_impl/client/events/filters/messages.py b/client/src/telethon/_impl/client/events/filters/messages.py
index 9045e190..70844f36 100644
--- a/client/src/telethon/_impl/client/events/filters/messages.py
+++ b/client/src/telethon/_impl/client/events/filters/messages.py
@@ -21,7 +21,9 @@ class Text(Combinable):
you need to manually perform the check inside the handler instead.
Note that the caption text in messages with media is also searched.
- If you want to filter based on media, use :class:`TextOnly` or :class:`Media`.
+ If you want to filter based on media, use :class:`Media`.
+
+ :param regexp: The regular expression to :func:`re.search` with on the text.
"""
__slots__ = ("_pattern",)
@@ -43,6 +45,8 @@ class Command(Combinable):
filter ``Command('/help')`` will match both ``"/help"`` and ``"/help@bot"``, but not
``"/list"`` or ``"/help@other"``.
+ :param command: The command to match on.
+
.. note::
The leading forward-slash is not automatically added!
@@ -58,7 +62,7 @@ class Command(Combinable):
__slots__ = ("_cmd", "_username")
def __init__(self, command: str) -> None:
- if re.match(r"\s", command):
+ if re.search(r"\s", command):
raise ValueError(f"command cannot contain spaces: {command}")
self._cmd = command
@@ -87,11 +91,7 @@ class Command(Combinable):
class Incoming(Combinable):
"""
- Filter by ``event.incoming``, that is, messages sent from others to the
- logged-in account.
-
- This is not a reliable way to check that the update was not produced by
- the logged-in account in broadcast channels.
+ Filter by ``event.incoming``, that is, messages sent from others to the logged-in account.
"""
__slots__ = ()
@@ -102,11 +102,9 @@ class Incoming(Combinable):
class Outgoing(Combinable):
"""
- Filter by ``event.outgoing``, that is, messages sent from others to the
- logged-in account.
+ Filter by ``event.outgoing``, that is, messages sent from the logged-in account.
- This is not a reliable way to check that the update was not produced by
- the logged-in account in broadcast channels.
+ This is not a reliable way to check that the update was not produced by the logged-in account in broadcast channels.
"""
__slots__ = ()
@@ -117,32 +115,24 @@ class Outgoing(Combinable):
class Forward(Combinable):
"""
- Filter by ``event.forward``.
+ Filter by ``event.forward_info``, that is, messages that have been forwarded from elsewhere.
"""
__slots__ = ()
def __call__(self, event: Event) -> bool:
- return getattr(event, "forward", None) is not None
+ return getattr(event, "forward_info", None) is not None
class Reply(Combinable):
"""
- Filter by ``event.reply``.
+ Filter by ``event.replied_message_id``, that is, messages which are a reply to another message.
"""
__slots__ = ()
def __call__(self, event: Event) -> bool:
- return getattr(event, "reply", None) is not None
-
-
-class TextOnly(Combinable):
- """
- Filter by messages with some text and no media.
-
- Note that link previews are only considered media if they have a photo or document.
- """
+ return getattr(event, "replied_message_id", None) is not None
class Media(Combinable):
@@ -156,6 +146,10 @@ class Media(Combinable):
When you specify one or more media types, *only* those types will be considered.
You can use literal strings or the constants defined by the filter.
+
+ :param types:
+ The media types to filter on.
+ This is all of them if none are specified.
"""
PHOTO = "photo"
diff --git a/client/src/telethon/_impl/client/events/messages.py b/client/src/telethon/_impl/client/events/messages.py
index f5139209..07218d0f 100644
--- a/client/src/telethon/_impl/client/events/messages.py
+++ b/client/src/telethon/_impl/client/events/messages.py
@@ -1,9 +1,9 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Dict, List, Optional, Self
+from typing import TYPE_CHECKING, Dict, List, Optional, Self, Union
from ...tl import abcs, types
-from ..types import Chat, Message
+from ..types import Chat, Message, expand_peer, peer_id
from .event import Event
if TYPE_CHECKING:
@@ -14,6 +14,8 @@ class NewMessage(Event, Message):
"""
Occurs when a new message is sent or received.
+ This event can be treated as the :class:`~telethon.types.Message` itself.
+
.. caution::
Messages sent with the :class:`~telethon.Client` are also caught,
@@ -39,6 +41,8 @@ class NewMessage(Event, Message):
class MessageEdited(Event, Message):
"""
Occurs when a new message is sent or received.
+
+ This event can be treated as the :class:`~telethon.types.Message` itself.
"""
@classmethod
@@ -80,32 +84,96 @@ class MessageDeleted(Event):
else:
return None
+ @property
+ def message_ids(self) -> List[int]:
+ """
+ The message identifiers of the messages that were deleted.
+ """
+ return self._msg_ids
+
+ @property
+ def channel_id(self) -> Optional[int]:
+ """
+ The channel identifier of the supergroup or broadcast channel where the messages were deleted.
+
+ This will be :data:`None` if the messages were deleted anywhere else.
+ """
+ return self._channel_id
+
class MessageRead(Event):
"""
Occurs both when your messages are read by others, and when you read messages.
"""
- def __init__(self, peer: abcs.Peer, max_id: int, out: bool) -> None:
- self._peer = peer
- self._max_id = max_id
- self._out = out
+ def __init__(
+ self,
+ client: Client,
+ update: Union[
+ types.UpdateReadHistoryInbox,
+ types.UpdateReadHistoryOutbox,
+ types.UpdateReadChannelInbox,
+ types.UpdateReadChannelOutbox,
+ ],
+ chat_map: Dict[int, Chat],
+ ) -> None:
+ self._client = client
+ self._raw = update
+ self._chat_map = chat_map
@classmethod
def _try_from_update(
cls, client: Client, update: abcs.Update, chat_map: Dict[int, Chat]
) -> Optional[Self]:
- if isinstance(update, types.UpdateReadHistoryInbox):
- return cls._create(update.peer, update.max_id, False)
- elif isinstance(update, types.UpdateReadHistoryOutbox):
- return cls._create(update.peer, update.max_id, True)
- elif isinstance(update, types.UpdateReadChannelInbox):
- return cls._create(
- types.PeerChannel(channel_id=update.channel_id), update.max_id, False
- )
- elif isinstance(update, types.UpdateReadChannelOutbox):
- return cls._create(
- types.PeerChannel(channel_id=update.channel_id), update.max_id, True
- )
+ if isinstance(
+ update,
+ (
+ types.UpdateReadHistoryInbox,
+ types.UpdateReadHistoryOutbox,
+ types.UpdateReadChannelInbox,
+ types.UpdateReadChannelOutbox,
+ ),
+ ):
+ return cls._create(client, update, chat_map)
else:
return None
+
+ def _peer(self) -> abcs.Peer:
+ if isinstance(
+ self._raw, (types.UpdateReadHistoryInbox, types.UpdateReadHistoryOutbox)
+ ):
+ return self._raw.peer
+ else:
+ return types.PeerChannel(channel_id=self._raw.channel_id)
+
+ @property
+ def chat(self) -> Chat:
+ """
+ The :term:`chat` when the messages were read.
+ """
+ peer = self._peer()
+ pid = peer_id(peer)
+ if pid not in self._chat_map:
+ self._chat_map[pid] = expand_peer(
+ self._client, peer, broadcast=getattr(self._raw, "post", None)
+ )
+ return self._chat_map[pid]
+
+ @property
+ def max_message_id_read(self) -> int:
+ """
+ The highest message identifier of the messages that have been marked as read.
+
+ In other words, messages with an identifier below or equal (``<=``) to this value are considered read.
+ Messages with an identifier higher (``>``) to this value are considered unread.
+
+ .. rubric:: Example
+
+ .. code-block:: python
+
+ if message.id <= event.max_message_id_read:
+ print('message is marked as read')
+ else:
+ print('message is not yet marked as read')
+ """
+ return self._raw.max_id
diff --git a/client/src/telethon/_impl/client/types/__init__.py b/client/src/telethon/_impl/client/types/__init__.py
index 1d13b2cc..72beb9d9 100644
--- a/client/src/telethon/_impl/client/types/__init__.py
+++ b/client/src/telethon/_impl/client/types/__init__.py
@@ -1,4 +1,5 @@
from .admin_right import AdminRight
+from .album_builder import AlbumBuilder
from .async_list import AsyncList
from .callback_answer import CallbackAnswer
from .chat import (
@@ -14,10 +15,23 @@ from .chat import (
from .chat_restriction import ChatRestriction
from .dialog import Dialog
from .draft import Draft
-from .file import File, InFileLike, OutFileLike, OutWrapper, expand_stripped_size
+from .file import (
+ File,
+ InFileLike,
+ OutFileLike,
+ OutWrapper,
+ expand_stripped_size,
+ try_get_url_path,
+)
from .inline_result import InlineResult
from .login_token import LoginToken
-from .message import Message, adapt_date, build_msg_map, generate_random_id
+from .message import (
+ Message,
+ adapt_date,
+ build_msg_map,
+ generate_random_id,
+ parse_message,
+)
from .meta import NoPublicConstructor
from .participant import Participant
from .password_token import PasswordToken
@@ -25,6 +39,7 @@ from .recent_action import RecentAction
__all__ = [
"AdminRight",
+ "AlbumBuilder",
"AsyncList",
"ChatRestriction",
"CallbackAnswer",
@@ -43,12 +58,14 @@ __all__ = [
"OutFileLike",
"OutWrapper",
"expand_stripped_size",
+ "try_get_url_path",
"InlineResult",
"LoginToken",
"Message",
"adapt_date",
"build_msg_map",
"generate_random_id",
+ "parse_message",
"NoPublicConstructor",
"Participant",
"PasswordToken",
diff --git a/client/src/telethon/_impl/client/types/album_builder.py b/client/src/telethon/_impl/client/types/album_builder.py
new file mode 100644
index 00000000..e3b3c694
--- /dev/null
+++ b/client/src/telethon/_impl/client/types/album_builder.py
@@ -0,0 +1,239 @@
+from __future__ import annotations
+
+import mimetypes
+from pathlib import Path
+from typing import TYPE_CHECKING, List, Optional, Union
+
+from ...tl import abcs, functions, types
+from .chat import ChatLike
+from .file import InFileLike, try_get_url_path
+from .message import Message, generate_random_id, parse_message
+from .meta import NoPublicConstructor
+
+if TYPE_CHECKING:
+ from ..client.client import Client
+
+
+class AlbumBuilder(metaclass=NoPublicConstructor):
+ """
+ Album builder to prepare albums with multiple files before sending it all at once.
+
+ This class is constructed by calling :meth:`telethon.Client.prepare_album`.
+ """
+
+ def __init__(self, *, client: Client):
+ self._client = client
+ self._medias: List[types.InputSingleMedia] = []
+
+ async def add_photo(
+ self,
+ file: Union[str, Path, InFileLike],
+ *,
+ size: Optional[int] = None,
+ caption: Optional[str] = None,
+ caption_markdown: Optional[str] = None,
+ caption_html: Optional[str] = None,
+ ) -> None:
+ """
+ Add a photo to the album.
+
+ :param file:
+ The photo to attach to the album.
+
+ This behaves the same way as the file parameter in :meth:`telethon.Client.send_file`,
+ *except* that it cannot be previously-sent media.
+
+ :param size: See :meth:`telethon.Client.send_file`.
+ :param caption: See :ref:`formatting`.
+ :param caption_markdown: See :ref:`formatting`.
+ :param caption_html: See :ref:`formatting`.
+ """
+ input_media: abcs.InputMedia
+ if try_get_url_path(file) is not None:
+ assert isinstance(file, str)
+ input_media = types.InputMediaPhotoExternal(
+ spoiler=False, url=file, ttl_seconds=None
+ )
+ else:
+ input_file, _ = await self._client._upload(file, size, "a.jpg")
+ input_media = types.InputMediaUploadedPhoto(
+ spoiler=False, file=input_file, stickers=None, ttl_seconds=None
+ )
+
+ media = await self._client(
+ functions.messages.upload_media(
+ peer=types.InputPeerSelf(), media=input_media
+ )
+ )
+ assert isinstance(media, types.MessageMediaPhoto)
+ assert isinstance(media.photo, types.Photo)
+ input_media = types.InputMediaPhoto(
+ spoiler=media.spoiler,
+ id=types.InputPhoto(
+ id=media.photo.id,
+ access_hash=media.photo.access_hash,
+ file_reference=media.photo.file_reference,
+ ),
+ ttl_seconds=media.ttl_seconds,
+ )
+ message, entities = parse_message(
+ text=caption, markdown=caption_markdown, html=caption_html, allow_empty=True
+ )
+ self._medias.append(
+ types.InputSingleMedia(
+ media=input_media,
+ random_id=generate_random_id(),
+ message=message,
+ entities=entities,
+ )
+ )
+
+ async def add_video(
+ self,
+ file: Union[str, Path, InFileLike],
+ *,
+ size: Optional[int] = None,
+ name: Optional[str] = None,
+ mime_type: Optional[str] = None,
+ duration: Optional[float] = None,
+ width: Optional[int] = None,
+ height: Optional[int] = None,
+ round: bool = False,
+ supports_streaming: bool = False,
+ muted: bool = False,
+ caption: Optional[str] = None,
+ caption_markdown: Optional[str] = None,
+ caption_html: Optional[str] = None,
+ ) -> None:
+ """
+ Add a video to the album.
+
+ :param file:
+ The video to attach to the album.
+
+ This behaves the same way as the file parameter in :meth:`telethon.Client.send_file`,
+ *except* that it cannot be previously-sent media.
+
+ :param size: See :meth:`telethon.Client.send_file`.
+ :param name: See :meth:`telethon.Client.send_file`.
+ :param mime_type: See :meth:`telethon.Client.send_file`.
+ :param duration: See :meth:`telethon.Client.send_file`.
+ :param width: See :meth:`telethon.Client.send_file`.
+ :param height: See :meth:`telethon.Client.send_file`.
+ :param round: See :meth:`telethon.Client.send_file`.
+ :param supports_streaming: See :meth:`telethon.Client.send_file`.
+ :param muted: See :meth:`telethon.Client.send_file`.
+ :param caption: See :ref:`formatting`.
+ :param caption_markdown: See :ref:`formatting`.
+ :param caption_html: See :ref:`formatting`.
+ """
+
+ input_media: abcs.InputMedia
+ if try_get_url_path(file) is not None:
+ assert isinstance(file, str)
+ input_media = types.InputMediaDocumentExternal(
+ spoiler=False, url=file, ttl_seconds=None
+ )
+ else:
+ input_file, name = await self._client._upload(file, size, name)
+ if mime_type is None:
+ mime_type, _ = mimetypes.guess_type(name, strict=False)
+ if mime_type is None:
+ mime_type = "application/octet-stream"
+
+ attributes: List[abcs.DocumentAttribute] = []
+ attributes.append(types.DocumentAttributeFilename(file_name=name))
+ if duration is not None and width is not None and height is not None:
+ attributes.append(
+ types.DocumentAttributeVideo(
+ round_message=round,
+ supports_streaming=supports_streaming,
+ nosound=muted,
+ duration=duration,
+ w=width,
+ h=height,
+ preload_prefix_size=None,
+ )
+ )
+ input_media = types.InputMediaUploadedDocument(
+ nosound_video=muted,
+ force_file=False,
+ spoiler=False,
+ file=input_file,
+ thumb=None,
+ mime_type=mime_type,
+ attributes=attributes,
+ stickers=None,
+ ttl_seconds=None,
+ )
+
+ media = await self._client(
+ functions.messages.upload_media(
+ peer=types.InputPeerEmpty(), media=input_media
+ )
+ )
+ assert isinstance(media, types.MessageMediaDocument)
+ assert isinstance(media.document, types.Document)
+ input_media = types.InputMediaDocument(
+ spoiler=media.spoiler,
+ id=types.InputDocument(
+ id=media.document.id,
+ access_hash=media.document.access_hash,
+ file_reference=media.document.file_reference,
+ ),
+ ttl_seconds=media.ttl_seconds,
+ query=None,
+ )
+ message, entities = parse_message(
+ text=caption, markdown=caption_markdown, html=caption_html, allow_empty=True
+ )
+ self._medias.append(
+ types.InputSingleMedia(
+ media=input_media,
+ random_id=generate_random_id(),
+ message=message,
+ entities=entities,
+ )
+ )
+
+ async def send(
+ self, chat: ChatLike, *, reply_to: Optional[int] = None
+ ) -> List[Message]:
+ """
+ Send the album.
+
+ :return: All sent messages that are part of the album.
+
+ .. rubric:: Example
+
+ .. code-block:: python
+
+ album = await client.prepare_album()
+ for photo in ('a.jpg', 'b.png'):
+ await album.add_photo(photo)
+
+ messages = await album.send(chat)
+ """
+ peer = (await self._client._resolve_to_packed(chat))._to_input_peer()
+ msg_map = self._client._build_message_map(
+ await self._client(
+ functions.messages.send_multi_media(
+ silent=False,
+ background=False,
+ clear_draft=False,
+ noforwards=False,
+ update_stickersets_order=False,
+ peer=peer,
+ reply_to=types.InputReplyToMessage(
+ reply_to_msg_id=reply_to, top_msg_id=None
+ )
+ if reply_to
+ else None,
+ multi_media=self._medias,
+ schedule_date=None,
+ send_as=None,
+ )
+ ),
+ peer,
+ )
+ return [msg_map.with_random_id(media.random_id) for media in self._medias]
diff --git a/client/src/telethon/_impl/client/types/buttons/__init__.py b/client/src/telethon/_impl/client/types/buttons/__init__.py
index 221734d8..65aaaac6 100644
--- a/client/src/telethon/_impl/client/types/buttons/__init__.py
+++ b/client/src/telethon/_impl/client/types/buttons/__init__.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import weakref
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, List, Optional, Union
from ....tl import abcs, types
from .button import Button
@@ -23,6 +23,40 @@ def as_concrete_row(row: abcs.KeyboardButtonRow) -> types.KeyboardButtonRow:
return row
+def build_keyboard(
+ btns: Optional[Union[List[Button], List[List[Button]]]]
+) -> Optional[abcs.ReplyMarkup]:
+ # list[button] -> list[list[button]]
+ # This does allow for "invalid" inputs (mixing lists and non-lists), but that's acceptable.
+ buttons_lists_iter = (
+ button if isinstance(button, list) else [button] for button in (btns or [])
+ )
+ # Remove empty rows (also making it easy to check if all-empty).
+ buttons_lists = [bs for bs in buttons_lists_iter if bs]
+
+ if not buttons_lists:
+ return None
+
+ rows: List[abcs.KeyboardButtonRow] = [
+ types.KeyboardButtonRow(buttons=[btn._raw for btn in btns])
+ for btns in buttons_lists
+ ]
+
+ # Guaranteed to have at least one, first one used to check if it's inline.
+ # If the user mixed inline with non-inline, Telegram will complain.
+ if isinstance(buttons_lists[0][0], InlineButton):
+ return types.ReplyInlineMarkup(rows=rows)
+ else:
+ return types.ReplyKeyboardMarkup(
+ resize=False,
+ single_use=False,
+ selective=False,
+ persistent=False,
+ rows=rows,
+ placeholder=None,
+ )
+
+
def create_button(message: Message, raw: abcs.KeyboardButton) -> Button:
"""
Create a custom button from a Telegram button.
diff --git a/client/src/telethon/_impl/client/types/draft.py b/client/src/telethon/_impl/client/types/draft.py
index d985f654..4568c613 100644
--- a/client/src/telethon/_impl/client/types/draft.py
+++ b/client/src/telethon/_impl/client/types/draft.py
@@ -163,7 +163,7 @@ class Draft(metaclass=NoPublicConstructor):
if chat := self._chat_map.get(peer_id(self._peer)):
packed = chat.pack()
if packed is None:
- packed = await self._client.resolve_to_packed(peer_id(self._peer))
+ packed = await self._client._resolve_to_packed(peer_id(self._peer))
return packed
async def send(self) -> Message:
diff --git a/client/src/telethon/_impl/client/types/file.py b/client/src/telethon/_impl/client/types/file.py
index b92a7a16..a75d2fd5 100644
--- a/client/src/telethon/_impl/client/types/file.py
+++ b/client/src/telethon/_impl/client/types/file.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import mimetypes
+import urllib.parse
from inspect import isawaitable
from io import BufferedWriter
from pathlib import Path
@@ -68,6 +69,15 @@ def photo_size_dimensions(
raise RuntimeError("unexpected case")
+def try_get_url_path(maybe_url: Union[str, Path, InFileLike]) -> Optional[str]:
+ if not isinstance(maybe_url, str):
+ return None
+ lowercase = maybe_url.lower()
+ if lowercase.startswith("http://") or lowercase.startswith("https://"):
+ return urllib.parse.urlparse(maybe_url).path
+ return None
+
+
class InFileLike(Protocol):
"""
A :term:`file-like object` used for input only.
diff --git a/client/src/telethon/_impl/client/types/message.py b/client/src/telethon/_impl/client/types/message.py
index 57c358b3..4e304509 100644
--- a/client/src/telethon/_impl/client/types/message.py
+++ b/client/src/telethon/_impl/client/types/message.py
@@ -2,10 +2,15 @@ from __future__ import annotations
import datetime
import time
-from typing import TYPE_CHECKING, Any, Dict, List, Optional, Self, Union
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Self, Tuple, Union
from ...tl import abcs, types
-from ..parsers import generate_html_message, generate_markdown_message
+from ..parsers import (
+ generate_html_message,
+ generate_markdown_message,
+ parse_html_message,
+ parse_markdown_message,
+)
from .buttons import Button, as_concrete_row, create_button
from .chat import Chat, ChatLike, expand_peer, peer_id
from .file import File
@@ -276,6 +281,26 @@ class Message(metaclass=NoPublicConstructor):
return None
+ @property
+ def incoming(self) -> bool:
+ """
+ :data:`True` if the message is incoming.
+ This would mean another user sent it, and the currently logged-in user received it.
+
+ This is usually the opposite of :attr:`outgoing`, although some messages can be neither.
+ """
+ return getattr(self._raw, "out", None) is False
+
+ @property
+ def outgoing(self) -> bool:
+ """
+ :data:`True` if the message is outgoing.
+ This would mean the currently logged-in user sent it.
+
+ This is usually the opposite of :attr:`incoming`, although some messages can be neither.
+ """
+ return getattr(self._raw, "out", None) is True
+
async def get_replied_message(self) -> Optional[Message]:
"""
Alias for :meth:`telethon.Client.get_messages_with_ids`.
@@ -465,3 +490,28 @@ def build_msg_map(
msg.id: msg
for msg in (Message._from_raw(client, m, chat_map) for m in messages)
}
+
+
+def parse_message(
+ *,
+ text: Optional[str],
+ markdown: Optional[str],
+ html: Optional[str],
+ allow_empty: bool,
+) -> Tuple[str, Optional[List[abcs.MessageEntity]]]:
+ cnt = sum((text is not None, markdown is not None, html is not None))
+ if cnt != 1:
+ if cnt == 0 and allow_empty:
+ return "", None
+ raise ValueError("must specify exactly one of text, markdown or html")
+
+ if text is not None:
+ parsed, entities = text, None
+ elif markdown is not None:
+ parsed, entities = parse_markdown_message(markdown)
+ elif html is not None:
+ parsed, entities = parse_html_message(html)
+ else:
+ raise RuntimeError("unexpected case")
+
+ return parsed, entities or None
diff --git a/client/src/telethon/_impl/mtproto/mtp/encrypted.py b/client/src/telethon/_impl/mtproto/mtp/encrypted.py
index 84e9cb8b..4031aa7f 100644
--- a/client/src/telethon/_impl/mtproto/mtp/encrypted.py
+++ b/client/src/telethon/_impl/mtproto/mtp/encrypted.py
@@ -45,6 +45,11 @@ from ...tl.types import (
UpdateShortSentMessage,
UpdatesTooLong,
)
+from ...tl.types.messages import (
+ AffectedFoundMessages,
+ AffectedHistory,
+ AffectedMessages,
+)
from ..utils import (
CONTAINER_MAX_LENGTH,
CONTAINER_MAX_SIZE,
@@ -69,6 +74,9 @@ UPDATE_IDS = {
UpdateShortMessage.constructor_id(),
UpdateShortSentMessage.constructor_id(),
UpdatesTooLong.constructor_id(),
+ AffectedFoundMessages.constructor_id(),
+ AffectedHistory.constructor_id(),
+ AffectedMessages.constructor_id(),
}
HEADER_LEN = 8 + 8 # salt, client_id
diff --git a/client/src/telethon/_impl/mtsender/sender.py b/client/src/telethon/_impl/mtsender/sender.py
index f0e20755..1b56340a 100644
--- a/client/src/telethon/_impl/mtsender/sender.py
+++ b/client/src/telethon/_impl/mtsender/sender.py
@@ -5,7 +5,7 @@ import time
from abc import ABC
from asyncio import FIRST_COMPLETED, Event, Future
from dataclasses import dataclass
-from typing import Generic, List, Optional, Protocol, Self, Tuple, TypeVar
+from typing import Generic, List, Optional, Protocol, Self, Tuple, Type, TypeVar
from ..crypto import AuthKey
from ..mtproto import (
@@ -21,7 +21,10 @@ from ..mtproto import (
)
from ..tl import Request as RemoteCall
from ..tl.abcs import Updates
+from ..tl.core import Serializable
from ..tl.mtproto.functions import ping_delay_disconnect
+from ..tl.types import UpdateDeleteMessages, UpdateShort
+from ..tl.types.messages import AffectedFoundMessages, AffectedHistory, AffectedMessages
MAXIMUM_DATA = (1024 * 1024) + (8 * 1024)
@@ -315,12 +318,41 @@ class Sender:
try:
u = Updates.from_bytes(update)
except ValueError:
- self._logger.warning(
- "failed to deserialize incoming update; make sure the session is not in use elsewhere: %s",
- update.hex(),
+ cid = struct.unpack_from("I", update)[0]
+ alt_classes: Tuple[Type[Serializable], ...] = (
+ AffectedFoundMessages,
+ AffectedHistory,
+ AffectedMessages,
)
- else:
- updates.append(u)
+ for cls in alt_classes:
+ if cid == cls.constructor_id():
+ affected = cls.from_bytes(update)
+ # mypy struggles with the types here quite a bit
+ assert isinstance(
+ affected,
+ (
+ AffectedFoundMessages,
+ AffectedHistory,
+ AffectedMessages,
+ ),
+ )
+ u = UpdateShort(
+ update=UpdateDeleteMessages(
+ messages=[],
+ pts=affected.pts,
+ pts_count=affected.pts_count,
+ ),
+ date=0,
+ )
+ break
+ else:
+ self._logger.warning(
+ "failed to deserialize incoming update; make sure the session is not in use elsewhere: %s",
+ update.hex(),
+ )
+ continue
+
+ updates.append(u)
for msg_id, ret in result.rpc_results:
for i, req in enumerate(self._requests):
diff --git a/client/src/telethon/_impl/session/message_box/defs.py b/client/src/telethon/_impl/session/message_box/defs.py
index 3b2cb262..b3451031 100644
--- a/client/src/telethon/_impl/session/message_box/defs.py
+++ b/client/src/telethon/_impl/session/message_box/defs.py
@@ -61,6 +61,7 @@ class Gap(ValueError):
return "Gap()"
+NO_DATE = 0 # used on adapted messages.affected* from lower layers
NO_SEQ = 0
NO_PTS = 0
diff --git a/client/src/telethon/_impl/session/message_box/messagebox.py b/client/src/telethon/_impl/session/message_box/messagebox.py
index 15f1b544..7a35147e 100644
--- a/client/src/telethon/_impl/session/message_box/messagebox.py
+++ b/client/src/telethon/_impl/session/message_box/messagebox.py
@@ -13,6 +13,7 @@ from .defs import (
ENTRY_ACCOUNT,
ENTRY_SECRET,
LOG_LEVEL_TRACE,
+ NO_DATE,
NO_PTS,
NO_SEQ,
NO_UPDATES_TIMEOUT,
@@ -294,9 +295,10 @@ class MessageBox:
if any_pts_applied:
if __debug__:
self._trace("updating seq as local pts was updated too")
- self.date = datetime.datetime.fromtimestamp(
- combined.date, tz=datetime.timezone.utc
- )
+ if combined.date != NO_DATE:
+ self.date = datetime.datetime.fromtimestamp(
+ combined.date, tz=datetime.timezone.utc
+ )
if combined.seq != NO_SEQ:
self.seq = combined.seq
diff --git a/client/src/telethon/_impl/tl/core/reader.py b/client/src/telethon/_impl/tl/core/reader.py
index 7dd0ca03..2a91b17d 100644
--- a/client/src/telethon/_impl/tl/core/reader.py
+++ b/client/src/telethon/_impl/tl/core/reader.py
@@ -82,9 +82,8 @@ class Reader:
assert self._pos <= self._len
cid = struct.unpack(" None:
if type_path not in fs:
writer.write("import struct")
- writer.write("from typing import List, Optional, Self")
+ writer.write("from typing import List, Optional, Self, Sequence")
writer.write("from .. import abcs")
writer.write("from ..core import Reader, Serializable, serialize_bytes_to")
@@ -118,7 +118,8 @@ def generate(fs: FakeFs, tl: ParsedTl) -> None:
# def __init__()
if property_params:
params = "".join(
- f", {p.name}: {param_type_fmt(p.ty)}" for p in property_params
+ f", {p.name}: {param_type_fmt(p.ty, immutable=False)}"
+ for p in property_params
)
writer.write(f" def __init__(_s, *{params}) -> None:")
for p in property_params:
@@ -158,15 +159,20 @@ def generate(fs: FakeFs, tl: ParsedTl) -> None:
if function_path not in fs:
writer.write("import struct")
- writer.write("from typing import List, Optional, Self")
+ writer.write("from typing import List, Optional, Self, Sequence")
writer.write("from .. import abcs")
writer.write("from ..core import Request, serialize_bytes_to")
# def name(params, ...)
required_params = [p for p in functiondef.params if not is_computed(p.ty)]
- params = "".join(f", {p.name}: {param_type_fmt(p.ty)}" for p in required_params)
+ params = "".join(
+ f", {p.name}: {param_type_fmt(p.ty, immutable=True)}"
+ for p in required_params
+ )
star = "*" if params else ""
- return_ty = param_type_fmt(NormalParameter(ty=functiondef.ty, flag=None))
+ return_ty = param_type_fmt(
+ NormalParameter(ty=functiondef.ty, flag=None), immutable=False
+ )
writer.write(
f"def {to_method_name(functiondef.name)}({star}{params}) -> Request[{return_ty}]:"
)
diff --git a/generator/src/telethon_generator/_impl/codegen/serde/common.py b/generator/src/telethon_generator/_impl/codegen/serde/common.py
index adcbe714..e96fca74 100644
--- a/generator/src/telethon_generator/_impl/codegen/serde/common.py
+++ b/generator/src/telethon_generator/_impl/codegen/serde/common.py
@@ -86,7 +86,7 @@ def inner_type_fmt(ty: Type) -> str:
return f"abcs.{ns}{to_class_name(ty.name)}"
-def param_type_fmt(ty: BaseParameter) -> str:
+def param_type_fmt(ty: BaseParameter, *, immutable: bool) -> str:
if isinstance(ty, FlagsParameter):
return "int"
elif not isinstance(ty, NormalParameter):
@@ -104,7 +104,10 @@ def param_type_fmt(ty: BaseParameter) -> str:
res = "bytes" if inner_ty.name == "Object" else inner_type_fmt(inner_ty)
if ty.ty.generic_arg:
- res = f"List[{res}]"
+ if immutable:
+ res = f"Sequence[{res}]"
+ else:
+ res = f"List[{res}]"
if ty.flag and ty.ty.name != "true":
res = f"Optional[{res}]"