diff --git a/telethon/__init__.py b/telethon/__init__.py index 1e86f236..7350987c 100644 --- a/telethon/__init__.py +++ b/telethon/__init__.py @@ -1,6 +1,5 @@ import logging -from .telegram_bare_client import TelegramBareClient -from .telegram_client import TelegramClient +from .client.telegramclient import TelegramClient from .network import connection from .tl import types, functions from . import version, events, utils diff --git a/telethon/client/__init__.py b/telethon/client/__init__.py new file mode 100644 index 00000000..4c133065 --- /dev/null +++ b/telethon/client/__init__.py @@ -0,0 +1,22 @@ +""" +This package defines clients as subclasses of others, and then a single +`telethon.client.telegramclient.TelegramClient` which is subclass of them +all to provide the final unified interface while the methods can live in +different subclasses to be more maintainable. + +The ABC is `telethon.client.telegrambaseclient.TelegramBaseClient` and the +first implementor is `telethon.client.users.UserMethods`, since calling +requests require them to be resolved first, and that requires accessing +entities (users). +""" +from .telegrambaseclient import TelegramBaseClient +from .users import UserMethods # Required for everything +from .messageparse import MessageParseMethods # Required for messages +from .uploads import UploadMethods # Required for messages to send files +from .messages import MessageMethods +from .chats import ChatMethods +from .dialogs import DialogMethods +from .downloads import DownloadMethods +from .auth import AuthMethods +from .updates import UpdateMethods +from .telegramclient import TelegramClient diff --git a/telethon/client/auth.py b/telethon/client/auth.py new file mode 100644 index 00000000..0aba1d5a --- /dev/null +++ b/telethon/client/auth.py @@ -0,0 +1,428 @@ +import getpass +import hashlib +import sys + +import os + +from .messageparse import MessageParseMethods +from .users import UserMethods +from .. import utils, helpers, errors +from ..tl import types, functions + + +class AuthMethods(MessageParseMethods, UserMethods): + + # region Public methods + + async def start( + self, + phone=lambda: input('Please enter your phone: '), + password=lambda: getpass.getpass('Please enter your password: '), + bot_token=None, force_sms=False, code_callback=None, + first_name='New User', last_name=''): + """ + Convenience method to interactively connect and sign in if required, + also taking into consideration that 2FA may be enabled in the account. + + If the phone doesn't belong to an existing account (and will hence + `sign_up` for a new one), **you are agreeing to Telegram's + Terms of Service. This is required and your account + will be banned otherwise.** See https://telegram.org/tos + and https://core.telegram.org/api/terms. + + Example usage: + >>> client = ... + >>> client.start(phone) + Please enter the code you received: 12345 + Please enter your password: ******* + (You are now logged in) + + Args: + phone (`str` | `int` | `callable`): + The phone (or callable without arguments to get it) + to which the code will be sent. + + password (`callable`, optional): + The password for 2 Factor Authentication (2FA). + This is only required if it is enabled in your account. + + bot_token (`str`): + Bot Token obtained by `@BotFather `_ + to log in as a bot. Cannot be specified with ``phone`` (only + one of either allowed). + + force_sms (`bool`, optional): + Whether to force sending the code request as SMS. + This only makes sense when signing in with a `phone`. + + code_callback (`callable`, optional): + A callable that will be used to retrieve the Telegram + login code. Defaults to `input()`. + + first_name (`str`, optional): + The first name to be used if signing up. This has no + effect if the account already exists and you sign in. + + last_name (`str`, optional): + Similar to the first name, but for the last. Optional. + + Returns: + This `TelegramClient`, so initialization + can be chained with ``.start()``. + """ + + if code_callback is None: + def code_callback(): + return input('Please enter the code you received: ') + elif not callable(code_callback): + raise ValueError( + 'The code_callback parameter needs to be a callable ' + 'function that returns the code you received by Telegram.' + ) + + if not phone and not bot_token: + raise ValueError('No phone number or bot token provided.') + + if phone and bot_token and not callable(phone): + raise ValueError('Both a phone and a bot token provided, ' + 'must only provide one of either') + + if not self.is_connected(): + await self.connect() + + if await self.is_user_authorized(): + return self + + if bot_token: + await self.sign_in(bot_token=bot_token) + return self + + # Turn the callable into a valid phone number + while callable(phone): + phone = utils.parse_phone(phone()) or phone + + me = None + attempts = 0 + max_attempts = 3 + two_step_detected = False + + sent_code = await self.send_code_request(phone, force_sms=force_sms) + sign_up = not sent_code.phone_registered + while attempts < max_attempts: + try: + if sign_up: + me = await self.sign_up( + code_callback(), first_name, last_name) + else: + # Raises SessionPasswordNeededError if 2FA enabled + me = await self.sign_in(phone, code_callback()) + break + except errors.SessionPasswordNeededError: + two_step_detected = True + break + except errors.PhoneNumberOccupiedError: + sign_up = False + except errors.PhoneNumberUnoccupiedError: + sign_up = True + except (errors.PhoneCodeEmptyError, + errors.PhoneCodeExpiredError, + errors.PhoneCodeHashEmptyError, + errors.PhoneCodeInvalidError): + print('Invalid code. Please try again.', file=sys.stderr) + + attempts += 1 + else: + raise RuntimeError( + '{} consecutive sign-in attempts failed. Aborting' + .format(max_attempts) + ) + + if two_step_detected: + if not password: + raise ValueError( + "Two-step verification is enabled for this account. " + "Please provide the 'password' argument to 'start()'." + ) + # TODO If callable given make it retry on invalid + if callable(password): + password = password() + me = await self.sign_in(phone=phone, password=password) + + # We won't reach here if any step failed (exit by exception) + signed, name = 'Signed in successfully as', utils.get_display_name(me) + try: + print(signed, name) + except UnicodeEncodeError: + # Some terminals don't support certain characters + print(signed, name.encode('utf-8', errors='ignore') + .decode('ascii', errors='ignore')) + + return self + + async def is_user_authorized(self): + return await self.get_me() is not None + + async def sign_in( + self, phone=None, code=None, password=None, + bot_token=None, phone_code_hash=None): + """ + Starts or completes the sign in process with the given phone number + or code that Telegram sent. + + Args: + phone (`str` | `int`): + The phone to send the code to if no code was provided, + or to override the phone that was previously used with + these requests. + + code (`str` | `int`): + The code that Telegram sent. Note that if you have sent this + code through the application itself it will immediately + expire. If you want to send the code, obfuscate it somehow. + If you're not doing any of this you can ignore this note. + + password (`str`): + 2FA password, should be used if a previous call raised + SessionPasswordNeededError. + + bot_token (`str`): + Used to sign in as a bot. Not all requests will be available. + This should be the hash the @BotFather gave you. + + phone_code_hash (`str`): + The hash returned by .send_code_request. This can be set to None + to use the last hash known. + + Returns: + The signed in user, or the information about + :meth:`send_code_request`. + """ + me = await self.get_me() + if me: + return me + + if phone and not code and not password: + return await self.send_code_request(phone) + elif code: + phone = utils.parse_phone(phone) or self._phone + phone_code_hash = \ + phone_code_hash or self._phone_code_hash.get(phone, None) + + if not phone: + raise ValueError( + 'Please make sure to call send_code_request first.' + ) + if not phone_code_hash: + raise ValueError('You also need to provide a phone_code_hash.') + + # May raise PhoneCodeEmptyError, PhoneCodeExpiredError, + # PhoneCodeHashEmptyError or PhoneCodeInvalidError. + result = await self(functions.auth.SignInRequest( + phone, phone_code_hash, str(code))) + elif password: + salt = (await self( + functions.account.GetPasswordRequest())).current_salt + result = await self(functions.auth.CheckPasswordRequest( + helpers.get_password_hash(password, salt) + )) + elif bot_token: + result = await self(functions.auth.ImportBotAuthorizationRequest( + flags=0, bot_auth_token=bot_token, + api_id=self.api_id, api_hash=self.api_hash + )) + else: + raise ValueError( + 'You must provide a phone and a code the first time, ' + 'and a password only if an RPCError was raised before.' + ) + + self._self_input_peer = utils.get_input_peer( + result.user, allow_self=False + ) + return result.user + + async def sign_up(self, code, first_name, last_name=''): + """ + Signs up to Telegram if you don't have an account yet. + You must call .send_code_request(phone) first. + + **By using this method you're agreeing to Telegram's + Terms of Service. This is required and your account + will be banned otherwise.** See https://telegram.org/tos + and https://core.telegram.org/api/terms. + + Args: + code (`str` | `int`): + The code sent by Telegram + + first_name (`str`): + The first name to be used by the new account. + + last_name (`str`, optional) + Optional last name. + + Returns: + The new created :tl:`User`. + """ + me = await self.get_me() + if me: + return me + + if self._tos and self._tos.text: + if self.parse_mode: + t = self.parse_mode.unparse(self._tos.text, self._tos.entities) + else: + t = self._tos.text + sys.stderr.write("{}\n".format(t)) + sys.stderr.flush() + + result = await self(functions.auth.SignUpRequest( + phone_number=self._phone, + phone_code_hash=self._phone_code_hash.get(self._phone, ''), + phone_code=str(code), + first_name=first_name, + last_name=last_name + )) + + if self._tos: + await self( + functions.help.AcceptTermsOfServiceRequest(self._tos.id)) + + self._self_input_peer = utils.get_input_peer( + result.user, allow_self=False + ) + return result.user + + async def send_code_request(self, phone, force_sms=False): + """ + Sends a code request to the specified phone number. + + Args: + phone (`str` | `int`): + The phone to which the code will be sent. + + force_sms (`bool`, optional): + Whether to force sending as SMS. + + Returns: + An instance of :tl:`SentCode`. + """ + phone = utils.parse_phone(phone) or self._phone + phone_hash = self._phone_code_hash.get(phone) + + if not phone_hash: + try: + result = await self(functions.auth.SendCodeRequest( + phone, self.api_id, self.api_hash)) + except errors.AuthRestartError: + return self.send_code_request(phone, force_sms=force_sms) + + self._tos = result.terms_of_service + self._phone_code_hash[phone] = phone_hash = result.phone_code_hash + else: + force_sms = True + + self._phone = phone + + if force_sms: + result = await self( + functions.auth.ResendCodeRequest(phone, phone_hash)) + + self._phone_code_hash[phone] = result.phone_code_hash + + return result + + async def log_out(self): + """ + Logs out Telegram and deletes the current ``*.session`` file. + + Returns: + ``True`` if the operation was successful. + """ + try: + await self(functions.auth.LogOutRequest()) + except errors.RPCError: + return False + + await self.disconnect() + self.session.delete() + self._authorized = False + return True + + async def edit_2fa( + self, current_password=None, new_password=None, hint='', + email=None): + """ + Changes the 2FA settings of the logged in user, according to the + passed parameters. Take note of the parameter explanations. + + Has no effect if both current and new password are omitted. + + current_password (`str`, optional): + The current password, to authorize changing to ``new_password``. + Must be set if changing existing 2FA settings. + Must **not** be set if 2FA is currently disabled. + Passing this by itself will remove 2FA (if correct). + + new_password (`str`, optional): + The password to set as 2FA. + If 2FA was already enabled, ``current_password`` **must** be set. + Leaving this blank or ``None`` will remove the password. + + hint (`str`, optional): + Hint to be displayed by Telegram when it asks for 2FA. + Leaving unspecified is highly discouraged. + Has no effect if ``new_password`` is not set. + + email (`str`, optional): + Recovery and verification email. Raises ``EmailUnconfirmedError`` + if value differs from current one, and has no effect if + ``new_password`` is not set. + + Returns: + ``True`` if successful, ``False`` otherwise. + """ + if new_password is None and current_password is None: + return False + + pass_result = await self(functions.account.GetPasswordRequest()) + if isinstance( + pass_result, types.account.NoPassword) and current_password: + current_password = None + + salt_random = os.urandom(8) + salt = pass_result.new_salt + salt_random + if not current_password: + current_password_hash = salt + else: + current_password = ( + pass_result.current_salt + + current_password.encode() + + pass_result.current_salt + ) + current_password_hash = hashlib.sha256(current_password).digest() + + if new_password: # Setting new password + new_password = salt + new_password.encode('utf-8') + salt + new_password_hash = hashlib.sha256(new_password).digest() + new_settings = types.account.PasswordInputSettings( + new_salt=salt, + new_password_hash=new_password_hash, + hint=hint + ) + if email: # If enabling 2FA or changing email + new_settings.email = email # TG counts empty string as None + return await self(functions.account.UpdatePasswordSettingsRequest( + current_password_hash, new_settings=new_settings + )) + else: # Removing existing password + return await self(functions.account.UpdatePasswordSettingsRequest( + current_password_hash, + new_settings=types.account.PasswordInputSettings( + new_salt=bytes(), + new_password_hash=bytes(), + hint=hint + ) + )) + + # endregion diff --git a/telethon/client/chats.py b/telethon/client/chats.py new file mode 100644 index 00000000..ee2aa56b --- /dev/null +++ b/telethon/client/chats.py @@ -0,0 +1,188 @@ +from collections import UserList + +from async_generator import async_generator, yield_ + +from .users import UserMethods +from .. import utils +from ..tl import types, functions + + +class ChatMethods(UserMethods): + + # region Public methods + + @async_generator + async def iter_participants( + self, entity, limit=None, search='', + filter=None, aggressive=False, _total=None): + """ + Iterator over the participants belonging to the specified chat. + + Args: + entity (`entity`): + The entity from which to retrieve the participants list. + + limit (`int`): + Limits amount of participants fetched. + + search (`str`, optional): + Look for participants with this string in name/username. + + filter (:tl:`ChannelParticipantsFilter`, optional): + The filter to be used, if you want e.g. only admins + Note that you might not have permissions for some filter. + This has no effect for normal chats or users. + + aggressive (`bool`, optional): + Aggressively looks for all participants in the chat in + order to get more than 10,000 members (a hard limit + imposed by Telegram). Note that this might take a long + time (over 5 minutes), but is able to return over 90,000 + participants on groups with 100,000 members. + + This has no effect for groups or channels with less than + 10,000 members, or if a ``filter`` is given. + + _total (`list`, optional): + A single-item list to pass the total parameter by reference. + + Yields: + The :tl:`User` objects returned by :tl:`GetParticipantsRequest` + with an additional ``.participant`` attribute which is the + matched :tl:`ChannelParticipant` type for channels/megagroups + or :tl:`ChatParticipants` for normal chats. + """ + if isinstance(filter, type): + if filter in (types.ChannelParticipantsBanned, + types.ChannelParticipantsKicked, + types.ChannelParticipantsSearch): + # These require a `q` parameter (support types for convenience) + filter = filter('') + else: + filter = filter() + + entity = await self.get_input_entity(entity) + if search and (filter + or not isinstance(entity, types.InputPeerChannel)): + # We need to 'search' ourselves unless we have a PeerChannel + search = search.lower() + + def filter_entity(ent): + return search in utils.get_display_name(ent).lower() or\ + search in (getattr(ent, 'username', '') or None).lower() + else: + def filter_entity(ent): + return True + + limit = float('inf') if limit is None else int(limit) + if isinstance(entity, types.InputPeerChannel): + if _total or (aggressive and not filter): + total = (await self(functions.channels.GetFullChannelRequest( + entity + ))).full_chat.participants_count + if _total: + _total[0] = total + else: + total = 0 + + if limit == 0: + return + + seen = set() + if total > 10000 and aggressive and not filter: + requests = [functions.channels.GetParticipantsRequest( + channel=entity, + filter=types.ChannelParticipantsSearch(search + chr(x)), + offset=0, + limit=200, + hash=0 + ) for x in range(ord('a'), ord('z') + 1)] + else: + requests = [functions.channels.GetParticipantsRequest( + channel=entity, + filter=filter or types.ChannelParticipantsSearch(search), + offset=0, + limit=200, + hash=0 + )] + + while requests: + # Only care about the limit for the first request + # (small amount of people, won't be aggressive). + # + # Most people won't care about getting exactly 12,345 + # members so it doesn't really matter not to be 100% + # precise with being out of the offset/limit here. + requests[0].limit = min(limit - requests[0].offset, 200) + if requests[0].offset > limit: + break + + results = await self(requests) + for i in reversed(range(len(requests))): + participants = results[i] + if not participants.users: + requests.pop(i) + else: + requests[i].offset += len(participants.participants) + users = {user.id: user for user in participants.users} + for participant in participants.participants: + user = users[participant.user_id] + if not filter_entity(user) or user.id in seen: + continue + + seen.add(participant.user_id) + user = users[participant.user_id] + user.participant = participant + await yield_(user) + if len(seen) >= limit: + return + + elif isinstance(entity, types.InputPeerChat): + # TODO We *could* apply the `filter` here ourselves + full = await self( + functions.messages.GetFullChatRequest(entity.chat_id)) + if not isinstance( + full.full_chat.participants, types.ChatParticipants): + # ChatParticipantsForbidden won't have ``.participants`` + _total[0] = 0 + return + + if _total: + _total[0] = len(full.full_chat.participants.participants) + + have = 0 + users = {user.id: user for user in full.users} + for participant in full.full_chat.participants.participants: + user = users[participant.user_id] + if not filter_entity(user): + continue + have += 1 + if have > limit: + break + else: + user = users[participant.user_id] + user.participant = participant + await yield_(user) + else: + if _total: + _total[0] = 1 + if limit != 0: + user = await self.get_entity(entity) + if filter_entity(user): + user.participant = None + await yield_(user) + + async def get_participants(self, *args, **kwargs): + """ + Same as :meth:`iter_participants`, but returns a list instead + with an additional ``.total`` attribute on the list. + """ + total = [0] + kwargs['_total'] = total + participants = UserList() + async for x in self.iter_participants(*args, **kwargs): + participants.append(x) + participants.total = total[0] + return participants + + # endregion diff --git a/telethon/client/dialogs.py b/telethon/client/dialogs.py new file mode 100644 index 00000000..c7b9c6d7 --- /dev/null +++ b/telethon/client/dialogs.py @@ -0,0 +1,136 @@ +import itertools +from collections import UserList + +from async_generator import async_generator, yield_ + +from .users import UserMethods +from .. import utils +from ..tl import types, functions, custom + + +class DialogMethods(UserMethods): + + # region Public methods + + @async_generator + async def iter_dialogs( + self, limit=None, offset_date=None, offset_id=0, + offset_peer=types.InputPeerEmpty(), _total=None): + """ + Returns an iterator over the dialogs, yielding 'limit' at most. + Dialogs are the open "chats" or conversations with other people, + groups you have joined, or channels you are subscribed to. + + Args: + limit (`int` | `None`): + How many dialogs to be retrieved as maximum. Can be set to + ``None`` to retrieve all dialogs. Note that this may take + whole minutes if you have hundreds of dialogs, as Telegram + will tell the library to slow down through a + ``FloodWaitError``. + + offset_date (`datetime`, optional): + The offset date to be used. + + offset_id (`int`, optional): + The message ID to be used as an offset. + + offset_peer (:tl:`InputPeer`, optional): + The peer to be used as an offset. + + _total (`list`, optional): + A single-item list to pass the total parameter by reference. + + Yields: + Instances of `telethon.tl.custom.dialog.Dialog`. + """ + limit = float('inf') if limit is None else int(limit) + if limit == 0: + if not _total: + return + # Special case, get a single dialog and determine count + dialogs = await self(functions.messages.GetDialogsRequest( + offset_date=offset_date, + offset_id=offset_id, + offset_peer=offset_peer, + limit=1 + )) + _total[0] = getattr(dialogs, 'count', len(dialogs.dialogs)) + return + + seen = set() + req = functions.messages.GetDialogsRequest( + offset_date=offset_date, + offset_id=offset_id, + offset_peer=offset_peer, + limit=0 + ) + while len(seen) < limit: + req.limit = min(limit - len(seen), 100) + r = await self(req) + + if _total: + _total[0] = getattr(r, 'count', len(r.dialogs)) + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats)} + messages = {m.id: custom.Message(self, m, entities, None) + for m in r.messages} + + # Happens when there are pinned dialogs + if len(r.dialogs) > limit: + r.dialogs = r.dialogs[:limit] + + for d in r.dialogs: + peer_id = utils.get_peer_id(d.peer) + if peer_id not in seen: + seen.add(peer_id) + await yield_(custom.Dialog(self, d, entities, messages)) + + if len(r.dialogs) < req.limit\ + or not isinstance(r, types.messages.DialogsSlice): + # Less than we requested means we reached the end, or + # we didn't get a DialogsSlice which means we got all. + break + + req.offset_date = r.messages[-1].date + req.offset_peer = entities[utils.get_peer_id(r.dialogs[-1].peer)] + req.offset_id = r.messages[-1].id + req.exclude_pinned = True + + async def get_dialogs(self, *args, **kwargs): + """ + Same as :meth:`iter_dialogs`, but returns a list instead + with an additional ``.total`` attribute on the list. + """ + total = [0] + kwargs['_total'] = total + dialogs = UserList() + async for x in self.iter_dialogs(*args, **kwargs): + dialogs.append(x) + dialogs.total = total[0] + return dialogs + + @async_generator + async def iter_drafts(self): # TODO: Ability to provide a `filter` + """ + Iterator over all open draft messages. + + Instances of `telethon.tl.custom.draft.Draft` are yielded. + You can call `telethon.tl.custom.draft.Draft.set_message` + to change the message or `telethon.tl.custom.draft.Draft.delete` + among other things. + """ + r = await self(functions.messages.GetAllDraftsRequest()) + for update in r.updates: + await yield_(custom.Draft._from_update(self, update)) + + async def get_drafts(self): + """ + Same as :meth:`iter_drafts`, but returns a list instead. + """ + result = [] + async for x in self.iter_drafts(): + result.append(x) + return result + + # endregion diff --git a/telethon/client/downloads.py b/telethon/client/downloads.py new file mode 100644 index 00000000..9fe65ec0 --- /dev/null +++ b/telethon/client/downloads.py @@ -0,0 +1,414 @@ +import datetime +import io +import logging +import os + +from .users import UserMethods +from .. import utils, helpers, errors +from ..crypto import CdnDecrypter +from ..tl import TLObject, types, functions + +__log__ = logging.getLogger(__name__) + + +class DownloadMethods(UserMethods): + + # region Public methods + + async def download_profile_photo( + self, entity, file=None, download_big=True): + """ + Downloads the profile photo of the given entity (user/chat/channel). + + Args: + entity (`entity`): + From who the photo will be downloaded. + + file (`str` | `file`, optional): + The output file path, directory, or stream-like object. + If the path exists and is a file, it will be overwritten. + + download_big (`bool`, optional): + Whether to use the big version of the available photos. + + Returns: + ``None`` if no photo was provided, or if it was Empty. On success + the file path is returned since it may differ from the one given. + """ + # hex(crc32(x.encode('ascii'))) for x in + # ('User', 'Chat', 'UserFull', 'ChatFull') + ENTITIES = (0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697) + # ('InputPeer', 'InputUser', 'InputChannel') + INPUTS = (0xc91c90b6, 0xe669bf46, 0x40f202fd) + if not isinstance(entity, TLObject) or entity.SUBCLASS_OF_ID in INPUTS: + entity = await self.get_entity(entity) + + possible_names = [] + if entity.SUBCLASS_OF_ID not in ENTITIES: + photo = entity + else: + if not hasattr(entity, 'photo'): + # Special case: may be a ChatFull with photo:Photo + # This is different from a normal UserProfilePhoto and Chat + if not hasattr(entity, 'chat_photo'): + return None + + return await self._download_photo( + entity.chat_photo, file, date=None, progress_callback=None) + + for attr in ('username', 'first_name', 'title'): + possible_names.append(getattr(entity, attr, None)) + + photo = entity.photo + + if isinstance(photo, (types.UserProfilePhoto, types.ChatPhoto)): + loc = photo.photo_big if download_big else photo.photo_small + else: + try: + loc = utils.get_input_location(photo) + except TypeError: + return None + + file = self._get_proper_filename( + file, 'profile_photo', '.jpg', + possible_names=possible_names + ) + + try: + await self.download_file(loc, file) + return file + except errors.LocationInvalidError: + # See issue #500, Android app fails as of v4.6.0 (1155). + # The fix seems to be using the full channel chat photo. + ie = await self.get_input_entity(entity) + if isinstance(ie, types.InputPeerChannel): + full = await self(functions.channels.GetFullChannelRequest(ie)) + return await self._download_photo( + full.full_chat.chat_photo, file, + date=None, progress_callback=None + ) + else: + # Until there's a report for chats, no need to. + return None + + async def download_media(self, message, file=None, progress_callback=None): + """ + Downloads the given media, or the media from a specified Message. + + Note that if the download is too slow, you should consider installing + ``cryptg`` (through ``pip install cryptg``) so that decrypting the + received data is done in C instead of Python (much faster). + + message (:tl:`Message` | :tl:`Media`): + The media or message containing the media that will be downloaded. + + file (`str` | `file`, optional): + The output file path, directory, or stream-like object. + If the path exists and is a file, it will be overwritten. + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(received bytes, total)``. + + Returns: + ``None`` if no media was provided, or if it was Empty. On success + the file path is returned since it may differ from the one given. + """ + # TODO This won't work for messageService + if isinstance(message, types.Message): + date = message.date + media = message.media + else: + date = datetime.datetime.now() + media = message + + if isinstance(media, types.MessageMediaWebPage): + if isinstance(media.webpage, types.WebPage): + media = media.webpage.document or media.webpage.photo + + if isinstance(media, (types.MessageMediaPhoto, types.Photo, + types.PhotoSize, types.PhotoCachedSize)): + return await self._download_photo( + media, file, date, progress_callback + ) + elif isinstance(media, (types.MessageMediaDocument, types.Document)): + return await self._download_document( + media, file, date, progress_callback + ) + elif isinstance(media, types.MessageMediaContact): + return self._download_contact( + media, file + ) + + async def download_file( + self, input_location, file=None, part_size_kb=None, + file_size=None, progress_callback=None): + """ + Downloads the given input location to a file. + + Args: + input_location (:tl:`FileLocation` | :tl:`InputFileLocation`): + The file location from which the file will be downloaded. + See `telethon.utils.get_input_location` source for a complete + list of supported types. + + file (`str` | `file`, optional): + The output file path, directory, or stream-like object. + If the path exists and is a file, it will be overwritten. + + If the file path is ``None``, then the result will be + saved in memory and returned as `bytes`. + + part_size_kb (`int`, optional): + Chunk size when downloading files. The larger, the less + requests will be made (up to 512KB maximum). + + file_size (`int`, optional): + The file size that is about to be downloaded, if known. + Only used if ``progress_callback`` is specified. + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(downloaded bytes, total)``. Note that the + ``total`` is the provided ``file_size``. + """ + if not part_size_kb: + if not file_size: + part_size_kb = 64 # Reasonable default + else: + part_size_kb = utils.get_appropriated_part_size(file_size) + + part_size = int(part_size_kb * 1024) + # https://core.telegram.org/api/files says: + # > part_size % 1024 = 0 (divisible by 1KB) + # + # But https://core.telegram.org/cdn (more recent) says: + # > limit must be divisible by 4096 bytes + # So we just stick to the 4096 limit. + if part_size % 4096 != 0: + raise ValueError( + 'The part size must be evenly divisible by 4096.') + + in_memory = file is None + if in_memory: + f = io.BytesIO() + elif isinstance(file, str): + # Ensure that we'll be able to download the media + helpers.ensure_parent_dir_exists(file) + f = open(file, 'wb') + else: + f = file + + # The used sender will change if ``FileMigrateError`` occurs + sender = self._sender + input_location = utils.get_input_location(input_location) + + __log__.info('Downloading file in chunks of %d bytes', part_size) + try: + offset = 0 + while True: + try: + result = await sender.send(functions.upload.GetFileRequest( + input_location, offset, part_size + )) + if isinstance(result, types.upload.FileCdnRedirect): + # TODO Implement + raise NotImplementedError + except errors.FileMigrateError as e: + __log__.info('File lives in another DC') + sender = await self._get_exported_sender(e.new_dc) + continue + + offset += part_size + if not result.bytes: + if in_memory: + f.flush() + return f.getvalue() + else: + return getattr(result, 'type', '') + + __log__.debug('Saving %d more bytes', len(result.bytes)) + f.write(result.bytes) + if progress_callback: + progress_callback(f.tell(), file_size) + finally: + if sender != self._sender: + await sender.disconnect() + if isinstance(file, str) or in_memory: + f.close() + + # endregion + + # region Private methods + + async def _download_photo(self, photo, file, date, progress_callback): + """Specialized version of .download_media() for photos""" + # Determine the photo and its largest size + if isinstance(photo, types.MessageMediaPhoto): + photo = photo.photo + if isinstance(photo, types.Photo): + for size in reversed(photo.sizes): + if not isinstance(size, types.PhotoSizeEmpty): + photo = size + break + else: + return + if not isinstance(photo, (types.PhotoSize, types.PhotoCachedSize)): + return + + file = self._get_proper_filename(file, 'photo', '.jpg', date=date) + if isinstance(photo, types.PhotoCachedSize): + # No need to download anything, simply write the bytes + if isinstance(file, str): + helpers.ensure_parent_dir_exists(file) + f = open(file, 'wb') + else: + f = file + try: + f.write(photo.bytes) + finally: + if isinstance(file, str): + f.close() + return file + + await self.download_file( + photo.location, file, file_size=photo.size, + progress_callback=progress_callback) + return file + + async def _download_document( + self, document, file, date, progress_callback): + """Specialized version of .download_media() for documents.""" + if isinstance(document, types.MessageMediaDocument): + document = document.document + if not isinstance(document, types.Document): + return + + file_size = document.size + + kind = 'document' + possible_names = [] + for attr in document.attributes: + if isinstance(attr, types.DocumentAttributeFilename): + possible_names.insert(0, attr.file_name) + + elif isinstance(attr, types.DocumentAttributeAudio): + kind = 'audio' + if attr.performer and attr.title: + possible_names.append('{} - {}'.format( + attr.performer, attr.title + )) + elif attr.performer: + possible_names.append(attr.performer) + elif attr.title: + possible_names.append(attr.title) + elif attr.voice: + kind = 'voice' + + file = self._get_proper_filename( + file, kind, utils.get_extension(document), + date=date, possible_names=possible_names + ) + + await self.download_file( + document, file, file_size=file_size, + progress_callback=progress_callback) + return file + + @classmethod + def _download_contact(cls, mm_contact, file): + """ + Specialized version of .download_media() for contacts. + Will make use of the vCard 4.0 format. + """ + first_name = mm_contact.first_name + last_name = mm_contact.last_name + phone_number = mm_contact.phone_number + + if isinstance(file, str): + file = cls._get_proper_filename( + file, 'contact', '.vcard', + possible_names=[first_name, phone_number, last_name] + ) + f = open(file, 'w', encoding='utf-8') + else: + f = file + + try: + # Remove these pesky characters + first_name = first_name.replace(';', '') + last_name = (last_name or '').replace(';', '') + f.write('BEGIN:VCARD\n') + f.write('VERSION:4.0\n') + f.write('N:{};{};;;\n'.format(first_name, last_name)) + f.write('FN:{} {}\n'.format(first_name, last_name)) + f.write('TEL;TYPE=cell;VALUE=uri:tel:+{}\n'.format(phone_number)) + f.write('END:VCARD\n') + finally: + # Only close the stream if we opened it + if isinstance(file, str): + f.close() + + return file + + @staticmethod + def _get_proper_filename(file, kind, extension, + date=None, possible_names=None): + """Gets a proper filename for 'file', if this is a path. + + 'kind' should be the kind of the output file (photo, document...) + 'extension' should be the extension to be added to the file if + the filename doesn't have any yet + 'date' should be when this file was originally sent, if known + 'possible_names' should be an ordered list of possible names + + If no modification is made to the path, any existing file + will be overwritten. + If any modification is made to the path, this method will + ensure that no existing file will be overwritten. + """ + if file is not None and not isinstance(file, str): + # Probably a stream-like object, we cannot set a filename here + return file + + if file is None: + file = '' + elif os.path.isfile(file): + # Make no modifications to valid existing paths + return file + + if os.path.isdir(file) or not file: + try: + name = None if possible_names is None else next( + x for x in possible_names if x + ) + except StopIteration: + name = None + + if not name: + if not date: + date = datetime.datetime.now() + name = '{}_{}-{:02}-{:02}_{:02}-{:02}-{:02}'.format( + kind, + date.year, date.month, date.day, + date.hour, date.minute, date.second, + ) + file = os.path.join(file, name) + + directory, name = os.path.split(file) + name, ext = os.path.splitext(name) + if not ext: + ext = extension + + result = os.path.join(directory, name + ext) + if not os.path.isfile(result): + return result + + i = 1 + while True: + result = os.path.join(directory, '{} ({}){}'.format(name, i, ext)) + if not os.path.isfile(result): + return result + i += 1 + + # endregion diff --git a/telethon/client/messageparse.py b/telethon/client/messageparse.py new file mode 100644 index 00000000..b0916cbe --- /dev/null +++ b/telethon/client/messageparse.py @@ -0,0 +1,129 @@ +import itertools +import re + +from .users import UserMethods +from .. import utils +from ..tl import types, custom + + +class MessageParseMethods(UserMethods): + + # region Public properties + + @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 = utils.sanitize_parse_mode(mode) + + # endregion + + # region Private methods + + 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 = utils.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 + + 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) + + # endregion diff --git a/telethon/client/messages.py b/telethon/client/messages.py new file mode 100644 index 00000000..e94a1eba --- /dev/null +++ b/telethon/client/messages.py @@ -0,0 +1,657 @@ +import asyncio +import itertools +import logging +import time +import warnings +from collections import UserList + +from async_generator import async_generator, yield_ + +from .messageparse import MessageParseMethods +from .uploads import UploadMethods +from .. import utils +from ..tl import types, functions, custom + +__log__ = logging.getLogger(__name__) + + +class MessageMethods(UploadMethods, MessageParseMethods): + + # region Public methods + + # region Message retrieval + + @async_generator + 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): + await 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 + + await 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 + + await asyncio.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() + async for x in self.iter_messages(*args, **kwargs): + msgs.append(x) + 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_generator + 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: + await 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): + await yield_(None) + else: + await yield_(custom.Message(self, message, entities, entity)) + + # endregion diff --git a/telethon/client/telegrambaseclient.py b/telethon/client/telegrambaseclient.py new file mode 100644 index 00000000..38de5f41 --- /dev/null +++ b/telethon/client/telegrambaseclient.py @@ -0,0 +1,370 @@ +import abc +import logging +import platform +import warnings +from datetime import timedelta, datetime + +from .. import version +from ..crypto import rsa +from ..extensions import markdown +from ..network import MTProtoSender, ConnectionTcpFull +from ..network.mtprotostate import MTProtoState +from ..sessions import Session, SQLiteSession +from ..tl import TLObject, functions +from ..tl.all_tlobjects import LAYER + +DEFAULT_DC_ID = 4 +DEFAULT_IPV4_IP = '149.154.167.51' +DEFAULT_IPV6_IP = '[2001:67c:4e8:f002::a]' +DEFAULT_PORT = 443 + +__log__ = logging.getLogger(__name__) + + +class TelegramBaseClient(abc.ABC): + """ + This is the abstract base class for the client. It defines some + basic stuff like connecting, switching data center, etc, and + leaves the `__call__` unimplemented. + + Args: + session (`str` | `telethon.sessions.abstract.Session`, `None`): + The file name of the session file to be used if a string is + given (it may be a full path), or the Session instance to be + used otherwise. If it's ``None``, the session will not be saved, + and you should call :meth:`.log_out()` when you're done. + + Note that if you pass a string it will be a file in the current + working directory, although you can also pass absolute paths. + + The session file contains enough information for you to login + without re-sending the code, so if you have to enter the code + more than once, maybe you're changing the working directory, + renaming or removing the file, or using random names. + + api_id (`int` | `str`): + The API ID you obtained from https://my.telegram.org. + + api_hash (`str`): + The API ID you obtained from https://my.telegram.org. + + connection (`telethon.network.connection.common.Connection`, optional): + The connection instance to be used when creating a new connection + to the servers. If it's a type, the `proxy` argument will be used. + + Defaults to `telethon.network.connection.tcpfull.ConnectionTcpFull`. + + use_ipv6 (`bool`, optional): + Whether to connect to the servers through IPv6 or not. + By default this is ``False`` as IPv6 support is not + too widespread yet. + + proxy (`tuple` | `dict`, optional): + A tuple consisting of ``(socks.SOCKS5, 'host', port)``. + See https://github.com/Anorov/PySocks#usage-1 for more. + + timeout (`int` | `float` | `timedelta`, optional): + The timeout to be used when receiving responses from + the network. Defaults to 5 seconds. + + report_errors (`bool`, optional): + Whether to report RPC errors or not. Defaults to ``True``, + see :ref:`api-status` for more information. + + device_model (`str`, optional): + "Device model" to be sent when creating the initial connection. + Defaults to ``platform.node()``. + + system_version (`str`, optional): + "System version" to be sent when creating the initial connection. + Defaults to ``platform.system()``. + + app_version (`str`, optional): + "App version" to be sent when creating the initial connection. + Defaults to `telethon.version.__version__`. + + lang_code (`str`, optional): + "Language code" to be sent when creating the initial connection. + Defaults to ``'en'``. + + system_lang_code (`str`, optional): + "System lang code" to be sent when creating the initial connection. + Defaults to `lang_code`. + """ + + # Current TelegramClient version + __version__ = version.__version__ + + # Cached server configuration (with .dc_options), can be "global" + _config = None + _cdn_config = None + + # region Initialization + + def __init__(self, session, api_id, api_hash, + *, + connection=ConnectionTcpFull, + use_ipv6=False, + proxy=None, + timeout=timedelta(seconds=5), + report_errors=True, + device_model=None, + system_version=None, + app_version=None, + lang_code='en', + system_lang_code='en'): + """Refer to TelegramClient.__init__ for docs on this method""" + if not api_id or not api_hash: + raise ValueError( + "Your API ID or Hash cannot be empty or None. " + "Refer to telethon.rtfd.io for more information.") + + self._use_ipv6 = use_ipv6 + + # Determine what session object we have + if isinstance(session, str) or session is None: + session = SQLiteSession(session) + elif not isinstance(session, Session): + raise TypeError( + 'The given session must be a str or a Session instance.' + ) + + # ':' in session.server_address is True if it's an IPv6 address + if (not session.server_address or + (':' in session.server_address) != use_ipv6): + session.set_dc( + DEFAULT_DC_ID, + DEFAULT_IPV6_IP if self._use_ipv6 else DEFAULT_IPV4_IP, + DEFAULT_PORT + ) + + session.report_errors = report_errors + self.session = session + self.api_id = int(api_id) + self.api_hash = api_hash + + # This is the main sender, which will be used from the thread + # that calls .connect(). Every other thread will spawn a new + # temporary connection. The connection on this one is always + # kept open so Telegram can send us updates. + if isinstance(connection, type): + connection = connection(proxy=proxy, timeout=timeout) + + # Used on connection. Capture the variables in a lambda since + # exporting clients need to create this InvokeWithLayerRequest. + system = platform.uname() + self._init_with = lambda x: functions.InvokeWithLayerRequest( + LAYER, functions.InitConnectionRequest( + api_id=self.api_id, + device_model=device_model or system.system or 'Unknown', + system_version=system_version or system.release or '1.0', + app_version=app_version or self.__version__, + lang_code=lang_code, + system_lang_code=system_lang_code, + lang_pack='', # "langPacks are for official apps only" + query=x + ) + ) + + state = MTProtoState(self.session.auth_key) + self._connection = connection + self._sender = MTProtoSender( + state, connection, + first_query=self._init_with(functions.help.GetConfigRequest()), + update_callback=self._handle_update + ) + + # Cache :tl:`ExportedAuthorization` as ``dc_id: MTProtoState`` + # to easily import them when getting an exported sender. + self._exported_auths = {} + + # Save whether the user is authorized here (a.k.a. logged in) + self._authorized = None # None = We don't know yet + + # Default PingRequest delay + self._last_ping = datetime.now() + self._ping_delay = timedelta(minutes=1) + + # Also have another delay for GetStateRequest. + # + # If the connection is kept alive for long without invoking any + # high level request the server simply stops sending updates. + # TODO maybe we can have ._last_request instead if any req works? + self._last_state = datetime.now() + self._state_delay = timedelta(hours=1) + + # Some further state for subclasses + self._event_builders = [] + self._events_pending_resolve = [] + + # Default parse mode + self._parse_mode = markdown + + # Some fields to easy signing in. Let {phone: hash} be + # a dictionary because the user may change their mind. + self._phone_code_hash = {} + self._phone = None + self._tos = None + + # Sometimes we need to know who we are, cache the self peer + self._self_input_peer = None + + # endregion + + # region Connecting + + async def connect(self): + """ + Connects to Telegram. + """ + had_auth = self.session.auth_key is not None + await self._sender.connect( + self.session.server_address, self.session.port) + + if not had_auth: + self.session.auth_key = self._sender.state.auth_key + self.session.save() + + def is_connected(self): + """ + Returns ``True`` if the user has connected. + """ + return self._sender.is_connected() + + async def disconnect(self): + """ + Disconnects from Telegram. + """ + await self._sender.disconnect() + # TODO What to do with the update state? Does it belong here? + # self.session.set_update_state(0, self.updates.get_update_state(0)) + self.session.close() + + async def _switch_dc(self, new_dc): + """ + Permanently switches the current connection to the new data center. + """ + __log__.info('Reconnecting to new data center %s', new_dc) + dc = await self._get_dc(new_dc) + + self.session.set_dc(dc.id, dc.ip_address, dc.port) + # auth_key's are associated with a server, which has now changed + # so it's not valid anymore. Set to None to force recreating it. + self.session.auth_key = self._sender.state.auth_key = None + self.session.save() + await self.disconnect() + return await self.connect() + + # endregion + + # region Working with different connections/Data Centers + + async def _get_dc(self, dc_id, cdn=False): + """Gets the Data Center (DC) associated to 'dc_id'""" + cls = self.__class__ + if not cls._config: + cls._config = await self(functions.help.GetConfigRequest()) + + if cdn and not self._cdn_config: + cls._cdn_config = await self(functions.help.GetCdnConfigRequest()) + for pk in cls._cdn_config.public_keys: + rsa.add_key(pk.public_key) + + return next( + dc for dc in cls._config.dc_options + if dc.id == dc_id + and bool(dc.ipv6) == self._use_ipv6 and bool(dc.cdn) == cdn + ) + + async def _get_exported_sender(self, dc_id): + """ + Returns a cached `MTProtoSender` for the given `dc_id`, or creates + a new one if it doesn't exist yet, and imports a freshly exported + authorization key for it to be usable. + """ + # Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt + # for clearly showing how to export the authorization + auth = self._exported_auths.get(dc_id) + dc = await self._get_dc(dc_id) + state = MTProtoState(auth) + # TODO Don't hardcode ConnectionTcpFull() + # Can't reuse self._sender._connection as it has its own seqno. + # + # If one were to do that, Telegram would reset the connection + # with no further clues. + sender = MTProtoSender(state, ConnectionTcpFull()) + await sender.connect(dc.ip_address, dc.port) + if not auth: + __log__.info('Exporting authorization for data center %s', dc) + auth = await self(functions.auth.ExportAuthorizationRequest(dc_id)) + req = self._init_with(functions.auth.ImportAuthorizationRequest( + id=auth.id, bytes=auth.bytes + )) + await sender.send(req) + self._exported_auths[dc_id] = sender.state.auth_key + + return sender + + async def _get_cdn_client(self, cdn_redirect): + """Similar to ._get_exported_client, but for CDNs""" + # TODO Implement + raise NotImplementedError + session = self._exported_sessions.get(cdn_redirect.dc_id) + if not session: + dc = await self._get_dc(cdn_redirect.dc_id, cdn=True) + session = self.session.clone() + session.set_dc(dc.id, dc.ip_address, dc.port) + self._exported_sessions[cdn_redirect.dc_id] = session + + __log__.info('Creating new CDN client') + client = TelegramBareClient( + session, self.api_id, self.api_hash, + proxy=self._sender.connection.conn.proxy, + timeout=self._sender.connection.get_timeout() + ) + + # This will make use of the new RSA keys for this specific CDN. + # + # We won't be calling GetConfigRequest because it's only called + # when needed by ._get_dc, and also it's static so it's likely + # set already. Avoid invoking non-CDN methods by not syncing updates. + client.connect(_sync_updates=False) + client._authorized = self._authorized + return client + + # endregion + + # region Invoking Telegram requests + + @abc.abstractmethod + def __call__(self, request, retries=5, ordered=False): + """ + Invokes (sends) one or more MTProtoRequests and returns (receives) + their result. + + Args: + request (`TLObject` | `list`): + The request or requests to be invoked. + + ordered (`bool`, optional): + Whether the requests (if more than one was given) should be + executed sequentially on the server. They run in arbitrary + order by default. + + Returns: + The result of the request (often a `TLObject`) or a list of + results if more than one request was given. + """ + raise NotImplementedError + + # Let people use client.invoke(SomeRequest()) instead client(...) + async def invoke(self, *args, **kwargs): + warnings.warn('client.invoke(...) is deprecated, ' + 'use client(...) instead') + return await self(*args, **kwargs) + + @abc.abstractmethod + def _handle_update(self, update): + raise NotImplementedError + + # endregion diff --git a/telethon/client/telegramclient.py b/telethon/client/telegramclient.py new file mode 100644 index 00000000..fecede72 --- /dev/null +++ b/telethon/client/telegramclient.py @@ -0,0 +1,13 @@ +from . import ( + UpdateMethods, AuthMethods, DownloadMethods, DialogMethods, + ChatMethods, MessageMethods, UploadMethods, MessageParseMethods, + UserMethods +) + + +class TelegramClient( + UpdateMethods, AuthMethods, DownloadMethods, DialogMethods, + ChatMethods, MessageMethods, UploadMethods, MessageParseMethods, + UserMethods +): + pass diff --git a/telethon/client/updates.py b/telethon/client/updates.py new file mode 100644 index 00000000..f5f83291 --- /dev/null +++ b/telethon/client/updates.py @@ -0,0 +1,179 @@ +import asyncio +import logging +import warnings + +from .users import UserMethods +from .. import events, utils +from ..tl import types, functions + +__log__ = logging.getLogger(__name__) + + +class UpdateMethods(UserMethods): + + # region Public methods + + def on(self, event): + """ + Decorator helper method around add_event_handler(). + + Args: + event (`_EventBuilder` | `type`): + The event builder class or instance to be used, + for instance ``events.NewMessage``. + """ + def decorator(f): + self.add_event_handler(f, event) + return f + + return decorator + + def add_event_handler(self, callback, event=None): + """ + Registers the given callback to be called on the specified event. + + Args: + callback (`callable`): + The callable function accepting one parameter to be used. + + event (`_EventBuilder` | `type`, optional): + The event builder class or instance to be used, + for instance ``events.NewMessage``. + + If left unspecified, `telethon.events.raw.Raw` (the + :tl:`Update` objects with no further processing) will + be passed instead. + """ + if isinstance(event, type): + event = event() + elif not event: + event = events.Raw() + + self._events_pending_resolve.append(event) + self._event_builders.append((event, callback)) + + def remove_event_handler(self, callback, event=None): + """ + Inverse operation of :meth:`add_event_handler`. + + If no event is given, all events for this callback are removed. + Returns how many callbacks were removed. + """ + found = 0 + if event and not isinstance(event, type): + event = type(event) + + i = len(self._event_builders) + while i: + i -= 1 + ev, cb = self._event_builders[i] + if cb == callback and (not event or isinstance(ev, event)): + del self._event_builders[i] + found += 1 + + return found + + def list_event_handlers(self): + """ + Lists all added event handlers, returning a list of pairs + consisting of (callback, event). + """ + return [(callback, event) for event, callback in self._event_builders] + + def add_update_handler(self, handler): + """Deprecated, see :meth:`add_event_handler`.""" + warnings.warn( + 'add_update_handler is deprecated, use the @client.on syntax ' + 'or add_event_handler(callback, events.Raw) instead (see ' + 'https://telethon.rtfd.io/en/latest/extra/basic/working-' + 'with-updates.html)' + ) + return self.add_event_handler(handler, events.Raw) + + def remove_update_handler(self, handler): + return self.remove_event_handler(handler) + + def list_update_handlers(self): + return [callback for callback, _ in self.list_event_handlers()] + + async def catch_up(self): + state = self.session.get_update_state(0) + if not state or not state.pts: + return + + self.session.catching_up = True + try: + while True: + d = await self(functions.updates.GetDifferenceRequest( + state.pts, state.date, state.qts)) + if isinstance(d, types.updates.DifferenceEmpty): + state.date = d.date + state.seq = d.seq + break + elif isinstance(d, (types.updates.DifferenceSlice, + types.updates.Difference)): + if isinstance(d, types.updates.Difference): + state = d.state + elif d.intermediate_state.pts > state.pts: + state = d.intermediate_state + else: + # TODO Figure out why other applications can rely on + # using always the intermediate_state to eventually + # reach a DifferenceEmpty, but that leads to an + # infinite loop here (so check against old pts to stop) + break + + self._handle_update(types.Updates( + users=d.users, + chats=d.chats, + date=state.date, + seq=state.seq, + updates=d.other_updates + [ + types.UpdateNewMessage(m, 0, 0) + for m in d.new_messages + ] + )) + elif isinstance(d, types.updates.DifferenceTooLong): + break + finally: + self.session.set_update_state(0, state) + self.session.catching_up = False + + # endregion + + # region Private methods + + def _handle_update(self, update): + asyncio.ensure_future(self._dispatch_update(update)) + + async def _dispatch_update(self, update): + if self._events_pending_resolve: + # TODO Add lock not to resolve them twice + for event in self._events_pending_resolve: + await event.resolve(self) + self._events_pending_resolve.clear() + + for builder, callback in self._event_builders: + event = builder.build(update) + if event: + if hasattr(event, '_set_client'): + event._set_client(self) + else: + event._client = self + + event.original_update = update + try: + await callback(event) + except events.StopPropagation: + __log__.debug( + "Event handler '{}' stopped chain of " + "propagation for event {}." + .format(callback.__name__, + type(event).__name__) + ) + break + except: + __log__.exception('Unhandled exception on {}' + .format(callback.__name__)) + + # endregion diff --git a/telethon/client/uploads.py b/telethon/client/uploads.py new file mode 100644 index 00000000..b6a162fe --- /dev/null +++ b/telethon/client/uploads.py @@ -0,0 +1,485 @@ +import hashlib +import io +import logging +import os +import warnings +from io import BytesIO +from mimetypes import guess_type + +from .messageparse import MessageParseMethods +from .users import UserMethods +from .. import utils, helpers +from ..tl import types, functions, custom + +try: + import hachoir + import hachoir.metadata + import hachoir.parser +except ImportError: + hachoir = None + +__log__ = logging.getLogger(__name__) + + +class UploadMethods(MessageParseMethods, 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 + + 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/users.py b/telethon/client/users.py new file mode 100644 index 00000000..583a873b --- /dev/null +++ b/telethon/client/users.py @@ -0,0 +1,264 @@ +import asyncio +import itertools + +from .telegrambaseclient import TelegramBaseClient +from .. import errors, utils +from ..tl import TLObject, TLRequest, types, functions + + +_NOT_A_REQUEST = TypeError('You can only invoke requests, not types!') + + +class UserMethods(TelegramBaseClient): + async def __call__(self, request, retries=5, ordered=False): + for r in (request if utils.is_list_like(request) else (request,)): + if not isinstance(r, TLRequest): + raise _NOT_A_REQUEST + await r.resolve(self, utils) + + for _ in range(retries): + try: + future = self._sender.send(request, ordered=ordered) + if isinstance(future, list): + results = [] + for f in future: + results.append(await f) + return results + else: + return await future + except (errors.ServerError, errors.RpcCallFailError): + pass + except (errors.FloodWaitError, errors.FloodTestPhoneWaitError) as e: + if e.seconds <= self.session.flood_sleep_threshold: + await asyncio.sleep(e.seconds) + else: + raise + except (errors.PhoneMigrateError, errors.NetworkMigrateError, + errors.UserMigrateError) as e: + await self._switch_dc(e.new_dc) + + raise ValueError('Number of retries reached 0') + + # region Public methods + + async def get_me(self, input_peer=False): + """ + Gets "me" (the self user) which is currently authenticated, + or None if the request fails (hence, not authenticated). + + Args: + input_peer (`bool`, optional): + Whether to return the :tl:`InputPeerUser` version or the normal + :tl:`User`. This can be useful if you just need to know the ID + of yourself. + + Returns: + Your own :tl:`User`. + """ + if input_peer and self._self_input_peer: + return self._self_input_peer + + try: + me = (await self( + functions.users.GetUsersRequest([types.InputUserSelf()])))[0] + + if not self._self_input_peer: + self._self_input_peer = utils.get_input_peer( + me, allow_self=False + ) + + return self._self_input_peer if input_peer else me + except errors.UnauthorizedError: + return None + + async def get_entity(self, entity): + """ + Turns the given entity into a valid Telegram user or chat. + + entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): + The entity (or iterable of entities) to be transformed. + If it's a string which can be converted to an integer or starts + with '+' it will be resolved as if it were a phone number. + + If it doesn't start with '+' or starts with a '@' it will be + be resolved from the username. If no exact match is returned, + an error will be raised. + + If the entity is an integer or a Peer, its information will be + returned through a call to self.get_input_peer(entity). + + If the entity is neither, and it's not a TLObject, an + error will be raised. + + Returns: + :tl:`User`, :tl:`Chat` or :tl:`Channel` corresponding to the + input entity. A list will be returned if more than one was given. + """ + single = not utils.is_list_like(entity) + if single: + entity = (entity,) + + # Group input entities by string (resolve username), + # input users (get users), input chat (get chats) and + # input channels (get channels) to get the most entities + # in the less amount of calls possible. + inputs = [ + x if isinstance(x, str) else await self.get_input_entity(x) + for x in entity + ] + users = [x for x in inputs + if isinstance(x, (types.InputPeerUser, types.InputPeerSelf))] + chats = [x.chat_id for x in inputs + if isinstance(x, types.InputPeerChat)] + channels = [x for x in inputs + if isinstance(x, types.InputPeerChannel)] + if users: + # GetUsersRequest has a limit of 200 per call + tmp = [] + while users: + curr, users = users[:200], users[200:] + tmp.extend(await self(functions.users.GetUsersRequest(curr))) + users = tmp + if chats: # TODO Handle chats slice? + chats = (await self( + functions.messages.GetChatsRequest(chats))).chats + if channels: + channels = (await self( + functions.channels.GetChannelsRequest(channels))).chats + + # Merge users, chats and channels into a single dictionary + id_entity = { + utils.get_peer_id(x): x + for x in itertools.chain(users, chats, channels) + } + + # We could check saved usernames and put them into the users, + # chats and channels list from before. While this would reduce + # the amount of ResolveUsername calls, it would fail to catch + # username changes. + result = [ + await self._get_entity_from_string(x) if isinstance(x, str) + else ( + id_entity[utils.get_peer_id(x)] + if not isinstance(x, types.InputPeerSelf) + else next(u for u in id_entity.values() + if isinstance(u, types.User) and u.is_self) + ) + for x in inputs + ] + return result[0] if single else result + + async def get_input_entity(self, peer): + """ + Turns the given peer into its input entity version. Most requests + use this kind of InputUser, InputChat and so on, so this is the + most suitable call to make for those cases. + + entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): + The integer ID of an user or otherwise either of a + :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`, for + which to get its ``Input*`` version. + + If this ``Peer`` hasn't been seen before by the library, the top + dialogs will be loaded and their entities saved to the session + file (unless this feature was disabled explicitly). + + If in the end the access hash required for the peer was not found, + a ValueError will be raised. + + Returns: + :tl:`InputPeerUser`, :tl:`InputPeerChat` or :tl:`InputPeerChannel` + or :tl:`InputPeerSelf` if the parameter is ``'me'`` or ``'self'``. + + If you need to get the ID of yourself, you should use + `get_me` with ``input_peer=True``) instead. + """ + if peer in ('me', 'self'): + return types.InputPeerSelf() + + try: + # First try to get the entity from cache, otherwise figure it out + return self.session.get_input_entity(peer) + except ValueError: + pass + + if isinstance(peer, str): + return utils.get_input_peer( + await self._get_entity_from_string(peer)) + + if not isinstance(peer, int) and (not isinstance(peer, TLObject) + or peer.SUBCLASS_OF_ID != 0x2d45687): + # Try casting the object into an input peer. Might TypeError. + # Don't do it if a not-found ID was given (instead ValueError). + # Also ignore Peer (0x2d45687 == crc32(b'Peer'))'s, lacking hash. + return utils.get_input_peer(peer) + + raise ValueError( + 'Could not find the input entity for "{}". Please read https://' + 'telethon.readthedocs.io/en/latest/extra/basic/entities.html to' + ' find out more details.' + .format(peer) + ) + + # endregion + + # region Private methods + + async def _get_entity_from_string(self, string): + """ + Gets a full entity from the given string, which may be a phone or + an username, and processes all the found entities on the session. + The string may also be a user link, or a channel/chat invite link. + + This method has the side effect of adding the found users to the + session database, so it can be queried later without API calls, + if this option is enabled on the session. + + Returns the found entity, or raises TypeError if not found. + """ + phone = utils.parse_phone(string) + if phone: + for user in (await self( + functions.contacts.GetContactsRequest(0))).users: + if user.phone == phone: + return user + else: + username, is_join_chat = utils.parse_username(string) + if is_join_chat: + invite = await self( + functions.messages.CheckChatInviteRequest(username)) + + if isinstance(invite, types.ChatInvite): + raise ValueError( + 'Cannot get entity from a channel (or group) ' + 'that you are not part of. Join the group and retry' + ) + elif isinstance(invite, types.ChatInviteAlready): + return invite.chat + elif username: + if username in ('me', 'self'): + return await self.get_me() + + try: + result = await self( + functions.contacts.ResolveUsernameRequest(username)) + except errors.UsernameNotOccupiedError as e: + raise ValueError('No user has "{}" as username' + .format(username)) from e + + for entity in itertools.chain(result.users, result.chats): + if getattr(entity, 'username', None) or '' \ + .lower() == username: + return entity + try: + # Nobody with this username, maybe it's an exact name/title + return await self.get_entity( + self.session.get_input_entity(string)) + except ValueError: + pass + + raise ValueError( + 'Cannot find any entity corresponding to "{}"'.format(string) + ) + + # endregion diff --git a/telethon/crypto/auth_key.py b/telethon/crypto/auth_key.py index a6c0675b..31d1fc9c 100644 --- a/telethon/crypto/auth_key.py +++ b/telethon/crypto/auth_key.py @@ -37,4 +37,4 @@ class AuthKey: data = new_nonce + struct.pack(' MAX_TIMEOUT: - raise - else: - raise - - def _get_connected(self): - """Determines whether the client is connected or not.""" - return self._socket is not None and self._socket.fileno() >= 0 - - connected = property(fget=_get_connected) - - def close(self): - """Closes the connection.""" - if self._closing_lock.locked(): - # Already closing, no need to close again (avoid None.close()) - return - - with self._closing_lock: - try: - if self._socket is not None: - self._socket.shutdown(socket.SHUT_RDWR) - self._socket.close() - except OSError: - pass # Ignore ENOTCONN, EBADF, and any other error when closing - finally: - self._socket = None - - def write(self, data): - """ - Writes (sends) the specified bytes to the connected peer. - - :param data: the data to send. - """ - if self._socket is None: - self._raise_connection_reset(None) - - # TODO Timeout may be an issue when sending the data, Changed in v3.5: - # The socket timeout is now the maximum total duration to send all data. try: - self._socket.sendall(data) - except socket.timeout as e: - __log__.debug('socket.timeout "%s" while writing data', e) - raise TimeoutError() from e - except ConnectionError as e: - __log__.info('ConnectionError "%s" while writing data', e) - self._raise_connection_reset(e) + if self._socket is None: + self._socket = self._create_socket(mode, self.proxy) + + await asyncio.wait_for( + self._loop.sock_connect(self._socket, address), + timeout=self.timeout, + loop=self._loop + ) + self._closed.clear() except OSError as e: - __log__.info('OSError "%s" while writing data', e) if e.errno in CONN_RESET_ERRNOS: - self._raise_connection_reset(e) + raise ConnectionResetError() from e else: raise - def read(self, size): + @property + def is_connected(self): + """Determines whether the client is connected or not.""" + return not self._closed.is_set() + + def close(self): + """Closes the connection.""" + try: + if self._socket is not None: + if self.is_connected: + self._socket.shutdown(socket.SHUT_RDWR) + self._socket.close() + except OSError: + pass # Ignore ENOTCONN, EBADF, and any other error when closing + finally: + self._socket = None + self._closed.set() + + async def _wait_timeout_or_close(self, coro): + """ + Waits for the given coroutine to complete unless + the socket is closed or `self.timeout` expires. + """ + done, running = await asyncio.wait( + [coro, self._closed.wait()], + timeout=self.timeout, + return_when=asyncio.FIRST_COMPLETED, + loop=self._loop + ) + for r in running: + r.cancel() + if not self.is_connected: + raise self.SocketClosed() + if not done: + raise asyncio.TimeoutError() + return done.pop().result() + + async def write(self, data): + """ + Writes (sends) the specified bytes to the connected peer. + :param data: the data to send. + """ + if not self.is_connected: + raise ConnectionResetError('Not connected') + + try: + await self._wait_timeout_or_close(self.sock_sendall(data)) + except OSError as e: + if e.errno in CONN_RESET_ERRNOS: + raise ConnectionResetError() from e + else: + raise + + async def read(self, size): """ Reads (receives) a whole block of size bytes from the connected peer. :param size: the size of the block to be read. :return: the read data with len(data) == size. """ - if self._socket is None: - self._raise_connection_reset(None) + if not self.is_connected: + raise ConnectionResetError('Not connected') - with BufferedWriter(BytesIO(), buffer_size=size) as buffer: + with BytesIO() as buffer: bytes_left = size while bytes_left != 0: try: - partial = self._socket.recv(bytes_left) - except socket.timeout as e: - # These are somewhat common if the server has nothing - # to send to us, so use a lower logging priority. + partial = await self._wait_timeout_or_close( + self.sock_recv(bytes_left) + ) + except asyncio.TimeoutError: if bytes_left < size: __log__.warning( - 'socket.timeout "%s" when %d/%d had been received', - e, size - bytes_left, size + 'Timeout when partial %d/%d had been received', + size - bytes_left, size ) - else: - __log__.debug( - 'socket.timeout "%s" while reading data', e - ) - - raise TimeoutError() from e - except ConnectionError as e: - __log__.info('ConnectionError "%s" while reading data', e) - self._raise_connection_reset(e) + raise except OSError as e: - if e.errno != errno.EBADF and self._closing_lock.locked(): - # Ignore bad file descriptor while closing - __log__.info('OSError "%s" while reading data', e) - if e.errno in CONN_RESET_ERRNOS: - self._raise_connection_reset(e) + raise ConnectionResetError() from e else: raise - if len(partial) == 0: - self._raise_connection_reset(None) + if not partial: + raise ConnectionResetError() buffer.write(partial) bytes_left -= len(partial) - # If everything went fine, return the read bytes - buffer.flush() - return buffer.raw.getvalue() + return buffer.getvalue() - def _raise_connection_reset(self, original): - """Disconnects the client and raises ConnectionResetError.""" - self.close() # Connection reset -> flag as socket closed - raise ConnectionResetError('The server has closed the connection.')\ - from original + # Due to recent https://github.com/python/cpython/pull/4386 + # Credit to @andr-04 for his original implementation + def sock_recv(self, n): + fut = self._loop.create_future() + self._sock_recv(fut, None, n) + return fut + + def _sock_recv(self, fut, registered_fd, n): + if registered_fd is not None: + self._loop.remove_reader(registered_fd) + if fut.cancelled() or self._socket is None: + return + + try: + data = self._socket.recv(n) + except (BlockingIOError, InterruptedError): + fd = self._socket.fileno() + self._loop.add_reader(fd, self._sock_recv, fut, fd, n) + except Exception as exc: + fut.set_exception(exc) + else: + fut.set_result(data) + + def sock_sendall(self, data): + fut = self._loop.create_future() + if data: + self._sock_sendall(fut, None, data) + else: + fut.set_result(None) + return fut + + def _sock_sendall(self, fut, registered_fd, data): + if registered_fd: + self._loop.remove_writer(registered_fd) + if fut.cancelled() or self._socket is None: + return + + try: + n = self._socket.send(data) + except (BlockingIOError, InterruptedError): + n = 0 + except Exception as exc: + fut.set_exception(exc) + return + + if n == len(data): + fut.set_result(None) + else: + if n: + data = data[n:] + fd = self._socket.fileno() + self._loop.add_writer(fd, self._sock_sendall, fut, fd, data) diff --git a/telethon/helpers.py b/telethon/helpers.py index 9ca91e4f..de66813f 100644 --- a/telethon/helpers.py +++ b/telethon/helpers.py @@ -1,12 +1,7 @@ """Various helpers not related to the Telegram API itself""" import os -import struct from hashlib import sha1, sha256 -from telethon.crypto import AES -from telethon.errors import SecurityError -from telethon.extensions import BinaryReader - # region Multiple utilities @@ -27,70 +22,6 @@ def ensure_parent_dir_exists(file_path): # region Cryptographic related utils -def pack_message(session, message): - """Packs a message following MtProto 2.0 guidelines""" - # See https://core.telegram.org/mtproto/description - data = struct.pack('= new_msg_id: - new_msg_id = self._last_msg_id + 4 - - self._last_msg_id = new_msg_id - return new_msg_id diff --git a/telethon/network/mtproto_sender.py b/telethon/network/mtproto_sender.py deleted file mode 100644 index e282abea..00000000 --- a/telethon/network/mtproto_sender.py +++ /dev/null @@ -1,590 +0,0 @@ -""" -This module contains the class used to communicate with Telegram's servers -encrypting every packet, and relies on a valid AuthKey in the used Session. -""" -import logging -from threading import Lock - -from .. import helpers, utils -from ..errors import ( - BadMessageError, InvalidChecksumError, BrokenAuthKeyError, - rpc_message_to_error -) -from ..extensions import BinaryReader -from ..tl import TLMessage, MessageContainer, GzipPacked -from ..tl.all_tlobjects import tlobjects -from ..tl.functions import InvokeAfterMsgRequest -from ..tl.functions.auth import LogOutRequest -from ..tl.types import ( - MsgsAck, Pong, BadServerSalt, BadMsgNotification, FutureSalts, - MsgNewDetailedInfo, NewSessionCreated, MsgDetailedInfo -) - -__log__ = logging.getLogger(__name__) - - -class MtProtoSender: - """ - MTProto Mobile Protocol sender - (https://core.telegram.org/mtproto/description). - - Note that this class is not thread-safe, and calling send/receive - from two or more threads at the same time is undefined behaviour. - Rationale: - a new connection should be spawned to send/receive requests - in parallel, so thread-safety (hence locking) isn't needed. - """ - - def __init__(self, session, connection): - """ - Initializes a new MTProto sender. - - :param session: - the Session to be used with this sender. Must contain the IP and - port of the server, salt, ID, and AuthKey, - :param connection: - the Connection to be used. - """ - self.session = session - self.connection = connection - - # Message IDs that need confirmation - self._need_confirmation = set() - - # Requests (as msg_id: Message) sent waiting to be received - self._pending_receive = {} - - # Multithreading - self._send_lock = Lock() - - # If we're invoking something from an update thread but we're also - # receiving other request from the main thread (e.g. an update arrives - # and we need to process it) we must ensure that only one is calling - # receive at a given moment, since the receive step is fragile. - self._recv_lock = Lock() - - def connect(self): - """Connects to the server.""" - self.connection.connect(self.session.server_address, self.session.port) - - def is_connected(self): - """ - Determines whether the sender is connected or not. - - :return: true if the sender is connected. - """ - return self.connection.is_connected() - - def disconnect(self): - """Disconnects from the server.""" - __log__.info('Disconnecting MtProtoSender...') - self.connection.close() - self._clear_all_pending() - - # region Send and receive - - def send(self, requests, ordered=False): - """ - Sends the specified TLObject(s) (which must be requests), - and acknowledging any message which needed confirmation. - - :param requests: the requests to be sent. - :param ordered: whether the requests should be invoked in the - order in which they appear or they can be executed - in arbitrary order in the server. - """ - if not utils.is_list_like(requests): - requests = (requests,) - - if ordered: - requests = iter(requests) - messages = [TLMessage(self.session, next(requests))] - for r in requests: - messages.append(TLMessage(self.session, r, - after_id=messages[-1].msg_id)) - else: - messages = [TLMessage(self.session, r) for r in requests] - - self._pending_receive.update({m.msg_id: m for m in messages}) - - __log__.debug('Sending requests with IDs: %s', ', '.join( - '{}: {}'.format(m.request.__class__.__name__, m.msg_id) - for m in messages - )) - - # Pack everything in the same container if we need to send AckRequests - if self._need_confirmation: - messages.append( - TLMessage(self.session, MsgsAck(list(self._need_confirmation))) - ) - self._need_confirmation.clear() - - if len(messages) == 1: - message = messages[0] - else: - message = TLMessage(self.session, MessageContainer(messages)) - # On bad_msg_salt errors, Telegram will reply with the ID of - # the container and not the requests it contains, so in case - # this happens we need to know to which container they belong. - for m in messages: - m.container_msg_id = message.msg_id - - self._send_message(message) - - def _send_acknowledge(self, msg_id): - """Sends a message acknowledge for the given msg_id.""" - self._send_message(TLMessage(self.session, MsgsAck([msg_id]))) - - def receive(self, update_state): - """ - Receives a single message from the connected endpoint. - - This method returns nothing, and will only affect other parts - of the MtProtoSender such as the updates callback being fired - or a pending request being confirmed. - - Any unhandled object (likely updates) will be passed to - update_state.process(TLObject). - - :param update_state: - the UpdateState that will process all the received - Update and Updates objects. - """ - if self._recv_lock.locked(): - with self._recv_lock: - # Don't busy wait, acquire it but return because there's - # already a receive running and we don't want another one. - # It would lock until Telegram sent another update even if - # the current receive already received the expected response. - return - - try: - with self._recv_lock: - body = self.connection.recv() - except (BufferError, InvalidChecksumError): - # TODO BufferError, we should spot the cause... - # "No more bytes left"; something wrong happened, clear - # everything to be on the safe side, or: - # - # "This packet should be skipped"; since this may have - # been a result for a request, invalidate every request - # and just re-invoke them to avoid problems - __log__.exception('Error while receiving server response. ' - '%d pending request(s) will be ignored', - len(self._pending_receive)) - self._clear_all_pending() - return - - message, remote_msg_id, remote_seq = self._decode_msg(body) - with BinaryReader(message) as reader: - self._process_msg(remote_msg_id, remote_seq, reader, update_state) - - # endregion - - # region Low level processing - - def _send_message(self, message): - """ - Sends the given encrypted through the network. - - :param message: the TLMessage to be sent. - """ - with self._send_lock: - self.connection.send(helpers.pack_message(self.session, message)) - - def _decode_msg(self, body): - """ - Decodes the body of the payload received from the network. - - :param body: the body to be decoded. - :return: a tuple of (decoded message, remote message id, remote seq). - """ - if len(body) < 8: - if body == b'l\xfe\xff\xff': - raise BrokenAuthKeyError() - else: - raise BufferError("Can't decode packet ({})".format(body)) - - with BinaryReader(body) as reader: - return helpers.unpack_message(self.session, reader) - - def _process_msg(self, msg_id, sequence, reader, state): - """ - Processes the message read from the network inside reader. - - :param msg_id: the ID of the message. - :param sequence: the sequence of the message. - :param reader: the BinaryReader that contains the message. - :param state: the current UpdateState. - :return: true if the message was handled correctly, false otherwise. - """ - # TODO Check salt, session_id and sequence_number - self._need_confirmation.add(msg_id) - - code = reader.read_int(signed=False) - reader.seek(-4) - - # These are a bit of special case, not yet generated by the code gen - if code == 0xf35c6d01: # rpc_result, (response of an RPC call) - __log__.debug('Processing Remote Procedure Call result') - return self._handle_rpc_result(msg_id, sequence, reader) - - if code == MessageContainer.CONSTRUCTOR_ID: - __log__.debug('Processing container result') - return self._handle_container(msg_id, sequence, reader, state) - - if code == GzipPacked.CONSTRUCTOR_ID: - __log__.debug('Processing gzipped result') - return self._handle_gzip_packed(msg_id, sequence, reader, state) - - if code not in tlobjects: - __log__.warning( - 'Unknown message with ID %d, data left in the buffer %s', - hex(code), repr(reader.get_bytes()[reader.tell_position():]) - ) - return False - - obj = reader.tgread_object() - __log__.debug('Processing %s result', type(obj).__name__) - - if isinstance(obj, Pong): - return self._handle_pong(msg_id, sequence, obj) - - if isinstance(obj, BadServerSalt): - return self._handle_bad_server_salt(msg_id, sequence, obj) - - if isinstance(obj, BadMsgNotification): - return self._handle_bad_msg_notification(msg_id, sequence, obj) - - if isinstance(obj, MsgDetailedInfo): - return self._handle_msg_detailed_info(msg_id, sequence, obj) - - if isinstance(obj, MsgNewDetailedInfo): - return self._handle_msg_new_detailed_info(msg_id, sequence, obj) - - if isinstance(obj, NewSessionCreated): - return self._handle_new_session_created(msg_id, sequence, obj) - - if isinstance(obj, MsgsAck): # may handle the request we wanted - # Ignore every ack request *unless* when logging out, when it's - # when it seems to only make sense. We also need to set a non-None - # result since Telegram doesn't send the response for these. - for msg_id in obj.msg_ids: - r = self._pop_request_of_type(msg_id, LogOutRequest) - if r: - r.result = True # Telegram won't send this value - r.confirm_received.set() - __log__.debug('Confirmed %s through ack', type(r).__name__) - - return True - - if isinstance(obj, FutureSalts): - r = self._pop_request(obj.req_msg_id) - if r: - r.result = obj - r.confirm_received.set() - __log__.debug('Confirmed %s through salt', type(r).__name__) - - # If the object isn't any of the above, then it should be an Update. - self.session.process_entities(obj) - if state: - state.process(obj) - - return True - - # endregion - - # region Message handling - - def _pop_request(self, msg_id): - """ - Pops a pending **request** from self._pending_receive. - - :param msg_id: the ID of the message that belongs to the request. - :return: the request, or None if it wasn't found. - """ - message = self._pending_receive.pop(msg_id, None) - if message: - return message.request - - def _pop_request_of_type(self, msg_id, t): - """ - Pops a pending **request** from self._pending_receive. - - :param msg_id: the ID of the message that belongs to the request. - :param t: the type of the desired request. - :return: the request matching the type t, or None if it wasn't found. - """ - message = self._pending_receive.get(msg_id, None) - if message and isinstance(message.request, t): - return self._pending_receive.pop(msg_id).request - - def _pop_requests_of_container(self, container_msg_id): - """ - Pops pending **requests** from self._pending_receive. - - :param container_msg_id: the ID of the container. - :return: the requests that belong to the given container. May be empty. - """ - msgs = [msg for msg in self._pending_receive.values() - if msg.container_msg_id == container_msg_id] - - requests = [msg.request for msg in msgs] - for msg in msgs: - self._pending_receive.pop(msg.msg_id, None) - return requests - - def _clear_all_pending(self): - """ - Clears all pending requests, and flags them all as received. - """ - for r in self._pending_receive.values(): - r.request.confirm_received.set() - __log__.info('Abruptly confirming %s', type(r).__name__) - self._pending_receive.clear() - - def _resend_request(self, msg_id): - """ - Re-sends the request that belongs to a certain msg_id. This may - also be the msg_id of a container if they were sent in one. - - :param msg_id: the ID of the request to be resent. - """ - request = self._pop_request(msg_id) - if request: - return self.send(request) - requests = self._pop_requests_of_container(msg_id) - if requests: - return self.send(*requests) - - def _handle_pong(self, msg_id, sequence, pong): - """ - Handles a Pong response. - - :param msg_id: the ID of the message. - :param sequence: the sequence of the message. - :param reader: the reader containing the Pong. - :return: true, as it always succeeds. - """ - request = self._pop_request(pong.msg_id) - if request: - request.result = pong - request.confirm_received.set() - __log__.debug('Confirmed %s through pong', type(request).__name__) - - return True - - def _handle_container(self, msg_id, sequence, reader, state): - """ - Handles a MessageContainer response. - - :param msg_id: the ID of the message. - :param sequence: the sequence of the message. - :param reader: the reader containing the MessageContainer. - :return: true, as it always succeeds. - """ - for inner_msg_id, _, inner_len in MessageContainer.iter_read(reader): - begin_position = reader.tell_position() - - # Note that this code is IMPORTANT for skipping RPC results of - # lost requests (i.e., ones from the previous connection session) - try: - if not self._process_msg(inner_msg_id, sequence, reader, state): - reader.set_position(begin_position + inner_len) - except: - # If any error is raised, something went wrong; skip the packet - reader.set_position(begin_position + inner_len) - raise - - return True - - def _handle_bad_server_salt(self, msg_id, sequence, bad_salt): - """ - Handles a BadServerSalt response. - - :param msg_id: the ID of the message. - :param sequence: the sequence of the message. - :param reader: the reader containing the BadServerSalt. - :return: true, as it always succeeds. - """ - self.session.salt = bad_salt.new_server_salt - self.session.save() - - # "the bad_server_salt response is received with the - # correct salt, and the message is to be re-sent with it" - self._resend_request(bad_salt.bad_msg_id) - return True - - def _handle_bad_msg_notification(self, msg_id, sequence, bad_msg): - """ - Handles a BadMessageError response. - - :param msg_id: the ID of the message. - :param sequence: the sequence of the message. - :param reader: the reader containing the BadMessageError. - :return: true, as it always succeeds. - """ - error = BadMessageError(bad_msg.error_code) - __log__.warning('Read bad msg notification %s: %s', bad_msg, error) - if bad_msg.error_code in (16, 17): - # sent msg_id too low or too high (respectively). - # Use the current msg_id to determine the right time offset. - self.session.update_time_offset(correct_msg_id=msg_id) - __log__.info('Attempting to use the correct time offset') - self._resend_request(bad_msg.bad_msg_id) - return True - elif bad_msg.error_code == 32: - # msg_seqno too low, so just pump it up by some "large" amount - # TODO A better fix would be to start with a new fresh session ID - self.session.sequence += 64 - __log__.info('Attempting to set the right higher sequence') - self._resend_request(bad_msg.bad_msg_id) - return True - elif bad_msg.error_code == 33: - # msg_seqno too high never seems to happen but just in case - self.session.sequence -= 16 - __log__.info('Attempting to set the right lower sequence') - self._resend_request(bad_msg.bad_msg_id) - return True - else: - raise error - - def _handle_msg_detailed_info(self, msg_id, sequence, msg_new): - """ - Handles a MsgDetailedInfo response. - - :param msg_id: the ID of the message. - :param sequence: the sequence of the message. - :param reader: the reader containing the MsgDetailedInfo. - :return: true, as it always succeeds. - """ - # TODO For now, simply ack msg_new.answer_msg_id - # Relevant tdesktop source code: https://goo.gl/VvpCC6 - self._send_acknowledge(msg_new.answer_msg_id) - return True - - def _handle_msg_new_detailed_info(self, msg_id, sequence, msg_new): - """ - Handles a MsgNewDetailedInfo response. - - :param msg_id: the ID of the message. - :param sequence: the sequence of the message. - :param reader: the reader containing the MsgNewDetailedInfo. - :return: true, as it always succeeds. - """ - # TODO For now, simply ack msg_new.answer_msg_id - # Relevant tdesktop source code: https://goo.gl/G7DPsR - self._send_acknowledge(msg_new.answer_msg_id) - return True - - def _handle_new_session_created(self, msg_id, sequence, new_session): - """ - Handles a NewSessionCreated response. - - :param msg_id: the ID of the message. - :param sequence: the sequence of the message. - :param reader: the reader containing the NewSessionCreated. - :return: true, as it always succeeds. - """ - self.session.salt = new_session.server_salt - # TODO https://goo.gl/LMyN7A - return True - - def _handle_rpc_result(self, msg_id, sequence, reader): - """ - Handles a RPCResult response. - - :param msg_id: the ID of the message. - :param sequence: the sequence of the message. - :param reader: the reader containing the RPCResult. - :return: true if the request ID to which this result belongs is found, - false otherwise (meaning nothing was read). - """ - reader.read_int(signed=False) # code - request_id = reader.read_long() - inner_code = reader.read_int(signed=False) - reader.seek(-4) - - __log__.debug('Received response for request with ID %d', request_id) - request = self._pop_request(request_id) - - if inner_code == 0x2144ca19: # RPC Error - reader.seek(4) - if self.session.report_errors and request: - error = rpc_message_to_error( - reader.read_int(), reader.tgread_string(), - report_method=type(request).CONSTRUCTOR_ID - ) - else: - error = rpc_message_to_error( - reader.read_int(), reader.tgread_string() - ) - - # Acknowledge that we received the error - self._send_acknowledge(request_id) - - if request: - request.rpc_error = error - request.confirm_received.set() - - __log__.debug('Confirmed %s through error %s', - type(request).__name__, error) - # else TODO Where should this error be reported? - # Read may be async. Can an error not-belong to a request? - return True # All contents were read okay - - elif request: - if inner_code == GzipPacked.CONSTRUCTOR_ID: - with BinaryReader(GzipPacked.read(reader)) as compressed_reader: - request.on_response(compressed_reader) - else: - request.on_response(reader) - - self.session.process_entities(request.result) - request.confirm_received.set() - __log__.debug( - 'Confirmed %s through normal result %s', - type(request).__name__, type(request.result).__name__ - ) - return True - - # If it's really a result for RPC from previous connection - # session, it will be skipped by the handle_container(). - # For some reason this also seems to happen when downloading - # photos, where the server responds with FileJpeg(). - def _try_read(r): - try: - return r.tgread_object() - except Exception as e: - return '(failed to read: {})'.format(e) - - if inner_code == GzipPacked.CONSTRUCTOR_ID: - with BinaryReader(GzipPacked.read(reader)) as compressed_reader: - obj = _try_read(compressed_reader) - else: - obj = _try_read(reader) - - __log__.warning( - 'Lost request (ID %d) with code %s will be skipped, contents: %s', - request_id, hex(inner_code), obj - ) - return False - - def _handle_gzip_packed(self, msg_id, sequence, reader, state): - """ - Handles a GzipPacked response. - - :param msg_id: the ID of the message. - :param sequence: the sequence of the message. - :param reader: the reader containing the GzipPacked. - :return: the result of processing the packed message. - """ - with BinaryReader(GzipPacked.read(reader)) as compressed_reader: - # We are reentering process_msg, which seemingly the same msg_id - # to the self._need_confirmation set. Remove it from there first - # to avoid any future conflicts (i.e. if we "ignore" messages - # that we are already aware of, see 1a91c02 and old 63dfb1e) - self._need_confirmation -= {msg_id} - return self._process_msg(msg_id, sequence, compressed_reader, state) - - # endregion diff --git a/telethon/network/mtprotoplainsender.py b/telethon/network/mtprotoplainsender.py new file mode 100644 index 00000000..ddbd4a30 --- /dev/null +++ b/telethon/network/mtprotoplainsender.py @@ -0,0 +1,46 @@ +""" +This module contains the class used to communicate with Telegram's servers +in plain text, when no authorization key has been created yet. +""" +import struct + +from .mtprotostate import MTProtoState +from ..errors import BrokenAuthKeyError +from ..extensions import BinaryReader + + +class MTProtoPlainSender: + """ + MTProto Mobile Protocol plain sender + (https://core.telegram.org/mtproto/description#unencrypted-messages) + """ + def __init__(self, connection): + """ + Initializes the MTProto plain sender. + + :param connection: the Connection to be used. + """ + self._state = MTProtoState(auth_key=None) + self._connection = connection + + async def send(self, request): + """ + Sends and receives the result for the given request. + """ + body = bytes(request) + msg_id = self._state._get_new_msg_id() + await self._connection.send( + struct.pack(' msg_id # msg_id + assert reader.read_int() # length + # No need to read "length" bytes first, just read the object + return reader.tgread_object() diff --git a/telethon/network/mtprotosender.py b/telethon/network/mtprotosender.py new file mode 100644 index 00000000..19a6afb1 --- /dev/null +++ b/telethon/network/mtprotosender.py @@ -0,0 +1,633 @@ +import asyncio +import logging + +from . import MTProtoPlainSender, authenticator +from .. import utils +from ..errors import ( + BadMessageError, TypeNotFoundError, BrokenAuthKeyError, SecurityError, + rpc_message_to_error +) +from ..extensions import BinaryReader +from ..tl.core import RpcResult, MessageContainer, GzipPacked +from ..tl.functions.auth import LogOutRequest +from ..tl.types import ( + MsgsAck, Pong, BadServerSalt, BadMsgNotification, FutureSalts, + MsgNewDetailedInfo, NewSessionCreated, MsgDetailedInfo, MsgsStateReq, + MsgsStateInfo, MsgsAllInfo, MsgResendReq +) + +__log__ = logging.getLogger(__name__) + + +# TODO Create some kind of "ReconnectionPolicy" that allows specifying +# what should be done in case of some errors, with some sane defaults. +# For instance, should all messages be set with an error upon network +# loss? Should we try reconnecting forever? A certain amount of times? +# A timeout? What about recoverable errors, like connection reset? +class MTProtoSender: + """ + MTProto Mobile Protocol sender + (https://core.telegram.org/mtproto/description). + + This class is responsible for wrapping requests into `TLMessage`'s, + sending them over the network and receiving them in a safe manner. + + Automatic reconnection due to temporary network issues is a concern + for this class as well, including retry of messages that could not + be sent successfully. + + A new authorization key will be generated on connection if no other + key exists yet. + """ + def __init__(self, state, connection, *, retries=5, + first_query=None, update_callback=None): + self.state = state + self._connection = connection + self._ip = None + self._port = None + self._retries = retries + self._first_query = first_query + self._is_first_query = bool(first_query) + self._update_callback = update_callback + + # Whether the user has explicitly connected or disconnected. + # + # If a disconnection happens for any other reason and it + # was *not* user action then the pending messages won't + # be cleared but on explicit user disconnection all the + # pending futures should be cancelled. + self._user_connected = False + self._reconnecting = False + + # We need to join the loops upon disconnection + self._send_loop_handle = None + self._recv_loop_handle = None + + # Sending something shouldn't block + self._send_queue = _ContainerQueue() + + # Telegram responds to messages out of order. Keep + # {id: Message} to set their Future result upon arrival. + self._pending_messages = {} + + # Containers are accepted or rejected as a whole when any of + # its inner requests are acknowledged. For this purpose we + # all the sent containers here. + self._pending_containers = [] + + # We need to acknowledge every response from Telegram + self._pending_ack = set() + + # Jump table from response ID to method that handles it + self._handlers = { + RpcResult.CONSTRUCTOR_ID: self._handle_rpc_result, + MessageContainer.CONSTRUCTOR_ID: self._handle_container, + GzipPacked.CONSTRUCTOR_ID: self._handle_gzip_packed, + Pong.CONSTRUCTOR_ID: self._handle_pong, + BadServerSalt.CONSTRUCTOR_ID: self._handle_bad_server_salt, + BadMsgNotification.CONSTRUCTOR_ID: self._handle_bad_notification, + MsgDetailedInfo.CONSTRUCTOR_ID: self._handle_detailed_info, + MsgNewDetailedInfo.CONSTRUCTOR_ID: self._handle_new_detailed_info, + NewSessionCreated.CONSTRUCTOR_ID: self._handle_new_session_created, + MsgsAck.CONSTRUCTOR_ID: self._handle_ack, + FutureSalts.CONSTRUCTOR_ID: self._handle_future_salts, + MsgsStateReq.CONSTRUCTOR_ID: self._handle_state_forgotten, + MsgResendReq.CONSTRUCTOR_ID: self._handle_state_forgotten, + MsgsAllInfo.CONSTRUCTOR_ID: self._handle_msg_all, + } + + # Public API + + async def connect(self, ip, port): + """ + Connects to the specified ``ip:port``, and generates a new + authorization key for the `MTProtoSender.session` if it does + not exist yet. + """ + if self._user_connected: + __log__.info('User is already connected!') + return + + self._ip = ip + self._port = port + self._user_connected = True + await self._connect() + + def is_connected(self): + return self._user_connected + + async def disconnect(self): + """ + Cleanly disconnects the instance from the network, cancels + all pending requests, and closes the send and receive loops. + """ + if not self._user_connected: + __log__.info('User is already disconnected!') + return + + __log__.info('Disconnecting from {}...'.format(self._ip)) + self._user_connected = False + try: + __log__.debug('Closing current connection...') + await self._connection.close() + finally: + __log__.debug('Cancelling {} pending message(s)...' + .format(len(self._pending_messages))) + for message in self._pending_messages.values(): + message.future.cancel() + + self._pending_messages.clear() + self._pending_ack.clear() + + __log__.debug('Cancelling the send loop...') + self._send_loop_handle.cancel() + + __log__.debug('Cancelling the receive loop...') + self._recv_loop_handle.cancel() + + __log__.info('Disconnection from {} complete!'.format(self._ip)) + + def send(self, request, ordered=False): + """ + This method enqueues the given request to be sent. + + The request will be wrapped inside a `TLMessage` until its + response arrives, and the `Future` response of the `TLMessage` + is immediately returned so that one can further ``await`` it: + + .. code-block:: python + + async def method(): + # Sending (enqueued for the send loop) + future = sender.send(request) + # Receiving (waits for the receive loop to read the result) + result = await future + + Designed like this because Telegram may send the response at + any point, and it can send other items while one waits for it. + Once the response for this future arrives, it is set with the + received result, quite similar to how a ``receive()`` call + would otherwise work. + + Since the receiving part is "built in" the future, it's + impossible to await receive a result that was never sent. + """ + if utils.is_list_like(request): + result = [] + after = None + for r in request: + message = self.state.create_message(r, after=after) + self._pending_messages[message.msg_id] = message + self._send_queue.put_nowait(message) + result.append(message.future) + after = ordered and message + return result + else: + message = self.state.create_message(request) + self._pending_messages[message.msg_id] = message + self._send_queue.put_nowait(message) + return message.future + + # Private methods + + async def _connect(self): + """ + Performs the actual connection, retrying, generating the + authorization key if necessary, and starting the send and + receive loops. + """ + __log__.info('Connecting to {}:{}...'.format(self._ip, self._port)) + _last_error = ConnectionError() + for retry in range(1, self._retries + 1): + try: + __log__.debug('Connection attempt {}...'.format(retry)) + await self._connection.connect(self._ip, self._port) + except OSError as e: + _last_error = e + __log__.warning('Attempt {} at connecting failed: {}' + .format(retry, e)) + else: + break + else: + raise _last_error + + __log__.debug('Connection success!') + if self.state.auth_key is None: + self._is_first_query = bool(self._first_query) + _last_error = SecurityError() + plain = MTProtoPlainSender(self._connection) + for retry in range(1, self._retries + 1): + try: + __log__.debug('New auth_key attempt {}...'.format(retry)) + self.state.auth_key, self.state.time_offset =\ + await authenticator.do_authentication(plain) + except (SecurityError, AssertionError) as e: + _last_error = e + __log__.warning('Attempt {} at new auth_key failed: {}' + .format(retry, e)) + else: + break + else: + raise _last_error + + __log__.debug('Starting send loop') + self._send_loop_handle = asyncio.ensure_future(self._send_loop()) + __log__.debug('Starting receive loop') + self._recv_loop_handle = asyncio.ensure_future(self._recv_loop()) + if self._is_first_query: + __log__.debug('Running first query') + self._is_first_query = False + await self.send(self._first_query) + + __log__.info('Connection to {} complete!'.format(self._ip)) + + async def _reconnect(self): + """ + Cleanly disconnects and then reconnects. + """ + self._reconnecting = True + + __log__.debug('Awaiting for the send loop before reconnecting...') + await self._send_loop_handle + + __log__.debug('Awaiting for the receive loop before reconnecting...') + await self._recv_loop_handle + + __log__.debug('Closing current connection...') + await self._connection.close() + + self._reconnecting = False + await self._connect() + + def _clean_containers(self, msg_ids): + """ + Helper method to clean containers from the pending messages + once a wrapped msg_id of them has been acknowledged. + + This is the only way we can resend TLMessage(MessageContainer) + on bad notifications and also mark them as received once any + of their inner TLMessage is acknowledged. + """ + for i in reversed(range(len(self._pending_containers))): + message = self._pending_containers[i] + for msg in message.obj.messages: + if msg.msg_id in msg_ids: + del self._pending_containers[i] + del self._pending_messages[message.msg_id] + break + + # Loops + + async def _send_loop(self): + """ + This loop is responsible for popping items off the send + queue, encrypting them, and sending them over the network. + + Besides `connect`, only this method ever sends data. + """ + while self._user_connected and not self._reconnecting: + if self._pending_ack: + self._send_queue.put_nowait(self.state.create_message( + MsgsAck(list(self._pending_ack)) + )) + self._pending_ack.clear() + + messages = await self._send_queue.get() + if isinstance(messages, list): + message = self.state.create_message(MessageContainer(messages)) + self._pending_messages[message.msg_id] = message + self._pending_containers.append(message) + else: + message = messages + messages = [message] + + __log__.debug('Packing {} outgoing message(s)...' + .format(len(messages))) + body = self.state.pack_message(message) + + while not any(m.future.cancelled() for m in messages): + try: + __log__.debug('Sending {} bytes...'.format(len(body))) + await self._connection.send(body) + break + except asyncio.TimeoutError: + continue + except OSError as e: + __log__.warning('OSError while sending %s', e) + else: + # Remove the cancelled messages from pending + __log__.info('Some futures were cancelled, aborted send') + self._clean_containers([m.msg_id for m in messages]) + for m in messages: + if m.future.cancelled(): + self._pending_messages.pop(m.msg_id, None) + else: + self._send_queue.put_nowait(m) + + __log__.debug('Outgoing messages {} sent!' + .format(', '.join(str(m.msg_id) for m in messages))) + + async def _recv_loop(self): + """ + This loop is responsible for reading all incoming responses + from the network, decrypting and handling or dispatching them. + + Besides `connect`, only this method ever receives data. + """ + while self._user_connected and not self._reconnecting: + # TODO Are there more exceptions besides timeout? + # Disconnecting or switching off WiFi only resulted in + # timeouts, and once the network was back it continued + # on its own after a short delay. + try: + __log__.debug('Receiving items from the network...') + body = await self._connection.recv() + except asyncio.TimeoutError: + # TODO If nothing is received for a minute, send a request + continue + except ConnectionError as e: + __log__.info('Connection reset while receiving %s', e) + asyncio.ensure_future(self._reconnect()) + break + except OSError as e: + __log__.warning('OSError while receiving %s', e) + asyncio.ensure_future(self._reconnect()) + break + + # TODO Check salt, session_id and sequence_number + __log__.debug('Decoding packet of %d bytes...', len(body)) + try: + message = self.state.unpack_message(body) + except (BrokenAuthKeyError, BufferError) as e: + # The authorization key may be broken if a message was + # sent malformed, or if the authkey truly is corrupted. + # + # There may be a buffer error if Telegram's response was too + # short and hence not understood. Reset the authorization key + # and try again in either case. + # + # TODO Is it possible to detect malformed messages vs + # an actually broken authkey? + __log__.warning('Broken authorization key?: {}'.format(e)) + self.state.auth_key = None + asyncio.ensure_future(self._reconnect()) + break + except SecurityError as e: + # A step while decoding had the incorrect data. This message + # should not be considered safe and it should be ignored. + __log__.warning('Security error while unpacking a ' + 'received message:'.format(e)) + continue + except TypeNotFoundError as e: + # The payload inside the message was not a known TLObject. + __log__.info('Server replied with an unknown type {:08x}: {!r}' + .format(e.invalid_constructor_id, e.remaining)) + else: + await self._process_message(message) + + # Response Handlers + + async def _process_message(self, message): + """ + Adds the given message to the list of messages that must be + acknowledged and dispatches control to different ``_handle_*`` + method based on its type. + """ + self._pending_ack.add(message.msg_id) + handler = self._handlers.get(message.obj.CONSTRUCTOR_ID, + self._handle_update) + await handler(message) + + async def _handle_rpc_result(self, message): + """ + Handles the result for Remote Procedure Calls: + + rpc_result#f35c6d01 req_msg_id:long result:bytes = RpcResult; + + This is where the future results for sent requests are set. + """ + rpc_result = message.obj + message = self._pending_messages.pop(rpc_result.req_msg_id, None) + __log__.debug('Handling RPC result for message {}' + .format(rpc_result.req_msg_id)) + + if rpc_result.error: + # TODO Report errors if possible/enabled + error = rpc_message_to_error(rpc_result.error) + self._send_queue.put_nowait(self.state.create_message( + MsgsAck([message.msg_id]) + )) + + if not message.future.cancelled(): + message.future.set_exception(error) + return + elif message: + with BinaryReader(rpc_result.body) as reader: + result = message.obj.read_result(reader) + + # TODO Process entities + if not message.future.cancelled(): + message.future.set_result(result) + return + else: + # TODO We should not get responses to things we never sent + __log__.info('Received response without parent request: {}' + .format(rpc_result.body)) + + async def _handle_container(self, message): + """ + Processes the inner messages of a container with many of them: + + msg_container#73f1f8dc messages:vector<%Message> = MessageContainer; + """ + __log__.debug('Handling container') + for inner_message in message.obj.messages: + await self._process_message(inner_message) + + async def _handle_gzip_packed(self, message): + """ + Unpacks the data from a gzipped object and processes it: + + gzip_packed#3072cfa1 packed_data:bytes = Object; + """ + __log__.debug('Handling gzipped data') + with BinaryReader(message.obj.data) as reader: + message.obj = reader.tgread_object() + await self._process_message(message) + + async def _handle_update(self, message): + __log__.debug('Handling update {}' + .format(message.obj.__class__.__name__)) + if self._update_callback: + self._update_callback(message.obj) + + async def _handle_pong(self, message): + """ + Handles pong results, which don't come inside a ``rpc_result`` + but are still sent through a request: + + pong#347773c5 msg_id:long ping_id:long = Pong; + """ + __log__.debug('Handling pong') + pong = message.obj + message = self._pending_messages.pop(pong.msg_id, None) + if message: + message.future.set_result(pong) + + async def _handle_bad_server_salt(self, message): + """ + Corrects the currently used server salt to use the right value + before enqueuing the rejected message to be re-sent: + + bad_server_salt#edab447b bad_msg_id:long bad_msg_seqno:int + error_code:int new_server_salt:long = BadMsgNotification; + """ + __log__.debug('Handling bad salt') + bad_salt = message.obj + self.state.salt = bad_salt.new_server_salt + self._send_queue.put_nowait(self._pending_messages[bad_salt.bad_msg_id]) + + async def _handle_bad_notification(self, message): + """ + Adjusts the current state to be correct based on the + received bad message notification whenever possible: + + bad_msg_notification#a7eff811 bad_msg_id:long bad_msg_seqno:int + error_code:int = BadMsgNotification; + """ + __log__.debug('Handling bad message') + bad_msg = message.obj + if bad_msg.error_code in (16, 17): + # Sent msg_id too low or too high (respectively). + # Use the current msg_id to determine the right time offset. + self.state.update_time_offset(correct_msg_id=message.msg_id) + elif bad_msg.error_code == 32: + # msg_seqno too low, so just pump it up by some "large" amount + # TODO A better fix would be to start with a new fresh session ID + self.state._sequence += 64 + elif bad_msg.error_code == 33: + # msg_seqno too high never seems to happen but just in case + self.state._sequence -= 16 + else: + msg = self._pending_messages.pop(bad_msg.bad_msg_id, None) + if msg: + msg.future.set_exception(BadMessageError(bad_msg.error_code)) + return + + # Messages are to be re-sent once we've corrected the issue + self._send_queue.put_nowait(self._pending_messages[bad_msg.bad_msg_id]) + + async def _handle_detailed_info(self, message): + """ + Updates the current status with the received detailed information: + + msg_detailed_info#276d3ec6 msg_id:long answer_msg_id:long + bytes:int status:int = MsgDetailedInfo; + """ + # TODO https://goo.gl/VvpCC6 + __log__.debug('Handling detailed info') + self._pending_ack.add(message.obj.answer_msg_id) + + async def _handle_new_detailed_info(self, message): + """ + Updates the current status with the received detailed information: + + msg_new_detailed_info#809db6df answer_msg_id:long + bytes:int status:int = MsgDetailedInfo; + """ + # TODO https://goo.gl/G7DPsR + __log__.debug('Handling new detailed info') + self._pending_ack.add(message.obj.answer_msg_id) + + async def _handle_new_session_created(self, message): + """ + Updates the current status with the received session information: + + new_session_created#9ec20908 first_msg_id:long unique_id:long + server_salt:long = NewSession; + """ + # TODO https://goo.gl/LMyN7A + __log__.debug('Handling new session created') + self.state.salt = message.obj.server_salt + + async def _handle_ack(self, message): + """ + Handles a server acknowledge about our messages. Normally + these can be ignored except in the case of ``auth.logOut``: + + auth.logOut#5717da40 = Bool; + + Telegram doesn't seem to send its result so we need to confirm + it manually. No other request is known to have this behaviour. + + Since the ID of sent messages consisting of a container is + never returned (unless on a bad notification), this method + also removes containers messages when any of their inner + messages are acknowledged. + """ + __log__.debug('Handling acknowledge') + ack = message.obj + if self._pending_containers: + self._clean_containers(ack.msg_ids) + + for msg_id in ack.msg_ids: + msg = self._pending_messages.get(msg_id, None) + if msg and isinstance(msg.obj, LogOutRequest): + del self._pending_messages[msg_id] + msg.future.set_result(True) + + async def _handle_future_salts(self, message): + """ + Handles future salt results, which don't come inside a + ``rpc_result`` but are still sent through a request: + + future_salts#ae500895 req_msg_id:long now:int + salts:vector = FutureSalts; + """ + # TODO save these salts and automatically adjust to the + # correct one whenever the salt in use expires. + __log__.debug('Handling future salts') + msg = self._pending_messages.pop(message.msg_id, None) + if msg: + msg.future.set_result(message.obj) + + async def _handle_state_forgotten(self, message): + """ + Handles both :tl:`MsgsStateReq` and :tl:`MsgResendReq` by + enqueuing a :tl:`MsgsStateInfo` to be sent at a later point. + """ + self.send(MsgsStateInfo(req_msg_id=message.msg_id, + info=chr(1) * len(message.obj.msg_ids))) + + async def _handle_msg_all(self, message): + """ + Handles :tl:`MsgsAllInfo` by doing nothing (yet). + """ + + +class _ContainerQueue(asyncio.Queue): + """ + An asyncio queue that's aware of `MessageContainer` instances. + + The `get` method returns either a single `TLMessage` or a list + of them that should be turned into a new `MessageContainer`. + + Instances of this class can be replaced with the simpler + ``asyncio.Queue`` when needed for testing purposes, and + a list won't be returned in said case. + """ + async def get(self): + result = await super().get() + if self.empty() or isinstance(result.obj, MessageContainer): + return result + + result = [result] + while not self.empty(): + item = self.get_nowait() + if isinstance(item.obj, MessageContainer): + self.put_nowait(item) + break + else: + result.append(item) + + return result diff --git a/telethon/network/mtprotostate.py b/telethon/network/mtprotostate.py new file mode 100644 index 00000000..2eb64427 --- /dev/null +++ b/telethon/network/mtprotostate.py @@ -0,0 +1,163 @@ +import logging +import os +import struct +import time +from hashlib import sha256 + +from ..crypto import AES +from ..errors import SecurityError, BrokenAuthKeyError +from ..extensions import BinaryReader +from ..tl.core import TLMessage +from ..tl.tlobject import TLRequest + +__log__ = logging.getLogger(__name__) + + +class MTProtoState: + """ + `telethon.network.mtprotosender.MTProtoSender` needs to hold a state + in order to be able to encrypt and decrypt incoming/outgoing messages, + as well as generating the message IDs. Instances of this class hold + together all the required information. + + It doesn't make sense to use `telethon.sessions.abstract.Session` for + the sender because the sender should *not* be concerned about storing + this information to disk, as one may create as many senders as they + desire to any other data center, or some CDN. Using the same session + for all these is not a good idea as each need their own authkey, and + the concept of "copying" sessions with the unnecessary entities or + updates state for these connections doesn't make sense. + """ + def __init__(self, auth_key): + # Session IDs can be random on every connection + self.id = struct.unpack('q', os.urandom(8))[0] + self.auth_key = auth_key + self.time_offset = 0 + self.salt = 0 + self._sequence = 0 + self._last_msg_id = 0 + + def create_message(self, obj, after=None): + """ + Creates a new `telethon.tl.tl_message.TLMessage` from + the given `telethon.tl.tlobject.TLObject` instance. + """ + return TLMessage( + msg_id=self._get_new_msg_id(), + seq_no=self._get_seq_no(isinstance(obj, TLRequest)), + obj=obj, + after_id=after.msg_id if after else None + ) + + @staticmethod + def _calc_key(auth_key, msg_key, client): + """ + Calculate the key based on Telegram guidelines for MTProto 2, + specifying whether it's the client or not. See + https://core.telegram.org/mtproto/description#defining-aes-key-and-initialization-vector + """ + x = 0 if client else 8 + sha256a = sha256(msg_key + auth_key[x: x + 36]).digest() + sha256b = sha256(auth_key[x + 40:x + 76] + msg_key).digest() + + aes_key = sha256a[:8] + sha256b[8:24] + sha256a[24:32] + aes_iv = sha256b[:8] + sha256a[8:24] + sha256b[24:32] + + return aes_key, aes_iv + + def pack_message(self, message): + """ + Packs the given `telethon.tl.tl_message.TLMessage` using the + current authorization key following MTProto 2.0 guidelines. + + See https://core.telegram.org/mtproto/description. + """ + data = struct.pack('= new_msg_id: + new_msg_id = self._last_msg_id + 4 + + self._last_msg_id = new_msg_id + return new_msg_id + + def update_time_offset(self, correct_msg_id): + """ + Updates the time offset to the correct + one given a known valid message ID. + """ + now = int(time.time()) + correct = correct_msg_id >> 32 + self.time_offset = correct - now + self._last_msg_id = 0 + + def _get_seq_no(self, content_related): + """ + Generates the next sequence number depending on whether + it should be for a content-related query or not. + """ + if content_related: + result = self._sequence * 2 + 1 + self._sequence += 1 + return result + else: + return self._sequence * 2 diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py deleted file mode 100644 index f3db342b..00000000 --- a/telethon/telegram_bare_client.py +++ /dev/null @@ -1,757 +0,0 @@ -import logging -import os -import platform -import threading -from datetime import timedelta, datetime -from signal import signal, SIGINT, SIGTERM, SIGABRT -from threading import Lock -from time import sleep -from . import version, utils -from .crypto import rsa -from .errors import ( - RPCError, BrokenAuthKeyError, ServerError, FloodWaitError, - FloodTestPhoneWaitError, TypeNotFoundError, UnauthorizedError, - PhoneMigrateError, NetworkMigrateError, UserMigrateError, AuthKeyError, - RpcCallFailError -) -from .network import authenticator, MtProtoSender, ConnectionTcpFull -from .sessions import Session, SQLiteSession -from .tl import TLObject -from .tl.all_tlobjects import LAYER -from .tl.functions import ( - InitConnectionRequest, InvokeWithLayerRequest, PingRequest -) -from .tl.functions.auth import ( - ImportAuthorizationRequest, ExportAuthorizationRequest -) -from .tl.functions.help import ( - GetCdnConfigRequest, GetConfigRequest -) -from .tl.functions.updates import GetStateRequest -from .tl.types.auth import ExportedAuthorization -from .update_state import UpdateState - -DEFAULT_DC_ID = 4 -DEFAULT_IPV4_IP = '149.154.167.51' -DEFAULT_IPV6_IP = '[2001:67c:4e8:f002::a]' -DEFAULT_PORT = 443 - -__log__ = logging.getLogger(__name__) - - -class TelegramBareClient: - """Bare Telegram Client with just the minimum - - - The reason to distinguish between a MtProtoSender and a - TelegramClient itself is because the sender is just that, - a sender, which should know nothing about Telegram but - rather how to handle this specific connection. - - The TelegramClient itself should know how to initialize - a proper connection to the servers, as well as other basic - methods such as disconnection and reconnection. - - This distinction between a bare client and a full client - makes it possible to create clones of the bare version - (by using the same session, IP address and port) to be - able to execute queries on either, without the additional - cost that would involve having the methods for signing in, - logging out, and such. - """ - - # Current TelegramClient version - __version__ = version.__version__ - - # TODO Make this thread-safe, all connections share the same DC - _config = None # Server configuration (with .dc_options) - - # region Initialization - - def __init__(self, session, api_id, api_hash, - *, - connection=ConnectionTcpFull, - use_ipv6=False, - proxy=None, - update_workers=None, - spawn_read_thread=False, - timeout=timedelta(seconds=5), - report_errors=True, - device_model=None, - system_version=None, - app_version=None, - lang_code='en', - system_lang_code='en'): - """Refer to TelegramClient.__init__ for docs on this method""" - if not api_id or not api_hash: - raise ValueError( - "Your API ID or Hash cannot be empty or None. " - "Refer to telethon.rtfd.io for more information.") - - self._use_ipv6 = use_ipv6 - - # Determine what session object we have - if isinstance(session, str) or session is None: - session = SQLiteSession(session) - elif not isinstance(session, Session): - raise TypeError( - 'The given session must be a str or a Session instance.' - ) - - # ':' in session.server_address is True if it's an IPv6 address - if (not session.server_address or - (':' in session.server_address) != use_ipv6): - session.set_dc( - DEFAULT_DC_ID, - DEFAULT_IPV6_IP if self._use_ipv6 else DEFAULT_IPV4_IP, - DEFAULT_PORT - ) - - session.report_errors = report_errors - self.session = session - self.api_id = int(api_id) - self.api_hash = api_hash - - # This is the main sender, which will be used from the thread - # that calls .connect(). Every other thread will spawn a new - # temporary connection. The connection on this one is always - # kept open so Telegram can send us updates. - if isinstance(connection, type): - connection = connection(proxy=proxy, timeout=timeout) - - self._sender = MtProtoSender(self.session, connection) - - # Two threads may be calling reconnect() when the connection is lost, - # we only want one to actually perform the reconnection. - self._reconnect_lock = Lock() - - # Cache "exported" sessions as 'dc_id: Session' not to recreate - # them all the time since generating a new key is a relatively - # expensive operation. - self._exported_sessions = {} - - # This member will process updates if enabled. - # One may change self.updates.enabled at any later point. - self.updates = UpdateState(workers=update_workers) - - # Used on connection - the user may modify these and reconnect - system = platform.uname() - self.device_model = device_model or system.system or 'Unknown' - self.system_version = system_version or system.release or '1.0' - self.app_version = app_version or self.__version__ - self.lang_code = lang_code - self.system_lang_code = system_lang_code - - # Despite the state of the real connection, keep track of whether - # the user has explicitly called .connect() or .disconnect() here. - # This information is required by the read thread, who will be the - # one attempting to reconnect on the background *while* the user - # doesn't explicitly call .disconnect(), thus telling it to stop - # retrying. The main thread, knowing there is a background thread - # attempting reconnection as soon as it happens, will just sleep. - self._user_connected = False - - # Save whether the user is authorized here (a.k.a. logged in) - self._authorized = None # None = We don't know yet - - # The first request must be in invokeWithLayer(initConnection(X)). - # See https://core.telegram.org/api/invoking#saving-client-info. - self._first_request = True - - # Constantly read for results and updates from within the main client, - # if the user has left enabled such option. - self._spawn_read_thread = spawn_read_thread - self._recv_thread = None - self._idling = threading.Event() - - # Default PingRequest delay - self._last_ping = datetime.now() - self._ping_delay = timedelta(minutes=1) - - # Also have another delay for GetStateRequest. - # - # If the connection is kept alive for long without invoking any - # high level request the server simply stops sending updates. - # TODO maybe we can have ._last_request instead if any req works? - self._last_state = datetime.now() - self._state_delay = timedelta(hours=1) - - # Some errors are known but there's nothing we can do from the - # background thread. If any of these happens, call .disconnect(), - # and raise them next time .invoke() is tried to be called. - self._background_error = None - - # endregion - - # region Connecting - - def connect(self, _sync_updates=True): - """Connects to the Telegram servers, executing authentication if - required. Note that authenticating to the Telegram servers is - not the same as authenticating the desired user itself, which - may require a call (or several) to 'sign_in' for the first time. - - Note that the optional parameters are meant for internal use. - - If '_sync_updates', sync_updates() will be called and a - second thread will be started if necessary. Note that this - will FAIL if the client is not connected to the user's - native data center, raising a "UserMigrateError", and - calling .disconnect() in the process. - """ - __log__.info('Connecting to %s:%d...', - self.session.server_address, self.session.port) - - self._background_error = None # Clear previous errors - - try: - self._sender.connect() - __log__.info('Connection success!') - - # Connection was successful! Try syncing the update state - # UNLESS '_sync_updates' is False (we probably are in - # another data center and this would raise UserMigrateError) - # to also assert whether the user is logged in or not. - self._user_connected = True - if self._authorized is None and _sync_updates: - try: - self.sync_updates() - self._set_connected_and_authorized() - except UnauthorizedError: - self._authorized = False - elif self._authorized: - self._set_connected_and_authorized() - - return True - - except TypeNotFoundError as e: - # This is fine, probably layer migration - __log__.warning('Connection failed, got unexpected type with ID ' - '%s. Migrating?', hex(e.invalid_constructor_id)) - self.disconnect() - return self.connect(_sync_updates=_sync_updates) - - except AuthKeyError as e: - # As of late March 2018 there were two AUTH_KEY_DUPLICATED - # reports. Retrying with a clean auth_key should fix this. - __log__.warning('Auth key error %s. Clearing it and retrying.', e) - self.disconnect() - self.session.auth_key = None - self.session.save() - return self.connect(_sync_updates=_sync_updates) - - except (RPCError, ConnectionError) as e: - # Probably errors from the previous session, ignore them - __log__.error('Connection failed due to %s', e) - self.disconnect() - return False - - def is_connected(self): - return self._sender.is_connected() - - def _wrap_init_connection(self, query): - """Wraps query around InvokeWithLayerRequest(InitConnectionRequest())""" - return InvokeWithLayerRequest(LAYER, InitConnectionRequest( - api_id=self.api_id, - device_model=self.device_model, - system_version=self.system_version, - app_version=self.app_version, - lang_code=self.lang_code, - system_lang_code=self.system_lang_code, - lang_pack='', # "langPacks are for official apps only" - query=query - )) - - def disconnect(self): - """Disconnects from the Telegram server - and stops all the spawned threads""" - __log__.info('Disconnecting...') - self._user_connected = False # This will stop recv_thread's loop - - __log__.debug('Stopping all workers...') - self.updates.stop_workers() - - # This will trigger a "ConnectionResetError" on the recv_thread, - # which won't attempt reconnecting as ._user_connected is False. - __log__.debug('Disconnecting the socket...') - self._sender.disconnect() - - # TODO Shall we clear the _exported_sessions, or may be reused? - self._first_request = True # On reconnect it will be first again - self.session.set_update_state(0, self.updates.get_update_state(0)) - self.session.close() - - def _reconnect(self, new_dc=None): - """If 'new_dc' is not set, only a call to .connect() will be made - since it's assumed that the connection has been lost and the - library is reconnecting. - - If 'new_dc' is set, the client is first disconnected from the - current data center, clears the auth key for the old DC, and - connects to the new data center. - """ - if new_dc is None: - if self.is_connected(): - __log__.info('Reconnection aborted: already connected') - return True - - try: - __log__.info('Attempting reconnection...') - return self.connect() - except ConnectionResetError as e: - __log__.warning('Reconnection failed due to %s', e) - return False - else: - # Since we're reconnecting possibly due to a UserMigrateError, - # we need to first know the Data Centers we can connect to. Do - # that before disconnecting. - dc = self._get_dc(new_dc) - __log__.info('Reconnecting to new data center %s', dc) - - self.session.set_dc(dc.id, dc.ip_address, dc.port) - # auth_key's are associated with a server, which has now changed - # so it's not valid anymore. Set to None to force recreating it. - self.session.auth_key = None - self.session.save() - self.disconnect() - return self.connect() - - def set_proxy(self, proxy): - """Change the proxy used by the connections. - """ - if self.is_connected(): - raise RuntimeError("You can't change the proxy while connected.") - self._sender.connection.conn.proxy = proxy - - # endregion - - # region Working with different connections/Data Centers - - def _on_read_thread(self): - return self._recv_thread is not None and \ - threading.get_ident() == self._recv_thread.ident - - def _get_dc(self, dc_id, cdn=False): - """Gets the Data Center (DC) associated to 'dc_id'""" - if not TelegramBareClient._config: - TelegramBareClient._config = self(GetConfigRequest()) - - try: - if cdn: - # Ensure we have the latest keys for the CDNs - for pk in self(GetCdnConfigRequest()).public_keys: - rsa.add_key(pk.public_key) - - return next( - dc for dc in TelegramBareClient._config.dc_options - if dc.id == dc_id and bool(dc.ipv6) == self._use_ipv6 and bool(dc.cdn) == cdn - ) - except StopIteration: - if not cdn: - raise - - # New configuration, perhaps a new CDN was added? - TelegramBareClient._config = self(GetConfigRequest()) - return self._get_dc(dc_id, cdn=cdn) - - def _get_exported_client(self, dc_id): - """Creates and connects a new TelegramBareClient for the desired DC. - - If it's the first time calling the method with a given dc_id, - a new session will be first created, and its auth key generated. - Exporting/Importing the authorization will also be done so that - the auth is bound with the key. - """ - # Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt - # for clearly showing how to export the authorization! ^^ - session = self._exported_sessions.get(dc_id) - if session: - export_auth = None # Already bound with the auth key - else: - # TODO Add a lock, don't allow two threads to create an auth key - # (when calling .connect() if there wasn't a previous session). - # for the same data center. - dc = self._get_dc(dc_id) - - # Export the current authorization to the new DC. - __log__.info('Exporting authorization for data center %s', dc) - export_auth = self(ExportAuthorizationRequest(dc_id)) - - # Create a temporary session for this IP address, which needs - # to be different because each auth_key is unique per DC. - # - # Construct this session with the connection parameters - # (system version, device model...) from the current one. - session = self.session.clone() - session.set_dc(dc.id, dc.ip_address, dc.port) - self._exported_sessions[dc_id] = session - - __log__.info('Creating exported new client') - client = TelegramBareClient( - session, self.api_id, self.api_hash, - proxy=self._sender.connection.conn.proxy, - timeout=self._sender.connection.get_timeout() - ) - client.connect(_sync_updates=False) - if isinstance(export_auth, ExportedAuthorization): - client(ImportAuthorizationRequest( - id=export_auth.id, bytes=export_auth.bytes - )) - elif export_auth is not None: - __log__.warning('Unknown export auth type %s', export_auth) - - client._authorized = True # We exported the auth, so we got auth - return client - - def _get_cdn_client(self, cdn_redirect): - """Similar to ._get_exported_client, but for CDNs""" - session = self._exported_sessions.get(cdn_redirect.dc_id) - if not session: - dc = self._get_dc(cdn_redirect.dc_id, cdn=True) - session = self.session.clone() - session.set_dc(dc.id, dc.ip_address, dc.port) - self._exported_sessions[cdn_redirect.dc_id] = session - - __log__.info('Creating new CDN client') - client = TelegramBareClient( - session, self.api_id, self.api_hash, - proxy=self._sender.connection.conn.proxy, - timeout=self._sender.connection.get_timeout() - ) - - # This will make use of the new RSA keys for this specific CDN. - # - # We won't be calling GetConfigRequest because it's only called - # when needed by ._get_dc, and also it's static so it's likely - # set already. Avoid invoking non-CDN methods by not syncing updates. - client.connect(_sync_updates=False) - client._authorized = self._authorized - return client - - # endregion - - # region Invoking Telegram requests - - def __call__(self, request, retries=5, ordered=False): - """ - Invokes (sends) one or more MTProtoRequests and returns (receives) - their result. - - Args: - request (`TLObject` | `list`): - The request or requests to be invoked. - - retries (`bool`, optional): - How many times the request should be retried automatically - in case it fails with a non-RPC error. - - The invoke will be retried up to 'retries' times before raising - ``RuntimeError``. - - ordered (`bool`, optional): - Whether the requests (if more than one was given) should be - executed sequentially on the server. They run in arbitrary - order by default. - - Returns: - The result of the request (often a `TLObject`) or a list of - results if more than one request was given. - """ - single = not utils.is_list_like(request) - if single: - request = (request,) - - if not all(isinstance(x, TLObject) and - x.content_related for x in request): - raise TypeError('You can only invoke requests, not types!') - - if self._background_error: - raise self._background_error - - for r in request: - r.resolve(self, utils) - - # For logging purposes - if single: - which = type(request[0]).__name__ - else: - which = '{} requests ({})'.format( - len(request), [type(x).__name__ for x in request]) - - # Determine the sender to be used (main or a new connection) - __log__.debug('Invoking %s', which) - call_receive = \ - not self._idling.is_set() or self._reconnect_lock.locked() - - for retry in range(retries): - result = self._invoke(call_receive, request, ordered=ordered) - if result is not None: - return result[0] if single else result - - log = __log__.info if retry == 0 else __log__.warning - log('Invoking %s failed %d times, connecting again and retrying', - which, retry + 1) - - sleep(1) - # The ReadThread has priority when attempting reconnection, - # since this thread is constantly running while __call__ is - # only done sometimes. Here try connecting only once/retry. - if not self._reconnect_lock.locked(): - with self._reconnect_lock: - self._reconnect() - - raise RuntimeError('Number of retries reached 0 for {}.'.format( - which - )) - - # Let people use client.invoke(SomeRequest()) instead client(...) - invoke = __call__ - - def _invoke(self, call_receive, requests, ordered=False): - try: - # Ensure that we start with no previous errors (i.e. resending) - for x in requests: - x.confirm_received.clear() - x.rpc_error = None - - if not self.session.auth_key: - __log__.info('Need to generate new auth key before invoking') - self._first_request = True - self.session.auth_key, self.session.time_offset = \ - authenticator.do_authentication(self._sender.connection) - - if self._first_request: - __log__.info('Initializing a new connection while invoking') - if len(requests) == 1: - requests = [self._wrap_init_connection(requests[0])] - else: - # We need a SINGLE request (like GetConfig) to init conn. - # Once that's done, the N original requests will be - # invoked. - TelegramBareClient._config = self( - self._wrap_init_connection(GetConfigRequest()) - ) - - self._sender.send(requests, ordered=ordered) - - if not call_receive: - # TODO This will be slightly troublesome if we allow - # switching between constant read or not on the fly. - # Must also watch out for calling .read() from two places, - # in which case a Lock would be required for .receive(). - for x in requests: - x.confirm_received.wait( - self._sender.connection.get_timeout() - ) - else: - while not all(x.confirm_received.is_set() for x in requests): - self._sender.receive(update_state=self.updates) - - except BrokenAuthKeyError: - __log__.error('Authorization key seems broken and was invalid!') - self.session.auth_key = None - - except TypeNotFoundError as e: - # Only occurs when we call receive. May happen when - # we need to reconnect to another DC on login and - # Telegram somehow sends old objects (like configOld) - self._first_request = True - __log__.warning('Read unknown TLObject code ({}). ' - 'Setting again first_request flag.' - .format(hex(e.invalid_constructor_id))) - - except TimeoutError: - __log__.warning('Invoking timed out') # We will just retry - - except ConnectionResetError as e: - __log__.warning('Connection was reset while invoking') - if self._user_connected: - # Server disconnected us, __call__ will try reconnecting. - try: - self._sender.disconnect() - except: - pass - - return None - else: - # User never called .connect(), so raise this error. - raise RuntimeError('Tried to invoke without .connect()') from e - - # Clear the flag if we got this far - self._first_request = False - - try: - raise next(x.rpc_error for x in requests if x.rpc_error) - except StopIteration: - if any(x.result is None for x in requests): - # "A container may only be accepted or - # rejected by the other party as a whole." - return None - - return [x.result for x in requests] - - except (PhoneMigrateError, NetworkMigrateError, - UserMigrateError) as e: - - # TODO What happens with the background thread here? - # For normal use cases, this won't happen, because this will only - # be on the very first connection (not authorized, not running), - # but may be an issue for people who actually travel? - self._reconnect(new_dc=e.new_dc) - return self._invoke(call_receive, requests) - - except (ServerError, RpcCallFailError) as e: - # Telegram is having some issues, just retry - __log__.warning('Telegram is having internal issues: %s', e) - - except (FloodWaitError, FloodTestPhoneWaitError) as e: - __log__.warning('Request invoked too often, wait %ds', e.seconds) - if e.seconds > self.session.flood_sleep_threshold | 0: - raise - - sleep(e.seconds) - - # Some really basic functionality - - def is_user_authorized(self): - """Has the user been authorized yet - (code request sent and confirmed)?""" - return self._authorized - - def get_input_entity(self, peer): - """ - Stub method, no functionality so that calling - ``.get_input_entity()`` from ``.resolve()`` doesn't fail. - """ - return peer - - # endregion - - # region Updates handling - - def sync_updates(self): - """Synchronizes self.updates to their initial state. Will be - called automatically on connection if self.updates.enabled = True, - otherwise it should be called manually after enabling updates. - """ - self.updates.process(self(GetStateRequest())) - self._last_state = datetime.now() - - # endregion - - # region Constant read - - def _set_connected_and_authorized(self): - self._authorized = True - self.updates.setup_workers() - if self._spawn_read_thread and self._recv_thread is None: - self._recv_thread = threading.Thread( - name='ReadThread', daemon=True, - target=self._recv_thread_impl - ) - self._recv_thread.start() - - def _signal_handler(self, signum, frame): - if self._user_connected: - self.disconnect() - else: - os._exit(1) - - def idle(self, stop_signals=(SIGINT, SIGTERM, SIGABRT)): - """ - Idles the program by looping forever and listening for updates - until one of the signals are received, which breaks the loop. - - :param stop_signals: - Iterable containing signals from the signal module that will - be subscribed to TelegramClient.disconnect() (effectively - stopping the idle loop), which will be called on receiving one - of those signals. - :return: - """ - if self._spawn_read_thread and not self._on_read_thread(): - raise RuntimeError('Can only idle if spawn_read_thread=False') - - self._idling.set() - for sig in stop_signals: - signal(sig, self._signal_handler) - - if self._on_read_thread(): - __log__.info('Starting to wait for items from the network') - else: - __log__.info('Idling to receive items from the network') - - while self._user_connected: - try: - if datetime.now() > self._last_ping + self._ping_delay: - self._sender.send(PingRequest( - int.from_bytes(os.urandom(8), 'big', signed=True) - )) - self._last_ping = datetime.now() - - if datetime.now() > self._last_state + self._state_delay: - self._sender.send(GetStateRequest()) - self._last_state = datetime.now() - - __log__.debug('Receiving items from the network...') - self._sender.receive(update_state=self.updates) - except TimeoutError: - # No problem - __log__.debug('Receiving items from the network timed out') - except ConnectionResetError: - if self._user_connected: - __log__.error('Connection was reset while receiving ' - 'items. Reconnecting') - with self._reconnect_lock: - while self._user_connected and not self._reconnect(): - sleep(0.1) # Retry forever, this is instant messaging - - if self.is_connected(): - # Telegram seems to kick us every 1024 items received - # from the network not considering things like bad salt. - # We must execute some *high level* request (that's not - # a ping) if we want to receive updates again. - # TODO Test if getDifference works too (better alternative) - self._sender.send(GetStateRequest()) - except: - self._idling.clear() - raise - - self._idling.clear() - __log__.info('Connection closed by the user, not reading anymore') - - # By using this approach, another thread will be - # created and started upon connection to constantly read - # from the other end. Otherwise, manual calls to .receive() - # must be performed. The MtProtoSender cannot be connected, - # or an error will be thrown. - # - # This way, sending and receiving will be completely independent. - def _recv_thread_impl(self): - # This thread is "idle" (only listening for updates), but also - # excepts everything unlike the manual idle because it should - # not crash. - while self._user_connected: - try: - self.idle(stop_signals=tuple()) - except Exception as error: - __log__.exception('Unknown exception in the read thread! ' - 'Disconnecting and leaving it to main thread') - # Unknown exception, pass it to the main thread - - try: - import socks - if isinstance(error, ( - socks.GeneralProxyError, socks.ProxyConnectionError - )): - # This is a known error, and it's not related to - # Telegram but rather to the proxy. Disconnect and - # hand it over to the main thread. - self._background_error = error - self.disconnect() - break - except ImportError: - "Not using PySocks, so it can't be a proxy error" - - self._recv_thread = None - - # endregion diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py deleted file mode 100644 index fb4f07ef..00000000 --- a/telethon/telegram_client.py +++ /dev/null @@ -1,2898 +0,0 @@ -import getpass -import hashlib -import io -import itertools -import logging -import os -import re -import sys -import time -import warnings -from collections import UserList -from datetime import datetime, timedelta -from io import BytesIO -from mimetypes import guess_type - -from .crypto import CdnDecrypter -from .tl import TLObject -from .tl.custom import InputSizedFile -from .tl.functions.help import AcceptTermsOfServiceRequest -from .tl.functions.updates import GetDifferenceRequest -from .tl.functions.upload import ( - SaveBigFilePartRequest, SaveFilePartRequest, GetFileRequest -) -from .tl.types.updates import ( - DifferenceSlice, DifferenceEmpty, Difference, DifferenceTooLong -) -from .tl.types.upload import FileCdnRedirect - -try: - import socks -except ImportError: - socks = None - -try: - import hachoir - import hachoir.metadata - import hachoir.parser -except ImportError: - hachoir = None - -from . import TelegramBareClient -from . import helpers, utils, events -from .errors import ( - RPCError, UnauthorizedError, PhoneCodeEmptyError, PhoneCodeExpiredError, - PhoneCodeHashEmptyError, PhoneCodeInvalidError, LocationInvalidError, - SessionPasswordNeededError, FileMigrateError, PhoneNumberUnoccupiedError, - PhoneNumberOccupiedError, UsernameNotOccupiedError -) -from .network import ConnectionTcpFull -from .tl.custom import Draft, Dialog -from .tl.functions.account import ( - GetPasswordRequest, UpdatePasswordSettingsRequest -) -from .tl.functions.auth import ( - CheckPasswordRequest, LogOutRequest, SendCodeRequest, SignInRequest, - SignUpRequest, ResendCodeRequest, ImportBotAuthorizationRequest -) -from .tl.functions.contacts import ( - GetContactsRequest, ResolveUsernameRequest -) -from .tl.functions.messages import ( - GetDialogsRequest, GetHistoryRequest, SendMediaRequest, - SendMessageRequest, GetChatsRequest, GetAllDraftsRequest, - CheckChatInviteRequest, ReadMentionsRequest, SendMultiMediaRequest, - UploadMediaRequest, EditMessageRequest, GetFullChatRequest, - ForwardMessagesRequest, SearchRequest -) - -from .tl.functions import channels -from .tl.functions import messages - -from .tl.functions.users import ( - GetUsersRequest -) -from .tl.functions.channels import ( - GetChannelsRequest, GetFullChannelRequest, GetParticipantsRequest -) -from .tl.types import ( - DocumentAttributeAudio, DocumentAttributeFilename, - InputMediaUploadedDocument, InputMediaUploadedPhoto, InputPeerEmpty, - Message, MessageMediaContact, MessageMediaDocument, MessageMediaPhoto, - InputUserSelf, UserProfilePhoto, ChatPhoto, UpdateMessageID, - UpdateNewChannelMessage, UpdateNewMessage, UpdateShortSentMessage, - PeerUser, InputPeerUser, InputPeerChat, InputPeerChannel, MessageEmpty, - ChatInvite, ChatInviteAlready, Photo, InputPeerSelf, - InputSingleMedia, InputMediaPhoto, InputPhoto, InputFile, InputFileBig, - InputDocument, InputMediaDocument, Document, MessageEntityTextUrl, - InputMessageEntityMentionName, DocumentAttributeVideo, - UpdateEditMessage, UpdateEditChannelMessage, UpdateShort, Updates, - MessageMediaWebPage, ChannelParticipantsSearch, PhotoSize, PhotoCachedSize, - PhotoSizeEmpty, MessageService, ChatParticipants, User, WebPage, - ChannelParticipantsBanned, ChannelParticipantsKicked, - InputMessagesFilterEmpty, UpdatesCombined -) -from .tl.types.messages import DialogsSlice, MessagesNotModified -from .tl.types.account import PasswordInputSettings, NoPassword -from .tl import custom -from .utils import Default -from .extensions import markdown, html - -__log__ = logging.getLogger(__name__) - - -class TelegramClient(TelegramBareClient): - """ - Initializes the Telegram client with the specified API ID and Hash. - - Args: - session (`str` | `telethon.sessions.abstract.Session`, `None`): - The file name of the session file to be used if a string is - given (it may be a full path), or the Session instance to be - used otherwise. If it's ``None``, the session will not be saved, - and you should call :meth:`.log_out()` when you're done. - - Note that if you pass a string it will be a file in the current - working directory, although you can also pass absolute paths. - - The session file contains enough information for you to login - without re-sending the code, so if you have to enter the code - more than once, maybe you're changing the working directory, - renaming or removing the file, or using random names. - - api_id (`int` | `str`): - The API ID you obtained from https://my.telegram.org. - - api_hash (`str`): - The API ID you obtained from https://my.telegram.org. - - connection (`telethon.network.connection.common.Connection`, optional): - The connection instance to be used when creating a new connection - to the servers. If it's a type, the `proxy` argument will be used. - - Defaults to `telethon.network.connection.tcpfull.ConnectionTcpFull`. - - use_ipv6 (`bool`, optional): - Whether to connect to the servers through IPv6 or not. - By default this is ``False`` as IPv6 support is not - too widespread yet. - - proxy (`tuple` | `dict`, optional): - A tuple consisting of ``(socks.SOCKS5, 'host', port)``. - See https://github.com/Anorov/PySocks#usage-1 for more. - - update_workers (`int`, optional): - If specified, represents how many extra threads should - be spawned to handle incoming updates, and updates will - be kept in memory until they are processed. Note that - you must set this to at least ``0`` if you want to be - able to process updates through :meth:`updates.poll()`. - - timeout (`int` | `float` | `timedelta`, optional): - The timeout to be used when receiving responses from - the network. Defaults to 5 seconds. - - spawn_read_thread (`bool`, optional): - Whether to use an extra background thread or not. Defaults - to ``True`` so receiving items from the network happens - instantly, as soon as they arrive. Can still be disabled - if you want to run the library without any additional thread. - - report_errors (`bool`, optional): - Whether to report RPC errors or not. Defaults to ``True``, - see :ref:`api-status` for more information. - - Kwargs: - Some extra parameters are required when establishing the first - connection. These are are (along with their default values): - - .. code-block:: python - - device_model = platform.node() - system_version = platform.system() - app_version = TelegramClient.__version__ - lang_code = 'en' - system_lang_code = lang_code - """ - - # region Initialization - - def __init__(self, session, api_id, api_hash, - *, - connection=ConnectionTcpFull, - use_ipv6=False, - proxy=None, - update_workers=None, - timeout=timedelta(seconds=10), - spawn_read_thread=True, - report_errors=True, - **kwargs): - super().__init__( - session, api_id, api_hash, - connection=connection, - use_ipv6=use_ipv6, - proxy=proxy, - update_workers=update_workers, - spawn_read_thread=spawn_read_thread, - timeout=timeout, - report_errors=report_errors, - **kwargs - ) - - self._event_builders = [] - self._events_pending_resolve = [] - - # Default parse mode - self._parse_mode = markdown - - # Some fields to easy signing in. Let {phone: hash} be - # a dictionary because the user may change their mind. - self._phone_code_hash = {} - self._phone = None - self._tos = None - - # Sometimes we need to know who we are, cache the self peer - self._self_input_peer = None - - # endregion - - # region Telegram requests functions - - # region Authorization requests - - def send_code_request(self, phone, force_sms=False): - """ - Sends a code request to the specified phone number. - - Args: - phone (`str` | `int`): - The phone to which the code will be sent. - - force_sms (`bool`, optional): - Whether to force sending as SMS. - - Returns: - An instance of :tl:`SentCode`. - """ - phone = utils.parse_phone(phone) or self._phone - phone_hash = self._phone_code_hash.get(phone) - - if not phone_hash: - result = self(SendCodeRequest(phone, self.api_id, self.api_hash)) - self._tos = result.terms_of_service - self._phone_code_hash[phone] = phone_hash = result.phone_code_hash - else: - force_sms = True - - self._phone = phone - - if force_sms: - result = self(ResendCodeRequest(phone, phone_hash)) - self._phone_code_hash[phone] = result.phone_code_hash - - return result - - def start(self, - phone=lambda: input('Please enter your phone: '), - password=lambda: getpass.getpass('Please enter your password: '), - bot_token=None, force_sms=False, code_callback=None, - first_name='New User', last_name=''): - """ - Convenience method to interactively connect and sign in if required, - also taking into consideration that 2FA may be enabled in the account. - - If the phone doesn't belong to an existing account (and will hence - `sign_up` for a new one), **you are agreeing to Telegram's - Terms of Service. This is required and your account - will be banned otherwise.** See https://telegram.org/tos - and https://core.telegram.org/api/terms. - - Example usage: - >>> client = TelegramClient(session, api_id, api_hash).start(phone) - Please enter the code you received: 12345 - Please enter your password: ******* - (You are now logged in) - - Args: - phone (`str` | `int` | `callable`): - The phone (or callable without arguments to get it) - to which the code will be sent. - - password (`callable`, optional): - The password for 2 Factor Authentication (2FA). - This is only required if it is enabled in your account. - - bot_token (`str`): - Bot Token obtained by `@BotFather `_ - to log in as a bot. Cannot be specified with ``phone`` (only - one of either allowed). - - force_sms (`bool`, optional): - Whether to force sending the code request as SMS. - This only makes sense when signing in with a `phone`. - - code_callback (`callable`, optional): - A callable that will be used to retrieve the Telegram - login code. Defaults to `input()`. - - first_name (`str`, optional): - The first name to be used if signing up. This has no - effect if the account already exists and you sign in. - - last_name (`str`, optional): - Similar to the first name, but for the last. Optional. - - Returns: - This `TelegramClient`, so initialization - can be chained with ``.start()``. - """ - - if code_callback is None: - def code_callback(): - return input('Please enter the code you received: ') - elif not callable(code_callback): - raise ValueError( - 'The code_callback parameter needs to be a callable ' - 'function that returns the code you received by Telegram.' - ) - - if not phone and not bot_token: - raise ValueError('No phone number or bot token provided.') - - if phone and bot_token and not callable(phone): - raise ValueError('Both a phone and a bot token provided, ' - 'must only provide one of either') - - if not self.is_connected(): - self.connect() - - if self.is_user_authorized(): - self._check_events_pending_resolve() - return self - - if bot_token: - self.sign_in(bot_token=bot_token) - return self - - # Turn the callable into a valid phone number - while callable(phone): - phone = utils.parse_phone(phone()) or phone - - me = None - attempts = 0 - max_attempts = 3 - two_step_detected = False - - sent_code = self.send_code_request(phone, force_sms=force_sms) - sign_up = not sent_code.phone_registered - while attempts < max_attempts: - try: - if sign_up: - me = self.sign_up(code_callback(), first_name, last_name) - else: - # Raises SessionPasswordNeededError if 2FA enabled - me = self.sign_in(phone, code_callback()) - break - except SessionPasswordNeededError: - two_step_detected = True - break - except PhoneNumberOccupiedError: - sign_up = False - except PhoneNumberUnoccupiedError: - sign_up = True - except (PhoneCodeEmptyError, PhoneCodeExpiredError, - PhoneCodeHashEmptyError, PhoneCodeInvalidError): - print('Invalid code. Please try again.', file=sys.stderr) - - attempts += 1 - else: - raise RuntimeError( - '{} consecutive sign-in attempts failed. Aborting' - .format(max_attempts) - ) - - if two_step_detected: - if not password: - raise ValueError( - "Two-step verification is enabled for this account. " - "Please provide the 'password' argument to 'start()'." - ) - # TODO If callable given make it retry on invalid - if callable(password): - password = password() - me = self.sign_in(phone=phone, password=password) - - # We won't reach here if any step failed (exit by exception) - signed, name = 'Signed in successfully as', utils.get_display_name(me) - try: - print(signed, name) - except UnicodeEncodeError: - # Some terminals don't support certain characters - print(signed, name.encode('utf-8', errors='ignore') - .decode('ascii', errors='ignore')) - - self._check_events_pending_resolve() - return self - - def sign_in(self, phone=None, code=None, - password=None, bot_token=None, phone_code_hash=None): - """ - Starts or completes the sign in process with the given phone number - or code that Telegram sent. - - Args: - phone (`str` | `int`): - The phone to send the code to if no code was provided, - or to override the phone that was previously used with - these requests. - - code (`str` | `int`): - The code that Telegram sent. Note that if you have sent this - code through the application itself it will immediately - expire. If you want to send the code, obfuscate it somehow. - If you're not doing any of this you can ignore this note. - - password (`str`): - 2FA password, should be used if a previous call raised - SessionPasswordNeededError. - - bot_token (`str`): - Used to sign in as a bot. Not all requests will be available. - This should be the hash the @BotFather gave you. - - phone_code_hash (`str`): - The hash returned by .send_code_request. This can be set to None - to use the last hash known. - - Returns: - The signed in user, or the information about - :meth:`send_code_request`. - """ - if self.is_user_authorized(): - self._check_events_pending_resolve() - return self.get_me() - - if phone and not code and not password: - return self.send_code_request(phone) - elif code: - phone = utils.parse_phone(phone) or self._phone - phone_code_hash = \ - phone_code_hash or self._phone_code_hash.get(phone, None) - - if not phone: - raise ValueError( - 'Please make sure to call send_code_request first.' - ) - if not phone_code_hash: - raise ValueError('You also need to provide a phone_code_hash.') - - # May raise PhoneCodeEmptyError, PhoneCodeExpiredError, - # PhoneCodeHashEmptyError or PhoneCodeInvalidError. - result = self(SignInRequest(phone, phone_code_hash, str(code))) - elif password: - salt = self(GetPasswordRequest()).current_salt - result = self(CheckPasswordRequest( - helpers.get_password_hash(password, salt) - )) - elif bot_token: - result = self(ImportBotAuthorizationRequest( - flags=0, bot_auth_token=bot_token, - api_id=self.api_id, api_hash=self.api_hash - )) - else: - raise ValueError( - 'You must provide a phone and a code the first time, ' - 'and a password only if an RPCError was raised before.' - ) - - self._self_input_peer = utils.get_input_peer( - result.user, allow_self=False - ) - self._set_connected_and_authorized() - return result.user - - def sign_up(self, code, first_name, last_name=''): - """ - Signs up to Telegram if you don't have an account yet. - You must call .send_code_request(phone) first. - - **By using this method you're agreeing to Telegram's - Terms of Service. This is required and your account - will be banned otherwise.** See https://telegram.org/tos - and https://core.telegram.org/api/terms. - - Args: - code (`str` | `int`): - The code sent by Telegram - - first_name (`str`): - The first name to be used by the new account. - - last_name (`str`, optional) - Optional last name. - - Returns: - The new created :tl:`User`. - """ - if self.is_user_authorized(): - self._check_events_pending_resolve() - return self.get_me() - - if self._tos and self._tos.text: - if self.parse_mode: - t = self.parse_mode.unparse(self._tos.text, self._tos.entities) - else: - t = self._tos.text - sys.stderr.write("{}\n".format(t)) - sys.stderr.flush() - - result = self(SignUpRequest( - phone_number=self._phone, - phone_code_hash=self._phone_code_hash.get(self._phone, ''), - phone_code=str(code), - first_name=first_name, - last_name=last_name - )) - - if self._tos: - self(AcceptTermsOfServiceRequest(self._tos.id)) - - self._self_input_peer = utils.get_input_peer( - result.user, allow_self=False - ) - self._set_connected_and_authorized() - return result.user - - def log_out(self): - """ - Logs out Telegram and deletes the current ``*.session`` file. - - Returns: - ``True`` if the operation was successful. - """ - try: - self(LogOutRequest()) - except RPCError: - return False - - self.disconnect() - self.session.delete() - self._authorized = False - return True - - def get_me(self, input_peer=False): - """ - Gets "me" (the self user) which is currently authenticated, - or None if the request fails (hence, not authenticated). - - Args: - input_peer (`bool`, optional): - Whether to return the :tl:`InputPeerUser` version or the normal - :tl:`User`. This can be useful if you just need to know the ID - of yourself. - - Returns: - Your own :tl:`User`. - """ - if input_peer and self._self_input_peer: - return self._self_input_peer - - try: - me = self(GetUsersRequest([InputUserSelf()]))[0] - if not self._self_input_peer: - self._self_input_peer = utils.get_input_peer( - me, allow_self=False - ) - - return self._self_input_peer if input_peer else me - except UnauthorizedError: - return None - - # endregion - - # region Dialogs ("chats") requests - - def iter_dialogs(self, limit=None, offset_date=None, offset_id=0, - offset_peer=InputPeerEmpty(), _total=None): - """ - Returns an iterator over the dialogs, yielding 'limit' at most. - Dialogs are the open "chats" or conversations with other people, - groups you have joined, or channels you are subscribed to. - - Args: - limit (`int` | `None`): - How many dialogs to be retrieved as maximum. Can be set to - ``None`` to retrieve all dialogs. Note that this may take - whole minutes if you have hundreds of dialogs, as Telegram - will tell the library to slow down through a - ``FloodWaitError``. - - offset_date (`datetime`, optional): - The offset date to be used. - - offset_id (`int`, optional): - The message ID to be used as an offset. - - offset_peer (:tl:`InputPeer`, optional): - The peer to be used as an offset. - - _total (`list`, optional): - A single-item list to pass the total parameter by reference. - - Yields: - Instances of `telethon.tl.custom.dialog.Dialog`. - """ - limit = float('inf') if limit is None else int(limit) - if limit == 0: - if not _total: - return - # Special case, get a single dialog and determine count - dialogs = self(GetDialogsRequest( - offset_date=offset_date, - offset_id=offset_id, - offset_peer=offset_peer, - limit=1 - )) - _total[0] = getattr(dialogs, 'count', len(dialogs.dialogs)) - return - - seen = set() - req = GetDialogsRequest( - offset_date=offset_date, - offset_id=offset_id, - offset_peer=offset_peer, - limit=0 - ) - while len(seen) < limit: - req.limit = min(limit - len(seen), 100) - r = self(req) - - if _total: - _total[0] = getattr(r, 'count', len(r.dialogs)) - entities = {utils.get_peer_id(x): x - for x in itertools.chain(r.users, r.chats)} - messages = {m.id: custom.Message(self, m, entities, None) - for m in r.messages} - - # Happens when there are pinned dialogs - if len(r.dialogs) > limit: - r.dialogs = r.dialogs[:limit] - - for d in r.dialogs: - peer_id = utils.get_peer_id(d.peer) - if peer_id not in seen: - seen.add(peer_id) - yield Dialog(self, d, entities, messages) - - if len(r.dialogs) < req.limit or not isinstance(r, DialogsSlice): - # Less than we requested means we reached the end, or - # we didn't get a DialogsSlice which means we got all. - break - - req.offset_date = r.messages[-1].date - req.offset_peer = entities[utils.get_peer_id(r.dialogs[-1].peer)] - req.offset_id = r.messages[-1].id - req.exclude_pinned = True - - def get_dialogs(self, *args, **kwargs): - """ - Same as :meth:`iter_dialogs`, but returns a list instead - with an additional ``.total`` attribute on the list. - """ - total = [0] - kwargs['_total'] = total - dialogs = UserList(self.iter_dialogs(*args, **kwargs)) - dialogs.total = total[0] - return dialogs - - def iter_drafts(self): # TODO: Ability to provide a `filter` - """ - Iterator over all open draft messages. - - Instances of `telethon.tl.custom.draft.Draft` are yielded. - You can call `telethon.tl.custom.draft.Draft.set_message` - to change the message or `telethon.tl.custom.draft.Draft.delete` - among other things. - """ - for update in self(GetAllDraftsRequest()).updates: - yield Draft._from_update(self, update) - - def get_drafts(self): - """ - Same as :meth:`iter_drafts`, but returns a list instead. - """ - 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): - """ - Iterator over the participants belonging to the specified chat. - - Args: - entity (`entity`): - The entity from which to retrieve the participants list. - - limit (`int`): - Limits amount of participants fetched. - - search (`str`, optional): - Look for participants with this string in name/username. - - filter (:tl:`ChannelParticipantsFilter`, optional): - The filter to be used, if you want e.g. only admins - Note that you might not have permissions for some filter. - This has no effect for normal chats or users. - - aggressive (`bool`, optional): - Aggressively looks for all participants in the chat in - order to get more than 10,000 members (a hard limit - imposed by Telegram). Note that this might take a long - time (over 5 minutes), but is able to return over 90,000 - participants on groups with 100,000 members. - - This has no effect for groups or channels with less than - 10,000 members, or if a ``filter`` is given. - - _total (`list`, optional): - A single-item list to pass the total parameter by reference. - - Yields: - The :tl:`User` objects returned by :tl:`GetParticipantsRequest` - with an additional ``.participant`` attribute which is the - matched :tl:`ChannelParticipant` type for channels/megagroups - or :tl:`ChatParticipants` for normal chats. - """ - if isinstance(filter, type): - if filter in (ChannelParticipantsBanned, ChannelParticipantsKicked, - ChannelParticipantsSearch): - # These require a `q` parameter (support types for convenience) - filter = filter('') - else: - filter = filter() - - entity = self.get_input_entity(entity) - if search and (filter or not isinstance(entity, InputPeerChannel)): - # We need to 'search' ourselves unless we have a PeerChannel - search = search.lower() - - def filter_entity(ent): - return search in utils.get_display_name(ent).lower() or\ - search in (getattr(ent, 'username', '') or None).lower() - else: - def filter_entity(ent): - return True - - limit = float('inf') if limit is None else int(limit) - if isinstance(entity, InputPeerChannel): - if _total or (aggressive and not filter): - total = self(GetFullChannelRequest( - entity - )).full_chat.participants_count - if _total: - _total[0] = total - else: - total = 0 - - if limit == 0: - return - - seen = set() - if total > 10000 and aggressive and not filter: - requests = [GetParticipantsRequest( - channel=entity, - filter=ChannelParticipantsSearch(search + chr(x)), - offset=0, - limit=200, - hash=0 - ) for x in range(ord('a'), ord('z') + 1)] - else: - requests = [GetParticipantsRequest( - channel=entity, - filter=filter or ChannelParticipantsSearch(search), - offset=0, - limit=200, - hash=0 - )] - - while requests: - # Only care about the limit for the first request - # (small amount of people, won't be aggressive). - # - # Most people won't care about getting exactly 12,345 - # members so it doesn't really matter not to be 100% - # precise with being out of the offset/limit here. - requests[0].limit = min(limit - requests[0].offset, 200) - if requests[0].offset > limit: - break - - results = self(requests) - for i in reversed(range(len(requests))): - participants = results[i] - if not participants.users: - requests.pop(i) - else: - requests[i].offset += len(participants.participants) - users = {user.id: user for user in participants.users} - for participant in participants.participants: - user = users[participant.user_id] - if not filter_entity(user) or user.id in seen: - continue - - seen.add(participant.user_id) - user = users[participant.user_id] - user.participant = participant - yield user - if len(seen) >= limit: - return - - elif isinstance(entity, InputPeerChat): - # TODO We *could* apply the `filter` here ourselves - full = self(GetFullChatRequest(entity.chat_id)) - if not isinstance(full.full_chat.participants, ChatParticipants): - # ChatParticipantsForbidden won't have ``.participants`` - _total[0] = 0 - return - - if _total: - _total[0] = len(full.full_chat.participants.participants) - - have = 0 - users = {user.id: user for user in full.users} - for participant in full.full_chat.participants.participants: - user = users[participant.user_id] - if not filter_entity(user): - continue - have += 1 - if have > limit: - break - else: - user = users[participant.user_id] - user.participant = participant - yield user - else: - if _total: - _total[0] = 1 - if limit != 0: - user = self.get_entity(entity) - if filter_entity(user): - user.participant = None - yield user - - def get_participants(self, *args, **kwargs): - """ - Same as :meth:`iter_participants`, but returns a list instead - with an additional ``.total`` attribute on the list. - """ - total = [0] - kwargs['_total'] = total - participants = UserList(self.iter_participants(*args, **kwargs)) - participants.total = total[0] - return participants - - # endregion - - # 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 - - def download_profile_photo(self, entity, file=None, download_big=True): - """ - Downloads the profile photo of the given entity (user/chat/channel). - - Args: - entity (`entity`): - From who the photo will be downloaded. - - file (`str` | `file`, optional): - The output file path, directory, or stream-like object. - If the path exists and is a file, it will be overwritten. - - download_big (`bool`, optional): - Whether to use the big version of the available photos. - - Returns: - ``None`` if no photo was provided, or if it was Empty. On success - the file path is returned since it may differ from the one given. - """ - # hex(crc32(x.encode('ascii'))) for x in - # ('User', 'Chat', 'UserFull', 'ChatFull') - ENTITIES = (0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697) - # ('InputPeer', 'InputUser', 'InputChannel') - INPUTS = (0xc91c90b6, 0xe669bf46, 0x40f202fd) - if not isinstance(entity, TLObject) or entity.SUBCLASS_OF_ID in INPUTS: - entity = self.get_entity(entity) - - possible_names = [] - if entity.SUBCLASS_OF_ID not in ENTITIES: - photo = entity - else: - if not hasattr(entity, 'photo'): - # Special case: may be a ChatFull with photo:Photo - # This is different from a normal UserProfilePhoto and Chat - if not hasattr(entity, 'chat_photo'): - return None - - return self._download_photo(entity.chat_photo, file, - date=None, progress_callback=None) - - for attr in ('username', 'first_name', 'title'): - possible_names.append(getattr(entity, attr, None)) - - photo = entity.photo - - if isinstance(photo, (UserProfilePhoto, ChatPhoto)): - loc = photo.photo_big if download_big else photo.photo_small - else: - try: - loc = utils.get_input_location(photo) - except TypeError: - return None - - file = self._get_proper_filename( - file, 'profile_photo', '.jpg', - possible_names=possible_names - ) - - try: - self.download_file(loc, file) - return file - except LocationInvalidError: - # See issue #500, Android app fails as of v4.6.0 (1155). - # The fix seems to be using the full channel chat photo. - ie = self.get_input_entity(entity) - if isinstance(ie, InputPeerChannel): - full = self(GetFullChannelRequest(ie)) - return self._download_photo( - full.full_chat.chat_photo, file, - date=None, progress_callback=None - ) - else: - # Until there's a report for chats, no need to. - return None - - def download_media(self, message, file=None, progress_callback=None): - """ - Downloads the given media, or the media from a specified Message. - - Note that if the download is too slow, you should consider installing - ``cryptg`` (through ``pip install cryptg``) so that decrypting the - received data is done in C instead of Python (much faster). - - message (:tl:`Message` | :tl:`Media`): - The media or message containing the media that will be downloaded. - - file (`str` | `file`, optional): - The output file path, directory, or stream-like object. - If the path exists and is a file, it will be overwritten. - - progress_callback (`callable`, optional): - A callback function accepting two parameters: - ``(received bytes, total)``. - - Returns: - ``None`` if no media was provided, or if it was Empty. On success - the file path is returned since it may differ from the one given. - """ - # TODO This won't work for messageService - if isinstance(message, Message): - date = message.date - media = message.media - else: - date = datetime.now() - media = message - - if isinstance(media, MessageMediaWebPage): - if isinstance(media.webpage, WebPage): - media = media.webpage.document or media.webpage.photo - - if isinstance(media, (MessageMediaPhoto, Photo, - PhotoSize, PhotoCachedSize)): - return self._download_photo( - media, file, date, progress_callback - ) - elif isinstance(media, (MessageMediaDocument, Document)): - return self._download_document( - media, file, date, progress_callback - ) - elif isinstance(media, MessageMediaContact): - return self._download_contact( - media, file - ) - - def _download_photo(self, photo, file, date, progress_callback): - """Specialized version of .download_media() for photos""" - # Determine the photo and its largest size - if isinstance(photo, MessageMediaPhoto): - photo = photo.photo - if isinstance(photo, Photo): - for size in reversed(photo.sizes): - if not isinstance(size, PhotoSizeEmpty): - photo = size - break - else: - return - if not isinstance(photo, (PhotoSize, PhotoCachedSize)): - return - - file = self._get_proper_filename(file, 'photo', '.jpg', date=date) - if isinstance(photo, PhotoCachedSize): - # No need to download anything, simply write the bytes - if isinstance(file, str): - helpers.ensure_parent_dir_exists(file) - f = open(file, 'wb') - else: - f = file - try: - f.write(photo.bytes) - finally: - if isinstance(file, str): - f.close() - return file - - self.download_file(photo.location, file, file_size=photo.size, - progress_callback=progress_callback) - return file - - def _download_document(self, document, file, date, progress_callback): - """Specialized version of .download_media() for documents.""" - if isinstance(document, MessageMediaDocument): - document = document.document - if not isinstance(document, Document): - return - - file_size = document.size - - kind = 'document' - possible_names = [] - for attr in document.attributes: - if isinstance(attr, DocumentAttributeFilename): - possible_names.insert(0, attr.file_name) - - elif isinstance(attr, DocumentAttributeAudio): - kind = 'audio' - if attr.performer and attr.title: - possible_names.append('{} - {}'.format( - attr.performer, attr.title - )) - elif attr.performer: - possible_names.append(attr.performer) - elif attr.title: - possible_names.append(attr.title) - elif attr.voice: - kind = 'voice' - - file = self._get_proper_filename( - file, kind, utils.get_extension(document), - date=date, possible_names=possible_names - ) - - self.download_file(document, file, file_size=file_size, - progress_callback=progress_callback) - return file - - @staticmethod - def _download_contact(mm_contact, file): - """Specialized version of .download_media() for contacts. - Will make use of the vCard 4.0 format. - """ - first_name = mm_contact.first_name - last_name = mm_contact.last_name - phone_number = mm_contact.phone_number - - if isinstance(file, str): - file = TelegramClient._get_proper_filename( - file, 'contact', '.vcard', - possible_names=[first_name, phone_number, last_name] - ) - f = open(file, 'w', encoding='utf-8') - else: - f = file - - try: - # Remove these pesky characters - first_name = first_name.replace(';', '') - last_name = (last_name or '').replace(';', '') - f.write('BEGIN:VCARD\n') - f.write('VERSION:4.0\n') - f.write('N:{};{};;;\n'.format(first_name, last_name)) - f.write('FN:{} {}\n'.format(first_name, last_name)) - f.write('TEL;TYPE=cell;VALUE=uri:tel:+{}\n'.format(phone_number)) - f.write('END:VCARD\n') - finally: - # Only close the stream if we opened it - if isinstance(file, str): - f.close() - - return file - - @staticmethod - def _get_proper_filename(file, kind, extension, - date=None, possible_names=None): - """Gets a proper filename for 'file', if this is a path. - - 'kind' should be the kind of the output file (photo, document...) - 'extension' should be the extension to be added to the file if - the filename doesn't have any yet - 'date' should be when this file was originally sent, if known - 'possible_names' should be an ordered list of possible names - - If no modification is made to the path, any existing file - will be overwritten. - If any modification is made to the path, this method will - ensure that no existing file will be overwritten. - """ - if file is not None and not isinstance(file, str): - # Probably a stream-like object, we cannot set a filename here - return file - - if file is None: - file = '' - elif os.path.isfile(file): - # Make no modifications to valid existing paths - return file - - if os.path.isdir(file) or not file: - try: - name = None if possible_names is None else next( - x for x in possible_names if x - ) - except StopIteration: - name = None - - if not name: - if not date: - date = datetime.now() - name = '{}_{}-{:02}-{:02}_{:02}-{:02}-{:02}'.format( - kind, - date.year, date.month, date.day, - date.hour, date.minute, date.second, - ) - file = os.path.join(file, name) - - directory, name = os.path.split(file) - name, ext = os.path.splitext(name) - if not ext: - ext = extension - - result = os.path.join(directory, name + ext) - if not os.path.isfile(result): - return result - - i = 1 - while True: - result = os.path.join(directory, '{} ({}){}'.format(name, i, ext)) - if not os.path.isfile(result): - return result - i += 1 - - def download_file(self, - input_location, - file=None, - part_size_kb=None, - file_size=None, - progress_callback=None): - """ - Downloads the given input location to a file. - - Args: - input_location (:tl:`FileLocation` | :tl:`InputFileLocation`): - The file location from which the file will be downloaded. - See `telethon.utils.get_input_location` source for a complete - list of supported types. - - file (`str` | `file`, optional): - The output file path, directory, or stream-like object. - If the path exists and is a file, it will be overwritten. - - If the file path is ``None``, then the result will be - saved in memory and returned as `bytes`. - - part_size_kb (`int`, optional): - Chunk size when downloading files. The larger, the less - requests will be made (up to 512KB maximum). - - file_size (`int`, optional): - The file size that is about to be downloaded, if known. - Only used if ``progress_callback`` is specified. - - progress_callback (`callable`, optional): - A callback function accepting two parameters: - ``(downloaded bytes, total)``. Note that the - ``total`` is the provided ``file_size``. - """ - if not part_size_kb: - if not file_size: - part_size_kb = 64 # Reasonable default - else: - part_size_kb = utils.get_appropriated_part_size(file_size) - - part_size = int(part_size_kb * 1024) - # https://core.telegram.org/api/files says: - # > part_size % 1024 = 0 (divisible by 1KB) - # - # But https://core.telegram.org/cdn (more recent) says: - # > limit must be divisible by 4096 bytes - # So we just stick to the 4096 limit. - if part_size % 4096 != 0: - raise ValueError( - 'The part size must be evenly divisible by 4096.') - - in_memory = file is None - if in_memory: - f = io.BytesIO() - elif isinstance(file, str): - # Ensure that we'll be able to download the media - helpers.ensure_parent_dir_exists(file) - f = open(file, 'wb') - else: - f = file - - # The used client will change if FileMigrateError occurs - client = self - cdn_decrypter = None - input_location = utils.get_input_location(input_location) - - __log__.info('Downloading file in chunks of %d bytes', part_size) - try: - offset = 0 - while True: - try: - if cdn_decrypter: - result = cdn_decrypter.get_file() - else: - result = client(GetFileRequest( - input_location, offset, part_size - )) - - if isinstance(result, FileCdnRedirect): - __log__.info('File lives in a CDN') - cdn_decrypter, result = \ - CdnDecrypter.prepare_decrypter( - client, self._get_cdn_client(result), - result - ) - - except FileMigrateError as e: - __log__.info('File lives in another DC') - client = self._get_exported_client(e.new_dc) - continue - - offset += part_size - - # If we have received no data (0 bytes), the file is over - # So there is nothing left to download and write - if not result.bytes: - # Return some extra information, unless it's a CDN file - if in_memory: - f.flush() - return f.getvalue() - else: - return getattr(result, 'type', '') - - f.write(result.bytes) - __log__.debug('Saved %d more bytes', len(result.bytes)) - if progress_callback: - progress_callback(f.tell(), file_size) - finally: - if client != self: - client.disconnect() - - if cdn_decrypter: - try: - cdn_decrypter.client.disconnect() - except: - pass - if isinstance(file, str) or in_memory: - f.close() - - # endregion - - # endregion - - # region Event handling - - def on(self, event): - """ - Decorator helper method around add_event_handler(). - - Args: - event (`_EventBuilder` | `type`): - The event builder class or instance to be used, - for instance ``events.NewMessage``. - """ - def decorator(f): - self.add_event_handler(f, event) - return f - - return decorator - - def _check_events_pending_resolve(self): - if self._events_pending_resolve: - for event in self._events_pending_resolve: - event.resolve(self) - self._events_pending_resolve.clear() - - def _on_handler(self, update): - for builder, callback in self._event_builders: - event = builder.build(update) - if event: - if hasattr(event, '_set_client'): - event._set_client(self) - else: - event._client = self - - event.original_update = update - try: - callback(event) - except events.StopPropagation: - __log__.debug( - "Event handler '{}' stopped chain of " - "propagation for event {}." - .format(callback.__name__, type(event).__name__) - ) - break - - def add_event_handler(self, callback, event=None): - """ - Registers the given callback to be called on the specified event. - - Args: - callback (`callable`): - The callable function accepting one parameter to be used. - - event (`_EventBuilder` | `type`, optional): - The event builder class or instance to be used, - for instance ``events.NewMessage``. - - If left unspecified, `telethon.events.raw.Raw` (the - :tl:`Update` objects with no further processing) will - be passed instead. - """ - if self.updates.workers is None: - warnings.warn( - "You have not setup any workers, so you won't receive updates." - " Pass update_workers=1 when creating the TelegramClient," - " or set client.self.updates.workers = 1" - ) - - self.updates.handler = self._on_handler - if isinstance(event, type): - event = event() - elif not event: - event = events.Raw() - - if self.is_user_authorized(): - event.resolve(self) - self._check_events_pending_resolve() - else: - self._events_pending_resolve.append(event) - - self._event_builders.append((event, callback)) - - def remove_event_handler(self, callback, event=None): - """ - Inverse operation of :meth:`add_event_handler`. - - If no event is given, all events for this callback are removed. - Returns how many callbacks were removed. - """ - found = 0 - if event and not isinstance(event, type): - event = type(event) - - i = len(self._event_builders) - while i: - i -= 1 - ev, cb = self._event_builders[i] - if cb == callback and (not event or isinstance(ev, event)): - del self._event_builders[i] - found += 1 - - return found - - def list_event_handlers(self): - """ - Lists all added event handlers, returning a list of pairs - consisting of (callback, event). - """ - return [(callback, event) for event, callback in self._event_builders] - - def add_update_handler(self, handler): - """Deprecated, see :meth:`add_event_handler`.""" - warnings.warn( - 'add_update_handler is deprecated, use the @client.on syntax ' - 'or add_event_handler(callback, events.Raw) instead (see ' - 'https://telethon.rtfd.io/en/latest/extra/basic/working-' - 'with-updates.html)' - ) - return self.add_event_handler(handler, events.Raw) - - def remove_update_handler(self, handler): - return self.remove_event_handler(handler) - - def list_update_handlers(self): - return [callback for callback, _ in self.list_event_handlers()] - - def catch_up(self): - state = self.session.get_update_state(0) - if not state or not state.pts: - return - - self.session.catching_up = True - try: - while True: - d = self(GetDifferenceRequest(state.pts, state.date, state.qts)) - if isinstance(d, DifferenceEmpty): - state.date = d.date - state.seq = d.seq - break - elif isinstance(d, (DifferenceSlice, Difference)): - if isinstance(d, Difference): - state = d.state - elif d.intermediate_state.pts > state.pts: - state = d.intermediate_state - else: - # TODO Figure out why other applications can rely on - # using always the intermediate_state to eventually - # reach a DifferenceEmpty, but that leads to an - # infinite loop here (so check against old pts to stop) - break - - self.updates.process(Updates( - users=d.users, - chats=d.chats, - date=state.date, - seq=state.seq, - updates=d.other_updates + [UpdateNewMessage(m, 0, 0) - for m in d.new_messages] - )) - elif isinstance(d, DifferenceTooLong): - break - finally: - self.session.set_update_state(0, state) - self.session.catching_up = False - - # endregion - - # region Small utilities to make users' life easier - - def _set_connected_and_authorized(self): - super()._set_connected_and_authorized() - self._check_events_pending_resolve() - - def get_entity(self, entity): - """ - Turns the given entity into a valid Telegram user or chat. - - entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): - The entity (or iterable of entities) to be transformed. - If it's a string which can be converted to an integer or starts - with '+' it will be resolved as if it were a phone number. - - If it doesn't start with '+' or starts with a '@' it will be - be resolved from the username. If no exact match is returned, - an error will be raised. - - If the entity is an integer or a Peer, its information will be - returned through a call to self.get_input_peer(entity). - - If the entity is neither, and it's not a TLObject, an - error will be raised. - - Returns: - :tl:`User`, :tl:`Chat` or :tl:`Channel` corresponding to the - input entity. A list will be returned if more than one was given. - """ - single = not utils.is_list_like(entity) - if single: - entity = (entity,) - - # Group input entities by string (resolve username), - # input users (get users), input chat (get chats) and - # input channels (get channels) to get the most entities - # in the less amount of calls possible. - inputs = [ - x if isinstance(x, str) else self.get_input_entity(x) - for x in entity - ] - users = [x for x in inputs - if isinstance(x, (InputPeerUser, InputPeerSelf))] - chats = [x.chat_id for x in inputs if isinstance(x, InputPeerChat)] - channels = [x for x in inputs if isinstance(x, InputPeerChannel)] - if users: - # GetUsersRequest has a limit of 200 per call - tmp = [] - while users: - curr, users = users[:200], users[200:] - tmp.extend(self(GetUsersRequest(curr))) - users = tmp - if chats: # TODO Handle chats slice? - chats = self(GetChatsRequest(chats)).chats - if channels: - channels = self(GetChannelsRequest(channels)).chats - - # Merge users, chats and channels into a single dictionary - id_entity = { - utils.get_peer_id(x): x - for x in itertools.chain(users, chats, channels) - } - - # We could check saved usernames and put them into the users, - # chats and channels list from before. While this would reduce - # the amount of ResolveUsername calls, it would fail to catch - # username changes. - result = [ - self._get_entity_from_string(x) if isinstance(x, str) - else ( - id_entity[utils.get_peer_id(x)] - if not isinstance(x, InputPeerSelf) - else next(u for u in id_entity.values() - if isinstance(u, User) and u.is_self) - ) - for x in inputs - ] - return result[0] if single else result - - def _get_entity_from_string(self, string): - """ - Gets a full entity from the given string, which may be a phone or - an username, and processes all the found entities on the session. - The string may also be a user link, or a channel/chat invite link. - - This method has the side effect of adding the found users to the - session database, so it can be queried later without API calls, - if this option is enabled on the session. - - Returns the found entity, or raises TypeError if not found. - """ - phone = utils.parse_phone(string) - if phone: - for user in self(GetContactsRequest(0)).users: - if user.phone == phone: - return user - else: - username, is_join_chat = utils.parse_username(string) - if is_join_chat: - invite = self(CheckChatInviteRequest(username)) - if isinstance(invite, ChatInvite): - raise ValueError( - 'Cannot get entity from a channel (or group) ' - 'that you are not part of. Join the group and retry' - ) - elif isinstance(invite, ChatInviteAlready): - return invite.chat - elif username: - if username in ('me', 'self'): - return self.get_me() - - try: - result = self(ResolveUsernameRequest(username)) - except UsernameNotOccupiedError as e: - raise ValueError('No user has "{}" as username' - .format(username)) from e - - for entity in itertools.chain(result.users, result.chats): - if getattr(entity, 'username', None) or ''\ - .lower() == username: - return entity - try: - # Nobody with this username, maybe it's an exact name/title - return self.get_entity(self.session.get_input_entity(string)) - except ValueError: - pass - - raise ValueError( - 'Cannot find any entity corresponding to "{}"'.format(string) - ) - - def get_input_entity(self, peer): - """ - Turns the given peer into its input entity version. Most requests - use this kind of InputUser, InputChat and so on, so this is the - most suitable call to make for those cases. - - entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): - The integer ID of an user or otherwise either of a - :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`, for - which to get its ``Input*`` version. - - If this ``Peer`` hasn't been seen before by the library, the top - dialogs will be loaded and their entities saved to the session - file (unless this feature was disabled explicitly). - - If in the end the access hash required for the peer was not found, - a ValueError will be raised. - - Returns: - :tl:`InputPeerUser`, :tl:`InputPeerChat` or :tl:`InputPeerChannel` - or :tl:`InputPeerSelf` if the parameter is ``'me'`` or ``'self'``. - - If you need to get the ID of yourself, you should use - `get_me` with ``input_peer=True``) instead. - """ - if peer in ('me', 'self'): - return InputPeerSelf() - - try: - # First try to get the entity from cache, otherwise figure it out - return self.session.get_input_entity(peer) - except ValueError: - pass - - if isinstance(peer, str): - return utils.get_input_peer(self._get_entity_from_string(peer)) - - if not isinstance(peer, int) and (not isinstance(peer, TLObject) - or peer.SUBCLASS_OF_ID != 0x2d45687): - # Try casting the object into an input peer. Might TypeError. - # Don't do it if a not-found ID was given (instead ValueError). - # Also ignore Peer (0x2d45687 == crc32(b'Peer'))'s, lacking hash. - return utils.get_input_peer(peer) - - raise ValueError( - 'Could not find the input entity for "{}". Please read https://' - 'telethon.readthedocs.io/en/latest/extra/basic/entities.html to' - ' find out more details.' - .format(peer) - ) - - def edit_2fa(self, current_password=None, new_password=None, hint='', - email=None): - """ - Changes the 2FA settings of the logged in user, according to the - passed parameters. Take note of the parameter explanations. - - Has no effect if both current and new password are omitted. - - current_password (`str`, optional): - The current password, to authorize changing to ``new_password``. - Must be set if changing existing 2FA settings. - Must **not** be set if 2FA is currently disabled. - Passing this by itself will remove 2FA (if correct). - - new_password (`str`, optional): - The password to set as 2FA. - If 2FA was already enabled, ``current_password`` **must** be set. - Leaving this blank or ``None`` will remove the password. - - hint (`str`, optional): - Hint to be displayed by Telegram when it asks for 2FA. - Leaving unspecified is highly discouraged. - Has no effect if ``new_password`` is not set. - - email (`str`, optional): - Recovery and verification email. Raises ``EmailUnconfirmedError`` - if value differs from current one, and has no effect if - ``new_password`` is not set. - - Returns: - ``True`` if successful, ``False`` otherwise. - """ - if new_password is None and current_password is None: - return False - - pass_result = self(GetPasswordRequest()) - if isinstance(pass_result, NoPassword) and current_password: - current_password = None - - salt_random = os.urandom(8) - salt = pass_result.new_salt + salt_random - if not current_password: - current_password_hash = salt - else: - current_password = pass_result.current_salt +\ - current_password.encode() + pass_result.current_salt - current_password_hash = hashlib.sha256(current_password).digest() - - if new_password: # Setting new password - new_password = salt + new_password.encode('utf-8') + salt - new_password_hash = hashlib.sha256(new_password).digest() - new_settings = PasswordInputSettings( - new_salt=salt, - new_password_hash=new_password_hash, - hint=hint - ) - if email: # If enabling 2FA or changing email - new_settings.email = email # TG counts empty string as None - return self(UpdatePasswordSettingsRequest( - current_password_hash, new_settings=new_settings - )) - else: # Removing existing password - return self(UpdatePasswordSettingsRequest( - current_password_hash, - new_settings=PasswordInputSettings( - new_salt=bytes(), - new_password_hash=bytes(), - hint=hint - ) - )) - - # endregion diff --git a/telethon/tl/__init__.py b/telethon/tl/__init__.py index 96c934bb..e187537f 100644 --- a/telethon/tl/__init__.py +++ b/telethon/tl/__init__.py @@ -1,4 +1 @@ -from .tlobject import TLObject -from .gzip_packed import GzipPacked -from .tl_message import TLMessage -from .message_container import MessageContainer +from .tlobject import TLObject, TLRequest diff --git a/telethon/tl/core/__init__.py b/telethon/tl/core/__init__.py new file mode 100644 index 00000000..3113196a --- /dev/null +++ b/telethon/tl/core/__init__.py @@ -0,0 +1,26 @@ +""" +This module holds core "special" types, which are more convenient ways +to do stuff in a `telethon.network.mtprotosender.MTProtoSender` instance. + +Only special cases are gzip-packed data, the response message (not a +client message), the message container which references these messages +and would otherwise conflict with the rest, and finally the RpcResult: + + rpc_result#f35c6d01 req_msg_id:long result:bytes = RpcResult; + +Three things to note with this definition: +1. The constructor ID is actually ``42d36c2c``. +2. Those bytes are not read like the rest of bytes (length + payload). + They are actually the raw bytes of another object, which can't be + read directly because it depends on per-request information (since + some can return ``Vector`` and ``Vector``). +3. Those bytes may be gzipped data, which needs to be treated early. +""" +from .tlmessage import TLMessage +from .gzippacked import GzipPacked +from .messagecontainer import MessageContainer +from .rpcresult import RpcResult + +core_objects = {x.CONSTRUCTOR_ID: x for x in ( + GzipPacked, MessageContainer, RpcResult +)} diff --git a/telethon/tl/gzip_packed.py b/telethon/tl/core/gzippacked.py similarity index 83% rename from telethon/tl/gzip_packed.py rename to telethon/tl/core/gzippacked.py index 053acd86..7b146363 100644 --- a/telethon/tl/gzip_packed.py +++ b/telethon/tl/core/gzippacked.py @@ -1,7 +1,7 @@ import gzip import struct -from . import TLObject +from .. import TLObject, TLRequest class GzipPacked(TLObject): @@ -21,7 +21,7 @@ class GzipPacked(TLObject): """ data = bytes(request) # TODO This threshold could be configurable - if request.content_related and len(data) > 512: + if isinstance(request, TLRequest) and len(data) > 512: gzipped = bytes(GzipPacked(data)) return gzipped if len(gzipped) < len(data) else data else: @@ -36,3 +36,7 @@ class GzipPacked(TLObject): def read(reader): assert reader.read_int(signed=False) == GzipPacked.CONSTRUCTOR_ID return gzip.decompress(reader.tgread_bytes()) + + @classmethod + def from_reader(cls, reader): + return GzipPacked(gzip.decompress(reader.tgread_bytes())) diff --git a/telethon/tl/core/messagecontainer.py b/telethon/tl/core/messagecontainer.py new file mode 100644 index 00000000..bb033a91 --- /dev/null +++ b/telethon/tl/core/messagecontainer.py @@ -0,0 +1,50 @@ +import logging +import struct + +from .tlmessage import TLMessage +from ..tlobject import TLObject + +__log__ = logging.getLogger(__name__) + + +class MessageContainer(TLObject): + CONSTRUCTOR_ID = 0x73f1f8dc + + def __init__(self, messages): + self.messages = messages + + def to_dict(self, recursive=True): + return { + 'messages': + ([] if self.messages is None else [ + None if x is None else x.to_dict() for x in self.messages + ]) if recursive else self.messages, + } + + def __bytes__(self): + return struct.pack( + '= 2: raise ValueError('You can only set either of i, text or filter') - if not self.buttons: + if not await self.buttons: return # Accessing the property sets self._buttons[_flat] if text is not None: if callable(text): for button in self._buttons_flat: if text(button.text): - return button.click() + return await button.click() else: for button in self._buttons_flat: if button.text == text: - return button.click() + return await button.click() return if filter is not None: for button in self._buttons_flat: if filter(button): - return button.click() + return await button.click() return if i is None: i = 0 if j is None: - return self._buttons_flat[i].click() + return await self._buttons_flat[i].click() else: - return self._buttons[i][j].click() + return await self._buttons[i][j].click() class _CustomMessage(Message, types.Message): diff --git a/telethon/tl/custom/messagebutton.py b/telethon/tl/custom/messagebutton.py index cd9b1ffc..7d3e4d50 100644 --- a/telethon/tl/custom/messagebutton.py +++ b/telethon/tl/custom/messagebutton.py @@ -1,4 +1,5 @@ from .. import types, functions +from ...errors import BotTimeout import webbrowser @@ -51,23 +52,37 @@ class MessageButton: if isinstance(self.button, types.KeyboardButtonUrl): return self.button.url - def click(self): + async def click(self): """ - Clicks the inline keyboard button of the message, if any. + Emulates the behaviour of clicking this button. - If the message has a non-inline keyboard, clicking it will - send the message, switch to inline, or open its URL. + If it's a normal :tl:`KeyboardButton` with text, a message will be + sent, and the sent `telethon.tl.custom.message.Message` returned. + + If it's an inline :tl:`KeyboardButtonCallback` with text and data, + it will be "clicked" and the :tl:`BotCallbackAnswer` returned. + + If it's an inline :tl:`KeyboardButtonSwitchInline` button, the + :tl:`StartBotRequest` will be invoked and the resulting updates + returned. + + If it's a :tl:`KeyboardButtonUrl`, the URL of the button will + be passed to ``webbrowser.open`` and return ``True`` on success. """ if isinstance(self.button, types.KeyboardButton): - return self._client.send_message( + return await self._client.send_message( self._chat, self.button.text, reply_to=self._msg_id) elif isinstance(self.button, types.KeyboardButtonCallback): - return self._client(functions.messages.GetBotCallbackAnswerRequest( + req = functions.messages.GetBotCallbackAnswerRequest( peer=self._chat, msg_id=self._msg_id, data=self.button.data - ), retries=1) + ) + try: + return await self._client(req) + except BotTimeout: + return None elif isinstance(self.button, types.KeyboardButtonSwitchInline): - return self._client(functions.messages.StartBotRequest( + return await self._client(functions.messages.StartBotRequest( bot=self._from, peer=self._chat, start_param=self.button.query - ), retries=1) + )) elif isinstance(self.button, types.KeyboardButtonUrl): return webbrowser.open(self.button.url) diff --git a/telethon/tl/message_container.py b/telethon/tl/message_container.py deleted file mode 100644 index 58fb8021..00000000 --- a/telethon/tl/message_container.py +++ /dev/null @@ -1,42 +0,0 @@ -import struct - -from . import TLObject - - -class MessageContainer(TLObject): - CONSTRUCTOR_ID = 0x73f1f8dc - - def __init__(self, messages): - super().__init__() - self.content_related = False - self.messages = messages - - def to_dict(self, recursive=True): - return { - 'content_related': self.content_related, - 'messages': - ([] if self.messages is None else [ - None if x is None else x.to_dict() for x in self.messages - ]) if recursive else self.messages, - } - - def __bytes__(self): - return struct.pack( - ' 0: 'workers' background threads will be spawned, any - any of them will invoke the self.handler. - """ - self._workers = workers - self._worker_threads = [] - + def __init__(self): self.handler = None self._updates_lock = RLock() self._updates = Queue() @@ -50,66 +40,6 @@ class UpdateState: except Empty: return None - def get_workers(self): - return self._workers - - def set_workers(self, n): - """Changes the number of workers running. - If 'n is None', clears all pending updates from memory. - """ - if n is None: - self.stop_workers() - else: - self._workers = n - self.setup_workers() - - workers = property(fget=get_workers, fset=set_workers) - - def stop_workers(self): - """ - Waits for all the worker threads to stop. - """ - # Put dummy ``None`` objects so that they don't need to timeout. - n = self._workers - self._workers = None - if n: - with self._updates_lock: - for _ in range(n): - self._updates.put(None) - - for t in self._worker_threads: - t.join() - - self._worker_threads.clear() - self._workers = n - - def setup_workers(self): - if self._worker_threads or not self._workers: - # There already are workers, or workers is None or 0. Do nothing. - return - - for i in range(self._workers): - thread = Thread( - target=UpdateState._worker_loop, - name='UpdateWorker{}'.format(i), - daemon=True, - args=(self, i) - ) - self._worker_threads.append(thread) - thread.start() - - def _worker_loop(self, wid): - while self._workers is not None: - try: - update = self.poll(timeout=UpdateState.WORKER_POLL_TIMEOUT) - if update and self.handler: - self.handler(update) - except StopIteration: - break - except: - # We don't want to crash a worker thread due to any reason - __log__.exception('Unhandled exception on worker %d', wid) - def get_update_state(self, entity_id): """Gets the updates.State corresponding to the given entity or 0.""" return self._state @@ -118,35 +48,32 @@ class UpdateState: """Processes an update object. This method is normally called by the library itself. """ - if self._workers is None: - return # No processing needs to be done if nobody's working + if isinstance(update, tl.updates.State): + __log__.debug('Saved new updates state') + self._state = update + return # Nothing else to be done - with self._updates_lock: - if isinstance(update, tl.updates.State): - __log__.debug('Saved new updates state') - self._state = update - return # Nothing else to be done + if hasattr(update, 'pts'): + self._state.pts = update.pts - if hasattr(update, 'pts'): - self._state.pts = update.pts + # After running the script for over an hour and receiving over + # 1000 updates, the only duplicates received were users going + # online or offline. We can trust the server until new reports. + # This should only be used as read-only. + if isinstance(update, tl.UpdateShort): + update.update._entities = {} + self._updates.put(update.update) - # After running the script for over an hour and receiving over - # 1000 updates, the only duplicates received were users going - # online or offline. We can trust the server until new reports. - # This should only be used as read-only. - if isinstance(update, tl.UpdateShort): - update.update._entities = {} - self._updates.put(update.update) - # Expand "Updates" into "Update", and pass these to callbacks. - # Since .users and .chats have already been processed, we - # don't need to care about those either. - elif isinstance(update, (tl.Updates, tl.UpdatesCombined)): - entities = {utils.get_peer_id(x): x for x in - itertools.chain(update.users, update.chats)} - for u in update.updates: - u._entities = entities - self._updates.put(u) - # TODO Handle "tl.UpdatesTooLong" - else: - update._entities = {} - self._updates.put(update) + # Expand "Updates" into "Update", and pass these to callbacks. + # Since .users and .chats have already been processed, we + # don't need to care about those either. + elif isinstance(update, (tl.Updates, tl.UpdatesCombined)): + entities = {utils.get_peer_id(x): x for x in + itertools.chain(update.users, update.chats)} + for u in update.updates: + u._entities = entities + self._updates.put(u) + # TODO Handle "tl.UpdatesTooLong" + else: + update._entities = {} + self._updates.put(update) diff --git a/telethon/utils.py b/telethon/utils.py index d8b4b97b..fb558e41 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -12,6 +12,7 @@ import types from collections import UserList from mimetypes import guess_extension +from .extensions import markdown, html from .tl import TLObject from .tl.types import ( Channel, ChannelForbidden, Chat, ChatEmpty, ChatForbidden, ChatFull, @@ -402,6 +403,60 @@ 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 sanitize_parse_mode(mode): + """ + Converts the given parse mode into an object with + ``parse`` and ``unparse`` callable properties. + """ + 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 get_input_location(location): """Similar to :meth:`get_input_peer`, but for input messages.""" try: diff --git a/telethon_generator/data/mtproto_api.tl b/telethon_generator/data/mtproto_api.tl index aa5e4c97..79fbb40d 100644 --- a/telethon_generator/data/mtproto_api.tl +++ b/telethon_generator/data/mtproto_api.tl @@ -49,7 +49,7 @@ new_session_created#9ec20908 first_msg_id:long unique_id:long server_salt:long = //message msg_id:long seqno:int bytes:int body:bytes = Message; //msg_copy#e06046b2 orig_message:Message = MessageCopy; -gzip_packed#3072cfa1 packed_data:bytes = Object; +//gzip_packed#3072cfa1 packed_data:bytes = Object; msgs_ack#62d6b459 msg_ids:Vector = MsgsAck; diff --git a/telethon_generator/generators/tlobject.py b/telethon_generator/generators/tlobject.py index 1058a1ae..c2c20ab8 100644 --- a/telethon_generator/generators/tlobject.py +++ b/telethon_generator/generators/tlobject.py @@ -14,10 +14,15 @@ AUTO_GEN_NOTICE = \ AUTO_CASTS = { - 'InputPeer': 'utils.get_input_peer(client.get_input_entity({}))', - 'InputChannel': 'utils.get_input_channel(client.get_input_entity({}))', - 'InputUser': 'utils.get_input_user(client.get_input_entity({}))', - 'InputDialogPeer': 'utils.get_input_dialog(client.get_input_entity({}))', + 'InputPeer': + 'utils.get_input_peer(await client.get_input_entity({}))', + 'InputChannel': + 'utils.get_input_channel(await client.get_input_entity({}))', + 'InputUser': + 'utils.get_input_user(await client.get_input_entity({}))', + 'InputDialogPeer': + 'utils.get_input_dialog(await client.get_input_entity({}))', + 'InputMedia': 'utils.get_input_media({})', 'InputPhoto': 'utils.get_input_photo({})', 'InputMessage': 'utils.get_input_message({})' @@ -27,7 +32,8 @@ BASE_TYPES = ('string', 'bytes', 'int', 'long', 'int128', 'int256', 'double', 'Bool', 'true', 'date') -def _write_modules(out_dir, depth, namespace_tlobjects, type_constructors): +def _write_modules( + out_dir, depth, kind, namespace_tlobjects, type_constructors): # namespace_tlobjects: {'namespace', [TLObject]} os.makedirs(out_dir, exist_ok=True) for ns, tlobjects in namespace_tlobjects.items(): @@ -36,7 +42,7 @@ def _write_modules(out_dir, depth, namespace_tlobjects, type_constructors): SourceBuilder(f) as builder: builder.writeln(AUTO_GEN_NOTICE) - builder.writeln('from {}.tl.tlobject import TLObject', '.' * depth) + builder.writeln('from {}.tl.tlobject import {}', '.' * depth, kind) builder.writeln('from typing import Optional, List, ' 'Union, TYPE_CHECKING') @@ -119,7 +125,7 @@ def _write_modules(out_dir, depth, namespace_tlobjects, type_constructors): # Generate the class for every TLObject for t in tlobjects: - _write_source_code(t, builder, type_constructors) + _write_source_code(t, kind, builder, type_constructors) builder.current_indent = 0 # Write the type definitions generated earlier. @@ -128,7 +134,7 @@ def _write_modules(out_dir, depth, namespace_tlobjects, type_constructors): builder.writeln(line) -def _write_source_code(tlobject, builder, type_constructors): +def _write_source_code(tlobject, kind, builder, type_constructors): """ Writes the source code corresponding to the given TLObject by making use of the ``builder`` `SourceBuilder`. @@ -137,18 +143,18 @@ def _write_source_code(tlobject, builder, type_constructors): the ``Type: [Constructors]`` must be given for proper importing and documentation strings. """ - _write_class_init(tlobject, type_constructors, builder) + _write_class_init(tlobject, kind, type_constructors, builder) _write_resolve(tlobject, builder) _write_to_dict(tlobject, builder) _write_to_bytes(tlobject, builder) _write_from_reader(tlobject, builder) - _write_on_response(tlobject, builder) + _write_read_result(tlobject, builder) -def _write_class_init(tlobject, type_constructors, builder): +def _write_class_init(tlobject, kind, type_constructors, builder): builder.writeln() builder.writeln() - builder.writeln('class {}(TLObject):', tlobject.class_name) + builder.writeln('class {}({}):', tlobject.class_name, kind) # Class-level variable to store its Telegram's constructor ID builder.writeln('CONSTRUCTOR_ID = {:#x}', tlobject.id) @@ -160,46 +166,39 @@ def _write_class_init(tlobject, type_constructors, builder): args = [(a.name if not a.is_flag and not a.can_be_inferred else '{}=None'.format(a.name)) for a in tlobject.real_args] - # Write the __init__ function + # Write the __init__ function if it has any argument + if not tlobject.real_args: + return + builder.writeln('def __init__({}):', ', '.join(['self'] + args)) - if tlobject.real_args: - # Write the docstring, to know the type of the args - builder.writeln('"""') - for arg in tlobject.real_args: - if not arg.flag_indicator: - builder.writeln(':param {} {}:', arg.type_hint(), arg.name) - builder.current_indent -= 1 # It will auto-indent (':') + # Write the docstring, to know the type of the args + builder.writeln('"""') + for arg in tlobject.real_args: + if not arg.flag_indicator: + builder.writeln(':param {} {}:', arg.type_hint(), arg.name) + builder.current_indent -= 1 # It will auto-indent (':') - # We also want to know what type this request returns - # or to which type this constructor belongs to - builder.writeln() - if tlobject.is_function: - builder.write(':returns {}: ', tlobject.result) - else: - builder.write('Constructor for {}: ', tlobject.result) - - constructors = type_constructors[tlobject.result] - if not constructors: - builder.writeln('This type has no constructors.') - elif len(constructors) == 1: - builder.writeln('Instance of {}.', - constructors[0].class_name) - else: - builder.writeln('Instance of either {}.', ', '.join( - c.class_name for c in constructors)) - - builder.writeln('"""') - - builder.writeln('super().__init__()') - # Functions have a result object and are confirmed by default + # We also want to know what type this request returns + # or to which type this constructor belongs to + builder.writeln() if tlobject.is_function: - builder.writeln('self.result = None') - builder.writeln('self.content_related = True') + builder.write(':returns {}: ', tlobject.result) + else: + builder.write('Constructor for {}: ', tlobject.result) + + constructors = type_constructors[tlobject.result] + if not constructors: + builder.writeln('This type has no constructors.') + elif len(constructors) == 1: + builder.writeln('Instance of {}.', + constructors[0].class_name) + else: + builder.writeln('Instance of either {}.', ', '.join( + c.class_name for c in constructors)) + + builder.writeln('"""') # Set the arguments - if tlobject.real_args: - builder.writeln() - for arg in tlobject.real_args: if not arg.can_be_inferred: builder.writeln('self.{0} = {0} # type: {1}', @@ -234,7 +233,7 @@ def _write_class_init(tlobject, type_constructors, builder): def _write_resolve(tlobject, builder): if any(arg.type in AUTO_CASTS for arg in tlobject.real_args): - builder.writeln('def resolve(self, client, utils):') + builder.writeln('async def resolve(self, client, utils):') for arg in tlobject.real_args: ac = AUTO_CASTS.get(arg.type, None) if not ac: @@ -333,7 +332,7 @@ def _write_from_reader(tlobject, builder): '{0}=_{0}'.format(a.name) for a in tlobject.real_args)) -def _write_on_response(tlobject, builder): +def _write_read_result(tlobject, builder): # Only requests can have a different response that's not their # serialized body, that is, we'll be setting their .result. # @@ -354,9 +353,10 @@ def _write_on_response(tlobject, builder): return builder.end_block() - builder.writeln('def on_response(self, reader):') + builder.writeln('@staticmethod') + builder.writeln('def read_result(reader):') builder.writeln('reader.read_int() # Vector ID') - builder.writeln('self.result = [reader.read_{}() ' + builder.writeln('return [reader.read_{}() ' 'for _ in range(reader.read_int())]', m.group(1)) @@ -447,7 +447,7 @@ def _write_arg_to_bytes(builder, arg, args, name=None): builder.write("struct.pack('