From 3853f98e5f9afd268b899118cc99899b57bc879e Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 9 Oct 2021 12:01:45 +0200 Subject: [PATCH] Begin work into making Message a viable way to send them --- telethon/types/_custom/inputfile.py | 170 +++++++++++++ telethon/types/_custom/inputmessage.py | 29 +++ telethon/types/_custom/message.py | 314 ++++++++++++++++++++++--- 3 files changed, 486 insertions(+), 27 deletions(-) create mode 100644 telethon/types/_custom/inputfile.py create mode 100644 telethon/types/_custom/inputmessage.py diff --git a/telethon/types/_custom/inputfile.py b/telethon/types/_custom/inputfile.py new file mode 100644 index 00000000..115e18a1 --- /dev/null +++ b/telethon/types/_custom/inputfile.py @@ -0,0 +1,170 @@ +import mimetypes +import os +import pathlib + +from ... import _tl + + +class InputFile: + def __init__( + self, + file = None, + *, + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, + file_size: int = None, + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, + supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + ttl: int = None, + ): + if isinstance(file, pathlib.Path): + if not file_name: + file_name = file.name + file = str(file.absolute()) + elif not file_name: + if isinstance(file, str): + file_name = os.path.basename(file) + else: + file_name = getattr(file, 'name', 'unnamed') + + if not mime_type: + mime_type = mimetypes.guess_type(file_name)[0] or 'application/octet-stream' + + mime_type = mime_type.lower() + + attributes = [_tl.DocumentAttributeFilename(file_name)] + + # TODO hachoir or tinytag or ffmpeg + if mime_type.startswith('image'): + if width is not None and height is not None: + attributes.append(_tl.DocumentAttributeImageSize( + w=width, + h=height, + )) + elif mime_type.startswith('audio'): + attributes.append(_tl.DocumentAttributeAudio( + duration=duration, + voice=voice_note, + title=title, + performer=performer, + waveform=waveform, + )) + elif mime_type.startswith('video'): + attributes.append(_tl.DocumentAttributeVideo( + duration=duration, + w=width, + h=height, + round_message=video_note, + supports_streaming=supports_streaming, + )) + + # mime_type: str = None, + # thumb: str = False, + # force_file: bool = False, + # file_size: int = None, + # ttl: int = None, + + self._file = file + self._attributes = attributes + + + # TODO rest + + is_image = utils.is_image(file) + if as_image is None: + as_image = is_image and not force_document + + # `aiofiles` do not base `io.IOBase` but do have `read`, so we + # just check for the read attribute to see if it's file-like. + if not isinstance(file, (str, bytes, _tl.InputFile, _tl.InputFileBig))\ + and not hasattr(file, 'read'): + # The user may pass a Message containing media (or the media, + # or anything similar) that should be treated as a file. Try + # getting the input media for whatever they passed and send it. + # + # We pass all attributes since these will be used if the user + # passed :tl:`InputFile`, and all information may be relevant. + try: + return (None, utils.get_input_media( + file, + is_photo=as_image, + attributes=attributes, + force_document=force_document, + voice_note=voice_note, + video_note=video_note, + supports_streaming=supports_streaming, + ttl=ttl + ), as_image) + except TypeError: + # Can't turn whatever was given into media + return None, None, as_image + + media = None + file_handle = None + + if isinstance(file, (_tl.InputFile, _tl.InputFileBig)): + file_handle = file + elif not isinstance(file, str) or os.path.isfile(file): + file_handle = await self.upload_file( + _resize_photo_if_needed(file, as_image), + file_size=file_size, + progress_callback=progress_callback + ) + elif re.match('https?://', file): + if as_image: + media = _tl.InputMediaPhotoExternal(file, ttl_seconds=ttl) + else: + media = _tl.InputMediaDocumentExternal(file, ttl_seconds=ttl) + + if media: + pass # Already have media, don't check the rest + elif not file_handle: + raise ValueError( + 'Failed to convert {} to media. Not an existing file or ' + 'HTTP URL'.format(file) + ) + elif as_image: + media = _tl.InputMediaUploadedPhoto(file_handle, ttl_seconds=ttl) + else: + attributes, mime_type = utils.get_attributes( + file, + mime_type=mime_type, + attributes=attributes, + force_document=force_document and not is_image, + voice_note=voice_note, + video_note=video_note, + supports_streaming=supports_streaming, + thumb=thumb + ) + + if not thumb: + thumb = None + else: + if isinstance(thumb, pathlib.Path): + thumb = str(thumb.absolute()) + thumb = await self.upload_file(thumb, file_size=file_size) + + media = _tl.InputMediaUploadedDocument( + file=file_handle, + mime_type=mime_type, + attributes=attributes, + thumb=thumb, + force_file=force_document and not is_image, + ttl_seconds=ttl + ) + return file_handle, media, as_image + + + + + + diff --git a/telethon/types/_custom/inputmessage.py b/telethon/types/_custom/inputmessage.py new file mode 100644 index 00000000..30b0b079 --- /dev/null +++ b/telethon/types/_custom/inputmessage.py @@ -0,0 +1,29 @@ + +class InputMessage: + __slots__ = ( + '_text', + '_link_preview', + '_silent', + '_reply_markup', + '_fmt_entities', + '_file', + ) + + def __init__( + self, + text, + *, + link_preview, + silent, + reply_markup, + fmt_entities, + file, + ): + self._text = text + self._link_preview = link_preview + self._silent = silent + self._reply_markup = reply_markup + self._fmt_entities = fmt_entities + self._file = file + + # oh! when this message is used, the file can be cached in here! if not inputfile upload and set inputfile diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index ef7cf734..7e9f0ba0 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -1,12 +1,20 @@ from typing import Optional, List, TYPE_CHECKING from datetime import datetime +import mimetypes from .chatgetter import ChatGetter from .sendergetter import SenderGetter from .messagebutton import MessageButton from .forward import Forward from .file import File +from .inputfile import InputFile +from .inputmessage import InputMessage +from .button import build_reply_markup from ..._misc import utils, tlobject -from ... import _tl +from ... import _tl, _misc + + +if TYPE_CHECKING: + from ..._misc import hints def _fwd(field, doc): @@ -23,13 +31,19 @@ def _fwd(field, doc): # Maybe parsing the init function alone if that's possible. class Message(ChatGetter, SenderGetter): """ - This custom class aggregates both :tl:`Message` and - :tl:`MessageService` to ease accessing their members. + Represents a :tl:`Message` (or :tl:`MessageService`) from the API. Remember that this class implements `ChatGetter ` and `SenderGetter ` which means you have access to all their sender and chat properties and methods. + + You can also create your own instance of this type to customize how a + message should be sent (rather than just plain text). For example, you + can create an instance with a text to be used for the caption of an audio + file with a certain performer, duration and thumbnail. However, most + properties and methods won't work (since messages you create have not yet + been sent). """ # region Forwarded properties @@ -150,7 +164,10 @@ class Message(ChatGetter, SenderGetter): @media.setter def media(self, value): - self._message.media = value + try: + self._message.media = value + except AttributeError: + pass reply_markup = _fwd('reply_markup', """ The reply markup for this message (which was sent @@ -211,12 +228,230 @@ class Message(ChatGetter, SenderGetter): # region Initialization - def __init__(self, client, message): + _default_parse_mode = None + _default_link_preview = True + + def __init__( + self, + text: str = None, + *, + # Formatting + markdown: str = None, + html: str = None, + formatting_entities: list = None, + link_preview: bool = (), + # Media + file: Optional[hints.FileLike] = None, + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, + file_size: int = None, + # Media attributes + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, + supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + # Additional parametrization + silent: bool = False, + buttons: list = None, + ttl: int = None, + ): + """ + The input parameters when creating a new message for sending are: + + :param text: The message text (also known as caption when including media). + This will be parsed according to the default parse mode, which can be changed with + ``set_default_parse_mode``. + + By default it's markdown if the ``markdown-it-py`` package is installed, or none otherwise. + Cannot be used in conjunction with ``text`` or ``html``. + + :param markdown: Sets the text, but forces the parse mode to be markdown. + Cannot be used in conjunction with ``text`` or ``html``. + + :param html: Sets the text, but forces the parse mode to be HTML. + Cannot be used in conjunction with ``text`` or ``markdown``. + + :param formatting_entities: Manually specifies the formatting entities. + Neither of ``text``, ``markdown`` or ``html`` will be processed. + + :param link_preview: Whether to include a link preview media in the message. + The default is to show it, but this can be changed with ``set_default_link_preview``. + Has no effect if the message contains other media (such as photos). + + :param file: Send a file. The library will automatically determine whether to send the + file as a photo or as a document based on the extension. You can force a specific type + by using ``photo`` or ``document`` instead. The file can be one of: + + * A local file path to an in-disk file. The file name will default to the path's base name. + + * A `bytes` byte array with the file's data to send (for example, by using + ``text.encode('utf-8')``). A default file name will be used. + + * A bytes `io.IOBase` stream over the file to send (for example, by using + ``open(file, 'rb')``). Its ``.name`` property will be used for the file name, or a + default if it doesn't have one. + + * An external URL to a file over the internet. This will send the file as "external" + media, and Telegram is the one that will fetch the media and send it. This means + the library won't download the file to send it first, but Telegram may fail to access + the media. The URL must start with either ``'http://'`` or ``https://``. + + * A handle to an existing file (for example, if you sent a message with media before, + you can use its ``message.media`` as a file here). + + * A :tl:`InputMedia` instance. For example, if you want to send a dice use + :tl:`InputMediaDice`, or if you want to send a contact use :tl:`InputMediaContact`. + + :param file_name: Forces a specific file name to be used, rather than an automatically + determined one. Has no effect with previously-sent media. + + :param mime_type: Sets a fixed mime type for the file, rather than having the library + guess it from the final file name. Useful when an URL does not contain an extension. + The mime-type will be used to determine which media attributes to include (for instance, + whether to send a video, an audio, or a photo). + + * For an image to contain an image size, you must specify width and height. + * For an audio, you must specify the duration. + * For a video, you must specify width, height and duration. + + :param thumb: A file to be used as the document's thumbnail. Only has effect on uploaded + documents. + + :param force_file: Forces whatever file was specified to be sent as a file. + Has no effect with previously-sent media. + + :param file_size: The size of the file to be uploaded if it needs to be uploaded, which + will be determined automatically if not specified. If the file size can't be determined + beforehand, the entire file will be read in-memory to find out how large it is. Telegram + requires the file size to be known before-hand (except for external media). + + :param duration: Specifies the duration, in seconds, of the audio or video file. Only has + effect on uploaded documents. + + :param width: Specifies the photo or video width, in pixels. Only has an effect on uploaded + documents. + + :param height: Specifies the photo or video height, in pixels. Only has an effect on + uploaded documents. + + :param title: Specifies the title of the song being sent. Only has effect on uploaded + documents. You must specify the audio duration. + + :param performer: Specifies the performer of the song being sent. Only has effect on + uploaded documents. You must specify the audio duration. + + :param supports_streaming: Whether the video has been recorded in such a way that it + supports streaming. Note that not all format can support streaming. Only has effect on + uploaded documents. You must specify the video duration, width and height. + + :param video_note: Whether the video should be a "video note" and render inside a circle. + Only has effect on uploaded documents. You must specify the video duration, width and + height. + + :param voice_note: Whether the audio should be a "voice note" and render with a waveform. + Only has effect on uploaded documents. You must specify the audio duration. + + :param waveform: The waveform. You must specify the audio duration. + + :param silent: Whether the message should notify people with sound or not. By default, a + notification with sound is sent unless the person has the chat muted). + + :param buttons: The matrix (list of lists), column list or button to be shown after + sending the message. This parameter will only work if you have signed in as a bot. + + :param schedule: If set, the message won't send immediately, and instead it will be + scheduled to be automatically sent at a later time. + + :param ttl: The Time-To-Live of the file (also known as "self-destruct timer" or + "self-destructing media"). If set, files can only be viewed for a short period of time + before they disappear from the message history automatically. + + The value must be at least 1 second, and at most 60 seconds, otherwise Telegram will + ignore this parameter. + + Not all types of media can be used with this parameter, such as text documents, which + will fail with ``TtlMediaInvalidError``. + """ + if (text and markdown) or (text and html) or (markdown and html): + raise ValueError('can only set one of: text, markdown, html') + + if formatting_entities: + text = text or markdown or html + elif text: + text, formatting_entities = self._default_parse_mode[0](text) + elif markdown: + text, formatting_entities = _misc.markdown.parse(markdown) + elif html: + text, formatting_entities = _misc.html.parse(html) + + reply_markup = build_reply_markup(buttons) if buttons else None + + if not text: + text = '' + if not formatting_entities: + formatting_entities = None + + if link_preview == (): + link_preview = self._default_link_preview + + if file: + file = InputFile( + file=file, + file_name=file_name, + mime_type=mime_type, + thumb=thumb, + force_file=force_file, + file_size=file_size, + duration=duration, + width=width, + height=height, + title=title, + performer=performer, + supports_streaming=supports_streaming, + video_note=video_note, + voice_note=voice_note, + waveform=waveform, + ) + + self._message = InputMessage( + text=text, + link_preview=link_preview, + silent=silent, + reply_markup=reply_markup, + fmt_entities=formatting_entities, + file=file, + ) + + @classmethod + def _new(cls, client, message, entities, input_chat): + self = cls.__new__(cls) + + sender_id = None + if isinstance(message, _tl.Message): + if message.from_id is not None: + sender_id = utils.get_peer_id(message.from_id) + if sender_id is None and message.peer_id and not isinstance(message, _tl.MessageEmpty): + # If the message comes from a Channel, let the sender be it + # ...or... + # incoming messages in private conversations no longer have from_id + # (layer 119+), but the sender can only be the chat we're in. + if message.post or (not message.out and isinstance(message.peer_id, _tl.PeerUser)): + sender_id = utils.get_peer_id(message.peer_id) + + # Note that these calls would reset the client + ChatGetter.__init__(self, self.peer_id, broadcast=self.post) + SenderGetter.__init__(self, sender_id) self._client = client self._message = message # Convenient storage for custom functions - self._client = None self._text = None self._file = None self._reply_message = None @@ -227,29 +462,8 @@ class Message(ChatGetter, SenderGetter): self._via_input_bot = None self._action_entities = None self._linked_chat = None - - sender_id = None - if self.from_id is not None: - sender_id = utils.get_peer_id(self.from_id) - elif self.peer_id: - # If the message comes from a Channel, let the sender be it - # ...or... - # incoming messages in private conversations no longer have from_id - # (layer 119+), but the sender can only be the chat we're in. - if self.post or (not self.out and isinstance(self.peer_id, _tl.PeerUser)): - sender_id = utils.get_peer_id(self.peer_id) - - # Note that these calls would reset the client - ChatGetter.__init__(self, self.peer_id, broadcast=self.post) - SenderGetter.__init__(self, sender_id) - self._forward = None - @classmethod - def _new(cls, client, message, entities, input_chat): - self = cls(client, message) - self._client = client - # Make messages sent to ourselves outgoing unless they're forwarded. # This makes it consistent with official client's appearance. if self.peer_id == _tl.PeerUser(client._session_state.user_id) and not self.fwd_from: @@ -296,6 +510,49 @@ class Message(ChatGetter, SenderGetter): return self + + @classmethod + def set_default_parse_mode(cls, mode): + """ + Change the default parse mode when creating messages. The ``mode`` can be: + + * ``None``, to disable parsing. + * A string equal to ``'md'`` or ``'markdown`` for parsing with commonmark, + ``'htm'`` or ``'html'`` for parsing HTML. + * A ``callable``, which accepts a ``str`` as input and returns a tuple of + ``(parsed str, formatting entities)``. + * A ``tuple`` of two ``callable``. The first must accept a ``str`` as input and return + a tuple of ``(parsed str, list of formatting entities)``. The second must accept two + parameters, a parsed ``str`` and a ``list`` of formatting entities, and must return + an "unparsed" ``str``. + + If it's not one of these values or types, the method fails accordingly. + """ + if isinstance(mode, str): + mode = mode.lower() + if mode in ('md', 'markdown'): + cls._default_parse_mode = (_misc.markdown.parse, _misc.markdown.unparse) + elif mode in ('htm', 'html'): + cls._default_parse_mode = (_misc.html.parse, _misc.html.unparse) + else: + raise ValueError(f'mode must be one of md, markdown, htm or html, but was {mode!r}') + elif callable(mode): + cls._default_parse_mode = (mode, lambda t, e: t) + elif isinstance(mode, tuple): + if len(mode) == 2 and callable(mode[0]) and callable(mode[1]): + cls._default_parse_mode = mode + else: + raise ValueError(f'mode must be a tuple of exactly two callables') + else: + raise TypeError(f'mode must be either a str, callable or tuple, but was {mode!r}') + + @classmethod + def set_default_link_preview(cls, enabled): + """ + Change the default value for link preview (either ``True`` or ``False``). + """ + cls._default_link_preview = enabled + # endregion Initialization # region Public Properties @@ -1121,3 +1378,6 @@ class Message(ChatGetter, SenderGetter): return None # endregion Private Methods + + +# TODO set md by default if commonmark is installed else nothing