From 6047c689ca8625c86cd88cce457fb49f33fa6a9a Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 7 Nov 2023 19:34:10 +0100 Subject: [PATCH 1/5] Add client.prepare_album --- .../telethon/_impl/client/client/client.py | 75 +++++- .../telethon/_impl/client/client/dialogs.py | 12 +- .../src/telethon/_impl/client/client/files.py | 113 ++++++--- .../telethon/_impl/client/client/messages.py | 139 +++------- .../telethon/_impl/client/types/__init__.py | 21 +- .../_impl/client/types/album_builder.py | 239 ++++++++++++++++++ .../_impl/client/types/buttons/__init__.py | 36 ++- .../src/telethon/_impl/client/types/file.py | 10 + .../telethon/_impl/client/types/message.py | 34 ++- client/src/telethon/types/__init__.py | 2 + 10 files changed, 531 insertions(+), 150 deletions(-) create mode 100644 client/src/telethon/_impl/client/types/album_builder.py diff --git a/client/src/telethon/_impl/client/client/client.py b/client/src/telethon/_impl/client/client/client.py index 7382c8d1..7de3154c 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, @@ -1085,6 +1088,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: @@ -1310,6 +1341,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 +1352,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 +1367,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 +1386,7 @@ class Client: self, chat, file, + mime_type, size=size, name=name, duration=duration, @@ -1360,6 +1396,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 +1424,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 +1478,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 +1578,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 +1591,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 +1636,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 +1662,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 +1682,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 +1700,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 +1725,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 +1747,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 +2019,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/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/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..2bc95173 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 @@ -465,3 +470,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/types/__init__.py b/client/src/telethon/types/__init__.py index e6988b10..7a31d8e8 100644 --- a/client/src/telethon/types/__init__.py +++ b/client/src/telethon/types/__init__.py @@ -3,6 +3,7 @@ Classes for the various objects the library returns. """ from .._impl.client.types import ( AdminRight, + AlbumBuilder, AsyncList, CallbackAnswer, Channel, @@ -25,6 +26,7 @@ from .._impl.session import PackedChat, PackedType __all__ = [ "AdminRight", + "AlbumBuilder", "AsyncList", "CallbackAnswer", "Channel", From 4cc6ecc39ba60ac4f341fd4c5e3f12f2fc8f8576 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 7 Nov 2023 19:40:09 +0100 Subject: [PATCH 2/5] Use Sequence as input in generated functions --- .../_impl/codegen/generator.py | 16 +++++++++++----- .../_impl/codegen/serde/common.py | 7 +++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/generator/src/telethon_generator/_impl/codegen/generator.py b/generator/src/telethon_generator/_impl/codegen/generator.py index d06146b3..ff13680a 100644 --- a/generator/src/telethon_generator/_impl/codegen/generator.py +++ b/generator/src/telethon_generator/_impl/codegen/generator.py @@ -94,7 +94,7 @@ def generate(fs: FakeFs, tl: ParsedTl) -> 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}]" From f9435aa1f67119dcc0f2177350cf3fced71d3f11 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 8 Nov 2023 14:07:33 +0100 Subject: [PATCH 3/5] Continue implementation --- .readthedocs.yaml | 2 + client/doc/concepts/botapi-vs-mtproto.rst | 6 +- client/doc/concepts/messages.rst | 2 +- .../telethon/_impl/client/client/client.py | 57 ++++++---- .../telethon/_impl/client/client/updates.py | 2 +- .../src/telethon/_impl/client/client/users.py | 60 ++++++++-- .../_impl/client/events/filters/__init__.py | 6 +- .../client/events/filters/combinators.py | 19 ++-- .../_impl/client/events/filters/common.py | 67 +++++------ .../_impl/client/events/filters/messages.py | 40 +++---- .../telethon/_impl/client/events/messages.py | 104 +++++++++++++++--- .../src/telethon/_impl/client/types/draft.py | 2 +- .../telethon/_impl/client/types/message.py | 20 ++++ client/src/telethon/events/filters.py | 4 +- 14 files changed, 267 insertions(+), 124 deletions(-) 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 7de3154c..a4cf6acd 100644 --- a/client/src/telethon/_impl/client/client/client.py +++ b/client/src/telethon/_impl/client/client/client.py @@ -118,6 +118,7 @@ from .updates import ( set_handler_filter, ) from .users import ( + get_chats, get_contacts, get_me, input_to_peer, @@ -683,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. @@ -1200,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`. 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/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/message.py b/client/src/telethon/_impl/client/types/message.py index 2bc95173..4e304509 100644 --- a/client/src/telethon/_impl/client/types/message.py +++ b/client/src/telethon/_impl/client/types/message.py @@ -281,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`. diff --git a/client/src/telethon/events/filters.py b/client/src/telethon/events/filters.py index a4726d9a..93944358 100644 --- a/client/src/telethon/events/filters.py +++ b/client/src/telethon/events/filters.py @@ -11,6 +11,7 @@ from .._impl.client.events.filters import ( All, Any, Chats, + ChatType, Command, Filter, Forward, @@ -21,13 +22,13 @@ from .._impl.client.events.filters import ( Reply, Senders, Text, - TextOnly, ) __all__ = [ "All", "Any", "Chats", + "ChatType", "Command", "Filter", "Forward", @@ -38,5 +39,4 @@ __all__ = [ "Reply", "Senders", "Text", - "TextOnly", ] From e1983b80c273c38c0e5a07ed2c649796cc12c3d6 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 8 Nov 2023 23:03:24 +0100 Subject: [PATCH 4/5] Treat messages.affected* as updateDeleteMessages --- .../telethon/_impl/mtproto/mtp/encrypted.py | 8 ++++ client/src/telethon/_impl/mtsender/sender.py | 44 ++++++++++++++++--- .../_impl/session/message_box/defs.py | 1 + .../_impl/session/message_box/messagebox.py | 8 ++-- client/src/telethon/_impl/tl/core/reader.py | 5 +-- 5 files changed, 54 insertions(+), 12 deletions(-) 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(" Date: Thu, 9 Nov 2023 01:06:27 +0300 Subject: [PATCH 5/5] Fix typo in README (#4242) --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index ef2cdfe9..2fc9a647 100755 --- a/README.rst +++ b/README.rst @@ -39,14 +39,14 @@ Creating a client .. code-block:: python - from telethon import TelegramClient, events, sync + from telethon import Client, events # These example values won't work. You must get your own api_id and # api_hash from https://my.telegram.org, under API Development. api_id = 12345 api_hash = '0123456789abcdef0123456789abcdef' - async with TelegramClient('session_name', api_id, api_hash) as client: + async with Client('session_name', api_id, api_hash) as client: await client.interactive_login()