From 4bd20f1ce2b56fb4e9b61058324fca416c8b313d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 9 Jun 2018 22:05:06 +0200 Subject: [PATCH] Separate file and message methods from TelegramClient --- telethon/client/files.py | 630 +++++++++++++++ telethon/client/messages.py | 650 ++++++++++++++++ telethon/client/telegramclient.py | 1206 ----------------------------- telethon/utils.py | 21 + 4 files changed, 1301 insertions(+), 1206 deletions(-) create mode 100644 telethon/client/files.py create mode 100644 telethon/client/messages.py diff --git a/telethon/client/files.py b/telethon/client/files.py new file mode 100644 index 00000000..974c4703 --- /dev/null +++ b/telethon/client/files.py @@ -0,0 +1,630 @@ +import hashlib +import io +import itertools +import logging +import os +import re +import warnings +from io import BytesIO +from mimetypes import guess_type + +from .users import UserMethods +from .. import utils, helpers +from ..extensions import markdown, html +from ..tl import types, functions, custom + +try: + import hachoir +except ImportError: + hachoir = None + +__log__ = logging.getLogger(__name__) + + +class FileMethods(UserMethods): + + # region Public methods + + async def send_file( + self, entity, file, caption='', force_document=False, + progress_callback=None, reply_to=None, attributes=None, + thumb=None, allow_cache=True, parse_mode=utils.Default, + voice_note=False, video_note=False, **kwargs): + """ + Sends a file to the specified entity. + + Args: + entity (`entity`): + Who will receive the file. + + file (`str` | `bytes` | `file` | `media`): + The path of the file, byte array, or stream that will be sent. + Note that if a byte array or a stream is given, a filename + or its type won't be inferred, and it will be sent as an + "unnamed application/octet-stream". + + Furthermore the file may be any media (a message, document, + photo or similar) so that it can be resent without the need + to download and re-upload it again. + + If a list or similar is provided, the files in it will be + sent as an album in the order in which they appear, sliced + in chunks of 10 if more than 10 are given. + + caption (`str`, optional): + Optional caption for the sent media message. + + force_document (`bool`, optional): + If left to ``False`` and the file is a path that ends with + the extension of an image file or a video file, it will be + sent as such. Otherwise always as a document. + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(sent bytes, total)``. + + reply_to (`int` | :tl:`Message`): + Same as `reply_to` from `send_message`. + + attributes (`list`, optional): + Optional attributes that override the inferred ones, like + :tl:`DocumentAttributeFilename` and so on. + + thumb (`str` | `bytes` | `file`, optional): + Optional thumbnail (for videos). + + allow_cache (`bool`, optional): + Whether to allow using the cached version stored in the + database or not. Defaults to ``True`` to avoid re-uploads. + Must be ``False`` if you wish to use different attributes + or thumb than those that were used when the file was cached. + + parse_mode (`object`, optional): + See the `TelegramClient.parse_mode` property for allowed + values. Markdown parsing will be used by default. + + voice_note (`bool`, optional): + If ``True`` the audio will be sent as a voice note. + + Set `allow_cache` to ``False`` if you sent the same file + without this setting before for it to work. + + video_note (`bool`, optional): + If ``True`` the video will be sent as a video note, + also known as a round video message. + + Set `allow_cache` to ``False`` if you sent the same file + without this setting before for it to work. + + Notes: + If the ``hachoir3`` package (``hachoir`` module) is installed, + it will be used to determine metadata from audio and video files. + + Returns: + The `telethon.tl.custom.message.Message` (or messages) containing + the sent file, or messages if a list of them was passed. + """ + # First check if the user passed an iterable, in which case + # we may want to send as an album if all are photo files. + if utils.is_list_like(file): + # TODO Fix progress_callback + images = [] + if force_document: + documents = file + else: + documents = [] + for x in file: + if utils.is_image(x): + images.append(x) + else: + documents.append(x) + + result = [] + while images: + result += await self._send_album( + entity, images[:10], caption=caption, + progress_callback=progress_callback, reply_to=reply_to, + parse_mode=parse_mode + ) + images = images[10:] + + result.extend( + await self.send_file( + entity, x, allow_cache=allow_cache, + caption=caption, force_document=force_document, + progress_callback=progress_callback, reply_to=reply_to, + attributes=attributes, thumb=thumb, voice_note=voice_note, + video_note=video_note, **kwargs + ) for x in documents + ) + return result + + entity = await self.get_input_entity(entity) + reply_to = utils.get_message_id(reply_to) + + # Not document since it's subject to change. + # Needed when a Message is passed to send_message and it has media. + if 'entities' in kwargs: + msg_entities = kwargs['entities'] + else: + caption, msg_entities =\ + await self._parse_message_text(caption, parse_mode) + + file_handle, media = await self._file_to_media( + file, allow_cache=allow_cache) + + request = functions.messages.SendMediaRequest( + entity, media, reply_to_msg_id=reply_to, message=caption, + entities=msg_entities + ) + msg = self._get_response_message(request, await self(request), entity) + self._cache_media(msg, file, file_handle, force_document=force_document) + + return msg + + async def send_voice_note(self, *args, **kwargs): + """Deprecated, see :meth:`send_file`.""" + warnings.warn('send_voice_note is deprecated, use ' + 'send_file(..., voice_note=True) instead') + kwargs['is_voice_note'] = True + return await self.send_file(*args, **kwargs) + + async def _send_album(self, entity, files, caption='', + progress_callback=None, reply_to=None, + parse_mode=utils.Default): + """Specialized version of .send_file for albums""" + # We don't care if the user wants to avoid cache, we will use it + # anyway. Why? The cached version will be exactly the same thing + # we need to produce right now to send albums (uploadMedia), and + # cache only makes a difference for documents where the user may + # want the attributes used on them to change. + # + # In theory documents can be sent inside the albums but they appear + # as different messages (not inside the album), and the logic to set + # the attributes/avoid cache is already written in .send_file(). + entity = await self.get_input_entity(entity) + if not utils.is_list_like(caption): + caption = (caption,) + captions = [ + await self._parse_message_text(caption or '', parse_mode) + for caption in reversed(caption) # Pop from the end (so reverse) + ] + reply_to = utils.get_message_id(reply_to) + + # Need to upload the media first, but only if they're not cached yet + media = [] + for file in files: + # fh will either be InputPhoto or a modified InputFile + fh = await self.upload_file(file, use_cache=types.InputPhoto) + if not isinstance(fh, types.InputPhoto): + r = await self(functions.messages.UploadMediaRequest( + entity, media=types.InputMediaUploadedPhoto(fh) + )) + input_photo = utils.get_input_photo(r.photo) + self.session.cache_file(fh.md5, fh.size, input_photo) + fh = input_photo + + if captions: + caption, msg_entities = captions.pop() + else: + caption, msg_entities = '', None + media.append(types.InputSingleMedia(types.InputMediaPhoto(fh), message=caption, + entities=msg_entities)) + + # Now we can construct the multi-media request + result = await self(functions.messages.SendMultiMediaRequest( + entity, reply_to_msg_id=reply_to, multi_media=media + )) + return [ + self._get_response_message(update.id, result, entity) + for update in result.updates + if isinstance(update, types.UpdateMessageID) + ] + + async def upload_file( + self, file, part_size_kb=None, file_name=None, use_cache=None, + progress_callback=None): + """ + Uploads the specified file and returns a handle (an instance of + :tl:`InputFile` or :tl:`InputFileBig`, as required) which can be + later used before it expires (they are usable during less than a day). + + Uploading a file will simply return a "handle" to the file stored + remotely in the Telegram servers, which can be later used on. This + will **not** upload the file to your own chat or any chat at all. + + Args: + file (`str` | `bytes` | `file`): + The path of the file, byte array, or stream that will be sent. + Note that if a byte array or a stream is given, a filename + or its type won't be inferred, and it will be sent as an + "unnamed application/octet-stream". + + part_size_kb (`int`, optional): + Chunk size when uploading files. The larger, the less + requests will be made (up to 512KB maximum). + + file_name (`str`, optional): + The file name which will be used on the resulting InputFile. + If not specified, the name will be taken from the ``file`` + and if this is not a ``str``, it will be ``"unnamed"``. + + use_cache (`type`, optional): + The type of cache to use (currently either :tl:`InputDocument` + or :tl:`InputPhoto`). If present and the file is small enough + to need the MD5, it will be checked against the database, + and if a match is found, the upload won't be made. Instead, + an instance of type ``use_cache`` will be returned. + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(sent bytes, total)``. + + Returns: + :tl:`InputFileBig` if the file size is larger than 10MB, + `telethon.tl.custom.input_sized_file.InputSizedFile` + (subclass of :tl:`InputFile`) otherwise. + """ + if isinstance(file, (types.InputFile, types.InputFileBig)): + return file # Already uploaded + + if isinstance(file, str): + file_size = os.path.getsize(file) + elif isinstance(file, bytes): + file_size = len(file) + else: + file = file.read() + file_size = len(file) + + # File will now either be a string or bytes + if not part_size_kb: + part_size_kb = utils.get_appropriated_part_size(file_size) + + if part_size_kb > 512: + raise ValueError('The part size must be less or equal to 512KB') + + part_size = int(part_size_kb * 1024) + if part_size % 1024 != 0: + raise ValueError( + 'The part size must be evenly divisible by 1024') + + # Set a default file name if None was specified + file_id = helpers.generate_random_long() + if not file_name: + if isinstance(file, str): + file_name = os.path.basename(file) + else: + file_name = str(file_id) + + # Determine whether the file is too big (over 10MB) or not + # Telegram does make a distinction between smaller or larger files + is_large = file_size > 10 * 1024 * 1024 + hash_md5 = hashlib.md5() + if not is_large: + # Calculate the MD5 hash before anything else. + # As this needs to be done always for small files, + # might as well do it before anything else and + # check the cache. + if isinstance(file, str): + with open(file, 'rb') as stream: + file = stream.read() + hash_md5.update(file) + if use_cache: + cached = self.session.get_file( + hash_md5.digest(), file_size, cls=use_cache + ) + if cached: + return cached + + part_count = (file_size + part_size - 1) // part_size + __log__.info('Uploading file of %d bytes in %d chunks of %d', + file_size, part_count, part_size) + + with open(file, 'rb') if isinstance(file, str) else BytesIO(file)\ + as stream: + for part_index in range(part_count): + # Read the file by in chunks of size part_size + part = stream.read(part_size) + + # The SavePartRequest is different depending on whether + # the file is too large or not (over or less than 10MB) + if is_large: + request = functions.upload.SaveBigFilePartRequest( + file_id, part_index, part_count, part) + else: + request = functions.upload.SaveFilePartRequest( + file_id, part_index, part) + + result = await self(request) + if result: + __log__.debug('Uploaded %d/%d', part_index + 1, + part_count) + if progress_callback: + progress_callback(stream.tell(), file_size) + else: + raise RuntimeError( + 'Failed to upload file part {}.'.format(part_index)) + + if is_large: + return types.InputFileBig(file_id, part_count, file_name) + else: + return custom.InputSizedFile( + file_id, part_count, file_name, md5=hash_md5, size=file_size + ) + + # endregion + + # region Private methods + + def _get_response_message(self, request, result, input_chat): + """ + Extracts the response message known a request and Update result. + The request may also be the ID of the message to match. + """ + # Telegram seems to send updateMessageID first, then updateNewMessage, + # however let's not rely on that just in case. + if isinstance(request, int): + msg_id = request + else: + msg_id = None + for update in result.updates: + if isinstance(update, types.UpdateMessageID): + if update.random_id == request.random_id: + msg_id = update.id + break + + if isinstance(result, types.UpdateShort): + updates = [result.update] + entities = {} + elif isinstance(result, (types.Updates, types.UpdatesCombined)): + updates = result.updates + entities = {utils.get_peer_id(x): x + for x in + itertools.chain(result.users, result.chats)} + else: + return + + found = None + for update in updates: + if isinstance(update, ( + types.UpdateNewChannelMessage, + types.UpdateNewMessage)): + if update.message.id == msg_id: + found = update.message + break + + elif (isinstance(update, types.UpdateEditMessage) and + not isinstance(request.peer, + types.InputPeerChannel)): + if request.id == update.message.id: + found = update.message + break + + elif (isinstance(update, types.UpdateEditChannelMessage) and + utils.get_peer_id(request.peer) == + utils.get_peer_id(update.message.to_id)): + if request.id == update.message.id: + found = update.message + break + + if found: + return custom.Message(self, found, entities, input_chat) + + @property + def parse_mode(self): + """ + This property is the default parse mode used when sending messages. + Defaults to `telethon.extensions.markdown`. It will always + be either ``None`` or an object with ``parse`` and ``unparse`` + methods. + + When setting a different value it should be one of: + + * Object with ``parse`` and ``unparse`` methods. + * A ``callable`` to act as the parse method. + * A ``str`` indicating the ``parse_mode``. For Markdown ``'md'`` + or ``'markdown'`` may be used. For HTML, ``'htm'`` or ``'html'`` + may be used. + + The ``parse`` method should be a function accepting a single + parameter, the text to parse, and returning a tuple consisting + of ``(parsed message str, [MessageEntity instances])``. + + The ``unparse`` method should be the inverse of ``parse`` such + that ``assert text == unparse(*parse(text))``. + + See :tl:`MessageEntity` for allowed message entities. + """ + return self._parse_mode + + @parse_mode.setter + def parse_mode(self, mode): + self._parse_mode = self._sanitize_parse_mode(mode) + + @staticmethod + def _sanitize_parse_mode(mode): + if not mode: + return None + + if callable(mode): + class CustomMode: + @staticmethod + def unparse(text, entities): + raise NotImplementedError + + CustomMode.parse = mode + return CustomMode + elif (all(hasattr(mode, x) for x in ('parse', 'unparse')) + and all(callable(x) for x in (mode.parse, mode.unparse))): + return mode + elif isinstance(mode, str): + try: + return { + 'md': markdown, + 'markdown': markdown, + 'htm': html, + 'html': html + }[mode.lower()] + except KeyError: + raise ValueError('Unknown parse mode {}'.format(mode)) + else: + raise TypeError('Invalid parse mode type {}'.format(mode)) + + async def _parse_message_text(self, message, parse_mode): + """ + Returns a (parsed message, entities) tuple depending on ``parse_mode``. + """ + if parse_mode == utils.Default: + parse_mode = self._parse_mode + else: + parse_mode = self._sanitize_parse_mode(parse_mode) + + if not parse_mode: + return message, [] + + message, msg_entities = parse_mode.parse(message) + for i, e in enumerate(msg_entities): + if isinstance(e, types.MessageEntityTextUrl): + m = re.match(r'^@|\+|tg://user\?id=(\d+)', e.url) + if m: + try: + msg_entities[i] = types.InputMessageEntityMentionName( + e.offset, e.length, await self.get_input_entity( + int(m.group(1)) if m.group(1) else e.url + ) + ) + except (ValueError, TypeError): + # Make no replacement + pass + + return message, msg_entities + + async def _file_to_media( + self, file, force_document=False, + progress_callback=None, attributes=None, thumb=None, + allow_cache=True, voice_note=False, video_note=False): + if not file: + return None, None + + if not isinstance(file, (str, bytes, io.IOBase)): + # 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. + try: + return None, utils.get_input_media(file) + except TypeError: + return None, None # Can't turn whatever was given into media + + as_image = utils.is_image(file) and not force_document + use_cache = types.InputPhoto if as_image else types.InputDocument + file_handle = await self.upload_file( + file, progress_callback=progress_callback, + use_cache=use_cache if allow_cache else None + ) + + if isinstance(file_handle, use_cache): + # File was cached, so an instance of use_cache was returned + if as_image: + media = types.InputMediaPhoto(file_handle) + else: + media = types.InputMediaDocument(file_handle) + elif as_image: + media = types.InputMediaUploadedPhoto(file_handle) + else: + mime_type = None + if isinstance(file, str): + # Determine mime-type and attributes + # Take the first element by using [0] since it returns a tuple + mime_type = guess_type(file)[0] + attr_dict = { + types.DocumentAttributeFilename: + types.DocumentAttributeFilename( + os.path.basename(file)) + } + if utils.is_audio(file) and hachoir: + m = hachoir.metadata.extractMetadata( + hachoir.parser.createParser(file) + ) + attr_dict[types.DocumentAttributeAudio] = \ + types.DocumentAttributeAudio( + voice=voice_note, + title=m.get('title') if m.has( + 'title') else None, + performer=m.get('author') if m.has( + 'author') else None, + duration=int(m.get('duration').seconds + if m.has('duration') else 0) + ) + + if not force_document and utils.is_video(file): + if hachoir: + m = hachoir.metadata.extractMetadata( + hachoir.parser.createParser(file) + ) + doc = types.DocumentAttributeVideo( + round_message=video_note, + w=m.get('width') if m.has('width') else 0, + h=m.get('height') if m.has('height') else 0, + duration=int(m.get('duration').seconds + if m.has('duration') else 0) + ) + else: + doc = types.DocumentAttributeVideo( + 0, 1, 1, round_message=video_note) + + attr_dict[types.DocumentAttributeVideo] = doc + else: + attr_dict = { + types.DocumentAttributeFilename: + types.DocumentAttributeFilename( + os.path.basename( + getattr(file, 'name', + None) or 'unnamed')) + } + + if voice_note: + if types.DocumentAttributeAudio in attr_dict: + attr_dict[types.DocumentAttributeAudio].voice = True + else: + attr_dict[types.DocumentAttributeAudio] = \ + types.DocumentAttributeAudio(0, voice=True) + + # Now override the attributes if any. As we have a dict of + # {cls: instance}, we can override any class with the list + # of attributes provided by the user easily. + if attributes: + for a in attributes: + attr_dict[type(a)] = a + + # Ensure we have a mime type, any; but it cannot be None + # 'The "octet-stream" subtype is used to indicate that a body + # contains arbitrary binary data.' + if not mime_type: + mime_type = 'application/octet-stream' + + input_kw = {} + if thumb: + input_kw['thumb'] = await self.upload_file(thumb) + + media = types.InputMediaUploadedDocument( + file=file_handle, + mime_type=mime_type, + attributes=list(attr_dict.values()), + **input_kw + ) + return file_handle, media + + def _cache_media(self, msg, file, file_handle, + force_document=False): + if file and msg and isinstance(file_handle, + custom.InputSizedFile): + # There was a response message and we didn't use cached + # version, so cache whatever we just sent to the database. + md5, size = file_handle.md5, file_handle.size + if utils.is_image(file) and not force_document: + to_cache = utils.get_input_photo(msg.media.photo) + else: + to_cache = utils.get_input_document(msg.media.document) + self.session.cache_file(md5, size, to_cache) + + # endregion diff --git a/telethon/client/messages.py b/telethon/client/messages.py new file mode 100644 index 00000000..1bb47878 --- /dev/null +++ b/telethon/client/messages.py @@ -0,0 +1,650 @@ +import asyncio +import itertools +import logging +import time +import warnings +from collections import UserList + +from .files import FileMethods +from .. import utils +from ..tl import types, functions, custom + +__log__ = logging.getLogger(__name__) + + +class MessageMethods(FileMethods): + + # region Public methods + + # region Message retrieval + + async def iter_messages( + self, entity, limit=None, offset_date=None, offset_id=0, + max_id=0, min_id=0, add_offset=0, search=None, filter=None, + from_user=None, batch_size=100, wait_time=None, ids=None, + _total=None): + """ + Iterator over the message history for the specified entity. + + If either `search`, `filter` or `from_user` are provided, + :tl:`messages.Search` will be used instead of :tl:`messages.getHistory`. + + Args: + entity (`entity`): + The entity from whom to retrieve the message history. + + limit (`int` | `None`, optional): + Number of messages to be retrieved. Due to limitations with + the API retrieving more than 3000 messages will take longer + than half a minute (or even more based on previous calls). + The limit may also be ``None``, which would eventually return + the whole history. + + offset_date (`datetime`): + Offset date (messages *previous* to this date will be + retrieved). Exclusive. + + offset_id (`int`): + Offset message ID (only messages *previous* to the given + ID will be retrieved). Exclusive. + + max_id (`int`): + All the messages with a higher (newer) ID or equal to this will + be excluded. + + min_id (`int`): + All the messages with a lower (older) ID or equal to this will + be excluded. + + add_offset (`int`): + Additional message offset (all of the specified offsets + + this offset = older messages). + + search (`str`): + The string to be used as a search query. + + filter (:tl:`MessagesFilter` | `type`): + The filter to use when returning messages. For instance, + :tl:`InputMessagesFilterPhotos` would yield only messages + containing photos. + + from_user (`entity`): + Only messages from this user will be returned. + + batch_size (`int`): + Messages will be returned in chunks of this size (100 is + the maximum). While it makes no sense to modify this value, + you are still free to do so. + + wait_time (`int`): + Wait time between different :tl:`GetHistoryRequest`. Use this + parameter to avoid hitting the ``FloodWaitError`` as needed. + If left to ``None``, it will default to 1 second only if + the limit is higher than 3000. + + ids (`int`, `list`): + A single integer ID (or several IDs) for the message that + should be returned. This parameter takes precedence over + the rest (which will be ignored if this is set). This can + for instance be used to get the message with ID 123 from + a channel. Note that if the message doesn't exist, ``None`` + will appear in its place, so that zipping the list of IDs + with the messages can match one-to-one. + + _total (`list`, optional): + A single-item list to pass the total parameter by reference. + + Yields: + Instances of `telethon.tl.custom.message.Message`. + + Notes: + Telegram's flood wait limit for :tl:`GetHistoryRequest` seems to + be around 30 seconds per 3000 messages, therefore a sleep of 1 + second is the default for this limit (or above). You may need + an higher limit, so you're free to set the ``batch_size`` that + you think may be good. + """ + # It's possible to get messages by ID without their entity, so only + # fetch the input version if we're not using IDs or if it was given. + if not ids or entity: + entity = await self.get_input_entity(entity) + + if ids: + if not utils.is_list_like(ids): + ids = (ids,) + async for x in self._iter_ids(entity, ids, total=_total): + yield x + return + + # Telegram doesn't like min_id/max_id. If these IDs are low enough + # (starting from last_id - 100), the request will return nothing. + # + # We can emulate their behaviour locally by setting offset = max_id + # and simply stopping once we hit a message with ID <= min_id. + offset_id = max(offset_id, max_id) + if offset_id and min_id: + if offset_id - min_id <= 1: + return + + limit = float('inf') if limit is None else int(limit) + if search is not None or filter or from_user: + if filter is None: + filter = types.InputMessagesFilterEmpty() + request = functions.messages.SearchRequest( + peer=entity, + q=search or '', + filter=filter() if isinstance(filter, type) else filter, + min_date=None, + max_date=offset_date, + offset_id=offset_id, + add_offset=add_offset, + limit=1, + max_id=0, + min_id=0, + hash=0, + from_id=( + await self.get_input_entity(from_user) + if from_user else None + ) + ) + else: + request = functions.messages.GetHistoryRequest( + peer=entity, + limit=1, + offset_date=offset_date, + offset_id=offset_id, + min_id=0, + max_id=0, + add_offset=add_offset, + hash=0 + ) + + if limit == 0: + if not _total: + return + # No messages, but we still need to know the total message count + result = await self(request) + if isinstance(result, types.messages.MessagesNotModified): + _total[0] = result.count + else: + _total[0] = getattr(result, 'count', len(result.messages)) + return + + if wait_time is None: + wait_time = 1 if limit > 3000 else 0 + + have = 0 + last_id = float('inf') + batch_size = min(max(batch_size, 1), 100) + while have < limit: + start = asyncio.get_event_loop().time() + # Telegram has a hard limit of 100 + request.limit = min(limit - have, batch_size) + r = await self(request) + if _total: + _total[0] = getattr(r, 'count', len(r.messages)) + + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats)} + + for message in r.messages: + if message.id <= min_id: + return + + if isinstance(message, types.MessageEmpty)\ + or message.id >= last_id: + continue + + # There has been reports that on bad connections this method + # was returning duplicated IDs sometimes. Using ``last_id`` + # is an attempt to avoid these duplicates, since the message + # IDs are returned in descending order. + last_id = message.id + + yield custom.Message(self, message, entities, entity) + have += 1 + + if len(r.messages) < request.limit: + break + + request.offset_id = r.messages[-1].id + if isinstance(request, functions.messages.GetHistoryRequest): + request.offset_date = r.messages[-1].date + else: + request.max_date = r.messages[-1].date + + time.sleep(max(wait_time - (time.time() - start), 0)) + + async def get_messages(self, *args, **kwargs): + """ + Same as :meth:`iter_messages`, but returns a list instead + with an additional ``.total`` attribute on the list. + + If the `limit` is not set, it will be 1 by default unless both + `min_id` **and** `max_id` are set (as *named* arguments), in + which case the entire range will be returned. + + This is so because any integer limit would be rather arbitrary and + it's common to only want to fetch one message, but if a range is + specified it makes sense that it should return the entirety of it. + + If `ids` is present in the *named* arguments and is not a list, + a single :tl:`Message` will be returned for convenience instead + of a list. + """ + total = [0] + kwargs['_total'] = total + if len(args) == 1 and 'limit' not in kwargs: + if 'min_id' in kwargs and 'max_id' in kwargs: + kwargs['limit'] = None + else: + kwargs['limit'] = 1 + + msgs = UserList(x async for x in self.iter_messages(*args, **kwargs)) + msgs.total = total[0] + if 'ids' in kwargs and not utils.is_list_like(kwargs['ids']): + return msgs[0] + + return msgs + + async def get_message_history(self, *args, **kwargs): + """Deprecated, see :meth:`get_messages`.""" + warnings.warn( + 'get_message_history is deprecated, use get_messages instead' + ) + return await self.get_messages(*args, **kwargs) + + # endregion + + # region Message sending/editing/deleting + + async def send_message( + self, entity, message='', reply_to=None, + parse_mode=utils.Default, link_preview=True, file=None, + force_document=False, clear_draft=False): + """ + Sends the given message to the specified entity (user/chat/channel). + + The default parse mode is the same as the official applications + (a custom flavour of markdown). ``**bold**, `code` or __italic__`` + are available. In addition you can send ``[links](https://example.com)`` + and ``[mentions](@username)`` (or using IDs like in the Bot API: + ``[mention](tg://user?id=123456789)``) and ``pre`` blocks with three + backticks. + + Sending a ``/start`` command with a parameter (like ``?start=data``) + is also done through this method. Simply send ``'/start data'`` to + the bot. + + Args: + entity (`entity`): + To who will it be sent. + + message (`str` | :tl:`Message`): + The message to be sent, or another message object to resend. + + The maximum length for a message is 35,000 bytes or 4,096 + characters. Longer messages will not be sliced automatically, + and you should slice them manually if the text to send is + longer than said length. + + reply_to (`int` | :tl:`Message`, optional): + Whether to reply to a message or not. If an integer is provided, + it should be the ID of the message that it should reply to. + + parse_mode (`object`, optional): + See the `TelegramClient.parse_mode` property for allowed + values. Markdown parsing will be used by default. + + link_preview (`bool`, optional): + Should the link preview be shown? + + file (`file`, optional): + Sends a message with a file attached (e.g. a photo, + video, audio or document). The ``message`` may be empty. + + force_document (`bool`, optional): + Whether to send the given file as a document or not. + + clear_draft (`bool`, optional): + Whether the existing draft should be cleared or not. + Has no effect when sending a file. + + Returns: + The sent `telethon.tl.custom.message.Message`. + """ + if file is not None: + return await self.send_file( + entity, file, caption=message, reply_to=reply_to, + parse_mode=parse_mode, force_document=force_document + ) + elif not message: + raise ValueError( + 'The message cannot be empty unless a file is provided' + ) + + entity = await self.get_input_entity(entity) + if isinstance(message, types.Message): + if (message.media and not isinstance( + message.media, types.MessageMediaWebPage)): + return await self.send_file( + entity, message.media, caption=message.message, + entities=message.entities + ) + + if reply_to is not None: + reply_id = utils.get_message_id(reply_to) + elif utils.get_peer_id(entity) == utils.get_peer_id(message.to_id): + reply_id = message.reply_to_msg_id + else: + reply_id = None + request = functions.messages.SendMessageRequest( + peer=entity, + message=message.message or '', + silent=message.silent, + reply_to_msg_id=reply_id, + reply_markup=message.reply_markup, + entities=message.entities, + clear_draft=clear_draft, + no_webpage=not isinstance( + message.media, types.MessageMediaWebPage) + ) + message = message.message + else: + message, msg_ent = await self._parse_message_text(message, + parse_mode) + request = functions.messages.SendMessageRequest( + peer=entity, + message=message, + entities=msg_ent, + no_webpage=not link_preview, + reply_to_msg_id=utils.get_message_id(reply_to), + clear_draft=clear_draft + ) + + result = await self(request) + if isinstance(result, types.UpdateShortSentMessage): + to_id, cls = utils.resolve_id(utils.get_peer_id(entity)) + return custom.Message(self, types.Message( + id=result.id, + to_id=cls(to_id), + message=message, + date=result.date, + out=result.out, + media=result.media, + entities=result.entities + ), {}, input_chat=entity) + + return self._get_response_message(request, result, entity) + + async def forward_messages(self, entity, messages, from_peer=None): + """ + Forwards the given message(s) to the specified entity. + + Args: + entity (`entity`): + To which entity the message(s) will be forwarded. + + messages (`list` | `int` | :tl:`Message`): + The message(s) to forward, or their integer IDs. + + from_peer (`entity`): + If the given messages are integer IDs and not instances + of the ``Message`` class, this *must* be specified in + order for the forward to work. + + Returns: + The list of forwarded `telethon.tl.custom.message.Message`, + or a single one if a list wasn't provided as input. + """ + single = not utils.is_list_like(messages) + if single: + messages = (messages,) + + if not from_peer: + try: + # On private chats (to_id = PeerUser), if the message is + # not outgoing, we actually need to use "from_id" to get + # the conversation on which the message was sent. + from_peer = next( + m.from_id + if not m.out and isinstance(m.to_id, types.PeerUser) + else m.to_id for m in messages + if isinstance(m, types.Message) + ) + except StopIteration: + raise ValueError( + 'from_chat must be given if integer IDs are used' + ) + + req = functions.messages.ForwardMessagesRequest( + from_peer=from_peer, + id=[m if isinstance(m, int) else m.id for m in messages], + to_peer=entity + ) + result = await self(req) + if isinstance(result, (types.Updates, types.UpdatesCombined)): + entities = {utils.get_peer_id(x): x + for x in itertools.chain(result.users, result.chats)} + else: + entities = {} + + random_to_id = {} + id_to_message = {} + for update in result.updates: + if isinstance(update, types.UpdateMessageID): + random_to_id[update.random_id] = update.id + elif isinstance(update, ( + types.UpdateNewMessage, types.UpdateNewChannelMessage)): + id_to_message[update.message.id] = custom.Message( + self, update.message, entities, input_chat=entity) + + result = [id_to_message[random_to_id[rnd]] for rnd in req.random_id] + return result[0] if single else result + + async def edit_message( + self, entity, message=None, text=None, parse_mode=utils.Default, + link_preview=True, file=None): + """ + Edits the given message ID (to change its contents or disable preview). + + Args: + entity (`entity` | :tl:`Message`): + From which chat to edit the message. This can also be + the message to be edited, and the entity will be inferred + from it, so the next parameter will be assumed to be the + message text. + + message (`int` | :tl:`Message` | `str`): + The ID of the message (or :tl:`Message` itself) to be edited. + If the `entity` was a :tl:`Message`, then this message will be + treated as the new text. + + text (`str`, optional): + The new text of the message. Does nothing if the `entity` + was a :tl:`Message`. + + parse_mode (`object`, optional): + See the `TelegramClient.parse_mode` property for allowed + values. Markdown parsing will be used by default. + + link_preview (`bool`, optional): + Should the link preview be shown? + + file (`str` | `bytes` | `file` | `media`, optional): + The file object that should replace the existing media + in the message. + + Examples: + + >>> client = ... + >>> message = client.send_message('username', 'hello') + >>> + >>> client.edit_message('username', message, 'hello!') + >>> # or + >>> client.edit_message('username', message.id, 'Hello') + >>> # or + >>> client.edit_message(message, 'Hello!') + + Raises: + ``MessageAuthorRequiredError`` if you're not the author of the + message but tried editing it anyway. + + ``MessageNotModifiedError`` if the contents of the message were + not modified at all. + + Returns: + The edited `telethon.tl.custom.message.Message`. + """ + if isinstance(entity, types.Message): + text = message # Shift the parameters to the right + message = entity + entity = entity.to_id + + entity = await self.get_input_entity(entity) + text, msg_entities = await self._parse_message_text(text, parse_mode) + file_handle, media = await self._file_to_media(file) + request = functions.messages.EditMessageRequest( + peer=entity, + id=utils.get_message_id(message), + message=text, + no_webpage=not link_preview, + entities=msg_entities, + media=media + ) + msg = self._get_response_message(request, self(request), entity) + self._cache_media(msg, file, file_handle) + return msg + + async def delete_messages(self, entity, message_ids, revoke=True): + """ + Deletes a message from a chat, optionally "for everyone". + + Args: + entity (`entity`): + From who the message will be deleted. This can actually + be ``None`` for normal chats, but **must** be present + for channels and megagroups. + + message_ids (`list` | `int` | :tl:`Message`): + The IDs (or ID) or messages to be deleted. + + revoke (`bool`, optional): + Whether the message should be deleted for everyone or not. + By default it has the opposite behaviour of official clients, + and it will delete the message for everyone. + This has no effect on channels or megagroups. + + Returns: + A list of :tl:`AffectedMessages`, each item being the result + for the delete calls of the messages in chunks of 100 each. + """ + if not utils.is_list_like(message_ids): + message_ids = (message_ids,) + + message_ids = ( + m.id if isinstance(m, ( + types.Message, types.MessageService, types.MessageEmpty)) + else int(m) for m in message_ids + ) + + entity = await self.get_input_entity(entity) if entity else None + if isinstance(entity, types.InputPeerChannel): + return await self([functions.channels.DeleteMessagesRequest( + entity, list(c)) for c in utils.chunks(message_ids)]) + else: + return await self([functions.messages.DeleteMessagesRequest( + list(c), revoke) for c in utils.chunks(message_ids)]) + + # endregion + + # region Miscellaneous + + async def send_read_acknowledge(self, entity, message=None, max_id=None, + clear_mentions=False): + """ + Sends a "read acknowledge" (i.e., notifying the given peer that we've + read their messages, also known as the "double check"). + + This effectively marks a message as read (or more than one) in the + given conversation. + + Args: + entity (`entity`): + The chat where these messages are located. + + message (`list` | :tl:`Message`): + Either a list of messages or a single message. + + max_id (`int`): + Overrides messages, until which message should the + acknowledge should be sent. + + clear_mentions (`bool`): + Whether the mention badge should be cleared (so that + there are no more mentions) or not for the given entity. + + If no message is provided, this will be the only action + taken. + """ + if max_id is None: + if message: + if utils.is_list_like(message): + max_id = max(msg.id for msg in message) + else: + max_id = message.id + elif not clear_mentions: + raise ValueError( + 'Either a message list or a max_id must be provided.') + + entity = await self.get_input_entity(entity) + if clear_mentions: + await self(functions.messages.ReadMentionsRequest(entity)) + if max_id is None: + return True + + if max_id is not None: + if isinstance(entity, types.InputPeerChannel): + return await self(functions.channels.ReadHistoryRequest( + entity, max_id=max_id)) + else: + return await self(functions.messages.ReadHistoryRequest( + entity, max_id=max_id)) + + return False + + # endregion + + # endregion + + # region Private methods + + async def _iter_ids(self, entity, ids, total): + """ + Special case for `iter_messages` when it should only fetch some IDs. + """ + if total: + total[0] = len(ids) + + if isinstance(entity, types.InputPeerChannel): + r = await self(functions.channels.GetMessagesRequest(entity, ids)) + else: + r = await self(functions.messages.GetMessagesRequest(ids)) + + if isinstance(r, types.messages.MessagesNotModified): + for _ in ids: + yield None + return + + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats)} + + # Telegram seems to return the messages in the order in which + # we asked them for, so we don't need to check it ourselves. + for message in r.messages: + if isinstance(message, types.MessageEmpty): + yield None + else: + yield custom.Message(self, message, entities, entity) + + # endregion diff --git a/telethon/client/telegramclient.py b/telethon/client/telegramclient.py index ee8ca624..75f41925 100644 --- a/telethon/client/telegramclient.py +++ b/telethon/client/telegramclient.py @@ -542,764 +542,6 @@ class TelegramClient(TelegramBaseClient): """ return list(self.iter_drafts()) - def _get_response_message(self, request, result, input_chat): - """ - Extracts the response message known a request and Update result. - The request may also be the ID of the message to match. - """ - # Telegram seems to send updateMessageID first, then updateNewMessage, - # however let's not rely on that just in case. - if isinstance(request, int): - msg_id = request - else: - msg_id = None - for update in result.updates: - if isinstance(update, UpdateMessageID): - if update.random_id == request.random_id: - msg_id = update.id - break - - if isinstance(result, UpdateShort): - updates = [result.update] - entities = {} - elif isinstance(result, (Updates, UpdatesCombined)): - updates = result.updates - entities = {utils.get_peer_id(x): x - for x in itertools.chain(result.users, result.chats)} - else: - return - - found = None - for update in updates: - if isinstance(update, (UpdateNewChannelMessage, UpdateNewMessage)): - if update.message.id == msg_id: - found = update.message - break - - elif (isinstance(update, UpdateEditMessage) and - not isinstance(request.peer, InputPeerChannel)): - if request.id == update.message.id: - found = update.message - break - - elif (isinstance(update, UpdateEditChannelMessage) and - utils.get_peer_id(request.peer) == - utils.get_peer_id(update.message.to_id)): - if request.id == update.message.id: - found = update.message - break - - if found: - return custom.Message(self, found, entities, input_chat) - - @property - def parse_mode(self): - """ - This property is the default parse mode used when sending messages. - Defaults to `telethon.extensions.markdown`. It will always - be either ``None`` or an object with ``parse`` and ``unparse`` - methods. - - When setting a different value it should be one of: - - * Object with ``parse`` and ``unparse`` methods. - * A ``callable`` to act as the parse method. - * A ``str`` indicating the ``parse_mode``. For Markdown ``'md'`` - or ``'markdown'`` may be used. For HTML, ``'htm'`` or ``'html'`` - may be used. - - The ``parse`` method should be a function accepting a single - parameter, the text to parse, and returning a tuple consisting - of ``(parsed message str, [MessageEntity instances])``. - - The ``unparse`` method should be the inverse of ``parse`` such - that ``assert text == unparse(*parse(text))``. - - See :tl:`MessageEntity` for allowed message entities. - """ - return self._parse_mode - - @parse_mode.setter - def parse_mode(self, mode): - self._parse_mode = self._sanitize_parse_mode(mode) - - @staticmethod - def _sanitize_parse_mode(mode): - if not mode: - return None - - if callable(mode): - class CustomMode: - @staticmethod - def unparse(text, entities): - raise NotImplementedError - CustomMode.parse = mode - return CustomMode - elif (all(hasattr(mode, x) for x in ('parse', 'unparse')) - and all(callable(x) for x in (mode.parse, mode.unparse))): - return mode - elif isinstance(mode, str): - try: - return { - 'md': markdown, - 'markdown': markdown, - 'htm': html, - 'html': html - }[mode.lower()] - except KeyError: - raise ValueError('Unknown parse mode {}'.format(mode)) - else: - raise TypeError('Invalid parse mode type {}'.format(mode)) - - def _parse_message_text(self, message, parse_mode): - """ - Returns a (parsed message, entities) tuple depending on ``parse_mode``. - """ - if parse_mode == Default: - parse_mode = self._parse_mode - else: - parse_mode = self._sanitize_parse_mode(parse_mode) - - if not parse_mode: - return message, [] - - message, msg_entities = parse_mode.parse(message) - for i, e in enumerate(msg_entities): - if isinstance(e, MessageEntityTextUrl): - m = re.match(r'^@|\+|tg://user\?id=(\d+)', e.url) - if m: - try: - msg_entities[i] = InputMessageEntityMentionName( - e.offset, e.length, self.get_input_entity( - int(m.group(1)) if m.group(1) else e.url - ) - ) - except (ValueError, TypeError): - # Make no replacement - pass - - return message, msg_entities - - def send_message(self, entity, message='', reply_to=None, - parse_mode=Default, link_preview=True, file=None, - force_document=False, clear_draft=False): - """ - Sends the given message to the specified entity (user/chat/channel). - - The default parse mode is the same as the official applications - (a custom flavour of markdown). ``**bold**, `code` or __italic__`` - are available. In addition you can send ``[links](https://example.com)`` - and ``[mentions](@username)`` (or using IDs like in the Bot API: - ``[mention](tg://user?id=123456789)``) and ``pre`` blocks with three - backticks. - - Sending a ``/start`` command with a parameter (like ``?start=data``) - is also done through this method. Simply send ``'/start data'`` to - the bot. - - Args: - entity (`entity`): - To who will it be sent. - - message (`str` | :tl:`Message`): - The message to be sent, or another message object to resend. - - The maximum length for a message is 35,000 bytes or 4,096 - characters. Longer messages will not be sliced automatically, - and you should slice them manually if the text to send is - longer than said length. - - reply_to (`int` | :tl:`Message`, optional): - Whether to reply to a message or not. If an integer is provided, - it should be the ID of the message that it should reply to. - - parse_mode (`object`, optional): - See the `TelegramClient.parse_mode` property for allowed - values. Markdown parsing will be used by default. - - link_preview (`bool`, optional): - Should the link preview be shown? - - file (`file`, optional): - Sends a message with a file attached (e.g. a photo, - video, audio or document). The ``message`` may be empty. - - force_document (`bool`, optional): - Whether to send the given file as a document or not. - - clear_draft (`bool`, optional): - Whether the existing draft should be cleared or not. - Has no effect when sending a file. - - Returns: - The sent `telethon.tl.custom.message.Message`. - """ - if file is not None: - return self.send_file( - entity, file, caption=message, reply_to=reply_to, - parse_mode=parse_mode, force_document=force_document - ) - elif not message: - raise ValueError( - 'The message cannot be empty unless a file is provided' - ) - - entity = self.get_input_entity(entity) - if isinstance(message, Message): - if (message.media - and not isinstance(message.media, MessageMediaWebPage)): - return self.send_file(entity, message.media, - caption=message.message, - entities=message.entities) - - if reply_to is not None: - reply_id = self._get_message_id(reply_to) - elif utils.get_peer_id(entity) == utils.get_peer_id(message.to_id): - reply_id = message.reply_to_msg_id - else: - reply_id = None - request = SendMessageRequest( - peer=entity, - message=message.message or '', - silent=message.silent, - reply_to_msg_id=reply_id, - reply_markup=message.reply_markup, - entities=message.entities, - no_webpage=not isinstance(message.media, MessageMediaWebPage), - clear_draft=clear_draft - ) - message = message.message - else: - message, msg_ent = self._parse_message_text(message, parse_mode) - request = SendMessageRequest( - peer=entity, - message=message, - entities=msg_ent, - no_webpage=not link_preview, - reply_to_msg_id=self._get_message_id(reply_to), - clear_draft=clear_draft - ) - - result = self(request) - if isinstance(result, UpdateShortSentMessage): - to_id, cls = utils.resolve_id(utils.get_peer_id(entity)) - return custom.Message(self, Message( - id=result.id, - to_id=cls(to_id), - message=message, - date=result.date, - out=result.out, - media=result.media, - entities=result.entities - ), {}, input_chat=entity) - - return self._get_response_message(request, result, entity) - - def forward_messages(self, entity, messages, from_peer=None): - """ - Forwards the given message(s) to the specified entity. - - Args: - entity (`entity`): - To which entity the message(s) will be forwarded. - - messages (`list` | `int` | :tl:`Message`): - The message(s) to forward, or their integer IDs. - - from_peer (`entity`): - If the given messages are integer IDs and not instances - of the ``Message`` class, this *must* be specified in - order for the forward to work. - - Returns: - The list of forwarded `telethon.tl.custom.message.Message`, - or a single one if a list wasn't provided as input. - """ - single = not utils.is_list_like(messages) - if single: - messages = (messages,) - - if not from_peer: - try: - # On private chats (to_id = PeerUser), if the message is - # not outgoing, we actually need to use "from_id" to get - # the conversation on which the message was sent. - from_peer = next( - m.from_id if not m.out and isinstance(m.to_id, PeerUser) - else m.to_id for m in messages if isinstance(m, Message) - ) - except StopIteration: - raise ValueError( - 'from_chat must be given if integer IDs are used' - ) - - req = ForwardMessagesRequest( - from_peer=from_peer, - id=[m if isinstance(m, int) else m.id for m in messages], - to_peer=entity - ) - result = self(req) - if isinstance(result, (Updates, UpdatesCombined)): - entities = {utils.get_peer_id(x): x - for x in itertools.chain(result.users, result.chats)} - else: - entities = {} - - random_to_id = {} - id_to_message = {} - for update in result.updates: - if isinstance(update, UpdateMessageID): - random_to_id[update.random_id] = update.id - elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)): - id_to_message[update.message.id] = custom.Message( - self, update.message, entities, input_chat=entity) - - result = [id_to_message[random_to_id[rnd]] for rnd in req.random_id] - return result[0] if single else result - - def edit_message(self, entity, message=None, text=None, - parse_mode=Default, link_preview=True, - file=None): - """ - Edits the given message ID (to change its contents or disable preview). - - Args: - entity (`entity` | :tl:`Message`): - From which chat to edit the message. This can also be - the message to be edited, and the entity will be inferred - from it, so the next parameter will be assumed to be the - message text. - - message (`int` | :tl:`Message` | `str`): - The ID of the message (or :tl:`Message` itself) to be edited. - If the `entity` was a :tl:`Message`, then this message will be - treated as the new text. - - text (`str`, optional): - The new text of the message. Does nothing if the `entity` - was a :tl:`Message`. - - parse_mode (`object`, optional): - See the `TelegramClient.parse_mode` property for allowed - values. Markdown parsing will be used by default. - - link_preview (`bool`, optional): - Should the link preview be shown? - - file (`str` | `bytes` | `file` | `media`, optional): - The file object that should replace the existing media - in the message. - - Examples: - - >>> client = TelegramClient(...).start() - >>> message = client.send_message('username', 'hello') - >>> - >>> client.edit_message('username', message, 'hello!') - >>> # or - >>> client.edit_message('username', message.id, 'Hello') - >>> # or - >>> client.edit_message(message, 'Hello!') - - Raises: - ``MessageAuthorRequiredError`` if you're not the author of the - message but tried editing it anyway. - - ``MessageNotModifiedError`` if the contents of the message were - not modified at all. - - Returns: - The edited `telethon.tl.custom.message.Message`. - """ - if isinstance(entity, Message): - text = message # Shift the parameters to the right - message = entity - entity = entity.to_id - - entity = self.get_input_entity(entity) - text, msg_entities = self._parse_message_text(text, parse_mode) - file_handle, media = self._file_to_media(file) - request = EditMessageRequest( - peer=entity, - id=self._get_message_id(message), - message=text, - no_webpage=not link_preview, - entities=msg_entities, - media=media - ) - msg = self._get_response_message(request, self(request), entity) - self._cache_media(msg, file, file_handle) - return msg - - def delete_messages(self, entity, message_ids, revoke=True): - """ - Deletes a message from a chat, optionally "for everyone". - - Args: - entity (`entity`): - From who the message will be deleted. This can actually - be ``None`` for normal chats, but **must** be present - for channels and megagroups. - - message_ids (`list` | `int` | :tl:`Message`): - The IDs (or ID) or messages to be deleted. - - revoke (`bool`, optional): - Whether the message should be deleted for everyone or not. - By default it has the opposite behaviour of official clients, - and it will delete the message for everyone. - This has no effect on channels or megagroups. - - Returns: - A list of :tl:`AffectedMessages`, each item being the result - for the delete calls of the messages in chunks of 100 each. - """ - if not utils.is_list_like(message_ids): - message_ids = (message_ids,) - - message_ids = ( - m.id if isinstance(m, (Message, MessageService, MessageEmpty)) - else int(m) for m in message_ids - ) - - entity = self.get_input_entity(entity) if entity else None - if isinstance(entity, InputPeerChannel): - return self([channels.DeleteMessagesRequest(entity, list(c)) - for c in utils.chunks(message_ids)]) - else: - return self([messages.DeleteMessagesRequest(list(c), revoke) - for c in utils.chunks(message_ids)]) - - def iter_messages(self, entity, limit=None, offset_date=None, - offset_id=0, max_id=0, min_id=0, add_offset=0, - search=None, filter=None, from_user=None, - batch_size=100, wait_time=None, ids=None, - _total=None): - """ - Iterator over the message history for the specified entity. - - If either `search`, `filter` or `from_user` are provided, - :tl:`messages.Search` will be used instead of :tl:`messages.getHistory`. - - Args: - entity (`entity`): - The entity from whom to retrieve the message history. - - limit (`int` | `None`, optional): - Number of messages to be retrieved. Due to limitations with - the API retrieving more than 3000 messages will take longer - than half a minute (or even more based on previous calls). - The limit may also be ``None``, which would eventually return - the whole history. - - offset_date (`datetime`): - Offset date (messages *previous* to this date will be - retrieved). Exclusive. - - offset_id (`int`): - Offset message ID (only messages *previous* to the given - ID will be retrieved). Exclusive. - - max_id (`int`): - All the messages with a higher (newer) ID or equal to this will - be excluded. - - min_id (`int`): - All the messages with a lower (older) ID or equal to this will - be excluded. - - add_offset (`int`): - Additional message offset (all of the specified offsets + - this offset = older messages). - - search (`str`): - The string to be used as a search query. - - filter (:tl:`MessagesFilter` | `type`): - The filter to use when returning messages. For instance, - :tl:`InputMessagesFilterPhotos` would yield only messages - containing photos. - - from_user (`entity`): - Only messages from this user will be returned. - - batch_size (`int`): - Messages will be returned in chunks of this size (100 is - the maximum). While it makes no sense to modify this value, - you are still free to do so. - - wait_time (`int`): - Wait time between different :tl:`GetHistoryRequest`. Use this - parameter to avoid hitting the ``FloodWaitError`` as needed. - If left to ``None``, it will default to 1 second only if - the limit is higher than 3000. - - ids (`int`, `list`): - A single integer ID (or several IDs) for the message that - should be returned. This parameter takes precedence over - the rest (which will be ignored if this is set). This can - for instance be used to get the message with ID 123 from - a channel. Note that if the message doesn't exist, ``None`` - will appear in its place, so that zipping the list of IDs - with the messages can match one-to-one. - - _total (`list`, optional): - A single-item list to pass the total parameter by reference. - - Yields: - Instances of `telethon.tl.custom.message.Message`. - - Notes: - Telegram's flood wait limit for :tl:`GetHistoryRequest` seems to - be around 30 seconds per 3000 messages, therefore a sleep of 1 - second is the default for this limit (or above). You may need - an higher limit, so you're free to set the ``batch_size`` that - you think may be good. - """ - # It's possible to get messages by ID without their entity, so only - # fetch the input version if we're not using IDs or if it was given. - if not ids or entity: - entity = self.get_input_entity(entity) - - if ids: - if not utils.is_list_like(ids): - ids = (ids,) - yield from self._iter_ids(entity, ids, total=_total) - return - - # Telegram doesn't like min_id/max_id. If these IDs are low enough - # (starting from last_id - 100), the request will return nothing. - # - # We can emulate their behaviour locally by setting offset = max_id - # and simply stopping once we hit a message with ID <= min_id. - offset_id = max(offset_id, max_id) - if offset_id and min_id: - if offset_id - min_id <= 1: - return - - limit = float('inf') if limit is None else int(limit) - if search is not None or filter or from_user: - if filter is None: - filter = InputMessagesFilterEmpty() - request = SearchRequest( - peer=entity, - q=search or '', - filter=filter() if isinstance(filter, type) else filter, - min_date=None, - max_date=offset_date, - offset_id=offset_id, - add_offset=add_offset, - limit=1, - max_id=0, - min_id=0, - hash=0, - from_id=self.get_input_entity(from_user) if from_user else None - ) - else: - request = GetHistoryRequest( - peer=entity, - limit=1, - offset_date=offset_date, - offset_id=offset_id, - min_id=0, - max_id=0, - add_offset=add_offset, - hash=0 - ) - - if limit == 0: - if not _total: - return - # No messages, but we still need to know the total message count - result = self(request) - if isinstance(result, MessagesNotModified): - _total[0] = result.count - else: - _total[0] = getattr(result, 'count', len(result.messages)) - return - - if wait_time is None: - wait_time = 1 if limit > 3000 else 0 - - have = 0 - last_id = float('inf') - batch_size = min(max(batch_size, 1), 100) - while have < limit: - start = time.time() - # Telegram has a hard limit of 100 - request.limit = min(limit - have, batch_size) - r = self(request) - if _total: - _total[0] = getattr(r, 'count', len(r.messages)) - - entities = {utils.get_peer_id(x): x - for x in itertools.chain(r.users, r.chats)} - - for message in r.messages: - if message.id <= min_id: - return - - if isinstance(message, MessageEmpty) or message.id >= last_id: - continue - - # There has been reports that on bad connections this method - # was returning duplicated IDs sometimes. Using ``last_id`` - # is an attempt to avoid these duplicates, since the message - # IDs are returned in descending order. - last_id = message.id - - yield custom.Message(self, message, entities, entity) - have += 1 - - if len(r.messages) < request.limit: - break - - request.offset_id = r.messages[-1].id - if isinstance(request, GetHistoryRequest): - request.offset_date = r.messages[-1].date - else: - request.max_date = r.messages[-1].date - - time.sleep(max(wait_time - (time.time() - start), 0)) - - def _iter_ids(self, entity, ids, total): - """ - Special case for `iter_messages` when it should only fetch some IDs. - """ - if total: - total[0] = len(ids) - - if isinstance(entity, InputPeerChannel): - r = self(channels.GetMessagesRequest(entity, ids)) - else: - r = self(messages.GetMessagesRequest(ids)) - - if isinstance(r, MessagesNotModified): - for _ in ids: - yield None - - entities = {utils.get_peer_id(x): x - for x in itertools.chain(r.users, r.chats)} - - # Telegram seems to return the messages in the order in which - # we asked them for, so we don't need to check it ourselves. - for message in r.messages: - if isinstance(message, MessageEmpty): - yield None - else: - yield custom.Message(self, message, entities, entity) - - def get_messages(self, *args, **kwargs): - """ - Same as :meth:`iter_messages`, but returns a list instead - with an additional ``.total`` attribute on the list. - - If the `limit` is not set, it will be 1 by default unless both - `min_id` **and** `max_id` are set (as *named* arguments), in - which case the entire range will be returned. - - This is so because any integer limit would be rather arbitrary and - it's common to only want to fetch one message, but if a range is - specified it makes sense that it should return the entirety of it. - - If `ids` is present in the *named* arguments and is not a list, - a single :tl:`Message` will be returned for convenience instead - of a list. - """ - total = [0] - kwargs['_total'] = total - if len(args) == 1 and 'limit' not in kwargs: - if 'min_id' in kwargs and 'max_id' in kwargs: - kwargs['limit'] = None - else: - kwargs['limit'] = 1 - - msgs = UserList(self.iter_messages(*args, **kwargs)) - msgs.total = total[0] - if 'ids' in kwargs and not utils.is_list_like(kwargs['ids']): - return msgs[0] - - return msgs - - def get_message_history(self, *args, **kwargs): - """Deprecated, see :meth:`get_messages`.""" - warnings.warn( - 'get_message_history is deprecated, use get_messages instead' - ) - return self.get_messages(*args, **kwargs) - - def send_read_acknowledge(self, entity, message=None, max_id=None, - clear_mentions=False): - """ - Sends a "read acknowledge" (i.e., notifying the given peer that we've - read their messages, also known as the "double check"). - - This effectively marks a message as read (or more than one) in the - given conversation. - - Args: - entity (`entity`): - The chat where these messages are located. - - message (`list` | :tl:`Message`): - Either a list of messages or a single message. - - max_id (`int`): - Overrides messages, until which message should the - acknowledge should be sent. - - clear_mentions (`bool`): - Whether the mention badge should be cleared (so that - there are no more mentions) or not for the given entity. - - If no message is provided, this will be the only action - taken. - """ - if max_id is None: - if message: - if utils.is_list_like(message): - max_id = max(msg.id for msg in message) - else: - max_id = message.id - elif not clear_mentions: - raise ValueError( - 'Either a message list or a max_id must be provided.') - - entity = self.get_input_entity(entity) - if clear_mentions: - self(ReadMentionsRequest(entity)) - if max_id is None: - return True - - if max_id is not None: - if isinstance(entity, InputPeerChannel): - return self(channels.ReadHistoryRequest(entity, max_id=max_id)) - else: - return self(messages.ReadHistoryRequest(entity, max_id=max_id)) - - return False - - @staticmethod - def _get_message_id(message): - """Sanitizes the 'reply_to' parameter a user may send""" - if message is None: - return None - - if isinstance(message, int): - return message - - if isinstance(message, custom.Message): - return message.original_message.id - - try: - if message.SUBCLASS_OF_ID == 0x790009e3: - # hex(crc32(b'Message')) = 0x790009e3 - return message.id - except AttributeError: - pass - - raise TypeError('Invalid message type: {}'.format(type(message))) - def iter_participants(self, entity, limit=None, search='', filter=None, aggressive=False, _total=None): """ @@ -1470,454 +712,6 @@ class TelegramClient(TelegramBaseClient): # region Uploading files - def _file_to_media(self, file, force_document=False, - progress_callback=None, attributes=None, thumb=None, - allow_cache=True, voice_note=False, video_note=False): - if not file: - return None, None - - if not isinstance(file, (str, bytes, io.IOBase)): - # 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. - try: - return None, utils.get_input_media(file) - except TypeError: - return None, None # Can't turn whatever was given into media - - as_image = utils.is_image(file) and not force_document - use_cache = InputPhoto if as_image else InputDocument - file_handle = self.upload_file( - file, progress_callback=progress_callback, - use_cache=use_cache if allow_cache else None - ) - - if isinstance(file_handle, use_cache): - # File was cached, so an instance of use_cache was returned - if as_image: - media = InputMediaPhoto(file_handle) - else: - media = InputMediaDocument(file_handle) - elif as_image: - media = InputMediaUploadedPhoto(file_handle) - else: - mime_type = None - if isinstance(file, str): - # Determine mime-type and attributes - # Take the first element by using [0] since it returns a tuple - mime_type = guess_type(file)[0] - attr_dict = { - DocumentAttributeFilename: - DocumentAttributeFilename(os.path.basename(file)) - } - if utils.is_audio(file) and hachoir: - m = hachoir.metadata.extractMetadata( - hachoir.parser.createParser(file) - ) - attr_dict[DocumentAttributeAudio] = DocumentAttributeAudio( - voice=voice_note, - title=m.get('title') if m.has('title') else None, - performer=m.get('author') if m.has('author') else None, - duration=int(m.get('duration').seconds - if m.has('duration') else 0) - ) - - if not force_document and utils.is_video(file): - if hachoir: - m = hachoir.metadata.extractMetadata( - hachoir.parser.createParser(file) - ) - doc = DocumentAttributeVideo( - round_message=video_note, - w=m.get('width') if m.has('width') else 0, - h=m.get('height') if m.has('height') else 0, - duration=int(m.get('duration').seconds - if m.has('duration') else 0) - ) - else: - doc = DocumentAttributeVideo( - 0, 1, 1, round_message=video_note) - - attr_dict[DocumentAttributeVideo] = doc - else: - attr_dict = { - DocumentAttributeFilename: DocumentAttributeFilename( - os.path.basename( - getattr(file, 'name', None) or 'unnamed')) - } - - if voice_note: - if DocumentAttributeAudio in attr_dict: - attr_dict[DocumentAttributeAudio].voice = True - else: - attr_dict[DocumentAttributeAudio] = \ - DocumentAttributeAudio(0, voice=True) - - # Now override the attributes if any. As we have a dict of - # {cls: instance}, we can override any class with the list - # of attributes provided by the user easily. - if attributes: - for a in attributes: - attr_dict[type(a)] = a - - # Ensure we have a mime type, any; but it cannot be None - # 'The "octet-stream" subtype is used to indicate that a body - # contains arbitrary binary data.' - if not mime_type: - mime_type = 'application/octet-stream' - - input_kw = {} - if thumb: - input_kw['thumb'] = self.upload_file(thumb) - - media = InputMediaUploadedDocument( - file=file_handle, - mime_type=mime_type, - attributes=list(attr_dict.values()), - **input_kw - ) - return file_handle, media - - def _cache_media(self, msg, file, file_handle, force_document=False): - if file and msg and isinstance(file_handle, InputSizedFile): - # There was a response message and we didn't use cached - # version, so cache whatever we just sent to the database. - md5, size = file_handle.md5, file_handle.size - if utils.is_image(file) and not force_document: - to_cache = utils.get_input_photo(msg.media.photo) - else: - to_cache = utils.get_input_document(msg.media.document) - self.session.cache_file(md5, size, to_cache) - - def send_file(self, entity, file, caption='', - force_document=False, progress_callback=None, - reply_to=None, - attributes=None, - thumb=None, - allow_cache=True, - parse_mode=Default, - voice_note=False, - video_note=False, - **kwargs): - """ - Sends a file to the specified entity. - - Args: - entity (`entity`): - Who will receive the file. - - file (`str` | `bytes` | `file` | `media`): - The path of the file, byte array, or stream that will be sent. - Note that if a byte array or a stream is given, a filename - or its type won't be inferred, and it will be sent as an - "unnamed application/octet-stream". - - Furthermore the file may be any media (a message, document, - photo or similar) so that it can be resent without the need - to download and re-upload it again. - - If a list or similar is provided, the files in it will be - sent as an album in the order in which they appear, sliced - in chunks of 10 if more than 10 are given. - - caption (`str`, optional): - Optional caption for the sent media message. - - force_document (`bool`, optional): - If left to ``False`` and the file is a path that ends with - the extension of an image file or a video file, it will be - sent as such. Otherwise always as a document. - - progress_callback (`callable`, optional): - A callback function accepting two parameters: - ``(sent bytes, total)``. - - reply_to (`int` | :tl:`Message`): - Same as `reply_to` from `send_message`. - - attributes (`list`, optional): - Optional attributes that override the inferred ones, like - :tl:`DocumentAttributeFilename` and so on. - - thumb (`str` | `bytes` | `file`, optional): - Optional thumbnail (for videos). - - allow_cache (`bool`, optional): - Whether to allow using the cached version stored in the - database or not. Defaults to ``True`` to avoid re-uploads. - Must be ``False`` if you wish to use different attributes - or thumb than those that were used when the file was cached. - - parse_mode (`object`, optional): - See the `TelegramClient.parse_mode` property for allowed - values. Markdown parsing will be used by default. - - voice_note (`bool`, optional): - If ``True`` the audio will be sent as a voice note. - - Set `allow_cache` to ``False`` if you sent the same file - without this setting before for it to work. - - video_note (`bool`, optional): - If ``True`` the video will be sent as a video note, - also known as a round video message. - - Set `allow_cache` to ``False`` if you sent the same file - without this setting before for it to work. - - Notes: - If the ``hachoir3`` package (``hachoir`` module) is installed, - it will be used to determine metadata from audio and video files. - - Returns: - The `telethon.tl.custom.message.Message` (or messages) containing - the sent file, or messages if a list of them was passed. - """ - # First check if the user passed an iterable, in which case - # we may want to send as an album if all are photo files. - if utils.is_list_like(file): - # TODO Fix progress_callback - images = [] - if force_document: - documents = file - else: - documents = [] - for x in file: - if utils.is_image(x): - images.append(x) - else: - documents.append(x) - - result = [] - while images: - result += self._send_album( - entity, images[:10], caption=caption, - progress_callback=progress_callback, reply_to=reply_to, - parse_mode=parse_mode - ) - images = images[10:] - - result.extend( - self.send_file( - entity, x, allow_cache=allow_cache, - caption=caption, force_document=force_document, - progress_callback=progress_callback, reply_to=reply_to, - attributes=attributes, thumb=thumb, voice_note=voice_note, - video_note=video_note, **kwargs - ) for x in documents - ) - return result - - entity = self.get_input_entity(entity) - reply_to = self._get_message_id(reply_to) - - # Not document since it's subject to change. - # Needed when a Message is passed to send_message and it has media. - if 'entities' in kwargs: - msg_entities = kwargs['entities'] - else: - caption, msg_entities =\ - self._parse_message_text(caption, parse_mode) - - file_handle, media = self._file_to_media(file, allow_cache=allow_cache) - request = SendMediaRequest(entity, media, reply_to_msg_id=reply_to, - message=caption, entities=msg_entities) - msg = self._get_response_message(request, self(request), entity) - self._cache_media(msg, file, file_handle, force_document=force_document) - - return msg - - def send_voice_note(self, *args, **kwargs): - """Deprecated, see :meth:`send_file`.""" - warnings.warn('send_voice_note is deprecated, use ' - 'send_file(..., voice_note=True) instead') - kwargs['is_voice_note'] = True - return self.send_file(*args, **kwargs) - - def _send_album(self, entity, files, caption='', - progress_callback=None, reply_to=None, - parse_mode=Default): - """Specialized version of .send_file for albums""" - # We don't care if the user wants to avoid cache, we will use it - # anyway. Why? The cached version will be exactly the same thing - # we need to produce right now to send albums (uploadMedia), and - # cache only makes a difference for documents where the user may - # want the attributes used on them to change. - # - # In theory documents can be sent inside the albums but they appear - # as different messages (not inside the album), and the logic to set - # the attributes/avoid cache is already written in .send_file(). - entity = self.get_input_entity(entity) - if not utils.is_list_like(caption): - caption = (caption,) - captions = [ - self._parse_message_text(caption or '', parse_mode) - for caption in reversed(caption) # Pop from the end (so reverse) - ] - reply_to = self._get_message_id(reply_to) - - # Need to upload the media first, but only if they're not cached yet - media = [] - for file in files: - # fh will either be InputPhoto or a modified InputFile - fh = self.upload_file(file, use_cache=InputPhoto) - if not isinstance(fh, InputPhoto): - input_photo = utils.get_input_photo(self(UploadMediaRequest( - entity, media=InputMediaUploadedPhoto(fh) - )).photo) - self.session.cache_file(fh.md5, fh.size, input_photo) - fh = input_photo - - if captions: - caption, msg_entities = captions.pop() - else: - caption, msg_entities = '', None - media.append(InputSingleMedia(InputMediaPhoto(fh), message=caption, - entities=msg_entities)) - - # Now we can construct the multi-media request - result = self(SendMultiMediaRequest( - entity, reply_to_msg_id=reply_to, multi_media=media - )) - return [ - self._get_response_message(update.id, result, entity) - for update in result.updates - if isinstance(update, UpdateMessageID) - ] - - def upload_file(self, - file, - part_size_kb=None, - file_name=None, - use_cache=None, - progress_callback=None): - """ - Uploads the specified file and returns a handle (an instance of - :tl:`InputFile` or :tl:`InputFileBig`, as required) which can be - later used before it expires (they are usable during less than a day). - - Uploading a file will simply return a "handle" to the file stored - remotely in the Telegram servers, which can be later used on. This - will **not** upload the file to your own chat or any chat at all. - - Args: - file (`str` | `bytes` | `file`): - The path of the file, byte array, or stream that will be sent. - Note that if a byte array or a stream is given, a filename - or its type won't be inferred, and it will be sent as an - "unnamed application/octet-stream". - - part_size_kb (`int`, optional): - Chunk size when uploading files. The larger, the less - requests will be made (up to 512KB maximum). - - file_name (`str`, optional): - The file name which will be used on the resulting InputFile. - If not specified, the name will be taken from the ``file`` - and if this is not a ``str``, it will be ``"unnamed"``. - - use_cache (`type`, optional): - The type of cache to use (currently either :tl:`InputDocument` - or :tl:`InputPhoto`). If present and the file is small enough - to need the MD5, it will be checked against the database, - and if a match is found, the upload won't be made. Instead, - an instance of type ``use_cache`` will be returned. - - progress_callback (`callable`, optional): - A callback function accepting two parameters: - ``(sent bytes, total)``. - - Returns: - :tl:`InputFileBig` if the file size is larger than 10MB, - `telethon.tl.custom.input_sized_file.InputSizedFile` - (subclass of :tl:`InputFile`) otherwise. - """ - if isinstance(file, (InputFile, InputFileBig)): - return file # Already uploaded - - if isinstance(file, str): - file_size = os.path.getsize(file) - elif isinstance(file, bytes): - file_size = len(file) - else: - file = file.read() - file_size = len(file) - - # File will now either be a string or bytes - if not part_size_kb: - part_size_kb = utils.get_appropriated_part_size(file_size) - - if part_size_kb > 512: - raise ValueError('The part size must be less or equal to 512KB') - - part_size = int(part_size_kb * 1024) - if part_size % 1024 != 0: - raise ValueError( - 'The part size must be evenly divisible by 1024') - - # Set a default file name if None was specified - file_id = helpers.generate_random_long() - if not file_name: - if isinstance(file, str): - file_name = os.path.basename(file) - else: - file_name = str(file_id) - - # Determine whether the file is too big (over 10MB) or not - # Telegram does make a distinction between smaller or larger files - is_large = file_size > 10 * 1024 * 1024 - hash_md5 = hashlib.md5() - if not is_large: - # Calculate the MD5 hash before anything else. - # As this needs to be done always for small files, - # might as well do it before anything else and - # check the cache. - if isinstance(file, str): - with open(file, 'rb') as stream: - file = stream.read() - hash_md5.update(file) - if use_cache: - cached = self.session.get_file( - hash_md5.digest(), file_size, cls=use_cache - ) - if cached: - return cached - - part_count = (file_size + part_size - 1) // part_size - __log__.info('Uploading file of %d bytes in %d chunks of %d', - file_size, part_count, part_size) - - with open(file, 'rb') if isinstance(file, str) else BytesIO(file) \ - as stream: - for part_index in range(part_count): - # Read the file by in chunks of size part_size - part = stream.read(part_size) - - # The SavePartRequest is different depending on whether - # the file is too large or not (over or less than 10MB) - if is_large: - request = SaveBigFilePartRequest(file_id, part_index, - part_count, part) - else: - request = SaveFilePartRequest(file_id, part_index, part) - - result = self(request) - if result: - __log__.debug('Uploaded %d/%d', part_index + 1, - part_count) - if progress_callback: - progress_callback(stream.tell(), file_size) - else: - raise RuntimeError( - 'Failed to upload file part {}.'.format(part_index)) - - if is_large: - return InputFileBig(file_id, part_count, file_name) - else: - return InputSizedFile( - file_id, part_count, file_name, md5=hash_md5, size=file_size - ) - # endregion # region Downloading media requests diff --git a/telethon/utils.py b/telethon/utils.py index 312b47ac..340574f2 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -402,6 +402,27 @@ def get_input_message(message): _raise_cast_fail(message, 'InputMedia') +def get_message_id(message): + """Sanitizes the 'reply_to' parameter a user may send""" + if message is None: + return None + + if isinstance(message, int): + return message + + if hasattr(message, 'original_message'): + return message.original_message.id + + try: + if message.SUBCLASS_OF_ID == 0x790009e3: + # hex(crc32(b'Message')) = 0x790009e3 + return message.id + except AttributeError: + pass + + raise TypeError('Invalid message type: {}'.format(type(message))) + + def get_input_location(location): """Similar to :meth:`get_input_peer`, but for input messages.""" try: