From 25a2b53d3fcd4723093c655e1fe019beeb7492c9 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 2 Oct 2023 23:26:40 +0200 Subject: [PATCH] Continue implementing file and message --- client/doc/concepts/botapi-vs-mtproto.rst | 2 + client/doc/developing/migration-guide.rst | 27 +- .../src/telethon/_impl/client/client/chats.py | 14 +- .../telethon/_impl/client/client/client.py | 22 +- .../src/telethon/_impl/client/client/files.py | 7 +- .../telethon/_impl/client/client/messages.py | 68 ++-- .../src/telethon/_impl/client/events/event.py | 2 +- .../_impl/client/events/filters/messages.py | 12 +- .../telethon/_impl/client/types/async_list.py | 2 +- .../src/telethon/_impl/client/types/file.py | 295 ++++++++++++++---- .../telethon/_impl/client/types/message.py | 159 +++++++++- .../telethon/_impl/session/storage/sqlite.py | 6 +- 12 files changed, 514 insertions(+), 102 deletions(-) diff --git a/client/doc/concepts/botapi-vs-mtproto.rst b/client/doc/concepts/botapi-vs-mtproto.rst index 3f9e2237..f72c71d1 100644 --- a/client/doc/concepts/botapi-vs-mtproto.rst +++ b/client/doc/concepts/botapi-vs-mtproto.rst @@ -356,6 +356,7 @@ Migrating from aiogram Using one of the examples from their v3 documentation with logging and comments removed: .. code-block:: python + import asyncio from aiogram import Bot, Dispatcher, types @@ -389,6 +390,7 @@ We can see a specific handler for the ``/start`` command and a catch-all echo ha In Telethon: .. code-block:: python + import asyncio, html from telethon import Client, RpcError, types, events diff --git a/client/doc/developing/migration-guide.rst b/client/doc/developing/migration-guide.rst index b477f1b6..4ad28959 100644 --- a/client/doc/developing/migration-guide.rst +++ b/client/doc/developing/migration-guide.rst @@ -98,6 +98,8 @@ Each of them can have an additional namespace (as seen above with ``account.``). * ``tl.types`` contains concrete instances, the "bare" types Telegram actually returns. You'll probably use these with :func:`isinstance` a lot. +Most custom :mod:`types` will also have a private ``_raw`` attribute with the original value from Telegram. + Raw API has a reduced feature-set --------------------------------- @@ -262,10 +264,10 @@ This means any method returning data from Telegram must have a custom wrapper ob Because the standards are higher, the barrier of entry for new additions and features is higher too. -No message.raw_text or message.message --------------------------------------- +Removed or renamed message properties and methods +------------------------------------------------- -Messages no longer have ``.raw_text`` or ``.message`` properties. +Messages no longer have ``raw_text`` or ``message`` properties. Instead, you can access the :attr:`types.Message.text`, :attr:`~types.Message.text_markdown` or :attr:`~types.Message.text_html`. @@ -279,6 +281,25 @@ Those coming from the raw API had no client, so ``text`` couldn't know how to fo Overall, the old design made the parse mode be pretty hidden. This was not very intuitive and also made it very awkward to combine multiple parse modes. +The ``forward`` property is now :attr:`~types.Message.forward_info`. +The ``forward_to`` method is now simply :meth:`~types.Message.forward`. +This makes it more consistent with the rest of message methods. + +The ``is_reply``, ``reply_to_msg_id`` and ``reply_to`` properties are now :attr:`~types.Message.replied_message_id`. +The ``get_reply_message`` method is now :meth:`~types.Message.get_replied_message`. +This should make it clear that you are not getting a reply to the current message, but rather the message it replied to. + +The ``to_id``, ``via_input_bot``, ``action_entities``, ``button_count`` properties are also gone. +Some were kept for backwards-compatibility, some were redundant. + +The ``click`` method no longer exists in the message. +Instead, find the right :attr:`~types.Message.buttons` to click on. + +The ``download`` method no longer exists in the message. +Instead, use :attr:`~types.File.download` on the message's :attr:`~types.Message.file`. + +HMMMM WEB_PREVIEW VS LINK_PREVIEW... probs use link. we're previewing a link not the web + Event and filters are now separate ---------------------------------- diff --git a/client/src/telethon/_impl/client/client/chats.py b/client/src/telethon/_impl/client/client/chats.py index 1f30e03c..ad3c3317 100644 --- a/client/src/telethon/_impl/client/client/chats.py +++ b/client/src/telethon/_impl/client/client/chats.py @@ -158,18 +158,20 @@ class ProfilePhotoList(AsyncList[File]): ) if isinstance(result, types.photos.Photos): - self._buffer.extend( - filter(None, (File._try_from_raw_photo(p) for p in result.photos)) - ) + photos = result.photos self._total = len(result.photos) elif isinstance(result, types.photos.PhotosSlice): - self._buffer.extend( - filter(None, (File._try_from_raw_photo(p) for p in result.photos)) - ) + photos = result.photos self._total = result.count else: raise RuntimeError("unexpected case") + self._buffer.extend( + filter( + None, (File._try_from_raw_photo(self._client, p) for p in photos) + ) + ) + def get_profile_photos(self: Client, chat: ChatLike) -> AsyncList[File]: return ProfilePhotoList(self, chat) diff --git a/client/src/telethon/_impl/client/client/client.py b/client/src/telethon/_impl/client/client/client.py index fe49d0c6..1de75c9b 100644 --- a/client/src/telethon/_impl/client/client/client.py +++ b/client/src/telethon/_impl/client/client/client.py @@ -1235,7 +1235,8 @@ class Client: *, markdown: Optional[str] = None, html: Optional[str] = None, - link_preview: Optional[bool] = None, + link_preview: bool = False, + reply_to: Optional[int] = None, ) -> Message: """ Send a message. @@ -1254,6 +1255,17 @@ class Client: :param text_html: Message text, parsed as HTML. + :param link_preview: + Whether the link preview is allowed. + + Setting this to :data:`True` does not guarantee a preview. + Telegram must be able to generate a preview from the first link in the message text. + + To regenerate the preview, send the link to `@WebpageBot `_. + + :param reply_to: + The message identifier of the message to reply to. + Note that exactly one *text* parameter must be provided. See the section on :doc:`/concepts/messages` to learn about message formatting. @@ -1265,7 +1277,13 @@ class Client: await client.send_message(chat, markdown='**Hello!**') """ return await send_message( - self, chat, text, markdown=markdown, html=html, link_preview=link_preview + self, + chat, + text, + markdown=markdown, + html=html, + link_preview=link_preview, + reply_to=reply_to, ) async def send_photo( diff --git a/client/src/telethon/_impl/client/client/files.py b/client/src/telethon/_impl/client/client/files.py index 2c4db2f2..fd9e30a4 100644 --- a/client/src/telethon/_impl/client/client/files.py +++ b/client/src/telethon/_impl/client/client/files.py @@ -15,6 +15,7 @@ from ..types import ( OutFileLike, OutWrapper, ) +from ..types.file import expand_stripped_size from ..utils import generate_random_id from .messages import parse_message @@ -182,8 +183,9 @@ async def send_file( muted=muted, ) message, entities = parse_message( - text=caption, markdown=caption_markdown, html=caption_html + text=caption, markdown=caption_markdown, html=caption_html, allow_empty=True ) + assert isinstance(message, str) peer = (await self._resolve_to_packed(chat))._to_input_peer() @@ -312,6 +314,9 @@ class FileBytesList(AsyncList[bytes]): self._client = client self._loc = file._input_location() self._offset = 0 + if isinstance(file._thumb, types.PhotoStrippedSize): + self._buffer.append(expand_stripped_size(file._thumb.bytes)) + self._done = True async def _fetch_next(self) -> None: result = await self._client( diff --git a/client/src/telethon/_impl/client/client/messages.py b/client/src/telethon/_impl/client/client/messages.py index 515b9817..3b8bb0b5 100644 --- a/client/src/telethon/_impl/client/client/messages.py +++ b/client/src/telethon/_impl/client/client/messages.py @@ -2,7 +2,7 @@ from __future__ import annotations import datetime import sys -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, TypeVar, Union from ...session import PackedChat from ...tl import abcs, functions, types @@ -16,11 +16,15 @@ if TYPE_CHECKING: def parse_message( *, - text: Optional[str] = None, - markdown: Optional[str] = None, - html: Optional[str] = None, -) -> Tuple[str, Optional[List[abcs.MessageEntity]]]: - if sum((text is not None, markdown is not None, html is not None)) != 1: + 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: @@ -38,14 +42,17 @@ def parse_message( async def send_message( self: Client, chat: ChatLike, - text: Optional[str] = None, + text: Optional[Union[str, Message]] = None, *, markdown: Optional[str] = None, html: Optional[str] = None, link_preview: Optional[bool] = None, + reply_to: Optional[int] = None, ) -> Message: peer = (await self._resolve_to_packed(chat))._to_input_peer() - message, entities = parse_message(text=text, markdown=markdown, html=html) + message, entities = parse_message( + text=text, markdown=markdown, html=html, allow_empty=False + ) random_id = generate_random_id() return self._build_message_map( await self( @@ -57,7 +64,11 @@ async def send_message( 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, message=message, random_id=random_id, reply_markup=None, @@ -65,6 +76,27 @@ async def send_message( schedule_date=None, send_as=None, ) + if isinstance(message, str) + else functions.messages.send_message( + no_webpage=not message.web_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, + ) ), peer, ).with_random_id(random_id) @@ -81,7 +113,10 @@ async def edit_message( link_preview: Optional[bool] = None, ) -> Message: peer = (await self._resolve_to_packed(chat))._to_input_peer() - message, entities = parse_message(text=text, markdown=markdown, html=html) + 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( @@ -232,8 +267,8 @@ class HistoryList(MessageList): self._extend_buffer(self._client, result) self._limit -= len(self._buffer) - self._done = not self._limit - if self._buffer: + self._done |= not self._limit + if self._buffer and not self._done: last = self._last_non_empty_message() self._offset_id = self._buffer[-1].id if (date := getattr(last, "date", None)) is not None: @@ -268,7 +303,7 @@ class CherryPickedList(MessageList): self._client = client self._chat = chat self._packed: Optional[PackedChat] = None - self._ids = ids + self._ids: List[abcs.InputMessage] = [types.InputMessageId(id=id) for id in ids] async def _fetch_next(self) -> None: if not self._ids: @@ -279,15 +314,12 @@ class CherryPickedList(MessageList): if self._packed.is_channel(): result = await self._client( functions.channels.get_messages( - channel=self._packed._to_input_channel(), - id=[types.InputMessageId(id=id) for id in self._ids[:100]], + channel=self._packed._to_input_channel(), id=self._ids[:100] ) ) else: result = await self._client( - functions.messages.get_messages( - id=[types.InputMessageId(id=id) for id in self._ids[:100]] - ) + functions.messages.get_messages(id=self._ids[:100]) ) self._extend_buffer(self._client, result) diff --git a/client/src/telethon/_impl/client/events/event.py b/client/src/telethon/_impl/client/events/event.py index 07e01c5e..9b0e25ce 100644 --- a/client/src/telethon/_impl/client/events/event.py +++ b/client/src/telethon/_impl/client/events/event.py @@ -20,7 +20,7 @@ class Event(metaclass=NoPublicConstructor): """ The :class:`~telethon.Client` that received this update. """ - return self._client + return getattr(self, "_client") # type: ignore [no-any-return] @classmethod @abc.abstractmethod diff --git a/client/src/telethon/_impl/client/events/filters/messages.py b/client/src/telethon/_impl/client/events/filters/messages.py index 54638e6e..288b36b7 100644 --- a/client/src/telethon/_impl/client/events/filters/messages.py +++ b/client/src/telethon/_impl/client/events/filters/messages.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Literal, Optional, Union +from typing import TYPE_CHECKING, Literal, Optional, Tuple, Union from ..event import Event @@ -144,7 +144,7 @@ class TextOnly: """ -MediaTypes = Union[Literal["photo"], Literal["audio"], Literal["video"]] +MediaType = Union[Literal["photo"], Literal["audio"], Literal["video"]] class Media: @@ -166,15 +166,15 @@ class Media: __slots__ = "_types" - def __init__(self, types: Optional[MediaTypes] = None) -> None: - self._types = types + def __init__(self, *types: MediaType) -> None: + self._types = types or None @property - def types(self) -> MediaTypes: + def types(self) -> Tuple[MediaType, ...]: """ The media types being checked. """ - return self._types + return self._types or () def __call__(self, event: Event) -> bool: if self._types is None: diff --git a/client/src/telethon/_impl/client/types/async_list.py b/client/src/telethon/_impl/client/types/async_list.py index 337c9e50..eeb924ed 100644 --- a/client/src/telethon/_impl/client/types/async_list.py +++ b/client/src/telethon/_impl/client/types/async_list.py @@ -41,7 +41,7 @@ class AsyncList(abc.ABC, Generic[T]): async def _collect(self) -> List[T]: prev = -1 - while prev != len(self._buffer): + while not self._done and prev != len(self._buffer): prev = len(self._buffer) await self._fetch_next() return list(self._buffer) diff --git a/client/src/telethon/_impl/client/types/file.py b/client/src/telethon/_impl/client/types/file.py index 22ae6086..1e38a2fc 100644 --- a/client/src/telethon/_impl/client/types/file.py +++ b/client/src/telethon/_impl/client/types/file.py @@ -1,14 +1,19 @@ +from __future__ import annotations + +import mimetypes import os from inspect import isawaitable from io import BufferedReader, BufferedWriter from mimetypes import guess_type from pathlib import Path -from types import TracebackType -from typing import Any, Coroutine, List, Optional, Protocol, Self, Type, Union +from typing import TYPE_CHECKING, Any, Coroutine, List, Optional, Protocol, Self, Union from ...tl import abcs, types from .meta import NoPublicConstructor +if TYPE_CHECKING: + from ..client import Client + math_round = round @@ -24,10 +29,43 @@ def photo_size_byte_count(size: abcs.PhotoSize) -> int: elif isinstance(size, types.PhotoSizeProgressive): return max(size.sizes) elif isinstance(size, types.PhotoStrippedSize): - if len(size.bytes) < 3 or size.bytes[0] != 1: - return len(size.bytes) + return ( + len(stripped_size_header) + + (len(size.bytes) - 3) + + len(stripped_size_footer) + ) + else: + raise RuntimeError("unexpected case") - return len(size.bytes) + 622 + +stripped_size_header = bytes.fromhex( + "FFD8FFE000104A46494600010100000100010000FFDB004300281C1E231E19282321232D2B28303C64413C37373C7B585D4964918099968F808C8AA0B4E6C3A0AADAAD8A8CC8FFCBDAEEF5FFFFFF9BC1FFFFFFFAFFE6FDFFF8FFDB0043012B2D2D3C353C76414176F8A58CA5F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8F8FFC0001108001E002803012200021101031101FFC4001F0000010501010101010100000000000000000102030405060708090A0BFFC400B5100002010303020403050504040000017D01020300041105122131410613516107227114328191A1082342B1C11552D1F02433627282090A161718191A25262728292A3435363738393A434445464748494A535455565758595A636465666768696A737475767778797A838485868788898A92939495969798999AA2A3A4A5A6A7A8A9AAB2B3B4B5B6B7B8B9BAC2C3C4C5C6C7C8C9CAD2D3D4D5D6D7D8D9DAE1E2E3E4E5E6E7E8E9EAF1F2F3F4F5F6F7F8F9FAFFC4001F0100030101010101010101010000000000000102030405060708090A0BFFC400B51100020102040403040705040400010277000102031104052131061241510761711322328108144291A1B1C109233352F0156272D10A162434E125F11718191A262728292A35363738393A434445464748494A535455565758595A636465666768696A737475767778797A82838485868788898A92939495969798999AA2A3A4A5A6A7A8A9AAB2B3B4B5B6B7B8B9BAC2C3C4C5C6C7C8C9CAD2D3D4D5D6D7D8D9DAE2E3E4E5E6E7E8E9EAF2F3F4F5F6F7F8F9FAFFDA000C03010002110311003F00" +) +stripped_size_footer = bytes.fromhex("FFD9") + + +def expand_stripped_size(data: bytes) -> bytearray: + header = bytearray(stripped_size_header) + header[164] = data[1] + header[166] = data[2] + return bytes(header) + data[3:] + stripped_size_footer + + +def photo_size_dimensions( + size: abcs.PhotoSize, +) -> Optional[types.DocumentAttributeImageSize]: + if isinstance(size, types.PhotoCachedSize): + return types.DocumentAttributeImageSize(w=size.w, h=size.h) + elif isinstance(size, types.PhotoPathSize): + return None + elif isinstance(size, types.PhotoSize): + return types.DocumentAttributeImageSize(w=size.w, h=size.h) + elif isinstance(size, types.PhotoSizeEmpty): + return None + elif isinstance(size, types.PhotoSizeProgressive): + return types.DocumentAttributeImageSize(w=size.w, h=size.h) + elif isinstance(size, types.PhotoStrippedSize): + return types.DocumentAttributeImageSize(w=size.bytes[1], h=size.bytes[2]) else: raise RuntimeError("unexpected case") @@ -122,7 +160,10 @@ class File(metaclass=NoPublicConstructor): photo: bool, muted: bool, input_media: Optional[abcs.InputMedia], - raw: Optional[Union[types.MessageMediaDocument, types.MessageMediaPhoto]], + thumb: Optional[abcs.PhotoSize], + thumbs: Optional[List[abcs.PhotoSize]], + raw: Optional[Union[abcs.MessageMedia, abcs.Photo, abcs.Document]], + client: Optional[Client], ): self._path = path self._file = file @@ -132,71 +173,116 @@ class File(metaclass=NoPublicConstructor): self._mime = mime self._photo = photo self._muted = muted - self._input_file: Optional[abcs.InputFile] = None - self._input_media: Optional[abcs.InputMedia] = input_media + self._input_media = input_media + self._thumb = thumb + self._thumbs = thumbs self._raw = raw + self._client = client @classmethod - def _try_from_raw_message_media(cls, raw: abcs.MessageMedia) -> Optional[Self]: + def _try_from_raw_message_media( + cls, client: Client, raw: abcs.MessageMedia + ) -> Optional[Self]: if isinstance(raw, types.MessageMediaDocument): - if isinstance(raw.document, types.Document): - return cls._create( - path=None, - file=None, - attributes=raw.document.attributes, - size=raw.document.size, - name=next( - ( - a.file_name - for a in raw.document.attributes - if isinstance(a, types.DocumentAttributeFilename) - ), - "", - ), - mime=raw.document.mime_type, - photo=False, - muted=next( - ( - a.nosound - for a in raw.document.attributes - if isinstance(a, types.DocumentAttributeVideo) - ), - False, - ), - input_media=types.InputMediaDocument( - spoiler=raw.spoiler, - id=types.InputDocument( - id=raw.document.id, - access_hash=raw.document.access_hash, - file_reference=raw.document.file_reference, - ), - ttl_seconds=raw.ttl_seconds, - query=None, - ), - raw=raw, + if raw.document: + return cls._try_from_raw_document( + client, + raw.document, + spoiler=raw.spoiler, + ttl_seconds=raw.ttl_seconds, + orig_raw=raw, ) elif isinstance(raw, types.MessageMediaPhoto): if raw.photo: return cls._try_from_raw_photo( - raw.photo, spoiler=raw.spoiler, ttl_seconds=raw.ttl_seconds + client, + raw.photo, + spoiler=raw.spoiler, + ttl_seconds=raw.ttl_seconds, + orig_raw=raw, ) + elif isinstance(raw, types.MessageMediaWebPage): + if isinstance(raw.webpage, types.WebPage): + if raw.webpage.document: + return cls._try_from_raw_document( + client, raw.webpage.document, orig_raw=raw + ) + if raw.webpage.photo: + return cls._try_from_raw_photo( + client, raw.webpage.photo, orig_raw=raw + ) + + return None + + @classmethod + def _try_from_raw_document( + cls, + client: Client, + raw: abcs.Document, + *, + spoiler: bool = False, + ttl_seconds: Optional[int] = None, + orig_raw: Optional[abcs.MessageMedia] = None, + ) -> Optional[Self]: + if isinstance(raw, types.Document): + return cls._create( + path=None, + file=None, + attributes=raw.attributes, + size=raw.size, + name=next( + ( + a.file_name + for a in raw.attributes + if isinstance(a, types.DocumentAttributeFilename) + ), + "", + ), + mime=raw.mime_type, + photo=False, + muted=next( + ( + a.nosound + for a in raw.attributes + if isinstance(a, types.DocumentAttributeVideo) + ), + False, + ), + input_media=types.InputMediaDocument( + spoiler=spoiler, + id=types.InputDocument( + id=raw.id, + access_hash=raw.access_hash, + file_reference=raw.file_reference, + ), + ttl_seconds=ttl_seconds, + query=None, + ), + thumb=None, + thumbs=raw.thumbs, + raw=orig_raw or raw, + client=client, + ) return None @classmethod def _try_from_raw_photo( cls, + client: Client, raw: abcs.Photo, *, spoiler: bool = False, ttl_seconds: Optional[int] = None, + orig_raw: Optional[abcs.MessageMedia] = None, ) -> Optional[Self]: if isinstance(raw, types.Photo): + largest_thumb = max(raw.sizes, key=photo_size_byte_count) return cls._create( path=None, file=None, attributes=[], - size=max(map(photo_size_byte_count, raw.sizes)), + size=photo_size_byte_count(largest_thumb), name="", mime="image/jpeg", photo=True, @@ -210,9 +296,10 @@ class File(metaclass=NoPublicConstructor): ), ttl_seconds=ttl_seconds, ), - raw=types.MessageMediaPhoto( - spoiler=spoiler, photo=raw, ttl_seconds=ttl_seconds - ), + thumb=largest_thumb, + thumbs=[t for t in raw.sizes if t is not largest_thumb], + raw=orig_raw or raw, + client=client, ) return None @@ -358,12 +445,100 @@ class File(metaclass=NoPublicConstructor): photo=photo, muted=muted, input_media=input_media, + thumb=None, + thumbs=None, raw=None, + client=None, ) @property def ext(self) -> str: - return self._path.suffix if self._path else "" + """ + The file extension, including the leading dot ``.``. + + If the file does not represent and local file, the mimetype is used in :meth:`mimetypes.guess_extension`. + + If no extension is known for the mimetype, the empty string will be returned. + This makes it safe to always append this property to a file name. + """ + if self._path: + return self._path.suffix + else: + return mimetypes.guess_extension(self._mime) or "" + + @property + def thumbnails(self) -> List[File]: + """ + The file thumbnails. + + For photos, these are often downscaled versions of the original size. + + For documents, these will be the thumbnails present in the document. + """ + return [ + File._create( + path=None, + file=None, + attributes=[], + size=photo_size_byte_count(t), + name="", + mime="image/jpeg", + photo=True, + muted=False, + input_media=self._input_media, + thumb=t, + thumbs=None, + raw=self._raw, + client=self._client, + ) + for t in (self._thumbs or []) + ] + + @property + def width(self) -> Optional[int]: + """ + The width of the image or video, if available. + """ + if self._thumb and (dim := photo_size_dimensions(self._thumb)): + return dim.w + + for attr in self._attributes: + if isinstance( + attr, (types.DocumentAttributeImageSize, types.DocumentAttributeVideo) + ): + return attr.w + + return None + + @property + def height(self) -> Optional[int]: + """ + The width of the image or video, if available. + """ + if self._thumb and (dim := photo_size_dimensions(self._thumb)): + return dim.h + + for attr in self._attributes: + if isinstance( + attr, (types.DocumentAttributeImageSize, types.DocumentAttributeVideo) + ): + return attr.h + + return None + + async def download(self, file: Union[str, Path, OutFileLike]) -> None: + """ + Alias for :meth:`telethon.Client.download`. + + The file must have been obtained from Telegram to be downloadable. + This means you cannot create local files, or files with an URL, and download them. + + See the documentation of :meth:`~telethon.Client.download` for an explanation of the parameters. + """ + if not self._client: + raise ValueError("only files from Telegram can be downloaded") + + await self._client.download(self, file) def _open(self) -> InWrapper: file = self._file or self._path @@ -372,27 +547,33 @@ class File(metaclass=NoPublicConstructor): return InWrapper(file) def _input_location(self) -> abcs.InputFileLocation: + thumb_types = ( + types.PhotoSizeEmpty, + types.PhotoSize, + types.PhotoCachedSize, + types.PhotoStrippedSize, + types.PhotoSizeProgressive, + types.PhotoPathSize, + ) if isinstance(self._input_media, types.InputMediaDocument): assert isinstance(self._input_media.id, types.InputDocument) return types.InputDocumentFileLocation( id=self._input_media.id.id, access_hash=self._input_media.id.access_hash, file_reference=self._input_media.id.file_reference, - thumb_size="", + thumb_size=self._thumb.type + if isinstance(self._thumb, thumb_types) + else "", ) elif isinstance(self._input_media, types.InputMediaPhoto): assert isinstance(self._input_media.id, types.InputPhoto) - assert isinstance(self._raw, types.MessageMediaPhoto) - assert isinstance(self._raw.photo, types.Photo) - - size = max(self._raw.photo.sizes, key=photo_size_byte_count) - assert hasattr(size, "type") + assert isinstance(self._thumb, thumb_types) return types.InputPhotoFileLocation( id=self._input_media.id.id, access_hash=self._input_media.id.access_hash, file_reference=self._input_media.id.file_reference, - thumb_size=size.type, + thumb_size=self._thumb.type, ) else: raise TypeError(f"cannot use file for downloading: {self}") diff --git a/client/src/telethon/_impl/client/types/message.py b/client/src/telethon/_impl/client/types/message.py index 30d52b28..bf60d3d0 100644 --- a/client/src/telethon/_impl/client/types/message.py +++ b/client/src/telethon/_impl/client/types/message.py @@ -1,7 +1,7 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING, Dict, Optional, Self +from typing import TYPE_CHECKING, Dict, Optional, Self, Union from ...tl import abcs, types from ..parsers import generate_html_message, generate_markdown_message @@ -109,7 +109,7 @@ class Message(metaclass=NoPublicConstructor): def _file(self) -> Optional[File]: return ( - File._try_from_raw_message_media(self._raw.media) + File._try_from_raw_message_media(self._client, self._raw.media) if isinstance(self._raw, types.Message) and self._raw.media else None ) @@ -147,13 +147,80 @@ class Message(metaclass=NoPublicConstructor): def file(self) -> Optional[File]: return self._file() - async def delete(self, *, revoke: bool = True) -> int: + @property + def replied_message_id(self) -> Optional[int]: + """ + Get the message identifier of the replied message. + + .. seealso:: + + :meth:`get_reply_message` + """ + if header := getattr(self._raw, "reply_to", None): + return getattr(header, "reply_to_msg_id", None) + + return None + + async def get_reply_message(self) -> Optional[Message]: + """ + Alias for :meth:`telethon.Client.get_messages_with_ids`. + + If all you want is to check whether this message is a reply, use :attr:`replied_message_id`. + """ + if self.replied_message_id is not None: + from ..client.messages import CherryPickedList + + lst = CherryPickedList(self._client, self.chat, []) + lst._ids.append(types.InputMessageReplyTo(id=self.id)) + return (await lst)[0] + return None + + async def respond( + self, + text: Optional[Union[str, Message]] = None, + *, + markdown: Optional[str] = None, + html: Optional[str] = None, + link_preview: bool = False, + ) -> Message: + """ + Alias for :meth:`telethon.Client.send_message`. + + See the documentation of :meth:`~telethon.Client.send_message` for an explanation of the parameters. + """ + return await self._client.send_message( + self.chat, text, markdown=markdown, html=html, link_preview=link_preview + ) + + async def reply( + self, + text: Optional[Union[str, Message]] = None, + *, + markdown: Optional[str] = None, + html: Optional[str] = None, + link_preview: bool = False, + ) -> Message: + """ + Alias for :meth:`telethon.Client.send_message` with the ``reply_to`` parameter set to this message. + + See the documentation of :meth:`~telethon.Client.send_message` for an explanation of the parameters. + """ + return await self._client.send_message( + self.chat, + text, + markdown=markdown, + html=html, + link_preview=link_preview, + reply_to=self.id, + ) + + async def delete(self, *, revoke: bool = True) -> None: """ Alias for :meth:`telethon.Client.delete_messages`. See the documentation of :meth:`~telethon.Client.delete_messages` for an explanation of the parameters. """ - return await self._client.delete_messages(self.chat, [self.id], revoke=revoke) + await self._client.delete_messages(self.chat, [self.id], revoke=revoke) async def edit( self, @@ -176,10 +243,92 @@ class Message(metaclass=NoPublicConstructor): link_preview=link_preview, ) - async def forward_to(self, target: ChatLike) -> Message: + async def forward(self, target: ChatLike) -> Message: """ Alias for :meth:`telethon.Client.forward_messages`. See the documentation of :meth:`~telethon.Client.forward_messages` for an explanation of the parameters. """ return (await self._client.forward_messages(target, [self.id], self.chat))[0] + + async def mark_read(self) -> None: + pass + + async def pin(self, *, notify: bool = False, pm_oneside: bool = False) -> None: + pass + + async def unpin(self) -> None: + pass + + # --- + + @property + def forward_info(self) -> None: + pass + + @property + def buttons(self) -> None: + pass + + @property + def web_preview(self) -> None: + pass + + @property + def voice(self) -> None: + pass + + @property + def video_note(self) -> None: + pass + + @property + def gif(self) -> None: + pass + + @property + def sticker(self) -> None: + pass + + @property + def contact(self) -> None: + pass + + @property + def game(self) -> None: + pass + + @property + def geo(self) -> None: + pass + + @property + def invoice(self) -> None: + pass + + @property + def poll(self) -> None: + pass + + @property + def venue(self) -> None: + pass + + @property + def dice(self) -> None: + pass + + @property + def via_bot(self) -> None: + pass + + @property + def silent(self) -> bool: + return getattr(self._raw, "silent", None) or False + + @property + def can_forward(self) -> bool: + if isinstance(self._raw, types.Message): + return not self._raw.noforwards + else: + return False diff --git a/client/src/telethon/_impl/session/storage/sqlite.py b/client/src/telethon/_impl/session/storage/sqlite.py index d4fe85c7..78aca875 100644 --- a/client/src/telethon/_impl/session/storage/sqlite.py +++ b/client/src/telethon/_impl/session/storage/sqlite.py @@ -134,7 +134,8 @@ class SqliteSession(Storage): ) if user := session.user: c.execute( - "insert into user values (?, ?, ?)", (user.id, user.dc, int(user.bot)) + "insert into user values (?, ?, ?, ?)", + (user.id, user.dc, int(user.bot), user.username), ) if state := session.state: c.execute( @@ -193,7 +194,8 @@ class SqliteSession(Storage): create table user( id integer primary key, dc integer not null, - bot integer not null + bot integer not null, + username text ); create table state( pts integer not null,