From af201c12ba4ab27356819f143eadde94e435adab Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 11 Sep 2021 12:00:28 +0200 Subject: [PATCH 001/131] Begin writing a migration guide for V2 --- readthedocs/index.rst | 1 + readthedocs/misc/v2-migration-guide.rst | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 readthedocs/misc/v2-migration-guide.rst diff --git a/readthedocs/index.rst b/readthedocs/index.rst index f4b1d877..827823cd 100644 --- a/readthedocs/index.rst +++ b/readthedocs/index.rst @@ -103,6 +103,7 @@ You can also use the menu on the left to quickly skip over sections. :caption: Miscellaneous misc/changelog + misc/v2-migration-guide.rst misc/wall-of-shame.rst misc/compatibility-and-convenience diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst new file mode 100644 index 00000000..fc1914be --- /dev/null +++ b/readthedocs/misc/v2-migration-guide.rst @@ -0,0 +1,24 @@ +========================= +Version 2 Migration Guide +========================= + +Version 2 represents the second major version change, breaking compatibility +with old code beyond the usual raw API changes in order to clean up a lot of +the technical debt that has grown on the project. + +This document documents all the things you should be aware of when migrating +from Telethon version 1.x to 2.0 onwards. + + +User, chat and channel identifiers are now 64-bit numbers +--------------------------------------------------------- + +`Layer 133 `__ changed *a lot* of +identifiers from ``int`` to ``long``, meaning they will no longer fit in 32 +bits, and instead require 64 bits. + +If you were storing these identifiers somewhere size did matter (for example, +a database), you will need to migrate that to support the new size requirement +of 8 bytes. + +For the full list of types changed, please review the above link. From f639992baaa5f8d97c17298eef5096b0c5ce805f Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 11 Sep 2021 13:33:27 +0200 Subject: [PATCH 002/131] Replace weird mixin Client classes with free-standing defs This should take care of the extremely precarious subclassing order. It should also make IDEs go a lot less crazy. Documentation and code can be kept separated. --- readthedocs/misc/v2-migration-guide.rst | 27 +- telethon/__init__.py | 2 +- telethon/client/account.py | 165 +- telethon/client/auth.py | 1041 +++--- telethon/client/bots.py | 82 +- telethon/client/buttons.py | 131 +- telethon/client/chats.py | 1261 ++------ telethon/client/dialogs.py | 564 +--- telethon/client/downloads.py | 1508 +++++---- telethon/client/messageparse.py | 368 ++- telethon/client/messages.py | 1475 +++------ telethon/client/telegrambaseclient.py | 1240 +++----- telethon/client/telegramclient.py | 3850 ++++++++++++++++++++++- telethon/client/updates.py | 1060 ++++--- telethon/client/uploads.py | 964 ++---- telethon/client/users.py | 1065 ++++--- 16 files changed, 7971 insertions(+), 6832 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index fc1914be..cd887251 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -13,12 +13,27 @@ from Telethon version 1.x to 2.0 onwards. User, chat and channel identifiers are now 64-bit numbers --------------------------------------------------------- -`Layer 133 `__ changed *a lot* of -identifiers from ``int`` to ``long``, meaning they will no longer fit in 32 -bits, and instead require 64 bits. +`Layer 133 `__ changed *a lot* of identifiers from +``int`` to ``long``, meaning they will no longer fit in 32 bits, and instead require 64 bits. -If you were storing these identifiers somewhere size did matter (for example, -a database), you will need to migrate that to support the new size requirement -of 8 bytes. +If you were storing these identifiers somewhere size did matter (for example, a database), you +will need to migrate that to support the new size requirement of 8 bytes. For the full list of types changed, please review the above link. + + +Many modules are now private +---------------------------- + +There were a lot of things which were public but should not have been. From now on, you should +only rely on things that are either publicly re-exported or defined. That is, as soon as anything +starts with an underscore (``_``) on its name, you're acknowledging that the functionality may +change even across minor version changes, and thus have your code break. + +* The ``telethon.client`` module is now ``telethon._client``, meaning you should stop relying on + anything inside of it. This includes all of the subclasses that used to exist (like ``UserMethods``). + + TODO REVIEW self\._\w+\( + and __signature__ + and property + abs abc abstract \ No newline at end of file diff --git a/telethon/__init__.py b/telethon/__init__.py index 3a62f1c8..d4e4c2c8 100644 --- a/telethon/__init__.py +++ b/telethon/__init__.py @@ -1,4 +1,4 @@ -from .client.telegramclient import TelegramClient +from ._client.telegramclient import TelegramClient from .network import connection from .tl import types, functions, custom from .tl.custom import Button diff --git a/telethon/client/account.py b/telethon/client/account.py index d82235b6..6300b791 100644 --- a/telethon/client/account.py +++ b/telethon/client/account.py @@ -107,137 +107,40 @@ class _TakeoutClient: return setattr(self.__client, name, value) -class AccountMethods: - def takeout( - self: 'TelegramClient', - finalize: bool = True, - *, - contacts: bool = None, - users: bool = None, - chats: bool = None, - megagroups: bool = None, - channels: bool = None, - files: bool = None, - max_file_size: bool = None) -> 'TelegramClient': - """ - Returns a :ref:`telethon-client` which calls methods behind a takeout session. +def takeout( + self: 'TelegramClient', + finalize: bool = True, + *, + contacts: bool = None, + users: bool = None, + chats: bool = None, + megagroups: bool = None, + channels: bool = None, + files: bool = None, + max_file_size: bool = None) -> 'TelegramClient': + request_kwargs = dict( + contacts=contacts, + message_users=users, + message_chats=chats, + message_megagroups=megagroups, + message_channels=channels, + files=files, + file_max_size=max_file_size + ) + arg_specified = (arg is not None for arg in request_kwargs.values()) - It does so by creating a proxy object over the current client through - which making requests will use :tl:`InvokeWithTakeoutRequest` to wrap - them. In other words, returns the current client modified so that - requests are done as a takeout: + if self.session.takeout_id is None or any(arg_specified): + request = functions.account.InitTakeoutSessionRequest( + **request_kwargs) + else: + request = None - Some of the calls made through the takeout session will have lower - flood limits. This is useful if you want to export the data from - conversations or mass-download media, since the rate limits will - be lower. Only some requests will be affected, and you will need - to adjust the `wait_time` of methods like `client.iter_messages - `. + return _TakeoutClient(finalize, self, request) - By default, all parameters are `None`, and you need to enable those - you plan to use by setting them to either `True` or `False`. - - You should ``except errors.TakeoutInitDelayError as e``, since this - exception will raise depending on the condition of the session. You - can then access ``e.seconds`` to know how long you should wait for - before calling the method again. - - There's also a `success` property available in the takeout proxy - object, so from the `with` body you can set the boolean result that - will be sent back to Telegram. But if it's left `None` as by - default, then the action is based on the `finalize` parameter. If - it's `True` then the takeout will be finished, and if no exception - occurred during it, then `True` will be considered as a result. - Otherwise, the takeout will not be finished and its ID will be - preserved for future usage as `client.session.takeout_id - `. - - Arguments - finalize (`bool`): - Whether the takeout session should be finalized upon - exit or not. - - contacts (`bool`): - Set to `True` if you plan on downloading contacts. - - users (`bool`): - Set to `True` if you plan on downloading information - from users and their private conversations with you. - - chats (`bool`): - Set to `True` if you plan on downloading information - from small group chats, such as messages and media. - - megagroups (`bool`): - Set to `True` if you plan on downloading information - from megagroups (channels), such as messages and media. - - channels (`bool`): - Set to `True` if you plan on downloading information - from broadcast channels, such as messages and media. - - files (`bool`): - Set to `True` if you plan on downloading media and - you don't only wish to export messages. - - max_file_size (`int`): - The maximum file size, in bytes, that you plan - to download for each message with media. - - Example - .. code-block:: python - - from telethon import errors - - try: - async with client.takeout() as takeout: - await client.get_messages('me') # normal call - await takeout.get_messages('me') # wrapped through takeout (less limits) - - async for message in takeout.iter_messages(chat, wait_time=0): - ... # Do something with the message - - except errors.TakeoutInitDelayError as e: - print('Must wait', e.seconds, 'before takeout') - """ - request_kwargs = dict( - contacts=contacts, - message_users=users, - message_chats=chats, - message_megagroups=megagroups, - message_channels=channels, - files=files, - file_max_size=max_file_size - ) - arg_specified = (arg is not None for arg in request_kwargs.values()) - - if self.session.takeout_id is None or any(arg_specified): - request = functions.account.InitTakeoutSessionRequest( - **request_kwargs) - else: - request = None - - return _TakeoutClient(finalize, self, request) - - async def end_takeout(self: 'TelegramClient', success: bool) -> bool: - """ - Finishes the current takeout session. - - Arguments - success (`bool`): - Whether the takeout completed successfully or not. - - Returns - `True` if the operation was successful, `False` otherwise. - - Example - .. code-block:: python - - await client.end_takeout(success=False) - """ - try: - async with _TakeoutClient(True, self, None) as takeout: - takeout.success = success - except ValueError: - return False - return True +async def end_takeout(self: 'TelegramClient', success: bool) -> bool: + try: + async with _TakeoutClient(True, self, None) as takeout: + takeout.success = success + except ValueError: + return False + return True diff --git a/telethon/client/auth.py b/telethon/client/auth.py index 9665262b..c27f6512 100644 --- a/telethon/client/auth.py +++ b/telethon/client/auth.py @@ -12,710 +12,407 @@ if typing.TYPE_CHECKING: from .telegramclient import TelegramClient -class AuthMethods: - - # region Public methods - - def start( - self: 'TelegramClient', - phone: typing.Callable[[], str] = lambda: input('Please enter your phone (or bot token): '), - password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '), - *, - bot_token: str = None, - force_sms: bool = False, - code_callback: typing.Callable[[], typing.Union[str, int]] = None, - first_name: str = 'New User', - last_name: str = '', - max_attempts: int = 3) -> 'TelegramClient': - """ - Starts the client (connects and logs in if necessary). - - By default, this method will be interactive (asking for - user input if needed), and will handle 2FA if enabled too. - - 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. - - If the event loop is already running, this method returns a - coroutine that you should await on your own code; otherwise - the loop is ran until said coroutine completes. - - Arguments - phone (`str` | `int` | `callable`): - The phone (or callable without arguments to get it) - to which the code will be sent. If a bot-token-like - string is given, it will be used as such instead. - The argument may be a coroutine. - - password (`str`, `callable`, optional): - The password for 2 Factor Authentication (2FA). - This is only required if it is enabled in your account. - The argument may be a coroutine. - - 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()`. - The argument may be a coroutine. - - 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. - - max_attempts (`int`, optional): - How many times the code/password callback should be - retried or switching between signing in and signing up. - - Returns - This `TelegramClient`, so initialization - can be chained with ``.start()``. - - Example - .. code-block:: python - - client = TelegramClient('anon', api_id, api_hash) - - # Starting as a bot account - await client.start(bot_token=bot_token) - - # Starting as a user account - await client.start(phone) - # Please enter the code you received: 12345 - # Please enter your password: ******* - # (You are now logged in) - - # Starting using a context manager (this calls start()): - with client: - pass - """ - 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') - - coro = self._start( - phone=phone, - password=password, - bot_token=bot_token, - force_sms=force_sms, - code_callback=code_callback, - first_name=first_name, - last_name=last_name, - max_attempts=max_attempts - ) - return ( - coro if self.loop.is_running() - else self.loop.run_until_complete(coro) +def start( + self: 'TelegramClient', + phone: typing.Callable[[], str] = lambda: input('Please enter your phone (or bot token): '), + password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '), + *, + bot_token: str = None, + force_sms: bool = False, + code_callback: typing.Callable[[], typing.Union[str, int]] = None, + first_name: str = 'New User', + last_name: str = '', + max_attempts: int = 3) -> 'TelegramClient': + 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.' ) - async def _start( - self: 'TelegramClient', phone, password, bot_token, force_sms, - code_callback, first_name, last_name, max_attempts): - if not self.is_connected(): - await self.connect() + if not phone and not bot_token: + raise ValueError('No phone number or bot token provided.') - # Rather than using `is_user_authorized`, use `get_me`. While this is - # more expensive and needs to retrieve more data from the server, it - # enables the library to warn users trying to login to a different - # account. See #1172. - me = await self.get_me() - if me is not None: - # The warnings here are on a best-effort and may fail. - if bot_token: - # bot_token's first part has the bot ID, but it may be invalid - # so don't try to parse as int (instead cast our ID to string). - if bot_token[:bot_token.find(':')] != str(me.id): - warnings.warn( - 'the session already had an authorized user so it did ' - 'not login to the bot account using the provided ' - 'bot_token (it may not be using the user you expect)' - ) - elif phone and not callable(phone) and utils.parse_phone(phone) != me.phone: + 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') + + coro = self._start( + phone=phone, + password=password, + bot_token=bot_token, + force_sms=force_sms, + code_callback=code_callback, + first_name=first_name, + last_name=last_name, + max_attempts=max_attempts + ) + return ( + coro if self.loop.is_running() + else self.loop.run_until_complete(coro) + ) + +async def _start( + self: 'TelegramClient', phone, password, bot_token, force_sms, + code_callback, first_name, last_name, max_attempts): + if not self.is_connected(): + await self.connect() + + # Rather than using `is_user_authorized`, use `get_me`. While this is + # more expensive and needs to retrieve more data from the server, it + # enables the library to warn users trying to login to a different + # account. See #1172. + me = await self.get_me() + if me is not None: + # The warnings here are on a best-effort and may fail. + if bot_token: + # bot_token's first part has the bot ID, but it may be invalid + # so don't try to parse as int (instead cast our ID to string). + if bot_token[:bot_token.find(':')] != str(me.id): warnings.warn( 'the session already had an authorized user so it did ' - 'not login to the user account using the provided ' - 'phone (it may not be using the user you expect)' + 'not login to the bot account using the provided ' + 'bot_token (it may not be using the user you expect)' ) - - return self - - if not bot_token: - # Turn the callable into a valid phone number (or bot token) - while callable(phone): - value = phone() - if inspect.isawaitable(value): - value = await value - - if ':' in value: - # Bot tokens have 'user_id:access_hash' format - bot_token = value - break - - phone = utils.parse_phone(value) or phone - - if bot_token: - await self.sign_in(bot_token=bot_token) - return self - - me = None - attempts = 0 - two_step_detected = False - - await self.send_code_request(phone, force_sms=force_sms) - sign_up = False # assume login - while attempts < max_attempts: - try: - value = code_callback() - if inspect.isawaitable(value): - value = await value - - # Since sign-in with no code works (it sends the code) - # we must double-check that here. Else we'll assume we - # logged in, and it will return None as the User. - if not value: - raise errors.PhoneCodeEmptyError(request=None) - - if sign_up: - me = await self.sign_up(value, first_name, last_name) - else: - # Raises SessionPasswordNeededError if 2FA enabled - me = await self.sign_in(phone, code=value) - 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) + elif phone and not callable(phone) and utils.parse_phone(phone) != me.phone: + warnings.warn( + 'the session already had an authorized user so it did ' + 'not login to the user account using the provided ' + 'phone (it may not be using the user you expect)' ) - if two_step_detected: - if not password: - raise ValueError( - "Two-step verification is enabled for this account. " - "Please provide the 'password' argument to 'start()'." - ) - - if callable(password): - for _ in range(max_attempts): - try: - value = password() - if inspect.isawaitable(value): - value = await value - - me = await self.sign_in(phone=phone, password=value) - break - except errors.PasswordHashInvalidError: - print('Invalid password. Please try again', - file=sys.stderr) - else: - raise errors.PasswordHashInvalidError(request=None) - else: - 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 - def _parse_phone_and_hash(self, phone, phone_hash): - """ - Helper method to both parse and validate phone and its hash. - """ - phone = utils.parse_phone(phone) or self._phone - if not phone: - raise ValueError( - 'Please make sure to call send_code_request first.' - ) + if not bot_token: + # Turn the callable into a valid phone number (or bot token) + while callable(phone): + value = phone() + if inspect.isawaitable(value): + value = await value - phone_hash = phone_hash or self._phone_code_hash.get(phone, None) - if not phone_hash: - raise ValueError('You also need to provide a phone_code_hash.') + if ':' in value: + # Bot tokens have 'user_id:access_hash' format + bot_token = value + break - return phone, phone_hash + phone = utils.parse_phone(value) or phone - async def sign_in( - self: 'TelegramClient', - phone: str = None, - code: typing.Union[str, int] = None, - *, - password: str = None, - bot_token: str = None, - phone_code_hash: str = None) -> 'typing.Union[types.User, types.auth.SentCode]': - """ - Logs in to Telegram to an existing user or bot account. + if bot_token: + await self.sign_in(bot_token=bot_token) + return self - You should only use this if you are not authorized yet. + me = None + attempts = 0 + two_step_detected = False - This method will send the code if it's not provided. + await self.send_code_request(phone, force_sms=force_sms) + sign_up = False # assume login + while attempts < max_attempts: + try: + value = code_callback() + if inspect.isawaitable(value): + value = await value - .. note:: + # Since sign-in with no code works (it sends the code) + # we must double-check that here. Else we'll assume we + # logged in, and it will return None as the User. + if not value: + raise errors.PhoneCodeEmptyError(request=None) - In most cases, you should simply use `start()` and not this method. - - Arguments - 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`, optional): - The hash returned by `send_code_request`. This can be left as - `None` to use the last hash known for the phone to be used. - - Returns - The signed in user, or the information about - :meth:`send_code_request`. - - Example - .. code-block:: python - - phone = '+34 123 123 123' - await client.sign_in(phone) # send code - - code = input('enter code: ') - await client.sign_in(phone, code) - """ - 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, phone_code_hash = \ - self._parse_phone_and_hash(phone, phone_code_hash) - - # May raise PhoneCodeEmptyError, PhoneCodeExpiredError, - # PhoneCodeHashEmptyError or PhoneCodeInvalidError. - request = functions.auth.SignInRequest( - phone, phone_code_hash, str(code) - ) - elif password: - pwd = await self(functions.account.GetPasswordRequest()) - request = functions.auth.CheckPasswordRequest( - pwd_mod.compute_check(pwd, password) - ) - elif bot_token: - request = 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.' - ) - - result = await self(request) - if isinstance(result, types.auth.AuthorizationSignUpRequired): - # Emulate pre-layer 104 behaviour - self._tos = result.terms_of_service - raise errors.PhoneNumberUnoccupiedError(request=request) - - return self._on_login(result.user) - - async def sign_up( - self: 'TelegramClient', - code: typing.Union[str, int], - first_name: str, - last_name: str = '', - *, - phone: str = None, - phone_code_hash: str = None) -> 'types.User': - """ - Signs up to Telegram as a new user account. - - Use this if you don't have an account yet. - - You must call `send_code_request` 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. - - Arguments - 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. - - phone (`str` | `int`, optional): - The phone to sign up. This will be the last phone used by - default (you normally don't need to set this). - - phone_code_hash (`str`, optional): - The hash returned by `send_code_request`. This can be left as - `None` to use the last hash known for the phone to be used. - - Returns - The new created :tl:`User`. - - Example - .. code-block:: python - - phone = '+34 123 123 123' - await client.send_code_request(phone) - - code = input('enter code: ') - await client.sign_up(code, first_name='Anna', last_name='Banana') - """ - me = await self.get_me() - if me: - return me - - # To prevent abuse, one has to try to sign in before signing up. This - # is the current way in which Telegram validates the code to sign up. - # - # `sign_in` will set `_tos`, so if it's set we don't need to call it - # because the user already tried to sign in. - # - # We're emulating pre-layer 104 behaviour so except the right error: - if not self._tos: - try: - return await self.sign_in( - phone=phone, - code=code, - phone_code_hash=phone_code_hash, - ) - except errors.PhoneNumberUnoccupiedError: - pass # code is correct and was used, now need to sign in - - if self._tos and self._tos.text: - if self.parse_mode: - t = self.parse_mode.unparse(self._tos.text, self._tos.entities) + if sign_up: + me = await self.sign_up(value, first_name, last_name) else: - t = self._tos.text - sys.stderr.write("{}\n".format(t)) - sys.stderr.flush() + # Raises SessionPasswordNeededError if 2FA enabled + me = await self.sign_in(phone, code=value) + 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()'." + ) + + if callable(password): + for _ in range(max_attempts): + try: + value = password() + if inspect.isawaitable(value): + value = await value + + me = await self.sign_in(phone=phone, password=value) + break + except errors.PasswordHashInvalidError: + print('Invalid password. Please try again', + file=sys.stderr) + else: + raise errors.PasswordHashInvalidError(request=None) + else: + 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 + +def _parse_phone_and_hash(self, phone, phone_hash): + """ + Helper method to both parse and validate phone and its hash. + """ + phone = utils.parse_phone(phone) or self._phone + if not phone: + raise ValueError( + 'Please make sure to call send_code_request first.' + ) + + phone_hash = phone_hash or self._phone_code_hash.get(phone, None) + if not phone_hash: + raise ValueError('You also need to provide a phone_code_hash.') + + return phone, phone_hash + +async def sign_in( + self: 'TelegramClient', + phone: str = None, + code: typing.Union[str, int] = None, + *, + password: str = None, + bot_token: str = None, + phone_code_hash: str = None) -> 'typing.Union[types.User, types.auth.SentCode]': + 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, phone_code_hash = \ self._parse_phone_and_hash(phone, phone_code_hash) - result = await self(functions.auth.SignUpRequest( - phone_number=phone, - phone_code_hash=phone_code_hash, - first_name=first_name, - last_name=last_name - )) - - if self._tos: - await self( - functions.help.AcceptTermsOfServiceRequest(self._tos.id)) - - return self._on_login(result.user) - - def _on_login(self, user): - """ - Callback called whenever the login or sign up process completes. - - Returns the input user parameter. - """ - self._bot = bool(user.bot) - self._self_input_peer = utils.get_input_peer(user, allow_self=False) - self._authorized = True - - return user - - async def send_code_request( - self: 'TelegramClient', - phone: str, - *, - force_sms: bool = False) -> 'types.auth.SentCode': - """ - Sends the Telegram code needed to login to the given phone number. - - Arguments - 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`. - - Example - .. code-block:: python - - phone = '+34 123 123 123' - sent = await client.send_code_request(phone) - print(sent) - """ - result = None - 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, types.CodeSettings())) - except errors.AuthRestartError: - return await self.send_code_request(phone, force_sms=force_sms) - - # If we already sent a SMS, do not resend the code (hash may be empty) - if isinstance(result.type, types.auth.SentCodeTypeSms): - force_sms = False - - # phone_code_hash may be empty, if it is, do not save it (#1283) - if result.phone_code_hash: - 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 qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> custom.QRLogin: - """ - Initiates the QR login procedure. - - Note that you must be connected before invoking this, as with any - other request. - - It is up to the caller to decide how to present the code to the user, - whether it's the URL, using the token bytes directly, or generating - a QR code and displaying it by other means. - - See the documentation for `QRLogin` to see how to proceed after this. - - Arguments - ignored_ids (List[`int`]): - List of already logged-in user IDs, to prevent logging in - twice with the same user. - - Returns - An instance of `QRLogin`. - - Example - .. code-block:: python - - def display_url_as_qr(url): - pass # do whatever to show url as a qr to the user - - qr_login = await client.qr_login() - display_url_as_qr(qr_login.url) - - # Important! You need to wait for the login to complete! - await qr_login.wait() - """ - qr_login = custom.QRLogin(self, ignored_ids or []) - await qr_login.recreate() - return qr_login - - async def log_out(self: 'TelegramClient') -> bool: - """ - Logs out Telegram and deletes the current ``*.session`` file. - - Returns - `True` if the operation was successful. - - Example - .. code-block:: python - - # Note: you will need to login again! - await client.log_out() - """ - try: - await self(functions.auth.LogOutRequest()) - except errors.RPCError: - return False - - self._bot = None - self._self_input_peer = None - self._authorized = False - self._state_cache.reset() - - await self.disconnect() - self.session.delete() - return True - - async def edit_2fa( - self: 'TelegramClient', - current_password: str = None, - new_password: str = None, - *, - hint: str = '', - email: str = None, - email_code_callback: typing.Callable[[int], str] = None) -> bool: - """ - Changes the 2FA settings of the logged in user. - - Review carefully the parameter explanations before using this method. - - Note that this method may be *incredibly* slow depending on the - prime numbers that must be used during the process to make sure - that everything is safe. - - Has no effect if both current and new password are omitted. - - Arguments - 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. If present, you must also - set `email_code_callback`, else it raises ``ValueError``. - - email_code_callback (`callable`, optional): - If an email is provided, a callback that returns the code sent - to it must also be set. This callback may be asynchronous. - It should return a string with the code. The length of the - code will be passed to the callback as an input parameter. - - If the callback returns an invalid code, it will raise - ``CodeInvalidError``. - - Returns - `True` if successful, `False` otherwise. - - Example - .. code-block:: python - - # Setting a password for your account which didn't have - await client.edit_2fa(new_password='I_<3_Telethon') - - # Removing the password - await client.edit_2fa(current_password='I_<3_Telethon') - """ - if new_password is None and current_password is None: - return False - - if email and not callable(email_code_callback): - raise ValueError('email present without email_code_callback') - + # May raise PhoneCodeEmptyError, PhoneCodeExpiredError, + # PhoneCodeHashEmptyError or PhoneCodeInvalidError. + request = functions.auth.SignInRequest( + phone, phone_code_hash, str(code) + ) + elif password: pwd = await self(functions.account.GetPasswordRequest()) - pwd.new_algo.salt1 += os.urandom(32) - assert isinstance(pwd, types.account.Password) - if not pwd.has_password and current_password: - current_password = None + request = functions.auth.CheckPasswordRequest( + pwd_mod.compute_check(pwd, password) + ) + elif bot_token: + request = 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.' + ) - if current_password: - password = pwd_mod.compute_check(pwd, current_password) - else: - password = types.InputCheckPasswordEmpty() + result = await self(request) + if isinstance(result, types.auth.AuthorizationSignUpRequired): + # Emulate pre-layer 104 behaviour + self._tos = result.terms_of_service + raise errors.PhoneNumberUnoccupiedError(request=request) - if new_password: - new_password_hash = pwd_mod.compute_digest( - pwd.new_algo, new_password) - else: - new_password_hash = b'' + return self._on_login(result.user) +async def sign_up( + self: 'TelegramClient', + code: typing.Union[str, int], + first_name: str, + last_name: str = '', + *, + phone: str = None, + phone_code_hash: str = None) -> 'types.User': + me = await self.get_me() + if me: + return me + + # To prevent abuse, one has to try to sign in before signing up. This + # is the current way in which Telegram validates the code to sign up. + # + # `sign_in` will set `_tos`, so if it's set we don't need to call it + # because the user already tried to sign in. + # + # We're emulating pre-layer 104 behaviour so except the right error: + if not self._tos: try: - await self(functions.account.UpdatePasswordSettingsRequest( - password=password, - new_settings=types.account.PasswordInputSettings( - new_algo=pwd.new_algo, - new_password_hash=new_password_hash, - hint=hint, - email=email, - new_secure_settings=None - ) - )) - except errors.EmailUnconfirmedError as e: - code = email_code_callback(e.code_length) - if inspect.isawaitable(code): - code = await code + return await self.sign_in( + phone=phone, + code=code, + phone_code_hash=phone_code_hash, + ) + except errors.PhoneNumberUnoccupiedError: + pass # code is correct and was used, now need to sign in - code = str(code) - await self(functions.account.ConfirmPasswordEmailRequest(code)) + 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() - return True + phone, phone_code_hash = \ + self._parse_phone_and_hash(phone, phone_code_hash) - # endregion + result = await self(functions.auth.SignUpRequest( + phone_number=phone, + phone_code_hash=phone_code_hash, + first_name=first_name, + last_name=last_name + )) - # region with blocks + if self._tos: + await self( + functions.help.AcceptTermsOfServiceRequest(self._tos.id)) - async def __aenter__(self): - return await self.start() + return self._on_login(result.user) - async def __aexit__(self, *args): - await self.disconnect() +def _on_login(self, user): + """ + Callback called whenever the login or sign up process completes. + Returns the input user parameter. + """ + self._bot = bool(user.bot) + self._self_input_peer = utils.get_input_peer(user, allow_self=False) + self._authorized = True - __enter__ = helpers._sync_enter - __exit__ = helpers._sync_exit + return user - # endregion +async def send_code_request( + self: 'TelegramClient', + phone: str, + *, + force_sms: bool = False) -> 'types.auth.SentCode': + result = None + 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, types.CodeSettings())) + except errors.AuthRestartError: + return await self.send_code_request(phone, force_sms=force_sms) + + # If we already sent a SMS, do not resend the code (hash may be empty) + if isinstance(result.type, types.auth.SentCodeTypeSms): + force_sms = False + + # phone_code_hash may be empty, if it is, do not save it (#1283) + if result.phone_code_hash: + 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 qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> custom.QRLogin: + qr_login = custom.QRLogin(self, ignored_ids or []) + await qr_login.recreate() + return qr_login + +async def log_out(self: 'TelegramClient') -> bool: + try: + await self(functions.auth.LogOutRequest()) + except errors.RPCError: + return False + + self._bot = None + self._self_input_peer = None + self._authorized = False + self._state_cache.reset() + + await self.disconnect() + self.session.delete() + return True + +async def edit_2fa( + self: 'TelegramClient', + current_password: str = None, + new_password: str = None, + *, + hint: str = '', + email: str = None, + email_code_callback: typing.Callable[[int], str] = None) -> bool: + if new_password is None and current_password is None: + return False + + if email and not callable(email_code_callback): + raise ValueError('email present without email_code_callback') + + pwd = await self(functions.account.GetPasswordRequest()) + pwd.new_algo.salt1 += os.urandom(32) + assert isinstance(pwd, types.account.Password) + if not pwd.has_password and current_password: + current_password = None + + if current_password: + password = pwd_mod.compute_check(pwd, current_password) + else: + password = types.InputCheckPasswordEmpty() + + if new_password: + new_password_hash = pwd_mod.compute_digest( + pwd.new_algo, new_password) + else: + new_password_hash = b'' + + try: + await self(functions.account.UpdatePasswordSettingsRequest( + password=password, + new_settings=types.account.PasswordInputSettings( + new_algo=pwd.new_algo, + new_password_hash=new_password_hash, + hint=hint, + email=email, + new_secure_settings=None + ) + )) + except errors.EmailUnconfirmedError as e: + code = email_code_callback(e.code_length) + if inspect.isawaitable(code): + code = await code + + code = str(code) + await self(functions.account.ConfirmPasswordEmailRequest(code)) + + return True diff --git a/telethon/client/bots.py b/telethon/client/bots.py index 044d8513..0912fc20 100644 --- a/telethon/client/bots.py +++ b/telethon/client/bots.py @@ -7,66 +7,26 @@ if typing.TYPE_CHECKING: from .telegramclient import TelegramClient -class BotMethods: - async def inline_query( - self: 'TelegramClient', - bot: 'hints.EntityLike', - query: str, - *, - entity: 'hints.EntityLike' = None, - offset: str = None, - geo_point: 'types.GeoPoint' = None) -> custom.InlineResults: - """ - Makes an inline query to the specified bot (``@vote New Poll``). +async def inline_query( + self: 'TelegramClient', + bot: 'hints.EntityLike', + query: str, + *, + entity: 'hints.EntityLike' = None, + offset: str = None, + geo_point: 'types.GeoPoint' = None) -> custom.InlineResults: + bot = await self.get_input_entity(bot) + if entity: + peer = await self.get_input_entity(entity) + else: + peer = types.InputPeerEmpty() - Arguments - bot (`entity`): - The bot entity to which the inline query should be made. + result = await self(functions.messages.GetInlineBotResultsRequest( + bot=bot, + peer=peer, + query=query, + offset=offset or '', + geo_point=geo_point + )) - query (`str`): - The query that should be made to the bot. - - entity (`entity`, optional): - The entity where the inline query is being made from. Certain - bots use this to display different results depending on where - it's used, such as private chats, groups or channels. - - If specified, it will also be the default entity where the - message will be sent after clicked. Otherwise, the "empty - peer" will be used, which some bots may not handle correctly. - - offset (`str`, optional): - The string offset to use for the bot. - - geo_point (:tl:`GeoPoint`, optional) - The geo point location information to send to the bot - for localised results. Available under some bots. - - Returns - A list of `custom.InlineResult - `. - - Example - .. code-block:: python - - # Make an inline query to @like - results = await client.inline_query('like', 'Do you like Telethon?') - - # Send the first result to some chat - message = await results[0].click('TelethonOffTopic') - """ - bot = await self.get_input_entity(bot) - if entity: - peer = await self.get_input_entity(entity) - else: - peer = types.InputPeerEmpty() - - result = await self(functions.messages.GetInlineBotResultsRequest( - bot=bot, - peer=peer, - query=query, - offset=offset or '', - geo_point=geo_point - )) - - return custom.InlineResults(self, result, entity=peer if entity else None) + return custom.InlineResults(self, result, entity=peer if entity else None) diff --git a/telethon/client/buttons.py b/telethon/client/buttons.py index 7e848ab1..41413708 100644 --- a/telethon/client/buttons.py +++ b/telethon/client/buttons.py @@ -4,93 +4,62 @@ from .. import utils, hints from ..tl import types, custom -class ButtonMethods: - @staticmethod - def build_reply_markup( - buttons: 'typing.Optional[hints.MarkupLike]', - inline_only: bool = False) -> 'typing.Optional[types.TypeReplyMarkup]': - """ - Builds a :tl:`ReplyInlineMarkup` or :tl:`ReplyKeyboardMarkup` for - the given buttons. +def build_reply_markup( + buttons: 'typing.Optional[hints.MarkupLike]', + inline_only: bool = False) -> 'typing.Optional[types.TypeReplyMarkup]': + if buttons is None: + return None - Does nothing if either no buttons are provided or the provided - argument is already a reply markup. + try: + if buttons.SUBCLASS_OF_ID == 0xe2e10ef2: + return buttons # crc32(b'ReplyMarkup'): + except AttributeError: + pass - You should consider using this method if you are going to reuse - the markup very often. Otherwise, it is not necessary. + if not utils.is_list_like(buttons): + buttons = [[buttons]] + elif not buttons or not utils.is_list_like(buttons[0]): + buttons = [buttons] - This method is **not** asynchronous (don't use ``await`` on it). + is_inline = False + is_normal = False + resize = None + single_use = None + selective = None - Arguments - buttons (`hints.MarkupLike`): - The button, list of buttons, array of buttons or markup - to convert into a markup. + rows = [] + for row in buttons: + current = [] + for button in row: + if isinstance(button, custom.Button): + if button.resize is not None: + resize = button.resize + if button.single_use is not None: + single_use = button.single_use + if button.selective is not None: + selective = button.selective - inline_only (`bool`, optional): - Whether the buttons **must** be inline buttons only or not. + button = button.button + elif isinstance(button, custom.MessageButton): + button = button.button - Example - .. code-block:: python + inline = custom.Button._is_inline(button) + is_inline |= inline + is_normal |= not inline - from telethon import Button + if button.SUBCLASS_OF_ID == 0xbad74a3: + # 0xbad74a3 == crc32(b'KeyboardButton') + current.append(button) - markup = client.build_reply_markup(Button.inline('hi')) - # later - await client.send_message(chat, 'click me', buttons=markup) - """ - if buttons is None: - return None + if current: + rows.append(types.KeyboardButtonRow(current)) - try: - if buttons.SUBCLASS_OF_ID == 0xe2e10ef2: - return buttons # crc32(b'ReplyMarkup'): - except AttributeError: - pass - - if not utils.is_list_like(buttons): - buttons = [[buttons]] - elif not buttons or not utils.is_list_like(buttons[0]): - buttons = [buttons] - - is_inline = False - is_normal = False - resize = None - single_use = None - selective = None - - rows = [] - for row in buttons: - current = [] - for button in row: - if isinstance(button, custom.Button): - if button.resize is not None: - resize = button.resize - if button.single_use is not None: - single_use = button.single_use - if button.selective is not None: - selective = button.selective - - button = button.button - elif isinstance(button, custom.MessageButton): - button = button.button - - inline = custom.Button._is_inline(button) - is_inline |= inline - is_normal |= not inline - - if button.SUBCLASS_OF_ID == 0xbad74a3: - # 0xbad74a3 == crc32(b'KeyboardButton') - current.append(button) - - if current: - rows.append(types.KeyboardButtonRow(current)) - - if inline_only and is_normal: - raise ValueError('You cannot use non-inline buttons here') - elif is_inline == is_normal and is_normal: - raise ValueError('You cannot mix inline with normal buttons') - elif is_inline: - return types.ReplyInlineMarkup(rows) - # elif is_normal: - return types.ReplyKeyboardMarkup( - rows, resize=resize, single_use=single_use, selective=selective) + if inline_only and is_normal: + raise ValueError('You cannot use non-inline buttons here') + elif is_inline == is_normal and is_normal: + raise ValueError('You cannot mix inline with normal buttons') + elif is_inline: + return types.ReplyInlineMarkup(rows) + # elif is_normal: + return types.ReplyKeyboardMarkup( + rows, resize=resize, single_use=single_use, selective=selective) diff --git a/telethon/client/chats.py b/telethon/client/chats.py index dfbeddcc..0429d563 100644 --- a/telethon/client/chats.py +++ b/telethon/client/chats.py @@ -404,965 +404,370 @@ class _ProfilePhotoIter(RequestIter): self.request.offset_id = result.messages[-1].id -class ChatMethods: - - # region Public methods - - def iter_participants( - self: 'TelegramClient', - entity: 'hints.EntityLike', - limit: float = None, - *, - search: str = '', - filter: 'types.TypeChannelParticipantsFilter' = None, - aggressive: bool = False) -> _ParticipantsIter: - """ - Iterator over the participants belonging to the specified chat. - - The order is unspecified. - - Arguments - 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. - - If ``aggressive is True``, the symbols from this string will - be used. - - 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. - - .. note:: - - The filter :tl:`ChannelParticipantsBanned` will return - *restricted* users. If you want *banned* users you should - use :tl:`ChannelParticipantsKicked` instead. - - aggressive (`bool`, optional): - Aggressively looks for all participants in the chat. - - This is useful for channels since 20 July 2018, - Telegram added a server-side limit where only the - first 200 members can be retrieved. With this flag - set, more than 200 will be often be retrieved. - - This has no effect if a ``filter`` is given. - - 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. - - Example - .. code-block:: python - - # Show all user IDs in a chat - async for user in client.iter_participants(chat): - print(user.id) - - # Search by name - async for user in client.iter_participants(chat, search='name'): - print(user.username) - - # Filter by admins - from telethon.tl.types import ChannelParticipantsAdmins - async for user in client.iter_participants(chat, filter=ChannelParticipantsAdmins): - print(user.first_name) - """ - return _ParticipantsIter( - self, - limit, - entity=entity, - filter=filter, - search=search, - aggressive=aggressive - ) - - async def get_participants( - self: 'TelegramClient', - *args, - **kwargs) -> 'hints.TotalList': - """ - Same as `iter_participants()`, but returns a - `TotalList ` instead. - - Example - .. code-block:: python - - users = await client.get_participants(chat) - print(users[0].first_name) - - for user in users: - if user.username is not None: - print(user.username) - """ - return await self.iter_participants(*args, **kwargs).collect() - - get_participants.__signature__ = inspect.signature(iter_participants) - - - def iter_admin_log( - self: 'TelegramClient', - entity: 'hints.EntityLike', - limit: float = None, - *, - max_id: int = 0, - min_id: int = 0, - search: str = None, - admins: 'hints.EntitiesLike' = None, - join: bool = None, - leave: bool = None, - invite: bool = None, - restrict: bool = None, - unrestrict: bool = None, - ban: bool = None, - unban: bool = None, - promote: bool = None, - demote: bool = None, - info: bool = None, - settings: bool = None, - pinned: bool = None, - edit: bool = None, - delete: bool = None, - group_call: bool = None) -> _AdminLogIter: - """ - Iterator over the admin log for the specified channel. - - The default order is from the most recent event to to the oldest. - - Note that you must be an administrator of it to use this method. - - If none of the filters are present (i.e. they all are `None`), - *all* event types will be returned. If at least one of them is - `True`, only those that are true will be returned. - - Arguments - entity (`entity`): - The channel entity from which to get its admin log. - - limit (`int` | `None`, optional): - Number of events to be retrieved. - - The limit may also be `None`, which would eventually return - the whole history. - - max_id (`int`): - All the events with a higher (newer) ID or equal to this will - be excluded. - - min_id (`int`): - All the events with a lower (older) ID or equal to this will - be excluded. - - search (`str`): - The string to be used as a search query. - - admins (`entity` | `list`): - If present, the events will be filtered by these admins - (or single admin) and only those caused by them will be - returned. - - join (`bool`): - If `True`, events for when a user joined will be returned. - - leave (`bool`): - If `True`, events for when a user leaves will be returned. - - invite (`bool`): - If `True`, events for when a user joins through an invite - link will be returned. - - restrict (`bool`): - If `True`, events with partial restrictions will be - returned. This is what the API calls "ban". - - unrestrict (`bool`): - If `True`, events removing restrictions will be returned. - This is what the API calls "unban". - - ban (`bool`): - If `True`, events applying or removing all restrictions will - be returned. This is what the API calls "kick" (restricting - all permissions removed is a ban, which kicks the user). - - unban (`bool`): - If `True`, events removing all restrictions will be - returned. This is what the API calls "unkick". - - promote (`bool`): - If `True`, events with admin promotions will be returned. - - demote (`bool`): - If `True`, events with admin demotions will be returned. - - info (`bool`): - If `True`, events changing the group info will be returned. - - settings (`bool`): - If `True`, events changing the group settings will be - returned. - - pinned (`bool`): - If `True`, events of new pinned messages will be returned. - - edit (`bool`): - If `True`, events of message edits will be returned. - - delete (`bool`): - If `True`, events of message deletions will be returned. - - group_call (`bool`): - If `True`, events related to group calls will be returned. - - Yields - Instances of `AdminLogEvent `. - - Example - .. code-block:: python - - async for event in client.iter_admin_log(channel): - if event.changed_title: - print('The title changed from', event.old, 'to', event.new) - """ - return _AdminLogIter( - self, - limit, - entity=entity, - admins=admins, - search=search, - min_id=min_id, - max_id=max_id, - join=join, - leave=leave, - invite=invite, - restrict=restrict, - unrestrict=unrestrict, - ban=ban, - unban=unban, - promote=promote, - demote=demote, - info=info, - settings=settings, - pinned=pinned, - edit=edit, - delete=delete, - group_call=group_call - ) - - async def get_admin_log( - self: 'TelegramClient', - *args, - **kwargs) -> 'hints.TotalList': - """ - Same as `iter_admin_log()`, but returns a ``list`` instead. - - Example - .. code-block:: python - - # Get a list of deleted message events which said "heck" - events = await client.get_admin_log(channel, search='heck', delete=True) - - # Print the old message before it was deleted - print(events[0].old) - """ - return await self.iter_admin_log(*args, **kwargs).collect() - - get_admin_log.__signature__ = inspect.signature(iter_admin_log) - - def iter_profile_photos( - self: 'TelegramClient', - entity: 'hints.EntityLike', - limit: int = None, - *, - offset: int = 0, - max_id: int = 0) -> _ProfilePhotoIter: - """ - Iterator over a user's profile photos or a chat's photos. - - The order is from the most recent photo to the oldest. - - Arguments - entity (`entity`): - The entity from which to get the profile or chat photos. - - limit (`int` | `None`, optional): - Number of photos to be retrieved. - - The limit may also be `None`, which would eventually all - the photos that are still available. - - offset (`int`): - How many photos should be skipped before returning the first one. - - max_id (`int`): - The maximum ID allowed when fetching photos. - - Yields - Instances of :tl:`Photo`. - - Example - .. code-block:: python - - # Download all the profile photos of some user - async for photo in client.iter_profile_photos(user): - await client.download_media(photo) - """ - return _ProfilePhotoIter( - self, - limit, - entity=entity, - offset=offset, - max_id=max_id - ) - - async def get_profile_photos( - self: 'TelegramClient', - *args, - **kwargs) -> 'hints.TotalList': - """ - Same as `iter_profile_photos()`, but returns a - `TotalList ` instead. - - Example - .. code-block:: python - - # Get the photos of a channel - photos = await client.get_profile_photos(channel) - - # Download the oldest photo - await client.download_media(photos[-1]) - """ - return await self.iter_profile_photos(*args, **kwargs).collect() - - get_profile_photos.__signature__ = inspect.signature(iter_profile_photos) - - def action( - self: 'TelegramClient', - entity: 'hints.EntityLike', - action: 'typing.Union[str, types.TypeSendMessageAction]', - *, - delay: float = 4, - auto_cancel: bool = True) -> 'typing.Union[_ChatAction, typing.Coroutine]': - """ - Returns a context-manager object to represent a "chat action". - - Chat actions indicate things like "user is typing", "user is - uploading a photo", etc. - - If the action is ``'cancel'``, you should just ``await`` the result, - since it makes no sense to use a context-manager for it. - - See the example below for intended usage. - - Arguments - entity (`entity`): - The entity where the action should be showed in. - - action (`str` | :tl:`SendMessageAction`): - The action to show. You can either pass a instance of - :tl:`SendMessageAction` or better, a string used while: - - * ``'typing'``: typing a text message. - * ``'contact'``: choosing a contact. - * ``'game'``: playing a game. - * ``'location'``: choosing a geo location. - * ``'sticker'``: choosing a sticker. - * ``'record-audio'``: recording a voice note. - You may use ``'record-voice'`` as alias. - * ``'record-round'``: recording a round video. - * ``'record-video'``: recording a normal video. - * ``'audio'``: sending an audio file (voice note or song). - You may use ``'voice'`` and ``'song'`` as aliases. - * ``'round'``: uploading a round video. - * ``'video'``: uploading a video file. - * ``'photo'``: uploading a photo. - * ``'document'``: uploading a document file. - You may use ``'file'`` as alias. - * ``'cancel'``: cancel any pending action in this chat. - - Invalid strings will raise a ``ValueError``. - - delay (`int` | `float`): - The delay, in seconds, to wait between sending actions. - For example, if the delay is 5 and it takes 7 seconds to - do something, three requests will be made at 0s, 5s, and - 7s to cancel the action. - - auto_cancel (`bool`): - Whether the action should be cancelled once the context - manager exists or not. The default is `True`, since - you don't want progress to be shown when it has already - completed. - - Returns - Either a context-manager object or a coroutine. - - Example - .. code-block:: python - - # Type for 2 seconds, then send a message - async with client.action(chat, 'typing'): - await asyncio.sleep(2) - await client.send_message(chat, 'Hello world! I type slow ^^') - - # Cancel any previous action - await client.action(chat, 'cancel') - - # Upload a document, showing its progress (most clients ignore this) - async with client.action(chat, 'document') as action: - await client.send_file(chat, zip_file, progress_callback=action.progress) - """ - if isinstance(action, str): - try: - action = _ChatAction._str_mapping[action.lower()] - except KeyError: - raise ValueError( - 'No such action "{}"'.format(action)) from None - elif not isinstance(action, types.TLObject) or action.SUBCLASS_OF_ID != 0x20b2cc21: - # 0x20b2cc21 = crc32(b'SendMessageAction') - if isinstance(action, type): - raise ValueError('You must pass an instance, not the class') - else: - raise ValueError('Cannot use {} as action'.format(action)) - - if isinstance(action, types.SendMessageCancelAction): - # ``SetTypingRequest.resolve`` will get input peer of ``entity``. - return self(functions.messages.SetTypingRequest( - entity, types.SendMessageCancelAction())) - - return _ChatAction( - self, entity, action, delay=delay, auto_cancel=auto_cancel) - - async def edit_admin( - self: 'TelegramClient', - entity: 'hints.EntityLike', - user: 'hints.EntityLike', - *, - change_info: bool = None, - post_messages: bool = None, - edit_messages: bool = None, - delete_messages: bool = None, - ban_users: bool = None, - invite_users: bool = None, - pin_messages: bool = None, - add_admins: bool = None, - manage_call: bool = None, - anonymous: bool = None, - is_admin: bool = None, - title: str = None) -> types.Updates: - """ - Edits admin permissions for someone in a chat. - - Raises an error if a wrong combination of rights are given - (e.g. you don't have enough permissions to grant one). - - Unless otherwise stated, permissions will work in channels and megagroups. - - Arguments - entity (`entity`): - The channel, megagroup or chat where the promotion should happen. - - user (`entity`): - The user to be promoted. - - change_info (`bool`, optional): - Whether the user will be able to change info. - - post_messages (`bool`, optional): - Whether the user will be able to post in the channel. - This will only work in broadcast channels. - - edit_messages (`bool`, optional): - Whether the user will be able to edit messages in the channel. - This will only work in broadcast channels. - - delete_messages (`bool`, optional): - Whether the user will be able to delete messages. - - ban_users (`bool`, optional): - Whether the user will be able to ban users. - - invite_users (`bool`, optional): - Whether the user will be able to invite users. Needs some testing. - - pin_messages (`bool`, optional): - Whether the user will be able to pin messages. - - add_admins (`bool`, optional): - Whether the user will be able to add admins. - - manage_call (`bool`, optional): - Whether the user will be able to manage group calls. - - anonymous (`bool`, optional): - Whether the user will remain anonymous when sending messages. - The sender of the anonymous messages becomes the group itself. - - .. note:: - - Users may be able to identify the anonymous admin by its - custom title, so additional care is needed when using both - ``anonymous`` and custom titles. For example, if multiple - anonymous admins share the same title, users won't be able - to distinguish them. - - is_admin (`bool`, optional): - Whether the user will be an admin in the chat. - This will only work in small group chats. - Whether the user will be an admin in the chat. This is the - only permission available in small group chats, and when - used in megagroups, all non-explicitly set permissions will - have this value. - - Essentially, only passing ``is_admin=True`` will grant all - permissions, but you can still disable those you need. - - title (`str`, optional): - The custom title (also known as "rank") to show for this admin. - This text will be shown instead of the "admin" badge. - This will only work in channels and megagroups. - - When left unspecified or empty, the default localized "admin" - badge will be shown. - - Returns - The resulting :tl:`Updates` object. - - Example - .. code-block:: python - - # Allowing `user` to pin messages in `chat` - await client.edit_admin(chat, user, pin_messages=True) - - # Granting all permissions except for `add_admins` - await client.edit_admin(chat, user, is_admin=True, add_admins=False) - """ - entity = await self.get_input_entity(entity) - user = await self.get_input_entity(user) - ty = helpers._entity_type(user) - if ty != helpers._EntityType.USER: - raise ValueError('You must pass a user entity') - - perm_names = ( - 'change_info', 'post_messages', 'edit_messages', 'delete_messages', - 'ban_users', 'invite_users', 'pin_messages', 'add_admins', - 'anonymous', 'manage_call', - ) - - ty = helpers._entity_type(entity) - if ty == helpers._EntityType.CHANNEL: - # If we try to set these permissions in a megagroup, we - # would get a RIGHT_FORBIDDEN. However, it makes sense - # that an admin can post messages, so we want to avoid the error - if post_messages or edit_messages: - # TODO get rid of this once sessions cache this information - if entity.channel_id not in self._megagroup_cache: - full_entity = await self.get_entity(entity) - self._megagroup_cache[entity.channel_id] = full_entity.megagroup - - if self._megagroup_cache[entity.channel_id]: - post_messages = None - edit_messages = None - - perms = locals() - return await self(functions.channels.EditAdminRequest(entity, user, types.ChatAdminRights(**{ - # A permission is its explicit (not-None) value or `is_admin`. - # This essentially makes `is_admin` be the default value. - name: perms[name] if perms[name] is not None else is_admin - for name in perm_names - }), rank=title or '')) - - elif ty == helpers._EntityType.CHAT: - # If the user passed any permission in a small - # group chat, they must be a full admin to have it. - if is_admin is None: - is_admin = any(locals()[x] for x in perm_names) - - return await self(functions.messages.EditChatAdminRequest( - entity, user, is_admin=is_admin)) - - else: +def iter_participants( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = None, + *, + search: str = '', + filter: 'types.TypeChannelParticipantsFilter' = None, + aggressive: bool = False) -> _ParticipantsIter: + return _ParticipantsIter( + self, + limit, + entity=entity, + filter=filter, + search=search, + aggressive=aggressive + ) + +async def get_participants( + self: 'TelegramClient', + *args, + **kwargs) -> 'hints.TotalList': + return await self.iter_participants(*args, **kwargs).collect() + + +def iter_admin_log( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = None, + *, + max_id: int = 0, + min_id: int = 0, + search: str = None, + admins: 'hints.EntitiesLike' = None, + join: bool = None, + leave: bool = None, + invite: bool = None, + restrict: bool = None, + unrestrict: bool = None, + ban: bool = None, + unban: bool = None, + promote: bool = None, + demote: bool = None, + info: bool = None, + settings: bool = None, + pinned: bool = None, + edit: bool = None, + delete: bool = None, + group_call: bool = None) -> _AdminLogIter: + return _AdminLogIter( + self, + limit, + entity=entity, + admins=admins, + search=search, + min_id=min_id, + max_id=max_id, + join=join, + leave=leave, + invite=invite, + restrict=restrict, + unrestrict=unrestrict, + ban=ban, + unban=unban, + promote=promote, + demote=demote, + info=info, + settings=settings, + pinned=pinned, + edit=edit, + delete=delete, + group_call=group_call + ) + +async def get_admin_log( + self: 'TelegramClient', + *args, + **kwargs) -> 'hints.TotalList': + return await self.iter_admin_log(*args, **kwargs).collect() + + +def iter_profile_photos( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: int = None, + *, + offset: int = 0, + max_id: int = 0) -> _ProfilePhotoIter: + return _ProfilePhotoIter( + self, + limit, + entity=entity, + offset=offset, + max_id=max_id + ) + +async def get_profile_photos( + self: 'TelegramClient', + *args, + **kwargs) -> 'hints.TotalList': + return await self.iter_profile_photos(*args, **kwargs).collect() + + +def action( + self: 'TelegramClient', + entity: 'hints.EntityLike', + action: 'typing.Union[str, types.TypeSendMessageAction]', + *, + delay: float = 4, + auto_cancel: bool = True) -> 'typing.Union[_ChatAction, typing.Coroutine]': + if isinstance(action, str): + try: + action = _ChatAction._str_mapping[action.lower()] + except KeyError: raise ValueError( - 'You can only edit permissions in groups and channels') + 'No such action "{}"'.format(action)) from None + elif not isinstance(action, types.TLObject) or action.SUBCLASS_OF_ID != 0x20b2cc21: + # 0x20b2cc21 = crc32(b'SendMessageAction') + if isinstance(action, type): + raise ValueError('You must pass an instance, not the class') + else: + raise ValueError('Cannot use {} as action'.format(action)) - async def edit_permissions( - self: 'TelegramClient', - entity: 'hints.EntityLike', - user: 'typing.Optional[hints.EntityLike]' = None, - until_date: 'hints.DateLike' = None, - *, - view_messages: bool = True, - send_messages: bool = True, - send_media: bool = True, - send_stickers: bool = True, - send_gifs: bool = True, - send_games: bool = True, - send_inline: bool = True, - embed_link_previews: bool = True, - send_polls: bool = True, - change_info: bool = True, - invite_users: bool = True, - pin_messages: bool = True) -> types.Updates: - """ - Edits user restrictions in a chat. + if isinstance(action, types.SendMessageCancelAction): + # ``SetTypingRequest.resolve`` will get input peer of ``entity``. + return self(functions.messages.SetTypingRequest( + entity, types.SendMessageCancelAction())) - Set an argument to `False` to apply a restriction (i.e. remove - the permission), or omit them to use the default `True` (i.e. - don't apply a restriction). + return _ChatAction( + self, entity, action, delay=delay, auto_cancel=auto_cancel) - Raises an error if a wrong combination of rights are given - (e.g. you don't have enough permissions to revoke one). +async def edit_admin( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'hints.EntityLike', + *, + change_info: bool = None, + post_messages: bool = None, + edit_messages: bool = None, + delete_messages: bool = None, + ban_users: bool = None, + invite_users: bool = None, + pin_messages: bool = None, + add_admins: bool = None, + manage_call: bool = None, + anonymous: bool = None, + is_admin: bool = None, + title: str = None) -> types.Updates: + entity = await self.get_input_entity(entity) + user = await self.get_input_entity(user) + ty = helpers._entity_type(user) + if ty != helpers._EntityType.USER: + raise ValueError('You must pass a user entity') - By default, each boolean argument is `True`, meaning that it - is true that the user has access to the default permission - and may be able to make use of it. + perm_names = ( + 'change_info', 'post_messages', 'edit_messages', 'delete_messages', + 'ban_users', 'invite_users', 'pin_messages', 'add_admins', + 'anonymous', 'manage_call', + ) - If you set an argument to `False`, then a restriction is applied - regardless of the default permissions. + ty = helpers._entity_type(entity) + if ty == helpers._EntityType.CHANNEL: + # If we try to set these permissions in a megagroup, we + # would get a RIGHT_FORBIDDEN. However, it makes sense + # that an admin can post messages, so we want to avoid the error + if post_messages or edit_messages: + # TODO get rid of this once sessions cache this information + if entity.channel_id not in self._megagroup_cache: + full_entity = await self.get_entity(entity) + self._megagroup_cache[entity.channel_id] = full_entity.megagroup - It is important to note that `True` does *not* mean grant, only - "don't restrict", and this is where the default permissions come - in. A user may have not been revoked the ``pin_messages`` permission - (it is `True`) but they won't be able to use it if the default - permissions don't allow it either. + if self._megagroup_cache[entity.channel_id]: + post_messages = None + edit_messages = None - Arguments - entity (`entity`): - The channel or megagroup where the restriction should happen. + perms = locals() + return await self(functions.channels.EditAdminRequest(entity, user, types.ChatAdminRights(**{ + # A permission is its explicit (not-None) value or `is_admin`. + # This essentially makes `is_admin` be the default value. + name: perms[name] if perms[name] is not None else is_admin + for name in perm_names + }), rank=title or '')) - user (`entity`, optional): - If specified, the permission will be changed for the specific user. - If left as `None`, the default chat permissions will be updated. + elif ty == helpers._EntityType.CHAT: + # If the user passed any permission in a small + # group chat, they must be a full admin to have it. + if is_admin is None: + is_admin = any(locals()[x] for x in perm_names) - until_date (`DateLike`, optional): - When the user will be unbanned. + return await self(functions.messages.EditChatAdminRequest( + entity, user, is_admin=is_admin)) - If the due date or duration is longer than 366 days or shorter than - 30 seconds, the ban will be forever. Defaults to ``0`` (ban forever). + else: + raise ValueError( + 'You can only edit permissions in groups and channels') - view_messages (`bool`, optional): - Whether the user is able to view messages or not. - Forbidding someone from viewing messages equals to banning them. - This will only work if ``user`` is set. +async def edit_permissions( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'typing.Optional[hints.EntityLike]' = None, + until_date: 'hints.DateLike' = None, + *, + view_messages: bool = True, + send_messages: bool = True, + send_media: bool = True, + send_stickers: bool = True, + send_gifs: bool = True, + send_games: bool = True, + send_inline: bool = True, + embed_link_previews: bool = True, + send_polls: bool = True, + change_info: bool = True, + invite_users: bool = True, + pin_messages: bool = True) -> types.Updates: + entity = await self.get_input_entity(entity) + ty = helpers._entity_type(entity) + if ty != helpers._EntityType.CHANNEL: + raise ValueError('You must pass either a channel or a supergroup') - send_messages (`bool`, optional): - Whether the user is able to send messages or not. + rights = types.ChatBannedRights( + until_date=until_date, + view_messages=not view_messages, + send_messages=not send_messages, + send_media=not send_media, + send_stickers=not send_stickers, + send_gifs=not send_gifs, + send_games=not send_games, + send_inline=not send_inline, + embed_links=not embed_link_previews, + send_polls=not send_polls, + change_info=not change_info, + invite_users=not invite_users, + pin_messages=not pin_messages + ) - send_media (`bool`, optional): - Whether the user is able to send media or not. - - send_stickers (`bool`, optional): - Whether the user is able to send stickers or not. - - send_gifs (`bool`, optional): - Whether the user is able to send animated gifs or not. - - send_games (`bool`, optional): - Whether the user is able to send games or not. - - send_inline (`bool`, optional): - Whether the user is able to use inline bots or not. - - embed_link_previews (`bool`, optional): - Whether the user is able to enable the link preview in the - messages they send. Note that the user will still be able to - send messages with links if this permission is removed, but - these links won't display a link preview. - - send_polls (`bool`, optional): - Whether the user is able to send polls or not. - - change_info (`bool`, optional): - Whether the user is able to change info or not. - - invite_users (`bool`, optional): - Whether the user is able to invite other users or not. - - pin_messages (`bool`, optional): - Whether the user is able to pin messages or not. - - Returns - The resulting :tl:`Updates` object. - - Example - .. code-block:: python - - from datetime import timedelta - - # Banning `user` from `chat` for 1 minute - await client.edit_permissions(chat, user, timedelta(minutes=1), - view_messages=False) - - # Banning `user` from `chat` forever - await client.edit_permissions(chat, user, view_messages=False) - - # Kicking someone (ban + un-ban) - await client.edit_permissions(chat, user, view_messages=False) - await client.edit_permissions(chat, user) - """ - entity = await self.get_input_entity(entity) - ty = helpers._entity_type(entity) - if ty != helpers._EntityType.CHANNEL: - raise ValueError('You must pass either a channel or a supergroup') - - rights = types.ChatBannedRights( - until_date=until_date, - view_messages=not view_messages, - send_messages=not send_messages, - send_media=not send_media, - send_stickers=not send_stickers, - send_gifs=not send_gifs, - send_games=not send_games, - send_inline=not send_inline, - embed_links=not embed_link_previews, - send_polls=not send_polls, - change_info=not change_info, - invite_users=not invite_users, - pin_messages=not pin_messages - ) - - if user is None: - return await self(functions.messages.EditChatDefaultBannedRightsRequest( - peer=entity, - banned_rights=rights - )) - - user = await self.get_input_entity(user) - ty = helpers._entity_type(user) - if ty != helpers._EntityType.USER: - raise ValueError('You must pass a user entity') - - if isinstance(user, types.InputPeerSelf): - raise ValueError('You cannot restrict yourself') - - return await self(functions.channels.EditBannedRequest( - channel=entity, - participant=user, + if user is None: + return await self(functions.messages.EditChatDefaultBannedRightsRequest( + peer=entity, banned_rights=rights )) - async def kick_participant( - self: 'TelegramClient', - entity: 'hints.EntityLike', - user: 'typing.Optional[hints.EntityLike]' - ): - """ - Kicks a user from a chat. + user = await self.get_input_entity(user) + ty = helpers._entity_type(user) + if ty != helpers._EntityType.USER: + raise ValueError('You must pass a user entity') - Kicking yourself (``'me'``) will result in leaving the chat. + if isinstance(user, types.InputPeerSelf): + raise ValueError('You cannot restrict yourself') - .. note:: + return await self(functions.channels.EditBannedRequest( + channel=entity, + participant=user, + banned_rights=rights + )) - Attempting to kick someone who was banned will remove their - restrictions (and thus unbanning them), since kicking is just - ban + unban. +async def kick_participant( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'typing.Optional[hints.EntityLike]' +): + entity = await self.get_input_entity(entity) + user = await self.get_input_entity(user) + if helpers._entity_type(user) != helpers._EntityType.USER: + raise ValueError('You must pass a user entity') - Arguments - entity (`entity`): - The channel or chat where the user should be kicked from. - - user (`entity`, optional): - The user to kick. - - Returns - Returns the service `Message ` - produced about a user being kicked, if any. - - Example - .. code-block:: python - - # Kick some user from some chat, and deleting the service message - msg = await client.kick_participant(chat, user) - await msg.delete() - - # Leaving chat - await client.kick_participant(chat, 'me') - """ - entity = await self.get_input_entity(entity) - user = await self.get_input_entity(user) - if helpers._entity_type(user) != helpers._EntityType.USER: - raise ValueError('You must pass a user entity') - - ty = helpers._entity_type(entity) - if ty == helpers._EntityType.CHAT: - resp = await self(functions.messages.DeleteChatUserRequest(entity.chat_id, user)) - elif ty == helpers._EntityType.CHANNEL: - if isinstance(user, types.InputPeerSelf): - # Despite no longer being in the channel, the account still - # seems to get the service message. - resp = await self(functions.channels.LeaveChannelRequest(entity)) - else: - resp = await self(functions.channels.EditBannedRequest( - channel=entity, - participant=user, - banned_rights=types.ChatBannedRights( - until_date=None, view_messages=True) - )) - await asyncio.sleep(0.5) - await self(functions.channels.EditBannedRequest( - channel=entity, - participant=user, - banned_rights=types.ChatBannedRights(until_date=None) - )) + ty = helpers._entity_type(entity) + if ty == helpers._EntityType.CHAT: + resp = await self(functions.messages.DeleteChatUserRequest(entity.chat_id, user)) + elif ty == helpers._EntityType.CHANNEL: + if isinstance(user, types.InputPeerSelf): + # Despite no longer being in the channel, the account still + # seems to get the service message. + resp = await self(functions.channels.LeaveChannelRequest(entity)) else: - raise ValueError('You must pass either a channel or a chat') - - return self._get_response_message(None, resp, entity) - - async def get_permissions( - self: 'TelegramClient', - entity: 'hints.EntityLike', - user: 'hints.EntityLike' = None - ) -> 'typing.Optional[custom.ParticipantPermissions]': - """ - Fetches the permissions of a user in a specific chat or channel or - get Default Restricted Rights of Chat or Channel. - - .. note:: - - This request has to fetch the entire chat for small group chats, - which can get somewhat expensive, so use of a cache is advised. - - Arguments - entity (`entity`): - The channel or chat the user is participant of. - - user (`entity`, optional): - Target user. - - Returns - A `ParticipantPermissions ` - instance. Refer to its documentation to see what properties are - available. - - Example - .. code-block:: python - - permissions = await client.get_permissions(chat, user) - if permissions.is_admin: - # do something - - # Get Banned Permissions of Chat - await client.get_permissions(chat) - """ - entity = await self.get_entity(entity) - - if not user: - if isinstance(entity, types.Channel): - FullChat = await self(functions.channels.GetFullChannelRequest(entity)) - elif isinstance(entity, types.Chat): - FullChat = await self(functions.messages.GetFullChatRequest(entity)) - else: - return - return FullChat.chats[0].default_banned_rights - - entity = await self.get_input_entity(entity) - user = await self.get_input_entity(user) - if helpers._entity_type(user) != helpers._EntityType.USER: - raise ValueError('You must pass a user entity') - if helpers._entity_type(entity) == helpers._EntityType.CHANNEL: - participant = await self(functions.channels.GetParticipantRequest( - entity, - user + resp = await self(functions.channels.EditBannedRequest( + channel=entity, + participant=user, + banned_rights=types.ChatBannedRights( + until_date=None, view_messages=True) )) - return custom.ParticipantPermissions(participant.participant, False) - elif helpers._entity_type(entity) == helpers._EntityType.CHAT: - chat = await self(functions.messages.GetFullChatRequest( - entity + await asyncio.sleep(0.5) + await self(functions.channels.EditBannedRequest( + channel=entity, + participant=user, + banned_rights=types.ChatBannedRights(until_date=None) )) - if isinstance(user, types.InputPeerSelf): - user = await self.get_me(input_peer=True) - for participant in chat.full_chat.participants.participants: - if participant.user_id == user.user_id: - return custom.ParticipantPermissions(participant, True) - raise errors.UserNotParticipantError(None) - + else: raise ValueError('You must pass either a channel or a chat') - async def get_stats( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message: 'typing.Union[int, types.Message]' = None, - ): - """ - Retrieves statistics from the given megagroup or broadcast channel. + return self._get_response_message(None, resp, entity) - Note that some restrictions apply before being able to fetch statistics, - in particular the channel must have enough members (for megagroups, this - requires `at least 500 members`_). +async def get_permissions( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'hints.EntityLike' = None +) -> 'typing.Optional[custom.ParticipantPermissions]': + entity = await self.get_entity(entity) - Arguments - entity (`entity`): - The channel from which to get statistics. - - message (`int` | ``Message``, optional): - The message ID from which to get statistics, if your goal is - to obtain the statistics of a single message. - - Raises - If the given entity is not a channel (broadcast or megagroup), - a `TypeError` is raised. - - If there are not enough members (poorly named) errors such as - ``telethon.errors.ChatAdminRequiredError`` will appear. - - Returns - If both ``entity`` and ``message`` were provided, returns - :tl:`MessageStats`. Otherwise, either :tl:`BroadcastStats` or - :tl:`MegagroupStats`, depending on whether the input belonged to a - broadcast channel or megagroup. - - Example - .. code-block:: python - - # Some megagroup or channel username or ID to fetch - channel = -100123 - stats = await client.get_stats(channel) - print('Stats from', stats.period.min_date, 'to', stats.period.max_date, ':') - print(stats.stringify()) - - .. _`at least 500 members`: https://telegram.org/blog/profile-videos-people-nearby-and-more - """ - entity = await self.get_input_entity(entity) - if helpers._entity_type(entity) != helpers._EntityType.CHANNEL: - raise TypeError('You must pass a channel entity') - - message = utils.get_message_id(message) - if message is not None: - try: - req = functions.stats.GetMessageStatsRequest(entity, message) - return await self(req) - except errors.StatsMigrateError as e: - dc = e.dc + if not user: + if isinstance(entity, types.Channel): + FullChat = await self(functions.channels.GetFullChannelRequest(entity)) + elif isinstance(entity, types.Chat): + FullChat = await self(functions.messages.GetFullChatRequest(entity)) else: - # Don't bother fetching the Channel entity (costs a request), instead - # try to guess and if it fails we know it's the other one (best case - # no extra request, worst just one). + return + return FullChat.chats[0].default_banned_rights + + entity = await self.get_input_entity(entity) + user = await self.get_input_entity(user) + if helpers._entity_type(user) != helpers._EntityType.USER: + raise ValueError('You must pass a user entity') + if helpers._entity_type(entity) == helpers._EntityType.CHANNEL: + participant = await self(functions.channels.GetParticipantRequest( + entity, + user + )) + return custom.ParticipantPermissions(participant.participant, False) + elif helpers._entity_type(entity) == helpers._EntityType.CHAT: + chat = await self(functions.messages.GetFullChatRequest( + entity + )) + if isinstance(user, types.InputPeerSelf): + user = await self.get_me(input_peer=True) + for participant in chat.full_chat.participants.participants: + if participant.user_id == user.user_id: + return custom.ParticipantPermissions(participant, True) + raise errors.UserNotParticipantError(None) + + raise ValueError('You must pass either a channel or a chat') + +async def get_stats( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Union[int, types.Message]' = None, +): + entity = await self.get_input_entity(entity) + if helpers._entity_type(entity) != helpers._EntityType.CHANNEL: + raise TypeError('You must pass a channel entity') + + message = utils.get_message_id(message) + if message is not None: + try: + req = functions.stats.GetMessageStatsRequest(entity, message) + return await self(req) + except errors.StatsMigrateError as e: + dc = e.dc + else: + # Don't bother fetching the Channel entity (costs a request), instead + # try to guess and if it fails we know it's the other one (best case + # no extra request, worst just one). + try: + req = functions.stats.GetBroadcastStatsRequest(entity) + return await self(req) + except errors.StatsMigrateError as e: + dc = e.dc + except errors.BroadcastRequiredError: + req = functions.stats.GetMegagroupStatsRequest(entity) try: - req = functions.stats.GetBroadcastStatsRequest(entity) return await self(req) except errors.StatsMigrateError as e: dc = e.dc - except errors.BroadcastRequiredError: - req = functions.stats.GetMegagroupStatsRequest(entity) - try: - return await self(req) - except errors.StatsMigrateError as e: - dc = e.dc - sender = await self._borrow_exported_sender(dc) - try: - # req will be resolved to use the right types inside by now - return await sender.send(req) - finally: - await self._return_exported_sender(sender) - - # endregion + sender = await self._borrow_exported_sender(dc) + try: + # req will be resolved to use the right types inside by now + return await sender.send(req) + finally: + await self._return_exported_sender(sender) diff --git a/telethon/client/dialogs.py b/telethon/client/dialogs.py index 8c0860fc..67c47458 100644 --- a/telethon/client/dialogs.py +++ b/telethon/client/dialogs.py @@ -136,471 +136,139 @@ class _DraftsIter(RequestIter): return [] -class DialogMethods: +def iter_dialogs( + self: 'TelegramClient', + limit: float = None, + *, + offset_date: 'hints.DateLike' = None, + offset_id: int = 0, + offset_peer: 'hints.EntityLike' = types.InputPeerEmpty(), + ignore_pinned: bool = False, + ignore_migrated: bool = False, + folder: int = None, + archived: bool = None +) -> _DialogsIter: + if archived is not None: + folder = 1 if archived else 0 - # region Public methods + return _DialogsIter( + self, + limit, + offset_date=offset_date, + offset_id=offset_id, + offset_peer=offset_peer, + ignore_pinned=ignore_pinned, + ignore_migrated=ignore_migrated, + folder=folder + ) - def iter_dialogs( - self: 'TelegramClient', - limit: float = None, - *, - offset_date: 'hints.DateLike' = None, - offset_id: int = 0, - offset_peer: 'hints.EntityLike' = types.InputPeerEmpty(), - ignore_pinned: bool = False, - ignore_migrated: bool = False, - folder: int = None, - archived: bool = None - ) -> _DialogsIter: - """ - Iterator over the dialogs (open conversations/subscribed channels). +async def get_dialogs(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': + return await self.iter_dialogs(*args, **kwargs).collect() - The order is the same as the one seen in official applications - (first pinned, them from those with the most recent message to - those with the oldest message). - Arguments - 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``. +def iter_drafts( + self: 'TelegramClient', + entity: 'hints.EntitiesLike' = None +) -> _DraftsIter: + if entity and not utils.is_list_like(entity): + entity = (entity,) - offset_date (`datetime`, optional): - The offset date to be used. + # TODO Passing a limit here makes no sense + return _DraftsIter(self, None, entities=entity) - offset_id (`int`, optional): - The message ID to be used as an offset. +async def get_drafts( + self: 'TelegramClient', + entity: 'hints.EntitiesLike' = None +) -> 'hints.TotalList': + items = await self.iter_drafts(entity).collect() + if not entity or utils.is_list_like(entity): + return items + else: + return items[0] - offset_peer (:tl:`InputPeer`, optional): - The peer to be used as an offset. +async def edit_folder( + self: 'TelegramClient', + entity: 'hints.EntitiesLike' = None, + folder: typing.Union[int, typing.Sequence[int]] = None, + *, + unpack=None +) -> types.Updates: + if (entity is None) == (unpack is None): + raise ValueError('You can only set either entities or unpack, not both') - ignore_pinned (`bool`, optional): - Whether pinned dialogs should be ignored or not. - When set to `True`, these won't be yielded at all. + if unpack is not None: + return await self(functions.folders.DeleteFolderRequest( + folder_id=unpack + )) - ignore_migrated (`bool`, optional): - Whether :tl:`Chat` that have ``migrated_to`` a :tl:`Channel` - should be included or not. By default all the chats in your - dialogs are returned, but setting this to `True` will ignore - (i.e. skip) them in the same way official applications do. + if not utils.is_list_like(entity): + entities = [await self.get_input_entity(entity)] + else: + entities = await asyncio.gather( + *(self.get_input_entity(x) for x in entity)) - folder (`int`, optional): - The folder from which the dialogs should be retrieved. + if folder is None: + raise ValueError('You must specify a folder') + elif not utils.is_list_like(folder): + folder = [folder] * len(entities) + elif len(entities) != len(folder): + raise ValueError('Number of folders does not match number of entities') - If left unspecified, all dialogs (including those from - folders) will be returned. + return await self(functions.folders.EditPeerFoldersRequest([ + types.InputFolderPeer(x, folder_id=y) + for x, y in zip(entities, folder) + ])) - If set to ``0``, all dialogs that don't belong to any - folder will be returned. +async def delete_dialog( + self: 'TelegramClient', + entity: 'hints.EntityLike', + *, + revoke: bool = False +): + # If we have enough information (`Dialog.delete` gives it to us), + # then we know we don't have to kick ourselves in deactivated chats. + if isinstance(entity, types.Chat): + deactivated = entity.deactivated + else: + deactivated = False - If set to a folder number like ``1``, only those from - said folder will be returned. + entity = await self.get_input_entity(entity) + ty = helpers._entity_type(entity) + if ty == helpers._EntityType.CHANNEL: + return await self(functions.channels.LeaveChannelRequest(entity)) - By default Telegram assigns the folder ID ``1`` to - archived chats, so you should use that if you need - to fetch the archived dialogs. - - archived (`bool`, optional): - Alias for `folder`. If unspecified, all will be returned, - `False` implies ``folder=0`` and `True` implies ``folder=1``. - Yields - Instances of `Dialog `. - - Example - .. code-block:: python - - # Print all dialog IDs and the title, nicely formatted - async for dialog in client.iter_dialogs(): - print('{:>14}: {}'.format(dialog.id, dialog.title)) - """ - if archived is not None: - folder = 1 if archived else 0 - - return _DialogsIter( - self, - limit, - offset_date=offset_date, - offset_id=offset_id, - offset_peer=offset_peer, - ignore_pinned=ignore_pinned, - ignore_migrated=ignore_migrated, - folder=folder - ) - - async def get_dialogs(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': - """ - Same as `iter_dialogs()`, but returns a - `TotalList ` instead. - - Example - .. code-block:: python - - # Get all open conversation, print the title of the first - dialogs = await client.get_dialogs() - first = dialogs[0] - print(first.title) - - # Use the dialog somewhere else - await client.send_message(first, 'hi') - - # Getting only non-archived dialogs (both equivalent) - non_archived = await client.get_dialogs(folder=0) - non_archived = await client.get_dialogs(archived=False) - - # Getting only archived dialogs (both equivalent) - archived = await client.get_dialogs(folder=1) - archived = await client.get_dialogs(archived=True) - """ - return await self.iter_dialogs(*args, **kwargs).collect() - - get_dialogs.__signature__ = inspect.signature(iter_dialogs) - - def iter_drafts( - self: 'TelegramClient', - entity: 'hints.EntitiesLike' = None - ) -> _DraftsIter: - """ - Iterator over draft messages. - - The order is unspecified. - - Arguments - entity (`hints.EntitiesLike`, optional): - The entity or entities for which to fetch the draft messages. - If left unspecified, all draft messages will be returned. - - Yields - Instances of `Draft `. - - Example - .. code-block:: python - - # Clear all drafts - async for draft in client.get_drafts(): - await draft.delete() - - # Getting the drafts with 'bot1' and 'bot2' - async for draft in client.iter_drafts(['bot1', 'bot2']): - print(draft.text) - """ - if entity and not utils.is_list_like(entity): - entity = (entity,) - - # TODO Passing a limit here makes no sense - return _DraftsIter(self, None, entities=entity) - - async def get_drafts( - self: 'TelegramClient', - entity: 'hints.EntitiesLike' = None - ) -> 'hints.TotalList': - """ - Same as `iter_drafts()`, but returns a list instead. - - Example - .. code-block:: python - - # Get drafts, print the text of the first - drafts = await client.get_drafts() - print(drafts[0].text) - - # Get the draft in your chat - draft = await client.get_drafts('me') - print(drafts.text) - """ - items = await self.iter_drafts(entity).collect() - if not entity or utils.is_list_like(entity): - return items - else: - return items[0] - - async def edit_folder( - self: 'TelegramClient', - entity: 'hints.EntitiesLike' = None, - folder: typing.Union[int, typing.Sequence[int]] = None, - *, - unpack=None - ) -> types.Updates: - """ - Edits the folder used by one or more dialogs to archive them. - - Arguments - entity (entities): - The entity or list of entities to move to the desired - archive folder. - - folder (`int`): - The folder to which the dialog should be archived to. - - If you want to "archive" a dialog, use ``folder=1``. - - If you want to "un-archive" it, use ``folder=0``. - - You may also pass a list with the same length as - `entities` if you want to control where each entity - will go. - - unpack (`int`, optional): - If you want to unpack an archived folder, set this - parameter to the folder number that you want to - delete. - - When you unpack a folder, all the dialogs inside are - moved to the folder number 0. - - You can only use this parameter if the other two - are not set. - - Returns - The :tl:`Updates` object that the request produces. - - Example - .. code-block:: python - - # Archiving the first 5 dialogs - dialogs = await client.get_dialogs(5) - await client.edit_folder(dialogs, 1) - - # Un-archiving the third dialog (archiving to folder 0) - await client.edit_folder(dialog[2], 0) - - # Moving the first dialog to folder 0 and the second to 1 - dialogs = await client.get_dialogs(2) - await client.edit_folder(dialogs, [0, 1]) - - # Un-archiving all dialogs - await client.edit_folder(unpack=1) - """ - if (entity is None) == (unpack is None): - raise ValueError('You can only set either entities or unpack, not both') - - if unpack is not None: - return await self(functions.folders.DeleteFolderRequest( - folder_id=unpack + if ty == helpers._EntityType.CHAT and not deactivated: + try: + result = await self(functions.messages.DeleteChatUserRequest( + entity.chat_id, types.InputUserSelf(), revoke_history=revoke )) - - if not utils.is_list_like(entity): - entities = [await self.get_input_entity(entity)] - else: - entities = await asyncio.gather( - *(self.get_input_entity(x) for x in entity)) - - if folder is None: - raise ValueError('You must specify a folder') - elif not utils.is_list_like(folder): - folder = [folder] * len(entities) - elif len(entities) != len(folder): - raise ValueError('Number of folders does not match number of entities') - - return await self(functions.folders.EditPeerFoldersRequest([ - types.InputFolderPeer(x, folder_id=y) - for x, y in zip(entities, folder) - ])) - - async def delete_dialog( - self: 'TelegramClient', - entity: 'hints.EntityLike', - *, - revoke: bool = False - ): - """ - Deletes a dialog (leaves a chat or channel). - - This method can be used as a user and as a bot. However, - bots will only be able to use it to leave groups and channels - (trying to delete a private conversation will do nothing). - - See also `Dialog.delete() `. - - Arguments - entity (entities): - The entity of the dialog to delete. If it's a chat or - channel, you will leave it. Note that the chat itself - is not deleted, only the dialog, because you left it. - - revoke (`bool`, optional): - On private chats, you may revoke the messages from - the other peer too. By default, it's `False`. Set - it to `True` to delete the history for both. - - This makes no difference for bot accounts, who can - only leave groups and channels. - - Returns - The :tl:`Updates` object that the request produces, - or nothing for private conversations. - - Example - .. code-block:: python - - # Deleting the first dialog - dialogs = await client.get_dialogs(5) - await client.delete_dialog(dialogs[0]) - - # Leaving a channel by username - await client.delete_dialog('username') - """ - # If we have enough information (`Dialog.delete` gives it to us), - # then we know we don't have to kick ourselves in deactivated chats. - if isinstance(entity, types.Chat): - deactivated = entity.deactivated - else: - deactivated = False - - entity = await self.get_input_entity(entity) - ty = helpers._entity_type(entity) - if ty == helpers._EntityType.CHANNEL: - return await self(functions.channels.LeaveChannelRequest(entity)) - - if ty == helpers._EntityType.CHAT and not deactivated: - try: - result = await self(functions.messages.DeleteChatUserRequest( - entity.chat_id, types.InputUserSelf(), revoke_history=revoke - )) - except errors.PeerIdInvalidError: - # Happens if we didn't have the deactivated information - result = None - else: + except errors.PeerIdInvalidError: + # Happens if we didn't have the deactivated information result = None + else: + result = None - if not await self.is_bot(): - await self(functions.messages.DeleteHistoryRequest(entity, 0, revoke=revoke)) + if not await self.is_bot(): + await self(functions.messages.DeleteHistoryRequest(entity, 0, revoke=revoke)) - return result + return result - def conversation( - self: 'TelegramClient', - entity: 'hints.EntityLike', - *, - timeout: float = 60, - total_timeout: float = None, - max_messages: int = 100, - exclusive: bool = True, - replies_are_responses: bool = True) -> custom.Conversation: - """ - Creates a `Conversation ` - with the given entity. +def conversation( + self: 'TelegramClient', + entity: 'hints.EntityLike', + *, + timeout: float = 60, + total_timeout: float = None, + max_messages: int = 100, + exclusive: bool = True, + replies_are_responses: bool = True) -> custom.Conversation: + return custom.Conversation( + self, + entity, + timeout=timeout, + total_timeout=total_timeout, + max_messages=max_messages, + exclusive=exclusive, + replies_are_responses=replies_are_responses - .. note:: - - This Conversation API has certain shortcomings, such as lacking - persistence, poor interaction with other event handlers, and - overcomplicated usage for anything beyond the simplest case. - - If you plan to interact with a bot without handlers, this works - fine, but when running a bot yourself, you may instead prefer - to follow the advice from https://stackoverflow.com/a/62246569/. - - This is not the same as just sending a message to create a "dialog" - with them, but rather a way to easily send messages and await for - responses or other reactions. Refer to its documentation for more. - - Arguments - entity (`entity`): - The entity with which a new conversation should be opened. - - timeout (`int` | `float`, optional): - The default timeout (in seconds) *per action* to be used. You - may also override this timeout on a per-method basis. By - default each action can take up to 60 seconds (the value of - this timeout). - - total_timeout (`int` | `float`, optional): - The total timeout (in seconds) to use for the whole - conversation. This takes priority over per-action - timeouts. After these many seconds pass, subsequent - actions will result in ``asyncio.TimeoutError``. - - max_messages (`int`, optional): - The maximum amount of messages this conversation will - remember. After these many messages arrive in the - specified chat, subsequent actions will result in - ``ValueError``. - - exclusive (`bool`, optional): - By default, conversations are exclusive within a single - chat. That means that while a conversation is open in a - chat, you can't open another one in the same chat, unless - you disable this flag. - - If you try opening an exclusive conversation for - a chat where it's already open, it will raise - ``AlreadyInConversationError``. - - replies_are_responses (`bool`, optional): - Whether replies should be treated as responses or not. - - If the setting is enabled, calls to `conv.get_response - ` - and a subsequent call to `conv.get_reply - ` - will return different messages, otherwise they may return - the same message. - - Consider the following scenario with one outgoing message, - 1, and two incoming messages, the second one replying:: - - Hello! <1 - 2> (reply to 1) Hi! - 3> (reply to 1) How are you? - - And the following code: - - .. code-block:: python - - async with client.conversation(chat) as conv: - msg1 = await conv.send_message('Hello!') - msg2 = await conv.get_response() - msg3 = await conv.get_reply() - - With the setting enabled, ``msg2`` will be ``'Hi!'`` and - ``msg3`` be ``'How are you?'`` since replies are also - responses, and a response was already returned. - - With the setting disabled, both ``msg2`` and ``msg3`` will - be ``'Hi!'`` since one is a response and also a reply. - - Returns - A `Conversation `. - - Example - .. code-block:: python - - # denotes outgoing messages you sent - # denotes incoming response messages - with bot.conversation(chat) as conv: - # Hi! - conv.send_message('Hi!') - - # Hello! - hello = conv.get_response() - - # Please tell me your name - conv.send_message('Please tell me your name') - - # ? - name = conv.get_response().raw_text - - while not any(x.isalpha() for x in name): - # Your name didn't have any letters! Try again - conv.send_message("Your name didn't have any letters! Try again") - - # Human - name = conv.get_response().raw_text - - # Thanks Human! - conv.send_message('Thanks {}!'.format(name)) - """ - return custom.Conversation( - self, - entity, - timeout=timeout, - total_timeout=total_timeout, - max_messages=max_messages, - exclusive=exclusive, - replies_are_responses=replies_are_responses - - ) - - # endregion + ) diff --git a/telethon/client/downloads.py b/telethon/client/downloads.py index 62ba6332..2150dc92 100644 --- a/telethon/client/downloads.py +++ b/telethon/client/downloads.py @@ -192,852 +192,838 @@ class _GenericDownloadIter(_DirectDownloadIter): self.request.offset -= self._stride -class DownloadMethods: +async def download_profile_photo( + self: 'TelegramClient', + entity: 'hints.EntityLike', + file: 'hints.FileLike' = None, + *, + download_big: bool = True) -> typing.Optional[str]: + """ + Downloads the profile photo from the given user, chat or channel. - # region Public methods + Arguments + entity (`entity`): + From who the photo will be downloaded. - async def download_profile_photo( - self: 'TelegramClient', - entity: 'hints.EntityLike', - file: 'hints.FileLike' = None, - *, - download_big: bool = True) -> typing.Optional[str]: - """ - Downloads the profile photo from the given user, chat or channel. + .. note:: - Arguments - entity (`entity`): - From who the photo will be downloaded. + This method expects the full entity (which has the data + to download the photo), not an input variant. - .. note:: + It's possible that sometimes you can't fetch the entity + from its input (since you can get errors like + ``ChannelPrivateError``) but you already have it through + another call, like getting a forwarded message from it. - This method expects the full entity (which has the data - to download the photo), not an input variant. + 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 file is the type `bytes`, it will be downloaded in-memory + as a bytestring (e.g. ``file=bytes``). - It's possible that sometimes you can't fetch the entity - from its input (since you can get errors like - ``ChannelPrivateError``) but you already have it through - another call, like getting a forwarded message from it. + download_big (`bool`, optional): + Whether to use the big version of the available photos. - 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 file is the type `bytes`, it will be downloaded in-memory - as a bytestring (e.g. ``file=bytes``). + 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. - download_big (`bool`, optional): - Whether to use the big version of the available photos. + Example + .. code-block:: python - 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. + # Download your own profile photo + path = await client.download_profile_photo('me') + print(path) + """ + # 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) - Example - .. code-block:: python + thumb = -1 if download_big else 0 - # Download your own profile photo - path = await client.download_profile_photo('me') - print(path) - """ - # 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) - - thumb = -1 if download_big else 0 - - 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, - thumb=thumb, 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)): - dc_id = photo.dc_id - loc = types.InputPeerPhotoFileLocation( - peer=await self.get_input_entity(entity), - photo_id=photo.photo_id, - big=download_big - ) - else: - # It doesn't make any sense to check if `photo` can be used - # as input location, because then this method would be able - # to "download the profile photo of a message", i.e. its - # media which should be done with `download_media` instead. - return None - - file = self._get_proper_filename( - file, 'profile_photo', '.jpg', - possible_names=possible_names - ) - - try: - result = await self.download_file(loc, file, dc_id=dc_id) - return result if file is bytes else 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) - ty = helpers._entity_type(ie) - if ty == helpers._EntityType.CHANNEL: - full = await self(functions.channels.GetFullChannelRequest(ie)) - return await self._download_photo( - full.full_chat.chat_photo, file, - date=None, progress_callback=None, - thumb=thumb - ) - else: - # Until there's a report for chats, no need to. + 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 - async def download_media( - self: 'TelegramClient', - message: 'hints.MessageLike', - file: 'hints.FileLike' = None, - *, - thumb: 'typing.Union[int, types.TypePhotoSize]' = None, - progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]: - """ - Downloads the given media from a message object. - - 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). - - See also `Message.download_media() `. - - Arguments - message (`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. - If file is the type `bytes`, it will be downloaded in-memory - as a bytestring (e.g. ``file=bytes``). - - progress_callback (`callable`, optional): - A callback function accepting two parameters: - ``(received bytes, total)``. - - thumb (`int` | :tl:`PhotoSize`, optional): - Which thumbnail size from the document or photo to download, - instead of downloading the document or photo itself. - - If it's specified but the file does not have a thumbnail, - this method will return `None`. - - The parameter should be an integer index between ``0`` and - ``len(sizes)``. ``0`` will download the smallest thumbnail, - and ``len(sizes) - 1`` will download the largest thumbnail. - You can also use negative indices, which work the same as - they do in Python's `list`. - - You can also pass the :tl:`PhotoSize` instance to use. - Alternatively, the thumb size type `str` may be used. - - In short, use ``thumb=0`` if you want the smallest thumbnail - and ``thumb=-1`` if you want the largest thumbnail. - - .. note:: - The largest thumbnail may be a video instead of a photo, - as they are available since layer 116 and are bigger than - any of the photos. - - 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. - - Example - .. code-block:: python - - path = await client.download_media(message) - await client.download_media(message, filename) - # or - path = await message.download_media() - await message.download_media(filename) - - # Printing download progress - def callback(current, total): - print('Downloaded', current, 'out of', total, - 'bytes: {:.2%}'.format(current / total)) - - await client.download_media(message, progress_callback=callback) - """ - # Downloading large documents may be slow enough to require a new file reference - # to be obtained mid-download. Store (input chat, message id) so that the message - # can be re-fetched. - msg_data = None - - # TODO This won't work for messageService - if isinstance(message, types.Message): - date = message.date - media = message.media - msg_data = (message.input_chat, message.id) if message.input_chat else None - else: - date = datetime.datetime.now() - media = message - - if isinstance(media, str): - media = utils.resolve_bot_file_id(media) - - if isinstance(media, types.MessageService): - if isinstance(message.action, - types.MessageActionChatEditPhoto): - media = media.photo - - 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)): return await self._download_photo( - media, file, date, thumb, progress_callback - ) - elif isinstance(media, (types.MessageMediaDocument, types.Document)): - return await self._download_document( - media, file, date, thumb, progress_callback, msg_data - ) - elif isinstance(media, types.MessageMediaContact) and thumb is None: - return self._download_contact( - media, file - ) - elif isinstance(media, (types.WebDocument, types.WebDocumentNoProxy)) and thumb is None: - return await self._download_web_document( - media, file, progress_callback + entity.chat_photo, file, date=None, + thumb=thumb, progress_callback=None ) - async def download_file( - self: 'TelegramClient', - input_location: 'hints.FileLike', - file: 'hints.OutFileLike' = None, - *, - part_size_kb: float = None, - file_size: int = None, - progress_callback: 'hints.ProgressCallback' = None, - dc_id: int = None, - key: bytes = None, - iv: bytes = None) -> typing.Optional[bytes]: - """ - Low-level method to download files from their input location. + for attr in ('username', 'first_name', 'title'): + possible_names.append(getattr(entity, attr, None)) - .. note:: + photo = entity.photo - Generally, you should instead use `download_media`. - This method is intended to be a bit more low-level. + if isinstance(photo, (types.UserProfilePhoto, types.ChatPhoto)): + dc_id = photo.dc_id + loc = types.InputPeerPhotoFileLocation( + peer=await self.get_input_entity(entity), + photo_id=photo.photo_id, + big=download_big + ) + else: + # It doesn't make any sense to check if `photo` can be used + # as input location, because then this method would be able + # to "download the profile photo of a message", i.e. its + # media which should be done with `download_media` instead. + return None - Arguments - input_location (: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 = self._get_proper_filename( + file, 'profile_photo', '.jpg', + possible_names=possible_names + ) - 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. + try: + result = await self.download_file(loc, file, dc_id=dc_id) + return result if file is bytes else 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) + ty = helpers._entity_type(ie) + if ty == helpers._EntityType.CHANNEL: + full = await self(functions.channels.GetFullChannelRequest(ie)) + return await self._download_photo( + full.full_chat.chat_photo, file, + date=None, progress_callback=None, + thumb=thumb + ) + else: + # Until there's a report for chats, no need to. + return None - If the file path is `None` or `bytes`, then the result - will be saved in memory and returned as `bytes`. +async def download_media( + self: 'TelegramClient', + message: 'hints.MessageLike', + file: 'hints.FileLike' = None, + *, + thumb: 'typing.Union[int, types.TypePhotoSize]' = None, + progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]: + """ + Downloads the given media from a message object. - part_size_kb (`int`, optional): - Chunk size when downloading files. The larger, the less - requests will be made (up to 512KB maximum). + 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). - file_size (`int`, optional): - The file size that is about to be downloaded, if known. - Only used if ``progress_callback`` is specified. + See also `Message.download_media() `. - progress_callback (`callable`, optional): - A callback function accepting two parameters: - ``(downloaded bytes, total)``. Note that the - ``total`` is the provided ``file_size``. + Arguments + message (`Message ` | :tl:`Media`): + The media or message containing the media that will be downloaded. - dc_id (`int`, optional): - The data center the library should connect to in order - to download the file. You shouldn't worry about this. + 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 file is the type `bytes`, it will be downloaded in-memory + as a bytestring (e.g. ``file=bytes``). - key ('bytes', optional): - In case of an encrypted upload (secret chats) a key is supplied + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(received bytes, total)``. - iv ('bytes', optional): - In case of an encrypted upload (secret chats) an iv is supplied + thumb (`int` | :tl:`PhotoSize`, optional): + Which thumbnail size from the document or photo to download, + instead of downloading the document or photo itself. + If it's specified but the file does not have a thumbnail, + this method will return `None`. - Example - .. code-block:: python + The parameter should be an integer index between ``0`` and + ``len(sizes)``. ``0`` will download the smallest thumbnail, + and ``len(sizes) - 1`` will download the largest thumbnail. + You can also use negative indices, which work the same as + they do in Python's `list`. - # Download a file and print its header - data = await client.download_file(input_file, bytes) - print(data[:16]) - """ - return await self._download_file( - input_location, - file, - part_size_kb=part_size_kb, - file_size=file_size, - progress_callback=progress_callback, - dc_id=dc_id, - key=key, - iv=iv, + You can also pass the :tl:`PhotoSize` instance to use. + Alternatively, the thumb size type `str` may be used. + + In short, use ``thumb=0`` if you want the smallest thumbnail + and ``thumb=-1`` if you want the largest thumbnail. + + .. note:: + The largest thumbnail may be a video instead of a photo, + as they are available since layer 116 and are bigger than + any of the photos. + + 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. + + Example + .. code-block:: python + + path = await client.download_media(message) + await client.download_media(message, filename) + # or + path = await message.download_media() + await message.download_media(filename) + + # Printing download progress + def callback(current, total): + print('Downloaded', current, 'out of', total, + 'bytes: {:.2%}'.format(current / total)) + + await client.download_media(message, progress_callback=callback) + """ + # Downloading large documents may be slow enough to require a new file reference + # to be obtained mid-download. Store (input chat, message id) so that the message + # can be re-fetched. + msg_data = None + + # TODO This won't work for messageService + if isinstance(message, types.Message): + date = message.date + media = message.media + msg_data = (message.input_chat, message.id) if message.input_chat else None + else: + date = datetime.datetime.now() + media = message + + if isinstance(media, str): + media = utils.resolve_bot_file_id(media) + + if isinstance(media, types.MessageService): + if isinstance(message.action, + types.MessageActionChatEditPhoto): + media = media.photo + + 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)): + return await self._download_photo( + media, file, date, thumb, progress_callback + ) + elif isinstance(media, (types.MessageMediaDocument, types.Document)): + return await self._download_document( + media, file, date, thumb, progress_callback, msg_data + ) + elif isinstance(media, types.MessageMediaContact) and thumb is None: + return self._download_contact( + media, file + ) + elif isinstance(media, (types.WebDocument, types.WebDocumentNoProxy)) and thumb is None: + return await self._download_web_document( + media, file, progress_callback ) - async def _download_file( - self: 'TelegramClient', - input_location: 'hints.FileLike', - file: 'hints.OutFileLike' = None, - *, - part_size_kb: float = None, - file_size: int = None, - progress_callback: 'hints.ProgressCallback' = None, - dc_id: int = None, - key: bytes = None, - iv: bytes = None, - msg_data: tuple = None) -> typing.Optional[bytes]: - 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) +async def download_file( + self: 'TelegramClient', + input_location: 'hints.FileLike', + file: 'hints.OutFileLike' = None, + *, + part_size_kb: float = None, + file_size: int = None, + progress_callback: 'hints.ProgressCallback' = None, + dc_id: int = None, + key: bytes = None, + iv: bytes = None) -> typing.Optional[bytes]: + """ + Low-level method to download files from their input location. - part_size = int(part_size_kb * 1024) - if part_size % MIN_CHUNK_SIZE != 0: - raise ValueError( - 'The part size must be evenly divisible by 4096.') + .. note:: - if isinstance(file, pathlib.Path): - file = str(file.absolute()) + Generally, you should instead use `download_media`. + This method is intended to be a bit more low-level. - in_memory = file is None or file is bytes - 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') + Arguments + input_location (: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` or `bytes`, 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``. + + dc_id (`int`, optional): + The data center the library should connect to in order + to download the file. You shouldn't worry about this. + + key ('bytes', optional): + In case of an encrypted upload (secret chats) a key is supplied + + iv ('bytes', optional): + In case of an encrypted upload (secret chats) an iv is supplied + + + Example + .. code-block:: python + + # Download a file and print its header + data = await client.download_file(input_file, bytes) + print(data[:16]) + """ + return await self._download_file( + input_location, + file, + part_size_kb=part_size_kb, + file_size=file_size, + progress_callback=progress_callback, + dc_id=dc_id, + key=key, + iv=iv, + ) + +async def _download_file( + self: 'TelegramClient', + input_location: 'hints.FileLike', + file: 'hints.OutFileLike' = None, + *, + part_size_kb: float = None, + file_size: int = None, + progress_callback: 'hints.ProgressCallback' = None, + dc_id: int = None, + key: bytes = None, + iv: bytes = None, + msg_data: tuple = None) -> typing.Optional[bytes]: + if not part_size_kb: + if not file_size: + part_size_kb = 64 # Reasonable default else: - f = file + part_size_kb = utils.get_appropriated_part_size(file_size) - try: - async for chunk in self._iter_download( - input_location, request_size=part_size, dc_id=dc_id, msg_data=msg_data): - if iv and key: - chunk = AES.decrypt_ige(chunk, key, iv) - r = f.write(chunk) + part_size = int(part_size_kb * 1024) + if part_size % MIN_CHUNK_SIZE != 0: + raise ValueError( + 'The part size must be evenly divisible by 4096.') + + if isinstance(file, pathlib.Path): + file = str(file.absolute()) + + in_memory = file is None or file is bytes + 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 + + try: + async for chunk in self._iter_download( + input_location, request_size=part_size, dc_id=dc_id, msg_data=msg_data): + if iv and key: + chunk = AES.decrypt_ige(chunk, key, iv) + r = f.write(chunk) + if inspect.isawaitable(r): + await r + + if progress_callback: + r = progress_callback(f.tell(), file_size) if inspect.isawaitable(r): await r - if progress_callback: - r = progress_callback(f.tell(), file_size) - if inspect.isawaitable(r): - await r + # Not all IO objects have flush (see #1227) + if callable(getattr(f, 'flush', None)): + f.flush() - # Not all IO objects have flush (see #1227) - if callable(getattr(f, 'flush', None)): - f.flush() + if in_memory: + return f.getvalue() + finally: + if isinstance(file, str) or in_memory: + f.close() - if in_memory: - return f.getvalue() - finally: - if isinstance(file, str) or in_memory: - f.close() +def iter_download( + self: 'TelegramClient', + file: 'hints.FileLike', + *, + offset: int = 0, + stride: int = None, + limit: int = None, + chunk_size: int = None, + request_size: int = MAX_CHUNK_SIZE, + file_size: int = None, + dc_id: int = None +): + """ + Iterates over a file download, yielding chunks of the file. - def iter_download( - self: 'TelegramClient', - file: 'hints.FileLike', - *, - offset: int = 0, - stride: int = None, - limit: int = None, - chunk_size: int = None, - request_size: int = MAX_CHUNK_SIZE, - file_size: int = None, - dc_id: int = None - ): - """ - Iterates over a file download, yielding chunks of the file. + This method can be used to stream files in a more convenient + way, since it offers more control (pausing, resuming, etc.) - This method can be used to stream files in a more convenient - way, since it offers more control (pausing, resuming, etc.) + .. note:: - .. note:: + Using a value for `offset` or `stride` which is not a multiple + of the minimum allowed `request_size`, or if `chunk_size` is + different from `request_size`, the library will need to do a + bit more work to fetch the data in the way you intend it to. - Using a value for `offset` or `stride` which is not a multiple - of the minimum allowed `request_size`, or if `chunk_size` is - different from `request_size`, the library will need to do a - bit more work to fetch the data in the way you intend it to. + You normally shouldn't worry about this. - You normally shouldn't worry about this. + Arguments + file (`hints.FileLike`): + The file of which contents you want to iterate over. - Arguments - file (`hints.FileLike`): - The file of which contents you want to iterate over. + offset (`int`, optional): + The offset in bytes into the file from where the + download should start. For example, if a file is + 1024KB long and you just want the last 512KB, you + would use ``offset=512 * 1024``. - offset (`int`, optional): - The offset in bytes into the file from where the - download should start. For example, if a file is - 1024KB long and you just want the last 512KB, you - would use ``offset=512 * 1024``. + stride (`int`, optional): + The stride of each chunk (how much the offset should + advance between reading each chunk). This parameter + should only be used for more advanced use cases. - stride (`int`, optional): - The stride of each chunk (how much the offset should - advance between reading each chunk). This parameter - should only be used for more advanced use cases. + It must be bigger than or equal to the `chunk_size`. - It must be bigger than or equal to the `chunk_size`. + limit (`int`, optional): + The limit for how many *chunks* will be yielded at most. - limit (`int`, optional): - The limit for how many *chunks* will be yielded at most. + chunk_size (`int`, optional): + The maximum size of the chunks that will be yielded. + Note that the last chunk may be less than this value. + By default, it equals to `request_size`. - chunk_size (`int`, optional): - The maximum size of the chunks that will be yielded. - Note that the last chunk may be less than this value. - By default, it equals to `request_size`. + request_size (`int`, optional): + How many bytes will be requested to Telegram when more + data is required. By default, as many bytes as possible + are requested. If you would like to request data in + smaller sizes, adjust this parameter. - request_size (`int`, optional): - How many bytes will be requested to Telegram when more - data is required. By default, as many bytes as possible - are requested. If you would like to request data in - smaller sizes, adjust this parameter. + Note that values outside the valid range will be clamped, + and the final value will also be a multiple of the minimum + allowed size. - Note that values outside the valid range will be clamped, - and the final value will also be a multiple of the minimum - allowed size. + file_size (`int`, optional): + If the file size is known beforehand, you should set + this parameter to said value. Depending on the type of + the input file passed, this may be set automatically. - file_size (`int`, optional): - If the file size is known beforehand, you should set - this parameter to said value. Depending on the type of - the input file passed, this may be set automatically. + dc_id (`int`, optional): + The data center the library should connect to in order + to download the file. You shouldn't worry about this. - dc_id (`int`, optional): - The data center the library should connect to in order - to download the file. You shouldn't worry about this. + Yields - Yields + `bytes` objects representing the chunks of the file if the + right conditions are met, or `memoryview` objects instead. - `bytes` objects representing the chunks of the file if the - right conditions are met, or `memoryview` objects instead. + Example + .. code-block:: python - Example - .. code-block:: python + # Streaming `media` to an output file + # After the iteration ends, the sender is cleaned up + with open('photo.jpg', 'wb') as fd: + async for chunk in client.iter_download(media): + fd.write(chunk) - # Streaming `media` to an output file - # After the iteration ends, the sender is cleaned up - with open('photo.jpg', 'wb') as fd: - async for chunk in client.iter_download(media): - fd.write(chunk) + # Fetching only the header of a file (32 bytes) + # You should manually close the iterator in this case. + # + # "stream" is a common name for asynchronous generators, + # and iter_download will yield `bytes` (chunks of the file). + stream = client.iter_download(media, request_size=32) + header = await stream.__anext__() # "manual" version of `async for` + await stream.close() + assert len(header) == 32 + """ + return self._iter_download( + file, + offset=offset, + stride=stride, + limit=limit, + chunk_size=chunk_size, + request_size=request_size, + file_size=file_size, + dc_id=dc_id, + ) - # Fetching only the header of a file (32 bytes) - # You should manually close the iterator in this case. - # - # "stream" is a common name for asynchronous generators, - # and iter_download will yield `bytes` (chunks of the file). - stream = client.iter_download(media, request_size=32) - header = await stream.__anext__() # "manual" version of `async for` - await stream.close() - assert len(header) == 32 - """ - return self._iter_download( - file, - offset=offset, - stride=stride, - limit=limit, - chunk_size=chunk_size, - request_size=request_size, - file_size=file_size, - dc_id=dc_id, +def _iter_download( + self: 'TelegramClient', + file: 'hints.FileLike', + *, + offset: int = 0, + stride: int = None, + limit: int = None, + chunk_size: int = None, + request_size: int = MAX_CHUNK_SIZE, + file_size: int = None, + dc_id: int = None, + msg_data: tuple = None +): + info = utils._get_file_info(file) + if info.dc_id is not None: + dc_id = info.dc_id + + if file_size is None: + file_size = info.size + + file = info.location + + if chunk_size is None: + chunk_size = request_size + + if limit is None and file_size is not None: + limit = (file_size + chunk_size - 1) // chunk_size + + if stride is None: + stride = chunk_size + elif stride < chunk_size: + raise ValueError('stride must be >= chunk_size') + + request_size -= request_size % MIN_CHUNK_SIZE + if request_size < MIN_CHUNK_SIZE: + request_size = MIN_CHUNK_SIZE + elif request_size > MAX_CHUNK_SIZE: + request_size = MAX_CHUNK_SIZE + + if chunk_size == request_size \ + and offset % MIN_CHUNK_SIZE == 0 \ + and stride % MIN_CHUNK_SIZE == 0 \ + and (limit is None or offset % limit == 0): + cls = _DirectDownloadIter + self._log[__name__].info('Starting direct file download in chunks of ' + '%d at %d, stride %d', request_size, offset, stride) + else: + cls = _GenericDownloadIter + self._log[__name__].info('Starting indirect file download in chunks of ' + '%d at %d, stride %d', request_size, offset, stride) + + return cls( + self, + limit, + file=file, + dc_id=dc_id, + offset=offset, + stride=stride, + chunk_size=chunk_size, + request_size=request_size, + file_size=file_size, + msg_data=msg_data, + ) + + +def _get_thumb(thumbs, thumb): + # Seems Telegram has changed the order and put `PhotoStrippedSize` + # last while this is the smallest (layer 116). Ensure we have the + # sizes sorted correctly with a custom function. + def sort_thumbs(thumb): + if isinstance(thumb, types.PhotoStrippedSize): + return 1, len(thumb.bytes) + if isinstance(thumb, types.PhotoCachedSize): + return 1, len(thumb.bytes) + if isinstance(thumb, types.PhotoSize): + return 1, thumb.size + if isinstance(thumb, types.PhotoSizeProgressive): + return 1, max(thumb.sizes) + if isinstance(thumb, types.VideoSize): + return 2, thumb.size + + # Empty size or invalid should go last + return 0, 0 + + thumbs = list(sorted(thumbs, key=sort_thumbs)) + + for i in reversed(range(len(thumbs))): + # :tl:`PhotoPathSize` is used for animated stickers preview, and the thumb is actually + # a SVG path of the outline. Users expect thumbnails to be JPEG files, so pretend this + # thumb size doesn't actually exist (#1655). + if isinstance(thumbs[i], types.PhotoPathSize): + thumbs.pop(i) + + if thumb is None: + return thumbs[-1] + elif isinstance(thumb, int): + return thumbs[thumb] + elif isinstance(thumb, str): + return next((t for t in thumbs if t.type == thumb), None) + elif isinstance(thumb, (types.PhotoSize, types.PhotoCachedSize, + types.PhotoStrippedSize, types.VideoSize)): + return thumb + else: + return None + +def _download_cached_photo_size(self: 'TelegramClient', size, file): + # No need to download anything, simply write the bytes + if isinstance(size, types.PhotoStrippedSize): + data = utils.stripped_photo_to_jpg(size.bytes) + else: + data = size.bytes + + if file is bytes: + return data + elif isinstance(file, str): + helpers.ensure_parent_dir_exists(file) + f = open(file, 'wb') + else: + f = file + + try: + f.write(data) + finally: + if isinstance(file, str): + f.close() + return file + +async def _download_photo(self: 'TelegramClient', photo, file, date, thumb, 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 not isinstance(photo, types.Photo): + return + + # Include video sizes here (but they may be None so provide an empty list) + size = self._get_thumb(photo.sizes + (photo.video_sizes or []), thumb) + if not size or isinstance(size, types.PhotoSizeEmpty): + return + + if isinstance(size, types.VideoSize): + file = self._get_proper_filename(file, 'video', '.mp4', date=date) + else: + file = self._get_proper_filename(file, 'photo', '.jpg', date=date) + + if isinstance(size, (types.PhotoCachedSize, types.PhotoStrippedSize)): + return self._download_cached_photo_size(size, file) + + if isinstance(size, types.PhotoSizeProgressive): + file_size = max(size.sizes) + else: + file_size = size.size + + result = await self.download_file( + types.InputPhotoFileLocation( + id=photo.id, + access_hash=photo.access_hash, + file_reference=photo.file_reference, + thumb_size=size.type + ), + file, + file_size=file_size, + progress_callback=progress_callback + ) + return result if file is bytes else file + +def _get_kind_and_names(attributes): + """Gets kind and possible names for :tl:`DocumentAttribute`.""" + kind = 'document' + possible_names = [] + for attr in 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' + + return kind, possible_names + +async def _download_document( + self, document, file, date, thumb, progress_callback, msg_data): + """Specialized version of .download_media() for documents.""" + if isinstance(document, types.MessageMediaDocument): + document = document.document + if not isinstance(document, types.Document): + return + + if thumb is None: + kind, possible_names = self._get_kind_and_names(document.attributes) + file = self._get_proper_filename( + file, kind, utils.get_extension(document), + date=date, possible_names=possible_names ) - - def _iter_download( - self: 'TelegramClient', - file: 'hints.FileLike', - *, - offset: int = 0, - stride: int = None, - limit: int = None, - chunk_size: int = None, - request_size: int = MAX_CHUNK_SIZE, - file_size: int = None, - dc_id: int = None, - msg_data: tuple = None - ): - info = utils._get_file_info(file) - if info.dc_id is not None: - dc_id = info.dc_id - - if file_size is None: - file_size = info.size - - file = info.location - - if chunk_size is None: - chunk_size = request_size - - if limit is None and file_size is not None: - limit = (file_size + chunk_size - 1) // chunk_size - - if stride is None: - stride = chunk_size - elif stride < chunk_size: - raise ValueError('stride must be >= chunk_size') - - request_size -= request_size % MIN_CHUNK_SIZE - if request_size < MIN_CHUNK_SIZE: - request_size = MIN_CHUNK_SIZE - elif request_size > MAX_CHUNK_SIZE: - request_size = MAX_CHUNK_SIZE - - if chunk_size == request_size \ - and offset % MIN_CHUNK_SIZE == 0 \ - and stride % MIN_CHUNK_SIZE == 0 \ - and (limit is None or offset % limit == 0): - cls = _DirectDownloadIter - self._log[__name__].info('Starting direct file download in chunks of ' - '%d at %d, stride %d', request_size, offset, stride) - else: - cls = _GenericDownloadIter - self._log[__name__].info('Starting indirect file download in chunks of ' - '%d at %d, stride %d', request_size, offset, stride) - - return cls( - self, - limit, - file=file, - dc_id=dc_id, - offset=offset, - stride=stride, - chunk_size=chunk_size, - request_size=request_size, - file_size=file_size, - msg_data=msg_data, - ) - - # endregion - - # region Private methods - - @staticmethod - def _get_thumb(thumbs, thumb): - # Seems Telegram has changed the order and put `PhotoStrippedSize` - # last while this is the smallest (layer 116). Ensure we have the - # sizes sorted correctly with a custom function. - def sort_thumbs(thumb): - if isinstance(thumb, types.PhotoStrippedSize): - return 1, len(thumb.bytes) - if isinstance(thumb, types.PhotoCachedSize): - return 1, len(thumb.bytes) - if isinstance(thumb, types.PhotoSize): - return 1, thumb.size - if isinstance(thumb, types.PhotoSizeProgressive): - return 1, max(thumb.sizes) - if isinstance(thumb, types.VideoSize): - return 2, thumb.size - - # Empty size or invalid should go last - return 0, 0 - - thumbs = list(sorted(thumbs, key=sort_thumbs)) - - for i in reversed(range(len(thumbs))): - # :tl:`PhotoPathSize` is used for animated stickers preview, and the thumb is actually - # a SVG path of the outline. Users expect thumbnails to be JPEG files, so pretend this - # thumb size doesn't actually exist (#1655). - if isinstance(thumbs[i], types.PhotoPathSize): - thumbs.pop(i) - - if thumb is None: - return thumbs[-1] - elif isinstance(thumb, int): - return thumbs[thumb] - elif isinstance(thumb, str): - return next((t for t in thumbs if t.type == thumb), None) - elif isinstance(thumb, (types.PhotoSize, types.PhotoCachedSize, - types.PhotoStrippedSize, types.VideoSize)): - return thumb - else: - return None - - def _download_cached_photo_size(self: 'TelegramClient', size, file): - # No need to download anything, simply write the bytes - if isinstance(size, types.PhotoStrippedSize): - data = utils.stripped_photo_to_jpg(size.bytes) - else: - data = size.bytes - - if file is bytes: - return data - elif isinstance(file, str): - helpers.ensure_parent_dir_exists(file) - f = open(file, 'wb') - else: - f = file - - try: - f.write(data) - finally: - if isinstance(file, str): - f.close() - return file - - async def _download_photo(self: 'TelegramClient', photo, file, date, thumb, 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 not isinstance(photo, types.Photo): - return - - # Include video sizes here (but they may be None so provide an empty list) - size = self._get_thumb(photo.sizes + (photo.video_sizes or []), thumb) - if not size or isinstance(size, types.PhotoSizeEmpty): - return - - if isinstance(size, types.VideoSize): - file = self._get_proper_filename(file, 'video', '.mp4', date=date) - else: - file = self._get_proper_filename(file, 'photo', '.jpg', date=date) - + size = None + else: + file = self._get_proper_filename(file, 'photo', '.jpg', date=date) + size = self._get_thumb(document.thumbs, thumb) if isinstance(size, (types.PhotoCachedSize, types.PhotoStrippedSize)): return self._download_cached_photo_size(size, file) - if isinstance(size, types.PhotoSizeProgressive): - file_size = max(size.sizes) - else: - file_size = size.size + result = await self._download_file( + types.InputDocumentFileLocation( + id=document.id, + access_hash=document.access_hash, + file_reference=document.file_reference, + thumb_size=size.type if size else '' + ), + file, + file_size=size.size if size else document.size, + progress_callback=progress_callback, + msg_data=msg_data, + ) - result = await self.download_file( - types.InputPhotoFileLocation( - id=photo.id, - access_hash=photo.access_hash, - file_reference=photo.file_reference, - thumb_size=size.type - ), - file, - file_size=file_size, - progress_callback=progress_callback + return result if file is bytes else file + +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 + + # Remove these pesky characters + first_name = first_name.replace(';', '') + last_name = (last_name or '').replace(';', '') + result = ( + 'BEGIN:VCARD\n' + 'VERSION:4.0\n' + 'N:{f};{l};;;\n' + 'FN:{f} {l}\n' + 'TEL;TYPE=cell;VALUE=uri:tel:+{p}\n' + 'END:VCARD\n' + ).format(f=first_name, l=last_name, p=phone_number).encode('utf-8') + + if file is bytes: + return result + elif isinstance(file, str): + file = cls._get_proper_filename( + file, 'contact', '.vcard', + possible_names=[first_name, phone_number, last_name] ) - return result if file is bytes else file + f = open(file, 'wb') + else: + f = file - @staticmethod - def _get_kind_and_names(attributes): - """Gets kind and possible names for :tl:`DocumentAttribute`.""" - kind = 'document' - possible_names = [] - for attr in attributes: - if isinstance(attr, types.DocumentAttributeFilename): - possible_names.insert(0, attr.file_name) + try: + f.write(result) + finally: + # Only close the stream if we opened it + if isinstance(file, str): + f.close() - 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' + return file - return kind, possible_names - - async def _download_document( - self, document, file, date, thumb, progress_callback, msg_data): - """Specialized version of .download_media() for documents.""" - if isinstance(document, types.MessageMediaDocument): - document = document.document - if not isinstance(document, types.Document): - return - - if thumb is None: - kind, possible_names = self._get_kind_and_names(document.attributes) - file = self._get_proper_filename( - file, kind, utils.get_extension(document), - date=date, possible_names=possible_names - ) - size = None - else: - file = self._get_proper_filename(file, 'photo', '.jpg', date=date) - size = self._get_thumb(document.thumbs, thumb) - if isinstance(size, (types.PhotoCachedSize, types.PhotoStrippedSize)): - return self._download_cached_photo_size(size, file) - - result = await self._download_file( - types.InputDocumentFileLocation( - id=document.id, - access_hash=document.access_hash, - file_reference=document.file_reference, - thumb_size=size.type if size else '' - ), - file, - file_size=size.size if size else document.size, - progress_callback=progress_callback, - msg_data=msg_data, +async def _download_web_document(cls, web, file, progress_callback): + """ + Specialized version of .download_media() for web documents. + """ + if not aiohttp: + raise ValueError( + 'Cannot download web documents without the aiohttp ' + 'dependency install it (pip install aiohttp)' ) - return result if file is bytes else file + # TODO Better way to get opened handles of files and auto-close + in_memory = file is bytes + if in_memory: + f = io.BytesIO() + elif isinstance(file, str): + kind, possible_names = cls._get_kind_and_names(web.attributes) + file = cls._get_proper_filename( + file, kind, utils.get_extension(web), + possible_names=possible_names + ) + f = open(file, 'wb') + else: + f = 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 + try: + with aiohttp.ClientSession() as session: + # TODO Use progress_callback; get content length from response + # https://github.com/telegramdesktop/tdesktop/blob/c7e773dd9aeba94e2be48c032edc9a78bb50234e/Telegram/SourceFiles/ui/images.cpp#L1318-L1319 + async with session.get(web.url) as response: + while True: + chunk = await response.content.read(128 * 1024) + if not chunk: + break + f.write(chunk) + finally: + if isinstance(file, str) or file is bytes: + f.close() - # Remove these pesky characters - first_name = first_name.replace(';', '') - last_name = (last_name or '').replace(';', '') - result = ( - 'BEGIN:VCARD\n' - 'VERSION:4.0\n' - 'N:{f};{l};;;\n' - 'FN:{f} {l}\n' - 'TEL;TYPE=cell;VALUE=uri:tel:+{p}\n' - 'END:VCARD\n' - ).format(f=first_name, l=last_name, p=phone_number).encode('utf-8') + return f.getvalue() if in_memory else file - if file is bytes: - return result - elif isinstance(file, str): - file = cls._get_proper_filename( - file, 'contact', '.vcard', - possible_names=[first_name, phone_number, last_name] - ) - f = open(file, 'wb') - else: - f = file +def _get_proper_filename(file, kind, extension, + date=None, possible_names=None): + """Gets a proper filename for 'file', if this is a path. - try: - f.write(result) - finally: - # Only close the stream if we opened it - if isinstance(file, str): - f.close() + '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 isinstance(file, pathlib.Path): + file = str(file.absolute()) + + if file is not None and not isinstance(file, str): + # Probably a stream-like object, we cannot set a filename here return file - @classmethod - async def _download_web_document(cls, web, file, progress_callback): - """ - Specialized version of .download_media() for web documents. - """ - if not aiohttp: - raise ValueError( - 'Cannot download web documents without the aiohttp ' - 'dependency install it (pip install aiohttp)' - ) - - # TODO Better way to get opened handles of files and auto-close - in_memory = file is bytes - if in_memory: - f = io.BytesIO() - elif isinstance(file, str): - kind, possible_names = cls._get_kind_and_names(web.attributes) - file = cls._get_proper_filename( - file, kind, utils.get_extension(web), - possible_names=possible_names - ) - f = open(file, 'wb') - else: - f = 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: - with aiohttp.ClientSession() as session: - # TODO Use progress_callback; get content length from response - # https://github.com/telegramdesktop/tdesktop/blob/c7e773dd9aeba94e2be48c032edc9a78bb50234e/Telegram/SourceFiles/ui/images.cpp#L1318-L1319 - async with session.get(web.url) as response: - while True: - chunk = await response.content.read(128 * 1024) - if not chunk: - break - f.write(chunk) - finally: - if isinstance(file, str) or file is bytes: - f.close() + name = None if possible_names is None else next( + x for x in possible_names if x + ) + except StopIteration: + name = None - return f.getvalue() if in_memory else file + 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) - @staticmethod - def _get_proper_filename(file, kind, extension, - date=None, possible_names=None): - """Gets a proper filename for 'file', if this is a path. + directory, name = os.path.split(file) + name, ext = os.path.splitext(name) + if not ext: + ext = extension - '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 + result = os.path.join(directory, name + ext) + if not os.path.isfile(result): + return result - 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 isinstance(file, pathlib.Path): - file = str(file.absolute()) - - 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) + i = 1 + while True: + result = os.path.join(directory, '{} ({}){}'.format(name, i, 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 + i += 1 diff --git a/telethon/client/messageparse.py b/telethon/client/messageparse.py index 322c541e..72b121a9 100644 --- a/telethon/client/messageparse.py +++ b/telethon/client/messageparse.py @@ -9,220 +9,212 @@ if typing.TYPE_CHECKING: from .telegramclient import TelegramClient -class MessageParseMethods: +def get_parse_mode(self: 'TelegramClient'): + """ + 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. - # region Public properties + When setting a different value it should be one of: - @property - def parse_mode(self: 'TelegramClient'): - """ - 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. + * 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. - When setting a different value it should be one of: + 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])``. - * 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 ``unparse`` method should be the inverse of ``parse`` such + that ``assert text == unparse(*parse(text))``. - 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])``. + See :tl:`MessageEntity` for allowed message entities. - The ``unparse`` method should be the inverse of ``parse`` such - that ``assert text == unparse(*parse(text))``. + Example + .. code-block:: python - See :tl:`MessageEntity` for allowed message entities. + # Disabling default formatting + client.parse_mode = None - Example - .. code-block:: python + # Enabling HTML as the default format + client.parse_mode = 'html' + """ + return self._parse_mode - # Disabling default formatting - client.parse_mode = None +def set_parse_mode(self: 'TelegramClient', mode: str): + self._parse_mode = utils.sanitize_parse_mode(mode) - # Enabling HTML as the default format - client.parse_mode = 'html' - """ - return self._parse_mode +# endregion - @parse_mode.setter - def parse_mode(self: 'TelegramClient', mode: str): - self._parse_mode = utils.sanitize_parse_mode(mode) +# region Private methods - # endregion +async def _replace_with_mention(self: 'TelegramClient', entities, i, user): + """ + Helper method to replace ``entities[i]`` to mention ``user``, + or do nothing if it can't be found. + """ + try: + entities[i] = types.InputMessageEntityMentionName( + entities[i].offset, entities[i].length, + await self.get_input_entity(user) + ) + return True + except (ValueError, TypeError): + return False - # region Private methods +async def _parse_message_text(self: 'TelegramClient', message, parse_mode): + """ + Returns a (parsed message, entities) tuple depending on ``parse_mode``. + """ + if parse_mode == (): + parse_mode = self._parse_mode + else: + parse_mode = utils.sanitize_parse_mode(parse_mode) - async def _replace_with_mention(self: 'TelegramClient', entities, i, user): - """ - Helper method to replace ``entities[i]`` to mention ``user``, - or do nothing if it can't be found. - """ - try: - entities[i] = types.InputMessageEntityMentionName( - entities[i].offset, entities[i].length, - await self.get_input_entity(user) - ) - return True - except (ValueError, TypeError): - return False + if not parse_mode: + return message, [] - async def _parse_message_text(self: 'TelegramClient', message, parse_mode): - """ - Returns a (parsed message, entities) tuple depending on ``parse_mode``. - """ - if parse_mode == (): - parse_mode = self._parse_mode - else: - parse_mode = utils.sanitize_parse_mode(parse_mode) + original_message = message + message, msg_entities = parse_mode.parse(message) + if original_message and not message and not msg_entities: + raise ValueError("Failed to parse message") - if not parse_mode: - return message, [] - - original_message = message - message, msg_entities = parse_mode.parse(message) - if original_message and not message and not msg_entities: - raise ValueError("Failed to parse message") - - for i in reversed(range(len(msg_entities))): - e = msg_entities[i] - if isinstance(e, types.MessageEntityTextUrl): - m = re.match(r'^@|\+|tg://user\?id=(\d+)', e.url) - if m: - user = int(m.group(1)) if m.group(1) else e.url - is_mention = await self._replace_with_mention(msg_entities, i, user) - if not is_mention: - del msg_entities[i] - elif isinstance(e, (types.MessageEntityMentionName, - types.InputMessageEntityMentionName)): - is_mention = await self._replace_with_mention(msg_entities, i, e.user_id) + for i in reversed(range(len(msg_entities))): + e = msg_entities[i] + if isinstance(e, types.MessageEntityTextUrl): + m = re.match(r'^@|\+|tg://user\?id=(\d+)', e.url) + if m: + user = int(m.group(1)) if m.group(1) else e.url + is_mention = await self._replace_with_mention(msg_entities, i, user) if not is_mention: del msg_entities[i] + elif isinstance(e, (types.MessageEntityMentionName, + types.InputMessageEntityMentionName)): + is_mention = await self._replace_with_mention(msg_entities, i, e.user_id) + if not is_mention: + del msg_entities[i] - return message, msg_entities + return message, msg_entities - def _get_response_message(self: 'TelegramClient', 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. +def _get_response_message(self: 'TelegramClient', 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. - If ``request is None`` this method returns ``{id: message}``. + If ``request is None`` this method returns ``{id: message}``. - If ``request.random_id`` is a list, this method returns a list too. - """ - 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 None + If ``request.random_id`` is a list, this method returns a list too. + """ + 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 None - random_to_id = {} - id_to_message = {} - for update in updates: - if isinstance(update, types.UpdateMessageID): - random_to_id[update.random_id] = update.id + random_to_id = {} + id_to_message = {} + for update in updates: + if isinstance(update, types.UpdateMessageID): + random_to_id[update.random_id] = update.id - elif isinstance(update, ( - types.UpdateNewChannelMessage, types.UpdateNewMessage)): - update.message._finish_init(self, entities, input_chat) + elif isinstance(update, ( + types.UpdateNewChannelMessage, types.UpdateNewMessage)): + update.message._finish_init(self, entities, input_chat) - # Pinning a message with `updatePinnedMessage` seems to - # always produce a service message we can't map so return - # it directly. The same happens for kicking users. - # - # It could also be a list (e.g. when sending albums). - # - # TODO this method is getting messier and messier as time goes on - if hasattr(request, 'random_id') or utils.is_list_like(request): - id_to_message[update.message.id] = update.message - else: - return update.message - - elif (isinstance(update, types.UpdateEditMessage) - and helpers._entity_type(request.peer) != helpers._EntityType.CHANNEL): - update.message._finish_init(self, entities, input_chat) - - # Live locations use `sendMedia` but Telegram responds with - # `updateEditMessage`, which means we won't have `id` field. - if hasattr(request, 'random_id'): - id_to_message[update.message.id] = update.message - elif request.id == update.message.id: - return update.message - - elif (isinstance(update, types.UpdateEditChannelMessage) - and utils.get_peer_id(request.peer) == - utils.get_peer_id(update.message.peer_id)): - if request.id == update.message.id: - update.message._finish_init(self, entities, input_chat) - return update.message - - elif isinstance(update, types.UpdateNewScheduledMessage): - update.message._finish_init(self, entities, input_chat) - # Scheduled IDs may collide with normal IDs. However, for a - # single request there *shouldn't* be a mix between "some - # scheduled and some not". - id_to_message[update.message.id] = update.message - - elif isinstance(update, types.UpdateMessagePoll): - if request.media.poll.id == update.poll_id: - m = types.Message( - id=request.id, - peer_id=utils.get_peer(request.peer), - media=types.MessageMediaPoll( - poll=update.poll, - results=update.results - ) - ) - m._finish_init(self, entities, input_chat) - return m - - if request is None: - return id_to_message - - random_id = request if isinstance(request, (int, list)) else getattr(request, 'random_id', None) - if random_id is None: - # Can happen when pinning a message does not actually produce a service message. - self._log[__name__].warning( - 'No random_id in %s to map to, returning None message for %s', request, result) - return None - - if not utils.is_list_like(random_id): - msg = id_to_message.get(random_to_id.get(random_id)) - - if not msg: - self._log[__name__].warning( - 'Request %s had missing message mapping %s', request, result) - - return msg - - try: - return [id_to_message[random_to_id[rnd]] for rnd in random_id] - except KeyError: - # Sometimes forwards fail (`MESSAGE_ID_INVALID` if a message gets - # deleted or `WORKER_BUSY_TOO_LONG_RETRY` if there are issues at - # Telegram), in which case we get some "missing" message mappings. - # Log them with the hope that we can better work around them. + # Pinning a message with `updatePinnedMessage` seems to + # always produce a service message we can't map so return + # it directly. The same happens for kicking users. # - # This also happens when trying to forward messages that can't - # be forwarded because they don't exist (0, service, deleted) - # among others which could be (like deleted or existing). + # It could also be a list (e.g. when sending albums). + # + # TODO this method is getting messier and messier as time goes on + if hasattr(request, 'random_id') or utils.is_list_like(request): + id_to_message[update.message.id] = update.message + else: + return update.message + + elif (isinstance(update, types.UpdateEditMessage) + and helpers._entity_type(request.peer) != helpers._EntityType.CHANNEL): + update.message._finish_init(self, entities, input_chat) + + # Live locations use `sendMedia` but Telegram responds with + # `updateEditMessage`, which means we won't have `id` field. + if hasattr(request, 'random_id'): + id_to_message[update.message.id] = update.message + elif request.id == update.message.id: + return update.message + + elif (isinstance(update, types.UpdateEditChannelMessage) + and utils.get_peer_id(request.peer) == + utils.get_peer_id(update.message.peer_id)): + if request.id == update.message.id: + update.message._finish_init(self, entities, input_chat) + return update.message + + elif isinstance(update, types.UpdateNewScheduledMessage): + update.message._finish_init(self, entities, input_chat) + # Scheduled IDs may collide with normal IDs. However, for a + # single request there *shouldn't* be a mix between "some + # scheduled and some not". + id_to_message[update.message.id] = update.message + + elif isinstance(update, types.UpdateMessagePoll): + if request.media.poll.id == update.poll_id: + m = types.Message( + id=request.id, + peer_id=utils.get_peer(request.peer), + media=types.MessageMediaPoll( + poll=update.poll, + results=update.results + ) + ) + m._finish_init(self, entities, input_chat) + return m + + if request is None: + return id_to_message + + random_id = request if isinstance(request, (int, list)) else getattr(request, 'random_id', None) + if random_id is None: + # Can happen when pinning a message does not actually produce a service message. + self._log[__name__].warning( + 'No random_id in %s to map to, returning None message for %s', request, result) + return None + + if not utils.is_list_like(random_id): + msg = id_to_message.get(random_to_id.get(random_id)) + + if not msg: self._log[__name__].warning( - 'Request %s had missing message mappings %s', request, result) + 'Request %s had missing message mapping %s', request, result) - return [ - id_to_message.get(random_to_id[rnd]) - if rnd in random_to_id - else None - for rnd in random_id - ] + return msg - # endregion + try: + return [id_to_message[random_to_id[rnd]] for rnd in random_id] + except KeyError: + # Sometimes forwards fail (`MESSAGE_ID_INVALID` if a message gets + # deleted or `WORKER_BUSY_TOO_LONG_RETRY` if there are issues at + # Telegram), in which case we get some "missing" message mappings. + # Log them with the hope that we can better work around them. + # + # This also happens when trying to forward messages that can't + # be forwarded because they don't exist (0, service, deleted) + # among others which could be (like deleted or existing). + self._log[__name__].warning( + 'Request %s had missing message mappings %s', request, result) + + return [ + id_to_message.get(random_to_id[rnd]) + if rnd in random_to_id + else None + for rnd in random_id + ] diff --git a/telethon/client/messages.py b/telethon/client/messages.py index 01011b58..1dde08ec 100644 --- a/telethon/client/messages.py +++ b/telethon/client/messages.py @@ -320,1129 +320,432 @@ class _IDsIter(RequestIter): self.buffer.append(message) -class MessageMethods: +def iter_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = None, + *, + offset_date: 'hints.DateLike' = None, + offset_id: int = 0, + max_id: int = 0, + min_id: int = 0, + add_offset: int = 0, + search: str = None, + filter: 'typing.Union[types.TypeMessagesFilter, typing.Type[types.TypeMessagesFilter]]' = None, + from_user: 'hints.EntityLike' = None, + wait_time: float = None, + ids: 'typing.Union[int, typing.Sequence[int]]' = None, + reverse: bool = False, + reply_to: int = None, + scheduled: bool = False +) -> 'typing.Union[_MessagesIter, _IDsIter]': + if ids is not None: + if not utils.is_list_like(ids): + ids = [ids] - # region Public methods - - # region Message retrieval - - def iter_messages( - self: 'TelegramClient', - entity: 'hints.EntityLike', - limit: float = None, - *, - offset_date: 'hints.DateLike' = None, - offset_id: int = 0, - max_id: int = 0, - min_id: int = 0, - add_offset: int = 0, - search: str = None, - filter: 'typing.Union[types.TypeMessagesFilter, typing.Type[types.TypeMessagesFilter]]' = None, - from_user: 'hints.EntityLike' = None, - wait_time: float = None, - ids: 'typing.Union[int, typing.Sequence[int]]' = None, - reverse: bool = False, - reply_to: int = None, - scheduled: bool = False - ) -> 'typing.Union[_MessagesIter, _IDsIter]': - """ - Iterator over the messages for the given chat. - - The default order is from newest to oldest, but this - behaviour can be changed with the `reverse` parameter. - - If either `search`, `filter` or `from_user` are provided, - :tl:`messages.Search` will be used instead of :tl:`messages.getHistory`. - - .. note:: - - Telegram's flood wait limit for :tl:`GetHistoryRequest` seems to - be around 30 seconds per 10 requests, therefore a sleep of 1 - second is the default for this limit (or above). - - Arguments - entity (`entity`): - The entity from whom to retrieve the message history. - - It may be `None` to perform a global search, or - to get messages by their ID from no particular chat. - Note that some of the offsets will not work if this - is the case. - - Note that if you want to perform a global search, - you **must** set a non-empty `search` string, a `filter`. - or `from_user`. - - 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 entity will be returned. - - wait_time (`int`): - Wait time (in seconds) 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. - - If the ``ids`` parameter is used, this time will default - to 10 seconds only if the amount of IDs is higher than 300. - - 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. - - .. note:: - - At the time of writing, Telegram will **not** return - :tl:`MessageEmpty` for :tl:`InputMessageReplyTo` IDs that - failed (i.e. the message is not replying to any, or is - replying to a deleted message). This means that it is - **not** possible to match messages one-by-one, so be - careful if you use non-integers in this parameter. - - reverse (`bool`, optional): - If set to `True`, the messages will be returned in reverse - order (from oldest to newest, instead of the default newest - to oldest). This also means that the meaning of `offset_id` - and `offset_date` parameters is reversed, although they will - still be exclusive. `min_id` becomes equivalent to `offset_id` - instead of being `max_id` as well since messages are returned - in ascending order. - - You cannot use this if both `entity` and `ids` are `None`. - - reply_to (`int`, optional): - If set to a message ID, the messages that reply to this ID - will be returned. This feature is also known as comments in - posts of broadcast channels, or viewing threads in groups. - - This feature can only be used in broadcast channels and their - linked megagroups. Using it in a chat or private conversation - will result in ``telethon.errors.PeerIdInvalidError`` to occur. - - When using this parameter, the ``filter`` and ``search`` - parameters have no effect, since Telegram's API doesn't - support searching messages in replies. - - .. note:: - - This feature is used to get replies to a message in the - *discussion* group. If the same broadcast channel sends - a message and replies to it itself, that reply will not - be included in the results. - - scheduled (`bool`, optional): - If set to `True`, messages which are scheduled will be returned. - All other parameter will be ignored for this, except `entity`. - - Yields - Instances of `Message `. - - Example - .. code-block:: python - - # From most-recent to oldest - async for message in client.iter_messages(chat): - print(message.id, message.text) - - # From oldest to most-recent - async for message in client.iter_messages(chat, reverse=True): - print(message.id, message.text) - - # Filter by sender - async for message in client.iter_messages(chat, from_user='me'): - print(message.text) - - # Server-side search with fuzzy text - async for message in client.iter_messages(chat, search='hello'): - print(message.id) - - # Filter by message type: - from telethon.tl.types import InputMessagesFilterPhotos - async for message in client.iter_messages(chat, filter=InputMessagesFilterPhotos): - print(message.photo) - - # Getting comments from a post in a channel: - async for message in client.iter_messages(channel, reply_to=123): - print(message.chat.title, message.text) - """ - if ids is not None: - if not utils.is_list_like(ids): - ids = [ids] - - return _IDsIter( - client=self, - reverse=reverse, - wait_time=wait_time, - limit=len(ids), - entity=entity, - ids=ids - ) - - return _MessagesIter( + return _IDsIter( client=self, reverse=reverse, wait_time=wait_time, - limit=limit, + limit=len(ids), entity=entity, - offset_id=offset_id, - min_id=min_id, - max_id=max_id, - from_user=from_user, - offset_date=offset_date, - add_offset=add_offset, - filter=filter, - search=search, - reply_to=reply_to, - scheduled=scheduled + ids=ids ) - async def get_messages(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': - """ - Same as `iter_messages()`, but returns a - `TotalList ` instead. + return _MessagesIter( + client=self, + reverse=reverse, + wait_time=wait_time, + limit=limit, + entity=entity, + offset_id=offset_id, + min_id=min_id, + max_id=max_id, + from_user=from_user, + offset_date=offset_date, + add_offset=add_offset, + filter=filter, + search=search, + reply_to=reply_to, + scheduled=scheduled + ) - 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 `Message ` will be - returned for convenience instead of a list. - - Example - .. code-block:: python - - # Get 0 photos and print the total to show how many photos there are - from telethon.tl.types import InputMessagesFilterPhotos - photos = await client.get_messages(chat, 0, filter=InputMessagesFilterPhotos) - print(photos.total) - - # Get all the photos - photos = await client.get_messages(chat, None, filter=InputMessagesFilterPhotos) - - # Get messages by ID: - message_1337 = await client.get_messages(chat, ids=1337) - """ - 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 - - it = self.iter_messages(*args, **kwargs) - - ids = kwargs.get('ids') - if ids and not utils.is_list_like(ids): - async for message in it: - return message - else: - # Iterator exhausted = empty, to handle InputMessageReplyTo - return None - - return await it.collect() - - get_messages.__signature__ = inspect.signature(iter_messages) - - # endregion - - # region Message sending/editing/deleting - - async def _get_comment_data( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message: 'typing.Union[int, types.Message]' - ): - r = await self(functions.messages.GetDiscussionMessageRequest( - peer=entity, - msg_id=utils.get_message_id(message) - )) - m = r.messages[0] - chat = next(c for c in r.chats if c.id == m.peer_id.channel_id) - return utils.get_input_peer(chat), m.id - - async def send_message( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message: 'hints.MessageLike' = '', - *, - reply_to: 'typing.Union[int, types.Message]' = None, - attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, - parse_mode: typing.Optional[str] = (), - formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, - link_preview: bool = True, - file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]' = None, - thumb: 'hints.FileLike' = None, - force_document: bool = False, - clear_draft: bool = False, - buttons: 'hints.MarkupLike' = None, - silent: bool = None, - background: bool = None, - supports_streaming: bool = False, - schedule: 'hints.DateLike' = None, - comment_to: 'typing.Union[int, types.Message]' = None - ) -> 'types.Message': - """ - Sends a message to the specified user, chat or 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. - - See also `Message.respond() ` - and `Message.reply() `. - - Arguments - entity (`entity`): - To who will it be sent. - - message (`str` | `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` | `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. - - attributes (`list`, optional): - Optional attributes that override the inferred ones, like - :tl:`DocumentAttributeFilename` and so on. - - parse_mode (`object`, optional): - See the `TelegramClient.parse_mode - ` - property for allowed values. Markdown parsing will be used by - default. - - formatting_entities (`list`, optional): - A list of message formatting entities. When provided, the ``parse_mode`` is ignored. - - 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. - - thumb (`str` | `bytes` | `file`, optional): - Optional JPEG thumbnail (for documents). **Telegram will - ignore this parameter** unless you pass a ``.jpg`` file! - The file must also be small in dimensions and in disk size. - Successful thumbnails were files below 20kB and 320x320px. - Width/height and dimensions/size ratios may be important. - For Telegram to accept a thumbnail, you must provide the - dimensions of the underlying media through ``attributes=`` - with :tl:`DocumentAttributesVideo` or by installing the - optional ``hachoir`` dependency. - - 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. - - buttons (`list`, `custom.Button `, :tl:`KeyboardButton`): - The matrix (list of lists), row list or button to be shown - after sending the message. This parameter will only work if - you have signed in as a bot. You can also pass your own - :tl:`ReplyMarkup` here. - - All the following limits apply together: - - * There can be 100 buttons at most (any more are ignored). - * There can be 8 buttons per row at most (more are ignored). - * The maximum callback data per button is 64 bytes. - * The maximum data that can be embedded in total is just - over 4KB, shared between inline callback data and text. - - silent (`bool`, optional): - Whether the message should notify people in a broadcast - channel or not. Defaults to `False`, which means it will - notify them. Set it to `True` to alter this behaviour. - - background (`bool`, optional): - Whether the message should be send in background. - - supports_streaming (`bool`, optional): - Whether the sent video supports streaming or not. Note that - Telegram only recognizes as streamable some formats like MP4, - and others like AVI or MKV will not work. You should convert - these to MP4 before sending if you want them to be streamable. - Unsupported formats will result in ``VideoContentTypeError``. - - schedule (`hints.DateLike`, optional): - If set, the message won't send immediately, and instead - it will be scheduled to be automatically sent at a later - time. - - comment_to (`int` | `Message `, optional): - Similar to ``reply_to``, but replies in the linked group of a - broadcast channel instead (effectively leaving a "comment to" - the specified message). - - This parameter takes precedence over ``reply_to``. If there is - no linked chat, `telethon.errors.sgIdInvalidError` is raised. - - Returns - The sent `custom.Message `. - - Example - .. code-block:: python - - # Markdown is the default - await client.send_message('me', 'Hello **world**!') - - # Default to another parse mode - client.parse_mode = 'html' - - await client.send_message('me', 'Some bold and italic text') - await client.send_message('me', 'An URL') - # code and pre tags also work, but those break the documentation :) - await client.send_message('me', 'Mentions') - - # Explicit parse mode - # No parse mode by default - client.parse_mode = None - - # ...but here I want markdown - await client.send_message('me', 'Hello, **world**!', parse_mode='md') - - # ...and here I need HTML - await client.send_message('me', 'Hello, world!', parse_mode='html') - - # If you logged in as a bot account, you can send buttons - from telethon import events, Button - - @client.on(events.CallbackQuery) - async def callback(event): - await event.edit('Thank you for clicking {}!'.format(event.data)) - - # Single inline button - await client.send_message(chat, 'A single button, with "clk1" as data', - buttons=Button.inline('Click me', b'clk1')) - - # Matrix of inline buttons - await client.send_message(chat, 'Pick one from this grid', buttons=[ - [Button.inline('Left'), Button.inline('Right')], - [Button.url('Check this site!', 'https://example.com')] - ]) - - # Reply keyboard - await client.send_message(chat, 'Welcome', buttons=[ - Button.text('Thanks!', resize=True, single_use=True), - Button.request_phone('Send phone'), - Button.request_location('Send location') - ]) - - # Forcing replies or clearing buttons. - await client.send_message(chat, 'Reply to me', buttons=Button.force_reply()) - await client.send_message(chat, 'Bye Keyboard!', buttons=Button.clear()) - - # Scheduling a message to be sent after 5 minutes - from datetime import timedelta - await client.send_message(chat, 'Hi, future!', schedule=timedelta(minutes=5)) - """ - if file is not None: - return await self.send_file( - entity, file, caption=message, reply_to=reply_to, - attributes=attributes, parse_mode=parse_mode, - force_document=force_document, thumb=thumb, - buttons=buttons, clear_draft=clear_draft, silent=silent, - schedule=schedule, supports_streaming=supports_streaming, - formatting_entities=formatting_entities, - comment_to=comment_to, background=background - ) - - entity = await self.get_input_entity(entity) - if comment_to is not None: - entity, reply_to = await self._get_comment_data(entity, comment_to) - - if isinstance(message, types.Message): - if buttons is None: - markup = message.reply_markup - else: - markup = self.build_reply_markup(buttons) - - if silent is None: - silent = message.silent - - if (message.media and not isinstance( - message.media, types.MessageMediaWebPage)): - return await self.send_file( - entity, - message.media, - caption=message.message, - silent=silent, - background=background, - reply_to=reply_to, - buttons=markup, - formatting_entities=message.entities, - schedule=schedule - ) - - request = functions.messages.SendMessageRequest( - peer=entity, - message=message.message or '', - silent=silent, - background=background, - reply_to_msg_id=utils.get_message_id(reply_to), - reply_markup=markup, - entities=message.entities, - clear_draft=clear_draft, - no_webpage=not isinstance( - message.media, types.MessageMediaWebPage), - schedule_date=schedule - ) - message = message.message +async def get_messages(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': + if len(args) == 1 and 'limit' not in kwargs: + if 'min_id' in kwargs and 'max_id' in kwargs: + kwargs['limit'] = None else: - if formatting_entities is None: - message, formatting_entities = await self._parse_message_text(message, parse_mode) - if not message: - raise ValueError( - 'The message cannot be empty unless a file is provided' - ) + kwargs['limit'] = 1 - request = functions.messages.SendMessageRequest( - peer=entity, - message=message, - entities=formatting_entities, - no_webpage=not link_preview, - reply_to_msg_id=utils.get_message_id(reply_to), - clear_draft=clear_draft, - silent=silent, - background=background, - reply_markup=self.build_reply_markup(buttons), - schedule_date=schedule - ) + it = self.iter_messages(*args, **kwargs) - result = await self(request) - if isinstance(result, types.UpdateShortSentMessage): - message = types.Message( - id=result.id, - peer_id=await self._get_peer(entity), - message=message, - date=result.date, - out=result.out, - media=result.media, - entities=result.entities, - reply_markup=request.reply_markup, - ttl_period=result.ttl_period - ) - message._finish_init(self, {}, entity) + ids = kwargs.get('ids') + if ids and not utils.is_list_like(ids): + async for message in it: return message - - return self._get_response_message(request, result, entity) - - async def forward_messages( - self: 'TelegramClient', - entity: 'hints.EntityLike', - messages: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', - from_peer: 'hints.EntityLike' = None, - *, - background: bool = None, - with_my_score: bool = None, - silent: bool = None, - as_album: bool = None, - schedule: 'hints.DateLike' = None - ) -> 'typing.Sequence[types.Message]': - """ - Forwards the given messages to the specified entity. - - If you want to "forward" a message without the forward header - (the "forwarded from" text), you should use `send_message` with - the original message instead. This will send a copy of it. - - See also `Message.forward_to() `. - - Arguments - entity (`entity`): - To which entity the message(s) will be forwarded. - - messages (`list` | `int` | `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. This parameter indicates - the entity from which the messages should be forwarded. - - silent (`bool`, optional): - Whether the message should notify people with sound or not. - Defaults to `False` (send with a notification sound unless - the person has the chat muted). Set it to `True` to alter - this behaviour. - - background (`bool`, optional): - Whether the message should be forwarded in background. - - with_my_score (`bool`, optional): - Whether forwarded should contain your game score. - - as_album (`bool`, optional): - This flag no longer has any effect. - - schedule (`hints.DateLike`, optional): - If set, the message(s) won't forward immediately, and - instead they will be scheduled to be automatically sent - at a later time. - - Returns - The list of forwarded `Message `, - or a single one if a list wasn't provided as input. - - Note that if all messages are invalid (i.e. deleted) the call - will fail with ``MessageIdInvalidError``. If only some are - invalid, the list will have `None` instead of those messages. - - Example - .. code-block:: python - - # a single one - await client.forward_messages(chat, message) - # or - await client.forward_messages(chat, message_id, from_chat) - # or - await message.forward_to(chat) - - # multiple - await client.forward_messages(chat, messages) - # or - await client.forward_messages(chat, message_ids, from_chat) - - # Forwarding as a copy - await client.send_message(chat, message) - """ - if as_album is not None: - warnings.warn('the as_album argument is deprecated and no longer has any effect') - - single = not utils.is_list_like(messages) - if single: - messages = (messages,) - - entity = await self.get_input_entity(entity) - - if from_peer: - from_peer = await self.get_input_entity(from_peer) - from_peer_id = await self.get_peer_id(from_peer) else: - from_peer_id = None + # Iterator exhausted = empty, to handle InputMessageReplyTo + return None - def get_key(m): - if isinstance(m, int): - if from_peer_id is not None: - return from_peer_id + return await it.collect() - raise ValueError('from_peer must be given if integer IDs are used') - elif isinstance(m, types.Message): - return m.chat_id - else: - raise TypeError('Cannot forward messages of type {}'.format(type(m))) - sent = [] - for _chat_id, chunk in itertools.groupby(messages, key=get_key): - chunk = list(chunk) - if isinstance(chunk[0], int): - chat = from_peer - else: - chat = await chunk[0].get_input_chat() - chunk = [m.id for m in chunk] +async def _get_comment_data( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Union[int, types.Message]' +): + r = await self(functions.messages.GetDiscussionMessageRequest( + peer=entity, + msg_id=utils.get_message_id(message) + )) + m = r.messages[0] + chat = next(c for c in r.chats if c.id == m.peer_id.channel_id) + return utils.get_input_peer(chat), m.id - req = functions.messages.ForwardMessagesRequest( - from_peer=chat, - id=chunk, - to_peer=entity, +async def send_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'hints.MessageLike' = '', + *, + reply_to: 'typing.Union[int, types.Message]' = None, + attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, + parse_mode: typing.Optional[str] = (), + formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, + link_preview: bool = True, + file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]' = None, + thumb: 'hints.FileLike' = None, + force_document: bool = False, + clear_draft: bool = False, + buttons: 'hints.MarkupLike' = None, + silent: bool = None, + background: bool = None, + supports_streaming: bool = False, + schedule: 'hints.DateLike' = None, + comment_to: 'typing.Union[int, types.Message]' = None +) -> 'types.Message': + if file is not None: + return await self.send_file( + entity, file, caption=message, reply_to=reply_to, + attributes=attributes, parse_mode=parse_mode, + force_document=force_document, thumb=thumb, + buttons=buttons, clear_draft=clear_draft, silent=silent, + schedule=schedule, supports_streaming=supports_streaming, + formatting_entities=formatting_entities, + comment_to=comment_to, background=background + ) + + entity = await self.get_input_entity(entity) + if comment_to is not None: + entity, reply_to = await self._get_comment_data(entity, comment_to) + + if isinstance(message, types.Message): + if buttons is None: + markup = message.reply_markup + else: + markup = self.build_reply_markup(buttons) + + if silent is None: + silent = message.silent + + if (message.media and not isinstance( + message.media, types.MessageMediaWebPage)): + return await self.send_file( + entity, + message.media, + caption=message.message, silent=silent, background=background, - with_my_score=with_my_score, - schedule_date=schedule + reply_to=reply_to, + buttons=markup, + formatting_entities=message.entities, + schedule=schedule ) - result = await self(req) - sent.extend(self._get_response_message(req, result, entity)) - return sent[0] if single else sent - - async def edit_message( - self: 'TelegramClient', - entity: 'typing.Union[hints.EntityLike, types.Message]', - message: 'hints.MessageLike' = None, - text: str = None, - *, - parse_mode: str = (), - attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, - formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, - link_preview: bool = True, - file: 'hints.FileLike' = None, - thumb: 'hints.FileLike' = None, - force_document: bool = False, - buttons: 'hints.MarkupLike' = None, - supports_streaming: bool = False, - schedule: 'hints.DateLike' = None - ) -> 'types.Message': - """ - Edits the given message to change its text or media. - - See also `Message.edit() `. - - Arguments - entity (`entity` | `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. - - You may also pass a :tl:`InputBotInlineMessageID`, - which is the only way to edit messages that were sent - after the user selects an inline query result. - - message (`int` | `Message ` | `str`): - The ID of the message (or `Message - ` itself) to be edited. - If the `entity` was a `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 `Message `. - - parse_mode (`object`, optional): - See the `TelegramClient.parse_mode - ` - property for allowed values. Markdown parsing will be used by - default. - - attributes (`list`, optional): - Optional attributes that override the inferred ones, like - :tl:`DocumentAttributeFilename` and so on. - - formatting_entities (`list`, optional): - A list of message formatting entities. When provided, the ``parse_mode`` is ignored. - - 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. - - thumb (`str` | `bytes` | `file`, optional): - Optional JPEG thumbnail (for documents). **Telegram will - ignore this parameter** unless you pass a ``.jpg`` file! - The file must also be small in dimensions and in disk size. - Successful thumbnails were files below 20kB and 320x320px. - Width/height and dimensions/size ratios may be important. - For Telegram to accept a thumbnail, you must provide the - dimensions of the underlying media through ``attributes=`` - with :tl:`DocumentAttributesVideo` or by installing the - optional ``hachoir`` dependency. - - force_document (`bool`, optional): - Whether to send the given file as a document or not. - - buttons (`list`, `custom.Button `, :tl:`KeyboardButton`): - The matrix (list of lists), row list or button to be shown - after sending the message. This parameter will only work if - you have signed in as a bot. You can also pass your own - :tl:`ReplyMarkup` here. - - supports_streaming (`bool`, optional): - Whether the sent video supports streaming or not. Note that - Telegram only recognizes as streamable some formats like MP4, - and others like AVI or MKV will not work. You should convert - these to MP4 before sending if you want them to be streamable. - Unsupported formats will result in ``VideoContentTypeError``. - - schedule (`hints.DateLike`, optional): - If set, the message won't be edited immediately, and instead - it will be scheduled to be automatically edited at a later - time. - - Note that this parameter will have no effect if you are - trying to edit a message that was sent via inline bots. - - Returns - The edited `Message `, - unless `entity` was a :tl:`InputBotInlineMessageID` in which - case this method returns a boolean. - - 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. - - ``MessageIdInvalidError`` if the ID of the message is invalid - (the ID itself may be correct, but the message with that ID - cannot be edited). For example, when trying to edit messages - with a reply markup (or clear markup) this error will be raised. - - Example - .. code-block:: python - - message = await client.send_message(chat, 'hello') - - await client.edit_message(chat, message, 'hello!') - # or - await client.edit_message(chat, message.id, 'hello!!') - # or - await client.edit_message(message, 'hello!!!') - """ - if isinstance(entity, types.InputBotInlineMessageID): - text = text or message - message = entity - elif isinstance(entity, types.Message): - text = message # Shift the parameters to the right - message = entity - entity = entity.peer_id - - if formatting_entities is None: - text, formatting_entities = await self._parse_message_text(text, parse_mode) - file_handle, media, image = await self._file_to_media(file, - supports_streaming=supports_streaming, - thumb=thumb, - attributes=attributes, - force_document=force_document) - - if isinstance(entity, types.InputBotInlineMessageID): - request = functions.messages.EditInlineBotMessageRequest( - id=entity, - message=text, - no_webpage=not link_preview, - entities=formatting_entities, - media=media, - reply_markup=self.build_reply_markup(buttons) - ) - # Invoke `messages.editInlineBotMessage` from the right datacenter. - # Otherwise, Telegram will error with `MESSAGE_ID_INVALID` and do nothing. - exported = self.session.dc_id != entity.dc_id - if exported: - try: - sender = await self._borrow_exported_sender(entity.dc_id) - return await self._call(sender, request) - finally: - await self._return_exported_sender(sender) - else: - return await self(request) - - entity = await self.get_input_entity(entity) - request = functions.messages.EditMessageRequest( + request = functions.messages.SendMessageRequest( peer=entity, - id=utils.get_message_id(message), + message=message.message or '', + silent=silent, + background=background, + reply_to_msg_id=utils.get_message_id(reply_to), + reply_markup=markup, + entities=message.entities, + clear_draft=clear_draft, + no_webpage=not isinstance( + message.media, types.MessageMediaWebPage), + schedule_date=schedule + ) + message = message.message + else: + if formatting_entities is None: + message, formatting_entities = await self._parse_message_text(message, parse_mode) + if not message: + raise ValueError( + 'The message cannot be empty unless a file is provided' + ) + + request = functions.messages.SendMessageRequest( + peer=entity, + message=message, + entities=formatting_entities, + no_webpage=not link_preview, + reply_to_msg_id=utils.get_message_id(reply_to), + clear_draft=clear_draft, + silent=silent, + background=background, + reply_markup=self.build_reply_markup(buttons), + schedule_date=schedule + ) + + result = await self(request) + if isinstance(result, types.UpdateShortSentMessage): + message = types.Message( + id=result.id, + peer_id=await self._get_peer(entity), + message=message, + date=result.date, + out=result.out, + media=result.media, + entities=result.entities, + reply_markup=request.reply_markup, + ttl_period=result.ttl_period + ) + message._finish_init(self, {}, entity) + return message + + return self._get_response_message(request, result, entity) + +async def forward_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + messages: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', + from_peer: 'hints.EntityLike' = None, + *, + background: bool = None, + with_my_score: bool = None, + silent: bool = None, + as_album: bool = None, + schedule: 'hints.DateLike' = None +) -> 'typing.Sequence[types.Message]': + if as_album is not None: + warnings.warn('the as_album argument is deprecated and no longer has any effect') + + single = not utils.is_list_like(messages) + if single: + messages = (messages,) + + entity = await self.get_input_entity(entity) + + if from_peer: + from_peer = await self.get_input_entity(from_peer) + from_peer_id = await self.get_peer_id(from_peer) + else: + from_peer_id = None + + def get_key(m): + if isinstance(m, int): + if from_peer_id is not None: + return from_peer_id + + raise ValueError('from_peer must be given if integer IDs are used') + elif isinstance(m, types.Message): + return m.chat_id + else: + raise TypeError('Cannot forward messages of type {}'.format(type(m))) + + sent = [] + for _chat_id, chunk in itertools.groupby(messages, key=get_key): + chunk = list(chunk) + if isinstance(chunk[0], int): + chat = from_peer + else: + chat = await chunk[0].get_input_chat() + chunk = [m.id for m in chunk] + + req = functions.messages.ForwardMessagesRequest( + from_peer=chat, + id=chunk, + to_peer=entity, + silent=silent, + background=background, + with_my_score=with_my_score, + schedule_date=schedule + ) + result = await self(req) + sent.extend(self._get_response_message(req, result, entity)) + + return sent[0] if single else sent + +async def edit_message( + self: 'TelegramClient', + entity: 'typing.Union[hints.EntityLike, types.Message]', + message: 'hints.MessageLike' = None, + text: str = None, + *, + parse_mode: str = (), + attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, + formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, + link_preview: bool = True, + file: 'hints.FileLike' = None, + thumb: 'hints.FileLike' = None, + force_document: bool = False, + buttons: 'hints.MarkupLike' = None, + supports_streaming: bool = False, + schedule: 'hints.DateLike' = None +) -> 'types.Message': + if isinstance(entity, types.InputBotInlineMessageID): + text = text or message + message = entity + elif isinstance(entity, types.Message): + text = message # Shift the parameters to the right + message = entity + entity = entity.peer_id + + if formatting_entities is None: + text, formatting_entities = await self._parse_message_text(text, parse_mode) + file_handle, media, image = await self._file_to_media(file, + supports_streaming=supports_streaming, + thumb=thumb, + attributes=attributes, + force_document=force_document) + + if isinstance(entity, types.InputBotInlineMessageID): + request = functions.messages.EditInlineBotMessageRequest( + id=entity, message=text, no_webpage=not link_preview, entities=formatting_entities, media=media, - reply_markup=self.build_reply_markup(buttons), - schedule_date=schedule + reply_markup=self.build_reply_markup(buttons) ) - msg = self._get_response_message(request, await self(request), entity) - return msg - - async def delete_messages( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message_ids: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', - *, - revoke: bool = True) -> 'typing.Sequence[types.messages.AffectedMessages]': - """ - Deletes the given messages, optionally "for everyone". - - See also `Message.delete() `. - - .. warning:: - - This method does **not** validate that the message IDs belong - to the chat that you passed! It's possible for the method to - delete messages from different private chats and small group - chats at once, so make sure to pass the right IDs. - - Arguments - 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` | `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. - - `Since 24 March 2019 - `_, you can - also revoke messages of any age (i.e. messages sent long in - the past) the *other* person sent in private conversations - (and of course your messages too). - - Disabling this has no effect on channels or megagroups, - since it will unconditionally delete the message for everyone. - - Returns - A list of :tl:`AffectedMessages`, each item being the result - for the delete calls of the messages in chunks of 100 each. - - Example - .. code-block:: python - - await client.delete_messages(chat, messages) - """ - 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 - ) - - if entity: - entity = await self.get_input_entity(entity) - ty = helpers._entity_type(entity) + # Invoke `messages.editInlineBotMessage` from the right datacenter. + # Otherwise, Telegram will error with `MESSAGE_ID_INVALID` and do nothing. + exported = self.session.dc_id != entity.dc_id + if exported: + try: + sender = await self._borrow_exported_sender(entity.dc_id) + return await self._call(sender, request) + finally: + await self._return_exported_sender(sender) else: - # no entity (None), set a value that's not a channel for private delete - ty = helpers._EntityType.USER + return await self(request) - if ty == helpers._EntityType.CHANNEL: - return await self([functions.channels.DeleteMessagesRequest( - entity, list(c)) for c in utils.chunks(message_ids)]) + entity = await self.get_input_entity(entity) + request = functions.messages.EditMessageRequest( + peer=entity, + id=utils.get_message_id(message), + message=text, + no_webpage=not link_preview, + entities=formatting_entities, + media=media, + reply_markup=self.build_reply_markup(buttons), + schedule_date=schedule + ) + msg = self._get_response_message(request, await self(request), entity) + return msg + +async def delete_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message_ids: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', + *, + revoke: bool = True) -> 'typing.Sequence[types.messages.AffectedMessages]': + 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 + ) + + if entity: + entity = await self.get_input_entity(entity) + ty = helpers._entity_type(entity) + else: + # no entity (None), set a value that's not a channel for private delete + ty = helpers._EntityType.USER + + if ty == helpers._EntityType.CHANNEL: + 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)]) + +async def send_read_acknowledge( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]' = None, + *, + max_id: int = None, + clear_mentions: bool = False) -> bool: + if max_id is None: + if not message: + max_id = 0 else: - return await self([functions.messages.DeleteMessagesRequest( - list(c), revoke) for c in utils.chunks(message_ids)]) + if utils.is_list_like(message): + max_id = max(msg.id for msg in message) + else: + max_id = message.id - # endregion - - # region Miscellaneous - - async def send_read_acknowledge( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]' = None, - *, - max_id: int = None, - clear_mentions: bool = False) -> bool: - """ - Marks messages as read and optionally clears mentions. - - This effectively marks a message as read (or more than one) in the - given conversation. - - If neither message nor maximum ID are provided, all messages will be - marked as read by assuming that ``max_id = 0``. - - If a message or maximum ID is provided, all the messages up to and - including such ID will be marked as read (for all messages whose ID - ≤ max_id). - - See also `Message.mark_read() `. - - Arguments - entity (`entity`): - The chat where these messages are located. - - message (`list` | `Message `): - Either a list of messages or a single message. - - max_id (`int`): - Until which message should the read acknowledge be sent for. - This has priority over the ``message`` parameter. - - 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. - - Example - .. code-block:: python - - # using a Message object - await client.send_read_acknowledge(chat, message) - # ...or using the int ID of a Message - await client.send_read_acknowledge(chat, message_id) - # ...or passing a list of messages to mark as read - await client.send_read_acknowledge(chat, messages) - """ + entity = await self.get_input_entity(entity) + if clear_mentions: + await self(functions.messages.ReadMentionsRequest(entity)) if max_id is None: - if not message: - max_id = 0 - else: - if utils.is_list_like(message): - max_id = max(msg.id for msg in message) - else: - max_id = message.id + return True - 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 helpers._entity_type(entity) == helpers._EntityType.CHANNEL: + return await self(functions.channels.ReadHistoryRequest( + utils.get_input_channel(entity), max_id=max_id)) + else: + return await self(functions.messages.ReadHistoryRequest( + entity, max_id=max_id)) - if max_id is not None: - if helpers._entity_type(entity) == helpers._EntityType.CHANNEL: - return await self(functions.channels.ReadHistoryRequest( - utils.get_input_channel(entity), max_id=max_id)) - else: - return await self(functions.messages.ReadHistoryRequest( - entity, max_id=max_id)) + return False - return False +async def pin_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Optional[hints.MessageIDLike]', + *, + notify: bool = False, + pm_oneside: bool = False +): + return await self._pin(entity, message, unpin=False, notify=notify, pm_oneside=pm_oneside) - async def pin_message( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message: 'typing.Optional[hints.MessageIDLike]', - *, - notify: bool = False, - pm_oneside: bool = False - ): - """ - Pins a message in a chat. +async def unpin_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Optional[hints.MessageIDLike]' = None, + *, + notify: bool = False +): + return await self._pin(entity, message, unpin=True, notify=notify) - The default behaviour is to *not* notify members, unlike the - official applications. +async def _pin(self, entity, message, *, unpin, notify=False, pm_oneside=False): + message = utils.get_message_id(message) or 0 + entity = await self.get_input_entity(entity) + if message <= 0: # old behaviour accepted negative IDs to unpin + await self(functions.messages.UnpinAllMessagesRequest(entity)) + return - See also `Message.pin() `. + request = functions.messages.UpdatePinnedMessageRequest( + peer=entity, + id=message, + silent=not notify, + unpin=unpin, + pm_oneside=pm_oneside + ) + result = await self(request) - Arguments - entity (`entity`): - The chat where the message should be pinned. + # Unpinning does not produce a service message. + # Pinning a message that was already pinned also produces no service message. + # Pinning a message in your own chat does not produce a service message, + # but pinning on a private conversation with someone else does. + if unpin or not result.updates: + return - message (`int` | `Message `): - The message or the message ID to pin. If it's - `None`, all messages will be unpinned instead. - - notify (`bool`, optional): - Whether the pin should notify people or not. - - pm_oneside (`bool`, optional): - Whether the message should be pinned for everyone or not. - By default it has the opposite behaviour of official clients, - and it will pin the message for both sides, in private chats. - - Example - .. code-block:: python - - # Send and pin a message to annoy everyone - message = await client.send_message(chat, 'Pinotifying is fun!') - await client.pin_message(chat, message, notify=True) - """ - return await self._pin(entity, message, unpin=False, notify=notify, pm_oneside=pm_oneside) - - async def unpin_message( - self: 'TelegramClient', - entity: 'hints.EntityLike', - message: 'typing.Optional[hints.MessageIDLike]' = None, - *, - notify: bool = False - ): - """ - Unpins a message in a chat. - - If no message ID is specified, all pinned messages will be unpinned. - - See also `Message.unpin() `. - - Arguments - entity (`entity`): - The chat where the message should be pinned. - - message (`int` | `Message `): - The message or the message ID to unpin. If it's - `None`, all messages will be unpinned instead. - - Example - .. code-block:: python - - # Unpin all messages from a chat - await client.unpin_message(chat) - """ - return await self._pin(entity, message, unpin=True, notify=notify) - - async def _pin(self, entity, message, *, unpin, notify=False, pm_oneside=False): - message = utils.get_message_id(message) or 0 - entity = await self.get_input_entity(entity) - if message <= 0: # old behaviour accepted negative IDs to unpin - await self(functions.messages.UnpinAllMessagesRequest(entity)) - return - - request = functions.messages.UpdatePinnedMessageRequest( - peer=entity, - id=message, - silent=not notify, - unpin=unpin, - pm_oneside=pm_oneside - ) - result = await self(request) - - # Unpinning does not produce a service message. - # Pinning a message that was already pinned also produces no service message. - # Pinning a message in your own chat does not produce a service message, - # but pinning on a private conversation with someone else does. - if unpin or not result.updates: - return - - # Pinning a message that doesn't exist would RPC-error earlier - return self._get_response_message(request, result, entity) - - # endregion - - # endregion + # Pinning a message that doesn't exist would RPC-error earlier + return self._get_response_message(request, result, entity) diff --git a/telethon/client/telegrambaseclient.py b/telethon/client/telegrambaseclient.py index 494daf9c..79ea85b3 100644 --- a/telethon/client/telegrambaseclient.py +++ b/telethon/client/telegrambaseclient.py @@ -64,812 +64,538 @@ class _ExportState: # TODO How hard would it be to support both `trio` and `asyncio`? -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. - Arguments - 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. +def init( + self: 'TelegramClient', + session: 'typing.Union[str, Session]', + api_id: int, + api_hash: str, + *, + connection: 'typing.Type[Connection]' = ConnectionTcpFull, + use_ipv6: bool = False, + proxy: typing.Union[tuple, dict] = None, + local_addr: typing.Union[str, tuple] = None, + timeout: int = 10, + request_retries: int = 5, + connection_retries: int = 5, + retry_delay: int = 1, + auto_reconnect: bool = True, + sequential_updates: bool = False, + flood_sleep_threshold: int = 60, + raise_last_call_error: bool = False, + device_model: str = None, + system_version: str = None, + app_version: str = None, + lang_code: str = 'en', + system_lang_code: str = 'en', + loop: asyncio.AbstractEventLoop = None, + base_logger: typing.Union[str, logging.Logger] = None, + receive_updates: bool = True +): + 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.") - 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. + self._use_ipv6 = use_ipv6 - api_id (`int` | `str`): - The API ID you obtained from https://my.telegram.org. + if isinstance(base_logger, str): + base_logger = logging.getLogger(base_logger) + elif not isinstance(base_logger, logging.Logger): + base_logger = _base_log - api_hash (`str`): - The API hash you obtained from https://my.telegram.org. + class _Loggers(dict): + def __missing__(self, key): + if key.startswith("telethon."): + key = key.split('.', maxsplit=1)[1] - connection (`telethon.network.connection.common.Connection`, optional): - The connection instance to be used when creating a new connection - to the servers. It **must** be a type. + return base_logger.getChild(key) - Defaults to `telethon.network.connection.tcpfull.ConnectionTcpFull`. + self._log = _Loggers() - 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` | `list` | `dict`, optional): - An iterable consisting of the proxy info. If `connection` is - one of `MTProxy`, then it should contain MTProxy credentials: - ``('hostname', port, 'secret')``. Otherwise, it's meant to store - function parameters for PySocks, like ``(type, 'hostname', port)``. - See https://github.com/Anorov/PySocks#usage-1 for more. - - local_addr (`str` | `tuple`, optional): - Local host address (and port, optionally) used to bind the socket to locally. - You only need to use this if you have multiple network cards and - want to use a specific one. - - timeout (`int` | `float`, optional): - The timeout in seconds to be used when connecting. - This is **not** the timeout to be used when ``await``'ing for - invoked requests, and you should use ``asyncio.wait`` or - ``asyncio.wait_for`` for that. - - request_retries (`int` | `None`, optional): - How many times a request should be retried. Request are retried - when Telegram is having internal issues (due to either - ``errors.ServerError`` or ``errors.RpcCallFailError``), - when there is a ``errors.FloodWaitError`` less than - `flood_sleep_threshold`, or when there's a migrate error. - - May take a negative or `None` value for infinite retries, but - this is not recommended, since some requests can always trigger - a call fail (such as searching for messages). - - connection_retries (`int` | `None`, optional): - How many times the reconnection should retry, either on the - initial connection or when Telegram disconnects us. May be - set to a negative or `None` value for infinite retries, but - this is not recommended, since the program can get stuck in an - infinite loop. - - retry_delay (`int` | `float`, optional): - The delay in seconds to sleep between automatic reconnections. - - auto_reconnect (`bool`, optional): - Whether reconnection should be retried `connection_retries` - times automatically if Telegram disconnects us or not. - - sequential_updates (`bool`, optional): - By default every incoming update will create a new task, so - you can handle several updates in parallel. Some scripts need - the order in which updates are processed to be sequential, and - this setting allows them to do so. - - If set to `True`, incoming updates will be put in a queue - and processed sequentially. This means your event handlers - should *not* perform long-running operations since new - updates are put inside of an unbounded queue. - - flood_sleep_threshold (`int` | `float`, optional): - The threshold below which the library should automatically - sleep on flood wait and slow mode wait errors (inclusive). For instance, if a - ``FloodWaitError`` for 17s occurs and `flood_sleep_threshold` - is 20s, the library will ``sleep`` automatically. If the error - was for 21s, it would ``raise FloodWaitError`` instead. Values - larger than a day (like ``float('inf')``) will be changed to a day. - - raise_last_call_error (`bool`, optional): - When API calls fail in a way that causes Telethon to retry - automatically, should the RPC error of the last attempt be raised - instead of a generic ValueError. This is mostly useful for - detecting when Telegram has internal issues. - - device_model (`str`, optional): - "Device model" to be sent when creating the initial connection. - Defaults to 'PC (n)bit' derived from ``platform.uname().machine``, or its direct value if unknown. - - system_version (`str`, optional): - "System version" to be sent when creating the initial connection. - Defaults to ``platform.uname().release`` stripped of everything ahead of -. - - 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`. - - loop (`asyncio.AbstractEventLoop`, optional): - Asyncio event loop to use. Defaults to `asyncio.get_event_loop()`. - This argument is ignored. - - base_logger (`str` | `logging.Logger`, optional): - Base logger name or instance to use. - If a `str` is given, it'll be passed to `logging.getLogger()`. If a - `logging.Logger` is given, it'll be used directly. If something - else or nothing is given, the default logger will be used. - - receive_updates (`bool`, optional): - Whether the client will receive updates or not. By default, updates - will be received from Telegram as they occur. - - Turning this off means that Telegram will not send updates at all - so event handlers, conversations, and QR login will not work. - However, certain scripts don't need updates, so this will reduce - the amount of bandwidth used. - """ - - # 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: 'TelegramClient', - session: 'typing.Union[str, Session]', - api_id: int, - api_hash: str, - *, - connection: 'typing.Type[Connection]' = ConnectionTcpFull, - use_ipv6: bool = False, - proxy: typing.Union[tuple, dict] = None, - local_addr: typing.Union[str, tuple] = None, - timeout: int = 10, - request_retries: int = 5, - connection_retries: int = 5, - retry_delay: int = 1, - auto_reconnect: bool = True, - sequential_updates: bool = False, - flood_sleep_threshold: int = 60, - raise_last_call_error: bool = False, - device_model: str = None, - system_version: str = None, - app_version: str = None, - lang_code: str = 'en', - system_lang_code: str = 'en', - loop: asyncio.AbstractEventLoop = None, - base_logger: typing.Union[str, logging.Logger] = None, - receive_updates: bool = True - ): - 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 - - if isinstance(base_logger, str): - base_logger = logging.getLogger(base_logger) - elif not isinstance(base_logger, logging.Logger): - base_logger = _base_log - - class _Loggers(dict): - def __missing__(self, key): - if key.startswith("telethon."): - key = key.split('.', maxsplit=1)[1] - - return base_logger.getChild(key) - - self._log = _Loggers() - - # Determine what session object we have - if isinstance(session, str) or session is None: - try: - session = SQLiteSession(session) - except ImportError: - import warnings - warnings.warn( - 'The sqlite3 module is not available under this ' - 'Python installation and no custom session ' - 'instance was given; using MemorySession.\n' - 'You will need to re-login every time unless ' - 'you use another session storage' - ) - session = MemorySession() - elif not isinstance(session, Session): - raise TypeError( - 'The given session must be a str or a Session instance.' + # Determine what session object we have + if isinstance(session, str) or session is None: + try: + session = SQLiteSession(session) + except ImportError: + import warnings + warnings.warn( + 'The sqlite3 module is not available under this ' + 'Python installation and no custom session ' + 'instance was given; using MemorySession.\n' + 'You will need to re-login every time unless ' + 'you use another session storage' ) - - # ':' 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 - ) - - self.flood_sleep_threshold = flood_sleep_threshold - - # TODO Use AsyncClassWrapper(session) - # ChatGetter and SenderGetter can use the in-memory _entity_cache - # to avoid network access and the need for await in session files. - # - # The session files only wants the entities to persist - # them to disk, and to save additional useful information. - # TODO Session should probably return all cached - # info of entities, not just the input versions - self.session = session - self._entity_cache = EntityCache() - self.api_id = int(api_id) - self.api_hash = api_hash - - # Current proxy implementation requires `sock_connect`, and some - # event loops lack this method. If the current loop is missing it, - # bail out early and suggest an alternative. - # - # TODO A better fix is obviously avoiding the use of `sock_connect` - # - # See https://github.com/LonamiWebs/Telethon/issues/1337 for details. - if not callable(getattr(self.loop, 'sock_connect', None)): - raise TypeError( - 'Event loop of type {} lacks `sock_connect`, which is needed to use proxies.\n\n' - 'Change the event loop in use to use proxies:\n' - '# https://github.com/LonamiWebs/Telethon/issues/1337\n' - 'import asyncio\n' - 'asyncio.set_event_loop(asyncio.SelectorEventLoop())'.format( - self.loop.__class__.__name__ - ) - ) - - if local_addr is not None: - if use_ipv6 is False and ':' in local_addr: - raise TypeError( - 'A local IPv6 address must only be used with `use_ipv6=True`.' - ) - elif use_ipv6 is True and ':' not in local_addr: - raise TypeError( - '`use_ipv6=True` must only be used with a local IPv6 address.' - ) - - self._raise_last_call_error = raise_last_call_error - - self._request_retries = request_retries - self._connection_retries = connection_retries - self._retry_delay = retry_delay or 0 - self._proxy = proxy - self._local_addr = local_addr - self._timeout = timeout - self._auto_reconnect = auto_reconnect - - assert isinstance(connection, type) - self._connection = connection - init_proxy = None if not issubclass(connection, TcpMTProxy) else \ - types.InputClientProxy(*connection.address_info(proxy)) - - # Used on connection. Capture the variables in a lambda since - # exporting clients need to create this InvokeWithLayerRequest. - system = platform.uname() - - if system.machine in ('x86_64', 'AMD64'): - default_device_model = 'PC 64bit' - elif system.machine in ('i386','i686','x86'): - default_device_model = 'PC 32bit' - else: - default_device_model = system.machine - default_system_version = re.sub(r'-.+','',system.release) - - self._init_request = functions.InitConnectionRequest( - api_id=self.api_id, - device_model=device_model or default_device_model or 'Unknown', - system_version=system_version or default_system_version 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=None, - proxy=init_proxy + session = MemorySession() + elif not isinstance(session, Session): + raise TypeError( + 'The given session must be a str or a Session instance.' ) - self._sender = MTProtoSender( - self.session.auth_key, - loggers=self._log, - retries=self._connection_retries, - delay=self._retry_delay, - auto_reconnect=self._auto_reconnect, - connect_timeout=self._timeout, - auth_key_callback=self._auth_key_callback, - update_callback=self._handle_update, - auto_reconnect_callback=self._handle_auto_reconnect + # ':' 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 ) - # Remember flood-waited requests to avoid making them again - self._flood_waited_requests = {} + self.flood_sleep_threshold = flood_sleep_threshold - # Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders - self._borrowed_senders = {} - self._borrow_sender_lock = asyncio.Lock() + # TODO Use AsyncClassWrapper(session) + # ChatGetter and SenderGetter can use the in-memory _entity_cache + # to avoid network access and the need for await in session files. + # + # The session files only wants the entities to persist + # them to disk, and to save additional useful information. + # TODO Session should probably return all cached + # info of entities, not just the input versions + self.session = session + self._entity_cache = EntityCache() + self.api_id = int(api_id) + self.api_hash = api_hash - self._updates_handle = None - self._last_request = time.time() - self._channel_pts = {} - self._no_updates = not receive_updates + # Current proxy implementation requires `sock_connect`, and some + # event loops lack this method. If the current loop is missing it, + # bail out early and suggest an alternative. + # + # TODO A better fix is obviously avoiding the use of `sock_connect` + # + # See https://github.com/LonamiWebs/Telethon/issues/1337 for details. + if not callable(getattr(self.loop, 'sock_connect', None)): + raise TypeError( + 'Event loop of type {} lacks `sock_connect`, which is needed to use proxies.\n\n' + 'Change the event loop in use to use proxies:\n' + '# https://github.com/LonamiWebs/Telethon/issues/1337\n' + 'import asyncio\n' + 'asyncio.set_event_loop(asyncio.SelectorEventLoop())'.format( + self.loop.__class__.__name__ + ) + ) - if sequential_updates: - self._updates_queue = asyncio.Queue() - self._dispatching_updates_queue = asyncio.Event() + if local_addr is not None: + if use_ipv6 is False and ':' in local_addr: + raise TypeError( + 'A local IPv6 address must only be used with `use_ipv6=True`.' + ) + elif use_ipv6 is True and ':' not in local_addr: + raise TypeError( + '`use_ipv6=True` must only be used with a local IPv6 address.' + ) + + self._raise_last_call_error = raise_last_call_error + + self._request_retries = request_retries + self._connection_retries = connection_retries + self._retry_delay = retry_delay or 0 + self._proxy = proxy + self._local_addr = local_addr + self._timeout = timeout + self._auto_reconnect = auto_reconnect + + assert isinstance(connection, type) + self._connection = connection + init_proxy = None if not issubclass(connection, TcpMTProxy) else \ + types.InputClientProxy(*connection.address_info(proxy)) + + # Used on connection. Capture the variables in a lambda since + # exporting clients need to create this InvokeWithLayerRequest. + system = platform.uname() + + if system.machine in ('x86_64', 'AMD64'): + default_device_model = 'PC 64bit' + elif system.machine in ('i386','i686','x86'): + default_device_model = 'PC 32bit' + else: + default_device_model = system.machine + default_system_version = re.sub(r'-.+','',system.release) + + self._init_request = functions.InitConnectionRequest( + api_id=self.api_id, + device_model=device_model or default_device_model or 'Unknown', + system_version=system_version or default_system_version 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=None, + proxy=init_proxy + ) + + self._sender = MTProtoSender( + self.session.auth_key, + loggers=self._log, + retries=self._connection_retries, + delay=self._retry_delay, + auto_reconnect=self._auto_reconnect, + connect_timeout=self._timeout, + auth_key_callback=self._auth_key_callback, + update_callback=self._handle_update, + auto_reconnect_callback=self._handle_auto_reconnect + ) + + # Remember flood-waited requests to avoid making them again + self._flood_waited_requests = {} + + # Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders + self._borrowed_senders = {} + self._borrow_sender_lock = asyncio.Lock() + + self._updates_handle = None + self._last_request = time.time() + self._channel_pts = {} + self._no_updates = not receive_updates + + if sequential_updates: + self._updates_queue = asyncio.Queue() + self._dispatching_updates_queue = asyncio.Event() + else: + # Use a set of pending instead of a queue so we can properly + # terminate all pending updates on disconnect. + self._updates_queue = set() + self._dispatching_updates_queue = None + + self._authorized = None # None = unknown, False = no, True = yes + + # Update state (for catching up after a disconnection) + # TODO Get state from channels too + self._state_cache = StateCache( + self.session.get_update_state(0), self._log) + + # Some further state for subclasses + self._event_builders = [] + + # {chat_id: {Conversation}} + self._conversations = collections.defaultdict(set) + + # Hack to workaround the fact Telegram may send album updates as + # different Updates when being sent from a different data center. + # {grouped_id: AlbumHack} + # + # FIXME: We don't bother cleaning this up because it's not really + # worth it, albums are pretty rare and this only holds them + # for a second at most. + self._albums = {} + + # 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 + self._bot = None + + # A place to store if channels are a megagroup or not (see `edit_admin`) + self._megagroup_cache = {} + + +def get_loop(self: 'TelegramClient') -> asyncio.AbstractEventLoop: + return asyncio.get_event_loop() + +def get_disconnected(self: 'TelegramClient') -> asyncio.Future: + return self._sender.disconnected + +def get_flood_sleep_threshold(self): + return self._flood_sleep_threshold + +def set_flood_sleep_threshold(self, value): + # None -> 0, negative values don't really matter + self._flood_sleep_threshold = min(value or 0, 24 * 60 * 60) + + +async def connect(self: 'TelegramClient') -> None: + if not await self._sender.connect(self._connection( + self.session.server_address, + self.session.port, + self.session.dc_id, + loggers=self._log, + proxy=self._proxy, + local_addr=self._local_addr + )): + # We don't want to init or modify anything if we were already connected + return + + self.session.auth_key = self._sender.auth_key + self.session.save() + + self._init_request.query = functions.help.GetConfigRequest() + + await self._sender.send(functions.InvokeWithLayerRequest( + LAYER, self._init_request + )) + + self._updates_handle = self.loop.create_task(self._update_loop()) + +def is_connected(self: 'TelegramClient') -> bool: + sender = getattr(self, '_sender', None) + return sender and sender.is_connected() + +def disconnect(self: 'TelegramClient'): + if self.loop.is_running(): + return self._disconnect_coro() + else: + try: + self.loop.run_until_complete(self._disconnect_coro()) + except RuntimeError: + # Python 3.5.x complains when called from + # `__aexit__` and there were pending updates with: + # "Event loop stopped before Future completed." + # + # However, it doesn't really make a lot of sense. + pass + +def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): + init_proxy = None if not issubclass(self._connection, TcpMTProxy) else \ + types.InputClientProxy(*self._connection.address_info(proxy)) + + self._init_request.proxy = init_proxy + self._proxy = proxy + + # While `await client.connect()` passes new proxy on each new call, + # auto-reconnect attempts use already set up `_connection` inside + # the `_sender`, so the only way to change proxy between those + # is to directly inject parameters. + + connection = getattr(self._sender, "_connection", None) + if connection: + if isinstance(connection, TcpMTProxy): + connection._ip = proxy[0] + connection._port = proxy[1] else: - # Use a set of pending instead of a queue so we can properly - # terminate all pending updates on disconnect. - self._updates_queue = set() - self._dispatching_updates_queue = None + connection._proxy = proxy - self._authorized = None # None = unknown, False = no, True = yes +async def _disconnect_coro(self: 'TelegramClient'): + await self._disconnect() - # Update state (for catching up after a disconnection) - # TODO Get state from channels too - self._state_cache = StateCache( - self.session.get_update_state(0), self._log) + # Also clean-up all exported senders because we're done with them + async with self._borrow_sender_lock: + for state, sender in self._borrowed_senders.values(): + # Note that we're not checking for `state.should_disconnect()`. + # If the user wants to disconnect the client, ALL connections + # to Telegram (including exported senders) should be closed. + # + # Disconnect should never raise, so there's no try/except. + await sender.disconnect() + # Can't use `mark_disconnected` because it may be borrowed. + state._connected = False - # Some further state for subclasses - self._event_builders = [] + # If any was borrowed + self._borrowed_senders.clear() - # {chat_id: {Conversation}} - self._conversations = collections.defaultdict(set) + # trio's nurseries would handle this for us, but this is asyncio. + # All tasks spawned in the background should properly be terminated. + if self._dispatching_updates_queue is None and self._updates_queue: + for task in self._updates_queue: + task.cancel() - # Hack to workaround the fact Telegram may send album updates as - # different Updates when being sent from a different data center. - # {grouped_id: AlbumHack} - # - # FIXME: We don't bother cleaning this up because it's not really - # worth it, albums are pretty rare and this only holds them - # for a second at most. - self._albums = {} + await asyncio.wait(self._updates_queue) + self._updates_queue.clear() - # 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 - self._bot = None - - # A place to store if channels are a megagroup or not (see `edit_admin`) - self._megagroup_cache = {} - - # endregion - - # region Properties - - @property - def loop(self: 'TelegramClient') -> asyncio.AbstractEventLoop: - """ - Property with the ``asyncio`` event loop used by this client. - - Example - .. code-block:: python - - # Download media in the background - task = client.loop.create_task(message.download_media()) - - # Do some work - ... - - # Join the task (wait for it to complete) - await task - """ - return asyncio.get_event_loop() - - @property - def disconnected(self: 'TelegramClient') -> asyncio.Future: - """ - Property with a ``Future`` that resolves upon disconnection. - - Example - .. code-block:: python - - # Wait for a disconnection to occur - try: - await client.disconnected - except OSError: - print('Error on disconnect') - """ - return self._sender.disconnected - - @property - def flood_sleep_threshold(self): - return self._flood_sleep_threshold - - @flood_sleep_threshold.setter - def flood_sleep_threshold(self, value): - # None -> 0, negative values don't really matter - self._flood_sleep_threshold = min(value or 0, 24 * 60 * 60) - - # endregion - - # region Connecting - - async def connect(self: 'TelegramClient') -> None: - """ - Connects to Telegram. - - .. note:: - - Connect means connect and nothing else, and only one low-level - request is made to notify Telegram about which layer we will be - using. - - Before Telegram sends you updates, you need to make a high-level - request, like `client.get_me() `, - as described in https://core.telegram.org/api/updates. - - Example - .. code-block:: python - - try: - await client.connect() - except OSError: - print('Failed to connect') - """ - if not await self._sender.connect(self._connection( - self.session.server_address, - self.session.port, - self.session.dc_id, - loggers=self._log, - proxy=self._proxy, - local_addr=self._local_addr - )): - # We don't want to init or modify anything if we were already connected - return - - self.session.auth_key = self._sender.auth_key - self.session.save() - - self._init_request.query = functions.help.GetConfigRequest() - - await self._sender.send(functions.InvokeWithLayerRequest( - LAYER, self._init_request + pts, date = self._state_cache[None] + if pts and date: + self.session.set_update_state(0, types.updates.State( + pts=pts, + qts=0, + date=date, + seq=0, + unread_count=0 )) - self._updates_handle = self.loop.create_task(self._update_loop()) + self.session.close() - def is_connected(self: 'TelegramClient') -> bool: - """ - Returns `True` if the user has connected. +async def _disconnect(self: 'TelegramClient'): + """ + Disconnect only, without closing the session. Used in reconnections + to different data centers, where we don't want to close the session + file; user disconnects however should close it since it means that + their job with the client is complete and we should clean it up all. + """ + await self._sender.disconnect() + await helpers._cancel(self._log[__name__], + updates_handle=self._updates_handle) - This method is **not** asynchronous (don't use ``await`` on it). +async def _switch_dc(self: 'TelegramClient', new_dc): + """ + Permanently switches the current connection to the new data center. + """ + self._log[__name__].info('Reconnecting to new data center %s', new_dc) + dc = await self._get_dc(new_dc) - Example - .. code-block:: python + 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._sender.auth_key.key = None + self.session.auth_key = None + self.session.save() + await self._disconnect() + return await self.connect() - while client.is_connected(): - await asyncio.sleep(1) - """ - sender = getattr(self, '_sender', None) - return sender and sender.is_connected() +def _auth_key_callback(self: 'TelegramClient', auth_key): + """ + Callback from the sender whenever it needed to generate a + new authorization key. This means we are not authorized. + """ + self.session.auth_key = auth_key + self.session.save() - def disconnect(self: 'TelegramClient'): - """ - Disconnects from Telegram. - If the event loop is already running, this method returns a - coroutine that you should await on your own code; otherwise - the loop is ran until said coroutine completes. +async def _get_dc(self: 'TelegramClient', 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()) - Example - .. code-block:: python + 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) - # You don't need to use this if you used "with client" - await client.disconnect() - """ - if self.loop.is_running(): - return self._disconnect_coro() - else: - try: - self.loop.run_until_complete(self._disconnect_coro()) - except RuntimeError: - # Python 3.5.x complains when called from - # `__aexit__` and there were pending updates with: - # "Event loop stopped before Future completed." - # - # However, it doesn't really make a lot of sense. - pass + try: + 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 + ) + except StopIteration: + self._log[__name__].warning( + 'Failed to get DC %s (cdn = %s) with use_ipv6 = %s; retrying ignoring IPv6 check', + dc_id, cdn, self._use_ipv6 + ) + return next( + dc for dc in cls._config.dc_options + if dc.id == dc_id and bool(dc.cdn) == cdn + ) - def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): - """ - Changes the proxy which will be used on next (re)connection. +async def _create_exported_sender(self: 'TelegramClient', dc_id): + """ + Creates a new exported `MTProtoSender` for the given `dc_id` and + returns it. This method should be used by `_borrow_exported_sender`. + """ + # Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt + # for clearly showing how to export the authorization + dc = await self._get_dc(dc_id) + # 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(None, loggers=self._log) + await sender.connect(self._connection( + dc.ip_address, + dc.port, + dc.id, + loggers=self._log, + proxy=self._proxy, + local_addr=self._local_addr + )) + self._log[__name__].info('Exporting auth for new borrowed sender in %s', dc) + auth = await self(functions.auth.ExportAuthorizationRequest(dc_id)) + self._init_request.query = functions.auth.ImportAuthorizationRequest(id=auth.id, bytes=auth.bytes) + req = functions.InvokeWithLayerRequest(LAYER, self._init_request) + await sender.send(req) + return sender - Method has no immediate effects if the client is currently connected. +async def _borrow_exported_sender(self: 'TelegramClient', dc_id): + """ + Borrows a connected `MTProtoSender` for the given `dc_id`. + If it's not cached, creates a new one if it doesn't exist yet, + and imports a freshly exported authorization key for it to be usable. - The new proxy will take it's effect on the next reconnection attempt: - - on a call `await client.connect()` (after complete disconnect) - - on auto-reconnect attempt (e.g, after previous connection was lost) - """ - init_proxy = None if not issubclass(self._connection, TcpMTProxy) else \ - types.InputClientProxy(*self._connection.address_info(proxy)) + Once its job is over it should be `_return_exported_sender`. + """ + async with self._borrow_sender_lock: + self._log[__name__].debug('Borrowing sender for dc_id %d', dc_id) + state, sender = self._borrowed_senders.get(dc_id, (None, None)) - self._init_request.proxy = init_proxy - self._proxy = proxy + if state is None: + state = _ExportState() + sender = await self._create_exported_sender(dc_id) + sender.dc_id = dc_id + self._borrowed_senders[dc_id] = (state, sender) - # While `await client.connect()` passes new proxy on each new call, - # auto-reconnect attempts use already set up `_connection` inside - # the `_sender`, so the only way to change proxy between those - # is to directly inject parameters. - - connection = getattr(self._sender, "_connection", None) - if connection: - if isinstance(connection, TcpMTProxy): - connection._ip = proxy[0] - connection._port = proxy[1] - else: - connection._proxy = proxy - - async def _disconnect_coro(self: 'TelegramClient'): - await self._disconnect() - - # Also clean-up all exported senders because we're done with them - async with self._borrow_sender_lock: - for state, sender in self._borrowed_senders.values(): - # Note that we're not checking for `state.should_disconnect()`. - # If the user wants to disconnect the client, ALL connections - # to Telegram (including exported senders) should be closed. - # - # Disconnect should never raise, so there's no try/except. - await sender.disconnect() - # Can't use `mark_disconnected` because it may be borrowed. - state._connected = False - - # If any was borrowed - self._borrowed_senders.clear() - - # trio's nurseries would handle this for us, but this is asyncio. - # All tasks spawned in the background should properly be terminated. - if self._dispatching_updates_queue is None and self._updates_queue: - for task in self._updates_queue: - task.cancel() - - await asyncio.wait(self._updates_queue) - self._updates_queue.clear() - - pts, date = self._state_cache[None] - if pts and date: - self.session.set_update_state(0, types.updates.State( - pts=pts, - qts=0, - date=date, - seq=0, - unread_count=0 + elif state.need_connect(): + dc = await self._get_dc(dc_id) + await sender.connect(self._connection( + dc.ip_address, + dc.port, + dc.id, + loggers=self._log, + proxy=self._proxy, + local_addr=self._local_addr )) - self.session.close() - - async def _disconnect(self: 'TelegramClient'): - """ - Disconnect only, without closing the session. Used in reconnections - to different data centers, where we don't want to close the session - file; user disconnects however should close it since it means that - their job with the client is complete and we should clean it up all. - """ - await self._sender.disconnect() - await helpers._cancel(self._log[__name__], - updates_handle=self._updates_handle) - - async def _switch_dc(self: 'TelegramClient', new_dc): - """ - Permanently switches the current connection to the new data center. - """ - self._log[__name__].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._sender.auth_key.key = None - self.session.auth_key = None - self.session.save() - await self._disconnect() - return await self.connect() - - def _auth_key_callback(self: 'TelegramClient', auth_key): - """ - Callback from the sender whenever it needed to generate a - new authorization key. This means we are not authorized. - """ - self.session.auth_key = auth_key - self.session.save() - - # endregion - - # region Working with different connections/Data Centers - - async def _get_dc(self: 'TelegramClient', 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) - - try: - 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 - ) - except StopIteration: - self._log[__name__].warning( - 'Failed to get DC %s (cdn = %s) with use_ipv6 = %s; retrying ignoring IPv6 check', - dc_id, cdn, self._use_ipv6 - ) - return next( - dc for dc in cls._config.dc_options - if dc.id == dc_id and bool(dc.cdn) == cdn - ) - - async def _create_exported_sender(self: 'TelegramClient', dc_id): - """ - Creates a new exported `MTProtoSender` for the given `dc_id` and - returns it. This method should be used by `_borrow_exported_sender`. - """ - # Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt - # for clearly showing how to export the authorization - dc = await self._get_dc(dc_id) - # 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(None, loggers=self._log) - await sender.connect(self._connection( - dc.ip_address, - dc.port, - dc.id, - loggers=self._log, - proxy=self._proxy, - local_addr=self._local_addr - )) - self._log[__name__].info('Exporting auth for new borrowed sender in %s', dc) - auth = await self(functions.auth.ExportAuthorizationRequest(dc_id)) - self._init_request.query = functions.auth.ImportAuthorizationRequest(id=auth.id, bytes=auth.bytes) - req = functions.InvokeWithLayerRequest(LAYER, self._init_request) - await sender.send(req) + state.add_borrow() return sender - async def _borrow_exported_sender(self: 'TelegramClient', dc_id): - """ - Borrows a connected `MTProtoSender` for the given `dc_id`. - If it's not cached, creates a new one if it doesn't exist yet, - and imports a freshly exported authorization key for it to be usable. +async def _return_exported_sender(self: 'TelegramClient', sender): + """ + Returns a borrowed exported sender. If all borrows have + been returned, the sender is cleanly disconnected. + """ + async with self._borrow_sender_lock: + self._log[__name__].debug('Returning borrowed sender for dc_id %d', sender.dc_id) + state, _ = self._borrowed_senders[sender.dc_id] + state.add_return() - Once its job is over it should be `_return_exported_sender`. - """ - async with self._borrow_sender_lock: - self._log[__name__].debug('Borrowing sender for dc_id %d', dc_id) - state, sender = self._borrowed_senders.get(dc_id, (None, None)) +async def _clean_exported_senders(self: 'TelegramClient'): + """ + Cleans-up all unused exported senders by disconnecting them. + """ + async with self._borrow_sender_lock: + for dc_id, (state, sender) in self._borrowed_senders.items(): + if state.should_disconnect(): + self._log[__name__].info( + 'Disconnecting borrowed sender for DC %d', dc_id) - if state is None: - state = _ExportState() - sender = await self._create_exported_sender(dc_id) - sender.dc_id = dc_id - self._borrowed_senders[dc_id] = (state, sender) + # Disconnect should never raise + await sender.disconnect() + state.mark_disconnected() - elif state.need_connect(): - dc = await self._get_dc(dc_id) - await sender.connect(self._connection( - dc.ip_address, - dc.port, - dc.id, - loggers=self._log, - proxy=self._proxy, - local_addr=self._local_addr - )) +async def _get_cdn_client(self: 'TelegramClient', cdn_redirect): + """Similar to ._borrow_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() + await session.set_dc(dc.id, dc.ip_address, dc.port) + self._exported_sessions[cdn_redirect.dc_id] = session - state.add_borrow() - return sender + self._log[__name__].info('Creating new CDN client') + client = TelegramBaseClient( + session, self.api_id, self.api_hash, + proxy=self._sender.connection.conn.proxy, + timeout=self._sender.connection.get_timeout() + ) - async def _return_exported_sender(self: 'TelegramClient', sender): - """ - Returns a borrowed exported sender. If all borrows have - been returned, the sender is cleanly disconnected. - """ - async with self._borrow_sender_lock: - self._log[__name__].debug('Returning borrowed sender for dc_id %d', sender.dc_id) - state, _ = self._borrowed_senders[sender.dc_id] - state.add_return() + # 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) + return client - async def _clean_exported_senders(self: 'TelegramClient'): - """ - Cleans-up all unused exported senders by disconnecting them. - """ - async with self._borrow_sender_lock: - for dc_id, (state, sender) in self._borrowed_senders.items(): - if state.should_disconnect(): - self._log[__name__].info( - 'Disconnecting borrowed sender for DC %d', dc_id) - # Disconnect should never raise - await sender.disconnect() - state.mark_disconnected() +@abc.abstractmethod +def __call__(self: 'TelegramClient', request, ordered=False): + raise NotImplementedError - async def _get_cdn_client(self: 'TelegramClient', cdn_redirect): - """Similar to ._borrow_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() - await session.set_dc(dc.id, dc.ip_address, dc.port) - self._exported_sessions[cdn_redirect.dc_id] = session +@abc.abstractmethod +def _handle_update(self: 'TelegramClient', update): + raise NotImplementedError - self._log[__name__].info('Creating new CDN client') - client = TelegramBaseClient( - session, self.api_id, self.api_hash, - proxy=self._sender.connection.conn.proxy, - timeout=self._sender.connection.get_timeout() - ) +@abc.abstractmethod +def _update_loop(self: 'TelegramClient'): + raise NotImplementedError - # 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) - return client - - # endregion - - # region Invoking Telegram requests - - @abc.abstractmethod - def __call__(self: 'TelegramClient', request, 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. - - flood_sleep_threshold (`int` | `None`, optional): - The flood sleep threshold to use for this request. This overrides - the default value stored in - `client.flood_sleep_threshold ` - - Returns: - The result of the request (often a `TLObject`) or a list of - results if more than one request was given. - """ - raise NotImplementedError - - @abc.abstractmethod - def _handle_update(self: 'TelegramClient', update): - raise NotImplementedError - - @abc.abstractmethod - def _update_loop(self: 'TelegramClient'): - raise NotImplementedError - - @abc.abstractmethod - async def _handle_auto_reconnect(self: 'TelegramClient'): - raise NotImplementedError - - # endregion +@abc.abstractmethod +async def _handle_auto_reconnect(self: 'TelegramClient'): + raise NotImplementedError diff --git a/telethon/client/telegramclient.py b/telethon/client/telegramclient.py index 144a6b2f..f1fbaa82 100644 --- a/telethon/client/telegramclient.py +++ b/telethon/client/telegramclient.py @@ -1,13 +1,3845 @@ +import functools +import inspect +import typing + from . import ( - AccountMethods, AuthMethods, DownloadMethods, DialogMethods, ChatMethods, - BotMethods, MessageMethods, UploadMethods, ButtonMethods, UpdateMethods, - MessageParseMethods, UserMethods, TelegramBaseClient + account, auth, bots, buttons, chats, dialogs, downloads, messageparse, messages, + telegrambaseclient, updates, uploads, users ) +from .. import helpers -class TelegramClient( - AccountMethods, AuthMethods, DownloadMethods, DialogMethods, ChatMethods, - BotMethods, MessageMethods, UploadMethods, ButtonMethods, UpdateMethods, - MessageParseMethods, UserMethods, TelegramBaseClient -): - pass +class TelegramClient: + """ + Arguments + 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 hash 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. It **must** be a type. + + 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` | `list` | `dict`, optional): + An iterable consisting of the proxy info. If `connection` is + one of `MTProxy`, then it should contain MTProxy credentials: + ``('hostname', port, 'secret')``. Otherwise, it's meant to store + function parameters for PySocks, like ``(type, 'hostname', port)``. + See https://github.com/Anorov/PySocks#usage-1 for more. + + local_addr (`str` | `tuple`, optional): + Local host address (and port, optionally) used to bind the socket to locally. + You only need to use this if you have multiple network cards and + want to use a specific one. + + timeout (`int` | `float`, optional): + The timeout in seconds to be used when connecting. + This is **not** the timeout to be used when ``await``'ing for + invoked requests, and you should use ``asyncio.wait`` or + ``asyncio.wait_for`` for that. + + request_retries (`int` | `None`, optional): + How many times a request should be retried. Request are retried + when Telegram is having internal issues (due to either + ``errors.ServerError`` or ``errors.RpcCallFailError``), + when there is a ``errors.FloodWaitError`` less than + `flood_sleep_threshold`, or when there's a migrate error. + + May take a negative or `None` value for infinite retries, but + this is not recommended, since some requests can always trigger + a call fail (such as searching for messages). + + connection_retries (`int` | `None`, optional): + How many times the reconnection should retry, either on the + initial connection or when Telegram disconnects us. May be + set to a negative or `None` value for infinite retries, but + this is not recommended, since the program can get stuck in an + infinite loop. + + retry_delay (`int` | `float`, optional): + The delay in seconds to sleep between automatic reconnections. + + auto_reconnect (`bool`, optional): + Whether reconnection should be retried `connection_retries` + times automatically if Telegram disconnects us or not. + + sequential_updates (`bool`, optional): + By default every incoming update will create a new task, so + you can handle several updates in parallel. Some scripts need + the order in which updates are processed to be sequential, and + this setting allows them to do so. + + If set to `True`, incoming updates will be put in a queue + and processed sequentially. This means your event handlers + should *not* perform long-running operations since new + updates are put inside of an unbounded queue. + + flood_sleep_threshold (`int` | `float`, optional): + The threshold below which the library should automatically + sleep on flood wait and slow mode wait errors (inclusive). For instance, if a + ``FloodWaitError`` for 17s occurs and `flood_sleep_threshold` + is 20s, the library will ``sleep`` automatically. If the error + was for 21s, it would ``raise FloodWaitError`` instead. Values + larger than a day (like ``float('inf')``) will be changed to a day. + + raise_last_call_error (`bool`, optional): + When API calls fail in a way that causes Telethon to retry + automatically, should the RPC error of the last attempt be raised + instead of a generic ValueError. This is mostly useful for + detecting when Telegram has internal issues. + + device_model (`str`, optional): + "Device model" to be sent when creating the initial connection. + Defaults to 'PC (n)bit' derived from ``platform.uname().machine``, or its direct value if unknown. + + system_version (`str`, optional): + "System version" to be sent when creating the initial connection. + Defaults to ``platform.uname().release`` stripped of everything ahead of -. + + 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`. + + loop (`asyncio.AbstractEventLoop`, optional): + Asyncio event loop to use. Defaults to `asyncio.get_event_loop()`. + This argument is ignored. + + base_logger (`str` | `logging.Logger`, optional): + Base logger name or instance to use. + If a `str` is given, it'll be passed to `logging.getLogger()`. If a + `logging.Logger` is given, it'll be used directly. If something + else or nothing is given, the default logger will be used. + + receive_updates (`bool`, optional): + Whether the client will receive updates or not. By default, updates + will be received from Telegram as they occur. + + Turning this off means that Telegram will not send updates at all + so event handlers, conversations, and QR login will not work. + However, certain scripts don't need updates, so this will reduce + the amount of bandwidth used. + """ + + # region Account + + def takeout( + self: 'TelegramClient', + finalize: bool = True, + *, + contacts: bool = None, + users: bool = None, + chats: bool = None, + megagroups: bool = None, + channels: bool = None, + files: bool = None, + max_file_size: bool = None) -> 'TelegramClient': + """ + Returns a :ref:`telethon-client` which calls methods behind a takeout session. + + It does so by creating a proxy object over the current client through + which making requests will use :tl:`InvokeWithTakeoutRequest` to wrap + them. In other words, returns the current client modified so that + requests are done as a takeout: + + Some of the calls made through the takeout session will have lower + flood limits. This is useful if you want to export the data from + conversations or mass-download media, since the rate limits will + be lower. Only some requests will be affected, and you will need + to adjust the `wait_time` of methods like `client.iter_messages + `. + + By default, all parameters are `None`, and you need to enable those + you plan to use by setting them to either `True` or `False`. + + You should ``except errors.TakeoutInitDelayError as e``, since this + exception will raise depending on the condition of the session. You + can then access ``e.seconds`` to know how long you should wait for + before calling the method again. + + There's also a `success` property available in the takeout proxy + object, so from the `with` body you can set the boolean result that + will be sent back to Telegram. But if it's left `None` as by + default, then the action is based on the `finalize` parameter. If + it's `True` then the takeout will be finished, and if no exception + occurred during it, then `True` will be considered as a result. + Otherwise, the takeout will not be finished and its ID will be + preserved for future usage as `client.session.takeout_id + `. + + Arguments + finalize (`bool`): + Whether the takeout session should be finalized upon + exit or not. + + contacts (`bool`): + Set to `True` if you plan on downloading contacts. + + users (`bool`): + Set to `True` if you plan on downloading information + from users and their private conversations with you. + + chats (`bool`): + Set to `True` if you plan on downloading information + from small group chats, such as messages and media. + + megagroups (`bool`): + Set to `True` if you plan on downloading information + from megagroups (channels), such as messages and media. + + channels (`bool`): + Set to `True` if you plan on downloading information + from broadcast channels, such as messages and media. + + files (`bool`): + Set to `True` if you plan on downloading media and + you don't only wish to export messages. + + max_file_size (`int`): + The maximum file size, in bytes, that you plan + to download for each message with media. + + Example + .. code-block:: python + + from telethon import errors + + try: + async with client.takeout() as takeout: + await client.get_messages('me') # normal call + await takeout.get_messages('me') # wrapped through takeout (less limits) + + async for message in takeout.iter_messages(chat, wait_time=0): + ... # Do something with the message + + except errors.TakeoutInitDelayError as e: + print('Must wait', e.seconds, 'before takeout') + """ + return account.takeout(**locals()) + + async def end_takeout(self: 'TelegramClient', success: bool) -> bool: + """ + Finishes the current takeout session. + + Arguments + success (`bool`): + Whether the takeout completed successfully or not. + + Returns + `True` if the operation was successful, `False` otherwise. + + Example + .. code-block:: python + + await client.end_takeout(success=False) + """ + return await account.end_takeout(**locals()) + + # endregion Account + + # region Auth + + def start( + self: 'TelegramClient', + phone: typing.Callable[[], str] = lambda: input('Please enter your phone (or bot token): '), + password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '), + *, + bot_token: str = None, + force_sms: bool = False, + code_callback: typing.Callable[[], typing.Union[str, int]] = None, + first_name: str = 'New User', + last_name: str = '', + max_attempts: int = 3) -> 'TelegramClient': + """ + Starts the client (connects and logs in if necessary). + + By default, this method will be interactive (asking for + user input if needed), and will handle 2FA if enabled too. + + 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. + + If the event loop is already running, this method returns a + coroutine that you should await on your own code; otherwise + the loop is ran until said coroutine completes. + + Arguments + phone (`str` | `int` | `callable`): + The phone (or callable without arguments to get it) + to which the code will be sent. If a bot-token-like + string is given, it will be used as such instead. + The argument may be a coroutine. + + password (`str`, `callable`, optional): + The password for 2 Factor Authentication (2FA). + This is only required if it is enabled in your account. + The argument may be a coroutine. + + 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()`. + The argument may be a coroutine. + + 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. + + max_attempts (`int`, optional): + How many times the code/password callback should be + retried or switching between signing in and signing up. + + Returns + This `TelegramClient`, so initialization + can be chained with ``.start()``. + + Example + .. code-block:: python + + client = TelegramClient('anon', api_id, api_hash) + + # Starting as a bot account + await client.start(bot_token=bot_token) + + # Starting as a user account + await client.start(phone) + # Please enter the code you received: 12345 + # Please enter your password: ******* + # (You are now logged in) + + # Starting using a context manager (this calls start()): + with client: + pass + """ + return auth.start(**locals()) + + async def sign_in( + self: 'TelegramClient', + phone: str = None, + code: typing.Union[str, int] = None, + *, + password: str = None, + bot_token: str = None, + phone_code_hash: str = None) -> 'typing.Union[types.User, types.auth.SentCode]': + """ + Logs in to Telegram to an existing user or bot account. + + You should only use this if you are not authorized yet. + + This method will send the code if it's not provided. + + .. note:: + + In most cases, you should simply use `start()` and not this method. + + Arguments + 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`, optional): + The hash returned by `send_code_request`. This can be left as + `None` to use the last hash known for the phone to be used. + + Returns + The signed in user, or the information about + :meth:`send_code_request`. + + Example + .. code-block:: python + + phone = '+34 123 123 123' + await client.sign_in(phone) # send code + + code = input('enter code: ') + await client.sign_in(phone, code) + """ + return auth.sign_in(**locals()) + + async def sign_up( + self: 'TelegramClient', + code: typing.Union[str, int], + first_name: str, + last_name: str = '', + *, + phone: str = None, + phone_code_hash: str = None) -> 'types.User': + """ + Signs up to Telegram as a new user account. + + Use this if you don't have an account yet. + + You must call `send_code_request` 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. + + Arguments + 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. + + phone (`str` | `int`, optional): + The phone to sign up. This will be the last phone used by + default (you normally don't need to set this). + + phone_code_hash (`str`, optional): + The hash returned by `send_code_request`. This can be left as + `None` to use the last hash known for the phone to be used. + + Returns + The new created :tl:`User`. + + Example + .. code-block:: python + + phone = '+34 123 123 123' + await client.send_code_request(phone) + + code = input('enter code: ') + await client.sign_up(code, first_name='Anna', last_name='Banana') + """ + return auth.sign_up(**locals()) + + async def send_code_request( + self: 'TelegramClient', + phone: str, + *, + force_sms: bool = False) -> 'types.auth.SentCode': + """ + Sends the Telegram code needed to login to the given phone number. + + Arguments + 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`. + + Example + .. code-block:: python + + phone = '+34 123 123 123' + sent = await client.send_code_request(phone) + print(sent) + """ + return auth.send_code_request(**locals()) + + async def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> custom.QRLogin: + """ + Initiates the QR login procedure. + + Note that you must be connected before invoking this, as with any + other request. + + It is up to the caller to decide how to present the code to the user, + whether it's the URL, using the token bytes directly, or generating + a QR code and displaying it by other means. + + See the documentation for `QRLogin` to see how to proceed after this. + + Arguments + ignored_ids (List[`int`]): + List of already logged-in user IDs, to prevent logging in + twice with the same user. + + Returns + An instance of `QRLogin`. + + Example + .. code-block:: python + + def display_url_as_qr(url): + pass # do whatever to show url as a qr to the user + + qr_login = await client.qr_login() + display_url_as_qr(qr_login.url) + + # Important! You need to wait for the login to complete! + await qr_login.wait() + """ + return auth.qr_login(**locals()) + + async def log_out(self: 'TelegramClient') -> bool: + """ + Logs out Telegram and deletes the current ``*.session`` file. + + Returns + `True` if the operation was successful. + + Example + .. code-block:: python + + # Note: you will need to login again! + await client.log_out() + """ + return auth.log_out(**locals()) + + async def edit_2fa( + self: 'TelegramClient', + current_password: str = None, + new_password: str = None, + *, + hint: str = '', + email: str = None, + email_code_callback: typing.Callable[[int], str] = None) -> bool: + """ + Changes the 2FA settings of the logged in user. + + Review carefully the parameter explanations before using this method. + + Note that this method may be *incredibly* slow depending on the + prime numbers that must be used during the process to make sure + that everything is safe. + + Has no effect if both current and new password are omitted. + + Arguments + 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. If present, you must also + set `email_code_callback`, else it raises ``ValueError``. + + email_code_callback (`callable`, optional): + If an email is provided, a callback that returns the code sent + to it must also be set. This callback may be asynchronous. + It should return a string with the code. The length of the + code will be passed to the callback as an input parameter. + + If the callback returns an invalid code, it will raise + ``CodeInvalidError``. + + Returns + `True` if successful, `False` otherwise. + + Example + .. code-block:: python + + # Setting a password for your account which didn't have + await client.edit_2fa(new_password='I_<3_Telethon') + + # Removing the password + await client.edit_2fa(current_password='I_<3_Telethon') + """ + return auth.edit_2fa(**locals()) + + async def __aenter__(self): + return await self.start() + + async def __aexit__(self, *args): + await self.disconnect() + + __enter__ = helpers._sync_enter + __exit__ = helpers._sync_exit + + # endregion Auth + + # region Bots + + async def inline_query( + self: 'TelegramClient', + bot: 'hints.EntityLike', + query: str, + *, + entity: 'hints.EntityLike' = None, + offset: str = None, + geo_point: 'types.GeoPoint' = None) -> custom.InlineResults: + """ + Makes an inline query to the specified bot (``@vote New Poll``). + + Arguments + bot (`entity`): + The bot entity to which the inline query should be made. + + query (`str`): + The query that should be made to the bot. + + entity (`entity`, optional): + The entity where the inline query is being made from. Certain + bots use this to display different results depending on where + it's used, such as private chats, groups or channels. + + If specified, it will also be the default entity where the + message will be sent after clicked. Otherwise, the "empty + peer" will be used, which some bots may not handle correctly. + + offset (`str`, optional): + The string offset to use for the bot. + + geo_point (:tl:`GeoPoint`, optional) + The geo point location information to send to the bot + for localised results. Available under some bots. + + Returns + A list of `custom.InlineResult + `. + + Example + .. code-block:: python + + # Make an inline query to @like + results = await client.inline_query('like', 'Do you like Telethon?') + + # Send the first result to some chat + message = await results[0].click('TelethonOffTopic') + """ + return bots.inline_query(**locals()) + + # endregion Bots + + # region Buttons + + @staticmethod + def build_reply_markup( + buttons: 'typing.Optional[hints.MarkupLike]', + inline_only: bool = False) -> 'typing.Optional[types.TypeReplyMarkup]': + """ + Builds a :tl:`ReplyInlineMarkup` or :tl:`ReplyKeyboardMarkup` for + the given buttons. + + Does nothing if either no buttons are provided or the provided + argument is already a reply markup. + + You should consider using this method if you are going to reuse + the markup very often. Otherwise, it is not necessary. + + This method is **not** asynchronous (don't use ``await`` on it). + + Arguments + buttons (`hints.MarkupLike`): + The button, list of buttons, array of buttons or markup + to convert into a markup. + + inline_only (`bool`, optional): + Whether the buttons **must** be inline buttons only or not. + + Example + .. code-block:: python + + from telethon import Button + + markup = client.build_reply_markup(Button.inline('hi')) + # later + await client.send_message(chat, 'click me', buttons=markup) + """ + return buttons.build_reply_markup(**locals()) + + + # endregion Buttons + + # region Chats + + def iter_participants( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = None, + *, + search: str = '', + filter: 'types.TypeChannelParticipantsFilter' = None, + aggressive: bool = False) -> _ParticipantsIter: + """ + Iterator over the participants belonging to the specified chat. + + The order is unspecified. + + Arguments + 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. + + If ``aggressive is True``, the symbols from this string will + be used. + + 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. + + .. note:: + + The filter :tl:`ChannelParticipantsBanned` will return + *restricted* users. If you want *banned* users you should + use :tl:`ChannelParticipantsKicked` instead. + + aggressive (`bool`, optional): + Aggressively looks for all participants in the chat. + + This is useful for channels since 20 July 2018, + Telegram added a server-side limit where only the + first 200 members can be retrieved. With this flag + set, more than 200 will be often be retrieved. + + This has no effect if a ``filter`` is given. + + 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. + + Example + .. code-block:: python + + # Show all user IDs in a chat + async for user in client.iter_participants(chat): + print(user.id) + + # Search by name + async for user in client.iter_participants(chat, search='name'): + print(user.username) + + # Filter by admins + from telethon.tl.types import ChannelParticipantsAdmins + async for user in client.iter_participants(chat, filter=ChannelParticipantsAdmins): + print(user.first_name) + """ + return chats.iter_participants(**locals()) + + async def get_participants( + self: 'TelegramClient', + *args, + **kwargs) -> 'hints.TotalList': + """ + Same as `iter_participants()`, but returns a + `TotalList ` instead. + + Example + .. code-block:: python + + users = await client.get_participants(chat) + print(users[0].first_name) + + for user in users: + if user.username is not None: + print(user.username) + """ + return chats.get_participants(*args, **kwargs) + + get_participants.__signature__ = inspect.signature(iter_participants) + + def iter_admin_log( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = None, + *, + max_id: int = 0, + min_id: int = 0, + search: str = None, + admins: 'hints.EntitiesLike' = None, + join: bool = None, + leave: bool = None, + invite: bool = None, + restrict: bool = None, + unrestrict: bool = None, + ban: bool = None, + unban: bool = None, + promote: bool = None, + demote: bool = None, + info: bool = None, + settings: bool = None, + pinned: bool = None, + edit: bool = None, + delete: bool = None, + group_call: bool = None) -> _AdminLogIter: + """ + Iterator over the admin log for the specified channel. + + The default order is from the most recent event to to the oldest. + + Note that you must be an administrator of it to use this method. + + If none of the filters are present (i.e. they all are `None`), + *all* event types will be returned. If at least one of them is + `True`, only those that are true will be returned. + + Arguments + entity (`entity`): + The channel entity from which to get its admin log. + + limit (`int` | `None`, optional): + Number of events to be retrieved. + + The limit may also be `None`, which would eventually return + the whole history. + + max_id (`int`): + All the events with a higher (newer) ID or equal to this will + be excluded. + + min_id (`int`): + All the events with a lower (older) ID or equal to this will + be excluded. + + search (`str`): + The string to be used as a search query. + + admins (`entity` | `list`): + If present, the events will be filtered by these admins + (or single admin) and only those caused by them will be + returned. + + join (`bool`): + If `True`, events for when a user joined will be returned. + + leave (`bool`): + If `True`, events for when a user leaves will be returned. + + invite (`bool`): + If `True`, events for when a user joins through an invite + link will be returned. + + restrict (`bool`): + If `True`, events with partial restrictions will be + returned. This is what the API calls "ban". + + unrestrict (`bool`): + If `True`, events removing restrictions will be returned. + This is what the API calls "unban". + + ban (`bool`): + If `True`, events applying or removing all restrictions will + be returned. This is what the API calls "kick" (restricting + all permissions removed is a ban, which kicks the user). + + unban (`bool`): + If `True`, events removing all restrictions will be + returned. This is what the API calls "unkick". + + promote (`bool`): + If `True`, events with admin promotions will be returned. + + demote (`bool`): + If `True`, events with admin demotions will be returned. + + info (`bool`): + If `True`, events changing the group info will be returned. + + settings (`bool`): + If `True`, events changing the group settings will be + returned. + + pinned (`bool`): + If `True`, events of new pinned messages will be returned. + + edit (`bool`): + If `True`, events of message edits will be returned. + + delete (`bool`): + If `True`, events of message deletions will be returned. + + group_call (`bool`): + If `True`, events related to group calls will be returned. + + Yields + Instances of `AdminLogEvent `. + + Example + .. code-block:: python + + async for event in client.iter_admin_log(channel): + if event.changed_title: + print('The title changed from', event.old, 'to', event.new) + """ + return chats.iter_admin_log(**locals()) + + async def get_admin_log( + self: 'TelegramClient', + *args, + **kwargs) -> 'hints.TotalList': + """ + Same as `iter_admin_log()`, but returns a ``list`` instead. + + Example + .. code-block:: python + + # Get a list of deleted message events which said "heck" + events = await client.get_admin_log(channel, search='heck', delete=True) + + # Print the old message before it was deleted + print(events[0].old) + """ + return chats.get_admin_log(*args, **kwargs) + + get_admin_log.__signature__ = inspect.signature(iter_admin_log) + + def iter_profile_photos( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: int = None, + *, + offset: int = 0, + max_id: int = 0) -> _ProfilePhotoIter: + """ + Iterator over a user's profile photos or a chat's photos. + + The order is from the most recent photo to the oldest. + + Arguments + entity (`entity`): + The entity from which to get the profile or chat photos. + + limit (`int` | `None`, optional): + Number of photos to be retrieved. + + The limit may also be `None`, which would eventually all + the photos that are still available. + + offset (`int`): + How many photos should be skipped before returning the first one. + + max_id (`int`): + The maximum ID allowed when fetching photos. + + Yields + Instances of :tl:`Photo`. + + Example + .. code-block:: python + + # Download all the profile photos of some user + async for photo in client.iter_profile_photos(user): + await client.download_media(photo) + """ + return chats.iter_profile_photos(**locals()) + + async def get_profile_photos( + self: 'TelegramClient', + *args, + **kwargs) -> 'hints.TotalList': + """ + Same as `iter_profile_photos()`, but returns a + `TotalList ` instead. + + Example + .. code-block:: python + + # Get the photos of a channel + photos = await client.get_profile_photos(channel) + + # Download the oldest photo + await client.download_media(photos[-1]) + """ + return chats.get_profile_photos(*args, **kwargs) + + get_profile_photos.__signature__ = inspect.signature(iter_profile_photos) + + def action( + self: 'TelegramClient', + entity: 'hints.EntityLike', + action: 'typing.Union[str, types.TypeSendMessageAction]', + *, + delay: float = 4, + auto_cancel: bool = True) -> 'typing.Union[_ChatAction, typing.Coroutine]': + """ + Returns a context-manager object to represent a "chat action". + + Chat actions indicate things like "user is typing", "user is + uploading a photo", etc. + + If the action is ``'cancel'``, you should just ``await`` the result, + since it makes no sense to use a context-manager for it. + + See the example below for intended usage. + + Arguments + entity (`entity`): + The entity where the action should be showed in. + + action (`str` | :tl:`SendMessageAction`): + The action to show. You can either pass a instance of + :tl:`SendMessageAction` or better, a string used while: + + * ``'typing'``: typing a text message. + * ``'contact'``: choosing a contact. + * ``'game'``: playing a game. + * ``'location'``: choosing a geo location. + * ``'sticker'``: choosing a sticker. + * ``'record-audio'``: recording a voice note. + You may use ``'record-voice'`` as alias. + * ``'record-round'``: recording a round video. + * ``'record-video'``: recording a normal video. + * ``'audio'``: sending an audio file (voice note or song). + You may use ``'voice'`` and ``'song'`` as aliases. + * ``'round'``: uploading a round video. + * ``'video'``: uploading a video file. + * ``'photo'``: uploading a photo. + * ``'document'``: uploading a document file. + You may use ``'file'`` as alias. + * ``'cancel'``: cancel any pending action in this chat. + + Invalid strings will raise a ``ValueError``. + + delay (`int` | `float`): + The delay, in seconds, to wait between sending actions. + For example, if the delay is 5 and it takes 7 seconds to + do something, three requests will be made at 0s, 5s, and + 7s to cancel the action. + + auto_cancel (`bool`): + Whether the action should be cancelled once the context + manager exists or not. The default is `True`, since + you don't want progress to be shown when it has already + completed. + + Returns + Either a context-manager object or a coroutine. + + Example + .. code-block:: python + + # Type for 2 seconds, then send a message + async with client.action(chat, 'typing'): + await asyncio.sleep(2) + await client.send_message(chat, 'Hello world! I type slow ^^') + + # Cancel any previous action + await client.action(chat, 'cancel') + + # Upload a document, showing its progress (most clients ignore this) + async with client.action(chat, 'document') as action: + await client.send_file(chat, zip_file, progress_callback=action.progress) + """ + return chats.action(**locals()) + + async def edit_admin( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'hints.EntityLike', + *, + change_info: bool = None, + post_messages: bool = None, + edit_messages: bool = None, + delete_messages: bool = None, + ban_users: bool = None, + invite_users: bool = None, + pin_messages: bool = None, + add_admins: bool = None, + manage_call: bool = None, + anonymous: bool = None, + is_admin: bool = None, + title: str = None) -> types.Updates: + """ + Edits admin permissions for someone in a chat. + + Raises an error if a wrong combination of rights are given + (e.g. you don't have enough permissions to grant one). + + Unless otherwise stated, permissions will work in channels and megagroups. + + Arguments + entity (`entity`): + The channel, megagroup or chat where the promotion should happen. + + user (`entity`): + The user to be promoted. + + change_info (`bool`, optional): + Whether the user will be able to change info. + + post_messages (`bool`, optional): + Whether the user will be able to post in the channel. + This will only work in broadcast channels. + + edit_messages (`bool`, optional): + Whether the user will be able to edit messages in the channel. + This will only work in broadcast channels. + + delete_messages (`bool`, optional): + Whether the user will be able to delete messages. + + ban_users (`bool`, optional): + Whether the user will be able to ban users. + + invite_users (`bool`, optional): + Whether the user will be able to invite users. Needs some testing. + + pin_messages (`bool`, optional): + Whether the user will be able to pin messages. + + add_admins (`bool`, optional): + Whether the user will be able to add admins. + + manage_call (`bool`, optional): + Whether the user will be able to manage group calls. + + anonymous (`bool`, optional): + Whether the user will remain anonymous when sending messages. + The sender of the anonymous messages becomes the group itself. + + .. note:: + + Users may be able to identify the anonymous admin by its + custom title, so additional care is needed when using both + ``anonymous`` and custom titles. For example, if multiple + anonymous admins share the same title, users won't be able + to distinguish them. + + is_admin (`bool`, optional): + Whether the user will be an admin in the chat. + This will only work in small group chats. + Whether the user will be an admin in the chat. This is the + only permission available in small group chats, and when + used in megagroups, all non-explicitly set permissions will + have this value. + + Essentially, only passing ``is_admin=True`` will grant all + permissions, but you can still disable those you need. + + title (`str`, optional): + The custom title (also known as "rank") to show for this admin. + This text will be shown instead of the "admin" badge. + This will only work in channels and megagroups. + + When left unspecified or empty, the default localized "admin" + badge will be shown. + + Returns + The resulting :tl:`Updates` object. + + Example + .. code-block:: python + + # Allowing `user` to pin messages in `chat` + await client.edit_admin(chat, user, pin_messages=True) + + # Granting all permissions except for `add_admins` + await client.edit_admin(chat, user, is_admin=True, add_admins=False) + """ + return chats.edit_admin(**locals()) + + async def edit_permissions( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'typing.Optional[hints.EntityLike]' = None, + until_date: 'hints.DateLike' = None, + *, + view_messages: bool = True, + send_messages: bool = True, + send_media: bool = True, + send_stickers: bool = True, + send_gifs: bool = True, + send_games: bool = True, + send_inline: bool = True, + embed_link_previews: bool = True, + send_polls: bool = True, + change_info: bool = True, + invite_users: bool = True, + pin_messages: bool = True) -> types.Updates: + """ + Edits user restrictions in a chat. + + Set an argument to `False` to apply a restriction (i.e. remove + the permission), or omit them to use the default `True` (i.e. + don't apply a restriction). + + Raises an error if a wrong combination of rights are given + (e.g. you don't have enough permissions to revoke one). + + By default, each boolean argument is `True`, meaning that it + is true that the user has access to the default permission + and may be able to make use of it. + + If you set an argument to `False`, then a restriction is applied + regardless of the default permissions. + + It is important to note that `True` does *not* mean grant, only + "don't restrict", and this is where the default permissions come + in. A user may have not been revoked the ``pin_messages`` permission + (it is `True`) but they won't be able to use it if the default + permissions don't allow it either. + + Arguments + entity (`entity`): + The channel or megagroup where the restriction should happen. + + user (`entity`, optional): + If specified, the permission will be changed for the specific user. + If left as `None`, the default chat permissions will be updated. + + until_date (`DateLike`, optional): + When the user will be unbanned. + + If the due date or duration is longer than 366 days or shorter than + 30 seconds, the ban will be forever. Defaults to ``0`` (ban forever). + + view_messages (`bool`, optional): + Whether the user is able to view messages or not. + Forbidding someone from viewing messages equals to banning them. + This will only work if ``user`` is set. + + send_messages (`bool`, optional): + Whether the user is able to send messages or not. + + send_media (`bool`, optional): + Whether the user is able to send media or not. + + send_stickers (`bool`, optional): + Whether the user is able to send stickers or not. + + send_gifs (`bool`, optional): + Whether the user is able to send animated gifs or not. + + send_games (`bool`, optional): + Whether the user is able to send games or not. + + send_inline (`bool`, optional): + Whether the user is able to use inline bots or not. + + embed_link_previews (`bool`, optional): + Whether the user is able to enable the link preview in the + messages they send. Note that the user will still be able to + send messages with links if this permission is removed, but + these links won't display a link preview. + + send_polls (`bool`, optional): + Whether the user is able to send polls or not. + + change_info (`bool`, optional): + Whether the user is able to change info or not. + + invite_users (`bool`, optional): + Whether the user is able to invite other users or not. + + pin_messages (`bool`, optional): + Whether the user is able to pin messages or not. + + Returns + The resulting :tl:`Updates` object. + + Example + .. code-block:: python + + from datetime import timedelta + + # Banning `user` from `chat` for 1 minute + await client.edit_permissions(chat, user, timedelta(minutes=1), + view_messages=False) + + # Banning `user` from `chat` forever + await client.edit_permissions(chat, user, view_messages=False) + + # Kicking someone (ban + un-ban) + await client.edit_permissions(chat, user, view_messages=False) + await client.edit_permissions(chat, user) + """ + return chats.edit_permissions(**locals()) + + async def kick_participant( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'typing.Optional[hints.EntityLike]' + ): + """ + Kicks a user from a chat. + + Kicking yourself (``'me'``) will result in leaving the chat. + + .. note:: + + Attempting to kick someone who was banned will remove their + restrictions (and thus unbanning them), since kicking is just + ban + unban. + + Arguments + entity (`entity`): + The channel or chat where the user should be kicked from. + + user (`entity`, optional): + The user to kick. + + Returns + Returns the service `Message ` + produced about a user being kicked, if any. + + Example + .. code-block:: python + + # Kick some user from some chat, and deleting the service message + msg = await client.kick_participant(chat, user) + await msg.delete() + + # Leaving chat + await client.kick_participant(chat, 'me') + """ + return chats.kick_participant(**locals()) + + async def get_permissions( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'hints.EntityLike' = None + ) -> 'typing.Optional[custom.ParticipantPermissions]': + """ + Fetches the permissions of a user in a specific chat or channel or + get Default Restricted Rights of Chat or Channel. + + .. note:: + + This request has to fetch the entire chat for small group chats, + which can get somewhat expensive, so use of a cache is advised. + + Arguments + entity (`entity`): + The channel or chat the user is participant of. + + user (`entity`, optional): + Target user. + + Returns + A `ParticipantPermissions ` + instance. Refer to its documentation to see what properties are + available. + + Example + .. code-block:: python + + permissions = await client.get_permissions(chat, user) + if permissions.is_admin: + # do something + + # Get Banned Permissions of Chat + await client.get_permissions(chat) + """ + return chats.get_permissions(**locals()) + + async def get_stats( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Union[int, types.Message]' = None, + ): + """ + Retrieves statistics from the given megagroup or broadcast channel. + + Note that some restrictions apply before being able to fetch statistics, + in particular the channel must have enough members (for megagroups, this + requires `at least 500 members`_). + + Arguments + entity (`entity`): + The channel from which to get statistics. + + message (`int` | ``Message``, optional): + The message ID from which to get statistics, if your goal is + to obtain the statistics of a single message. + + Raises + If the given entity is not a channel (broadcast or megagroup), + a `TypeError` is raised. + + If there are not enough members (poorly named) errors such as + ``telethon.errors.ChatAdminRequiredError`` will appear. + + Returns + If both ``entity`` and ``message`` were provided, returns + :tl:`MessageStats`. Otherwise, either :tl:`BroadcastStats` or + :tl:`MegagroupStats`, depending on whether the input belonged to a + broadcast channel or megagroup. + + Example + .. code-block:: python + + # Some megagroup or channel username or ID to fetch + channel = -100123 + stats = await client.get_stats(channel) + print('Stats from', stats.period.min_date, 'to', stats.period.max_date, ':') + print(stats.stringify()) + + .. _`at least 500 members`: https://telegram.org/blog/profile-videos-people-nearby-and-more + """ + return chats.get_stats(**locals()) + + # endregion Chats + + # region Dialogs + + def iter_dialogs( + self: 'TelegramClient', + limit: float = None, + *, + offset_date: 'hints.DateLike' = None, + offset_id: int = 0, + offset_peer: 'hints.EntityLike' = types.InputPeerEmpty(), + ignore_pinned: bool = False, + ignore_migrated: bool = False, + folder: int = None, + archived: bool = None + ) -> _DialogsIter: + """ + Iterator over the dialogs (open conversations/subscribed channels). + + The order is the same as the one seen in official applications + (first pinned, them from those with the most recent message to + those with the oldest message). + + Arguments + 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. + + ignore_pinned (`bool`, optional): + Whether pinned dialogs should be ignored or not. + When set to `True`, these won't be yielded at all. + + ignore_migrated (`bool`, optional): + Whether :tl:`Chat` that have ``migrated_to`` a :tl:`Channel` + should be included or not. By default all the chats in your + dialogs are returned, but setting this to `True` will ignore + (i.e. skip) them in the same way official applications do. + + folder (`int`, optional): + The folder from which the dialogs should be retrieved. + + If left unspecified, all dialogs (including those from + folders) will be returned. + + If set to ``0``, all dialogs that don't belong to any + folder will be returned. + + If set to a folder number like ``1``, only those from + said folder will be returned. + + By default Telegram assigns the folder ID ``1`` to + archived chats, so you should use that if you need + to fetch the archived dialogs. + + archived (`bool`, optional): + Alias for `folder`. If unspecified, all will be returned, + `False` implies ``folder=0`` and `True` implies ``folder=1``. + Yields + Instances of `Dialog `. + + Example + .. code-block:: python + + # Print all dialog IDs and the title, nicely formatted + async for dialog in client.iter_dialogs(): + print('{:>14}: {}'.format(dialog.id, dialog.title)) + """ + return dialogs.iter_dialogs(**locals()) + + async def get_dialogs(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': + """ + Same as `iter_dialogs()`, but returns a + `TotalList ` instead. + + Example + .. code-block:: python + + # Get all open conversation, print the title of the first + dialogs = await client.get_dialogs() + first = dialogs[0] + print(first.title) + + # Use the dialog somewhere else + await client.send_message(first, 'hi') + + # Getting only non-archived dialogs (both equivalent) + non_archived = await client.get_dialogs(folder=0) + non_archived = await client.get_dialogs(archived=False) + + # Getting only archived dialogs (both equivalent) + archived = await client.get_dialogs(folder=1) + archived = await client.get_dialogs(archived=True) + """ + return dialogs.get_dialogs(*args, **kwargs) + + get_dialogs.__signature__ = inspect.signature(iter_dialogs) + + def iter_drafts( + self: 'TelegramClient', + entity: 'hints.EntitiesLike' = None + ) -> _DraftsIter: + """ + Iterator over draft messages. + + The order is unspecified. + + Arguments + entity (`hints.EntitiesLike`, optional): + The entity or entities for which to fetch the draft messages. + If left unspecified, all draft messages will be returned. + + Yields + Instances of `Draft `. + + Example + .. code-block:: python + + # Clear all drafts + async for draft in client.get_drafts(): + await draft.delete() + + # Getting the drafts with 'bot1' and 'bot2' + async for draft in client.iter_drafts(['bot1', 'bot2']): + print(draft.text) + """ + return dialogs.iter_drafts(**locals()) + + async def get_drafts( + self: 'TelegramClient', + entity: 'hints.EntitiesLike' = None + ) -> 'hints.TotalList': + """ + Same as `iter_drafts()`, but returns a list instead. + + Example + .. code-block:: python + + # Get drafts, print the text of the first + drafts = await client.get_drafts() + print(drafts[0].text) + + # Get the draft in your chat + draft = await client.get_drafts('me') + print(drafts.text) + """ + return dialogs.get_drafts(**locals()) + + async def edit_folder( + self: 'TelegramClient', + entity: 'hints.EntitiesLike' = None, + folder: typing.Union[int, typing.Sequence[int]] = None, + *, + unpack=None + ) -> types.Updates: + """ + Edits the folder used by one or more dialogs to archive them. + + Arguments + entity (entities): + The entity or list of entities to move to the desired + archive folder. + + folder (`int`): + The folder to which the dialog should be archived to. + + If you want to "archive" a dialog, use ``folder=1``. + + If you want to "un-archive" it, use ``folder=0``. + + You may also pass a list with the same length as + `entities` if you want to control where each entity + will go. + + unpack (`int`, optional): + If you want to unpack an archived folder, set this + parameter to the folder number that you want to + delete. + + When you unpack a folder, all the dialogs inside are + moved to the folder number 0. + + You can only use this parameter if the other two + are not set. + + Returns + The :tl:`Updates` object that the request produces. + + Example + .. code-block:: python + + # Archiving the first 5 dialogs + dialogs = await client.get_dialogs(5) + await client.edit_folder(dialogs, 1) + + # Un-archiving the third dialog (archiving to folder 0) + await client.edit_folder(dialog[2], 0) + + # Moving the first dialog to folder 0 and the second to 1 + dialogs = await client.get_dialogs(2) + await client.edit_folder(dialogs, [0, 1]) + + # Un-archiving all dialogs + await client.edit_folder(unpack=1) + """ + return dialogs.edit_folder(**locals()) + + async def delete_dialog( + self: 'TelegramClient', + entity: 'hints.EntityLike', + *, + revoke: bool = False + ): + """ + Deletes a dialog (leaves a chat or channel). + + This method can be used as a user and as a bot. However, + bots will only be able to use it to leave groups and channels + (trying to delete a private conversation will do nothing). + + See also `Dialog.delete() `. + + Arguments + entity (entities): + The entity of the dialog to delete. If it's a chat or + channel, you will leave it. Note that the chat itself + is not deleted, only the dialog, because you left it. + + revoke (`bool`, optional): + On private chats, you may revoke the messages from + the other peer too. By default, it's `False`. Set + it to `True` to delete the history for both. + + This makes no difference for bot accounts, who can + only leave groups and channels. + + Returns + The :tl:`Updates` object that the request produces, + or nothing for private conversations. + + Example + .. code-block:: python + + # Deleting the first dialog + dialogs = await client.get_dialogs(5) + await client.delete_dialog(dialogs[0]) + + # Leaving a channel by username + await client.delete_dialog('username') + """ + return dialogs.delete_dialog(**locals()) + + def conversation( + self: 'TelegramClient', + entity: 'hints.EntityLike', + *, + timeout: float = 60, + total_timeout: float = None, + max_messages: int = 100, + exclusive: bool = True, + replies_are_responses: bool = True) -> custom.Conversation: + """ + Creates a `Conversation ` + with the given entity. + + .. note:: + + This Conversation API has certain shortcomings, such as lacking + persistence, poor interaction with other event handlers, and + overcomplicated usage for anything beyond the simplest case. + + If you plan to interact with a bot without handlers, this works + fine, but when running a bot yourself, you may instead prefer + to follow the advice from https://stackoverflow.com/a/62246569/. + + This is not the same as just sending a message to create a "dialog" + with them, but rather a way to easily send messages and await for + responses or other reactions. Refer to its documentation for more. + + Arguments + entity (`entity`): + The entity with which a new conversation should be opened. + + timeout (`int` | `float`, optional): + The default timeout (in seconds) *per action* to be used. You + may also override this timeout on a per-method basis. By + default each action can take up to 60 seconds (the value of + this timeout). + + total_timeout (`int` | `float`, optional): + The total timeout (in seconds) to use for the whole + conversation. This takes priority over per-action + timeouts. After these many seconds pass, subsequent + actions will result in ``asyncio.TimeoutError``. + + max_messages (`int`, optional): + The maximum amount of messages this conversation will + remember. After these many messages arrive in the + specified chat, subsequent actions will result in + ``ValueError``. + + exclusive (`bool`, optional): + By default, conversations are exclusive within a single + chat. That means that while a conversation is open in a + chat, you can't open another one in the same chat, unless + you disable this flag. + + If you try opening an exclusive conversation for + a chat where it's already open, it will raise + ``AlreadyInConversationError``. + + replies_are_responses (`bool`, optional): + Whether replies should be treated as responses or not. + + If the setting is enabled, calls to `conv.get_response + ` + and a subsequent call to `conv.get_reply + ` + will return different messages, otherwise they may return + the same message. + + Consider the following scenario with one outgoing message, + 1, and two incoming messages, the second one replying:: + + Hello! <1 + 2> (reply to 1) Hi! + 3> (reply to 1) How are you? + + And the following code: + + .. code-block:: python + + async with client.conversation(chat) as conv: + msg1 = await conv.send_message('Hello!') + msg2 = await conv.get_response() + msg3 = await conv.get_reply() + + With the setting enabled, ``msg2`` will be ``'Hi!'`` and + ``msg3`` be ``'How are you?'`` since replies are also + responses, and a response was already returned. + + With the setting disabled, both ``msg2`` and ``msg3`` will + be ``'Hi!'`` since one is a response and also a reply. + + Returns + A `Conversation `. + + Example + .. code-block:: python + + # denotes outgoing messages you sent + # denotes incoming response messages + with bot.conversation(chat) as conv: + # Hi! + conv.send_message('Hi!') + + # Hello! + hello = conv.get_response() + + # Please tell me your name + conv.send_message('Please tell me your name') + + # ? + name = conv.get_response().raw_text + + while not any(x.isalpha() for x in name): + # Your name didn't have any letters! Try again + conv.send_message("Your name didn't have any letters! Try again") + + # Human + name = conv.get_response().raw_text + + # Thanks Human! + conv.send_message('Thanks {}!'.format(name)) + """ + return dialogs.conversation(**locals()) + + # endregion Dialogs + + # region Downloads + + async def download_profile_photo( + self: 'TelegramClient', + entity: 'hints.EntityLike', + file: 'hints.FileLike' = None, + *, + download_big: bool = True) -> typing.Optional[str]: + """ + Downloads the profile photo from the given user, chat or channel. + + Arguments + entity (`entity`): + From who the photo will be downloaded. + + .. note:: + + This method expects the full entity (which has the data + to download the photo), not an input variant. + + It's possible that sometimes you can't fetch the entity + from its input (since you can get errors like + ``ChannelPrivateError``) but you already have it through + another call, like getting a forwarded message from it. + + 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 file is the type `bytes`, it will be downloaded in-memory + as a bytestring (e.g. ``file=bytes``). + + 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. + + Example + .. code-block:: python + + # Download your own profile photo + path = await client.download_profile_photo('me') + print(path) + """ + return downloads.download_profile_photo(**locals()) + + async def download_media( + self: 'TelegramClient', + message: 'hints.MessageLike', + file: 'hints.FileLike' = None, + *, + thumb: 'typing.Union[int, types.TypePhotoSize]' = None, + progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]: + """ + Downloads the given media from a message object. + + 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). + + See also `Message.download_media() `. + + Arguments + message (`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. + If file is the type `bytes`, it will be downloaded in-memory + as a bytestring (e.g. ``file=bytes``). + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(received bytes, total)``. + + thumb (`int` | :tl:`PhotoSize`, optional): + Which thumbnail size from the document or photo to download, + instead of downloading the document or photo itself. + + If it's specified but the file does not have a thumbnail, + this method will return `None`. + + The parameter should be an integer index between ``0`` and + ``len(sizes)``. ``0`` will download the smallest thumbnail, + and ``len(sizes) - 1`` will download the largest thumbnail. + You can also use negative indices, which work the same as + they do in Python's `list`. + + You can also pass the :tl:`PhotoSize` instance to use. + Alternatively, the thumb size type `str` may be used. + + In short, use ``thumb=0`` if you want the smallest thumbnail + and ``thumb=-1`` if you want the largest thumbnail. + + .. note:: + The largest thumbnail may be a video instead of a photo, + as they are available since layer 116 and are bigger than + any of the photos. + + 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. + + Example + .. code-block:: python + + path = await client.download_media(message) + await client.download_media(message, filename) + # or + path = await message.download_media() + await message.download_media(filename) + + # Printing download progress + def callback(current, total): + print('Downloaded', current, 'out of', total, + 'bytes: {:.2%}'.format(current / total)) + + await client.download_media(message, progress_callback=callback) + """ + return downloads.download_media(**locals()) + + async def download_file( + self: 'TelegramClient', + input_location: 'hints.FileLike', + file: 'hints.OutFileLike' = None, + *, + part_size_kb: float = None, + file_size: int = None, + progress_callback: 'hints.ProgressCallback' = None, + dc_id: int = None, + key: bytes = None, + iv: bytes = None) -> typing.Optional[bytes]: + """ + Low-level method to download files from their input location. + + .. note:: + + Generally, you should instead use `download_media`. + This method is intended to be a bit more low-level. + + Arguments + input_location (: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` or `bytes`, 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``. + + dc_id (`int`, optional): + The data center the library should connect to in order + to download the file. You shouldn't worry about this. + + key ('bytes', optional): + In case of an encrypted upload (secret chats) a key is supplied + + iv ('bytes', optional): + In case of an encrypted upload (secret chats) an iv is supplied + + + Example + .. code-block:: python + + # Download a file and print its header + data = await client.download_file(input_file, bytes) + print(data[:16]) + """ + return downloads.download_file(**locals()) + + def iter_download( + self: 'TelegramClient', + file: 'hints.FileLike', + *, + offset: int = 0, + stride: int = None, + limit: int = None, + chunk_size: int = None, + request_size: int = MAX_CHUNK_SIZE, + file_size: int = None, + dc_id: int = None + ): + """ + Iterates over a file download, yielding chunks of the file. + + This method can be used to stream files in a more convenient + way, since it offers more control (pausing, resuming, etc.) + + .. note:: + + Using a value for `offset` or `stride` which is not a multiple + of the minimum allowed `request_size`, or if `chunk_size` is + different from `request_size`, the library will need to do a + bit more work to fetch the data in the way you intend it to. + + You normally shouldn't worry about this. + + Arguments + file (`hints.FileLike`): + The file of which contents you want to iterate over. + + offset (`int`, optional): + The offset in bytes into the file from where the + download should start. For example, if a file is + 1024KB long and you just want the last 512KB, you + would use ``offset=512 * 1024``. + + stride (`int`, optional): + The stride of each chunk (how much the offset should + advance between reading each chunk). This parameter + should only be used for more advanced use cases. + + It must be bigger than or equal to the `chunk_size`. + + limit (`int`, optional): + The limit for how many *chunks* will be yielded at most. + + chunk_size (`int`, optional): + The maximum size of the chunks that will be yielded. + Note that the last chunk may be less than this value. + By default, it equals to `request_size`. + + request_size (`int`, optional): + How many bytes will be requested to Telegram when more + data is required. By default, as many bytes as possible + are requested. If you would like to request data in + smaller sizes, adjust this parameter. + + Note that values outside the valid range will be clamped, + and the final value will also be a multiple of the minimum + allowed size. + + file_size (`int`, optional): + If the file size is known beforehand, you should set + this parameter to said value. Depending on the type of + the input file passed, this may be set automatically. + + dc_id (`int`, optional): + The data center the library should connect to in order + to download the file. You shouldn't worry about this. + + Yields + + `bytes` objects representing the chunks of the file if the + right conditions are met, or `memoryview` objects instead. + + Example + .. code-block:: python + + # Streaming `media` to an output file + # After the iteration ends, the sender is cleaned up + with open('photo.jpg', 'wb') as fd: + async for chunk in client.iter_download(media): + fd.write(chunk) + + # Fetching only the header of a file (32 bytes) + # You should manually close the iterator in this case. + # + # "stream" is a common name for asynchronous generators, + # and iter_download will yield `bytes` (chunks of the file). + stream = client.iter_download(media, request_size=32) + header = await stream.__anext__() # "manual" version of `async for` + await stream.close() + assert len(header) == 32 + """ + return downloads.iter_download(**locals()) + + # endregion Downloads + + # region Message parse + + @property + def parse_mode(self: 'TelegramClient'): + """ + 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. + + Example + .. code-block:: python + + # Disabling default formatting + client.parse_mode = None + + # Enabling HTML as the default format + client.parse_mode = 'html' + """ + return messageparse.get_parse_mode(**locals()) + + @parse_mode.setter + def parse_mode(self: 'TelegramClient', mode: str): + return messageparse.set_parse_mode(**locals()) + + # endregion Message parse + + # region Messages + + def iter_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = None, + *, + offset_date: 'hints.DateLike' = None, + offset_id: int = 0, + max_id: int = 0, + min_id: int = 0, + add_offset: int = 0, + search: str = None, + filter: 'typing.Union[types.TypeMessagesFilter, typing.Type[types.TypeMessagesFilter]]' = None, + from_user: 'hints.EntityLike' = None, + wait_time: float = None, + ids: 'typing.Union[int, typing.Sequence[int]]' = None, + reverse: bool = False, + reply_to: int = None, + scheduled: bool = False + ) -> 'typing.Union[_MessagesIter, _IDsIter]': + """ + Iterator over the messages for the given chat. + + The default order is from newest to oldest, but this + behaviour can be changed with the `reverse` parameter. + + If either `search`, `filter` or `from_user` are provided, + :tl:`messages.Search` will be used instead of :tl:`messages.getHistory`. + + .. note:: + + Telegram's flood wait limit for :tl:`GetHistoryRequest` seems to + be around 30 seconds per 10 requests, therefore a sleep of 1 + second is the default for this limit (or above). + + Arguments + entity (`entity`): + The entity from whom to retrieve the message history. + + It may be `None` to perform a global search, or + to get messages by their ID from no particular chat. + Note that some of the offsets will not work if this + is the case. + + Note that if you want to perform a global search, + you **must** set a non-empty `search` string, a `filter`. + or `from_user`. + + 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 entity will be returned. + + wait_time (`int`): + Wait time (in seconds) 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. + + If the ``ids`` parameter is used, this time will default + to 10 seconds only if the amount of IDs is higher than 300. + + 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. + + .. note:: + + At the time of writing, Telegram will **not** return + :tl:`MessageEmpty` for :tl:`InputMessageReplyTo` IDs that + failed (i.e. the message is not replying to any, or is + replying to a deleted message). This means that it is + **not** possible to match messages one-by-one, so be + careful if you use non-integers in this parameter. + + reverse (`bool`, optional): + If set to `True`, the messages will be returned in reverse + order (from oldest to newest, instead of the default newest + to oldest). This also means that the meaning of `offset_id` + and `offset_date` parameters is reversed, although they will + still be exclusive. `min_id` becomes equivalent to `offset_id` + instead of being `max_id` as well since messages are returned + in ascending order. + + You cannot use this if both `entity` and `ids` are `None`. + + reply_to (`int`, optional): + If set to a message ID, the messages that reply to this ID + will be returned. This feature is also known as comments in + posts of broadcast channels, or viewing threads in groups. + + This feature can only be used in broadcast channels and their + linked megagroups. Using it in a chat or private conversation + will result in ``telethon.errors.PeerIdInvalidError`` to occur. + + When using this parameter, the ``filter`` and ``search`` + parameters have no effect, since Telegram's API doesn't + support searching messages in replies. + + .. note:: + + This feature is used to get replies to a message in the + *discussion* group. If the same broadcast channel sends + a message and replies to it itself, that reply will not + be included in the results. + + scheduled (`bool`, optional): + If set to `True`, messages which are scheduled will be returned. + All other parameter will be ignored for this, except `entity`. + + Yields + Instances of `Message `. + + Example + .. code-block:: python + + # From most-recent to oldest + async for message in client.iter_messages(chat): + print(message.id, message.text) + + # From oldest to most-recent + async for message in client.iter_messages(chat, reverse=True): + print(message.id, message.text) + + # Filter by sender + async for message in client.iter_messages(chat, from_user='me'): + print(message.text) + + # Server-side search with fuzzy text + async for message in client.iter_messages(chat, search='hello'): + print(message.id) + + # Filter by message type: + from telethon.tl.types import InputMessagesFilterPhotos + async for message in client.iter_messages(chat, filter=InputMessagesFilterPhotos): + print(message.photo) + + # Getting comments from a post in a channel: + async for message in client.iter_messages(channel, reply_to=123): + print(message.chat.title, message.text) + """ + return messages.iter_messages(**locals()) + + async def get_messages(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': + """ + Same as `iter_messages()`, but returns a + `TotalList ` instead. + + 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 `Message ` will be + returned for convenience instead of a list. + + Example + .. code-block:: python + + # Get 0 photos and print the total to show how many photos there are + from telethon.tl.types import InputMessagesFilterPhotos + photos = await client.get_messages(chat, 0, filter=InputMessagesFilterPhotos) + print(photos.total) + + # Get all the photos + photos = await client.get_messages(chat, None, filter=InputMessagesFilterPhotos) + + # Get messages by ID: + message_1337 = await client.get_messages(chat, ids=1337) + """ + return messages.get_messages(**locals()) + + get_messages.__signature__ = inspect.signature(iter_messages) + + async def send_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'hints.MessageLike' = '', + *, + reply_to: 'typing.Union[int, types.Message]' = None, + attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, + parse_mode: typing.Optional[str] = (), + formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, + link_preview: bool = True, + file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]' = None, + thumb: 'hints.FileLike' = None, + force_document: bool = False, + clear_draft: bool = False, + buttons: 'hints.MarkupLike' = None, + silent: bool = None, + background: bool = None, + supports_streaming: bool = False, + schedule: 'hints.DateLike' = None, + comment_to: 'typing.Union[int, types.Message]' = None + ) -> 'types.Message': + """ + Sends a message to the specified user, chat or 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. + + See also `Message.respond() ` + and `Message.reply() `. + + Arguments + entity (`entity`): + To who will it be sent. + + message (`str` | `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` | `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. + + attributes (`list`, optional): + Optional attributes that override the inferred ones, like + :tl:`DocumentAttributeFilename` and so on. + + parse_mode (`object`, optional): + See the `TelegramClient.parse_mode + ` + property for allowed values. Markdown parsing will be used by + default. + + formatting_entities (`list`, optional): + A list of message formatting entities. When provided, the ``parse_mode`` is ignored. + + 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. + + thumb (`str` | `bytes` | `file`, optional): + Optional JPEG thumbnail (for documents). **Telegram will + ignore this parameter** unless you pass a ``.jpg`` file! + The file must also be small in dimensions and in disk size. + Successful thumbnails were files below 20kB and 320x320px. + Width/height and dimensions/size ratios may be important. + For Telegram to accept a thumbnail, you must provide the + dimensions of the underlying media through ``attributes=`` + with :tl:`DocumentAttributesVideo` or by installing the + optional ``hachoir`` dependency. + + 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. + + buttons (`list`, `custom.Button `, :tl:`KeyboardButton`): + The matrix (list of lists), row list or button to be shown + after sending the message. This parameter will only work if + you have signed in as a bot. You can also pass your own + :tl:`ReplyMarkup` here. + + All the following limits apply together: + + * There can be 100 buttons at most (any more are ignored). + * There can be 8 buttons per row at most (more are ignored). + * The maximum callback data per button is 64 bytes. + * The maximum data that can be embedded in total is just + over 4KB, shared between inline callback data and text. + + silent (`bool`, optional): + Whether the message should notify people in a broadcast + channel or not. Defaults to `False`, which means it will + notify them. Set it to `True` to alter this behaviour. + + background (`bool`, optional): + Whether the message should be send in background. + + supports_streaming (`bool`, optional): + Whether the sent video supports streaming or not. Note that + Telegram only recognizes as streamable some formats like MP4, + and others like AVI or MKV will not work. You should convert + these to MP4 before sending if you want them to be streamable. + Unsupported formats will result in ``VideoContentTypeError``. + + schedule (`hints.DateLike`, optional): + If set, the message won't send immediately, and instead + it will be scheduled to be automatically sent at a later + time. + + comment_to (`int` | `Message `, optional): + Similar to ``reply_to``, but replies in the linked group of a + broadcast channel instead (effectively leaving a "comment to" + the specified message). + + This parameter takes precedence over ``reply_to``. If there is + no linked chat, `telethon.errors.sgIdInvalidError` is raised. + + Returns + The sent `custom.Message `. + + Example + .. code-block:: python + + # Markdown is the default + await client.send_message('me', 'Hello **world**!') + + # Default to another parse mode + client.parse_mode = 'html' + + await client.send_message('me', 'Some bold and italic text') + await client.send_message('me', 'An URL') + # code and pre tags also work, but those break the documentation :) + await client.send_message('me', 'Mentions') + + # Explicit parse mode + # No parse mode by default + client.parse_mode = None + + # ...but here I want markdown + await client.send_message('me', 'Hello, **world**!', parse_mode='md') + + # ...and here I need HTML + await client.send_message('me', 'Hello, world!', parse_mode='html') + + # If you logged in as a bot account, you can send buttons + from telethon import events, Button + + @client.on(events.CallbackQuery) + async def callback(event): + await event.edit('Thank you for clicking {}!'.format(event.data)) + + # Single inline button + await client.send_message(chat, 'A single button, with "clk1" as data', + buttons=Button.inline('Click me', b'clk1')) + + # Matrix of inline buttons + await client.send_message(chat, 'Pick one from this grid', buttons=[ + [Button.inline('Left'), Button.inline('Right')], + [Button.url('Check this site!', 'https://example.com')] + ]) + + # Reply keyboard + await client.send_message(chat, 'Welcome', buttons=[ + Button.text('Thanks!', resize=True, single_use=True), + Button.request_phone('Send phone'), + Button.request_location('Send location') + ]) + + # Forcing replies or clearing buttons. + await client.send_message(chat, 'Reply to me', buttons=Button.force_reply()) + await client.send_message(chat, 'Bye Keyboard!', buttons=Button.clear()) + + # Scheduling a message to be sent after 5 minutes + from datetime import timedelta + await client.send_message(chat, 'Hi, future!', schedule=timedelta(minutes=5)) + """ + return messages.send_message(**locals()) + + async def forward_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + messages: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', + from_peer: 'hints.EntityLike' = None, + *, + background: bool = None, + with_my_score: bool = None, + silent: bool = None, + as_album: bool = None, + schedule: 'hints.DateLike' = None + ) -> 'typing.Sequence[types.Message]': + """ + Forwards the given messages to the specified entity. + + If you want to "forward" a message without the forward header + (the "forwarded from" text), you should use `send_message` with + the original message instead. This will send a copy of it. + + See also `Message.forward_to() `. + + Arguments + entity (`entity`): + To which entity the message(s) will be forwarded. + + messages (`list` | `int` | `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. This parameter indicates + the entity from which the messages should be forwarded. + + silent (`bool`, optional): + Whether the message should notify people with sound or not. + Defaults to `False` (send with a notification sound unless + the person has the chat muted). Set it to `True` to alter + this behaviour. + + background (`bool`, optional): + Whether the message should be forwarded in background. + + with_my_score (`bool`, optional): + Whether forwarded should contain your game score. + + as_album (`bool`, optional): + This flag no longer has any effect. + + schedule (`hints.DateLike`, optional): + If set, the message(s) won't forward immediately, and + instead they will be scheduled to be automatically sent + at a later time. + + Returns + The list of forwarded `Message `, + or a single one if a list wasn't provided as input. + + Note that if all messages are invalid (i.e. deleted) the call + will fail with ``MessageIdInvalidError``. If only some are + invalid, the list will have `None` instead of those messages. + + Example + .. code-block:: python + + # a single one + await client.forward_messages(chat, message) + # or + await client.forward_messages(chat, message_id, from_chat) + # or + await message.forward_to(chat) + + # multiple + await client.forward_messages(chat, messages) + # or + await client.forward_messages(chat, message_ids, from_chat) + + # Forwarding as a copy + await client.send_message(chat, message) + """ + return messages.forward_messages(**locals()) + + async def edit_message( + self: 'TelegramClient', + entity: 'typing.Union[hints.EntityLike, types.Message]', + message: 'hints.MessageLike' = None, + text: str = None, + *, + parse_mode: str = (), + attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, + formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, + link_preview: bool = True, + file: 'hints.FileLike' = None, + thumb: 'hints.FileLike' = None, + force_document: bool = False, + buttons: 'hints.MarkupLike' = None, + supports_streaming: bool = False, + schedule: 'hints.DateLike' = None + ) -> 'types.Message': + """ + Edits the given message to change its text or media. + + See also `Message.edit() `. + + Arguments + entity (`entity` | `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. + + You may also pass a :tl:`InputBotInlineMessageID`, + which is the only way to edit messages that were sent + after the user selects an inline query result. + + message (`int` | `Message ` | `str`): + The ID of the message (or `Message + ` itself) to be edited. + If the `entity` was a `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 `Message `. + + parse_mode (`object`, optional): + See the `TelegramClient.parse_mode + ` + property for allowed values. Markdown parsing will be used by + default. + + attributes (`list`, optional): + Optional attributes that override the inferred ones, like + :tl:`DocumentAttributeFilename` and so on. + + formatting_entities (`list`, optional): + A list of message formatting entities. When provided, the ``parse_mode`` is ignored. + + 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. + + thumb (`str` | `bytes` | `file`, optional): + Optional JPEG thumbnail (for documents). **Telegram will + ignore this parameter** unless you pass a ``.jpg`` file! + The file must also be small in dimensions and in disk size. + Successful thumbnails were files below 20kB and 320x320px. + Width/height and dimensions/size ratios may be important. + For Telegram to accept a thumbnail, you must provide the + dimensions of the underlying media through ``attributes=`` + with :tl:`DocumentAttributesVideo` or by installing the + optional ``hachoir`` dependency. + + force_document (`bool`, optional): + Whether to send the given file as a document or not. + + buttons (`list`, `custom.Button `, :tl:`KeyboardButton`): + The matrix (list of lists), row list or button to be shown + after sending the message. This parameter will only work if + you have signed in as a bot. You can also pass your own + :tl:`ReplyMarkup` here. + + supports_streaming (`bool`, optional): + Whether the sent video supports streaming or not. Note that + Telegram only recognizes as streamable some formats like MP4, + and others like AVI or MKV will not work. You should convert + these to MP4 before sending if you want them to be streamable. + Unsupported formats will result in ``VideoContentTypeError``. + + schedule (`hints.DateLike`, optional): + If set, the message won't be edited immediately, and instead + it will be scheduled to be automatically edited at a later + time. + + Note that this parameter will have no effect if you are + trying to edit a message that was sent via inline bots. + + Returns + The edited `Message `, + unless `entity` was a :tl:`InputBotInlineMessageID` in which + case this method returns a boolean. + + 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. + + ``MessageIdInvalidError`` if the ID of the message is invalid + (the ID itself may be correct, but the message with that ID + cannot be edited). For example, when trying to edit messages + with a reply markup (or clear markup) this error will be raised. + + Example + .. code-block:: python + + message = await client.send_message(chat, 'hello') + + await client.edit_message(chat, message, 'hello!') + # or + await client.edit_message(chat, message.id, 'hello!!') + # or + await client.edit_message(message, 'hello!!!') + """ + return messages.edit_message(**locals()) + + async def delete_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message_ids: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', + *, + revoke: bool = True) -> 'typing.Sequence[types.messages.AffectedMessages]': + """ + Deletes the given messages, optionally "for everyone". + + See also `Message.delete() `. + + .. warning:: + + This method does **not** validate that the message IDs belong + to the chat that you passed! It's possible for the method to + delete messages from different private chats and small group + chats at once, so make sure to pass the right IDs. + + Arguments + 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` | `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. + + `Since 24 March 2019 + `_, you can + also revoke messages of any age (i.e. messages sent long in + the past) the *other* person sent in private conversations + (and of course your messages too). + + Disabling this has no effect on channels or megagroups, + since it will unconditionally delete the message for everyone. + + Returns + A list of :tl:`AffectedMessages`, each item being the result + for the delete calls of the messages in chunks of 100 each. + + Example + .. code-block:: python + + await client.delete_messages(chat, messages) + """ + return messages.delete_messages(**locals()) + + async def send_read_acknowledge( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]' = None, + *, + max_id: int = None, + clear_mentions: bool = False) -> bool: + """ + Marks messages as read and optionally clears mentions. + + This effectively marks a message as read (or more than one) in the + given conversation. + + If neither message nor maximum ID are provided, all messages will be + marked as read by assuming that ``max_id = 0``. + + If a message or maximum ID is provided, all the messages up to and + including such ID will be marked as read (for all messages whose ID + ≤ max_id). + + See also `Message.mark_read() `. + + Arguments + entity (`entity`): + The chat where these messages are located. + + message (`list` | `Message `): + Either a list of messages or a single message. + + max_id (`int`): + Until which message should the read acknowledge be sent for. + This has priority over the ``message`` parameter. + + 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. + + Example + .. code-block:: python + + # using a Message object + await client.send_read_acknowledge(chat, message) + # ...or using the int ID of a Message + await client.send_read_acknowledge(chat, message_id) + # ...or passing a list of messages to mark as read + await client.send_read_acknowledge(chat, messages) + """ + return messages.send_read_acknowledge(**locals()) + + async def pin_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Optional[hints.MessageIDLike]', + *, + notify: bool = False, + pm_oneside: bool = False + ): + """ + Pins a message in a chat. + + The default behaviour is to *not* notify members, unlike the + official applications. + + See also `Message.pin() `. + + Arguments + entity (`entity`): + The chat where the message should be pinned. + + message (`int` | `Message `): + The message or the message ID to pin. If it's + `None`, all messages will be unpinned instead. + + notify (`bool`, optional): + Whether the pin should notify people or not. + + pm_oneside (`bool`, optional): + Whether the message should be pinned for everyone or not. + By default it has the opposite behaviour of official clients, + and it will pin the message for both sides, in private chats. + + Example + .. code-block:: python + + # Send and pin a message to annoy everyone + message = await client.send_message(chat, 'Pinotifying is fun!') + await client.pin_message(chat, message, notify=True) + """ + return messages.pin_message(**locals()) + + async def unpin_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Optional[hints.MessageIDLike]' = None, + *, + notify: bool = False + ): + """ + Unpins a message in a chat. + + If no message ID is specified, all pinned messages will be unpinned. + + See also `Message.unpin() `. + + Arguments + entity (`entity`): + The chat where the message should be pinned. + + message (`int` | `Message `): + The message or the message ID to unpin. If it's + `None`, all messages will be unpinned instead. + + Example + .. code-block:: python + + # Unpin all messages from a chat + await client.unpin_message(chat) + """ + return messages.unpin_message(**locals()) + + # endregion Messages + + # region Base + + # Current TelegramClient version + __version__ = version.__version__ + + # Cached server configuration (with .dc_options), can be "global" + _config = None + _cdn_config = None + + def __init__( + self: 'TelegramClient', + session: 'typing.Union[str, Session]', + api_id: int, + api_hash: str, + *, + connection: 'typing.Type[Connection]' = ConnectionTcpFull, + use_ipv6: bool = False, + proxy: typing.Union[tuple, dict] = None, + local_addr: typing.Union[str, tuple] = None, + timeout: int = 10, + request_retries: int = 5, + connection_retries: int = 5, + retry_delay: int = 1, + auto_reconnect: bool = True, + sequential_updates: bool = False, + flood_sleep_threshold: int = 60, + raise_last_call_error: bool = False, + device_model: str = None, + system_version: str = None, + app_version: str = None, + lang_code: str = 'en', + system_lang_code: str = 'en', + loop: asyncio.AbstractEventLoop = None, + base_logger: typing.Union[str, logging.Logger] = None, + receive_updates: bool = True + ): + return telegrambaseclient.init(**locals()) + + @property + def loop(self: 'TelegramClient') -> asyncio.AbstractEventLoop: + """ + Property with the ``asyncio`` event loop used by this client. + + Example + .. code-block:: python + + # Download media in the background + task = client.loop.create_task(message.download_media()) + + # Do some work + ... + + # Join the task (wait for it to complete) + await task + """ + return telegrambaseclient.get_loop(**locals()) + + @property + def disconnected(self: 'TelegramClient') -> asyncio.Future: + """ + Property with a ``Future`` that resolves upon disconnection. + + Example + .. code-block:: python + + # Wait for a disconnection to occur + try: + await client.disconnected + except OSError: + print('Error on disconnect') + """ + return telegrambaseclient.get_disconnected(**locals()) + + @property + def flood_sleep_threshold(self): + return telegrambaseclient.get_flood_sleep_threshold(**locals()) + + @flood_sleep_threshold.setter + def flood_sleep_threshold(self, value): + return telegrambaseclient.set_flood_sleep_threshold(**locals()) + + async def connect(self: 'TelegramClient') -> None: + """ + Connects to Telegram. + + .. note:: + + Connect means connect and nothing else, and only one low-level + request is made to notify Telegram about which layer we will be + using. + + Before Telegram sends you updates, you need to make a high-level + request, like `client.get_me() `, + as described in https://core.telegram.org/api/updates. + + Example + .. code-block:: python + + try: + await client.connect() + except OSError: + print('Failed to connect') + """ + return telegrambaseclient.connect(**locals()) + + def is_connected(self: 'TelegramClient') -> bool: + """ + Returns `True` if the user has connected. + + This method is **not** asynchronous (don't use ``await`` on it). + + Example + .. code-block:: python + + while client.is_connected(): + await asyncio.sleep(1) + """ + return telegrambaseclient.is_connected(**locals()) + + def disconnect(self: 'TelegramClient'): + """ + Disconnects from Telegram. + + If the event loop is already running, this method returns a + coroutine that you should await on your own code; otherwise + the loop is ran until said coroutine completes. + + Example + .. code-block:: python + + # You don't need to use this if you used "with client" + await client.disconnect() + """ + return telegrambaseclient.disconnect(**locals()) + + def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): + """ + Changes the proxy which will be used on next (re)connection. + + Method has no immediate effects if the client is currently connected. + + The new proxy will take it's effect on the next reconnection attempt: + - on a call `await client.connect()` (after complete disconnect) + - on auto-reconnect attempt (e.g, after previous connection was lost) + """ + return telegrambaseclient.set_proxy(**locals()) + + # endregion Base + + # region Updates + + async def set_receive_updates(self: 'TelegramClient', receive_updates): + """ + Change the value of `receive_updates`. + + This is an `async` method, because in order for Telegram to start + sending updates again, a request must be made. + """ + return updates.set_receive_updates(**locals()) + + def run_until_disconnected(self: 'TelegramClient'): + """ + Runs the event loop until the library is disconnected. + + It also notifies Telegram that we want to receive updates + as described in https://core.telegram.org/api/updates. + + Manual disconnections can be made by calling `disconnect() + ` + or sending a ``KeyboardInterrupt`` (e.g. by pressing ``Ctrl+C`` on + the console window running the script). + + If a disconnection error occurs (i.e. the library fails to reconnect + automatically), said error will be raised through here, so you have a + chance to ``except`` it on your own code. + + If the loop is already running, this method returns a coroutine + that you should await on your own code. + + .. note:: + + If you want to handle ``KeyboardInterrupt`` in your code, + simply run the event loop in your code too in any way, such as + ``loop.run_forever()`` or ``await client.disconnected`` (e.g. + ``loop.run_until_complete(client.disconnected)``). + + Example + .. code-block:: python + + # Blocks the current task here until a disconnection occurs. + # + # You will still receive updates, since this prevents the + # script from exiting. + await client.run_until_disconnected() + """ + return updates.run_until_disconnected(**locals()) + + def on(self: 'TelegramClient', event: EventBuilder): + """ + Decorator used to `add_event_handler` more conveniently. + + + Arguments + event (`_EventBuilder` | `type`): + The event builder class or instance to be used, + for instance ``events.NewMessage``. + + Example + .. code-block:: python + + from telethon import TelegramClient, events + client = TelegramClient(...) + + # Here we use client.on + @client.on(events.NewMessage) + async def handler(event): + ... + """ + return updates.on(**locals()) + + def add_event_handler( + self: 'TelegramClient', + callback: Callback, + event: EventBuilder = None): + """ + Registers a new event handler callback. + + The callback will be called when the specified event occurs. + + Arguments + callback (`callable`): + The callable function accepting one parameter to be used. + + Note that if you have used `telethon.events.register` in + the callback, ``event`` will be ignored, and instead the + events you previously registered will 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. + + Example + .. code-block:: python + + from telethon import TelegramClient, events + client = TelegramClient(...) + + async def handler(event): + ... + + client.add_event_handler(handler, events.NewMessage) + """ + return updates.add_event_handler(**locals()) + + def remove_event_handler( + self: 'TelegramClient', + callback: Callback, + event: EventBuilder = None) -> int: + """ + Inverse operation of `add_event_handler()`. + + If no event is given, all events for this callback are removed. + Returns how many callbacks were removed. + + Example + .. code-block:: python + + @client.on(events.Raw) + @client.on(events.NewMessage) + async def handler(event): + ... + + # Removes only the "Raw" handling + # "handler" will still receive "events.NewMessage" + client.remove_event_handler(handler, events.Raw) + + # "handler" will stop receiving anything + client.remove_event_handler(handler) + """ + return updates.remove_event_handler(**locals()) + + def list_event_handlers(self: 'TelegramClient')\ + -> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]': + """ + Lists all registered event handlers. + + Returns + A list of pairs consisting of ``(callback, event)``. + + Example + .. code-block:: python + + @client.on(events.NewMessage(pattern='hello')) + async def on_greeting(event): + '''Greets someone''' + await event.reply('Hi') + + for callback, event in client.list_event_handlers(): + print(id(callback), type(event)) + """ + return updates.list_event_handlers(**locals()) + + async def catch_up(self: 'TelegramClient'): + """ + "Catches up" on the missed updates while the client was offline. + You should call this method after registering the event handlers + so that the updates it loads can by processed by your script. + + This can also be used to forcibly fetch new updates if there are any. + + Example + .. code-block:: python + + await client.catch_up() + """ + return updates.catch_up(**locals()) + + # endregion Updates + + # region Uploads + + async def send_file( + self: 'TelegramClient', + entity: 'hints.EntityLike', + file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]', + *, + caption: typing.Union[str, typing.Sequence[str]] = None, + force_document: bool = False, + file_size: int = None, + clear_draft: bool = False, + progress_callback: 'hints.ProgressCallback' = None, + reply_to: 'hints.MessageIDLike' = None, + attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, + thumb: 'hints.FileLike' = None, + allow_cache: bool = True, + parse_mode: str = (), + formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, + voice_note: bool = False, + video_note: bool = False, + buttons: 'hints.MarkupLike' = None, + silent: bool = None, + background: bool = None, + supports_streaming: bool = False, + schedule: 'hints.DateLike' = None, + comment_to: 'typing.Union[int, types.Message]' = None, + ttl: int = None, + **kwargs) -> 'types.Message': + """ + Sends message with the given file to the specified entity. + + .. note:: + + If the ``hachoir3`` package (``hachoir`` module) is installed, + it will be used to determine metadata from audio and video files. + + If the ``pillow`` package is installed and you are sending a photo, + it will be resized to fit within the maximum dimensions allowed + by Telegram to avoid ``errors.PhotoInvalidDimensionsError``. This + cannot be done if you are sending :tl:`InputFile`, however. + + Arguments + entity (`entity`): + Who will receive the file. + + file (`str` | `bytes` | `file` | `media`): + The file to send, which can be one of: + + * A local file path to an in-disk file. The file name + will be the path's base name. + + * A `bytes` byte array with the file's data to send + (for example, by using ``text.encode('utf-8')``). + A default file name will be used. + + * A bytes `io.IOBase` stream over the file to send + (for example, by using ``open(file, 'rb')``). + Its ``.name`` property will be used for the file name, + or a default if it doesn't have one. + + * An external URL to a file over the internet. This will + send the file as "external" media, and Telegram is the + one that will fetch the media and send it. + + * A Bot API-like ``file_id``. You can convert previously + sent media to file IDs for later reusing with + `telethon.utils.pack_bot_file_id`. + + * A handle to an existing file (for example, if you sent a + message with media before, you can use its ``message.media`` + as a file here). + + * A handle to an uploaded file (from `upload_file`). + + * A :tl:`InputMedia` instance. For example, if you want to + send a dice use :tl:`InputMediaDice`, or if you want to + send a contact use :tl:`InputMediaContact`. + + To send an album, you should provide a list in this parameter. + + 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. When sending an + album, the caption may be a list of strings, which will be + assigned to the files pairwise. + + 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. + + file_size (`int`, optional): + The size of the file to be uploaded if it needs to be uploaded, + which will be determined automatically if not specified. + + If the file size can't be determined beforehand, the entire + file will be read in-memory to find out how large it is. + + clear_draft (`bool`, optional): + Whether the existing draft should be cleared or not. + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(sent bytes, total)``. + + reply_to (`int` | `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 JPEG thumbnail (for documents). **Telegram will + ignore this parameter** unless you pass a ``.jpg`` file! + + The file must also be small in dimensions and in disk size. + Successful thumbnails were files below 20kB and 320x320px. + Width/height and dimensions/size ratios may be important. + For Telegram to accept a thumbnail, you must provide the + dimensions of the underlying media through ``attributes=`` + with :tl:`DocumentAttributesVideo` or by installing the + optional ``hachoir`` dependency. + + + allow_cache (`bool`, optional): + This parameter currently does nothing, but is kept for + backward-compatibility (and it may get its use back in + the future). + + parse_mode (`object`, optional): + See the `TelegramClient.parse_mode + ` + property for allowed values. Markdown parsing will be used by + default. + + formatting_entities (`list`, optional): + A list of message formatting entities. When provided, the ``parse_mode`` is ignored. + + voice_note (`bool`, optional): + If `True` the audio will be sent as a voice note. + + video_note (`bool`, optional): + If `True` the video will be sent as a video note, + also known as a round video message. + + buttons (`list`, `custom.Button `, :tl:`KeyboardButton`): + The matrix (list of lists), row list or button to be shown + after sending the message. This parameter will only work if + you have signed in as a bot. You can also pass your own + :tl:`ReplyMarkup` here. + + silent (`bool`, optional): + Whether the message should notify people with sound or not. + Defaults to `False` (send with a notification sound unless + the person has the chat muted). Set it to `True` to alter + this behaviour. + + background (`bool`, optional): + Whether the message should be send in background. + + supports_streaming (`bool`, optional): + Whether the sent video supports streaming or not. Note that + Telegram only recognizes as streamable some formats like MP4, + and others like AVI or MKV will not work. You should convert + these to MP4 before sending if you want them to be streamable. + Unsupported formats will result in ``VideoContentTypeError``. + + schedule (`hints.DateLike`, optional): + If set, the file won't send immediately, and instead + it will be scheduled to be automatically sent at a later + time. + + comment_to (`int` | `Message `, optional): + Similar to ``reply_to``, but replies in the linked group of a + broadcast channel instead (effectively leaving a "comment to" + the specified message). + + This parameter takes precedence over ``reply_to``. If there is + no linked chat, `telethon.errors.sgIdInvalidError` is raised. + + ttl (`int`. optional): + The Time-To-Live of the file (also known as "self-destruct timer" + or "self-destructing media"). If set, files can only be viewed for + a short period of time before they disappear from the message + history automatically. + + The value must be at least 1 second, and at most 60 seconds, + otherwise Telegram will ignore this parameter. + + Not all types of media can be used with this parameter, such + as text documents, which will fail with ``TtlMediaInvalidError``. + + Returns + The `Message ` (or messages) + containing the sent file, or messages if a list of them was passed. + + Example + .. code-block:: python + + # Normal files like photos + await client.send_file(chat, '/my/photos/me.jpg', caption="It's me!") + # or + await client.send_message(chat, "It's me!", file='/my/photos/me.jpg') + + # Voice notes or round videos + await client.send_file(chat, '/my/songs/song.mp3', voice_note=True) + await client.send_file(chat, '/my/videos/video.mp4', video_note=True) + + # Custom thumbnails + await client.send_file(chat, '/my/documents/doc.txt', thumb='photo.jpg') + + # Only documents + await client.send_file(chat, '/my/photos/photo.png', force_document=True) + + # Albums + await client.send_file(chat, [ + '/my/photos/holiday1.jpg', + '/my/photos/holiday2.jpg', + '/my/drawings/portrait.png' + ]) + + # Printing upload progress + def callback(current, total): + print('Uploaded', current, 'out of', total, + 'bytes: {:.2%}'.format(current / total)) + + await client.send_file(chat, file, progress_callback=callback) + + # Dices, including dart and other future emoji + from telethon.tl import types + await client.send_file(chat, types.InputMediaDice('')) + await client.send_file(chat, types.InputMediaDice('🎯')) + + # Contacts + await client.send_file(chat, types.InputMediaContact( + phone_number='+34 123 456 789', + first_name='Example', + last_name='', + vcard='' + )) + """ + return uploads.send_file(**locals()) + + async def upload_file( + self: 'TelegramClient', + file: 'hints.FileLike', + *, + part_size_kb: float = None, + file_size: int = None, + file_name: str = None, + use_cache: type = None, + key: bytes = None, + iv: bytes = None, + progress_callback: 'hints.ProgressCallback' = None) -> 'types.TypeInputFile': + """ + Uploads a file to Telegram's servers, without sending it. + + .. note:: + + Generally, you want to use `send_file` instead. + + This method 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. + + Arguments + 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_size (`int`, optional): + The size of the file to be uploaded, which will be determined + automatically if not specified. + + If the file size can't be determined beforehand, the entire + file will be read in-memory to find out how large it is. + + 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): + This parameter currently does nothing, but is kept for + backward-compatibility (and it may get its use back in + the future). + + key ('bytes', optional): + In case of an encrypted upload (secret chats) a key is supplied + + iv ('bytes', optional): + In case of an encrypted upload (secret chats) an iv is supplied + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(sent bytes, total)``. + + Returns + :tl:`InputFileBig` if the file size is larger than 10MB, + `InputSizedFile ` + (subclass of :tl:`InputFile`) otherwise. + + Example + .. code-block:: python + + # Photos as photo and document + file = await client.upload_file('photo.jpg') + await client.send_file(chat, file) # sends as photo + await client.send_file(chat, file, force_document=True) # sends as document + + file.name = 'not a photo.jpg' + await client.send_file(chat, file, force_document=True) # document, new name + + # As song or as voice note + file = await client.upload_file('song.ogg') + await client.send_file(chat, file) # sends as song + await client.send_file(chat, file, voice_note=True) # sends as voice note + """ + return uploads.upload_file(**locals()) + + # endregion Uploads + + # region Users + + def __call__(self: 'TelegramClient', request, 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. + + flood_sleep_threshold (`int` | `None`, optional): + The flood sleep threshold to use for this request. This overrides + the default value stored in + `client.flood_sleep_threshold ` + + Returns: + The result of the request (often a `TLObject`) or a list of + results if more than one request was given. + """ + return users.call(self._sender, request, ordered=ordered) + + async def get_me(self: 'TelegramClient', input_peer: bool = False) \ + -> 'typing.Union[types.User, types.InputPeerUser]': + """ + Gets "me", the current :tl:`User` who is logged in. + + If the user has not logged in yet, this method returns `None`. + + Arguments + 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`. + + Example + .. code-block:: python + + me = await client.get_me() + print(me.username) + """ + return users.get_me(**locals()) + + async def is_bot(self: 'TelegramClient') -> bool: + """ + Return `True` if the signed-in user is a bot, `False` otherwise. + + Example + .. code-block:: python + + if await client.is_bot(): + print('Beep') + else: + print('Hello') + """ + return users.is_bot(**locals()) + + async def is_user_authorized(self: 'TelegramClient') -> bool: + """ + Returns `True` if the user is authorized (logged in). + + Example + .. code-block:: python + + if not await client.is_user_authorized(): + await client.send_code_request(phone) + code = input('enter code: ') + await client.sign_in(phone, code) + """ + return users.is_user_authorized(**locals()) + + async def get_entity( + self: 'TelegramClient', + entity: 'hints.EntitiesLike') -> 'hints.Entity': + """ + Turns the given entity into a valid Telegram :tl:`User`, :tl:`Chat` + or :tl:`Channel`. You can also pass a list or iterable of entities, + and they will be efficiently fetched from the network. + + Arguments + entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): + If a username is given, **the username will be resolved** making + an API call every time. Resolving usernames is an expensive + operation and will start hitting flood waits around 50 usernames + in a short period of time. + + If you want to get the entity for a *cached* username, you should + first `get_input_entity(username) ` which will + use the cache), and then use `get_entity` with the result of the + previous call. + + Similar limits apply to invite links, and you should use their + ID instead. + + Using phone numbers (from people in your contact list), exact + names, integer IDs or :tl:`Peer` rely on a `get_input_entity` + first, which in turn needs the entity to be in cache, unless + a :tl:`InputPeer` was passed. + + Unsupported types will raise ``TypeError``. + + If the entity can't be found, ``ValueError`` 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. + + Example + .. code-block:: python + + from telethon import utils + + me = await client.get_entity('me') + print(utils.get_display_name(me)) + + chat = await client.get_input_entity('username') + async for message in client.iter_messages(chat): + ... + + # Note that you could have used the username directly, but it's + # good to use get_input_entity if you will reuse it a lot. + async for message in client.iter_messages('username'): + ... + + # Note that for this to work the phone number must be in your contacts + some_id = await client.get_peer_id('+34123456789') + """ + return users.get_entity(**locals()) + + async def get_input_entity( + self: 'TelegramClient', + peer: 'hints.EntityLike') -> 'types.TypeInputPeer': + """ + Turns the given entity into its input entity version. + + Most requests use this kind of :tl:`InputPeer`, so this is the most + suitable call to make for those cases. **Generally you should let the + library do its job** and don't worry about getting the input entity + first, but if you're going to use an entity often, consider making the + call: + + Arguments + entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): + If a username or invite link is given, **the library will + use the cache**. This means that it's possible to be using + a username that *changed* or an old invite link (this only + happens if an invite link for a small group chat is used + after it was upgraded to a mega-group). + + If the username or ID from the invite link is not found in + the cache, it will be fetched. The same rules apply to phone + numbers (``'+34 123456789'``) from people in your contact list. + + If an exact name is given, it must be in the cache too. This + is not reliable as different people can share the same name + and which entity is returned is arbitrary, and should be used + only for quick tests. + + If a positive integer ID is given, the entity will be searched + in cached users, chats or channels, without making any call. + + If a negative integer ID is given, the entity will be searched + exactly as either a chat (prefixed with ``-``) or as a channel + (prefixed with ``-100``). + + If a :tl:`Peer` is given, it will be searched exactly in the + cache as either a user, chat or channel. + + If the given object can be turned into an input entity directly, + said operation will be done. + + Unsupported types will raise ``TypeError``. + + If the entity can't be found, ``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. + + Example + .. code-block:: python + + # If you're going to use "username" often in your code + # (make a lot of calls), consider getting its input entity + # once, and then using the "user" everywhere instead. + user = await client.get_input_entity('username') + + # The same applies to IDs, chats or channels. + chat = await client.get_input_entity(-123456789) + """ + return users.get_input_entity(**locals()) + + async def get_peer_id( + self: 'TelegramClient', + peer: 'hints.EntityLike', + add_mark: bool = True) -> int: + """ + Gets the ID for the given entity. + + This method needs to be ``async`` because `peer` supports usernames, + invite-links, phone numbers (from people in your contact list), etc. + + If ``add_mark is False``, then a positive ID will be returned + instead. By default, bot-API style IDs (signed) are returned. + + Example + .. code-block:: python + + print(await client.get_peer_id('me')) + """ + return users.get_peer_id(**locals()) + + # endregion Users + +# TODO re-patch everything to remove the intermediate calls diff --git a/telethon/client/updates.py b/telethon/client/updates.py index bcc983f3..4860a8cd 100644 --- a/telethon/client/updates.py +++ b/telethon/client/updates.py @@ -18,616 +18,608 @@ if typing.TYPE_CHECKING: Callback = typing.Callable[[typing.Any], typing.Any] -class UpdateMethods: - # region Public methods +async def _run_until_disconnected(self: 'TelegramClient'): + try: + # Make a high-level request to notify that we want updates + await self(functions.updates.GetStateRequest()) + return await self.disconnected + except KeyboardInterrupt: + pass + finally: + await self.disconnect() - async def _run_until_disconnected(self: 'TelegramClient'): - try: - # Make a high-level request to notify that we want updates - await self(functions.updates.GetStateRequest()) - return await self.disconnected - except KeyboardInterrupt: - pass - finally: - await self.disconnect() +async def set_receive_updates(self: 'TelegramClient', receive_updates): + """ + Change the value of `receive_updates`. - async def set_receive_updates(self: 'TelegramClient', receive_updates): - """ - Change the value of `receive_updates`. + This is an `async` method, because in order for Telegram to start + sending updates again, a request must be made. + """ + self._no_updates = not receive_updates + if receive_updates: + await self(functions.updates.GetStateRequest()) - This is an `async` method, because in order for Telegram to start - sending updates again, a request must be made. - """ - self._no_updates = not receive_updates - if receive_updates: - await self(functions.updates.GetStateRequest()) +def run_until_disconnected(self: 'TelegramClient'): + """ + Runs the event loop until the library is disconnected. - def run_until_disconnected(self: 'TelegramClient'): - """ - Runs the event loop until the library is disconnected. + It also notifies Telegram that we want to receive updates + as described in https://core.telegram.org/api/updates. - It also notifies Telegram that we want to receive updates - as described in https://core.telegram.org/api/updates. + Manual disconnections can be made by calling `disconnect() + ` + or sending a ``KeyboardInterrupt`` (e.g. by pressing ``Ctrl+C`` on + the console window running the script). - Manual disconnections can be made by calling `disconnect() - ` - or sending a ``KeyboardInterrupt`` (e.g. by pressing ``Ctrl+C`` on - the console window running the script). + If a disconnection error occurs (i.e. the library fails to reconnect + automatically), said error will be raised through here, so you have a + chance to ``except`` it on your own code. - If a disconnection error occurs (i.e. the library fails to reconnect - automatically), said error will be raised through here, so you have a - chance to ``except`` it on your own code. + If the loop is already running, this method returns a coroutine + that you should await on your own code. - If the loop is already running, this method returns a coroutine - that you should await on your own code. + .. note:: - .. note:: + If you want to handle ``KeyboardInterrupt`` in your code, + simply run the event loop in your code too in any way, such as + ``loop.run_forever()`` or ``await client.disconnected`` (e.g. + ``loop.run_until_complete(client.disconnected)``). - If you want to handle ``KeyboardInterrupt`` in your code, - simply run the event loop in your code too in any way, such as - ``loop.run_forever()`` or ``await client.disconnected`` (e.g. - ``loop.run_until_complete(client.disconnected)``). + Example + .. code-block:: python - Example - .. code-block:: python + # Blocks the current task here until a disconnection occurs. + # + # You will still receive updates, since this prevents the + # script from exiting. + await client.run_until_disconnected() + """ + if self.loop.is_running(): + return self._run_until_disconnected() + try: + return self.loop.run_until_complete(self._run_until_disconnected()) + except KeyboardInterrupt: + pass + finally: + # No loop.run_until_complete; it's already syncified + self.disconnect() - # Blocks the current task here until a disconnection occurs. - # - # You will still receive updates, since this prevents the - # script from exiting. - await client.run_until_disconnected() - """ - if self.loop.is_running(): - return self._run_until_disconnected() - try: - return self.loop.run_until_complete(self._run_until_disconnected()) - except KeyboardInterrupt: - pass - finally: - # No loop.run_until_complete; it's already syncified - self.disconnect() - - def on(self: 'TelegramClient', event: EventBuilder): - """ - Decorator used to `add_event_handler` more conveniently. +def on(self: 'TelegramClient', event: EventBuilder): + """ + Decorator used to `add_event_handler` more conveniently. - Arguments - event (`_EventBuilder` | `type`): - The event builder class or instance to be used, - for instance ``events.NewMessage``. + Arguments + event (`_EventBuilder` | `type`): + The event builder class or instance to be used, + for instance ``events.NewMessage``. - Example - .. code-block:: python + Example + .. code-block:: python - from telethon import TelegramClient, events - client = TelegramClient(...) + from telethon import TelegramClient, events + client = TelegramClient(...) - # Here we use client.on - @client.on(events.NewMessage) - async def handler(event): - ... - """ - def decorator(f): - self.add_event_handler(f, event) - return f + # Here we use client.on + @client.on(events.NewMessage) + async def handler(event): + ... + """ + def decorator(f): + self.add_event_handler(f, event) + return f - return decorator + return decorator - def add_event_handler( - self: 'TelegramClient', - callback: Callback, - event: EventBuilder = None): - """ - Registers a new event handler callback. +def add_event_handler( + self: 'TelegramClient', + callback: Callback, + event: EventBuilder = None): + """ + Registers a new event handler callback. - The callback will be called when the specified event occurs. + The callback will be called when the specified event occurs. - Arguments - callback (`callable`): - The callable function accepting one parameter to be used. + Arguments + callback (`callable`): + The callable function accepting one parameter to be used. - Note that if you have used `telethon.events.register` in - the callback, ``event`` will be ignored, and instead the - events you previously registered will be used. + Note that if you have used `telethon.events.register` in + the callback, ``event`` will be ignored, and instead the + events you previously registered will be used. - event (`_EventBuilder` | `type`, optional): - The event builder class or instance to be used, - for instance ``events.NewMessage``. + 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 left unspecified, `telethon.events.raw.Raw` (the + :tl:`Update` objects with no further processing) will + be passed instead. - Example - .. code-block:: python + Example + .. code-block:: python - from telethon import TelegramClient, events - client = TelegramClient(...) + from telethon import TelegramClient, events + client = TelegramClient(...) - async def handler(event): - ... + async def handler(event): + ... - client.add_event_handler(handler, events.NewMessage) - """ - builders = events._get_handlers(callback) - if builders is not None: - for event in builders: - self._event_builders.append((event, callback)) - return + client.add_event_handler(handler, events.NewMessage) + """ + builders = events._get_handlers(callback) + if builders is not None: + for event in builders: + self._event_builders.append((event, callback)) + return - if isinstance(event, type): - event = event() - elif not event: - event = events.Raw() + if isinstance(event, type): + event = event() + elif not event: + event = events.Raw() - self._event_builders.append((event, callback)) + self._event_builders.append((event, callback)) - def remove_event_handler( - self: 'TelegramClient', - callback: Callback, - event: EventBuilder = None) -> int: - """ - Inverse operation of `add_event_handler()`. +def remove_event_handler( + self: 'TelegramClient', + callback: Callback, + event: EventBuilder = None) -> int: + """ + Inverse operation of `add_event_handler()`. - If no event is given, all events for this callback are removed. - Returns how many callbacks were removed. + If no event is given, all events for this callback are removed. + Returns how many callbacks were removed. - Example - .. code-block:: python + Example + .. code-block:: python - @client.on(events.Raw) - @client.on(events.NewMessage) - async def handler(event): - ... + @client.on(events.Raw) + @client.on(events.NewMessage) + async def handler(event): + ... - # Removes only the "Raw" handling - # "handler" will still receive "events.NewMessage" - client.remove_event_handler(handler, events.Raw) + # Removes only the "Raw" handling + # "handler" will still receive "events.NewMessage" + client.remove_event_handler(handler, events.Raw) - # "handler" will stop receiving anything - client.remove_event_handler(handler) - """ - found = 0 - if event and not isinstance(event, type): - event = type(event) + # "handler" will stop receiving anything + client.remove_event_handler(handler) + """ + 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 + 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 + return found - def list_event_handlers(self: 'TelegramClient')\ - -> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]': - """ - Lists all registered event handlers. +def list_event_handlers(self: 'TelegramClient')\ + -> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]': + """ + Lists all registered event handlers. - Returns - A list of pairs consisting of ``(callback, event)``. + Returns + A list of pairs consisting of ``(callback, event)``. - Example - .. code-block:: python + Example + .. code-block:: python - @client.on(events.NewMessage(pattern='hello')) - async def on_greeting(event): - '''Greets someone''' - await event.reply('Hi') + @client.on(events.NewMessage(pattern='hello')) + async def on_greeting(event): + '''Greets someone''' + await event.reply('Hi') - for callback, event in client.list_event_handlers(): - print(id(callback), type(event)) - """ - return [(callback, event) for event, callback in self._event_builders] + for callback, event in client.list_event_handlers(): + print(id(callback), type(event)) + """ + return [(callback, event) for event, callback in self._event_builders] - async def catch_up(self: 'TelegramClient'): - """ - "Catches up" on the missed updates while the client was offline. - You should call this method after registering the event handlers - so that the updates it loads can by processed by your script. +async def catch_up(self: 'TelegramClient'): + """ + "Catches up" on the missed updates while the client was offline. + You should call this method after registering the event handlers + so that the updates it loads can by processed by your script. - This can also be used to forcibly fetch new updates if there are any. + This can also be used to forcibly fetch new updates if there are any. - Example - .. code-block:: python + Example + .. code-block:: python - await client.catch_up() - """ - pts, date = self._state_cache[None] - if not pts: - return + await client.catch_up() + """ + pts, date = self._state_cache[None] + if not pts: + return - self.session.catching_up = True - try: - while True: - d = await self(functions.updates.GetDifferenceRequest( - pts, date, 0 - )) - if isinstance(d, (types.updates.DifferenceSlice, - types.updates.Difference)): - if isinstance(d, types.updates.Difference): - state = d.state - else: - state = d.intermediate_state - - pts, date = state.pts, state.date - 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 - ] - )) - - # TODO Implement upper limit (max_pts) - # We don't want to fetch updates we already know about. - # - # We may still get duplicates because the Difference - # contains a lot of updates and presumably only has - # the state for the last one, but at least we don't - # unnecessarily fetch too many. - # - # updates.getDifference's pts_total_limit seems to mean - # "how many pts is the request allowed to return", and - # if there is more than that, it returns "too long" (so - # there would be duplicate updates since we know about - # some). This can be used to detect collisions (i.e. - # it would return an update we have already seen). + self.session.catching_up = True + try: + while True: + d = await self(functions.updates.GetDifferenceRequest( + pts, date, 0 + )) + if isinstance(d, (types.updates.DifferenceSlice, + types.updates.Difference)): + if isinstance(d, types.updates.Difference): + state = d.state else: - if isinstance(d, types.updates.DifferenceEmpty): - date = d.date - elif isinstance(d, types.updates.DifferenceTooLong): - pts = d.pts - break - except (ConnectionError, asyncio.CancelledError): + state = d.intermediate_state + + pts, date = state.pts, state.date + 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 + ] + )) + + # TODO Implement upper limit (max_pts) + # We don't want to fetch updates we already know about. + # + # We may still get duplicates because the Difference + # contains a lot of updates and presumably only has + # the state for the last one, but at least we don't + # unnecessarily fetch too many. + # + # updates.getDifference's pts_total_limit seems to mean + # "how many pts is the request allowed to return", and + # if there is more than that, it returns "too long" (so + # there would be duplicate updates since we know about + # some). This can be used to detect collisions (i.e. + # it would return an update we have already seen). + else: + if isinstance(d, types.updates.DifferenceEmpty): + date = d.date + elif isinstance(d, types.updates.DifferenceTooLong): + pts = d.pts + break + except (ConnectionError, asyncio.CancelledError): + pass + finally: + # TODO Save new pts to session + self._state_cache._pts_date = (pts, date) + self.session.catching_up = False + + +# It is important to not make _handle_update async because we rely on +# the order that the updates arrive in to update the pts and date to +# be always-increasing. There is also no need to make this async. +def _handle_update(self: 'TelegramClient', update): + self.session.process_entities(update) + self._entity_cache.add(update) + + if isinstance(update, (types.Updates, types.UpdatesCombined)): + entities = {utils.get_peer_id(x): x for x in + itertools.chain(update.users, update.chats)} + for u in update.updates: + self._process_update(u, update.updates, entities=entities) + elif isinstance(update, types.UpdateShort): + self._process_update(update.update, None) + else: + self._process_update(update, None) + + self._state_cache.update(update) + +def _process_update(self: 'TelegramClient', update, others, entities=None): + update._entities = entities or {} + + # This part is somewhat hot so we don't bother patching + # update with channel ID/its state. Instead we just pass + # arguments which is faster. + channel_id = self._state_cache.get_channel_id(update) + args = (update, others, channel_id, self._state_cache[channel_id]) + if self._dispatching_updates_queue is None: + task = self.loop.create_task(self._dispatch_update(*args)) + self._updates_queue.add(task) + task.add_done_callback(lambda _: self._updates_queue.discard(task)) + else: + self._updates_queue.put_nowait(args) + if not self._dispatching_updates_queue.is_set(): + self._dispatching_updates_queue.set() + self.loop.create_task(self._dispatch_queue_updates()) + + self._state_cache.update(update) + +async def _update_loop(self: 'TelegramClient'): + # Pings' ID don't really need to be secure, just "random" + rnd = lambda: random.randrange(-2**63, 2**63) + while self.is_connected(): + try: + await asyncio.wait_for( + self.disconnected, timeout=60 + ) + continue # We actually just want to act upon timeout + except asyncio.TimeoutError: pass - finally: - # TODO Save new pts to session - self._state_cache._pts_date = (pts, date) - self.session.catching_up = False + except asyncio.CancelledError: + return + except Exception: + continue # Any disconnected exception should be ignored - # endregion + # Check if we have any exported senders to clean-up periodically + await self._clean_exported_senders() - # region Private methods + # Don't bother sending pings until the low-level connection is + # ready, otherwise a lot of pings will be batched to be sent upon + # reconnect, when we really don't care about that. + if not self._sender._transport_connected(): + continue - # It is important to not make _handle_update async because we rely on - # the order that the updates arrive in to update the pts and date to - # be always-increasing. There is also no need to make this async. - def _handle_update(self: 'TelegramClient', update): - self.session.process_entities(update) - self._entity_cache.add(update) + # We also don't really care about their result. + # Just send them periodically. + try: + self._sender._keepalive_ping(rnd()) + except (ConnectionError, asyncio.CancelledError): + return - if isinstance(update, (types.Updates, types.UpdatesCombined)): - entities = {utils.get_peer_id(x): x for x in - itertools.chain(update.users, update.chats)} - for u in update.updates: - self._process_update(u, update.updates, entities=entities) - elif isinstance(update, types.UpdateShort): - self._process_update(update.update, None) - else: - self._process_update(update, None) + # Entities and cached files are not saved when they are + # inserted because this is a rather expensive operation + # (default's sqlite3 takes ~0.1s to commit changes). Do + # it every minute instead. No-op if there's nothing new. + self.session.save() - self._state_cache.update(update) - - def _process_update(self: 'TelegramClient', update, others, entities=None): - update._entities = entities or {} - - # This part is somewhat hot so we don't bother patching - # update with channel ID/its state. Instead we just pass - # arguments which is faster. - channel_id = self._state_cache.get_channel_id(update) - args = (update, others, channel_id, self._state_cache[channel_id]) - if self._dispatching_updates_queue is None: - task = self.loop.create_task(self._dispatch_update(*args)) - self._updates_queue.add(task) - task.add_done_callback(lambda _: self._updates_queue.discard(task)) - else: - self._updates_queue.put_nowait(args) - if not self._dispatching_updates_queue.is_set(): - self._dispatching_updates_queue.set() - self.loop.create_task(self._dispatch_queue_updates()) - - self._state_cache.update(update) - - async def _update_loop(self: 'TelegramClient'): - # Pings' ID don't really need to be secure, just "random" - rnd = lambda: random.randrange(-2**63, 2**63) - while self.is_connected(): - try: - await asyncio.wait_for( - self.disconnected, timeout=60 - ) - continue # We actually just want to act upon timeout - except asyncio.TimeoutError: - pass - except asyncio.CancelledError: - return - except Exception: - continue # Any disconnected exception should be ignored - - # Check if we have any exported senders to clean-up periodically - await self._clean_exported_senders() - - # Don't bother sending pings until the low-level connection is - # ready, otherwise a lot of pings will be batched to be sent upon - # reconnect, when we really don't care about that. - if not self._sender._transport_connected(): + # We need to send some content-related request at least hourly + # for Telegram to keep delivering updates, otherwise they will + # just stop even if we're connected. Do so every 30 minutes. + # + # TODO Call getDifference instead since it's more relevant + if time.time() - self._last_request > 30 * 60: + if not await self.is_user_authorized(): + # What can be the user doing for so + # long without being logged in...? continue - # We also don't really care about their result. - # Just send them periodically. try: - self._sender._keepalive_ping(rnd()) + await self(functions.updates.GetStateRequest()) except (ConnectionError, asyncio.CancelledError): return - # Entities and cached files are not saved when they are - # inserted because this is a rather expensive operation - # (default's sqlite3 takes ~0.1s to commit changes). Do - # it every minute instead. No-op if there's nothing new. - self.session.save() +async def _dispatch_queue_updates(self: 'TelegramClient'): + while not self._updates_queue.empty(): + await self._dispatch_update(*self._updates_queue.get_nowait()) - # We need to send some content-related request at least hourly - # for Telegram to keep delivering updates, otherwise they will - # just stop even if we're connected. Do so every 30 minutes. - # - # TODO Call getDifference instead since it's more relevant - if time.time() - self._last_request > 30 * 60: - if not await self.is_user_authorized(): - # What can be the user doing for so - # long without being logged in...? - continue + self._dispatching_updates_queue.clear() - try: - await self(functions.updates.GetStateRequest()) - except (ConnectionError, asyncio.CancelledError): - return - - async def _dispatch_queue_updates(self: 'TelegramClient'): - while not self._updates_queue.empty(): - await self._dispatch_update(*self._updates_queue.get_nowait()) - - self._dispatching_updates_queue.clear() - - async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, pts_date): - if not self._entity_cache.ensure_cached(update): - # We could add a lock to not fetch the same pts twice if we are - # already fetching it. However this does not happen in practice, - # which makes sense, because different updates have different pts. - if self._state_cache.update(update, check_only=True): - # If the update doesn't have pts, fetching won't do anything. - # For example, UpdateUserStatus or UpdateChatUserTyping. - try: - await self._get_difference(update, channel_id, pts_date) - except OSError: - pass # We were disconnected, that's okay - except errors.RPCError: - # There's a high chance the request fails because we lack - # the channel. Because these "happen sporadically" (#1428) - # we should be okay (no flood waits) even if more occur. - pass - except ValueError: - # There is a chance that GetFullChannelRequest and GetDifferenceRequest - # inside the _get_difference() function will end up with - # ValueError("Request was unsuccessful N time(s)") for whatever reasons. - pass - - if not self._self_input_peer: - # Some updates require our own ID, so we must make sure - # that the event builder has offline access to it. Calling - # `get_me()` will cache it under `self._self_input_peer`. - # - # It will return `None` if we haven't logged in yet which is - # fine, we will just retry next time anyway. +async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, pts_date): + if not self._entity_cache.ensure_cached(update): + # We could add a lock to not fetch the same pts twice if we are + # already fetching it. However this does not happen in practice, + # which makes sense, because different updates have different pts. + if self._state_cache.update(update, check_only=True): + # If the update doesn't have pts, fetching won't do anything. + # For example, UpdateUserStatus or UpdateChatUserTyping. try: - await self.get_me(input_peer=True) + await self._get_difference(update, channel_id, pts_date) except OSError: - pass # might not have connection - - built = EventBuilderDict(self, update, others) - for conv_set in self._conversations.values(): - for conv in conv_set: - ev = built[events.NewMessage] - if ev: - conv._on_new_message(ev) - - ev = built[events.MessageEdited] - if ev: - conv._on_edit(ev) - - ev = built[events.MessageRead] - if ev: - conv._on_read(ev) - - if conv._custom: - await conv._check_custom(built) - - for builder, callback in self._event_builders: - event = built[type(builder)] - if not event: - continue - - if not builder.resolved: - await builder.resolve(self) - - filter = builder.filter(event) - if inspect.isawaitable(filter): - filter = await filter - if not filter: - continue - - try: - await callback(event) - except errors.AlreadyInConversationError: - name = getattr(callback, '__name__', repr(callback)) - self._log[__name__].debug( - 'Event handler "%s" already has an open conversation, ' - 'ignoring new one', name) - except events.StopPropagation: - name = getattr(callback, '__name__', repr(callback)) - self._log[__name__].debug( - 'Event handler "%s" stopped chain of propagation ' - 'for event %s.', name, type(event).__name__ - ) - break - except Exception as e: - if not isinstance(e, asyncio.CancelledError) or self.is_connected(): - name = getattr(callback, '__name__', repr(callback)) - self._log[__name__].exception('Unhandled exception on %s', name) - - async def _dispatch_event(self: 'TelegramClient', event): - """ - Dispatches a single, out-of-order event. Used by `AlbumHack`. - """ - # We're duplicating a most logic from `_dispatch_update`, but all in - # the name of speed; we don't want to make it worse for all updates - # just because albums may need it. - for builder, callback in self._event_builders: - if isinstance(builder, events.Raw): - continue - if not isinstance(event, builder.Event): - continue - - if not builder.resolved: - await builder.resolve(self) - - filter = builder.filter(event) - if inspect.isawaitable(filter): - filter = await filter - if not filter: - continue - - try: - await callback(event) - except errors.AlreadyInConversationError: - name = getattr(callback, '__name__', repr(callback)) - self._log[__name__].debug( - 'Event handler "%s" already has an open conversation, ' - 'ignoring new one', name) - except events.StopPropagation: - name = getattr(callback, '__name__', repr(callback)) - self._log[__name__].debug( - 'Event handler "%s" stopped chain of propagation ' - 'for event %s.', name, type(event).__name__ - ) - break - except Exception as e: - if not isinstance(e, asyncio.CancelledError) or self.is_connected(): - name = getattr(callback, '__name__', repr(callback)) - self._log[__name__].exception('Unhandled exception on %s', name) - - async def _get_difference(self: 'TelegramClient', update, channel_id, pts_date): - """ - Get the difference for this `channel_id` if any, then load entities. - - Calls :tl:`updates.getDifference`, which fills the entities cache - (always done by `__call__`) and lets us know about the full entities. - """ - # Fetch since the last known pts/date before this update arrived, - # in order to fetch this update at full, including its entities. - self._log[__name__].debug('Getting difference for entities ' - 'for %r', update.__class__) - if channel_id: - # There are reports where we somehow call get channel difference - # with `InputPeerEmpty`. Check our assumptions to better debug - # this when it happens. - assert isinstance(channel_id, int), 'channel_id was {}, not int in {}'.format(type(channel_id), update) - try: - # Wrap the ID inside a peer to ensure we get a channel back. - where = await self.get_input_entity(types.PeerChannel(channel_id)) + pass # We were disconnected, that's okay + except errors.RPCError: + # There's a high chance the request fails because we lack + # the channel. Because these "happen sporadically" (#1428) + # we should be okay (no flood waits) even if more occur. + pass except ValueError: - # There's a high chance that this fails, since - # we are getting the difference to fetch entities. - return + # There is a chance that GetFullChannelRequest and GetDifferenceRequest + # inside the _get_difference() function will end up with + # ValueError("Request was unsuccessful N time(s)") for whatever reasons. + pass - if not pts_date: - # First-time, can't get difference. Get pts instead. - result = await self(functions.channels.GetFullChannelRequest( - utils.get_input_channel(where) - )) - self._state_cache[channel_id] = result.full_chat.pts - return - - result = await self(functions.updates.GetChannelDifferenceRequest( - channel=where, - filter=types.ChannelMessagesFilterEmpty(), - pts=pts_date, # just pts - limit=100, - force=True - )) - else: - if not pts_date[0]: - # First-time, can't get difference. Get pts instead. - result = await self(functions.updates.GetStateRequest()) - self._state_cache[None] = result.pts, result.date - return - - result = await self(functions.updates.GetDifferenceRequest( - pts=pts_date[0], - date=pts_date[1], - qts=0 - )) - - if isinstance(result, (types.updates.Difference, - types.updates.DifferenceSlice, - types.updates.ChannelDifference, - types.updates.ChannelDifferenceTooLong)): - update._entities.update({ - utils.get_peer_id(x): x for x in - itertools.chain(result.users, result.chats) - }) - - async def _handle_auto_reconnect(self: 'TelegramClient'): - # TODO Catch-up - # For now we make a high-level request to let Telegram - # know we are still interested in receiving more updates. + if not self._self_input_peer: + # Some updates require our own ID, so we must make sure + # that the event builder has offline access to it. Calling + # `get_me()` will cache it under `self._self_input_peer`. + # + # It will return `None` if we haven't logged in yet which is + # fine, we will just retry next time anyway. try: - await self.get_me() + await self.get_me(input_peer=True) + except OSError: + pass # might not have connection + + built = EventBuilderDict(self, update, others) + for conv_set in self._conversations.values(): + for conv in conv_set: + ev = built[events.NewMessage] + if ev: + conv._on_new_message(ev) + + ev = built[events.MessageEdited] + if ev: + conv._on_edit(ev) + + ev = built[events.MessageRead] + if ev: + conv._on_read(ev) + + if conv._custom: + await conv._check_custom(built) + + for builder, callback in self._event_builders: + event = built[type(builder)] + if not event: + continue + + if not builder.resolved: + await builder.resolve(self) + + filter = builder.filter(event) + if inspect.isawaitable(filter): + filter = await filter + if not filter: + continue + + try: + await callback(event) + except errors.AlreadyInConversationError: + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].debug( + 'Event handler "%s" already has an open conversation, ' + 'ignoring new one', name) + except events.StopPropagation: + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].debug( + 'Event handler "%s" stopped chain of propagation ' + 'for event %s.', name, type(event).__name__ + ) + break except Exception as e: - self._log[__name__].warning('Error executing high-level request ' - 'after reconnect: %s: %s', type(e), e) + if not isinstance(e, asyncio.CancelledError) or self.is_connected(): + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].exception('Unhandled exception on %s', name) + +async def _dispatch_event(self: 'TelegramClient', event): + """ + Dispatches a single, out-of-order event. Used by `AlbumHack`. + """ + # We're duplicating a most logic from `_dispatch_update`, but all in + # the name of speed; we don't want to make it worse for all updates + # just because albums may need it. + for builder, callback in self._event_builders: + if isinstance(builder, events.Raw): + continue + if not isinstance(event, builder.Event): + continue + + if not builder.resolved: + await builder.resolve(self) + + filter = builder.filter(event) + if inspect.isawaitable(filter): + filter = await filter + if not filter: + continue - return try: - self._log[__name__].info( - 'Asking for the current state after reconnect...') + await callback(event) + except errors.AlreadyInConversationError: + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].debug( + 'Event handler "%s" already has an open conversation, ' + 'ignoring new one', name) + except events.StopPropagation: + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].debug( + 'Event handler "%s" stopped chain of propagation ' + 'for event %s.', name, type(event).__name__ + ) + break + except Exception as e: + if not isinstance(e, asyncio.CancelledError) or self.is_connected(): + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].exception('Unhandled exception on %s', name) - # TODO consider: - # If there aren't many updates while the client is disconnected - # (I tried with up to 20), Telegram seems to send them without - # asking for them (via updates.getDifference). - # - # On disconnection, the library should probably set a "need - # difference" or "catching up" flag so that any new updates are - # ignored, and then the library should call updates.getDifference - # itself to fetch them. - # - # In any case (either there are too many updates and Telegram - # didn't send them, or there isn't a lot and Telegram sent them - # but we dropped them), we fetch the new difference to get all - # missed updates. I feel like this would be the best solution. +async def _get_difference(self: 'TelegramClient', update, channel_id, pts_date): + """ + Get the difference for this `channel_id` if any, then load entities. - # If a disconnection occurs, the old known state will be - # the latest one we were aware of, so we can catch up since - # the most recent state we were aware of. - await self.catch_up() + Calls :tl:`updates.getDifference`, which fills the entities cache + (always done by `__call__`) and lets us know about the full entities. + """ + # Fetch since the last known pts/date before this update arrived, + # in order to fetch this update at full, including its entities. + self._log[__name__].debug('Getting difference for entities ' + 'for %r', update.__class__) + if channel_id: + # There are reports where we somehow call get channel difference + # with `InputPeerEmpty`. Check our assumptions to better debug + # this when it happens. + assert isinstance(channel_id, int), 'channel_id was {}, not int in {}'.format(type(channel_id), update) + try: + # Wrap the ID inside a peer to ensure we get a channel back. + where = await self.get_input_entity(types.PeerChannel(channel_id)) + except ValueError: + # There's a high chance that this fails, since + # we are getting the difference to fetch entities. + return - self._log[__name__].info('Successfully fetched missed updates') - except errors.RPCError as e: - self._log[__name__].warning('Failed to get missed updates after ' - 'reconnect: %r', e) - except Exception: - self._log[__name__].exception( - 'Unhandled exception while getting update difference after reconnect') + if not pts_date: + # First-time, can't get difference. Get pts instead. + result = await self(functions.channels.GetFullChannelRequest( + utils.get_input_channel(where) + )) + self._state_cache[channel_id] = result.full_chat.pts + return - # endregion + result = await self(functions.updates.GetChannelDifferenceRequest( + channel=where, + filter=types.ChannelMessagesFilterEmpty(), + pts=pts_date, # just pts + limit=100, + force=True + )) + else: + if not pts_date[0]: + # First-time, can't get difference. Get pts instead. + result = await self(functions.updates.GetStateRequest()) + self._state_cache[None] = result.pts, result.date + return + + result = await self(functions.updates.GetDifferenceRequest( + pts=pts_date[0], + date=pts_date[1], + qts=0 + )) + + if isinstance(result, (types.updates.Difference, + types.updates.DifferenceSlice, + types.updates.ChannelDifference, + types.updates.ChannelDifferenceTooLong)): + update._entities.update({ + utils.get_peer_id(x): x for x in + itertools.chain(result.users, result.chats) + }) + +async def _handle_auto_reconnect(self: 'TelegramClient'): + # TODO Catch-up + # For now we make a high-level request to let Telegram + # know we are still interested in receiving more updates. + try: + await self.get_me() + except Exception as e: + self._log[__name__].warning('Error executing high-level request ' + 'after reconnect: %s: %s', type(e), e) + + return + try: + self._log[__name__].info( + 'Asking for the current state after reconnect...') + + # TODO consider: + # If there aren't many updates while the client is disconnected + # (I tried with up to 20), Telegram seems to send them without + # asking for them (via updates.getDifference). + # + # On disconnection, the library should probably set a "need + # difference" or "catching up" flag so that any new updates are + # ignored, and then the library should call updates.getDifference + # itself to fetch them. + # + # In any case (either there are too many updates and Telegram + # didn't send them, or there isn't a lot and Telegram sent them + # but we dropped them), we fetch the new difference to get all + # missed updates. I feel like this would be the best solution. + + # If a disconnection occurs, the old known state will be + # the latest one we were aware of, so we can catch up since + # the most recent state we were aware of. + await self.catch_up() + + self._log[__name__].info('Successfully fetched missed updates') + except errors.RPCError as e: + self._log[__name__].warning('Failed to get missed updates after ' + 'reconnect: %r', e) + except Exception: + self._log[__name__].exception( + 'Unhandled exception while getting update difference after reconnect') class EventBuilderDict: diff --git a/telethon/client/uploads.py b/telethon/client/uploads.py index 4c9f9d32..58f69bad 100644 --- a/telethon/client/uploads.py +++ b/telethon/client/uploads.py @@ -88,679 +88,381 @@ def _resize_photo_if_needed( file.seek(before, io.SEEK_SET) -class UploadMethods: - - # region Public methods - - async def send_file( - self: 'TelegramClient', - entity: 'hints.EntityLike', - file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]', - *, - caption: typing.Union[str, typing.Sequence[str]] = None, - force_document: bool = False, - file_size: int = None, - clear_draft: bool = False, - progress_callback: 'hints.ProgressCallback' = None, - reply_to: 'hints.MessageIDLike' = None, - attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, - thumb: 'hints.FileLike' = None, - allow_cache: bool = True, - parse_mode: str = (), - formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, - voice_note: bool = False, - video_note: bool = False, - buttons: 'hints.MarkupLike' = None, - silent: bool = None, - background: bool = None, - supports_streaming: bool = False, - schedule: 'hints.DateLike' = None, - comment_to: 'typing.Union[int, types.Message]' = None, - ttl: int = None, - **kwargs) -> 'types.Message': - """ - Sends message with the given file to the specified entity. - - .. note:: - - If the ``hachoir3`` package (``hachoir`` module) is installed, - it will be used to determine metadata from audio and video files. - - If the ``pillow`` package is installed and you are sending a photo, - it will be resized to fit within the maximum dimensions allowed - by Telegram to avoid ``errors.PhotoInvalidDimensionsError``. This - cannot be done if you are sending :tl:`InputFile`, however. - - Arguments - entity (`entity`): - Who will receive the file. - - file (`str` | `bytes` | `file` | `media`): - The file to send, which can be one of: - - * A local file path to an in-disk file. The file name - will be the path's base name. - - * A `bytes` byte array with the file's data to send - (for example, by using ``text.encode('utf-8')``). - A default file name will be used. - - * A bytes `io.IOBase` stream over the file to send - (for example, by using ``open(file, 'rb')``). - Its ``.name`` property will be used for the file name, - or a default if it doesn't have one. - - * An external URL to a file over the internet. This will - send the file as "external" media, and Telegram is the - one that will fetch the media and send it. - - * A Bot API-like ``file_id``. You can convert previously - sent media to file IDs for later reusing with - `telethon.utils.pack_bot_file_id`. - - * A handle to an existing file (for example, if you sent a - message with media before, you can use its ``message.media`` - as a file here). - - * A handle to an uploaded file (from `upload_file`). - - * A :tl:`InputMedia` instance. For example, if you want to - send a dice use :tl:`InputMediaDice`, or if you want to - send a contact use :tl:`InputMediaContact`. - - To send an album, you should provide a list in this parameter. - - 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. When sending an - album, the caption may be a list of strings, which will be - assigned to the files pairwise. - - 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. - - file_size (`int`, optional): - The size of the file to be uploaded if it needs to be uploaded, - which will be determined automatically if not specified. - - If the file size can't be determined beforehand, the entire - file will be read in-memory to find out how large it is. - - clear_draft (`bool`, optional): - Whether the existing draft should be cleared or not. - - progress_callback (`callable`, optional): - A callback function accepting two parameters: - ``(sent bytes, total)``. - - reply_to (`int` | `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 JPEG thumbnail (for documents). **Telegram will - ignore this parameter** unless you pass a ``.jpg`` file! - - The file must also be small in dimensions and in disk size. - Successful thumbnails were files below 20kB and 320x320px. - Width/height and dimensions/size ratios may be important. - For Telegram to accept a thumbnail, you must provide the - dimensions of the underlying media through ``attributes=`` - with :tl:`DocumentAttributesVideo` or by installing the - optional ``hachoir`` dependency. - - - allow_cache (`bool`, optional): - This parameter currently does nothing, but is kept for - backward-compatibility (and it may get its use back in - the future). - - parse_mode (`object`, optional): - See the `TelegramClient.parse_mode - ` - property for allowed values. Markdown parsing will be used by - default. - - formatting_entities (`list`, optional): - A list of message formatting entities. When provided, the ``parse_mode`` is ignored. - - voice_note (`bool`, optional): - If `True` the audio will be sent as a voice note. - - video_note (`bool`, optional): - If `True` the video will be sent as a video note, - also known as a round video message. - - buttons (`list`, `custom.Button `, :tl:`KeyboardButton`): - The matrix (list of lists), row list or button to be shown - after sending the message. This parameter will only work if - you have signed in as a bot. You can also pass your own - :tl:`ReplyMarkup` here. - - silent (`bool`, optional): - Whether the message should notify people with sound or not. - Defaults to `False` (send with a notification sound unless - the person has the chat muted). Set it to `True` to alter - this behaviour. - - background (`bool`, optional): - Whether the message should be send in background. - - supports_streaming (`bool`, optional): - Whether the sent video supports streaming or not. Note that - Telegram only recognizes as streamable some formats like MP4, - and others like AVI or MKV will not work. You should convert - these to MP4 before sending if you want them to be streamable. - Unsupported formats will result in ``VideoContentTypeError``. - - schedule (`hints.DateLike`, optional): - If set, the file won't send immediately, and instead - it will be scheduled to be automatically sent at a later - time. - - comment_to (`int` | `Message `, optional): - Similar to ``reply_to``, but replies in the linked group of a - broadcast channel instead (effectively leaving a "comment to" - the specified message). - - This parameter takes precedence over ``reply_to``. If there is - no linked chat, `telethon.errors.sgIdInvalidError` is raised. - - ttl (`int`. optional): - The Time-To-Live of the file (also known as "self-destruct timer" - or "self-destructing media"). If set, files can only be viewed for - a short period of time before they disappear from the message - history automatically. - - The value must be at least 1 second, and at most 60 seconds, - otherwise Telegram will ignore this parameter. - - Not all types of media can be used with this parameter, such - as text documents, which will fail with ``TtlMediaInvalidError``. - - Returns - The `Message ` (or messages) - containing the sent file, or messages if a list of them was passed. - - Example - .. code-block:: python - - # Normal files like photos - await client.send_file(chat, '/my/photos/me.jpg', caption="It's me!") - # or - await client.send_message(chat, "It's me!", file='/my/photos/me.jpg') - - # Voice notes or round videos - await client.send_file(chat, '/my/songs/song.mp3', voice_note=True) - await client.send_file(chat, '/my/videos/video.mp4', video_note=True) - - # Custom thumbnails - await client.send_file(chat, '/my/documents/doc.txt', thumb='photo.jpg') - - # Only documents - await client.send_file(chat, '/my/photos/photo.png', force_document=True) - - # Albums - await client.send_file(chat, [ - '/my/photos/holiday1.jpg', - '/my/photos/holiday2.jpg', - '/my/drawings/portrait.png' - ]) - - # Printing upload progress - def callback(current, total): - print('Uploaded', current, 'out of', total, - 'bytes: {:.2%}'.format(current / total)) - - await client.send_file(chat, file, progress_callback=callback) - - # Dices, including dart and other future emoji - from telethon.tl import types - await client.send_file(chat, types.InputMediaDice('')) - await client.send_file(chat, types.InputMediaDice('🎯')) - - # Contacts - await client.send_file(chat, types.InputMediaContact( - phone_number='+34 123 456 789', - first_name='Example', - last_name='', - vcard='' - )) - """ - # TODO Properly implement allow_cache to reuse the sha256 of the file - # i.e. `None` was used - if not file: - raise TypeError('Cannot use {!r} as file'.format(file)) - - if not caption: - caption = '' - - entity = await self.get_input_entity(entity) - if comment_to is not None: - entity, reply_to = await self._get_comment_data(entity, comment_to) - else: - reply_to = utils.get_message_id(reply_to) - - # First check if the user passed an iterable, in which case - # we may want to send grouped. - if utils.is_list_like(file): - if utils.is_list_like(caption): - captions = caption - else: - captions = [caption] - - result = [] - while file: - result += await self._send_album( - entity, file[:10], caption=captions[:10], - progress_callback=progress_callback, reply_to=reply_to, - parse_mode=parse_mode, silent=silent, schedule=schedule, - supports_streaming=supports_streaming, clear_draft=clear_draft, - force_document=force_document, background=background, - ) - file = file[10:] - captions = captions[10:] - - for doc, cap in zip(file, captions): - result.append(await self.send_file( - entity, doc, allow_cache=allow_cache, - caption=cap, force_document=force_document, - progress_callback=progress_callback, reply_to=reply_to, - attributes=attributes, thumb=thumb, voice_note=voice_note, - video_note=video_note, buttons=buttons, silent=silent, - supports_streaming=supports_streaming, schedule=schedule, - clear_draft=clear_draft, background=background, - **kwargs - )) - - return result - - if formatting_entities is not None: - msg_entities = formatting_entities - else: - caption, msg_entities =\ - await self._parse_message_text(caption, parse_mode) - - file_handle, media, image = await self._file_to_media( - file, force_document=force_document, - file_size=file_size, - progress_callback=progress_callback, - attributes=attributes, allow_cache=allow_cache, thumb=thumb, - voice_note=voice_note, video_note=video_note, - supports_streaming=supports_streaming, ttl=ttl - ) - - # e.g. invalid cast from :tl:`MessageMediaWebPage` - if not media: - raise TypeError('Cannot use {!r} as file'.format(file)) - - markup = self.build_reply_markup(buttons) - request = functions.messages.SendMediaRequest( - entity, media, reply_to_msg_id=reply_to, message=caption, - entities=msg_entities, reply_markup=markup, silent=silent, - schedule_date=schedule, clear_draft=clear_draft, - background=background - ) - return self._get_response_message(request, await self(request), entity) - - async def _send_album(self: 'TelegramClient', entity, files, caption='', - progress_callback=None, reply_to=None, - parse_mode=(), silent=None, schedule=None, - supports_streaming=None, clear_draft=None, - force_document=False, background=None, ttl=None): - """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 = [] - for c in reversed(caption): # Pop from the end (so reverse) - captions.append(await self._parse_message_text(c or '', parse_mode)) - +async def send_file( + self: 'TelegramClient', + entity: 'hints.EntityLike', + file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]', + *, + caption: typing.Union[str, typing.Sequence[str]] = None, + force_document: bool = False, + file_size: int = None, + clear_draft: bool = False, + progress_callback: 'hints.ProgressCallback' = None, + reply_to: 'hints.MessageIDLike' = None, + attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, + thumb: 'hints.FileLike' = None, + allow_cache: bool = True, + parse_mode: str = (), + formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, + voice_note: bool = False, + video_note: bool = False, + buttons: 'hints.MarkupLike' = None, + silent: bool = None, + background: bool = None, + supports_streaming: bool = False, + schedule: 'hints.DateLike' = None, + comment_to: 'typing.Union[int, types.Message]' = None, + ttl: int = None, + **kwargs) -> 'types.Message': + # TODO Properly implement allow_cache to reuse the sha256 of the file + # i.e. `None` was used + if not file: + raise TypeError('Cannot use {!r} as file'.format(file)) + + if not caption: + caption = '' + + entity = await self.get_input_entity(entity) + if comment_to is not None: + entity, reply_to = await self._get_comment_data(entity, comment_to) + else: 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: - # Albums want :tl:`InputMedia` which, in theory, includes - # :tl:`InputMediaUploadedPhoto`. However using that will - # make it `raise MediaInvalidError`, so we need to upload - # it as media and then convert that to :tl:`InputMediaPhoto`. - fh, fm, _ = await self._file_to_media( - file, supports_streaming=supports_streaming, - force_document=force_document, ttl=ttl) - if isinstance(fm, (types.InputMediaUploadedPhoto, types.InputMediaPhotoExternal)): - r = await self(functions.messages.UploadMediaRequest( - entity, media=fm - )) + # First check if the user passed an iterable, in which case + # we may want to send grouped. + if utils.is_list_like(file): + if utils.is_list_like(caption): + captions = caption + else: + captions = [caption] - fm = utils.get_input_media(r.photo) - elif isinstance(fm, types.InputMediaUploadedDocument): - r = await self(functions.messages.UploadMediaRequest( - entity, media=fm - )) + result = [] + while file: + result += await self._send_album( + entity, file[:10], caption=captions[:10], + progress_callback=progress_callback, reply_to=reply_to, + parse_mode=parse_mode, silent=silent, schedule=schedule, + supports_streaming=supports_streaming, clear_draft=clear_draft, + force_document=force_document, background=background, + ) + file = file[10:] + captions = captions[10:] - fm = utils.get_input_media( - r.document, supports_streaming=supports_streaming) - - if captions: - caption, msg_entities = captions.pop() - else: - caption, msg_entities = '', None - media.append(types.InputSingleMedia( - fm, - message=caption, - entities=msg_entities - # random_id is autogenerated + for doc, cap in zip(file, captions): + result.append(await self.send_file( + entity, doc, allow_cache=allow_cache, + caption=cap, force_document=force_document, + progress_callback=progress_callback, reply_to=reply_to, + attributes=attributes, thumb=thumb, voice_note=voice_note, + video_note=video_note, buttons=buttons, silent=silent, + supports_streaming=supports_streaming, schedule=schedule, + clear_draft=clear_draft, background=background, + **kwargs )) - # Now we can construct the multi-media request - request = functions.messages.SendMultiMediaRequest( - entity, reply_to_msg_id=reply_to, multi_media=media, - silent=silent, schedule_date=schedule, clear_draft=clear_draft, - background=background - ) - result = await self(request) + return result - random_ids = [m.random_id for m in media] - return self._get_response_message(random_ids, result, entity) + if formatting_entities is not None: + msg_entities = formatting_entities + else: + caption, msg_entities =\ + await self._parse_message_text(caption, parse_mode) - async def upload_file( - self: 'TelegramClient', - file: 'hints.FileLike', - *, - part_size_kb: float = None, - file_size: int = None, - file_name: str = None, - use_cache: type = None, - key: bytes = None, - iv: bytes = None, - progress_callback: 'hints.ProgressCallback' = None) -> 'types.TypeInputFile': - """ - Uploads a file to Telegram's servers, without sending it. + file_handle, media, image = await self._file_to_media( + file, force_document=force_document, + file_size=file_size, + progress_callback=progress_callback, + attributes=attributes, allow_cache=allow_cache, thumb=thumb, + voice_note=voice_note, video_note=video_note, + supports_streaming=supports_streaming, ttl=ttl + ) - .. note:: + # e.g. invalid cast from :tl:`MessageMediaWebPage` + if not media: + raise TypeError('Cannot use {!r} as file'.format(file)) - Generally, you want to use `send_file` instead. + markup = self.build_reply_markup(buttons) + request = functions.messages.SendMediaRequest( + entity, media, reply_to_msg_id=reply_to, message=caption, + entities=msg_entities, reply_markup=markup, silent=silent, + schedule_date=schedule, clear_draft=clear_draft, + background=background + ) + return self._get_response_message(request, await self(request), entity) - This method 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). +async def _send_album(self: 'TelegramClient', entity, files, caption='', + progress_callback=None, reply_to=None, + parse_mode=(), silent=None, schedule=None, + supports_streaming=None, clear_draft=None, + force_document=False, background=None, ttl=None): + """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,) - 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. + captions = [] + for c in reversed(caption): # Pop from the end (so reverse) + captions.append(await self._parse_message_text(c or '', parse_mode)) - Arguments - 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". + reply_to = utils.get_message_id(reply_to) - part_size_kb (`int`, optional): - Chunk size when uploading files. The larger, the less - requests will be made (up to 512KB maximum). + # Need to upload the media first, but only if they're not cached yet + media = [] + for file in files: + # Albums want :tl:`InputMedia` which, in theory, includes + # :tl:`InputMediaUploadedPhoto`. However using that will + # make it `raise MediaInvalidError`, so we need to upload + # it as media and then convert that to :tl:`InputMediaPhoto`. + fh, fm, _ = await self._file_to_media( + file, supports_streaming=supports_streaming, + force_document=force_document, ttl=ttl) + if isinstance(fm, (types.InputMediaUploadedPhoto, types.InputMediaPhotoExternal)): + r = await self(functions.messages.UploadMediaRequest( + entity, media=fm + )) - file_size (`int`, optional): - The size of the file to be uploaded, which will be determined - automatically if not specified. + fm = utils.get_input_media(r.photo) + elif isinstance(fm, types.InputMediaUploadedDocument): + r = await self(functions.messages.UploadMediaRequest( + entity, media=fm + )) - If the file size can't be determined beforehand, the entire - file will be read in-memory to find out how large it is. + fm = utils.get_input_media( + r.document, supports_streaming=supports_streaming) - 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"``. + if captions: + caption, msg_entities = captions.pop() + else: + caption, msg_entities = '', None + media.append(types.InputSingleMedia( + fm, + message=caption, + entities=msg_entities + # random_id is autogenerated + )) - use_cache (`type`, optional): - This parameter currently does nothing, but is kept for - backward-compatibility (and it may get its use back in - the future). + # Now we can construct the multi-media request + request = functions.messages.SendMultiMediaRequest( + entity, reply_to_msg_id=reply_to, multi_media=media, + silent=silent, schedule_date=schedule, clear_draft=clear_draft, + background=background + ) + result = await self(request) - key ('bytes', optional): - In case of an encrypted upload (secret chats) a key is supplied + random_ids = [m.random_id for m in media] + return self._get_response_message(random_ids, result, entity) - iv ('bytes', optional): - In case of an encrypted upload (secret chats) an iv is supplied +async def upload_file( + self: 'TelegramClient', + file: 'hints.FileLike', + *, + part_size_kb: float = None, + file_size: int = None, + file_name: str = None, + use_cache: type = None, + key: bytes = None, + iv: bytes = None, + progress_callback: 'hints.ProgressCallback' = None) -> 'types.TypeInputFile': + if isinstance(file, (types.InputFile, types.InputFileBig)): + return file # Already uploaded - progress_callback (`callable`, optional): - A callback function accepting two parameters: - ``(sent bytes, total)``. + pos = 0 + async with helpers._FileStream(file, file_size=file_size) as stream: + # Opening the stream will determine the correct file size + file_size = stream.file_size - Returns - :tl:`InputFileBig` if the file size is larger than 10MB, - `InputSizedFile ` - (subclass of :tl:`InputFile`) otherwise. + if not part_size_kb: + part_size_kb = utils.get_appropriated_part_size(file_size) - Example - .. code-block:: python + if part_size_kb > 512: + raise ValueError('The part size must be less or equal to 512KB') - # Photos as photo and document - file = await client.upload_file('photo.jpg') - await client.send_file(chat, file) # sends as photo - await client.send_file(chat, file, force_document=True) # sends as document + part_size = int(part_size_kb * 1024) + if part_size % 1024 != 0: + raise ValueError( + 'The part size must be evenly divisible by 1024') - file.name = 'not a photo.jpg' - await client.send_file(chat, file, force_document=True) # document, new name + # Set a default file name if None was specified + file_id = helpers.generate_random_long() + if not file_name: + file_name = stream.name or str(file_id) - # As song or as voice note - file = await client.upload_file('song.ogg') - await client.send_file(chat, file) # sends as song - await client.send_file(chat, file, voice_note=True) # sends as voice note - """ - if isinstance(file, (types.InputFile, types.InputFileBig)): - return file # Already uploaded + # If the file name lacks extension, add it if possible. + # Else Telegram complains with `PHOTO_EXT_INVALID_ERROR` + # even if the uploaded image is indeed a photo. + if not os.path.splitext(file_name)[-1]: + file_name += utils._get_extension(stream) + + # Determine whether the file is too big (over 10MB) or not + # Telegram does make a distinction between smaller or larger files + is_big = file_size > 10 * 1024 * 1024 + hash_md5 = hashlib.md5() + + part_count = (file_size + part_size - 1) // part_size + self._log[__name__].info('Uploading file of %d bytes in %d chunks of %d', + file_size, part_count, part_size) pos = 0 - async with helpers._FileStream(file, file_size=file_size) as stream: - # Opening the stream will determine the correct file size - file_size = stream.file_size + for part_index in range(part_count): + # Read the file by in chunks of size part_size + part = await helpers._maybe_await(stream.read(part_size)) - if not part_size_kb: - part_size_kb = utils.get_appropriated_part_size(file_size) + if not isinstance(part, bytes): + raise TypeError( + 'file descriptor returned {}, not bytes (you must ' + 'open the file in bytes mode)'.format(type(part))) - 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: + # `file_size` could be wrong in which case `part` may not be + # `part_size` before reaching the end. + if len(part) != part_size and part_index < part_count - 1: raise ValueError( - 'The part size must be evenly divisible by 1024') + 'read less than {} before reaching the end; either ' + '`file_size` or `read` are wrong'.format(part_size)) - # Set a default file name if None was specified - file_id = helpers.generate_random_long() - if not file_name: - file_name = stream.name or str(file_id) + pos += len(part) - # If the file name lacks extension, add it if possible. - # Else Telegram complains with `PHOTO_EXT_INVALID_ERROR` - # even if the uploaded image is indeed a photo. - if not os.path.splitext(file_name)[-1]: - file_name += utils._get_extension(stream) + # Encryption part if needed + if key and iv: + part = AES.encrypt_ige(part, key, iv) - # Determine whether the file is too big (over 10MB) or not - # Telegram does make a distinction between smaller or larger files - is_big = file_size > 10 * 1024 * 1024 - hash_md5 = hashlib.md5() + if not is_big: + # Bit odd that MD5 is only needed for small files and not + # big ones with more chance for corruption, but that's + # what Telegram wants. + hash_md5.update(part) - part_count = (file_size + part_size - 1) // part_size - self._log[__name__].info('Uploading file of %d bytes in %d chunks of %d', - file_size, part_count, part_size) - - pos = 0 - for part_index in range(part_count): - # Read the file by in chunks of size part_size - part = await helpers._maybe_await(stream.read(part_size)) - - if not isinstance(part, bytes): - raise TypeError( - 'file descriptor returned {}, not bytes (you must ' - 'open the file in bytes mode)'.format(type(part))) - - # `file_size` could be wrong in which case `part` may not be - # `part_size` before reaching the end. - if len(part) != part_size and part_index < part_count - 1: - raise ValueError( - 'read less than {} before reaching the end; either ' - '`file_size` or `read` are wrong'.format(part_size)) - - pos += len(part) - - # Encryption part if needed - if key and iv: - part = AES.encrypt_ige(part, key, iv) - - if not is_big: - # Bit odd that MD5 is only needed for small files and not - # big ones with more chance for corruption, but that's - # what Telegram wants. - hash_md5.update(part) - - # The SavePartRequest is different depending on whether - # the file is too large or not (over or less than 10MB) - if is_big: - 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: - self._log[__name__].debug('Uploaded %d/%d', - part_index + 1, part_count) - if progress_callback: - await helpers._maybe_await(progress_callback(pos, file_size)) - else: - raise RuntimeError( - 'Failed to upload file part {}.'.format(part_index)) - - if is_big: - 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, file_size=None, - progress_callback=None, attributes=None, thumb=None, - allow_cache=True, voice_note=False, video_note=False, - supports_streaming=False, mime_type=None, as_image=None, - ttl=None): - if not file: - return None, None, None - - if isinstance(file, pathlib.Path): - file = str(file.absolute()) - - is_image = utils.is_image(file) - if as_image is None: - as_image = is_image and not force_document - - # `aiofiles` do not base `io.IOBase` but do have `read`, so we - # just check for the read attribute to see if it's file-like. - if not isinstance(file, (str, bytes, types.InputFile, types.InputFileBig))\ - and not hasattr(file, 'read'): - # The user may pass a Message containing media (or the media, - # or anything similar) that should be treated as a file. Try - # getting the input media for whatever they passed and send it. - # - # We pass all attributes since these will be used if the user - # passed :tl:`InputFile`, and all information may be relevant. - try: - return (None, utils.get_input_media( - file, - is_photo=as_image, - attributes=attributes, - force_document=force_document, - voice_note=voice_note, - video_note=video_note, - supports_streaming=supports_streaming, - ttl=ttl - ), as_image) - except TypeError: - # Can't turn whatever was given into media - return None, None, as_image - - media = None - file_handle = None - - if isinstance(file, (types.InputFile, types.InputFileBig)): - file_handle = file - elif not isinstance(file, str) or os.path.isfile(file): - file_handle = await self.upload_file( - _resize_photo_if_needed(file, as_image), - file_size=file_size, - progress_callback=progress_callback - ) - elif re.match('https?://', file): - if as_image: - media = types.InputMediaPhotoExternal(file, ttl_seconds=ttl) + # The SavePartRequest is different depending on whether + # the file is too large or not (over or less than 10MB) + if is_big: + request = functions.upload.SaveBigFilePartRequest( + file_id, part_index, part_count, part) else: - media = types.InputMediaDocumentExternal(file, ttl_seconds=ttl) - else: - bot_file = utils.resolve_bot_file_id(file) - if bot_file: - media = utils.get_input_media(bot_file, ttl=ttl) + request = functions.upload.SaveFilePartRequest( + file_id, part_index, part) - if media: - pass # Already have media, don't check the rest - elif not file_handle: - raise ValueError( - 'Failed to convert {} to media. Not an existing file, ' - 'an HTTP URL or a valid bot-API-like file ID'.format(file) - ) - elif as_image: - media = types.InputMediaUploadedPhoto(file_handle, ttl_seconds=ttl) - else: - attributes, mime_type = utils.get_attributes( + result = await self(request) + if result: + self._log[__name__].debug('Uploaded %d/%d', + part_index + 1, part_count) + if progress_callback: + await helpers._maybe_await(progress_callback(pos, file_size)) + else: + raise RuntimeError( + 'Failed to upload file part {}.'.format(part_index)) + + if is_big: + 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 + ) + + +async def _file_to_media( + self, file, force_document=False, file_size=None, + progress_callback=None, attributes=None, thumb=None, + allow_cache=True, voice_note=False, video_note=False, + supports_streaming=False, mime_type=None, as_image=None, + ttl=None): + if not file: + return None, None, None + + if isinstance(file, pathlib.Path): + file = str(file.absolute()) + + is_image = utils.is_image(file) + if as_image is None: + as_image = is_image and not force_document + + # `aiofiles` do not base `io.IOBase` but do have `read`, so we + # just check for the read attribute to see if it's file-like. + if not isinstance(file, (str, bytes, types.InputFile, types.InputFileBig))\ + and not hasattr(file, 'read'): + # The user may pass a Message containing media (or the media, + # or anything similar) that should be treated as a file. Try + # getting the input media for whatever they passed and send it. + # + # We pass all attributes since these will be used if the user + # passed :tl:`InputFile`, and all information may be relevant. + try: + return (None, utils.get_input_media( file, - mime_type=mime_type, + is_photo=as_image, attributes=attributes, - force_document=force_document and not is_image, + force_document=force_document, voice_note=voice_note, video_note=video_note, supports_streaming=supports_streaming, - thumb=thumb - ) + ttl=ttl + ), as_image) + except TypeError: + # Can't turn whatever was given into media + return None, None, as_image - if not thumb: - thumb = None - else: - if isinstance(thumb, pathlib.Path): - thumb = str(thumb.absolute()) - thumb = await self.upload_file(thumb, file_size=file_size) + media = None + file_handle = None - media = types.InputMediaUploadedDocument( - file=file_handle, - mime_type=mime_type, - attributes=attributes, - thumb=thumb, - force_file=force_document and not is_image, - ttl_seconds=ttl - ) - return file_handle, media, as_image + if isinstance(file, (types.InputFile, types.InputFileBig)): + file_handle = file + elif not isinstance(file, str) or os.path.isfile(file): + file_handle = await self.upload_file( + _resize_photo_if_needed(file, as_image), + file_size=file_size, + progress_callback=progress_callback + ) + elif re.match('https?://', file): + if as_image: + media = types.InputMediaPhotoExternal(file, ttl_seconds=ttl) + else: + media = types.InputMediaDocumentExternal(file, ttl_seconds=ttl) + else: + bot_file = utils.resolve_bot_file_id(file) + if bot_file: + media = utils.get_input_media(bot_file, ttl=ttl) - # endregion + if media: + pass # Already have media, don't check the rest + elif not file_handle: + raise ValueError( + 'Failed to convert {} to media. Not an existing file, ' + 'an HTTP URL or a valid bot-API-like file ID'.format(file) + ) + elif as_image: + media = types.InputMediaUploadedPhoto(file_handle, ttl_seconds=ttl) + else: + attributes, mime_type = utils.get_attributes( + file, + mime_type=mime_type, + attributes=attributes, + force_document=force_document and not is_image, + voice_note=voice_note, + video_note=video_note, + supports_streaming=supports_streaming, + thumb=thumb + ) + + if not thumb: + thumb = None + else: + if isinstance(thumb, pathlib.Path): + thumb = str(thumb.absolute()) + thumb = await self.upload_file(thumb, file_size=file_size) + + media = types.InputMediaUploadedDocument( + file=file_handle, + mime_type=mime_type, + attributes=attributes, + thumb=thumb, + force_file=force_document and not is_image, + ttl_seconds=ttl + ) + return file_handle, media, as_image diff --git a/telethon/client/users.py b/telethon/client/users.py index 22db969e..e6964e55 100644 --- a/telethon/client/users.py +++ b/telethon/client/users.py @@ -25,587 +25,576 @@ def _fmt_flood(delay, request, *, early=False, td=datetime.timedelta): ) -class UserMethods: - async def __call__(self: 'TelegramClient', request, ordered=False, flood_sleep_threshold=None): - return await self._call(self._sender, request, ordered=ordered) +async def call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None): + if flood_sleep_threshold is None: + flood_sleep_threshold = self.flood_sleep_threshold + requests = (request if utils.is_list_like(request) else (request,)) + for r in requests: + if not isinstance(r, TLRequest): + raise _NOT_A_REQUEST() + await r.resolve(self, utils) - async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None): - if flood_sleep_threshold is None: - flood_sleep_threshold = self.flood_sleep_threshold - requests = (request if utils.is_list_like(request) else (request,)) - for r in requests: - if not isinstance(r, TLRequest): - raise _NOT_A_REQUEST() - await r.resolve(self, utils) + # Avoid making the request if it's already in a flood wait + if r.CONSTRUCTOR_ID in self._flood_waited_requests: + due = self._flood_waited_requests[r.CONSTRUCTOR_ID] + diff = round(due - time.time()) + if diff <= 3: # Flood waits below 3 seconds are "ignored" + self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) + elif diff <= flood_sleep_threshold: + self._log[__name__].info(*_fmt_flood(diff, r, early=True)) + await asyncio.sleep(diff) + self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) + else: + raise errors.FloodWaitError(request=r, capture=diff) - # Avoid making the request if it's already in a flood wait - if r.CONSTRUCTOR_ID in self._flood_waited_requests: - due = self._flood_waited_requests[r.CONSTRUCTOR_ID] - diff = round(due - time.time()) - if diff <= 3: # Flood waits below 3 seconds are "ignored" - self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) - elif diff <= flood_sleep_threshold: - self._log[__name__].info(*_fmt_flood(diff, r, early=True)) - await asyncio.sleep(diff) - self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) - else: - raise errors.FloodWaitError(request=r, capture=diff) + if self._no_updates: + r = functions.InvokeWithoutUpdatesRequest(r) - if self._no_updates: - r = functions.InvokeWithoutUpdatesRequest(r) + request_index = 0 + last_error = None + self._last_request = time.time() - request_index = 0 - last_error = None - self._last_request = time.time() - - for attempt in retry_range(self._request_retries): - try: - future = sender.send(request, ordered=ordered) - if isinstance(future, list): - results = [] - exceptions = [] - for f in future: - try: - result = await f - except RPCError as e: - exceptions.append(e) - results.append(None) - continue - self.session.process_entities(result) - self._entity_cache.add(result) - exceptions.append(None) - results.append(result) - request_index += 1 - if any(x is not None for x in exceptions): - raise MultiError(exceptions, results, requests) - else: - return results - else: - result = await future + for attempt in retry_range(self._request_retries): + try: + future = sender.send(request, ordered=ordered) + if isinstance(future, list): + results = [] + exceptions = [] + for f in future: + try: + result = await f + except RPCError as e: + exceptions.append(e) + results.append(None) + continue self.session.process_entities(result) self._entity_cache.add(result) - return result - except (errors.ServerError, errors.RpcCallFailError, - errors.RpcMcgetFailError, errors.InterdcCallErrorError, - errors.InterdcCallRichErrorError) as e: - last_error = e - self._log[__name__].warning( - 'Telegram is having internal issues %s: %s', - e.__class__.__name__, e) - - await asyncio.sleep(2) - except (errors.FloodWaitError, errors.SlowModeWaitError, errors.FloodTestPhoneWaitError) as e: - last_error = e - if utils.is_list_like(request): - request = request[request_index] - - # SLOW_MODE_WAIT is chat-specific, not request-specific - if not isinstance(e, errors.SlowModeWaitError): - self._flood_waited_requests\ - [request.CONSTRUCTOR_ID] = time.time() + e.seconds - - # In test servers, FLOOD_WAIT_0 has been observed, and sleeping for - # such a short amount will cause retries very fast leading to issues. - if e.seconds == 0: - e.seconds = 1 - - if e.seconds <= self.flood_sleep_threshold: - self._log[__name__].info(*_fmt_flood(e.seconds, request)) - await asyncio.sleep(e.seconds) + exceptions.append(None) + results.append(result) + request_index += 1 + if any(x is not None for x in exceptions): + raise MultiError(exceptions, results, requests) else: - raise - except (errors.PhoneMigrateError, errors.NetworkMigrateError, - errors.UserMigrateError) as e: - last_error = e - self._log[__name__].info('Phone migrated to %d', e.new_dc) - should_raise = isinstance(e, ( - errors.PhoneMigrateError, errors.NetworkMigrateError - )) - if should_raise and await self.is_user_authorized(): - raise - await self._switch_dc(e.new_dc) - - if self._raise_last_call_error and last_error is not None: - raise last_error - raise ValueError('Request was unsuccessful {} time(s)' - .format(attempt)) - - # region Public methods - - async def get_me(self: 'TelegramClient', input_peer: bool = False) \ - -> 'typing.Union[types.User, types.InputPeerUser]': - """ - Gets "me", the current :tl:`User` who is logged in. - - If the user has not logged in yet, this method returns `None`. - - Arguments - 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`. - - Example - .. code-block:: python - - me = await client.get_me() - print(me.username) - """ - if input_peer and self._self_input_peer: - return self._self_input_peer - - try: - me = (await self( - functions.users.GetUsersRequest([types.InputUserSelf()])))[0] - - self._bot = me.bot - 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 - - @property - def _self_id(self: 'TelegramClient') -> typing.Optional[int]: - """ - Returns the ID of the logged-in user, if known. - - This property is used in every update, and some like `updateLoginToken` - occur prior to login, so it gracefully handles when no ID is known yet. - """ - return self._self_input_peer.user_id if self._self_input_peer else None - - async def is_bot(self: 'TelegramClient') -> bool: - """ - Return `True` if the signed-in user is a bot, `False` otherwise. - - Example - .. code-block:: python - - if await client.is_bot(): - print('Beep') - else: - print('Hello') - """ - if self._bot is None: - self._bot = (await self.get_me()).bot - - return self._bot - - async def is_user_authorized(self: 'TelegramClient') -> bool: - """ - Returns `True` if the user is authorized (logged in). - - Example - .. code-block:: python - - if not await client.is_user_authorized(): - await client.send_code_request(phone) - code = input('enter code: ') - await client.sign_in(phone, code) - """ - if self._authorized is None: - try: - # Any request that requires authorization will work - await self(functions.updates.GetStateRequest()) - self._authorized = True - except errors.RPCError: - self._authorized = False - - return self._authorized - - async def get_entity( - self: 'TelegramClient', - entity: 'hints.EntitiesLike') -> 'hints.Entity': - """ - Turns the given entity into a valid Telegram :tl:`User`, :tl:`Chat` - or :tl:`Channel`. You can also pass a list or iterable of entities, - and they will be efficiently fetched from the network. - - Arguments - entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): - If a username is given, **the username will be resolved** making - an API call every time. Resolving usernames is an expensive - operation and will start hitting flood waits around 50 usernames - in a short period of time. - - If you want to get the entity for a *cached* username, you should - first `get_input_entity(username) ` which will - use the cache), and then use `get_entity` with the result of the - previous call. - - Similar limits apply to invite links, and you should use their - ID instead. - - Using phone numbers (from people in your contact list), exact - names, integer IDs or :tl:`Peer` rely on a `get_input_entity` - first, which in turn needs the entity to be in cache, unless - a :tl:`InputPeer` was passed. - - Unsupported types will raise ``TypeError``. - - If the entity can't be found, ``ValueError`` 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. - - Example - .. code-block:: python - - from telethon import utils - - me = await client.get_entity('me') - print(utils.get_display_name(me)) - - chat = await client.get_input_entity('username') - async for message in client.iter_messages(chat): - ... - - # Note that you could have used the username directly, but it's - # good to use get_input_entity if you will reuse it a lot. - async for message in client.iter_messages('username'): - ... - - # Note that for this to work the phone number must be in your contacts - some_id = await client.get_peer_id('+34123456789') - """ - 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 = [] - for x in entity: - if isinstance(x, str): - inputs.append(x) + return results else: - inputs.append(await self.get_input_entity(x)) + result = await future + self.session.process_entities(result) + self._entity_cache.add(result) + return result + except (errors.ServerError, errors.RpcCallFailError, + errors.RpcMcgetFailError, errors.InterdcCallErrorError, + errors.InterdcCallRichErrorError) as e: + last_error = e + self._log[__name__].warning( + 'Telegram is having internal issues %s: %s', + e.__class__.__name__, e) - lists = { - helpers._EntityType.USER: [], - helpers._EntityType.CHAT: [], - helpers._EntityType.CHANNEL: [], - } - for x in inputs: - try: - lists[helpers._entity_type(x)].append(x) - except TypeError: - pass + await asyncio.sleep(2) + except (errors.FloodWaitError, errors.SlowModeWaitError, errors.FloodTestPhoneWaitError) as e: + last_error = e + if utils.is_list_like(request): + request = request[request_index] - users = lists[helpers._EntityType.USER] - chats = lists[helpers._EntityType.CHAT] - channels = lists[helpers._EntityType.CHANNEL] - 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([x.chat_id for x in chats]))).chats - if channels: - channels = (await self( - functions.channels.GetChannelsRequest(channels))).chats + # SLOW_MODE_WAIT is chat-specific, not request-specific + if not isinstance(e, errors.SlowModeWaitError): + self._flood_waited_requests\ + [request.CONSTRUCTOR_ID] = time.time() + e.seconds - # 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) - } + # In test servers, FLOOD_WAIT_0 has been observed, and sleeping for + # such a short amount will cause retries very fast leading to issues. + if e.seconds == 0: + e.seconds = 1 - # 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 = [] - for x in inputs: - if isinstance(x, str): - result.append(await self._get_entity_from_string(x)) - elif not isinstance(x, types.InputPeerSelf): - result.append(id_entity[utils.get_peer_id(x)]) + if e.seconds <= self.flood_sleep_threshold: + self._log[__name__].info(*_fmt_flood(e.seconds, request)) + await asyncio.sleep(e.seconds) else: - result.append(next( - u for u in id_entity.values() - if isinstance(u, types.User) and u.is_self - )) + raise + except (errors.PhoneMigrateError, errors.NetworkMigrateError, + errors.UserMigrateError) as e: + last_error = e + self._log[__name__].info('Phone migrated to %d', e.new_dc) + should_raise = isinstance(e, ( + errors.PhoneMigrateError, errors.NetworkMigrateError + )) + if should_raise and await self.is_user_authorized(): + raise + await self._switch_dc(e.new_dc) - return result[0] if single else result + if self._raise_last_call_error and last_error is not None: + raise last_error + raise ValueError('Request was unsuccessful {} time(s)' + .format(attempt)) - async def get_input_entity( - self: 'TelegramClient', - peer: 'hints.EntityLike') -> 'types.TypeInputPeer': - """ - Turns the given entity into its input entity version. - Most requests use this kind of :tl:`InputPeer`, so this is the most - suitable call to make for those cases. **Generally you should let the - library do its job** and don't worry about getting the input entity - first, but if you're going to use an entity often, consider making the - call: +async def get_me(self: 'TelegramClient', input_peer: bool = False) \ + -> 'typing.Union[types.User, types.InputPeerUser]': + """ + Gets "me", the current :tl:`User` who is logged in. - Arguments - entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): - If a username or invite link is given, **the library will - use the cache**. This means that it's possible to be using - a username that *changed* or an old invite link (this only - happens if an invite link for a small group chat is used - after it was upgraded to a mega-group). + If the user has not logged in yet, this method returns `None`. - If the username or ID from the invite link is not found in - the cache, it will be fetched. The same rules apply to phone - numbers (``'+34 123456789'``) from people in your contact list. + Arguments + 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. - If an exact name is given, it must be in the cache too. This - is not reliable as different people can share the same name - and which entity is returned is arbitrary, and should be used - only for quick tests. + Returns + Your own :tl:`User`. - If a positive integer ID is given, the entity will be searched - in cached users, chats or channels, without making any call. + Example + .. code-block:: python - If a negative integer ID is given, the entity will be searched - exactly as either a chat (prefixed with ``-``) or as a channel - (prefixed with ``-100``). + me = await client.get_me() + print(me.username) + """ + if input_peer and self._self_input_peer: + return self._self_input_peer - If a :tl:`Peer` is given, it will be searched exactly in the - cache as either a user, chat or channel. + try: + me = (await self( + functions.users.GetUsersRequest([types.InputUserSelf()])))[0] - If the given object can be turned into an input entity directly, - said operation will be done. + self._bot = me.bot + if not self._self_input_peer: + self._self_input_peer = utils.get_input_peer( + me, allow_self=False + ) - Unsupported types will raise ``TypeError``. + return self._self_input_peer if input_peer else me + except errors.UnauthorizedError: + return None - If the entity can't be found, ``ValueError`` will be raised. +def _self_id(self: 'TelegramClient') -> typing.Optional[int]: + """ + Returns the ID of the logged-in user, if known. - Returns - :tl:`InputPeerUser`, :tl:`InputPeerChat` or :tl:`InputPeerChannel` - or :tl:`InputPeerSelf` if the parameter is ``'me'`` or ``'self'``. + This property is used in every update, and some like `updateLoginToken` + occur prior to login, so it gracefully handles when no ID is known yet. + """ + return self._self_input_peer.user_id if self._self_input_peer else None - If you need to get the ID of yourself, you should use - `get_me` with ``input_peer=True``) instead. +async def is_bot(self: 'TelegramClient') -> bool: + """ + Return `True` if the signed-in user is a bot, `False` otherwise. - Example - .. code-block:: python + Example + .. code-block:: python - # If you're going to use "username" often in your code - # (make a lot of calls), consider getting its input entity - # once, and then using the "user" everywhere instead. - user = await client.get_input_entity('username') + if await client.is_bot(): + print('Beep') + else: + print('Hello') + """ + if self._bot is None: + self._bot = (await self.get_me()).bot - # The same applies to IDs, chats or channels. - chat = await client.get_input_entity(-123456789) - """ - # Short-circuit if the input parameter directly maps to an InputPeer + return self._bot + +async def is_user_authorized(self: 'TelegramClient') -> bool: + """ + Returns `True` if the user is authorized (logged in). + + Example + .. code-block:: python + + if not await client.is_user_authorized(): + await client.send_code_request(phone) + code = input('enter code: ') + await client.sign_in(phone, code) + """ + if self._authorized is None: try: - return utils.get_input_peer(peer) + # Any request that requires authorization will work + await self(functions.updates.GetStateRequest()) + self._authorized = True + except errors.RPCError: + self._authorized = False + + return self._authorized + +async def get_entity( + self: 'TelegramClient', + entity: 'hints.EntitiesLike') -> 'hints.Entity': + """ + Turns the given entity into a valid Telegram :tl:`User`, :tl:`Chat` + or :tl:`Channel`. You can also pass a list or iterable of entities, + and they will be efficiently fetched from the network. + + Arguments + entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): + If a username is given, **the username will be resolved** making + an API call every time. Resolving usernames is an expensive + operation and will start hitting flood waits around 50 usernames + in a short period of time. + + If you want to get the entity for a *cached* username, you should + first `get_input_entity(username) ` which will + use the cache), and then use `get_entity` with the result of the + previous call. + + Similar limits apply to invite links, and you should use their + ID instead. + + Using phone numbers (from people in your contact list), exact + names, integer IDs or :tl:`Peer` rely on a `get_input_entity` + first, which in turn needs the entity to be in cache, unless + a :tl:`InputPeer` was passed. + + Unsupported types will raise ``TypeError``. + + If the entity can't be found, ``ValueError`` 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. + + Example + .. code-block:: python + + from telethon import utils + + me = await client.get_entity('me') + print(utils.get_display_name(me)) + + chat = await client.get_input_entity('username') + async for message in client.iter_messages(chat): + ... + + # Note that you could have used the username directly, but it's + # good to use get_input_entity if you will reuse it a lot. + async for message in client.iter_messages('username'): + ... + + # Note that for this to work the phone number must be in your contacts + some_id = await client.get_peer_id('+34123456789') + """ + 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 = [] + for x in entity: + if isinstance(x, str): + inputs.append(x) + else: + inputs.append(await self.get_input_entity(x)) + + lists = { + helpers._EntityType.USER: [], + helpers._EntityType.CHAT: [], + helpers._EntityType.CHANNEL: [], + } + for x in inputs: + try: + lists[helpers._entity_type(x)].append(x) except TypeError: pass - # Next in priority is having a peer (or its ID) cached in-memory + users = lists[helpers._EntityType.USER] + chats = lists[helpers._EntityType.CHAT] + channels = lists[helpers._EntityType.CHANNEL] + 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([x.chat_id for x in 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 = [] + for x in inputs: + if isinstance(x, str): + result.append(await self._get_entity_from_string(x)) + elif not isinstance(x, types.InputPeerSelf): + result.append(id_entity[utils.get_peer_id(x)]) + else: + result.append(next( + u for u in id_entity.values() + if isinstance(u, types.User) and u.is_self + )) + + return result[0] if single else result + +async def get_input_entity( + self: 'TelegramClient', + peer: 'hints.EntityLike') -> 'types.TypeInputPeer': + """ + Turns the given entity into its input entity version. + + Most requests use this kind of :tl:`InputPeer`, so this is the most + suitable call to make for those cases. **Generally you should let the + library do its job** and don't worry about getting the input entity + first, but if you're going to use an entity often, consider making the + call: + + Arguments + entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): + If a username or invite link is given, **the library will + use the cache**. This means that it's possible to be using + a username that *changed* or an old invite link (this only + happens if an invite link for a small group chat is used + after it was upgraded to a mega-group). + + If the username or ID from the invite link is not found in + the cache, it will be fetched. The same rules apply to phone + numbers (``'+34 123456789'``) from people in your contact list. + + If an exact name is given, it must be in the cache too. This + is not reliable as different people can share the same name + and which entity is returned is arbitrary, and should be used + only for quick tests. + + If a positive integer ID is given, the entity will be searched + in cached users, chats or channels, without making any call. + + If a negative integer ID is given, the entity will be searched + exactly as either a chat (prefixed with ``-``) or as a channel + (prefixed with ``-100``). + + If a :tl:`Peer` is given, it will be searched exactly in the + cache as either a user, chat or channel. + + If the given object can be turned into an input entity directly, + said operation will be done. + + Unsupported types will raise ``TypeError``. + + If the entity can't be found, ``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. + + Example + .. code-block:: python + + # If you're going to use "username" often in your code + # (make a lot of calls), consider getting its input entity + # once, and then using the "user" everywhere instead. + user = await client.get_input_entity('username') + + # The same applies to IDs, chats or channels. + chat = await client.get_input_entity(-123456789) + """ + # Short-circuit if the input parameter directly maps to an InputPeer + try: + return utils.get_input_peer(peer) + except TypeError: + pass + + # Next in priority is having a peer (or its ID) cached in-memory + try: + # 0x2d45687 == crc32(b'Peer') + if isinstance(peer, int) or peer.SUBCLASS_OF_ID == 0x2d45687: + return self._entity_cache[peer] + except (AttributeError, KeyError): + pass + + # Then come known strings that take precedence + if peer in ('me', 'self'): + return types.InputPeerSelf() + + # No InputPeer, cached peer, or known string. Fetch from disk cache + try: + return self.session.get_input_entity(peer) + except ValueError: + pass + + # Only network left to try + if isinstance(peer, str): + return utils.get_input_peer( + await self._get_entity_from_string(peer)) + + # If we're a bot and the user has messaged us privately users.getUsers + # will work with access_hash = 0. Similar for channels.getChannels. + # If we're not a bot but the user is in our contacts, it seems to work + # regardless. These are the only two special-cased requests. + peer = utils.get_peer(peer) + if isinstance(peer, types.PeerUser): + users = await self(functions.users.GetUsersRequest([ + types.InputUser(peer.user_id, access_hash=0)])) + if users and not isinstance(users[0], types.UserEmpty): + # If the user passed a valid ID they expect to work for + # channels but would be valid for users, we get UserEmpty. + # Avoid returning the invalid empty input peer for that. + # + # We *could* try to guess if it's a channel first, and if + # it's not, work as a chat and try to validate it through + # another request, but that becomes too much work. + return utils.get_input_peer(users[0]) + elif isinstance(peer, types.PeerChat): + return types.InputPeerChat(peer.chat_id) + elif isinstance(peer, types.PeerChannel): try: - # 0x2d45687 == crc32(b'Peer') - if isinstance(peer, int) or peer.SUBCLASS_OF_ID == 0x2d45687: - return self._entity_cache[peer] - except (AttributeError, KeyError): + channels = await self(functions.channels.GetChannelsRequest([ + types.InputChannel(peer.channel_id, access_hash=0)])) + return utils.get_input_peer(channels.chats[0]) + except errors.ChannelInvalidError: pass - # Then come known strings that take precedence - if peer in ('me', 'self'): - return types.InputPeerSelf() + raise ValueError( + 'Could not find the input entity for {} ({}). Please read https://' + 'docs.telethon.dev/en/latest/concepts/entities.html to' + ' find out more details.' + .format(peer, type(peer).__name__) + ) - # No InputPeer, cached peer, or known string. Fetch from disk cache +async def _get_peer(self: 'TelegramClient', peer: 'hints.EntityLike'): + i, cls = utils.resolve_id(await self.get_peer_id(peer)) + return cls(i) + +async def get_peer_id( + self: 'TelegramClient', + peer: 'hints.EntityLike', + add_mark: bool = True) -> int: + """ + Gets the ID for the given entity. + + This method needs to be ``async`` because `peer` supports usernames, + invite-links, phone numbers (from people in your contact list), etc. + + If ``add_mark is False``, then a positive ID will be returned + instead. By default, bot-API style IDs (signed) are returned. + + Example + .. code-block:: python + + print(await client.get_peer_id('me')) + """ + if isinstance(peer, int): + return utils.get_peer_id(peer, add_mark=add_mark) + + try: + if peer.SUBCLASS_OF_ID not in (0x2d45687, 0xc91c90b6): + # 0x2d45687, 0xc91c90b6 == crc32(b'Peer') and b'InputPeer' + peer = await self.get_input_entity(peer) + except AttributeError: + peer = await self.get_input_entity(peer) + + if isinstance(peer, types.InputPeerSelf): + peer = await self.get_me(input_peer=True) + + return utils.get_peer_id(peer, add_mark=add_mark) + + +async def _get_entity_from_string(self: 'TelegramClient', string): + """ + Gets a full entity from the given string, which may be a phone or + a 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: try: - return self.session.get_input_entity(peer) + for user in (await self( + functions.contacts.GetContactsRequest(0))).users: + if user.phone == phone: + return user + except errors.BotMethodInvalidError: + raise ValueError('Cannot get entity by phone number as a ' + 'bot (try using integer IDs, not strings)') + elif string.lower() in ('me', 'self'): + return await self.get_me() + 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: + try: + result = await self( + functions.contacts.ResolveUsernameRequest(username)) + except errors.UsernameNotOccupiedError as e: + raise ValueError('No user has "{}" as username' + .format(username)) from e + + try: + pid = utils.get_peer_id(result.peer, add_mark=False) + if isinstance(result.peer, types.PeerUser): + return next(x for x in result.users if x.id == pid) + else: + return next(x for x in result.chats if x.id == pid) + except StopIteration: + pass + 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 - # Only network left to try - if isinstance(peer, str): - return utils.get_input_peer( - await self._get_entity_from_string(peer)) + raise ValueError( + 'Cannot find any entity corresponding to "{}"'.format(string) + ) - # If we're a bot and the user has messaged us privately users.getUsers - # will work with access_hash = 0. Similar for channels.getChannels. - # If we're not a bot but the user is in our contacts, it seems to work - # regardless. These are the only two special-cased requests. - peer = utils.get_peer(peer) - if isinstance(peer, types.PeerUser): - users = await self(functions.users.GetUsersRequest([ - types.InputUser(peer.user_id, access_hash=0)])) - if users and not isinstance(users[0], types.UserEmpty): - # If the user passed a valid ID they expect to work for - # channels but would be valid for users, we get UserEmpty. - # Avoid returning the invalid empty input peer for that. - # - # We *could* try to guess if it's a channel first, and if - # it's not, work as a chat and try to validate it through - # another request, but that becomes too much work. - return utils.get_input_peer(users[0]) - elif isinstance(peer, types.PeerChat): - return types.InputPeerChat(peer.chat_id) - elif isinstance(peer, types.PeerChannel): - try: - channels = await self(functions.channels.GetChannelsRequest([ - types.InputChannel(peer.channel_id, access_hash=0)])) - return utils.get_input_peer(channels.chats[0]) - except errors.ChannelInvalidError: - pass +async def _get_input_dialog(self: 'TelegramClient', dialog): + """ + Returns a :tl:`InputDialogPeer`. This is a bit tricky because + it may or not need access to the client to convert what's given + into an input entity. + """ + try: + if dialog.SUBCLASS_OF_ID == 0xa21c9795: # crc32(b'InputDialogPeer') + dialog.peer = await self.get_input_entity(dialog.peer) + return dialog + elif dialog.SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer') + return types.InputDialogPeer(dialog) + except AttributeError: + pass - raise ValueError( - 'Could not find the input entity for {} ({}). Please read https://' - 'docs.telethon.dev/en/latest/concepts/entities.html to' - ' find out more details.' - .format(peer, type(peer).__name__) - ) + return types.InputDialogPeer(await self.get_input_entity(dialog)) - async def _get_peer(self: 'TelegramClient', peer: 'hints.EntityLike'): - i, cls = utils.resolve_id(await self.get_peer_id(peer)) - return cls(i) +async def _get_input_notify(self: 'TelegramClient', notify): + """ + Returns a :tl:`InputNotifyPeer`. This is a bit tricky because + it may or not need access to the client to convert what's given + into an input entity. + """ + try: + if notify.SUBCLASS_OF_ID == 0x58981615: + if isinstance(notify, types.InputNotifyPeer): + notify.peer = await self.get_input_entity(notify.peer) + return notify + except AttributeError: + pass - async def get_peer_id( - self: 'TelegramClient', - peer: 'hints.EntityLike', - add_mark: bool = True) -> int: - """ - Gets the ID for the given entity. - - This method needs to be ``async`` because `peer` supports usernames, - invite-links, phone numbers (from people in your contact list), etc. - - If ``add_mark is False``, then a positive ID will be returned - instead. By default, bot-API style IDs (signed) are returned. - - Example - .. code-block:: python - - print(await client.get_peer_id('me')) - """ - if isinstance(peer, int): - return utils.get_peer_id(peer, add_mark=add_mark) - - try: - if peer.SUBCLASS_OF_ID not in (0x2d45687, 0xc91c90b6): - # 0x2d45687, 0xc91c90b6 == crc32(b'Peer') and b'InputPeer' - peer = await self.get_input_entity(peer) - except AttributeError: - peer = await self.get_input_entity(peer) - - if isinstance(peer, types.InputPeerSelf): - peer = await self.get_me(input_peer=True) - - return utils.get_peer_id(peer, add_mark=add_mark) - - # endregion - - # region Private methods - - async def _get_entity_from_string(self: 'TelegramClient', string): - """ - Gets a full entity from the given string, which may be a phone or - a 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: - try: - for user in (await self( - functions.contacts.GetContactsRequest(0))).users: - if user.phone == phone: - return user - except errors.BotMethodInvalidError: - raise ValueError('Cannot get entity by phone number as a ' - 'bot (try using integer IDs, not strings)') - elif string.lower() in ('me', 'self'): - return await self.get_me() - 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: - try: - result = await self( - functions.contacts.ResolveUsernameRequest(username)) - except errors.UsernameNotOccupiedError as e: - raise ValueError('No user has "{}" as username' - .format(username)) from e - - try: - pid = utils.get_peer_id(result.peer, add_mark=False) - if isinstance(result.peer, types.PeerUser): - return next(x for x in result.users if x.id == pid) - else: - return next(x for x in result.chats if x.id == pid) - except StopIteration: - pass - 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) - ) - - async def _get_input_dialog(self: 'TelegramClient', dialog): - """ - Returns a :tl:`InputDialogPeer`. This is a bit tricky because - it may or not need access to the client to convert what's given - into an input entity. - """ - try: - if dialog.SUBCLASS_OF_ID == 0xa21c9795: # crc32(b'InputDialogPeer') - dialog.peer = await self.get_input_entity(dialog.peer) - return dialog - elif dialog.SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer') - return types.InputDialogPeer(dialog) - except AttributeError: - pass - - return types.InputDialogPeer(await self.get_input_entity(dialog)) - - async def _get_input_notify(self: 'TelegramClient', notify): - """ - Returns a :tl:`InputNotifyPeer`. This is a bit tricky because - it may or not need access to the client to convert what's given - into an input entity. - """ - try: - if notify.SUBCLASS_OF_ID == 0x58981615: - if isinstance(notify, types.InputNotifyPeer): - notify.peer = await self.get_input_entity(notify.peer) - return notify - except AttributeError: - pass - - return types.InputNotifyPeer(await self.get_input_entity(notify)) - - # endregion + return types.InputNotifyPeer(await self.get_input_entity(notify)) From d6326abacb43496958129101343c06930c5e691e Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 11 Sep 2021 13:35:35 +0200 Subject: [PATCH 003/131] Rename client module as _client --- readthedocs/misc/v2-migration-guide.rst | 5 ----- telethon/{client => _client}/__init__.py | 0 telethon/{client => _client}/account.py | 0 telethon/{client => _client}/auth.py | 0 telethon/{client => _client}/bots.py | 0 telethon/{client => _client}/buttons.py | 0 telethon/{client => _client}/chats.py | 0 telethon/{client => _client}/dialogs.py | 0 telethon/{client => _client}/downloads.py | 0 telethon/{client => _client}/messageparse.py | 0 telethon/{client => _client}/messages.py | 0 telethon/{client => _client}/telegrambaseclient.py | 0 telethon/{client => _client}/telegramclient.py | 0 telethon/{client => _client}/updates.py | 0 telethon/{client => _client}/uploads.py | 0 telethon/{client => _client}/users.py | 0 16 files changed, 5 deletions(-) rename telethon/{client => _client}/__init__.py (100%) rename telethon/{client => _client}/account.py (100%) rename telethon/{client => _client}/auth.py (100%) rename telethon/{client => _client}/bots.py (100%) rename telethon/{client => _client}/buttons.py (100%) rename telethon/{client => _client}/chats.py (100%) rename telethon/{client => _client}/dialogs.py (100%) rename telethon/{client => _client}/downloads.py (100%) rename telethon/{client => _client}/messageparse.py (100%) rename telethon/{client => _client}/messages.py (100%) rename telethon/{client => _client}/telegrambaseclient.py (100%) rename telethon/{client => _client}/telegramclient.py (100%) rename telethon/{client => _client}/updates.py (100%) rename telethon/{client => _client}/uploads.py (100%) rename telethon/{client => _client}/users.py (100%) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index cd887251..7655e673 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -32,8 +32,3 @@ change even across minor version changes, and thus have your code break. * The ``telethon.client`` module is now ``telethon._client``, meaning you should stop relying on anything inside of it. This includes all of the subclasses that used to exist (like ``UserMethods``). - - TODO REVIEW self\._\w+\( - and __signature__ - and property - abs abc abstract \ No newline at end of file diff --git a/telethon/client/__init__.py b/telethon/_client/__init__.py similarity index 100% rename from telethon/client/__init__.py rename to telethon/_client/__init__.py diff --git a/telethon/client/account.py b/telethon/_client/account.py similarity index 100% rename from telethon/client/account.py rename to telethon/_client/account.py diff --git a/telethon/client/auth.py b/telethon/_client/auth.py similarity index 100% rename from telethon/client/auth.py rename to telethon/_client/auth.py diff --git a/telethon/client/bots.py b/telethon/_client/bots.py similarity index 100% rename from telethon/client/bots.py rename to telethon/_client/bots.py diff --git a/telethon/client/buttons.py b/telethon/_client/buttons.py similarity index 100% rename from telethon/client/buttons.py rename to telethon/_client/buttons.py diff --git a/telethon/client/chats.py b/telethon/_client/chats.py similarity index 100% rename from telethon/client/chats.py rename to telethon/_client/chats.py diff --git a/telethon/client/dialogs.py b/telethon/_client/dialogs.py similarity index 100% rename from telethon/client/dialogs.py rename to telethon/_client/dialogs.py diff --git a/telethon/client/downloads.py b/telethon/_client/downloads.py similarity index 100% rename from telethon/client/downloads.py rename to telethon/_client/downloads.py diff --git a/telethon/client/messageparse.py b/telethon/_client/messageparse.py similarity index 100% rename from telethon/client/messageparse.py rename to telethon/_client/messageparse.py diff --git a/telethon/client/messages.py b/telethon/_client/messages.py similarity index 100% rename from telethon/client/messages.py rename to telethon/_client/messages.py diff --git a/telethon/client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py similarity index 100% rename from telethon/client/telegrambaseclient.py rename to telethon/_client/telegrambaseclient.py diff --git a/telethon/client/telegramclient.py b/telethon/_client/telegramclient.py similarity index 100% rename from telethon/client/telegramclient.py rename to telethon/_client/telegramclient.py diff --git a/telethon/client/updates.py b/telethon/_client/updates.py similarity index 100% rename from telethon/client/updates.py rename to telethon/_client/updates.py diff --git a/telethon/client/uploads.py b/telethon/_client/uploads.py similarity index 100% rename from telethon/client/uploads.py rename to telethon/_client/uploads.py diff --git a/telethon/client/users.py b/telethon/_client/users.py similarity index 100% rename from telethon/client/users.py rename to telethon/_client/users.py From 34e7b7cc9fd8ea3b9a0179e3de8edc5b63b52e78 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 11 Sep 2021 13:43:20 +0200 Subject: [PATCH 004/131] Fix some import errors --- telethon/_client/__init__.py | 25 ++----------------------- telethon/_client/telegramclient.py | 23 ++++++++++++++--------- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/telethon/_client/__init__.py b/telethon/_client/__init__.py index e0463ab0..5cfe7a83 100644 --- a/telethon/_client/__init__.py +++ b/telethon/_client/__init__.py @@ -1,25 +1,4 @@ """ -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). +This package defines the main `telethon._client.telegramclient.TelegramClient` instance +which delegates the work to free-standing functions defined in the rest of files. """ -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 .updates import UpdateMethods # Required for buttons (register callbacks) -from .buttons import ButtonMethods # Required for messages to use buttons -from .messages import MessageMethods -from .chats import ChatMethods -from .dialogs import DialogMethods -from .downloads import DownloadMethods -from .account import AccountMethods -from .auth import AuthMethods -from .bots import BotMethods -from .telegramclient import TelegramClient diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index f1fbaa82..58f6b56a 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -1,12 +1,17 @@ +import asyncio import functools import inspect import typing +import logging from . import ( account, auth, bots, buttons, chats, dialogs, downloads, messageparse, messages, telegrambaseclient, updates, uploads, users ) -from .. import helpers +from .. import helpers, version +from ..tl import types, custom +from ..network import ConnectionTcpFull +from ..events.common import EventBuilder, EventCommon class TelegramClient: @@ -721,7 +726,7 @@ class TelegramClient: *, search: str = '', filter: 'types.TypeChannelParticipantsFilter' = None, - aggressive: bool = False) -> _ParticipantsIter: + aggressive: bool = False) -> chats._ParticipantsIter: """ Iterator over the participants belonging to the specified chat. @@ -830,7 +835,7 @@ class TelegramClient: pinned: bool = None, edit: bool = None, delete: bool = None, - group_call: bool = None) -> _AdminLogIter: + group_call: bool = None) -> chats._AdminLogIter: """ Iterator over the admin log for the specified channel. @@ -958,7 +963,7 @@ class TelegramClient: limit: int = None, *, offset: int = 0, - max_id: int = 0) -> _ProfilePhotoIter: + max_id: int = 0) -> chats._ProfilePhotoIter: """ Iterator over a user's profile photos or a chat's photos. @@ -1452,7 +1457,7 @@ class TelegramClient: ignore_migrated: bool = False, folder: int = None, archived: bool = None - ) -> _DialogsIter: + ) -> dialogs._DialogsIter: """ Iterator over the dialogs (open conversations/subscribed channels). @@ -1549,7 +1554,7 @@ class TelegramClient: def iter_drafts( self: 'TelegramClient', entity: 'hints.EntitiesLike' = None - ) -> _DraftsIter: + ) -> dialogs._DraftsIter: """ Iterator over draft messages. @@ -2023,7 +2028,7 @@ class TelegramClient: stride: int = None, limit: int = None, chunk_size: int = None, - request_size: int = MAX_CHUNK_SIZE, + request_size: int = downloads.MAX_CHUNK_SIZE, file_size: int = None, dc_id: int = None ): @@ -3180,7 +3185,7 @@ class TelegramClient: def add_event_handler( self: 'TelegramClient', - callback: Callback, + callback: updates.Callback, event: EventBuilder = None): """ Registers a new event handler callback. @@ -3218,7 +3223,7 @@ class TelegramClient: def remove_event_handler( self: 'TelegramClient', - callback: Callback, + callback: updates.Callback, event: EventBuilder = None) -> int: """ Inverse operation of `add_event_handler()`. From 2a933ac3bd02cc36b39e3e967b850c6e4511016d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 11 Sep 2021 14:05:24 +0200 Subject: [PATCH 005/131] Remove sync hack --- .github/ISSUE_TEMPLATE/bug-report.md | 2 +- README.rst | 22 +++--- readthedocs/basic/quick-start.rst | 12 ++-- readthedocs/basic/signing-in.rst | 20 ++++-- readthedocs/concepts/asyncio.rst | 93 +------------------------ readthedocs/concepts/sessions.rst | 8 +-- readthedocs/index.rst | 20 +++--- readthedocs/misc/v2-migration-guide.rst | 19 +++++ readthedocs/quick-references/faq.rst | 23 +----- telethon/_client/account.py | 3 - telethon/_client/auth.py | 8 +-- telethon/_client/chats.py | 3 - telethon/_client/downloads.py | 3 - telethon/_client/telegrambaseclient.py | 15 +--- telethon/_client/telegramclient.py | 3 - telethon/_client/updates.py | 12 +--- telethon/helpers.py | 30 +------- telethon/requestiter.py | 18 ----- telethon/sync.py | 74 -------------------- telethon/tl/custom/conversation.py | 3 - telethon_generator/generators/docs.py | 2 +- tests/telethon/test_helpers.py | 37 ---------- 22 files changed, 77 insertions(+), 353 deletions(-) delete mode 100644 telethon/sync.py diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index dc7a26c2..1e7ebec6 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -14,7 +14,7 @@ assignees: '' **Code that causes the issue** ```python -from telethon.sync import TelegramClient +from telethon import TelegramClient ... ``` diff --git a/README.rst b/README.rst index f1eb902c..15985350 100755 --- a/README.rst +++ b/README.rst @@ -35,15 +35,19 @@ Creating a client .. code-block:: python - from telethon import TelegramClient, events, sync + import asyncio + from telethon import TelegramClient, events # These example values won't work. You must get your own api_id and # api_hash from https://my.telegram.org, under API Development. api_id = 12345 api_hash = '0123456789abcdef0123456789abcdef' - client = TelegramClient('session_name', api_id, api_hash) - client.start() + async def main(): + client = TelegramClient('session_name', api_id, api_hash) + await client.start() + + asyncio.run(main()) Doing stuff @@ -51,14 +55,14 @@ Doing stuff .. code-block:: python - print(client.get_me().stringify()) + print((await client.get_me()).stringify()) - client.send_message('username', 'Hello! Talking to you from Telethon') - client.send_file('username', '/home/myself/Pictures/holidays.jpg') + await client.send_message('username', 'Hello! Talking to you from Telethon') + await client.send_file('username', '/home/myself/Pictures/holidays.jpg') - client.download_profile_photo('me') - messages = client.get_messages('username') - messages[0].download_media() + await client.download_profile_photo('me') + messages = await client.get_messages('username') + await messages[0].download_media() @client.on(events.NewMessage(pattern='(?i)hi|hello')) async def handler(event): diff --git a/readthedocs/basic/quick-start.rst b/readthedocs/basic/quick-start.rst index cd187c81..8dbf928d 100644 --- a/readthedocs/basic/quick-start.rst +++ b/readthedocs/basic/quick-start.rst @@ -100,12 +100,8 @@ proceeding. We will see all the available methods later on. # Most of your code should go here. # You can of course make and use your own async def (do_something). # They only need to be async if they need to await things. - me = await client.get_me() - await do_something(me) + async with client: + me = await client.get_me() + await do_something(me) - with client: - client.loop.run_until_complete(main()) - - After you understand this, you may use the ``telethon.sync`` hack if you - want do so (see :ref:`compatibility-and-convenience`), but note you may - run into other issues (iPython, Anaconda, etc. have some issues with it). + client.loop.run_until_complete(main()) diff --git a/readthedocs/basic/signing-in.rst b/readthedocs/basic/signing-in.rst index 9fb14853..7f584a95 100644 --- a/readthedocs/basic/signing-in.rst +++ b/readthedocs/basic/signing-in.rst @@ -55,9 +55,12 @@ We can finally write some code to log into our account! api_id = 12345 api_hash = '0123456789abcdef0123456789abcdef' - # The first parameter is the .session file name (absolute paths allowed) - with TelegramClient('anon', api_id, api_hash) as client: - client.loop.run_until_complete(client.send_message('me', 'Hello, myself!')) + async def main(): + # The first parameter is the .session file name (absolute paths allowed) + async with TelegramClient('anon', api_id, api_hash) as client: + await client.send_message('me', 'Hello, myself!') + + client.loop.run_until_complete(main()) In the first line, we import the class name so we can create an instance @@ -95,7 +98,7 @@ You will still need an API ID and hash, but the process is very similar: .. code-block:: python - from telethon.sync import TelegramClient + from telethon import TelegramClient api_id = 12345 api_hash = '0123456789abcdef0123456789abcdef' @@ -104,9 +107,12 @@ You will still need an API ID and hash, but the process is very similar: # We have to manually call "start" if we want an explicit bot token bot = TelegramClient('bot', api_id, api_hash).start(bot_token=bot_token) - # But then we can use the client instance as usual - with bot: - ... + async def main(): + # But then we can use the client instance as usual + async with bot: + ... + + client.loop.run_until_complete(main()) To get a bot account, you need to talk diff --git a/readthedocs/concepts/asyncio.rst b/readthedocs/concepts/asyncio.rst index ef7c3cd3..dd85f957 100644 --- a/readthedocs/concepts/asyncio.rst +++ b/readthedocs/concepts/asyncio.rst @@ -58,84 +58,6 @@ What are asyncio basics? loop.run_until_complete(main()) -What does telethon.sync do? -=========================== - -The moment you import any of these: - -.. code-block:: python - - from telethon import sync, ... - # or - from telethon.sync import ... - # or - import telethon.sync - -The ``sync`` module rewrites most ``async def`` -methods in Telethon to something similar to this: - -.. code-block:: python - - def new_method(): - result = original_method() - if loop.is_running(): - # the loop is already running, return the await-able to the user - return result - else: - # the loop is not running yet, so we can run it for the user - return loop.run_until_complete(result) - - -That means you can do this: - -.. code-block:: python - - print(client.get_me().username) - -Instead of this: - -.. code-block:: python - - me = client.loop.run_until_complete(client.get_me()) - print(me.username) - - # or, using asyncio's default loop (it's the same) - import asyncio - loop = asyncio.get_event_loop() # == client.loop - me = loop.run_until_complete(client.get_me()) - print(me.username) - - -As you can see, it's a lot of boilerplate and noise having to type -``run_until_complete`` all the time, so you can let the magic module -to rewrite it for you. But notice the comment above: it won't run -the loop if it's already running, because it can't. That means this: - -.. code-block:: python - - async def main(): - # 3. the loop is running here - print( - client.get_me() # 4. this will return a coroutine! - .username # 5. this fails, coroutines don't have usernames - ) - - loop.run_until_complete( # 2. run the loop and the ``main()`` coroutine - main() # 1. calling ``async def`` "returns" a coroutine - ) - - -Will fail. So if you're inside an ``async def``, then the loop is -running, and if the loop is running, you must ``await`` things yourself: - -.. code-block:: python - - async def main(): - print((await client.get_me()).username) - - loop.run_until_complete(main()) - - What are async, await and coroutines? ===================================== @@ -275,7 +197,7 @@ in it. So if you want to run *other* code, create tasks for it: loop.create_task(clock()) ... - client.run_until_disconnected() + await client.run_until_disconnected() This creates a task for a clock that prints the time every second. You don't need to use `client.run_until_disconnected() @@ -344,19 +266,6 @@ When you use a library, you're not limited to use only its methods. You can combine all the libraries you want. People seem to forget this simple fact! -Why does client.start() work outside async? -=========================================== - -Because it's so common that it's really convenient to offer said -functionality by default. This means you can set up all your event -handlers and start the client without worrying about loops at all. - -Using the client in a ``with`` block, `start -`, `run_until_disconnected -`, and -`disconnect ` -all support this. - Where can I read more? ====================== diff --git a/readthedocs/concepts/sessions.rst b/readthedocs/concepts/sessions.rst index a94bc773..8ba75938 100644 --- a/readthedocs/concepts/sessions.rst +++ b/readthedocs/concepts/sessions.rst @@ -73,10 +73,10 @@ You can import these ``from telethon.sessions``. For example, using the .. code-block:: python - from telethon.sync import TelegramClient + from telethon import TelegramClient from telethon.sessions import StringSession - with TelegramClient(StringSession(string), api_id, api_hash) as client: + async with TelegramClient(StringSession(string), api_id, api_hash) as client: ... # use the client # Save the string session as a string; you should decide how @@ -129,10 +129,10 @@ The easiest way to generate a string session is as follows: .. code-block:: python - from telethon.sync import TelegramClient + from telethon import TelegramClient from telethon.sessions import StringSession - with TelegramClient(StringSession(), api_id, api_hash) as client: + async with TelegramClient(StringSession(), api_id, api_hash) as client: print(client.session.save()) diff --git a/readthedocs/index.rst b/readthedocs/index.rst index 827823cd..1794ce72 100644 --- a/readthedocs/index.rst +++ b/readthedocs/index.rst @@ -4,17 +4,21 @@ Telethon's Documentation .. code-block:: python - from telethon.sync import TelegramClient, events + import asyncio + from telethon import TelegramClient, events - with TelegramClient('name', api_id, api_hash) as client: - client.send_message('me', 'Hello, myself!') - print(client.download_profile_photo('me')) + async def main(): + async with TelegramClient('name', api_id, api_hash) as client: + await client.send_message('me', 'Hello, myself!') + print(await client.download_profile_photo('me')) - @client.on(events.NewMessage(pattern='(?i).*Hello')) - async def handler(event): - await event.reply('Hey!') + @client.on(events.NewMessage(pattern='(?i).*Hello')) + async def handler(event): + await event.reply('Hey!') - client.run_until_disconnected() + await client.run_until_disconnected() + + asyncio.run(main()) * Are you new here? Jump straight into :ref:`installation`! diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 7655e673..5c748895 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -32,3 +32,22 @@ change even across minor version changes, and thus have your code break. * The ``telethon.client`` module is now ``telethon._client``, meaning you should stop relying on anything inside of it. This includes all of the subclasses that used to exist (like ``UserMethods``). + + +Synchronous compatibility mode has been removed +----------------------------------------------- + +The "sync hack" (which kicked in as soon as anything from ``telethon.sync`` was imported) has been +removed. This implies: + +* The ``telethon.sync`` module is gone. +* Synchronous context-managers (``with`` as opposed to ``async with``) are no longer supported. + Most notably, you can no longer do ``with client``. It must be ``async with client`` now. +* The "smart" behaviour of the following methods has been removed and now they no longer work in + a synchronous context when the ``asyncio`` event loop was not running. This means they now need + to be used with ``await`` (or, alternatively, manually used with ``loop.run_until_complete``): + * ``start`` + * ``disconnect`` + * ``run_until_disconnected`` + +// TODO provide standalone alternative for this? diff --git a/readthedocs/quick-references/faq.rst b/readthedocs/quick-references/faq.rst index df267b86..0b1e28b0 100644 --- a/readthedocs/quick-references/faq.rst +++ b/readthedocs/quick-references/faq.rst @@ -127,14 +127,7 @@ This is basic Python knowledge. You should use the dot operator: AttributeError: 'coroutine' object has no attribute 'id' ======================================================== -You either forgot to: - -.. code-block:: python - - import telethon.sync - # ^^^^^ import sync - -Or: +Telethon is an asynchronous library. This means you need to ``await`` most methods: .. code-block:: python @@ -218,19 +211,7 @@ Check out `quart_login.py`_ for an example web-application based on Quart. Can I use Anaconda/Spyder/IPython with the library? =================================================== -Yes, but these interpreters run the asyncio event loop implicitly, -which interferes with the ``telethon.sync`` magic module. - -If you use them, you should **not** import ``sync``: - -.. code-block:: python - - # Change any of these...: - from telethon import TelegramClient, sync, ... - from telethon.sync import TelegramClient, ... - - # ...with this: - from telethon import TelegramClient, ... +Yes, but these interpreters run the asyncio event loop implicitly, so be wary of that. You are also more likely to get "sqlite3.OperationalError: database is locked" with them. If they cause too much trouble, just write your code in a ``.py`` diff --git a/telethon/_client/account.py b/telethon/_client/account.py index 6300b791..46e0b6dc 100644 --- a/telethon/_client/account.py +++ b/telethon/_client/account.py @@ -56,9 +56,6 @@ class _TakeoutClient: raise ValueError("Failed to finish the takeout.") self.session.takeout_id = None - __enter__ = helpers._sync_enter - __exit__ = helpers._sync_exit - async def __call__(self, request, ordered=False): takeout_id = self.__client.session.takeout_id if takeout_id is None: diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index c27f6512..3699d795 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -12,7 +12,7 @@ if typing.TYPE_CHECKING: from .telegramclient import TelegramClient -def start( +async def start( self: 'TelegramClient', phone: typing.Callable[[], str] = lambda: input('Please enter your phone (or bot token): '), password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '), @@ -39,7 +39,7 @@ def start( raise ValueError('Both a phone and a bot token provided, ' 'must only provide one of either') - coro = self._start( + return await self._start( phone=phone, password=password, bot_token=bot_token, @@ -49,10 +49,6 @@ def start( last_name=last_name, max_attempts=max_attempts ) - return ( - coro if self.loop.is_running() - else self.loop.run_until_complete(coro) - ) async def _start( self: 'TelegramClient', phone, password, bot_token, force_sms, diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 0429d563..4147b45b 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -76,9 +76,6 @@ class _ChatAction: self._task = None - __enter__ = helpers._sync_enter - __exit__ = helpers._sync_exit - async def _update(self): try: while self._running: diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py index 2150dc92..4500df33 100644 --- a/telethon/_client/downloads.py +++ b/telethon/_client/downloads.py @@ -137,9 +137,6 @@ class _DirectDownloadIter(RequestIter): async def __aexit__(self, *args): await self.close() - __enter__ = helpers._sync_enter - __exit__ = helpers._sync_exit - class _GenericDownloadIter(_DirectDownloadIter): async def _load_next_chunk(self): diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 79ea85b3..16822d6a 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -337,19 +337,8 @@ def is_connected(self: 'TelegramClient') -> bool: sender = getattr(self, '_sender', None) return sender and sender.is_connected() -def disconnect(self: 'TelegramClient'): - if self.loop.is_running(): - return self._disconnect_coro() - else: - try: - self.loop.run_until_complete(self._disconnect_coro()) - except RuntimeError: - # Python 3.5.x complains when called from - # `__aexit__` and there were pending updates with: - # "Event loop stopped before Future completed." - # - # However, it doesn't really make a lot of sense. - pass +async def disconnect(self: 'TelegramClient'): + return await self._disconnect_coro() def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): init_proxy = None if not issubclass(self._connection, TcpMTProxy) else \ diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 58f6b56a..1c50a805 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -619,9 +619,6 @@ class TelegramClient: async def __aexit__(self, *args): await self.disconnect() - __enter__ = helpers._sync_enter - __exit__ = helpers._sync_exit - # endregion Auth # region Bots diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index 4860a8cd..04d7fbfb 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -40,7 +40,7 @@ async def set_receive_updates(self: 'TelegramClient', receive_updates): if receive_updates: await self(functions.updates.GetStateRequest()) -def run_until_disconnected(self: 'TelegramClient'): +async def run_until_disconnected(self: 'TelegramClient'): """ Runs the event loop until the library is disconnected. @@ -75,15 +75,7 @@ def run_until_disconnected(self: 'TelegramClient'): # script from exiting. await client.run_until_disconnected() """ - if self.loop.is_running(): - return self._run_until_disconnected() - try: - return self.loop.run_until_complete(self._run_until_disconnected()) - except KeyboardInterrupt: - pass - finally: - # No loop.run_until_complete; it's already syncified - self.disconnect() + return await self._run_until_disconnected() def on(self: 'TelegramClient', event: EventBuilder): """ diff --git a/telethon/helpers.py b/telethon/helpers.py index 6c782b0b..f9297816 100644 --- a/telethon/helpers.py +++ b/telethon/helpers.py @@ -118,7 +118,7 @@ def retry_range(retries, force_retry=True): while attempt != retries: attempt += 1 yield attempt - + async def _maybe_await(value): @@ -165,34 +165,6 @@ async def _cancel(log, **tasks): '%s (%s)', name, type(task), task) -def _sync_enter(self): - """ - Helps to cut boilerplate on async context - managers that offer synchronous variants. - """ - if hasattr(self, 'loop'): - loop = self.loop - else: - loop = self._client.loop - - if loop.is_running(): - raise RuntimeError( - 'You must use "async with" if the event loop ' - 'is running (i.e. you are inside an "async def")' - ) - - return loop.run_until_complete(self.__aenter__()) - - -def _sync_exit(self, *args): - if hasattr(self, 'loop'): - loop = self.loop - else: - loop = self._client.loop - - return loop.run_until_complete(self.__aexit__(*args)) - - def _entity_type(entity): # This could be a `utils` method that just ran a few `isinstance` on # `utils.get_peer(...)`'s result. However, there are *a lot* of auto diff --git a/telethon/requestiter.py b/telethon/requestiter.py index fd28419d..6473fe0f 100644 --- a/telethon/requestiter.py +++ b/telethon/requestiter.py @@ -12,9 +12,6 @@ class RequestIter(abc.ABC): It has some facilities, such as automatically sleeping a desired amount of time between requests if needed (but not more). - Can be used synchronously if the event loop is not running and - as an asynchronous iterator otherwise. - `limit` is the total amount of items that the iterator should return. This is handled on this base class, and will be always ``>= 0``. @@ -82,12 +79,6 @@ class RequestIter(abc.ABC): self.index += 1 return result - def __next__(self): - try: - return self.client.loop.run_until_complete(self.__anext__()) - except StopAsyncIteration: - raise StopIteration - def __aiter__(self): self.buffer = None self.index = 0 @@ -95,15 +86,6 @@ class RequestIter(abc.ABC): self.left = self.limit return self - def __iter__(self): - if self.client.loop.is_running(): - raise RuntimeError( - 'You must use "async for" if the event loop ' - 'is running (i.e. you are inside an "async def")' - ) - - return self.__aiter__() - async def collect(self): """ Create a `self` iterator and collect it into a `TotalList` diff --git a/telethon/sync.py b/telethon/sync.py deleted file mode 100644 index 80b80bea..00000000 --- a/telethon/sync.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -This magical module will rewrite all public methods in the public interface -of the library so they can run the loop on their own if it's not already -running. This rewrite may not be desirable if the end user always uses the -methods they way they should be ran, but it's incredibly useful for quick -scripts and the runtime overhead is relatively low. - -Some really common methods which are hardly used offer this ability by -default, such as ``.start()`` and ``.run_until_disconnected()`` (since -you may want to start, and then run until disconnected while using async -event handlers). -""" -import asyncio -import functools -import inspect - -from . import events, errors, utils, connection -from .client.account import _TakeoutClient -from .client.telegramclient import TelegramClient -from .tl import types, functions, custom -from .tl.custom import ( - Draft, Dialog, MessageButton, Forward, Button, - Message, InlineResult, Conversation -) -from .tl.custom.chatgetter import ChatGetter -from .tl.custom.sendergetter import SenderGetter - - -def _syncify_wrap(t, method_name): - method = getattr(t, method_name) - - @functools.wraps(method) - def syncified(*args, **kwargs): - coro = method(*args, **kwargs) - loop = asyncio.get_event_loop() - if loop.is_running(): - return coro - else: - return loop.run_until_complete(coro) - - # Save an accessible reference to the original method - setattr(syncified, '__tl.sync', method) - setattr(t, method_name, syncified) - - -def syncify(*types): - """ - Converts all the methods in the given types (class definitions) - into synchronous, which return either the coroutine or the result - based on whether ``asyncio's`` event loop is running. - """ - # Our asynchronous generators all are `RequestIter`, which already - # provide a synchronous iterator variant, so we don't need to worry - # about asyncgenfunction's here. - for t in types: - for name in dir(t): - if not name.startswith('_') or name == '__call__': - if inspect.iscoroutinefunction(getattr(t, name)): - _syncify_wrap(t, name) - - -syncify(TelegramClient, _TakeoutClient, Draft, Dialog, MessageButton, - ChatGetter, SenderGetter, Forward, Message, InlineResult, Conversation) - - -# Private special case, since a conversation's methods return -# futures (but the public function themselves are synchronous). -_syncify_wrap(Conversation, '_get_result') - -__all__ = [ - 'TelegramClient', 'Button', - 'types', 'functions', 'custom', 'errors', - 'events', 'utils', 'connection' -] diff --git a/telethon/tl/custom/conversation.py b/telethon/tl/custom/conversation.py index 6cb973d4..b99831f3 100644 --- a/telethon/tl/custom/conversation.py +++ b/telethon/tl/custom/conversation.py @@ -524,6 +524,3 @@ class Conversation(ChatGetter): del self._client._conversations[chat_id] self._cancel_all() - - __enter__ = helpers._sync_enter - __exit__ = helpers._sync_exit diff --git a/telethon_generator/generators/docs.py b/telethon_generator/generators/docs.py index 34b599ff..8b46e4d1 100755 --- a/telethon_generator/generators/docs.py +++ b/telethon_generator/generators/docs.py @@ -396,7 +396,7 @@ def _write_html_pages(tlobjects, methods, layer, input_res): docs.write('
') docs.write('''
\
-from telethon.sync import TelegramClient
+from telethon import TelegramClient
 from telethon import functions, types
 
 with TelegramClient(name, api_id, api_hash) as client:
diff --git a/tests/telethon/test_helpers.py b/tests/telethon/test_helpers.py
index 689db8af..5ac4a78e 100644
--- a/tests/telethon/test_helpers.py
+++ b/tests/telethon/test_helpers.py
@@ -14,43 +14,6 @@ def test_strip_text():
     # I can't interpret the rest of the code well enough yet
 
 
-class TestSyncifyAsyncContext:
-    class NoopContextManager:
-        def __init__(self, loop):
-            self.count = 0
-            self.loop = loop
-
-        async def __aenter__(self):
-            self.count += 1
-            return self
-
-        async def __aexit__(self, exc_type, *args):
-            assert exc_type is None
-            self.count -= 1
-
-        __enter__ = helpers._sync_enter
-        __exit__ = helpers._sync_exit
-
-    def test_sync_acontext(self, event_loop):
-        contm = self.NoopContextManager(event_loop)
-        assert contm.count == 0
-
-        with contm:
-            assert contm.count == 1
-
-        assert contm.count == 0
-
-    @pytest.mark.asyncio
-    async def test_async_acontext(self, event_loop):
-        contm = self.NoopContextManager(event_loop)
-        assert contm.count == 0
-
-        async with contm:
-            assert contm.count == 1
-
-        assert contm.count == 0
-
-
 def test_generate_key_data_from_nonce():
     gkdfn = helpers.generate_key_data_from_nonce
 

From f86339ab1701c1043a971a08777da5cdbc3b47b8 Mon Sep 17 00:00:00 2001
From: Lonami Exo 
Date: Sat, 11 Sep 2021 14:16:25 +0200
Subject: [PATCH 006/131] Remove Conversation API

---
 readthedocs/misc/v2-migration-guide.rst       |  53 ++
 readthedocs/modules/custom.rst                |   9 -
 .../quick-references/client-reference.rst     |   1 -
 .../quick-references/objects-reference.rst    |  27 -
 telethon/_client/dialogs.py                   |  20 -
 telethon/_client/telegrambaseclient.py        |   3 -
 telethon/_client/telegramclient.py            | 131 +----
 telethon/_client/updates.py                   |  26 -
 telethon/errors/__init__.py                   |   2 +-
 telethon/errors/common.py                     |  11 -
 telethon/tl/custom/__init__.py                |   1 -
 telethon/tl/custom/conversation.py            | 526 ------------------
 12 files changed, 57 insertions(+), 753 deletions(-)
 delete mode 100644 telethon/tl/custom/conversation.py

diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst
index 5c748895..1ee9bda4 100644
--- a/readthedocs/misc/v2-migration-guide.rst
+++ b/readthedocs/misc/v2-migration-guide.rst
@@ -51,3 +51,56 @@ removed. This implies:
   * ``run_until_disconnected``
 
 // TODO provide standalone alternative for this?
+
+
+The Conversation API has been removed
+-------------------------------------
+
+This API had certain shortcomings, such as lacking persistence, poor interaction with other event
+handlers, and overcomplicated usage for anything beyond the simplest case.
+
+It is not difficult to write your own code to deal with a conversation's state. A simple
+`Finite State Machine `__ inside your handlers will do
+just fine:
+
+.. code-block:: python
+
+    from enum import Enum, auto
+
+    # We use a Python Enum for the state because it's a clean and easy way to do it
+    class State(Enum):
+        WAIT_NAME = auto()
+        WAIT_AGE = auto()
+
+    # The state in which different users are, {user_id: state}
+    conversation_state = {}
+
+    # ...code to create and setup your client...
+
+    @client.on(events.NewMessage)
+    async def handler(event):
+        who = event.sender_id
+        state = conversation_state.get(who)
+
+        if state is None:
+            # Starting a conversation
+            await event.respond('Hi! What is your name?')
+            conversation_state[who] = State.WAIT_NAME
+
+        elif state == State.WAIT_NAME:
+            name = event.text  # Save the name wherever you want
+            await event.respond('Nice! What is your age?')
+            conversation_state[who] = State.WAIT_AGE
+
+        elif state == State.WAIT_AGE:
+            age = event.text  # Save the age wherever you want
+            await event.respond('Thank you!')
+            # Conversation is done so we can forget the state of this user
+            del conversation_state[who]
+
+    # ...code to keep Telethon running...
+
+Not only is this approach simpler, but it can also be easily persisted, and you can adjust it
+to your needs and your handlers much more easily.
+
+// TODO provide standalone alternative for this?
diff --git a/readthedocs/modules/custom.rst b/readthedocs/modules/custom.rst
index 074b2161..01284fbb 100644
--- a/readthedocs/modules/custom.rst
+++ b/readthedocs/modules/custom.rst
@@ -46,15 +46,6 @@ ChatGetter
     :show-inheritance:
 
 
-Conversation
-============
-
-.. automodule:: telethon.tl.custom.conversation
-    :members:
-    :undoc-members:
-    :show-inheritance:
-
-
 Dialog
 ======
 
diff --git a/readthedocs/quick-references/client-reference.rst b/readthedocs/quick-references/client-reference.rst
index 6dd8245c..22517288 100644
--- a/readthedocs/quick-references/client-reference.rst
+++ b/readthedocs/quick-references/client-reference.rst
@@ -107,7 +107,6 @@ Dialogs
     iter_drafts
     get_drafts
     delete_dialog
-    conversation
 
 Users
 -----
diff --git a/readthedocs/quick-references/objects-reference.rst b/readthedocs/quick-references/objects-reference.rst
index 51ed4607..41f73033 100644
--- a/readthedocs/quick-references/objects-reference.rst
+++ b/readthedocs/quick-references/objects-reference.rst
@@ -155,33 +155,6 @@ its name, bot-API style file ID, etc.
     sticker_set
 
 
-Conversation
-============
-
-The `Conversation ` object
-is returned by the `client.conversation()
-` method to easily
-send and receive responses like a normal conversation.
-
-It bases `ChatGetter `.
-
-.. currentmodule:: telethon.tl.custom.conversation.Conversation
-
-.. autosummary::
-    :nosignatures:
-
-    send_message
-    send_file
-    mark_read
-    get_response
-    get_reply
-    get_edit
-    wait_read
-    wait_event
-    cancel
-    cancel_all
-
-
 AdminLogEvent
 =============
 
diff --git a/telethon/_client/dialogs.py b/telethon/_client/dialogs.py
index 67c47458..8471c7fb 100644
--- a/telethon/_client/dialogs.py
+++ b/telethon/_client/dialogs.py
@@ -252,23 +252,3 @@ async def delete_dialog(
         await self(functions.messages.DeleteHistoryRequest(entity, 0, revoke=revoke))
 
     return result
-
-def conversation(
-        self: 'TelegramClient',
-        entity: 'hints.EntityLike',
-        *,
-        timeout: float = 60,
-        total_timeout: float = None,
-        max_messages: int = 100,
-        exclusive: bool = True,
-        replies_are_responses: bool = True) -> custom.Conversation:
-    return custom.Conversation(
-        self,
-        entity,
-        timeout=timeout,
-        total_timeout=total_timeout,
-        max_messages=max_messages,
-        exclusive=exclusive,
-        replies_are_responses=replies_are_responses
-
-    )
diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py
index 16822d6a..256e8e6f 100644
--- a/telethon/_client/telegrambaseclient.py
+++ b/telethon/_client/telegrambaseclient.py
@@ -267,9 +267,6 @@ def init(
     # Some further state for subclasses
     self._event_builders = []
 
-    # {chat_id: {Conversation}}
-    self._conversations = collections.defaultdict(set)
-
     # Hack to workaround the fact Telegram may send album updates as
     # different Updates when being sent from a different data center.
     # {grouped_id: AlbumHack}
diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py
index 1c50a805..01cd5d14 100644
--- a/telethon/_client/telegramclient.py
+++ b/telethon/_client/telegramclient.py
@@ -151,9 +151,9 @@ class TelegramClient:
             will be received from Telegram as they occur.
 
             Turning this off means that Telegram will not send updates at all
-            so event handlers, conversations, and QR login will not work.
-            However, certain scripts don't need updates, so this will reduce
-            the amount of bandwidth used.
+            so event handlers and QR login will not work. However, certain
+            scripts don't need updates, so this will reduce the amount of
+            bandwidth used.
     """
 
     # region Account
@@ -1702,131 +1702,6 @@ class TelegramClient:
         """
         return dialogs.delete_dialog(**locals())
 
-    def conversation(
-            self: 'TelegramClient',
-            entity: 'hints.EntityLike',
-            *,
-            timeout: float = 60,
-            total_timeout: float = None,
-            max_messages: int = 100,
-            exclusive: bool = True,
-            replies_are_responses: bool = True) -> custom.Conversation:
-        """
-        Creates a `Conversation `
-        with the given entity.
-
-        .. note::
-
-            This Conversation API has certain shortcomings, such as lacking
-            persistence, poor interaction with other event handlers, and
-            overcomplicated usage for anything beyond the simplest case.
-
-            If you plan to interact with a bot without handlers, this works
-            fine, but when running a bot yourself, you may instead prefer
-            to follow the advice from https://stackoverflow.com/a/62246569/.
-
-        This is not the same as just sending a message to create a "dialog"
-        with them, but rather a way to easily send messages and await for
-        responses or other reactions. Refer to its documentation for more.
-
-        Arguments
-            entity (`entity`):
-                The entity with which a new conversation should be opened.
-
-            timeout (`int` | `float`, optional):
-                The default timeout (in seconds) *per action* to be used. You
-                may also override this timeout on a per-method basis. By
-                default each action can take up to 60 seconds (the value of
-                this timeout).
-
-            total_timeout (`int` | `float`, optional):
-                The total timeout (in seconds) to use for the whole
-                conversation. This takes priority over per-action
-                timeouts. After these many seconds pass, subsequent
-                actions will result in ``asyncio.TimeoutError``.
-
-            max_messages (`int`, optional):
-                The maximum amount of messages this conversation will
-                remember. After these many messages arrive in the
-                specified chat, subsequent actions will result in
-                ``ValueError``.
-
-            exclusive (`bool`, optional):
-                By default, conversations are exclusive within a single
-                chat. That means that while a conversation is open in a
-                chat, you can't open another one in the same chat, unless
-                you disable this flag.
-
-                If you try opening an exclusive conversation for
-                a chat where it's already open, it will raise
-                ``AlreadyInConversationError``.
-
-            replies_are_responses (`bool`, optional):
-                Whether replies should be treated as responses or not.
-
-                If the setting is enabled, calls to `conv.get_response
-                `
-                and a subsequent call to `conv.get_reply
-                `
-                will return different messages, otherwise they may return
-                the same message.
-
-                Consider the following scenario with one outgoing message,
-                1, and two incoming messages, the second one replying::
-
-                                        Hello! <1
-                    2> (reply to 1) Hi!
-                    3> (reply to 1) How are you?
-
-                And the following code:
-
-                .. code-block:: python
-
-                    async with client.conversation(chat) as conv:
-                        msg1 = await conv.send_message('Hello!')
-                        msg2 = await conv.get_response()
-                        msg3 = await conv.get_reply()
-
-                With the setting enabled, ``msg2`` will be ``'Hi!'`` and
-                ``msg3`` be ``'How are you?'`` since replies are also
-                responses, and a response was already returned.
-
-                With the setting disabled, both ``msg2`` and ``msg3`` will
-                be ``'Hi!'`` since one is a response and also a reply.
-
-        Returns
-            A `Conversation `.
-
-        Example
-            .. code-block:: python
-
-                #  denotes outgoing messages you sent
-                #  denotes incoming response messages
-                with bot.conversation(chat) as conv:
-                    #  Hi!
-                    conv.send_message('Hi!')
-
-                    #  Hello!
-                    hello = conv.get_response()
-
-                    #  Please tell me your name
-                    conv.send_message('Please tell me your name')
-
-                    #  ?
-                    name = conv.get_response().raw_text
-
-                    while not any(x.isalpha() for x in name):
-                        #  Your name didn't have any letters! Try again
-                        conv.send_message("Your name didn't have any letters! Try again")
-
-                        #  Human
-                        name = conv.get_response().raw_text
-
-                    #  Thanks Human!
-                    conv.send_message('Thanks {}!'.format(name))
-        """
-        return dialogs.conversation(**locals())
-
     # endregion Dialogs
 
     # region Downloads
diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py
index 04d7fbfb..c5d04ade 100644
--- a/telethon/_client/updates.py
+++ b/telethon/_client/updates.py
@@ -418,22 +418,6 @@ async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, p
             pass  # might not have connection
 
     built = EventBuilderDict(self, update, others)
-    for conv_set in self._conversations.values():
-        for conv in conv_set:
-            ev = built[events.NewMessage]
-            if ev:
-                conv._on_new_message(ev)
-
-            ev = built[events.MessageEdited]
-            if ev:
-                conv._on_edit(ev)
-
-            ev = built[events.MessageRead]
-            if ev:
-                conv._on_read(ev)
-
-            if conv._custom:
-                await conv._check_custom(built)
 
     for builder, callback in self._event_builders:
         event = built[type(builder)]
@@ -451,11 +435,6 @@ async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, p
 
         try:
             await callback(event)
-        except errors.AlreadyInConversationError:
-            name = getattr(callback, '__name__', repr(callback))
-            self._log[__name__].debug(
-                'Event handler "%s" already has an open conversation, '
-                'ignoring new one', name)
         except events.StopPropagation:
             name = getattr(callback, '__name__', repr(callback))
             self._log[__name__].debug(
@@ -492,11 +471,6 @@ async def _dispatch_event(self: 'TelegramClient', event):
 
         try:
             await callback(event)
-        except errors.AlreadyInConversationError:
-            name = getattr(callback, '__name__', repr(callback))
-            self._log[__name__].debug(
-                'Event handler "%s" already has an open conversation, '
-                'ignoring new one', name)
         except events.StopPropagation:
             name = getattr(callback, '__name__', repr(callback))
             self._log[__name__].debug(
diff --git a/telethon/errors/__init__.py b/telethon/errors/__init__.py
index f6bc16e5..a50ae36b 100644
--- a/telethon/errors/__init__.py
+++ b/telethon/errors/__init__.py
@@ -7,7 +7,7 @@ import re
 from .common import (
     ReadCancelledError, TypeNotFoundError, InvalidChecksumError,
     InvalidBufferError, SecurityError, CdnFileTamperedError,
-    AlreadyInConversationError, BadMessageError, MultiError
+    BadMessageError, MultiError
 )
 
 # This imports the base errors too, as they're imported there
diff --git a/telethon/errors/common.py b/telethon/errors/common.py
index de7d95f8..3ac246b3 100644
--- a/telethon/errors/common.py
+++ b/telethon/errors/common.py
@@ -79,17 +79,6 @@ class CdnFileTamperedError(SecurityError):
         )
 
 
-class AlreadyInConversationError(Exception):
-    """
-    Occurs when another exclusive conversation is opened in the same chat.
-    """
-    def __init__(self):
-        super().__init__(
-            'Cannot open exclusive conversation in a '
-            'chat that already has one open conversation'
-        )
-
-
 class BadMessageError(Exception):
     """Occurs when handling a bad_message_notification."""
     ErrorMessages = {
diff --git a/telethon/tl/custom/__init__.py b/telethon/tl/custom/__init__.py
index 9804969e..00a0d00f 100644
--- a/telethon/tl/custom/__init__.py
+++ b/telethon/tl/custom/__init__.py
@@ -9,6 +9,5 @@ from .button import Button
 from .inlinebuilder import InlineBuilder
 from .inlineresult import InlineResult
 from .inlineresults import InlineResults
-from .conversation import Conversation
 from .qrlogin import QRLogin
 from .participantpermissions import ParticipantPermissions
diff --git a/telethon/tl/custom/conversation.py b/telethon/tl/custom/conversation.py
deleted file mode 100644
index b99831f3..00000000
--- a/telethon/tl/custom/conversation.py
+++ /dev/null
@@ -1,526 +0,0 @@
-import asyncio
-import functools
-import inspect
-import itertools
-import time
-
-from .chatgetter import ChatGetter
-from ... import helpers, utils, errors
-
-# Sometimes the edits arrive very fast (within the same second).
-# In that case we add a small delta so that the age is older, for
-# comparision purposes. This value is enough for up to 1000 messages.
-_EDIT_COLLISION_DELTA = 0.001
-
-
-def _checks_cancelled(f):
-    @functools.wraps(f)
-    def wrapper(self, *args, **kwargs):
-        if self._cancelled:
-            raise asyncio.CancelledError('The conversation was cancelled before')
-
-        return f(self, *args, **kwargs)
-    return wrapper
-
-
-class Conversation(ChatGetter):
-    """
-    Represents a conversation inside an specific chat.
-
-    A conversation keeps track of new messages since it was
-    created until its exit and easily lets you query the
-    current state.
-
-    If you need a conversation across two or more chats,
-    you should use two conversations and synchronize them
-    as you better see fit.
-    """
-    _id_counter = 0
-    _custom_counter = 0
-
-    def __init__(self, client, input_chat,
-                 *, timeout, total_timeout, max_messages,
-                 exclusive, replies_are_responses):
-        # This call resets the client
-        ChatGetter.__init__(self, input_chat=input_chat)
-
-        self._id = Conversation._id_counter
-        Conversation._id_counter += 1
-
-        self._client = client
-        self._timeout = timeout
-        self._total_timeout = total_timeout
-        self._total_due = None
-
-        self._outgoing = set()
-        self._last_outgoing = 0
-        self._incoming = []
-        self._last_incoming = 0
-        self._max_incoming = max_messages
-        self._last_read = None
-        self._custom = {}
-
-        self._pending_responses = {}
-        self._pending_replies = {}
-        self._pending_edits = {}
-        self._pending_reads = {}
-
-        self._exclusive = exclusive
-        self._cancelled = False
-
-        # The user is able to expect two responses for the same message.
-        # {desired message ID: next incoming index}
-        self._response_indices = {}
-        if replies_are_responses:
-            self._reply_indices = self._response_indices
-        else:
-            self._reply_indices = {}
-
-        self._edit_dates = {}
-
-    @_checks_cancelled
-    async def send_message(self, *args, **kwargs):
-        """
-        Sends a message in the context of this conversation. Shorthand
-        for `telethon.client.messages.MessageMethods.send_message` with
-        ``entity`` already set.
-        """
-        sent = await self._client.send_message(
-            self._input_chat, *args, **kwargs)
-
-        # Albums will be lists, so handle that
-        ms = sent if isinstance(sent, list) else (sent,)
-        self._outgoing.update(m.id for m in ms)
-        self._last_outgoing = ms[-1].id
-        return sent
-
-    @_checks_cancelled
-    async def send_file(self, *args, **kwargs):
-        """
-        Sends a file in the context of this conversation. Shorthand
-        for `telethon.client.uploads.UploadMethods.send_file` with
-        ``entity`` already set.
-        """
-        sent = await self._client.send_file(
-            self._input_chat, *args, **kwargs)
-
-        # Albums will be lists, so handle that
-        ms = sent if isinstance(sent, list) else (sent,)
-        self._outgoing.update(m.id for m in ms)
-        self._last_outgoing = ms[-1].id
-        return sent
-
-    @_checks_cancelled
-    def mark_read(self, message=None):
-        """
-        Marks as read the latest received message if ``message is None``.
-        Otherwise, marks as read until the given message (or message ID).
-
-        This is equivalent to calling `client.send_read_acknowledge
-        `.
-        """
-        if message is None:
-            if self._incoming:
-                message = self._incoming[-1].id
-            else:
-                message = 0
-        elif not isinstance(message, int):
-            message = message.id
-
-        return self._client.send_read_acknowledge(
-            self._input_chat, max_id=message)
-
-    def get_response(self, message=None, *, timeout=None):
-        """
-        Gets the next message that responds to a previous one. This is
-        the method you need most of the time, along with `get_edit`.
-
-        Args:
-            message (`Message ` | `int`, optional):
-                The message (or the message ID) for which a response
-                is expected. By default this is the last sent message.
-
-            timeout (`int` | `float`, optional):
-                If present, this `timeout` (in seconds) will override the
-                per-action timeout defined for the conversation.
-
-        .. code-block:: python
-
-            async with client.conversation(...) as conv:
-                await conv.send_message('Hey, what is your name?')
-
-                response = await conv.get_response()
-                name = response.text
-
-                await conv.send_message('Nice to meet you, {}!'.format(name))
-        """
-        return self._get_message(
-            message, self._response_indices, self._pending_responses, timeout,
-            lambda x, y: True
-        )
-
-    def get_reply(self, message=None, *, timeout=None):
-        """
-        Gets the next message that explicitly replies to a previous one.
-        """
-        return self._get_message(
-            message, self._reply_indices, self._pending_replies, timeout,
-            lambda x, y: x.reply_to and x.reply_to.reply_to_msg_id == y
-        )
-
-    def _get_message(
-            self, target_message, indices, pending, timeout, condition):
-        """
-        Gets the next desired message under the desired condition.
-
-        Args:
-            target_message (`object`):
-                The target message for which we want to find another
-                response that applies based on `condition`.
-
-            indices (`dict`):
-                This dictionary remembers the last ID chosen for the
-                input `target_message`.
-
-            pending (`dict`):
-                This dictionary remembers {msg_id: Future} to be set
-                once `condition` is met.
-
-            timeout (`int`):
-                The timeout (in seconds) override to use for this operation.
-
-            condition (`callable`):
-                The condition callable that checks if an incoming
-                message is a valid response.
-        """
-        start_time = time.time()
-        target_id = self._get_message_id(target_message)
-
-        # If there is no last-chosen ID, make sure to pick one *after*
-        # the input message, since we don't want responses back in time
-        if target_id not in indices:
-            for i, incoming in enumerate(self._incoming):
-                if incoming.id > target_id:
-                    indices[target_id] = i
-                    break
-            else:
-                indices[target_id] = len(self._incoming)
-
-        # We will always return a future from here, even if the result
-        # can be set immediately. Otherwise, needing to await only
-        # sometimes is an annoying edge case (i.e. we would return
-        # a `Message` but `get_response()` always `await`'s).
-        future = self._client.loop.create_future()
-
-        # If there are enough responses saved return the next one
-        last_idx = indices[target_id]
-        if last_idx < len(self._incoming):
-            incoming = self._incoming[last_idx]
-            if condition(incoming, target_id):
-                indices[target_id] += 1
-                future.set_result(incoming)
-                return future
-
-        # Otherwise the next incoming response will be the one to use
-        #
-        # Note how we fill "pending" before giving control back to the
-        # event loop through "await". We want to register it as soon as
-        # possible, since any other task switch may arrive with the result.
-        pending[target_id] = future
-        return self._get_result(future, start_time, timeout, pending, target_id)
-
-    def get_edit(self, message=None, *, timeout=None):
-        """
-        Awaits for an edit after the last message to arrive.
-        The arguments are the same as those for `get_response`.
-        """
-        start_time = time.time()
-        target_id = self._get_message_id(message)
-
-        target_date = self._edit_dates.get(target_id, 0)
-        earliest_edit = min(
-            (x for x in self._incoming
-             if x.edit_date
-             and x.id > target_id
-             and x.edit_date.timestamp() > target_date
-             ),
-            key=lambda x: x.edit_date.timestamp(),
-            default=None
-        )
-
-        future = self._client.loop.create_future()
-        if earliest_edit and earliest_edit.edit_date.timestamp() > target_date:
-            self._edit_dates[target_id] = earliest_edit.edit_date.timestamp()
-            future.set_result(earliest_edit)
-            return future  # we should always return something we can await
-
-        # Otherwise the next incoming response will be the one to use
-        self._pending_edits[target_id] = future
-        return self._get_result(future, start_time, timeout, self._pending_edits, target_id)
-
-    def wait_read(self, message=None, *, timeout=None):
-        """
-        Awaits for the sent message to be marked as read. Note that
-        receiving a response doesn't imply the message was read, and
-        this action will also trigger even without a response.
-        """
-        start_time = time.time()
-        future = self._client.loop.create_future()
-        target_id = self._get_message_id(message)
-
-        if self._last_read is None:
-            self._last_read = target_id - 1
-
-        if self._last_read >= target_id:
-            return
-
-        self._pending_reads[target_id] = future
-        return self._get_result(future, start_time, timeout, self._pending_reads, target_id)
-
-    async def wait_event(self, event, *, timeout=None):
-        """
-        Waits for a custom event to occur. Timeouts still apply.
-
-        .. note::
-
-            **Only use this if there isn't another method available!**
-            For example, don't use `wait_event` for new messages,
-            since `get_response` already exists, etc.
-
-        Unless you're certain that your code will run fast enough,
-        generally you should get a "handle" of this special coroutine
-        before acting. In this example you will see how to wait for a user
-        to join a group with proper use of `wait_event`:
-
-        .. code-block:: python
-
-            from telethon import TelegramClient, events
-
-            client = TelegramClient(...)
-            group_id = ...
-
-            async def main():
-                # Could also get the user id from an event; this is just an example
-                user_id = ...
-
-                async with client.conversation(user_id) as conv:
-                    # Get a handle to the future event we'll wait for
-                    handle = conv.wait_event(events.ChatAction(
-                        group_id,
-                        func=lambda e: e.user_joined and e.user_id == user_id
-                    ))
-
-                    # Perform whatever action in between
-                    await conv.send_message('Please join this group before speaking to me!')
-
-                    # Wait for the event we registered above to fire
-                    event = await handle
-
-                    # Continue with the conversation
-                    await conv.send_message('Thanks!')
-
-        This way your event can be registered before acting,
-        since the response may arrive before your event was
-        registered. It depends on your use case since this
-        also means the event can arrive before you send
-        a previous action.
-        """
-        start_time = time.time()
-        if isinstance(event, type):
-            event = event()
-
-        await event.resolve(self._client)
-
-        counter = Conversation._custom_counter
-        Conversation._custom_counter += 1
-
-        future = self._client.loop.create_future()
-        self._custom[counter] = (event, future)
-        try:
-            return await self._get_result(future, start_time, timeout, self._custom, counter)
-        finally:
-            # Need to remove it from the dict if it times out, else we may
-            # try and fail to set the result later (#1618).
-            self._custom.pop(counter, None)
-
-    async def _check_custom(self, built):
-        for key, (ev, fut) in list(self._custom.items()):
-            ev_type = type(ev)
-            inst = built[ev_type]
-
-            if inst:
-                filter = ev.filter(inst)
-                if inspect.isawaitable(filter):
-                    filter = await filter
-
-                if filter:
-                    fut.set_result(inst)
-                    del self._custom[key]
-
-    def _on_new_message(self, response):
-        response = response.message
-        if response.chat_id != self.chat_id or response.out:
-            return
-
-        if len(self._incoming) == self._max_incoming:
-            self._cancel_all(ValueError('Too many incoming messages'))
-            return
-
-        self._incoming.append(response)
-
-        # Most of the time, these dictionaries will contain just one item
-        # TODO In fact, why not make it be that way? Force one item only.
-        #      How often will people want to wait for two responses at
-        #      the same time? It's impossible, first one will arrive
-        #      and then another, so they can do that.
-        for msg_id, future in list(self._pending_responses.items()):
-            self._response_indices[msg_id] = len(self._incoming)
-            future.set_result(response)
-            del self._pending_responses[msg_id]
-
-        for msg_id, future in list(self._pending_replies.items()):
-            if response.reply_to and msg_id == response.reply_to.reply_to_msg_id:
-                self._reply_indices[msg_id] = len(self._incoming)
-                future.set_result(response)
-                del self._pending_replies[msg_id]
-
-    def _on_edit(self, message):
-        message = message.message
-        if message.chat_id != self.chat_id or message.out:
-            return
-
-        # We have to update our incoming messages with the new edit date
-        for i, m in enumerate(self._incoming):
-            if m.id == message.id:
-                self._incoming[i] = message
-                break
-
-        for msg_id, future in list(self._pending_edits.items()):
-            if msg_id < message.id:
-                edit_ts = message.edit_date.timestamp()
-
-                # We compare <= because edit_ts resolution is always to
-                # seconds, but we may have increased _edit_dates before.
-                # Since the dates are ever growing this is not a problem.
-                if edit_ts <= self._edit_dates.get(msg_id, 0):
-                    self._edit_dates[msg_id] += _EDIT_COLLISION_DELTA
-                else:
-                    self._edit_dates[msg_id] = message.edit_date.timestamp()
-
-                future.set_result(message)
-                del self._pending_edits[msg_id]
-
-    def _on_read(self, event):
-        if event.chat_id != self.chat_id or event.inbox:
-            return
-
-        self._last_read = event.max_id
-
-        for msg_id, pending in list(self._pending_reads.items()):
-            if msg_id >= self._last_read:
-                pending.set_result(True)
-                del self._pending_reads[msg_id]
-
-    def _get_message_id(self, message):
-        if message is not None:  # 0 is valid but false-y, check for None
-            return message if isinstance(message, int) else message.id
-        elif self._last_outgoing:
-            return self._last_outgoing
-        else:
-            raise ValueError('No message was sent previously')
-
-    @_checks_cancelled
-    def _get_result(self, future, start_time, timeout, pending, target_id):
-        due = self._total_due
-        if timeout is None:
-            timeout = self._timeout
-
-        if timeout is not None:
-            due = min(due, start_time + timeout)
-
-        # NOTE: We can't try/finally to pop from pending here because
-        #       the event loop needs to get back to us, but it might
-        #       dispatch another update before, and in that case a
-        #       response could be set twice. So responses must be
-        #       cleared when their futures are set to a result.
-        return asyncio.wait_for(
-            future,
-            timeout=None if due == float('inf') else due - time.time()
-        )
-
-    def _cancel_all(self, exception=None):
-        self._cancelled = True
-        for pending in itertools.chain(
-                self._pending_responses.values(),
-                self._pending_replies.values(),
-                self._pending_edits.values()):
-            if exception:
-                pending.set_exception(exception)
-            else:
-                pending.cancel()
-
-        for _, fut in self._custom.values():
-            if exception:
-                fut.set_exception(exception)
-            else:
-                fut.cancel()
-
-    async def __aenter__(self):
-        self._input_chat = \
-            await self._client.get_input_entity(self._input_chat)
-
-        self._chat_peer = utils.get_peer(self._input_chat)
-
-        # Make sure we're the only conversation in this chat if it's exclusive
-        chat_id = utils.get_peer_id(self._chat_peer)
-        conv_set = self._client._conversations[chat_id]
-        if self._exclusive and conv_set:
-            raise errors.AlreadyInConversationError()
-
-        conv_set.add(self)
-        self._cancelled = False
-
-        self._last_outgoing = 0
-        self._last_incoming = 0
-        for d in (
-                self._outgoing, self._incoming,
-                self._pending_responses, self._pending_replies,
-                self._pending_edits, self._response_indices,
-                self._reply_indices, self._edit_dates, self._custom):
-            d.clear()
-
-        if self._total_timeout:
-            self._total_due = time.time() + self._total_timeout
-        else:
-            self._total_due = float('inf')
-
-        return self
-
-    def cancel(self):
-        """
-        Cancels the current conversation. Pending responses and subsequent
-        calls to get a response will raise ``asyncio.CancelledError``.
-
-        This method is synchronous and should not be awaited.
-        """
-        self._cancel_all()
-
-    async def cancel_all(self):
-        """
-        Calls `cancel` on *all* conversations in this chat.
-
-        Note that you should ``await`` this method, since it's meant to be
-        used outside of a context manager, and it needs to resolve the chat.
-        """
-        chat_id = await self._client.get_peer_id(self._input_chat)
-        for conv in self._client._conversations[chat_id]:
-            conv.cancel()
-
-    async def __aexit__(self, exc_type, exc_val, exc_tb):
-        chat_id = utils.get_peer_id(self._chat_peer)
-        conv_set = self._client._conversations[chat_id]
-        conv_set.discard(self)
-        if not conv_set:
-            del self._client._conversations[chat_id]
-
-        self._cancel_all()

From 66ef553adca172c4e49052b7cba1330082bf0af9 Mon Sep 17 00:00:00 2001
From: Lonami Exo 
Date: Sat, 11 Sep 2021 15:28:24 +0200
Subject: [PATCH 007/131] Remove duplicated docstrings

---
 telethon/_client/downloads.py    | 239 -------------------------------
 telethon/_client/messageparse.py |  32 -----
 telethon/_client/updates.py      | 142 ------------------
 telethon/_client/users.py        | 170 ----------------------
 4 files changed, 583 deletions(-)

diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py
index 4500df33..aa8ed59a 100644
--- a/telethon/_client/downloads.py
+++ b/telethon/_client/downloads.py
@@ -195,43 +195,6 @@ async def download_profile_photo(
         file: 'hints.FileLike' = None,
         *,
         download_big: bool = True) -> typing.Optional[str]:
-    """
-    Downloads the profile photo from the given user, chat or channel.
-
-    Arguments
-        entity (`entity`):
-            From who the photo will be downloaded.
-
-            .. note::
-
-                This method expects the full entity (which has the data
-                to download the photo), not an input variant.
-
-                It's possible that sometimes you can't fetch the entity
-                from its input (since you can get errors like
-                ``ChannelPrivateError``) but you already have it through
-                another call, like getting a forwarded message from it.
-
-        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 file is the type `bytes`, it will be downloaded in-memory
-            as a bytestring (e.g. ``file=bytes``).
-
-        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.
-
-    Example
-        .. code-block:: python
-
-            # Download your own profile photo
-            path = await client.download_profile_photo('me')
-            print(path)
-    """
     # hex(crc32(x.encode('ascii'))) for x in
     # ('User', 'Chat', 'UserFull', 'ChatFull')
     ENTITIES = (0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697)
@@ -307,73 +270,6 @@ async def download_media(
         *,
         thumb: 'typing.Union[int, types.TypePhotoSize]' = None,
         progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]:
-    """
-    Downloads the given media from a message object.
-
-    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).
-
-    See also `Message.download_media() `.
-
-    Arguments
-        message (`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.
-            If file is the type `bytes`, it will be downloaded in-memory
-            as a bytestring (e.g. ``file=bytes``).
-
-        progress_callback (`callable`, optional):
-            A callback function accepting two parameters:
-            ``(received bytes, total)``.
-
-        thumb (`int` | :tl:`PhotoSize`, optional):
-            Which thumbnail size from the document or photo to download,
-            instead of downloading the document or photo itself.
-
-            If it's specified but the file does not have a thumbnail,
-            this method will return `None`.
-
-            The parameter should be an integer index between ``0`` and
-            ``len(sizes)``. ``0`` will download the smallest thumbnail,
-            and ``len(sizes) - 1`` will download the largest thumbnail.
-            You can also use negative indices, which work the same as
-            they do in Python's `list`.
-
-            You can also pass the :tl:`PhotoSize` instance to use.
-            Alternatively, the thumb size type `str` may be used.
-
-            In short, use ``thumb=0`` if you want the smallest thumbnail
-            and ``thumb=-1`` if you want the largest thumbnail.
-
-            .. note::
-                The largest thumbnail may be a video instead of a photo,
-                as they are available since layer 116 and are bigger than
-                any of the photos.
-
-    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.
-
-    Example
-        .. code-block:: python
-
-            path = await client.download_media(message)
-            await client.download_media(message, filename)
-            # or
-            path = await message.download_media()
-            await message.download_media(filename)
-
-            # Printing download progress
-            def callback(current, total):
-                print('Downloaded', current, 'out of', total,
-                        'bytes: {:.2%}'.format(current / total))
-
-            await client.download_media(message, progress_callback=callback)
-    """
     # Downloading large documents may be slow enough to require a new file reference
     # to be obtained mid-download. Store (input chat, message id) so that the message
     # can be re-fetched.
@@ -428,58 +324,6 @@ async def download_file(
         dc_id: int = None,
         key: bytes = None,
         iv: bytes = None) -> typing.Optional[bytes]:
-    """
-    Low-level method to download files from their input location.
-
-    .. note::
-
-        Generally, you should instead use `download_media`.
-        This method is intended to be a bit more low-level.
-
-    Arguments
-        input_location (: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` or `bytes`, 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``.
-
-        dc_id (`int`, optional):
-            The data center the library should connect to in order
-            to download the file. You shouldn't worry about this.
-
-        key ('bytes', optional):
-            In case of an encrypted upload (secret chats) a key is supplied
-
-        iv ('bytes', optional):
-            In case of an encrypted upload (secret chats) an iv is supplied
-
-
-    Example
-        .. code-block:: python
-
-            # Download a file and print its header
-            data = await client.download_file(input_file, bytes)
-            print(data[:16])
-    """
     return await self._download_file(
         input_location,
         file,
@@ -563,89 +407,6 @@ def iter_download(
         file_size: int = None,
         dc_id: int = None
 ):
-    """
-    Iterates over a file download, yielding chunks of the file.
-
-    This method can be used to stream files in a more convenient
-    way, since it offers more control (pausing, resuming, etc.)
-
-    .. note::
-
-        Using a value for `offset` or `stride` which is not a multiple
-        of the minimum allowed `request_size`, or if `chunk_size` is
-        different from `request_size`, the library will need to do a
-        bit more work to fetch the data in the way you intend it to.
-
-        You normally shouldn't worry about this.
-
-    Arguments
-        file (`hints.FileLike`):
-            The file of which contents you want to iterate over.
-
-        offset (`int`, optional):
-            The offset in bytes into the file from where the
-            download should start. For example, if a file is
-            1024KB long and you just want the last 512KB, you
-            would use ``offset=512 * 1024``.
-
-        stride (`int`, optional):
-            The stride of each chunk (how much the offset should
-            advance between reading each chunk). This parameter
-            should only be used for more advanced use cases.
-
-            It must be bigger than or equal to the `chunk_size`.
-
-        limit (`int`, optional):
-            The limit for how many *chunks* will be yielded at most.
-
-        chunk_size (`int`, optional):
-            The maximum size of the chunks that will be yielded.
-            Note that the last chunk may be less than this value.
-            By default, it equals to `request_size`.
-
-        request_size (`int`, optional):
-            How many bytes will be requested to Telegram when more
-            data is required. By default, as many bytes as possible
-            are requested. If you would like to request data in
-            smaller sizes, adjust this parameter.
-
-            Note that values outside the valid range will be clamped,
-            and the final value will also be a multiple of the minimum
-            allowed size.
-
-        file_size (`int`, optional):
-            If the file size is known beforehand, you should set
-            this parameter to said value. Depending on the type of
-            the input file passed, this may be set automatically.
-
-        dc_id (`int`, optional):
-            The data center the library should connect to in order
-            to download the file. You shouldn't worry about this.
-
-    Yields
-
-        `bytes` objects representing the chunks of the file if the
-        right conditions are met, or `memoryview` objects instead.
-
-    Example
-        .. code-block:: python
-
-            # Streaming `media` to an output file
-            # After the iteration ends, the sender is cleaned up
-            with open('photo.jpg', 'wb') as fd:
-                async for chunk in client.iter_download(media):
-                    fd.write(chunk)
-
-            # Fetching only the header of a file (32 bytes)
-            # You should manually close the iterator in this case.
-            #
-            # "stream" is a common name for asynchronous generators,
-            # and iter_download will yield `bytes` (chunks of the file).
-            stream = client.iter_download(media, request_size=32)
-            header = await stream.__anext__()  # "manual" version of `async for`
-            await stream.close()
-            assert len(header) == 32
-    """
     return self._iter_download(
         file,
         offset=offset,
diff --git a/telethon/_client/messageparse.py b/telethon/_client/messageparse.py
index 72b121a9..0cbdb40c 100644
--- a/telethon/_client/messageparse.py
+++ b/telethon/_client/messageparse.py
@@ -10,38 +10,6 @@ if typing.TYPE_CHECKING:
 
 
 def get_parse_mode(self: 'TelegramClient'):
-    """
-    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.
-
-    Example
-        .. code-block:: python
-
-            # Disabling default formatting
-            client.parse_mode = None
-
-            # Enabling HTML as the default format
-            client.parse_mode = 'html'
-    """
     return self._parse_mode
 
 def set_parse_mode(self: 'TelegramClient', mode: str):
diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py
index c5d04ade..89816530 100644
--- a/telethon/_client/updates.py
+++ b/telethon/_client/updates.py
@@ -30,74 +30,14 @@ async def _run_until_disconnected(self: 'TelegramClient'):
         await self.disconnect()
 
 async def set_receive_updates(self: 'TelegramClient', receive_updates):
-    """
-    Change the value of `receive_updates`.
-
-    This is an `async` method, because in order for Telegram to start
-    sending updates again, a request must be made.
-    """
     self._no_updates = not receive_updates
     if receive_updates:
         await self(functions.updates.GetStateRequest())
 
 async def run_until_disconnected(self: 'TelegramClient'):
-    """
-    Runs the event loop until the library is disconnected.
-
-    It also notifies Telegram that we want to receive updates
-    as described in https://core.telegram.org/api/updates.
-
-    Manual disconnections can be made by calling `disconnect()
-    `
-    or sending a ``KeyboardInterrupt`` (e.g. by pressing ``Ctrl+C`` on
-    the console window running the script).
-
-    If a disconnection error occurs (i.e. the library fails to reconnect
-    automatically), said error will be raised through here, so you have a
-    chance to ``except`` it on your own code.
-
-    If the loop is already running, this method returns a coroutine
-    that you should await on your own code.
-
-    .. note::
-
-        If you want to handle ``KeyboardInterrupt`` in your code,
-        simply run the event loop in your code too in any way, such as
-        ``loop.run_forever()`` or ``await client.disconnected`` (e.g.
-        ``loop.run_until_complete(client.disconnected)``).
-
-    Example
-        .. code-block:: python
-
-            # Blocks the current task here until a disconnection occurs.
-            #
-            # You will still receive updates, since this prevents the
-            # script from exiting.
-            await client.run_until_disconnected()
-    """
     return await self._run_until_disconnected()
 
 def on(self: 'TelegramClient', event: EventBuilder):
-    """
-    Decorator used to `add_event_handler` more conveniently.
-
-
-    Arguments
-        event (`_EventBuilder` | `type`):
-            The event builder class or instance to be used,
-            for instance ``events.NewMessage``.
-
-    Example
-        .. code-block:: python
-
-            from telethon import TelegramClient, events
-            client = TelegramClient(...)
-
-            # Here we use client.on
-            @client.on(events.NewMessage)
-            async def handler(event):
-                ...
-    """
     def decorator(f):
         self.add_event_handler(f, event)
         return f
@@ -108,38 +48,6 @@ def add_event_handler(
         self: 'TelegramClient',
         callback: Callback,
         event: EventBuilder = None):
-    """
-    Registers a new event handler callback.
-
-    The callback will be called when the specified event occurs.
-
-    Arguments
-        callback (`callable`):
-            The callable function accepting one parameter to be used.
-
-            Note that if you have used `telethon.events.register` in
-            the callback, ``event`` will be ignored, and instead the
-            events you previously registered will 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.
-
-    Example
-        .. code-block:: python
-
-            from telethon import TelegramClient, events
-            client = TelegramClient(...)
-
-            async def handler(event):
-                ...
-
-            client.add_event_handler(handler, events.NewMessage)
-    """
     builders = events._get_handlers(callback)
     if builders is not None:
         for event in builders:
@@ -157,27 +65,6 @@ def remove_event_handler(
         self: 'TelegramClient',
         callback: Callback,
         event: EventBuilder = None) -> int:
-    """
-    Inverse operation of `add_event_handler()`.
-
-    If no event is given, all events for this callback are removed.
-    Returns how many callbacks were removed.
-
-    Example
-        .. code-block:: python
-
-            @client.on(events.Raw)
-            @client.on(events.NewMessage)
-            async def handler(event):
-                ...
-
-            # Removes only the "Raw" handling
-            # "handler" will still receive "events.NewMessage"
-            client.remove_event_handler(handler, events.Raw)
-
-            # "handler" will stop receiving anything
-            client.remove_event_handler(handler)
-    """
     found = 0
     if event and not isinstance(event, type):
         event = type(event)
@@ -194,38 +81,9 @@ def remove_event_handler(
 
 def list_event_handlers(self: 'TelegramClient')\
         -> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]':
-    """
-    Lists all registered event handlers.
-
-    Returns
-        A list of pairs consisting of ``(callback, event)``.
-
-    Example
-        .. code-block:: python
-
-            @client.on(events.NewMessage(pattern='hello'))
-            async def on_greeting(event):
-                '''Greets someone'''
-                await event.reply('Hi')
-
-            for callback, event in client.list_event_handlers():
-                print(id(callback), type(event))
-    """
     return [(callback, event) for event, callback in self._event_builders]
 
 async def catch_up(self: 'TelegramClient'):
-    """
-    "Catches up" on the missed updates while the client was offline.
-    You should call this method after registering the event handlers
-    so that the updates it loads can by processed by your script.
-
-    This can also be used to forcibly fetch new updates if there are any.
-
-    Example
-        .. code-block:: python
-
-            await client.catch_up()
-    """
     pts, date = self._state_cache[None]
     if not pts:
         return
diff --git a/telethon/_client/users.py b/telethon/_client/users.py
index e6964e55..0d871878 100644
--- a/telethon/_client/users.py
+++ b/telethon/_client/users.py
@@ -129,26 +129,6 @@ async def call(self: 'TelegramClient', sender, request, ordered=False, flood_sle
 
 async def get_me(self: 'TelegramClient', input_peer: bool = False) \
         -> 'typing.Union[types.User, types.InputPeerUser]':
-    """
-    Gets "me", the current :tl:`User` who is logged in.
-
-    If the user has not logged in yet, this method returns `None`.
-
-    Arguments
-        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`.
-
-    Example
-        .. code-block:: python
-
-            me = await client.get_me()
-            print(me.username)
-    """
     if input_peer and self._self_input_peer:
         return self._self_input_peer
 
@@ -176,34 +156,12 @@ def _self_id(self: 'TelegramClient') -> typing.Optional[int]:
     return self._self_input_peer.user_id if self._self_input_peer else None
 
 async def is_bot(self: 'TelegramClient') -> bool:
-    """
-    Return `True` if the signed-in user is a bot, `False` otherwise.
-
-    Example
-        .. code-block:: python
-
-            if await client.is_bot():
-                print('Beep')
-            else:
-                print('Hello')
-    """
     if self._bot is None:
         self._bot = (await self.get_me()).bot
 
     return self._bot
 
 async def is_user_authorized(self: 'TelegramClient') -> bool:
-    """
-    Returns `True` if the user is authorized (logged in).
-
-    Example
-        .. code-block:: python
-
-            if not await client.is_user_authorized():
-                await client.send_code_request(phone)
-                code = input('enter code: ')
-                await client.sign_in(phone, code)
-    """
     if self._authorized is None:
         try:
             # Any request that requires authorization will work
@@ -217,59 +175,6 @@ async def is_user_authorized(self: 'TelegramClient') -> bool:
 async def get_entity(
         self: 'TelegramClient',
         entity: 'hints.EntitiesLike') -> 'hints.Entity':
-    """
-    Turns the given entity into a valid Telegram :tl:`User`, :tl:`Chat`
-    or :tl:`Channel`. You can also pass a list or iterable of entities,
-    and they will be efficiently fetched from the network.
-
-    Arguments
-        entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`):
-            If a username is given, **the username will be resolved** making
-            an API call every time. Resolving usernames is an expensive
-            operation and will start hitting flood waits around 50 usernames
-            in a short period of time.
-
-            If you want to get the entity for a *cached* username, you should
-            first `get_input_entity(username) ` which will
-            use the cache), and then use `get_entity` with the result of the
-            previous call.
-
-            Similar limits apply to invite links, and you should use their
-            ID instead.
-
-            Using phone numbers (from people in your contact list), exact
-            names, integer IDs or :tl:`Peer` rely on a `get_input_entity`
-            first, which in turn needs the entity to be in cache, unless
-            a :tl:`InputPeer` was passed.
-
-            Unsupported types will raise ``TypeError``.
-
-            If the entity can't be found, ``ValueError`` 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.
-
-    Example
-        .. code-block:: python
-
-            from telethon import utils
-
-            me = await client.get_entity('me')
-            print(utils.get_display_name(me))
-
-            chat = await client.get_input_entity('username')
-            async for message in client.iter_messages(chat):
-                ...
-
-            # Note that you could have used the username directly, but it's
-            # good to use get_input_entity if you will reuse it a lot.
-            async for message in client.iter_messages('username'):
-                ...
-
-            # Note that for this to work the phone number must be in your contacts
-            some_id = await client.get_peer_id('+34123456789')
-    """
     single = not utils.is_list_like(entity)
     if single:
         entity = (entity,)
@@ -340,67 +245,6 @@ async def get_entity(
 async def get_input_entity(
         self: 'TelegramClient',
         peer: 'hints.EntityLike') -> 'types.TypeInputPeer':
-    """
-    Turns the given entity into its input entity version.
-
-    Most requests use this kind of :tl:`InputPeer`, so this is the most
-    suitable call to make for those cases. **Generally you should let the
-    library do its job** and don't worry about getting the input entity
-    first, but if you're going to use an entity often, consider making the
-    call:
-
-    Arguments
-        entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`):
-            If a username or invite link is given, **the library will
-            use the cache**. This means that it's possible to be using
-            a username that *changed* or an old invite link (this only
-            happens if an invite link for a small group chat is used
-            after it was upgraded to a mega-group).
-
-            If the username or ID from the invite link is not found in
-            the cache, it will be fetched. The same rules apply to phone
-            numbers (``'+34 123456789'``) from people in your contact list.
-
-            If an exact name is given, it must be in the cache too. This
-            is not reliable as different people can share the same name
-            and which entity is returned is arbitrary, and should be used
-            only for quick tests.
-
-            If a positive integer ID is given, the entity will be searched
-            in cached users, chats or channels, without making any call.
-
-            If a negative integer ID is given, the entity will be searched
-            exactly as either a chat (prefixed with ``-``) or as a channel
-            (prefixed with ``-100``).
-
-            If a :tl:`Peer` is given, it will be searched exactly in the
-            cache as either a user, chat or channel.
-
-            If the given object can be turned into an input entity directly,
-            said operation will be done.
-
-            Unsupported types will raise ``TypeError``.
-
-            If the entity can't be found, ``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.
-
-    Example
-        .. code-block:: python
-
-            # If you're going to use "username" often in your code
-            # (make a lot of calls), consider getting its input entity
-            # once, and then using the "user" everywhere instead.
-            user = await client.get_input_entity('username')
-
-            # The same applies to IDs, chats or channels.
-            chat = await client.get_input_entity(-123456789)
-    """
     # Short-circuit if the input parameter directly maps to an InputPeer
     try:
         return utils.get_input_peer(peer)
@@ -472,20 +316,6 @@ async def get_peer_id(
         self: 'TelegramClient',
         peer: 'hints.EntityLike',
         add_mark: bool = True) -> int:
-    """
-    Gets the ID for the given entity.
-
-    This method needs to be ``async`` because `peer` supports usernames,
-    invite-links, phone numbers (from people in your contact list), etc.
-
-    If ``add_mark is False``, then a positive ID will be returned
-    instead. By default, bot-API style IDs (signed) are returned.
-
-    Example
-        .. code-block:: python
-
-            print(await client.get_peer_id('me'))
-    """
     if isinstance(peer, int):
         return utils.get_peer_id(peer, add_mark=add_mark)
 

From a901d43a6d124af65fee622a51eef28d8f77743c Mon Sep 17 00:00:00 2001
From: Lonami Exo 
Date: Sat, 11 Sep 2021 17:48:23 +0200
Subject: [PATCH 008/131] Rename more subpackages and modules

---
 .gitignore                                    |   6 +-
 readthedocs/misc/v2-migration-guide.rst       | 111 +++++++++++-------
 telethon/{crypto => _crypto}/__init__.py      |   0
 telethon/{crypto => _crypto}/aes.py           |   0
 telethon/{crypto => _crypto}/aesctr.py        |   0
 telethon/{crypto => _crypto}/authkey.py       |   0
 telethon/{crypto => _crypto}/cdndecrypter.py  |   0
 telethon/{crypto => _crypto}/factorization.py |   0
 telethon/{crypto => _crypto}/libssl.py        |   0
 telethon/{crypto => _crypto}/rsa.py           |   0
 telethon/{extensions => _misc}/__init__.py    |   0
 .../{extensions => _misc}/binaryreader.py     |   0
 telethon/{ => _misc}/entitycache.py           |   0
 telethon/{ => _misc}/helpers.py               |   0
 telethon/{ => _misc}/hints.py                 |   0
 telethon/{extensions => _misc}/html.py        |   0
 telethon/{extensions => _misc}/markdown.py    |   0
 .../{extensions => _misc}/messagepacker.py    |   0
 telethon/{ => _misc}/password.py              |   0
 telethon/{ => _misc}/requestiter.py           |   0
 telethon/{ => _misc}/statecache.py            |   0
 telethon/{ => _misc}/utils.py                 |   0
 telethon/{network => _network}/__init__.py    |   0
 .../{network => _network}/authenticator.py    |   0
 .../connection/__init__.py                    |   0
 .../connection/connection.py                  |   0
 .../{network => _network}/connection/http.py  |   0
 .../connection/tcpabridged.py                 |   0
 .../connection/tcpfull.py                     |   0
 .../connection/tcpintermediate.py             |   0
 .../connection/tcpmtproxy.py                  |   0
 .../connection/tcpobfuscated.py               |   0
 .../mtprotoplainsender.py                     |   0
 .../{network => _network}/mtprotosender.py    |   0
 .../{network => _network}/mtprotostate.py     |   0
 .../{network => _network}/requeststate.py     |   0
 telethon/{tl => _tl}/__init__.py              |   0
 telethon/{tl => _tl}/core/__init__.py         |   0
 telethon/{tl => _tl}/core/gzippacked.py       |   0
 telethon/{tl => _tl}/core/messagecontainer.py |   0
 telethon/{tl => _tl}/core/rpcresult.py        |   0
 telethon/{tl => _tl}/core/tlmessage.py        |   0
 telethon/{tl => _tl}/custom/__init__.py       |   0
 telethon/{tl => _tl}/custom/adminlogevent.py  |   0
 telethon/{tl => _tl}/custom/button.py         |   0
 telethon/{tl => _tl}/custom/chatgetter.py     |   0
 telethon/{tl => _tl}/custom/dialog.py         |   0
 telethon/{tl => _tl}/custom/draft.py          |   0
 telethon/{tl => _tl}/custom/file.py           |   0
 telethon/{tl => _tl}/custom/forward.py        |   0
 telethon/{tl => _tl}/custom/inlinebuilder.py  |   0
 telethon/{tl => _tl}/custom/inlineresult.py   |   0
 telethon/{tl => _tl}/custom/inlineresults.py  |   0
 telethon/{tl => _tl}/custom/inputsizedfile.py |   0
 telethon/{tl => _tl}/custom/message.py        |   0
 telethon/{tl => _tl}/custom/messagebutton.py  |   0
 .../custom/participantpermissions.py          |   0
 telethon/{tl => _tl}/custom/qrlogin.py        |   0
 telethon/{tl => _tl}/custom/sendergetter.py   |   0
 telethon/{tl => _tl}/patched/__init__.py      |   0
 telethon/{tl => _tl}/tlobject.py              |   0
 61 files changed, 69 insertions(+), 48 deletions(-)
 rename telethon/{crypto => _crypto}/__init__.py (100%)
 rename telethon/{crypto => _crypto}/aes.py (100%)
 rename telethon/{crypto => _crypto}/aesctr.py (100%)
 rename telethon/{crypto => _crypto}/authkey.py (100%)
 rename telethon/{crypto => _crypto}/cdndecrypter.py (100%)
 rename telethon/{crypto => _crypto}/factorization.py (100%)
 rename telethon/{crypto => _crypto}/libssl.py (100%)
 rename telethon/{crypto => _crypto}/rsa.py (100%)
 rename telethon/{extensions => _misc}/__init__.py (100%)
 rename telethon/{extensions => _misc}/binaryreader.py (100%)
 rename telethon/{ => _misc}/entitycache.py (100%)
 rename telethon/{ => _misc}/helpers.py (100%)
 rename telethon/{ => _misc}/hints.py (100%)
 rename telethon/{extensions => _misc}/html.py (100%)
 rename telethon/{extensions => _misc}/markdown.py (100%)
 rename telethon/{extensions => _misc}/messagepacker.py (100%)
 rename telethon/{ => _misc}/password.py (100%)
 rename telethon/{ => _misc}/requestiter.py (100%)
 rename telethon/{ => _misc}/statecache.py (100%)
 rename telethon/{ => _misc}/utils.py (100%)
 rename telethon/{network => _network}/__init__.py (100%)
 rename telethon/{network => _network}/authenticator.py (100%)
 rename telethon/{network => _network}/connection/__init__.py (100%)
 rename telethon/{network => _network}/connection/connection.py (100%)
 rename telethon/{network => _network}/connection/http.py (100%)
 rename telethon/{network => _network}/connection/tcpabridged.py (100%)
 rename telethon/{network => _network}/connection/tcpfull.py (100%)
 rename telethon/{network => _network}/connection/tcpintermediate.py (100%)
 rename telethon/{network => _network}/connection/tcpmtproxy.py (100%)
 rename telethon/{network => _network}/connection/tcpobfuscated.py (100%)
 rename telethon/{network => _network}/mtprotoplainsender.py (100%)
 rename telethon/{network => _network}/mtprotosender.py (100%)
 rename telethon/{network => _network}/mtprotostate.py (100%)
 rename telethon/{network => _network}/requeststate.py (100%)
 rename telethon/{tl => _tl}/__init__.py (100%)
 rename telethon/{tl => _tl}/core/__init__.py (100%)
 rename telethon/{tl => _tl}/core/gzippacked.py (100%)
 rename telethon/{tl => _tl}/core/messagecontainer.py (100%)
 rename telethon/{tl => _tl}/core/rpcresult.py (100%)
 rename telethon/{tl => _tl}/core/tlmessage.py (100%)
 rename telethon/{tl => _tl}/custom/__init__.py (100%)
 rename telethon/{tl => _tl}/custom/adminlogevent.py (100%)
 rename telethon/{tl => _tl}/custom/button.py (100%)
 rename telethon/{tl => _tl}/custom/chatgetter.py (100%)
 rename telethon/{tl => _tl}/custom/dialog.py (100%)
 rename telethon/{tl => _tl}/custom/draft.py (100%)
 rename telethon/{tl => _tl}/custom/file.py (100%)
 rename telethon/{tl => _tl}/custom/forward.py (100%)
 rename telethon/{tl => _tl}/custom/inlinebuilder.py (100%)
 rename telethon/{tl => _tl}/custom/inlineresult.py (100%)
 rename telethon/{tl => _tl}/custom/inlineresults.py (100%)
 rename telethon/{tl => _tl}/custom/inputsizedfile.py (100%)
 rename telethon/{tl => _tl}/custom/message.py (100%)
 rename telethon/{tl => _tl}/custom/messagebutton.py (100%)
 rename telethon/{tl => _tl}/custom/participantpermissions.py (100%)
 rename telethon/{tl => _tl}/custom/qrlogin.py (100%)
 rename telethon/{tl => _tl}/custom/sendergetter.py (100%)
 rename telethon/{tl => _tl}/patched/__init__.py (100%)
 rename telethon/{tl => _tl}/tlobject.py (100%)

diff --git a/.gitignore b/.gitignore
index 6f2cf6f7..e81bec11 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,7 @@
 # Generated code
-/telethon/tl/functions/
-/telethon/tl/types/
-/telethon/tl/alltlobjects.py
+/telethon/_tl/functions/
+/telethon/_tl/types/
+/telethon/_tl/alltlobjects.py
 /telethon/errors/rpcerrorlist.py
 
 # User session
diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst
index 1ee9bda4..de1af171 100644
--- a/readthedocs/misc/v2-migration-guide.rst
+++ b/readthedocs/misc/v2-migration-guide.rst
@@ -9,6 +9,8 @@ the technical debt that has grown on the project.
 This document documents all the things you should be aware of when migrating
 from Telethon version 1.x to 2.0 onwards.
 
+**Please read this document in full before upgrading your code to Telethon 2.0.**
+
 
 User, chat and channel identifiers are now 64-bit numbers
 ---------------------------------------------------------
@@ -22,17 +24,75 @@ will need to migrate that to support the new size requirement of 8 bytes.
 For the full list of types changed, please review the above link.
 
 
-Many modules are now private
-----------------------------
+Many subpackages and modules are now private
+--------------------------------------------
 
 There were a lot of things which were public but should not have been. From now on, you should
 only rely on things that are either publicly re-exported or defined. That is, as soon as anything
 starts with an underscore (``_``) on its name, you're acknowledging that the functionality may
 change even across minor version changes, and thus have your code break.
 
-* The ``telethon.client`` module is now ``telethon._client``, meaning you should stop relying on
-  anything inside of it. This includes all of the subclasses that used to exist (like ``UserMethods``).
+The following subpackages are now considered private:
 
+* ``client`` is now ``_client``.
+* ``crypto`` is now ``_crypto``.
+* ``extensions`` is now ``_misc``.
+* ``tl`` is now ``_tl``.
+
+The following modules have been moved inside ``_misc``:
+
+* ``entitycache.py``
+* ``helpers.py``
+* ``hints.py``
+* ``password.py``
+* ``requestiter.py`
+* ``statecache.py``
+* ``utils.py``
+
+
+The TelegramClient is no longer made out of mixins
+--------------------------------------------------
+
+If you were relying on any of the individual mixins that made up the client, such as
+``UserMethods`` inside the ``telethon.client`` subpackage, those are now gone.
+There is a single ``TelegramClient`` class now, containing everything you need.
+
+
+Raw API methods have been renamed
+---------------------------------
+
+The subpackage holding the raw API methods has been renamed from ``tl`` to ``_tl`` in order to
+signal that these are prone to change across minor version bumps (the ``y`` in version ``x.y.z``).
+
+The ``Request`` suffix has been removed from the classes inside ``tl.functions``.
+
+The ``tl.types`` is now simply ``_tl``, and the ``tl.functions`` is now ``_tl.fn``.
+
+Some examples:
+
+.. code-block:: python
+
+    # Before
+    from telethon.tl import types, functions
+
+    await client(functions.messages.SendMessageRequest(...))
+    message: types.Message = ...
+
+    # After
+    from telethon import _tl
+    await client(_tl.fn.messages.SendMessage(...))
+    message: _tl.Message
+
+This serves multiple goals:
+
+* It removes redundant parts from the names. The "recommended" way of using the raw API is through
+  the subpackage namespace, which already contains a mention to "functions" in it. In addition,
+  some requests were awkward, such as ``SendCustomRequestRequest``.
+* It makes it easier to search for code that is using the raw API, so that you can quickly
+  identify which parts are making use of it.
+* The name is shorter, but remains recognizable.
+
+// TODO this definitely generated files mapping from the original name to this new one...
 
 Synchronous compatibility mode has been removed
 -----------------------------------------------
@@ -61,46 +121,7 @@ handlers, and overcomplicated usage for anything beyond the simplest case.
 
 It is not difficult to write your own code to deal with a conversation's state. A simple
 `Finite State Machine `__ inside your handlers will do
-just fine:
-
-.. code-block:: python
-
-    from enum import Enum, auto
-
-    # We use a Python Enum for the state because it's a clean and easy way to do it
-    class State(Enum):
-        WAIT_NAME = auto()
-        WAIT_AGE = auto()
-
-    # The state in which different users are, {user_id: state}
-    conversation_state = {}
-
-    # ...code to create and setup your client...
-
-    @client.on(events.NewMessage)
-    async def handler(event):
-        who = event.sender_id
-        state = conversation_state.get(who)
-
-        if state is None:
-            # Starting a conversation
-            await event.respond('Hi! What is your name?')
-            conversation_state[who] = State.WAIT_NAME
-
-        elif state == State.WAIT_NAME:
-            name = event.text  # Save the name wherever you want
-            await event.respond('Nice! What is your age?')
-            conversation_state[who] = State.WAIT_AGE
-
-        elif state == State.WAIT_AGE:
-            age = event.text  # Save the age wherever you want
-            await event.respond('Thank you!')
-            # Conversation is done so we can forget the state of this user
-            del conversation_state[who]
-
-    # ...code to keep Telethon running...
-
-Not only is this approach simpler, but it can also be easily persisted, and you can adjust it
-to your needs and your handlers much more easily.
+just fine This approach can also be easily persisted, and you can adjust it to your needs and
+your handlers much more easily.
 
 // TODO provide standalone alternative for this?
diff --git a/telethon/crypto/__init__.py b/telethon/_crypto/__init__.py
similarity index 100%
rename from telethon/crypto/__init__.py
rename to telethon/_crypto/__init__.py
diff --git a/telethon/crypto/aes.py b/telethon/_crypto/aes.py
similarity index 100%
rename from telethon/crypto/aes.py
rename to telethon/_crypto/aes.py
diff --git a/telethon/crypto/aesctr.py b/telethon/_crypto/aesctr.py
similarity index 100%
rename from telethon/crypto/aesctr.py
rename to telethon/_crypto/aesctr.py
diff --git a/telethon/crypto/authkey.py b/telethon/_crypto/authkey.py
similarity index 100%
rename from telethon/crypto/authkey.py
rename to telethon/_crypto/authkey.py
diff --git a/telethon/crypto/cdndecrypter.py b/telethon/_crypto/cdndecrypter.py
similarity index 100%
rename from telethon/crypto/cdndecrypter.py
rename to telethon/_crypto/cdndecrypter.py
diff --git a/telethon/crypto/factorization.py b/telethon/_crypto/factorization.py
similarity index 100%
rename from telethon/crypto/factorization.py
rename to telethon/_crypto/factorization.py
diff --git a/telethon/crypto/libssl.py b/telethon/_crypto/libssl.py
similarity index 100%
rename from telethon/crypto/libssl.py
rename to telethon/_crypto/libssl.py
diff --git a/telethon/crypto/rsa.py b/telethon/_crypto/rsa.py
similarity index 100%
rename from telethon/crypto/rsa.py
rename to telethon/_crypto/rsa.py
diff --git a/telethon/extensions/__init__.py b/telethon/_misc/__init__.py
similarity index 100%
rename from telethon/extensions/__init__.py
rename to telethon/_misc/__init__.py
diff --git a/telethon/extensions/binaryreader.py b/telethon/_misc/binaryreader.py
similarity index 100%
rename from telethon/extensions/binaryreader.py
rename to telethon/_misc/binaryreader.py
diff --git a/telethon/entitycache.py b/telethon/_misc/entitycache.py
similarity index 100%
rename from telethon/entitycache.py
rename to telethon/_misc/entitycache.py
diff --git a/telethon/helpers.py b/telethon/_misc/helpers.py
similarity index 100%
rename from telethon/helpers.py
rename to telethon/_misc/helpers.py
diff --git a/telethon/hints.py b/telethon/_misc/hints.py
similarity index 100%
rename from telethon/hints.py
rename to telethon/_misc/hints.py
diff --git a/telethon/extensions/html.py b/telethon/_misc/html.py
similarity index 100%
rename from telethon/extensions/html.py
rename to telethon/_misc/html.py
diff --git a/telethon/extensions/markdown.py b/telethon/_misc/markdown.py
similarity index 100%
rename from telethon/extensions/markdown.py
rename to telethon/_misc/markdown.py
diff --git a/telethon/extensions/messagepacker.py b/telethon/_misc/messagepacker.py
similarity index 100%
rename from telethon/extensions/messagepacker.py
rename to telethon/_misc/messagepacker.py
diff --git a/telethon/password.py b/telethon/_misc/password.py
similarity index 100%
rename from telethon/password.py
rename to telethon/_misc/password.py
diff --git a/telethon/requestiter.py b/telethon/_misc/requestiter.py
similarity index 100%
rename from telethon/requestiter.py
rename to telethon/_misc/requestiter.py
diff --git a/telethon/statecache.py b/telethon/_misc/statecache.py
similarity index 100%
rename from telethon/statecache.py
rename to telethon/_misc/statecache.py
diff --git a/telethon/utils.py b/telethon/_misc/utils.py
similarity index 100%
rename from telethon/utils.py
rename to telethon/_misc/utils.py
diff --git a/telethon/network/__init__.py b/telethon/_network/__init__.py
similarity index 100%
rename from telethon/network/__init__.py
rename to telethon/_network/__init__.py
diff --git a/telethon/network/authenticator.py b/telethon/_network/authenticator.py
similarity index 100%
rename from telethon/network/authenticator.py
rename to telethon/_network/authenticator.py
diff --git a/telethon/network/connection/__init__.py b/telethon/_network/connection/__init__.py
similarity index 100%
rename from telethon/network/connection/__init__.py
rename to telethon/_network/connection/__init__.py
diff --git a/telethon/network/connection/connection.py b/telethon/_network/connection/connection.py
similarity index 100%
rename from telethon/network/connection/connection.py
rename to telethon/_network/connection/connection.py
diff --git a/telethon/network/connection/http.py b/telethon/_network/connection/http.py
similarity index 100%
rename from telethon/network/connection/http.py
rename to telethon/_network/connection/http.py
diff --git a/telethon/network/connection/tcpabridged.py b/telethon/_network/connection/tcpabridged.py
similarity index 100%
rename from telethon/network/connection/tcpabridged.py
rename to telethon/_network/connection/tcpabridged.py
diff --git a/telethon/network/connection/tcpfull.py b/telethon/_network/connection/tcpfull.py
similarity index 100%
rename from telethon/network/connection/tcpfull.py
rename to telethon/_network/connection/tcpfull.py
diff --git a/telethon/network/connection/tcpintermediate.py b/telethon/_network/connection/tcpintermediate.py
similarity index 100%
rename from telethon/network/connection/tcpintermediate.py
rename to telethon/_network/connection/tcpintermediate.py
diff --git a/telethon/network/connection/tcpmtproxy.py b/telethon/_network/connection/tcpmtproxy.py
similarity index 100%
rename from telethon/network/connection/tcpmtproxy.py
rename to telethon/_network/connection/tcpmtproxy.py
diff --git a/telethon/network/connection/tcpobfuscated.py b/telethon/_network/connection/tcpobfuscated.py
similarity index 100%
rename from telethon/network/connection/tcpobfuscated.py
rename to telethon/_network/connection/tcpobfuscated.py
diff --git a/telethon/network/mtprotoplainsender.py b/telethon/_network/mtprotoplainsender.py
similarity index 100%
rename from telethon/network/mtprotoplainsender.py
rename to telethon/_network/mtprotoplainsender.py
diff --git a/telethon/network/mtprotosender.py b/telethon/_network/mtprotosender.py
similarity index 100%
rename from telethon/network/mtprotosender.py
rename to telethon/_network/mtprotosender.py
diff --git a/telethon/network/mtprotostate.py b/telethon/_network/mtprotostate.py
similarity index 100%
rename from telethon/network/mtprotostate.py
rename to telethon/_network/mtprotostate.py
diff --git a/telethon/network/requeststate.py b/telethon/_network/requeststate.py
similarity index 100%
rename from telethon/network/requeststate.py
rename to telethon/_network/requeststate.py
diff --git a/telethon/tl/__init__.py b/telethon/_tl/__init__.py
similarity index 100%
rename from telethon/tl/__init__.py
rename to telethon/_tl/__init__.py
diff --git a/telethon/tl/core/__init__.py b/telethon/_tl/core/__init__.py
similarity index 100%
rename from telethon/tl/core/__init__.py
rename to telethon/_tl/core/__init__.py
diff --git a/telethon/tl/core/gzippacked.py b/telethon/_tl/core/gzippacked.py
similarity index 100%
rename from telethon/tl/core/gzippacked.py
rename to telethon/_tl/core/gzippacked.py
diff --git a/telethon/tl/core/messagecontainer.py b/telethon/_tl/core/messagecontainer.py
similarity index 100%
rename from telethon/tl/core/messagecontainer.py
rename to telethon/_tl/core/messagecontainer.py
diff --git a/telethon/tl/core/rpcresult.py b/telethon/_tl/core/rpcresult.py
similarity index 100%
rename from telethon/tl/core/rpcresult.py
rename to telethon/_tl/core/rpcresult.py
diff --git a/telethon/tl/core/tlmessage.py b/telethon/_tl/core/tlmessage.py
similarity index 100%
rename from telethon/tl/core/tlmessage.py
rename to telethon/_tl/core/tlmessage.py
diff --git a/telethon/tl/custom/__init__.py b/telethon/_tl/custom/__init__.py
similarity index 100%
rename from telethon/tl/custom/__init__.py
rename to telethon/_tl/custom/__init__.py
diff --git a/telethon/tl/custom/adminlogevent.py b/telethon/_tl/custom/adminlogevent.py
similarity index 100%
rename from telethon/tl/custom/adminlogevent.py
rename to telethon/_tl/custom/adminlogevent.py
diff --git a/telethon/tl/custom/button.py b/telethon/_tl/custom/button.py
similarity index 100%
rename from telethon/tl/custom/button.py
rename to telethon/_tl/custom/button.py
diff --git a/telethon/tl/custom/chatgetter.py b/telethon/_tl/custom/chatgetter.py
similarity index 100%
rename from telethon/tl/custom/chatgetter.py
rename to telethon/_tl/custom/chatgetter.py
diff --git a/telethon/tl/custom/dialog.py b/telethon/_tl/custom/dialog.py
similarity index 100%
rename from telethon/tl/custom/dialog.py
rename to telethon/_tl/custom/dialog.py
diff --git a/telethon/tl/custom/draft.py b/telethon/_tl/custom/draft.py
similarity index 100%
rename from telethon/tl/custom/draft.py
rename to telethon/_tl/custom/draft.py
diff --git a/telethon/tl/custom/file.py b/telethon/_tl/custom/file.py
similarity index 100%
rename from telethon/tl/custom/file.py
rename to telethon/_tl/custom/file.py
diff --git a/telethon/tl/custom/forward.py b/telethon/_tl/custom/forward.py
similarity index 100%
rename from telethon/tl/custom/forward.py
rename to telethon/_tl/custom/forward.py
diff --git a/telethon/tl/custom/inlinebuilder.py b/telethon/_tl/custom/inlinebuilder.py
similarity index 100%
rename from telethon/tl/custom/inlinebuilder.py
rename to telethon/_tl/custom/inlinebuilder.py
diff --git a/telethon/tl/custom/inlineresult.py b/telethon/_tl/custom/inlineresult.py
similarity index 100%
rename from telethon/tl/custom/inlineresult.py
rename to telethon/_tl/custom/inlineresult.py
diff --git a/telethon/tl/custom/inlineresults.py b/telethon/_tl/custom/inlineresults.py
similarity index 100%
rename from telethon/tl/custom/inlineresults.py
rename to telethon/_tl/custom/inlineresults.py
diff --git a/telethon/tl/custom/inputsizedfile.py b/telethon/_tl/custom/inputsizedfile.py
similarity index 100%
rename from telethon/tl/custom/inputsizedfile.py
rename to telethon/_tl/custom/inputsizedfile.py
diff --git a/telethon/tl/custom/message.py b/telethon/_tl/custom/message.py
similarity index 100%
rename from telethon/tl/custom/message.py
rename to telethon/_tl/custom/message.py
diff --git a/telethon/tl/custom/messagebutton.py b/telethon/_tl/custom/messagebutton.py
similarity index 100%
rename from telethon/tl/custom/messagebutton.py
rename to telethon/_tl/custom/messagebutton.py
diff --git a/telethon/tl/custom/participantpermissions.py b/telethon/_tl/custom/participantpermissions.py
similarity index 100%
rename from telethon/tl/custom/participantpermissions.py
rename to telethon/_tl/custom/participantpermissions.py
diff --git a/telethon/tl/custom/qrlogin.py b/telethon/_tl/custom/qrlogin.py
similarity index 100%
rename from telethon/tl/custom/qrlogin.py
rename to telethon/_tl/custom/qrlogin.py
diff --git a/telethon/tl/custom/sendergetter.py b/telethon/_tl/custom/sendergetter.py
similarity index 100%
rename from telethon/tl/custom/sendergetter.py
rename to telethon/_tl/custom/sendergetter.py
diff --git a/telethon/tl/patched/__init__.py b/telethon/_tl/patched/__init__.py
similarity index 100%
rename from telethon/tl/patched/__init__.py
rename to telethon/_tl/patched/__init__.py
diff --git a/telethon/tl/tlobject.py b/telethon/_tl/tlobject.py
similarity index 100%
rename from telethon/tl/tlobject.py
rename to telethon/_tl/tlobject.py

From d48649602b92de1f4036f2246907d282f60959bf Mon Sep 17 00:00:00 2001
From: Lonami Exo 
Date: Sun, 12 Sep 2021 12:16:02 +0200
Subject: [PATCH 009/131] Replace most raw API usage with new location

---
 .gitignore                                    |   4 +-
 setup.py                                      |   2 +-
 telethon/__init__.py                          |   5 +-
 telethon/_client/account.py                   |  11 +-
 telethon/_client/auth.py                      |  45 +-
 telethon/_client/bots.py                      |  11 +-
 telethon/_client/buttons.py                   |  17 +-
 telethon/_client/chats.py                     | 187 ++++----
 telethon/_client/dialogs.py                   |  41 +-
 telethon/_client/downloads.py                 |  85 ++--
 telethon/_client/messageparse.py              |  31 +-
 telethon/_client/messages.py                  | 115 +++--
 telethon/_client/telegrambaseclient.py        |  30 +-
 telethon/_client/telegramclient.py            |  75 ++--
 telethon/_client/updates.py                   |  49 ++-
 telethon/_client/uploads.py                   |  49 ++-
 telethon/_client/users.py                     |  65 ++-
 telethon/_crypto/cdndecrypter.py              |  13 +-
 telethon/_crypto/rsa.py                       |   6 +-
 telethon/_misc/binaryreader.py                |   7 +-
 telethon/_misc/entitycache.py                 |  15 +-
 telethon/_misc/hints.py                       |  25 +-
 telethon/_misc/html.py                        |  61 ++-
 telethon/_misc/markdown.py                    |  35 +-
 telethon/_misc/password.py                    |  12 +-
 telethon/_misc/statecache.py                  |  72 ++--
 telethon/_misc/utils.py                       | 408 +++++++++---------
 telethon/_network/authenticator.py            |  33 +-
 telethon/_tl/__init__.py                      |   1 -
 telethon/_tl/custom/dialog.py                 |   2 +-
 telethon/_tl/custom/draft.py                  |  14 +-
 telethon/_tl/custom/inlinebuilder.py          |   4 +-
 telethon/_tl/custom/inlineresult.py           |   2 +-
 telethon/_tl/custom/message.py                |   2 +-
 telethon/_tl/custom/messagebutton.py          |   8 +-
 telethon/_tl/custom/qrlogin.py                |   4 +-
 telethon/errors/common.py                     |   4 +-
 telethon/errors/rpcbaseerrors.py              |  16 +-
 telethon/events/album.py                      |  18 +-
 telethon/events/callbackquery.py              |  20 +-
 telethon/events/chataction.py                 |  55 ++-
 telethon/events/common.py                     |  18 +-
 telethon/events/inlinequery.py                |  16 +-
 telethon/events/messagedeleted.py             |   8 +-
 telethon/events/messageedited.py              |   6 +-
 telethon/events/messageread.py                |  21 +-
 telethon/events/newmessage.py                 |  23 +-
 telethon/events/userupdate.py                 |  84 ++--
 telethon/sessions/memory.py                   |  42 +-
 telethon/sessions/sqlite.py                   |  16 +-
 telethon_generator/generators/tlobject.py     |   4 +-
 .../parsers/tlobject/tlobject.py              |   3 +-
 tests/telethon/tl/test_serialization.py       |   2 +-
 53 files changed, 918 insertions(+), 984 deletions(-)
 delete mode 100644 telethon/_tl/__init__.py

diff --git a/.gitignore b/.gitignore
index e81bec11..65fabceb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,6 @@
 # Generated code
-/telethon/_tl/functions/
-/telethon/_tl/types/
+/telethon/_tl/fn/
+/telethon/_tl/*.py
 /telethon/_tl/alltlobjects.py
 /telethon/errors/rpcerrorlist.py
 
diff --git a/setup.py b/setup.py
index a498980a..2cb63901 100755
--- a/setup.py
+++ b/setup.py
@@ -55,7 +55,7 @@ METHODS_IN = GENERATOR_DIR / 'data/methods.csv'
 FRIENDLY_IN = GENERATOR_DIR / 'data/friendly.csv'
 
 TLOBJECT_IN_TLS = [Path(x) for x in GENERATOR_DIR.glob('data/*.tl')]
-TLOBJECT_OUT = LIBRARY_DIR / 'tl'
+TLOBJECT_OUT = LIBRARY_DIR / '_tl'
 IMPORT_DEPTH = 2
 
 DOCS_IN_RES = GENERATOR_DIR / 'data/html'
diff --git a/telethon/__init__.py b/telethon/__init__.py
index d4e4c2c8..335abab6 100644
--- a/telethon/__init__.py
+++ b/telethon/__init__.py
@@ -1,8 +1,7 @@
 from ._client.telegramclient import TelegramClient
 from .network import connection
-from .tl import types, functions, custom
-from .tl.custom import Button
-from .tl import patched as _  # import for its side-effects
+from ._tl import custom
+from ._tl.custom import Button
 from . import version, events, utils, errors
 
 __version__ = version.__version__
diff --git a/telethon/_client/account.py b/telethon/_client/account.py
index 46e0b6dc..0331a195 100644
--- a/telethon/_client/account.py
+++ b/telethon/_client/account.py
@@ -3,8 +3,7 @@ import inspect
 import typing
 
 from .users import _NOT_A_REQUEST
-from .. import helpers, utils
-from ..tl import functions, TLRequest
+from .. import helpers, utils, _tl
 
 if typing.TYPE_CHECKING:
     from .telegramclient import TelegramClient
@@ -50,7 +49,7 @@ class _TakeoutClient:
             self.__success = exc_type is None
 
         if self.__success is not None:
-            result = await self(functions.account.FinishTakeoutSessionRequest(
+            result = await self(_tl.fn.account.FinishTakeoutSession(
                 self.__success))
             if not result:
                 raise ValueError("Failed to finish the takeout.")
@@ -66,10 +65,10 @@ class _TakeoutClient:
         requests = ((request,) if single else request)
         wrapped = []
         for r in requests:
-            if not isinstance(r, TLRequest):
+            if not isinstance(r, _tl.TLRequest):
                 raise _NOT_A_REQUEST()
             await r.resolve(self, utils)
-            wrapped.append(functions.InvokeWithTakeoutRequest(takeout_id, r))
+            wrapped.append(_tl.fn.InvokeWithTakeout(takeout_id, r))
 
         return await self.__client(
             wrapped[0] if single else wrapped, ordered=ordered)
@@ -127,7 +126,7 @@ def takeout(
     arg_specified = (arg is not None for arg in request_kwargs.values())
 
     if self.session.takeout_id is None or any(arg_specified):
-        request = functions.account.InitTakeoutSessionRequest(
+        request = _tl.fn.account.InitTakeoutSession(
             **request_kwargs)
     else:
         request = None
diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py
index 3699d795..859a4e87 100644
--- a/telethon/_client/auth.py
+++ b/telethon/_client/auth.py
@@ -5,8 +5,7 @@ import sys
 import typing
 import warnings
 
-from .. import utils, helpers, errors, password as pwd_mod
-from ..tl import types, functions, custom
+from .. import utils, helpers, errors, password as pwd_mod, _tl
 
 if typing.TYPE_CHECKING:
     from .telegramclient import TelegramClient
@@ -201,7 +200,7 @@ async def sign_in(
         *,
         password: str = None,
         bot_token: str = None,
-        phone_code_hash: str = None) -> 'typing.Union[types.User, types.auth.SentCode]':
+        phone_code_hash: str = None) -> 'typing.Union[_tl.User, _tl.auth.SentCode]':
     me = await self.get_me()
     if me:
         return me
@@ -214,16 +213,16 @@ async def sign_in(
 
         # May raise PhoneCodeEmptyError, PhoneCodeExpiredError,
         # PhoneCodeHashEmptyError or PhoneCodeInvalidError.
-        request = functions.auth.SignInRequest(
+        request = _tl.fn.auth.SignIn(
             phone, phone_code_hash, str(code)
         )
     elif password:
-        pwd = await self(functions.account.GetPasswordRequest())
-        request = functions.auth.CheckPasswordRequest(
+        pwd = await self(_tl.fn.account.GetPassword())
+        request = _tl.fn.auth.CheckPassword(
             pwd_mod.compute_check(pwd, password)
         )
     elif bot_token:
-        request = functions.auth.ImportBotAuthorizationRequest(
+        request = _tl.fn.auth.ImportBotAuthorization(
             flags=0, bot_auth_token=bot_token,
             api_id=self.api_id, api_hash=self.api_hash
         )
@@ -234,7 +233,7 @@ async def sign_in(
         )
 
     result = await self(request)
-    if isinstance(result, types.auth.AuthorizationSignUpRequired):
+    if isinstance(result, _tl.auth.AuthorizationSignUpRequired):
         # Emulate pre-layer 104 behaviour
         self._tos = result.terms_of_service
         raise errors.PhoneNumberUnoccupiedError(request=request)
@@ -248,7 +247,7 @@ async def sign_up(
         last_name: str = '',
         *,
         phone: str = None,
-        phone_code_hash: str = None) -> 'types.User':
+        phone_code_hash: str = None) -> '_tl.User':
     me = await self.get_me()
     if me:
         return me
@@ -281,7 +280,7 @@ async def sign_up(
     phone, phone_code_hash = \
         self._parse_phone_and_hash(phone, phone_code_hash)
 
-    result = await self(functions.auth.SignUpRequest(
+    result = await self(_tl.fn.auth.SignUp(
         phone_number=phone,
         phone_code_hash=phone_code_hash,
         first_name=first_name,
@@ -290,7 +289,7 @@ async def sign_up(
 
     if self._tos:
         await self(
-            functions.help.AcceptTermsOfServiceRequest(self._tos.id))
+            _tl.fn.help.AcceptTermsOfService(self._tos.id))
 
     return self._on_login(result.user)
 
@@ -309,20 +308,20 @@ async def send_code_request(
         self: 'TelegramClient',
         phone: str,
         *,
-        force_sms: bool = False) -> 'types.auth.SentCode':
+        force_sms: bool = False) -> '_tl.auth.SentCode':
     result = None
     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, types.CodeSettings()))
+            result = await self(_tl.fn.auth.SendCode(
+                phone, self.api_id, self.api_hash, _tl.CodeSettings()))
         except errors.AuthRestartError:
             return await self.send_code_request(phone, force_sms=force_sms)
 
         # If we already sent a SMS, do not resend the code (hash may be empty)
-        if isinstance(result.type, types.auth.SentCodeTypeSms):
+        if isinstance(result.type, _tl.auth.SentCodeTypeSms):
             force_sms = False
 
         # phone_code_hash may be empty, if it is, do not save it (#1283)
@@ -335,7 +334,7 @@ async def send_code_request(
 
     if force_sms:
         result = await self(
-            functions.auth.ResendCodeRequest(phone, phone_hash))
+            _tl.fn.auth.ResendCode(phone, phone_hash))
 
         self._phone_code_hash[phone] = result.phone_code_hash
 
@@ -348,7 +347,7 @@ async def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None)
 
 async def log_out(self: 'TelegramClient') -> bool:
     try:
-        await self(functions.auth.LogOutRequest())
+        await self(_tl.fn.auth.LogOut())
     except errors.RPCError:
         return False
 
@@ -375,16 +374,16 @@ async def edit_2fa(
     if email and not callable(email_code_callback):
         raise ValueError('email present without email_code_callback')
 
-    pwd = await self(functions.account.GetPasswordRequest())
+    pwd = await self(_tl.fn.account.GetPassword())
     pwd.new_algo.salt1 += os.urandom(32)
-    assert isinstance(pwd, types.account.Password)
+    assert isinstance(pwd, _tl.account.Password)
     if not pwd.has_password and current_password:
         current_password = None
 
     if current_password:
         password = pwd_mod.compute_check(pwd, current_password)
     else:
-        password = types.InputCheckPasswordEmpty()
+        password = _tl.InputCheckPasswordEmpty()
 
     if new_password:
         new_password_hash = pwd_mod.compute_digest(
@@ -393,9 +392,9 @@ async def edit_2fa(
         new_password_hash = b''
 
     try:
-        await self(functions.account.UpdatePasswordSettingsRequest(
+        await self(_tl.fn.account.UpdatePasswordSettings(
             password=password,
-            new_settings=types.account.PasswordInputSettings(
+            new_settings=_tl.account.PasswordInputSettings(
                 new_algo=pwd.new_algo,
                 new_password_hash=new_password_hash,
                 hint=hint,
@@ -409,6 +408,6 @@ async def edit_2fa(
             code = await code
 
         code = str(code)
-        await self(functions.account.ConfirmPasswordEmailRequest(code))
+        await self(_tl.fn.account.ConfirmPasswordEmail(code))
 
     return True
diff --git a/telethon/_client/bots.py b/telethon/_client/bots.py
index 0912fc20..0e967ed9 100644
--- a/telethon/_client/bots.py
+++ b/telethon/_client/bots.py
@@ -1,7 +1,6 @@
 import typing
 
-from .. import hints
-from ..tl import types, functions, custom
+from .. import hints, _tl
 
 if typing.TYPE_CHECKING:
     from .telegramclient import TelegramClient
@@ -14,14 +13,14 @@ async def inline_query(
         *,
         entity: 'hints.EntityLike' = None,
         offset: str = None,
-        geo_point: 'types.GeoPoint' = None) -> custom.InlineResults:
+        geo_point: '_tl.GeoPoint' = None) -> _tl.custom.InlineResults:
     bot = await self.get_input_entity(bot)
     if entity:
         peer = await self.get_input_entity(entity)
     else:
-        peer = types.InputPeerEmpty()
+        peer = _tl.InputPeerEmpty()
 
-    result = await self(functions.messages.GetInlineBotResultsRequest(
+    result = await self(_tl.fn.messages.GetInlineBotResults(
         bot=bot,
         peer=peer,
         query=query,
@@ -29,4 +28,4 @@ async def inline_query(
         geo_point=geo_point
     ))
 
-    return custom.InlineResults(self, result, entity=peer if entity else None)
+    return _tl.custom.InlineResults(self, result, entity=peer if entity else None)
diff --git a/telethon/_client/buttons.py b/telethon/_client/buttons.py
index 41413708..5dd9c413 100644
--- a/telethon/_client/buttons.py
+++ b/telethon/_client/buttons.py
@@ -1,12 +1,11 @@
 import typing
 
-from .. import utils, hints
-from ..tl import types, custom
+from .. import utils, hints, _tl
 
 
 def build_reply_markup(
         buttons: 'typing.Optional[hints.MarkupLike]',
-        inline_only: bool = False) -> 'typing.Optional[types.TypeReplyMarkup]':
+        inline_only: bool = False) -> 'typing.Optional[_tl.TypeReplyMarkup]':
     if buttons is None:
         return None
 
@@ -31,7 +30,7 @@ def build_reply_markup(
     for row in buttons:
         current = []
         for button in row:
-            if isinstance(button, custom.Button):
+            if isinstance(button, _tl.custom.Button):
                 if button.resize is not None:
                     resize = button.resize
                 if button.single_use is not None:
@@ -40,10 +39,10 @@ def build_reply_markup(
                     selective = button.selective
 
                 button = button.button
-            elif isinstance(button, custom.MessageButton):
+            elif isinstance(button, _tl.custom.MessageButton):
                 button = button.button
 
-            inline = custom.Button._is_inline(button)
+            inline = _tl.custom.Button._is_inline(button)
             is_inline |= inline
             is_normal |= not inline
 
@@ -52,14 +51,14 @@ def build_reply_markup(
                 current.append(button)
 
         if current:
-            rows.append(types.KeyboardButtonRow(current))
+            rows.append(_tl.KeyboardButtonRow(current))
 
     if inline_only and is_normal:
         raise ValueError('You cannot use non-inline buttons here')
     elif is_inline == is_normal and is_normal:
         raise ValueError('You cannot mix inline with normal buttons')
     elif is_inline:
-        return types.ReplyInlineMarkup(rows)
+        return _tl.ReplyInlineMarkup(rows)
     # elif is_normal:
-    return types.ReplyKeyboardMarkup(
+    return _tl.ReplyKeyboardMarkup(
         rows, resize=resize, single_use=single_use, selective=selective)
diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py
index 4147b45b..904bba10 100644
--- a/telethon/_client/chats.py
+++ b/telethon/_client/chats.py
@@ -4,9 +4,8 @@ import itertools
 import string
 import typing
 
-from .. import helpers, utils, hints, errors
+from .. import helpers, utils, hints, errors, _tl
 from ..requestiter import RequestIter
-from ..tl import types, functions, custom
 
 if typing.TYPE_CHECKING:
     from .telegramclient import TelegramClient
@@ -18,28 +17,28 @@ _MAX_PROFILE_PHOTO_CHUNK_SIZE = 100
 
 class _ChatAction:
     _str_mapping = {
-        'typing': types.SendMessageTypingAction(),
-        'contact': types.SendMessageChooseContactAction(),
-        'game': types.SendMessageGamePlayAction(),
-        'location': types.SendMessageGeoLocationAction(),
-        'sticker': types.SendMessageChooseStickerAction(),
+        'typing': _tl.SendMessageTypingAction(),
+        'contact': _tl.SendMessageChooseContactAction(),
+        'game': _tl.SendMessageGamePlayAction(),
+        'location': _tl.SendMessageGeoLocationAction(),
+        'sticker': _tl.SendMessageChooseStickerAction(),
 
-        'record-audio': types.SendMessageRecordAudioAction(),
-        'record-voice': types.SendMessageRecordAudioAction(),  # alias
-        'record-round': types.SendMessageRecordRoundAction(),
-        'record-video': types.SendMessageRecordVideoAction(),
+        'record-audio': _tl.SendMessageRecordAudioAction(),
+        'record-voice': _tl.SendMessageRecordAudioAction(),  # alias
+        'record-round': _tl.SendMessageRecordRoundAction(),
+        'record-video': _tl.SendMessageRecordVideoAction(),
 
-        'audio': types.SendMessageUploadAudioAction(1),
-        'voice': types.SendMessageUploadAudioAction(1),  # alias
-        'song': types.SendMessageUploadAudioAction(1),  # alias
-        'round': types.SendMessageUploadRoundAction(1),
-        'video': types.SendMessageUploadVideoAction(1),
+        'audio': _tl.SendMessageUploadAudioAction(1),
+        'voice': _tl.SendMessageUploadAudioAction(1),  # alias
+        'song': _tl.SendMessageUploadAudioAction(1),  # alias
+        'round': _tl.SendMessageUploadRoundAction(1),
+        'video': _tl.SendMessageUploadVideoAction(1),
 
-        'photo': types.SendMessageUploadPhotoAction(1),
-        'document': types.SendMessageUploadDocumentAction(1),
-        'file': types.SendMessageUploadDocumentAction(1),  # alias
+        'photo': _tl.SendMessageUploadPhotoAction(1),
+        'document': _tl.SendMessageUploadDocumentAction(1),
+        'file': _tl.SendMessageUploadDocumentAction(1),  # alias
 
-        'cancel': types.SendMessageCancelAction()
+        'cancel': _tl.SendMessageCancelAction()
     }
 
     def __init__(self, client, chat, action, *, delay, auto_cancel):
@@ -58,7 +57,7 @@ class _ChatAction:
         # Since `self._action` is passed by reference we can avoid
         # recreating the request all the time and still modify
         # `self._action.progress` directly in `progress`.
-        self._request = functions.messages.SetTypingRequest(
+        self._request = _tl.fn.messages.SetTyping(
             self._chat, self._action)
 
         self._running = True
@@ -85,8 +84,8 @@ class _ChatAction:
             pass
         except asyncio.CancelledError:
             if self._auto_cancel:
-                await self._client(functions.messages.SetTypingRequest(
-                    self._chat, types.SendMessageCancelAction()))
+                await self._client(_tl.fn.messages.SetTyping(
+                    self._chat, _tl.SendMessageCancelAction()))
 
     def progress(self, current, total):
         if hasattr(self._action, 'progress'):
@@ -96,10 +95,10 @@ class _ChatAction:
 class _ParticipantsIter(RequestIter):
     async def _init(self, entity, filter, search, aggressive):
         if isinstance(filter, type):
-            if filter in (types.ChannelParticipantsBanned,
-                          types.ChannelParticipantsKicked,
-                          types.ChannelParticipantsSearch,
-                          types.ChannelParticipantsContacts):
+            if filter in (_tl.ChannelParticipantsBanned,
+                          _tl.ChannelParticipantsKicked,
+                          _tl.ChannelParticipantsSearch,
+                          _tl.ChannelParticipantsContacts):
                 # These require a `q` parameter (support types for convenience)
                 filter = filter('')
             else:
@@ -125,23 +124,23 @@ class _ParticipantsIter(RequestIter):
             if self.limit <= 0:
                 # May not have access to the channel, but getFull can get the .total.
                 self.total = (await self.client(
-                    functions.channels.GetFullChannelRequest(entity)
+                    _tl.fn.channels.GetFullChannel(entity)
                 )).full_chat.participants_count
                 raise StopAsyncIteration
 
             self.seen = set()
             if aggressive and not filter:
-                self.requests.extend(functions.channels.GetParticipantsRequest(
+                self.requests.extend(_tl.fn.channels.GetParticipants(
                     channel=entity,
-                    filter=types.ChannelParticipantsSearch(x),
+                    filter=_tl.ChannelParticipantsSearch(x),
                     offset=0,
                     limit=_MAX_PARTICIPANTS_CHUNK_SIZE,
                     hash=0
                 ) for x in (search or string.ascii_lowercase))
             else:
-                self.requests.append(functions.channels.GetParticipantsRequest(
+                self.requests.append(_tl.fn.channels.GetParticipants(
                     channel=entity,
-                    filter=filter or types.ChannelParticipantsSearch(search),
+                    filter=filter or _tl.ChannelParticipantsSearch(search),
                     offset=0,
                     limit=_MAX_PARTICIPANTS_CHUNK_SIZE,
                     hash=0
@@ -149,9 +148,9 @@ class _ParticipantsIter(RequestIter):
 
         elif ty == helpers._EntityType.CHAT:
             full = await self.client(
-                functions.messages.GetFullChatRequest(entity.chat_id))
+                _tl.fn.messages.GetFullChat(entity.chat_id))
             if not isinstance(
-                    full.full_chat.participants, types.ChatParticipants):
+                    full.full_chat.participants, _tl.ChatParticipants):
                 # ChatParticipantsForbidden won't have ``.participants``
                 self.total = 0
                 raise StopAsyncIteration
@@ -160,7 +159,7 @@ class _ParticipantsIter(RequestIter):
 
             users = {user.id: user for user in full.users}
             for participant in full.full_chat.participants.participants:
-                if isinstance(participant, types.ChannelParticipantBanned):
+                if isinstance(participant, _tl.ChannelParticipantBanned):
                     user_id = participant.peer.user_id
                 else:
                     user_id = participant.user_id
@@ -202,15 +201,15 @@ class _ParticipantsIter(RequestIter):
         if self.total is None:
             f = self.requests[0].filter
             if len(self.requests) > 1 or (
-                not isinstance(f, types.ChannelParticipantsRecent)
-                and (not isinstance(f, types.ChannelParticipantsSearch) or f.q)
+                not isinstance(f, _tl.ChannelParticipantsRecent)
+                and (not isinstance(f, _tl.ChannelParticipantsSearch) or f.q)
             ):
                 # Only do an additional getParticipants here to get the total
                 # if there's a filter which would reduce the real total number.
                 # getParticipants is cheaper than getFull.
-                self.total = (await self.client(functions.channels.GetParticipantsRequest(
+                self.total = (await self.client(_tl.fn.channels.GetParticipants(
                     channel=self.requests[0].channel,
-                    filter=types.ChannelParticipantsRecent(),
+                    filter=_tl.ChannelParticipantsRecent(),
                     offset=0,
                     limit=1,
                     hash=0
@@ -230,8 +229,8 @@ class _ParticipantsIter(RequestIter):
             users = {user.id: user for user in participants.users}
             for participant in participants.participants:
 
-                if isinstance(participant, types.ChannelParticipantBanned):
-                    if not isinstance(participant.peer, types.PeerUser):
+                if isinstance(participant, _tl.ChannelParticipantBanned):
+                    if not isinstance(participant.peer, _tl.PeerUser):
                         # May have the entire channel banned. See #3105.
                         continue
                     user_id = participant.peer.user_id
@@ -257,7 +256,7 @@ class _AdminLogIter(RequestIter):
         if any((join, leave, invite, restrict, unrestrict, ban, unban,
                 promote, demote, info, settings, pinned, edit, delete,
                 group_call)):
-            events_filter = types.ChannelAdminLogEventsFilter(
+            events_filter = _tl.ChannelAdminLogEventsFilter(
                 join=join, leave=leave, invite=invite, ban=restrict,
                 unban=unrestrict, kick=ban, unkick=unban, promote=promote,
                 demote=demote, info=info, settings=settings, pinned=pinned,
@@ -276,7 +275,7 @@ class _AdminLogIter(RequestIter):
             for admin in admins:
                 admin_list.append(await self.client.get_input_entity(admin))
 
-        self.request = functions.channels.GetAdminLogRequest(
+        self.request = _tl.fn.channels.GetAdminLog(
             self.entity, q=search or '', min_id=min_id, max_id=max_id,
             limit=0, events_filter=events_filter, admins=admin_list or None
         )
@@ -290,7 +289,7 @@ class _AdminLogIter(RequestIter):
         self.request.max_id = min((e.id for e in r.events), default=0)
         for ev in r.events:
             if isinstance(ev.action,
-                          types.ChannelAdminLogEventActionEditMessage):
+                          _tl.ChannelAdminLogEventActionEditMessage):
                 ev.action.prev_message._finish_init(
                     self.client, entities, self.entity)
 
@@ -298,11 +297,11 @@ class _AdminLogIter(RequestIter):
                     self.client, entities, self.entity)
 
             elif isinstance(ev.action,
-                            types.ChannelAdminLogEventActionDeleteMessage):
+                            _tl.ChannelAdminLogEventActionDeleteMessage):
                 ev.action.message._finish_init(
                     self.client, entities, self.entity)
 
-            self.buffer.append(custom.AdminLogEvent(ev, entities))
+            self.buffer.append(_tl.custom.AdminLogEvent(ev, entities))
 
         if len(r.events) < self.request.limit:
             return True
@@ -315,17 +314,17 @@ class _ProfilePhotoIter(RequestIter):
         entity = await self.client.get_input_entity(entity)
         ty = helpers._entity_type(entity)
         if ty == helpers._EntityType.USER:
-            self.request = functions.photos.GetUserPhotosRequest(
+            self.request = _tl.fn.photos.GetUserPhotos(
                 entity,
                 offset=offset,
                 max_id=max_id,
                 limit=1
             )
         else:
-            self.request = functions.messages.SearchRequest(
+            self.request = _tl.fn.messages.Search(
                 peer=entity,
                 q='',
-                filter=types.InputMessagesFilterChatPhotos(),
+                filter=_tl.InputMessagesFilterChatPhotos(),
                 min_date=None,
                 max_date=None,
                 offset_id=0,
@@ -339,9 +338,9 @@ class _ProfilePhotoIter(RequestIter):
         if self.limit == 0:
             self.request.limit = 1
             result = await self.client(self.request)
-            if isinstance(result, types.photos.Photos):
+            if isinstance(result, _tl.photos.Photos):
                 self.total = len(result.photos)
-            elif isinstance(result, types.messages.Messages):
+            elif isinstance(result, _tl.messages.Messages):
                 self.total = len(result.messages)
             else:
                 # Luckily both photosSlice and messages have a count for total
@@ -351,17 +350,17 @@ class _ProfilePhotoIter(RequestIter):
         self.request.limit = min(self.left, _MAX_PROFILE_PHOTO_CHUNK_SIZE)
         result = await self.client(self.request)
 
-        if isinstance(result, types.photos.Photos):
+        if isinstance(result, _tl.photos.Photos):
             self.buffer = result.photos
             self.left = len(self.buffer)
             self.total = len(self.buffer)
-        elif isinstance(result, types.messages.Messages):
+        elif isinstance(result, _tl.messages.Messages):
             self.buffer = [x.action.photo for x in result.messages
-                           if isinstance(x.action, types.MessageActionChatEditPhoto)]
+                           if isinstance(x.action, _tl.MessageActionChatEditPhoto)]
 
             self.left = len(self.buffer)
             self.total = len(self.buffer)
-        elif isinstance(result, types.photos.PhotosSlice):
+        elif isinstance(result, _tl.photos.PhotosSlice):
             self.buffer = result.photos
             self.total = result.count
             if len(self.buffer) < self.request.limit:
@@ -381,16 +380,16 @@ class _ProfilePhotoIter(RequestIter):
             # Unconditionally fetch the full channel to obtain this photo and
             # yield it with the rest (unless it's a duplicate).
             seen_id = None
-            if isinstance(result, types.messages.ChannelMessages):
-                channel = await self.client(functions.channels.GetFullChannelRequest(self.request.peer))
+            if isinstance(result, _tl.messages.ChannelMessages):
+                channel = await self.client(_tl.fn.channels.GetFullChannel(self.request.peer))
                 photo = channel.full_chat.chat_photo
-                if isinstance(photo, types.Photo):
+                if isinstance(photo, _tl.Photo):
                     self.buffer.append(photo)
                     seen_id = photo.id
 
             self.buffer.extend(
                 x.action.photo for x in result.messages
-                if isinstance(x.action, types.MessageActionChatEditPhoto)
+                if isinstance(x.action, _tl.MessageActionChatEditPhoto)
                 and x.action.photo.id != seen_id
             )
 
@@ -407,7 +406,7 @@ def iter_participants(
         limit: float = None,
         *,
         search: str = '',
-        filter: 'types.TypeChannelParticipantsFilter' = None,
+        filter: '_tl.TypeChannelParticipantsFilter' = None,
         aggressive: bool = False) -> _ParticipantsIter:
     return _ParticipantsIter(
         self,
@@ -506,7 +505,7 @@ async def get_profile_photos(
 def action(
         self: 'TelegramClient',
         entity: 'hints.EntityLike',
-        action: 'typing.Union[str, types.TypeSendMessageAction]',
+        action: 'typing.Union[str, _tl.TypeSendMessageAction]',
         *,
         delay: float = 4,
         auto_cancel: bool = True) -> 'typing.Union[_ChatAction, typing.Coroutine]':
@@ -516,17 +515,17 @@ def action(
         except KeyError:
             raise ValueError(
                 'No such action "{}"'.format(action)) from None
-    elif not isinstance(action, types.TLObject) or action.SUBCLASS_OF_ID != 0x20b2cc21:
+    elif not isinstance(action, _tl.TLObject) or action.SUBCLASS_OF_ID != 0x20b2cc21:
         # 0x20b2cc21 = crc32(b'SendMessageAction')
         if isinstance(action, type):
             raise ValueError('You must pass an instance, not the class')
         else:
             raise ValueError('Cannot use {} as action'.format(action))
 
-    if isinstance(action, types.SendMessageCancelAction):
+    if isinstance(action, _tl.SendMessageCancelAction):
         # ``SetTypingRequest.resolve`` will get input peer of ``entity``.
-        return self(functions.messages.SetTypingRequest(
-            entity, types.SendMessageCancelAction()))
+        return self(_tl.fn.messages.SetTyping(
+            entity, _tl.SendMessageCancelAction()))
 
     return _ChatAction(
         self, entity, action, delay=delay, auto_cancel=auto_cancel)
@@ -547,7 +546,7 @@ async def edit_admin(
         manage_call: bool = None,
         anonymous: bool = None,
         is_admin: bool = None,
-        title: str = None) -> types.Updates:
+        title: str = None) -> _tl.Updates:
     entity = await self.get_input_entity(entity)
     user = await self.get_input_entity(user)
     ty = helpers._entity_type(user)
@@ -576,7 +575,7 @@ async def edit_admin(
                 edit_messages = None
 
         perms = locals()
-        return await self(functions.channels.EditAdminRequest(entity, user, types.ChatAdminRights(**{
+        return await self(_tl.fn.channels.EditAdmin(entity, user, _tl.ChatAdminRights(**{
             # A permission is its explicit (not-None) value or `is_admin`.
             # This essentially makes `is_admin` be the default value.
             name: perms[name] if perms[name] is not None else is_admin
@@ -589,7 +588,7 @@ async def edit_admin(
         if is_admin is None:
             is_admin = any(locals()[x] for x in perm_names)
 
-        return await self(functions.messages.EditChatAdminRequest(
+        return await self(_tl.fn.messages.EditChatAdmin(
             entity, user, is_admin=is_admin))
 
     else:
@@ -613,13 +612,13 @@ async def edit_permissions(
         send_polls: bool = True,
         change_info: bool = True,
         invite_users: bool = True,
-        pin_messages: bool = True) -> types.Updates:
+        pin_messages: bool = True) -> _tl.Updates:
     entity = await self.get_input_entity(entity)
     ty = helpers._entity_type(entity)
     if ty != helpers._EntityType.CHANNEL:
         raise ValueError('You must pass either a channel or a supergroup')
 
-    rights = types.ChatBannedRights(
+    rights = _tl.ChatBannedRights(
         until_date=until_date,
         view_messages=not view_messages,
         send_messages=not send_messages,
@@ -636,7 +635,7 @@ async def edit_permissions(
     )
 
     if user is None:
-        return await self(functions.messages.EditChatDefaultBannedRightsRequest(
+        return await self(_tl.fn.messages.EditChatDefaultBannedRights(
             peer=entity,
             banned_rights=rights
         ))
@@ -646,10 +645,10 @@ async def edit_permissions(
     if ty != helpers._EntityType.USER:
         raise ValueError('You must pass a user entity')
 
-    if isinstance(user, types.InputPeerSelf):
+    if isinstance(user, _tl.InputPeerSelf):
         raise ValueError('You cannot restrict yourself')
 
-    return await self(functions.channels.EditBannedRequest(
+    return await self(_tl.fn.channels.EditBanned(
         channel=entity,
         participant=user,
         banned_rights=rights
@@ -667,24 +666,24 @@ async def kick_participant(
 
     ty = helpers._entity_type(entity)
     if ty == helpers._EntityType.CHAT:
-        resp = await self(functions.messages.DeleteChatUserRequest(entity.chat_id, user))
+        resp = await self(_tl.fn.messages.DeleteChatUser(entity.chat_id, user))
     elif ty == helpers._EntityType.CHANNEL:
-        if isinstance(user, types.InputPeerSelf):
+        if isinstance(user, _tl.InputPeerSelf):
             # Despite no longer being in the channel, the account still
             # seems to get the service message.
-            resp = await self(functions.channels.LeaveChannelRequest(entity))
+            resp = await self(_tl.fn.channels.LeaveChannel(entity))
         else:
-            resp = await self(functions.channels.EditBannedRequest(
+            resp = await self(_tl.fn.channels.EditBanned(
                 channel=entity,
                 participant=user,
-                banned_rights=types.ChatBannedRights(
+                banned_rights=_tl.ChatBannedRights(
                     until_date=None, view_messages=True)
             ))
             await asyncio.sleep(0.5)
-            await self(functions.channels.EditBannedRequest(
+            await self(_tl.fn.channels.EditBanned(
                 channel=entity,
                 participant=user,
-                banned_rights=types.ChatBannedRights(until_date=None)
+                banned_rights=_tl.ChatBannedRights(until_date=None)
             ))
     else:
         raise ValueError('You must pass either a channel or a chat')
@@ -695,14 +694,14 @@ async def get_permissions(
         self: 'TelegramClient',
         entity: 'hints.EntityLike',
         user: 'hints.EntityLike' = None
-) -> 'typing.Optional[custom.ParticipantPermissions]':
+) -> 'typing.Optional[_tl.custom.ParticipantPermissions]':
     entity = await self.get_entity(entity)
 
     if not user:
-        if isinstance(entity, types.Channel):
-            FullChat = await self(functions.channels.GetFullChannelRequest(entity))
-        elif isinstance(entity, types.Chat):
-            FullChat = await self(functions.messages.GetFullChatRequest(entity))
+        if isinstance(entity, _tl.Channel):
+            FullChat = await self(_tl.fn.channels.GetFullChannel(entity))
+        elif isinstance(entity, _tl.Chat):
+            FullChat = await self(_tl.fn.messages.GetFullChat(entity))
         else:
             return
         return FullChat.chats[0].default_banned_rights
@@ -712,20 +711,20 @@ async def get_permissions(
     if helpers._entity_type(user) != helpers._EntityType.USER:
         raise ValueError('You must pass a user entity')
     if helpers._entity_type(entity) == helpers._EntityType.CHANNEL:
-        participant = await self(functions.channels.GetParticipantRequest(
+        participant = await self(_tl.fn.channels.GetParticipant(
             entity,
             user
         ))
-        return custom.ParticipantPermissions(participant.participant, False)
+        return _tl.custom.ParticipantPermissions(participant.participant, False)
     elif helpers._entity_type(entity) == helpers._EntityType.CHAT:
-        chat = await self(functions.messages.GetFullChatRequest(
+        chat = await self(_tl.fn.messages.GetFullChat(
             entity
         ))
-        if isinstance(user, types.InputPeerSelf):
+        if isinstance(user, _tl.InputPeerSelf):
             user = await self.get_me(input_peer=True)
         for participant in chat.full_chat.participants.participants:
             if participant.user_id == user.user_id:
-                return custom.ParticipantPermissions(participant, True)
+                return _tl.custom.ParticipantPermissions(participant, True)
         raise errors.UserNotParticipantError(None)
 
     raise ValueError('You must pass either a channel or a chat')
@@ -733,7 +732,7 @@ async def get_permissions(
 async def get_stats(
         self: 'TelegramClient',
         entity: 'hints.EntityLike',
-        message: 'typing.Union[int, types.Message]' = None,
+        message: 'typing.Union[int, _tl.Message]' = None,
 ):
     entity = await self.get_input_entity(entity)
     if helpers._entity_type(entity) != helpers._EntityType.CHANNEL:
@@ -742,7 +741,7 @@ async def get_stats(
     message = utils.get_message_id(message)
     if message is not None:
         try:
-            req = functions.stats.GetMessageStatsRequest(entity, message)
+            req = _tl.fn.stats.GetMessageStats(entity, message)
             return await self(req)
         except errors.StatsMigrateError as e:
             dc = e.dc
@@ -751,12 +750,12 @@ async def get_stats(
         # try to guess and if it fails we know it's the other one (best case
         # no extra request, worst just one).
         try:
-            req = functions.stats.GetBroadcastStatsRequest(entity)
+            req = _tl.fn.stats.GetBroadcastStats(entity)
             return await self(req)
         except errors.StatsMigrateError as e:
             dc = e.dc
         except errors.BroadcastRequiredError:
-            req = functions.stats.GetMegagroupStatsRequest(entity)
+            req = _tl.fn.stats.GetMegagroupStats(entity)
             try:
                 return await self(req)
             except errors.StatsMigrateError as e:
diff --git a/telethon/_client/dialogs.py b/telethon/_client/dialogs.py
index 8471c7fb..aee8861d 100644
--- a/telethon/_client/dialogs.py
+++ b/telethon/_client/dialogs.py
@@ -3,9 +3,8 @@ import inspect
 import itertools
 import typing
 
-from .. import helpers, utils, hints, errors
+from .. import helpers, utils, hints, errors, _tl
 from ..requestiter import RequestIter
-from ..tl import types, functions, custom
 
 _MAX_CHUNK_SIZE = 100
 
@@ -21,14 +20,14 @@ def _dialog_message_key(peer, message_id):
     and the peer ID is required to distinguish between them. But it is not
     necessary in small group chats and private chats.
     """
-    return (peer.channel_id if isinstance(peer, types.PeerChannel) else None), message_id
+    return (peer.channel_id if isinstance(peer, _tl.PeerChannel) else None), message_id
 
 
 class _DialogsIter(RequestIter):
     async def _init(
             self, offset_date, offset_id, offset_peer, ignore_pinned, ignore_migrated, folder
     ):
-        self.request = functions.messages.GetDialogsRequest(
+        self.request = _tl.fn.messages.GetDialogs(
             offset_date=offset_date,
             offset_id=offset_id,
             offset_peer=offset_peer,
@@ -56,7 +55,7 @@ class _DialogsIter(RequestIter):
 
         entities = {utils.get_peer_id(x): x
                     for x in itertools.chain(r.users, r.chats)
-                    if not isinstance(x, (types.UserEmpty, types.ChatEmpty))}
+                    if not isinstance(x, (_tl.UserEmpty, _tl.ChatEmpty))}
 
         messages = {}
         for m in r.messages:
@@ -80,7 +79,7 @@ class _DialogsIter(RequestIter):
                     # Real world example: https://t.me/TelethonChat/271471
                     continue
 
-                cd = custom.Dialog(self.client, d, entities, message)
+                cd = _tl.custom.Dialog(self.client, d, entities, message)
                 if cd.dialog.pts:
                     self.client._channel_pts[cd.id] = cd.dialog.pts
 
@@ -89,7 +88,7 @@ class _DialogsIter(RequestIter):
                     self.buffer.append(cd)
 
         if len(r.dialogs) < self.request.limit\
-                or not isinstance(r, types.messages.DialogsSlice):
+                or not isinstance(r, _tl.messages.DialogsSlice):
             # Less than we requested means we reached the end, or
             # we didn't get a DialogsSlice which means we got all.
             return True
@@ -112,15 +111,15 @@ class _DialogsIter(RequestIter):
 class _DraftsIter(RequestIter):
     async def _init(self, entities, **kwargs):
         if not entities:
-            r = await self.client(functions.messages.GetAllDraftsRequest())
+            r = await self.client(_tl.fn.messages.GetAllDrafts())
             items = r.updates
         else:
             peers = []
             for entity in entities:
-                peers.append(types.InputDialogPeer(
+                peers.append(_tl.InputDialogPeer(
                     await self.client.get_input_entity(entity)))
 
-            r = await self.client(functions.messages.GetPeerDialogsRequest(peers))
+            r = await self.client(_tl.fn.messages.GetPeerDialogs(peers))
             items = r.dialogs
 
         # TODO Maybe there should be a helper method for this?
@@ -128,7 +127,7 @@ class _DraftsIter(RequestIter):
                     for x in itertools.chain(r.users, r.chats)}
 
         self.buffer.extend(
-            custom.Draft(self.client, entities[utils.get_peer_id(d.peer)], d.draft)
+            _tl.custom.Draft(self.client, entities[utils.get_peer_id(d.peer)], d.draft)
             for d in items
         )
 
@@ -142,7 +141,7 @@ def iter_dialogs(
         *,
         offset_date: 'hints.DateLike' = None,
         offset_id: int = 0,
-        offset_peer: 'hints.EntityLike' = types.InputPeerEmpty(),
+        offset_peer: 'hints.EntityLike' = _tl.InputPeerEmpty(),
         ignore_pinned: bool = False,
         ignore_migrated: bool = False,
         folder: int = None,
@@ -192,12 +191,12 @@ async def edit_folder(
         folder: typing.Union[int, typing.Sequence[int]] = None,
         *,
         unpack=None
-) -> types.Updates:
+) -> _tl.Updates:
     if (entity is None) == (unpack is None):
         raise ValueError('You can only set either entities or unpack, not both')
 
     if unpack is not None:
-        return await self(functions.folders.DeleteFolderRequest(
+        return await self(_tl.fn.folders.DeleteFolder(
             folder_id=unpack
         ))
 
@@ -214,8 +213,8 @@ async def edit_folder(
     elif len(entities) != len(folder):
         raise ValueError('Number of folders does not match number of entities')
 
-    return await self(functions.folders.EditPeerFoldersRequest([
-        types.InputFolderPeer(x, folder_id=y)
+    return await self(_tl.fn.folders.EditPeerFolders([
+        _tl.InputFolderPeer(x, folder_id=y)
         for x, y in zip(entities, folder)
     ]))
 
@@ -227,7 +226,7 @@ async def delete_dialog(
 ):
     # If we have enough information (`Dialog.delete` gives it to us),
     # then we know we don't have to kick ourselves in deactivated chats.
-    if isinstance(entity, types.Chat):
+    if isinstance(entity, _tl.Chat):
         deactivated = entity.deactivated
     else:
         deactivated = False
@@ -235,12 +234,12 @@ async def delete_dialog(
     entity = await self.get_input_entity(entity)
     ty = helpers._entity_type(entity)
     if ty == helpers._EntityType.CHANNEL:
-        return await self(functions.channels.LeaveChannelRequest(entity))
+        return await self(_tl.fn.channels.LeaveChannel(entity))
 
     if ty == helpers._EntityType.CHAT and not deactivated:
         try:
-            result = await self(functions.messages.DeleteChatUserRequest(
-                entity.chat_id, types.InputUserSelf(), revoke_history=revoke
+            result = await self(_tl.fn.messages.DeleteChatUser(
+                entity.chat_id, _tl.InputUserSelf(), revoke_history=revoke
             ))
         except errors.PeerIdInvalidError:
             # Happens if we didn't have the deactivated information
@@ -249,6 +248,6 @@ async def delete_dialog(
         result = None
 
     if not await self.is_bot():
-        await self(functions.messages.DeleteHistoryRequest(entity, 0, revoke=revoke))
+        await self(_tl.fn.messages.DeleteHistory(entity, 0, revoke=revoke))
 
     return result
diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py
index aa8ed59a..974db2a0 100644
--- a/telethon/_client/downloads.py
+++ b/telethon/_client/downloads.py
@@ -8,9 +8,8 @@ import asyncio
 
 from ..crypto import AES
 
-from .. import utils, helpers, errors, hints
+from .. import utils, helpers, errors, hints, _tl
 from ..requestiter import RequestIter
-from ..tl import TLObject, types, functions
 
 try:
     import aiohttp
@@ -31,7 +30,7 @@ class _DirectDownloadIter(RequestIter):
     async def _init(
             self, file, dc_id, offset, stride, chunk_size, request_size, file_size, msg_data
     ):
-        self.request = functions.upload.GetFileRequest(
+        self.request = _tl.fn.upload.GetFile(
             file, offset=offset, limit=request_size)
 
         self.total = file_size
@@ -50,7 +49,7 @@ class _DirectDownloadIter(RequestIter):
                 self._sender = await self.client._borrow_exported_sender(dc_id)
             except errors.DcIdInvalidError:
                 # Can't export a sender for the ID we are currently in
-                config = await self.client(functions.help.GetConfigRequest())
+                config = await self.client(_tl.fn.help.GetConfig())
                 for option in config.dc_options:
                     if option.ip_address == self.client.session.server_address:
                         self.client.session.set_dc(
@@ -75,7 +74,7 @@ class _DirectDownloadIter(RequestIter):
         try:
             result = await self.client._call(self._sender, self.request)
             self._timed_out = False
-            if isinstance(result, types.upload.FileCdnRedirect):
+            if isinstance(result, _tl.upload.FileCdnRedirect):
                 raise NotImplementedError  # TODO Implement
             else:
                 return result.bytes
@@ -99,7 +98,7 @@ class _DirectDownloadIter(RequestIter):
         except errors.FilerefUpgradeNeededError as e:
             # Only implemented for documents which are the ones that may take that long to download
             if not self._msg_data \
-                    or not isinstance(self.request.location, types.InputDocumentFileLocation) \
+                    or not isinstance(self.request.location, _tl.InputDocumentFileLocation) \
                     or self.request.location.thumb_size != '':
                 raise
 
@@ -107,7 +106,7 @@ class _DirectDownloadIter(RequestIter):
             chat, msg_id = self._msg_data
             msg = await self.client.get_messages(chat, ids=msg_id)
 
-            if not isinstance(msg.media, types.MessageMediaDocument):
+            if not isinstance(msg.media, _tl.MessageMediaDocument):
                 raise
 
             document = msg.media.document
@@ -200,7 +199,7 @@ async def download_profile_photo(
     ENTITIES = (0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697)
     # ('InputPeer', 'InputUser', 'InputChannel')
     INPUTS = (0xc91c90b6, 0xe669bf46, 0x40f202fd)
-    if not isinstance(entity, TLObject) or entity.SUBCLASS_OF_ID in INPUTS:
+    if not isinstance(entity, _tl.TLObject) or entity.SUBCLASS_OF_ID in INPUTS:
         entity = await self.get_entity(entity)
 
     thumb = -1 if download_big else 0
@@ -225,9 +224,9 @@ async def download_profile_photo(
 
         photo = entity.photo
 
-    if isinstance(photo, (types.UserProfilePhoto, types.ChatPhoto)):
+    if isinstance(photo, (_tl.UserProfilePhoto, _tl.ChatPhoto)):
         dc_id = photo.dc_id
-        loc = types.InputPeerPhotoFileLocation(
+        loc = _tl.InputPeerPhotoFileLocation(
             peer=await self.get_input_entity(entity),
             photo_id=photo.photo_id,
             big=download_big
@@ -253,7 +252,7 @@ async def download_profile_photo(
         ie = await self.get_input_entity(entity)
         ty = helpers._entity_type(ie)
         if ty == helpers._EntityType.CHANNEL:
-            full = await self(functions.channels.GetFullChannelRequest(ie))
+            full = await self(_tl.fn.channels.GetFullChannel(ie))
             return await self._download_photo(
                 full.full_chat.chat_photo, file,
                 date=None, progress_callback=None,
@@ -268,7 +267,7 @@ async def download_media(
         message: 'hints.MessageLike',
         file: 'hints.FileLike' = None,
         *,
-        thumb: 'typing.Union[int, types.TypePhotoSize]' = None,
+        thumb: 'typing.Union[int, _tl.TypePhotoSize]' = None,
         progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]:
     # Downloading large documents may be slow enough to require a new file reference
     # to be obtained mid-download. Store (input chat, message id) so that the message
@@ -276,7 +275,7 @@ async def download_media(
     msg_data = None
 
     # TODO This won't work for messageService
-    if isinstance(message, types.Message):
+    if isinstance(message, _tl.Message):
         date = message.date
         media = message.media
         msg_data = (message.input_chat, message.id) if message.input_chat else None
@@ -287,28 +286,28 @@ async def download_media(
     if isinstance(media, str):
         media = utils.resolve_bot_file_id(media)
 
-    if isinstance(media, types.MessageService):
+    if isinstance(media, _tl.MessageService):
         if isinstance(message.action,
-                        types.MessageActionChatEditPhoto):
+                        _tl.MessageActionChatEditPhoto):
             media = media.photo
 
-    if isinstance(media, types.MessageMediaWebPage):
-        if isinstance(media.webpage, types.WebPage):
+    if isinstance(media, _tl.MessageMediaWebPage):
+        if isinstance(media.webpage, _tl.WebPage):
             media = media.webpage.document or media.webpage.photo
 
-    if isinstance(media, (types.MessageMediaPhoto, types.Photo)):
+    if isinstance(media, (_tl.MessageMediaPhoto, _tl.Photo)):
         return await self._download_photo(
             media, file, date, thumb, progress_callback
         )
-    elif isinstance(media, (types.MessageMediaDocument, types.Document)):
+    elif isinstance(media, (_tl.MessageMediaDocument, _tl.Document)):
         return await self._download_document(
             media, file, date, thumb, progress_callback, msg_data
         )
-    elif isinstance(media, types.MessageMediaContact) and thumb is None:
+    elif isinstance(media, _tl.MessageMediaContact) and thumb is None:
         return self._download_contact(
             media, file
         )
-    elif isinstance(media, (types.WebDocument, types.WebDocumentNoProxy)) and thumb is None:
+    elif isinstance(media, (_tl.WebDocument, _tl.WebDocumentNoProxy)) and thumb is None:
         return await self._download_web_document(
             media, file, progress_callback
         )
@@ -488,15 +487,15 @@ def _get_thumb(thumbs, thumb):
     # last while this is the smallest (layer 116). Ensure we have the
     # sizes sorted correctly with a custom function.
     def sort_thumbs(thumb):
-        if isinstance(thumb, types.PhotoStrippedSize):
+        if isinstance(thumb, _tl.PhotoStrippedSize):
             return 1, len(thumb.bytes)
-        if isinstance(thumb, types.PhotoCachedSize):
+        if isinstance(thumb, _tl.PhotoCachedSize):
             return 1, len(thumb.bytes)
-        if isinstance(thumb, types.PhotoSize):
+        if isinstance(thumb, _tl.PhotoSize):
             return 1, thumb.size
-        if isinstance(thumb, types.PhotoSizeProgressive):
+        if isinstance(thumb, _tl.PhotoSizeProgressive):
             return 1, max(thumb.sizes)
-        if isinstance(thumb, types.VideoSize):
+        if isinstance(thumb, _tl.VideoSize):
             return 2, thumb.size
 
         # Empty size or invalid should go last
@@ -508,7 +507,7 @@ def _get_thumb(thumbs, thumb):
         # :tl:`PhotoPathSize` is used for animated stickers preview, and the thumb is actually
         # a SVG path of the outline. Users expect thumbnails to be JPEG files, so pretend this
         # thumb size doesn't actually exist (#1655).
-        if isinstance(thumbs[i], types.PhotoPathSize):
+        if isinstance(thumbs[i], _tl.PhotoPathSize):
             thumbs.pop(i)
 
     if thumb is None:
@@ -517,15 +516,15 @@ def _get_thumb(thumbs, thumb):
         return thumbs[thumb]
     elif isinstance(thumb, str):
         return next((t for t in thumbs if t.type == thumb), None)
-    elif isinstance(thumb, (types.PhotoSize, types.PhotoCachedSize,
-                            types.PhotoStrippedSize, types.VideoSize)):
+    elif isinstance(thumb, (_tl.PhotoSize, _tl.PhotoCachedSize,
+                            _tl.PhotoStrippedSize, _tl.VideoSize)):
         return thumb
     else:
         return None
 
 def _download_cached_photo_size(self: 'TelegramClient', size, file):
     # No need to download anything, simply write the bytes
-    if isinstance(size, types.PhotoStrippedSize):
+    if isinstance(size, _tl.PhotoStrippedSize):
         data = utils.stripped_photo_to_jpg(size.bytes)
     else:
         data = size.bytes
@@ -548,31 +547,31 @@ def _download_cached_photo_size(self: 'TelegramClient', size, file):
 async def _download_photo(self: 'TelegramClient', photo, file, date, thumb, progress_callback):
     """Specialized version of .download_media() for photos"""
     # Determine the photo and its largest size
-    if isinstance(photo, types.MessageMediaPhoto):
+    if isinstance(photo, _tl.MessageMediaPhoto):
         photo = photo.photo
-    if not isinstance(photo, types.Photo):
+    if not isinstance(photo, _tl.Photo):
         return
 
     # Include video sizes here (but they may be None so provide an empty list)
     size = self._get_thumb(photo.sizes + (photo.video_sizes or []), thumb)
-    if not size or isinstance(size, types.PhotoSizeEmpty):
+    if not size or isinstance(size, _tl.PhotoSizeEmpty):
         return
 
-    if isinstance(size, types.VideoSize):
+    if isinstance(size, _tl.VideoSize):
         file = self._get_proper_filename(file, 'video', '.mp4', date=date)
     else:
         file = self._get_proper_filename(file, 'photo', '.jpg', date=date)
 
-    if isinstance(size, (types.PhotoCachedSize, types.PhotoStrippedSize)):
+    if isinstance(size, (_tl.PhotoCachedSize, _tl.PhotoStrippedSize)):
         return self._download_cached_photo_size(size, file)
 
-    if isinstance(size, types.PhotoSizeProgressive):
+    if isinstance(size, _tl.PhotoSizeProgressive):
         file_size = max(size.sizes)
     else:
         file_size = size.size
 
     result = await self.download_file(
-        types.InputPhotoFileLocation(
+        _tl.InputPhotoFileLocation(
             id=photo.id,
             access_hash=photo.access_hash,
             file_reference=photo.file_reference,
@@ -589,10 +588,10 @@ def _get_kind_and_names(attributes):
     kind = 'document'
     possible_names = []
     for attr in attributes:
-        if isinstance(attr, types.DocumentAttributeFilename):
+        if isinstance(attr, _tl.DocumentAttributeFilename):
             possible_names.insert(0, attr.file_name)
 
-        elif isinstance(attr, types.DocumentAttributeAudio):
+        elif isinstance(attr, _tl.DocumentAttributeAudio):
             kind = 'audio'
             if attr.performer and attr.title:
                 possible_names.append('{} - {}'.format(
@@ -610,9 +609,9 @@ def _get_kind_and_names(attributes):
 async def _download_document(
         self, document, file, date, thumb, progress_callback, msg_data):
     """Specialized version of .download_media() for documents."""
-    if isinstance(document, types.MessageMediaDocument):
+    if isinstance(document, _tl.MessageMediaDocument):
         document = document.document
-    if not isinstance(document, types.Document):
+    if not isinstance(document, _tl.Document):
         return
 
     if thumb is None:
@@ -625,11 +624,11 @@ async def _download_document(
     else:
         file = self._get_proper_filename(file, 'photo', '.jpg', date=date)
         size = self._get_thumb(document.thumbs, thumb)
-        if isinstance(size, (types.PhotoCachedSize, types.PhotoStrippedSize)):
+        if isinstance(size, (_tl.PhotoCachedSize, _tl.PhotoStrippedSize)):
             return self._download_cached_photo_size(size, file)
 
     result = await self._download_file(
-        types.InputDocumentFileLocation(
+        _tl.InputDocumentFileLocation(
             id=document.id,
             access_hash=document.access_hash,
             file_reference=document.file_reference,
diff --git a/telethon/_client/messageparse.py b/telethon/_client/messageparse.py
index 0cbdb40c..d68ab38f 100644
--- a/telethon/_client/messageparse.py
+++ b/telethon/_client/messageparse.py
@@ -2,8 +2,7 @@ import itertools
 import re
 import typing
 
-from .. import helpers, utils
-from ..tl import types
+from .. import helpers, utils, _tl
 
 if typing.TYPE_CHECKING:
     from .telegramclient import TelegramClient
@@ -25,7 +24,7 @@ async def _replace_with_mention(self: 'TelegramClient', entities, i, user):
     or do nothing if it can't be found.
     """
     try:
-        entities[i] = types.InputMessageEntityMentionName(
+        entities[i] = _tl.InputMessageEntityMentionName(
             entities[i].offset, entities[i].length,
             await self.get_input_entity(user)
         )
@@ -52,15 +51,15 @@ async def _parse_message_text(self: 'TelegramClient', message, parse_mode):
 
     for i in reversed(range(len(msg_entities))):
         e = msg_entities[i]
-        if isinstance(e, types.MessageEntityTextUrl):
+        if isinstance(e, _tl.MessageEntityTextUrl):
             m = re.match(r'^@|\+|tg://user\?id=(\d+)', e.url)
             if m:
                 user = int(m.group(1)) if m.group(1) else e.url
                 is_mention = await self._replace_with_mention(msg_entities, i, user)
                 if not is_mention:
                     del msg_entities[i]
-        elif isinstance(e, (types.MessageEntityMentionName,
-                            types.InputMessageEntityMentionName)):
+        elif isinstance(e, (_tl.MessageEntityMentionName,
+                            _tl.InputMessageEntityMentionName)):
             is_mention = await self._replace_with_mention(msg_entities, i, e.user_id)
             if not is_mention:
                 del msg_entities[i]
@@ -76,10 +75,10 @@ def _get_response_message(self: 'TelegramClient', request, result, input_chat):
 
     If ``request.random_id`` is a list, this method returns a list too.
     """
-    if isinstance(result, types.UpdateShort):
+    if isinstance(result, _tl.UpdateShort):
         updates = [result.update]
         entities = {}
-    elif isinstance(result, (types.Updates, types.UpdatesCombined)):
+    elif isinstance(result, (_tl.Updates, _tl.UpdatesCombined)):
         updates = result.updates
         entities = {utils.get_peer_id(x): x
                     for x in
@@ -90,11 +89,11 @@ def _get_response_message(self: 'TelegramClient', request, result, input_chat):
     random_to_id = {}
     id_to_message = {}
     for update in updates:
-        if isinstance(update, types.UpdateMessageID):
+        if isinstance(update, _tl.UpdateMessageID):
             random_to_id[update.random_id] = update.id
 
         elif isinstance(update, (
-                types.UpdateNewChannelMessage, types.UpdateNewMessage)):
+                _tl.UpdateNewChannelMessage, _tl.UpdateNewMessage)):
             update.message._finish_init(self, entities, input_chat)
 
             # Pinning a message with `updatePinnedMessage` seems to
@@ -109,7 +108,7 @@ def _get_response_message(self: 'TelegramClient', request, result, input_chat):
             else:
                 return update.message
 
-        elif (isinstance(update, types.UpdateEditMessage)
+        elif (isinstance(update, _tl.UpdateEditMessage)
                 and helpers._entity_type(request.peer) != helpers._EntityType.CHANNEL):
             update.message._finish_init(self, entities, input_chat)
 
@@ -120,26 +119,26 @@ def _get_response_message(self: 'TelegramClient', request, result, input_chat):
             elif request.id == update.message.id:
                 return update.message
 
-        elif (isinstance(update, types.UpdateEditChannelMessage)
+        elif (isinstance(update, _tl.UpdateEditChannelMessage)
                 and utils.get_peer_id(request.peer) ==
                 utils.get_peer_id(update.message.peer_id)):
             if request.id == update.message.id:
                 update.message._finish_init(self, entities, input_chat)
                 return update.message
 
-        elif isinstance(update, types.UpdateNewScheduledMessage):
+        elif isinstance(update, _tl.UpdateNewScheduledMessage):
             update.message._finish_init(self, entities, input_chat)
             # Scheduled IDs may collide with normal IDs. However, for a
             # single request there *shouldn't* be a mix between "some
             # scheduled and some not".
             id_to_message[update.message.id] = update.message
 
-        elif isinstance(update, types.UpdateMessagePoll):
+        elif isinstance(update, _tl.UpdateMessagePoll):
             if request.media.poll.id == update.poll_id:
-                m = types.Message(
+                m = _tl.Message(
                     id=request.id,
                     peer_id=utils.get_peer(request.peer),
-                    media=types.MessageMediaPoll(
+                    media=_tl.MessageMediaPoll(
                         poll=update.poll,
                         results=update.results
                     )
diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py
index 1dde08ec..ba88c665 100644
--- a/telethon/_client/messages.py
+++ b/telethon/_client/messages.py
@@ -3,9 +3,8 @@ import itertools
 import typing
 import warnings
 
-from .. import helpers, utils, errors, hints
+from .. import helpers, utils, errors, hints, _tl
 from ..requestiter import RequestIter
-from ..tl import types, functions
 
 _MAX_CHUNK_SIZE = 100
 
@@ -67,31 +66,31 @@ class _MessagesIter(RequestIter):
         # If we want to perform global a search with `from_user` we have to perform
         # a normal `messages.search`, *but* we can make the entity be `inputPeerEmpty`.
         if not self.entity and from_user:
-            self.entity = types.InputPeerEmpty()
+            self.entity = _tl.InputPeerEmpty()
 
         if filter is None:
-            filter = types.InputMessagesFilterEmpty()
+            filter = _tl.InputMessagesFilterEmpty()
         else:
             filter = filter() if isinstance(filter, type) else filter
 
         if not self.entity:
-            self.request = functions.messages.SearchGlobalRequest(
+            self.request = _tl.fn.messages.SearchGlobal(
                 q=search or '',
                 filter=filter,
                 min_date=None,
                 max_date=offset_date,
                 offset_rate=0,
-                offset_peer=types.InputPeerEmpty(),
+                offset_peer=_tl.InputPeerEmpty(),
                 offset_id=offset_id,
                 limit=1
             )
         elif scheduled:
-            self.request = functions.messages.GetScheduledHistoryRequest(
+            self.request = _tl.fn.messages.GetScheduledHistory(
                 peer=entity,
                 hash=0
             )
         elif reply_to is not None:
-            self.request = functions.messages.GetRepliesRequest(
+            self.request = _tl.fn.messages.GetReplies(
                 peer=self.entity,
                 msg_id=reply_to,
                 offset_id=offset_id,
@@ -102,7 +101,7 @@ class _MessagesIter(RequestIter):
                 min_id=0,
                 hash=0
             )
-        elif search is not None or not isinstance(filter, types.InputMessagesFilterEmpty) or from_user:
+        elif search is not None or not isinstance(filter, _tl.InputMessagesFilterEmpty) or from_user:
             # Telegram completely ignores `from_id` in private chats
             ty = helpers._entity_type(self.entity)
             if ty == helpers._EntityType.USER:
@@ -114,7 +113,7 @@ class _MessagesIter(RequestIter):
                 # and set `from_id` to None to avoid checking it locally.
                 self.from_id = None
 
-            self.request = functions.messages.SearchRequest(
+            self.request = _tl.fn.messages.Search(
                 peer=self.entity,
                 q=search or '',
                 filter=filter,
@@ -136,13 +135,13 @@ class _MessagesIter(RequestIter):
             #
             # Even better, using `filter` and `from_id` seems to always
             # trigger `RPC_CALL_FAIL` which is "internal issues"...
-            if not isinstance(filter, types.InputMessagesFilterEmpty) \
+            if not isinstance(filter, _tl.InputMessagesFilterEmpty) \
                     and offset_date and not search and not offset_id:
                 async for m in self.client.iter_messages(
                         self.entity, 1, offset_date=offset_date):
                     self.request.offset_id = m.id + 1
         else:
-            self.request = functions.messages.GetHistoryRequest(
+            self.request = _tl.fn.messages.GetHistory(
                 peer=self.entity,
                 limit=1,
                 offset_date=offset_date,
@@ -156,7 +155,7 @@ class _MessagesIter(RequestIter):
         if self.limit <= 0:
             # No messages, but we still need to know the total message count
             result = await self.client(self.request)
-            if isinstance(result, types.messages.MessagesNotModified):
+            if isinstance(result, _tl.messages.MessagesNotModified):
                 self.total = result.count
             else:
                 self.total = getattr(result, 'count', len(result.messages))
@@ -189,7 +188,7 @@ class _MessagesIter(RequestIter):
 
         messages = reversed(r.messages) if self.reverse else r.messages
         for message in messages:
-            if (isinstance(message, types.MessageEmpty)
+            if (isinstance(message, _tl.MessageEmpty)
                     or self.from_id and message.sender_id != self.from_id):
                 continue
 
@@ -245,7 +244,7 @@ class _MessagesIter(RequestIter):
             # We want to skip the one we already have
             self.request.offset_id += 1
 
-        if isinstance(self.request, functions.messages.SearchRequest):
+        if isinstance(self.request, _tl.fn.messages.SearchRequest):
             # Unlike getHistory and searchGlobal that use *offset* date,
             # this is *max* date. This means that doing a search in reverse
             # will break it. Since it's not really needed once we're going
@@ -255,11 +254,11 @@ class _MessagesIter(RequestIter):
             # getHistory, searchGlobal and getReplies call it offset_date
             self.request.offset_date = last_message.date
 
-        if isinstance(self.request, functions.messages.SearchGlobalRequest):
+        if isinstance(self.request, _tl.fn.messages.SearchGlobalRequest):
             if last_message.input_chat:
                 self.request.offset_peer = last_message.input_chat
             else:
-                self.request.offset_peer = types.InputPeerEmpty()
+                self.request.offset_peer = _tl.InputPeerEmpty()
 
             self.request.offset_rate = getattr(response, 'next_rate', 0)
 
@@ -287,16 +286,16 @@ class _IDsIter(RequestIter):
         if self._ty == helpers._EntityType.CHANNEL:
             try:
                 r = await self.client(
-                    functions.channels.GetMessagesRequest(self._entity, ids))
+                    _tl.fn.channels.GetMessages(self._entity, ids))
             except errors.MessageIdsEmptyError:
                 # All IDs were invalid, use a dummy result
-                r = types.messages.MessagesNotModified(len(ids))
+                r = _tl.messages.MessagesNotModified(len(ids))
         else:
-            r = await self.client(functions.messages.GetMessagesRequest(ids))
+            r = await self.client(_tl.fn.messages.GetMessages(ids))
             if self._entity:
                 from_id = await self.client._get_peer(self._entity)
 
-        if isinstance(r, types.messages.MessagesNotModified):
+        if isinstance(r, _tl.messages.MessagesNotModified):
             self.buffer.extend(None for _ in ids)
             return
 
@@ -312,7 +311,7 @@ class _IDsIter(RequestIter):
         # since the user can enter arbitrary numbers which can belong to
         # arbitrary chats. Validate these unless ``from_id is None``.
         for message in r.messages:
-            if isinstance(message, types.MessageEmpty) or (
+            if isinstance(message, _tl.MessageEmpty) or (
                     from_id and message.peer_id != from_id):
                 self.buffer.append(None)
             else:
@@ -331,7 +330,7 @@ def iter_messages(
         min_id: int = 0,
         add_offset: int = 0,
         search: str = None,
-        filter: 'typing.Union[types.TypeMessagesFilter, typing.Type[types.TypeMessagesFilter]]' = None,
+        filter: 'typing.Union[_tl.TypeMessagesFilter, typing.Type[_tl.TypeMessagesFilter]]' = None,
         from_user: 'hints.EntityLike' = None,
         wait_time: float = None,
         ids: 'typing.Union[int, typing.Sequence[int]]' = None,
@@ -393,9 +392,9 @@ async def get_messages(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalL
 async def _get_comment_data(
         self: 'TelegramClient',
         entity: 'hints.EntityLike',
-        message: 'typing.Union[int, types.Message]'
+        message: 'typing.Union[int, _tl.Message]'
 ):
-    r = await self(functions.messages.GetDiscussionMessageRequest(
+    r = await self(_tl.fn.messages.GetDiscussionMessage(
         peer=entity,
         msg_id=utils.get_message_id(message)
     ))
@@ -408,10 +407,10 @@ async def send_message(
         entity: 'hints.EntityLike',
         message: 'hints.MessageLike' = '',
         *,
-        reply_to: 'typing.Union[int, types.Message]' = None,
-        attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None,
+        reply_to: 'typing.Union[int, _tl.Message]' = None,
+        attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None,
         parse_mode: typing.Optional[str] = (),
-        formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None,
+        formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None,
         link_preview: bool = True,
         file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]' = None,
         thumb: 'hints.FileLike' = None,
@@ -422,8 +421,8 @@ async def send_message(
         background: bool = None,
         supports_streaming: bool = False,
         schedule: 'hints.DateLike' = None,
-        comment_to: 'typing.Union[int, types.Message]' = None
-) -> 'types.Message':
+        comment_to: 'typing.Union[int, _tl.Message]' = None
+) -> '_tl.Message':
     if file is not None:
         return await self.send_file(
             entity, file, caption=message, reply_to=reply_to,
@@ -439,7 +438,7 @@ async def send_message(
     if comment_to is not None:
         entity, reply_to = await self._get_comment_data(entity, comment_to)
 
-    if isinstance(message, types.Message):
+    if isinstance(message, _tl.Message):
         if buttons is None:
             markup = message.reply_markup
         else:
@@ -449,7 +448,7 @@ async def send_message(
             silent = message.silent
 
         if (message.media and not isinstance(
-                message.media, types.MessageMediaWebPage)):
+                message.media, _tl.MessageMediaWebPage)):
             return await self.send_file(
                 entity,
                 message.media,
@@ -462,7 +461,7 @@ async def send_message(
                 schedule=schedule
             )
 
-        request = functions.messages.SendMessageRequest(
+        request = _tl.fn.messages.SendMessage(
             peer=entity,
             message=message.message or '',
             silent=silent,
@@ -472,7 +471,7 @@ async def send_message(
             entities=message.entities,
             clear_draft=clear_draft,
             no_webpage=not isinstance(
-                message.media, types.MessageMediaWebPage),
+                message.media, _tl.MessageMediaWebPage),
             schedule_date=schedule
         )
         message = message.message
@@ -484,7 +483,7 @@ async def send_message(
                 'The message cannot be empty unless a file is provided'
             )
 
-        request = functions.messages.SendMessageRequest(
+        request = _tl.fn.messages.SendMessage(
             peer=entity,
             message=message,
             entities=formatting_entities,
@@ -498,8 +497,8 @@ async def send_message(
         )
 
     result = await self(request)
-    if isinstance(result, types.UpdateShortSentMessage):
-        message = types.Message(
+    if isinstance(result, _tl.UpdateShortSentMessage):
+        message = _tl.Message(
             id=result.id,
             peer_id=await self._get_peer(entity),
             message=message,
@@ -526,7 +525,7 @@ async def forward_messages(
         silent: bool = None,
         as_album: bool = None,
         schedule: 'hints.DateLike' = None
-) -> 'typing.Sequence[types.Message]':
+) -> 'typing.Sequence[_tl.Message]':
     if as_album is not None:
         warnings.warn('the as_album argument is deprecated and no longer has any effect')
 
@@ -548,7 +547,7 @@ async def forward_messages(
                 return from_peer_id
 
             raise ValueError('from_peer must be given if integer IDs are used')
-        elif isinstance(m, types.Message):
+        elif isinstance(m, _tl.Message):
             return m.chat_id
         else:
             raise TypeError('Cannot forward messages of type {}'.format(type(m)))
@@ -562,7 +561,7 @@ async def forward_messages(
             chat = await chunk[0].get_input_chat()
             chunk = [m.id for m in chunk]
 
-        req = functions.messages.ForwardMessagesRequest(
+        req = _tl.fn.messages.ForwardMessages(
             from_peer=chat,
             id=chunk,
             to_peer=entity,
@@ -578,13 +577,13 @@ async def forward_messages(
 
 async def edit_message(
         self: 'TelegramClient',
-        entity: 'typing.Union[hints.EntityLike, types.Message]',
+        entity: 'typing.Union[hints.EntityLike, _tl.Message]',
         message: 'hints.MessageLike' = None,
         text: str = None,
         *,
         parse_mode: str = (),
-        attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None,
-        formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None,
+        attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None,
+        formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None,
         link_preview: bool = True,
         file: 'hints.FileLike' = None,
         thumb: 'hints.FileLike' = None,
@@ -592,11 +591,11 @@ async def edit_message(
         buttons: 'hints.MarkupLike' = None,
         supports_streaming: bool = False,
         schedule: 'hints.DateLike' = None
-) -> 'types.Message':
-    if isinstance(entity, types.InputBotInlineMessageID):
+) -> '_tl.Message':
+    if isinstance(entity, _tl.InputBotInlineMessageID):
         text = text or message
         message = entity
-    elif isinstance(entity, types.Message):
+    elif isinstance(entity, _tl.Message):
         text = message  # Shift the parameters to the right
         message = entity
         entity = entity.peer_id
@@ -609,8 +608,8 @@ async def edit_message(
             attributes=attributes,
             force_document=force_document)
 
-    if isinstance(entity, types.InputBotInlineMessageID):
-        request = functions.messages.EditInlineBotMessageRequest(
+    if isinstance(entity, _tl.InputBotInlineMessageID):
+        request = _tl.fn.messages.EditInlineBotMessage(
             id=entity,
             message=text,
             no_webpage=not link_preview,
@@ -631,7 +630,7 @@ async def edit_message(
             return await self(request)
 
     entity = await self.get_input_entity(entity)
-    request = functions.messages.EditMessageRequest(
+    request = _tl.fn.messages.EditMessage(
         peer=entity,
         id=utils.get_message_id(message),
         message=text,
@@ -649,13 +648,13 @@ async def delete_messages(
         entity: 'hints.EntityLike',
         message_ids: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]',
         *,
-        revoke: bool = True) -> 'typing.Sequence[types.messages.AffectedMessages]':
+        revoke: bool = True) -> 'typing.Sequence[_tl.messages.AffectedMessages]':
     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))
+            _tl.Message, _tl.MessageService, _tl.MessageEmpty))
         else int(m) for m in message_ids
     )
 
@@ -667,10 +666,10 @@ async def delete_messages(
         ty = helpers._EntityType.USER
 
     if ty == helpers._EntityType.CHANNEL:
-        return await self([functions.channels.DeleteMessagesRequest(
+        return await self([_tl.fn.channels.DeleteMessages(
                         entity, list(c)) for c in utils.chunks(message_ids)])
     else:
-        return await self([functions.messages.DeleteMessagesRequest(
+        return await self([_tl.fn.messages.DeleteMessages(
                         list(c), revoke) for c in utils.chunks(message_ids)])
 
 async def send_read_acknowledge(
@@ -691,16 +690,16 @@ async def send_read_acknowledge(
 
     entity = await self.get_input_entity(entity)
     if clear_mentions:
-        await self(functions.messages.ReadMentionsRequest(entity))
+        await self(_tl.fn.messages.ReadMentions(entity))
         if max_id is None:
             return True
 
     if max_id is not None:
         if helpers._entity_type(entity) == helpers._EntityType.CHANNEL:
-            return await self(functions.channels.ReadHistoryRequest(
+            return await self(_tl.fn.channels.ReadHistory(
                 utils.get_input_channel(entity), max_id=max_id))
         else:
-            return await self(functions.messages.ReadHistoryRequest(
+            return await self(_tl.fn.messages.ReadHistory(
                 entity, max_id=max_id))
 
     return False
@@ -728,10 +727,10 @@ async def _pin(self, entity, message, *, unpin, notify=False, pm_oneside=False):
     message = utils.get_message_id(message) or 0
     entity = await self.get_input_entity(entity)
     if message <= 0:  # old behaviour accepted negative IDs to unpin
-        await self(functions.messages.UnpinAllMessagesRequest(entity))
+        await self(_tl.fn.messages.UnpinAllMessages(entity))
         return
 
-    request = functions.messages.UpdatePinnedMessageRequest(
+    request = _tl.fn.messages.UpdatePinnedMessage(
         peer=entity,
         id=message,
         silent=not notify,
diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py
index 256e8e6f..9c5d62d7 100644
--- a/telethon/_client/telegrambaseclient.py
+++ b/telethon/_client/telegrambaseclient.py
@@ -7,15 +7,13 @@ import platform
 import time
 import typing
 
-from .. import version, helpers, __name__ as __base_name__
+from .. import version, helpers, __name__ as __base_name__, _tl
 from ..crypto import rsa
 from ..entitycache import EntityCache
 from ..extensions import markdown
 from ..network import MTProtoSender, Connection, ConnectionTcpFull, TcpMTProxy
 from ..sessions import Session, SQLiteSession, MemorySession
 from ..statecache import StateCache
-from ..tl import functions, types
-from ..tl.alltlobjects import LAYER
 
 DEFAULT_DC_ID = 2
 DEFAULT_IPV4_IP = '149.154.167.51'
@@ -122,7 +120,7 @@ def init(
             import warnings
             warnings.warn(
                 'The sqlite3 module is not available under this '
-                'Python installation and no custom session '
+                'Python installation and no _ session '
                 'instance was given; using MemorySession.\n'
                 'You will need to re-login every time unless '
                 'you use another session storage'
@@ -198,7 +196,7 @@ def init(
     assert isinstance(connection, type)
     self._connection = connection
     init_proxy = None if not issubclass(connection, TcpMTProxy) else \
-        types.InputClientProxy(*connection.address_info(proxy))
+        _tl.InputClientProxy(*connection.address_info(proxy))
 
     # Used on connection. Capture the variables in a lambda since
     # exporting clients need to create this InvokeWithLayerRequest.
@@ -212,7 +210,7 @@ def init(
         default_device_model = system.machine
     default_system_version = re.sub(r'-.+','',system.release)
 
-    self._init_request = functions.InitConnectionRequest(
+    self._init_request = _tl.fn.InitConnection(
         api_id=self.api_id,
         device_model=device_model or default_device_model or 'Unknown',
         system_version=system_version or default_system_version or '1.0',
@@ -322,10 +320,10 @@ async def connect(self: 'TelegramClient') -> None:
     self.session.auth_key = self._sender.auth_key
     self.session.save()
 
-    self._init_request.query = functions.help.GetConfigRequest()
+    self._init_request.query = _tl.fn.help.GetConfig()
 
-    await self._sender.send(functions.InvokeWithLayerRequest(
-        LAYER, self._init_request
+    await self._sender.send(_tl.fn.InvokeWithLayer(
+        _tl.alltlobjects.LAYER, self._init_request
     ))
 
     self._updates_handle = self.loop.create_task(self._update_loop())
@@ -339,7 +337,7 @@ async def disconnect(self: 'TelegramClient'):
 
 def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]):
     init_proxy = None if not issubclass(self._connection, TcpMTProxy) else \
-        types.InputClientProxy(*self._connection.address_info(proxy))
+        _tl.InputClientProxy(*self._connection.address_info(proxy))
 
     self._init_request.proxy = init_proxy
     self._proxy = proxy
@@ -386,7 +384,7 @@ async def _disconnect_coro(self: 'TelegramClient'):
 
     pts, date = self._state_cache[None]
     if pts and date:
-        self.session.set_update_state(0, types.updates.State(
+        self.session.set_update_state(0, _tl.updates.State(
             pts=pts,
             qts=0,
             date=date,
@@ -436,10 +434,10 @@ async def _get_dc(self: 'TelegramClient', 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())
+        cls._config = await self(_tl.fn.help.GetConfig())
 
     if cdn and not self._cdn_config:
-        cls._cdn_config = await self(functions.help.GetCdnConfigRequest())
+        cls._cdn_config = await self(_tl.fn.help.GetCdnConfig())
         for pk in cls._cdn_config.public_keys:
             rsa.add_key(pk.public_key)
 
@@ -481,9 +479,9 @@ async def _create_exported_sender(self: 'TelegramClient', dc_id):
         local_addr=self._local_addr
     ))
     self._log[__name__].info('Exporting auth for new borrowed sender in %s', dc)
-    auth = await self(functions.auth.ExportAuthorizationRequest(dc_id))
-    self._init_request.query = functions.auth.ImportAuthorizationRequest(id=auth.id, bytes=auth.bytes)
-    req = functions.InvokeWithLayerRequest(LAYER, self._init_request)
+    auth = await self(_tl.fn.auth.ExportAuthorization(dc_id))
+    self._init_request.query = _tl.fn.auth.ImportAuthorization(id=auth.id, bytes=auth.bytes)
+    req = _tl.fn.InvokeWithLayer(LAYER, self._init_request)
     await sender.send(req)
     return sender
 
diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py
index 01cd5d14..d3935273 100644
--- a/telethon/_client/telegramclient.py
+++ b/telethon/_client/telegramclient.py
@@ -8,8 +8,7 @@ from . import (
     account, auth, bots, buttons, chats, dialogs, downloads, messageparse, messages,
     telegrambaseclient, updates, uploads, users
 )
-from .. import helpers, version
-from ..tl import types, custom
+from .. import helpers, version, _tl
 from ..network import ConnectionTcpFull
 from ..events.common import EventBuilder, EventCommon
 
@@ -369,7 +368,7 @@ class TelegramClient:
             *,
             password: str = None,
             bot_token: str = None,
-            phone_code_hash: str = None) -> 'typing.Union[types.User, types.auth.SentCode]':
+            phone_code_hash: str = None) -> 'typing.Union[_tl.User, _tl.auth.SentCode]':
         """
         Logs in to Telegram to an existing user or bot account.
 
@@ -428,7 +427,7 @@ class TelegramClient:
             last_name: str = '',
             *,
             phone: str = None,
-            phone_code_hash: str = None) -> 'types.User':
+            phone_code_hash: str = None) -> '_tl.User':
         """
         Signs up to Telegram as a new user account.
 
@@ -477,7 +476,7 @@ class TelegramClient:
             self: 'TelegramClient',
             phone: str,
             *,
-            force_sms: bool = False) -> 'types.auth.SentCode':
+            force_sms: bool = False) -> '_tl.auth.SentCode':
         """
         Sends the Telegram code needed to login to the given phone number.
 
@@ -630,7 +629,7 @@ class TelegramClient:
             *,
             entity: 'hints.EntityLike' = None,
             offset: str = None,
-            geo_point: 'types.GeoPoint' = None) -> custom.InlineResults:
+            geo_point: '_tl.GeoPoint' = None) -> custom.InlineResults:
         """
         Makes an inline query to the specified bot (``@vote New Poll``).
 
@@ -679,7 +678,7 @@ class TelegramClient:
     @staticmethod
     def build_reply_markup(
             buttons: 'typing.Optional[hints.MarkupLike]',
-            inline_only: bool = False) -> 'typing.Optional[types.TypeReplyMarkup]':
+            inline_only: bool = False) -> 'typing.Optional[_tl.TypeReplyMarkup]':
         """
         Builds a :tl:`ReplyInlineMarkup` or :tl:`ReplyKeyboardMarkup` for
         the given buttons.
@@ -722,7 +721,7 @@ class TelegramClient:
             limit: float = None,
             *,
             search: str = '',
-            filter: 'types.TypeChannelParticipantsFilter' = None,
+            filter: '_tl.TypeChannelParticipantsFilter' = None,
             aggressive: bool = False) -> chats._ParticipantsIter:
         """
         Iterator over the participants belonging to the specified chat.
@@ -1018,7 +1017,7 @@ class TelegramClient:
     def action(
             self: 'TelegramClient',
             entity: 'hints.EntityLike',
-            action: 'typing.Union[str, types.TypeSendMessageAction]',
+            action: 'typing.Union[str, _tl.TypeSendMessageAction]',
             *,
             delay: float = 4,
             auto_cancel: bool = True) -> 'typing.Union[_ChatAction, typing.Coroutine]':
@@ -1109,7 +1108,7 @@ class TelegramClient:
             manage_call: bool = None,
             anonymous: bool = None,
             is_admin: bool = None,
-            title: str = None) -> types.Updates:
+            title: str = None) -> _tl.Updates:
         """
         Edits admin permissions for someone in a chat.
 
@@ -1216,7 +1215,7 @@ class TelegramClient:
             send_polls: bool = True,
             change_info: bool = True,
             invite_users: bool = True,
-            pin_messages: bool = True) -> types.Updates:
+            pin_messages: bool = True) -> _tl.Updates:
         """
         Edits user restrictions in a chat.
 
@@ -1396,7 +1395,7 @@ class TelegramClient:
     async def get_stats(
             self: 'TelegramClient',
             entity: 'hints.EntityLike',
-            message: 'typing.Union[int, types.Message]' = None,
+            message: 'typing.Union[int, _tl.Message]' = None,
     ):
         """
         Retrieves statistics from the given megagroup or broadcast channel.
@@ -1449,7 +1448,7 @@ class TelegramClient:
             *,
             offset_date: 'hints.DateLike' = None,
             offset_id: int = 0,
-            offset_peer: 'hints.EntityLike' = types.InputPeerEmpty(),
+            offset_peer: 'hints.EntityLike' = _tl.InputPeerEmpty(),
             ignore_pinned: bool = False,
             ignore_migrated: bool = False,
             folder: int = None,
@@ -1604,7 +1603,7 @@ class TelegramClient:
             folder: typing.Union[int, typing.Sequence[int]] = None,
             *,
             unpack=None
-    ) -> types.Updates:
+    ) -> _tl.Updates:
         """
         Edits the folder used by one or more dialogs to archive them.
 
@@ -1756,7 +1755,7 @@ class TelegramClient:
             message: 'hints.MessageLike',
             file: 'hints.FileLike' = None,
             *,
-            thumb: 'typing.Union[int, types.TypePhotoSize]' = None,
+            thumb: 'typing.Union[int, _tl.TypePhotoSize]' = None,
             progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]:
         """
         Downloads the given media from a message object.
@@ -1850,7 +1849,7 @@ class TelegramClient:
             input_location (: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.
+                list of supported _tl.
 
             file (`str` | `file`, optional):
                 The output file path, directory, or stream-like object.
@@ -2048,7 +2047,7 @@ class TelegramClient:
             min_id: int = 0,
             add_offset: int = 0,
             search: str = None,
-            filter: 'typing.Union[types.TypeMessagesFilter, typing.Type[types.TypeMessagesFilter]]' = None,
+            filter: 'typing.Union[_tl.TypeMessagesFilter, typing.Type[_tl.TypeMessagesFilter]]' = None,
             from_user: 'hints.EntityLike' = None,
             wait_time: float = None,
             ids: 'typing.Union[int, typing.Sequence[int]]' = None,
@@ -2258,10 +2257,10 @@ class TelegramClient:
             entity: 'hints.EntityLike',
             message: 'hints.MessageLike' = '',
             *,
-            reply_to: 'typing.Union[int, types.Message]' = None,
-            attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None,
+            reply_to: 'typing.Union[int, _tl.Message]' = None,
+            attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None,
             parse_mode: typing.Optional[str] = (),
-            formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None,
+            formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None,
             link_preview: bool = True,
             file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]' = None,
             thumb: 'hints.FileLike' = None,
@@ -2272,8 +2271,8 @@ class TelegramClient:
             background: bool = None,
             supports_streaming: bool = False,
             schedule: 'hints.DateLike' = None,
-            comment_to: 'typing.Union[int, types.Message]' = None
-    ) -> 'types.Message':
+            comment_to: 'typing.Union[int, _tl.Message]' = None
+    ) -> '_tl.Message':
         """
         Sends a message to the specified user, chat or channel.
 
@@ -2458,7 +2457,7 @@ class TelegramClient:
             silent: bool = None,
             as_album: bool = None,
             schedule: 'hints.DateLike' = None
-    ) -> 'typing.Sequence[types.Message]':
+    ) -> 'typing.Sequence[_tl.Message]':
         """
         Forwards the given messages to the specified entity.
 
@@ -2531,13 +2530,13 @@ class TelegramClient:
 
     async def edit_message(
             self: 'TelegramClient',
-            entity: 'typing.Union[hints.EntityLike, types.Message]',
+            entity: 'typing.Union[hints.EntityLike, _tl.Message]',
             message: 'hints.MessageLike' = None,
             text: str = None,
             *,
             parse_mode: str = (),
-            attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None,
-            formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None,
+            attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None,
+            formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None,
             link_preview: bool = True,
             file: 'hints.FileLike' = None,
             thumb: 'hints.FileLike' = None,
@@ -2545,7 +2544,7 @@ class TelegramClient:
             buttons: 'hints.MarkupLike' = None,
             supports_streaming: bool = False,
             schedule: 'hints.DateLike' = None
-    ) -> 'types.Message':
+    ) -> '_tl.Message':
         """
         Edits the given message to change its text or media.
 
@@ -2663,7 +2662,7 @@ class TelegramClient:
             entity: 'hints.EntityLike',
             message_ids: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]',
             *,
-            revoke: bool = True) -> 'typing.Sequence[types.messages.AffectedMessages]':
+            revoke: bool = True) -> 'typing.Sequence[_tl.messages.AffectedMessages]':
         """
         Deletes the given messages, optionally "for everyone".
 
@@ -3171,11 +3170,11 @@ class TelegramClient:
             clear_draft: bool = False,
             progress_callback: 'hints.ProgressCallback' = None,
             reply_to: 'hints.MessageIDLike' = None,
-            attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None,
+            attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None,
             thumb: 'hints.FileLike' = None,
             allow_cache: bool = True,
             parse_mode: str = (),
-            formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None,
+            formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None,
             voice_note: bool = False,
             video_note: bool = False,
             buttons: 'hints.MarkupLike' = None,
@@ -3183,9 +3182,9 @@ class TelegramClient:
             background: bool = None,
             supports_streaming: bool = False,
             schedule: 'hints.DateLike' = None,
-            comment_to: 'typing.Union[int, types.Message]' = None,
+            comment_to: 'typing.Union[int, _tl.Message]' = None,
             ttl: int = None,
-            **kwargs) -> 'types.Message':
+            **kwargs) -> '_tl.Message':
         """
         Sends message with the given file to the specified entity.
 
@@ -3392,11 +3391,11 @@ class TelegramClient:
 
                 # Dices, including dart and other future emoji
                 from telethon.tl import types
-                await client.send_file(chat, types.InputMediaDice(''))
-                await client.send_file(chat, types.InputMediaDice('🎯'))
+                await client.send_file(chat, _tl.InputMediaDice(''))
+                await client.send_file(chat, _tl.InputMediaDice('🎯'))
 
                 # Contacts
-                await client.send_file(chat, types.InputMediaContact(
+                await client.send_file(chat, _tl.InputMediaContact(
                     phone_number='+34 123 456 789',
                     first_name='Example',
                     last_name='',
@@ -3415,7 +3414,7 @@ class TelegramClient:
             use_cache: type = None,
             key: bytes = None,
             iv: bytes = None,
-            progress_callback: 'hints.ProgressCallback' = None) -> 'types.TypeInputFile':
+            progress_callback: 'hints.ProgressCallback' = None) -> '_tl.TypeInputFile':
         """
         Uploads a file to Telegram's servers, without sending it.
 
@@ -3522,7 +3521,7 @@ class TelegramClient:
         return users.call(self._sender, request, ordered=ordered)
 
     async def get_me(self: 'TelegramClient', input_peer: bool = False) \
-            -> 'typing.Union[types.User, types.InputPeerUser]':
+            -> 'typing.Union[_tl.User, _tl.InputPeerUser]':
         """
         Gets "me", the current :tl:`User` who is logged in.
 
@@ -3633,7 +3632,7 @@ class TelegramClient:
 
     async def get_input_entity(
             self: 'TelegramClient',
-            peer: 'hints.EntityLike') -> 'types.TypeInputPeer':
+            peer: 'hints.EntityLike') -> '_tl.TypeInputPeer':
         """
         Turns the given entity into its input entity version.
 
diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py
index 89816530..e8c5709c 100644
--- a/telethon/_client/updates.py
+++ b/telethon/_client/updates.py
@@ -8,9 +8,8 @@ import traceback
 import typing
 import logging
 
-from .. import events, utils, errors
+from .. import events, utils, errors, _tl
 from ..events.common import EventBuilder, EventCommon
-from ..tl import types, functions
 
 if typing.TYPE_CHECKING:
     from .telegramclient import TelegramClient
@@ -22,7 +21,7 @@ Callback = typing.Callable[[typing.Any], typing.Any]
 async def _run_until_disconnected(self: 'TelegramClient'):
     try:
         # Make a high-level request to notify that we want updates
-        await self(functions.updates.GetStateRequest())
+        await self(_tl.fn.updates.GetState())
         return await self.disconnected
     except KeyboardInterrupt:
         pass
@@ -32,7 +31,7 @@ async def _run_until_disconnected(self: 'TelegramClient'):
 async def set_receive_updates(self: 'TelegramClient', receive_updates):
     self._no_updates = not receive_updates
     if receive_updates:
-        await self(functions.updates.GetStateRequest())
+        await self(_tl.fn.updates.GetState())
 
 async def run_until_disconnected(self: 'TelegramClient'):
     return await self._run_until_disconnected()
@@ -91,24 +90,24 @@ async def catch_up(self: 'TelegramClient'):
     self.session.catching_up = True
     try:
         while True:
-            d = await self(functions.updates.GetDifferenceRequest(
+            d = await self(_tl.fn.updates.GetDifference(
                 pts, date, 0
             ))
-            if isinstance(d, (types.updates.DifferenceSlice,
-                                types.updates.Difference)):
-                if isinstance(d, types.updates.Difference):
+            if isinstance(d, (_tl.updates.DifferenceSlice,
+                                _tl.updates.Difference)):
+                if isinstance(d, _tl.updates.Difference):
                     state = d.state
                 else:
                     state = d.intermediate_state
 
                 pts, date = state.pts, state.date
-                self._handle_update(types.Updates(
+                self._handle_update(_tl.Updates(
                     users=d.users,
                     chats=d.chats,
                     date=state.date,
                     seq=state.seq,
                     updates=d.other_updates + [
-                        types.UpdateNewMessage(m, 0, 0)
+                        _tl.UpdateNewMessage(m, 0, 0)
                         for m in d.new_messages
                     ]
                 ))
@@ -128,9 +127,9 @@ async def catch_up(self: 'TelegramClient'):
                 # some). This can be used to detect collisions (i.e.
                 # it would return an update we have already seen).
             else:
-                if isinstance(d, types.updates.DifferenceEmpty):
+                if isinstance(d, _tl.updates.DifferenceEmpty):
                     date = d.date
-                elif isinstance(d, types.updates.DifferenceTooLong):
+                elif isinstance(d, _tl.updates.DifferenceTooLong):
                     pts = d.pts
                 break
     except (ConnectionError, asyncio.CancelledError):
@@ -148,12 +147,12 @@ def _handle_update(self: 'TelegramClient', update):
     self.session.process_entities(update)
     self._entity_cache.add(update)
 
-    if isinstance(update, (types.Updates, types.UpdatesCombined)):
+    if 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:
             self._process_update(u, update.updates, entities=entities)
-    elif isinstance(update, types.UpdateShort):
+    elif isinstance(update, _tl.UpdateShort):
         self._process_update(update.update, None)
     else:
         self._process_update(update, None)
@@ -230,7 +229,7 @@ async def _update_loop(self: 'TelegramClient'):
                 continue
 
             try:
-                await self(functions.updates.GetStateRequest())
+                await self(_tl.fn.updates.GetState())
             except (ConnectionError, asyncio.CancelledError):
                 return
 
@@ -359,7 +358,7 @@ async def _get_difference(self: 'TelegramClient', update, channel_id, pts_date):
         assert isinstance(channel_id, int), 'channel_id was {}, not int in {}'.format(type(channel_id), update)
         try:
             # Wrap the ID inside a peer to ensure we get a channel back.
-            where = await self.get_input_entity(types.PeerChannel(channel_id))
+            where = await self.get_input_entity(_tl.PeerChannel(channel_id))
         except ValueError:
             # There's a high chance that this fails, since
             # we are getting the difference to fetch entities.
@@ -367,15 +366,15 @@ async def _get_difference(self: 'TelegramClient', update, channel_id, pts_date):
 
         if not pts_date:
             # First-time, can't get difference. Get pts instead.
-            result = await self(functions.channels.GetFullChannelRequest(
+            result = await self(_tl.fn.channels.GetFullChannel(
                 utils.get_input_channel(where)
             ))
             self._state_cache[channel_id] = result.full_chat.pts
             return
 
-        result = await self(functions.updates.GetChannelDifferenceRequest(
+        result = await self(_tl.fn.updates.GetChannelDifference(
             channel=where,
-            filter=types.ChannelMessagesFilterEmpty(),
+            filter=_tl.ChannelMessagesFilterEmpty(),
             pts=pts_date,  # just pts
             limit=100,
             force=True
@@ -383,20 +382,20 @@ async def _get_difference(self: 'TelegramClient', update, channel_id, pts_date):
     else:
         if not pts_date[0]:
             # First-time, can't get difference. Get pts instead.
-            result = await self(functions.updates.GetStateRequest())
+            result = await self(_tl.fn.updates.GetState())
             self._state_cache[None] = result.pts, result.date
             return
 
-        result = await self(functions.updates.GetDifferenceRequest(
+        result = await self(_tl.fn.updates.GetDifference(
             pts=pts_date[0],
             date=pts_date[1],
             qts=0
         ))
 
-    if isinstance(result, (types.updates.Difference,
-                            types.updates.DifferenceSlice,
-                            types.updates.ChannelDifference,
-                            types.updates.ChannelDifferenceTooLong)):
+    if isinstance(result, (_tl.updates.Difference,
+                            _tl.updates.DifferenceSlice,
+                            _tl.updates.ChannelDifference,
+                            _tl.updates.ChannelDifferenceTooLong)):
         update._entities.update({
             utils.get_peer_id(x): x for x in
             itertools.chain(result.users, result.chats)
diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py
index 58f69bad..6c0d8146 100644
--- a/telethon/_client/uploads.py
+++ b/telethon/_client/uploads.py
@@ -9,8 +9,7 @@ from io import BytesIO
 
 from ..crypto import AES
 
-from .. import utils, helpers, hints
-from ..tl import types, functions, custom
+from .. import utils, helpers, hints, _tl
 
 try:
     import PIL
@@ -99,11 +98,11 @@ async def send_file(
         clear_draft: bool = False,
         progress_callback: 'hints.ProgressCallback' = None,
         reply_to: 'hints.MessageIDLike' = None,
-        attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None,
+        attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None,
         thumb: 'hints.FileLike' = None,
         allow_cache: bool = True,
         parse_mode: str = (),
-        formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None,
+        formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None,
         voice_note: bool = False,
         video_note: bool = False,
         buttons: 'hints.MarkupLike' = None,
@@ -111,9 +110,9 @@ async def send_file(
         background: bool = None,
         supports_streaming: bool = False,
         schedule: 'hints.DateLike' = None,
-        comment_to: 'typing.Union[int, types.Message]' = None,
+        comment_to: 'typing.Union[int, _tl.Message]' = None,
         ttl: int = None,
-        **kwargs) -> 'types.Message':
+        **kwargs) -> '_tl.Message':
     # TODO Properly implement allow_cache to reuse the sha256 of the file
     # i.e. `None` was used
     if not file:
@@ -182,7 +181,7 @@ async def send_file(
         raise TypeError('Cannot use {!r} as file'.format(file))
 
     markup = self.build_reply_markup(buttons)
-    request = functions.messages.SendMediaRequest(
+    request = _tl.fn.messages.SendMedia(
         entity, media, reply_to_msg_id=reply_to, message=caption,
         entities=msg_entities, reply_markup=markup, silent=silent,
         schedule_date=schedule, clear_draft=clear_draft,
@@ -225,14 +224,14 @@ async def _send_album(self: 'TelegramClient', entity, files, caption='',
         fh, fm, _ = await self._file_to_media(
             file, supports_streaming=supports_streaming,
             force_document=force_document, ttl=ttl)
-        if isinstance(fm, (types.InputMediaUploadedPhoto, types.InputMediaPhotoExternal)):
-            r = await self(functions.messages.UploadMediaRequest(
+        if isinstance(fm, (_tl.InputMediaUploadedPhoto, _tl.InputMediaPhotoExternal)):
+            r = await self(_tl.fn.messages.UploadMedia(
                 entity, media=fm
             ))
 
             fm = utils.get_input_media(r.photo)
-        elif isinstance(fm, types.InputMediaUploadedDocument):
-            r = await self(functions.messages.UploadMediaRequest(
+        elif isinstance(fm, _tl.InputMediaUploadedDocument):
+            r = await self(_tl.fn.messages.UploadMedia(
                 entity, media=fm
             ))
 
@@ -243,7 +242,7 @@ async def _send_album(self: 'TelegramClient', entity, files, caption='',
             caption, msg_entities = captions.pop()
         else:
             caption, msg_entities = '', None
-        media.append(types.InputSingleMedia(
+        media.append(_tl.InputSingleMedia(
             fm,
             message=caption,
             entities=msg_entities
@@ -251,7 +250,7 @@ async def _send_album(self: 'TelegramClient', entity, files, caption='',
         ))
 
     # Now we can construct the multi-media request
-    request = functions.messages.SendMultiMediaRequest(
+    request = _tl.fn.messages.SendMultiMedia(
         entity, reply_to_msg_id=reply_to, multi_media=media,
         silent=silent, schedule_date=schedule, clear_draft=clear_draft,
         background=background
@@ -271,8 +270,8 @@ async def upload_file(
         use_cache: type = None,
         key: bytes = None,
         iv: bytes = None,
-        progress_callback: 'hints.ProgressCallback' = None) -> 'types.TypeInputFile':
-    if isinstance(file, (types.InputFile, types.InputFileBig)):
+        progress_callback: 'hints.ProgressCallback' = None) -> '_tl.TypeInputFile':
+    if isinstance(file, (_tl.InputFile, _tl.InputFileBig)):
         return file  # Already uploaded
 
     pos = 0
@@ -343,10 +342,10 @@ async def upload_file(
             # The SavePartRequest is different depending on whether
             # the file is too large or not (over or less than 10MB)
             if is_big:
-                request = functions.upload.SaveBigFilePartRequest(
+                request = _tl.fn.upload.SaveBigFilePart(
                     file_id, part_index, part_count, part)
             else:
-                request = functions.upload.SaveFilePartRequest(
+                request = _tl.fn.upload.SaveFilePart(
                     file_id, part_index, part)
 
             result = await self(request)
@@ -360,9 +359,9 @@ async def upload_file(
                     'Failed to upload file part {}.'.format(part_index))
 
     if is_big:
-        return types.InputFileBig(file_id, part_count, file_name)
+        return _tl.InputFileBig(file_id, part_count, file_name)
     else:
-        return custom.InputSizedFile(
+        return _tl.custom.InputSizedFile(
             file_id, part_count, file_name, md5=hash_md5, size=file_size
         )
 
@@ -385,7 +384,7 @@ async def _file_to_media(
 
     # `aiofiles` do not base `io.IOBase` but do have `read`, so we
     # just check for the read attribute to see if it's file-like.
-    if not isinstance(file, (str, bytes, types.InputFile, types.InputFileBig))\
+    if not isinstance(file, (str, bytes, _tl.InputFile, _tl.InputFileBig))\
             and not hasattr(file, 'read'):
         # The user may pass a Message containing media (or the media,
         # or anything similar) that should be treated as a file. Try
@@ -411,7 +410,7 @@ async def _file_to_media(
     media = None
     file_handle = None
 
-    if isinstance(file, (types.InputFile, types.InputFileBig)):
+    if isinstance(file, (_tl.InputFile, _tl.InputFileBig)):
         file_handle = file
     elif not isinstance(file, str) or os.path.isfile(file):
         file_handle = await self.upload_file(
@@ -421,9 +420,9 @@ async def _file_to_media(
         )
     elif re.match('https?://', file):
         if as_image:
-            media = types.InputMediaPhotoExternal(file, ttl_seconds=ttl)
+            media = _tl.InputMediaPhotoExternal(file, ttl_seconds=ttl)
         else:
-            media = types.InputMediaDocumentExternal(file, ttl_seconds=ttl)
+            media = _tl.InputMediaDocumentExternal(file, ttl_seconds=ttl)
     else:
         bot_file = utils.resolve_bot_file_id(file)
         if bot_file:
@@ -437,7 +436,7 @@ async def _file_to_media(
             'an HTTP URL or a valid bot-API-like file ID'.format(file)
         )
     elif as_image:
-        media = types.InputMediaUploadedPhoto(file_handle, ttl_seconds=ttl)
+        media = _tl.InputMediaUploadedPhoto(file_handle, ttl_seconds=ttl)
     else:
         attributes, mime_type = utils.get_attributes(
             file,
@@ -457,7 +456,7 @@ async def _file_to_media(
                 thumb = str(thumb.absolute())
             thumb = await self.upload_file(thumb, file_size=file_size)
 
-        media = types.InputMediaUploadedDocument(
+        media = _tl.InputMediaUploadedDocument(
             file=file_handle,
             mime_type=mime_type,
             attributes=attributes,
diff --git a/telethon/_client/users.py b/telethon/_client/users.py
index 0d871878..6209619a 100644
--- a/telethon/_client/users.py
+++ b/telethon/_client/users.py
@@ -4,10 +4,9 @@ import itertools
 import time
 import typing
 
-from .. import errors, helpers, utils, hints
+from .. import errors, helpers, utils, hints, _tl
 from ..errors import MultiError, RPCError
 from ..helpers import retry_range
-from ..tl import TLRequest, types, functions
 
 _NOT_A_REQUEST = lambda: TypeError('You can only invoke requests, not types!')
 
@@ -48,7 +47,7 @@ async def call(self: 'TelegramClient', sender, request, ordered=False, flood_sle
                 raise errors.FloodWaitError(request=r, capture=diff)
 
         if self._no_updates:
-            r = functions.InvokeWithoutUpdatesRequest(r)
+            r = _tl.fn.InvokeWithoutUpdates(r)
 
     request_index = 0
     last_error = None
@@ -128,13 +127,13 @@ async def call(self: 'TelegramClient', sender, request, ordered=False, flood_sle
 
 
 async def get_me(self: 'TelegramClient', input_peer: bool = False) \
-        -> 'typing.Union[types.User, types.InputPeerUser]':
+        -> 'typing.Union[_tl.User, _tl.InputPeerUser]':
     if input_peer and self._self_input_peer:
         return self._self_input_peer
 
     try:
         me = (await self(
-            functions.users.GetUsersRequest([types.InputUserSelf()])))[0]
+            _tl.fn.users.GetUsers([_tl.InputUserSelf()])))[0]
 
         self._bot = me.bot
         if not self._self_input_peer:
@@ -165,7 +164,7 @@ async def is_user_authorized(self: 'TelegramClient') -> bool:
     if self._authorized is None:
         try:
             # Any request that requires authorization will work
-            await self(functions.updates.GetStateRequest())
+            await self(_tl.fn.updates.GetState())
             self._authorized = True
         except errors.RPCError:
             self._authorized = False
@@ -209,14 +208,14 @@ async def get_entity(
         tmp = []
         while users:
             curr, users = users[:200], users[200:]
-            tmp.extend(await self(functions.users.GetUsersRequest(curr)))
+            tmp.extend(await self(_tl.fn.users.GetUsers(curr)))
         users = tmp
     if chats:  # TODO Handle chats slice?
         chats = (await self(
-            functions.messages.GetChatsRequest([x.chat_id for x in chats]))).chats
+            _tl.fn.messages.GetChats([x.chat_id for x in chats]))).chats
     if channels:
         channels = (await self(
-            functions.channels.GetChannelsRequest(channels))).chats
+            _tl.fn.channels.GetChannels(channels))).chats
 
     # Merge users, chats and channels into a single dictionary
     id_entity = {
@@ -232,19 +231,19 @@ async def get_entity(
     for x in inputs:
         if isinstance(x, str):
             result.append(await self._get_entity_from_string(x))
-        elif not isinstance(x, types.InputPeerSelf):
+        elif not isinstance(x, _tl.InputPeerSelf):
             result.append(id_entity[utils.get_peer_id(x)])
         else:
             result.append(next(
                 u for u in id_entity.values()
-                if isinstance(u, types.User) and u.is_self
+                if isinstance(u, _tl.User) and u.is_self
             ))
 
     return result[0] if single else result
 
 async def get_input_entity(
         self: 'TelegramClient',
-        peer: 'hints.EntityLike') -> 'types.TypeInputPeer':
+        peer: 'hints.EntityLike') -> '_tl.TypeInputPeer':
     # Short-circuit if the input parameter directly maps to an InputPeer
     try:
         return utils.get_input_peer(peer)
@@ -261,7 +260,7 @@ async def get_input_entity(
 
     # Then come known strings that take precedence
     if peer in ('me', 'self'):
-        return types.InputPeerSelf()
+        return _tl.InputPeerSelf()
 
     # No InputPeer, cached peer, or known string. Fetch from disk cache
     try:
@@ -279,10 +278,10 @@ async def get_input_entity(
     # If we're not a bot but the user is in our contacts, it seems to work
     # regardless. These are the only two special-cased requests.
     peer = utils.get_peer(peer)
-    if isinstance(peer, types.PeerUser):
-        users = await self(functions.users.GetUsersRequest([
-            types.InputUser(peer.user_id, access_hash=0)]))
-        if users and not isinstance(users[0], types.UserEmpty):
+    if isinstance(peer, _tl.PeerUser):
+        users = await self(_tl.fn.users.GetUsers([
+            _tl.InputUser(peer.user_id, access_hash=0)]))
+        if users and not isinstance(users[0], _tl.UserEmpty):
             # If the user passed a valid ID they expect to work for
             # channels but would be valid for users, we get UserEmpty.
             # Avoid returning the invalid empty input peer for that.
@@ -291,12 +290,12 @@ async def get_input_entity(
             # it's not, work as a chat and try to validate it through
             # another request, but that becomes too much work.
             return utils.get_input_peer(users[0])
-    elif isinstance(peer, types.PeerChat):
-        return types.InputPeerChat(peer.chat_id)
-    elif isinstance(peer, types.PeerChannel):
+    elif isinstance(peer, _tl.PeerChat):
+        return _tl.InputPeerChat(peer.chat_id)
+    elif isinstance(peer, _tl.PeerChannel):
         try:
-            channels = await self(functions.channels.GetChannelsRequest([
-                types.InputChannel(peer.channel_id, access_hash=0)]))
+            channels = await self(_tl.fn.channels.GetChannels([
+                _tl.InputChannel(peer.channel_id, access_hash=0)]))
             return utils.get_input_peer(channels.chats[0])
         except errors.ChannelInvalidError:
             pass
@@ -326,7 +325,7 @@ async def get_peer_id(
     except AttributeError:
         peer = await self.get_input_entity(peer)
 
-    if isinstance(peer, types.InputPeerSelf):
+    if isinstance(peer, _tl.InputPeerSelf):
         peer = await self.get_me(input_peer=True)
 
     return utils.get_peer_id(peer, add_mark=add_mark)
@@ -348,7 +347,7 @@ async def _get_entity_from_string(self: 'TelegramClient', string):
     if phone:
         try:
             for user in (await self(
-                    functions.contacts.GetContactsRequest(0))).users:
+                    _tl.fn.contacts.GetContacts(0))).users:
                 if user.phone == phone:
                     return user
         except errors.BotMethodInvalidError:
@@ -360,26 +359,26 @@ async def _get_entity_from_string(self: 'TelegramClient', string):
         username, is_join_chat = utils.parse_username(string)
         if is_join_chat:
             invite = await self(
-                functions.messages.CheckChatInviteRequest(username))
+                _tl.fn.messages.CheckChatInvite(username))
 
-            if isinstance(invite, types.ChatInvite):
+            if isinstance(invite, _tl.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):
+            elif isinstance(invite, _tl.ChatInviteAlready):
                 return invite.chat
         elif username:
             try:
                 result = await self(
-                    functions.contacts.ResolveUsernameRequest(username))
+                    _tl.fn.contacts.ResolveUsername(username))
             except errors.UsernameNotOccupiedError as e:
                 raise ValueError('No user has "{}" as username'
                                     .format(username)) from e
 
             try:
                 pid = utils.get_peer_id(result.peer, add_mark=False)
-                if isinstance(result.peer, types.PeerUser):
+                if isinstance(result.peer, _tl.PeerUser):
                     return next(x for x in result.users if x.id == pid)
                 else:
                     return next(x for x in result.chats if x.id == pid)
@@ -407,11 +406,11 @@ async def _get_input_dialog(self: 'TelegramClient', dialog):
             dialog.peer = await self.get_input_entity(dialog.peer)
             return dialog
         elif dialog.SUBCLASS_OF_ID == 0xc91c90b6:  # crc32(b'InputPeer')
-            return types.InputDialogPeer(dialog)
+            return _tl.InputDialogPeer(dialog)
     except AttributeError:
         pass
 
-    return types.InputDialogPeer(await self.get_input_entity(dialog))
+    return _tl.InputDialogPeer(await self.get_input_entity(dialog))
 
 async def _get_input_notify(self: 'TelegramClient', notify):
     """
@@ -421,10 +420,10 @@ async def _get_input_notify(self: 'TelegramClient', notify):
     """
     try:
         if notify.SUBCLASS_OF_ID == 0x58981615:
-            if isinstance(notify, types.InputNotifyPeer):
+            if isinstance(notify, _tl.InputNotifyPeer):
                 notify.peer = await self.get_input_entity(notify.peer)
             return notify
     except AttributeError:
         pass
 
-    return types.InputNotifyPeer(await self.get_input_entity(notify))
+    return _tl.InputNotifyPeer(await self.get_input_entity(notify))
diff --git a/telethon/_crypto/cdndecrypter.py b/telethon/_crypto/cdndecrypter.py
index dd615a5a..efdc3288 100644
--- a/telethon/_crypto/cdndecrypter.py
+++ b/telethon/_crypto/cdndecrypter.py
@@ -3,8 +3,7 @@ This module holds the CdnDecrypter utility class.
 """
 from hashlib import sha256
 
-from ..tl.functions.upload import GetCdnFileRequest, ReuploadCdnFileRequest
-from ..tl.types.upload import CdnFileReuploadNeeded, CdnFile
+from .. import _tl
 from ..crypto import AESModeCTR
 from ..errors import CdnFileTamperedError
 
@@ -52,14 +51,14 @@ class CdnDecrypter:
             cdn_aes, cdn_redirect.cdn_file_hashes
         )
 
-        cdn_file = await cdn_client(GetCdnFileRequest(
+        cdn_file = await cdn_client(_tl.fn.upload.GetCdnFile(
             file_token=cdn_redirect.file_token,
             offset=cdn_redirect.cdn_file_hashes[0].offset,
             limit=cdn_redirect.cdn_file_hashes[0].limit
         ))
-        if isinstance(cdn_file, CdnFileReuploadNeeded):
+        if isinstance(cdn_file, _tl.upload.CdnFileReuploadNeeded):
             # We need to use the original client here
-            await client(ReuploadCdnFileRequest(
+            await client(_tl.fn.upload.ReuploadCdnFile(
                 file_token=cdn_redirect.file_token,
                 request_token=cdn_file.request_token
             ))
@@ -82,13 +81,13 @@ class CdnDecrypter:
         """
         if self.cdn_file_hashes:
             cdn_hash = self.cdn_file_hashes.pop(0)
-            cdn_file = self.client(GetCdnFileRequest(
+            cdn_file = self.client(_tl.fn.upload.GetCdnFile(
                 self.file_token, cdn_hash.offset, cdn_hash.limit
             ))
             cdn_file.bytes = self.cdn_aes.encrypt(cdn_file.bytes)
             self.check(cdn_file.bytes, cdn_hash)
         else:
-            cdn_file = CdnFile(bytes(0))
+            cdn_file = _tl.upload.CdnFile(bytes(0))
 
         return cdn_file
 
diff --git a/telethon/_crypto/rsa.py b/telethon/_crypto/rsa.py
index 91ca7bad..d1f1b588 100644
--- a/telethon/_crypto/rsa.py
+++ b/telethon/_crypto/rsa.py
@@ -11,7 +11,7 @@ except ImportError:
     rsa = None
     raise ImportError('Missing module "rsa", please install via pip.')
 
-from ..tl import TLObject
+from .. import _tl
 
 
 # {fingerprint: (Crypto.PublicKey.RSA._RSAobj, old)} dictionary
@@ -41,8 +41,8 @@ def _compute_fingerprint(key):
     :param key: the Crypto.RSA key.
     :return: its 8-bytes-long fingerprint.
     """
-    n = TLObject.serialize_bytes(get_byte_array(key.n))
-    e = TLObject.serialize_bytes(get_byte_array(key.e))
+    n = _tl.TLObject.serialize_bytes(get_byte_array(key.n))
+    e = _tl.TLObject.serialize_bytes(get_byte_array(key.e))
     # Telegram uses the last 8 bytes as the fingerprint
     return struct.unpack(' tag, this  tag is
@@ -69,9 +62,9 @@ class HTMLToTelegramParser(HTMLParser):
                 except KeyError:
                     pass
             except KeyError:
-                EntityType = MessageEntityCode
+                EntityType = _tl.MessageEntityCode
         elif tag == 'pre':
-            EntityType = MessageEntityPre
+            EntityType = _tl.MessageEntityPre
             args['language'] = ''
         elif tag == 'a':
             try:
@@ -80,12 +73,12 @@ class HTMLToTelegramParser(HTMLParser):
                 return
             if url.startswith('mailto:'):
                 url = url[len('mailto:'):]
-                EntityType = MessageEntityEmail
+                EntityType = _tl.MessageEntityEmail
             else:
                 if self.get_starttag_text() == url:
-                    EntityType = MessageEntityUrl
+                    EntityType = _tl.MessageEntityUrl
                 else:
-                    EntityType = MessageEntityTextUrl
+                    EntityType = _tl.MessageEntityTextUrl
                     args['url'] = url
                     url = None
             self._open_tags_meta.popleft()
@@ -121,10 +114,10 @@ class HTMLToTelegramParser(HTMLParser):
             self.entities.append(entity)
 
 
-def parse(html: str) -> Tuple[str, List[TypeMessageEntity]]:
+def parse(html: str) -> Tuple[str, List[_tl.TypeMessageEntity]]:
     """
     Parses the given HTML message and returns its stripped representation
-    plus a list of the MessageEntity's that were found.
+    plus a list of the _tl.MessageEntity's that were found.
 
     :param html: the message with HTML to be parsed.
     :return: a tuple consisting of (clean message, [message entities]).
@@ -138,14 +131,14 @@ def parse(html: str) -> Tuple[str, List[TypeMessageEntity]]:
     return _del_surrogate(text), parser.entities
 
 
-def unparse(text: str, entities: Iterable[TypeMessageEntity], _offset: int = 0,
+def unparse(text: str, entities: Iterable[_tl.TypeMessageEntity], _offset: int = 0,
             _length: Optional[int] = None) -> str:
     """
     Performs the reverse operation to .parse(), effectively returning HTML
-    given a normal text and its MessageEntity's.
+    given a normal text and its _tl.MessageEntity's.
 
     :param text: the text to be reconverted into HTML.
-    :param entities: the MessageEntity's applied to the text.
+    :param entities: the _tl.MessageEntity's applied to the text.
     :return: a HTML representation of the combination of both inputs.
     """
     if not text:
@@ -185,19 +178,19 @@ def unparse(text: str, entities: Iterable[TypeMessageEntity], _offset: int = 0,
                               _offset=entity.offset, _length=length)
         entity_type = type(entity)
 
-        if entity_type == MessageEntityBold:
+        if entity_type == _tl.MessageEntityBold:
             html.append('{}'.format(entity_text))
-        elif entity_type == MessageEntityItalic:
+        elif entity_type == _tl.MessageEntityItalic:
             html.append('{}'.format(entity_text))
-        elif entity_type == MessageEntityCode:
+        elif entity_type == _tl.MessageEntityCode:
             html.append('{}'.format(entity_text))
-        elif entity_type == MessageEntityUnderline:
+        elif entity_type == _tl.MessageEntityUnderline:
             html.append('{}'.format(entity_text))
-        elif entity_type == MessageEntityStrike:
+        elif entity_type == _tl.MessageEntityStrike:
             html.append('{}'.format(entity_text))
-        elif entity_type == MessageEntityBlockquote:
+        elif entity_type == _tl.MessageEntityBlockquote:
             html.append('
{}
'.format(entity_text)) - elif entity_type == MessageEntityPre: + elif entity_type == _tl.MessageEntityPre: if entity.language: html.append( "
\n"
@@ -208,14 +201,14 @@ def unparse(text: str, entities: Iterable[TypeMessageEntity], _offset: int = 0,
             else:
                 html.append('
{}
' .format(entity_text)) - elif entity_type == MessageEntityEmail: + elif entity_type == _tl.MessageEntityEmail: html.append('{0}'.format(entity_text)) - elif entity_type == MessageEntityUrl: + elif entity_type == _tl.MessageEntityUrl: html.append('{0}'.format(entity_text)) - elif entity_type == MessageEntityTextUrl: + elif entity_type == _tl.MessageEntityTextUrl: html.append('{}' .format(escape(entity.url), entity_text)) - elif entity_type == MessageEntityMentionName: + elif entity_type == _tl.MessageEntityMentionName: html.append('{}' .format(entity.user_id, entity_text)) else: diff --git a/telethon/_misc/markdown.py b/telethon/_misc/markdown.py index f6d59106..336da0b9 100644 --- a/telethon/_misc/markdown.py +++ b/telethon/_misc/markdown.py @@ -7,19 +7,14 @@ import re import warnings from ..helpers import add_surrogate, del_surrogate, within_surrogate, strip_text -from ..tl import TLObject -from ..tl.types import ( - MessageEntityBold, MessageEntityItalic, MessageEntityCode, - MessageEntityPre, MessageEntityTextUrl, MessageEntityMentionName, - MessageEntityStrike -) +from .. import _tl DEFAULT_DELIMITERS = { - '**': MessageEntityBold, - '__': MessageEntityItalic, - '~~': MessageEntityStrike, - '`': MessageEntityCode, - '```': MessageEntityPre + '**': _tl.MessageEntityBold, + '__': _tl.MessageEntityItalic, + '~~': _tl.MessageEntityStrike, + '`': _tl.MessageEntityCode, + '```': _tl.MessageEntityPre } DEFAULT_URL_RE = re.compile(r'\[([\S\s]+?)\]\((.+?)\)') @@ -33,7 +28,7 @@ def overlap(a, b, x, y): def parse(message, delimiters=None, url_re=None): """ Parses the given markdown message and returns its stripped representation - plus a list of the MessageEntity's that were found. + plus a list of the _tl.MessageEntity's that were found. :param message: the message with markdown-like syntax to be parsed. :param delimiters: the delimiters to be used, {delimiter: type}. @@ -98,13 +93,13 @@ def parse(message, delimiters=None, url_re=None): # Append the found entity ent = delimiters[delim] - if ent == MessageEntityPre: + if ent == _tl.MessageEntityPre: result.append(ent(i, end - i - len(delim), '')) # has 'lang' else: result.append(ent(i, end - i - len(delim))) # No nested entities inside code blocks - if ent in (MessageEntityCode, MessageEntityPre): + if ent in (_tl.MessageEntityCode, _tl.MessageEntityPre): i = end - len(delim) continue @@ -125,7 +120,7 @@ def parse(message, delimiters=None, url_re=None): if ent.offset + ent.length > m.start(): ent.length -= delim_size - result.append(MessageEntityTextUrl( + result.append(_tl.MessageEntityTextUrl( offset=m.start(), length=len(m.group(1)), url=del_surrogate(m.group(2)) )) @@ -141,10 +136,10 @@ def parse(message, delimiters=None, url_re=None): def unparse(text, entities, delimiters=None, url_fmt=None): """ Performs the reverse operation to .parse(), effectively returning - markdown-like syntax given a normal text and its MessageEntity's. + markdown-like syntax given a normal text and its _tl.MessageEntity's. :param text: the text to be reconverted into markdown. - :param entities: the MessageEntity's applied to the text. + :param entities: the _tl.MessageEntity's applied to the text. :return: a markdown-like text representing the combination of both inputs. """ if not text or not entities: @@ -158,7 +153,7 @@ def unparse(text, entities, delimiters=None, url_fmt=None): if url_fmt is not None: warnings.warn('url_fmt is deprecated') # since it complicates everything *a lot* - if isinstance(entities, TLObject): + if isinstance(entities, _tl.TLObject): entities = (entities,) text = add_surrogate(text) @@ -173,9 +168,9 @@ def unparse(text, entities, delimiters=None, url_fmt=None): insert_at.append((e, delimiter)) else: url = None - if isinstance(entity, MessageEntityTextUrl): + if isinstance(entity, _tl.MessageEntityTextUrl): url = entity.url - elif isinstance(entity, MessageEntityMentionName): + elif isinstance(entity, _tl.MessageEntityMentionName): url = 'tg://user?id={}'.format(entity.user_id) if url: insert_at.append((s, '[')) diff --git a/telethon/_misc/password.py b/telethon/_misc/password.py index 0f950254..e02c8eb8 100644 --- a/telethon/_misc/password.py +++ b/telethon/_misc/password.py @@ -2,7 +2,7 @@ import hashlib import os from .crypto import factorization -from .tl import types +from . import _tl def check_prime_and_good_check(prime: int, g: int): @@ -110,7 +110,7 @@ def pbkdf2sha512(password: bytes, salt: bytes, iterations: int): return hashlib.pbkdf2_hmac('sha512', password, salt, iterations) -def compute_hash(algo: types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow, +def compute_hash(algo: _tl.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow, password: str): hash1 = sha256(algo.salt1, password.encode('utf-8'), algo.salt1) hash2 = sha256(algo.salt2, hash1, algo.salt2) @@ -118,7 +118,7 @@ def compute_hash(algo: types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter1000 return sha256(algo.salt2, hash3, algo.salt2) -def compute_digest(algo: types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow, +def compute_digest(algo: _tl.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow, password: str): try: check_prime_and_good(algo.p, algo.g) @@ -133,9 +133,9 @@ def compute_digest(algo: types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter10 # https://github.com/telegramdesktop/tdesktop/blob/18b74b90451a7db2379a9d753c9cbaf8734b4d5d/Telegram/SourceFiles/core/core_cloud_password.cpp -def compute_check(request: types.account.Password, password: str): +def compute_check(request: _tl.account.Password, password: str): algo = request.current_algo - if not isinstance(algo, types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow): + if not isinstance(algo, _tl.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow): raise ValueError('unsupported password algorithm {}' .format(algo.__class__.__name__)) @@ -190,5 +190,5 @@ def compute_check(request: types.account.Password, password: str): K ) - return types.InputCheckPasswordSRP( + return _tl.InputCheckPasswordSRP( request.srp_id, bytes(a_for_hash), bytes(M1)) diff --git a/telethon/_misc/statecache.py b/telethon/_misc/statecache.py index 0e02bbd4..3f2475bf 100644 --- a/telethon/_misc/statecache.py +++ b/telethon/_misc/statecache.py @@ -1,6 +1,6 @@ import inspect -from .tl import types +from . import _tl # Which updates have the following fields? @@ -9,8 +9,8 @@ _has_channel_id = [] # TODO EntityCache does the same. Reuse? def _fill(): - for name in dir(types): - update = getattr(types, name) + for name in dir(_tl): + update = getattr(_tl, name) if getattr(update, 'SUBCLASS_OF_ID', None) == 0x9f89304e: cid = update.CONSTRUCTOR_ID sig = inspect.signature(update.__init__) @@ -51,41 +51,41 @@ class StateCache: *, channel_id=None, has_pts=frozenset(x.CONSTRUCTOR_ID for x in ( - types.UpdateNewMessage, - types.UpdateDeleteMessages, - types.UpdateReadHistoryInbox, - types.UpdateReadHistoryOutbox, - types.UpdateWebPage, - types.UpdateReadMessagesContents, - types.UpdateEditMessage, - types.updates.State, - types.updates.DifferenceTooLong, - types.UpdateShortMessage, - types.UpdateShortChatMessage, - types.UpdateShortSentMessage + _tl.UpdateNewMessage, + _tl.UpdateDeleteMessages, + _tl.UpdateReadHistoryInbox, + _tl.UpdateReadHistoryOutbox, + _tl.UpdateWebPage, + _tl.UpdateReadMessagesContents, + _tl.UpdateEditMessage, + _tl.updates.State, + _tl.updates.DifferenceTooLong, + _tl.UpdateShortMessage, + _tl.UpdateShortChatMessage, + _tl.UpdateShortSentMessage )), has_date=frozenset(x.CONSTRUCTOR_ID for x in ( - types.UpdateUserPhoto, - types.UpdateEncryption, - types.UpdateEncryptedMessagesRead, - types.UpdateChatParticipantAdd, - types.updates.DifferenceEmpty, - types.UpdateShortMessage, - types.UpdateShortChatMessage, - types.UpdateShort, - types.UpdatesCombined, - types.Updates, - types.UpdateShortSentMessage, + _tl.UpdateUserPhoto, + _tl.UpdateEncryption, + _tl.UpdateEncryptedMessagesRead, + _tl.UpdateChatParticipantAdd, + _tl.updates.DifferenceEmpty, + _tl.UpdateShortMessage, + _tl.UpdateShortChatMessage, + _tl.UpdateShort, + _tl.UpdatesCombined, + _tl.Updates, + _tl.UpdateShortSentMessage, )), has_channel_pts=frozenset(x.CONSTRUCTOR_ID for x in ( - types.UpdateChannelTooLong, - types.UpdateNewChannelMessage, - types.UpdateDeleteChannelMessages, - types.UpdateEditChannelMessage, - types.UpdateChannelWebPage, - types.updates.ChannelDifferenceEmpty, - types.updates.ChannelDifferenceTooLong, - types.updates.ChannelDifference + _tl.UpdateChannelTooLong, + _tl.UpdateNewChannelMessage, + _tl.UpdateDeleteChannelMessages, + _tl.UpdateEditChannelMessage, + _tl.UpdateChannelWebPage, + _tl.updates.ChannelDifferenceEmpty, + _tl.updates.ChannelDifferenceTooLong, + _tl.updates.ChannelDifference )), check_only=False ): @@ -120,8 +120,8 @@ class StateCache: has_channel_id=frozenset(_has_channel_id), # Hardcoded because only some with message are for channels has_message=frozenset(x.CONSTRUCTOR_ID for x in ( - types.UpdateNewChannelMessage, - types.UpdateEditChannelMessage + _tl.UpdateNewChannelMessage, + _tl.UpdateEditChannelMessage )) ): """ diff --git a/telethon/_misc/utils.py b/telethon/_misc/utils.py index e8c59c01..9826e551 100644 --- a/telethon/_misc/utils.py +++ b/telethon/_misc/utils.py @@ -21,7 +21,7 @@ from types import GeneratorType from .extensions import markdown, html from .helpers import add_surrogate, del_surrogate, strip_text -from .tl import types +from . import _tl try: import hachoir @@ -32,26 +32,26 @@ except ImportError: # Register some of the most common mime-types to avoid any issues. # See https://github.com/LonamiWebs/Telethon/issues/1096. -mimetypes.add_type('image/png', '.png') -mimetypes.add_type('image/jpeg', '.jpeg') -mimetypes.add_type('image/webp', '.webp') -mimetypes.add_type('image/gif', '.gif') -mimetypes.add_type('image/bmp', '.bmp') -mimetypes.add_type('image/x-tga', '.tga') -mimetypes.add_type('image/tiff', '.tiff') -mimetypes.add_type('image/vnd.adobe.photoshop', '.psd') +mime_tl.add_type('image/png', '.png') +mime_tl.add_type('image/jpeg', '.jpeg') +mime_tl.add_type('image/webp', '.webp') +mime_tl.add_type('image/gif', '.gif') +mime_tl.add_type('image/bmp', '.bmp') +mime_tl.add_type('image/x-tga', '.tga') +mime_tl.add_type('image/tiff', '.tiff') +mime_tl.add_type('image/vnd.adobe.photoshop', '.psd') -mimetypes.add_type('video/mp4', '.mp4') -mimetypes.add_type('video/quicktime', '.mov') -mimetypes.add_type('video/avi', '.avi') +mime_tl.add_type('video/mp4', '.mp4') +mime_tl.add_type('video/quicktime', '.mov') +mime_tl.add_type('video/avi', '.avi') -mimetypes.add_type('audio/mpeg', '.mp3') -mimetypes.add_type('audio/m4a', '.m4a') -mimetypes.add_type('audio/aac', '.aac') -mimetypes.add_type('audio/ogg', '.ogg') -mimetypes.add_type('audio/flac', '.flac') +mime_tl.add_type('audio/mpeg', '.mp3') +mime_tl.add_type('audio/m4a', '.m4a') +mime_tl.add_type('audio/aac', '.aac') +mime_tl.add_type('audio/ogg', '.ogg') +mime_tl.add_type('audio/flac', '.flac') -mimetypes.add_type('application/x-tgsticker', '.tgs') +mime_tl.add_type('application/x-tgsticker', '.tgs') USERNAME_RE = re.compile( r'@|(?:https?://)?(?:www\.)?(?:telegram\.(?:me|dog)|t\.me)/(@|joinchat/)?' @@ -92,7 +92,7 @@ def get_display_name(entity): Gets the display name for the given :tl:`User`, :tl:`Chat` or :tl:`Channel`. Returns an empty string otherwise. """ - if isinstance(entity, types.User): + if isinstance(entity, _tl.User): if entity.last_name and entity.first_name: return '{} {}'.format(entity.first_name, entity.last_name) elif entity.first_name: @@ -102,7 +102,7 @@ def get_display_name(entity): else: return '' - elif isinstance(entity, (types.Chat, types.ChatForbidden, types.Channel)): + elif isinstance(entity, (_tl.Chat, _tl.ChatForbidden, _tl.Channel)): return entity.title return '' @@ -117,14 +117,14 @@ def get_extension(media): return '.jpg' except TypeError: # These cases are not handled by input photo because it can't - if isinstance(media, (types.UserProfilePhoto, types.ChatPhoto)): + if isinstance(media, (_tl.UserProfilePhoto, _tl.ChatPhoto)): return '.jpg' # Documents will come with a mime type - if isinstance(media, types.MessageMediaDocument): + if isinstance(media, _tl.MessageMediaDocument): media = media.document if isinstance(media, ( - types.Document, types.WebDocument, types.WebDocumentNoProxy)): + _tl.Document, _tl.WebDocument, _tl.WebDocumentNoProxy)): if media.mime_type == 'application/octet-stream': # Octet stream are just bytes, which have no default extension return '' @@ -184,53 +184,53 @@ def get_input_peer(entity, allow_self=True, check_hash=True): else: _raise_cast_fail(entity, 'InputPeer') - if isinstance(entity, types.User): + if isinstance(entity, _tl.User): if entity.is_self and allow_self: - return types.InputPeerSelf() + return _tl.InputPeerSelf() elif (entity.access_hash is not None and not entity.min) or not check_hash: - return types.InputPeerUser(entity.id, entity.access_hash) + return _tl.InputPeerUser(entity.id, entity.access_hash) else: raise TypeError('User without access_hash or min info cannot be input') - if isinstance(entity, (types.Chat, types.ChatEmpty, types.ChatForbidden)): - return types.InputPeerChat(entity.id) + if isinstance(entity, (_tl.Chat, _tl.ChatEmpty, _tl.ChatForbidden)): + return _tl.InputPeerChat(entity.id) - if isinstance(entity, types.Channel): + if isinstance(entity, _tl.Channel): if (entity.access_hash is not None and not entity.min) or not check_hash: - return types.InputPeerChannel(entity.id, entity.access_hash) + return _tl.InputPeerChannel(entity.id, entity.access_hash) else: raise TypeError('Channel without access_hash or min info cannot be input') - if isinstance(entity, types.ChannelForbidden): + if isinstance(entity, _tl.ChannelForbidden): # "channelForbidden are never min", and since their hash is # also not optional, we assume that this truly is the case. - return types.InputPeerChannel(entity.id, entity.access_hash) + return _tl.InputPeerChannel(entity.id, entity.access_hash) - if isinstance(entity, types.InputUser): - return types.InputPeerUser(entity.user_id, entity.access_hash) + if isinstance(entity, _tl.InputUser): + return _tl.InputPeerUser(entity.user_id, entity.access_hash) - if isinstance(entity, types.InputChannel): - return types.InputPeerChannel(entity.channel_id, entity.access_hash) + if isinstance(entity, _tl.InputChannel): + return _tl.InputPeerChannel(entity.channel_id, entity.access_hash) - if isinstance(entity, types.InputUserSelf): - return types.InputPeerSelf() + if isinstance(entity, _tl.InputUserSelf): + return _tl.InputPeerSelf() - if isinstance(entity, types.InputUserFromMessage): - return types.InputPeerUserFromMessage(entity.peer, entity.msg_id, entity.user_id) + if isinstance(entity, _tl.InputUserFromMessage): + return _tl.InputPeerUserFromMessage(entity.peer, entity.msg_id, entity.user_id) - if isinstance(entity, types.InputChannelFromMessage): - return types.InputPeerChannelFromMessage(entity.peer, entity.msg_id, entity.channel_id) + if isinstance(entity, _tl.InputChannelFromMessage): + return _tl.InputPeerChannelFromMessage(entity.peer, entity.msg_id, entity.channel_id) - if isinstance(entity, types.UserEmpty): - return types.InputPeerEmpty() + if isinstance(entity, _tl.UserEmpty): + return _tl.InputPeerEmpty() - if isinstance(entity, types.UserFull): + if isinstance(entity, _tl.UserFull): return get_input_peer(entity.user) - if isinstance(entity, types.ChatFull): - return types.InputPeerChat(entity.id) + if isinstance(entity, _tl.ChatFull): + return _tl.InputPeerChat(entity.id) - if isinstance(entity, types.PeerChat): - return types.InputPeerChat(entity.chat_id) + if isinstance(entity, _tl.PeerChat): + return _tl.InputPeerChat(entity.chat_id) _raise_cast_fail(entity, 'InputPeer') @@ -251,14 +251,14 @@ def get_input_channel(entity): except AttributeError: _raise_cast_fail(entity, 'InputChannel') - if isinstance(entity, (types.Channel, types.ChannelForbidden)): - return types.InputChannel(entity.id, entity.access_hash or 0) + if isinstance(entity, (_tl.Channel, _tl.ChannelForbidden)): + return _tl.InputChannel(entity.id, entity.access_hash or 0) - if isinstance(entity, types.InputPeerChannel): - return types.InputChannel(entity.channel_id, entity.access_hash) + if isinstance(entity, _tl.InputPeerChannel): + return _tl.InputChannel(entity.channel_id, entity.access_hash) - if isinstance(entity, types.InputPeerChannelFromMessage): - return types.InputChannelFromMessage(entity.peer, entity.msg_id, entity.channel_id) + if isinstance(entity, _tl.InputPeerChannelFromMessage): + return _tl.InputChannelFromMessage(entity.peer, entity.msg_id, entity.channel_id) _raise_cast_fail(entity, 'InputChannel') @@ -279,26 +279,26 @@ def get_input_user(entity): except AttributeError: _raise_cast_fail(entity, 'InputUser') - if isinstance(entity, types.User): + if isinstance(entity, _tl.User): if entity.is_self: - return types.InputUserSelf() + return _tl.InputUserSelf() else: - return types.InputUser(entity.id, entity.access_hash or 0) + return _tl.InputUser(entity.id, entity.access_hash or 0) - if isinstance(entity, types.InputPeerSelf): - return types.InputUserSelf() + if isinstance(entity, _tl.InputPeerSelf): + return _tl.InputUserSelf() - if isinstance(entity, (types.UserEmpty, types.InputPeerEmpty)): - return types.InputUserEmpty() + if isinstance(entity, (_tl.UserEmpty, _tl.InputPeerEmpty)): + return _tl.InputUserEmpty() - if isinstance(entity, types.UserFull): + if isinstance(entity, _tl.UserFull): return get_input_user(entity.user) - if isinstance(entity, types.InputPeerUser): - return types.InputUser(entity.user_id, entity.access_hash) + if isinstance(entity, _tl.InputPeerUser): + return _tl.InputUser(entity.user_id, entity.access_hash) - if isinstance(entity, types.InputPeerUserFromMessage): - return types.InputUserFromMessage(entity.peer, entity.msg_id, entity.user_id) + if isinstance(entity, _tl.InputPeerUserFromMessage): + return _tl.InputUserFromMessage(entity.peer, entity.msg_id, entity.user_id) _raise_cast_fail(entity, 'InputUser') @@ -309,12 +309,12 @@ def get_input_dialog(dialog): if dialog.SUBCLASS_OF_ID == 0xa21c9795: # crc32(b'InputDialogPeer') return dialog if dialog.SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer') - return types.InputDialogPeer(dialog) + return _tl.InputDialogPeer(dialog) except AttributeError: _raise_cast_fail(dialog, 'InputDialogPeer') try: - return types.InputDialogPeer(get_input_peer(dialog)) + return _tl.InputDialogPeer(get_input_peer(dialog)) except TypeError: pass @@ -329,18 +329,18 @@ def get_input_document(document): except AttributeError: _raise_cast_fail(document, 'InputDocument') - if isinstance(document, types.Document): - return types.InputDocument( + if isinstance(document, _tl.Document): + return _tl.InputDocument( id=document.id, access_hash=document.access_hash, file_reference=document.file_reference) - if isinstance(document, types.DocumentEmpty): - return types.InputDocumentEmpty() + if isinstance(document, _tl.DocumentEmpty): + return _tl.InputDocumentEmpty() - if isinstance(document, types.MessageMediaDocument): + if isinstance(document, _tl.MessageMediaDocument): return get_input_document(document.document) - if isinstance(document, types.Message): + if isinstance(document, _tl.Message): return get_input_document(document.media) _raise_cast_fail(document, 'InputDocument') @@ -354,32 +354,32 @@ def get_input_photo(photo): except AttributeError: _raise_cast_fail(photo, 'InputPhoto') - if isinstance(photo, types.Message): + if isinstance(photo, _tl.Message): photo = photo.media - if isinstance(photo, (types.photos.Photo, types.MessageMediaPhoto)): + if isinstance(photo, (_tl.photos.Photo, _tl.MessageMediaPhoto)): photo = photo.photo - if isinstance(photo, types.Photo): - return types.InputPhoto(id=photo.id, access_hash=photo.access_hash, + if isinstance(photo, _tl.Photo): + return _tl.InputPhoto(id=photo.id, access_hash=photo.access_hash, file_reference=photo.file_reference) - if isinstance(photo, types.PhotoEmpty): - return types.InputPhotoEmpty() + if isinstance(photo, _tl.PhotoEmpty): + return _tl.InputPhotoEmpty() - if isinstance(photo, types.messages.ChatFull): + if isinstance(photo, _tl.messages.ChatFull): photo = photo.full_chat - if isinstance(photo, types.ChannelFull): + if isinstance(photo, _tl.ChannelFull): return get_input_photo(photo.chat_photo) - elif isinstance(photo, types.UserFull): + elif isinstance(photo, _tl.UserFull): return get_input_photo(photo.profile_photo) - elif isinstance(photo, (types.Channel, types.Chat, types.User)): + elif isinstance(photo, (_tl.Channel, _tl.Chat, _tl.User)): return get_input_photo(photo.photo) - if isinstance(photo, (types.UserEmpty, types.ChatEmpty, - types.ChatForbidden, types.ChannelForbidden)): - return types.InputPhotoEmpty() + if isinstance(photo, (_tl.UserEmpty, _tl.ChatEmpty, + _tl.ChatForbidden, _tl.ChannelForbidden)): + return _tl.InputPhotoEmpty() _raise_cast_fail(photo, 'InputPhoto') @@ -390,15 +390,15 @@ def get_input_chat_photo(photo): if photo.SUBCLASS_OF_ID == 0xd4eb2d74: # crc32(b'InputChatPhoto') return photo elif photo.SUBCLASS_OF_ID == 0xe7655f1f: # crc32(b'InputFile'): - return types.InputChatUploadedPhoto(photo) + return _tl.InputChatUploadedPhoto(photo) except AttributeError: _raise_cast_fail(photo, 'InputChatPhoto') photo = get_input_photo(photo) - if isinstance(photo, types.InputPhoto): - return types.InputChatPhoto(photo) - elif isinstance(photo, types.InputPhotoEmpty): - return types.InputChatPhotoEmpty() + if isinstance(photo, _tl.InputPhoto): + return _tl.InputChatPhoto(photo) + elif isinstance(photo, _tl.InputPhotoEmpty): + return _tl.InputChatPhotoEmpty() _raise_cast_fail(photo, 'InputChatPhoto') @@ -411,16 +411,16 @@ def get_input_geo(geo): except AttributeError: _raise_cast_fail(geo, 'InputGeoPoint') - if isinstance(geo, types.GeoPoint): - return types.InputGeoPoint(lat=geo.lat, long=geo.long) + if isinstance(geo, _tl.GeoPoint): + return _tl.InputGeoPoint(lat=geo.lat, long=geo.long) - if isinstance(geo, types.GeoPointEmpty): - return types.InputGeoPointEmpty() + if isinstance(geo, _tl.GeoPointEmpty): + return _tl.InputGeoPointEmpty() - if isinstance(geo, types.MessageMediaGeo): + if isinstance(geo, _tl.MessageMediaGeo): return get_input_geo(geo.geo) - if isinstance(geo, types.Message): + if isinstance(geo, _tl.Message): return get_input_geo(geo.media) _raise_cast_fail(geo, 'InputGeoPoint') @@ -443,39 +443,39 @@ def get_input_media( if media.SUBCLASS_OF_ID == 0xfaf846f4: # crc32(b'InputMedia') return media elif media.SUBCLASS_OF_ID == 0x846363e0: # crc32(b'InputPhoto') - return types.InputMediaPhoto(media, ttl_seconds=ttl) + return _tl.InputMediaPhoto(media, ttl_seconds=ttl) elif media.SUBCLASS_OF_ID == 0xf33fdb68: # crc32(b'InputDocument') - return types.InputMediaDocument(media, ttl_seconds=ttl) + return _tl.InputMediaDocument(media, ttl_seconds=ttl) except AttributeError: _raise_cast_fail(media, 'InputMedia') - if isinstance(media, types.MessageMediaPhoto): - return types.InputMediaPhoto( + if isinstance(media, _tl.MessageMediaPhoto): + return _tl.InputMediaPhoto( id=get_input_photo(media.photo), ttl_seconds=ttl or media.ttl_seconds ) - if isinstance(media, (types.Photo, types.photos.Photo, types.PhotoEmpty)): - return types.InputMediaPhoto( + if isinstance(media, (_tl.Photo, _tl.photos.Photo, _tl.PhotoEmpty)): + return _tl.InputMediaPhoto( id=get_input_photo(media), ttl_seconds=ttl ) - if isinstance(media, types.MessageMediaDocument): - return types.InputMediaDocument( + if isinstance(media, _tl.MessageMediaDocument): + return _tl.InputMediaDocument( id=get_input_document(media.document), ttl_seconds=ttl or media.ttl_seconds ) - if isinstance(media, (types.Document, types.DocumentEmpty)): - return types.InputMediaDocument( + if isinstance(media, (_tl.Document, _tl.DocumentEmpty)): + return _tl.InputMediaDocument( id=get_input_document(media), ttl_seconds=ttl ) - if isinstance(media, (types.InputFile, types.InputFileBig)): + if isinstance(media, (_tl.InputFile, _tl.InputFileBig)): if is_photo: - return types.InputMediaUploadedPhoto(file=media, ttl_seconds=ttl) + return _tl.InputMediaUploadedPhoto(file=media, ttl_seconds=ttl) else: attrs, mime = get_attributes( media, @@ -485,29 +485,29 @@ def get_input_media( video_note=video_note, supports_streaming=supports_streaming ) - return types.InputMediaUploadedDocument( + return _tl.InputMediaUploadedDocument( file=media, mime_type=mime, attributes=attrs, force_file=force_document, ttl_seconds=ttl) - if isinstance(media, types.MessageMediaGame): - return types.InputMediaGame(id=types.InputGameID( + if isinstance(media, _tl.MessageMediaGame): + return _tl.InputMediaGame(id=_tl.InputGameID( id=media.game.id, access_hash=media.game.access_hash )) - if isinstance(media, types.MessageMediaContact): - return types.InputMediaContact( + if isinstance(media, _tl.MessageMediaContact): + return _tl.InputMediaContact( phone_number=media.phone_number, first_name=media.first_name, last_name=media.last_name, vcard='' ) - if isinstance(media, types.MessageMediaGeo): - return types.InputMediaGeoPoint(geo_point=get_input_geo(media.geo)) + if isinstance(media, _tl.MessageMediaGeo): + return _tl.InputMediaGeoPoint(geo_point=get_input_geo(media.geo)) - if isinstance(media, types.MessageMediaVenue): - return types.InputMediaVenue( + if isinstance(media, _tl.MessageMediaVenue): + return _tl.InputMediaVenue( geo_point=get_input_geo(media.geo), title=media.title, address=media.address, @@ -516,19 +516,19 @@ def get_input_media( venue_type='' ) - if isinstance(media, types.MessageMediaDice): - return types.InputMediaDice(media.emoticon) + if isinstance(media, _tl.MessageMediaDice): + return _tl.InputMediaDice(media.emoticon) if isinstance(media, ( - types.MessageMediaEmpty, types.MessageMediaUnsupported, - types.ChatPhotoEmpty, types.UserProfilePhotoEmpty, - types.ChatPhoto, types.UserProfilePhoto)): - return types.InputMediaEmpty() + _tl.MessageMediaEmpty, _tl.MessageMediaUnsupported, + _tl.ChatPhotoEmpty, _tl.UserProfilePhotoEmpty, + _tl.ChatPhoto, _tl.UserProfilePhoto)): + return _tl.InputMediaEmpty() - if isinstance(media, types.Message): + if isinstance(media, _tl.Message): return get_input_media(media.media, is_photo=is_photo, ttl=ttl) - if isinstance(media, types.MessageMediaPoll): + if isinstance(media, _tl.MessageMediaPoll): if media.poll.quiz: if not media.results.results: # A quiz has correct answers, which we don't know until answered. @@ -539,15 +539,15 @@ def get_input_media( else: correct_answers = None - return types.InputMediaPoll( + return _tl.InputMediaPoll( poll=media.poll, correct_answers=correct_answers, solution=media.results.solution, solution_entities=media.results.solution_entities, ) - if isinstance(media, types.Poll): - return types.InputMediaPoll(media) + if isinstance(media, _tl.Poll): + return _tl.InputMediaPoll(media) _raise_cast_fail(media, 'InputMedia') @@ -556,11 +556,11 @@ def get_input_message(message): """Similar to :meth:`get_input_peer`, but for input messages.""" try: if isinstance(message, int): # This case is really common too - return types.InputMessageID(message) + return _tl.InputMessageID(message) elif message.SUBCLASS_OF_ID == 0x54b6bcc5: # crc32(b'InputMessage'): return message elif message.SUBCLASS_OF_ID == 0x790009e3: # crc32(b'Message'): - return types.InputMessageID(message.id) + return _tl.InputMessageID(message.id) except AttributeError: pass @@ -573,7 +573,7 @@ def get_input_group_call(call): if call.SUBCLASS_OF_ID == 0x58611ab1: # crc32(b'InputGroupCall') return call elif call.SUBCLASS_OF_ID == 0x20b4f320: # crc32(b'GroupCall') - return types.InputGroupCall(id=call.id, access_hash=call.access_hash) + return _tl.InputGroupCall(id=call.id, access_hash=call.access_hash) except AttributeError: _raise_cast_fail(call, 'InputGroupCall') @@ -675,10 +675,10 @@ def get_attributes(file, *, attributes=None, mime_type=None, # Note: ``file.name`` works for :tl:`InputFile` and some `IOBase` streams name = file if isinstance(file, str) else getattr(file, 'name', 'unnamed') if mime_type is None: - mime_type = mimetypes.guess_type(name)[0] + mime_type = mime_tl.guess_type(name)[0] - attr_dict = {types.DocumentAttributeFilename: - types.DocumentAttributeFilename(os.path.basename(name))} + attr_dict = {_tl.DocumentAttributeFilename: + _tl.DocumentAttributeFilename(os.path.basename(name))} if is_audio(file): m = _get_metadata(file) @@ -690,8 +690,8 @@ def get_attributes(file, *, attributes=None, mime_type=None, else: performer = None - attr_dict[types.DocumentAttributeAudio] = \ - types.DocumentAttributeAudio( + attr_dict[_tl.DocumentAttributeAudio] = \ + _tl.DocumentAttributeAudio( voice=voice_note, title=m.get('title') if m.has('title') else None, performer=performer, @@ -702,7 +702,7 @@ def get_attributes(file, *, attributes=None, mime_type=None, if not force_document and is_video(file): m = _get_metadata(file) if m: - doc = types.DocumentAttributeVideo( + doc = _tl.DocumentAttributeVideo( round_message=video_note, w=m.get('width') if m.has('width') else 1, h=m.get('height') if m.has('height') else 1, @@ -719,22 +719,22 @@ def get_attributes(file, *, attributes=None, mime_type=None, if t_m and t_m.has("height"): height = t_m.get("height") - doc = types.DocumentAttributeVideo( + doc = _tl.DocumentAttributeVideo( 0, width, height, round_message=video_note, supports_streaming=supports_streaming) else: - doc = types.DocumentAttributeVideo( + doc = _tl.DocumentAttributeVideo( 0, 1, 1, round_message=video_note, supports_streaming=supports_streaming) - attr_dict[types.DocumentAttributeVideo] = doc + attr_dict[_tl.DocumentAttributeVideo] = doc if voice_note: - if types.DocumentAttributeAudio in attr_dict: - attr_dict[types.DocumentAttributeAudio].voice = True + if _tl.DocumentAttributeAudio in attr_dict: + attr_dict[_tl.DocumentAttributeAudio].voice = True else: - attr_dict[types.DocumentAttributeAudio] = \ - types.DocumentAttributeAudio(0, voice=True) + attr_dict[_tl.DocumentAttributeAudio] = \ + _tl.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 @@ -803,23 +803,23 @@ def _get_file_info(location): except AttributeError: _raise_cast_fail(location, 'InputFileLocation') - if isinstance(location, types.Message): + if isinstance(location, _tl.Message): location = location.media - if isinstance(location, types.MessageMediaDocument): + if isinstance(location, _tl.MessageMediaDocument): location = location.document - elif isinstance(location, types.MessageMediaPhoto): + elif isinstance(location, _tl.MessageMediaPhoto): location = location.photo - if isinstance(location, types.Document): - return _FileInfo(location.dc_id, types.InputDocumentFileLocation( + if isinstance(location, _tl.Document): + return _FileInfo(location.dc_id, _tl.InputDocumentFileLocation( id=location.id, access_hash=location.access_hash, file_reference=location.file_reference, thumb_size='' # Presumably to download one of its thumbnails ), location.size) - elif isinstance(location, types.Photo): - return _FileInfo(location.dc_id, types.InputPhotoFileLocation( + elif isinstance(location, _tl.Photo): + return _FileInfo(location.dc_id, _tl.InputPhotoFileLocation( id=location.id, access_hash=location.access_hash, file_reference=location.file_reference, @@ -860,7 +860,7 @@ def is_image(file): if match: return True else: - return isinstance(resolve_bot_file_id(file), types.Photo) + return isinstance(resolve_bot_file_id(file), _tl.Photo) def is_gif(file): @@ -881,7 +881,7 @@ def is_audio(file): return False else: file = 'a' + ext - return (mimetypes.guess_type(file)[0] or '').startswith('audio/') + return (mime_tl.guess_type(file)[0] or '').startswith('audio/') def is_video(file): @@ -895,7 +895,7 @@ def is_video(file): return False else: file = 'a' + ext - return (mimetypes.guess_type(file)[0] or '').startswith('video/') + return (mime_tl.guess_type(file)[0] or '').startswith('video/') def is_list_like(obj): @@ -971,27 +971,27 @@ def get_peer(peer): elif peer.SUBCLASS_OF_ID == 0x2d45687: return peer elif isinstance(peer, ( - types.contacts.ResolvedPeer, types.InputNotifyPeer, - types.TopPeer, types.Dialog, types.DialogPeer)): + _tl.contacts.ResolvedPeer, _tl.InputNotifyPeer, + _tl.TopPeer, _tl.Dialog, _tl.DialogPeer)): return peer.peer - elif isinstance(peer, types.ChannelFull): - return types.PeerChannel(peer.id) - elif isinstance(peer, types.UserEmpty): - return types.PeerUser(peer.id) - elif isinstance(peer, types.ChatEmpty): - return types.PeerChat(peer.id) + elif isinstance(peer, _tl.ChannelFull): + return _tl.PeerChannel(peer.id) + elif isinstance(peer, _tl.UserEmpty): + return _tl.PeerUser(peer.id) + elif isinstance(peer, _tl.ChatEmpty): + return _tl.PeerChat(peer.id) if peer.SUBCLASS_OF_ID in (0x7d7c6f86, 0xd9c7fc18): # ChatParticipant, ChannelParticipant - return types.PeerUser(peer.user_id) + return _tl.PeerUser(peer.user_id) peer = get_input_peer(peer, allow_self=False, check_hash=False) - if isinstance(peer, (types.InputPeerUser, types.InputPeerUserFromMessage)): - return types.PeerUser(peer.user_id) - elif isinstance(peer, types.InputPeerChat): - return types.PeerChat(peer.chat_id) - elif isinstance(peer, (types.InputPeerChannel, types.InputPeerChannelFromMessage)): - return types.PeerChannel(peer.channel_id) + if isinstance(peer, (_tl.InputPeerUser, _tl.InputPeerUserFromMessage)): + return _tl.PeerUser(peer.user_id) + elif isinstance(peer, _tl.InputPeerChat): + return _tl.PeerChat(peer.chat_id) + elif isinstance(peer, (_tl.InputPeerChannel, _tl.InputPeerChannelFromMessage)): + return _tl.PeerChannel(peer.channel_id) except (AttributeError, TypeError): pass _raise_cast_fail(peer, 'Peer') @@ -1017,7 +1017,7 @@ def get_peer_id(peer, add_mark=True): return peer if add_mark else resolve_id(peer)[0] # Tell the user to use their client to resolve InputPeerSelf if we got one - if isinstance(peer, types.InputPeerSelf): + if isinstance(peer, _tl.InputPeerSelf): _raise_cast_fail(peer, 'int (you might want to use client.get_peer_id)') try: @@ -1025,15 +1025,15 @@ def get_peer_id(peer, add_mark=True): except TypeError: _raise_cast_fail(peer, 'int') - if isinstance(peer, types.PeerUser): + if isinstance(peer, _tl.PeerUser): return peer.user_id - elif isinstance(peer, types.PeerChat): + elif isinstance(peer, _tl.PeerChat): # Check in case the user mixed things up to avoid blowing up if not (0 < peer.chat_id <= 0x7fffffff): peer.chat_id = resolve_id(peer.chat_id)[0] return -peer.chat_id if add_mark else peer.chat_id - else: # if isinstance(peer, types.PeerChannel): + else: # if isinstance(peer, _tl.PeerChannel): # Check in case the user mixed things up to avoid blowing up if not (0 < peer.channel_id <= 0x7fffffff): peer.channel_id = resolve_id(peer.channel_id)[0] @@ -1048,14 +1048,14 @@ def get_peer_id(peer, add_mark=True): def resolve_id(marked_id): """Given a marked ID, returns the original ID and its :tl:`Peer` type.""" if marked_id >= 0: - return marked_id, types.PeerUser + return marked_id, _tl.PeerUser marked_id = -marked_id if marked_id > 1000000000000: marked_id -= 1000000000000 - return marked_id, types.PeerChannel + return marked_id, _tl.PeerChannel else: - return marked_id, types.PeerChat + return marked_id, _tl.PeerChat def _rle_decode(data): @@ -1159,12 +1159,12 @@ def resolve_bot_file_id(file_id): attributes = [] if file_type == 3 or file_type == 9: - attributes.append(types.DocumentAttributeAudio( + attributes.append(_tl.DocumentAttributeAudio( duration=0, voice=file_type == 3 )) elif file_type == 4 or file_type == 13: - attributes.append(types.DocumentAttributeVideo( + attributes.append(_tl.DocumentAttributeVideo( duration=0, w=0, h=0, @@ -1172,14 +1172,14 @@ def resolve_bot_file_id(file_id): )) # elif file_type == 5: # other, cannot know which elif file_type == 8: - attributes.append(types.DocumentAttributeSticker( + attributes.append(_tl.DocumentAttributeSticker( alt='', - stickerset=types.InputStickerSetEmpty() + stickerset=_tl.InputStickerSetEmpty() )) elif file_type == 10: - attributes.append(types.DocumentAttributeAnimated()) + attributes.append(_tl.DocumentAttributeAnimated()) - return types.Document( + return _tl.Document( id=media_id, access_hash=access_hash, date=None, @@ -1210,12 +1210,12 @@ def resolve_bot_file_id(file_id): # Thumbnails (small) always have ID 0; otherwise size 'x' photo_size = 's' if media_id or access_hash else 'x' - return types.Photo( + return _tl.Photo( id=media_id, access_hash=access_hash, file_reference=b'', date=None, - sizes=[types.PhotoSize( + sizes=[_tl.PhotoSize( type=photo_size, w=0, h=0, @@ -1235,21 +1235,21 @@ def pack_bot_file_id(file): If an invalid parameter is given, it will ``return None``. """ - if isinstance(file, types.MessageMediaDocument): + if isinstance(file, _tl.MessageMediaDocument): file = file.document - elif isinstance(file, types.MessageMediaPhoto): + elif isinstance(file, _tl.MessageMediaPhoto): file = file.photo - if isinstance(file, types.Document): + if isinstance(file, _tl.Document): file_type = 5 for attribute in file.attributes: - if isinstance(attribute, types.DocumentAttributeAudio): + if isinstance(attribute, _tl.DocumentAttributeAudio): file_type = 3 if attribute.voice else 9 - elif isinstance(attribute, types.DocumentAttributeVideo): + elif isinstance(attribute, _tl.DocumentAttributeVideo): file_type = 13 if attribute.round_message else 4 - elif isinstance(attribute, types.DocumentAttributeSticker): + elif isinstance(attribute, _tl.DocumentAttributeSticker): file_type = 8 - elif isinstance(attribute, types.DocumentAttributeAnimated): + elif isinstance(attribute, _tl.DocumentAttributeAnimated): file_type = 10 else: continue @@ -1258,9 +1258,9 @@ def pack_bot_file_id(file): return _encode_telegram_base64(_rle_encode(struct.pack( ' 1: return super().filter(event) - class Event(EventCommon, SenderGetter): + class Event(EventCommon, _tl.custom.sendergetter.SenderGetter): """ Represents the event of a new album. @@ -150,7 +148,7 @@ class Album(EventBuilder): """ def __init__(self, messages): message = messages[0] - if not message.out and isinstance(message.peer_id, types.PeerUser): + if not message.out and isinstance(message.peer_id, _tl.PeerUser): # Incoming message (e.g. from a bot) has peer_id=us, and # from_id=bot (the actual "chat" from a user's perspective). chat_peer = message.from_id @@ -160,7 +158,7 @@ class Album(EventBuilder): super().__init__(chat_peer=chat_peer, msg_id=message.id, broadcast=bool(message.post)) - SenderGetter.__init__(self, message.sender_id) + _tl.custom.sendergetter.SenderGetter.__init__(self, message.sender_id) self.messages = messages def _set_client(self, client): diff --git a/telethon/events/callbackquery.py b/telethon/events/callbackquery.py index 94e03b7b..954ccf2d 100644 --- a/telethon/events/callbackquery.py +++ b/telethon/events/callbackquery.py @@ -2,9 +2,7 @@ import re import struct from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils -from ..tl import types, functions -from ..tl.custom.sendergetter import SenderGetter +from .. import utils, _tl @name_inner_event @@ -88,13 +86,13 @@ class CallbackQuery(EventBuilder): @classmethod def build(cls, update, others=None, self_id=None): - if isinstance(update, types.UpdateBotCallbackQuery): + if isinstance(update, _tl.UpdateBotCallbackQuery): return cls.Event(update, update.peer, update.msg_id) - elif isinstance(update, types.UpdateInlineBotCallbackQuery): + elif isinstance(update, _tl.UpdateInlineBotCallbackQuery): # See https://github.com/LonamiWebs/Telethon/pull/1005 # The long message ID is actually just msg_id + peer_id mid, pid = struct.unpack(' Date: Sun, 12 Sep 2021 12:35:48 +0200 Subject: [PATCH 010/131] Adapt generator to new subpackage path --- setup.py | 4 ++-- telethon_generator/generators/tlobject.py | 25 ++++++++++++++--------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/setup.py b/setup.py index 2cb63901..c82d236d 100755 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ FRIENDLY_IN = GENERATOR_DIR / 'data/friendly.csv' TLOBJECT_IN_TLS = [Path(x) for x in GENERATOR_DIR.glob('data/*.tl')] TLOBJECT_OUT = LIBRARY_DIR / '_tl' -IMPORT_DEPTH = 2 +TLOBJECT_MOD = 'telethon._tl' DOCS_IN_RES = GENERATOR_DIR / 'data/html' DOCS_OUT = Path('docs') @@ -94,7 +94,7 @@ def generate(which, action='gen'): if clean: clean_tlobjects(TLOBJECT_OUT) else: - generate_tlobjects(tlobjects, layer, IMPORT_DEPTH, TLOBJECT_OUT) + generate_tlobjects(tlobjects, layer, TLOBJECT_MOD, TLOBJECT_OUT) if 'errors' in which: which.remove('errors') diff --git a/telethon_generator/generators/tlobject.py b/telethon_generator/generators/tlobject.py index 0cc02c88..cc37cb92 100644 --- a/telethon_generator/generators/tlobject.py +++ b/telethon_generator/generators/tlobject.py @@ -52,7 +52,7 @@ BASE_TYPES = ('string', 'bytes', 'int', 'long', 'int128', def _write_modules( - out_dir, depth, kind, namespace_tlobjects, type_constructors): + out_dir, in_mod, kind, namespace_tlobjects, type_constructors): # namespace_tlobjects: {'namespace', [TLObject]} out_dir.mkdir(parents=True, exist_ok=True) for ns, tlobjects in namespace_tlobjects.items(): @@ -60,10 +60,11 @@ def _write_modules( with file.open('w') as f, SourceBuilder(f) as builder: builder.writeln(AUTO_GEN_NOTICE) - builder.writeln('from {}.tl.tlobject import TLObject', '.' * depth) - if kind != 'TLObject': - builder.writeln( - 'from {}.tl.tlobject import {}', '.' * depth, kind) + if kind == 'TLObject': + builder.writeln('from .tlobject import TLObject, TLRequest') + builder.writeln('from . import fn') + else: + builder.writeln('from .. import TLObject, TLRequest') builder.writeln('from typing import Optional, List, ' 'Union, TYPE_CHECKING') @@ -124,7 +125,11 @@ def _write_modules( if not name or name in primitives: continue - import_space = '{}.tl.types'.format('.' * depth) + if kind == 'TLObject': + import_space = '.' + else: + import_space = '..' + if '.' in name: namespace = name.split('.')[0] name = name.split('.')[1] @@ -681,7 +686,7 @@ def _write_all_tlobjects(tlobjects, layer, builder): builder.writeln('}') -def generate_tlobjects(tlobjects, layer, import_depth, output_dir): +def generate_tlobjects(tlobjects, layer, input_mod, output_dir): # Group everything by {namespace: [tlobjects]} to generate __init__.py namespace_functions = defaultdict(list) namespace_types = defaultdict(list) @@ -695,10 +700,10 @@ def generate_tlobjects(tlobjects, layer, import_depth, output_dir): namespace_types[tlobject.namespace].append(tlobject) type_constructors[tlobject.result].append(tlobject) - _write_modules(output_dir / 'fn', import_depth, 'TLRequest', - namespace_functions, type_constructors) - _write_modules(output_dir, import_depth - 1, 'TLObject', + _write_modules(output_dir, input_mod, 'TLObject', namespace_types, type_constructors) + _write_modules(output_dir / 'fn', input_mod + '.fn', 'TLRequest', + namespace_functions, type_constructors) filename = output_dir / 'alltlobjects.py' with filename.open('w') as file: From f222dc167e5e9a737341524cf9ff1f493e761932 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 12 Sep 2021 13:27:13 +0200 Subject: [PATCH 011/131] Fix imports --- .gitignore | 2 +- readthedocs/misc/v2-migration-guide.rst | 2 + telethon/__init__.py | 10 +- telethon/_client/auth.py | 4 +- telethon/_client/bots.py | 5 +- telethon/_client/buttons.py | 10 +- telethon/_client/chats.py | 19 +-- telethon/_client/dialogs.py | 13 +- telethon/_client/downloads.py | 9 +- telethon/_client/messages.py | 8 +- telethon/_client/telegrambaseclient.py | 12 +- telethon/_client/telegramclient.py | 5 +- telethon/_client/uploads.py | 8 +- telethon/_client/users.py | 6 +- telethon/_crypto/authkey.py | 2 +- telethon/_crypto/cdndecrypter.py | 2 +- telethon/_misc/entitycache.py | 3 +- telethon/_misc/hints.py | 4 +- telethon/_misc/markdown.py | 2 +- telethon/_misc/messagepacker.py | 6 +- telethon/_misc/password.py | 4 +- telethon/_misc/statecache.py | 2 +- telethon/_misc/utils.py | 44 +++---- telethon/_network/authenticator.py | 4 +- telethon/_network/connection/tcpmtproxy.py | 2 +- telethon/_network/connection/tcpobfuscated.py | 2 +- telethon/_network/mtprotoplainsender.py | 2 +- telethon/_network/mtprotosender.py | 61 ++++----- telethon/_network/mtprotostate.py | 14 +-- telethon/_tl/core/rpcresult.py | 3 +- telethon/_tl/custom/adminlogevent.py | 4 +- telethon/_tl/custom/button.py | 4 +- telethon/_tl/custom/chatgetter.py | 11 +- telethon/_tl/custom/dialog.py | 18 +-- telethon/_tl/custom/draft.py | 4 +- telethon/_tl/custom/file.py | 36 +++--- telethon/_tl/custom/forward.py | 3 +- telethon/_tl/custom/inlinebuilder.py | 40 +++--- telethon/_tl/custom/inlineresult.py | 4 +- telethon/_tl/custom/inputsizedfile.py | 4 +- telethon/_tl/custom/message.py | 116 +++++++++--------- telethon/_tl/custom/messagebutton.py | 28 ++--- telethon/_tl/custom/participantpermissions.py | 6 +- telethon/_tl/custom/qrlogin.py | 3 +- telethon/_tl/patched/__init__.py | 2 +- telethon/events/album.py | 8 +- telethon/events/callbackquery.py | 8 +- telethon/events/chataction.py | 3 +- telethon/events/common.py | 3 +- telethon/events/inlinequery.py | 8 +- telethon/events/messageread.py | 3 +- telethon/events/newmessage.py | 3 +- telethon/events/raw.py | 2 +- telethon/events/userupdate.py | 8 +- telethon/sessions/memory.py | 3 +- telethon/sessions/sqlite.py | 5 +- telethon/sessions/string.py | 2 +- telethon_generator/generators/docs.py | 2 +- telethon_generator/generators/tlobject.py | 2 +- telethon_generator/parsers/errors.py | 2 +- .../parsers/tlobject/tlobject.py | 2 +- tests/telethon/tl/test_serialization.py | 6 +- 62 files changed, 322 insertions(+), 301 deletions(-) diff --git a/.gitignore b/.gitignore index 65fabceb..1e497d62 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,4 @@ __pycache__/ /docs/ # File used to manually test new changes, contains sensitive data -/example.py +/example*.py diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index de1af171..8f9bab4d 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -49,6 +49,8 @@ The following modules have been moved inside ``_misc``: * ``statecache.py`` * ``utils.py`` +// TODO review telethon/__init__.py isn't exposing more than it should + The TelegramClient is no longer made out of mixins -------------------------------------------------- diff --git a/telethon/__init__.py b/telethon/__init__.py index 335abab6..fa01de5b 100644 --- a/telethon/__init__.py +++ b/telethon/__init__.py @@ -1,6 +1,12 @@ +# Note: the import order matters +from ._misc import helpers # no dependencies +from . import _tl # no dependencies +from ._misc import utils # depends on helpers and _tl +from ._tl import custom # depends on utils +from ._misc import hints # depends on custom + from ._client.telegramclient import TelegramClient -from .network import connection -from ._tl import custom +from ._network import connection from ._tl.custom import Button from . import version, events, utils, errors diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index 859a4e87..ccfaa68b 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -5,7 +5,9 @@ import sys import typing import warnings -from .. import utils, helpers, errors, password as pwd_mod, _tl +from .._misc import utils, helpers, password as pwd_mod +from .. import errors, _tl +from .._tl import custom if typing.TYPE_CHECKING: from .telegramclient import TelegramClient diff --git a/telethon/_client/bots.py b/telethon/_client/bots.py index 0e967ed9..8c2d50fe 100644 --- a/telethon/_client/bots.py +++ b/telethon/_client/bots.py @@ -1,6 +1,7 @@ import typing from .. import hints, _tl +from .._tl import custom if typing.TYPE_CHECKING: from .telegramclient import TelegramClient @@ -13,7 +14,7 @@ async def inline_query( *, entity: 'hints.EntityLike' = None, offset: str = None, - geo_point: '_tl.GeoPoint' = None) -> _tl.custom.InlineResults: + geo_point: '_tl.GeoPoint' = None) -> custom.InlineResults: bot = await self.get_input_entity(bot) if entity: peer = await self.get_input_entity(entity) @@ -28,4 +29,4 @@ async def inline_query( geo_point=geo_point )) - return _tl.custom.InlineResults(self, result, entity=peer if entity else None) + return custom.InlineResults(self, result, entity=peer if entity else None) diff --git a/telethon/_client/buttons.py b/telethon/_client/buttons.py index 5dd9c413..897b4703 100644 --- a/telethon/_client/buttons.py +++ b/telethon/_client/buttons.py @@ -1,6 +1,8 @@ import typing -from .. import utils, hints, _tl +from .._misc import utils, hints +from .. import _tl +from .._tl import custom def build_reply_markup( @@ -30,7 +32,7 @@ def build_reply_markup( for row in buttons: current = [] for button in row: - if isinstance(button, _tl.custom.Button): + if isinstance(button, custom.Button): if button.resize is not None: resize = button.resize if button.single_use is not None: @@ -39,10 +41,10 @@ def build_reply_markup( selective = button.selective button = button.button - elif isinstance(button, _tl.custom.MessageButton): + elif isinstance(button, custom.MessageButton): button = button.button - inline = _tl.custom.Button._is_inline(button) + inline = custom.Button._is_inline(button) is_inline |= inline is_normal |= not inline diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 904bba10..0fb88ed8 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -4,8 +4,9 @@ import itertools import string import typing -from .. import helpers, utils, hints, errors, _tl -from ..requestiter import RequestIter +from .. import hints, errors, _tl +from .._misc import helpers, utils, requestiter +from .._tl import custom if typing.TYPE_CHECKING: from .telegramclient import TelegramClient @@ -92,7 +93,7 @@ class _ChatAction: self._action.progress = 100 * round(current / total) -class _ParticipantsIter(RequestIter): +class _ParticipantsIter(requestiter.RequestIter): async def _init(self, entity, filter, search, aggressive): if isinstance(filter, type): if filter in (_tl.ChannelParticipantsBanned, @@ -246,7 +247,7 @@ class _ParticipantsIter(RequestIter): self.buffer.append(user) -class _AdminLogIter(RequestIter): +class _AdminLogIter(requestiter.RequestIter): async def _init( self, entity, admins, search, min_id, max_id, join, leave, invite, restrict, unrestrict, ban, unban, @@ -301,13 +302,13 @@ class _AdminLogIter(RequestIter): ev.action.message._finish_init( self.client, entities, self.entity) - self.buffer.append(_tl.custom.AdminLogEvent(ev, entities)) + self.buffer.append(custom.AdminLogEvent(ev, entities)) if len(r.events) < self.request.limit: return True -class _ProfilePhotoIter(RequestIter): +class _ProfilePhotoIter(requestiter.RequestIter): async def _init( self, entity, offset, max_id ): @@ -694,7 +695,7 @@ async def get_permissions( self: 'TelegramClient', entity: 'hints.EntityLike', user: 'hints.EntityLike' = None -) -> 'typing.Optional[_tl.custom.ParticipantPermissions]': +) -> 'typing.Optional[custom.ParticipantPermissions]': entity = await self.get_entity(entity) if not user: @@ -715,7 +716,7 @@ async def get_permissions( entity, user )) - return _tl.custom.ParticipantPermissions(participant.participant, False) + return custom.ParticipantPermissions(participant.participant, False) elif helpers._entity_type(entity) == helpers._EntityType.CHAT: chat = await self(_tl.fn.messages.GetFullChat( entity @@ -724,7 +725,7 @@ async def get_permissions( user = await self.get_me(input_peer=True) for participant in chat.full_chat.participants.participants: if participant.user_id == user.user_id: - return _tl.custom.ParticipantPermissions(participant, True) + return custom.ParticipantPermissions(participant, True) raise errors.UserNotParticipantError(None) raise ValueError('You must pass either a channel or a chat') diff --git a/telethon/_client/dialogs.py b/telethon/_client/dialogs.py index aee8861d..bfd76f61 100644 --- a/telethon/_client/dialogs.py +++ b/telethon/_client/dialogs.py @@ -3,8 +3,9 @@ import inspect import itertools import typing -from .. import helpers, utils, hints, errors, _tl -from ..requestiter import RequestIter +from .. import hints, errors, _tl +from .._misc import helpers, utils, requestiter +from .._tl import custom _MAX_CHUNK_SIZE = 100 @@ -23,7 +24,7 @@ def _dialog_message_key(peer, message_id): return (peer.channel_id if isinstance(peer, _tl.PeerChannel) else None), message_id -class _DialogsIter(RequestIter): +class _DialogsIter(requestiter.RequestIter): async def _init( self, offset_date, offset_id, offset_peer, ignore_pinned, ignore_migrated, folder ): @@ -79,7 +80,7 @@ class _DialogsIter(RequestIter): # Real world example: https://t.me/TelethonChat/271471 continue - cd = _tl.custom.Dialog(self.client, d, entities, message) + cd = custom.Dialog(self.client, d, entities, message) if cd.dialog.pts: self.client._channel_pts[cd.id] = cd.dialog.pts @@ -108,7 +109,7 @@ class _DialogsIter(RequestIter): self.request.offset_peer = self.buffer[-1].input_entity -class _DraftsIter(RequestIter): +class _DraftsIter(requestiter.RequestIter): async def _init(self, entities, **kwargs): if not entities: r = await self.client(_tl.fn.messages.GetAllDrafts()) @@ -127,7 +128,7 @@ class _DraftsIter(RequestIter): for x in itertools.chain(r.users, r.chats)} self.buffer.extend( - _tl.custom.Draft(self.client, entities[utils.get_peer_id(d.peer)], d.draft) + custom.Draft(self.client, entities[utils.get_peer_id(d.peer)], d.draft) for d in items ) diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py index 974db2a0..dbb279f3 100644 --- a/telethon/_client/downloads.py +++ b/telethon/_client/downloads.py @@ -6,10 +6,9 @@ import typing import inspect import asyncio -from ..crypto import AES - -from .. import utils, helpers, errors, hints, _tl -from ..requestiter import RequestIter +from .._crypto import AES +from .._misc import utils, helpers, requestiter +from .. import errors, hints, _tl try: import aiohttp @@ -26,7 +25,7 @@ MAX_CHUNK_SIZE = 512 * 1024 # 2021-01-15, users reported that `errors.TimeoutError` can occur while downloading files. TIMED_OUT_SLEEP = 1 -class _DirectDownloadIter(RequestIter): +class _DirectDownloadIter(requestiter.RequestIter): async def _init( self, file, dc_id, offset, stride, chunk_size, request_size, file_size, msg_data ): diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index ba88c665..00ee418a 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -3,8 +3,8 @@ import itertools import typing import warnings -from .. import helpers, utils, errors, hints, _tl -from ..requestiter import RequestIter +from .. import errors, hints, _tl +from .._misc import helpers, utils, requestiter _MAX_CHUNK_SIZE = 100 @@ -12,7 +12,7 @@ if typing.TYPE_CHECKING: from .telegramclient import TelegramClient -class _MessagesIter(RequestIter): +class _MessagesIter(requestiter.RequestIter): """ Common factor for all requests that need to iterate over messages. """ @@ -263,7 +263,7 @@ class _MessagesIter(RequestIter): self.request.offset_rate = getattr(response, 'next_rate', 0) -class _IDsIter(RequestIter): +class _IDsIter(requestiter.RequestIter): async def _init(self, entity, ids): self.total = len(ids) self._ids = list(reversed(ids)) if self.reverse else ids diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 9c5d62d7..c89b1809 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -8,12 +8,10 @@ import time import typing from .. import version, helpers, __name__ as __base_name__, _tl -from ..crypto import rsa -from ..entitycache import EntityCache -from ..extensions import markdown -from ..network import MTProtoSender, Connection, ConnectionTcpFull, TcpMTProxy +from .._crypto import rsa +from .._misc import markdown, entitycache, statecache +from .._network import MTProtoSender, Connection, ConnectionTcpFull, TcpMTProxy from ..sessions import Session, SQLiteSession, MemorySession -from ..statecache import StateCache DEFAULT_DC_ID = 2 DEFAULT_IPV4_IP = '149.154.167.51' @@ -151,7 +149,7 @@ def init( # TODO Session should probably return all cached # info of entities, not just the input versions self.session = session - self._entity_cache = EntityCache() + self._entity_cache = entitycache.EntityCache() self.api_id = int(api_id) self.api_hash = api_hash @@ -259,7 +257,7 @@ def init( # Update state (for catching up after a disconnection) # TODO Get state from channels too - self._state_cache = StateCache( + self._state_cache = statecache.StateCache( self.session.get_update_state(0), self._log) # Some further state for subclasses diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index d3935273..986b89f0 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -9,7 +9,8 @@ from . import ( telegrambaseclient, updates, uploads, users ) from .. import helpers, version, _tl -from ..network import ConnectionTcpFull +from .._tl import custom +from .._network import ConnectionTcpFull from ..events.common import EventBuilder, EventCommon @@ -3390,7 +3391,7 @@ class TelegramClient: await client.send_file(chat, file, progress_callback=callback) # Dices, including dart and other future emoji - from telethon.tl import types + from telethon import _tl await client.send_file(chat, _tl.InputMediaDice('')) await client.send_file(chat, _tl.InputMediaDice('🎯')) diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py index 6c0d8146..db2cdd77 100644 --- a/telethon/_client/uploads.py +++ b/telethon/_client/uploads.py @@ -7,9 +7,11 @@ import re import typing from io import BytesIO -from ..crypto import AES +from .._crypto import AES -from .. import utils, helpers, hints, _tl +from .._misc import utils, helpers +from .. import hints, _tl +from .._tl import custom try: import PIL @@ -361,7 +363,7 @@ async def upload_file( if is_big: return _tl.InputFileBig(file_id, part_count, file_name) else: - return _tl.custom.InputSizedFile( + return custom.InputSizedFile( file_id, part_count, file_name, md5=hash_md5, size=file_size ) diff --git a/telethon/_client/users.py b/telethon/_client/users.py index 6209619a..e493ea61 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -4,9 +4,9 @@ import itertools import time import typing -from .. import errors, helpers, utils, hints, _tl +from .. import errors, hints, _tl +from .._misc import helpers, utils from ..errors import MultiError, RPCError -from ..helpers import retry_range _NOT_A_REQUEST = lambda: TypeError('You can only invoke requests, not types!') @@ -53,7 +53,7 @@ async def call(self: 'TelegramClient', sender, request, ordered=False, flood_sle last_error = None self._last_request = time.time() - for attempt in retry_range(self._request_retries): + for attempt in helpers.retry_range(self._request_retries): try: future = sender.send(request, ordered=ordered) if isinstance(future, list): diff --git a/telethon/_crypto/authkey.py b/telethon/_crypto/authkey.py index 8475ec17..fa6fbb78 100644 --- a/telethon/_crypto/authkey.py +++ b/telethon/_crypto/authkey.py @@ -4,7 +4,7 @@ This module holds the AuthKey class. import struct from hashlib import sha1 -from ..extensions import BinaryReader +from .._misc import BinaryReader class AuthKey: diff --git a/telethon/_crypto/cdndecrypter.py b/telethon/_crypto/cdndecrypter.py index efdc3288..8347a561 100644 --- a/telethon/_crypto/cdndecrypter.py +++ b/telethon/_crypto/cdndecrypter.py @@ -4,7 +4,7 @@ This module holds the CdnDecrypter utility class. from hashlib import sha256 from .. import _tl -from ..crypto import AESModeCTR +from .._crypto import AESModeCTR from ..errors import CdnFileTamperedError diff --git a/telethon/_misc/entitycache.py b/telethon/_misc/entitycache.py index f3116b7d..b6f87697 100644 --- a/telethon/_misc/entitycache.py +++ b/telethon/_misc/entitycache.py @@ -1,7 +1,8 @@ import inspect import itertools -from . import utils, _tl +from .._misc import utils +from .. import _tl # Which updates have the following fields? _has_field = { diff --git a/telethon/_misc/hints.py b/telethon/_misc/hints.py index 7b1ec5ae..a5299a25 100644 --- a/telethon/_misc/hints.py +++ b/telethon/_misc/hints.py @@ -1,7 +1,9 @@ import datetime import typing -from . import helpers, _tl +from . import helpers +from .. import _tl +from .._tl import custom Phone = str Username = str diff --git a/telethon/_misc/markdown.py b/telethon/_misc/markdown.py index 336da0b9..b9661af3 100644 --- a/telethon/_misc/markdown.py +++ b/telethon/_misc/markdown.py @@ -6,7 +6,7 @@ since they seem to count as two characters and it's a bit strange. import re import warnings -from ..helpers import add_surrogate, del_surrogate, within_surrogate, strip_text +from .helpers import add_surrogate, del_surrogate, within_surrogate, strip_text from .. import _tl DEFAULT_DELIMITERS = { diff --git a/telethon/_misc/messagepacker.py b/telethon/_misc/messagepacker.py index c0f46f48..f4efb1ac 100644 --- a/telethon/_misc/messagepacker.py +++ b/telethon/_misc/messagepacker.py @@ -3,9 +3,9 @@ import collections import io import struct -from ..tl import TLRequest -from ..tl.core.messagecontainer import MessageContainer -from ..tl.core.tlmessage import TLMessage +from .._tl import TLRequest +from .._tl.core.messagecontainer import MessageContainer +from .._tl.core.tlmessage import TLMessage class MessagePacker: diff --git a/telethon/_misc/password.py b/telethon/_misc/password.py index e02c8eb8..b18e6b10 100644 --- a/telethon/_misc/password.py +++ b/telethon/_misc/password.py @@ -1,8 +1,8 @@ import hashlib import os -from .crypto import factorization -from . import _tl +from .._crypto import factorization +from .. import _tl def check_prime_and_good_check(prime: int, g: int): diff --git a/telethon/_misc/statecache.py b/telethon/_misc/statecache.py index 3f2475bf..7f3ddf59 100644 --- a/telethon/_misc/statecache.py +++ b/telethon/_misc/statecache.py @@ -1,6 +1,6 @@ import inspect -from . import _tl +from .. import _tl # Which updates have the following fields? diff --git a/telethon/_misc/utils.py b/telethon/_misc/utils.py index 9826e551..956a154d 100644 --- a/telethon/_misc/utils.py +++ b/telethon/_misc/utils.py @@ -19,9 +19,9 @@ from collections import namedtuple from mimetypes import guess_extension from types import GeneratorType -from .extensions import markdown, html from .helpers import add_surrogate, del_surrogate, strip_text -from . import _tl +from . import markdown, html +from .. import _tl try: import hachoir @@ -32,26 +32,26 @@ except ImportError: # Register some of the most common mime-types to avoid any issues. # See https://github.com/LonamiWebs/Telethon/issues/1096. -mime_tl.add_type('image/png', '.png') -mime_tl.add_type('image/jpeg', '.jpeg') -mime_tl.add_type('image/webp', '.webp') -mime_tl.add_type('image/gif', '.gif') -mime_tl.add_type('image/bmp', '.bmp') -mime_tl.add_type('image/x-tga', '.tga') -mime_tl.add_type('image/tiff', '.tiff') -mime_tl.add_type('image/vnd.adobe.photoshop', '.psd') +mimetypes.add_type('image/png', '.png') +mimetypes.add_type('image/jpeg', '.jpeg') +mimetypes.add_type('image/webp', '.webp') +mimetypes.add_type('image/gif', '.gif') +mimetypes.add_type('image/bmp', '.bmp') +mimetypes.add_type('image/x-tga', '.tga') +mimetypes.add_type('image/tiff', '.tiff') +mimetypes.add_type('image/vnd.adobe.photoshop', '.psd') -mime_tl.add_type('video/mp4', '.mp4') -mime_tl.add_type('video/quicktime', '.mov') -mime_tl.add_type('video/avi', '.avi') +mimetypes.add_type('video/mp4', '.mp4') +mimetypes.add_type('video/quicktime', '.mov') +mimetypes.add_type('video/avi', '.avi') -mime_tl.add_type('audio/mpeg', '.mp3') -mime_tl.add_type('audio/m4a', '.m4a') -mime_tl.add_type('audio/aac', '.aac') -mime_tl.add_type('audio/ogg', '.ogg') -mime_tl.add_type('audio/flac', '.flac') +mimetypes.add_type('audio/mpeg', '.mp3') +mimetypes.add_type('audio/m4a', '.m4a') +mimetypes.add_type('audio/aac', '.aac') +mimetypes.add_type('audio/ogg', '.ogg') +mimetypes.add_type('audio/flac', '.flac') -mime_tl.add_type('application/x-tgsticker', '.tgs') +mimetypes.add_type('application/x-tgsticker', '.tgs') USERNAME_RE = re.compile( r'@|(?:https?://)?(?:www\.)?(?:telegram\.(?:me|dog)|t\.me)/(@|joinchat/)?' @@ -675,7 +675,7 @@ def get_attributes(file, *, attributes=None, mime_type=None, # Note: ``file.name`` works for :tl:`InputFile` and some `IOBase` streams name = file if isinstance(file, str) else getattr(file, 'name', 'unnamed') if mime_type is None: - mime_type = mime_tl.guess_type(name)[0] + mime_type = mimetypes.guess_type(name)[0] attr_dict = {_tl.DocumentAttributeFilename: _tl.DocumentAttributeFilename(os.path.basename(name))} @@ -881,7 +881,7 @@ def is_audio(file): return False else: file = 'a' + ext - return (mime_tl.guess_type(file)[0] or '').startswith('audio/') + return (mimetypes.guess_type(file)[0] or '').startswith('audio/') def is_video(file): @@ -895,7 +895,7 @@ def is_video(file): return False else: file = 'a' + ext - return (mime_tl.guess_type(file)[0] or '').startswith('video/') + return (mimetypes.guess_type(file)[0] or '').startswith('video/') def is_list_like(obj): diff --git a/telethon/_network/authenticator.py b/telethon/_network/authenticator.py index 04b6f5e3..f5b3591c 100644 --- a/telethon/_network/authenticator.py +++ b/telethon/_network/authenticator.py @@ -7,9 +7,9 @@ import time from hashlib import sha1 from .. import helpers, _tl -from ..crypto import AES, AuthKey, Factorization, rsa +from .._crypto import AES, AuthKey, Factorization, rsa from ..errors import SecurityError -from ..extensions import BinaryReader +from .._misc import BinaryReader async def do_authentication(sender): diff --git a/telethon/_network/connection/tcpmtproxy.py b/telethon/_network/connection/tcpmtproxy.py index 69a43bce..db18a61c 100644 --- a/telethon/_network/connection/tcpmtproxy.py +++ b/telethon/_network/connection/tcpmtproxy.py @@ -9,7 +9,7 @@ from .tcpintermediate import ( RandomizedIntermediatePacketCodec ) -from ...crypto import AESModeCTR +from ..._crypto import AESModeCTR class MTProxyIO: diff --git a/telethon/_network/connection/tcpobfuscated.py b/telethon/_network/connection/tcpobfuscated.py index cf2e6af5..2aeeeac1 100644 --- a/telethon/_network/connection/tcpobfuscated.py +++ b/telethon/_network/connection/tcpobfuscated.py @@ -3,7 +3,7 @@ import os from .tcpabridged import AbridgedPacketCodec from .connection import ObfuscatedConnection -from ...crypto import AESModeCTR +from ..._crypto import AESModeCTR class ObfuscatedIO: diff --git a/telethon/_network/mtprotoplainsender.py b/telethon/_network/mtprotoplainsender.py index 563affd7..433c3795 100644 --- a/telethon/_network/mtprotoplainsender.py +++ b/telethon/_network/mtprotoplainsender.py @@ -6,7 +6,7 @@ import struct from .mtprotostate import MTProtoState from ..errors import InvalidBufferError -from ..extensions import BinaryReader +from .._misc import BinaryReader class MTProtoPlainSender: diff --git a/telethon/_network/mtprotosender.py b/telethon/_network/mtprotosender.py index ca592ac0..59f14a7a 100644 --- a/telethon/_network/mtprotosender.py +++ b/telethon/_network/mtprotosender.py @@ -3,27 +3,20 @@ import collections import struct from . import authenticator -from ..extensions.messagepacker import MessagePacker +from .._misc.messagepacker import MessagePacker from .mtprotoplainsender import MTProtoPlainSender from .requeststate import RequestState from .mtprotostate import MTProtoState -from ..tl.tlobject import TLRequest -from .. import helpers, utils +from .._tl.tlobject import TLRequest +from .. import helpers, utils, _tl from ..errors import ( BadMessageError, InvalidBufferError, SecurityError, TypeNotFoundError, rpc_message_to_error ) -from ..extensions import BinaryReader -from ..tl.core import RpcResult, MessageContainer, GzipPacked -from ..tl.functions.auth import LogOutRequest -from ..tl.functions import PingRequest, DestroySessionRequest -from ..tl.types import ( - MsgsAck, Pong, BadServerSalt, BadMsgNotification, FutureSalts, - MsgNewDetailedInfo, NewSessionCreated, MsgDetailedInfo, MsgsStateReq, - MsgsStateInfo, MsgsAllInfo, MsgResendReq, upload, DestroySessionOk, DestroySessionNone, -) -from ..crypto import AuthKey -from ..helpers import retry_range +from .._misc import BinaryReader +from .._tl.core import RpcResult, MessageContainer, GzipPacked +from .._crypto import AuthKey +from .._misc.helpers import retry_range class MTProtoSender: @@ -97,19 +90,19 @@ class MTProtoSender: 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, - DestroySessionOk: self._handle_destroy_session, - DestroySessionNone: self._handle_destroy_session, + _tl.Pong.CONSTRUCTOR_ID: self._handle_pong, + _tl.BadServerSalt.CONSTRUCTOR_ID: self._handle_bad_server_salt, + _tl.BadMsgNotification.CONSTRUCTOR_ID: self._handle_bad_notification, + _tl.MsgDetailedInfo.CONSTRUCTOR_ID: self._handle_detailed_info, + _tl.MsgNewDetailedInfo.CONSTRUCTOR_ID: self._handle_new_detailed_info, + _tl.NewSessionCreated.CONSTRUCTOR_ID: self._handle_new_session_created, + _tl.MsgsAck.CONSTRUCTOR_ID: self._handle_ack, + _tl.FutureSalts.CONSTRUCTOR_ID: self._handle_future_salts, + _tl.MsgsStateReq.CONSTRUCTOR_ID: self._handle_state_forgotten, + _tl.MsgResendReq.CONSTRUCTOR_ID: self._handle_state_forgotten, + _tl.MsgsAllInfo.CONSTRUCTOR_ID: self._handle_msg_all, + _tl.DestroySessionOk: self._handle_destroy_session, + _tl.DestroySessionNone: self._handle_destroy_session, } # Public API @@ -433,7 +426,7 @@ class MTProtoSender: # TODO this is ugly, update loop shouldn't worry about this, sender should if self._ping is None: self._ping = rnd_id - self.send(PingRequest(rnd_id)) + self.send(_tl.fn.Ping(rnd_id)) else: self._start_reconnect(None) @@ -448,7 +441,7 @@ class MTProtoSender: """ while self._user_connected and not self._reconnecting: if self._pending_ack: - ack = RequestState(MsgsAck(list(self._pending_ack))) + ack = RequestState(_tl.MsgsAck(list(self._pending_ack))) self._send_queue.append(ack) self._last_acks.append(ack) self._pending_ack.clear() @@ -598,7 +591,7 @@ class MTProtoSender: # which contain the real response right after. try: with BinaryReader(rpc_result.body) as reader: - if not isinstance(reader.tgread_object(), upload.File): + if not isinstance(reader.tgread_object(), _tl.upload.File): raise ValueError('Not an upload.File') except (TypeNotFoundError, ValueError): self._log.info('Received response without parent request: %s', rpc_result.body) @@ -607,7 +600,7 @@ class MTProtoSender: if rpc_result.error: error = rpc_message_to_error(rpc_result.error, state.request) self._send_queue.append( - RequestState(MsgsAck([state.msg_id]))) + RequestState(_tl.MsgsAck([state.msg_id]))) if not state.future.cancelled(): state.future.set_exception(error) @@ -777,7 +770,7 @@ class MTProtoSender: self._log.debug('Handling acknowledge for %s', str(ack.msg_ids)) for msg_id in ack.msg_ids: state = self._pending_state.get(msg_id) - if state and isinstance(state.request, LogOutRequest): + if state and isinstance(state.request, _tl.fn.auth.LogOut): del self._pending_state[msg_id] if not state.future.cancelled(): state.future.set_result(True) @@ -802,7 +795,7 @@ class MTProtoSender: Handles both :tl:`MsgsStateReq` and :tl:`MsgResendReq` by enqueuing a :tl:`MsgsStateInfo` to be sent at a later point. """ - self._send_queue.append(RequestState(MsgsStateInfo( + self._send_queue.append(RequestState(_tl.MsgsStateInfo( req_msg_id=message.msg_id, info=chr(1) * len(message.obj.msg_ids) ))) @@ -817,7 +810,7 @@ class MTProtoSender: It behaves pretty much like handling an RPC result. """ for msg_id, state in self._pending_state.items(): - if isinstance(state.request, DestroySessionRequest)\ + if isinstance(state.request, _tl.fn.DestroySession)\ and state.request.session_id == message.obj.session_id: break else: diff --git a/telethon/_network/mtprotostate.py b/telethon/_network/mtprotostate.py index 0fe9cc08..e2c6c21a 100644 --- a/telethon/_network/mtprotostate.py +++ b/telethon/_network/mtprotostate.py @@ -3,13 +3,13 @@ import struct import time from hashlib import sha256 -from ..crypto import AES +from .._crypto import AES from ..errors import SecurityError, InvalidBufferError -from ..extensions import BinaryReader -from ..tl.core import TLMessage -from ..tl.tlobject import TLRequest -from ..tl.functions import InvokeAfterMsgRequest -from ..tl.core.gzippacked import GzipPacked +from .._misc import BinaryReader +from .._tl.core import TLMessage +from .._tl.tlobject import TLRequest +from .. import _tl +from .._tl.core.gzippacked import GzipPacked class _OpaqueRequest(TLRequest): @@ -103,7 +103,7 @@ class MTProtoState: # The `RequestState` stores `bytes(request)`, not the request itself. # `invokeAfterMsg` wants a `TLRequest` though, hence the wrapping. body = GzipPacked.gzip_if_smaller(content_related, - bytes(InvokeAfterMsgRequest(after_id, _OpaqueRequest(data)))) + bytes(_tl.fn.InvokeAfterMsgRequest(after_id, _OpaqueRequest(data)))) buffer.write(struct.pack('`). """ - return isinstance(self._chat_peer, types.PeerUser) if self._chat_peer else None + return isinstance(self._chat_peer, _tl.PeerUser) if self._chat_peer else None @property def is_group(self): @@ -128,20 +127,20 @@ class ChatGetter(abc.ABC): if self._broadcast is None and hasattr(self.chat, 'broadcast'): self._broadcast = bool(self.chat.broadcast) - if isinstance(self._chat_peer, types.PeerChannel): + if isinstance(self._chat_peer, _tl.PeerChannel): if self._broadcast is None: return None else: return not self._broadcast - return isinstance(self._chat_peer, types.PeerChat) + return isinstance(self._chat_peer, _tl.PeerChat) @property def is_channel(self): """`True` if the message was sent on a megagroup or channel.""" # The only case where chat peer could be none is in MessageDeleted, # however those always have the peer in channels. - return isinstance(self._chat_peer, types.PeerChannel) + return isinstance(self._chat_peer, _tl.PeerChannel) async def _refetch_chat(self): """ diff --git a/telethon/_tl/custom/dialog.py b/telethon/_tl/custom/dialog.py index 955bbdf2..cc307b4b 100644 --- a/telethon/_tl/custom/dialog.py +++ b/telethon/_tl/custom/dialog.py @@ -1,6 +1,6 @@ from . import Draft -from .. import TLObject, types, functions -from ... import utils +from ... import _tl +from ..._misc import utils class Dialog: @@ -89,12 +89,12 @@ class Dialog: self.draft = Draft(client, self.entity, self.dialog.draft) - self.is_user = isinstance(self.entity, types.User) + self.is_user = isinstance(self.entity, _tl.User) self.is_group = ( - isinstance(self.entity, (types.Chat, types.ChatForbidden)) or - (isinstance(self.entity, types.Channel) and self.entity.megagroup) + isinstance(self.entity, (_tl.Chat, _tl.ChatForbidden)) or + (isinstance(self.entity, _tl.Channel) and self.entity.megagroup) ) - self.is_channel = isinstance(self.entity, types.Channel) + self.is_channel = isinstance(self.entity, _tl.Channel) async def send_message(self, *args, **kwargs): """ @@ -141,7 +141,7 @@ class Dialog: dialog.archive(0) """ return await self._client(_tl.fn.folders.EditPeerFolders([ - types.InputFolderPeer(self.input_entity, folder_id=folder) + _tl.InputFolderPeer(self.input_entity, folder_id=folder) ])) def to_dict(self): @@ -155,7 +155,7 @@ class Dialog: } def __str__(self): - return TLObject.pretty_format(self.to_dict()) + return _tl.TLObject.pretty_format(self.to_dict()) def stringify(self): - return TLObject.pretty_format(self.to_dict(), indent=0) + return _tl.TLObject.pretty_format(self.to_dict(), indent=0) diff --git a/telethon/_tl/custom/draft.py b/telethon/_tl/custom/draft.py index ed6360b0..fb1df26f 100644 --- a/telethon/_tl/custom/draft.py +++ b/telethon/_tl/custom/draft.py @@ -2,8 +2,8 @@ import datetime from ... import _tl from ...errors import RPCError -from ...extensions import markdown -from ...utils import get_input_peer, get_peer +from ..._misc import markdown +from ..._misc.utils import get_input_peer, get_peer class Draft: diff --git a/telethon/_tl/custom/file.py b/telethon/_tl/custom/file.py index 210eb53d..228727ea 100644 --- a/telethon/_tl/custom/file.py +++ b/telethon/_tl/custom/file.py @@ -1,8 +1,8 @@ import mimetypes import os -from ... import utils -from ...tl import types +from ..._misc import utils +from ... import _tl class File: @@ -38,7 +38,7 @@ class File: """ The file name of this document. """ - return self._from_attr(types.DocumentAttributeFilename, 'file_name') + return self._from_attr(_tl.DocumentAttributeFilename, 'file_name') @property def ext(self): @@ -49,7 +49,7 @@ class File: from the file name (if any) will be used. """ return ( - mimetypes.guess_extension(self.mime_type) + mime_tl.guess_extension(self.mime_type) or os.path.splitext(self.name or '')[-1] or None ) @@ -59,9 +59,9 @@ class File: """ The mime-type of this file. """ - if isinstance(self.media, types.Photo): + if isinstance(self.media, _tl.Photo): return 'image/jpeg' - elif isinstance(self.media, types.Document): + elif isinstance(self.media, _tl.Document): return self.media.mime_type @property @@ -69,22 +69,22 @@ class File: """ The width in pixels of this media if it's a photo or a video. """ - if isinstance(self.media, types.Photo): + if isinstance(self.media, _tl.Photo): return max(getattr(s, 'w', 0) for s in self.media.sizes) return self._from_attr(( - types.DocumentAttributeImageSize, types.DocumentAttributeVideo), 'w') + _tl.DocumentAttributeImageSize, _tl.DocumentAttributeVideo), 'w') @property def height(self): """ The height in pixels of this media if it's a photo or a video. """ - if isinstance(self.media, types.Photo): + if isinstance(self.media, _tl.Photo): return max(getattr(s, 'h', 0) for s in self.media.sizes) return self._from_attr(( - types.DocumentAttributeImageSize, types.DocumentAttributeVideo), 'h') + _tl.DocumentAttributeImageSize, _tl.DocumentAttributeVideo), 'h') @property def duration(self): @@ -92,35 +92,35 @@ class File: The duration in seconds of the audio or video. """ return self._from_attr(( - types.DocumentAttributeAudio, types.DocumentAttributeVideo), 'duration') + _tl.DocumentAttributeAudio, _tl.DocumentAttributeVideo), 'duration') @property def title(self): """ The title of the song. """ - return self._from_attr(types.DocumentAttributeAudio, 'title') + return self._from_attr(_tl.DocumentAttributeAudio, 'title') @property def performer(self): """ The performer of the song. """ - return self._from_attr(types.DocumentAttributeAudio, 'performer') + return self._from_attr(_tl.DocumentAttributeAudio, 'performer') @property def emoji(self): """ A string with all emoji that represent the current sticker. """ - return self._from_attr(types.DocumentAttributeSticker, 'alt') + return self._from_attr(_tl.DocumentAttributeSticker, 'alt') @property def sticker_set(self): """ The :tl:`InputStickerSet` to which the sticker file belongs. """ - return self._from_attr(types.DocumentAttributeSticker, 'stickerset') + return self._from_attr(_tl.DocumentAttributeSticker, 'stickerset') @property def size(self): @@ -129,13 +129,13 @@ class File: For photos, this is the heaviest thumbnail, as it often repressents the largest dimensions. """ - if isinstance(self.media, types.Photo): + if isinstance(self.media, _tl.Photo): return max(filter(None, map(utils._photo_size_byte_count, self.media.sizes)), default=None) - elif isinstance(self.media, types.Document): + elif isinstance(self.media, _tl.Document): return self.media.size def _from_attr(self, cls, field): - if isinstance(self.media, types.Document): + if isinstance(self.media, _tl.Document): for attr in self.media.attributes: if isinstance(attr, cls): return getattr(attr, field, None) diff --git a/telethon/_tl/custom/forward.py b/telethon/_tl/custom/forward.py index a95eae30..d6a46cb7 100644 --- a/telethon/_tl/custom/forward.py +++ b/telethon/_tl/custom/forward.py @@ -1,7 +1,6 @@ from .chatgetter import ChatGetter from .sendergetter import SenderGetter -from ... import utils, helpers -from ...tl import types +from ..._misc import utils, helpers class Forward(ChatGetter, SenderGetter): diff --git a/telethon/_tl/custom/inlinebuilder.py b/telethon/_tl/custom/inlinebuilder.py index f3851f35..b401ab04 100644 --- a/telethon/_tl/custom/inlinebuilder.py +++ b/telethon/_tl/custom/inlinebuilder.py @@ -1,7 +1,7 @@ import hashlib -from .. import functions, types -from ... import utils +from ... import _tl +from ..._misc import utils _TYPE_TO_MIMES = { 'gif': ['image/gif'], # 'video/mp4' too, but that's used for video @@ -126,7 +126,7 @@ class InlineBuilder: # TODO Does 'article' work always? # article, photo, gif, mpeg4_gif, video, audio, # voice, document, location, venue, contact, game - result = types.InputBotInlineResult( + result = _tl.InputBotInlineResult( id=id or '', type='article', send_message=await self._message( @@ -194,15 +194,15 @@ class InlineBuilder: _, media, _ = await self._client._file_to_media( file, allow_cache=True, as_image=True ) - if isinstance(media, types.InputPhoto): + if isinstance(media, _tl.InputPhoto): fh = media else: r = await self._client(_tl.fn.messages.UploadMedia( - types.InputPeerSelf(), media=media + _tl.InputPeerSelf(), media=media )) fh = utils.get_input_photo(r.photo) - result = types.InputBotInlineResultPhoto( + result = _tl.InputBotInlineResultPhoto( id=id or '', type='photo', photo=fh, @@ -314,15 +314,15 @@ class InlineBuilder: video_note=video_note, allow_cache=use_cache ) - if isinstance(media, types.InputDocument): + if isinstance(media, _tl.InputDocument): fh = media else: r = await self._client(_tl.fn.messages.UploadMedia( - types.InputPeerSelf(), media=media + _tl.InputPeerSelf(), media=media )) fh = utils.get_input_document(r.document) - result = types.InputBotInlineResultDocument( + result = _tl.InputBotInlineResultDocument( id=id or '', type=type, document=fh, @@ -361,7 +361,7 @@ class InlineBuilder: short_name (`str`): The short name of the game to use. """ - result = types.InputBotInlineResultGame( + result = _tl.InputBotInlineResultGame( id=id or '', short_name=short_name, send_message=await self._message( @@ -400,31 +400,31 @@ class InlineBuilder: # "MediaAuto" means it will use whatever media the inline # result itself has (stickers, photos, or documents), while # respecting the user's text (caption) and formatting. - return types.InputBotInlineMessageMediaAuto( + return _tl.InputBotInlineMessageMediaAuto( message=text, entities=msg_entities, reply_markup=markup ) else: - return types.InputBotInlineMessageText( + return _tl.InputBotInlineMessageText( message=text, no_webpage=not link_preview, entities=msg_entities, reply_markup=markup ) - elif isinstance(geo, (types.InputGeoPoint, types.GeoPoint)): - return types.InputBotInlineMessageMediaGeo( + elif isinstance(geo, (_tl.InputGeoPoint, _tl.GeoPoint)): + return _tl.InputBotInlineMessageMediaGeo( geo_point=utils.get_input_geo(geo), period=period, reply_markup=markup ) - elif isinstance(geo, (types.InputMediaVenue, types.MessageMediaVenue)): - if isinstance(geo, types.InputMediaVenue): + elif isinstance(geo, (_tl.InputMediaVenue, _tl.MessageMediaVenue)): + if isinstance(geo, _tl.InputMediaVenue): geo_point = geo.geo_point else: geo_point = geo.geo - return types.InputBotInlineMessageMediaVenue( + return _tl.InputBotInlineMessageMediaVenue( geo_point=geo_point, title=geo.title, address=geo.address, @@ -434,8 +434,8 @@ class InlineBuilder: reply_markup=markup ) elif isinstance(contact, ( - types.InputMediaContact, types.MessageMediaContact)): - return types.InputBotInlineMessageMediaContact( + _tl.InputMediaContact, _tl.MessageMediaContact)): + return _tl.InputBotInlineMessageMediaContact( phone_number=contact.phone_number, first_name=contact.first_name, last_name=contact.last_name, @@ -443,7 +443,7 @@ class InlineBuilder: reply_markup=markup ) elif game: - return types.InputBotInlineMessageGame( + return _tl.InputBotInlineMessageGame( reply_markup=markup ) else: diff --git a/telethon/_tl/custom/inlineresult.py b/telethon/_tl/custom/inlineresult.py index eefbd7b4..fa617af1 100644 --- a/telethon/_tl/custom/inlineresult.py +++ b/telethon/_tl/custom/inlineresult.py @@ -1,5 +1,5 @@ -from .. import types, functions -from ... import utils +from ... import _tl +from ..._misc import utils class InlineResult: diff --git a/telethon/_tl/custom/inputsizedfile.py b/telethon/_tl/custom/inputsizedfile.py index fcb743f6..4183ecb7 100644 --- a/telethon/_tl/custom/inputsizedfile.py +++ b/telethon/_tl/custom/inputsizedfile.py @@ -1,7 +1,7 @@ -from ..types import InputFile +from ... import _tl -class InputSizedFile(InputFile): +class InputSizedFile(_tl.InputFile): """InputFile class with two extra parameters: md5 (digest) and size""" def __init__(self, id_, parts, name, md5, size): super().__init__(id_, parts, name, md5.hexdigest()) diff --git a/telethon/_tl/custom/message.py b/telethon/_tl/custom/message.py index fb134ac3..7672de2c 100644 --- a/telethon/_tl/custom/message.py +++ b/telethon/_tl/custom/message.py @@ -5,13 +5,13 @@ from .sendergetter import SenderGetter from .messagebutton import MessageButton from .forward import Forward from .file import File -from .. import TLObject, types, functions, alltlobjects -from ... import utils, errors +from ..._misc import utils +from ... import errors, _tl # TODO Figure out a way to have the code generator error on missing fields # Maybe parsing the init function alone if that's possible. -class Message(ChatGetter, SenderGetter, TLObject): +class Message(ChatGetter, SenderGetter, _tl.TLObject): """ This custom class aggregates both :tl:`Message` and :tl:`MessageService` to ease accessing their members. @@ -163,7 +163,7 @@ class Message(ChatGetter, SenderGetter, TLObject): self, id: int, # Common to Message and MessageService (mandatory) - peer_id: types.TypePeer = None, + peer_id: _tl.TypePeer = None, date: Optional[datetime] = None, # Common to Message and MessageService (flags) @@ -172,19 +172,19 @@ class Message(ChatGetter, SenderGetter, TLObject): media_unread: Optional[bool] = None, silent: Optional[bool] = None, post: Optional[bool] = None, - from_id: Optional[types.TypePeer] = None, - reply_to: Optional[types.TypeMessageReplyHeader] = None, + from_id: Optional[_tl.TypePeer] = None, + reply_to: Optional[_tl.TypeMessageReplyHeader] = None, ttl_period: Optional[int] = None, # For Message (mandatory) message: Optional[str] = None, # For Message (flags) - fwd_from: Optional[types.TypeMessageFwdHeader] = None, + fwd_from: Optional[_tl.TypeMessageFwdHeader] = None, via_bot_id: Optional[int] = None, - media: Optional[types.TypeMessageMedia] = None, - reply_markup: Optional[types.TypeReplyMarkup] = None, - entities: Optional[List[types.TypeMessageEntity]] = None, + media: Optional[_tl.TypeMessageMedia] = None, + reply_markup: Optional[_tl.TypeReplyMarkup] = None, + entities: Optional[List[_tl.TypeMessageEntity]] = None, views: Optional[int] = None, edit_date: Optional[datetime] = None, post_author: Optional[str] = None, @@ -193,12 +193,12 @@ class Message(ChatGetter, SenderGetter, TLObject): legacy: Optional[bool] = None, edit_hide: Optional[bool] = None, pinned: Optional[bool] = None, - restriction_reason: Optional[types.TypeRestrictionReason] = None, + restriction_reason: Optional[_tl.TypeRestrictionReason] = None, forwards: Optional[int] = None, - replies: Optional[types.TypeMessageReplies] = None, + replies: Optional[_tl.TypeMessageReplies] = None, # For MessageAction (mandatory) - action: Optional[types.TypeMessageAction] = None + action: Optional[_tl.TypeMessageAction] = None ): # Common properties to messages, then to service (in the order they're defined in the `.tl`) self.out = bool(out) @@ -217,7 +217,7 @@ class Message(ChatGetter, SenderGetter, TLObject): self.reply_to = reply_to self.date = date self.message = message - self.media = None if isinstance(media, types.MessageMediaEmpty) else media + self.media = None if isinstance(media, _tl.MessageMediaEmpty) else media self.reply_markup = reply_markup self.entities = entities self.views = views @@ -253,7 +253,7 @@ class Message(ChatGetter, SenderGetter, TLObject): # ...or... # incoming messages in private conversations no longer have from_id # (layer 119+), but the sender can only be the chat we're in. - if post or (not out and isinstance(peer_id, types.PeerUser)): + if post or (not out and isinstance(peer_id, _tl.PeerUser)): sender_id = utils.get_peer_id(peer_id) # Note that these calls would reset the client @@ -272,7 +272,7 @@ class Message(ChatGetter, SenderGetter, TLObject): # Make messages sent to ourselves outgoing unless they're forwarded. # This makes it consistent with official client's appearance. - if self.peer_id == types.PeerUser(client._self_id) and not self.fwd_from: + if self.peer_id == _tl.PeerUser(client._self_id) and not self.fwd_from: self.out = True cache = client._entity_cache @@ -294,25 +294,25 @@ class Message(ChatGetter, SenderGetter, TLObject): self._forward = Forward(self._client, self.fwd_from, entities) if self.action: - if isinstance(self.action, (types.MessageActionChatAddUser, - types.MessageActionChatCreate)): + if isinstance(self.action, (_tl.MessageActionChatAddUser, + _tl.MessageActionChatCreate)): self._action_entities = [entities.get(i) for i in self.action.users] - elif isinstance(self.action, types.MessageActionChatDeleteUser): + elif isinstance(self.action, _tl.MessageActionChatDeleteUser): self._action_entities = [entities.get(self.action.user_id)] - elif isinstance(self.action, types.MessageActionChatJoinedByLink): + elif isinstance(self.action, _tl.MessageActionChatJoinedByLink): self._action_entities = [entities.get(self.action.inviter_id)] - elif isinstance(self.action, types.MessageActionChatMigrateTo): + elif isinstance(self.action, _tl.MessageActionChatMigrateTo): self._action_entities = [entities.get(utils.get_peer_id( - types.PeerChannel(self.action.channel_id)))] + _tl.PeerChannel(self.action.channel_id)))] elif isinstance( - self.action, types.MessageActionChannelMigrateFrom): + self.action, _tl.MessageActionChannelMigrateFrom): self._action_entities = [entities.get(utils.get_peer_id( - types.PeerChat(self.action.chat_id)))] + _tl.PeerChat(self.action.chat_id)))] if self.replies and self.replies.channel_id: self._linked_chat = entities.get(utils.get_peer_id( - types.PeerChannel(self.replies.channel_id))) + _tl.PeerChannel(self.replies.channel_id))) # endregion Initialization @@ -435,7 +435,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ if self._buttons_count is None: if isinstance(self.reply_markup, ( - types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): + _tl.ReplyInlineMarkup, _tl.ReplyKeyboardMarkup)): self._buttons_count = sum( len(row.buttons) for row in self.reply_markup.rows) else: @@ -471,14 +471,14 @@ class Message(ChatGetter, SenderGetter, TLObject): action is :tl:`MessageActionChatEditPhoto`, or if the message has a web preview with a photo. """ - if isinstance(self.media, types.MessageMediaPhoto): - if isinstance(self.media.photo, types.Photo): + if isinstance(self.media, _tl.MessageMediaPhoto): + if isinstance(self.media.photo, _tl.Photo): return self.media.photo - elif isinstance(self.action, types.MessageActionChatEditPhoto): + elif isinstance(self.action, _tl.MessageActionChatEditPhoto): return self.action.photo else: web = self.web_preview - if web and isinstance(web.photo, types.Photo): + if web and isinstance(web.photo, _tl.Photo): return web.photo @property @@ -486,12 +486,12 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`Document` media in this message, if any. """ - if isinstance(self.media, types.MessageMediaDocument): - if isinstance(self.media.document, types.Document): + if isinstance(self.media, _tl.MessageMediaDocument): + if isinstance(self.media.document, _tl.Document): return self.media.document else: web = self.web_preview - if web and isinstance(web.document, types.Document): + if web and isinstance(web.document, _tl.Document): return web.document @property @@ -499,8 +499,8 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`WebPage` media in this message, if any. """ - if isinstance(self.media, types.MessageMediaWebPage): - if isinstance(self.media.webpage, types.WebPage): + if isinstance(self.media, _tl.MessageMediaWebPage): + if isinstance(self.media.webpage, _tl.WebPage): return self.media.webpage @property @@ -508,7 +508,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`Document` media in this message, if it's an audio file. """ - return self._document_by_attribute(types.DocumentAttributeAudio, + return self._document_by_attribute(_tl.DocumentAttributeAudio, lambda attr: not attr.voice) @property @@ -516,7 +516,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`Document` media in this message, if it's a voice note. """ - return self._document_by_attribute(types.DocumentAttributeAudio, + return self._document_by_attribute(_tl.DocumentAttributeAudio, lambda attr: attr.voice) @property @@ -524,14 +524,14 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`Document` media in this message, if it's a video. """ - return self._document_by_attribute(types.DocumentAttributeVideo) + return self._document_by_attribute(_tl.DocumentAttributeVideo) @property def video_note(self): """ The :tl:`Document` media in this message, if it's a video note. """ - return self._document_by_attribute(types.DocumentAttributeVideo, + return self._document_by_attribute(_tl.DocumentAttributeVideo, lambda attr: attr.round_message) @property @@ -543,21 +543,21 @@ class Message(ChatGetter, SenderGetter, TLObject): sound, the so called "animated" media. However, it may be the actual gif format if the file is too large. """ - return self._document_by_attribute(types.DocumentAttributeAnimated) + return self._document_by_attribute(_tl.DocumentAttributeAnimated) @property def sticker(self): """ The :tl:`Document` media in this message, if it's a sticker. """ - return self._document_by_attribute(types.DocumentAttributeSticker) + return self._document_by_attribute(_tl.DocumentAttributeSticker) @property def contact(self): """ The :tl:`MessageMediaContact` in this message, if it's a contact. """ - if isinstance(self.media, types.MessageMediaContact): + if isinstance(self.media, _tl.MessageMediaContact): return self.media @property @@ -565,7 +565,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`Game` media in this message, if it's a game. """ - if isinstance(self.media, types.MessageMediaGame): + if isinstance(self.media, _tl.MessageMediaGame): return self.media.game @property @@ -573,9 +573,9 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`GeoPoint` media in this message, if it has a location. """ - if isinstance(self.media, (types.MessageMediaGeo, - types.MessageMediaGeoLive, - types.MessageMediaVenue)): + if isinstance(self.media, (_tl.MessageMediaGeo, + _tl.MessageMediaGeoLive, + _tl.MessageMediaVenue)): return self.media.geo @property @@ -583,7 +583,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`MessageMediaInvoice` in this message, if it's an invoice. """ - if isinstance(self.media, types.MessageMediaInvoice): + if isinstance(self.media, _tl.MessageMediaInvoice): return self.media @property @@ -591,7 +591,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`MessageMediaPoll` in this message, if it's a poll. """ - if isinstance(self.media, types.MessageMediaPoll): + if isinstance(self.media, _tl.MessageMediaPoll): return self.media @property @@ -599,7 +599,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`MessageMediaVenue` in this message, if it's a venue. """ - if isinstance(self.media, types.MessageMediaVenue): + if isinstance(self.media, _tl.MessageMediaVenue): return self.media @property @@ -607,7 +607,7 @@ class Message(ChatGetter, SenderGetter, TLObject): """ The :tl:`MessageMediaDice` in this message, if it's a dice roll. """ - if isinstance(self.media, types.MessageMediaDice): + if isinstance(self.media, _tl.MessageMediaDice): return self.media @property @@ -616,7 +616,7 @@ class Message(ChatGetter, SenderGetter, TLObject): Returns a list of entities that took part in this action. Possible cases for this are :tl:`MessageActionChatAddUser`, - :tl:`types.MessageActionChatCreate`, :tl:`MessageActionChatDeleteUser`, + :tl:`_tl.MessageActionChatCreate`, :tl:`MessageActionChatDeleteUser`, :tl:`MessageActionChatJoinedByLink` :tl:`MessageActionChatMigrateTo` and :tl:`MessageActionChannelMigrateFrom`. @@ -660,7 +660,7 @@ class Message(ChatGetter, SenderGetter, TLObject): # If the client wasn't set we can't emulate the behaviour correctly, # so as a best-effort simply return the chat peer. if self._client and not self.out and self.is_private: - return types.PeerUser(self._client._self_id) + return _tl.PeerUser(self._client._self_id) return self.peer_id @@ -722,7 +722,7 @@ class Message(ChatGetter, SenderGetter, TLObject): # However they can access them through replies... self._reply_message = await self._client.get_messages( await self.get_input_chat() if self.is_channel else None, - ids=types.InputMessageReplyTo(self.id) + ids=_tl.InputMessageReplyTo(self.id) ) if not self._reply_message: # ...unless the current message got deleted. @@ -883,7 +883,7 @@ class Message(ChatGetter, SenderGetter, TLObject): Clicks the first button or poll option for which the callable returns `True`. The callable should accept a single `MessageButton ` - or `PollAnswer ` argument. + or `PollAnswer ` argument. If you need to select multiple options in a poll, pass a list of indices to the ``i`` parameter. @@ -950,7 +950,7 @@ class Message(ChatGetter, SenderGetter, TLObject): if not chat: return None - but = types.KeyboardButtonCallback('', data) + but = _tl.KeyboardButtonCallback('', data) return await MessageButton(self._client, but, chat, None, self.id).click( share_phone=share_phone, share_geo=share_geo, password=password) @@ -1098,7 +1098,7 @@ class Message(ChatGetter, SenderGetter, TLObject): Helper methods to set the buttons given the input sender and chat. """ if self._client and isinstance(self.reply_markup, ( - types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): + _tl.ReplyInlineMarkup, _tl.ReplyKeyboardMarkup)): self._buttons = [[ MessageButton(self._client, button, chat, bot, self.id) for button in row.buttons @@ -1114,12 +1114,12 @@ class Message(ChatGetter, SenderGetter, TLObject): cannot be found but is needed. Returns `None` if it's not needed. """ if self._client and not isinstance(self.reply_markup, ( - types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): + _tl.ReplyInlineMarkup, _tl.ReplyKeyboardMarkup)): return None for row in self.reply_markup.rows: for button in row.buttons: - if isinstance(button, types.KeyboardButtonSwitchInline): + if isinstance(button, _tl.KeyboardButtonSwitchInline): # no via_bot_id means the bot sent the message itself (#1619) if button.same_peer or not self.via_bot_id: bot = self.input_sender diff --git a/telethon/_tl/custom/messagebutton.py b/telethon/_tl/custom/messagebutton.py index 61e39dc6..8596ff97 100644 --- a/telethon/_tl/custom/messagebutton.py +++ b/telethon/_tl/custom/messagebutton.py @@ -1,5 +1,5 @@ -from .. import types, functions -from ... import password as pwd_mod +from ..._misc import password as pwd_mod +from ... import _tl from ...errors import BotResponseTimeoutError import webbrowser import os @@ -46,19 +46,19 @@ class MessageButton: @property def data(self): """The `bytes` data for :tl:`KeyboardButtonCallback` objects.""" - if isinstance(self.button, types.KeyboardButtonCallback): + if isinstance(self.button, _tl.KeyboardButtonCallback): return self.button.data @property def inline_query(self): """The query `str` for :tl:`KeyboardButtonSwitchInline` objects.""" - if isinstance(self.button, types.KeyboardButtonSwitchInline): + if isinstance(self.button, _tl.KeyboardButtonSwitchInline): return self.button.query @property def url(self): """The url `str` for :tl:`KeyboardButtonUrl` objects.""" - if isinstance(self.button, types.KeyboardButtonUrl): + if isinstance(self.button, _tl.KeyboardButtonUrl): return self.button.url async def click(self, share_phone=None, share_geo=None, *, password=None): @@ -91,10 +91,10 @@ class MessageButton: this value a lot quickly may not work as expected. You may also pass a :tl:`InputGeoPoint` if you find the order confusing. """ - if isinstance(self.button, types.KeyboardButton): + if isinstance(self.button, _tl.KeyboardButton): return await self._client.send_message( self._chat, self.button.text, parse_mode=None) - elif isinstance(self.button, types.KeyboardButtonCallback): + elif isinstance(self.button, _tl.KeyboardButtonCallback): if password is not None: pwd = await self._client(_tl.fn.account.GetPassword()) password = pwd_mod.compute_check(pwd, password) @@ -107,13 +107,13 @@ class MessageButton: return await self._client(req) except BotResponseTimeoutError: return None - elif isinstance(self.button, types.KeyboardButtonSwitchInline): + elif isinstance(self.button, _tl.KeyboardButtonSwitchInline): return await self._client(_tl.fn.messages.StartBot( bot=self._bot, peer=self._chat, start_param=self.button.query )) - elif isinstance(self.button, types.KeyboardButtonUrl): + elif isinstance(self.button, _tl.KeyboardButtonUrl): return webbrowser.open(self.button.url) - elif isinstance(self.button, types.KeyboardButtonGame): + elif isinstance(self.button, _tl.KeyboardButtonGame): req = _tl.fn.messages.GetBotCallbackAnswer( peer=self._chat, msg_id=self._msg_id, game=True ) @@ -121,13 +121,13 @@ class MessageButton: return await self._client(req) except BotResponseTimeoutError: return None - elif isinstance(self.button, types.KeyboardButtonRequestPhone): + elif isinstance(self.button, _tl.KeyboardButtonRequestPhone): if not share_phone: raise ValueError('cannot click on phone buttons unless share_phone=True') if share_phone == True or isinstance(share_phone, str): me = await self._client.get_me() - share_phone = types.InputMediaContact( + share_phone = _tl.InputMediaContact( phone_number=me.phone if share_phone == True else share_phone, first_name=me.first_name or '', last_name=me.last_name or '', @@ -135,12 +135,12 @@ class MessageButton: ) return await self._client.send_file(self._chat, share_phone) - elif isinstance(self.button, types.KeyboardButtonRequestGeoLocation): + elif isinstance(self.button, _tl.KeyboardButtonRequestGeoLocation): if not share_geo: raise ValueError('cannot click on geo buttons unless share_geo=(longitude, latitude)') if isinstance(share_geo, (tuple, list)): long, lat = share_geo - share_geo = types.InputMediaGeoPoint(types.InputGeoPoint(lat=lat, long=long)) + share_geo = _tl.InputMediaGeoPoint(_tl.InputGeoPoint(lat=lat, long=long)) return await self._client.send_file(self._chat, share_geo) diff --git a/telethon/_tl/custom/participantpermissions.py b/telethon/_tl/custom/participantpermissions.py index d3719778..6d4db912 100644 --- a/telethon/_tl/custom/participantpermissions.py +++ b/telethon/_tl/custom/participantpermissions.py @@ -1,4 +1,4 @@ -from .. import types +from ... import _tl def _admin_prop(field_name, doc): @@ -85,7 +85,7 @@ class ParticipantPermissions: Whether the user left the chat. """ return isinstance(self.participant, types.ChannelParticipantLeft) - + @property def add_admins(self): """ @@ -132,7 +132,7 @@ class ParticipantPermissions: anonymous = property(**_admin_prop('anonymous', """ Whether the administrator will remain anonymous when sending messages. """)) - + manage_call = property(**_admin_prop('manage_call', """ Whether the user will be able to manage group calls. """)) diff --git a/telethon/_tl/custom/qrlogin.py b/telethon/_tl/custom/qrlogin.py index 38105921..3f2a0207 100644 --- a/telethon/_tl/custom/qrlogin.py +++ b/telethon/_tl/custom/qrlogin.py @@ -2,8 +2,7 @@ import asyncio import base64 import datetime -from .. import types, functions -from ... import events +from ... import events, _tl class QRLogin: diff --git a/telethon/_tl/patched/__init__.py b/telethon/_tl/patched/__init__.py index 2951f2af..ddffeb4c 100644 --- a/telethon/_tl/patched/__init__.py +++ b/telethon/_tl/patched/__init__.py @@ -1,4 +1,4 @@ -from .. import types, alltlobjects +from .. import _tl from ..custom.message import Message as _Message class MessageEmpty(_Message, types.MessageEmpty): diff --git a/telethon/events/album.py b/telethon/events/album.py index e06be9ee..0317db61 100644 --- a/telethon/events/album.py +++ b/telethon/events/album.py @@ -3,7 +3,9 @@ import time import weakref from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils, _tl +from .._misc import utils +from .. import _tl +from .._tl import custom _IGNORE_MAX_SIZE = 100 # len() _IGNORE_MAX_AGE = 5 # seconds @@ -138,7 +140,7 @@ class Album(EventBuilder): if len(event.messages) > 1: return super().filter(event) - class Event(EventCommon, _tl.custom.sendergetter.SenderGetter): + class Event(EventCommon, custom.sendergetter.SenderGetter): """ Represents the event of a new album. @@ -158,7 +160,7 @@ class Album(EventBuilder): super().__init__(chat_peer=chat_peer, msg_id=message.id, broadcast=bool(message.post)) - _tl.custom.sendergetter.SenderGetter.__init__(self, message.sender_id) + custom.sendergetter.SenderGetter.__init__(self, message.sender_id) self.messages = messages def _set_client(self, client): diff --git a/telethon/events/callbackquery.py b/telethon/events/callbackquery.py index 954ccf2d..f850ecd5 100644 --- a/telethon/events/callbackquery.py +++ b/telethon/events/callbackquery.py @@ -2,7 +2,9 @@ import re import struct from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils, _tl +from .._misc import utils +from .. import _tl +from .._tl import custom @name_inner_event @@ -121,7 +123,7 @@ class CallbackQuery(EventBuilder): return self.func(event) return True - class Event(EventCommon, _tl.custom.sendergetter.SenderGetter): + class Event(EventCommon, custom.sendergetter.SenderGetter): """ Represents the event of a new callback query. @@ -139,7 +141,7 @@ class CallbackQuery(EventBuilder): """ def __init__(self, query, peer, msg_id): super().__init__(peer, msg_id=msg_id) - _tl.custom.sendergetter.SenderGetter.__init__(self, query.user_id) + custom.sendergetter.SenderGetter.__init__(self, query.user_id) self.query = query self.data_match = None self.pattern_match = None diff --git a/telethon/events/chataction.py b/telethon/events/chataction.py index 09e1de70..d330656a 100644 --- a/telethon/events/chataction.py +++ b/telethon/events/chataction.py @@ -1,5 +1,6 @@ from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils, _tl +from .._misc import utils +from .. import _tl @name_inner_event diff --git a/telethon/events/common.py b/telethon/events/common.py index 8367ce94..cce243e6 100644 --- a/telethon/events/common.py +++ b/telethon/events/common.py @@ -2,7 +2,8 @@ import abc import asyncio import warnings -from .. import utils, _tl +from .._misc import utils +from .._tl.custom.chatgetter import ChatGetter async def _into_id_set(client, chats): diff --git a/telethon/events/inlinequery.py b/telethon/events/inlinequery.py index dc602af9..f2c13d3d 100644 --- a/telethon/events/inlinequery.py +++ b/telethon/events/inlinequery.py @@ -4,7 +4,9 @@ import re import asyncio from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils, _tl +from .._misc import utils +from .. import _tl +from .._tl import custom @name_inner_event @@ -72,7 +74,7 @@ class InlineQuery(EventBuilder): return super().filter(event) - class Event(EventCommon, _tl.custom.sendergetter.SenderGetter): + class Event(EventCommon, custom.sendergetter.SenderGetter): """ Represents the event of a new callback query. @@ -89,7 +91,7 @@ class InlineQuery(EventBuilder): """ def __init__(self, query): super().__init__(chat_peer=_tl.PeerUser(query.user_id)) - _tl.custom.sendergetter.SenderGetter.__init__(self, query.user_id) + custom.sendergetter.SenderGetter.__init__(self, query.user_id) self.query = query self.pattern_match = None self._answered = False diff --git a/telethon/events/messageread.py b/telethon/events/messageread.py index a25e5f66..5c37eb2c 100644 --- a/telethon/events/messageread.py +++ b/telethon/events/messageread.py @@ -1,5 +1,6 @@ from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils, _tl +from .._misc import utils +from .. import _tl @name_inner_event diff --git a/telethon/events/newmessage.py b/telethon/events/newmessage.py index 192f9937..cfe7b88a 100644 --- a/telethon/events/newmessage.py +++ b/telethon/events/newmessage.py @@ -1,7 +1,8 @@ import re from .common import EventBuilder, EventCommon, name_inner_event, _into_id_set -from .. import utils, _tl +from .._misc import utils +from .. import _tl @name_inner_event diff --git a/telethon/events/raw.py b/telethon/events/raw.py index 84910778..68fdfc0c 100644 --- a/telethon/events/raw.py +++ b/telethon/events/raw.py @@ -1,5 +1,5 @@ from .common import EventBuilder -from .. import utils +from .._misc import utils class Raw(EventBuilder): diff --git a/telethon/events/userupdate.py b/telethon/events/userupdate.py index 8b6642a8..8144cadb 100644 --- a/telethon/events/userupdate.py +++ b/telethon/events/userupdate.py @@ -2,7 +2,9 @@ import datetime import functools from .common import EventBuilder, EventCommon, name_inner_event -from .. import utils, _tl +from .._misc import utils +from .. import _tl +from .._tl import custom # TODO Either the properties are poorly named or they should be @@ -63,7 +65,7 @@ class UserUpdate(EventBuilder): return cls.Event(update.user_id, typing=update.action) - class Event(EventCommon, _tl.custom.sendergetter.SenderGetter): + class Event(EventCommon, custom.sendergetter.SenderGetter): """ Represents the event of a user update such as gone online, started typing, etc. @@ -85,7 +87,7 @@ class UserUpdate(EventBuilder): """ def __init__(self, peer, *, status=None, chat_peer=None, typing=None): super().__init__(chat_peer or peer) - _tl.custom.sendergetter.SenderGetter.__init__(self, utils.get_peer_id(peer)) + custom.sendergetter.SenderGetter.__init__(self, utils.get_peer_id(peer)) self.status = status self.action = typing diff --git a/telethon/sessions/memory.py b/telethon/sessions/memory.py index 0fda05ba..9f5314a3 100644 --- a/telethon/sessions/memory.py +++ b/telethon/sessions/memory.py @@ -1,7 +1,8 @@ from enum import Enum from .abstract import Session -from .. import utils, _tl +from .._misc import utils +from .. import _tl class _SentFileType(Enum): diff --git a/telethon/sessions/sqlite.py b/telethon/sessions/sqlite.py index 32336000..5b4505c8 100644 --- a/telethon/sessions/sqlite.py +++ b/telethon/sessions/sqlite.py @@ -3,8 +3,9 @@ import os import time from .memory import MemorySession, _SentFileType -from .. import utils, _tl -from ..crypto import AuthKey +from .._misc import utils +from .. import _tl +from .._crypto import AuthKey try: import sqlite3 diff --git a/telethon/sessions/string.py b/telethon/sessions/string.py index fb971d82..72617f24 100644 --- a/telethon/sessions/string.py +++ b/telethon/sessions/string.py @@ -4,7 +4,7 @@ import struct from .abstract import Session from .memory import MemorySession -from ..crypto import AuthKey +from .._crypto import AuthKey _STRUCT_PREFORMAT = '>B{}sH256s' diff --git a/telethon_generator/generators/docs.py b/telethon_generator/generators/docs.py index 8b46e4d1..d2da55d1 100755 --- a/telethon_generator/generators/docs.py +++ b/telethon_generator/generators/docs.py @@ -9,7 +9,7 @@ from pathlib import Path from ..docswriter import DocsWriter from ..parsers import TLObject, Usability -from ..utils import snake_to_camel_case +from .._misc.utils import snake_to_camel_case CORE_TYPES = { 'int', 'long', 'int128', 'int256', 'double', diff --git a/telethon_generator/generators/tlobject.py b/telethon_generator/generators/tlobject.py index cc37cb92..04003a6b 100644 --- a/telethon_generator/generators/tlobject.py +++ b/telethon_generator/generators/tlobject.py @@ -7,7 +7,7 @@ from collections import defaultdict from zlib import crc32 from ..sourcebuilder import SourceBuilder -from ..utils import snake_to_camel_case +from .._misc.utils import snake_to_camel_case AUTO_GEN_NOTICE = \ '"""File generated by TLObjects\' generator. All changes will be ERASED"""' diff --git a/telethon_generator/parsers/errors.py b/telethon_generator/parsers/errors.py index 04cd3412..9bac2142 100644 --- a/telethon_generator/parsers/errors.py +++ b/telethon_generator/parsers/errors.py @@ -1,7 +1,7 @@ import csv import re -from ..utils import snake_to_camel_case +from .._misc.utils import snake_to_camel_case # Core base classes depending on the integer error code KNOWN_BASE_CLASSES = { diff --git a/telethon_generator/parsers/tlobject/tlobject.py b/telethon_generator/parsers/tlobject/tlobject.py index 60b9e996..0f753fa2 100644 --- a/telethon_generator/parsers/tlobject/tlobject.py +++ b/telethon_generator/parsers/tlobject/tlobject.py @@ -2,7 +2,7 @@ import re import struct import zlib -from ...utils import snake_to_camel_case +from ..._misc.utils import snake_to_camel_case # https://github.com/telegramdesktop/tdesktop/blob/4bf66cb6e93f3965b40084771b595e93d0b11bcd/Telegram/SourceFiles/codegen/scheme/codegen_scheme.py#L57-L62 WHITELISTED_MISMATCHING_IDS = { diff --git a/tests/telethon/tl/test_serialization.py b/tests/telethon/tl/test_serialization.py index 4b455784..7bcdf25b 100644 --- a/tests/telethon/tl/test_serialization.py +++ b/tests/telethon/tl/test_serialization.py @@ -1,13 +1,13 @@ import pytest -from telethon.tl import types, functions +from telethon import _tl def test_nested_invalid_serialization(): large_long = 2**62 request = _tl.fn.account.SetPrivacy( - key=types.InputPrivacyKeyChatInvite(), - rules=[types.InputPrivacyValueDisallowUsers(users=[large_long])] + key=_tl.InputPrivacyKeyChatInvite(), + rules=[_tl.InputPrivacyValueDisallowUsers(users=[large_long])] ) with pytest.raises(TypeError): bytes(request) From c84043cf71bbe200823aa1af99ec0659dcc7bc6c Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 12 Sep 2021 14:09:53 +0200 Subject: [PATCH 012/131] Fix calls to private client methods --- telethon/_client/auth.py | 11 ++--- telethon/_client/downloads.py | 56 ++++++++++++------------ telethon/_client/messageparse.py | 4 +- telethon/_client/messages.py | 6 +-- telethon/_client/telegrambaseclient.py | 33 ++++---------- telethon/_client/telegramclient.py | 59 +++++++++++++++++++++++++- telethon/_client/updates.py | 18 ++++---- telethon/_client/uploads.py | 16 +++---- telethon/_client/users.py | 4 +- 9 files changed, 124 insertions(+), 83 deletions(-) diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index ccfaa68b..4ae32c43 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -40,7 +40,8 @@ async def start( raise ValueError('Both a phone and a bot token provided, ' 'must only provide one of either') - return await self._start( + return await _start( + self=self, phone=phone, password=password, bot_token=bot_token, @@ -211,7 +212,7 @@ async def sign_in( return await self.send_code_request(phone) elif code: phone, phone_code_hash = \ - self._parse_phone_and_hash(phone, phone_code_hash) + _parse_phone_and_hash(self, phone, phone_code_hash) # May raise PhoneCodeEmptyError, PhoneCodeExpiredError, # PhoneCodeHashEmptyError or PhoneCodeInvalidError. @@ -240,7 +241,7 @@ async def sign_in( self._tos = result.terms_of_service raise errors.PhoneNumberUnoccupiedError(request=request) - return self._on_login(result.user) + return _on_login(self, result.user) async def sign_up( self: 'TelegramClient', @@ -280,7 +281,7 @@ async def sign_up( sys.stderr.flush() phone, phone_code_hash = \ - self._parse_phone_and_hash(phone, phone_code_hash) + _parse_phone_and_hash(self, phone, phone_code_hash) result = await self(_tl.fn.auth.SignUp( phone_number=phone, @@ -293,7 +294,7 @@ async def sign_up( await self( _tl.fn.help.AcceptTermsOfService(self._tos.id)) - return self._on_login(result.user) + return _on_login(self, result.user) def _on_login(self, user): """ diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py index dbb279f3..25a461cb 100644 --- a/telethon/_client/downloads.py +++ b/telethon/_client/downloads.py @@ -213,8 +213,8 @@ async def download_profile_photo( if not hasattr(entity, 'chat_photo'): return None - return await self._download_photo( - entity.chat_photo, file, date=None, + return await _download_photo( + self, entity.chat_photo, file, date=None, thumb=thumb, progress_callback=None ) @@ -237,7 +237,7 @@ async def download_profile_photo( # media which should be done with `download_media` instead. return None - file = self._get_proper_filename( + file = _get_proper_filename( file, 'profile_photo', '.jpg', possible_names=possible_names ) @@ -252,8 +252,8 @@ async def download_profile_photo( ty = helpers._entity_type(ie) if ty == helpers._EntityType.CHANNEL: full = await self(_tl.fn.channels.GetFullChannel(ie)) - return await self._download_photo( - full.full_chat.chat_photo, file, + return await _download_photo( + self, full.full_chat.chat_photo, file, date=None, progress_callback=None, thumb=thumb ) @@ -295,20 +295,20 @@ async def download_media( media = media.webpage.document or media.webpage.photo if isinstance(media, (_tl.MessageMediaPhoto, _tl.Photo)): - return await self._download_photo( - media, file, date, thumb, progress_callback + return await _download_photo( + self, media, file, date, thumb, progress_callback ) elif isinstance(media, (_tl.MessageMediaDocument, _tl.Document)): - return await self._download_document( - media, file, date, thumb, progress_callback, msg_data + return await _download_document( + self, media, file, date, thumb, progress_callback, msg_data ) elif isinstance(media, _tl.MessageMediaContact) and thumb is None: - return self._download_contact( - media, file + return _download_contact( + self, media, file ) elif isinstance(media, (_tl.WebDocument, _tl.WebDocumentNoProxy)) and thumb is None: - return await self._download_web_document( - media, file, progress_callback + return await _download_web_document( + self, media, file, progress_callback ) async def download_file( @@ -322,7 +322,8 @@ async def download_file( dc_id: int = None, key: bytes = None, iv: bytes = None) -> typing.Optional[bytes]: - return await self._download_file( + return await _download_file( + self, input_location, file, part_size_kb=part_size_kb, @@ -370,8 +371,8 @@ async def _download_file( f = file try: - async for chunk in self._iter_download( - input_location, request_size=part_size, dc_id=dc_id, msg_data=msg_data): + async for chunk in _iter_download( + self, input_location, request_size=part_size, dc_id=dc_id, msg_data=msg_data): if iv and key: chunk = AES.decrypt_ige(chunk, key, iv) r = f.write(chunk) @@ -405,7 +406,8 @@ def iter_download( file_size: int = None, dc_id: int = None ): - return self._iter_download( + return _iter_download( + self, file, offset=offset, stride=stride, @@ -552,17 +554,17 @@ async def _download_photo(self: 'TelegramClient', photo, file, date, thumb, prog return # Include video sizes here (but they may be None so provide an empty list) - size = self._get_thumb(photo.sizes + (photo.video_sizes or []), thumb) + size = _get_thumb(photo.sizes + (photo.video_sizes or []), thumb) if not size or isinstance(size, _tl.PhotoSizeEmpty): return if isinstance(size, _tl.VideoSize): - file = self._get_proper_filename(file, 'video', '.mp4', date=date) + file = _get_proper_filename(file, 'video', '.mp4', date=date) else: - file = self._get_proper_filename(file, 'photo', '.jpg', date=date) + file = _get_proper_filename(file, 'photo', '.jpg', date=date) if isinstance(size, (_tl.PhotoCachedSize, _tl.PhotoStrippedSize)): - return self._download_cached_photo_size(size, file) + return _download_cached_photo_size(self, size, file) if isinstance(size, _tl.PhotoSizeProgressive): file_size = max(size.sizes) @@ -614,19 +616,19 @@ async def _download_document( return if thumb is None: - kind, possible_names = self._get_kind_and_names(document.attributes) - file = self._get_proper_filename( + kind, possible_names = _get_kind_and_names(document.attributes) + file = _get_proper_filename( file, kind, utils.get_extension(document), date=date, possible_names=possible_names ) size = None else: - file = self._get_proper_filename(file, 'photo', '.jpg', date=date) - size = self._get_thumb(document.thumbs, thumb) + file = _get_proper_filename(file, 'photo', '.jpg', date=date) + size = _get_thumb(document.thumbs, thumb) if isinstance(size, (_tl.PhotoCachedSize, _tl.PhotoStrippedSize)): - return self._download_cached_photo_size(size, file) + return _download_cached_photo_size(self, size, file) - result = await self._download_file( + result = await _download_file( _tl.InputDocumentFileLocation( id=document.id, access_hash=document.access_hash, diff --git a/telethon/_client/messageparse.py b/telethon/_client/messageparse.py index d68ab38f..9f1f3b70 100644 --- a/telethon/_client/messageparse.py +++ b/telethon/_client/messageparse.py @@ -55,12 +55,12 @@ async def _parse_message_text(self: 'TelegramClient', message, parse_mode): m = re.match(r'^@|\+|tg://user\?id=(\d+)', e.url) if m: user = int(m.group(1)) if m.group(1) else e.url - is_mention = await self._replace_with_mention(msg_entities, i, user) + is_mention = await _replace_with_mention(self, msg_entities, i, user) if not is_mention: del msg_entities[i] elif isinstance(e, (_tl.MessageEntityMentionName, _tl.InputMessageEntityMentionName)): - is_mention = await self._replace_with_mention(msg_entities, i, e.user_id) + is_mention = await _replace_with_mention(self, msg_entities, i, e.user_id) if not is_mention: del msg_entities[i] diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index 00ee418a..5dae6eb4 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -436,7 +436,7 @@ async def send_message( entity = await self.get_input_entity(entity) if comment_to is not None: - entity, reply_to = await self._get_comment_data(entity, comment_to) + entity, reply_to = await _get_comment_data(self, entity, comment_to) if isinstance(message, _tl.Message): if buttons is None: @@ -712,7 +712,7 @@ async def pin_message( notify: bool = False, pm_oneside: bool = False ): - return await self._pin(entity, message, unpin=False, notify=notify, pm_oneside=pm_oneside) + return await _pin(self, entity, message, unpin=False, notify=notify, pm_oneside=pm_oneside) async def unpin_message( self: 'TelegramClient', @@ -721,7 +721,7 @@ async def unpin_message( *, notify: bool = False ): - return await self._pin(entity, message, unpin=True, notify=notify) + return await _pin(self, entity, message, unpin=True, notify=notify) async def _pin(self, entity, message, *, unpin, notify=False, pm_oneside=False): message = utils.get_message_id(message) or 0 diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index c89b1809..1cf74821 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -331,7 +331,7 @@ def is_connected(self: 'TelegramClient') -> bool: return sender and sender.is_connected() async def disconnect(self: 'TelegramClient'): - return await self._disconnect_coro() + return await _disconnect_coro(self) def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): init_proxy = None if not issubclass(self._connection, TcpMTProxy) else \ @@ -354,7 +354,7 @@ def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): connection._proxy = proxy async def _disconnect_coro(self: 'TelegramClient'): - await self._disconnect() + await _disconnect(self) # Also clean-up all exported senders because we're done with them async with self._borrow_sender_lock: @@ -408,7 +408,7 @@ async def _switch_dc(self: 'TelegramClient', new_dc): Permanently switches the current connection to the new data center. """ self._log[__name__].info('Reconnecting to new data center %s', new_dc) - dc = await self._get_dc(new_dc) + dc = await _get_dc(self, 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 @@ -416,7 +416,7 @@ async def _switch_dc(self: 'TelegramClient', new_dc): self._sender.auth_key.key = None self.session.auth_key = None self.session.save() - await self._disconnect() + await _disconnect(self) return await self.connect() def _auth_key_callback(self: 'TelegramClient', auth_key): @@ -462,7 +462,7 @@ async def _create_exported_sender(self: 'TelegramClient', dc_id): """ # Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt # for clearly showing how to export the authorization - dc = await self._get_dc(dc_id) + dc = await _get_dc(self, dc_id) # Can't reuse self._sender._connection as it has its own seqno. # # If one were to do that, Telegram would reset the connection @@ -497,12 +497,12 @@ async def _borrow_exported_sender(self: 'TelegramClient', dc_id): if state is None: state = _ExportState() - sender = await self._create_exported_sender(dc_id) + sender = await _create_exported_sender(self, dc_id) sender.dc_id = dc_id self._borrowed_senders[dc_id] = (state, sender) elif state.need_connect(): - dc = await self._get_dc(dc_id) + dc = await _get_dc(self, dc_id) await sender.connect(self._connection( dc.ip_address, dc.port, @@ -545,7 +545,7 @@ async def _get_cdn_client(self: 'TelegramClient', cdn_redirect): 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) + dc = await _get_dc(self, cdn_redirect.dc_id, cdn=True) session = self.session.clone() await session.set_dc(dc.id, dc.ip_address, dc.port) self._exported_sessions[cdn_redirect.dc_id] = session @@ -564,20 +564,3 @@ async def _get_cdn_client(self: 'TelegramClient', cdn_redirect): # set already. Avoid invoking non-CDN methods by not syncing updates. client.connect(_sync_updates=False) return client - - -@abc.abstractmethod -def __call__(self: 'TelegramClient', request, ordered=False): - raise NotImplementedError - -@abc.abstractmethod -def _handle_update(self: 'TelegramClient', update): - raise NotImplementedError - -@abc.abstractmethod -def _update_loop(self: 'TelegramClient'): - raise NotImplementedError - -@abc.abstractmethod -async def _handle_auto_reconnect(self: 'TelegramClient'): - raise NotImplementedError diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 986b89f0..f219605e 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -3496,7 +3496,7 @@ class TelegramClient: # region Users - def __call__(self: 'TelegramClient', request, ordered=False): + async def __call__(self: 'TelegramClient', request, ordered=False, flood_sleep_threshold=None): """ Invokes (sends) one or more MTProtoRequests and returns (receives) their result. @@ -3519,7 +3519,7 @@ class TelegramClient: The result of the request (often a `TLObject`) or a list of results if more than one request was given. """ - return users.call(self._sender, request, ordered=ordered) + return self._call(request, ordered, flood_sleep_threshold) async def get_me(self: 'TelegramClient', input_peer: bool = False) \ -> 'typing.Union[_tl.User, _tl.InputPeerUser]': @@ -3719,4 +3719,59 @@ class TelegramClient: # endregion Users + # region Private + + async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None): + return users.call(self._sender, request, ordered=ordered, flood_sleep_threshold=flood_sleep_threshold) + + async def _update_loop(self: 'TelegramClient'): + return updates._update_loop(**locals()) + + async def _parse_message_text(self: 'TelegramClient', message, parse_mode): + return messageparse._parse_message_text(**locals()) + + async def _file_to_media( + self, file, force_document=False, file_size=None, + progress_callback=None, attributes=None, thumb=None, + allow_cache=True, voice_note=False, video_note=False, + supports_streaming=False, mime_type=None, as_image=None, + ttl=None): + return uploads._file_to_media(**locals()) + + async def _get_peer(self: 'TelegramClient', peer: 'hints.EntityLike'): + return users._get_peer(**locals()) + + def _get_response_message(self: 'TelegramClient', request, result, input_chat): + return messageparse._get_response_message(**locals()) + + async def _get_comment_data( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Union[int, _tl.Message]' + ): + return messages._get_comment_data(**locals()) + + async def _switch_dc(self: 'TelegramClient', new_dc): + return telegrambaseclient._switch_dc(**locals()) + + async def _borrow_exported_sender(self: 'TelegramClient', dc_id): + return telegrambaseclient._borrow_exported_sender(**locals()) + + async def _return_exported_sender(self: 'TelegramClient', sender): + return telegrambaseclient._return_exported_sender(**locals()) + + async def _clean_exported_senders(self: 'TelegramClient'): + return telegrambaseclient._clean_exported_senders(**locals()) + + def _auth_key_callback(self: 'TelegramClient', auth_key): + return telegrambaseclient._auth_key_callback + + def _handle_update(self: 'TelegramClient', update): + return updates._handle_update(**locals()) + + async def _handle_auto_reconnect(self: 'TelegramClient'): + return updates._handle_auto_reconnect(**locals()) + + # endregion Private + # TODO re-patch everything to remove the intermediate calls diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index e8c5709c..9d1e205c 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -34,7 +34,7 @@ async def set_receive_updates(self: 'TelegramClient', receive_updates): await self(_tl.fn.updates.GetState()) async def run_until_disconnected(self: 'TelegramClient'): - return await self._run_until_disconnected() + return await _run_until_disconnected(self) def on(self: 'TelegramClient', event: EventBuilder): def decorator(f): @@ -101,7 +101,7 @@ async def catch_up(self: 'TelegramClient'): state = d.intermediate_state pts, date = state.pts, state.date - self._handle_update(_tl.Updates( + _handle_update(self, _tl.Updates( users=d.users, chats=d.chats, date=state.date, @@ -151,11 +151,11 @@ def _handle_update(self: 'TelegramClient', update): entities = {utils.get_peer_id(x): x for x in itertools.chain(update.users, update.chats)} for u in update.updates: - self._process_update(u, update.updates, entities=entities) + _process_update(self, u, update.updates, entities=entities) elif isinstance(update, _tl.UpdateShort): - self._process_update(update.update, None) + _process_update(self, update.update, None) else: - self._process_update(update, None) + _process_update(self, update, None) self._state_cache.update(update) @@ -168,14 +168,14 @@ def _process_update(self: 'TelegramClient', update, others, entities=None): channel_id = self._state_cache.get_channel_id(update) args = (update, others, channel_id, self._state_cache[channel_id]) if self._dispatching_updates_queue is None: - task = self.loop.create_task(self._dispatch_update(*args)) + task = self.loop.create_task(_dispatch_update(self, *args)) self._updates_queue.add(task) task.add_done_callback(lambda _: self._updates_queue.discard(task)) else: self._updates_queue.put_nowait(args) if not self._dispatching_updates_queue.is_set(): self._dispatching_updates_queue.set() - self.loop.create_task(self._dispatch_queue_updates()) + self.loop.create_task(_dispatch_queue_updates(self)) self._state_cache.update(update) @@ -235,7 +235,7 @@ async def _update_loop(self: 'TelegramClient'): async def _dispatch_queue_updates(self: 'TelegramClient'): while not self._updates_queue.empty(): - await self._dispatch_update(*self._updates_queue.get_nowait()) + await _dispatch_update(self, *self._updates_queue.get_nowait()) self._dispatching_updates_queue.clear() @@ -248,7 +248,7 @@ async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, p # If the update doesn't have pts, fetching won't do anything. # For example, UpdateUserStatus or UpdateChatUserTyping. try: - await self._get_difference(update, channel_id, pts_date) + await _get_difference(self, update, channel_id, pts_date) except OSError: pass # We were disconnected, that's okay except errors.RPCError: diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py index db2cdd77..8043f26c 100644 --- a/telethon/_client/uploads.py +++ b/telethon/_client/uploads.py @@ -125,7 +125,7 @@ async def send_file( entity = await self.get_input_entity(entity) if comment_to is not None: - entity, reply_to = await self._get_comment_data(entity, comment_to) + entity, reply_to = await _get_comment_data(self, entity, comment_to) else: reply_to = utils.get_message_id(reply_to) @@ -139,8 +139,8 @@ async def send_file( result = [] while file: - result += await self._send_album( - entity, file[:10], caption=captions[:10], + result += await _send_album( + self, entity, file[:10], caption=captions[:10], progress_callback=progress_callback, reply_to=reply_to, parse_mode=parse_mode, silent=silent, schedule=schedule, supports_streaming=supports_streaming, clear_draft=clear_draft, @@ -167,10 +167,10 @@ async def send_file( msg_entities = formatting_entities else: caption, msg_entities =\ - await self._parse_message_text(caption, parse_mode) + await _parse_message_text(self, caption, parse_mode) - file_handle, media, image = await self._file_to_media( - file, force_document=force_document, + file_handle, media, image = await _file_to_media( + self, file, force_document=force_document, file_size=file_size, progress_callback=progress_callback, attributes=attributes, allow_cache=allow_cache, thumb=thumb, @@ -223,8 +223,8 @@ async def _send_album(self: 'TelegramClient', entity, files, caption='', # :tl:`InputMediaUploadedPhoto`. However using that will # make it `raise MediaInvalidError`, so we need to upload # it as media and then convert that to :tl:`InputMediaPhoto`. - fh, fm, _ = await self._file_to_media( - file, supports_streaming=supports_streaming, + fh, fm, _ = await _file_to_media( + self, file, supports_streaming=supports_streaming, force_document=force_document, ttl=ttl) if isinstance(fm, (_tl.InputMediaUploadedPhoto, _tl.InputMediaPhotoExternal)): r = await self(_tl.fn.messages.UploadMedia( diff --git a/telethon/_client/users.py b/telethon/_client/users.py index e493ea61..0719cdc4 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -230,7 +230,7 @@ async def get_entity( result = [] for x in inputs: if isinstance(x, str): - result.append(await self._get_entity_from_string(x)) + result.append(await _get_entity_from_string(self, x)) elif not isinstance(x, _tl.InputPeerSelf): result.append(id_entity[utils.get_peer_id(x)]) else: @@ -271,7 +271,7 @@ async def get_input_entity( # Only network left to try if isinstance(peer, str): return utils.get_input_peer( - await self._get_entity_from_string(peer)) + await _get_entity_from_string(self, peer)) # If we're a bot and the user has messaged us privately users.getUsers # will work with access_hash = 0. Similar for channels.getChannels. From e9b97b5e4a746a2d181ee041049927549f4d46c2 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 12 Sep 2021 15:46:57 +0200 Subject: [PATCH 013/131] Fix client method calls and reading TLObjects --- telethon/_client/telegrambaseclient.py | 3 +- telethon/_client/telegramclient.py | 125 ++++++++++++++----------- telethon/_client/uploads.py | 2 +- telethon/_client/users.py | 8 +- telethon/_misc/binaryreader.py | 6 +- 5 files changed, 81 insertions(+), 63 deletions(-) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 1cf74821..b45ac6d3 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -11,6 +11,7 @@ from .. import version, helpers, __name__ as __base_name__, _tl from .._crypto import rsa from .._misc import markdown, entitycache, statecache from .._network import MTProtoSender, Connection, ConnectionTcpFull, TcpMTProxy +from .._tl.alltlobjects import LAYER from ..sessions import Session, SQLiteSession, MemorySession DEFAULT_DC_ID = 2 @@ -321,7 +322,7 @@ async def connect(self: 'TelegramClient') -> None: self._init_request.query = _tl.fn.help.GetConfig() await self._sender.send(_tl.fn.InvokeWithLayer( - _tl.alltlobjects.LAYER, self._init_request + LAYER, self._init_request )) self._updates_handle = self.loop.create_task(self._update_loop()) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index f219605e..3596a506 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -274,7 +274,7 @@ class TelegramClient: # region Auth - def start( + async def start( self: 'TelegramClient', phone: typing.Callable[[], str] = lambda: input('Please enter your phone (or bot token): '), password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '), @@ -360,7 +360,7 @@ class TelegramClient: with client: pass """ - return auth.start(**locals()) + return await auth.start(**locals()) async def sign_in( self: 'TelegramClient', @@ -419,7 +419,7 @@ class TelegramClient: code = input('enter code: ') await client.sign_in(phone, code) """ - return auth.sign_in(**locals()) + return await auth.sign_in(**locals()) async def sign_up( self: 'TelegramClient', @@ -471,7 +471,7 @@ class TelegramClient: code = input('enter code: ') await client.sign_up(code, first_name='Anna', last_name='Banana') """ - return auth.sign_up(**locals()) + return await auth.sign_up(**locals()) async def send_code_request( self: 'TelegramClient', @@ -498,7 +498,7 @@ class TelegramClient: sent = await client.send_code_request(phone) print(sent) """ - return auth.send_code_request(**locals()) + return await auth.send_code_request(**locals()) async def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> custom.QRLogin: """ @@ -533,7 +533,7 @@ class TelegramClient: # Important! You need to wait for the login to complete! await qr_login.wait() """ - return auth.qr_login(**locals()) + return await auth.qr_login(**locals()) async def log_out(self: 'TelegramClient') -> bool: """ @@ -548,7 +548,7 @@ class TelegramClient: # Note: you will need to login again! await client.log_out() """ - return auth.log_out(**locals()) + return await auth.log_out(**locals()) async def edit_2fa( self: 'TelegramClient', @@ -611,7 +611,7 @@ class TelegramClient: # Removing the password await client.edit_2fa(current_password='I_<3_Telethon') """ - return auth.edit_2fa(**locals()) + return await auth.edit_2fa(**locals()) async def __aenter__(self): return await self.start() @@ -670,7 +670,7 @@ class TelegramClient: # Send the first result to some chat message = await results[0].click('TelethonOffTopic') """ - return bots.inline_query(**locals()) + return await bots.inline_query(**locals()) # endregion Bots @@ -709,8 +709,8 @@ class TelegramClient: # later await client.send_message(chat, 'click me', buttons=markup) """ - return buttons.build_reply_markup(**locals()) - + from . import buttons as b + return b.build_reply_markup(buttons=buttons, inline_only=inline_only) # endregion Buttons @@ -805,7 +805,7 @@ class TelegramClient: if user.username is not None: print(user.username) """ - return chats.get_participants(*args, **kwargs) + return await chats.get_participants(*args, **kwargs) get_participants.__signature__ = inspect.signature(iter_participants) @@ -950,7 +950,7 @@ class TelegramClient: # Print the old message before it was deleted print(events[0].old) """ - return chats.get_admin_log(*args, **kwargs) + return await chats.get_admin_log(*args, **kwargs) get_admin_log.__signature__ = inspect.signature(iter_admin_log) @@ -1011,7 +1011,7 @@ class TelegramClient: # Download the oldest photo await client.download_media(photos[-1]) """ - return chats.get_profile_photos(*args, **kwargs) + return await chats.get_profile_photos(*args, **kwargs) get_profile_photos.__signature__ = inspect.signature(iter_profile_photos) @@ -1197,7 +1197,7 @@ class TelegramClient: # Granting all permissions except for `add_admins` await client.edit_admin(chat, user, is_admin=True, add_admins=False) """ - return chats.edit_admin(**locals()) + return await chats.edit_admin(**locals()) async def edit_permissions( self: 'TelegramClient', @@ -1314,7 +1314,7 @@ class TelegramClient: await client.edit_permissions(chat, user, view_messages=False) await client.edit_permissions(chat, user) """ - return chats.edit_permissions(**locals()) + return await chats.edit_permissions(**locals()) async def kick_participant( self: 'TelegramClient', @@ -1353,7 +1353,7 @@ class TelegramClient: # Leaving chat await client.kick_participant(chat, 'me') """ - return chats.kick_participant(**locals()) + return await chats.kick_participant(**locals()) async def get_permissions( self: 'TelegramClient', @@ -1391,7 +1391,7 @@ class TelegramClient: # Get Banned Permissions of Chat await client.get_permissions(chat) """ - return chats.get_permissions(**locals()) + return await chats.get_permissions(**locals()) async def get_stats( self: 'TelegramClient', @@ -1437,7 +1437,7 @@ class TelegramClient: .. _`at least 500 members`: https://telegram.org/blog/profile-videos-people-nearby-and-more """ - return chats.get_stats(**locals()) + return await chats.get_stats(**locals()) # endregion Chats @@ -1544,7 +1544,7 @@ class TelegramClient: archived = await client.get_dialogs(folder=1) archived = await client.get_dialogs(archived=True) """ - return dialogs.get_dialogs(*args, **kwargs) + return await dialogs.get_dialogs(*args, **kwargs) get_dialogs.__signature__ = inspect.signature(iter_dialogs) @@ -1596,7 +1596,7 @@ class TelegramClient: draft = await client.get_drafts('me') print(drafts.text) """ - return dialogs.get_drafts(**locals()) + return await dialogs.get_drafts(**locals()) async def edit_folder( self: 'TelegramClient', @@ -1655,7 +1655,7 @@ class TelegramClient: # Un-archiving all dialogs await client.edit_folder(unpack=1) """ - return dialogs.edit_folder(**locals()) + return await dialogs.edit_folder(**locals()) async def delete_dialog( self: 'TelegramClient', @@ -1700,7 +1700,7 @@ class TelegramClient: # Leaving a channel by username await client.delete_dialog('username') """ - return dialogs.delete_dialog(**locals()) + return await dialogs.delete_dialog(**locals()) # endregion Dialogs @@ -1749,7 +1749,7 @@ class TelegramClient: path = await client.download_profile_photo('me') print(path) """ - return downloads.download_profile_photo(**locals()) + return await downloads.download_profile_photo(**locals()) async def download_media( self: 'TelegramClient', @@ -1825,7 +1825,7 @@ class TelegramClient: await client.download_media(message, progress_callback=callback) """ - return downloads.download_media(**locals()) + return await downloads.download_media(**locals()) async def download_file( self: 'TelegramClient', @@ -1890,7 +1890,7 @@ class TelegramClient: data = await client.download_file(input_file, bytes) print(data[:16]) """ - return downloads.download_file(**locals()) + return await downloads.download_file(**locals()) def iter_download( self: 'TelegramClient', @@ -2249,7 +2249,7 @@ class TelegramClient: # Get messages by ID: message_1337 = await client.get_messages(chat, ids=1337) """ - return messages.get_messages(**locals()) + return await messages.get_messages(**locals()) get_messages.__signature__ = inspect.signature(iter_messages) @@ -2445,7 +2445,7 @@ class TelegramClient: from datetime import timedelta await client.send_message(chat, 'Hi, future!', schedule=timedelta(minutes=5)) """ - return messages.send_message(**locals()) + return await messages.send_message(**locals()) async def forward_messages( self: 'TelegramClient', @@ -2527,7 +2527,18 @@ class TelegramClient: # Forwarding as a copy await client.send_message(chat, message) """ - return messages.forward_messages(**locals()) + from . import messages as m + return await m.forward_messages( + self=self, + entity=entity, + messages=messages, + from_peer=from_peer, + background=background, + with_my_score=with_my_score, + silent=silent, + as_album=as_album, + schedule=schedule + ) async def edit_message( self: 'TelegramClient', @@ -2656,7 +2667,7 @@ class TelegramClient: # or await client.edit_message(message, 'hello!!!') """ - return messages.edit_message(**locals()) + return await messages.edit_message(**locals()) async def delete_messages( self: 'TelegramClient', @@ -2708,7 +2719,7 @@ class TelegramClient: await client.delete_messages(chat, messages) """ - return messages.delete_messages(**locals()) + return await messages.delete_messages(**locals()) async def send_read_acknowledge( self: 'TelegramClient', @@ -2760,7 +2771,7 @@ class TelegramClient: # ...or passing a list of messages to mark as read await client.send_read_acknowledge(chat, messages) """ - return messages.send_read_acknowledge(**locals()) + return await messages.send_read_acknowledge(**locals()) async def pin_message( self: 'TelegramClient', @@ -2801,7 +2812,7 @@ class TelegramClient: message = await client.send_message(chat, 'Pinotifying is fun!') await client.pin_message(chat, message, notify=True) """ - return messages.pin_message(**locals()) + return await messages.pin_message(**locals()) async def unpin_message( self: 'TelegramClient', @@ -2831,7 +2842,7 @@ class TelegramClient: # Unpin all messages from a chat await client.unpin_message(chat) """ - return messages.unpin_message(**locals()) + return await messages.unpin_message(**locals()) # endregion Messages @@ -2938,7 +2949,7 @@ class TelegramClient: except OSError: print('Failed to connect') """ - return telegrambaseclient.connect(**locals()) + return await telegrambaseclient.connect(**locals()) def is_connected(self: 'TelegramClient') -> bool: """ @@ -2993,7 +3004,7 @@ class TelegramClient: This is an `async` method, because in order for Telegram to start sending updates again, a request must be made. """ - return updates.set_receive_updates(**locals()) + return await updates.set_receive_updates(**locals()) def run_until_disconnected(self: 'TelegramClient'): """ @@ -3154,7 +3165,7 @@ class TelegramClient: await client.catch_up() """ - return updates.catch_up(**locals()) + return await updates.catch_up(**locals()) # endregion Updates @@ -3403,7 +3414,7 @@ class TelegramClient: vcard='' )) """ - return uploads.send_file(**locals()) + return await uploads.send_file(**locals()) async def upload_file( self: 'TelegramClient', @@ -3490,7 +3501,7 @@ class TelegramClient: await client.send_file(chat, file) # sends as song await client.send_file(chat, file, voice_note=True) # sends as voice note """ - return uploads.upload_file(**locals()) + return await uploads.upload_file(**locals()) # endregion Uploads @@ -3519,7 +3530,7 @@ class TelegramClient: The result of the request (often a `TLObject`) or a list of results if more than one request was given. """ - return self._call(request, ordered, flood_sleep_threshold) + return await users.call(**locals()) async def get_me(self: 'TelegramClient', input_peer: bool = False) \ -> 'typing.Union[_tl.User, _tl.InputPeerUser]': @@ -3543,7 +3554,7 @@ class TelegramClient: me = await client.get_me() print(me.username) """ - return users.get_me(**locals()) + return await users.get_me(**locals()) async def is_bot(self: 'TelegramClient') -> bool: """ @@ -3557,7 +3568,7 @@ class TelegramClient: else: print('Hello') """ - return users.is_bot(**locals()) + return await users.is_bot(**locals()) async def is_user_authorized(self: 'TelegramClient') -> bool: """ @@ -3571,7 +3582,7 @@ class TelegramClient: code = input('enter code: ') await client.sign_in(phone, code) """ - return users.is_user_authorized(**locals()) + return await users.is_user_authorized(**locals()) async def get_entity( self: 'TelegramClient', @@ -3629,7 +3640,7 @@ class TelegramClient: # Note that for this to work the phone number must be in your contacts some_id = await client.get_peer_id('+34123456789') """ - return users.get_entity(**locals()) + return await users.get_entity(**locals()) async def get_input_entity( self: 'TelegramClient', @@ -3695,7 +3706,7 @@ class TelegramClient: # The same applies to IDs, chats or channels. chat = await client.get_input_entity(-123456789) """ - return users.get_input_entity(**locals()) + return await users.get_input_entity(**locals()) async def get_peer_id( self: 'TelegramClient', @@ -3715,20 +3726,20 @@ class TelegramClient: print(await client.get_peer_id('me')) """ - return users.get_peer_id(**locals()) + return await users.get_peer_id(**locals()) # endregion Users # region Private async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None): - return users.call(self._sender, request, ordered=ordered, flood_sleep_threshold=flood_sleep_threshold) + return await users._call(**locals()) async def _update_loop(self: 'TelegramClient'): - return updates._update_loop(**locals()) + return await updates._update_loop(**locals()) async def _parse_message_text(self: 'TelegramClient', message, parse_mode): - return messageparse._parse_message_text(**locals()) + return await messageparse._parse_message_text(**locals()) async def _file_to_media( self, file, force_document=False, file_size=None, @@ -3736,10 +3747,10 @@ class TelegramClient: allow_cache=True, voice_note=False, video_note=False, supports_streaming=False, mime_type=None, as_image=None, ttl=None): - return uploads._file_to_media(**locals()) + return await uploads._file_to_media(**locals()) async def _get_peer(self: 'TelegramClient', peer: 'hints.EntityLike'): - return users._get_peer(**locals()) + return await users._get_peer(**locals()) def _get_response_message(self: 'TelegramClient', request, result, input_chat): return messageparse._get_response_message(**locals()) @@ -3749,19 +3760,19 @@ class TelegramClient: entity: 'hints.EntityLike', message: 'typing.Union[int, _tl.Message]' ): - return messages._get_comment_data(**locals()) + return await messages._get_comment_data(**locals()) async def _switch_dc(self: 'TelegramClient', new_dc): - return telegrambaseclient._switch_dc(**locals()) + return await telegrambaseclient._switch_dc(**locals()) async def _borrow_exported_sender(self: 'TelegramClient', dc_id): - return telegrambaseclient._borrow_exported_sender(**locals()) + return await telegrambaseclient._borrow_exported_sender(**locals()) async def _return_exported_sender(self: 'TelegramClient', sender): - return telegrambaseclient._return_exported_sender(**locals()) + return await telegrambaseclient._return_exported_sender(**locals()) async def _clean_exported_senders(self: 'TelegramClient'): - return telegrambaseclient._clean_exported_senders(**locals()) + return await telegrambaseclient._clean_exported_senders(**locals()) def _auth_key_callback(self: 'TelegramClient', auth_key): return telegrambaseclient._auth_key_callback @@ -3770,7 +3781,7 @@ class TelegramClient: return updates._handle_update(**locals()) async def _handle_auto_reconnect(self: 'TelegramClient'): - return updates._handle_auto_reconnect(**locals()) + return await updates._handle_auto_reconnect(**locals()) # endregion Private diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py index 8043f26c..c8fbcea7 100644 --- a/telethon/_client/uploads.py +++ b/telethon/_client/uploads.py @@ -167,7 +167,7 @@ async def send_file( msg_entities = formatting_entities else: caption, msg_entities =\ - await _parse_message_text(self, caption, parse_mode) + await self._parse_message_text(caption, parse_mode) file_handle, media, image = await _file_to_media( self, file, force_document=force_document, diff --git a/telethon/_client/users.py b/telethon/_client/users.py index 0719cdc4..9bf18faf 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -24,12 +24,16 @@ def _fmt_flood(delay, request, *, early=False, td=datetime.timedelta): ) -async def call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None): +async def call(self: 'TelegramClient', request, ordered=False, flood_sleep_threshold=None): + return await _call(self, self._sender, request, ordered=ordered) + + +async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None): if flood_sleep_threshold is None: flood_sleep_threshold = self.flood_sleep_threshold requests = (request if utils.is_list_like(request) else (request,)) for r in requests: - if not isinstance(r, TLRequest): + if not isinstance(r, _tl.TLRequest): raise _NOT_A_REQUEST() await r.resolve(self, utils) diff --git a/telethon/_misc/binaryreader.py b/telethon/_misc/binaryreader.py index 6a87b64d..e5c34c7f 100644 --- a/telethon/_misc/binaryreader.py +++ b/telethon/_misc/binaryreader.py @@ -9,6 +9,8 @@ from struct import unpack from ..errors import TypeNotFoundError from .. import _tl +from .._tl.alltlobjects import tlobjects +from .._tl import core _EPOCH_NAIVE = datetime(*time.gmtime(0)[:6]) _EPOCH = _EPOCH_NAIVE.replace(tzinfo=timezone.utc) @@ -117,7 +119,7 @@ class BinaryReader: def tgread_object(self): """Reads a Telegram object.""" constructor_id = self.read_int(signed=False) - clazz = _tl.tlobjects.get(constructor_id, None) + clazz = tlobjects.get(constructor_id, None) if clazz is None: # The class was None, but there's still a # chance of it being a manually parsed value like bool! @@ -129,7 +131,7 @@ class BinaryReader: elif value == 0x1cb5c415: # Vector return [self.tgread_object() for _ in range(self.read_int())] - clazz = _tl.core.get(constructor_id, None) + clazz = core.core_objects.get(constructor_id, None) if clazz is None: # If there was still no luck, give up self.seek(-4) # Go back From f6c94f4d84aed53252504318c470e0725dc058d6 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 12 Sep 2021 15:50:26 +0200 Subject: [PATCH 014/131] Mention Python 3.5 will no longer be supported --- readthedocs/misc/v2-migration-guide.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 8f9bab4d..ccf53027 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -12,6 +12,12 @@ from Telethon version 1.x to 2.0 onwards. **Please read this document in full before upgrading your code to Telethon 2.0.** +Pyhton 3.5 is no longer supported +--------------------------------- + +The library will no longer attempt to support Python 3.5. The minimum version is now Python 3.6. + + User, chat and channel identifiers are now 64-bit numbers --------------------------------------------------------- @@ -96,6 +102,7 @@ This serves multiple goals: // TODO this definitely generated files mapping from the original name to this new one... + Synchronous compatibility mode has been removed ----------------------------------------------- From c08d724baaf178bb68bc4030ac6f571fff971599 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 12 Sep 2021 15:52:01 +0200 Subject: [PATCH 015/131] Delete _tl.patched backward-compatibility hack --- telethon/_tl/patched/__init__.py | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 telethon/_tl/patched/__init__.py diff --git a/telethon/_tl/patched/__init__.py b/telethon/_tl/patched/__init__.py deleted file mode 100644 index ddffeb4c..00000000 --- a/telethon/_tl/patched/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from .. import _tl -from ..custom.message import Message as _Message - -class MessageEmpty(_Message, types.MessageEmpty): - pass - -types.MessageEmpty = MessageEmpty -alltlobjects.tlobjects[MessageEmpty.CONSTRUCTOR_ID] = MessageEmpty - -class MessageService(_Message, types.MessageService): - pass - -types.MessageService = MessageService -alltlobjects.tlobjects[MessageService.CONSTRUCTOR_ID] = MessageService - -class Message(_Message, types.Message): - pass - -types.Message = Message -alltlobjects.tlobjects[Message.CONSTRUCTOR_ID] = Message From 604c3de070a6f318347fc0ebcf101402d7726cd7 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 12 Sep 2021 16:05:56 +0200 Subject: [PATCH 016/131] Move custom and core objects to a new subpackage This should keep it cleaner, as now _tl is fully auto-generated. --- telethon/{_tl/core => types/_core}/__init__.py | 0 telethon/{_tl/core => types/_core}/gzippacked.py | 0 telethon/{_tl/core => types/_core}/messagecontainer.py | 0 telethon/{_tl/core => types/_core}/rpcresult.py | 0 telethon/{_tl/core => types/_core}/tlmessage.py | 0 telethon/{_tl/custom => types/_custom}/__init__.py | 0 telethon/{_tl/custom => types/_custom}/adminlogevent.py | 0 telethon/{_tl/custom => types/_custom}/button.py | 0 telethon/{_tl/custom => types/_custom}/chatgetter.py | 0 telethon/{_tl/custom => types/_custom}/dialog.py | 0 telethon/{_tl/custom => types/_custom}/draft.py | 0 telethon/{_tl/custom => types/_custom}/file.py | 0 telethon/{_tl/custom => types/_custom}/forward.py | 0 telethon/{_tl/custom => types/_custom}/inlinebuilder.py | 0 telethon/{_tl/custom => types/_custom}/inlineresult.py | 0 telethon/{_tl/custom => types/_custom}/inlineresults.py | 0 telethon/{_tl/custom => types/_custom}/inputsizedfile.py | 0 telethon/{_tl/custom => types/_custom}/message.py | 0 telethon/{_tl/custom => types/_custom}/messagebutton.py | 0 telethon/{_tl/custom => types/_custom}/participantpermissions.py | 0 telethon/{_tl/custom => types/_custom}/qrlogin.py | 0 telethon/{_tl/custom => types/_custom}/sendergetter.py | 0 telethon/{_tl => types}/tlobject.py | 0 23 files changed, 0 insertions(+), 0 deletions(-) rename telethon/{_tl/core => types/_core}/__init__.py (100%) rename telethon/{_tl/core => types/_core}/gzippacked.py (100%) rename telethon/{_tl/core => types/_core}/messagecontainer.py (100%) rename telethon/{_tl/core => types/_core}/rpcresult.py (100%) rename telethon/{_tl/core => types/_core}/tlmessage.py (100%) rename telethon/{_tl/custom => types/_custom}/__init__.py (100%) rename telethon/{_tl/custom => types/_custom}/adminlogevent.py (100%) rename telethon/{_tl/custom => types/_custom}/button.py (100%) rename telethon/{_tl/custom => types/_custom}/chatgetter.py (100%) rename telethon/{_tl/custom => types/_custom}/dialog.py (100%) rename telethon/{_tl/custom => types/_custom}/draft.py (100%) rename telethon/{_tl/custom => types/_custom}/file.py (100%) rename telethon/{_tl/custom => types/_custom}/forward.py (100%) rename telethon/{_tl/custom => types/_custom}/inlinebuilder.py (100%) rename telethon/{_tl/custom => types/_custom}/inlineresult.py (100%) rename telethon/{_tl/custom => types/_custom}/inlineresults.py (100%) rename telethon/{_tl/custom => types/_custom}/inputsizedfile.py (100%) rename telethon/{_tl/custom => types/_custom}/message.py (100%) rename telethon/{_tl/custom => types/_custom}/messagebutton.py (100%) rename telethon/{_tl/custom => types/_custom}/participantpermissions.py (100%) rename telethon/{_tl/custom => types/_custom}/qrlogin.py (100%) rename telethon/{_tl/custom => types/_custom}/sendergetter.py (100%) rename telethon/{_tl => types}/tlobject.py (100%) diff --git a/telethon/_tl/core/__init__.py b/telethon/types/_core/__init__.py similarity index 100% rename from telethon/_tl/core/__init__.py rename to telethon/types/_core/__init__.py diff --git a/telethon/_tl/core/gzippacked.py b/telethon/types/_core/gzippacked.py similarity index 100% rename from telethon/_tl/core/gzippacked.py rename to telethon/types/_core/gzippacked.py diff --git a/telethon/_tl/core/messagecontainer.py b/telethon/types/_core/messagecontainer.py similarity index 100% rename from telethon/_tl/core/messagecontainer.py rename to telethon/types/_core/messagecontainer.py diff --git a/telethon/_tl/core/rpcresult.py b/telethon/types/_core/rpcresult.py similarity index 100% rename from telethon/_tl/core/rpcresult.py rename to telethon/types/_core/rpcresult.py diff --git a/telethon/_tl/core/tlmessage.py b/telethon/types/_core/tlmessage.py similarity index 100% rename from telethon/_tl/core/tlmessage.py rename to telethon/types/_core/tlmessage.py diff --git a/telethon/_tl/custom/__init__.py b/telethon/types/_custom/__init__.py similarity index 100% rename from telethon/_tl/custom/__init__.py rename to telethon/types/_custom/__init__.py diff --git a/telethon/_tl/custom/adminlogevent.py b/telethon/types/_custom/adminlogevent.py similarity index 100% rename from telethon/_tl/custom/adminlogevent.py rename to telethon/types/_custom/adminlogevent.py diff --git a/telethon/_tl/custom/button.py b/telethon/types/_custom/button.py similarity index 100% rename from telethon/_tl/custom/button.py rename to telethon/types/_custom/button.py diff --git a/telethon/_tl/custom/chatgetter.py b/telethon/types/_custom/chatgetter.py similarity index 100% rename from telethon/_tl/custom/chatgetter.py rename to telethon/types/_custom/chatgetter.py diff --git a/telethon/_tl/custom/dialog.py b/telethon/types/_custom/dialog.py similarity index 100% rename from telethon/_tl/custom/dialog.py rename to telethon/types/_custom/dialog.py diff --git a/telethon/_tl/custom/draft.py b/telethon/types/_custom/draft.py similarity index 100% rename from telethon/_tl/custom/draft.py rename to telethon/types/_custom/draft.py diff --git a/telethon/_tl/custom/file.py b/telethon/types/_custom/file.py similarity index 100% rename from telethon/_tl/custom/file.py rename to telethon/types/_custom/file.py diff --git a/telethon/_tl/custom/forward.py b/telethon/types/_custom/forward.py similarity index 100% rename from telethon/_tl/custom/forward.py rename to telethon/types/_custom/forward.py diff --git a/telethon/_tl/custom/inlinebuilder.py b/telethon/types/_custom/inlinebuilder.py similarity index 100% rename from telethon/_tl/custom/inlinebuilder.py rename to telethon/types/_custom/inlinebuilder.py diff --git a/telethon/_tl/custom/inlineresult.py b/telethon/types/_custom/inlineresult.py similarity index 100% rename from telethon/_tl/custom/inlineresult.py rename to telethon/types/_custom/inlineresult.py diff --git a/telethon/_tl/custom/inlineresults.py b/telethon/types/_custom/inlineresults.py similarity index 100% rename from telethon/_tl/custom/inlineresults.py rename to telethon/types/_custom/inlineresults.py diff --git a/telethon/_tl/custom/inputsizedfile.py b/telethon/types/_custom/inputsizedfile.py similarity index 100% rename from telethon/_tl/custom/inputsizedfile.py rename to telethon/types/_custom/inputsizedfile.py diff --git a/telethon/_tl/custom/message.py b/telethon/types/_custom/message.py similarity index 100% rename from telethon/_tl/custom/message.py rename to telethon/types/_custom/message.py diff --git a/telethon/_tl/custom/messagebutton.py b/telethon/types/_custom/messagebutton.py similarity index 100% rename from telethon/_tl/custom/messagebutton.py rename to telethon/types/_custom/messagebutton.py diff --git a/telethon/_tl/custom/participantpermissions.py b/telethon/types/_custom/participantpermissions.py similarity index 100% rename from telethon/_tl/custom/participantpermissions.py rename to telethon/types/_custom/participantpermissions.py diff --git a/telethon/_tl/custom/qrlogin.py b/telethon/types/_custom/qrlogin.py similarity index 100% rename from telethon/_tl/custom/qrlogin.py rename to telethon/types/_custom/qrlogin.py diff --git a/telethon/_tl/custom/sendergetter.py b/telethon/types/_custom/sendergetter.py similarity index 100% rename from telethon/_tl/custom/sendergetter.py rename to telethon/types/_custom/sendergetter.py diff --git a/telethon/_tl/tlobject.py b/telethon/types/tlobject.py similarity index 100% rename from telethon/_tl/tlobject.py rename to telethon/types/tlobject.py From 01061e07191558d0bdf9cd25bdaaaa05d0d0d92f Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 12 Sep 2021 16:08:20 +0200 Subject: [PATCH 017/131] Sort migration guide in roughly order of importance --- readthedocs/misc/v2-migration-guide.rst | 103 +++++++++++++----------- 1 file changed, 54 insertions(+), 49 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index ccf53027..3815f2dd 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -6,8 +6,9 @@ Version 2 represents the second major version change, breaking compatibility with old code beyond the usual raw API changes in order to clean up a lot of the technical debt that has grown on the project. -This document documents all the things you should be aware of when migrating -from Telethon version 1.x to 2.0 onwards. +This document documents all the things you should be aware of when migrating from Telethon version +1.x to 2.0 onwards. It is sorted roughly from the "likely most impactful changes" to "there's a +good chance you were not relying on this to begin with". **Please read this document in full before upgrading your code to Telethon 2.0.** @@ -30,48 +31,35 @@ will need to migrate that to support the new size requirement of 8 bytes. For the full list of types changed, please review the above link. -Many subpackages and modules are now private --------------------------------------------- +Synchronous compatibility mode has been removed +----------------------------------------------- -There were a lot of things which were public but should not have been. From now on, you should -only rely on things that are either publicly re-exported or defined. That is, as soon as anything -starts with an underscore (``_``) on its name, you're acknowledging that the functionality may -change even across minor version changes, and thus have your code break. +The "sync hack" (which kicked in as soon as anything from ``telethon.sync`` was imported) has been +removed. This implies: -The following subpackages are now considered private: +* The ``telethon.sync`` module is gone. +* Synchronous context-managers (``with`` as opposed to ``async with``) are no longer supported. + Most notably, you can no longer do ``with client``. It must be ``async with client`` now. +* The "smart" behaviour of the following methods has been removed and now they no longer work in + a synchronous context when the ``asyncio`` event loop was not running. This means they now need + to be used with ``await`` (or, alternatively, manually used with ``loop.run_until_complete``): + * ``start`` + * ``disconnect`` + * ``run_until_disconnected`` -* ``client`` is now ``_client``. -* ``crypto`` is now ``_crypto``. -* ``extensions`` is now ``_misc``. -* ``tl`` is now ``_tl``. - -The following modules have been moved inside ``_misc``: - -* ``entitycache.py`` -* ``helpers.py`` -* ``hints.py`` -* ``password.py`` -* ``requestiter.py` -* ``statecache.py`` -* ``utils.py`` - -// TODO review telethon/__init__.py isn't exposing more than it should +// TODO provide standalone alternative for this? -The TelegramClient is no longer made out of mixins --------------------------------------------------- - -If you were relying on any of the individual mixins that made up the client, such as -``UserMethods`` inside the ``telethon.client`` subpackage, those are now gone. -There is a single ``TelegramClient`` class now, containing everything you need. - - -Raw API methods have been renamed ---------------------------------- +Raw API methods have been renamed and are now considered private +---------------------------------------------------------------- The subpackage holding the raw API methods has been renamed from ``tl`` to ``_tl`` in order to signal that these are prone to change across minor version bumps (the ``y`` in version ``x.y.z``). +Because in Python "we're all adults", you *can* use this private module if you need to. However, +you *are* also acknowledging that this is a private module prone to change (and indeed, it will +change on layer upgrades across minor version bumps). + The ``Request`` suffix has been removed from the classes inside ``tl.functions``. The ``tl.types`` is now simply ``_tl``, and the ``tl.functions`` is now ``_tl.fn``. @@ -103,23 +91,32 @@ This serves multiple goals: // TODO this definitely generated files mapping from the original name to this new one... -Synchronous compatibility mode has been removed ------------------------------------------------ +Many subpackages and modules are now private +-------------------------------------------- -The "sync hack" (which kicked in as soon as anything from ``telethon.sync`` was imported) has been -removed. This implies: +There were a lot of things which were public but should not have been. From now on, you should +only rely on things that are either publicly re-exported or defined. That is, as soon as anything +starts with an underscore (``_``) on its name, you're acknowledging that the functionality may +change even across minor version changes, and thus have your code break. -* The ``telethon.sync`` module is gone. -* Synchronous context-managers (``with`` as opposed to ``async with``) are no longer supported. - Most notably, you can no longer do ``with client``. It must be ``async with client`` now. -* The "smart" behaviour of the following methods has been removed and now they no longer work in - a synchronous context when the ``asyncio`` event loop was not running. This means they now need - to be used with ``await`` (or, alternatively, manually used with ``loop.run_until_complete``): - * ``start`` - * ``disconnect`` - * ``run_until_disconnected`` +The following subpackages are now considered private: -// TODO provide standalone alternative for this? +* ``client`` is now ``_client``. +* ``crypto`` is now ``_crypto``. +* ``extensions`` is now ``_misc``. +* ``tl`` is now ``_tl``. + +The following modules have been moved inside ``_misc``: + +* ``entitycache.py`` +* ``helpers.py`` +* ``hints.py`` +* ``password.py`` +* ``requestiter.py` +* ``statecache.py`` +* ``utils.py`` + +// TODO review telethon/__init__.py isn't exposing more than it should The Conversation API has been removed @@ -134,3 +131,11 @@ just fine This approach can also be easily persisted, and you can adjust it to y your handlers much more easily. // TODO provide standalone alternative for this? + + +The TelegramClient is no longer made out of mixins +-------------------------------------------------- + +If you were relying on any of the individual mixins that made up the client, such as +``UserMethods`` inside the ``telethon.client`` subpackage, those are now gone. +There is a single ``TelegramClient`` class now, containing everything you need. From 5fd2a017b2759c598e1ea0bd5965049a003ce05c Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 12 Sep 2021 16:23:55 +0200 Subject: [PATCH 018/131] Fix imports --- telethon/__init__.py | 3 +-- telethon/_crypto/authkey.py | 2 +- telethon/_misc/__init__.py | 1 - telethon/_misc/binaryreader.py | 2 +- telethon/_misc/hints.py | 4 ++-- telethon/{types => _misc}/tlobject.py | 0 telethon/types/__init__.py | 16 ++++++++++++++++ telethon_generator/generators/docs.py | 2 +- telethon_generator/generators/tlobject.py | 6 +++--- telethon_generator/parsers/errors.py | 2 +- telethon_generator/parsers/tlobject/tlobject.py | 2 +- 11 files changed, 27 insertions(+), 13 deletions(-) rename telethon/{types => _misc}/tlobject.py (100%) create mode 100644 telethon/types/__init__.py diff --git a/telethon/__init__.py b/telethon/__init__.py index fa01de5b..59ea6691 100644 --- a/telethon/__init__.py +++ b/telethon/__init__.py @@ -2,8 +2,7 @@ from ._misc import helpers # no dependencies from . import _tl # no dependencies from ._misc import utils # depends on helpers and _tl -from ._tl import custom # depends on utils -from ._misc import hints # depends on custom +from ._misc import hints # depends on types/custom from ._client.telegramclient import TelegramClient from ._network import connection diff --git a/telethon/_crypto/authkey.py b/telethon/_crypto/authkey.py index fa6fbb78..54c66c76 100644 --- a/telethon/_crypto/authkey.py +++ b/telethon/_crypto/authkey.py @@ -4,7 +4,7 @@ This module holds the AuthKey class. import struct from hashlib import sha1 -from .._misc import BinaryReader +from .._misc.binaryreader import BinaryReader class AuthKey: diff --git a/telethon/_misc/__init__.py b/telethon/_misc/__init__.py index a3c77295..71c26b8a 100644 --- a/telethon/_misc/__init__.py +++ b/telethon/_misc/__init__.py @@ -3,4 +3,3 @@ Several extensions Python is missing, such as a proper class to handle a TCP communication with support for cancelling the operation, and a utility class to read arbitrary binary data in a more comfortable way, with int/strings/etc. """ -from .binaryreader import BinaryReader diff --git a/telethon/_misc/binaryreader.py b/telethon/_misc/binaryreader.py index e5c34c7f..34648a7a 100644 --- a/telethon/_misc/binaryreader.py +++ b/telethon/_misc/binaryreader.py @@ -10,7 +10,7 @@ from struct import unpack from ..errors import TypeNotFoundError from .. import _tl from .._tl.alltlobjects import tlobjects -from .._tl import core +from ..types import core _EPOCH_NAIVE = datetime(*time.gmtime(0)[:6]) _EPOCH = _EPOCH_NAIVE.replace(tzinfo=timezone.utc) diff --git a/telethon/_misc/hints.py b/telethon/_misc/hints.py index a5299a25..47f26a8f 100644 --- a/telethon/_misc/hints.py +++ b/telethon/_misc/hints.py @@ -3,7 +3,7 @@ import typing from . import helpers from .. import _tl -from .._tl import custom +from ..types import _custom Phone = str Username = str @@ -22,7 +22,7 @@ EntityLike = typing.Union[ ] EntitiesLike = typing.Union[EntityLike, typing.Sequence[EntityLike]] -ButtonLike = typing.Union[_tl.TypeKeyboardButton, custom.Button] +ButtonLike = typing.Union[_tl.TypeKeyboardButton, _custom.Button] MarkupLike = typing.Union[ _tl.TypeReplyMarkup, ButtonLike, diff --git a/telethon/types/tlobject.py b/telethon/_misc/tlobject.py similarity index 100% rename from telethon/types/tlobject.py rename to telethon/_misc/tlobject.py diff --git a/telethon/types/__init__.py b/telethon/types/__init__.py new file mode 100644 index 00000000..ac52ff6d --- /dev/null +++ b/telethon/types/__init__.py @@ -0,0 +1,16 @@ +from .._misc.tlobject import TLObject, TLRequest +from ._custom import ( + AdminLogEvent, + Draft, + Dialog, + InputSizedFile, + MessageButton, + Forward, + Message, + Button, + InlineBuilder, + InlineResult, + InlineResults, + QRLogin, + ParticipantPermissions, +) diff --git a/telethon_generator/generators/docs.py b/telethon_generator/generators/docs.py index d2da55d1..8b46e4d1 100755 --- a/telethon_generator/generators/docs.py +++ b/telethon_generator/generators/docs.py @@ -9,7 +9,7 @@ from pathlib import Path from ..docswriter import DocsWriter from ..parsers import TLObject, Usability -from .._misc.utils import snake_to_camel_case +from ..utils import snake_to_camel_case CORE_TYPES = { 'int', 'long', 'int128', 'int256', 'double', diff --git a/telethon_generator/generators/tlobject.py b/telethon_generator/generators/tlobject.py index 04003a6b..25c42e1e 100644 --- a/telethon_generator/generators/tlobject.py +++ b/telethon_generator/generators/tlobject.py @@ -7,7 +7,7 @@ from collections import defaultdict from zlib import crc32 from ..sourcebuilder import SourceBuilder -from .._misc.utils import snake_to_camel_case +from ..utils import snake_to_camel_case AUTO_GEN_NOTICE = \ '"""File generated by TLObjects\' generator. All changes will be ERASED"""' @@ -61,10 +61,10 @@ def _write_modules( builder.writeln(AUTO_GEN_NOTICE) if kind == 'TLObject': - builder.writeln('from .tlobject import TLObject, TLRequest') + builder.writeln('from .._misc.tlobject import TLObject, TLRequest') builder.writeln('from . import fn') else: - builder.writeln('from .. import TLObject, TLRequest') + builder.writeln('from ..._misc.tlobject import TLObject, TLRequest') builder.writeln('from typing import Optional, List, ' 'Union, TYPE_CHECKING') diff --git a/telethon_generator/parsers/errors.py b/telethon_generator/parsers/errors.py index 9bac2142..04cd3412 100644 --- a/telethon_generator/parsers/errors.py +++ b/telethon_generator/parsers/errors.py @@ -1,7 +1,7 @@ import csv import re -from .._misc.utils import snake_to_camel_case +from ..utils import snake_to_camel_case # Core base classes depending on the integer error code KNOWN_BASE_CLASSES = { diff --git a/telethon_generator/parsers/tlobject/tlobject.py b/telethon_generator/parsers/tlobject/tlobject.py index 0f753fa2..60b9e996 100644 --- a/telethon_generator/parsers/tlobject/tlobject.py +++ b/telethon_generator/parsers/tlobject/tlobject.py @@ -2,7 +2,7 @@ import re import struct import zlib -from ..._misc.utils import snake_to_camel_case +from ...utils import snake_to_camel_case # https://github.com/telegramdesktop/tdesktop/blob/4bf66cb6e93f3965b40084771b595e93d0b11bcd/Telegram/SourceFiles/codegen/scheme/codegen_scheme.py#L57-L62 WHITELISTED_MISMATCHING_IDS = { From 499fc9f603c62f63c53120e5ecdbd43b575ebcb5 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 12 Sep 2021 16:58:06 +0200 Subject: [PATCH 019/131] Move alltlobjects.py and fix imports --- telethon/__init__.py | 1 - telethon/_client/auth.py | 6 +- telethon/_client/bots.py | 6 +- telethon/_client/buttons.py | 8 +- telethon/_client/chats.py | 12 +-- telethon/_client/dialogs.py | 6 +- telethon/_client/downloads.py | 4 +- telethon/_client/telegrambaseclient.py | 3 +- telethon/_client/telegramclient.py | 104 +++++++++++----------- telethon/_client/uploads.py | 4 +- telethon/_crypto/rsa.py | 6 +- telethon/_misc/binaryreader.py | 7 +- telethon/_misc/markdown.py | 3 +- telethon/_misc/messagepacker.py | 3 +- telethon/_network/authenticator.py | 2 +- telethon/_network/mtprotoplainsender.py | 2 +- telethon/_network/mtprotosender.py | 6 +- telethon/_network/mtprotostate.py | 7 +- telethon/events/album.py | 12 +-- telethon/events/callbackquery.py | 8 +- telethon/events/common.py | 7 +- telethon/events/inlinequery.py | 6 +- telethon/events/userupdate.py | 6 +- telethon/sessions/memory.py | 8 +- telethon/types/_core/gzippacked.py | 6 +- telethon/types/_core/messagecontainer.py | 2 +- telethon/types/_core/rpcresult.py | 7 +- telethon/types/_core/tlmessage.py | 4 +- telethon/types/_custom/dialog.py | 6 +- telethon/types/_custom/draft.py | 6 +- telethon/types/_custom/message.py | 4 +- telethon_generator/generators/tlobject.py | 31 +++---- 32 files changed, 145 insertions(+), 158 deletions(-) diff --git a/telethon/__init__.py b/telethon/__init__.py index 59ea6691..edbd4126 100644 --- a/telethon/__init__.py +++ b/telethon/__init__.py @@ -6,7 +6,6 @@ from ._misc import hints # depends on types/custom from ._client.telegramclient import TelegramClient from ._network import connection -from ._tl.custom import Button from . import version, events, utils, errors __version__ = version.__version__ diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index 4ae32c43..992d44e1 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -7,7 +7,7 @@ import warnings from .._misc import utils, helpers, password as pwd_mod from .. import errors, _tl -from .._tl import custom +from ..types import _custom if typing.TYPE_CHECKING: from .telegramclient import TelegramClient @@ -343,8 +343,8 @@ async def send_code_request( return result -async def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> custom.QRLogin: - qr_login = custom.QRLogin(self, ignored_ids or []) +async def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> _custom.QRLogin: + qr_login = _custom.QRLogin(self, ignored_ids or []) await qr_login.recreate() return qr_login diff --git a/telethon/_client/bots.py b/telethon/_client/bots.py index 8c2d50fe..9360d7f3 100644 --- a/telethon/_client/bots.py +++ b/telethon/_client/bots.py @@ -1,7 +1,7 @@ import typing from .. import hints, _tl -from .._tl import custom +from ..types import _custom if typing.TYPE_CHECKING: from .telegramclient import TelegramClient @@ -14,7 +14,7 @@ async def inline_query( *, entity: 'hints.EntityLike' = None, offset: str = None, - geo_point: '_tl.GeoPoint' = None) -> custom.InlineResults: + geo_point: '_tl.GeoPoint' = None) -> _custom.InlineResults: bot = await self.get_input_entity(bot) if entity: peer = await self.get_input_entity(entity) @@ -29,4 +29,4 @@ async def inline_query( geo_point=geo_point )) - return custom.InlineResults(self, result, entity=peer if entity else None) + return _custom.InlineResults(self, result, entity=peer if entity else None) diff --git a/telethon/_client/buttons.py b/telethon/_client/buttons.py index 897b4703..fbd0f51c 100644 --- a/telethon/_client/buttons.py +++ b/telethon/_client/buttons.py @@ -2,7 +2,7 @@ import typing from .._misc import utils, hints from .. import _tl -from .._tl import custom +from ..types import _custom def build_reply_markup( @@ -32,7 +32,7 @@ def build_reply_markup( for row in buttons: current = [] for button in row: - if isinstance(button, custom.Button): + if isinstance(button, _custom.Button): if button.resize is not None: resize = button.resize if button.single_use is not None: @@ -41,10 +41,10 @@ def build_reply_markup( selective = button.selective button = button.button - elif isinstance(button, custom.MessageButton): + elif isinstance(button, _custom.MessageButton): button = button.button - inline = custom.Button._is_inline(button) + inline = _custom.Button._is_inline(button) is_inline |= inline is_normal |= not inline diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 0fb88ed8..0ff93c02 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -5,8 +5,8 @@ import string import typing from .. import hints, errors, _tl -from .._misc import helpers, utils, requestiter -from .._tl import custom +from .._misc import helpers, utils, requestiter, tlobject +from ..types import _custom if typing.TYPE_CHECKING: from .telegramclient import TelegramClient @@ -302,7 +302,7 @@ class _AdminLogIter(requestiter.RequestIter): ev.action.message._finish_init( self.client, entities, self.entity) - self.buffer.append(custom.AdminLogEvent(ev, entities)) + self.buffer.append(_custom.AdminLogEvent(ev, entities)) if len(r.events) < self.request.limit: return True @@ -516,7 +516,7 @@ def action( except KeyError: raise ValueError( 'No such action "{}"'.format(action)) from None - elif not isinstance(action, _tl.TLObject) or action.SUBCLASS_OF_ID != 0x20b2cc21: + elif not isinstance(action, tlobject.TLObject) or action.SUBCLASS_OF_ID != 0x20b2cc21: # 0x20b2cc21 = crc32(b'SendMessageAction') if isinstance(action, type): raise ValueError('You must pass an instance, not the class') @@ -716,7 +716,7 @@ async def get_permissions( entity, user )) - return custom.ParticipantPermissions(participant.participant, False) + return _custom.ParticipantPermissions(participant.participant, False) elif helpers._entity_type(entity) == helpers._EntityType.CHAT: chat = await self(_tl.fn.messages.GetFullChat( entity @@ -725,7 +725,7 @@ async def get_permissions( user = await self.get_me(input_peer=True) for participant in chat.full_chat.participants.participants: if participant.user_id == user.user_id: - return custom.ParticipantPermissions(participant, True) + return _custom.ParticipantPermissions(participant, True) raise errors.UserNotParticipantError(None) raise ValueError('You must pass either a channel or a chat') diff --git a/telethon/_client/dialogs.py b/telethon/_client/dialogs.py index bfd76f61..850e3ac9 100644 --- a/telethon/_client/dialogs.py +++ b/telethon/_client/dialogs.py @@ -5,7 +5,7 @@ import typing from .. import hints, errors, _tl from .._misc import helpers, utils, requestiter -from .._tl import custom +from ..types import _custom _MAX_CHUNK_SIZE = 100 @@ -80,7 +80,7 @@ class _DialogsIter(requestiter.RequestIter): # Real world example: https://t.me/TelethonChat/271471 continue - cd = custom.Dialog(self.client, d, entities, message) + cd = _custom.Dialog(self.client, d, entities, message) if cd.dialog.pts: self.client._channel_pts[cd.id] = cd.dialog.pts @@ -128,7 +128,7 @@ class _DraftsIter(requestiter.RequestIter): for x in itertools.chain(r.users, r.chats)} self.buffer.extend( - custom.Draft(self.client, entities[utils.get_peer_id(d.peer)], d.draft) + _custom.Draft(self.client, entities[utils.get_peer_id(d.peer)], d.draft) for d in items ) diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py index 25a461cb..6cd34b10 100644 --- a/telethon/_client/downloads.py +++ b/telethon/_client/downloads.py @@ -7,7 +7,7 @@ import inspect import asyncio from .._crypto import AES -from .._misc import utils, helpers, requestiter +from .._misc import utils, helpers, requestiter, tlobject from .. import errors, hints, _tl try: @@ -198,7 +198,7 @@ async def download_profile_photo( ENTITIES = (0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697) # ('InputPeer', 'InputUser', 'InputChannel') INPUTS = (0xc91c90b6, 0xe669bf46, 0x40f202fd) - if not isinstance(entity, _tl.TLObject) or entity.SUBCLASS_OF_ID in INPUTS: + if not isinstance(entity, tlobject.TLObject) or entity.SUBCLASS_OF_ID in INPUTS: entity = await self.get_entity(entity) thumb = -1 if download_big else 0 diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index b45ac6d3..0ac127f0 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -11,7 +11,6 @@ from .. import version, helpers, __name__ as __base_name__, _tl from .._crypto import rsa from .._misc import markdown, entitycache, statecache from .._network import MTProtoSender, Connection, ConnectionTcpFull, TcpMTProxy -from .._tl.alltlobjects import LAYER from ..sessions import Session, SQLiteSession, MemorySession DEFAULT_DC_ID = 2 @@ -322,7 +321,7 @@ async def connect(self: 'TelegramClient') -> None: self._init_request.query = _tl.fn.help.GetConfig() await self._sender.send(_tl.fn.InvokeWithLayer( - LAYER, self._init_request + _tl.LAYER, self._init_request )) self._updates_handle = self.loop.create_task(self._update_loop()) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 3596a506..85b28cd3 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -9,7 +9,7 @@ from . import ( telegrambaseclient, updates, uploads, users ) from .. import helpers, version, _tl -from .._tl import custom +from ..types import _custom from .._network import ConnectionTcpFull from ..events.common import EventBuilder, EventCommon @@ -500,7 +500,7 @@ class TelegramClient: """ return await auth.send_code_request(**locals()) - async def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> custom.QRLogin: + async def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> _custom.QRLogin: """ Initiates the QR login procedure. @@ -630,7 +630,7 @@ class TelegramClient: *, entity: 'hints.EntityLike' = None, offset: str = None, - geo_point: '_tl.GeoPoint' = None) -> custom.InlineResults: + geo_point: '_tl.GeoPoint' = None) -> _custom.InlineResults: """ Makes an inline query to the specified bot (``@vote New Poll``). @@ -658,8 +658,8 @@ class TelegramClient: for localised results. Available under some bots. Returns - A list of `custom.InlineResult - `. + A list of `_custom.InlineResult + `. Example .. code-block:: python @@ -923,7 +923,7 @@ class TelegramClient: If `True`, events related to group calls will be returned. Yields - Instances of `AdminLogEvent `. + Instances of `AdminLogEvent `. Example .. code-block:: python @@ -1161,8 +1161,8 @@ class TelegramClient: .. note:: Users may be able to identify the anonymous admin by its - custom title, so additional care is needed when using both - ``anonymous`` and custom titles. For example, if multiple + _custom title, so additional care is needed when using both + ``anonymous`` and _custom titles. For example, if multiple anonymous admins share the same title, users won't be able to distinguish them. @@ -1178,7 +1178,7 @@ class TelegramClient: permissions, but you can still disable those you need. title (`str`, optional): - The custom title (also known as "rank") to show for this admin. + The _custom title (also known as "rank") to show for this admin. This text will be shown instead of the "admin" badge. This will only work in channels and megagroups. @@ -1340,7 +1340,7 @@ class TelegramClient: The user to kick. Returns - Returns the service `Message ` + Returns the service `Message ` produced about a user being kicked, if any. Example @@ -1359,7 +1359,7 @@ class TelegramClient: self: 'TelegramClient', entity: 'hints.EntityLike', user: 'hints.EntityLike' = None - ) -> 'typing.Optional[custom.ParticipantPermissions]': + ) -> 'typing.Optional[_custom.ParticipantPermissions]': """ Fetches the permissions of a user in a specific chat or channel or get Default Restricted Rights of Chat or Channel. @@ -1377,7 +1377,7 @@ class TelegramClient: Target user. Returns - A `ParticipantPermissions ` + A `ParticipantPermissions ` instance. Refer to its documentation to see what properties are available. @@ -1509,7 +1509,7 @@ class TelegramClient: Alias for `folder`. If unspecified, all will be returned, `False` implies ``folder=0`` and `True` implies ``folder=1``. Yields - Instances of `Dialog `. + Instances of `Dialog `. Example .. code-block:: python @@ -1563,7 +1563,7 @@ class TelegramClient: If left unspecified, all draft messages will be returned. Yields - Instances of `Draft `. + Instances of `Draft `. Example .. code-block:: python @@ -1670,7 +1670,7 @@ class TelegramClient: bots will only be able to use it to leave groups and channels (trying to delete a private conversation will do nothing). - See also `Dialog.delete() `. + See also `Dialog.delete() `. Arguments entity (entities): @@ -1765,10 +1765,10 @@ class TelegramClient: ``cryptg`` (through ``pip install cryptg``) so that decrypting the received data is done in C instead of Python (much faster). - See also `Message.download_media() `. + See also `Message.download_media() `. Arguments - message (`Message ` | :tl:`Media`): + message (`Message ` | :tl:`Media`): The media or message containing the media that will be downloaded. file (`str` | `file`, optional): @@ -2186,7 +2186,7 @@ class TelegramClient: All other parameter will be ignored for this, except `entity`. Yields - Instances of `Message `. + Instances of `Message `. Example .. code-block:: python @@ -2232,7 +2232,7 @@ class TelegramClient: 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 `Message ` will be + a single `Message ` will be returned for convenience instead of a list. Example @@ -2278,7 +2278,7 @@ class TelegramClient: Sends a message to the specified user, chat or channel. The default parse mode is the same as the official applications - (a custom flavour of markdown). ``**bold**, `code` or __italic__`` + (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 @@ -2288,14 +2288,14 @@ class TelegramClient: is also done through this method. Simply send ``'/start data'`` to the bot. - See also `Message.respond() ` - and `Message.reply() `. + See also `Message.respond() ` + and `Message.reply() `. Arguments entity (`entity`): To who will it be sent. - message (`str` | `Message `): + message (`str` | `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 @@ -2303,7 +2303,7 @@ class TelegramClient: and you should slice them manually if the text to send is longer than said length. - reply_to (`int` | `Message `, optional): + reply_to (`int` | `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. @@ -2344,7 +2344,7 @@ class TelegramClient: clear_draft (`bool`, optional): Whether the existing draft should be cleared or not. - buttons (`list`, `custom.Button `, :tl:`KeyboardButton`): + buttons (`list`, `_custom.Button `, :tl:`KeyboardButton`): The matrix (list of lists), row list or button to be shown after sending the message. This parameter will only work if you have signed in as a bot. You can also pass your own @@ -2378,7 +2378,7 @@ class TelegramClient: it will be scheduled to be automatically sent at a later time. - comment_to (`int` | `Message `, optional): + comment_to (`int` | `Message `, optional): Similar to ``reply_to``, but replies in the linked group of a broadcast channel instead (effectively leaving a "comment to" the specified message). @@ -2387,7 +2387,7 @@ class TelegramClient: no linked chat, `telethon.errors.sgIdInvalidError` is raised. Returns - The sent `custom.Message `. + The sent `_custom.Message `. Example .. code-block:: python @@ -2466,13 +2466,13 @@ class TelegramClient: (the "forwarded from" text), you should use `send_message` with the original message instead. This will send a copy of it. - See also `Message.forward_to() `. + See also `Message.forward_to() `. Arguments entity (`entity`): To which entity the message(s) will be forwarded. - messages (`list` | `int` | `Message `): + messages (`list` | `int` | `Message `): The message(s) to forward, or their integer IDs. from_peer (`entity`): @@ -2502,7 +2502,7 @@ class TelegramClient: at a later time. Returns - The list of forwarded `Message `, + The list of forwarded `Message `, or a single one if a list wasn't provided as input. Note that if all messages are invalid (i.e. deleted) the call @@ -2560,10 +2560,10 @@ class TelegramClient: """ Edits the given message to change its text or media. - See also `Message.edit() `. + See also `Message.edit() `. Arguments - entity (`entity` | `Message `): + entity (`entity` | `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 @@ -2573,16 +2573,16 @@ class TelegramClient: which is the only way to edit messages that were sent after the user selects an inline query result. - message (`int` | `Message ` | `str`): + message (`int` | `Message ` | `str`): The ID of the message (or `Message - ` itself) to be edited. + ` itself) to be edited. If the `entity` was a `Message - `, then this 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 `Message `. + was a `Message `. parse_mode (`object`, optional): See the `TelegramClient.parse_mode @@ -2618,7 +2618,7 @@ class TelegramClient: force_document (`bool`, optional): Whether to send the given file as a document or not. - buttons (`list`, `custom.Button `, :tl:`KeyboardButton`): + buttons (`list`, `_custom.Button `, :tl:`KeyboardButton`): The matrix (list of lists), row list or button to be shown after sending the message. This parameter will only work if you have signed in as a bot. You can also pass your own @@ -2640,7 +2640,7 @@ class TelegramClient: trying to edit a message that was sent via inline bots. Returns - The edited `Message `, + The edited `Message `, unless `entity` was a :tl:`InputBotInlineMessageID` in which case this method returns a boolean. @@ -2678,7 +2678,7 @@ class TelegramClient: """ Deletes the given messages, optionally "for everyone". - See also `Message.delete() `. + See also `Message.delete() `. .. warning:: @@ -2693,7 +2693,7 @@ class TelegramClient: be `None` for normal chats, but **must** be present for channels and megagroups. - message_ids (`list` | `int` | `Message `): + message_ids (`list` | `int` | `Message `): The IDs (or ID) or messages to be deleted. revoke (`bool`, optional): @@ -2741,13 +2741,13 @@ class TelegramClient: including such ID will be marked as read (for all messages whose ID ≤ max_id). - See also `Message.mark_read() `. + See also `Message.mark_read() `. Arguments entity (`entity`): The chat where these messages are located. - message (`list` | `Message `): + message (`list` | `Message `): Either a list of messages or a single message. max_id (`int`): @@ -2787,13 +2787,13 @@ class TelegramClient: The default behaviour is to *not* notify members, unlike the official applications. - See also `Message.pin() `. + See also `Message.pin() `. Arguments entity (`entity`): The chat where the message should be pinned. - message (`int` | `Message `): + message (`int` | `Message `): The message or the message ID to pin. If it's `None`, all messages will be unpinned instead. @@ -2826,13 +2826,13 @@ class TelegramClient: If no message ID is specified, all pinned messages will be unpinned. - See also `Message.unpin() `. + See also `Message.unpin() `. Arguments entity (`entity`): The chat where the message should be pinned. - message (`int` | `Message `): + message (`int` | `Message `): The message or the message ID to unpin. If it's `None`, all messages will be unpinned instead. @@ -3277,7 +3277,7 @@ class TelegramClient: A callback function accepting two parameters: ``(sent bytes, total)``. - reply_to (`int` | `Message `): + reply_to (`int` | `Message `): Same as `reply_to` from `send_message`. attributes (`list`, optional): @@ -3318,7 +3318,7 @@ class TelegramClient: If `True` the video will be sent as a video note, also known as a round video message. - buttons (`list`, `custom.Button `, :tl:`KeyboardButton`): + buttons (`list`, `_custom.Button `, :tl:`KeyboardButton`): The matrix (list of lists), row list or button to be shown after sending the message. This parameter will only work if you have signed in as a bot. You can also pass your own @@ -3345,7 +3345,7 @@ class TelegramClient: it will be scheduled to be automatically sent at a later time. - comment_to (`int` | `Message `, optional): + comment_to (`int` | `Message `, optional): Similar to ``reply_to``, but replies in the linked group of a broadcast channel instead (effectively leaving a "comment to" the specified message). @@ -3366,7 +3366,7 @@ class TelegramClient: as text documents, which will fail with ``TtlMediaInvalidError``. Returns - The `Message ` (or messages) + The `Message ` (or messages) containing the sent file, or messages if a list of them was passed. Example @@ -3381,7 +3381,7 @@ class TelegramClient: await client.send_file(chat, '/my/songs/song.mp3', voice_note=True) await client.send_file(chat, '/my/videos/video.mp4', video_note=True) - # Custom thumbnails + # _custom thumbnails await client.send_file(chat, '/my/documents/doc.txt', thumb='photo.jpg') # Only documents @@ -3482,7 +3482,7 @@ class TelegramClient: Returns :tl:`InputFileBig` if the file size is larger than 10MB, - `InputSizedFile ` + `InputSizedFile ` (subclass of :tl:`InputFile`) otherwise. Example diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py index c8fbcea7..fb5bfc0c 100644 --- a/telethon/_client/uploads.py +++ b/telethon/_client/uploads.py @@ -11,7 +11,7 @@ from .._crypto import AES from .._misc import utils, helpers from .. import hints, _tl -from .._tl import custom +from ..types import _custom try: import PIL @@ -363,7 +363,7 @@ async def upload_file( if is_big: return _tl.InputFileBig(file_id, part_count, file_name) else: - return custom.InputSizedFile( + return _custom.InputSizedFile( file_id, part_count, file_name, md5=hash_md5, size=file_size ) diff --git a/telethon/_crypto/rsa.py b/telethon/_crypto/rsa.py index d1f1b588..eca09743 100644 --- a/telethon/_crypto/rsa.py +++ b/telethon/_crypto/rsa.py @@ -11,7 +11,7 @@ except ImportError: rsa = None raise ImportError('Missing module "rsa", please install via pip.') -from .. import _tl +from .._misc import tlobject # {fingerprint: (Crypto.PublicKey.RSA._RSAobj, old)} dictionary @@ -41,8 +41,8 @@ def _compute_fingerprint(key): :param key: the Crypto.RSA key. :return: its 8-bytes-long fingerprint. """ - n = _tl.TLObject.serialize_bytes(get_byte_array(key.n)) - e = _tl.TLObject.serialize_bytes(get_byte_array(key.e)) + n = tlobject.TLObject.serialize_bytes(get_byte_array(key.n)) + e = tlobject.TLObject.serialize_bytes(get_byte_array(key.e)) # Telegram uses the last 8 bytes as the fingerprint return struct.unpack(' 1: return super().filter(event) - class Event(EventCommon, custom.sendergetter.SenderGetter): + class Event(EventCommon, _custom.sendergetter.SenderGetter): """ Represents the event of a new album. Members: - messages (Sequence[`Message `]): + messages (Sequence[`Message `]): The list of messages belonging to the same album. """ def __init__(self, messages): @@ -160,7 +160,7 @@ class Album(EventBuilder): super().__init__(chat_peer=chat_peer, msg_id=message.id, broadcast=bool(message.post)) - custom.sendergetter.SenderGetter.__init__(self, message.sender_id) + _custom.sendergetter.SenderGetter.__init__(self, message.sender_id) self.messages = messages def _set_client(self, client): @@ -217,7 +217,7 @@ class Album(EventBuilder): @property def forward(self): """ - The `Forward ` + The `Forward ` information for the first message in the album if it was forwarded. """ # Each individual message in an album all reply to the same message @@ -229,7 +229,7 @@ class Album(EventBuilder): async def get_reply_message(self): """ - The `Message ` + The `Message ` that this album is replying to, or `None`. The result will be cached after its first use. diff --git a/telethon/events/callbackquery.py b/telethon/events/callbackquery.py index f850ecd5..6d8ee00d 100644 --- a/telethon/events/callbackquery.py +++ b/telethon/events/callbackquery.py @@ -4,7 +4,7 @@ import struct from .common import EventBuilder, EventCommon, name_inner_event from .._misc import utils from .. import _tl -from .._tl import custom +from ..types import _custom @name_inner_event @@ -123,7 +123,7 @@ class CallbackQuery(EventBuilder): return self.func(event) return True - class Event(EventCommon, custom.sendergetter.SenderGetter): + class Event(EventCommon, _custom.sendergetter.SenderGetter): """ Represents the event of a new callback query. @@ -141,7 +141,7 @@ class CallbackQuery(EventBuilder): """ def __init__(self, query, peer, msg_id): super().__init__(peer, msg_id=msg_id) - custom.sendergetter.SenderGetter.__init__(self, query.user_id) + _custom.sendergetter.SenderGetter.__init__(self, query.user_id) self.query = query self.data_match = None self.pattern_match = None @@ -308,7 +308,7 @@ class CallbackQuery(EventBuilder): .. note:: This method won't respect the previous message unlike - `Message.edit `, + `Message.edit `, since the message object is normally not present. """ self._client.loop.create_task(self.answer()) diff --git a/telethon/events/common.py b/telethon/events/common.py index cce243e6..405537d2 100644 --- a/telethon/events/common.py +++ b/telethon/events/common.py @@ -2,8 +2,9 @@ import abc import asyncio import warnings -from .._misc import utils -from .._tl.custom.chatgetter import ChatGetter +from .. import _tl +from .._misc import utils, tlobject +from ..types._custom.chatgetter import ChatGetter async def _into_id_set(client, chats): @@ -25,7 +26,7 @@ async def _into_id_set(client, chats): utils.get_peer_id(_tl.PeerChat(chat)), utils.get_peer_id(_tl.PeerChannel(chat)), }) - elif isinstance(chat, _tl.TLObject) and chat.SUBCLASS_OF_ID == 0x2d45687: + elif isinstance(chat, tlobject.TLObject) and chat.SUBCLASS_OF_ID == 0x2d45687: # 0x2d45687 == crc32(b'Peer') result.add(utils.get_peer_id(chat)) else: diff --git a/telethon/events/inlinequery.py b/telethon/events/inlinequery.py index f2c13d3d..0af44dd0 100644 --- a/telethon/events/inlinequery.py +++ b/telethon/events/inlinequery.py @@ -6,7 +6,7 @@ import asyncio from .common import EventBuilder, EventCommon, name_inner_event from .._misc import utils from .. import _tl -from .._tl import custom +from ..types import _custom @name_inner_event @@ -74,7 +74,7 @@ class InlineQuery(EventBuilder): return super().filter(event) - class Event(EventCommon, custom.sendergetter.SenderGetter): + class Event(EventCommon, _custom.sendergetter.SenderGetter): """ Represents the event of a new callback query. @@ -91,7 +91,7 @@ class InlineQuery(EventBuilder): """ def __init__(self, query): super().__init__(chat_peer=_tl.PeerUser(query.user_id)) - custom.sendergetter.SenderGetter.__init__(self, query.user_id) + _custom.sendergetter.SenderGetter.__init__(self, query.user_id) self.query = query self.pattern_match = None self._answered = False diff --git a/telethon/events/userupdate.py b/telethon/events/userupdate.py index 8144cadb..61db39ea 100644 --- a/telethon/events/userupdate.py +++ b/telethon/events/userupdate.py @@ -4,7 +4,7 @@ import functools from .common import EventBuilder, EventCommon, name_inner_event from .._misc import utils from .. import _tl -from .._tl import custom +from ..types import _custom # TODO Either the properties are poorly named or they should be @@ -65,7 +65,7 @@ class UserUpdate(EventBuilder): return cls.Event(update.user_id, typing=update.action) - class Event(EventCommon, custom.sendergetter.SenderGetter): + class Event(EventCommon, _custom.sendergetter.SenderGetter): """ Represents the event of a user update such as gone online, started typing, etc. @@ -87,7 +87,7 @@ class UserUpdate(EventBuilder): """ def __init__(self, peer, *, status=None, chat_peer=None, typing=None): super().__init__(chat_peer or peer) - custom.sendergetter.SenderGetter.__init__(self, utils.get_peer_id(peer)) + _custom.sendergetter.SenderGetter.__init__(self, utils.get_peer_id(peer)) self.status = status self.action = typing diff --git a/telethon/sessions/memory.py b/telethon/sessions/memory.py index 9f5314a3..82067101 100644 --- a/telethon/sessions/memory.py +++ b/telethon/sessions/memory.py @@ -1,7 +1,7 @@ from enum import Enum from .abstract import Session -from .._misc import utils +from .._misc import utils, tlobject from .. import _tl @@ -89,7 +89,7 @@ class MemorySession(Session): return id, hash, username, phone, name def _entity_to_row(self, e): - if not isinstance(e, _tl.TLObject): + if not isinstance(e, tlobject.TLObject): return try: p = utils.get_input_peer(e, allow_self=False) @@ -118,7 +118,7 @@ class MemorySession(Session): ) def _entities_to_rows(self, tlo): - if not isinstance(tlo, _tl.TLObject) and utils.is_list_like(tlo): + if not isinstance(tlo, tlobject.TLObject) and utils.is_list_like(tlo): # This may be a list of users already for instance entities = tlo else: @@ -189,7 +189,7 @@ class MemorySession(Session): return utils.get_input_peer(key) except (AttributeError, TypeError): # Not a TLObject or can't be cast into InputPeer - if isinstance(key, _tl.TLObject): + if isinstance(key, tlobject.TLObject): key = utils.get_peer_id(key) exact = True else: diff --git a/telethon/types/_core/gzippacked.py b/telethon/types/_core/gzippacked.py index fb4094e4..fd153196 100644 --- a/telethon/types/_core/gzippacked.py +++ b/telethon/types/_core/gzippacked.py @@ -1,10 +1,10 @@ import gzip import struct -from .. import TLObject +from ..._misc import tlobject -class GzipPacked(TLObject): +class GzipPacked(tlobject.TLObject): CONSTRUCTOR_ID = 0x3072cfa1 def __init__(self, data): @@ -26,7 +26,7 @@ class GzipPacked(TLObject): def __bytes__(self): return struct.pack(' Date: Mon, 13 Sep 2021 20:37:29 +0200 Subject: [PATCH 020/131] Make custom.Message functional --- readthedocs/misc/v2-migration-guide.rst | 26 ++ telethon/_client/chats.py | 12 +- telethon/_client/dialogs.py | 8 +- telethon/_client/messageparse.py | 17 +- telethon/_client/messages.py | 13 +- telethon/_client/telegramclient.py | 3 + telethon/events/album.py | 6 +- telethon/events/chataction.py | 1 + telethon/events/newmessage.py | 1 + telethon/types/_custom/message.py | 424 ++++++++++++------------ 10 files changed, 261 insertions(+), 250 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 3815f2dd..483c5287 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -119,6 +119,32 @@ The following modules have been moved inside ``_misc``: // TODO review telethon/__init__.py isn't exposing more than it should +The custom.Message class and the way it is used has changed +----------------------------------------------------------- + +It no longer inherits ``TLObject``, and rather than trying to mimick Telegram's ``Message`` +constructor, it now takes two parameters: a ``TelegramClient`` instance and a ``_tl.Message``. +As a benefit, you can now more easily reconstruct instances of this type from a previously-stored +``_tl.Message`` instance. + +There are no public attributes. Instead, they are now properties which forward the values into and +from the private ``_message`` field. As a benefit, the documentation will now be easier to follow. +However, you can no longer use ``del`` on these. + +The ``_tl.Message.media`` attribute will no longer be ``None`` when using raw API if the media was +``messageMediaEmpty``. As a benefit, you can now actually distinguish between no media and empty +media. The ``Message.media`` property as returned by friendly methods will still be ``None`` on +empty media. + +The ``telethon.tl.patched`` hack has been removed. + +In order to avoid breaking more code than strictly necessary, ``.raw_text`` will remain a synonym +of ``.message``, and ``.text`` will still be the text formatted through the ``client.parse_mode``. +However, you're encouraged to change uses of ``.raw_text`` with ``.message``, and ``.text`` with +either ``.md_text`` or ``.html_text`` as needed. This is because both ``.text`` and ``.raw_text`` +may disappear in future versions, and their behaviour is not immediately obvious. + + The Conversation API has been removed ------------------------------------- diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 0ff93c02..7eb6a2a1 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -291,16 +291,16 @@ class _AdminLogIter(requestiter.RequestIter): for ev in r.events: if isinstance(ev.action, _tl.ChannelAdminLogEventActionEditMessage): - ev.action.prev_message._finish_init( - self.client, entities, self.entity) + ev.action.prev_message = _custom.Message._new( + self.client, ev.action.prev_message, entities, self.entity) - ev.action.new_message._finish_init( - self.client, entities, self.entity) + ev.action.new_message = _custom.Message._new( + self.client, ev.action.new_message, entities, self.entity) elif isinstance(ev.action, _tl.ChannelAdminLogEventActionDeleteMessage): - ev.action.message._finish_init( - self.client, entities, self.entity) + ev.action.message = _custom.Message._new( + self.client, ev.action.message, entities, self.entity) self.buffer.append(_custom.AdminLogEvent(ev, entities)) diff --git a/telethon/_client/dialogs.py b/telethon/_client/dialogs.py index 850e3ac9..b293edca 100644 --- a/telethon/_client/dialogs.py +++ b/telethon/_client/dialogs.py @@ -58,10 +58,10 @@ class _DialogsIter(requestiter.RequestIter): for x in itertools.chain(r.users, r.chats) if not isinstance(x, (_tl.UserEmpty, _tl.ChatEmpty))} - messages = {} - for m in r.messages: - m._finish_init(self.client, entities, None) - messages[_dialog_message_key(m.peer_id, m.id)] = m + messages = { + _dialog_message_key(m.peer_id, m.id): _custom.Message._new(self.client, m, entities, None) + for m in r.messages + } for d in r.dialogs: # We check the offset date here because Telegram may ignore it diff --git a/telethon/_client/messageparse.py b/telethon/_client/messageparse.py index 9f1f3b70..69d438fd 100644 --- a/telethon/_client/messageparse.py +++ b/telethon/_client/messageparse.py @@ -3,6 +3,7 @@ import re import typing from .. import helpers, utils, _tl +from ..types import _custom if typing.TYPE_CHECKING: from .telegramclient import TelegramClient @@ -94,7 +95,7 @@ def _get_response_message(self: 'TelegramClient', request, result, input_chat): elif isinstance(update, ( _tl.UpdateNewChannelMessage, _tl.UpdateNewMessage)): - update.message._finish_init(self, entities, input_chat) + update.message = _custom.Message._new(self, update.message, entities, input_chat) # Pinning a message with `updatePinnedMessage` seems to # always produce a service message we can't map so return @@ -110,7 +111,7 @@ def _get_response_message(self: 'TelegramClient', request, result, input_chat): elif (isinstance(update, _tl.UpdateEditMessage) and helpers._entity_type(request.peer) != helpers._EntityType.CHANNEL): - update.message._finish_init(self, entities, input_chat) + update.message = _custom.Message._new(self, update.message, entities, input_chat) # Live locations use `sendMedia` but Telegram responds with # `updateEditMessage`, which means we won't have `id` field. @@ -123,28 +124,24 @@ def _get_response_message(self: 'TelegramClient', request, result, input_chat): and utils.get_peer_id(request.peer) == utils.get_peer_id(update.message.peer_id)): if request.id == update.message.id: - update.message._finish_init(self, entities, input_chat) - return update.message + return _custom.Message._new(self, update.message, entities, input_chat) elif isinstance(update, _tl.UpdateNewScheduledMessage): - update.message._finish_init(self, entities, input_chat) # Scheduled IDs may collide with normal IDs. However, for a # single request there *shouldn't* be a mix between "some # scheduled and some not". - id_to_message[update.message.id] = update.message + id_to_message[update.message.id] = _custom.Message._new(self, update.message, entities, input_chat) elif isinstance(update, _tl.UpdateMessagePoll): if request.media.poll.id == update.poll_id: - m = _tl.Message( + return _custom.Message._new(self, _tl.Message( id=request.id, peer_id=utils.get_peer(request.peer), media=_tl.MessageMediaPoll( poll=update.poll, results=update.results ) - ) - m._finish_init(self, entities, input_chat) - return m + ), entities, input_chat) if request is None: return id_to_message diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index 5dae6eb4..3b63d35b 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -5,6 +5,7 @@ import warnings from .. import errors, hints, _tl from .._misc import helpers, utils, requestiter +from ..types import _custom _MAX_CHUNK_SIZE = 100 @@ -200,8 +201,7 @@ class _MessagesIter(requestiter.RequestIter): # is an attempt to avoid these duplicates, since the message # IDs are returned in descending order (or asc if reverse). self.last_id = message.id - message._finish_init(self.client, entities, self.entity) - self.buffer.append(message) + self.buffer.append(_custom.Message._new(self.client, message, entities, self.entity)) if len(r.messages) < self.request.limit: return True @@ -315,8 +315,7 @@ class _IDsIter(requestiter.RequestIter): from_id and message.peer_id != from_id): self.buffer.append(None) else: - message._finish_init(self.client, entities, self._entity) - self.buffer.append(message) + self.buffer.append(_custom.Message._new(self.client, message, entities, self._entity)) def iter_messages( @@ -498,7 +497,7 @@ async def send_message( result = await self(request) if isinstance(result, _tl.UpdateShortSentMessage): - message = _tl.Message( + return _custom.Message._new(self, _tl.Message( id=result.id, peer_id=await self._get_peer(entity), message=message, @@ -508,9 +507,7 @@ async def send_message( entities=result.entities, reply_markup=request.reply_markup, ttl_period=result.ttl_period - ) - message._finish_init(self, {}, entity) - return message + ), {}, entity) return self._get_response_message(request, result, entity) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 85b28cd3..5c84a990 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -3783,6 +3783,9 @@ class TelegramClient: async def _handle_auto_reconnect(self: 'TelegramClient'): return await updates._handle_auto_reconnect(**locals()) + def _self_id(self: 'TelegramClient') -> typing.Optional[int]: + return users._self_id(**locals()) + # endregion Private # TODO re-patch everything to remove the intermediate calls diff --git a/telethon/events/album.py b/telethon/events/album.py index fdc3c02c..d8dacb98 100644 --- a/telethon/events/album.py +++ b/telethon/events/album.py @@ -168,8 +168,10 @@ class Album(EventBuilder): self._sender, self._input_sender = utils._get_entity_pair( self.sender_id, self._entities, client._entity_cache) - for msg in self.messages: - msg._finish_init(client, self._entities, None) + self.messages = [ + _custom.Message._new(client, m, self._entities, None) + for m in self.messages + ] if len(self.messages) == 1: # This will require hacks to be a proper album event diff --git a/telethon/events/chataction.py b/telethon/events/chataction.py index d330656a..75f19075 100644 --- a/telethon/events/chataction.py +++ b/telethon/events/chataction.py @@ -1,6 +1,7 @@ from .common import EventBuilder, EventCommon, name_inner_event from .._misc import utils from .. import _tl +from ..types import _custom @name_inner_event diff --git a/telethon/events/newmessage.py b/telethon/events/newmessage.py index cfe7b88a..58e1a425 100644 --- a/telethon/events/newmessage.py +++ b/telethon/events/newmessage.py @@ -3,6 +3,7 @@ import re from .common import EventBuilder, EventCommon, name_inner_event, _into_id_set from .._misc import utils from .. import _tl +from ..types import _custom @name_inner_event diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index 2be48847..88a1a615 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -9,9 +9,22 @@ from ..._misc import utils, tlobject from ... import errors, _tl +def _fwd(field, doc): + def fget(self): + try: + return self._message.__dict__[field] + except KeyError: + return None + + def fset(self, value): + self._message.__dict__[field] = value + + return property(fget, fset, None, doc) + + # TODO Figure out a way to have the code generator error on missing fields # Maybe parsing the init function alone if that's possible. -class Message(ChatGetter, SenderGetter, tlobject.TLObject): +class Message(ChatGetter, SenderGetter): """ This custom class aggregates both :tl:`Message` and :tl:`MessageService` to ease accessing their members. @@ -20,219 +33,192 @@ class Message(ChatGetter, SenderGetter, tlobject.TLObject): ` and `SenderGetter ` which means you have access to all their sender and chat properties and methods. - - Members: - out (`bool`): - Whether the message is outgoing (i.e. you sent it from - another session) or incoming (i.e. someone else sent it). - - Note that messages in your own chat are always incoming, - but this member will be `True` if you send a message - to your own chat. Messages you forward to your chat are - *not* considered outgoing, just like official clients - display them. - - mentioned (`bool`): - Whether you were mentioned in this message or not. - Note that replies to your own messages also count - as mentions. - - media_unread (`bool`): - Whether you have read the media in this message - or not, e.g. listened to the voice note media. - - silent (`bool`): - Whether the message should notify people with sound or not. - Previously used in channels, but since 9 August 2019, it can - also be `used in private chats - `_. - - post (`bool`): - Whether this message is a post in a broadcast - channel or not. - - from_scheduled (`bool`): - Whether this message was originated from a previously-scheduled - message or not. - - legacy (`bool`): - Whether this is a legacy message or not. - - edit_hide (`bool`): - Whether the edited mark of this message is edited - should be hidden (e.g. in GUI clients) or shown. - - pinned (`bool`): - Whether this message is currently pinned or not. - - id (`int`): - The ID of this message. This field is *always* present. - Any other member is optional and may be `None`. - - from_id (:tl:`Peer`): - The peer who sent this message, which is either - :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. - This value will be `None` for anonymous messages. - - peer_id (:tl:`Peer`): - The peer to which this message was sent, which is either - :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. This - will always be present except for empty messages. - - fwd_from (:tl:`MessageFwdHeader`): - The original forward header if this message is a forward. - You should probably use the `forward` property instead. - - via_bot_id (`int`): - The ID of the bot used to send this message - through its inline mode (e.g. "via @like"). - - reply_to (:tl:`MessageReplyHeader`): - The original reply header if this message is replying to another. - - date (`datetime`): - The UTC+0 `datetime` object indicating when this message - was sent. This will always be present except for empty - messages. - - message (`str`): - The string text of the message for `Message - ` instances, - which will be `None` for other types of messages. - - media (:tl:`MessageMedia`): - The media sent with this message if any (such as - photos, videos, documents, gifs, stickers, etc.). - - You may want to access the `photo`, `document` - etc. properties instead. - - If the media was not present or it was :tl:`MessageMediaEmpty`, - this member will instead be `None` for convenience. - - reply_markup (:tl:`ReplyMarkup`): - The reply markup for this message (which was sent - either via a bot or by a bot). You probably want - to access `buttons` instead. - - entities (List[:tl:`MessageEntity`]): - The list of markup entities in this message, - such as bold, italics, code, hyperlinks, etc. - - views (`int`): - The number of views this message from a broadcast - channel has. This is also present in forwards. - - forwards (`int`): - The number of times this message has been forwarded. - - replies (`int`): - The number of times another message has replied to this message. - - edit_date (`datetime`): - The date when this message was last edited. - - post_author (`str`): - The display name of the message sender to - show in messages sent to broadcast channels. - - grouped_id (`int`): - If this message belongs to a group of messages - (photo albums or video albums), all of them will - have the same value here. - - restriction_reason (List[:tl:`RestrictionReason`]) - An optional list of reasons why this message was restricted. - If the list is `None`, this message has not been restricted. - - ttl_period (`int`): - The Time To Live period configured for this message. - The message should be erased from wherever it's stored (memory, a - local database, etc.) when - ``datetime.now() > message.date + timedelta(seconds=message.ttl_period)``. - - action (:tl:`MessageAction`): - The message action object of the message for :tl:`MessageService` - instances, which will be `None` for other types of messages. """ + # region Forwarded properties + + out = _fwd('out', """ + Whether the message is outgoing (i.e. you sent it from + another session) or incoming (i.e. someone else sent it). + + Note that messages in your own chat are always incoming, + but this member will be `True` if you send a message + to your own chat. Messages you forward to your chat are + *not* considered outgoing, just like official clients + display them. + """) + + mentioned = _fwd('mentioned', """ + Whether you were mentioned in this message or not. + Note that replies to your own messages also count + as mentions. + """) + + media_unread = _fwd('media_unread', """ + Whether you have read the media in this message + or not, e.g. listened to the voice note media. + """) + + silent = _fwd('silent', """ + Whether the message should notify people with sound or not. + Previously used in channels, but since 9 August 2019, it can + also be `used in private chats + `_. + """) + + post = _fwd('post', """ + Whether this message is a post in a broadcast + channel or not. + """) + + from_scheduled = _fwd('from_scheduled', """ + Whether this message was originated from a previously-scheduled + message or not. + """) + + legacy = _fwd('legacy', """ + Whether this is a legacy message or not. + """) + + edit_hide = _fwd('edit_hide', """ + Whether the edited mark of this message is edited + should be hidden (e.g. in GUI clients) or shown. + """) + + pinned = _fwd('pinned', """ + Whether this message is currently pinned or not. + """) + + id = _fwd('id', """ + The ID of this message. This field is *always* present. + Any other member is optional and may be `None`. + """) + + from_id = _fwd('from_id', """ + The peer who sent this message, which is either + :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. + This value will be `None` for anonymous messages. + """) + + peer_id = _fwd('peer_id', """ + The peer to which this message was sent, which is either + :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. This + will always be present except for empty messages. + """) + + fwd_from = _fwd('fwd_from', """ + The original forward header if this message is a forward. + You should probably use the `forward` property instead. + """) + + via_bot_id = _fwd('via_bot_id', """ + The ID of the bot used to send this message + through its inline mode (e.g. "via @like"). + """) + + reply_to = _fwd('reply_to', """ + The original reply header if this message is replying to another. + """) + + date = _fwd('date', """ + The UTC+0 `datetime` object indicating when this message + was sent. This will always be present except for empty + messages. + """) + + message = _fwd('message', """ + The string text of the message for `Message + ` instances, + which will be `None` for other types of messages. + """) + + @property + def media(self): + """ + The media sent with this message if any (such as + photos, videos, documents, gifs, stickers, etc.). + + You may want to access the `photo`, `document` + etc. properties instead. + + If the media was not present or it was :tl:`MessageMediaEmpty`, + this member will instead be `None` for convenience. + """ + try: + media = self._message.media + except AttributeError: + return None + + return None if media.CONSTRUCTOR_ID == 0x3ded6320 else media + + @media.setter + def media(self, value): + self._message.media = value + + reply_markup = _fwd('reply_markup', """ + The reply markup for this message (which was sent + either via a bot or by a bot). You probably want + to access `buttons` instead. + """) + + entities = _fwd('entities', """ + The list of markup entities in this message, + such as bold, italics, code, hyperlinks, etc. + """) + + views = _fwd('views', """ + The number of views this message from a broadcast + channel has. This is also present in forwards. + """) + + forwards = _fwd('forwards', """ + The number of times this message has been forwarded. + """) + + replies = _fwd('replies', """ + The number of times another message has replied to this message. + """) + + edit_date = _fwd('edit_date', """ + The date when this message was last edited. + """) + + post_author = _fwd('post_author', """ + The display name of the message sender to + show in messages sent to broadcast channels. + """) + + grouped_id = _fwd('grouped_id', """ + If this message belongs to a group of messages + (photo albums or video albums), all of them will + have the same value here. + + restriction_reason (List[:tl:`RestrictionReason`]) + An optional list of reasons why this message was restricted. + If the list is `None`, this message has not been restricted. + """) + + ttl_period = _fwd('ttl_period', """ + The Time To Live period configured for this message. + The message should be erased from wherever it's stored (memory, a + local database, etc.) when + ``datetime.now() > message.date + timedelta(seconds=message.ttl_period)``. + """) + + action = _fwd('action', """ + The message action object of the message for :tl:`MessageService` + instances, which will be `None` for other types of messages. + """) + + # endregion + # region Initialization - def __init__( - # Common to all - self, id: int, - - # Common to Message and MessageService (mandatory) - peer_id: _tl.TypePeer = None, - date: Optional[datetime] = None, - - # Common to Message and MessageService (flags) - out: Optional[bool] = None, - mentioned: Optional[bool] = None, - media_unread: Optional[bool] = None, - silent: Optional[bool] = None, - post: Optional[bool] = None, - from_id: Optional[_tl.TypePeer] = None, - reply_to: Optional[_tl.TypeMessageReplyHeader] = None, - ttl_period: Optional[int] = None, - - # For Message (mandatory) - message: Optional[str] = None, - - # For Message (flags) - fwd_from: Optional[_tl.TypeMessageFwdHeader] = None, - via_bot_id: Optional[int] = None, - media: Optional[_tl.TypeMessageMedia] = None, - reply_markup: Optional[_tl.TypeReplyMarkup] = None, - entities: Optional[List[_tl.TypeMessageEntity]] = None, - views: Optional[int] = None, - edit_date: Optional[datetime] = None, - post_author: Optional[str] = None, - grouped_id: Optional[int] = None, - from_scheduled: Optional[bool] = None, - legacy: Optional[bool] = None, - edit_hide: Optional[bool] = None, - pinned: Optional[bool] = None, - restriction_reason: Optional[_tl.TypeRestrictionReason] = None, - forwards: Optional[int] = None, - replies: Optional[_tl.TypeMessageReplies] = None, - - # For MessageAction (mandatory) - action: Optional[_tl.TypeMessageAction] = None - ): - # Common properties to messages, then to service (in the order they're defined in the `.tl`) - self.out = bool(out) - self.mentioned = mentioned - self.media_unread = media_unread - self.silent = silent - self.post = post - self.from_scheduled = from_scheduled - self.legacy = legacy - self.edit_hide = edit_hide - self.id = id - self.from_id = from_id - self.peer_id = peer_id - self.fwd_from = fwd_from - self.via_bot_id = via_bot_id - self.reply_to = reply_to - self.date = date - self.message = message - self.media = None if isinstance(media, _tl.MessageMediaEmpty) else media - self.reply_markup = reply_markup - self.entities = entities - self.views = views - self.forwards = forwards - self.replies = replies - self.edit_date = edit_date - self.pinned = pinned - self.post_author = post_author - self.grouped_id = grouped_id - self.restriction_reason = restriction_reason - self.ttl_period = ttl_period - self.action = action + def __init__(self, client, message): + self._client = client + self._message = message # Convenient storage for custom functions - # TODO This is becoming a bit of bloat self._client = None self._text = None self._file = None @@ -246,28 +232,25 @@ class Message(ChatGetter, SenderGetter, tlobject.TLObject): self._linked_chat = None sender_id = None - if from_id is not None: - sender_id = utils.get_peer_id(from_id) - elif peer_id: + if self.from_id is not None: + sender_id = utils.get_peer_id(self.from_id) + elif self.peer_id: # If the message comes from a Channel, let the sender be it # ...or... # incoming messages in private conversations no longer have from_id # (layer 119+), but the sender can only be the chat we're in. - if post or (not out and isinstance(peer_id, _tl.PeerUser)): - sender_id = utils.get_peer_id(peer_id) + if self.post or (not self.out and isinstance(self.peer_id, _tl.PeerUser)): + sender_id = utils.get_peer_id(self.peer_id) # Note that these calls would reset the client - ChatGetter.__init__(self, peer_id, broadcast=post) + ChatGetter.__init__(self, self.peer_id, broadcast=self.post) SenderGetter.__init__(self, sender_id) self._forward = None - def _finish_init(self, client, entities, input_chat): - """ - Finishes the initialization of this message by setting - the client that sent the message and making use of the - known entities. - """ + @classmethod + def _new(cls, client, message, entities, input_chat): + self = cls(client, message) self._client = client # Make messages sent to ourselves outgoing unless they're forwarded. @@ -314,6 +297,7 @@ class Message(ChatGetter, SenderGetter, tlobject.TLObject): self._linked_chat = entities.get(utils.get_peer_id( _tl.PeerChannel(self.replies.channel_id))) + return self # endregion Initialization From eb659b9a581172c3444b096fbe4b334c630b722f Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 13 Sep 2021 20:43:44 +0200 Subject: [PATCH 021/131] Fix _write_all_tlobjects call --- telethon_generator/generators/tlobject.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/telethon_generator/generators/tlobject.py b/telethon_generator/generators/tlobject.py index 78a09884..9cb43140 100644 --- a/telethon_generator/generators/tlobject.py +++ b/telethon_generator/generators/tlobject.py @@ -52,7 +52,7 @@ BASE_TYPES = ('string', 'bytes', 'int', 'long', 'int128', def _write_modules( - out_dir, in_mod, kind, namespace_tlobjects, type_constructors, layer): + out_dir, in_mod, kind, namespace_tlobjects, type_constructors, layer, all_tlobjects): # namespace_tlobjects: {'namespace', [TLObject]} out_dir.mkdir(parents=True, exist_ok=True) for ns, tlobjects in namespace_tlobjects.items(): @@ -164,7 +164,7 @@ def _write_modules( builder.writeln(line) if not ns and kind == 'TLObject': - _write_all_tlobjects(tlobjects, layer, builder) + _write_all_tlobjects(all_tlobjects, layer, builder) def _write_source_code(tlobject, kind, builder, type_constructors): @@ -699,9 +699,9 @@ def generate_tlobjects(tlobjects, layer, input_mod, output_dir): type_constructors[tlobject.result].append(tlobject) _write_modules(output_dir, input_mod, 'TLObject', - namespace_types, type_constructors, layer) + namespace_types, type_constructors, layer, tlobjects) _write_modules(output_dir / 'fn', input_mod + '.fn', 'TLRequest', - namespace_functions, type_constructors, layer) + namespace_functions, type_constructors, layer, tlobjects) def clean_tlobjects(output_dir): From 943ad892f7a2926b4bf2f851992155d343cb8247 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 13 Sep 2021 21:00:31 +0200 Subject: [PATCH 022/131] Address remaining uses of the Request suffix with raw API --- telethon/_client/chats.py | 2 +- telethon/_client/messages.py | 4 ++-- telethon/_client/telegrambaseclient.py | 4 ++-- telethon/_client/telegramclient.py | 8 +++---- telethon/_client/updates.py | 2 +- telethon/_client/uploads.py | 2 +- telethon/_client/users.py | 2 +- telethon/_crypto/cdndecrypter.py | 2 +- telethon/_network/mtprotostate.py | 2 +- telethon/events/callbackquery.py | 2 +- telethon/types/_custom/draft.py | 2 +- telethon/types/_custom/messagebutton.py | 2 +- telethon_examples/payment.py | 30 ++++++++++++------------- 13 files changed, 32 insertions(+), 32 deletions(-) diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 7eb6a2a1..21fb02e5 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -524,7 +524,7 @@ def action( raise ValueError('Cannot use {} as action'.format(action)) if isinstance(action, _tl.SendMessageCancelAction): - # ``SetTypingRequest.resolve`` will get input peer of ``entity``. + # ``SetTyping.resolve`` will get input peer of ``entity``. return self(_tl.fn.messages.SetTyping( entity, _tl.SendMessageCancelAction())) diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index 3b63d35b..b7bbf518 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -244,7 +244,7 @@ class _MessagesIter(requestiter.RequestIter): # We want to skip the one we already have self.request.offset_id += 1 - if isinstance(self.request, _tl.fn.messages.SearchRequest): + if isinstance(self.request, _tl.fn.messages.Search): # Unlike getHistory and searchGlobal that use *offset* date, # this is *max* date. This means that doing a search in reverse # will break it. Since it's not really needed once we're going @@ -254,7 +254,7 @@ class _MessagesIter(requestiter.RequestIter): # getHistory, searchGlobal and getReplies call it offset_date self.request.offset_date = last_message.date - if isinstance(self.request, _tl.fn.messages.SearchGlobalRequest): + if isinstance(self.request, _tl.fn.messages.SearchGlobal): if last_message.input_chat: self.request.offset_peer = last_message.input_chat else: diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 0ac127f0..d8d333f0 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -197,7 +197,7 @@ def init( _tl.InputClientProxy(*connection.address_info(proxy)) # Used on connection. Capture the variables in a lambda since - # exporting clients need to create this InvokeWithLayerRequest. + # exporting clients need to create this InvokeWithLayer. system = platform.uname() if system.machine in ('x86_64', 'AMD64'): @@ -559,7 +559,7 @@ async def _get_cdn_client(self: 'TelegramClient', cdn_redirect): # This will make use of the new RSA keys for this specific CDN. # - # We won't be calling GetConfigRequest because it's only called + # We won't be calling GetConfig 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) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 5c84a990..1a12b122 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -173,7 +173,7 @@ class TelegramClient: Returns a :ref:`telethon-client` which calls methods behind a takeout session. It does so by creating a proxy object over the current client through - which making requests will use :tl:`InvokeWithTakeoutRequest` to wrap + which making requests will use :tl:`InvokeWithTakeout` to wrap them. In other words, returns the current client modified so that requests are done as a takeout: @@ -764,7 +764,7 @@ class TelegramClient: This has no effect if a ``filter`` is given. Yields - The :tl:`User` objects returned by :tl:`GetParticipantsRequest` + The :tl:`User` objects returned by :tl:`GetParticipants` with an additional ``.participant`` attribute which is the matched :tl:`ChannelParticipant` type for channels/megagroups or :tl:`ChatParticipants` for normal chats. @@ -2067,7 +2067,7 @@ class TelegramClient: .. note:: - Telegram's flood wait limit for :tl:`GetHistoryRequest` seems to + Telegram's flood wait limit for :tl:`GetHistory` seems to be around 30 seconds per 10 requests, therefore a sleep of 1 second is the default for this limit (or above). @@ -2125,7 +2125,7 @@ class TelegramClient: wait_time (`int`): Wait time (in seconds) between different - :tl:`GetHistoryRequest`. Use this parameter to avoid hitting + :tl:`GetHistory`. 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. diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index 9d1e205c..93e8a9bc 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -257,7 +257,7 @@ async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, p # we should be okay (no flood waits) even if more occur. pass except ValueError: - # There is a chance that GetFullChannelRequest and GetDifferenceRequest + # There is a chance that GetFullChannel and GetDifference # inside the _get_difference() function will end up with # ValueError("Request was unsuccessful N time(s)") for whatever reasons. pass diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py index fb5bfc0c..244c5b59 100644 --- a/telethon/_client/uploads.py +++ b/telethon/_client/uploads.py @@ -341,7 +341,7 @@ async def upload_file( # what Telegram wants. hash_md5.update(part) - # The SavePartRequest is different depending on whether + # The SavePart is different depending on whether # the file is too large or not (over or less than 10MB) if is_big: request = _tl.fn.upload.SaveBigFilePart( diff --git a/telethon/_client/users.py b/telethon/_client/users.py index 9bf18faf..f3393196 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -208,7 +208,7 @@ async def get_entity( chats = lists[helpers._EntityType.CHAT] channels = lists[helpers._EntityType.CHANNEL] if users: - # GetUsersRequest has a limit of 200 per call + # GetUsers has a limit of 200 per call tmp = [] while users: curr, users = users[:200], users[200:] diff --git a/telethon/_crypto/cdndecrypter.py b/telethon/_crypto/cdndecrypter.py index 8347a561..73a568d1 100644 --- a/telethon/_crypto/cdndecrypter.py +++ b/telethon/_crypto/cdndecrypter.py @@ -74,7 +74,7 @@ class CdnDecrypter: def get_file(self): """ - Calls GetCdnFileRequest and decrypts its bytes. + Calls GetCdnFile and decrypts its bytes. Also ensures that the file hasn't been tampered. :return: the CdnFile result. diff --git a/telethon/_network/mtprotostate.py b/telethon/_network/mtprotostate.py index ec777d75..f96554ac 100644 --- a/telethon/_network/mtprotostate.py +++ b/telethon/_network/mtprotostate.py @@ -102,7 +102,7 @@ class MTProtoState: # The `RequestState` stores `bytes(request)`, not the request itself. # `invokeAfterMsg` wants a `TLRequest` though, hence the wrapping. body = GzipPacked.gzip_if_smaller(content_related, - bytes(_tl.fn.InvokeAfterMsgRequest(after_id, _OpaqueRequest(data)))) + bytes(_tl.fn.InvokeAfterMsg(after_id, _OpaqueRequest(data)))) buffer.write(struct.pack(' types.InputMediaInvoice: - price = types.LabeledPrice(label=price_label, amount=price_amount) # label - just a text, amount=10000 means 100.00 - invoice = types.Invoice( + description: str, payload: str, start_param: str) -> _tl.InputMediaInvoice: + price = _tl.LabeledPrice(label=price_label, amount=price_amount) # label - just a text, amount=10000 means 100.00 + invoice = _tl.Invoice( currency=currency, # currency like USD prices=[price], # there could be a couple of prices. test=True, # if you're working with test token, else set test=False. @@ -114,14 +114,14 @@ def generate_invoice(price_label: str, price_amount: int, currency: str, title: phone_to_provider=False, email_to_provider=False ) - return types.InputMediaInvoice( + return _tl.InputMediaInvoice( title=title, description=description, invoice=invoice, payload=payload.encode('UTF-8'), # payload, which will be sent to next 2 handlers provider=provider_token, - provider_data=types.DataJSON('{}'), + provider_data=_tl.DataJSON('{}'), # data about the invoice, which will be shared with the payment provider. A detailed description of # required fields should be provided by the payment provider. From e9f9994f4ae25b0fbc900b395c8296215cbd7334 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 17 Sep 2021 19:35:10 +0200 Subject: [PATCH 023/131] Unify client.iter_* methods --- readthedocs/misc/v2-migration-guide.rst | 34 +++++ telethon/_client/chats.py | 24 +--- telethon/_client/dialogs.py | 16 +-- telethon/_client/messages.py | 21 +-- telethon/_client/telegramclient.py | 168 +++++------------------- telethon/_misc/requestiter.py | 3 + 6 files changed, 78 insertions(+), 188 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 483c5287..e48fb41a 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -50,6 +50,40 @@ removed. This implies: // TODO provide standalone alternative for this? +The "iter" variant of the client methods have been removed +---------------------------------------------------------- + +Instead, you can now use the result of the ``get_*`` variant. For instance, where before you had: + +.. code-block:: python + + async for message in client.iter_messages(...): + pass + +You would now do: + + .. code-block:: python + + async for message in client.get_messages(...): + pass # ^^^ now it's get, not iter + +You can still use ``await`` on the ``get_`` methods to retrieve the list. + +The removed methods are: + +* iter_messages +* iter_dialogs +* iter_participants +* iter_admin_log +* iter_profile_photos +* iter_drafts + +The only exception to this rule is ``iter_download``. + +// TODO keep providing the old ``iter_`` versions? it doesn't really hurt, even if the recommended way changed +// TODO does the download really need to be special? get download is kind of weird though + + Raw API methods have been renamed and are now considered private ---------------------------------------------------------------- diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 21fb02e5..ec786b36 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -401,7 +401,7 @@ class _ProfilePhotoIter(requestiter.RequestIter): self.request.offset_id = result.messages[-1].id -def iter_participants( +def get_participants( self: 'TelegramClient', entity: 'hints.EntityLike', limit: float = None, @@ -418,14 +418,8 @@ def iter_participants( aggressive=aggressive ) -async def get_participants( - self: 'TelegramClient', - *args, - **kwargs) -> 'hints.TotalList': - return await self.iter_participants(*args, **kwargs).collect() - -def iter_admin_log( +def get_admin_log( self: 'TelegramClient', entity: 'hints.EntityLike', limit: float = None, @@ -474,14 +468,8 @@ def iter_admin_log( group_call=group_call ) -async def get_admin_log( - self: 'TelegramClient', - *args, - **kwargs) -> 'hints.TotalList': - return await self.iter_admin_log(*args, **kwargs).collect() - -def iter_profile_photos( +def get_profile_photos( self: 'TelegramClient', entity: 'hints.EntityLike', limit: int = None, @@ -496,12 +484,6 @@ def iter_profile_photos( max_id=max_id ) -async def get_profile_photos( - self: 'TelegramClient', - *args, - **kwargs) -> 'hints.TotalList': - return await self.iter_profile_photos(*args, **kwargs).collect() - def action( self: 'TelegramClient', diff --git a/telethon/_client/dialogs.py b/telethon/_client/dialogs.py index b293edca..d4b4644b 100644 --- a/telethon/_client/dialogs.py +++ b/telethon/_client/dialogs.py @@ -136,7 +136,7 @@ class _DraftsIter(requestiter.RequestIter): return [] -def iter_dialogs( +def get_dialogs( self: 'TelegramClient', limit: float = None, *, @@ -162,11 +162,8 @@ def iter_dialogs( folder=folder ) -async def get_dialogs(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': - return await self.iter_dialogs(*args, **kwargs).collect() - -def iter_drafts( +def get_drafts( self: 'TelegramClient', entity: 'hints.EntitiesLike' = None ) -> _DraftsIter: @@ -176,15 +173,6 @@ def iter_drafts( # TODO Passing a limit here makes no sense return _DraftsIter(self, None, entities=entity) -async def get_drafts( - self: 'TelegramClient', - entity: 'hints.EntitiesLike' = None -) -> 'hints.TotalList': - items = await self.iter_drafts(entity).collect() - if not entity or utils.is_list_like(entity): - return items - else: - return items[0] async def edit_folder( self: 'TelegramClient', diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index b7bbf518..c29610e4 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -318,7 +318,7 @@ class _IDsIter(requestiter.RequestIter): self.buffer.append(_custom.Message._new(self.client, message, entities, self._entity)) -def iter_messages( +def get_messages( self: 'TelegramClient', entity: 'hints.EntityLike', limit: float = None, @@ -368,25 +368,6 @@ def iter_messages( scheduled=scheduled ) -async def get_messages(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': - 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 - - it = self.iter_messages(*args, **kwargs) - - ids = kwargs.get('ids') - if ids and not utils.is_list_like(ids): - async for message in it: - return message - else: - # Iterator exhausted = empty, to handle InputMessageReplyTo - return None - - return await it.collect() - async def _get_comment_data( self: 'TelegramClient', diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 1a12b122..1482dbdd 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -716,7 +716,7 @@ class TelegramClient: # region Chats - def iter_participants( + def get_participants( self: 'TelegramClient', entity: 'hints.EntityLike', limit: float = None, @@ -784,32 +784,14 @@ class TelegramClient: from telethon.tl.types import ChannelParticipantsAdmins async for user in client.iter_participants(chat, filter=ChannelParticipantsAdmins): print(user.first_name) + + # Get a list of 0 people but print the total amount of participants in the chat + users = await client.get_participants(chat, limit=0) + print(users.total) """ - return chats.iter_participants(**locals()) + return chats.get_participants(**locals()) - async def get_participants( - self: 'TelegramClient', - *args, - **kwargs) -> 'hints.TotalList': - """ - Same as `iter_participants()`, but returns a - `TotalList ` instead. - - Example - .. code-block:: python - - users = await client.get_participants(chat) - print(users[0].first_name) - - for user in users: - if user.username is not None: - print(user.username) - """ - return await chats.get_participants(*args, **kwargs) - - get_participants.__signature__ = inspect.signature(iter_participants) - - def iter_admin_log( + def get_admin_log( self: 'TelegramClient', entity: 'hints.EntityLike', limit: float = None, @@ -931,30 +913,16 @@ class TelegramClient: async for event in client.iter_admin_log(channel): if event.changed_title: print('The title changed from', event.old, 'to', event.new) - """ - return chats.iter_admin_log(**locals()) - async def get_admin_log( - self: 'TelegramClient', - *args, - **kwargs) -> 'hints.TotalList': - """ - Same as `iter_admin_log()`, but returns a ``list`` instead. - - Example - .. code-block:: python - - # Get a list of deleted message events which said "heck" - events = await client.get_admin_log(channel, search='heck', delete=True) + # Get all events of deleted message events which said "heck" and print the last one + events = await client.get_admin_log(channel, limit=None, search='heck', delete=True) # Print the old message before it was deleted - print(events[0].old) + print(events[-1].old) """ - return await chats.get_admin_log(*args, **kwargs) + return chats.get_admin_log(**locals()) - get_admin_log.__signature__ = inspect.signature(iter_admin_log) - - def iter_profile_photos( + def get_profile_photos( self: 'TelegramClient', entity: 'hints.EntityLike', limit: int = None, @@ -991,29 +959,12 @@ class TelegramClient: # Download all the profile photos of some user async for photo in client.iter_profile_photos(user): await client.download_media(photo) - """ - return chats.iter_profile_photos(**locals()) - async def get_profile_photos( - self: 'TelegramClient', - *args, - **kwargs) -> 'hints.TotalList': - """ - Same as `iter_profile_photos()`, but returns a - `TotalList ` instead. - - Example - .. code-block:: python - - # Get the photos of a channel - photos = await client.get_profile_photos(channel) - - # Download the oldest photo + # Get all the photos of a channel and download the oldest one + photos = await client.get_profile_photos(channel, limit=None) await client.download_media(photos[-1]) """ - return await chats.get_profile_photos(*args, **kwargs) - - get_profile_photos.__signature__ = inspect.signature(iter_profile_photos) + return chats.get_profile_photos(**locals()) def action( self: 'TelegramClient', @@ -1443,7 +1394,7 @@ class TelegramClient: # region Dialogs - def iter_dialogs( + def get_dialogs( self: 'TelegramClient', limit: float = None, *, @@ -1517,19 +1468,9 @@ class TelegramClient: # Print all dialog IDs and the title, nicely formatted async for dialog in client.iter_dialogs(): print('{:>14}: {}'.format(dialog.id, dialog.title)) - """ - return dialogs.iter_dialogs(**locals()) - - async def get_dialogs(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': - """ - Same as `iter_dialogs()`, but returns a - `TotalList ` instead. - - Example - .. code-block:: python # Get all open conversation, print the title of the first - dialogs = await client.get_dialogs() + dialogs = await client.get_dialogs(limit=None) first = dialogs[0] print(first.title) @@ -1537,18 +1478,16 @@ class TelegramClient: await client.send_message(first, 'hi') # Getting only non-archived dialogs (both equivalent) - non_archived = await client.get_dialogs(folder=0) - non_archived = await client.get_dialogs(archived=False) + non_archived = await client.get_dialogs(folder=0, limit=None) + non_archived = await client.get_dialogs(archived=False, limit=None) # Getting only archived dialogs (both equivalent) - archived = await client.get_dialogs(folder=1) - archived = await client.get_dialogs(archived=True) + archived = await client.get_dialogs(folder=1, limit=None) + archived = await client.get_dialogs(archived=True, limit=None) """ - return await dialogs.get_dialogs(*args, **kwargs) + return dialogs.get_dialogs(**locals()) - get_dialogs.__signature__ = inspect.signature(iter_dialogs) - - def iter_drafts( + def get_drafts( self: 'TelegramClient', entity: 'hints.EntitiesLike' = None ) -> dialogs._DraftsIter: @@ -1575,28 +1514,12 @@ class TelegramClient: # Getting the drafts with 'bot1' and 'bot2' async for draft in client.iter_drafts(['bot1', 'bot2']): print(draft.text) - """ - return dialogs.iter_drafts(**locals()) - - async def get_drafts( - self: 'TelegramClient', - entity: 'hints.EntitiesLike' = None - ) -> 'hints.TotalList': - """ - Same as `iter_drafts()`, but returns a list instead. - - Example - .. code-block:: python - - # Get drafts, print the text of the first - drafts = await client.get_drafts() - print(drafts[0].text) # Get the draft in your chat draft = await client.get_drafts('me') - print(drafts.text) + print(draft.text) """ - return await dialogs.get_drafts(**locals()) + return dialogs.get_drafts(**locals()) async def edit_folder( self: 'TelegramClient', @@ -2037,7 +1960,7 @@ class TelegramClient: # region Messages - def iter_messages( + def get_messages( self: 'TelegramClient', entity: 'hints.EntityLike', limit: float = None, @@ -2199,8 +2122,8 @@ class TelegramClient: async for message in client.iter_messages(chat, reverse=True): print(message.id, message.text) - # Filter by sender - async for message in client.iter_messages(chat, from_user='me'): + # Filter by sender, and limit to 10 + async for message in client.iter_messages(chat, 10, from_user='me'): print(message.text) # Server-side search with fuzzy text @@ -2215,43 +2138,22 @@ class TelegramClient: # Getting comments from a post in a channel: async for message in client.iter_messages(channel, reply_to=123): print(message.chat.title, message.text) - """ - return messages.iter_messages(**locals()) - - async def get_messages(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': - """ - Same as `iter_messages()`, but returns a - `TotalList ` instead. - - 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 `Message ` will be - returned for convenience instead of a list. - - Example - .. code-block:: python # Get 0 photos and print the total to show how many photos there are from telethon.tl.types import InputMessagesFilterPhotos photos = await client.get_messages(chat, 0, filter=InputMessagesFilterPhotos) print(photos.total) - # Get all the photos - photos = await client.get_messages(chat, None, filter=InputMessagesFilterPhotos) + # Get all the photos in a list + all_photos = await client.get_messages(chat, None, filter=InputMessagesFilterPhotos) - # Get messages by ID: + # Get the last photo or None if none has been sent yet (same as setting limit 1) + photo = await client.get_messages(chat, filter=InputMessagesFilterPhotos) + + # Get a single message given an ID: message_1337 = await client.get_messages(chat, ids=1337) """ - return await messages.get_messages(**locals()) - - get_messages.__signature__ = inspect.signature(iter_messages) + return messages.get_messages(**locals()) async def send_message( self: 'TelegramClient', diff --git a/telethon/_misc/requestiter.py b/telethon/_misc/requestiter.py index 6473fe0f..9c837c05 100644 --- a/telethon/_misc/requestiter.py +++ b/telethon/_misc/requestiter.py @@ -114,3 +114,6 @@ class RequestIter(abc.ABC): def __reversed__(self): self.reverse = not self.reverse return self # __aiter__ will be called after, too + + def __await__(self): + return self.collect().__await__() From be3ed894c6a157404204683bcda630eab5584c25 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 17 Sep 2021 20:04:57 +0200 Subject: [PATCH 024/131] Make changes to the default limit in client.get_list methods --- readthedocs/misc/v2-migration-guide.rst | 25 +++++++++++++++++++++++++ telethon/_client/dialogs.py | 10 ++++++---- telethon/_client/telegramclient.py | 10 +++++----- telethon/_misc/requestiter.py | 16 +++++++++++++--- 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index e48fb41a..cf5fce04 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -80,6 +80,31 @@ The removed methods are: The only exception to this rule is ``iter_download``. +Additionally, when using ``await``, if the method was called with a limit of 1 (either through +setting just one value to fetch, or setting the limit to one), either ``None`` or a single item +(outside of a ``list``) will be returned. This used to be the case only for ``get_messages``, +but now all methods behave in the same way for consistency. + +When using ``async for``, the default limit will be ``None``, meaning all items will be fetched. +When using ``await``, the default limit will be ``1``, meaning the latest item will be fetched. +If you want to use ``await`` but still get a list, use the ``.collect()`` method to collect the +results into a list: + +.. code-block:: python + + chat = ... + + # will iterate over all (default limit=None) + async for message in client.get_messages(chat): + ... + + # will return either a single Message or None if there is not any (limit=1) + message = await client.get_messages(chat) + + # will collect all messages into a list (default limit=None). will also take long! + all_messages = await client.get_messages(chat).collect() + + // TODO keep providing the old ``iter_`` versions? it doesn't really hurt, even if the recommended way changed // TODO does the download really need to be special? get download is kind of weird though diff --git a/telethon/_client/dialogs.py b/telethon/_client/dialogs.py index d4b4644b..1277a3a4 100644 --- a/telethon/_client/dialogs.py +++ b/telethon/_client/dialogs.py @@ -167,11 +167,13 @@ def get_drafts( self: 'TelegramClient', entity: 'hints.EntitiesLike' = None ) -> _DraftsIter: - if entity and not utils.is_list_like(entity): - entity = (entity,) + limit = None + if entity: + if not utils.is_list_like(entity): + entity = (entity,) + limit = len(entity) - # TODO Passing a limit here makes no sense - return _DraftsIter(self, None, entities=entity) + return _DraftsIter(self, limit, entities=entity) async def edit_folder( diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 1482dbdd..1501fee3 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -719,7 +719,7 @@ class TelegramClient: def get_participants( self: 'TelegramClient', entity: 'hints.EntityLike', - limit: float = None, + limit: float = (), *, search: str = '', filter: '_tl.TypeChannelParticipantsFilter' = None, @@ -794,7 +794,7 @@ class TelegramClient: def get_admin_log( self: 'TelegramClient', entity: 'hints.EntityLike', - limit: float = None, + limit: float = (), *, max_id: int = 0, min_id: int = 0, @@ -925,7 +925,7 @@ class TelegramClient: def get_profile_photos( self: 'TelegramClient', entity: 'hints.EntityLike', - limit: int = None, + limit: int = (), *, offset: int = 0, max_id: int = 0) -> chats._ProfilePhotoIter: @@ -1396,7 +1396,7 @@ class TelegramClient: def get_dialogs( self: 'TelegramClient', - limit: float = None, + limit: float = (), *, offset_date: 'hints.DateLike' = None, offset_id: int = 0, @@ -1963,7 +1963,7 @@ class TelegramClient: def get_messages( self: 'TelegramClient', entity: 'hints.EntityLike', - limit: float = None, + limit: float = (), *, offset_date: 'hints.DateLike' = None, offset_id: int = 0, diff --git a/telethon/_misc/requestiter.py b/telethon/_misc/requestiter.py index 9c837c05..96ceb97a 100644 --- a/telethon/_misc/requestiter.py +++ b/telethon/_misc/requestiter.py @@ -28,12 +28,13 @@ class RequestIter(abc.ABC): self.reverse = reverse self.wait_time = wait_time self.kwargs = kwargs - self.limit = max(float('inf') if limit is None else limit, 0) + self.limit = max(float('inf') if limit is None or limit == () else limit, 0) self.left = self.limit self.buffer = None self.index = 0 self.total = None self.last_load = 0 + self.return_single = limit == 1 or limit == () async def _init(self, **kwargs): """ @@ -86,11 +87,20 @@ class RequestIter(abc.ABC): self.left = self.limit return self - async def collect(self): + async def collect(self, force_list=True): """ Create a `self` iterator and collect it into a `TotalList` (a normal list with a `.total` attribute). + + If ``force_list`` is ``False`` and ``self.return_single`` is ``True``, no list + will be returned. Instead, either a single item or ``None`` will be returned. """ + if not force_list and self.return_single: + self.limit = 1 + async for message in self: + return message + return None + result = helpers.TotalList() async for message in self: result.append(message) @@ -116,4 +126,4 @@ class RequestIter(abc.ABC): return self # __aiter__ will be called after, too def __await__(self): - return self.collect().__await__() + return self.collect(force_list=False).__await__() From 1036c3cb525d23d60c26c53df7b125478abefb52 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 17 Sep 2021 20:13:05 +0200 Subject: [PATCH 025/131] Remove the aggressive hack from get_participants --- readthedocs/misc/v2-migration-guide.rst | 7 ++ telethon/_client/chats.py | 99 ++++++++++--------------- telethon/_client/telegramclient.py | 16 +--- 3 files changed, 46 insertions(+), 76 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index cf5fce04..628b7a95 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -218,6 +218,13 @@ your handlers much more easily. // TODO provide standalone alternative for this? +The aggressive parameter hack has been removed +---------------------------------------------- + +The "aggressive" hack in ``get_participants`` (and ``iter_participants``) is now gone. +It was not reliable, and was a cause of flood wait errors. + + The TelegramClient is no longer made out of mixins -------------------------------------------------- diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index ec786b36..3fcce1b4 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -94,7 +94,7 @@ class _ChatAction: class _ParticipantsIter(requestiter.RequestIter): - async def _init(self, entity, filter, search, aggressive): + async def _init(self, entity, filter, search): if isinstance(filter, type): if filter in (_tl.ChannelParticipantsBanned, _tl.ChannelParticipantsKicked, @@ -118,9 +118,6 @@ class _ParticipantsIter(requestiter.RequestIter): else: self.filter_entity = lambda ent: True - # Only used for channels, but we should always set the attribute - self.requests = [] - if ty == helpers._EntityType.CHANNEL: if self.limit <= 0: # May not have access to the channel, but getFull can get the .total. @@ -130,22 +127,13 @@ class _ParticipantsIter(requestiter.RequestIter): raise StopAsyncIteration self.seen = set() - if aggressive and not filter: - self.requests.extend(_tl.fn.channels.GetParticipants( - channel=entity, - filter=_tl.ChannelParticipantsSearch(x), - offset=0, - limit=_MAX_PARTICIPANTS_CHUNK_SIZE, - hash=0 - ) for x in (search or string.ascii_lowercase)) - else: - self.requests.append(_tl.fn.channels.GetParticipants( - channel=entity, - filter=filter or _tl.ChannelParticipantsSearch(search), - offset=0, - limit=_MAX_PARTICIPANTS_CHUNK_SIZE, - hash=0 - )) + self.request = _tl.fn.channels.GetParticipants( + channel=entity, + filter=filter or _tl.ChannelParticipantsSearch(search), + offset=0, + limit=_MAX_PARTICIPANTS_CHUNK_SIZE, + hash=0 + ) elif ty == helpers._EntityType.CHAT: full = await self.client( @@ -184,24 +172,21 @@ class _ParticipantsIter(requestiter.RequestIter): return True async def _load_next_chunk(self): - if not self.requests: - return True - # Only care about the limit for the first request - # (small amount of people, won't be aggressive). + # (small amount of people). # # 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. - self.requests[0].limit = min( - self.limit - self.requests[0].offset, _MAX_PARTICIPANTS_CHUNK_SIZE) + self.request.limit = min( + self.limit - self.request.offset, _MAX_PARTICIPANTS_CHUNK_SIZE) - if self.requests[0].offset > self.limit: + if self.request.offset > self.limit: return True if self.total is None: - f = self.requests[0].filter - if len(self.requests) > 1 or ( + f = self.request.filter + if ( not isinstance(f, _tl.ChannelParticipantsRecent) and (not isinstance(f, _tl.ChannelParticipantsSearch) or f.q) ): @@ -209,42 +194,36 @@ class _ParticipantsIter(requestiter.RequestIter): # if there's a filter which would reduce the real total number. # getParticipants is cheaper than getFull. self.total = (await self.client(_tl.fn.channels.GetParticipants( - channel=self.requests[0].channel, + channel=self.request.channel, filter=_tl.ChannelParticipantsRecent(), offset=0, limit=1, hash=0 ))).count - results = await self.client(self.requests) - for i in reversed(range(len(self.requests))): - participants = results[i] - if self.total is None: - # Will only get here if there was one request with a filter that matched all users. - self.total = participants.count - if not participants.users: - self.requests.pop(i) - continue + participants = await self.client(self.request) + if self.total is None: + # Will only get here if there was one request with a filter that matched all users. + self.total = participants.count - self.requests[i].offset += len(participants.participants) - users = {user.id: user for user in participants.users} - for participant in participants.participants: - - if isinstance(participant, _tl.ChannelParticipantBanned): - if not isinstance(participant.peer, _tl.PeerUser): - # May have the entire channel banned. See #3105. - continue - user_id = participant.peer.user_id - else: - user_id = participant.user_id - - user = users[user_id] - if not self.filter_entity(user) or user.id in self.seen: + self.request.offset += len(participants.participants) + users = {user.id: user for user in participants.users} + for participant in participants.participants: + if isinstance(participant, _tl.ChannelParticipantBanned): + if not isinstance(participant.peer, _tl.PeerUser): + # May have the entire channel banned. See #3105. continue - self.seen.add(user_id) - user = users[user_id] - user.participant = participant - self.buffer.append(user) + user_id = participant.peer.user_id + else: + user_id = participant.user_id + + user = users[user_id] + if not self.filter_entity(user) or user.id in self.seen: + continue + self.seen.add(user_id) + user = users[user_id] + user.participant = participant + self.buffer.append(user) class _AdminLogIter(requestiter.RequestIter): @@ -407,15 +386,13 @@ def get_participants( limit: float = None, *, search: str = '', - filter: '_tl.TypeChannelParticipantsFilter' = None, - aggressive: bool = False) -> _ParticipantsIter: + filter: '_tl.TypeChannelParticipantsFilter' = None) -> _ParticipantsIter: return _ParticipantsIter( self, limit, entity=entity, filter=filter, - search=search, - aggressive=aggressive + search=search ) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 1501fee3..1a9e7bef 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -722,8 +722,7 @@ class TelegramClient: limit: float = (), *, search: str = '', - filter: '_tl.TypeChannelParticipantsFilter' = None, - aggressive: bool = False) -> chats._ParticipantsIter: + filter: '_tl.TypeChannelParticipantsFilter' = None) -> chats._ParticipantsIter: """ Iterator over the participants belonging to the specified chat. @@ -739,9 +738,6 @@ class TelegramClient: search (`str`, optional): Look for participants with this string in name/username. - If ``aggressive is True``, the symbols from this string will - be used. - 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. @@ -753,16 +749,6 @@ class TelegramClient: *restricted* users. If you want *banned* users you should use :tl:`ChannelParticipantsKicked` instead. - aggressive (`bool`, optional): - Aggressively looks for all participants in the chat. - - This is useful for channels since 20 July 2018, - Telegram added a server-side limit where only the - first 200 members can be retrieved. With this flag - set, more than 200 will be often be retrieved. - - This has no effect if a ``filter`` is given. - Yields The :tl:`User` objects returned by :tl:`GetParticipants` with an additional ``.participant`` attribute which is the From 6e9ad9e31c5a7e5cf2ebccf18d2f15aae442b0ee Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 17 Sep 2021 20:16:01 +0200 Subject: [PATCH 026/131] Return correct total participant count when a filter is desired --- readthedocs/misc/v2-migration-guide.rst | 10 ++++++++++ telethon/_client/chats.py | 21 +-------------------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 628b7a95..5e9d6a5d 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -225,6 +225,16 @@ The "aggressive" hack in ``get_participants`` (and ``iter_participants``) is now It was not reliable, and was a cause of flood wait errors. +The total value when getting participants has changed +----------------------------------------------------- + +Before, it used to always be the total amount of people inside the chat. Now the filter is also +considered. If you were running ``client.get_participants`` with a ``filter`` other than the +default and accessing the ``list.total``, you will now get a different result. You will need to +perform a separate request with no filter to fetch the total without filter (this is what the +library used to do). + + The TelegramClient is no longer made out of mixins -------------------------------------------------- diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 3fcce1b4..1aa0724d 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -184,27 +184,8 @@ class _ParticipantsIter(requestiter.RequestIter): if self.request.offset > self.limit: return True - if self.total is None: - f = self.request.filter - if ( - not isinstance(f, _tl.ChannelParticipantsRecent) - and (not isinstance(f, _tl.ChannelParticipantsSearch) or f.q) - ): - # Only do an additional getParticipants here to get the total - # if there's a filter which would reduce the real total number. - # getParticipants is cheaper than getFull. - self.total = (await self.client(_tl.fn.channels.GetParticipants( - channel=self.request.channel, - filter=_tl.ChannelParticipantsRecent(), - offset=0, - limit=1, - hash=0 - ))).count - participants = await self.client(self.request) - if self.total is None: - # Will only get here if there was one request with a filter that matched all users. - self.total = participants.count + self.total = participants.count self.request.offset += len(participants.participants) users = {user.id: user for user in participants.users} From 40ff7c6bdf29f178da1df341bd4dc2f3685e2906 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 17 Sep 2021 20:19:23 +0200 Subject: [PATCH 027/131] Document default behaviour of limit --- telethon/_client/telegramclient.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 1a9e7bef..30c2811a 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -735,6 +735,10 @@ class TelegramClient: limit (`int`): Limits amount of participants fetched. + By default, there is no limit set when using the result as + an iterator. When using ``await``, the default limit is 1, + so the method returns a single user. + search (`str`, optional): Look for participants with this string in name/username. @@ -822,6 +826,10 @@ class TelegramClient: The limit may also be `None`, which would eventually return the whole history. + By default, there is no limit set when using the result as + an iterator. When using ``await``, the default limit is 1, + so the method returns the last event. + max_id (`int`): All the events with a higher (newer) ID or equal to this will be excluded. @@ -930,6 +938,10 @@ class TelegramClient: The limit may also be `None`, which would eventually all the photos that are still available. + By default, there is no limit set when using the result as + an iterator. When using ``await``, the default limit is 1, + so the method returns the last profile photo. + offset (`int`): How many photos should be skipped before returning the first one. @@ -1407,6 +1419,10 @@ class TelegramClient: will tell the library to slow down through a ``FloodWaitError``. + By default, there is no limit set when using the result as + an iterator. When using ``await``, the default limit is 1, + so the method returns the most-recent dialog. + offset_date (`datetime`, optional): The offset date to be used. @@ -2001,6 +2017,10 @@ class TelegramClient: The limit may also be `None`, which would eventually return the whole history. + By default, there is no limit set when using the result as + an iterator. When using ``await``, the default limit is 1, + so the method returns the last message. + offset_date (`datetime`): Offset date (messages *previous* to this date will be retrieved). Exclusive. From d81ebe92f7ae146d0d4ac1e183cc0243634a880f Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 17 Sep 2021 20:25:53 +0200 Subject: [PATCH 028/131] Remove Wall of Shame People make mistakes. Get over it. No need to be a child about it. --- readthedocs/index.rst | 1 - readthedocs/misc/wall-of-shame.rst | 65 ------------------------------ 2 files changed, 66 deletions(-) delete mode 100644 readthedocs/misc/wall-of-shame.rst diff --git a/readthedocs/index.rst b/readthedocs/index.rst index 1794ce72..91d08e0f 100644 --- a/readthedocs/index.rst +++ b/readthedocs/index.rst @@ -108,7 +108,6 @@ You can also use the menu on the left to quickly skip over sections. misc/changelog misc/v2-migration-guide.rst - misc/wall-of-shame.rst misc/compatibility-and-convenience .. toctree:: diff --git a/readthedocs/misc/wall-of-shame.rst b/readthedocs/misc/wall-of-shame.rst deleted file mode 100644 index 87be0464..00000000 --- a/readthedocs/misc/wall-of-shame.rst +++ /dev/null @@ -1,65 +0,0 @@ -============= -Wall of Shame -============= - - -This project has an -`issues `__ section for -you to file **issues** whenever you encounter any when working with the -library. Said section is **not** for issues on *your* program but rather -issues with Telethon itself. - -If you have not made the effort to 1. read through the docs and 2. -`look for the method you need `__, -you will end up on the `Wall of -Shame `__, -i.e. all issues labeled -`"RTFM" `__: - - **rtfm** - Literally "Read The F--king Manual"; a term showing the - frustration of being bothered with questions so trivial that the asker - could have quickly figured out the answer on their own with minimal - effort, usually by reading readily-available documents. People who - say"RTFM!" might be considered rude, but the true rude ones are the - annoying people who take absolutely no self-responibility and expect to - have all the answers handed to them personally. - - *"Damn, that's the twelveth time that somebody posted this question - to the messageboard today! RTFM, already!"* - - *by Bill M. July 27, 2004* - -If you have indeed read the docs, and have tried looking for the method, -and yet you didn't find what you need, **that's fine**. Telegram's API -can have some obscure names at times, and for this reason, there is a -`"question" -label `__ -with questions that are okay to ask. Just state what you've tried so -that we know you've made an effort, or you'll go to the Wall of Shame. - -Of course, if the issue you're going to open is not even a question but -a real issue with the library (thankfully, most of the issues have been -that!), you won't end up here. Don't worry. - -Current winner --------------- - -The current winner is `issue -213 `__: - -**Issue:** - -.. figure:: https://user-images.githubusercontent.com/6297805/29822978-9a9a6ef0-8ccd-11e7-9ec5-934ea0f57681.jpg - -:alt: Winner issue - -Winner issue - -**Answer:** - -.. figure:: https://user-images.githubusercontent.com/6297805/29822983-9d523402-8ccd-11e7-9fb1-5783740ee366.jpg - -:alt: Winner issue answer - -Winner issue answer From b3c23e343a6f04a01327bd738f2e21f903d75e6d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 17 Sep 2021 20:36:40 +0200 Subject: [PATCH 029/131] Return deleted count from delete_messages --- readthedocs/misc/v2-migration-guide.rst | 10 +++++++++- telethon/_client/messages.py | 10 ++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 5e9d6a5d..e1621fb4 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -13,7 +13,7 @@ good chance you were not relying on this to begin with". **Please read this document in full before upgrading your code to Telethon 2.0.** -Pyhton 3.5 is no longer supported +Python 3.5 is no longer supported --------------------------------- The library will no longer attempt to support Python 3.5. The minimum version is now Python 3.6. @@ -218,6 +218,14 @@ your handlers much more easily. // TODO provide standalone alternative for this? +Deleting messages now returns a more useful value +------------------------------------------------- + +It used to return a list of :tl:`messages.affectedMessages` which I expect very little people were +actually using. Now it returns an ``int`` value indicating the number of messages that did exist +and were deleted. + + The aggressive parameter hack has been removed ---------------------------------------------- diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index c29610e4..5c073e60 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -644,11 +644,13 @@ async def delete_messages( ty = helpers._EntityType.USER if ty == helpers._EntityType.CHANNEL: - return await self([_tl.fn.channels.DeleteMessages( - entity, list(c)) for c in utils.chunks(message_ids)]) + res = await self([_tl.fn.channels.DeleteMessages( + entity, list(c)) for c in utils.chunks(message_ids)]) else: - return await self([_tl.fn.messages.DeleteMessages( - list(c), revoke) for c in utils.chunks(message_ids)]) + res = await self([_tl.fn.messages.DeleteMessages( + list(c), revoke) for c in utils.chunks(message_ids)]) + + return sum(r.pts_count for r in res) async def send_read_acknowledge( self: 'TelegramClient', From 3bc46e80728272e18a43149647d48ce272a43ea7 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 17 Sep 2021 20:55:27 +0200 Subject: [PATCH 030/131] Remove broken CdnDecrypter --- readthedocs/misc/v2-migration-guide.rst | 6 ++ telethon/_client/telegrambaseclient.py | 41 ++-------- telethon/_client/telegramclient.py | 1 - telethon/_crypto/__init__.py | 1 - telethon/_crypto/cdndecrypter.py | 104 ------------------------ 5 files changed, 11 insertions(+), 142 deletions(-) delete mode 100644 telethon/_crypto/cdndecrypter.py diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index e1621fb4..7e7a2302 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -249,3 +249,9 @@ The TelegramClient is no longer made out of mixins If you were relying on any of the individual mixins that made up the client, such as ``UserMethods`` inside the ``telethon.client`` subpackage, those are now gone. There is a single ``TelegramClient`` class now, containing everything you need. + + +CdnDecrypter has been removed +----------------------------- + +It was not really working and was more intended to be an implementation detail than anything else. diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index d8d333f0..35507edd 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -428,31 +428,26 @@ def _auth_key_callback(self: 'TelegramClient', auth_key): self.session.save() -async def _get_dc(self: 'TelegramClient', dc_id, cdn=False): +async def _get_dc(self: 'TelegramClient', dc_id): """Gets the Data Center (DC) associated to 'dc_id'""" cls = self.__class__ if not cls._config: cls._config = await self(_tl.fn.help.GetConfig()) - if cdn and not self._cdn_config: - cls._cdn_config = await self(_tl.fn.help.GetCdnConfig()) - for pk in cls._cdn_config.public_keys: - rsa.add_key(pk.public_key) - try: 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 + and bool(dc.ipv6) == self._use_ipv6 and not dc.cdn ) except StopIteration: self._log[__name__].warning( - 'Failed to get DC %s (cdn = %s) with use_ipv6 = %s; retrying ignoring IPv6 check', - dc_id, cdn, self._use_ipv6 + 'Failed to get DC %swith use_ipv6 = %s; retrying ignoring IPv6 check', + dc_id, self._use_ipv6 ) return next( dc for dc in cls._config.dc_options - if dc.id == dc_id and bool(dc.cdn) == cdn + if dc.id == dc_id and not dc.cdn ) async def _create_exported_sender(self: 'TelegramClient', dc_id): @@ -538,29 +533,3 @@ async def _clean_exported_senders(self: 'TelegramClient'): # Disconnect should never raise await sender.disconnect() state.mark_disconnected() - -async def _get_cdn_client(self: 'TelegramClient', cdn_redirect): - """Similar to ._borrow_exported_client, but for CDNs""" - # TODO Implement - raise NotImplementedError - session = self._exported_sessions.get(cdn_redirect.dc_id) - if not session: - dc = await _get_dc(self, cdn_redirect.dc_id, cdn=True) - session = self.session.clone() - await session.set_dc(dc.id, dc.ip_address, dc.port) - self._exported_sessions[cdn_redirect.dc_id] = session - - self._log[__name__].info('Creating new CDN client') - client = TelegramBaseClient( - 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 GetConfig 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) - return client diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 30c2811a..cb644dd2 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -2761,7 +2761,6 @@ class TelegramClient: # Cached server configuration (with .dc_options), can be "global" _config = None - _cdn_config = None def __init__( self: 'TelegramClient', diff --git a/telethon/_crypto/__init__.py b/telethon/_crypto/__init__.py index 69be1da8..f10c9ad7 100644 --- a/telethon/_crypto/__init__.py +++ b/telethon/_crypto/__init__.py @@ -7,4 +7,3 @@ from .aes import AES from .aesctr import AESModeCTR from .authkey import AuthKey from .factorization import Factorization -from .cdndecrypter import CdnDecrypter diff --git a/telethon/_crypto/cdndecrypter.py b/telethon/_crypto/cdndecrypter.py deleted file mode 100644 index 73a568d1..00000000 --- a/telethon/_crypto/cdndecrypter.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -This module holds the CdnDecrypter utility class. -""" -from hashlib import sha256 - -from .. import _tl -from .._crypto import AESModeCTR -from ..errors import CdnFileTamperedError - - -class CdnDecrypter: - """ - Used when downloading a file results in a 'FileCdnRedirect' to - both prepare the redirect, decrypt the file as it downloads, and - ensure the file hasn't been tampered. https://core.telegram.org/cdn - """ - def __init__(self, cdn_client, file_token, cdn_aes, cdn_file_hashes): - """ - Initializes the CDN decrypter. - - :param cdn_client: a client connected to a CDN. - :param file_token: the token of the file to be used. - :param cdn_aes: the AES CTR used to decrypt the file. - :param cdn_file_hashes: the hashes the decrypted file must match. - """ - self.client = cdn_client - self.file_token = file_token - self.cdn_aes = cdn_aes - self.cdn_file_hashes = cdn_file_hashes - - @staticmethod - async def prepare_decrypter(client, cdn_client, cdn_redirect): - """ - Prepares a new CDN decrypter. - - :param client: a TelegramClient connected to the main servers. - :param cdn_client: a new client connected to the CDN. - :param cdn_redirect: the redirect file object that caused this call. - :return: (CdnDecrypter, first chunk file data) - """ - cdn_aes = AESModeCTR( - key=cdn_redirect.encryption_key, - # 12 first bytes of the IV..4 bytes of the offset (0, big endian) - iv=cdn_redirect.encryption_iv[:12] + bytes(4) - ) - - # We assume that cdn_redirect.cdn_file_hashes are ordered by offset, - # and that there will be enough of these to retrieve the whole file. - decrypter = CdnDecrypter( - cdn_client, cdn_redirect.file_token, - cdn_aes, cdn_redirect.cdn_file_hashes - ) - - cdn_file = await cdn_client(_tl.fn.upload.GetCdnFile( - file_token=cdn_redirect.file_token, - offset=cdn_redirect.cdn_file_hashes[0].offset, - limit=cdn_redirect.cdn_file_hashes[0].limit - )) - if isinstance(cdn_file, _tl.upload.CdnFileReuploadNeeded): - # We need to use the original client here - await client(_tl.fn.upload.ReuploadCdnFile( - file_token=cdn_redirect.file_token, - request_token=cdn_file.request_token - )) - - # We want to always return a valid upload.CdnFile - cdn_file = decrypter.get_file() - else: - cdn_file.bytes = decrypter.cdn_aes.encrypt(cdn_file.bytes) - cdn_hash = decrypter.cdn_file_hashes.pop(0) - decrypter.check(cdn_file.bytes, cdn_hash) - - return decrypter, cdn_file - - def get_file(self): - """ - Calls GetCdnFile and decrypts its bytes. - Also ensures that the file hasn't been tampered. - - :return: the CdnFile result. - """ - if self.cdn_file_hashes: - cdn_hash = self.cdn_file_hashes.pop(0) - cdn_file = self.client(_tl.fn.upload.GetCdnFile( - self.file_token, cdn_hash.offset, cdn_hash.limit - )) - cdn_file.bytes = self.cdn_aes.encrypt(cdn_file.bytes) - self.check(cdn_file.bytes, cdn_hash) - else: - cdn_file = _tl.upload.CdnFile(bytes(0)) - - return cdn_file - - @staticmethod - def check(data, cdn_hash): - """ - Checks the integrity of the given data. - Raises CdnFileTamperedError if the integrity check fails. - - :param data: the data to be hashed. - :param cdn_hash: the expected hash. - """ - if sha256(data).digest() != cdn_hash.hash: - raise CdnFileTamperedError() From dc29a95cef8deca2f18cb1cc95f8ba879a6512b9 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 17 Sep 2021 21:03:47 +0200 Subject: [PATCH 031/131] Change list of buttons to show up as rows and not cols --- readthedocs/misc/v2-migration-guide.rst | 27 +++++++++++++++++++++++++ telethon/_client/buttons.py | 6 +++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 7e7a2302..4276dec8 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -204,6 +204,33 @@ either ``.md_text`` or ``.html_text`` as needed. This is because both ``.text`` may disappear in future versions, and their behaviour is not immediately obvious. +Using a flat list to define buttons will now create rows and not columns +------------------------------------------------------------------------ + +When sending a message with buttons under a bot account, passing a flat list such as the following: + +.. code-block:: python + + bot.send_message(chat, message, buttons=[ + Button.inline('top'), + Button.inline('middle'), + Button.inline('bottom'), + ]) + +Will now send a message with 3 rows of buttons, instead of a message with 3 columns (old behaviour). +If you still want the old behaviour, wrap the list inside another list: + +.. code-block:: python + + bot.send_message(chat, message, buttons=[[ + # + + Button.inline('top'), + Button.inline('middle'), + Button.inline('bottom'), + ]]) + #+ + + The Conversation API has been removed ------------------------------------- diff --git a/telethon/_client/buttons.py b/telethon/_client/buttons.py index fbd0f51c..599ebc96 100644 --- a/telethon/_client/buttons.py +++ b/telethon/_client/buttons.py @@ -8,7 +8,7 @@ from ..types import _custom def build_reply_markup( buttons: 'typing.Optional[hints.MarkupLike]', inline_only: bool = False) -> 'typing.Optional[_tl.TypeReplyMarkup]': - if buttons is None: + if not buttons: return None try: @@ -18,9 +18,9 @@ def build_reply_markup( pass if not utils.is_list_like(buttons): - buttons = [[buttons]] - elif not buttons or not utils.is_list_like(buttons[0]): buttons = [buttons] + if not utils.is_list_like(buttons[0]): + buttons = [[b] for b in buttons] is_inline = False is_normal = False From 783c1771abe6a8e7ab552c38ef376ccd3f7116fa Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 17 Sep 2021 21:05:09 +0200 Subject: [PATCH 032/131] Fix remaining uses of old types namespace --- telethon/types/_custom/adminlogevent.py | 144 +++++++++--------- telethon/types/_custom/button.py | 38 ++--- telethon/types/_custom/inlineresult.py | 10 +- .../types/_custom/participantpermissions.py | 18 +-- telethon/types/_custom/qrlogin.py | 6 +- 5 files changed, 108 insertions(+), 108 deletions(-) diff --git a/telethon/types/_custom/adminlogevent.py b/telethon/types/_custom/adminlogevent.py index 6d8af269..8c63a954 100644 --- a/telethon/types/_custom/adminlogevent.py +++ b/telethon/types/_custom/adminlogevent.py @@ -64,43 +64,43 @@ class AdminLogEvent: """ ori = self.original.action if isinstance(ori, ( - types.ChannelAdminLogEventActionChangeAbout, - types.ChannelAdminLogEventActionChangeTitle, - types.ChannelAdminLogEventActionChangeUsername, - types.ChannelAdminLogEventActionChangeLocation, - types.ChannelAdminLogEventActionChangeHistoryTTL, + _tl.ChannelAdminLogEventActionChangeAbout, + _tl.ChannelAdminLogEventActionChangeTitle, + _tl.ChannelAdminLogEventActionChangeUsername, + _tl.ChannelAdminLogEventActionChangeLocation, + _tl.ChannelAdminLogEventActionChangeHistoryTTL, )): return ori.prev_value - elif isinstance(ori, types.ChannelAdminLogEventActionChangePhoto): + elif isinstance(ori, _tl.ChannelAdminLogEventActionChangePhoto): return ori.prev_photo - elif isinstance(ori, types.ChannelAdminLogEventActionChangeStickerSet): + elif isinstance(ori, _tl.ChannelAdminLogEventActionChangeStickerSet): return ori.prev_stickerset - elif isinstance(ori, types.ChannelAdminLogEventActionEditMessage): + elif isinstance(ori, _tl.ChannelAdminLogEventActionEditMessage): return ori.prev_message elif isinstance(ori, ( - types.ChannelAdminLogEventActionParticipantToggleAdmin, - types.ChannelAdminLogEventActionParticipantToggleBan + _tl.ChannelAdminLogEventActionParticipantToggleAdmin, + _tl.ChannelAdminLogEventActionParticipantToggleBan )): return ori.prev_participant elif isinstance(ori, ( - types.ChannelAdminLogEventActionToggleInvites, - types.ChannelAdminLogEventActionTogglePreHistoryHidden, - types.ChannelAdminLogEventActionToggleSignatures + _tl.ChannelAdminLogEventActionToggleInvites, + _tl.ChannelAdminLogEventActionTogglePreHistoryHidden, + _tl.ChannelAdminLogEventActionToggleSignatures )): return not ori.new_value - elif isinstance(ori, types.ChannelAdminLogEventActionDeleteMessage): + elif isinstance(ori, _tl.ChannelAdminLogEventActionDeleteMessage): return ori.message - elif isinstance(ori, types.ChannelAdminLogEventActionDefaultBannedRights): + elif isinstance(ori, _tl.ChannelAdminLogEventActionDefaultBannedRights): return ori.prev_banned_rights - elif isinstance(ori, types.ChannelAdminLogEventActionDiscardGroupCall): + elif isinstance(ori, _tl.ChannelAdminLogEventActionDiscardGroupCall): return ori.call elif isinstance(ori, ( - types.ChannelAdminLogEventActionExportedInviteDelete, - types.ChannelAdminLogEventActionExportedInviteRevoke, - types.ChannelAdminLogEventActionParticipantJoinByInvite, + _tl.ChannelAdminLogEventActionExportedInviteDelete, + _tl.ChannelAdminLogEventActionExportedInviteRevoke, + _tl.ChannelAdminLogEventActionParticipantJoinByInvite, )): return ori.invite - elif isinstance(ori, types.ChannelAdminLogEventActionExportedInviteEdit): + elif isinstance(ori, _tl.ChannelAdminLogEventActionExportedInviteEdit): return ori.prev_invite @property @@ -110,46 +110,46 @@ class AdminLogEvent: """ ori = self.original.action if isinstance(ori, ( - types.ChannelAdminLogEventActionChangeAbout, - types.ChannelAdminLogEventActionChangeTitle, - types.ChannelAdminLogEventActionChangeUsername, - types.ChannelAdminLogEventActionToggleInvites, - types.ChannelAdminLogEventActionTogglePreHistoryHidden, - types.ChannelAdminLogEventActionToggleSignatures, - types.ChannelAdminLogEventActionChangeLocation, - types.ChannelAdminLogEventActionChangeHistoryTTL, + _tl.ChannelAdminLogEventActionChangeAbout, + _tl.ChannelAdminLogEventActionChangeTitle, + _tl.ChannelAdminLogEventActionChangeUsername, + _tl.ChannelAdminLogEventActionToggleInvites, + _tl.ChannelAdminLogEventActionTogglePreHistoryHidden, + _tl.ChannelAdminLogEventActionToggleSignatures, + _tl.ChannelAdminLogEventActionChangeLocation, + _tl.ChannelAdminLogEventActionChangeHistoryTTL, )): return ori.new_value - elif isinstance(ori, types.ChannelAdminLogEventActionChangePhoto): + elif isinstance(ori, _tl.ChannelAdminLogEventActionChangePhoto): return ori.new_photo - elif isinstance(ori, types.ChannelAdminLogEventActionChangeStickerSet): + elif isinstance(ori, _tl.ChannelAdminLogEventActionChangeStickerSet): return ori.new_stickerset - elif isinstance(ori, types.ChannelAdminLogEventActionEditMessage): + elif isinstance(ori, _tl.ChannelAdminLogEventActionEditMessage): return ori.new_message elif isinstance(ori, ( - types.ChannelAdminLogEventActionParticipantToggleAdmin, - types.ChannelAdminLogEventActionParticipantToggleBan + _tl.ChannelAdminLogEventActionParticipantToggleAdmin, + _tl.ChannelAdminLogEventActionParticipantToggleBan )): return ori.new_participant elif isinstance(ori, ( - types.ChannelAdminLogEventActionParticipantInvite, - types.ChannelAdminLogEventActionParticipantVolume, + _tl.ChannelAdminLogEventActionParticipantInvite, + _tl.ChannelAdminLogEventActionParticipantVolume, )): return ori.participant - elif isinstance(ori, types.ChannelAdminLogEventActionDefaultBannedRights): + elif isinstance(ori, _tl.ChannelAdminLogEventActionDefaultBannedRights): return ori.new_banned_rights - elif isinstance(ori, types.ChannelAdminLogEventActionStopPoll): + elif isinstance(ori, _tl.ChannelAdminLogEventActionStopPoll): return ori.message - elif isinstance(ori, types.ChannelAdminLogEventActionStartGroupCall): + elif isinstance(ori, _tl.ChannelAdminLogEventActionStartGroupCall): return ori.call elif isinstance(ori, ( - types.ChannelAdminLogEventActionParticipantMute, - types.ChannelAdminLogEventActionParticipantUnmute, + _tl.ChannelAdminLogEventActionParticipantMute, + _tl.ChannelAdminLogEventActionParticipantUnmute, )): return ori.participant - elif isinstance(ori, types.ChannelAdminLogEventActionToggleGroupCallSetting): + elif isinstance(ori, _tl.ChannelAdminLogEventActionToggleGroupCallSetting): return ori.join_muted - elif isinstance(ori, types.ChannelAdminLogEventActionExportedInviteEdit): + elif isinstance(ori, _tl.ChannelAdminLogEventActionExportedInviteEdit): return ori.new_invite @property @@ -160,7 +160,7 @@ class AdminLogEvent: If `True`, `old` and `new` will be present as `str`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionChangeAbout) + _tl.ChannelAdminLogEventActionChangeAbout) @property def changed_title(self): @@ -170,7 +170,7 @@ class AdminLogEvent: If `True`, `old` and `new` will be present as `str`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionChangeTitle) + _tl.ChannelAdminLogEventActionChangeTitle) @property def changed_username(self): @@ -180,7 +180,7 @@ class AdminLogEvent: If `True`, `old` and `new` will be present as `str`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionChangeUsername) + _tl.ChannelAdminLogEventActionChangeUsername) @property def changed_photo(self): @@ -190,7 +190,7 @@ class AdminLogEvent: If `True`, `old` and `new` will be present as :tl:`Photo`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionChangePhoto) + _tl.ChannelAdminLogEventActionChangePhoto) @property def changed_sticker_set(self): @@ -200,7 +200,7 @@ class AdminLogEvent: If `True`, `old` and `new` will be present as :tl:`InputStickerSet`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionChangeStickerSet) + _tl.ChannelAdminLogEventActionChangeStickerSet) @property def changed_message(self): @@ -211,7 +211,7 @@ class AdminLogEvent: `Message `. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionEditMessage) + _tl.ChannelAdminLogEventActionEditMessage) @property def deleted_message(self): @@ -222,7 +222,7 @@ class AdminLogEvent: `Message `. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionDeleteMessage) + _tl.ChannelAdminLogEventActionDeleteMessage) @property def changed_admin(self): @@ -235,7 +235,7 @@ class AdminLogEvent: """ return isinstance( self.original.action, - types.ChannelAdminLogEventActionParticipantToggleAdmin) + _tl.ChannelAdminLogEventActionParticipantToggleAdmin) @property def changed_restrictions(self): @@ -247,7 +247,7 @@ class AdminLogEvent: """ return isinstance( self.original.action, - types.ChannelAdminLogEventActionParticipantToggleBan) + _tl.ChannelAdminLogEventActionParticipantToggleBan) @property def changed_invites(self): @@ -257,7 +257,7 @@ class AdminLogEvent: If `True`, `old` and `new` will be present as `bool`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionToggleInvites) + _tl.ChannelAdminLogEventActionToggleInvites) @property def changed_location(self): @@ -267,7 +267,7 @@ class AdminLogEvent: If `True`, `old` and `new` will be present as :tl:`ChannelLocation`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionChangeLocation) + _tl.ChannelAdminLogEventActionChangeLocation) @property def joined(self): @@ -276,7 +276,7 @@ class AdminLogEvent: public username or not. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantJoin) + _tl.ChannelAdminLogEventActionParticipantJoin) @property def joined_invite(self): @@ -288,7 +288,7 @@ class AdminLogEvent: :tl:`ChannelParticipant`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantInvite) + _tl.ChannelAdminLogEventActionParticipantInvite) @property def left(self): @@ -296,7 +296,7 @@ class AdminLogEvent: Whether `user` left the channel or not. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantLeave) + _tl.ChannelAdminLogEventActionParticipantLeave) @property def changed_hide_history(self): @@ -307,7 +307,7 @@ class AdminLogEvent: If `True`, `old` and `new` will be present as `bool`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionTogglePreHistoryHidden) + _tl.ChannelAdminLogEventActionTogglePreHistoryHidden) @property def changed_signatures(self): @@ -318,7 +318,7 @@ class AdminLogEvent: If `True`, `old` and `new` will be present as `bool`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionToggleSignatures) + _tl.ChannelAdminLogEventActionToggleSignatures) @property def changed_pin(self): @@ -329,7 +329,7 @@ class AdminLogEvent: `Message `. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionUpdatePinned) + _tl.ChannelAdminLogEventActionUpdatePinned) @property def changed_default_banned_rights(self): @@ -340,7 +340,7 @@ class AdminLogEvent: be present as :tl:`ChatBannedRights`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionDefaultBannedRights) + _tl.ChannelAdminLogEventActionDefaultBannedRights) @property def stopped_poll(self): @@ -351,7 +351,7 @@ class AdminLogEvent: `Message `. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionStopPoll) + _tl.ChannelAdminLogEventActionStopPoll) @property def started_group_call(self): @@ -361,7 +361,7 @@ class AdminLogEvent: If `True`, `new` will be present as :tl:`InputGroupCall`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionStartGroupCall) + _tl.ChannelAdminLogEventActionStartGroupCall) @property def discarded_group_call(self): @@ -371,7 +371,7 @@ class AdminLogEvent: If `True`, `old` will be present as :tl:`InputGroupCall`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionDiscardGroupCall) + _tl.ChannelAdminLogEventActionDiscardGroupCall) @property def user_muted(self): @@ -381,7 +381,7 @@ class AdminLogEvent: If `True`, `new` will be present as :tl:`GroupCallParticipant`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantMute) + _tl.ChannelAdminLogEventActionParticipantMute) @property def user_unmutted(self): @@ -391,7 +391,7 @@ class AdminLogEvent: If `True`, `new` will be present as :tl:`GroupCallParticipant`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantUnmute) + _tl.ChannelAdminLogEventActionParticipantUnmute) @property def changed_call_settings(self): @@ -401,7 +401,7 @@ class AdminLogEvent: If `True`, `new` will be `True` if new users are muted on join. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionToggleGroupCallSetting) + _tl.ChannelAdminLogEventActionToggleGroupCallSetting) @property def changed_history_ttl(self): @@ -414,7 +414,7 @@ class AdminLogEvent: If `True`, `old` will be the old TTL, and `new` the new TTL, in seconds. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionChangeHistoryTTL) + _tl.ChannelAdminLogEventActionChangeHistoryTTL) @property def deleted_exported_invite(self): @@ -424,7 +424,7 @@ class AdminLogEvent: If `True`, `old` will be the deleted :tl:`ExportedChatInvite`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionExportedInviteDelete) + _tl.ChannelAdminLogEventActionExportedInviteDelete) @property def edited_exported_invite(self): @@ -435,7 +435,7 @@ class AdminLogEvent: :tl:`ExportedChatInvite`, respectively. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionExportedInviteEdit) + _tl.ChannelAdminLogEventActionExportedInviteEdit) @property def revoked_exported_invite(self): @@ -445,7 +445,7 @@ class AdminLogEvent: If `True`, `old` will be the revoked :tl:`ExportedChatInvite`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionExportedInviteRevoke) + _tl.ChannelAdminLogEventActionExportedInviteRevoke) @property def joined_by_invite(self): @@ -456,7 +456,7 @@ class AdminLogEvent: used to join. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantJoinByInvite) + _tl.ChannelAdminLogEventActionParticipantJoinByInvite) @property def changed_user_volume(self): @@ -466,7 +466,7 @@ class AdminLogEvent: If `True`, `new` will be the updated :tl:`GroupCallParticipant`. """ return isinstance(self.original.action, - types.ChannelAdminLogEventActionParticipantVolume) + _tl.ChannelAdminLogEventActionParticipantVolume) def __str__(self): return str(self.original) diff --git a/telethon/types/_custom/button.py b/telethon/types/_custom/button.py index 4785271a..4dbcab99 100644 --- a/telethon/types/_custom/button.py +++ b/telethon/types/_custom/button.py @@ -49,12 +49,12 @@ class Button: Returns `True` if the button belongs to an inline keyboard. """ return isinstance(button, ( - types.KeyboardButtonBuy, - types.KeyboardButtonCallback, - types.KeyboardButtonGame, - types.KeyboardButtonSwitchInline, - types.KeyboardButtonUrl, - types.InputKeyboardButtonUrlAuth + _tl.KeyboardButtonBuy, + _tl.KeyboardButtonCallback, + _tl.KeyboardButtonGame, + _tl.KeyboardButtonSwitchInline, + _tl.KeyboardButtonUrl, + _tl.InputKeyboardButtonUrlAuth )) @staticmethod @@ -83,7 +83,7 @@ class Button: if len(data) > 64: raise ValueError('Too many bytes for the data') - return types.KeyboardButtonCallback(text, data) + return _tl.KeyboardButtonCallback(text, data) @staticmethod def switch_inline(text, query='', same_peer=False): @@ -101,7 +101,7 @@ class Button: input field will be filled with the username of your bot followed by the query text, ready to make inline queries. """ - return types.KeyboardButtonSwitchInline(text, query, same_peer) + return _tl.KeyboardButtonSwitchInline(text, query, same_peer) @staticmethod def url(text, url=None): @@ -117,7 +117,7 @@ class Button: the domain is trusted, and once confirmed the URL will open in their device. """ - return types.KeyboardButtonUrl(text, url or text) + return _tl.KeyboardButtonUrl(text, url or text) @staticmethod def auth(text, url=None, *, bot=None, write_access=False, fwd_text=None): @@ -157,10 +157,10 @@ class Button: When the user clicks this button, a confirmation box will be shown to the user asking whether they want to login to the specified domain. """ - return types.InputKeyboardButtonUrlAuth( + return _tl.InputKeyboardButtonUrlAuth( text=text, url=url or text, - bot=utils.get_input_user(bot or types.InputUserSelf()), + bot=utils.get_input_user(bot or _tl.InputUserSelf()), request_write_access=write_access, fwd_text=fwd_text ) @@ -191,7 +191,7 @@ class Button: between a button press and the user typing and sending exactly the same text on their own. """ - return cls(types.KeyboardButton(text), + return cls(_tl.KeyboardButton(text), resize=resize, single_use=single_use, selective=selective) @classmethod @@ -206,7 +206,7 @@ class Button: to the user asking whether they want to share their location with the bot, and if confirmed a message with geo media will be sent. """ - return cls(types.KeyboardButtonRequestGeoLocation(text), + return cls(_tl.KeyboardButtonRequestGeoLocation(text), resize=resize, single_use=single_use, selective=selective) @classmethod @@ -221,7 +221,7 @@ class Button: to the user asking whether they want to share their phone with the bot, and if confirmed a message with contact media will be sent. """ - return cls(types.KeyboardButtonRequestPhone(text), + return cls(_tl.KeyboardButtonRequestPhone(text), resize=resize, single_use=single_use, selective=selective) @classmethod @@ -243,7 +243,7 @@ class Button: When the user clicks this button, a screen letting the user create a poll will be shown, and if they do create one, the poll will be sent. """ - return cls(types.KeyboardButtonRequestPoll(text, quiz=force_quiz), + return cls(_tl.KeyboardButtonRequestPoll(text, quiz=force_quiz), resize=resize, single_use=single_use, selective=selective) @staticmethod @@ -255,7 +255,7 @@ class Button: ``selective`` is as documented in `text`. """ - return types.ReplyKeyboardHide(selective=selective) + return _tl.ReplyKeyboardHide(selective=selective) @staticmethod def force_reply(single_use=None, selective=None, placeholder=None): @@ -273,7 +273,7 @@ class Button: crop the text (for example, to 64 characters and adding an ellipsis (…) character as the 65th). """ - return types.ReplyKeyboardForceReply( + return _tl.ReplyKeyboardForceReply( single_use=single_use, selective=selective, placeholder=placeholder) @@ -291,7 +291,7 @@ class Button: `Payments API `__ documentation for more information. """ - return types.KeyboardButtonBuy(text) + return _tl.KeyboardButtonBuy(text) @staticmethod def game(text): @@ -305,4 +305,4 @@ class Button: `Games `__ documentation for more information on using games. """ - return types.KeyboardButtonGame(text) + return _tl.KeyboardButtonGame(text) diff --git a/telethon/types/_custom/inlineresult.py b/telethon/types/_custom/inlineresult.py index fa617af1..052be4dd 100644 --- a/telethon/types/_custom/inlineresult.py +++ b/telethon/types/_custom/inlineresult.py @@ -77,7 +77,7 @@ class InlineResult: this URL to open it in your browser, you should use Python's `webbrowser.open(url)` for such task. """ - if isinstance(self.result, types.BotInlineResult): + if isinstance(self.result, _tl.BotInlineResult): return self.result.url @property @@ -86,9 +86,9 @@ class InlineResult: Returns either the :tl:`WebDocument` thumbnail for normal results or the :tl:`Photo` for media results. """ - if isinstance(self.result, types.BotInlineResult): + if isinstance(self.result, _tl.BotInlineResult): return self.result.thumb - elif isinstance(self.result, types.BotInlineMediaResult): + elif isinstance(self.result, _tl.BotInlineMediaResult): return self.result.photo @property @@ -97,9 +97,9 @@ class InlineResult: Returns either the :tl:`WebDocument` content for normal results or the :tl:`Document` for media results. """ - if isinstance(self.result, types.BotInlineResult): + if isinstance(self.result, _tl.BotInlineResult): return self.result.content - elif isinstance(self.result, types.BotInlineMediaResult): + elif isinstance(self.result, _tl.BotInlineMediaResult): return self.result.document async def click(self, entity=None, reply_to=None, comment_to=None, diff --git a/telethon/types/_custom/participantpermissions.py b/telethon/types/_custom/participantpermissions.py index 6d4db912..7410aa88 100644 --- a/telethon/types/_custom/participantpermissions.py +++ b/telethon/types/_custom/participantpermissions.py @@ -46,8 +46,8 @@ class ParticipantPermissions: also counts as begin an administrator, since they have all permissions. """ return self.is_creator or isinstance(self.participant, ( - types.ChannelParticipantAdmin, - types.ChatParticipantAdmin + _tl.ChannelParticipantAdmin, + _tl.ChatParticipantAdmin )) @property @@ -56,8 +56,8 @@ class ParticipantPermissions: Whether the user is the creator of the chat or not. """ return isinstance(self.participant, ( - types.ChannelParticipantCreator, - types.ChatParticipantCreator + _tl.ChannelParticipantCreator, + _tl.ChatParticipantCreator )) @property @@ -67,9 +67,9 @@ class ParticipantPermissions: not banned either, and has no restrictions applied). """ return isinstance(self.participant, ( - types.ChannelParticipant, - types.ChatParticipant, - types.ChannelParticipantSelf + _tl.ChannelParticipant, + _tl.ChatParticipant, + _tl.ChannelParticipantSelf )) @property @@ -77,14 +77,14 @@ class ParticipantPermissions: """ Whether the user is banned in the chat. """ - return isinstance(self.participant, types.ChannelParticipantBanned) + return isinstance(self.participant, _tl.ChannelParticipantBanned) @property def has_left(self): """ Whether the user left the chat. """ - return isinstance(self.participant, types.ChannelParticipantLeft) + return isinstance(self.participant, _tl.ChannelParticipantLeft) @property def add_admins(self): diff --git a/telethon/types/_custom/qrlogin.py b/telethon/types/_custom/qrlogin.py index 3f2a0207..9a48884a 100644 --- a/telethon/types/_custom/qrlogin.py +++ b/telethon/types/_custom/qrlogin.py @@ -94,7 +94,7 @@ class QRLogin: async def handler(_update): event.set() - self._client.add_event_handler(handler, events.Raw(types.UpdateLoginToken)) + self._client.add_event_handler(handler, events.Raw(_tl.UpdateLoginToken)) try: # Will raise timeout error if it doesn't complete quick enough, @@ -105,12 +105,12 @@ class QRLogin: # We got here without it raising timeout error, so we can proceed resp = await self._client(self._request) - if isinstance(resp, types.auth.LoginTokenMigrateTo): + if isinstance(resp, _tl.auth.LoginTokenMigrateTo): await self._client._switch_dc(resp.dc_id) resp = await self._client(_tl.fn.auth.ImportLoginToken(resp.token)) # resp should now be auth.loginTokenSuccess - if isinstance(resp, types.auth.LoginTokenSuccess): + if isinstance(resp, _tl.auth.LoginTokenSuccess): user = resp.authorization.user self._client._on_login(user) return user From 3d36bb7b930af60705618066fe5a080c25bd46a5 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 18 Sep 2021 12:49:44 +0200 Subject: [PATCH 033/131] Change the way connection modes are specified --- readthedocs/misc/v2-migration-guide.rst | 30 +++++++++++++++++++++++++ telethon/__init__.py | 9 +------- telethon/_client/telegrambaseclient.py | 25 ++++++++++++++------- telethon/_client/telegramclient.py | 15 +++++++++---- telethon/_misc/enums.py | 22 ++++++++++++++++++ telethon/enums.py | 3 +++ 6 files changed, 84 insertions(+), 20 deletions(-) create mode 100644 telethon/_misc/enums.py create mode 100644 telethon/enums.py diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 4276dec8..8bf6ae6a 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -231,6 +231,36 @@ If you still want the old behaviour, wrap the list inside another list: #+ +Changes on how to configure a different connection mode +------------------------------------------------------- + +The ``connection`` parameter of the ``TelegramClient`` now expects a string, and not a type. +The supported values are: + +* ``'full'`` +* ``'intermediate'`` +* ``'abridged'`` +* ``'obfuscated'`` +* ``'http'`` + +The value chosen by the library is left as an implementation detail which may change. However, +you can force a certain mode by explicitly configuring it. If you don't want to hardcode the +string, you can import these values from the new ``telethon.enums`` module: + +.. code-block:: python + + client = TelegramClient(..., connection='tcp') + + # or + + from telethon.enums import ConnectionMode + client = TelegramClient(..., connection=ConnectionMode.TCP) + +You may have noticed there's currently no alternative for ``TcpMTProxy``. This mode has been +broken for some time now (see `issue #1319 `__) +anyway, so until there's a working solution, the mode is not supported. Pull Requests are welcome! + + The Conversation API has been removed ------------------------------------- diff --git a/telethon/__init__.py b/telethon/__init__.py index edbd4126..a10dc90c 100644 --- a/telethon/__init__.py +++ b/telethon/__init__.py @@ -5,13 +5,6 @@ from ._misc import utils # depends on helpers and _tl from ._misc import hints # depends on types/custom from ._client.telegramclient import TelegramClient -from ._network import connection -from . import version, events, utils, errors +from . import version, events, utils, errors, enums __version__ = version.__version__ - -__all__ = [ - 'TelegramClient', 'Button', - 'types', 'functions', 'custom', 'errors', - 'events', 'utils', 'connection' -] diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 35507edd..85c07057 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -9,8 +9,8 @@ import typing from .. import version, helpers, __name__ as __base_name__, _tl from .._crypto import rsa -from .._misc import markdown, entitycache, statecache -from .._network import MTProtoSender, Connection, ConnectionTcpFull, TcpMTProxy +from .._misc import markdown, entitycache, statecache, enums +from .._network import MTProtoSender, Connection, ConnectionTcpFull, connection as conns from ..sessions import Session, SQLiteSession, MemorySession DEFAULT_DC_ID = 2 @@ -191,10 +191,19 @@ def init( self._timeout = timeout self._auto_reconnect = auto_reconnect - assert isinstance(connection, type) - self._connection = connection - init_proxy = None if not issubclass(connection, TcpMTProxy) else \ - _tl.InputClientProxy(*connection.address_info(proxy)) + if connection == (): + # For now the current default remains TCP Full; may change to be "smart" if proxies are specified + connection = enums.ConnectionMode.FULL + + self._connection = { + enums.ConnectionMode.FULL: conns.ConnectionTcpFull, + enums.ConnectionMode.INTERMEDIATE: conns.ConnectionTcpIntermediate, + enums.ConnectionMode.ABRIDGED: conns.ConnectionTcpAbridged, + enums.ConnectionMode.OBFUSCATED: conns.ConnectionTcpObfuscated, + enums.ConnectionMode.HTTP: conns.ConnectionHttp, + }[enums.parse_conn_mode(connection)] + init_proxy = None if not issubclass(self._connection, conns.TcpMTProxy) else \ + _tl.InputClientProxy(*self._connection.address_info(proxy)) # Used on connection. Capture the variables in a lambda since # exporting clients need to create this InvokeWithLayer. @@ -334,7 +343,7 @@ async def disconnect(self: 'TelegramClient'): return await _disconnect_coro(self) def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): - init_proxy = None if not issubclass(self._connection, TcpMTProxy) else \ + init_proxy = None if not issubclass(self._connection, conns.TcpMTProxy) else \ _tl.InputClientProxy(*self._connection.address_info(proxy)) self._init_request.proxy = init_proxy @@ -347,7 +356,7 @@ def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): connection = getattr(self._sender, "_connection", None) if connection: - if isinstance(connection, TcpMTProxy): + if isinstance(connection, conns.TcpMTProxy): connection._ip = proxy[0] connection._port = proxy[1] else: diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index cb644dd2..796af317 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -12,6 +12,7 @@ from .. import helpers, version, _tl from ..types import _custom from .._network import ConnectionTcpFull from ..events.common import EventBuilder, EventCommon +from .._misc import enums class TelegramClient: @@ -37,9 +38,15 @@ class TelegramClient: api_hash (`str`): The API hash 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. It **must** be a type. + connection (`str`, optional): + The connection mode to be used when creating a new connection + to the servers. The available modes are: + + * ``'full'`` + * ``'intermediate'`` + * ``'abridged'`` + * ``'obfuscated'`` + * ``'http'`` Defaults to `telethon.network.connection.tcpfull.ConnectionTcpFull`. @@ -2768,7 +2775,7 @@ class TelegramClient: api_id: int, api_hash: str, *, - connection: 'typing.Type[Connection]' = ConnectionTcpFull, + connection: typing.Union[str, enums.ConnectionMode] = (), use_ipv6: bool = False, proxy: typing.Union[tuple, dict] = None, local_addr: typing.Union[str, tuple] = None, diff --git a/telethon/_misc/enums.py b/telethon/_misc/enums.py new file mode 100644 index 00000000..107bbc31 --- /dev/null +++ b/telethon/_misc/enums.py @@ -0,0 +1,22 @@ +from enum import Enum + + +class ConnectionMode(Enum): + FULL = 'full' + INTERMEDIATE = 'intermediate' + ABRIDGED = 'abridged' + OBFUSCATED = 'obfuscated' + HTTP = 'http' + + +def parse_conn_mode(mode): + if isinstance(mode, ConnectionMode): + return mode + elif isinstance(mode, str): + for cm in ConnectionMode: + if mode == cm.value: + return cm + + raise ValueError(f'unknown connection mode: {mode!r}') + else: + raise TypeError(f'not a valid connection mode: {type(mode).__name__!r}') diff --git a/telethon/enums.py b/telethon/enums.py new file mode 100644 index 00000000..42e588c0 --- /dev/null +++ b/telethon/enums.py @@ -0,0 +1,3 @@ +from ._misc.enums import ( + ConnectionMode, +) From 9af8ec8ccead54553d77b9c5cc2148fc8d3382cd Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 18 Sep 2021 13:04:13 +0200 Subject: [PATCH 034/131] Officially remove bot_file_id support --- readthedocs/misc/v2-migration-guide.rst | 17 +++ telethon/_client/downloads.py | 3 - telethon/_client/telegramclient.py | 4 - telethon/_client/uploads.py | 8 +- telethon/_misc/utils.py | 161 +----------------------- telethon/types/_custom/file.py | 15 --- 6 files changed, 20 insertions(+), 188 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 8bf6ae6a..51f161b5 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -178,6 +178,23 @@ The following modules have been moved inside ``_misc``: // TODO review telethon/__init__.py isn't exposing more than it should +Support for bot-API style file_id has been removed +-------------------------------------------------- + +They have been half-broken for a while now, so this is just making an existing reality official. +See `issue #1613 `__ for details. + +An alternative solution to re-use files may be provided in the future. For the time being, you +should either upload the file as needed, or keep a message with the media somewhere you can +later fetch it (by storing the chat and message identifier). + +Additionally, the ``custom.File.id`` property is gone (which used to provide access to this +"bot-API style" file identifier. + +// TODO could probably provide an in-memory cache for uploads to temporarily reuse old InputFile. +// this should lessen the impact of the removal of this feature + + The custom.Message class and the way it is used has changed ----------------------------------------------------------- diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py index 6cd34b10..53e22cc1 100644 --- a/telethon/_client/downloads.py +++ b/telethon/_client/downloads.py @@ -282,9 +282,6 @@ async def download_media( date = datetime.datetime.now() media = message - if isinstance(media, str): - media = utils.resolve_bot_file_id(media) - if isinstance(media, _tl.MessageService): if isinstance(message.action, _tl.MessageActionChatEditPhoto): diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 796af317..b83369e9 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -3147,10 +3147,6 @@ class TelegramClient: send the file as "external" media, and Telegram is the one that will fetch the media and send it. - * A Bot API-like ``file_id``. You can convert previously - sent media to file IDs for later reusing with - `telethon.utils.pack_bot_file_id`. - * A handle to an existing file (for example, if you sent a message with media before, you can use its ``message.media`` as a file here). diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py index 244c5b59..912954b0 100644 --- a/telethon/_client/uploads.py +++ b/telethon/_client/uploads.py @@ -425,17 +425,13 @@ async def _file_to_media( media = _tl.InputMediaPhotoExternal(file, ttl_seconds=ttl) else: media = _tl.InputMediaDocumentExternal(file, ttl_seconds=ttl) - else: - bot_file = utils.resolve_bot_file_id(file) - if bot_file: - media = utils.get_input_media(bot_file, ttl=ttl) if media: pass # Already have media, don't check the rest elif not file_handle: raise ValueError( - 'Failed to convert {} to media. Not an existing file, ' - 'an HTTP URL or a valid bot-API-like file ID'.format(file) + 'Failed to convert {} to media. Not an existing file or ' + 'HTTP URL'.format(file) ) elif as_image: media = _tl.InputMediaUploadedPhoto(file_handle, ttl_seconds=ttl) diff --git a/telethon/_misc/utils.py b/telethon/_misc/utils.py index 956a154d..8f4667b8 100644 --- a/telethon/_misc/utils.py +++ b/telethon/_misc/utils.py @@ -856,11 +856,7 @@ def is_image(file): """ Returns `True` if the file extension looks like an image file to Telegram. """ - match = re.match(r'\.(png|jpe?g)', _get_extension(file), re.IGNORECASE) - if match: - return True - else: - return isinstance(resolve_bot_file_id(file), _tl.Photo) + return bool(re.match(r'\.(png|jpe?g)', _get_extension(file), re.IGNORECASE)) def is_gif(file): @@ -1119,161 +1115,6 @@ def _encode_telegram_base64(string): return None # not valid base64, not valid ascii, not a string -def resolve_bot_file_id(file_id): - """ - Given a Bot API-style `file_id `, - returns the media it represents. If the `file_id ` - is not valid, `None` is returned instead. - - Note that the `file_id ` does not have information - such as image dimensions or file size, so these will be zero if present. - - For thumbnails, the photo ID and hash will always be zero. - """ - data = _rle_decode(_decode_telegram_base64(file_id)) - if not data: - return None - - # This isn't officially documented anywhere, but - # we assume the last byte is some kind of "version". - data, version = data[:-1], data[-1] - if version not in (2, 4): - return None - - if (version == 2 and len(data) == 24) or (version == 4 and len(data) == 25): - if version == 2: - file_type, dc_id, media_id, access_hash = struct.unpack(' Date: Sat, 18 Sep 2021 13:10:31 +0200 Subject: [PATCH 035/131] Officially remove resolve_invite_link --- readthedocs/misc/v2-migration-guide.rst | 13 +++++++++ telethon/_misc/utils.py | 37 ------------------------- telethon/sessions/memory.py | 4 --- 3 files changed, 13 insertions(+), 41 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 51f161b5..514aa8b8 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -195,6 +195,19 @@ Additionally, the ``custom.File.id`` property is gone (which used to provide acc // this should lessen the impact of the removal of this feature +Removal of several utility methods +---------------------------------- + +The following ``utils`` methods no longer exist or have been made private: + +* ``utils.resolve_bot_file_id``. It was half-broken. +* ``utils.pack_bot_file_id``. It was half-broken. +* ``utils.resolve_invite_link``. It has been broken for a while, so this just makes its removal + official (see `issue #1723 `__). + +// TODO provide the new clean utils + + The custom.Message class and the way it is used has changed ----------------------------------------------------------- diff --git a/telethon/_misc/utils.py b/telethon/_misc/utils.py index 8f4667b8..a92aff0b 100644 --- a/telethon/_misc/utils.py +++ b/telethon/_misc/utils.py @@ -1115,43 +1115,6 @@ def _encode_telegram_base64(string): return None # not valid base64, not valid ascii, not a string -def resolve_invite_link(link): - """ - Resolves the given invite link. Returns a tuple of - ``(link creator user id, global chat id, random int)``. - - Note that for broadcast channels or with the newest link format, the link - creator user ID will be zero to protect their identity. Normal chats and - megagroup channels will have such ID. - - Note that the chat ID may not be accurate for chats with a link that were - upgraded to megagroup, since the link can remain the same, but the chat - ID will be correct once a new link is generated. - """ - link_hash, is_link = parse_username(link) - if not is_link: - # Perhaps the user passed the link hash directly - link_hash = link - - # Little known fact, but invite links with a - # hex-string of bytes instead of base64 also works. - if re.match(r'[a-fA-F\d]+', link_hash) and len(link_hash) in (24, 32): - payload = bytes.fromhex(link_hash) - else: - payload = _decode_telegram_base64(link_hash) - - try: - if len(payload) == 12: - return (0, *struct.unpack('>LQ', payload)) - elif len(payload) == 16: - return struct.unpack('>LLQ', payload) - else: - pass - except (struct.error, TypeError): - pass - return None, None, None - - def resolve_inline_message_id(inline_msg_id): """ Resolves an inline message ID. Returns a tuple of diff --git a/telethon/sessions/memory.py b/telethon/sessions/memory.py index 82067101..d3d6e22a 100644 --- a/telethon/sessions/memory.py +++ b/telethon/sessions/memory.py @@ -204,10 +204,6 @@ class MemorySession(Session): username, invite = utils.parse_username(key) if username and not invite: result = self.get_entity_rows_by_username(username) - else: - tup = utils.resolve_invite_link(key)[1] - if tup: - result = self.get_entity_rows_by_id(tup, exact=False) elif isinstance(key, int): result = self.get_entity_rows_by_id(key, exact) From af81899bdc206596a9492df893e97255c918d0fb Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 18 Sep 2021 13:30:39 +0200 Subject: [PATCH 036/131] Don't automatically start the client via async-with --- readthedocs/misc/v2-migration-guide.rst | 32 +++++++++++++++++++++++++ telethon/_client/auth.py | 23 +++++++++++++++--- telethon/_client/telegramclient.py | 16 ++++++------- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 514aa8b8..8c014498 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -178,6 +178,38 @@ The following modules have been moved inside ``_misc``: // TODO review telethon/__init__.py isn't exposing more than it should +Using the client in a context-manager no longer calls start automatically +------------------------------------------------------------------------- + + +The following code no longer automatically calls ``client.start()``: + +.. code-block:: python + + async with TelegramClient(...) as client: + ... + + # or + + async with client: + ... + + +This means the context-manager will only call ``client.connect()`` and ``client.disconnect()``. +The rationale for this change is that it could be strange for this to ask for the login code if +the session ever was invalid. If you want the old behaviour, you now need to be explicit: + + +.. code-block:: python + + async with TelegramClient(...).start() as client: + ... # ++++++++ + + +Note that you do not need to ``await`` the call to ``.start()`` if you are going to use the result +in a context-manager (but it's okay if you put the ``await``). + + Support for bot-API style file_id has been removed -------------------------------------------------- diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index 992d44e1..27ef4767 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -4,6 +4,7 @@ import os import sys import typing import warnings +import functools from .._misc import utils, helpers, password as pwd_mod from .. import errors, _tl @@ -13,7 +14,23 @@ if typing.TYPE_CHECKING: from .telegramclient import TelegramClient -async def start( +class StartingClient: + def __init__(self, client, start_fn): + self.client = client + self.start_fn = start_fn + + async def __aenter__(self): + await self.start_fn() + return self.client + + async def __aexit__(self, *args): + await self.client.__aexit__(*args) + + def __await__(self): + return self.__aenter__().__await__() + + +def start( self: 'TelegramClient', phone: typing.Callable[[], str] = lambda: input('Please enter your phone (or bot token): '), password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '), @@ -40,7 +57,7 @@ async def start( raise ValueError('Both a phone and a bot token provided, ' 'must only provide one of either') - return await _start( + return StartingClient(self, functools.partial(_start, self=self, phone=phone, password=password, @@ -50,7 +67,7 @@ async def start( first_name=first_name, last_name=last_name, max_attempts=max_attempts - ) + )) async def _start( self: 'TelegramClient', phone, password, bot_token, force_sms, diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index b83369e9..b1c07a35 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -281,7 +281,7 @@ class TelegramClient: # region Auth - async def start( + def start( self: 'TelegramClient', phone: typing.Callable[[], str] = lambda: input('Please enter your phone (or bot token): '), password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '), @@ -304,9 +304,8 @@ class TelegramClient: will be banned otherwise.** See https://telegram.org/tos and https://core.telegram.org/api/terms. - If the event loop is already running, this method returns a - coroutine that you should await on your own code; otherwise - the loop is ran until said coroutine completes. + Even though this method is not marked as ``async``, you still need to + ``await`` its result for it to do anything useful. Arguments phone (`str` | `int` | `callable`): @@ -363,11 +362,11 @@ class TelegramClient: # Please enter your password: ******* # (You are now logged in) - # Starting using a context manager (this calls start()): - with client: + # Starting using a context manager (note the lack of await): + async with client.start(): pass """ - return await auth.start(**locals()) + return auth.start(**locals()) async def sign_in( self: 'TelegramClient', @@ -621,7 +620,8 @@ class TelegramClient: return await auth.edit_2fa(**locals()) async def __aenter__(self): - return await self.start() + await self.connect() + return self async def __aexit__(self, *args): await self.disconnect() From 8114fb6c9b7bfa50737e8f9b083a1227e1c83a34 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 18 Sep 2021 13:34:21 +0200 Subject: [PATCH 037/131] Stop checking fwd_from or not out in message.edit --- readthedocs/misc/v2-migration-guide.rst | 11 +++++++++++ telethon/types/_custom/message.py | 6 ------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 8c014498..b28cf2bf 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -362,6 +362,17 @@ perform a separate request with no filter to fetch the total without filter (thi library used to do). +Using message.edit will now raise an error if the message cannot be edited +-------------------------------------------------------------------------- + +Before, calling ``message.edit()`` would completely ignore your attempt to edit a message if the +message had a forward header or was not outgoing. This is no longer the case. It is now the user's +responsibility to check for this. + +However, most likely, you were already doing the right thing (or else you would've experienced a +"why is this not being edited", which you would most likely consider a bug rather than a feature). + + The TelegramClient is no longer made out of mixins -------------------------------------------------- diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index 88a1a615..2d8f3fbe 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -762,9 +762,6 @@ class Message(ChatGetter, SenderGetter): `telethon.client.messages.MessageMethods.edit_message` with both ``entity`` and ``message`` already set. - Returns `None` if the message was incoming, - or the edited `Message` otherwise. - .. note:: This is different from `client.edit_message @@ -777,9 +774,6 @@ class Message(ChatGetter, SenderGetter): This is generally the most desired and convenient behaviour, and will work for link previews and message buttons. """ - if self.fwd_from or not self.out or not self._client: - return None # We assume self.out was patched for our chat - if 'link_preview' not in kwargs: kwargs['link_preview'] = bool(self.web_preview) From bf61dd32af45ad1a4a3139db4ea90ee4d889d7b5 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 18 Sep 2021 14:16:19 +0200 Subject: [PATCH 038/131] Change the way iter_participants filters are specified --- readthedocs/misc/v2-migration-guide.rst | 25 ++++++++++++++++-- telethon/_client/chats.py | 28 +++++++++++++------- telethon/_client/telegramclient.py | 18 ++++++++----- telethon/_misc/enums.py | 35 ++++++++++++++++++------- telethon/enums.py | 1 + 5 files changed, 80 insertions(+), 27 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index b28cf2bf..62a8bc30 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -240,6 +240,25 @@ The following ``utils`` methods no longer exist or have been made private: // TODO provide the new clean utils +Changes on how to configure filters for certain client methods +-------------------------------------------------------------- + +Before, ``client.iter_participants`` (and ``get_participants``) would expect a type or instance +of the raw Telegram definition as a ``filter``. Now, this ``filter`` expects a string. +The supported values are: + +* ``'admin'`` +* ``'bot'`` +* ``'kicked'`` +* ``'banned'`` +* ``'contact'`` + +If you prefer to avoid hardcoding strings, you may use ``telethon.enums.Participant``. + +// TODO maintain support for the old way of doing it? +// TODO now that there's a custom filter, filter client-side for small chats? + + The custom.Message class and the way it is used has changed ----------------------------------------------------------- @@ -345,12 +364,14 @@ actually using. Now it returns an ``int`` value indicating the number of message and were deleted. -The aggressive parameter hack has been removed ----------------------------------------------- +Changes to the methods to retrieve participants +----------------------------------------------- The "aggressive" hack in ``get_participants`` (and ``iter_participants``) is now gone. It was not reliable, and was a cause of flood wait errors. +The ``search`` parameter is no longer ignored when ``filter`` is specified. + The total value when getting participants has changed ----------------------------------------------------- diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 1aa0724d..ffa06ac7 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -5,7 +5,7 @@ import string import typing from .. import hints, errors, _tl -from .._misc import helpers, utils, requestiter, tlobject +from .._misc import helpers, utils, requestiter, tlobject, enums from ..types import _custom if typing.TYPE_CHECKING: @@ -95,15 +95,25 @@ class _ChatAction: class _ParticipantsIter(requestiter.RequestIter): async def _init(self, entity, filter, search): - if isinstance(filter, type): - if filter in (_tl.ChannelParticipantsBanned, - _tl.ChannelParticipantsKicked, - _tl.ChannelParticipantsSearch, - _tl.ChannelParticipantsContacts): - # These require a `q` parameter (support types for convenience) - filter = filter('') + if not filter: + if search: + filter = _tl.ChannelParticipantsSearch(search) else: - filter = filter() + filter = _tl.ChannelParticipantsRecent() + else: + filter = enums.parse_participant(filter) + if filter == enums.Participant.ADMIN: + filter = _tl.ChannelParticipantsAdmins() + elif filter == enums.Participant.BOT: + filter = _tl.ChannelParticipantsBots() + elif filter == enums.Participant.KICKED: + filter = _tl.ChannelParticipantsKicked(search) + elif filter == enums.Participant.BANNED: + filter = _tl.ChannelParticipantsBanned(search) + elif filter == enums.Participant.CONTACT: + filter = _tl.ChannelParticipantsContacts(search) + else: + raise RuntimeError('unhandled enum variant') entity = await self.client.get_input_entity(entity) ty = helpers._entity_type(entity) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index b1c07a35..839e1ea3 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -729,7 +729,7 @@ class TelegramClient: limit: float = (), *, search: str = '', - filter: '_tl.TypeChannelParticipantsFilter' = None) -> chats._ParticipantsIter: + filter: typing.Union[str, enums.Participant] = ()) -> chats._ParticipantsIter: """ Iterator over the participants belonging to the specified chat. @@ -749,16 +749,22 @@ class TelegramClient: search (`str`, optional): Look for participants with this string in name/username. - filter (:tl:`ChannelParticipantsFilter`, optional): + Note that the search is only compatible with some ``filter`` + when fetching members from a channel or megagroup. This may + change in the future. + + filter (`str`, 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. - .. note:: + The available filters are: - The filter :tl:`ChannelParticipantsBanned` will return - *restricted* users. If you want *banned* users you should - use :tl:`ChannelParticipantsKicked` instead. + * ``'admin'`` + * ``'bot'`` + * ``'kicked'`` + * ``'banned'`` + * ``'contact'`` Yields The :tl:`User` objects returned by :tl:`GetParticipants` diff --git a/telethon/_misc/enums.py b/telethon/_misc/enums.py index 107bbc31..edce6776 100644 --- a/telethon/_misc/enums.py +++ b/telethon/_misc/enums.py @@ -9,14 +9,29 @@ class ConnectionMode(Enum): HTTP = 'http' -def parse_conn_mode(mode): - if isinstance(mode, ConnectionMode): - return mode - elif isinstance(mode, str): - for cm in ConnectionMode: - if mode == cm.value: - return cm +class Participant(Enum): + ADMIN = 'admin' + BOT = 'bot' + KICKED = 'kicked' + BANNED = 'banned' + CONTACT = 'contact' - raise ValueError(f'unknown connection mode: {mode!r}') - else: - raise TypeError(f'not a valid connection mode: {type(mode).__name__!r}') + +def _mk_parser(cls): + def parser(value): + if isinstance(value, cls): + return value + elif isinstance(value, str): + for variant in cls: + if value == variant.value: + return variant + + raise ValueError(f'unknown {cls.__name__}: {value!r}') + else: + raise TypeError(f'not a valid {cls.__name__}: {type(value).__name__!r}') + + return parser + + +parse_conn_mode = _mk_parser(ConnectionMode) +parse_participant = _mk_parser(Participant) diff --git a/telethon/enums.py b/telethon/enums.py index 42e588c0..8de39a15 100644 --- a/telethon/enums.py +++ b/telethon/enums.py @@ -1,3 +1,4 @@ from ._misc.enums import ( ConnectionMode, + Participant, ) From e524a74b8452f22ebbd6ba83d35084b40d2de494 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 18 Sep 2021 15:41:04 +0200 Subject: [PATCH 039/131] Remove client.disconnected property --- readthedocs/misc/v2-migration-guide.rst | 6 ++++ telethon/_client/telegrambaseclient.py | 3 -- telethon/_client/telegramclient.py | 38 ++++++------------------- telethon/_client/updates.py | 14 ++------- 4 files changed, 18 insertions(+), 43 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 62a8bc30..1ca03003 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -394,6 +394,12 @@ However, most likely, you were already doing the right thing (or else you would' "why is this not being edited", which you would most likely consider a bug rather than a feature). +The client.disconnected property has been removed +------------------------------------------------- + +``client.run_until_disconnected()`` should be used instead. + + The TelegramClient is no longer made out of mixins -------------------------------------------------- diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 85c07057..c8023429 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -301,9 +301,6 @@ def init( def get_loop(self: 'TelegramClient') -> asyncio.AbstractEventLoop: return asyncio.get_event_loop() -def get_disconnected(self: 'TelegramClient') -> asyncio.Future: - return self._sender.disconnected - def get_flood_sleep_threshold(self): return self._flood_sleep_threshold diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 839e1ea3..e2f1de76 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -2823,22 +2823,6 @@ class TelegramClient: """ return telegrambaseclient.get_loop(**locals()) - @property - def disconnected(self: 'TelegramClient') -> asyncio.Future: - """ - Property with a ``Future`` that resolves upon disconnection. - - Example - .. code-block:: python - - # Wait for a disconnection to occur - try: - await client.disconnected - except OSError: - print('Error on disconnect') - """ - return telegrambaseclient.get_disconnected(**locals()) - @property def flood_sleep_threshold(self): return telegrambaseclient.get_flood_sleep_threshold(**locals()) @@ -2928,30 +2912,26 @@ class TelegramClient: def run_until_disconnected(self: 'TelegramClient'): """ - Runs the event loop until the library is disconnected. + Wait until the library is disconnected. It also notifies Telegram that we want to receive updates as described in https://core.telegram.org/api/updates. + Event handlers will continue to run while the method awaits for a + disconnection to occur. Essentially, this method "blocks" until a + disconnection occurs, and keeps your code running if you have nothing + else to do. + Manual disconnections can be made by calling `disconnect() ` - or sending a ``KeyboardInterrupt`` (e.g. by pressing ``Ctrl+C`` on - the console window running the script). + or exiting the context-manager using the client (for example, a + ``KeyboardInterrupt`` by pressing ``Ctrl+C`` on the console window + would propagate the error, exit the ``with`` block and disconnect). If a disconnection error occurs (i.e. the library fails to reconnect automatically), said error will be raised through here, so you have a chance to ``except`` it on your own code. - If the loop is already running, this method returns a coroutine - that you should await on your own code. - - .. note:: - - If you want to handle ``KeyboardInterrupt`` in your code, - simply run the event loop in your code too in any way, such as - ``loop.run_forever()`` or ``await client.disconnected`` (e.g. - ``loop.run_until_complete(client.disconnected)``). - Example .. code-block:: python diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index 93e8a9bc..b319f8e2 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -18,23 +18,15 @@ if typing.TYPE_CHECKING: Callback = typing.Callable[[typing.Any], typing.Any] -async def _run_until_disconnected(self: 'TelegramClient'): - try: - # Make a high-level request to notify that we want updates - await self(_tl.fn.updates.GetState()) - return await self.disconnected - except KeyboardInterrupt: - pass - finally: - await self.disconnect() - async def set_receive_updates(self: 'TelegramClient', receive_updates): self._no_updates = not receive_updates if receive_updates: await self(_tl.fn.updates.GetState()) async def run_until_disconnected(self: 'TelegramClient'): - return await _run_until_disconnected(self) + # Make a high-level request to notify that we want updates + await self(_tl.fn.updates.GetState()) + return await self._sender.disconnected def on(self: 'TelegramClient', event: EventBuilder): def decorator(f): From 48c14df957c1fa521ca4ae2d3a2613369ef931e2 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 18 Sep 2021 16:05:07 +0200 Subject: [PATCH 040/131] Remove client.download_file --- readthedocs/misc/v2-migration-guide.rst | 10 ++- telethon/_client/downloads.py | 82 ++++++++++++++++--------- telethon/_client/telegramclient.py | 65 -------------------- 3 files changed, 62 insertions(+), 95 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 1ca03003..22bd2acd 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -181,7 +181,6 @@ The following modules have been moved inside ``_misc``: Using the client in a context-manager no longer calls start automatically ------------------------------------------------------------------------- - The following code no longer automatically calls ``client.start()``: .. code-block:: python @@ -210,6 +209,15 @@ Note that you do not need to ``await`` the call to ``.start()`` if you are going in a context-manager (but it's okay if you put the ``await``). +download_file has been removed from the client +---------------------------------------------- + +Instead, ``client.download_media`` should be used. + +The now-removed ``client.download_file`` method was a lower level implementation which should +have not been exposed at all. + + Support for bot-API style file_id has been removed -------------------------------------------------- diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py index 53e22cc1..9ae1aec8 100644 --- a/telethon/_client/downloads.py +++ b/telethon/_client/downloads.py @@ -243,7 +243,12 @@ async def download_profile_photo( ) try: - result = await self.download_file(loc, file, dc_id=dc_id) + result = await _download_file( + self=self, + input_location=loc, + file=file, + dc_id=dc_id + ) return result if file is bytes else file except errors.LocationInvalidError: # See issue #500, Android app fails as of v4.6.0 (1155). @@ -308,29 +313,6 @@ async def download_media( self, media, file, progress_callback ) -async def download_file( - self: 'TelegramClient', - input_location: 'hints.FileLike', - file: 'hints.OutFileLike' = None, - *, - part_size_kb: float = None, - file_size: int = None, - progress_callback: 'hints.ProgressCallback' = None, - dc_id: int = None, - key: bytes = None, - iv: bytes = None) -> typing.Optional[bytes]: - return await _download_file( - self, - input_location, - file, - part_size_kb=part_size_kb, - file_size=file_size, - progress_callback=progress_callback, - dc_id=dc_id, - key=key, - iv=iv, - ) - async def _download_file( self: 'TelegramClient', input_location: 'hints.FileLike', @@ -343,6 +325,46 @@ async def _download_file( key: bytes = None, iv: bytes = None, msg_data: tuple = None) -> typing.Optional[bytes]: + """ + Low-level method to download files from their input location. + + Arguments + input_location (: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 _tl. + + 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` or `bytes`, 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``. + + dc_id (`int`, optional): + The data center the library should connect to in order + to download the file. You shouldn't worry about this. + + key ('bytes', optional): + In case of an encrypted upload (secret chats) a key is supplied + + iv ('bytes', optional): + In case of an encrypted upload (secret chats) an iv is supplied + """ + if not part_size_kb: if not file_size: part_size_kb = 64 # Reasonable default @@ -568,14 +590,15 @@ async def _download_photo(self: 'TelegramClient', photo, file, date, thumb, prog else: file_size = size.size - result = await self.download_file( - _tl.InputPhotoFileLocation( + result = await _download_file( + self=self, + input_location=_tl.InputPhotoFileLocation( id=photo.id, access_hash=photo.access_hash, file_reference=photo.file_reference, thumb_size=size.type ), - file, + file=file, file_size=file_size, progress_callback=progress_callback ) @@ -626,13 +649,14 @@ async def _download_document( return _download_cached_photo_size(self, size, file) result = await _download_file( - _tl.InputDocumentFileLocation( + self=self, + input_location=_tl.InputDocumentFileLocation( id=document.id, access_hash=document.access_hash, file_reference=document.file_reference, thumb_size=size.type if size else '' ), - file, + file=file, file_size=size.size if size else document.size, progress_callback=progress_callback, msg_data=msg_data, diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index e2f1de76..811ad94b 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -1765,71 +1765,6 @@ class TelegramClient: """ return await downloads.download_media(**locals()) - async def download_file( - self: 'TelegramClient', - input_location: 'hints.FileLike', - file: 'hints.OutFileLike' = None, - *, - part_size_kb: float = None, - file_size: int = None, - progress_callback: 'hints.ProgressCallback' = None, - dc_id: int = None, - key: bytes = None, - iv: bytes = None) -> typing.Optional[bytes]: - """ - Low-level method to download files from their input location. - - .. note:: - - Generally, you should instead use `download_media`. - This method is intended to be a bit more low-level. - - Arguments - input_location (: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 _tl. - - 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` or `bytes`, 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``. - - dc_id (`int`, optional): - The data center the library should connect to in order - to download the file. You shouldn't worry about this. - - key ('bytes', optional): - In case of an encrypted upload (secret chats) a key is supplied - - iv ('bytes', optional): - In case of an encrypted upload (secret chats) an iv is supplied - - - Example - .. code-block:: python - - # Download a file and print its header - data = await client.download_file(input_file, bytes) - print(data[:16]) - """ - return await downloads.download_file(**locals()) - def iter_download( self: 'TelegramClient', file: 'hints.FileLike', From 9f3bb52e4e61cd2af519c5320de2d2fd4000fb44 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 18 Sep 2021 16:10:01 +0200 Subject: [PATCH 041/131] Possibly fix _get_response_message for UpdateMessagePoll --- telethon/_client/messageparse.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/telethon/_client/messageparse.py b/telethon/_client/messageparse.py index 69d438fd..4cc17e15 100644 --- a/telethon/_client/messageparse.py +++ b/telethon/_client/messageparse.py @@ -140,7 +140,9 @@ def _get_response_message(self: 'TelegramClient', request, result, input_chat): media=_tl.MessageMediaPoll( poll=update.poll, results=update.results - ) + ), + date=None, + message='' ), entities, input_chat) if request is None: From 431a9309e392bef267507caabba3201078aab388 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 18 Sep 2021 16:29:45 +0200 Subject: [PATCH 042/131] Remove mark from peer_id --- readthedocs/misc/v2-migration-guide.rst | 26 ++++++++++++ telethon/_client/telegramclient.py | 6 +-- telethon/_client/users.py | 9 ++-- telethon/_misc/entitycache.py | 6 +-- telethon/_misc/utils.py | 51 +++-------------------- telethon/events/common.py | 9 +--- telethon/sessions/memory.py | 13 +----- telethon/sessions/sqlite.py | 12 +----- telethon_generator/generators/tlobject.py | 2 +- 9 files changed, 45 insertions(+), 89 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 22bd2acd..995a412e 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -31,6 +31,29 @@ will need to migrate that to support the new size requirement of 8 bytes. For the full list of types changed, please review the above link. +Peer IDs, including chat_id and sender_id, no longer follow bot API conventions +------------------------------------------------------------------------------- + +Both the ``utils.get_peer_id`` and ``client.get_peer_id`` methods no longer have an ``add_mark`` +parameter. Both will always return the original ID as given by Telegram. This should lead to less +confusion. However, it also means that an integer ID on its own no longer embeds the information +about the type (did it belong to a user, chat, or channel?), so ``utils.get_peer`` can no longer +guess the type from just a number. + +Because it's not possible to know what other changes Telegram will do with identifiers, it's +probably best to get used to transparently storing whatever value they send along with the type +separatedly. + +As far as I can tell, user, chat and channel identifiers are globally unique, meaning a channel +and a user cannot share the same identifier. The library currently makes this assumption. However, +this is merely an observation (I have never heard of such a collision exist), and Telegram could +change at any time. If you want to be on the safe side, you're encouraged to save a pair of type +and identifier, rather than just the number. + +// TODO we DEFINITELY need to provide a way to "upgrade" old ids +// TODO and storing type+number by hand is a pain, provide better alternative + + Synchronous compatibility mode has been removed ----------------------------------------------- @@ -244,6 +267,9 @@ The following ``utils`` methods no longer exist or have been made private: * ``utils.pack_bot_file_id``. It was half-broken. * ``utils.resolve_invite_link``. It has been broken for a while, so this just makes its removal official (see `issue #1723 `__). +* ``utils.resolve_id``. Marked IDs are no longer used thorough the library. The removal of this + method also means ``utils.get_peer`` can no longer get a ``Peer`` from just a number, as the + type is no longer embedded inside the ID. // TODO provide the new clean utils diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 811ad94b..f3671e0b 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -3541,17 +3541,13 @@ class TelegramClient: async def get_peer_id( self: 'TelegramClient', - peer: 'hints.EntityLike', - add_mark: bool = True) -> int: + peer: 'hints.EntityLike') -> int: """ Gets the ID for the given entity. This method needs to be ``async`` because `peer` supports usernames, invite-links, phone numbers (from people in your contact list), etc. - If ``add_mark is False``, then a positive ID will be returned - instead. By default, bot-API style IDs (signed) are returned. - Example .. code-block:: python diff --git a/telethon/_client/users.py b/telethon/_client/users.py index f3393196..72786fe5 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -317,10 +317,9 @@ async def _get_peer(self: 'TelegramClient', peer: 'hints.EntityLike'): async def get_peer_id( self: 'TelegramClient', - peer: 'hints.EntityLike', - add_mark: bool = True) -> int: + peer: 'hints.EntityLike') -> int: if isinstance(peer, int): - return utils.get_peer_id(peer, add_mark=add_mark) + return utils.get_peer_id(peer) try: if peer.SUBCLASS_OF_ID not in (0x2d45687, 0xc91c90b6): @@ -332,7 +331,7 @@ async def get_peer_id( if isinstance(peer, _tl.InputPeerSelf): peer = await self.get_me(input_peer=True) - return utils.get_peer_id(peer, add_mark=add_mark) + return utils.get_peer_id(peer) async def _get_entity_from_string(self: 'TelegramClient', string): @@ -381,7 +380,7 @@ async def _get_entity_from_string(self: 'TelegramClient', string): .format(username)) from e try: - pid = utils.get_peer_id(result.peer, add_mark=False) + pid = utils.get_peer_id(result.peer) if isinstance(result.peer, _tl.PeerUser): return next(x for x in result.users if x.id == pid) else: diff --git a/telethon/_misc/entitycache.py b/telethon/_misc/entitycache.py index b6f87697..a191dc6f 100644 --- a/telethon/_misc/entitycache.py +++ b/telethon/_misc/entitycache.py @@ -119,12 +119,10 @@ class EntityCache: update.user_id not in dct: return False - if cid in has_chat_id and \ - utils.get_peer_id(_tl.PeerChat(update.chat_id)) not in dct: + if cid in has_chat_id and update.chat_id not in dct: return False - if cid in has_channel_id and \ - utils.get_peer_id(_tl.PeerChannel(update.channel_id)) not in dct: + if cid in has_channel_id and update.channel_id not in dct: return False if cid in has_peer and \ diff --git a/telethon/_misc/utils.py b/telethon/_misc/utils.py index a92aff0b..e412d563 100644 --- a/telethon/_misc/utils.py +++ b/telethon/_misc/utils.py @@ -961,10 +961,7 @@ def get_inner_text(text, entities): def get_peer(peer): try: - if isinstance(peer, int): - pid, cls = resolve_id(peer) - return cls(pid) - elif peer.SUBCLASS_OF_ID == 0x2d45687: + if peer.SUBCLASS_OF_ID == 0x2d45687: return peer elif isinstance(peer, ( _tl.contacts.ResolvedPeer, _tl.InputNotifyPeer, @@ -993,24 +990,13 @@ def get_peer(peer): _raise_cast_fail(peer, 'Peer') -def get_peer_id(peer, add_mark=True): +def get_peer_id(peer): """ - Convert the given peer into its marked ID by default. - - This "mark" comes from the "bot api" format, and with it the peer type - can be identified back. User ID is left unmodified, chat ID is negated, - and channel ID is "prefixed" with -100: - - * ``user_id`` - * ``-chat_id`` - * ``-100channel_id`` - - The original ID and the peer type class can be returned with - a call to :meth:`resolve_id(marked_id)`. + Extract the integer ID from the given peer. """ # First we assert it's a Peer TLObject, or early return for integers if isinstance(peer, int): - return peer if add_mark else resolve_id(peer)[0] + return peer # Tell the user to use their client to resolve InputPeerSelf if we got one if isinstance(peer, _tl.InputPeerSelf): @@ -1024,34 +1010,9 @@ def get_peer_id(peer, add_mark=True): if isinstance(peer, _tl.PeerUser): return peer.user_id elif isinstance(peer, _tl.PeerChat): - # Check in case the user mixed things up to avoid blowing up - if not (0 < peer.chat_id <= 0x7fffffff): - peer.chat_id = resolve_id(peer.chat_id)[0] - - return -peer.chat_id if add_mark else peer.chat_id + return peer.chat_id else: # if isinstance(peer, _tl.PeerChannel): - # Check in case the user mixed things up to avoid blowing up - if not (0 < peer.channel_id <= 0x7fffffff): - peer.channel_id = resolve_id(peer.channel_id)[0] - - if not add_mark: - return peer.channel_id - - # Growing backwards from -100_0000_000_000 indicates it's a channel - return -(1000000000000 + peer.channel_id) - - -def resolve_id(marked_id): - """Given a marked ID, returns the original ID and its :tl:`Peer` type.""" - if marked_id >= 0: - return marked_id, _tl.PeerUser - - marked_id = -marked_id - if marked_id > 1000000000000: - marked_id -= 1000000000000 - return marked_id, _tl.PeerChannel - else: - return marked_id, _tl.PeerChat + return peer.channel_id def _rle_decode(data): diff --git a/telethon/events/common.py b/telethon/events/common.py index 405537d2..f7b2e066 100644 --- a/telethon/events/common.py +++ b/telethon/events/common.py @@ -18,14 +18,7 @@ async def _into_id_set(client, chats): result = set() for chat in chats: if isinstance(chat, int): - if chat < 0: - result.add(chat) # Explicitly marked IDs are negative - else: - result.update({ # Support all valid types of peers - utils.get_peer_id(_tl.PeerUser(chat)), - utils.get_peer_id(_tl.PeerChat(chat)), - utils.get_peer_id(_tl.PeerChannel(chat)), - }) + result.add(chat) elif isinstance(chat, tlobject.TLObject) and chat.SUBCLASS_OF_ID == 0x2d45687: # 0x2d45687 == crc32(b'Peer') result.add(utils.get_peer_id(chat)) diff --git a/telethon/sessions/memory.py b/telethon/sessions/memory.py index d3d6e22a..5da811b2 100644 --- a/telethon/sessions/memory.py +++ b/telethon/sessions/memory.py @@ -165,17 +165,8 @@ class MemorySession(Session): def get_entity_rows_by_id(self, id, exact=True): try: - if exact: - return next((id, hash) for found_id, hash, _, _, _ - in self._entities if found_id == id) - else: - ids = ( - utils.get_peer_id(_tl.PeerUser(id)), - utils.get_peer_id(_tl.PeerChat(id)), - utils.get_peer_id(_tl.PeerChannel(id)) - ) - return next((id, hash) for found_id, hash, _, _, _ - in self._entities if found_id in ids) + return next((id, hash) for found_id, hash, _, _, _ + in self._entities if found_id == id) except StopIteration: pass diff --git a/telethon/sessions/sqlite.py b/telethon/sessions/sqlite.py index 5b4505c8..15fe5eaf 100644 --- a/telethon/sessions/sqlite.py +++ b/telethon/sessions/sqlite.py @@ -316,16 +316,8 @@ class SQLiteSession(MemorySession): 'select id, hash from entities where name = ?', name) def get_entity_rows_by_id(self, id, exact=True): - if exact: - return self._execute( - 'select id, hash from entities where id = ?', id) - else: - return self._execute( - 'select id, hash from entities where id in (?,?,?)', - utils.get_peer_id(_tl.PeerUser(id)), - utils.get_peer_id(_tl.PeerChat(id)), - utils.get_peer_id(_tl.PeerChannel(id)) - ) + return self._execute( + 'select id, hash from entities where id = ?', id) # File processing diff --git a/telethon_generator/generators/tlobject.py b/telethon_generator/generators/tlobject.py index 9cb43140..bb310c5a 100644 --- a/telethon_generator/generators/tlobject.py +++ b/telethon_generator/generators/tlobject.py @@ -32,7 +32,7 @@ AUTO_CASTS = { } NAMED_AUTO_CASTS = { - ('chat_id', 'int'): 'await client.get_peer_id({}, add_mark=False)' + ('chat_id', 'int'): 'await client.get_peer_id({})' } # Secret chats have a chat_id which may be negative. From 4321b97e9887f24b68b5807e371251e519ee51ca Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 18 Sep 2021 16:36:11 +0200 Subject: [PATCH 043/131] No longer run send_code_request from sign_in --- readthedocs/misc/v2-migration-guide.rst | 7 +++++++ telethon/_client/auth.py | 9 ++------- telethon/_client/telegramclient.py | 2 -- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 995a412e..b1f3a483 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -428,6 +428,13 @@ However, most likely, you were already doing the right thing (or else you would' "why is this not being edited", which you would most likely consider a bug rather than a feature). +Signing in no longer sends the code +----------------------------------- + +``client.sign_in()`` used to run ``client.send_code_request()`` if you only provided the phone and +not the code. It no longer does this. If you need that convenience, use ``client.start()`` instead. + + The client.disconnected property has been removed ------------------------------------------------- diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index 27ef4767..05dd05c3 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -225,9 +225,7 @@ async def sign_in( if me: return me - if phone and not code and not password: - return await self.send_code_request(phone) - elif code: + if phone and code: phone, phone_code_hash = \ _parse_phone_and_hash(self, phone, phone_code_hash) @@ -247,10 +245,7 @@ async def sign_in( 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.' - ) + raise ValueError('You must provide either phone and code, password, or bot_token.') result = await self(request) if isinstance(result, _tl.auth.AuthorizationSignUpRequired): diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index f3671e0b..5683e81f 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -381,8 +381,6 @@ class TelegramClient: You should only use this if you are not authorized yet. - This method will send the code if it's not provided. - .. note:: In most cases, you should simply use `start()` and not this method. From 0b54fa7a25940132682ef3c3b26a5fdebeecd457 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 18 Sep 2021 16:54:54 +0200 Subject: [PATCH 044/131] Make edit_message parameters more consistent --- readthedocs/misc/v2-migration-guide.rst | 21 +++++++++++++++++++-- telethon/_client/messages.py | 12 ++---------- telethon/_client/telegramclient.py | 4 ++-- telethon/events/callbackquery.py | 2 +- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index b1f3a483..97cf43d3 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -417,8 +417,8 @@ perform a separate request with no filter to fetch the total without filter (thi library used to do). -Using message.edit will now raise an error if the message cannot be edited --------------------------------------------------------------------------- +Changes to editing messages +--------------------------- Before, calling ``message.edit()`` would completely ignore your attempt to edit a message if the message had a forward header or was not outgoing. This is no longer the case. It is now the user's @@ -427,6 +427,23 @@ responsibility to check for this. However, most likely, you were already doing the right thing (or else you would've experienced a "why is this not being edited", which you would most likely consider a bug rather than a feature). +When using ``client.edit_message``, you now must always specify the chat and the message (or +message identifier). This should be less "magic". As an example, if you were doing this before: + +.. code-block:: python + + await client.edit_message(message, 'new text') + +You now have to do the following: + +.. code-block:: python + + await client.edit_message(message.input_chat, message.id, 'new text') + + # or + + await message.edit('new text') + Signing in no longer sends the code ----------------------------------- diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index 5c073e60..e5290742 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -570,14 +570,6 @@ async def edit_message( supports_streaming: bool = False, schedule: 'hints.DateLike' = None ) -> '_tl.Message': - if isinstance(entity, _tl.InputBotInlineMessageID): - text = text or message - message = entity - elif isinstance(entity, _tl.Message): - text = message # Shift the parameters to the right - message = entity - entity = entity.peer_id - if formatting_entities is None: text, formatting_entities = await self._parse_message_text(text, parse_mode) file_handle, media, image = await self._file_to_media(file, @@ -586,9 +578,9 @@ async def edit_message( attributes=attributes, force_document=force_document) - if isinstance(entity, _tl.InputBotInlineMessageID): + if isinstance(message, _tl.InputBotInlineMessageID): request = _tl.fn.messages.EditInlineBotMessage( - id=entity, + id=message, message=text, no_webpage=not link_preview, entities=formatting_entities, diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 5683e81f..3d2360ad 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -2397,7 +2397,7 @@ class TelegramClient: async def edit_message( self: 'TelegramClient', entity: 'typing.Union[hints.EntityLike, _tl.Message]', - message: 'hints.MessageLike' = None, + message: 'hints.MessageLike', text: str = None, *, parse_mode: str = (), @@ -2519,7 +2519,7 @@ class TelegramClient: # or await client.edit_message(chat, message.id, 'hello!!') # or - await client.edit_message(message, 'hello!!!') + await message.edit('hello!!!') """ return await messages.edit_message(**locals()) diff --git a/telethon/events/callbackquery.py b/telethon/events/callbackquery.py index a100a947..0c944400 100644 --- a/telethon/events/callbackquery.py +++ b/telethon/events/callbackquery.py @@ -314,7 +314,7 @@ class CallbackQuery(EventBuilder): self._client.loop.create_task(self.answer()) if isinstance(self.query.msg_id, _tl.InputBotInlineMessageID): return await self._client.edit_message( - self.query.msg_id, *args, **kwargs + None, self.query.msg_id, *args, **kwargs ) else: return await self._client.edit_message( From 684f640b6071c0a609c9ac6f1fe1e4b3cdd14587 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 13:45:19 +0200 Subject: [PATCH 045/131] Completely overhaul sessions --- readthedocs/misc/v2-migration-guide.rst | 66 ++++ telethon/sessions/abstract.py | 169 +++------- telethon/sessions/memory.py | 239 ++------------ telethon/sessions/sqlite.py | 394 ++++++++++-------------- telethon/sessions/string.py | 45 ++- 5 files changed, 339 insertions(+), 574 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 97cf43d3..f5e5fe90 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -73,6 +73,72 @@ removed. This implies: // TODO provide standalone alternative for this? +Complete overhaul of session files +---------------------------------- + +If you were using third-party libraries to deal with sessions, you will need to wait for those to +be updated. The library will automatically upgrade the SQLite session files to the new version, +and the ``StringSession`` remains backward-compatible. The sessions can now be async. + +In case you were relying on the tables used by SQLite (even though these should have been, and +will still need to be, treated as an implementation detail), here are the changes: + +* The ``sessions`` table is now correctly split into ``datacenter`` and ``session``. + ``datacenter`` contains information about a Telegram datacenter, along with its corresponding + authorization key, and ``session`` contains information about the update state and user. +* The ``entities`` table is now called ``entity`` and stores the ``type`` separatedly. +* The ``update_state`` table is now split into ``session`` and ``channel``, which can contain + a per-channel ``pts``. + +Because **the new version does not cache usernames, phone numbers and display names**, using these +in method calls is now quite expensive. You *should* migrate your code to do the Right Thing and +start using identifiers rather than usernames, phone numbers or invite links. This is both simpler +and more reliable, because while a user identifier won't change, their username could. + +You can use the following snippet to make a JSON backup (alternatively, you could just copy the +``.session`` file and keep it around) in case you want to preserve the cached usernames: + +.. code-block:: python + + import sqlite, json + with sqlite3.connect('your.session') as conn, open('entities.json', 'w', encoding='utf-8') as fp: + json.dump([ + {'id': id, 'hash': hash, 'username': username, 'phone': phone, 'name': name, 'date': date} + for (id, hash, username, phone, name, date) + in conn.execute('select id, hash, username, phone, name, date from entities') + ], fp) + +The following public methods or properties have also been removed from ``SQLiteSession`` because +they no longer make sense: + +* ``list_sessions``. You can ``glob.glob('*.session')`` instead. +* ``clone``. + +And the following, which were inherited from ``MemorySession``: + +* ``delete``. You can ``os.remove`` the file instead (preferably after ``client.log_out()``). +* ``set_dc``. +* ``dc_id``. +* ``server_address``. +* ``port``. +* ``auth_key``. +* ``takeout_id``. +* ``get_update_state``. +* ``set_update_state``. +* ``process_entities``. +* ``get_entity_rows_by_phone``. +* ``get_entity_rows_by_username``. +* ``get_entity_rows_by_name``. +* ``get_entity_rows_by_id``. +* ``get_input_entity``. +* ``cache_file``. +* ``get_file``. + +You also can no longer set ``client.session.save_entities = False``. The entities must be saved +for the library to work properly. If you still don't want it, you should subclass the session and +override the methods to do nothing. + + The "iter" variant of the client methods have been removed ---------------------------------------------------------- diff --git a/telethon/sessions/abstract.py b/telethon/sessions/abstract.py index 5fda1c18..4cdc9131 100644 --- a/telethon/sessions/abstract.py +++ b/telethon/sessions/abstract.py @@ -1,167 +1,90 @@ +from .types import DataCenter, ChannelState, SessionState, Entity + from abc import ABC, abstractmethod +from typing import List, Optional class Session(ABC): - def __init__(self): - pass - - def clone(self, to_instance=None): - """ - Creates a clone of this session file. - """ - return to_instance or self.__class__() - @abstractmethod - def set_dc(self, dc_id, server_address, port): + async def insert_dc(self, dc: DataCenter): """ - Sets the information of the data center address and port that - the library should connect to, as well as the data center ID, - which is currently unused. - """ - raise NotImplementedError - - @property - @abstractmethod - def dc_id(self): - """ - Returns the currently-used data center ID. - """ - raise NotImplementedError - - @property - @abstractmethod - def server_address(self): - """ - Returns the server address where the library should connect to. - """ - raise NotImplementedError - - @property - @abstractmethod - def port(self): - """ - Returns the port to which the library should connect to. - """ - raise NotImplementedError - - @property - @abstractmethod - def auth_key(self): - """ - Returns an ``AuthKey`` instance associated with the saved - data center, or `None` if a new one should be generated. - """ - raise NotImplementedError - - @auth_key.setter - @abstractmethod - def auth_key(self, value): - """ - Sets the ``AuthKey`` to be used for the saved data center. - """ - raise NotImplementedError - - @property - @abstractmethod - def takeout_id(self): - """ - Returns an ID of the takeout process initialized for this session, - or `None` if there's no were any unfinished takeout requests. - """ - raise NotImplementedError - - @takeout_id.setter - @abstractmethod - def takeout_id(self, value): - """ - Sets the ID of the unfinished takeout process for this session. + Store a new or update an existing `DataCenter` with matching ``id``. """ raise NotImplementedError @abstractmethod - def get_update_state(self, entity_id): + async def get_all_dc(self) -> List[DataCenter]: """ - Returns the ``UpdateState`` associated with the given `entity_id`. - If the `entity_id` is 0, it should return the ``UpdateState`` for - no specific channel (the "general" state). If no state is known - it should ``return None``. + Get a list of all currently-stored `DataCenter`. Should not contain duplicate ``id``. """ raise NotImplementedError @abstractmethod - def set_update_state(self, entity_id, state): + async def set_state(self, state: SessionState): """ - Sets the given ``UpdateState`` for the specified `entity_id`, which - should be 0 if the ``UpdateState`` is the "general" state (and not - for any specific channel). + Set the state about the current session. """ raise NotImplementedError @abstractmethod - def close(self): + async def get_state(self) -> Optional[SessionState]: """ - Called on client disconnection. Should be used to - free any used resources. Can be left empty if none. - """ - - @abstractmethod - def save(self): - """ - Called whenever important properties change. It should - make persist the relevant session information to disk. + Get the state about the current session. """ raise NotImplementedError @abstractmethod - def delete(self): + async def insert_channel_state(self, state: ChannelState): """ - Called upon client.log_out(). Should delete the stored - information from disk since it's not valid anymore. - """ - raise NotImplementedError - - @classmethod - def list_sessions(cls): - """ - Lists available sessions. Not used by the library itself. - """ - return [] - - @abstractmethod - def process_entities(self, tlo): - """ - Processes the input ``TLObject`` or ``list`` and saves - whatever information is relevant (e.g., ID or access hash). + Store a new or update an existing `ChannelState` with matching ``id``. """ raise NotImplementedError @abstractmethod - def get_input_entity(self, key): + async def get_all_channel_states(self) -> List[ChannelState]: """ - Turns the given key into an ``InputPeer`` (e.g. ``InputPeerUser``). - The library uses this method whenever an ``InputPeer`` is needed - to suit several purposes (e.g. user only provided its ID or wishes - to use a cached username to avoid extra RPC). + Get a list of all currently-stored `ChannelState`. Should not contain duplicate ``id``. """ raise NotImplementedError @abstractmethod - def cache_file(self, md5_digest, file_size, instance): + async def insert_entities(self, entities: List[Entity]): """ - Caches the given file information persistently, so that it - doesn't need to be re-uploaded in case the file is used again. + Store new or update existing `Entity` with matching ``id``. - The ``instance`` will be either an ``InputPhoto`` or ``InputDocument``, - both with an ``.id`` and ``.access_hash`` attributes. + Entities should be saved on a best-effort. It is okay to not save them, although the + library may need to do extra work if a previously-saved entity is missing, or even be + unable to continue without the entity. """ raise NotImplementedError @abstractmethod - def get_file(self, md5_digest, file_size, cls): + async def get_entity(self, ty: int, id: int) -> Optional[Entity]: """ - Returns an instance of ``cls`` if the ``md5_digest`` and ``file_size`` - match an existing saved record. The class will either be an - ``InputPhoto`` or ``InputDocument``, both with two parameters - ``id`` and ``access_hash`` in that order. + Get the `Entity` with matching ``ty`` and ``id``. + + The following groups of ``ty`` should be treated to be equivalent, that is, for a given + ``ty`` and ``id``, if the ``ty`` is in a given group, a matching ``access_hash`` with + that ``id`` from within any ``ty`` in that group should be returned. + + * ``'U'`` and ``'B'`` (user and bot). + * ``'G'`` (small group chat). + * ``'C'``, ``'M'`` and ``'E'`` (broadcast channel, megagroup channel, and gigagroup channel). + + For example, if a ``ty`` representing a bot is stored but the asking ``ty`` is a user, + the corresponding ``access_hash`` should still be returned. + + You may use `types.canonical_entity_type` to find out the canonical type. + """ + raise NotImplementedError + + @abstractmethod + async def save(self): + """ + Save the session. + + May do nothing if the other methods already saved when they were called. + + May return custom data when manual saving is intended. """ raise NotImplementedError diff --git a/telethon/sessions/memory.py b/telethon/sessions/memory.py index 5da811b2..67602ec9 100644 --- a/telethon/sessions/memory.py +++ b/telethon/sessions/memory.py @@ -1,230 +1,47 @@ -from enum import Enum - +from .types import DataCenter, ChannelState, SessionState, Entity from .abstract import Session from .._misc import utils, tlobject from .. import _tl - -class _SentFileType(Enum): - DOCUMENT = 0 - PHOTO = 1 - - @staticmethod - def from_type(cls): - if cls == _tl.InputDocument: - return _SentFileType.DOCUMENT - elif cls == _tl.InputPhoto: - return _SentFileType.PHOTO - else: - raise ValueError('The cls must be either InputDocument/InputPhoto') +from typing import List, Optional class MemorySession(Session): + __slots__ = ('dcs', 'state', 'channel_states', 'entities') + def __init__(self): - super().__init__() + self.dcs = {} + self.state = None + self.channel_states = {} + self.entities = {} - self._dc_id = 0 - self._server_address = None - self._port = None - self._auth_key = None - self._takeout_id = None + async def insert_dc(self, dc: DataCenter): + self.dcs[dc.id] = dc - self._files = {} - self._entities = set() - self._update_states = {} + async def get_all_dc(self) -> List[DataCenter]: + return list(self.dcs.values()) - def set_dc(self, dc_id, server_address, port): - self._dc_id = dc_id or 0 - self._server_address = server_address - self._port = port + async def set_state(self, state: SessionState): + self.state = state - @property - def dc_id(self): - return self._dc_id + async def get_state(self) -> Optional[SessionState]: + return self.state - @property - def server_address(self): - return self._server_address + async def insert_channel_state(self, state: ChannelState): + self.channel_states[state.channel_id] = state - @property - def port(self): - return self._port + async def get_all_channel_states(self) -> List[ChannelState]: + return list(self.channel_states.values()) - @property - def auth_key(self): - return self._auth_key + async def insert_entities(self, entities: List[Entity]): + self.entities.update((e.id, (e.ty, e.access_hash)) for e in entities) - @auth_key.setter - def auth_key(self, value): - self._auth_key = value - - @property - def takeout_id(self): - return self._takeout_id - - @takeout_id.setter - def takeout_id(self, value): - self._takeout_id = value - - def get_update_state(self, entity_id): - return self._update_states.get(entity_id, None) - - def set_update_state(self, entity_id, state): - self._update_states[entity_id] = state - - def close(self): - pass - - def save(self): - pass - - def delete(self): - pass - - @staticmethod - def _entity_values_to_row(id, hash, username, phone, name): - # While this is a simple implementation it might be overrode by, - # other classes so they don't need to implement the plural form - # of the method. Don't remove. - return id, hash, username, phone, name - - def _entity_to_row(self, e): - if not isinstance(e, tlobject.TLObject): - return + async def get_entity(self, ty: int, id: int) -> Optional[Entity]: try: - p = utils.get_input_peer(e, allow_self=False) - marked_id = utils.get_peer_id(p) - except TypeError: - # Note: `get_input_peer` already checks for non-zero `access_hash`. - # See issues #354 and #392. It also checks that the entity - # is not `min`, because its `access_hash` cannot be used - # anywhere (since layer 102, there are two access hashes). - return - - if isinstance(p, (_tl.InputPeerUser, _tl.InputPeerChannel)): - p_hash = p.access_hash - elif isinstance(p, _tl.InputPeerChat): - p_hash = 0 - else: - return - - username = getattr(e, 'username', None) or None - if username is not None: - username = username.lower() - phone = getattr(e, 'phone', None) - name = utils.get_display_name(e) or None - return self._entity_values_to_row( - marked_id, p_hash, username, phone, name - ) - - def _entities_to_rows(self, tlo): - if not isinstance(tlo, tlobject.TLObject) and utils.is_list_like(tlo): - # This may be a list of users already for instance - entities = tlo - else: - entities = [] - if hasattr(tlo, 'user'): - entities.append(tlo.user) - if hasattr(tlo, 'chat'): - entities.append(tlo.chat) - if hasattr(tlo, 'chats') and utils.is_list_like(tlo.chats): - entities.extend(tlo.chats) - if hasattr(tlo, 'users') and utils.is_list_like(tlo.users): - entities.extend(tlo.users) - - rows = [] # Rows to add (id, hash, username, phone, name) - for e in entities: - row = self._entity_to_row(e) - if row: - rows.append(row) - return rows - - def process_entities(self, tlo): - self._entities |= set(self._entities_to_rows(tlo)) - - def get_entity_rows_by_phone(self, phone): - try: - return next((id, hash) for id, hash, _, found_phone, _ - in self._entities if found_phone == phone) - except StopIteration: - pass - - def get_entity_rows_by_username(self, username): - try: - return next((id, hash) for id, hash, found_username, _, _ - in self._entities if found_username == username) - except StopIteration: - pass - - def get_entity_rows_by_name(self, name): - try: - return next((id, hash) for id, hash, _, _, found_name - in self._entities if found_name == name) - except StopIteration: - pass - - def get_entity_rows_by_id(self, id, exact=True): - try: - return next((id, hash) for found_id, hash, _, _, _ - in self._entities if found_id == id) - except StopIteration: - pass - - def get_input_entity(self, key): - try: - if key.SUBCLASS_OF_ID in (0xc91c90b6, 0xe669bf46, 0x40f202fd): - # hex(crc32(b'InputPeer', b'InputUser' and b'InputChannel')) - # We already have an Input version, so nothing else required - return key - # Try to early return if this key can be casted as input peer - return utils.get_input_peer(key) - except (AttributeError, TypeError): - # Not a TLObject or can't be cast into InputPeer - if isinstance(key, tlobject.TLObject): - key = utils.get_peer_id(key) - exact = True - else: - exact = not isinstance(key, int) or key < 0 - - result = None - if isinstance(key, str): - phone = utils.parse_phone(key) - if phone: - result = self.get_entity_rows_by_phone(phone) - else: - username, invite = utils.parse_username(key) - if username and not invite: - result = self.get_entity_rows_by_username(username) - - elif isinstance(key, int): - result = self.get_entity_rows_by_id(key, exact) - - if not result and isinstance(key, str): - result = self.get_entity_rows_by_name(key) - - if result: - entity_id, entity_hash = result # unpack resulting tuple - entity_id, kind = utils.resolve_id(entity_id) - # removes the mark and returns type of entity - if kind == _tl.PeerUser: - return _tl.InputPeerUser(entity_id, entity_hash) - elif kind == _tl.PeerChat: - return _tl.InputPeerChat(entity_id) - elif kind == _tl.PeerChannel: - return _tl.InputPeerChannel(entity_id, entity_hash) - else: - raise ValueError('Could not find input entity with key ', key) - - def cache_file(self, md5_digest, file_size, instance): - if not isinstance(instance, (_tl.InputDocument, _tl.InputPhoto)): - raise TypeError('Cannot cache %s instance' % type(instance)) - key = (md5_digest, file_size, _SentFileType.from_type(type(instance))) - value = (instance.id, instance.access_hash) - self._files[key] = value - - def get_file(self, md5_digest, file_size, cls): - key = (md5_digest, file_size, _SentFileType.from_type(cls)) - try: - return cls(*self._files[key]) + ty, access_hash = self.entities[id] + return Entity(ty, id, access_hash) except KeyError: return None + + async def save(self): + pass diff --git a/telethon/sessions/sqlite.py b/telethon/sessions/sqlite.py index 15fe5eaf..5cd288aa 100644 --- a/telethon/sessions/sqlite.py +++ b/telethon/sessions/sqlite.py @@ -1,11 +1,13 @@ import datetime import os import time +import ipaddress +from typing import Optional, List -from .memory import MemorySession, _SentFileType +from .abstract import Session from .._misc import utils from .. import _tl -from .._crypto import AuthKey +from .types import DataCenter, ChannelState, SessionState, Entity try: import sqlite3 @@ -15,16 +17,17 @@ except ImportError as e: sqlite3_err = type(e) EXTENSION = '.session' -CURRENT_VERSION = 7 # database version +CURRENT_VERSION = 8 # database version -class SQLiteSession(MemorySession): - """This session contains the required information to login into your - Telegram account. NEVER give the saved session file to anyone, since - they would gain instant access to all your messages and contacts. +class SQLiteSession(Session): + """ + This session contains the required information to login into your + Telegram account. NEVER give the saved session file to anyone, since + they would gain instant access to all your messages and contacts. - If you think the session has been compromised, close all the sessions - through an official Telegram client to revoke the authorization. + If you think the session has been compromised, close all the sessions + through an official Telegram client to revoke the authorization. """ def __init__(self, session_id=None): @@ -53,66 +56,13 @@ class SQLiteSession(MemorySession): c.execute("delete from version") c.execute("insert into version values (?)", (CURRENT_VERSION,)) self.save() - - # These values will be saved - c.execute('select * from sessions') - tuple_ = c.fetchone() - if tuple_: - self._dc_id, self._server_address, self._port, key, \ - self._takeout_id = tuple_ - self._auth_key = AuthKey(data=key) - - c.close() else: # Tables don't exist, create new ones - self._create_table( - c, - "version (version integer primary key)" - , - """sessions ( - dc_id integer primary key, - server_address text, - port integer, - auth_key blob, - takeout_id integer - )""" - , - """entities ( - id integer primary key, - hash integer not null, - username text, - phone integer, - name text, - date integer - )""" - , - """sent_files ( - md5_digest blob, - file_size integer, - type integer, - id integer, - hash integer, - primary key(md5_digest, file_size, type) - )""" - , - """update_state ( - id integer primary key, - pts integer, - qts integer, - date integer, - seq integer - )""" - ) + self._mk_tables(c) c.execute("insert into version values (?)", (CURRENT_VERSION,)) - self._update_session_table() c.close() self.save() - def clone(self, to_instance=None): - cloned = super().clone(to_instance) - cloned.save_entities = self.save_entities - return cloned - def _upgrade_database(self, old): c = self._cursor() if old == 1: @@ -150,75 +100,164 @@ class SQLiteSession(MemorySession): if old == 6: old += 1 c.execute("alter table entities add column date integer") + if old == 7: + self._mk_tables(c) + c.execute(''' + insert into datacenter (id, ip, port, auth) + select dc_id, server_address, port, auth_key + from sessions + ''') + c.execute(''' + insert into session (user_id, dc_id, bot, pts, qts, date, seq, takeout_id) + select + 0, + s.dc_id, + 0, + coalesce(u.pts, 0), + coalesce(u.qts, 0), + coalesce(u.date, 0), + coalesce(u.seq, 0), + s.takeout_id + from sessions s + left join update_state u on u.id = 0 + limit 1 + ''') + c.execute(''' + insert into entity (id, access_hash, ty) + select + case + when id < -1000000000000 then -(id + 1000000000000) + when id < 0 then -id + else id + end, + hash, + case + when id < -1000000000000 then 67 + when id < 0 then 71 + else 85 + end + from entities + ''') + c.execute('drop table sessions') + c.execute('drop table entities') + c.execute('drop table sent_files') + c.execute('drop table update_state') - c.close() + def _mk_tables(self, c): + self._create_table( + c, + '''version ( + version integer primary key + )''', + '''datacenter ( + id integer primary key, + ip text not null, + port integer not null, + auth blob not null + )''', + '''session ( + user_id integer primary key, + dc_id integer not null, + bot integer not null, + pts integer not null, + qts integer not null, + date integer not null, + seq integer not null, + takeout_id integer + )''', + '''channel ( + channel_id integer primary key, + pts integer not null + )''', + '''entity ( + id integer primary key, + access_hash integer not null, + ty integer not null + )''', + ) + + async def insert_dc(self, dc: DataCenter): + self._execute( + 'insert or replace into datacenter values (?,?,?,?)', + dc.id, + str(ipaddress.ip_address(dc.ipv6 or dc.ipv4)), + dc.port, + dc.auth + ) + + async def get_all_dc(self) -> List[DataCenter]: + c = self._cursor() + res = [] + for (id, ip, port, auth) in c.execute('select * from datacenter'): + ip = ipaddress.ip_address(ip) + res.append(DataCenter( + id=id, + ipv4=int(ip) if ip.version == 4 else None, + ipv6=int(ip) if ip.version == 6 else None, + port=port, + auth=auth, + )) + return res + + async def set_state(self, state: SessionState): + self._execute( + 'insert or replace into session values (?,?,?,?,?,?,?,?)', + state.user_id, + state.dc_id, + int(state.bot), + state.pts, + state.qts, + state.date, + state.seq, + state.takeout_id, + ) + + async def get_state(self) -> Optional[SessionState]: + row = self._execute('select * from session') + return SessionState(*row) if row else None + + async def insert_channel_state(self, state: ChannelState): + self._execute( + 'insert or replace into channel values (?,?)', + state.channel_id, + state.pts, + ) + + async def get_all_channel_states(self) -> List[ChannelState]: + c = self._cursor() + try: + return [ + ChannelState(*row) + for row in c.execute('select * from channel') + ] + finally: + c.close() + + async def insert_entities(self, entities: List[Entity]): + c = self._cursor() + try: + c.executemany( + 'insert or replace into entity values (?,?,?)', + [(e.id, e.access_hash, e.ty) for e in entities] + ) + finally: + c.close() + + async def get_entity(self, ty: int, id: int) -> Optional[Entity]: + row = self._execute('select ty, id, access_hash from entity where id = ?', id) + return Entity(*row) if row else None + + async def save(self): + # This is a no-op if there are no changes to commit, so there's + # no need for us to keep track of an "unsaved changes" variable. + if self._conn is not None: + self._conn.commit() @staticmethod def _create_table(c, *definitions): for definition in definitions: c.execute('create table {}'.format(definition)) - # Data from sessions should be kept as properties - # not to fetch the database every time we need it - def set_dc(self, dc_id, server_address, port): - super().set_dc(dc_id, server_address, port) - self._update_session_table() - - # Fetch the auth_key corresponding to this data center - row = self._execute('select auth_key from sessions') - if row and row[0]: - self._auth_key = AuthKey(data=row[0]) - else: - self._auth_key = None - - @MemorySession.auth_key.setter - def auth_key(self, value): - self._auth_key = value - self._update_session_table() - - @MemorySession.takeout_id.setter - def takeout_id(self, value): - self._takeout_id = value - self._update_session_table() - - def _update_session_table(self): - c = self._cursor() - # While we can save multiple rows into the sessions table - # currently we only want to keep ONE as the tables don't - # tell us which auth_key's are usable and will work. Needs - # some more work before being able to save auth_key's for - # multiple DCs. Probably done differently. - c.execute('delete from sessions') - c.execute('insert or replace into sessions values (?,?,?,?,?)', ( - self._dc_id, - self._server_address, - self._port, - self._auth_key.key if self._auth_key else b'', - self._takeout_id - )) - c.close() - - def get_update_state(self, entity_id): - row = self._execute('select pts, qts, date, seq from update_state ' - 'where id = ?', entity_id) - if row: - pts, qts, date, seq = row - date = datetime.datetime.fromtimestamp( - date, tz=datetime.timezone.utc) - return _tl.updates.State(pts, qts, date, seq, unread_count=0) - - def set_update_state(self, entity_id, state): - self._execute('insert or replace into update_state values (?,?,?,?,?)', - entity_id, state.pts, state.qts, - state.date.timestamp(), state.seq) - - def save(self): - """Saves the current session object as session_user_id.session""" - # This is a no-op if there are no changes to commit, so there's - # no need for us to keep track of an "unsaved changes" variable. - if self._conn is not None: - self._conn.commit() - def _cursor(self): """Asserts that the connection is open and returns a cursor""" if self._conn is None: @@ -236,108 +275,3 @@ class SQLiteSession(MemorySession): return c.execute(stmt, values).fetchone() finally: c.close() - - def close(self): - """Closes the connection unless we're working in-memory""" - if self.filename != ':memory:': - if self._conn is not None: - self._conn.commit() - self._conn.close() - self._conn = None - - def delete(self): - """Deletes the current session file""" - if self.filename == ':memory:': - return True - try: - os.remove(self.filename) - return True - except OSError: - return False - - @classmethod - def list_sessions(cls): - """Lists all the sessions of the users who have ever connected - using this client and never logged out - """ - return [os.path.splitext(os.path.basename(f))[0] - for f in os.listdir('.') if f.endswith(EXTENSION)] - - # Entity processing - - def process_entities(self, tlo): - """ - Processes all the found entities on the given TLObject, - unless .save_entities is False. - """ - if not self.save_entities: - return - - rows = self._entities_to_rows(tlo) - if not rows: - return - - c = self._cursor() - try: - now_tup = (int(time.time()),) - rows = [row + now_tup for row in rows] - c.executemany( - 'insert or replace into entities values (?,?,?,?,?,?)', rows) - finally: - c.close() - - def get_entity_rows_by_phone(self, phone): - return self._execute( - 'select id, hash from entities where phone = ?', phone) - - def get_entity_rows_by_username(self, username): - c = self._cursor() - try: - results = c.execute( - 'select id, hash, date from entities where username = ?', - (username,) - ).fetchall() - - if not results: - return None - - # If there is more than one result for the same username, evict the oldest one - if len(results) > 1: - results.sort(key=lambda t: t[2] or 0) - c.executemany('update entities set username = null where id = ?', - [(t[0],) for t in results[:-1]]) - - return results[-1][0], results[-1][1] - finally: - c.close() - - def get_entity_rows_by_name(self, name): - return self._execute( - 'select id, hash from entities where name = ?', name) - - def get_entity_rows_by_id(self, id, exact=True): - return self._execute( - 'select id, hash from entities where id = ?', id) - - # File processing - - def get_file(self, md5_digest, file_size, cls): - row = self._execute( - 'select id, hash from sent_files ' - 'where md5_digest = ? and file_size = ? and type = ?', - md5_digest, file_size, _SentFileType.from_type(cls).value - ) - if row: - # Both allowed classes have (id, access_hash) as parameters - return cls(row[0], row[1]) - - def cache_file(self, md5_digest, file_size, instance): - if not isinstance(instance, (_tl.InputDocument, _tl.InputPhoto)): - raise TypeError('Cannot cache %s instance' % type(instance)) - - self._execute( - 'insert or replace into sent_files values (?,?,?,?,?)', - md5_digest, file_size, - _SentFileType.from_type(type(instance)).value, - instance.id, instance.access_hash - ) diff --git a/telethon/sessions/string.py b/telethon/sessions/string.py index 72617f24..2cb66aa6 100644 --- a/telethon/sessions/string.py +++ b/telethon/sessions/string.py @@ -4,7 +4,7 @@ import struct from .abstract import Session from .memory import MemorySession -from .._crypto import AuthKey +from .types import DataCenter, ChannelState, SessionState, Entity _STRUCT_PREFORMAT = '>B{}sH256s' @@ -34,12 +34,33 @@ class StringSession(MemorySession): string = string[1:] ip_len = 4 if len(string) == 352 else 16 - self._dc_id, ip, self._port, key = struct.unpack( + dc_id, ip, port, key = struct.unpack( _STRUCT_PREFORMAT.format(ip_len), StringSession.decode(string)) - self._server_address = ipaddress.ip_address(ip).compressed - if any(key): - self._auth_key = AuthKey(key) + self.state = SessionState( + dc_id=dc_id, + user_id=0, + bot=False, + pts=0, + qts=0, + date=0, + seq=0, + takeout_id=0 + ) + if ip_len == 4: + ipv4 = int.from_bytes(ip, 'big', False) + ipv6 = None + else: + ipv4 = None + ipv6 = int.from_bytes(ip, 'big', signed=False) + + self.dcs[dc_id] = DataCenter( + id=dc_id, + ipv4=ipv4, + ipv6=ipv6, + port=port, + auth=key + ) @staticmethod def encode(x: bytes) -> str: @@ -50,14 +71,18 @@ class StringSession(MemorySession): return base64.urlsafe_b64decode(x) def save(self: Session): - if not self.auth_key: + if not self.state: return '' - ip = ipaddress.ip_address(self.server_address).packed + if self.state.ipv6 is not None: + ip = self.state.ipv6.to_bytes(16, 'big', signed=False) + else: + ip = self.state.ipv6.to_bytes(4, 'big', signed=False) + return CURRENT_VERSION + StringSession.encode(struct.pack( _STRUCT_PREFORMAT.format(len(ip)), - self.dc_id, + self.state.dc_id, ip, - self.port, - self.auth_key.key + self.state.port, + self.dcs[self.state.dc_id].auth )) From 29d3c3fd7c26beb743463dd431237d8b01542d30 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 15:56:28 +0200 Subject: [PATCH 046/131] Fix outdated LAYER usage in _create_exported_sender --- telethon/_client/telegrambaseclient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index c8023429..f9541e44 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -480,7 +480,7 @@ async def _create_exported_sender(self: 'TelegramClient', dc_id): self._log[__name__].info('Exporting auth for new borrowed sender in %s', dc) auth = await self(_tl.fn.auth.ExportAuthorization(dc_id)) self._init_request.query = _tl.fn.auth.ImportAuthorization(id=auth.id, bytes=auth.bytes) - req = _tl.fn.InvokeWithLayer(LAYER, self._init_request) + req = _tl.fn.InvokeWithLayer(_tl.LAYER, self._init_request) await sender.send(req) return sender From 1f5722c925441200fa6b52a91d3487a5cb38cdf8 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 16:37:53 +0200 Subject: [PATCH 047/131] Add missing session/types file --- telethon/sessions/types.py | 156 +++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 telethon/sessions/types.py diff --git a/telethon/sessions/types.py b/telethon/sessions/types.py new file mode 100644 index 00000000..39033a1f --- /dev/null +++ b/telethon/sessions/types.py @@ -0,0 +1,156 @@ +from typing import Optional, Tuple + + +class DataCenter: + """ + Stores the information needed to connect to a datacenter. + + * id: 32-bit number representing the datacenter identifier as given by Telegram. + * ipv4 and ipv6: 32-bit or 128-bit number storing the IP address of the datacenter. + * port: 16-bit number storing the port number needed to connect to the datacenter. + * bytes: arbitrary binary payload needed to authenticate to the datacenter. + """ + __slots__ = ('id', 'ipv4', 'ipv6', 'port', 'auth') + + def __init__( + self, + id: int, + ipv4: Optional[int], + ipv6: Optional[int], + port: int, + auth: bytes + ): + self.id = id + self.ipv4 = ipv4 + self.ipv6 = ipv6 + self.port = port + self.auth = auth + + +class SessionState: + """ + Stores the information needed to fetch updates and about the current user. + + * user_id: 64-bit number representing the user identifier. + * dc_id: 32-bit number relating to the datacenter identifier where the user is. + * bot: is the logged-in user a bot? + * pts: 64-bit number holding the state needed to fetch updates. + * qts: alternative 64-bit number holding the state needed to fetch updates. + * date: 64-bit number holding the date needed to fetch updates. + * seq: 64-bit-number holding the sequence number needed to fetch updates. + * takeout_id: 64-bit-number holding the identifier of the current takeout session. + + Note that some of the numbers will only use 32 out of the 64 available bits. + However, for future-proofing reasons, we recommend you pretend they are 64-bit long. + """ + __slots__ = ('user_id', 'dc_id', 'bot', 'pts', 'qts', 'date', 'seq', 'takeout_id') + + def __init__( + self, + user_id: int, + dc_id: int, + bot: bool, + pts: int, + qts: int, + date: int, + seq: int, + takeout_id: Optional[int], + ): + self.user_id = user_id + self.dc_id = dc_id + self.bot = bot + self.pts = pts + self.qts = qts + self.date = date + self.seq = seq + + +class ChannelState: + """ + Stores the information needed to fetch updates from a channel. + + * channel_id: 64-bit number representing the channel identifier. + * pts: 64-bit number holding the state needed to fetch updates. + """ + __slots__ = ('channel_id', 'pts') + + def __init__( + self, + channel_id: int, + pts: int + ): + self.channel_id = channel_id + self.pts = pts + + +class Entity: + """ + Stores the information needed to use a certain user, chat or channel with the API. + + * ty: 8-bit number indicating the type of the entity. + * id: 64-bit number uniquely identifying the entity among those of the same type. + * access_hash: 64-bit number needed to use this entity with the API. + + You can rely on the ``ty`` value to be equal to the ASCII character one of: + + * 'U' (85): this entity belongs to a :tl:`User` who is not a ``bot``. + * 'B' (66): this entity belongs to a :tl:`User` who is a ``bot``. + * 'G' (71): this entity belongs to a small group :tl:`Chat`. + * 'C' (67): this entity belongs to a standard broadcast :tl:`Channel`. + * 'M' (77): this entity belongs to a megagroup :tl:`Channel`. + * 'E' (69): this entity belongs to an "enormous" "gigagroup" :tl:`Channel`. + """ + __slots__ = ('ty', 'id', 'access_hash') + + USER = ord('U') + BOT = ord('B') + GROUP = ord('G') + CHANNEL = ord('C') + MEGAGROUP = ord('M') + GIGAGROUP = ord('E') + + def __init__( + self, + ty: int, + id: int, + access_hash: int + ): + self.ty = ty + self.id = id + self.access_hash = access_hash + + +def canonical_entity_type(ty: int, *, _mapping={ + Entity.USER: Entity.USER, + Entity.BOT: Entity.USER, + Entity.GROUP: Entity.GROUP, + Entity.CHANNEL: Entity.CHANNEL, + Entity.MEGAGROUP: Entity.CHANNEL, + Entity.GIGAGROUP: Entity.CHANNEL, +}) -> int: + """ + Return the canonical version of an entity type. + """ + try: + return _mapping[ty] + except KeyError: + ty = chr(ty) if isinstance(ty, int) else ty + raise ValueError(f'entity type {ty!r} is not valid') + + +def get_entity_type_group(ty: int, *, _mapping={ + Entity.USER: (Entity.USER, Entity.BOT), + Entity.BOT: (Entity.USER, Entity.BOT), + Entity.GROUP: (Entity.GROUP,), + Entity.CHANNEL: (Entity.CHANNEL, Entity.MEGAGROUP, Entity.GIGAGROUP), + Entity.MEGAGROUP: (Entity.CHANNEL, Entity.MEGAGROUP, Entity.GIGAGROUP), + Entity.GIGAGROUP: (Entity.CHANNEL, Entity.MEGAGROUP, Entity.GIGAGROUP), +}) -> Tuple[int]: + """ + Return the group where an entity type belongs to. + """ + try: + return _mapping[ty] + except KeyError: + ty = chr(ty) if isinstance(ty, int) else ty + raise ValueError(f'entity type {ty!r} is not valid') From 81b4957d9bf6d0ee69530bc6eaa0ae13b315d5a8 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 16:38:11 +0200 Subject: [PATCH 048/131] Update code to deal with the new sessions --- readthedocs/misc/v2-migration-guide.rst | 1 + telethon/_client/auth.py | 1 - telethon/_client/downloads.py | 23 ++-- telethon/_client/messages.py | 5 +- telethon/_client/telegrambaseclient.py | 134 +++++++++++++++--------- telethon/_client/telegramclient.py | 6 +- telethon/_client/updates.py | 20 ++-- telethon/_client/users.py | 31 +++--- telethon/_misc/entitycache.py | 54 ++++++++-- telethon/_network/mtprotosender.py | 17 +-- 10 files changed, 173 insertions(+), 119 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index f5e5fe90..a2c32f70 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -117,6 +117,7 @@ they no longer make sense: And the following, which were inherited from ``MemorySession``: * ``delete``. You can ``os.remove`` the file instead (preferably after ``client.log_out()``). + ``client.log_out()`` also no longer deletes the session file (it can't as there's no method). * ``set_dc``. * ``dc_id``. * ``server_address``. diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index 05dd05c3..296656f7 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -372,7 +372,6 @@ async def log_out(self: 'TelegramClient') -> bool: self._state_cache.reset() await self.disconnect() - self.session.delete() return True async def edit_2fa( diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py index 9ae1aec8..99932cdc 100644 --- a/telethon/_client/downloads.py +++ b/telethon/_client/downloads.py @@ -39,26 +39,17 @@ class _DirectDownloadIter(requestiter.RequestIter): self._msg_data = msg_data self._timed_out = False - self._exported = dc_id and self.client.session.dc_id != dc_id + # TODO should cache current session state + state = await self.client.session.get_state() + + self._exported = dc_id and state.dc_id != dc_id if not self._exported: # The used sender will also change if ``FileMigrateError`` occurs self._sender = self.client._sender else: - try: - self._sender = await self.client._borrow_exported_sender(dc_id) - except errors.DcIdInvalidError: - # Can't export a sender for the ID we are currently in - config = await self.client(_tl.fn.help.GetConfig()) - for option in config.dc_options: - if option.ip_address == self.client.session.server_address: - self.client.session.set_dc( - option.id, option.ip_address, option.port) - self.client.session.save() - break - - # TODO Figure out why the session may have the wrong DC ID - self._sender = self.client._sender - self._exported = False + # If this raises DcIdInvalidError, it means we tried exporting the same DC we're in. + # This should not happen, but if it does, it's a bug. + self._sender = await self.client._borrow_exported_sender(dc_id) async def _load_next_chunk(self): cur = await self._request() diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index e5290742..69f00a0c 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -589,7 +589,10 @@ async def edit_message( ) # Invoke `messages.editInlineBotMessage` from the right datacenter. # Otherwise, Telegram will error with `MESSAGE_ID_INVALID` and do nothing. - exported = self.session.dc_id != entity.dc_id + # TODO should cache current session state + state = await self.session.get_state() + + exported = state.dc_id != entity.dc_id if exported: try: sender = await self._borrow_exported_sender(entity.dc_id) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index f9541e44..1050c2a5 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -6,12 +6,14 @@ import logging import platform import time import typing +import ipaddress from .. import version, helpers, __name__ as __base_name__, _tl from .._crypto import rsa from .._misc import markdown, entitycache, statecache, enums from .._network import MTProtoSender, Connection, ConnectionTcpFull, connection as conns from ..sessions import Session, SQLiteSession, MemorySession +from ..sessions.types import DataCenter, SessionState DEFAULT_DC_ID = 2 DEFAULT_IPV4_IP = '149.154.167.51' @@ -129,15 +131,6 @@ def init( '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 - ) - self.flood_sleep_threshold = flood_sleep_threshold # TODO Use AsyncClassWrapper(session) @@ -230,13 +223,11 @@ def init( ) self._sender = MTProtoSender( - self.session.auth_key, loggers=self._log, retries=self._connection_retries, delay=self._retry_delay, auto_reconnect=self._auto_reconnect, connect_timeout=self._timeout, - auth_key_callback=self._auth_key_callback, update_callback=self._handle_update, auto_reconnect_callback=self._handle_auto_reconnect ) @@ -264,11 +255,6 @@ def init( self._authorized = None # None = unknown, False = no, True = yes - # Update state (for catching up after a disconnection) - # TODO Get state from channels too - self._state_cache = statecache.StateCache( - self.session.get_update_state(0), self._log) - # Some further state for subclasses self._event_builders = [] @@ -310,10 +296,33 @@ def set_flood_sleep_threshold(self, value): async def connect(self: 'TelegramClient') -> None: + all_dc = await self.session.get_all_dc() + state = await self.session.get_state() + + dc = None + if state: + for d in all_dc: + if d.id == state.dc_id: + dc = d + break + + if dc is None: + dc = DataCenter( + id=DEFAULT_DC_ID, + ipv4=None if self._use_ipv6 else int(ipaddress.ip_address(DEFAULT_IPV4_IP)), + ipv6=int(ipaddress.ip_address(DEFAULT_IPV6_IP)) if self._use_ipv6 else None, + port=DEFAULT_PORT, + auth=b'', + ) + + # Update state (for catching up after a disconnection) + # TODO Get state from channels too + self._state_cache = statecache.StateCache(state, self._log) + if not await self._sender.connect(self._connection( - self.session.server_address, - self.session.port, - self.session.dc_id, + str(ipaddress.ip_address(dc.ipv6 or dc.ipv4)), + dc.port, + dc.id, loggers=self._log, proxy=self._proxy, local_addr=self._local_addr @@ -321,8 +330,10 @@ async def connect(self: 'TelegramClient') -> None: # We don't want to init or modify anything if we were already connected return - self.session.auth_key = self._sender.auth_key - self.session.save() + if self._sender.auth_key.key != dc.key: + dc.key = self._sender.auth_key.key + await self.session.insert_dc(dc) + await self.session.save() self._init_request.query = _tl.fn.help.GetConfig() @@ -388,15 +399,12 @@ async def _disconnect_coro(self: 'TelegramClient'): pts, date = self._state_cache[None] if pts and date: - self.session.set_update_state(0, _tl.updates.State( - pts=pts, - qts=0, - date=date, - seq=0, - unread_count=0 - )) - - self.session.close() + state = await self.session.get_state() + if state: + state.pts = pts + state.date = date + await self.session.set_state(state) + await self.session.save() async def _disconnect(self: 'TelegramClient'): """ @@ -414,31 +422,59 @@ async def _switch_dc(self: 'TelegramClient', new_dc): Permanently switches the current connection to the new data center. """ self._log[__name__].info('Reconnecting to new data center %s', new_dc) - dc = await _get_dc(self, new_dc) + dc = await _refresh_and_get_dc(self, new_dc) + + state = await self.session.get_state() + if state is None: + state = SessionState( + user_id=0, + dc_id=dc.id, + bot=False, + pts=0, + qts=0, + date=0, + seq=0, + takeout_id=None, + ) + else: + state.dc_id = dc.id + + await self.session.set_state(dc.id) + await self.session.save() - 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._sender.auth_key.key = None - self.session.auth_key = None - self.session.save() await _disconnect(self) return await self.connect() -def _auth_key_callback(self: 'TelegramClient', auth_key): - """ - Callback from the sender whenever it needed to generate a - new authorization key. This means we are not authorized. - """ - self.session.auth_key = auth_key - self.session.save() +async def _refresh_and_get_dc(self: 'TelegramClient', dc_id): + """ + Gets the Data Center (DC) associated to `dc_id`. -async def _get_dc(self: 'TelegramClient', dc_id): - """Gets the Data Center (DC) associated to 'dc_id'""" + Also take this opportunity to refresh the addresses stored in the session if needed. + """ cls = self.__class__ if not cls._config: cls._config = await self(_tl.fn.help.GetConfig()) + all_dc = {dc.id: dc for dc in await self.session.get_all_dc()} + for dc in cls._config.dc_options: + if dc.media_only or dc.tcpo_only or dc.cdn: + continue + + ip = int(ipaddress.ip_address(dc.ip_address)) + if dc.id in all_dc: + all_dc[dc.id].port = dc.port + if dc.ipv6: + all_dc[dc.id].ipv6 = ip + else: + all_dc[dc.id].ipv4 = ip + elif dc.ipv6: + all_dc[dc.id] = DataCenter(dc.id, None, ip, dc.port, b'') + else: + all_dc[dc.id] = DataCenter(dc.id, ip, None, dc.port, b'') + + for dc in all_dc.values(): + await self.session.insert_dc(dc) + await self.session.save() try: return next( @@ -463,12 +499,12 @@ async def _create_exported_sender(self: 'TelegramClient', dc_id): """ # Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt # for clearly showing how to export the authorization - dc = await _get_dc(self, dc_id) + dc = await _refresh_and_get_dc(self, dc_id) # 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(None, loggers=self._log) + sender = MTProtoSender(loggers=self._log) await sender.connect(self._connection( dc.ip_address, dc.port, @@ -503,7 +539,7 @@ async def _borrow_exported_sender(self: 'TelegramClient', dc_id): self._borrowed_senders[dc_id] = (state, sender) elif state.need_connect(): - dc = await _get_dc(self, dc_id) + dc = await _refresh_and_get_dc(self, dc_id) await sender.connect(self._connection( dc.ip_address, dc.port, diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 3d2360ad..e5dc6f8f 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -206,8 +206,7 @@ class TelegramClient: it's `True` then the takeout will be finished, and if no exception occurred during it, then `True` will be considered as a result. Otherwise, the takeout will not be finished and its ID will be - preserved for future usage as `client.session.takeout_id - `. + preserved for future usage in the session. Arguments finalize (`bool`): @@ -3599,9 +3598,6 @@ class TelegramClient: async def _clean_exported_senders(self: 'TelegramClient'): return await telegrambaseclient._clean_exported_senders(**locals()) - def _auth_key_callback(self: 'TelegramClient', auth_key): - return telegrambaseclient._auth_key_callback - def _handle_update(self: 'TelegramClient', update): return updates._handle_update(**locals()) diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index b319f8e2..0ae8b299 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -79,7 +79,7 @@ async def catch_up(self: 'TelegramClient'): if not pts: return - self.session.catching_up = True + self._catching_up = True try: while True: d = await self(_tl.fn.updates.GetDifference( @@ -129,16 +129,13 @@ async def catch_up(self: 'TelegramClient'): finally: # TODO Save new pts to session self._state_cache._pts_date = (pts, date) - self.session.catching_up = False + self._catching_up = False # It is important to not make _handle_update async because we rely on # the order that the updates arrive in to update the pts and date to # be always-increasing. There is also no need to make this async. def _handle_update(self: 'TelegramClient', update): - self.session.process_entities(update) - self._entity_cache.add(update) - if isinstance(update, (_tl.Updates, _tl.UpdatesCombined)): entities = {utils.get_peer_id(x): x for x in itertools.chain(update.users, update.chats)} @@ -203,11 +200,10 @@ async def _update_loop(self: 'TelegramClient'): except (ConnectionError, asyncio.CancelledError): return - # Entities and cached files are not saved when they are - # inserted because this is a rather expensive operation - # (default's sqlite3 takes ~0.1s to commit changes). Do - # it every minute instead. No-op if there's nothing new. - self.session.save() + # Entities are not saved when they are inserted because this is a rather expensive + # operation (default's sqlite3 takes ~0.1s to commit changes). Do it every minute + # instead. No-op if there's nothing new. + await self.session.save() # We need to send some content-related request at least hourly # for Telegram to keep delivering updates, otherwise they will @@ -232,6 +228,10 @@ async def _dispatch_queue_updates(self: 'TelegramClient'): self._dispatching_updates_queue.clear() async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, pts_date): + entities = self._entity_cache.add(list((update._entities or {}).values())) + if entities: + await self.session.insert_entities(entities) + if not self._entity_cache.ensure_cached(update): # We could add a lock to not fetch the same pts twice if we are # already fetching it. However this does not happen in practice, diff --git a/telethon/_client/users.py b/telethon/_client/users.py index 72786fe5..394baee9 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -7,6 +7,7 @@ import typing from .. import errors, hints, _tl from .._misc import helpers, utils from ..errors import MultiError, RPCError +from ..sessions.types import Entity _NOT_A_REQUEST = lambda: TypeError('You can only invoke requests, not types!') @@ -70,8 +71,9 @@ async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sl exceptions.append(e) results.append(None) continue - self.session.process_entities(result) - self._entity_cache.add(result) + entities = self._entity_cache.add(result) + if entities: + await self.session.insert_entities(entities) exceptions.append(None) results.append(result) request_index += 1 @@ -81,8 +83,9 @@ async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sl return results else: result = await future - self.session.process_entities(result) - self._entity_cache.add(result) + entities = self._entity_cache.add(result) + if entities: + await self.session.insert_entities(entities) return result except (errors.ServerError, errors.RpcCallFailError, errors.RpcMcgetFailError, errors.InterdcCallErrorError, @@ -266,9 +269,19 @@ async def get_input_entity( if peer in ('me', 'self'): return _tl.InputPeerSelf() - # No InputPeer, cached peer, or known string. Fetch from disk cache + # No InputPeer, cached peer, or known string. Fetch from session cache try: - return self.session.get_input_entity(peer) + peer = utils.get_peer(peer) + if isinstance(peer, _tl.PeerUser): + entity = await self.session.get_entity(Entity.USER, peer.user_id) + if entity: + return _tl.InputPeerUser(entity.id, entity.access_hash) + elif isinstance(peer, _tl.PeerChat): + return _tl.InputPeerChat(peer.chat_id) + elif isinstance(peer, _tl.PeerChannel): + entity = await self.session.get_entity(Entity.CHANNEL, peer.user_id) + if entity: + return _tl.InputPeerChannel(entity.id, entity.access_hash) except ValueError: pass @@ -387,12 +400,6 @@ async def _get_entity_from_string(self: 'TelegramClient', string): return next(x for x in result.chats if x.id == pid) except StopIteration: pass - 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) diff --git a/telethon/_misc/entitycache.py b/telethon/_misc/entitycache.py index a191dc6f..685aa411 100644 --- a/telethon/_misc/entitycache.py +++ b/telethon/_misc/entitycache.py @@ -3,6 +3,7 @@ import itertools from .._misc import utils from .. import _tl +from ..sessions.types import Entity # Which updates have the following fields? _has_field = { @@ -51,27 +52,60 @@ class EntityCache: """ In-memory input entity cache, defaultdict-like behaviour. """ - def add(self, entities): + def add(self, entities, _mappings={ + _tl.User.CONSTRUCTOR_ID: lambda e: (Entity.BOT if e.bot else Entity.USER, e.id, e.access_hash), + _tl.UserFull.CONSTRUCTOR_ID: lambda e: (Entity.BOT if e.user.bot else Entity.USER, e.user.id, e.user.access_hash), + _tl.Chat.CONSTRUCTOR_ID: lambda e: (Entity.GROUP, e.id, 0), + _tl.ChatFull.CONSTRUCTOR_ID: lambda e: (Entity.GROUP, e.id, 0), + _tl.ChatEmpty.CONSTRUCTOR_ID: lambda e: (Entity.GROUP, e.id, 0), + _tl.ChatForbidden.CONSTRUCTOR_ID: lambda e: (Entity.GROUP, e.id, 0), + _tl.Channel.CONSTRUCTOR_ID: lambda e: ( + Entity.MEGAGROUP if e.megagroup else (Entity.GIGAGROUP if e.gigagroup else Entity.CHANNEL), + e.id, + e.access_hash, + ), + _tl.ChannelForbidden.CONSTRUCTOR_ID: lambda e: (Entity.MEGAGROUP if e.megagroup else Entity.CHANNEL, e.id, e.access_hash), + }): """ Adds the given entities to the cache, if they weren't saved before. + + Returns a list of Entity that can be saved in the session. """ if not utils.is_list_like(entities): # Invariant: all "chats" and "users" are always iterables, - # and "user" never is (so we wrap it inside a list). + # and "user" and "chat" never are (so we wrap them inside a list). + # + # Itself may be already the entity we want to cache. entities = itertools.chain( + [entities], getattr(entities, 'chats', []), getattr(entities, 'users', []), - (hasattr(entities, 'user') and [entities.user]) or [] + (hasattr(entities, 'user') and [entities.user]) or [], + (hasattr(entities, 'chat') and [entities.user]) or [], ) - for entity in entities: + rows = [] + for e in entities: try: - pid = utils.get_peer_id(entity) - if pid not in self.__dict__: - # Note: `get_input_peer` already checks for `access_hash` - self.__dict__[pid] = utils.get_input_peer(entity) - except TypeError: - pass + mapper = _mappings[e.CONSTRUCTOR_ID] + except (AttributeError, KeyError): + continue + + ty, id, access_hash = mapper(e) + + # Need to check for non-zero access hash unless it's a group (#354 and #392). + # Also check it's not `min` (`access_hash` usage is limited since layer 102). + if not getattr(e, 'min', False) and (access_hash or ty == Entity.GROUP): + rows.append(Entity(ty, id, access_hash)) + if id not in self.__dict__: + if ty in (Entity.USER, Entity.BOT): + self.__dict__[id] = _tl.InputPeerUser(id, access_hash) + elif ty in (Entity.GROUP): + self.__dict__[id] = _tl.InputPeerChat(id) + elif ty in (Entity.CHANNEL, Entity.MEGAGROUP, Entity.GIGAGROUP): + self.__dict__[id] = _tl.InputPeerChannel(id, access_hash) + + return rows def __getitem__(self, item): """ diff --git a/telethon/_network/mtprotosender.py b/telethon/_network/mtprotosender.py index 9a873fc8..16bd6e32 100644 --- a/telethon/_network/mtprotosender.py +++ b/telethon/_network/mtprotosender.py @@ -34,9 +34,8 @@ class MTProtoSender: A new authorization key will be generated on connection if no other key exists yet. """ - def __init__(self, auth_key, *, loggers, + def __init__(self, *, loggers, retries=5, delay=1, auto_reconnect=True, connect_timeout=None, - auth_key_callback=None, update_callback=None, auto_reconnect_callback=None): self._connection = None self._loggers = loggers @@ -45,7 +44,6 @@ class MTProtoSender: self._delay = delay self._auto_reconnect = auto_reconnect self._connect_timeout = connect_timeout - self._auth_key_callback = auth_key_callback self._update_callback = update_callback self._auto_reconnect_callback = auto_reconnect_callback self._connect_lock = asyncio.Lock() @@ -67,7 +65,7 @@ class MTProtoSender: self._recv_loop_handle = None # Preserving the references of the AuthKey and state is important - self.auth_key = auth_key or AuthKey(None) + self.auth_key = AuthKey(None) self._state = MTProtoState(self.auth_key, loggers=self._loggers) # Outgoing messages are put in a queue and sent in a batch. @@ -283,13 +281,6 @@ class MTProtoSender: self.auth_key.key, self._state.time_offset = \ await authenticator.do_authentication(plain) - # This is *EXTREMELY* important since we don't control - # external references to the authorization key, we must - # notify whenever we change it. This is crucial when we - # switch to different data centers. - if self._auth_key_callback: - self._auth_key_callback(self.auth_key) - self._log.debug('auth_key generation success!') return True except (SecurityError, AssertionError) as e: @@ -372,8 +363,6 @@ class MTProtoSender: if isinstance(e, InvalidBufferError) and e.code == 404: self._log.info('Broken authorization key; resetting') self.auth_key.key = None - if self._auth_key_callback: - self._auth_key_callback(None) ok = False break @@ -516,8 +505,6 @@ class MTProtoSender: if isinstance(e, InvalidBufferError) and e.code == 404: self._log.info('Broken authorization key; resetting') self.auth_key.key = None - if self._auth_key_callback: - self._auth_key_callback(None) await self._disconnect(error=e) else: From d33402f02e344b154ca00b3748c34f6d2054e32f Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 17:08:34 +0200 Subject: [PATCH 049/131] Fix _update_loop could get stuck in an infinite loop with no feedback --- telethon/_client/updates.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index 0ae8b299..2e7483ac 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -173,16 +173,17 @@ async def _update_loop(self: 'TelegramClient'): rnd = lambda: random.randrange(-2**63, 2**63) while self.is_connected(): try: - await asyncio.wait_for( - self.disconnected, timeout=60 - ) + await asyncio.wait_for(self.run_until_disconnected(), timeout=60) continue # We actually just want to act upon timeout except asyncio.TimeoutError: pass except asyncio.CancelledError: return - except Exception: - continue # Any disconnected exception should be ignored + except Exception as e: + # Any disconnected exception should be ignored (or it may hint at + # another problem, leading to an infinite loop, hence the logging call) + self._log[__name__].info('Exception waiting on a disconnect: %s', e) + continue # Check if we have any exported senders to clean-up periodically await self._clean_exported_senders() From 9479e215fbada441466640d64fd39951579eef17 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 17:08:51 +0200 Subject: [PATCH 050/131] Fix remaining upgraded uses of the session to work correctly --- telethon/_client/telegrambaseclient.py | 7 +++++-- telethon/_client/users.py | 23 +++++++++++------------ telethon/sessions/abstract.py | 4 +++- telethon/sessions/memory.py | 2 +- telethon/sessions/sqlite.py | 15 ++++++++------- 5 files changed, 28 insertions(+), 23 deletions(-) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 1050c2a5..1add1abb 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -319,6 +319,9 @@ async def connect(self: 'TelegramClient') -> None: # TODO Get state from channels too self._state_cache = statecache.StateCache(state, self._log) + # Use known key, if any + self._sender.auth_key.key = dc.auth + if not await self._sender.connect(self._connection( str(ipaddress.ip_address(dc.ipv6 or dc.ipv4)), dc.port, @@ -330,8 +333,8 @@ async def connect(self: 'TelegramClient') -> None: # We don't want to init or modify anything if we were already connected return - if self._sender.auth_key.key != dc.key: - dc.key = self._sender.auth_key.key + if self._sender.auth_key.key != dc.auth: + dc.auth = self._sender.auth_key.key await self.session.insert_dc(dc) await self.session.save() diff --git a/telethon/_client/users.py b/telethon/_client/users.py index 394baee9..18a6f0f4 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -271,19 +271,18 @@ async def get_input_entity( # No InputPeer, cached peer, or known string. Fetch from session cache try: - peer = utils.get_peer(peer) - if isinstance(peer, _tl.PeerUser): - entity = await self.session.get_entity(Entity.USER, peer.user_id) - if entity: - return _tl.InputPeerUser(entity.id, entity.access_hash) - elif isinstance(peer, _tl.PeerChat): - return _tl.InputPeerChat(peer.chat_id) - elif isinstance(peer, _tl.PeerChannel): - entity = await self.session.get_entity(Entity.CHANNEL, peer.user_id) - if entity: - return _tl.InputPeerChannel(entity.id, entity.access_hash) - except ValueError: + peer_id = utils.get_peer_id(peer) + except TypeError: pass + else: + entity = await self.session.get_entity(None, peer_id) + if entity: + if entity.ty in (Entity.USER, Entity.BOT): + return _tl.InputPeerUser(entity.id, entity.access_hash) + elif entity.ty in (Entity.GROUP): + return _tl.InputPeerChat(peer.chat_id) + elif entity.ty in (Entity.CHANNEL, Entity.MEGAGROUP, Entity.GIGAGROUP): + return _tl.InputPeerChannel(entity.id, entity.access_hash) # Only network left to try if isinstance(peer, str): diff --git a/telethon/sessions/abstract.py b/telethon/sessions/abstract.py index 4cdc9131..2b28ae76 100644 --- a/telethon/sessions/abstract.py +++ b/telethon/sessions/abstract.py @@ -59,7 +59,7 @@ class Session(ABC): raise NotImplementedError @abstractmethod - async def get_entity(self, ty: int, id: int) -> Optional[Entity]: + async def get_entity(self, ty: Optional[int], id: int) -> Optional[Entity]: """ Get the `Entity` with matching ``ty`` and ``id``. @@ -75,6 +75,8 @@ class Session(ABC): the corresponding ``access_hash`` should still be returned. You may use `types.canonical_entity_type` to find out the canonical type. + + A ``ty`` with the value of ``None`` should be treated as "any entity with matching ID". """ raise NotImplementedError diff --git a/telethon/sessions/memory.py b/telethon/sessions/memory.py index 67602ec9..1c86aff7 100644 --- a/telethon/sessions/memory.py +++ b/telethon/sessions/memory.py @@ -36,7 +36,7 @@ class MemorySession(Session): async def insert_entities(self, entities: List[Entity]): self.entities.update((e.id, (e.ty, e.access_hash)) for e in entities) - async def get_entity(self, ty: int, id: int) -> Optional[Entity]: + async def get_entity(self, ty: Optional[int], id: int) -> Optional[Entity]: try: ty, access_hash = self.entities[id] return Entity(ty, id, access_hash) diff --git a/telethon/sessions/sqlite.py b/telethon/sessions/sqlite.py index 5cd288aa..5bfc0433 100644 --- a/telethon/sessions/sqlite.py +++ b/telethon/sessions/sqlite.py @@ -55,13 +55,17 @@ class SQLiteSession(Session): self._upgrade_database(old=version) c.execute("delete from version") c.execute("insert into version values (?)", (CURRENT_VERSION,)) - self.save() + self._conn.commit() else: # Tables don't exist, create new ones + self._create_table(c, 'version (version integer primary key)') self._mk_tables(c) c.execute("insert into version values (?)", (CURRENT_VERSION,)) - c.close() - self.save() + self._conn.commit() + + # Must have committed or else the version will not have been updated while new tables + # exist, leading to a half-upgraded state. + c.close() def _upgrade_database(self, old): c = self._cursor() @@ -146,9 +150,6 @@ class SQLiteSession(Session): def _mk_tables(self, c): self._create_table( c, - '''version ( - version integer primary key - )''', '''datacenter ( id integer primary key, ip text not null, @@ -243,7 +244,7 @@ class SQLiteSession(Session): finally: c.close() - async def get_entity(self, ty: int, id: int) -> Optional[Entity]: + async def get_entity(self, ty: Optional[int], id: int) -> Optional[Entity]: row = self._execute('select ty, id, access_hash from entity where id = ?', id) return Entity(*row) if row else None From d60ebbe6eaa2b5bf74a7d13bc7e86c961fe8d889 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 17:21:11 +0200 Subject: [PATCH 051/131] Fix _get_peer was relying on old utils.resolve_id --- telethon/_client/messages.py | 12 ++++++++++-- telethon/_client/telegramclient.py | 3 --- telethon/_client/users.py | 4 ---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index 69f00a0c..8305f60c 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -293,7 +293,7 @@ class _IDsIter(requestiter.RequestIter): else: r = await self.client(_tl.fn.messages.GetMessages(ids)) if self._entity: - from_id = await self.client._get_peer(self._entity) + from_id = await _get_peer(self.client, self._entity) if isinstance(r, _tl.messages.MessagesNotModified): self.buffer.extend(None for _ in ids) @@ -318,6 +318,14 @@ class _IDsIter(requestiter.RequestIter): self.buffer.append(_custom.Message._new(self.client, message, entities, self._entity)) +async def _get_peer(self: 'TelegramClient', input_peer: 'hints.EntityLike'): + try: + return utils.get_peer(input_peer) + except TypeError: + # Can only be self by now + return _tl.PeerUser(await self.get_peer_id(input_peer)) + + def get_messages( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -480,7 +488,7 @@ async def send_message( if isinstance(result, _tl.UpdateShortSentMessage): return _custom.Message._new(self, _tl.Message( id=result.id, - peer_id=await self._get_peer(entity), + peer_id=await _get_peer(self, entity), message=message, date=result.date, out=result.out, diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index e5dc6f8f..62511cd8 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -3573,9 +3573,6 @@ class TelegramClient: ttl=None): return await uploads._file_to_media(**locals()) - async def _get_peer(self: 'TelegramClient', peer: 'hints.EntityLike'): - return await users._get_peer(**locals()) - def _get_response_message(self: 'TelegramClient', request, result, input_chat): return messageparse._get_response_message(**locals()) diff --git a/telethon/_client/users.py b/telethon/_client/users.py index 18a6f0f4..5f3d7116 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -323,10 +323,6 @@ async def get_input_entity( .format(peer, type(peer).__name__) ) -async def _get_peer(self: 'TelegramClient', peer: 'hints.EntityLike'): - i, cls = utils.resolve_id(await self.get_peer_id(peer)) - return cls(i) - async def get_peer_id( self: 'TelegramClient', peer: 'hints.EntityLike') -> int: From 58c0a5bc24a8f7e047c020620ccafdadf8bbb924 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 17:30:31 +0200 Subject: [PATCH 052/131] Make IPv4 mandatory in session files --- telethon/_client/telegrambaseclient.py | 2 +- telethon/sessions/sqlite.py | 19 ++++++++++--------- telethon/sessions/types.py | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 1add1abb..eb8b6cd3 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -323,7 +323,7 @@ async def connect(self: 'TelegramClient') -> None: self._sender.auth_key.key = dc.auth if not await self._sender.connect(self._connection( - str(ipaddress.ip_address(dc.ipv6 or dc.ipv4)), + str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)), dc.port, dc.id, loggers=self._log, diff --git a/telethon/sessions/sqlite.py b/telethon/sessions/sqlite.py index 5bfc0433..00cc3895 100644 --- a/telethon/sessions/sqlite.py +++ b/telethon/sessions/sqlite.py @@ -107,8 +107,8 @@ class SQLiteSession(Session): if old == 7: self._mk_tables(c) c.execute(''' - insert into datacenter (id, ip, port, auth) - select dc_id, server_address, port, auth_key + insert into datacenter (id, ipv4, ipv6, port, auth) + select dc_id, server_address, server_address, port, auth_key from sessions ''') c.execute(''' @@ -152,7 +152,8 @@ class SQLiteSession(Session): c, '''datacenter ( id integer primary key, - ip text not null, + ipv4 text not null, + ipv6 text, port integer not null, auth blob not null )''', @@ -179,9 +180,10 @@ class SQLiteSession(Session): async def insert_dc(self, dc: DataCenter): self._execute( - 'insert or replace into datacenter values (?,?,?,?)', + 'insert or replace into datacenter values (?,?,?,?,?)', dc.id, - str(ipaddress.ip_address(dc.ipv6 or dc.ipv4)), + str(ipaddress.ip_address(dc.ipv4)), + str(ipaddress.ip_address(dc.ipv6)) if dc.ipv6 else None, dc.port, dc.auth ) @@ -189,12 +191,11 @@ class SQLiteSession(Session): async def get_all_dc(self) -> List[DataCenter]: c = self._cursor() res = [] - for (id, ip, port, auth) in c.execute('select * from datacenter'): - ip = ipaddress.ip_address(ip) + for (id, ipv4, ipv6, port, auth) in c.execute('select * from datacenter'): res.append(DataCenter( id=id, - ipv4=int(ip) if ip.version == 4 else None, - ipv6=int(ip) if ip.version == 6 else None, + ipv4=int(ipaddress.ip_address(ipv4)), + ipv6=int(ipaddress.ip_address(ipv6)) if ipv6 else None, port=port, auth=auth, )) diff --git a/telethon/sessions/types.py b/telethon/sessions/types.py index 39033a1f..5fb0d608 100644 --- a/telethon/sessions/types.py +++ b/telethon/sessions/types.py @@ -15,7 +15,7 @@ class DataCenter: def __init__( self, id: int, - ipv4: Optional[int], + ipv4: int, ipv6: Optional[int], port: int, auth: bytes From 93dd2a186a63f45a079e008c9c09d4a8606072ec Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 17:41:40 +0200 Subject: [PATCH 053/131] Refresh DC info on connection --- telethon/_client/telegrambaseclient.py | 39 ++++++++++++++++++-------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index eb8b6cd3..5afd0328 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -296,16 +296,10 @@ def set_flood_sleep_threshold(self, value): async def connect(self: 'TelegramClient') -> None: - all_dc = await self.session.get_all_dc() + all_dc = {dc.id: dc for dc in await self.session.get_all_dc()} state = await self.session.get_state() - dc = None - if state: - for d in all_dc: - if d.id == state.dc_id: - dc = d - break - + dc = all_dc.get(state.dc_id) if state else None if dc is None: dc = DataCenter( id=DEFAULT_DC_ID, @@ -314,6 +308,7 @@ async def connect(self: 'TelegramClient') -> None: port=DEFAULT_PORT, auth=b'', ) + all_dc[dc.id] = dc # Update state (for catching up after a disconnection) # TODO Get state from channels too @@ -335,15 +330,37 @@ async def connect(self: 'TelegramClient') -> None: if self._sender.auth_key.key != dc.auth: dc.auth = self._sender.auth_key.key - await self.session.insert_dc(dc) - await self.session.save() + # Need to send invokeWithLayer for things to work out. + # Make the most out of this opportunity by also refreshing our state. + # During the v1 to v2 migration, this also correctly sets the IPv* columns. self._init_request.query = _tl.fn.help.GetConfig() - await self._sender.send(_tl.fn.InvokeWithLayer( + config = await self._sender.send(_tl.fn.InvokeWithLayer( _tl.LAYER, self._init_request )) + for dc in config.dc_options: + if dc.media_only or dc.tcpo_only or dc.cdn: + continue + + ip = int(ipaddress.ip_address(dc.ip_address)) + if dc.id in all_dc: + all_dc[dc.id].port = dc.port + if dc.ipv6: + all_dc[dc.id].ipv6 = ip + else: + all_dc[dc.id].ipv4 = ip + elif dc.ipv6: + all_dc[dc.id] = DataCenter(dc.id, None, ip, dc.port, b'') + else: + all_dc[dc.id] = DataCenter(dc.id, ip, None, dc.port, b'') + + for dc in all_dc.values(): + await self.session.insert_dc(dc) + + await self.session.save() + self._updates_handle = self.loop.create_task(self._update_loop()) def is_connected(self: 'TelegramClient') -> bool: From 545e9d69ce3734a1285b84fabed6a136f7e3215f Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 17:51:05 +0200 Subject: [PATCH 054/131] Cache session_state and all_dcs right after connect --- telethon/_client/downloads.py | 5 +- telethon/_client/messages.py | 5 +- telethon/_client/telegrambaseclient.py | 121 ++++++++----------------- telethon/_client/telegramclient.py | 3 - telethon/_misc/statecache.py | 2 +- 5 files changed, 39 insertions(+), 97 deletions(-) diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py index 99932cdc..50a383dd 100644 --- a/telethon/_client/downloads.py +++ b/telethon/_client/downloads.py @@ -39,10 +39,7 @@ class _DirectDownloadIter(requestiter.RequestIter): self._msg_data = msg_data self._timed_out = False - # TODO should cache current session state - state = await self.client.session.get_state() - - self._exported = dc_id and state.dc_id != dc_id + self._exported = dc_id and self.client._session_state.dc_id != dc_id if not self._exported: # The used sender will also change if ``FileMigrateError`` occurs self._sender = self.client._sender diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index 8305f60c..5b7ca110 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -597,10 +597,7 @@ async def edit_message( ) # Invoke `messages.editInlineBotMessage` from the right datacenter. # Otherwise, Telegram will error with `MESSAGE_ID_INVALID` and do nothing. - # TODO should cache current session state - state = await self.session.get_state() - - exported = state.dc_id != entity.dc_id + exported = self._session_state.dc_id != entity.dc_id if exported: try: sender = await self._borrow_exported_sender(entity.dc_id) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 5afd0328..960b074f 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -142,6 +142,11 @@ def init( # TODO Session should probably return all cached # info of entities, not just the input versions self.session = session + + # Cache session data for convenient access + self._session_state = None + self._all_dcs = None + self._entity_cache = entitycache.EntityCache() self.api_id = int(api_id) self.api_hash = api_hash @@ -296,10 +301,19 @@ def set_flood_sleep_threshold(self, value): async def connect(self: 'TelegramClient') -> None: - all_dc = {dc.id: dc for dc in await self.session.get_all_dc()} - state = await self.session.get_state() + self._all_dcs = {dc.id: dc for dc in await self.session.get_all_dc()} + self._session_state = await self.session.get_state() or SessionState( + user_id=0, + dc_id=DEFAULT_DC_ID, + bot=False, + pts=0, + qts=0, + date=0, + seq=0, + takeout_id=None, + ) - dc = all_dc.get(state.dc_id) if state else None + dc = self._all_dcs.get(self._session_state.dc_id) if dc is None: dc = DataCenter( id=DEFAULT_DC_ID, @@ -308,11 +322,11 @@ async def connect(self: 'TelegramClient') -> None: port=DEFAULT_PORT, auth=b'', ) - all_dc[dc.id] = dc + self._all_dcs[dc.id] = dc # Update state (for catching up after a disconnection) # TODO Get state from channels too - self._state_cache = statecache.StateCache(state, self._log) + self._state_cache = statecache.StateCache(self._session_state, self._log) # Use known key, if any self._sender.auth_key.key = dc.auth @@ -345,18 +359,18 @@ async def connect(self: 'TelegramClient') -> None: continue ip = int(ipaddress.ip_address(dc.ip_address)) - if dc.id in all_dc: - all_dc[dc.id].port = dc.port + if dc.id in self._all_dcs: + self._all_dcs[dc.id].port = dc.port if dc.ipv6: - all_dc[dc.id].ipv6 = ip + self._all_dcs[dc.id].ipv6 = ip else: - all_dc[dc.id].ipv4 = ip + self._all_dcs[dc.id].ipv4 = ip elif dc.ipv6: - all_dc[dc.id] = DataCenter(dc.id, None, ip, dc.port, b'') + self._all_dcs[dc.id] = DataCenter(dc.id, None, ip, dc.port, b'') else: - all_dc[dc.id] = DataCenter(dc.id, ip, None, dc.port, b'') + self._all_dcs[dc.id] = DataCenter(dc.id, ip, None, dc.port, b'') - for dc in all_dc.values(): + for dc in self._all_dcs.values(): await self.session.insert_dc(dc) await self.session.save() @@ -419,11 +433,10 @@ async def _disconnect_coro(self: 'TelegramClient'): pts, date = self._state_cache[None] if pts and date: - state = await self.session.get_state() - if state: - state.pts = pts - state.date = date - await self.session.set_state(state) + if self._session_state: + self._session_state.pts = pts + self._session_state.date = date + await self.session.set_state(self._session_state) await self.session.save() async def _disconnect(self: 'TelegramClient'): @@ -442,76 +455,14 @@ async def _switch_dc(self: 'TelegramClient', new_dc): Permanently switches the current connection to the new data center. """ self._log[__name__].info('Reconnecting to new data center %s', new_dc) - dc = await _refresh_and_get_dc(self, new_dc) - state = await self.session.get_state() - if state is None: - state = SessionState( - user_id=0, - dc_id=dc.id, - bot=False, - pts=0, - qts=0, - date=0, - seq=0, - takeout_id=None, - ) - else: - state.dc_id = dc.id - - await self.session.set_state(dc.id) + self._session_state.dc_id = new_dc + await self.session.set_state(self._session_state) await self.session.save() await _disconnect(self) return await self.connect() - -async def _refresh_and_get_dc(self: 'TelegramClient', dc_id): - """ - Gets the Data Center (DC) associated to `dc_id`. - - Also take this opportunity to refresh the addresses stored in the session if needed. - """ - cls = self.__class__ - if not cls._config: - cls._config = await self(_tl.fn.help.GetConfig()) - all_dc = {dc.id: dc for dc in await self.session.get_all_dc()} - for dc in cls._config.dc_options: - if dc.media_only or dc.tcpo_only or dc.cdn: - continue - - ip = int(ipaddress.ip_address(dc.ip_address)) - if dc.id in all_dc: - all_dc[dc.id].port = dc.port - if dc.ipv6: - all_dc[dc.id].ipv6 = ip - else: - all_dc[dc.id].ipv4 = ip - elif dc.ipv6: - all_dc[dc.id] = DataCenter(dc.id, None, ip, dc.port, b'') - else: - all_dc[dc.id] = DataCenter(dc.id, ip, None, dc.port, b'') - - for dc in all_dc.values(): - await self.session.insert_dc(dc) - await self.session.save() - - try: - return next( - dc for dc in cls._config.dc_options - if dc.id == dc_id - and bool(dc.ipv6) == self._use_ipv6 and not dc.cdn - ) - except StopIteration: - self._log[__name__].warning( - 'Failed to get DC %swith use_ipv6 = %s; retrying ignoring IPv6 check', - dc_id, self._use_ipv6 - ) - return next( - dc for dc in cls._config.dc_options - if dc.id == dc_id and not dc.cdn - ) - async def _create_exported_sender(self: 'TelegramClient', dc_id): """ Creates a new exported `MTProtoSender` for the given `dc_id` and @@ -519,14 +470,14 @@ async def _create_exported_sender(self: 'TelegramClient', dc_id): """ # Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt # for clearly showing how to export the authorization - dc = await _refresh_and_get_dc(self, dc_id) + dc = self._all_dcs[dc_id] # 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(loggers=self._log) await sender.connect(self._connection( - dc.ip_address, + str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)), dc.port, dc.id, loggers=self._log, @@ -559,9 +510,9 @@ async def _borrow_exported_sender(self: 'TelegramClient', dc_id): self._borrowed_senders[dc_id] = (state, sender) elif state.need_connect(): - dc = await _refresh_and_get_dc(self, dc_id) + dc = self._all_dcs[dc_id] await sender.connect(self._connection( - dc.ip_address, + str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)), dc.port, dc.id, loggers=self._log, diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 62511cd8..10db3557 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -2704,9 +2704,6 @@ class TelegramClient: # Current TelegramClient version __version__ = version.__version__ - # Cached server configuration (with .dc_options), can be "global" - _config = None - def __init__( self: 'TelegramClient', session: 'typing.Union[str, Session]', diff --git a/telethon/_misc/statecache.py b/telethon/_misc/statecache.py index 7f3ddf59..c1a6d7c9 100644 --- a/telethon/_misc/statecache.py +++ b/telethon/_misc/statecache.py @@ -36,7 +36,7 @@ class StateCache: # each update in case they need to fetch missing entities. self._logger = loggers[__name__] if initial: - self._pts_date = initial.pts, initial.date + self._pts_date = initial.pts or None, initial.date or None else: self._pts_date = None, None From 35a6d1e294f9e7953bcee85f7e256663bd99b242 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 17:59:35 +0200 Subject: [PATCH 055/131] Fix SessionState did not store takeout_id --- telethon/sessions/types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/telethon/sessions/types.py b/telethon/sessions/types.py index 5fb0d608..51d4ecb5 100644 --- a/telethon/sessions/types.py +++ b/telethon/sessions/types.py @@ -63,6 +63,7 @@ class SessionState: self.qts = qts self.date = date self.seq = seq + self.takeout_id = takeout_id class ChannelState: From 016347474a3bc700e1708894a5d0151a0ae0efc5 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 18:01:01 +0200 Subject: [PATCH 056/131] Populate current user on connection if it's not yet saved --- telethon/_client/telegrambaseclient.py | 43 ++++++++++++++++++++------ 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 960b074f..a447ae61 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -302,16 +302,22 @@ def set_flood_sleep_threshold(self, value): async def connect(self: 'TelegramClient') -> None: self._all_dcs = {dc.id: dc for dc in await self.session.get_all_dc()} - self._session_state = await self.session.get_state() or SessionState( - user_id=0, - dc_id=DEFAULT_DC_ID, - bot=False, - pts=0, - qts=0, - date=0, - seq=0, - takeout_id=None, - ) + self._session_state = await self.session.get_state() + + if self._session_state is None: + try_fetch_user = False + self._session_state = SessionState( + user_id=0, + dc_id=DEFAULT_DC_ID, + bot=False, + pts=0, + qts=0, + date=0, + seq=0, + takeout_id=None, + ) + else: + try_fetch_user = self._session_state.user_id == 0 dc = self._all_dcs.get(self._session_state.dc_id) if dc is None: @@ -373,6 +379,23 @@ async def connect(self: 'TelegramClient') -> None: for dc in self._all_dcs.values(): await self.session.insert_dc(dc) + if try_fetch_user: + # If there was a previous session state, but the current user ID is 0, it means we've + # migrated and not yet populated the current user (or the client connected but never + # logged in). Attempt to fetch the user now. If it works, also get the update state. + me = await self.get_me() + if me: + self._session_state.user_id = me.id + self._session_state.bot = me.bot + + state = await self(_tl.fn.updates.GetState()) + self._session_state.pts = state.pts + self._session_state.qts = state.qts + self._session_state.date = int(state.date.timestamp()) + self._session_state.seq = state.seq + + await self.session.set_state(self._session_state) + await self.session.save() self._updates_handle = self.loop.create_task(self._update_loop()) From 3f13357d0f8e86772d61149d54d30469bcfcc0b1 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 18:02:08 +0200 Subject: [PATCH 057/131] Fix SQLiteSession.set_state did not always clear old state For instance, when we stored a user_id of 0 because we did not login yet. --- telethon/sessions/sqlite.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/telethon/sessions/sqlite.py b/telethon/sessions/sqlite.py index 00cc3895..2ea419be 100644 --- a/telethon/sessions/sqlite.py +++ b/telethon/sessions/sqlite.py @@ -202,17 +202,22 @@ class SQLiteSession(Session): return res async def set_state(self, state: SessionState): - self._execute( - 'insert or replace into session values (?,?,?,?,?,?,?,?)', - state.user_id, - state.dc_id, - int(state.bot), - state.pts, - state.qts, - state.date, - state.seq, - state.takeout_id, - ) + c = self._cursor() + try: + self._execute('delete from session') + self._execute( + 'insert into session values (?,?,?,?,?,?,?,?)', + state.user_id, + state.dc_id, + int(state.bot), + state.pts, + state.qts, + state.date, + state.seq, + state.takeout_id, + ) + finally: + c.close() async def get_state(self) -> Optional[SessionState]: row = self._execute('select * from session') From cc3d4145d85ad3b0b31faba38206d0e1f4dbaab5 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 18:15:09 +0200 Subject: [PATCH 058/131] Update and persist session state on successful login --- .gitignore | 1 + telethon/_client/auth.py | 21 ++++++++++++++++----- telethon/_client/telegrambaseclient.py | 11 +---------- telethon/_client/telegramclient.py | 4 ++-- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 1e497d62..3856ed42 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # User session *.session +sessions/ /usermedia/ # Builds and testing diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index 296656f7..365e7257 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -253,7 +253,7 @@ async def sign_in( self._tos = result.terms_of_service raise errors.PhoneNumberUnoccupiedError(request=request) - return _on_login(self, result.user) + return await _update_session_state(self, result.user) async def sign_up( self: 'TelegramClient', @@ -306,17 +306,28 @@ async def sign_up( await self( _tl.fn.help.AcceptTermsOfService(self._tos.id)) - return _on_login(self, result.user) + return await _update_session_state(self, result.user) -def _on_login(self, user): +async def _update_session_state(self, user, save=True): """ Callback called whenever the login or sign up process completes. Returns the input user parameter. """ - self._bot = bool(user.bot) - self._self_input_peer = utils.get_input_peer(user, allow_self=False) self._authorized = True + self._session_state.user_id = user.id + self._session_state.bot = user.bot + + state = await self(_tl.fn.updates.GetState()) + self._session_state.pts = state.pts + self._session_state.qts = state.qts + self._session_state.date = int(state.date.timestamp()) + self._session_state.seq = state.seq + + await self.session.set_state(self._session_state) + if save: + await self.session.save() + return user async def send_code_request( diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index a447ae61..38ad77ed 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -385,16 +385,7 @@ async def connect(self: 'TelegramClient') -> None: # logged in). Attempt to fetch the user now. If it works, also get the update state. me = await self.get_me() if me: - self._session_state.user_id = me.id - self._session_state.bot = me.bot - - state = await self(_tl.fn.updates.GetState()) - self._session_state.pts = state.pts - self._session_state.qts = state.qts - self._session_state.date = int(state.date.timestamp()) - self._session_state.seq = state.seq - - await self.session.set_state(self._session_state) + await self._update_session_state(me, save=False) await self.session.save() diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 10db3557..15a7db75 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -3598,8 +3598,8 @@ class TelegramClient: async def _handle_auto_reconnect(self: 'TelegramClient'): return await updates._handle_auto_reconnect(**locals()) - def _self_id(self: 'TelegramClient') -> typing.Optional[int]: - return users._self_id(**locals()) + async def _update_session_state(self, user, save=True): + return await auth._update_session_state(**locals()) # endregion Private From 3b1660669e6eecd789b16de27596a33f7bc73518 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 18:16:12 +0200 Subject: [PATCH 059/131] Remove self input user and bot cache from client The session_state cache can be used instead. This does put get_me with input_peer at a disadvantage, but I expect this is not used all that often, since 'me' does just fine. --- telethon/_client/auth.py | 2 -- telethon/_client/telegrambaseclient.py | 4 ---- telethon/_client/updates.py | 14 +------------ telethon/_client/users.py | 29 +++----------------------- telethon/types/_custom/message.py | 4 ++-- 5 files changed, 6 insertions(+), 47 deletions(-) diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index 365e7257..031b3a11 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -377,8 +377,6 @@ async def log_out(self: 'TelegramClient') -> bool: except errors.RPCError: return False - self._bot = None - self._self_input_peer = None self._authorized = False self._state_cache.reset() diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 38ad77ed..8bbf6051 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -281,10 +281,6 @@ def init( self._phone = None self._tos = None - # Sometimes we need to know who we are, cache the self peer - self._self_input_peer = None - self._bot = None - # A place to store if channels are a megagroup or not (see `edit_admin`) self._megagroup_cache = {} diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index 2e7483ac..2a03a0b3 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -255,18 +255,6 @@ async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, p # ValueError("Request was unsuccessful N time(s)") for whatever reasons. pass - if not self._self_input_peer: - # Some updates require our own ID, so we must make sure - # that the event builder has offline access to it. Calling - # `get_me()` will cache it under `self._self_input_peer`. - # - # It will return `None` if we haven't logged in yet which is - # fine, we will just retry next time anyway. - try: - await self.get_me(input_peer=True) - except OSError: - pass # might not have connection - built = EventBuilderDict(self, update, others) for builder, callback in self._event_builders: @@ -452,7 +440,7 @@ class EventBuilderDict: return self.__dict__[builder] except KeyError: event = self.__dict__[builder] = builder.build( - self.update, self.others, self.client._self_id) + self.update, self.others, self.client._session_state.user_id) if isinstance(event, EventCommon): event.original_update = self.update diff --git a/telethon/_client/users.py b/telethon/_client/users.py index 5f3d7116..b653af94 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -135,37 +135,14 @@ async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sl async def get_me(self: 'TelegramClient', input_peer: bool = False) \ -> 'typing.Union[_tl.User, _tl.InputPeerUser]': - if input_peer and self._self_input_peer: - return self._self_input_peer - try: - me = (await self( - _tl.fn.users.GetUsers([_tl.InputUserSelf()])))[0] - - self._bot = me.bot - 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 + me = (await self(_tl.fn.users.GetUsers([_tl.InputUserSelf()])))[0] + return utils.get_input_peer(me, allow_self=False) if input_peer else me except errors.UnauthorizedError: return None -def _self_id(self: 'TelegramClient') -> typing.Optional[int]: - """ - Returns the ID of the logged-in user, if known. - - This property is used in every update, and some like `updateLoginToken` - occur prior to login, so it gracefully handles when no ID is known yet. - """ - return self._self_input_peer.user_id if self._self_input_peer else None - async def is_bot(self: 'TelegramClient') -> bool: - if self._bot is None: - self._bot = (await self.get_me()).bot - - return self._bot + return self._session_state.bot if self._session_state else False async def is_user_authorized(self: 'TelegramClient') -> bool: if self._authorized is None: diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index 2d8f3fbe..ae0b395e 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -255,7 +255,7 @@ class Message(ChatGetter, SenderGetter): # Make messages sent to ourselves outgoing unless they're forwarded. # This makes it consistent with official client's appearance. - if self.peer_id == _tl.PeerUser(client._self_id) and not self.fwd_from: + if self.peer_id == _tl.PeerUser(client._session_state.user_id) and not self.fwd_from: self.out = True cache = client._entity_cache @@ -644,7 +644,7 @@ class Message(ChatGetter, SenderGetter): # If the client wasn't set we can't emulate the behaviour correctly, # so as a best-effort simply return the chat peer. if self._client and not self.out and self.is_private: - return _tl.PeerUser(self._client._self_id) + return _tl.PeerUser(self._client._session_state.user_id) return self.peer_id From 26f6c62ce4c44bcdc5ad4f02f20d66d6f4924932 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 18:17:37 +0200 Subject: [PATCH 060/131] Init update state cache to empty in init --- telethon/_client/telegrambaseclient.py | 1 + 1 file changed, 1 insertion(+) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 8bbf6051..06f6bac8 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -146,6 +146,7 @@ def init( # Cache session data for convenient access self._session_state = None self._all_dcs = None + self._state_cache = statecache.StateCache(None, self._log) self._entity_cache = entitycache.EntityCache() self.api_id = int(api_id) From cfe47a04341ac6a6ab8984bf2236f107f9aa3ad7 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 19 Sep 2021 18:24:16 +0200 Subject: [PATCH 061/131] Correct privacy on sessions module --- telethon/{sessions => _sessions}/__init__.py | 0 telethon/{sessions => _sessions}/abstract.py | 0 telethon/{sessions => _sessions}/memory.py | 0 telethon/{sessions => _sessions}/sqlite.py | 0 telethon/{sessions => _sessions}/string.py | 0 telethon/{sessions => _sessions}/types.py | 0 telethon/sessions.py | 12 ++++++++++++ 7 files changed, 12 insertions(+) rename telethon/{sessions => _sessions}/__init__.py (100%) rename telethon/{sessions => _sessions}/abstract.py (100%) rename telethon/{sessions => _sessions}/memory.py (100%) rename telethon/{sessions => _sessions}/sqlite.py (100%) rename telethon/{sessions => _sessions}/string.py (100%) rename telethon/{sessions => _sessions}/types.py (100%) create mode 100644 telethon/sessions.py diff --git a/telethon/sessions/__init__.py b/telethon/_sessions/__init__.py similarity index 100% rename from telethon/sessions/__init__.py rename to telethon/_sessions/__init__.py diff --git a/telethon/sessions/abstract.py b/telethon/_sessions/abstract.py similarity index 100% rename from telethon/sessions/abstract.py rename to telethon/_sessions/abstract.py diff --git a/telethon/sessions/memory.py b/telethon/_sessions/memory.py similarity index 100% rename from telethon/sessions/memory.py rename to telethon/_sessions/memory.py diff --git a/telethon/sessions/sqlite.py b/telethon/_sessions/sqlite.py similarity index 100% rename from telethon/sessions/sqlite.py rename to telethon/_sessions/sqlite.py diff --git a/telethon/sessions/string.py b/telethon/_sessions/string.py similarity index 100% rename from telethon/sessions/string.py rename to telethon/_sessions/string.py diff --git a/telethon/sessions/types.py b/telethon/_sessions/types.py similarity index 100% rename from telethon/sessions/types.py rename to telethon/_sessions/types.py diff --git a/telethon/sessions.py b/telethon/sessions.py new file mode 100644 index 00000000..b4c9bf4f --- /dev/null +++ b/telethon/sessions.py @@ -0,0 +1,12 @@ +from ._sessions.types import ( + DataCenter, + SessionState, + ChannelState, + Entity, +) +from ._sessions import ( + Session, + MemorySession, + SQLiteSession, + StringSession, +) From debde6e85677391b876e253fd7121b5d612c6e7a Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 24 Sep 2021 20:07:34 +0200 Subject: [PATCH 062/131] Completely overhaul errors to be generated dynamically --- .gitignore | 2 +- readthedocs/misc/v2-migration-guide.rst | 68 ++++++++++ setup.py | 2 +- telethon/_client/chats.py | 10 +- telethon/_client/dialogs.py | 2 +- telethon/_client/telegrambaseclient.py | 4 +- telethon/_client/updates.py | 7 +- telethon/_client/users.py | 34 +++-- telethon/_misc/binaryreader.py | 2 +- telethon/_misc/entitycache.py | 2 +- telethon/_network/authenticator.py | 2 +- telethon/_network/connection/connection.py | 2 +- telethon/_network/connection/tcpfull.py | 2 +- telethon/_network/mtprotoplainsender.py | 2 +- telethon/_network/mtprotosender.py | 12 +- telethon/_network/mtprotostate.py | 2 +- telethon/errors/__init__.py | 82 ++++++------ telethon/errors/{common.py => _custom.py} | 0 telethon/errors/_rpcbase.py | 144 +++++++++++++++++++++ telethon/errors/rpcbaseerrors.py | 131 ------------------- telethon/types/_custom/draft.py | 4 +- telethon/types/_custom/message.py | 2 +- telethon_generator/data/errors.csv | 35 +++-- telethon_generator/data/methods.csv | 12 +- telethon_generator/generators/errors.py | 64 ++------- telethon_generator/parsers/errors.py | 34 ++--- 26 files changed, 345 insertions(+), 318 deletions(-) rename telethon/errors/{common.py => _custom.py} (100%) create mode 100644 telethon/errors/_rpcbase.py delete mode 100644 telethon/errors/rpcbaseerrors.py diff --git a/.gitignore b/.gitignore index 3856ed42..a0864768 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ /telethon/_tl/fn/ /telethon/_tl/*.py /telethon/_tl/alltlobjects.py -/telethon/errors/rpcerrorlist.py +/telethon/errors/_generated.py # User session *.session diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index a2c32f70..b35e076d 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -140,6 +140,72 @@ for the library to work properly. If you still don't want it, you should subclas override the methods to do nothing. +Complete overhaul of errors +--------------------------- + +The following error name have changed to follow a better naming convention (clearer acronyms): + +* ``RPCError`` is now ``RpcError``. +* ``InvalidDCError`` is now ``InvalidDcError`` (lowercase ``c``). + +The base errors no longer have a ``.message`` field at the class-level. Instead, it is now an +attribute at the instance level (meaning you cannot do ``BadRequestError.message``, it must be +``bad_request_err.message`` where ``isinstance(bad_request_err, BadRequestError)``). + +The ``.message`` will gain its value at the time the error is constructed, rather than being +known beforehand. + +The parameter order for ``RpcError`` and all its subclasses are now ``(code, message, request)``, +as opposed to ``(message, request, code)``. + +Because Telegram errors can be added at any time, the library no longer generate a fixed set of +them. This means you can no longer use ``dir`` to get a full list of them. Instead, the errors +are automatically generated depending on the name you use for the error, with the following rules: + +* Numbers are removed from the name. The Telegram error ``FLOOD_WAIT_42`` is transformed into + ``FLOOD_WAIT_``. +* Underscores are removed from the name. ``FLOOD_WAIT_`` becomes ``FLOODWAIT``. +* Everything is lowercased. ``FLOODWAIT`` turns into ``floodwait``. +* While the name ends with ``error``, this suffix is removed. + +The only exception to this rule is ``2FA_CONFIRM_WAIT_0``, which is transformed as +``twofaconfirmwait`` (read as ``TwoFaConfirmWait``). + +What all this means is that, if Telegram raises a ``FLOOD_WAIT_42``, you can write the following: + +.. code-block:: python + + from telethon.errors import FloodWaitError + + try: + await client.send_message(chat, message) + except FloodWaitError as e: + print(f'Flood! wait for {e.seconds} seconds') + +Essentially, old code will keep working, but now you have the freedom to define even yet-to-be +discovered errors. This makes use of `PEP 562 `__ on +Python 3.7 and above and a more-hacky approach below (which your IDE may not love). + +Given the above rules, you could also write ``except errors.FLOOD_WAIT`` if you prefer to match +Telegram's naming conventions. We recommend Camel-Case naming with the "Error" suffix, but that's +up to you. + +All errors will include a list of ``.values`` (the extracted number) and ``.value`` (the first +number extracted, or ``None`` if ``values`` is empty). In addition to that, certain errors have +a more-recognizable alias (such as ``FloodWait`` which has ``.seconds`` for its ``.value``). + +The ``telethon.errors`` module continues to provide certain predefined ``RpcError`` to match on +the *code* of the error and not its message (for instance, match all errors with code 403 with +``ForbiddenError``). Note that a certain error message can appear with different codes too, this +is decided by Telegram. + +The ``telethon.errors`` module continues to provide custom errors used by the library such as +``TypeNotFoundError``. + +// TODO keep RPCError around? eh idk how much it's used +// TODO should RpcError subclass ValueError? technically the values used in the request somehow were wrong… +// TODO provide a way to see which errors are known in the docs or at tl.telethon.dev + The "iter" variant of the client methods have been removed ---------------------------------------------------------- @@ -385,6 +451,8 @@ However, you're encouraged to change uses of ``.raw_text`` with ``.message``, an either ``.md_text`` or ``.html_text`` as needed. This is because both ``.text`` and ``.raw_text`` may disappear in future versions, and their behaviour is not immediately obvious. +// TODO actually provide the things mentioned here + Using a flat list to define buttons will now create rows and not columns ------------------------------------------------------------------------ diff --git a/setup.py b/setup.py index c82d236d..05f0f7b6 100755 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ GENERATOR_DIR = Path('telethon_generator') LIBRARY_DIR = Path('telethon') ERRORS_IN = GENERATOR_DIR / 'data/errors.csv' -ERRORS_OUT = LIBRARY_DIR / 'errors/rpcerrorlist.py' +ERRORS_OUT = LIBRARY_DIR / 'errors/_generated.py' METHODS_IN = GENERATOR_DIR / 'data/methods.csv' diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index ffa06ac7..205113d6 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -676,7 +676,7 @@ async def get_permissions( for participant in chat.full_chat.participants.participants: if participant.user_id == user.user_id: return _custom.ParticipantPermissions(participant, True) - raise errors.UserNotParticipantError(None) + raise errors.USER_NOT_PARTICIPANT(400, 'USER_NOT_PARTICIPANT') raise ValueError('You must pass either a channel or a chat') @@ -694,7 +694,7 @@ async def get_stats( try: req = _tl.fn.stats.GetMessageStats(entity, message) return await self(req) - except errors.StatsMigrateError as e: + except errors.STATS_MIGRATE as e: dc = e.dc else: # Don't bother fetching the Channel entity (costs a request), instead @@ -703,13 +703,13 @@ async def get_stats( try: req = _tl.fn.stats.GetBroadcastStats(entity) return await self(req) - except errors.StatsMigrateError as e: + except errors.STATS_MIGRATE as e: dc = e.dc - except errors.BroadcastRequiredError: + except errors.BROADCAST_REQUIRED: req = _tl.fn.stats.GetMegagroupStats(entity) try: return await self(req) - except errors.StatsMigrateError as e: + except errors.STATS_MIGRATE as e: dc = e.dc sender = await self._borrow_exported_sender(dc) diff --git a/telethon/_client/dialogs.py b/telethon/_client/dialogs.py index 1277a3a4..444a0570 100644 --- a/telethon/_client/dialogs.py +++ b/telethon/_client/dialogs.py @@ -232,7 +232,7 @@ async def delete_dialog( result = await self(_tl.fn.messages.DeleteChatUser( entity.chat_id, _tl.InputUserSelf(), revoke_history=revoke )) - except errors.PeerIdInvalidError: + except errors.PEER_ID_INVALID: # Happens if we didn't have the deactivated information result = None else: diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 06f6bac8..a4349c19 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -12,8 +12,8 @@ from .. import version, helpers, __name__ as __base_name__, _tl from .._crypto import rsa from .._misc import markdown, entitycache, statecache, enums from .._network import MTProtoSender, Connection, ConnectionTcpFull, connection as conns -from ..sessions import Session, SQLiteSession, MemorySession -from ..sessions.types import DataCenter, SessionState +from .._sessions import Session, SQLiteSession, MemorySession +from .._sessions.types import DataCenter, SessionState DEFAULT_DC_ID = 2 DEFAULT_IPV4_IP = '149.154.167.51' diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index 2a03a0b3..da60b24c 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -8,7 +8,8 @@ import traceback import typing import logging -from .. import events, utils, errors, _tl +from .. import events, utils, _tl +from ..errors._rpcbase import RpcError from ..events.common import EventBuilder, EventCommon if typing.TYPE_CHECKING: @@ -244,7 +245,7 @@ async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, p await _get_difference(self, update, channel_id, pts_date) except OSError: pass # We were disconnected, that's okay - except errors.RPCError: + except RpcError: # There's a high chance the request fails because we lack # the channel. Because these "happen sporadically" (#1428) # we should be okay (no flood waits) even if more occur. @@ -418,7 +419,7 @@ async def _handle_auto_reconnect(self: 'TelegramClient'): await self.catch_up() self._log[__name__].info('Successfully fetched missed updates') - except errors.RPCError as e: + except RpcError as e: self._log[__name__].warning('Failed to get missed updates after ' 'reconnect: %r', e) except Exception: diff --git a/telethon/_client/users.py b/telethon/_client/users.py index b653af94..de8a49a3 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -4,10 +4,11 @@ import itertools import time import typing +from ..errors._custom import MultiError +from ..errors._rpcbase import RpcError, ServerError, FloodError, InvalidDcError, UnauthorizedError from .. import errors, hints, _tl from .._misc import helpers, utils -from ..errors import MultiError, RPCError -from ..sessions.types import Entity +from .._sessions.types import Entity _NOT_A_REQUEST = lambda: TypeError('You can only invoke requests, not types!') @@ -49,7 +50,7 @@ async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sl await asyncio.sleep(diff) self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) else: - raise errors.FloodWaitError(request=r, capture=diff) + raise errors.FLOOD_WAIT(420, f'FLOOD_WAIT_{diff}', request=r) if self._no_updates: r = _tl.fn.InvokeWithoutUpdates(r) @@ -67,7 +68,7 @@ async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sl for f in future: try: result = await f - except RPCError as e: + except RpcError as e: exceptions.append(e) results.append(None) continue @@ -87,22 +88,20 @@ async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sl if entities: await self.session.insert_entities(entities) return result - except (errors.ServerError, errors.RpcCallFailError, - errors.RpcMcgetFailError, errors.InterdcCallErrorError, - errors.InterdcCallRichErrorError) as e: + except ServerError as e: last_error = e self._log[__name__].warning( 'Telegram is having internal issues %s: %s', e.__class__.__name__, e) await asyncio.sleep(2) - except (errors.FloodWaitError, errors.SlowModeWaitError, errors.FloodTestPhoneWaitError) as e: + except FloodError as e: last_error = e if utils.is_list_like(request): request = request[request_index] - # SLOW_MODE_WAIT is chat-specific, not request-specific - if not isinstance(e, errors.SlowModeWaitError): + # SLOWMODE_WAIT is chat-specific, not request-specific + if not isinstance(e, errors.SLOWMODE_WAIT): self._flood_waited_requests\ [request.CONSTRUCTOR_ID] = time.time() + e.seconds @@ -116,12 +115,11 @@ async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sl await asyncio.sleep(e.seconds) else: raise - except (errors.PhoneMigrateError, errors.NetworkMigrateError, - errors.UserMigrateError) as e: + except InvalidDcError as e: last_error = e self._log[__name__].info('Phone migrated to %d', e.new_dc) should_raise = isinstance(e, ( - errors.PhoneMigrateError, errors.NetworkMigrateError + errors.PHONE_MIGRATE, errors.NETWORK_MIGRATE )) if should_raise and await self.is_user_authorized(): raise @@ -138,7 +136,7 @@ async def get_me(self: 'TelegramClient', input_peer: bool = False) \ try: me = (await self(_tl.fn.users.GetUsers([_tl.InputUserSelf()])))[0] return utils.get_input_peer(me, allow_self=False) if input_peer else me - except errors.UnauthorizedError: + except UnauthorizedError: return None async def is_bot(self: 'TelegramClient') -> bool: @@ -150,7 +148,7 @@ async def is_user_authorized(self: 'TelegramClient') -> bool: # Any request that requires authorization will work await self(_tl.fn.updates.GetState()) self._authorized = True - except errors.RPCError: + except RpcError: self._authorized = False return self._authorized @@ -290,7 +288,7 @@ async def get_input_entity( channels = await self(_tl.fn.channels.GetChannels([ _tl.InputChannel(peer.channel_id, access_hash=0)])) return utils.get_input_peer(channels.chats[0]) - except errors.ChannelInvalidError: + except errors.CHANNEL_INVALID: pass raise ValueError( @@ -338,7 +336,7 @@ async def _get_entity_from_string(self: 'TelegramClient', string): _tl.fn.contacts.GetContacts(0))).users: if user.phone == phone: return user - except errors.BotMethodInvalidError: + except errors.BOT_METHOD_INVALID: raise ValueError('Cannot get entity by phone number as a ' 'bot (try using integer IDs, not strings)') elif string.lower() in ('me', 'self'): @@ -360,7 +358,7 @@ async def _get_entity_from_string(self: 'TelegramClient', string): try: result = await self( _tl.fn.contacts.ResolveUsername(username)) - except errors.UsernameNotOccupiedError as e: + except errors.USERNAME_NOT_OCCUPIED as e: raise ValueError('No user has "{}" as username' .format(username)) from e diff --git a/telethon/_misc/binaryreader.py b/telethon/_misc/binaryreader.py index b0be805b..4117653f 100644 --- a/telethon/_misc/binaryreader.py +++ b/telethon/_misc/binaryreader.py @@ -7,7 +7,7 @@ from datetime import datetime, timezone, timedelta from io import BytesIO from struct import unpack -from ..errors import TypeNotFoundError +from ..errors._custom import TypeNotFoundError from .. import _tl from ..types import _core diff --git a/telethon/_misc/entitycache.py b/telethon/_misc/entitycache.py index 685aa411..a5be14c9 100644 --- a/telethon/_misc/entitycache.py +++ b/telethon/_misc/entitycache.py @@ -3,7 +3,7 @@ import itertools from .._misc import utils from .. import _tl -from ..sessions.types import Entity +from .._sessions.types import Entity # Which updates have the following fields? _has_field = { diff --git a/telethon/_network/authenticator.py b/telethon/_network/authenticator.py index d5b18c56..533eeb5c 100644 --- a/telethon/_network/authenticator.py +++ b/telethon/_network/authenticator.py @@ -8,7 +8,7 @@ from hashlib import sha1 from .. import helpers, _tl from .._crypto import AES, AuthKey, Factorization, rsa -from ..errors import SecurityError +from ..errors._custom import SecurityError from .._misc.binaryreader import BinaryReader diff --git a/telethon/_network/connection/connection.py b/telethon/_network/connection/connection.py index 8bff043d..d206b185 100644 --- a/telethon/_network/connection/connection.py +++ b/telethon/_network/connection/connection.py @@ -13,7 +13,7 @@ try: except ImportError: python_socks = None -from ...errors import InvalidChecksumError +from ...errors._custom import InvalidChecksumError from ... import helpers diff --git a/telethon/_network/connection/tcpfull.py b/telethon/_network/connection/tcpfull.py index 7ebbbe6f..cd60e693 100644 --- a/telethon/_network/connection/tcpfull.py +++ b/telethon/_network/connection/tcpfull.py @@ -2,7 +2,7 @@ import struct from zlib import crc32 from .connection import Connection, PacketCodec -from ...errors import InvalidChecksumError +from ...errors._custom import InvalidChecksumError class FullPacketCodec(PacketCodec): diff --git a/telethon/_network/mtprotoplainsender.py b/telethon/_network/mtprotoplainsender.py index 3a5cffed..427f27ce 100644 --- a/telethon/_network/mtprotoplainsender.py +++ b/telethon/_network/mtprotoplainsender.py @@ -5,7 +5,7 @@ in plain text, when no authorization key has been created yet. import struct from .mtprotostate import MTProtoState -from ..errors import InvalidBufferError +from ..errors._custom import InvalidBufferError from .._misc.binaryreader import BinaryReader diff --git a/telethon/_network/mtprotosender.py b/telethon/_network/mtprotosender.py index 16bd6e32..20e68d72 100644 --- a/telethon/_network/mtprotosender.py +++ b/telethon/_network/mtprotosender.py @@ -4,6 +4,7 @@ import struct from . import authenticator from .._misc.messagepacker import MessagePacker +from ..errors._rpcbase import _mk_error_type from .mtprotoplainsender import MTProtoPlainSender from .requeststate import RequestState from .mtprotostate import MTProtoState @@ -585,12 +586,19 @@ class MTProtoSender: return if rpc_result.error: - error = rpc_message_to_error(rpc_result.error, state.request) self._send_queue.append( RequestState(_tl.MsgsAck([state.msg_id]))) if not state.future.cancelled(): - state.future.set_exception(error) + err_ty = _mk_error_type( + name=rpc_result.error.error_message, + code=rpc_result.error.error_code, + ) + state.future.set_exception(err_ty( + rpc_result.error.error_code, + rpc_result.error.error_message, + state.request + )) else: try: with BinaryReader(rpc_result.body) as reader: diff --git a/telethon/_network/mtprotostate.py b/telethon/_network/mtprotostate.py index f96554ac..19578da3 100644 --- a/telethon/_network/mtprotostate.py +++ b/telethon/_network/mtprotostate.py @@ -4,7 +4,7 @@ import time from hashlib import sha256 from .._crypto import AES -from ..errors import SecurityError, InvalidBufferError +from ..errors._custom import SecurityError, InvalidBufferError from .._misc.binaryreader import BinaryReader from ..types._core import TLMessage, GzipPacked from .._misc.tlobject import TLRequest diff --git a/telethon/errors/__init__.py b/telethon/errors/__init__.py index a50ae36b..152e7823 100644 --- a/telethon/errors/__init__.py +++ b/telethon/errors/__init__.py @@ -1,46 +1,48 @@ -""" -This module holds all the base and automatically generated errors that the -Telegram API has. See telethon_generator/errors.json for more. -""" -import re +import sys -from .common import ( - ReadCancelledError, TypeNotFoundError, InvalidChecksumError, - InvalidBufferError, SecurityError, CdnFileTamperedError, - BadMessageError, MultiError +from ._custom import ( + ReadCancelledError, + TypeNotFoundError, + InvalidChecksumError, + InvalidBufferError, + SecurityError, + CdnFileTamperedError, + BadMessageError, + MultiError, +) +from ._rpcbase import ( + RpcError, + InvalidDcError, + BadRequestError, + UnauthorizedError, + ForbiddenError, + NotFoundError, + AuthKeyError, + FloodError, + ServerError, + BotTimeout, + TimedOutError, + _mk_error_type ) -# This imports the base errors too, as they're imported there -from .rpcbaseerrors import * -from .rpcerrorlist import * +if sys.version_info < (3, 7): + # https://stackoverflow.com/a/7668273/ + class _TelethonErrors: + def __init__(self, _mk_error_type, everything): + self._mk_error_type = _mk_error_type + self.__dict__.update({ + k: v + for k, v in everything.items() + if isinstance(v, type) and issubclass(v, Exception) + }) + def __getattr__(self, name): + return self._mk_error_type(name=name) -def rpc_message_to_error(rpc_error, request): - """ - Converts a Telegram's RPC Error to a Python error. + sys.modules[__name__] = _TelethonErrors(_mk_error_type, globals()) +else: + # https://www.python.org/dev/peps/pep-0562/ + def __getattr__(name): + return _mk_error_type(name=name) - :param rpc_error: the RpcError instance. - :param request: the request that caused this error. - :return: the RPCError as a Python exception that represents this error. - """ - # Try to get the error by direct look-up, otherwise regex - # Case-insensitive, for things like "timeout" which don't conform. - cls = rpc_errors_dict.get(rpc_error.error_message.upper(), None) - if cls: - return cls(request=request) - - for msg_regex, cls in rpc_errors_re: - m = re.match(msg_regex, rpc_error.error_message) - if m: - capture = int(m.group(1)) if m.groups() else None - return cls(request=request, capture=capture) - - # Some errors are negative: - # * -500 for "No workers running", - # * -503 for "Timeout" - # - # We treat them as if they were positive, so -500 will be treated - # as a `ServerError`, etc. - cls = base_errors.get(abs(rpc_error.error_code), RPCError) - return cls(request=request, message=rpc_error.error_message, - code=rpc_error.error_code) +del sys diff --git a/telethon/errors/common.py b/telethon/errors/_custom.py similarity index 100% rename from telethon/errors/common.py rename to telethon/errors/_custom.py diff --git a/telethon/errors/_rpcbase.py b/telethon/errors/_rpcbase.py new file mode 100644 index 00000000..d074be26 --- /dev/null +++ b/telethon/errors/_rpcbase.py @@ -0,0 +1,144 @@ +import re + +from ._generated import _captures, _descriptions +from .. import _tl + + +_NESTS_QUERY = ( + _tl.fn.InvokeAfterMsg, + _tl.fn.InvokeAfterMsgs, + _tl.fn.InitConnection, + _tl.fn.InvokeWithLayer, + _tl.fn.InvokeWithoutUpdates, + _tl.fn.InvokeWithMessagesRange, + _tl.fn.InvokeWithTakeout, +) + + +class RpcError(Exception): + def __init__(self, code, message, request=None): + doc = self.__doc__ + if doc is None: + doc = ( + '\n Please report this error at https://github.com/LonamiWebs/Telethon/issues/3169' + '\n (the library is not aware of it yet and we would appreciate your help, thank you!)' + ) + elif not doc: + doc = '(no description available)' + + super().__init__(f'{message}, code={code}{self._fmt_request(request)}: {doc}') + self.code = code + self.message = message + self.request = request + # Special-case '2fa' to exclude the 2 from values + self.values = [int(x) for x in re.findall(r'-?\d+', re.sub(r'^2fa', '', self.message, flags=re.IGNORECASE))] + self.value = self.values[0] if self.values else None + + @staticmethod + def _fmt_request(request): + if not request: + return '' + + n = 0 + reason = '' + while isinstance(request, _NESTS_QUERY): + n += 1 + reason += request.__class__.__name__ + '(' + request = request.query + reason += request.__class__.__name__ + ')' * n + + return ', request={}'.format(reason) + + def __reduce__(self): + return type(self), (self.request, self.message, self.code) + + +def _mk_error_type(*, name=None, code=None, doc=None, _errors={}) -> type: + if name is None and code is None: + raise ValueError('at least one of `name` or `code` must be provided') + + if name is not None: + # Special-case '2fa' to 'twofa' + name = re.sub(r'^2fa', 'twofa', name, flags=re.IGNORECASE) + + # Get canonical name + name = re.sub(r'[-_\d]', '', name).lower() + while name.endswith('error'): + name = name[:-len('error')] + + doc = _descriptions.get(name, doc) + capture_alias = _captures.get(name) + + d = {'__doc__': doc} + + if capture_alias: + d[capture_alias] = property( + fget=lambda s: s.value, + doc='Alias for `self.value`. Useful to make the code easier to follow.' + ) + + if (name, None) not in _errors: + _errors[(name, None)] = type(f'RpcError{name.title()}', (RpcError,), d) + + if code is not None: + # Pretend negative error codes are positive + code = str(abs(code)) + if (None, code) not in _errors: + _errors[(None, code)] = type(f'RpcError{code}', (RpcError,), {'__doc__': doc}) + + if (name, code) not in _errors: + specific = _errors[(name, None)] + base = _errors[(None, code)] + _errors[(name, code)] = type(f'RpcError{name.title()}{code}', (specific, base), {'__doc__': doc}) + + return _errors[(name, code)] + + +InvalidDcError = _mk_error_type(code=303, doc=""" + The request must be repeated, but directed to a different data center. +""") + +BadRequestError = _mk_error_type(code=400, doc=""" + The query contains errors. In the event that a request was created + using a form and contains user generated data, the user should be + notified that the data must be corrected before the query is repeated. +""") + +UnauthorizedError = _mk_error_type(code=401, doc=""" + There was an unauthorized attempt to use functionality available only + to authorized users. +""") + +ForbiddenError = _mk_error_type(code=403, doc=""" + Privacy violation. For example, an attempt to write a message to + someone who has blacklisted the current user. +""") + +NotFoundError = _mk_error_type(code=404, doc=""" + An attempt to invoke a non-existent object, such as a method. +""") + +AuthKeyError = _mk_error_type(code=406, doc=""" + Errors related to invalid authorization key, like + AUTH_KEY_DUPLICATED which can cause the connection to fail. +""") + +FloodError = _mk_error_type(code=420, doc=""" + The maximum allowed number of attempts to invoke the given method + with the given input parameters has been exceeded. For example, in an + attempt to request a large number of text messages (SMS) for the same + phone number. +""") + +# Witnessed as -500 for "No workers running" +ServerError = _mk_error_type(code=500, doc=""" + An internal server error occurred while a request was being processed + for example, there was a disruption while accessing a database or file + storage. +""") + +# Witnessed as -503 for "Timeout" +BotTimeout = TimedOutError = _mk_error_type(code=503, doc=""" + Clicking the inline buttons of bots that never (or take to long to) + call ``answerCallbackQuery`` will result in this "special" RPCError. +""") diff --git a/telethon/errors/rpcbaseerrors.py b/telethon/errors/rpcbaseerrors.py deleted file mode 100644 index d90bbb02..00000000 --- a/telethon/errors/rpcbaseerrors.py +++ /dev/null @@ -1,131 +0,0 @@ -from .. import _tl - -_NESTS_QUERY = ( - _tl.fn.InvokeAfterMsg, - _tl.fn.InvokeAfterMsgs, - _tl.fn.InitConnection, - _tl.fn.InvokeWithLayer, - _tl.fn.InvokeWithoutUpdates, - _tl.fn.InvokeWithMessagesRange, - _tl.fn.InvokeWithTakeout, -) - -class RPCError(Exception): - """Base class for all Remote Procedure Call errors.""" - code = None - message = None - - def __init__(self, request, message, code=None): - super().__init__('RPCError {}: {}{}'.format( - code or self.code, message, self._fmt_request(request))) - - self.request = request - self.code = code - self.message = message - - @staticmethod - def _fmt_request(request): - n = 0 - reason = '' - while isinstance(request, _NESTS_QUERY): - n += 1 - reason += request.__class__.__name__ + '(' - request = request.query - reason += request.__class__.__name__ + ')' * n - - return ' (caused by {})'.format(reason) - - def __reduce__(self): - return type(self), (self.request, self.message, self.code) - - -class InvalidDCError(RPCError): - """ - The request must be repeated, but directed to a different data center. - """ - code = 303 - message = 'ERROR_SEE_OTHER' - - -class BadRequestError(RPCError): - """ - The query contains errors. In the event that a request was created - using a form and contains user generated data, the user should be - notified that the data must be corrected before the query is repeated. - """ - code = 400 - message = 'BAD_REQUEST' - - -class UnauthorizedError(RPCError): - """ - There was an unauthorized attempt to use functionality available only - to authorized users. - """ - code = 401 - message = 'UNAUTHORIZED' - - -class ForbiddenError(RPCError): - """ - Privacy violation. For example, an attempt to write a message to - someone who has blacklisted the current user. - """ - code = 403 - message = 'FORBIDDEN' - - -class NotFoundError(RPCError): - """ - An attempt to invoke a non-existent object, such as a method. - """ - code = 404 - message = 'NOT_FOUND' - - -class AuthKeyError(RPCError): - """ - Errors related to invalid authorization key, like - AUTH_KEY_DUPLICATED which can cause the connection to fail. - """ - code = 406 - message = 'AUTH_KEY' - - -class FloodError(RPCError): - """ - The maximum allowed number of attempts to invoke the given method - with the given input parameters has been exceeded. For example, in an - attempt to request a large number of text messages (SMS) for the same - phone number. - """ - code = 420 - message = 'FLOOD' - - -class ServerError(RPCError): - """ - An internal server error occurred while a request was being processed - for example, there was a disruption while accessing a database or file - storage. - """ - code = 500 # Also witnessed as -500 - message = 'INTERNAL' - - -class TimedOutError(RPCError): - """ - Clicking the inline buttons of bots that never (or take to long to) - call ``answerCallbackQuery`` will result in this "special" RPCError. - """ - code = 503 # Only witnessed as -503 - message = 'Timeout' - - -BotTimeout = TimedOutError - - -base_errors = {x.code: x for x in ( - InvalidDCError, BadRequestError, UnauthorizedError, ForbiddenError, - NotFoundError, AuthKeyError, FloodError, ServerError, TimedOutError -)} diff --git a/telethon/types/_custom/draft.py b/telethon/types/_custom/draft.py index e81f85ad..82e0cb26 100644 --- a/telethon/types/_custom/draft.py +++ b/telethon/types/_custom/draft.py @@ -1,7 +1,7 @@ import datetime from ... import _tl -from ...errors import RPCError +from ...errors._rpcbase import RpcError from ..._misc import markdown, tlobject from ..._misc.utils import get_input_peer, get_peer @@ -169,7 +169,7 @@ class Draft: def to_dict(self): try: entity = self.entity - except RPCError as e: + except RpcError as e: entity = e return { diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index ae0b395e..4d5d615a 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -6,7 +6,7 @@ from .messagebutton import MessageButton from .forward import Forward from .file import File from ..._misc import utils, tlobject -from ... import errors, _tl +from ... import _tl def _fwd(field, doc): diff --git a/telethon_generator/data/errors.csv b/telethon_generator/data/errors.csv index ff9d8168..71a417d8 100644 --- a/telethon_generator/data/errors.csv +++ b/telethon_generator/data/errors.csv @@ -1,5 +1,5 @@ name,codes,description -2FA_CONFIRM_WAIT_X,420,The account is 2FA protected so it will be deleted in a week. Otherwise it can be reset in {seconds} +2FA_CONFIRM_WAIT_0,420,The account is 2FA protected so it will be deleted in a week. Otherwise it can be reset in {seconds} ABOUT_TOO_LONG,400,The provided bio is too long ACCESS_TOKEN_EXPIRED,400,Bot token expired ACCESS_TOKEN_INVALID,400,The provided token is not valid @@ -101,7 +101,7 @@ DH_G_A_INVALID,400,g_a invalid DOCUMENT_INVALID,400,The document file was invalid and can't be used in inline mode EMAIL_HASH_EXPIRED,400,The email hash expired and cannot be used to verify it EMAIL_INVALID,400,The given email is invalid -EMAIL_UNCONFIRMED_X,400,"Email unconfirmed, the length of the code must be {code_length}" +EMAIL_UNCONFIRMED_0,400,"Email unconfirmed, the length of the code must be {code_length}" EMOJI_INVALID,400, EMOJI_NOT_MODIFIED,400, EMOTICON_EMPTY,400,The emoticon field cannot be empty @@ -124,22 +124,21 @@ FIELD_NAME_INVALID,400,The field with the name FIELD_NAME is invalid FILEREF_UPGRADE_NEEDED,406,The file reference needs to be refreshed before being used again FILE_CONTENT_TYPE_INVALID,400, FILE_ID_INVALID,400,"The provided file id is invalid. Make sure all parameters are present, have the correct type and are not empty (ID, access hash, file reference, thumb size ...)" -FILE_MIGRATE_X,303,The file to be accessed is currently stored in DC {new_dc} +FILE_MIGRATE_0,303,The file to be accessed is currently stored in DC {new_dc} FILE_PARTS_INVALID,400,The number of file parts is invalid -FILE_PART_0_MISSING,400,File part 0 missing FILE_PART_EMPTY,400,The provided file part is empty FILE_PART_INVALID,400,The file part number is invalid FILE_PART_LENGTH_INVALID,400,The length of a file part is invalid FILE_PART_SIZE_CHANGED,400,The file part size (chunk size) cannot change during upload FILE_PART_SIZE_INVALID,400,The provided file part size is invalid -FILE_PART_X_MISSING,400,Part {which} of the file is missing from storage +FILE_PART_0_MISSING,400,Part {which} of the file is missing from storage FILE_REFERENCE_EMPTY,400,The file reference must exist to access the media and it cannot be empty FILE_REFERENCE_EXPIRED,400,The file reference has expired and is no longer valid or it belongs to self-destructing media and cannot be resent FILE_REFERENCE_INVALID,400,The file reference is invalid or you can't do that operation on such message FILE_TITLE_EMPTY,400, FIRSTNAME_INVALID,400,The first name is invalid -FLOOD_TEST_PHONE_WAIT_X,420,A wait of {seconds} seconds is required in the test servers -FLOOD_WAIT_X,420,A wait of {seconds} seconds is required +FLOOD_TEST_PHONE_WAIT_0,420,A wait of {seconds} seconds is required in the test servers +FLOOD_WAIT_0,420,A wait of {seconds} seconds is required FOLDER_ID_EMPTY,400,The folder you tried to delete was already empty FOLDER_ID_INVALID,400,The folder you tried to use was not valid FRESH_CHANGE_ADMINS_FORBIDDEN,400,Recently logged-in users cannot add or change admins @@ -175,8 +174,8 @@ INPUT_LAYER_INVALID,400,The provided layer is invalid INPUT_METHOD_INVALID,400,The invoked method does not exist anymore or has never existed INPUT_REQUEST_TOO_LONG,400,The input request was too long. This may be a bug in the library as it can occur when serializing more bytes than it should (like appending the vector constructor code at the end of a message) INPUT_USER_DEACTIVATED,400,The specified user was deleted -INTERDC_X_CALL_ERROR,500,An error occurred while communicating with DC {dc} -INTERDC_X_CALL_RICH_ERROR,500,A rich error occurred while communicating with DC {dc} +INTERDC_0_CALL_ERROR,500,An error occurred while communicating with DC {dc} +INTERDC_0_CALL_RICH_ERROR,500,A rich error occurred while communicating with DC {dc} INVITE_HASH_EMPTY,400,The invite hash is empty INVITE_HASH_EXPIRED,400,The chat the user tried to join has expired and is not valid anymore INVITE_HASH_INVALID,400,The invite hash is invalid @@ -218,7 +217,7 @@ MT_SEND_QUEUE_TOO_LONG,500, MULTI_MEDIA_TOO_LONG,400,Too many media files were included in the same album NEED_CHAT_INVALID,500,The provided chat is invalid NEED_MEMBER_INVALID,500,The provided member is invalid or does not exist (for example a thumb size) -NETWORK_MIGRATE_X,303,The source IP address is associated with DC {new_dc} +NETWORK_MIGRATE_0,303,The source IP address is associated with DC {new_dc} NEW_SALT_INVALID,400,The new salt is invalid NEW_SETTINGS_INVALID,400,The new settings are invalid NEXT_OFFSET_INVALID,400,The value for next_offset is invalid. Check that it has normal characters and is not too long @@ -238,7 +237,7 @@ PASSWORD_MISSING,400,The account must have 2-factor authentication enabled (a pa PASSWORD_RECOVERY_EXPIRED,400, PASSWORD_RECOVERY_NA,400, PASSWORD_REQUIRED,400,The account must have 2-factor authentication enabled (a password) before this method can be used -PASSWORD_TOO_FRESH_X,400,The password was added too recently and {seconds} seconds must pass before using the method +PASSWORD_TOO_FRESH_0,400,The password was added too recently and {seconds} seconds must pass before using the method PAYMENT_PROVIDER_INVALID,400,The payment provider was not recognised or its token was invalid PEER_FLOOD,400,Too many requests PEER_ID_INVALID,400,"An invalid Peer was used. Make sure to pass the right peer type and that the value is valid (for instance, bots cannot start conversations)" @@ -250,7 +249,7 @@ PHONE_CODE_EMPTY,400,The phone code is missing PHONE_CODE_EXPIRED,400,The confirmation code has expired PHONE_CODE_HASH_EMPTY,400,The phone code hash is missing PHONE_CODE_INVALID,400,The phone code entered was invalid -PHONE_MIGRATE_X,303,The phone number a user is trying to use for authorization is associated with DC {new_dc} +PHONE_MIGRATE_0,303,The phone number a user is trying to use for authorization is associated with DC {new_dc} PHONE_NUMBER_APP_SIGNUP_FORBIDDEN,400,You can't sign up using this app PHONE_NUMBER_BANNED,400,The used phone number has been banned from Telegram and cannot be used anymore. Maybe check https://www.telegram.org/faq_spam PHONE_NUMBER_FLOOD,400,You asked for the code too many times. @@ -276,7 +275,7 @@ POLL_OPTION_INVALID,400,A poll option used invalid data (the data may be too lon POLL_QUESTION_INVALID,400,The poll question was either empty or too long POLL_UNSUPPORTED,400,This layer does not support polls in the issued method POLL_VOTE_REQUIRED,403, -PREVIOUS_CHAT_IMPORT_ACTIVE_WAIT_XMIN,406,"Similar to a flood wait, must wait {minutes} minutes" +PREVIOUS_CHAT_IMPORT_ACTIVE_WAIT_0MIN,406,"Similar to a flood wait, must wait {minutes} minutes" PRIVACY_KEY_INVALID,400,The privacy key is invalid PRIVACY_TOO_LONG,400,Cannot add that many entities in a single request PRIVACY_VALUE_INVALID,400,The privacy value is invalid @@ -322,16 +321,16 @@ SENSITIVE_CHANGE_FORBIDDEN,403,Your sensitive content settings cannot be changed SESSION_EXPIRED,401,The authorization has expired SESSION_PASSWORD_NEEDED,401,Two-steps verification is enabled and a password is required SESSION_REVOKED,401,"The authorization has been invalidated, because of the user terminating all sessions" -SESSION_TOO_FRESH_X,400,The session logged in too recently and {seconds} seconds must pass before calling the method +SESSION_TOO_FRESH_0,400,The session logged in too recently and {seconds} seconds must pass before calling the method SHA256_HASH_INVALID,400,The provided SHA256 hash is invalid SHORTNAME_OCCUPY_FAILED,400,An error occurred when trying to register the short-name used for the sticker pack. Try a different name SHORT_NAME_INVALID,400, SHORT_NAME_OCCUPIED,400, -SLOWMODE_WAIT_X,420,A wait of {seconds} seconds is required before sending another message in this chat +SLOWMODE_WAIT_0,420,A wait of {seconds} seconds is required before sending another message in this chat SRP_ID_INVALID,400, START_PARAM_EMPTY,400,The start parameter is empty START_PARAM_INVALID,400,Start parameter invalid -STATS_MIGRATE_X,303,The channel statistics must be fetched from DC {dc} +STATS_MIGRATE_0,303,The channel statistics must be fetched from DC {dc} STICKERSET_INVALID,400,The provided sticker set is invalid STICKERSET_OWNER_ANONYMOUS,406,This sticker set can't be used as the group's official stickers because it was created by one of its anonymous admins STICKERS_EMPTY,400,No sticker provided @@ -348,7 +347,7 @@ STICKER_THUMB_PNG_NOPNG,400,Stickerset thumb must be a png file but the used fil STICKER_THUMB_TGS_NOTGS,400,Stickerset thumb must be a tgs file but the used file was not tgs STORAGE_CHECK_FAILED,500,Server storage check failed STORE_INVALID_SCALAR_TYPE,500, -TAKEOUT_INIT_DELAY_X,420,A wait of {seconds} seconds is required before being able to initiate the takeout +TAKEOUT_INIT_DELAY_0,420,A wait of {seconds} seconds is required before being able to initiate the takeout TAKEOUT_INVALID,400,The takeout session has been invalidated by another data export session TAKEOUT_REQUIRED,400,You must initialize a takeout request first TEMP_AUTH_KEY_EMPTY,400,No temporary auth key provided @@ -391,7 +390,7 @@ USER_INVALID,400,The given user was invalid USER_IS_BLOCKED,400 403,User is blocked USER_IS_BOT,400,Bots can't send messages to other bots USER_KICKED,400,This user was kicked from this supergroup/channel -USER_MIGRATE_X,303,The user whose identity is being used to execute queries is associated with DC {new_dc} +USER_MIGRATE_0,303,The user whose identity is being used to execute queries is associated with DC {new_dc} USER_NOT_MUTUAL_CONTACT,400 403,The provided user is not a mutual contact USER_NOT_PARTICIPANT,400,The target user is not a member of the specified megagroup or channel USER_PRIVACY_RESTRICTED,403,The user's privacy settings do not allow you to do this diff --git a/telethon_generator/data/methods.csv b/telethon_generator/data/methods.csv index 4427492b..135d5618 100644 --- a/telethon_generator/data/methods.csv +++ b/telethon_generator/data/methods.csv @@ -7,7 +7,7 @@ account.confirmPasswordEmail,user, account.confirmPhone,user,CODE_HASH_INVALID PHONE_CODE_EMPTY account.createTheme,user,THEME_MIME_INVALID account.declinePasswordReset,user,RESET_REQUEST_MISSING -account.deleteAccount,user,2FA_CONFIRM_WAIT_X +account.deleteAccount,user,2FA_CONFIRM_WAIT_0 account.deleteSecureValue,user, account.finishTakeoutSession,user, account.getAccountTTL,user, @@ -57,7 +57,7 @@ account.setPrivacy,user,PRIVACY_KEY_INVALID PRIVACY_TOO_LONG account.unregisterDevice,user,TOKEN_INVALID account.updateDeviceLocked,user, account.updateNotifySettings,user,PEER_ID_INVALID -account.updatePasswordSettings,user,EMAIL_UNCONFIRMED_X NEW_SALT_INVALID NEW_SETTINGS_INVALID PASSWORD_HASH_INVALID +account.updatePasswordSettings,user,EMAIL_UNCONFIRMED_0 NEW_SALT_INVALID NEW_SETTINGS_INVALID PASSWORD_HASH_INVALID account.updateProfile,user,ABOUT_TOO_LONG FIRSTNAME_INVALID account.updateStatus,user,SESSION_PASSWORD_NEEDED account.updateTheme,user,THEME_INVALID @@ -97,7 +97,7 @@ channels.deleteMessages,both,CHANNEL_INVALID CHANNEL_PRIVATE MESSAGE_DELETE_FORB channels.deleteUserHistory,user,CHANNEL_INVALID CHAT_ADMIN_REQUIRED channels.editAdmin,both,ADMINS_TOO_MUCH ADMIN_RANK_EMOJI_NOT_ALLOWED ADMIN_RANK_INVALID BOT_CHANNELS_NA CHANNEL_INVALID CHAT_ADMIN_INVITE_REQUIRED CHAT_ADMIN_REQUIRED FRESH_CHANGE_ADMINS_FORBIDDEN RIGHT_FORBIDDEN USER_CREATOR USER_ID_INVALID USER_NOT_MUTUAL_CONTACT USER_PRIVACY_RESTRICTED channels.editBanned,both,CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED USER_ADMIN_INVALID USER_ID_INVALID -channels.editCreator,user,PASSWORD_MISSING PASSWORD_TOO_FRESH_X SESSION_TOO_FRESH_X SRP_ID_INVALID +channels.editCreator,user,PASSWORD_MISSING PASSWORD_TOO_FRESH_0 SESSION_TOO_FRESH_0 SRP_ID_INVALID channels.editLocation,user, channels.editPhoto,both,CHANNEL_INVALID CHAT_ADMIN_REQUIRED FILE_REFERENCE_INVALID PHOTO_INVALID channels.editTitle,both,CHANNEL_INVALID CHAT_ADMIN_REQUIRED CHAT_NOT_MODIFIED @@ -251,7 +251,7 @@ messages.getWebPage,user,WC_CONVERT_URL_INVALID messages.getWebPagePreview,user, messages.hidePeerSettingsBar,user, messages.importChatInvite,user,CHANNELS_TOO_MUCH INVITE_HASH_EMPTY INVITE_HASH_EXPIRED INVITE_HASH_INVALID SESSION_PASSWORD_NEEDED USERS_TOO_MUCH USER_ALREADY_PARTICIPANT -messages.initHistoryImport,user,IMPORT_FILE_INVALID IMPORT_FORMAT_UNRECOGNIZED PREVIOUS_CHAT_IMPORT_ACTIVE_WAIT_XMIN TIMEOUT +messages.initHistoryImport,user,IMPORT_FILE_INVALID IMPORT_FORMAT_UNRECOGNIZED PREVIOUS_CHAT_IMPORT_ACTIVE_WAIT_0MIN TIMEOUT messages.installStickerSet,user,STICKERSET_INVALID messages.markDialogUnread,user, messages.migrateChat,user,CHAT_ADMIN_REQUIRED CHAT_ID_INVALID PEER_ID_INVALID @@ -337,8 +337,8 @@ reqPq,both, reqPqMulti,both, rpcDropAnswer,both, setClientDHParams,both, -stats.getBroadcastStats,user,BROADCAST_REQUIRED CHAT_ADMIN_REQUIRED CHP_CALL_FAIL STATS_MIGRATE_X -stats.getMegagroupStats,user,CHAT_ADMIN_REQUIRED MEGAGROUP_REQUIRED STATS_MIGRATE_X +stats.getBroadcastStats,user,BROADCAST_REQUIRED CHAT_ADMIN_REQUIRED CHP_CALL_FAIL STATS_MIGRATE_0 +stats.getMegagroupStats,user,CHAT_ADMIN_REQUIRED MEGAGROUP_REQUIRED STATS_MIGRATE_0 stats.loadAsyncGraph,user,GRAPH_INVALID_RELOAD GRAPH_OUTDATED_RELOAD stickers.addStickerToSet,bot,BOT_MISSING STICKERSET_INVALID STICKER_PNG_NOPNG STICKER_TGS_NOTGS stickers.changeStickerPosition,bot,BOT_MISSING STICKER_INVALID diff --git a/telethon_generator/generators/errors.py b/telethon_generator/generators/errors.py index 386575be..5771369c 100644 --- a/telethon_generator/generators/errors.py +++ b/telethon_generator/generators/errors.py @@ -1,60 +1,12 @@ def generate_errors(errors, f): - # Exact/regex match to create {CODE: ErrorClassName} - exact_match = [] - regex_match = [] - - # Find out what subclasses to import and which to create - import_base, create_base = set(), {} + f.write('_captures = {\n') for error in errors: - if error.subclass_exists: - import_base.add(error.subclass) - else: - create_base[error.subclass] = error.int_code + if error.capture_name: + f.write(f" {error.canonical_name!r}: {error.capture_name!r},\n") + f.write('}\n') - if error.has_captures: - regex_match.append(error) - else: - exact_match.append(error) - - # Imports and new subclass creation - f.write('from .rpcbaseerrors import RPCError, {}\n' - .format(", ".join(sorted(import_base)))) - - for cls, int_code in sorted(create_base.items(), key=lambda t: t[1]): - f.write('\n\nclass {}(RPCError):\n code = {}\n' - .format(cls, int_code)) - - # Error classes generation + f.write('\n\n_descriptions = {\n') for error in errors: - f.write('\n\nclass {}({}):\n '.format(error.name, error.subclass)) - - if error.has_captures: - f.write('def __init__(self, request, capture=0):\n ' - ' self.request = request\n ') - f.write(' self.{} = int(capture)\n ' - .format(error.capture_name)) - else: - f.write('def __init__(self, request):\n ' - ' self.request = request\n ') - - f.write('super(Exception, self).__init__(' - '{}'.format(repr(error.description))) - - if error.has_captures: - f.write('.format({0}=self.{0})'.format(error.capture_name)) - - f.write(' + self._fmt_request(self.request))\n\n') - f.write(' def __reduce__(self):\n ') - if error.has_captures: - f.write('return type(self), (self.request, self.{})\n'.format(error.capture_name)) - else: - f.write('return type(self), (self.request,)\n') - - # Create the actual {CODE: ErrorClassName} dict once classes are defined - f.write('\n\nrpc_errors_dict = {\n') - for error in exact_match: - f.write(' {}: {},\n'.format(repr(error.pattern), error.name)) - f.write('}\n\nrpc_errors_re = (\n') - for error in regex_match: - f.write(' ({}, {}),\n'.format(repr(error.pattern), error.name)) - f.write(')\n') + if error.description: + f.write(f" {error.canonical_name!r}: {error.description!r},\n") + f.write('}\n') diff --git a/telethon_generator/parsers/errors.py b/telethon_generator/parsers/errors.py index 04cd3412..0982edea 100644 --- a/telethon_generator/parsers/errors.py +++ b/telethon_generator/parsers/errors.py @@ -17,25 +17,16 @@ KNOWN_BASE_CLASSES = { } -def _get_class_name(error_code): +def _get_canonical_name(error_code): """ - Gets the corresponding class name for the given error code, - this either being an integer (thus base error name) or str. + Gets the corresponding canonical name for the given error code. """ - if isinstance(error_code, int): - return KNOWN_BASE_CLASSES.get( - abs(error_code), 'RPCError' + str(error_code).replace('-', 'Neg') - ) + # This code should match that of the library itself. + name = re.sub(r'[-_\d]', '', error_code).lower() + while name.endswith('error'): + name = name[:-len('error')] - if error_code.startswith('2'): - error_code = re.sub(r'2', 'TWO_', error_code, count=1) - - if re.match(r'\d+', error_code): - raise RuntimeError('error code starting with a digit cannot have valid Python name: {}'.format(error_code)) - - return snake_to_camel_case( - error_code.replace('FIRSTNAME', 'FIRST_NAME')\ - .replace('SLOWMODE', 'SLOW_MODE').lower(), suffix='Error') + return name class Error: @@ -45,18 +36,13 @@ class Error: # Telegram isn't exactly consistent with returned errors anyway. self.int_code = codes[0] self.str_code = name - self.subclass = _get_class_name(codes[0]) - self.subclass_exists = abs(codes[0]) in KNOWN_BASE_CLASSES + self.canonical_name = _get_canonical_name(name) self.description = description - self.has_captures = '_X' in name - if self.has_captures: - self.name = _get_class_name(name.replace('_X', '_')) - self.pattern = name.replace('_X', r'_(\d+)') + has_captures = '0' in name + if has_captures: self.capture_name = re.search(r'{(\w+)}', description).group(1) else: - self.name = _get_class_name(name) - self.pattern = name self.capture_name = None From ce292b36abc29999b86fd9dd595c617b6cc6a7a3 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 24 Sep 2021 20:12:23 +0200 Subject: [PATCH 063/131] Fix GROUP check in EntityCache --- telethon/_misc/entitycache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/_misc/entitycache.py b/telethon/_misc/entitycache.py index a5be14c9..87bde9fa 100644 --- a/telethon/_misc/entitycache.py +++ b/telethon/_misc/entitycache.py @@ -100,7 +100,7 @@ class EntityCache: if id not in self.__dict__: if ty in (Entity.USER, Entity.BOT): self.__dict__[id] = _tl.InputPeerUser(id, access_hash) - elif ty in (Entity.GROUP): + elif ty in (Entity.GROUP,): self.__dict__[id] = _tl.InputPeerChat(id) elif ty in (Entity.CHANNEL, Entity.MEGAGROUP, Entity.GIGAGROUP): self.__dict__[id] = _tl.InputPeerChannel(id, access_hash) From 5a44510e2dc65bc7829a83290f6a28bfca9105fd Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 24 Sep 2021 20:46:33 +0200 Subject: [PATCH 064/131] Forward client calls to impl in a more straightforward manner --- telethon/_client/telegramclient.py | 168 +++++++++++++++-------------- 1 file changed, 87 insertions(+), 81 deletions(-) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 15a7db75..54ab9710 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -15,6 +15,12 @@ from ..events.common import EventBuilder, EventCommon from .._misc import enums +def forward_call(to_func): + def decorator(from_func): + return functools.wraps(from_func)(to_func) + return decorator + + class TelegramClient: """ Arguments @@ -165,6 +171,7 @@ class TelegramClient: # region Account + @forward_call(account.takeout) def takeout( self: 'TelegramClient', finalize: bool = True, @@ -256,8 +263,8 @@ class TelegramClient: except errors.TakeoutInitDelayError as e: print('Must wait', e.seconds, 'before takeout') """ - return account.takeout(**locals()) + @forward_call(account.end_takeout) async def end_takeout(self: 'TelegramClient', success: bool) -> bool: """ Finishes the current takeout session. @@ -274,12 +281,12 @@ class TelegramClient: await client.end_takeout(success=False) """ - return await account.end_takeout(**locals()) # endregion Account # region Auth + @forward_call(auth.start) def start( self: 'TelegramClient', phone: typing.Callable[[], str] = lambda: input('Please enter your phone (or bot token): '), @@ -365,8 +372,8 @@ class TelegramClient: async with client.start(): pass """ - return auth.start(**locals()) + @forward_call(auth.sign_in) async def sign_in( self: 'TelegramClient', phone: str = None, @@ -422,8 +429,8 @@ class TelegramClient: code = input('enter code: ') await client.sign_in(phone, code) """ - return await auth.sign_in(**locals()) + @forward_call(auth.sign_up) async def sign_up( self: 'TelegramClient', code: typing.Union[str, int], @@ -474,8 +481,8 @@ class TelegramClient: code = input('enter code: ') await client.sign_up(code, first_name='Anna', last_name='Banana') """ - return await auth.sign_up(**locals()) + @forward_call(auth.send_code_request) async def send_code_request( self: 'TelegramClient', phone: str, @@ -501,8 +508,8 @@ class TelegramClient: sent = await client.send_code_request(phone) print(sent) """ - return await auth.send_code_request(**locals()) + @forward_call(auth.qr_login) async def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> _custom.QRLogin: """ Initiates the QR login procedure. @@ -536,8 +543,8 @@ class TelegramClient: # Important! You need to wait for the login to complete! await qr_login.wait() """ - return await auth.qr_login(**locals()) + @forward_call(auth.log_out) async def log_out(self: 'TelegramClient') -> bool: """ Logs out Telegram and deletes the current ``*.session`` file. @@ -551,8 +558,8 @@ class TelegramClient: # Note: you will need to login again! await client.log_out() """ - return await auth.log_out(**locals()) + @forward_call(auth.edit_2fa) async def edit_2fa( self: 'TelegramClient', current_password: str = None, @@ -614,7 +621,6 @@ class TelegramClient: # Removing the password await client.edit_2fa(current_password='I_<3_Telethon') """ - return await auth.edit_2fa(**locals()) async def __aenter__(self): await self.connect() @@ -627,6 +633,7 @@ class TelegramClient: # region Bots + @forward_call(bots.inline_query) async def inline_query( self: 'TelegramClient', bot: 'hints.EntityLike', @@ -674,7 +681,6 @@ class TelegramClient: # Send the first result to some chat message = await results[0].click('TelethonOffTopic') """ - return await bots.inline_query(**locals()) # endregion Bots @@ -720,6 +726,7 @@ class TelegramClient: # region Chats + @forward_call(chats.get_participants) def get_participants( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -789,8 +796,8 @@ class TelegramClient: users = await client.get_participants(chat, limit=0) print(users.total) """ - return chats.get_participants(**locals()) + @forward_call(chats.get_admin_log) def get_admin_log( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -924,8 +931,8 @@ class TelegramClient: # Print the old message before it was deleted print(events[-1].old) """ - return chats.get_admin_log(**locals()) + @forward_call(chats.get_profile_photos) def get_profile_photos( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -972,8 +979,8 @@ class TelegramClient: photos = await client.get_profile_photos(channel, limit=None) await client.download_media(photos[-1]) """ - return chats.get_profile_photos(**locals()) + @forward_call(chats.action) def action( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -1050,8 +1057,8 @@ class TelegramClient: async with client.action(chat, 'document') as action: await client.send_file(chat, zip_file, progress_callback=action.progress) """ - return chats.action(**locals()) + @forward_call(chats.edit_admin) async def edit_admin( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -1156,8 +1163,8 @@ class TelegramClient: # Granting all permissions except for `add_admins` await client.edit_admin(chat, user, is_admin=True, add_admins=False) """ - return await chats.edit_admin(**locals()) + @forward_call(chats.edit_permissions) async def edit_permissions( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -1273,8 +1280,8 @@ class TelegramClient: await client.edit_permissions(chat, user, view_messages=False) await client.edit_permissions(chat, user) """ - return await chats.edit_permissions(**locals()) + @forward_call(chats.kick_participant) async def kick_participant( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -1312,8 +1319,8 @@ class TelegramClient: # Leaving chat await client.kick_participant(chat, 'me') """ - return await chats.kick_participant(**locals()) + @forward_call(chats.get_permissions) async def get_permissions( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -1350,8 +1357,8 @@ class TelegramClient: # Get Banned Permissions of Chat await client.get_permissions(chat) """ - return await chats.get_permissions(**locals()) + @forward_call(chats.get_stats) async def get_stats( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -1396,12 +1403,12 @@ class TelegramClient: .. _`at least 500 members`: https://telegram.org/blog/profile-videos-people-nearby-and-more """ - return await chats.get_stats(**locals()) # endregion Chats # region Dialogs + @forward_call(dialogs.get_dialogs) def get_dialogs( self: 'TelegramClient', limit: float = (), @@ -1497,8 +1504,8 @@ class TelegramClient: archived = await client.get_dialogs(folder=1, limit=None) archived = await client.get_dialogs(archived=True, limit=None) """ - return dialogs.get_dialogs(**locals()) + @forward_call(dialogs.get_drafts) def get_drafts( self: 'TelegramClient', entity: 'hints.EntitiesLike' = None @@ -1531,8 +1538,8 @@ class TelegramClient: draft = await client.get_drafts('me') print(draft.text) """ - return dialogs.get_drafts(**locals()) + @forward_call(dialogs.edit_folder) async def edit_folder( self: 'TelegramClient', entity: 'hints.EntitiesLike' = None, @@ -1590,8 +1597,8 @@ class TelegramClient: # Un-archiving all dialogs await client.edit_folder(unpack=1) """ - return await dialogs.edit_folder(**locals()) + @forward_call(dialogs.delete_dialog) async def delete_dialog( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -1635,12 +1642,12 @@ class TelegramClient: # Leaving a channel by username await client.delete_dialog('username') """ - return await dialogs.delete_dialog(**locals()) # endregion Dialogs # region Downloads + @forward_call(downloads.download_profile_photo) async def download_profile_photo( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -1684,8 +1691,8 @@ class TelegramClient: path = await client.download_profile_photo('me') print(path) """ - return await downloads.download_profile_photo(**locals()) + @forward_call(downloads.download_media) async def download_media( self: 'TelegramClient', message: 'hints.MessageLike', @@ -1760,8 +1767,8 @@ class TelegramClient: await client.download_media(message, progress_callback=callback) """ - return await downloads.download_media(**locals()) + @forward_call(downloads.iter_download) def iter_download( self: 'TelegramClient', file: 'hints.FileLike', @@ -1857,7 +1864,6 @@ class TelegramClient: await stream.close() assert len(header) == 32 """ - return downloads.iter_download(**locals()) # endregion Downloads @@ -1907,6 +1913,7 @@ class TelegramClient: # region Messages + @forward_call(messages.get_messages) def get_messages( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -2104,8 +2111,8 @@ class TelegramClient: # Get a single message given an ID: message_1337 = await client.get_messages(chat, ids=1337) """ - return messages.get_messages(**locals()) + @forward_call(messages.send_message) async def send_message( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -2298,8 +2305,8 @@ class TelegramClient: from datetime import timedelta await client.send_message(chat, 'Hi, future!', schedule=timedelta(minutes=5)) """ - return await messages.send_message(**locals()) + @forward_call(messages.forward_messages) async def forward_messages( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -2380,19 +2387,8 @@ class TelegramClient: # Forwarding as a copy await client.send_message(chat, message) """ - from . import messages as m - return await m.forward_messages( - self=self, - entity=entity, - messages=messages, - from_peer=from_peer, - background=background, - with_my_score=with_my_score, - silent=silent, - as_album=as_album, - schedule=schedule - ) + @forward_call(messages.edit_message) async def edit_message( self: 'TelegramClient', entity: 'typing.Union[hints.EntityLike, _tl.Message]', @@ -2520,8 +2516,8 @@ class TelegramClient: # or await message.edit('hello!!!') """ - return await messages.edit_message(**locals()) + @forward_call(messages.delete_messages) async def delete_messages( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -2572,8 +2568,8 @@ class TelegramClient: await client.delete_messages(chat, messages) """ - return await messages.delete_messages(**locals()) + @forward_call(messages.send_read_acknowledge) async def send_read_acknowledge( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -2624,8 +2620,8 @@ class TelegramClient: # ...or passing a list of messages to mark as read await client.send_read_acknowledge(chat, messages) """ - return await messages.send_read_acknowledge(**locals()) + @forward_call(messages.pin_message) async def pin_message( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -2665,8 +2661,8 @@ class TelegramClient: message = await client.send_message(chat, 'Pinotifying is fun!') await client.pin_message(chat, message, notify=True) """ - return await messages.pin_message(**locals()) + @forward_call(messages.unpin_message) async def unpin_message( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -2695,7 +2691,6 @@ class TelegramClient: # Unpin all messages from a chat await client.unpin_message(chat) """ - return await messages.unpin_message(**locals()) # endregion Messages @@ -2731,7 +2726,7 @@ class TelegramClient: base_logger: typing.Union[str, logging.Logger] = None, receive_updates: bool = True ): - return telegrambaseclient.init(**locals()) + telegrambaseclient.init(**locals()) @property def loop(self: 'TelegramClient') -> asyncio.AbstractEventLoop: @@ -2760,6 +2755,7 @@ class TelegramClient: def flood_sleep_threshold(self, value): return telegrambaseclient.set_flood_sleep_threshold(**locals()) + @forward_call(telegrambaseclient.connect) async def connect(self: 'TelegramClient') -> None: """ Connects to Telegram. @@ -2782,8 +2778,8 @@ class TelegramClient: except OSError: print('Failed to connect') """ - return await telegrambaseclient.connect(**locals()) + @forward_call(telegrambaseclient.is_connected) def is_connected(self: 'TelegramClient') -> bool: """ Returns `True` if the user has connected. @@ -2796,8 +2792,8 @@ class TelegramClient: while client.is_connected(): await asyncio.sleep(1) """ - return telegrambaseclient.is_connected(**locals()) + @forward_call(telegrambaseclient.disconnect) def disconnect(self: 'TelegramClient'): """ Disconnects from Telegram. @@ -2812,8 +2808,8 @@ class TelegramClient: # You don't need to use this if you used "with client" await client.disconnect() """ - return telegrambaseclient.disconnect(**locals()) + @forward_call(telegrambaseclient.set_proxy) def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): """ Changes the proxy which will be used on next (re)connection. @@ -2824,12 +2820,12 @@ class TelegramClient: - on a call `await client.connect()` (after complete disconnect) - on auto-reconnect attempt (e.g, after previous connection was lost) """ - return telegrambaseclient.set_proxy(**locals()) # endregion Base # region Updates + @forward_call(updates.set_receive_updates) async def set_receive_updates(self: 'TelegramClient', receive_updates): """ Change the value of `receive_updates`. @@ -2837,8 +2833,8 @@ class TelegramClient: This is an `async` method, because in order for Telegram to start sending updates again, a request must be made. """ - return await updates.set_receive_updates(**locals()) + @forward_call(updates.run_until_disconnected) def run_until_disconnected(self: 'TelegramClient'): """ Wait until the library is disconnected. @@ -2870,8 +2866,8 @@ class TelegramClient: # script from exiting. await client.run_until_disconnected() """ - return updates.run_until_disconnected(**locals()) + @forward_call(updates.on) def on(self: 'TelegramClient', event: EventBuilder): """ Decorator used to `add_event_handler` more conveniently. @@ -2893,8 +2889,8 @@ class TelegramClient: async def handler(event): ... """ - return updates.on(**locals()) + @forward_call(updates.add_event_handler) def add_event_handler( self: 'TelegramClient', callback: updates.Callback, @@ -2931,8 +2927,8 @@ class TelegramClient: client.add_event_handler(handler, events.NewMessage) """ - return updates.add_event_handler(**locals()) + @forward_call(updates.remove_event_handler) def remove_event_handler( self: 'TelegramClient', callback: updates.Callback, @@ -2958,8 +2954,8 @@ class TelegramClient: # "handler" will stop receiving anything client.remove_event_handler(handler) """ - return updates.remove_event_handler(**locals()) + @forward_call(updates.list_event_handlers) def list_event_handlers(self: 'TelegramClient')\ -> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]': """ @@ -2979,8 +2975,8 @@ class TelegramClient: for callback, event in client.list_event_handlers(): print(id(callback), type(event)) """ - return updates.list_event_handlers(**locals()) + @forward_call(updates.catch_up) async def catch_up(self: 'TelegramClient'): """ "Catches up" on the missed updates while the client was offline. @@ -2994,12 +2990,12 @@ class TelegramClient: await client.catch_up() """ - return await updates.catch_up(**locals()) # endregion Updates # region Uploads + @forward_call(uploads.send_file) async def send_file( self: 'TelegramClient', entity: 'hints.EntityLike', @@ -3239,8 +3235,8 @@ class TelegramClient: vcard='' )) """ - return await uploads.send_file(**locals()) + @forward_call(uploads.upload_file) async def upload_file( self: 'TelegramClient', file: 'hints.FileLike', @@ -3326,12 +3322,12 @@ class TelegramClient: await client.send_file(chat, file) # sends as song await client.send_file(chat, file, voice_note=True) # sends as voice note """ - return await uploads.upload_file(**locals()) # endregion Uploads # region Users + @forward_call(users.call) async def __call__(self: 'TelegramClient', request, ordered=False, flood_sleep_threshold=None): """ Invokes (sends) one or more MTProtoRequests and returns (receives) @@ -3355,8 +3351,8 @@ class TelegramClient: The result of the request (often a `TLObject`) or a list of results if more than one request was given. """ - return await users.call(**locals()) + @forward_call(users.get_me) async def get_me(self: 'TelegramClient', input_peer: bool = False) \ -> 'typing.Union[_tl.User, _tl.InputPeerUser]': """ @@ -3379,8 +3375,8 @@ class TelegramClient: me = await client.get_me() print(me.username) """ - return await users.get_me(**locals()) + @forward_call(users.is_bot) async def is_bot(self: 'TelegramClient') -> bool: """ Return `True` if the signed-in user is a bot, `False` otherwise. @@ -3393,8 +3389,8 @@ class TelegramClient: else: print('Hello') """ - return await users.is_bot(**locals()) + @forward_call(users.is_user_authorized) async def is_user_authorized(self: 'TelegramClient') -> bool: """ Returns `True` if the user is authorized (logged in). @@ -3407,8 +3403,8 @@ class TelegramClient: code = input('enter code: ') await client.sign_in(phone, code) """ - return await users.is_user_authorized(**locals()) + @forward_call(users.get_entity) async def get_entity( self: 'TelegramClient', entity: 'hints.EntitiesLike') -> 'hints.Entity': @@ -3465,8 +3461,8 @@ class TelegramClient: # Note that for this to work the phone number must be in your contacts some_id = await client.get_peer_id('+34123456789') """ - return await users.get_entity(**locals()) + @forward_call(users.get_input_entity) async def get_input_entity( self: 'TelegramClient', peer: 'hints.EntityLike') -> '_tl.TypeInputPeer': @@ -3531,8 +3527,8 @@ class TelegramClient: # The same applies to IDs, chats or channels. chat = await client.get_input_entity(-123456789) """ - return await users.get_input_entity(**locals()) + @forward_call(users.get_peer_id) async def get_peer_id( self: 'TelegramClient', peer: 'hints.EntityLike') -> int: @@ -3547,60 +3543,70 @@ class TelegramClient: print(await client.get_peer_id('me')) """ - return await users.get_peer_id(**locals()) # endregion Users # region Private + @forward_call(users._call) async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None): - return await users._call(**locals()) + pass + @forward_call(updates._update_loop) async def _update_loop(self: 'TelegramClient'): - return await updates._update_loop(**locals()) + pass + @forward_call(messageparse._parse_message_text) async def _parse_message_text(self: 'TelegramClient', message, parse_mode): - return await messageparse._parse_message_text(**locals()) + pass + @forward_call(uploads._file_to_media) async def _file_to_media( self, file, force_document=False, file_size=None, progress_callback=None, attributes=None, thumb=None, allow_cache=True, voice_note=False, video_note=False, supports_streaming=False, mime_type=None, as_image=None, ttl=None): - return await uploads._file_to_media(**locals()) + pass + @forward_call(messageparse._get_response_message) def _get_response_message(self: 'TelegramClient', request, result, input_chat): - return messageparse._get_response_message(**locals()) + pass + @forward_call(messages._get_comment_data) async def _get_comment_data( self: 'TelegramClient', entity: 'hints.EntityLike', message: 'typing.Union[int, _tl.Message]' ): - return await messages._get_comment_data(**locals()) + pass + @forward_call(telegrambaseclient._switch_dc) async def _switch_dc(self: 'TelegramClient', new_dc): - return await telegrambaseclient._switch_dc(**locals()) + pass + @forward_call(telegrambaseclient._borrow_exported_sender) async def _borrow_exported_sender(self: 'TelegramClient', dc_id): - return await telegrambaseclient._borrow_exported_sender(**locals()) + pass + @forward_call(telegrambaseclient._return_exported_sender) async def _return_exported_sender(self: 'TelegramClient', sender): - return await telegrambaseclient._return_exported_sender(**locals()) + pass + @forward_call(telegrambaseclient._clean_exported_senders) async def _clean_exported_senders(self: 'TelegramClient'): - return await telegrambaseclient._clean_exported_senders(**locals()) + pass + @forward_call(updates._handle_update) def _handle_update(self: 'TelegramClient', update): - return updates._handle_update(**locals()) + pass + @forward_call(updates._handle_auto_reconnect) async def _handle_auto_reconnect(self: 'TelegramClient'): - return await updates._handle_auto_reconnect(**locals()) + pass + @forward_call(auth._update_session_state) async def _update_session_state(self, user, save=True): - return await auth._update_session_state(**locals()) + pass # endregion Private - -# TODO re-patch everything to remove the intermediate calls From 1762f554df897ba000acd16034c102ec54c406ca Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 24 Sep 2021 21:11:50 +0200 Subject: [PATCH 065/131] Make events subpackage private --- telethon/_client/telegramclient.py | 2 +- telethon/_client/updates.py | 16 +++++++++------- telethon/_events/__init__.py | 0 telethon/{events => _events}/album.py | 0 telethon/{events/__init__.py => _events/base.py} | 9 --------- telethon/{events => _events}/callbackquery.py | 0 telethon/{events => _events}/chataction.py | 0 telethon/{events => _events}/common.py | 0 telethon/{events => _events}/inlinequery.py | 0 telethon/{events => _events}/messagedeleted.py | 0 telethon/{events => _events}/messageedited.py | 0 telethon/{events => _events}/messageread.py | 0 telethon/{events => _events}/newmessage.py | 0 telethon/{events => _events}/raw.py | 0 telethon/{events => _events}/userupdate.py | 0 telethon/events.py | 12 ++++++++++++ telethon/types/_custom/qrlogin.py | 5 +++-- 17 files changed, 25 insertions(+), 19 deletions(-) create mode 100644 telethon/_events/__init__.py rename telethon/{events => _events}/album.py (100%) rename telethon/{events/__init__.py => _events/base.py} (92%) rename telethon/{events => _events}/callbackquery.py (100%) rename telethon/{events => _events}/chataction.py (100%) rename telethon/{events => _events}/common.py (100%) rename telethon/{events => _events}/inlinequery.py (100%) rename telethon/{events => _events}/messagedeleted.py (100%) rename telethon/{events => _events}/messageedited.py (100%) rename telethon/{events => _events}/messageread.py (100%) rename telethon/{events => _events}/newmessage.py (100%) rename telethon/{events => _events}/raw.py (100%) rename telethon/{events => _events}/userupdate.py (100%) create mode 100644 telethon/events.py diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 54ab9710..5812296c 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -11,7 +11,7 @@ from . import ( from .. import helpers, version, _tl from ..types import _custom from .._network import ConnectionTcpFull -from ..events.common import EventBuilder, EventCommon +from .._events.common import EventBuilder, EventCommon from .._misc import enums diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index da60b24c..6ff3c9d1 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -8,9 +8,11 @@ import traceback import typing import logging -from .. import events, utils, _tl +from .. import utils, _tl from ..errors._rpcbase import RpcError -from ..events.common import EventBuilder, EventCommon +from .._events.common import EventBuilder, EventCommon +from .._events.raw import Raw +from .._events.base import StopPropagation, _get_handlers if typing.TYPE_CHECKING: from .telegramclient import TelegramClient @@ -40,7 +42,7 @@ def add_event_handler( self: 'TelegramClient', callback: Callback, event: EventBuilder = None): - builders = events._get_handlers(callback) + builders = _get_handlers(callback) if builders is not None: for event in builders: self._event_builders.append((event, callback)) @@ -49,7 +51,7 @@ def add_event_handler( if isinstance(event, type): event = event() elif not event: - event = events.Raw() + event = Raw() self._event_builders.append((event, callback)) @@ -274,7 +276,7 @@ async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, p try: await callback(event) - except events.StopPropagation: + except StopPropagation: name = getattr(callback, '__name__', repr(callback)) self._log[__name__].debug( 'Event handler "%s" stopped chain of propagation ' @@ -294,7 +296,7 @@ async def _dispatch_event(self: 'TelegramClient', event): # the name of speed; we don't want to make it worse for all updates # just because albums may need it. for builder, callback in self._event_builders: - if isinstance(builder, events.Raw): + if isinstance(builder, Raw): continue if not isinstance(event, builder.Event): continue @@ -310,7 +312,7 @@ async def _dispatch_event(self: 'TelegramClient', event): try: await callback(event) - except events.StopPropagation: + except StopPropagation: name = getattr(callback, '__name__', repr(callback)) self._log[__name__].debug( 'Event handler "%s" stopped chain of propagation ' diff --git a/telethon/_events/__init__.py b/telethon/_events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/telethon/events/album.py b/telethon/_events/album.py similarity index 100% rename from telethon/events/album.py rename to telethon/_events/album.py diff --git a/telethon/events/__init__.py b/telethon/_events/base.py similarity index 92% rename from telethon/events/__init__.py rename to telethon/_events/base.py index 28f85b12..8f913ad7 100644 --- a/telethon/events/__init__.py +++ b/telethon/_events/base.py @@ -1,13 +1,4 @@ from .raw import Raw -from .album import Album -from .chataction import ChatAction -from .messagedeleted import MessageDeleted -from .messageedited import MessageEdited -from .messageread import MessageRead -from .newmessage import NewMessage -from .userupdate import UserUpdate -from .callbackquery import CallbackQuery -from .inlinequery import InlineQuery _HANDLERS_ATTRIBUTE = '__tl.handlers' diff --git a/telethon/events/callbackquery.py b/telethon/_events/callbackquery.py similarity index 100% rename from telethon/events/callbackquery.py rename to telethon/_events/callbackquery.py diff --git a/telethon/events/chataction.py b/telethon/_events/chataction.py similarity index 100% rename from telethon/events/chataction.py rename to telethon/_events/chataction.py diff --git a/telethon/events/common.py b/telethon/_events/common.py similarity index 100% rename from telethon/events/common.py rename to telethon/_events/common.py diff --git a/telethon/events/inlinequery.py b/telethon/_events/inlinequery.py similarity index 100% rename from telethon/events/inlinequery.py rename to telethon/_events/inlinequery.py diff --git a/telethon/events/messagedeleted.py b/telethon/_events/messagedeleted.py similarity index 100% rename from telethon/events/messagedeleted.py rename to telethon/_events/messagedeleted.py diff --git a/telethon/events/messageedited.py b/telethon/_events/messageedited.py similarity index 100% rename from telethon/events/messageedited.py rename to telethon/_events/messageedited.py diff --git a/telethon/events/messageread.py b/telethon/_events/messageread.py similarity index 100% rename from telethon/events/messageread.py rename to telethon/_events/messageread.py diff --git a/telethon/events/newmessage.py b/telethon/_events/newmessage.py similarity index 100% rename from telethon/events/newmessage.py rename to telethon/_events/newmessage.py diff --git a/telethon/events/raw.py b/telethon/_events/raw.py similarity index 100% rename from telethon/events/raw.py rename to telethon/_events/raw.py diff --git a/telethon/events/userupdate.py b/telethon/_events/userupdate.py similarity index 100% rename from telethon/events/userupdate.py rename to telethon/_events/userupdate.py diff --git a/telethon/events.py b/telethon/events.py new file mode 100644 index 00000000..5dca03ae --- /dev/null +++ b/telethon/events.py @@ -0,0 +1,12 @@ +from ._events.base import StopPropagation, register, unregister, is_handler, list +from ._events.raw import Raw + +from ._events.album import Album +from ._events.chataction import ChatAction +from ._events.messagedeleted import MessageDeleted +from ._events.messageedited import MessageEdited +from ._events.messageread import MessageRead +from ._events.newmessage import NewMessage +from ._events.userupdate import UserUpdate +from ._events.callbackquery import CallbackQuery +from ._events.inlinequery import InlineQuery diff --git a/telethon/types/_custom/qrlogin.py b/telethon/types/_custom/qrlogin.py index 9a48884a..473e4bd0 100644 --- a/telethon/types/_custom/qrlogin.py +++ b/telethon/types/_custom/qrlogin.py @@ -2,7 +2,8 @@ import asyncio import base64 import datetime -from ... import events, _tl +from ... import _tl +from ..._events.raw import Raw class QRLogin: @@ -94,7 +95,7 @@ class QRLogin: async def handler(_update): event.set() - self._client.add_event_handler(handler, events.Raw(_tl.UpdateLoginToken)) + self._client.add_event_handler(handler, Raw(_tl.UpdateLoginToken)) try: # Will raise timeout error if it doesn't complete quick enough, From 6fec2a68c5a9953ffedc50a4e10ae1f5eec175ff Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 25 Sep 2021 20:33:25 +0200 Subject: [PATCH 066/131] Use a proper markdown parser --- readthedocs/misc/v2-migration-guide.rst | 21 +++ requirements.txt | 5 +- telethon/_misc/markdown.py | 237 +++++++++++------------- 3 files changed, 130 insertions(+), 133 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index b35e076d..27a4a783 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -206,6 +206,27 @@ The ``telethon.errors`` module continues to provide custom errors used by the li // TODO should RpcError subclass ValueError? technically the values used in the request somehow were wrong… // TODO provide a way to see which errors are known in the docs or at tl.telethon.dev + +The default markdown parse mode now conforms to the commonmark specification +---------------------------------------------------------------------------- + +The old markdown parser (which was used as the default ``client.parse_mode``) used to emulate +Telegram Desktop's behaviour. Now ``__ +is used instead, which fixes certain parsing bugs but also means the formatting will be different. + +Most notably, ``__`` will now make text bold. If you want the old behaviour, use a single +underscore instead (such as ``_``). You can also use a single asterisk (``*``) for italics. +Because now there's proper parsing, you also gain: + +* Headings (``# text``) will now be underlined. +* Certain HTML tags will now also be recognized in markdown (including ```` for underlining text). +* Line breaks behave properly now. For a single-line break, end your line with ``\\``. +* Inline links should no longer behave in a strange manner. +* Pre-blocks can now have a language. Official clients don't syntax highlight code yet, though. + +// TODO provide a way to get back the old behaviour? + + The "iter" variant of the client methods have been removed ---------------------------------------------------------- diff --git a/requirements.txt b/requirements.txt index 2b650ec4..a7d0e620 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -pyaes -rsa +markdown-it-py~=1.1.0 +pyaes~=1.6.1 +rsa~=4.7.2 diff --git a/telethon/_misc/markdown.py b/telethon/_misc/markdown.py index 77577ee0..8dc82701 100644 --- a/telethon/_misc/markdown.py +++ b/telethon/_misc/markdown.py @@ -5,177 +5,152 @@ since they seem to count as two characters and it's a bit strange. """ import re import warnings +import markdown_it from .helpers import add_surrogate, del_surrogate, within_surrogate, strip_text from .. import _tl from .._misc import tlobject -DEFAULT_DELIMITERS = { - '**': _tl.MessageEntityBold, - '__': _tl.MessageEntityItalic, - '~~': _tl.MessageEntityStrike, - '`': _tl.MessageEntityCode, - '```': _tl.MessageEntityPre + +MARKDOWN = markdown_it.MarkdownIt().enable('strikethrough') +DELIMITERS = { + _tl.MessageEntityBlockquote: ('> ', ''), + _tl.MessageEntityBold: ('**', '**'), + _tl.MessageEntityCode: ('`', '`'), + _tl.MessageEntityItalic: ('_', '_'), + _tl.MessageEntityStrike: ('~~', '~~'), + _tl.MessageEntityUnderline: ('# ', ''), } -DEFAULT_URL_RE = re.compile(r'\[([\S\s]+?)\]\((.+?)\)') -DEFAULT_URL_FORMAT = '[{0}]({1})' +# Not trying to be complete; just enough to have an alternative (mostly for inline underline). +# The fact headings are treated as underline is an implementation detail. +TAG_PATTERN = re.compile(r'<\s*(/?)\s*(\w+)') +HTML_TO_TYPE = { + 'i': ('em_close', 'em_open'), + 'em': ('em_close', 'em_open'), + 'b': ('strong_close', 'strong_open'), + 'strong': ('strong_close', 'strong_open'), + 's': ('s_close', 's_open'), + 'del': ('s_close', 's_open'), + 'u': ('heading_open', 'heading_close'), + 'mark': ('heading_open', 'heading_close'), +} -def overlap(a, b, x, y): - return max(a, x) < min(b, y) +def expand_inline_and_html(tokens): + for token in tokens: + if token.type == 'inline': + yield from expand_inline_and_html(token.children) + elif token.type == 'html_inline': + match = TAG_PATTERN.match(token.content) + if match: + close, tag = match.groups() + tys = HTML_TO_TYPE.get(tag.lower()) + if tys: + token.type = tys[bool(close)] + token.nesting = -1 if close else 1 + yield token + else: + yield token -def parse(message, delimiters=None, url_re=None): +def parse(message): """ Parses the given markdown message and returns its stripped representation plus a list of the _tl.MessageEntity's that were found. - - :param message: the message with markdown-like syntax to be parsed. - :param delimiters: the delimiters to be used, {delimiter: type}. - :param url_re: the URL bytes regex to be used. Must have two groups. - :return: a tuple consisting of (clean message, [message entities]). """ if not message: return message, [] - if url_re is None: - url_re = DEFAULT_URL_RE - elif isinstance(url_re, str): - url_re = re.compile(url_re) + def push(ty, **extra): + nonlocal message, entities, token + if token.nesting > 0: + entities.append(ty(offset=len(message), length=0, **extra)) + else: + for entity in reversed(entities): + if isinstance(entity, ty): + entity.length = len(message) - entity.offset + break - if not delimiters: - if delimiters is not None: - return message, [] - delimiters = DEFAULT_DELIMITERS + parsed = MARKDOWN.parse(add_surrogate(message.strip())) + message = '' + entities = [] + last_map = [0, 0] + for token in expand_inline_and_html(parsed): + if token.map is not None and token.map != last_map: + # paragraphs, quotes fences have a line mapping. Use it to determine how many newlines to insert. + # But don't inssert any (leading) new lines if we're yet to reach the first textual content, or + # if the mappings are the same (e.g. a quote then opens a paragraph but the mapping is equal). + if message: + message += '\n' + '\n' * (token.map[0] - last_map[-1]) + last_map = token.map - # Build a regex to efficiently test all delimiters at once. - # Note that the largest delimiter should go first, we don't - # want ``` to be interpreted as a single back-tick in a code block. - delim_re = re.compile('|'.join('({})'.format(re.escape(k)) - for k in sorted(delimiters, key=len, reverse=True))) + if token.type in ('blockquote_close', 'blockquote_open'): + push(_tl.MessageEntityBlockquote) + elif token.type == 'code_block': + entities.append(_tl.MessageEntityPre(offset=len(message), length=len(token.content), language='')) + message += token.content + elif token.type == 'code_inline': + entities.append(_tl.MessageEntityCode(offset=len(message), length=len(token.content))) + message += token.content + elif token.type in ('em_close', 'em_open'): + push(_tl.MessageEntityItalic) + elif token.type == 'fence': + entities.append(_tl.MessageEntityPre(offset=len(message), length=len(token.content), language=token.info)) + message += token.content[:-1] # remove a single trailing newline + elif token.type == 'hardbreak': + message += '\n' + elif token.type in ('heading_close', 'heading_open'): + push(_tl.MessageEntityUnderline) + elif token.type == 'hr': + message += '\u2015\n\n' + elif token.type in ('link_close', 'link_open'): + if token.markup != 'autolink': # telegram already picks up on these automatically + push(_tl.MessageEntityTextUrl, url=token.attrs.get('href')) + elif token.type in ('s_close', 's_open'): + push(_tl.MessageEntityStrike) + elif token.type == 'softbreak': + message += ' ' + elif token.type in ('strong_close', 'strong_open'): + push(_tl.MessageEntityBold) + elif token.type == 'text': + message += token.content - # Cannot use a for loop because we need to skip some indices - i = 0 - result = [] - - # Work on byte level with the utf-16le encoding to get the offsets right. - # The offset will just be half the index we're at. - message = add_surrogate(message) - while i < len(message): - m = delim_re.match(message, pos=i) - - # Did we find some delimiter here at `i`? - if m: - delim = next(filter(None, m.groups())) - - # +1 to avoid matching right after (e.g. "****") - end = message.find(delim, i + len(delim) + 1) - - # Did we find the earliest closing tag? - if end != -1: - - # Remove the delimiter from the string - message = ''.join(( - message[:i], - message[i + len(delim):end], - message[end + len(delim):] - )) - - # Check other affected entities - for ent in result: - # If the end is after our start, it is affected - if ent.offset + ent.length > i: - # If the old start is also before ours, it is fully enclosed - if ent.offset <= i: - ent.length -= len(delim) * 2 - else: - ent.length -= len(delim) - - # Append the found entity - ent = delimiters[delim] - if ent == _tl.MessageEntityPre: - result.append(ent(i, end - i - len(delim), '')) # has 'lang' - else: - result.append(ent(i, end - i - len(delim))) - - # No nested entities inside code blocks - if ent in (_tl.MessageEntityCode, _tl.MessageEntityPre): - i = end - len(delim) - - continue - - elif url_re: - m = url_re.match(message, pos=i) - if m: - # Replace the whole match with only the inline URL text. - message = ''.join(( - message[:m.start()], - m.group(1), - message[m.end():] - )) - - delim_size = m.end() - m.start() - len(m.group()) - for ent in result: - # If the end is after our start, it is affected - if ent.offset + ent.length > m.start(): - ent.length -= delim_size - - result.append(_tl.MessageEntityTextUrl( - offset=m.start(), length=len(m.group(1)), - url=del_surrogate(m.group(2)) - )) - i += len(m.group(1)) - continue - - i += 1 - - message = strip_text(message, result) - return del_surrogate(message), result + return del_surrogate(message), entities -def unparse(text, entities, delimiters=None, url_fmt=None): +def unparse(text, entities): """ Performs the reverse operation to .parse(), effectively returning markdown-like syntax given a normal text and its _tl.MessageEntity's. - :param text: the text to be reconverted into markdown. - :param entities: the _tl.MessageEntity's applied to the text. - :return: a markdown-like text representing the combination of both inputs. + Because there are many possible ways for markdown to produce a certain + output, this function cannot invert .parse() perfectly. """ if not text or not entities: return text - if not delimiters: - if delimiters is not None: - return text - delimiters = DEFAULT_DELIMITERS - - if url_fmt is not None: - warnings.warn('url_fmt is deprecated') # since it complicates everything *a lot* - if isinstance(entities, tlobject.TLObject): entities = (entities,) text = add_surrogate(text) - delimiters = {v: k for k, v in delimiters.items()} insert_at = [] for entity in entities: s = entity.offset e = entity.offset + entity.length - delimiter = delimiters.get(type(entity), None) + delimiter = DELIMITERS.get(type(entity), None) if delimiter: - insert_at.append((s, delimiter)) - insert_at.append((e, delimiter)) - else: - url = None - if isinstance(entity, _tl.MessageEntityTextUrl): - url = entity.url - elif isinstance(entity, _tl.MessageEntityMentionName): - url = 'tg://user?id={}'.format(entity.user_id) - if url: - insert_at.append((s, '[')) - insert_at.append((e, ']({})'.format(url))) + insert_at.append((s, delimiter[0])) + insert_at.append((e, delimiter[1])) + elif isinstance(entity, _tl.MessageEntityPre): + insert_at.append((s, f'```{entity.language}\n')) + insert_at.append((e, '```\n')) + elif isinstance(entity, _tl.MessageEntityTextUrl): + insert_at.append((s, '[')) + insert_at.append((e, f']({entity.url})')) + elif isinstance(entity, _tl.MessageEntityMentionName): + insert_at.append((s, '[')) + insert_at.append((e, f'](tg://user?id={entity.user_id})')) insert_at.sort(key=lambda t: t[0]) while insert_at: From 8bd4835eb21943245ef3d9479d7769957cd0a40c Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 25 Sep 2021 20:42:51 +0200 Subject: [PATCH 067/131] Remove build_reply_markup from the client --- readthedocs/misc/v2-migration-guide.rst | 11 ++-- telethon/_client/buttons.py | 66 -------------------- telethon/_client/messages.py | 8 +-- telethon/_client/telegramclient.py | 42 +------------ telethon/_client/uploads.py | 2 +- telethon/types/_custom/button.py | 82 +++++++++++++++++++++++++ telethon/types/_custom/inlinebuilder.py | 3 +- 7 files changed, 97 insertions(+), 117 deletions(-) delete mode 100644 telethon/_client/buttons.py diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 27a4a783..e3f9be6a 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -386,14 +386,17 @@ Note that you do not need to ``await`` the call to ``.start()`` if you are going in a context-manager (but it's okay if you put the ``await``). -download_file has been removed from the client ----------------------------------------------- - -Instead, ``client.download_media`` should be used. +Several methods have been removed from the client +------------------------------------------------- +``client.download_file`` has been removed. Instead, ``client.download_media`` should be used. The now-removed ``client.download_file`` method was a lower level implementation which should have not been exposed at all. +``client.build_reply_markup`` has been removed. Manually calling this method was purely an +optimization (the buttons won't need to be transformed into a reply markup every time they're +used). This means you can just remove any calls to this method and things will continue to work. + Support for bot-API style file_id has been removed -------------------------------------------------- diff --git a/telethon/_client/buttons.py b/telethon/_client/buttons.py deleted file mode 100644 index 599ebc96..00000000 --- a/telethon/_client/buttons.py +++ /dev/null @@ -1,66 +0,0 @@ -import typing - -from .._misc import utils, hints -from .. import _tl -from ..types import _custom - - -def build_reply_markup( - buttons: 'typing.Optional[hints.MarkupLike]', - inline_only: bool = False) -> 'typing.Optional[_tl.TypeReplyMarkup]': - if not buttons: - return None - - try: - if buttons.SUBCLASS_OF_ID == 0xe2e10ef2: - return buttons # crc32(b'ReplyMarkup'): - except AttributeError: - pass - - if not utils.is_list_like(buttons): - buttons = [buttons] - if not utils.is_list_like(buttons[0]): - buttons = [[b] for b in buttons] - - is_inline = False - is_normal = False - resize = None - single_use = None - selective = None - - rows = [] - for row in buttons: - current = [] - for button in row: - if isinstance(button, _custom.Button): - if button.resize is not None: - resize = button.resize - if button.single_use is not None: - single_use = button.single_use - if button.selective is not None: - selective = button.selective - - button = button.button - elif isinstance(button, _custom.MessageButton): - button = button.button - - inline = _custom.Button._is_inline(button) - is_inline |= inline - is_normal |= not inline - - if button.SUBCLASS_OF_ID == 0xbad74a3: - # 0xbad74a3 == crc32(b'KeyboardButton') - current.append(button) - - if current: - rows.append(_tl.KeyboardButtonRow(current)) - - if inline_only and is_normal: - raise ValueError('You cannot use non-inline buttons here') - elif is_inline == is_normal and is_normal: - raise ValueError('You cannot mix inline with normal buttons') - elif is_inline: - return _tl.ReplyInlineMarkup(rows) - # elif is_normal: - return _tl.ReplyKeyboardMarkup( - rows, resize=resize, single_use=single_use, selective=selective) diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index 5b7ca110..66d3512e 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -430,7 +430,7 @@ async def send_message( if buttons is None: markup = message.reply_markup else: - markup = self.build_reply_markup(buttons) + markup = _custom.button.build_reply_markup(buttons) if silent is None: silent = message.silent @@ -480,7 +480,7 @@ async def send_message( clear_draft=clear_draft, silent=silent, background=background, - reply_markup=self.build_reply_markup(buttons), + reply_markup=_custom.button.build_reply_markup(buttons), schedule_date=schedule ) @@ -593,7 +593,7 @@ async def edit_message( no_webpage=not link_preview, entities=formatting_entities, media=media, - reply_markup=self.build_reply_markup(buttons) + reply_markup=_custom.button.build_reply_markup(buttons) ) # Invoke `messages.editInlineBotMessage` from the right datacenter. # Otherwise, Telegram will error with `MESSAGE_ID_INVALID` and do nothing. @@ -615,7 +615,7 @@ async def edit_message( no_webpage=not link_preview, entities=formatting_entities, media=media, - reply_markup=self.build_reply_markup(buttons), + reply_markup=_custom.button.build_reply_markup(buttons), schedule_date=schedule ) msg = self._get_response_message(request, await self(request), entity) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 5812296c..1e23440a 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -5,7 +5,7 @@ import typing import logging from . import ( - account, auth, bots, buttons, chats, dialogs, downloads, messageparse, messages, + account, auth, bots, chats, dialogs, downloads, messageparse, messages, telegrambaseclient, updates, uploads, users ) from .. import helpers, version, _tl @@ -684,46 +684,6 @@ class TelegramClient: # endregion Bots - # region Buttons - - @staticmethod - def build_reply_markup( - buttons: 'typing.Optional[hints.MarkupLike]', - inline_only: bool = False) -> 'typing.Optional[_tl.TypeReplyMarkup]': - """ - Builds a :tl:`ReplyInlineMarkup` or :tl:`ReplyKeyboardMarkup` for - the given buttons. - - Does nothing if either no buttons are provided or the provided - argument is already a reply markup. - - You should consider using this method if you are going to reuse - the markup very often. Otherwise, it is not necessary. - - This method is **not** asynchronous (don't use ``await`` on it). - - Arguments - buttons (`hints.MarkupLike`): - The button, list of buttons, array of buttons or markup - to convert into a markup. - - inline_only (`bool`, optional): - Whether the buttons **must** be inline buttons only or not. - - Example - .. code-block:: python - - from telethon import Button - - markup = client.build_reply_markup(Button.inline('hi')) - # later - await client.send_message(chat, 'click me', buttons=markup) - """ - from . import buttons as b - return b.build_reply_markup(buttons=buttons, inline_only=inline_only) - - # endregion Buttons - # region Chats @forward_call(chats.get_participants) diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py index 912954b0..a141d199 100644 --- a/telethon/_client/uploads.py +++ b/telethon/_client/uploads.py @@ -182,7 +182,7 @@ async def send_file( if not media: raise TypeError('Cannot use {!r} as file'.format(file)) - markup = self.build_reply_markup(buttons) + markup = _custom.button.build_reply_markup(buttons) request = _tl.fn.messages.SendMedia( entity, media, reply_to_msg_id=reply_to, message=caption, entities=msg_entities, reply_markup=markup, silent=silent, diff --git a/telethon/types/_custom/button.py b/telethon/types/_custom/button.py index 4dbcab99..ffac7c99 100644 --- a/telethon/types/_custom/button.py +++ b/telethon/types/_custom/button.py @@ -1,3 +1,4 @@ +from .messagebutton import MessageButton from ... import _tl from ..._misc import utils @@ -306,3 +307,84 @@ class Button: documentation for more information on using games. """ return _tl.KeyboardButtonGame(text) + + +def build_reply_markup( + buttons: 'typing.Optional[hints.MarkupLike]', + inline_only: bool = False) -> 'typing.Optional[_tl.TypeReplyMarkup]': + """ + Builds a :tl:`ReplyInlineMarkup` or :tl:`ReplyKeyboardMarkup` for + the given buttons. + + Does nothing if either no buttons are provided or the provided + argument is already a reply markup. + + You should consider using this method if you are going to reuse + the markup very often. Otherwise, it is not necessary. + + This method is **not** asynchronous (don't use ``await`` on it). + + Arguments + buttons (`hints.MarkupLike`): + The button, list of buttons, array of buttons or markup + to convert into a markup. + + inline_only (`bool`, optional): + Whether the buttons **must** be inline buttons only or not. + """ + if not buttons: + return None + + try: + if buttons.SUBCLASS_OF_ID == 0xe2e10ef2: + return buttons # crc32(b'ReplyMarkup'): + except AttributeError: + pass + + if not utils.is_list_like(buttons): + buttons = [buttons] + if not utils.is_list_like(buttons[0]): + buttons = [[b] for b in buttons] + + is_inline = False + is_normal = False + resize = None + single_use = None + selective = None + + rows = [] + for row in buttons: + current = [] + for button in row: + if isinstance(button, Button): + if button.resize is not None: + resize = button.resize + if button.single_use is not None: + single_use = button.single_use + if button.selective is not None: + selective = button.selective + + button = button.button + elif isinstance(button, MessageButton): + button = button.button + + inline = Button._is_inline(button) + is_inline |= inline + is_normal |= not inline + + if button.SUBCLASS_OF_ID == 0xbad74a3: + # 0xbad74a3 == crc32(b'KeyboardButton') + current.append(button) + + if current: + rows.append(_tl.KeyboardButtonRow(current)) + + if inline_only and is_normal: + raise ValueError('You cannot use non-inline buttons here') + elif is_inline == is_normal and is_normal: + raise ValueError('You cannot mix inline with normal buttons') + elif is_inline: + return _tl.ReplyInlineMarkup(rows) + # elif is_normal: + return _tl.ReplyKeyboardMarkup( + rows, resize=resize, single_use=single_use, selective=selective) diff --git a/telethon/types/_custom/inlinebuilder.py b/telethon/types/_custom/inlinebuilder.py index b401ab04..3fea2fc2 100644 --- a/telethon/types/_custom/inlinebuilder.py +++ b/telethon/types/_custom/inlinebuilder.py @@ -2,6 +2,7 @@ import hashlib from ... import _tl from ..._misc import utils +from ...types import _custom _TYPE_TO_MIMES = { 'gif': ['image/gif'], # 'video/mp4' too, but that's used for video @@ -391,7 +392,7 @@ class InlineBuilder: 'text geo contact game'.split(), args) if x[1]) or 'none') ) - markup = self._client.build_reply_markup(buttons, inline_only=True) + markup = _custom.button.build_reply_markup(buttons, inline_only=True) if text is not None: text, msg_entities = await self._client._parse_message_text( text, parse_mode From 86c47a277113ec325ef6336ed925e72a8a1b4588 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 26 Sep 2021 17:52:16 +0200 Subject: [PATCH 068/131] Use __slots__ in all generated classes --- readthedocs/misc/v2-migration-guide.rst | 11 ++++++-- telethon/_client/updates.py | 34 +++++++++++------------ telethon/_misc/tlobject.py | 3 +- telethon/types/_custom/message.py | 7 ++--- telethon_generator/generators/tlobject.py | 9 ++++++ 5 files changed, 38 insertions(+), 26 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index e3f9be6a..3a3f18cf 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -286,8 +286,8 @@ results into a list: // TODO does the download really need to be special? get download is kind of weird though -Raw API methods have been renamed and are now considered private ----------------------------------------------------------------- +Raw API has been renamed and is now considered private +------------------------------------------------------ The subpackage holding the raw API methods has been renamed from ``tl`` to ``_tl`` in order to signal that these are prone to change across minor version bumps (the ``y`` in version ``x.y.z``). @@ -324,7 +324,14 @@ This serves multiple goals: identify which parts are making use of it. * The name is shorter, but remains recognizable. +Because *a lot* of these objects are created, they now define ``__slots__``. This means you can +no longer monkey-patch them to add new attributes at runtime. You have to create a subclass if you +want to define new attributes. + +This also means that the updates from ``events.Raw`` **no longer have** ``update._entities``. + // TODO this definitely generated files mapping from the original name to this new one... +// TODO what's the alternative to update._entities? and update._client?? Many subpackages and modules are now private diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index 6ff3c9d1..e40ff9fa 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -143,22 +143,20 @@ def _handle_update(self: 'TelegramClient', update): entities = {utils.get_peer_id(x): x for x in itertools.chain(update.users, update.chats)} for u in update.updates: - _process_update(self, u, update.updates, entities=entities) + _process_update(self, u, entities, update.updates) elif isinstance(update, _tl.UpdateShort): - _process_update(self, update.update, None) + _process_update(self, update.update, {}, None) else: - _process_update(self, update, None) + _process_update(self, update, {}, None) self._state_cache.update(update) -def _process_update(self: 'TelegramClient', update, others, entities=None): - update._entities = entities or {} - +def _process_update(self: 'TelegramClient', update, entities, others): # This part is somewhat hot so we don't bother patching # update with channel ID/its state. Instead we just pass # arguments which is faster. channel_id = self._state_cache.get_channel_id(update) - args = (update, others, channel_id, self._state_cache[channel_id]) + args = (update, entities, others, channel_id, self._state_cache[channel_id]) if self._dispatching_updates_queue is None: task = self.loop.create_task(_dispatch_update(self, *args)) self._updates_queue.add(task) @@ -231,10 +229,11 @@ async def _dispatch_queue_updates(self: 'TelegramClient'): self._dispatching_updates_queue.clear() -async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, pts_date): - entities = self._entity_cache.add(list((update._entities or {}).values())) +async def _dispatch_update(self: 'TelegramClient', update, entities, others, channel_id, pts_date): if entities: - await self.session.insert_entities(entities) + rows = self._entity_cache.add(list(entities.values())) + if rows: + await self.session.insert_entities(rows) if not self._entity_cache.ensure_cached(update): # We could add a lock to not fetch the same pts twice if we are @@ -244,7 +243,7 @@ async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, p # If the update doesn't have pts, fetching won't do anything. # For example, UpdateUserStatus or UpdateChatUserTyping. try: - await _get_difference(self, update, channel_id, pts_date) + await _get_difference(self, update, entities, channel_id, pts_date) except OSError: pass # We were disconnected, that's okay except RpcError: @@ -258,7 +257,7 @@ async def _dispatch_update(self: 'TelegramClient', update, others, channel_id, p # ValueError("Request was unsuccessful N time(s)") for whatever reasons. pass - built = EventBuilderDict(self, update, others) + built = EventBuilderDict(self, update, entities, others) for builder, callback in self._event_builders: event = built[type(builder)] @@ -324,7 +323,7 @@ async def _dispatch_event(self: 'TelegramClient', event): name = getattr(callback, '__name__', repr(callback)) self._log[__name__].exception('Unhandled exception on %s', name) -async def _get_difference(self: 'TelegramClient', update, channel_id, pts_date): +async def _get_difference(self: 'TelegramClient', update, entities, channel_id, pts_date): """ Get the difference for this `channel_id` if any, then load entities. @@ -380,7 +379,7 @@ async def _get_difference(self: 'TelegramClient', update, channel_id, pts_date): _tl.updates.DifferenceSlice, _tl.updates.ChannelDifference, _tl.updates.ChannelDifferenceTooLong)): - update._entities.update({ + entities.update({ utils.get_peer_id(x): x for x in itertools.chain(result.users, result.chats) }) @@ -433,9 +432,10 @@ class EventBuilderDict: """ Helper "dictionary" to return events from types and cache them. """ - def __init__(self, client: 'TelegramClient', update, others): + def __init__(self, client: 'TelegramClient', update, entities, others): self.client = client self.update = update + self.entities = entities self.others = others def __getitem__(self, builder): @@ -447,9 +447,7 @@ class EventBuilderDict: if isinstance(event, EventCommon): event.original_update = self.update - event._entities = self.update._entities + event._entities = self.entities or {} event._set_client(self.client) - elif event: - event._client = self.client return event diff --git a/telethon/_misc/tlobject.py b/telethon/_misc/tlobject.py index 4b94e00f..c9e3d425 100644 --- a/telethon/_misc/tlobject.py +++ b/telethon/_misc/tlobject.py @@ -13,7 +13,7 @@ def _datetime_to_timestamp(dt): # If no timezone is specified, it is assumed to be in utc zone if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) - # We use .total_seconds() method instead of simply dt.timestamp(), + # We use .total_seconds() method instead of simply dt.timestamp(), # because on Windows the latter raises OSError on datetimes ~< datetime(1970,1,1) secs = int((dt - _EPOCH).total_seconds()) # Make sure it's a valid signed 32 bit integer, as used by Telegram. @@ -32,6 +32,7 @@ def _json_default(value): class TLObject: + __slots__ = () CONSTRUCTOR_ID = None SUBCLASS_OF_ID = None diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index 4d5d615a..ef7cf734 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -11,13 +11,10 @@ from ... import _tl def _fwd(field, doc): def fget(self): - try: - return self._message.__dict__[field] - except KeyError: - return None + return getattr(self._message, field, None) def fset(self, value): - self._message.__dict__[field] = value + setattr(self._message, field, value) return property(fget, fset, None, doc) diff --git a/telethon_generator/generators/tlobject.py b/telethon_generator/generators/tlobject.py index bb310c5a..55c580b0 100644 --- a/telethon_generator/generators/tlobject.py +++ b/telethon_generator/generators/tlobject.py @@ -189,6 +189,15 @@ def _write_class_init(tlobject, kind, type_constructors, builder): builder.writeln() builder.writeln('class {}({}):', tlobject.class_name, kind) + # Define slots to help reduce the size of the objects a little bit. + # It's also good for knowing what fields an object has. + builder.write('__slots__ = (') + sep = '' + for arg in tlobject.real_args: + builder.write('{}{!r},', sep, arg.name) + sep = ' ' + builder.writeln(')') + # Class-level variable to store its Telegram's constructor ID builder.writeln('CONSTRUCTOR_ID = {:#x}', tlobject.id) builder.writeln('SUBCLASS_OF_ID = {:#x}', From e3b1dc205952091981a0a36d9213d2aa672816ac Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 26 Sep 2021 18:30:08 +0200 Subject: [PATCH 069/131] Make to_dict dynamic --- readthedocs/misc/v2-migration-guide.rst | 11 +++++++ telethon/_misc/tlobject.py | 17 ++++++++++- telethon_generator/generators/tlobject.py | 37 ----------------------- 3 files changed, 27 insertions(+), 38 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 3a3f18cf..61ccf252 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -330,6 +330,17 @@ want to define new attributes. This also means that the updates from ``events.Raw`` **no longer have** ``update._entities``. +``tlobject.to_dict()`` has changed and is now generated dynamically based on the ``__slots__`. +This may incur a small performance hit (but you shouldn't really be using ``.to_dict()`` when +you can just use attribute access and ``getattr``). In general, this should handle ill-defined +objects more gracefully (for instance, those where you're using a ``tuple`` and not a ``list`` +or using a list somewhere it shouldn't be), and have no other observable effects. As an extra +benefit, this slightly cuts down on the amount of bloat. + +In ``tlobject.to_dict()``, the special ``_`` key is now also contains the module (so you can +actually distinguish between equally-named classes). If you want the old behaviour, use +``tlobject.__class__.__name__` instead (and add ``Request`` for functions). + // TODO this definitely generated files mapping from the original name to this new one... // TODO what's the alternative to update._entities? and update._client?? diff --git a/telethon/_misc/tlobject.py b/telethon/_misc/tlobject.py index c9e3d425..da2ed6d6 100644 --- a/telethon/_misc/tlobject.py +++ b/telethon/_misc/tlobject.py @@ -171,7 +171,22 @@ class TLObject: return TLObject.pretty_format(self, indent=0) def to_dict(self): - raise NotImplementedError + res = {} + pre = ('', 'fn.')[isinstance(self, TLRequest)] + mod = self.__class__.__module__[self.__class__.__module__.rfind('.') + 1:] + if mod in ('_tl', 'fn'): + res['_'] = f'{pre}{self.__class__.__name__}' + else: + res['_'] = f'{pre}{mod}.{self.__class__.__name__}' + + for slot in self.__slots__: + attr = getattr(self, slot) + if isinstance(attr, list): + res[slot] = [val.to_dict() if hasattr(val, 'to_dict') else val for val in attr] + else: + res[slot] = attr.to_dict() if hasattr(attr, 'to_dict') else attr + + return res def to_json(self, fp=None, default=_json_default, **kwargs): """ diff --git a/telethon_generator/generators/tlobject.py b/telethon_generator/generators/tlobject.py index 55c580b0..e5a3d07e 100644 --- a/telethon_generator/generators/tlobject.py +++ b/telethon_generator/generators/tlobject.py @@ -178,7 +178,6 @@ def _write_source_code(tlobject, kind, builder, type_constructors): """ _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_read_result(tlobject, builder) @@ -301,42 +300,6 @@ def _write_resolve(tlobject, builder): builder.end_block() -def _write_to_dict(tlobject, builder): - builder.writeln('def to_dict(self):') - builder.writeln('return {') - builder.current_indent += 1 - - builder.write("'_': '{}'", tlobject.class_name) - for arg in tlobject.real_args: - builder.writeln(',') - builder.write("'{}': ", arg.name) - if arg.type in BASE_TYPES: - if arg.is_vector: - builder.write('[] if self.{0} is None else self.{0}[:]', - arg.name) - else: - builder.write('self.{}', arg.name) - else: - if arg.is_vector: - builder.write( - '[] if self.{0} is None else [x.to_dict() ' - 'if isinstance(x, TLObject) else x for x in self.{0}]', - arg.name - ) - else: - builder.write( - 'self.{0}.to_dict() ' - 'if isinstance(self.{0}, TLObject) else self.{0}', - arg.name - ) - - builder.writeln() - builder.current_indent -= 1 - builder.writeln("}") - - builder.end_block() - - def _write_to_bytes(tlobject, builder): builder.writeln('def _bytes(self):') From 6f602a203ed675cf50ac67a1ede85e578b086df4 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 26 Sep 2021 18:33:03 +0200 Subject: [PATCH 070/131] Fix custom.Forward not using the new __slots__ --- telethon/types/_custom/forward.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/telethon/types/_custom/forward.py b/telethon/types/_custom/forward.py index d6a46cb7..c5839c4b 100644 --- a/telethon/types/_custom/forward.py +++ b/telethon/types/_custom/forward.py @@ -26,7 +26,8 @@ class Forward(ChatGetter, SenderGetter): # Copy all the fields, not reference! It would cause memory cycles: # self.original_fwd.original_fwd.original_fwd.original_fwd # ...would be valid if we referenced. - self.__dict__.update(original.__dict__) + for slot in original.__slots__: + setattr(self, slot, getattr(original, slot)) self.original_fwd = original sender_id = sender = input_sender = peer = chat = input_chat = None From a9e1a574aee050e4b48d32ff35fd52f6242a9c60 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 26 Sep 2021 18:37:09 +0200 Subject: [PATCH 071/131] Fix limit was no longer defaulting to empty tuple Introduced by 5a44510e2dc65bc7829a83290f6a28bfca9105fd. When forwarding the calls, both signantures should match. --- telethon/_client/chats.py | 6 +++--- telethon/_client/dialogs.py | 2 +- telethon/_client/downloads.py | 2 +- telethon/_client/messages.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 205113d6..221a420a 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -374,7 +374,7 @@ class _ProfilePhotoIter(requestiter.RequestIter): def get_participants( self: 'TelegramClient', entity: 'hints.EntityLike', - limit: float = None, + limit: float = (), *, search: str = '', filter: '_tl.TypeChannelParticipantsFilter' = None) -> _ParticipantsIter: @@ -390,7 +390,7 @@ def get_participants( def get_admin_log( self: 'TelegramClient', entity: 'hints.EntityLike', - limit: float = None, + limit: float = (), *, max_id: int = 0, min_id: int = 0, @@ -440,7 +440,7 @@ def get_admin_log( def get_profile_photos( self: 'TelegramClient', entity: 'hints.EntityLike', - limit: int = None, + limit: int = (), *, offset: int = 0, max_id: int = 0) -> _ProfilePhotoIter: diff --git a/telethon/_client/dialogs.py b/telethon/_client/dialogs.py index 444a0570..2ee272fe 100644 --- a/telethon/_client/dialogs.py +++ b/telethon/_client/dialogs.py @@ -138,7 +138,7 @@ class _DraftsIter(requestiter.RequestIter): def get_dialogs( self: 'TelegramClient', - limit: float = None, + limit: float = (), *, offset_date: 'hints.DateLike' = None, offset_id: int = 0, diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py index 50a383dd..1f340982 100644 --- a/telethon/_client/downloads.py +++ b/telethon/_client/downloads.py @@ -407,7 +407,7 @@ def iter_download( *, offset: int = 0, stride: int = None, - limit: int = None, + limit: int = (), chunk_size: int = None, request_size: int = MAX_CHUNK_SIZE, file_size: int = None, diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index 66d3512e..ad4324d7 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -329,7 +329,7 @@ async def _get_peer(self: 'TelegramClient', input_peer: 'hints.EntityLike'): def get_messages( self: 'TelegramClient', entity: 'hints.EntityLike', - limit: float = None, + limit: float = (), *, offset_date: 'hints.DateLike' = None, offset_id: int = 0, From 197a1ca996f0223833530d3735c95ca767e4f64d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 26 Sep 2021 19:58:42 +0200 Subject: [PATCH 072/131] Fix some modules were public when they should not have been --- readthedocs/misc/v2-migration-guide.rst | 3 +++ telethon/__init__.py | 8 ++++---- telethon/_client/account.py | 3 ++- telethon/_client/bots.py | 3 ++- telethon/_client/chats.py | 4 ++-- telethon/_client/dialogs.py | 4 ++-- telethon/_client/downloads.py | 4 ++-- telethon/_client/messageparse.py | 3 ++- telethon/_client/messages.py | 4 ++-- telethon/_client/telegrambaseclient.py | 4 ++-- telethon/_client/telegramclient.py | 2 +- telethon/_client/updates.py | 3 ++- telethon/_client/uploads.py | 4 ++-- telethon/_client/users.py | 4 ++-- telethon/_misc/html.py | 3 ++- telethon/_network/authenticator.py | 3 ++- telethon/_network/connection/connection.py | 2 +- telethon/_network/mtprotosender.py | 8 ++++---- telethon/types/_custom/chatgetter.py | 3 ++- 19 files changed, 41 insertions(+), 31 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 61ccf252..9444b87c 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -341,6 +341,9 @@ In ``tlobject.to_dict()``, the special ``_`` key is now also contains the module actually distinguish between equally-named classes). If you want the old behaviour, use ``tlobject.__class__.__name__` instead (and add ``Request`` for functions). +Because the string representation of an object used ``tlobject.to_dict()``, it is now also +affected by these changes. + // TODO this definitely generated files mapping from the original name to this new one... // TODO what's the alternative to update._entities? and update._client?? diff --git a/telethon/__init__.py b/telethon/__init__.py index a10dc90c..86e0580f 100644 --- a/telethon/__init__.py +++ b/telethon/__init__.py @@ -1,10 +1,10 @@ # Note: the import order matters -from ._misc import helpers # no dependencies +from ._misc import helpers as _ # no dependencies from . import _tl # no dependencies -from ._misc import utils # depends on helpers and _tl -from ._misc import hints # depends on types/custom +from ._misc import utils as _ # depends on helpers and _tl +from ._misc import hints as _ # depends on types/custom from ._client.telegramclient import TelegramClient -from . import version, events, utils, errors, enums +from . import version, events, errors, enums __version__ = version.__version__ diff --git a/telethon/_client/account.py b/telethon/_client/account.py index 0331a195..eedad595 100644 --- a/telethon/_client/account.py +++ b/telethon/_client/account.py @@ -3,7 +3,8 @@ import inspect import typing from .users import _NOT_A_REQUEST -from .. import helpers, utils, _tl +from .._misc import helpers, utils +from .. import _tl if typing.TYPE_CHECKING: from .telegramclient import TelegramClient diff --git a/telethon/_client/bots.py b/telethon/_client/bots.py index 9360d7f3..2e3ef1b6 100644 --- a/telethon/_client/bots.py +++ b/telethon/_client/bots.py @@ -1,7 +1,8 @@ import typing -from .. import hints, _tl from ..types import _custom +from .._misc import hints +from .. import _tl if typing.TYPE_CHECKING: from .telegramclient import TelegramClient diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 221a420a..ffb68c0f 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -4,8 +4,8 @@ import itertools import string import typing -from .. import hints, errors, _tl -from .._misc import helpers, utils, requestiter, tlobject, enums +from .. import errors, _tl +from .._misc import helpers, utils, requestiter, tlobject, enums, hints from ..types import _custom if typing.TYPE_CHECKING: diff --git a/telethon/_client/dialogs.py b/telethon/_client/dialogs.py index 2ee272fe..e3832ee8 100644 --- a/telethon/_client/dialogs.py +++ b/telethon/_client/dialogs.py @@ -3,8 +3,8 @@ import inspect import itertools import typing -from .. import hints, errors, _tl -from .._misc import helpers, utils, requestiter +from .. import errors, _tl +from .._misc import helpers, utils, requestiter, hints from ..types import _custom _MAX_CHUNK_SIZE = 100 diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py index 1f340982..b29206d4 100644 --- a/telethon/_client/downloads.py +++ b/telethon/_client/downloads.py @@ -7,8 +7,8 @@ import inspect import asyncio from .._crypto import AES -from .._misc import utils, helpers, requestiter, tlobject -from .. import errors, hints, _tl +from .._misc import utils, helpers, requestiter, tlobject, hints +from .. import errors, _tl try: import aiohttp diff --git a/telethon/_client/messageparse.py b/telethon/_client/messageparse.py index 4cc17e15..b7e76e8d 100644 --- a/telethon/_client/messageparse.py +++ b/telethon/_client/messageparse.py @@ -2,8 +2,9 @@ import itertools import re import typing -from .. import helpers, utils, _tl +from .._misc import helpers, utils from ..types import _custom +from .. import _tl if typing.TYPE_CHECKING: from .telegramclient import TelegramClient diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index ad4324d7..fd3fbea8 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -3,9 +3,9 @@ import itertools import typing import warnings -from .. import errors, hints, _tl -from .._misc import helpers, utils, requestiter +from .._misc import helpers, utils, requestiter, hints from ..types import _custom +from .. import errors, _tl _MAX_CHUNK_SIZE = 100 diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index a4349c19..9316668b 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -8,9 +8,9 @@ import time import typing import ipaddress -from .. import version, helpers, __name__ as __base_name__, _tl +from .. import version, __name__ as __base_name__, _tl from .._crypto import rsa -from .._misc import markdown, entitycache, statecache, enums +from .._misc import markdown, entitycache, statecache, enums, helpers from .._network import MTProtoSender, Connection, ConnectionTcpFull, connection as conns from .._sessions import Session, SQLiteSession, MemorySession from .._sessions.types import DataCenter, SessionState diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 1e23440a..136718ab 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -8,7 +8,7 @@ from . import ( account, auth, bots, chats, dialogs, downloads, messageparse, messages, telegrambaseclient, updates, uploads, users ) -from .. import helpers, version, _tl +from .. import version, _tl from ..types import _custom from .._network import ConnectionTcpFull from .._events.common import EventBuilder, EventCommon diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index e40ff9fa..71a3e7e8 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -8,11 +8,12 @@ import traceback import typing import logging -from .. import utils, _tl from ..errors._rpcbase import RpcError from .._events.common import EventBuilder, EventCommon from .._events.raw import Raw from .._events.base import StopPropagation, _get_handlers +from .._misc import utils +from .. import _tl if typing.TYPE_CHECKING: from .telegramclient import TelegramClient diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py index a141d199..9c203008 100644 --- a/telethon/_client/uploads.py +++ b/telethon/_client/uploads.py @@ -9,9 +9,9 @@ from io import BytesIO from .._crypto import AES -from .._misc import utils, helpers -from .. import hints, _tl +from .._misc import utils, helpers, hints from ..types import _custom +from .. import _tl try: import PIL diff --git a/telethon/_client/users.py b/telethon/_client/users.py index de8a49a3..02dccf95 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -6,9 +6,9 @@ import typing from ..errors._custom import MultiError from ..errors._rpcbase import RpcError, ServerError, FloodError, InvalidDcError, UnauthorizedError -from .. import errors, hints, _tl -from .._misc import helpers, utils +from .._misc import helpers, utils, hints from .._sessions.types import Entity +from .. import errors, _tl _NOT_A_REQUEST = lambda: TypeError('You can only invoke requests, not types!') diff --git a/telethon/_misc/html.py b/telethon/_misc/html.py index fbf97011..c17f6f7c 100644 --- a/telethon/_misc/html.py +++ b/telethon/_misc/html.py @@ -7,7 +7,8 @@ from html import escape from html.parser import HTMLParser from typing import Iterable, Optional, Tuple, List -from .. import helpers, _tl +from .._misc import helpers +from .. import _tl # Helpers from markdown.py diff --git a/telethon/_network/authenticator.py b/telethon/_network/authenticator.py index 533eeb5c..dfd16469 100644 --- a/telethon/_network/authenticator.py +++ b/telethon/_network/authenticator.py @@ -6,7 +6,8 @@ import os import time from hashlib import sha1 -from .. import helpers, _tl +from .. import _tl +from .._misc import helpers from .._crypto import AES, AuthKey, Factorization, rsa from ..errors._custom import SecurityError from .._misc.binaryreader import BinaryReader diff --git a/telethon/_network/connection/connection.py b/telethon/_network/connection/connection.py index d206b185..4570180d 100644 --- a/telethon/_network/connection/connection.py +++ b/telethon/_network/connection/connection.py @@ -14,7 +14,7 @@ except ImportError: python_socks = None from ...errors._custom import InvalidChecksumError -from ... import helpers +from ..._misc import helpers class Connection(abc.ABC): diff --git a/telethon/_network/mtprotosender.py b/telethon/_network/mtprotosender.py index 20e68d72..29371bf6 100644 --- a/telethon/_network/mtprotosender.py +++ b/telethon/_network/mtprotosender.py @@ -8,7 +8,6 @@ from ..errors._rpcbase import _mk_error_type from .mtprotoplainsender import MTProtoPlainSender from .requeststate import RequestState from .mtprotostate import MTProtoState -from .. import helpers, utils, _tl from ..errors import ( BadMessageError, InvalidBufferError, SecurityError, TypeNotFoundError, rpc_message_to_error @@ -17,7 +16,8 @@ from .._misc.binaryreader import BinaryReader from .._misc.tlobject import TLRequest from ..types._core import RpcResult, MessageContainer, GzipPacked from .._crypto import AuthKey -from .._misc.helpers import retry_range +from .._misc import helpers, utils +from .. import _tl class MTProtoSender: @@ -214,7 +214,7 @@ class MTProtoSender: connected = False - for attempt in retry_range(self._retries): + for attempt in helpers.retry_range(self._retries): if not connected: connected = await self._try_connect(attempt) if not connected: @@ -351,7 +351,7 @@ class MTProtoSender: attempt = 0 ok = True # We're already "retrying" to connect, so we don't want to force retries - for attempt in retry_range(retries, force_retry=False): + for attempt in helpers.retry_range(retries, force_retry=False): try: await self._connect() except (IOError, asyncio.TimeoutError) as e: diff --git a/telethon/types/_custom/chatgetter.py b/telethon/types/_custom/chatgetter.py index d995dce6..13e234ee 100644 --- a/telethon/types/_custom/chatgetter.py +++ b/telethon/types/_custom/chatgetter.py @@ -1,6 +1,7 @@ import abc -from ... import errors, utils, _tl +from ..._misc import utils +from ... import errors, _tl class ChatGetter(abc.ABC): From 1c15375ea41183c42a7e6122c8744bc5db403822 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 28 Sep 2021 21:06:00 +0200 Subject: [PATCH 073/131] Fix get_participants was monkey-patching User It no longer can do that. User has __slots__. --- telethon/_client/chats.py | 3 --- telethon/_client/telegramclient.py | 5 +---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index ffb68c0f..693feeab 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -167,7 +167,6 @@ class _ParticipantsIter(requestiter.RequestIter): continue user = users[user_id] - user.participant = participant self.buffer.append(user) return True @@ -176,7 +175,6 @@ class _ParticipantsIter(requestiter.RequestIter): if self.limit != 0: user = await self.client.get_entity(entity) if self.filter_entity(user): - user.participant = None self.buffer.append(user) return True @@ -213,7 +211,6 @@ class _ParticipantsIter(requestiter.RequestIter): continue self.seen.add(user_id) user = users[user_id] - user.participant = participant self.buffer.append(user) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 136718ab..bc4371f3 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -731,10 +731,7 @@ class TelegramClient: * ``'contact'`` Yields - The :tl:`User` objects returned by :tl:`GetParticipants` - with an additional ``.participant`` attribute which is the - matched :tl:`ChannelParticipant` type for channels/megagroups - or :tl:`ChatParticipants` for normal chats. + The :tl:`User` objects returned by :tl:`GetParticipants`. Example .. code-block:: python From 5a8c066ff7b6b75b88efa524b52dc709a5683959 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 28 Sep 2021 21:07:15 +0200 Subject: [PATCH 074/131] Fix generated RpcError were no longer formatting the value --- telethon/errors/_rpcbase.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/telethon/errors/_rpcbase.py b/telethon/errors/_rpcbase.py index d074be26..c67c6ebf 100644 --- a/telethon/errors/_rpcbase.py +++ b/telethon/errors/_rpcbase.py @@ -17,6 +17,10 @@ _NESTS_QUERY = ( class RpcError(Exception): def __init__(self, code, message, request=None): + # Special-case '2fa' to exclude the 2 from values + self.values = [int(x) for x in re.findall(r'-?\d+', re.sub(r'^2fa', '', message, flags=re.IGNORECASE))] + self.value = self.values[0] if self.values else None + doc = self.__doc__ if doc is None: doc = ( @@ -25,14 +29,13 @@ class RpcError(Exception): ) elif not doc: doc = '(no description available)' + elif self.value: + doc = re.sub(r'{(\w+)}', str(self.value), doc) super().__init__(f'{message}, code={code}{self._fmt_request(request)}: {doc}') self.code = code self.message = message self.request = request - # Special-case '2fa' to exclude the 2 from values - self.values = [int(x) for x in re.findall(r'-?\d+', re.sub(r'^2fa', '', self.message, flags=re.IGNORECASE))] - self.value = self.values[0] if self.values else None @staticmethod def _fmt_request(request): From 3853f98e5f9afd268b899118cc99899b57bc879e Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 9 Oct 2021 12:01:45 +0200 Subject: [PATCH 075/131] Begin work into making Message a viable way to send them --- telethon/types/_custom/inputfile.py | 170 +++++++++++++ telethon/types/_custom/inputmessage.py | 29 +++ telethon/types/_custom/message.py | 314 ++++++++++++++++++++++--- 3 files changed, 486 insertions(+), 27 deletions(-) create mode 100644 telethon/types/_custom/inputfile.py create mode 100644 telethon/types/_custom/inputmessage.py diff --git a/telethon/types/_custom/inputfile.py b/telethon/types/_custom/inputfile.py new file mode 100644 index 00000000..115e18a1 --- /dev/null +++ b/telethon/types/_custom/inputfile.py @@ -0,0 +1,170 @@ +import mimetypes +import os +import pathlib + +from ... import _tl + + +class InputFile: + def __init__( + self, + file = None, + *, + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, + file_size: int = None, + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, + supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + ttl: int = None, + ): + if isinstance(file, pathlib.Path): + if not file_name: + file_name = file.name + file = str(file.absolute()) + elif not file_name: + if isinstance(file, str): + file_name = os.path.basename(file) + else: + file_name = getattr(file, 'name', 'unnamed') + + if not mime_type: + mime_type = mimetypes.guess_type(file_name)[0] or 'application/octet-stream' + + mime_type = mime_type.lower() + + attributes = [_tl.DocumentAttributeFilename(file_name)] + + # TODO hachoir or tinytag or ffmpeg + if mime_type.startswith('image'): + if width is not None and height is not None: + attributes.append(_tl.DocumentAttributeImageSize( + w=width, + h=height, + )) + elif mime_type.startswith('audio'): + attributes.append(_tl.DocumentAttributeAudio( + duration=duration, + voice=voice_note, + title=title, + performer=performer, + waveform=waveform, + )) + elif mime_type.startswith('video'): + attributes.append(_tl.DocumentAttributeVideo( + duration=duration, + w=width, + h=height, + round_message=video_note, + supports_streaming=supports_streaming, + )) + + # mime_type: str = None, + # thumb: str = False, + # force_file: bool = False, + # file_size: int = None, + # ttl: int = None, + + self._file = file + self._attributes = attributes + + + # TODO rest + + is_image = utils.is_image(file) + if as_image is None: + as_image = is_image and not force_document + + # `aiofiles` do not base `io.IOBase` but do have `read`, so we + # just check for the read attribute to see if it's file-like. + if not isinstance(file, (str, bytes, _tl.InputFile, _tl.InputFileBig))\ + and not hasattr(file, 'read'): + # The user may pass a Message containing media (or the media, + # or anything similar) that should be treated as a file. Try + # getting the input media for whatever they passed and send it. + # + # We pass all attributes since these will be used if the user + # passed :tl:`InputFile`, and all information may be relevant. + try: + return (None, utils.get_input_media( + file, + is_photo=as_image, + attributes=attributes, + force_document=force_document, + voice_note=voice_note, + video_note=video_note, + supports_streaming=supports_streaming, + ttl=ttl + ), as_image) + except TypeError: + # Can't turn whatever was given into media + return None, None, as_image + + media = None + file_handle = None + + if isinstance(file, (_tl.InputFile, _tl.InputFileBig)): + file_handle = file + elif not isinstance(file, str) or os.path.isfile(file): + file_handle = await self.upload_file( + _resize_photo_if_needed(file, as_image), + file_size=file_size, + progress_callback=progress_callback + ) + elif re.match('https?://', file): + if as_image: + media = _tl.InputMediaPhotoExternal(file, ttl_seconds=ttl) + else: + media = _tl.InputMediaDocumentExternal(file, ttl_seconds=ttl) + + if media: + pass # Already have media, don't check the rest + elif not file_handle: + raise ValueError( + 'Failed to convert {} to media. Not an existing file or ' + 'HTTP URL'.format(file) + ) + elif as_image: + media = _tl.InputMediaUploadedPhoto(file_handle, ttl_seconds=ttl) + else: + attributes, mime_type = utils.get_attributes( + file, + mime_type=mime_type, + attributes=attributes, + force_document=force_document and not is_image, + voice_note=voice_note, + video_note=video_note, + supports_streaming=supports_streaming, + thumb=thumb + ) + + if not thumb: + thumb = None + else: + if isinstance(thumb, pathlib.Path): + thumb = str(thumb.absolute()) + thumb = await self.upload_file(thumb, file_size=file_size) + + media = _tl.InputMediaUploadedDocument( + file=file_handle, + mime_type=mime_type, + attributes=attributes, + thumb=thumb, + force_file=force_document and not is_image, + ttl_seconds=ttl + ) + return file_handle, media, as_image + + + + + + diff --git a/telethon/types/_custom/inputmessage.py b/telethon/types/_custom/inputmessage.py new file mode 100644 index 00000000..30b0b079 --- /dev/null +++ b/telethon/types/_custom/inputmessage.py @@ -0,0 +1,29 @@ + +class InputMessage: + __slots__ = ( + '_text', + '_link_preview', + '_silent', + '_reply_markup', + '_fmt_entities', + '_file', + ) + + def __init__( + self, + text, + *, + link_preview, + silent, + reply_markup, + fmt_entities, + file, + ): + self._text = text + self._link_preview = link_preview + self._silent = silent + self._reply_markup = reply_markup + self._fmt_entities = fmt_entities + self._file = file + + # oh! when this message is used, the file can be cached in here! if not inputfile upload and set inputfile diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index ef7cf734..7e9f0ba0 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -1,12 +1,20 @@ from typing import Optional, List, TYPE_CHECKING from datetime import datetime +import mimetypes from .chatgetter import ChatGetter from .sendergetter import SenderGetter from .messagebutton import MessageButton from .forward import Forward from .file import File +from .inputfile import InputFile +from .inputmessage import InputMessage +from .button import build_reply_markup from ..._misc import utils, tlobject -from ... import _tl +from ... import _tl, _misc + + +if TYPE_CHECKING: + from ..._misc import hints def _fwd(field, doc): @@ -23,13 +31,19 @@ def _fwd(field, doc): # Maybe parsing the init function alone if that's possible. class Message(ChatGetter, SenderGetter): """ - This custom class aggregates both :tl:`Message` and - :tl:`MessageService` to ease accessing their members. + Represents a :tl:`Message` (or :tl:`MessageService`) from the API. Remember that this class implements `ChatGetter ` and `SenderGetter ` which means you have access to all their sender and chat properties and methods. + + You can also create your own instance of this type to customize how a + message should be sent (rather than just plain text). For example, you + can create an instance with a text to be used for the caption of an audio + file with a certain performer, duration and thumbnail. However, most + properties and methods won't work (since messages you create have not yet + been sent). """ # region Forwarded properties @@ -150,7 +164,10 @@ class Message(ChatGetter, SenderGetter): @media.setter def media(self, value): - self._message.media = value + try: + self._message.media = value + except AttributeError: + pass reply_markup = _fwd('reply_markup', """ The reply markup for this message (which was sent @@ -211,12 +228,230 @@ class Message(ChatGetter, SenderGetter): # region Initialization - def __init__(self, client, message): + _default_parse_mode = None + _default_link_preview = True + + def __init__( + self, + text: str = None, + *, + # Formatting + markdown: str = None, + html: str = None, + formatting_entities: list = None, + link_preview: bool = (), + # Media + file: Optional[hints.FileLike] = None, + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, + file_size: int = None, + # Media attributes + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, + supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + # Additional parametrization + silent: bool = False, + buttons: list = None, + ttl: int = None, + ): + """ + The input parameters when creating a new message for sending are: + + :param text: The message text (also known as caption when including media). + This will be parsed according to the default parse mode, which can be changed with + ``set_default_parse_mode``. + + By default it's markdown if the ``markdown-it-py`` package is installed, or none otherwise. + Cannot be used in conjunction with ``text`` or ``html``. + + :param markdown: Sets the text, but forces the parse mode to be markdown. + Cannot be used in conjunction with ``text`` or ``html``. + + :param html: Sets the text, but forces the parse mode to be HTML. + Cannot be used in conjunction with ``text`` or ``markdown``. + + :param formatting_entities: Manually specifies the formatting entities. + Neither of ``text``, ``markdown`` or ``html`` will be processed. + + :param link_preview: Whether to include a link preview media in the message. + The default is to show it, but this can be changed with ``set_default_link_preview``. + Has no effect if the message contains other media (such as photos). + + :param file: Send a file. The library will automatically determine whether to send the + file as a photo or as a document based on the extension. You can force a specific type + by using ``photo`` or ``document`` instead. The file can be one of: + + * A local file path to an in-disk file. The file name will default to the path's base name. + + * A `bytes` byte array with the file's data to send (for example, by using + ``text.encode('utf-8')``). A default file name will be used. + + * A bytes `io.IOBase` stream over the file to send (for example, by using + ``open(file, 'rb')``). Its ``.name`` property will be used for the file name, or a + default if it doesn't have one. + + * An external URL to a file over the internet. This will send the file as "external" + media, and Telegram is the one that will fetch the media and send it. This means + the library won't download the file to send it first, but Telegram may fail to access + the media. The URL must start with either ``'http://'`` or ``https://``. + + * A handle to an existing file (for example, if you sent a message with media before, + you can use its ``message.media`` as a file here). + + * A :tl:`InputMedia` instance. For example, if you want to send a dice use + :tl:`InputMediaDice`, or if you want to send a contact use :tl:`InputMediaContact`. + + :param file_name: Forces a specific file name to be used, rather than an automatically + determined one. Has no effect with previously-sent media. + + :param mime_type: Sets a fixed mime type for the file, rather than having the library + guess it from the final file name. Useful when an URL does not contain an extension. + The mime-type will be used to determine which media attributes to include (for instance, + whether to send a video, an audio, or a photo). + + * For an image to contain an image size, you must specify width and height. + * For an audio, you must specify the duration. + * For a video, you must specify width, height and duration. + + :param thumb: A file to be used as the document's thumbnail. Only has effect on uploaded + documents. + + :param force_file: Forces whatever file was specified to be sent as a file. + Has no effect with previously-sent media. + + :param file_size: The size of the file to be uploaded if it needs to be uploaded, which + will be determined automatically if not specified. If the file size can't be determined + beforehand, the entire file will be read in-memory to find out how large it is. Telegram + requires the file size to be known before-hand (except for external media). + + :param duration: Specifies the duration, in seconds, of the audio or video file. Only has + effect on uploaded documents. + + :param width: Specifies the photo or video width, in pixels. Only has an effect on uploaded + documents. + + :param height: Specifies the photo or video height, in pixels. Only has an effect on + uploaded documents. + + :param title: Specifies the title of the song being sent. Only has effect on uploaded + documents. You must specify the audio duration. + + :param performer: Specifies the performer of the song being sent. Only has effect on + uploaded documents. You must specify the audio duration. + + :param supports_streaming: Whether the video has been recorded in such a way that it + supports streaming. Note that not all format can support streaming. Only has effect on + uploaded documents. You must specify the video duration, width and height. + + :param video_note: Whether the video should be a "video note" and render inside a circle. + Only has effect on uploaded documents. You must specify the video duration, width and + height. + + :param voice_note: Whether the audio should be a "voice note" and render with a waveform. + Only has effect on uploaded documents. You must specify the audio duration. + + :param waveform: The waveform. You must specify the audio duration. + + :param silent: Whether the message should notify people with sound or not. By default, a + notification with sound is sent unless the person has the chat muted). + + :param buttons: The matrix (list of lists), column list or button to be shown after + sending the message. This parameter will only work if you have signed in as a bot. + + :param schedule: If set, the message won't send immediately, and instead it will be + scheduled to be automatically sent at a later time. + + :param ttl: The Time-To-Live of the file (also known as "self-destruct timer" or + "self-destructing media"). If set, files can only be viewed for a short period of time + before they disappear from the message history automatically. + + The value must be at least 1 second, and at most 60 seconds, otherwise Telegram will + ignore this parameter. + + Not all types of media can be used with this parameter, such as text documents, which + will fail with ``TtlMediaInvalidError``. + """ + if (text and markdown) or (text and html) or (markdown and html): + raise ValueError('can only set one of: text, markdown, html') + + if formatting_entities: + text = text or markdown or html + elif text: + text, formatting_entities = self._default_parse_mode[0](text) + elif markdown: + text, formatting_entities = _misc.markdown.parse(markdown) + elif html: + text, formatting_entities = _misc.html.parse(html) + + reply_markup = build_reply_markup(buttons) if buttons else None + + if not text: + text = '' + if not formatting_entities: + formatting_entities = None + + if link_preview == (): + link_preview = self._default_link_preview + + if file: + file = InputFile( + file=file, + file_name=file_name, + mime_type=mime_type, + thumb=thumb, + force_file=force_file, + file_size=file_size, + duration=duration, + width=width, + height=height, + title=title, + performer=performer, + supports_streaming=supports_streaming, + video_note=video_note, + voice_note=voice_note, + waveform=waveform, + ) + + self._message = InputMessage( + text=text, + link_preview=link_preview, + silent=silent, + reply_markup=reply_markup, + fmt_entities=formatting_entities, + file=file, + ) + + @classmethod + def _new(cls, client, message, entities, input_chat): + self = cls.__new__(cls) + + sender_id = None + if isinstance(message, _tl.Message): + if message.from_id is not None: + sender_id = utils.get_peer_id(message.from_id) + if sender_id is None and message.peer_id and not isinstance(message, _tl.MessageEmpty): + # If the message comes from a Channel, let the sender be it + # ...or... + # incoming messages in private conversations no longer have from_id + # (layer 119+), but the sender can only be the chat we're in. + if message.post or (not message.out and isinstance(message.peer_id, _tl.PeerUser)): + sender_id = utils.get_peer_id(message.peer_id) + + # Note that these calls would reset the client + ChatGetter.__init__(self, self.peer_id, broadcast=self.post) + SenderGetter.__init__(self, sender_id) self._client = client self._message = message # Convenient storage for custom functions - self._client = None self._text = None self._file = None self._reply_message = None @@ -227,29 +462,8 @@ class Message(ChatGetter, SenderGetter): self._via_input_bot = None self._action_entities = None self._linked_chat = None - - sender_id = None - if self.from_id is not None: - sender_id = utils.get_peer_id(self.from_id) - elif self.peer_id: - # If the message comes from a Channel, let the sender be it - # ...or... - # incoming messages in private conversations no longer have from_id - # (layer 119+), but the sender can only be the chat we're in. - if self.post or (not self.out and isinstance(self.peer_id, _tl.PeerUser)): - sender_id = utils.get_peer_id(self.peer_id) - - # Note that these calls would reset the client - ChatGetter.__init__(self, self.peer_id, broadcast=self.post) - SenderGetter.__init__(self, sender_id) - self._forward = None - @classmethod - def _new(cls, client, message, entities, input_chat): - self = cls(client, message) - self._client = client - # Make messages sent to ourselves outgoing unless they're forwarded. # This makes it consistent with official client's appearance. if self.peer_id == _tl.PeerUser(client._session_state.user_id) and not self.fwd_from: @@ -296,6 +510,49 @@ class Message(ChatGetter, SenderGetter): return self + + @classmethod + def set_default_parse_mode(cls, mode): + """ + Change the default parse mode when creating messages. The ``mode`` can be: + + * ``None``, to disable parsing. + * A string equal to ``'md'`` or ``'markdown`` for parsing with commonmark, + ``'htm'`` or ``'html'`` for parsing HTML. + * A ``callable``, which accepts a ``str`` as input and returns a tuple of + ``(parsed str, formatting entities)``. + * A ``tuple`` of two ``callable``. The first must accept a ``str`` as input and return + a tuple of ``(parsed str, list of formatting entities)``. The second must accept two + parameters, a parsed ``str`` and a ``list`` of formatting entities, and must return + an "unparsed" ``str``. + + If it's not one of these values or types, the method fails accordingly. + """ + if isinstance(mode, str): + mode = mode.lower() + if mode in ('md', 'markdown'): + cls._default_parse_mode = (_misc.markdown.parse, _misc.markdown.unparse) + elif mode in ('htm', 'html'): + cls._default_parse_mode = (_misc.html.parse, _misc.html.unparse) + else: + raise ValueError(f'mode must be one of md, markdown, htm or html, but was {mode!r}') + elif callable(mode): + cls._default_parse_mode = (mode, lambda t, e: t) + elif isinstance(mode, tuple): + if len(mode) == 2 and callable(mode[0]) and callable(mode[1]): + cls._default_parse_mode = mode + else: + raise ValueError(f'mode must be a tuple of exactly two callables') + else: + raise TypeError(f'mode must be either a str, callable or tuple, but was {mode!r}') + + @classmethod + def set_default_link_preview(cls, enabled): + """ + Change the default value for link preview (either ``True`` or ``False``). + """ + cls._default_link_preview = enabled + # endregion Initialization # region Public Properties @@ -1121,3 +1378,6 @@ class Message(ChatGetter, SenderGetter): return None # endregion Private Methods + + +# TODO set md by default if commonmark is installed else nothing From 72fc8f680817ea067b85c06c2d9223b219fd8f06 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 12 Oct 2021 17:59:30 +0200 Subject: [PATCH 076/131] Continue work on Message sending overhaul --- readthedocs/misc/v2-migration-guide.rst | 15 ++ telethon/_client/messages.py | 139 +++++++------ telethon/_client/telegramclient.py | 64 +++--- telethon/_client/uploads.py | 246 ++++++---------------- telethon/types/_custom/inputfile.py | 265 ++++++++++++------------ telethon/types/_custom/inputmessage.py | 81 +++++++- telethon/types/_custom/message.py | 95 ++++----- 7 files changed, 435 insertions(+), 470 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 9444b87c..c16ed02f 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -650,3 +650,18 @@ CdnDecrypter has been removed ----------------------------- It was not really working and was more intended to be an implementation detail than anything else. + + +--- + +you can no longer pass an attributes list because the constructor is now nice. +use raw api if you really need it. +goal is to hide raw api from high level api. sorry. + +no parsemode. use the correct parameter. it's more convenient than setting two. + +formatting_entities stays because otherwise it's the only feasible way to manually specify it. + +todo update send_message and send_file docs (well review all functions) + +album overhaul. use a list of Message instead. diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index fd3fbea8..4d2ad927 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -1,10 +1,12 @@ import inspect import itertools +import time import typing import warnings from .._misc import helpers, utils, requestiter, hints from ..types import _custom +from ..types._custom.inputmessage import InputMessage from .. import errors, _tl _MAX_CHUNK_SIZE = 100 @@ -395,82 +397,95 @@ async def send_message( entity: 'hints.EntityLike', message: 'hints.MessageLike' = '', *, - reply_to: 'typing.Union[int, _tl.Message]' = None, - attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None, - parse_mode: typing.Optional[str] = (), - formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None, - link_preview: bool = True, - file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]' = None, - thumb: 'hints.FileLike' = None, - force_document: bool = False, - clear_draft: bool = False, - buttons: 'hints.MarkupLike' = None, - silent: bool = None, - background: bool = None, + # - Message contents + # Formatting + markdown: str = None, + html: str = None, + formatting_entities: list = None, + link_preview: bool = (), + # Media + file: typing.Optional[hints.FileLike] = None, + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, + file_size: int = None, + # Media attributes + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + # Additional parametrization + silent: bool = False, + buttons: list = None, + ttl: int = None, + # - Send options + reply_to: 'typing.Union[int, _tl.Message]' = None, + clear_draft: bool = False, + background: bool = None, schedule: 'hints.DateLike' = None, - comment_to: 'typing.Union[int, _tl.Message]' = None + comment_to: 'typing.Union[int, _tl.Message]' = None, ) -> '_tl.Message': - if file is not None: - return await self.send_file( - entity, file, caption=message, reply_to=reply_to, - attributes=attributes, parse_mode=parse_mode, - force_document=force_document, thumb=thumb, - buttons=buttons, clear_draft=clear_draft, silent=silent, - schedule=schedule, supports_streaming=supports_streaming, + if isinstance(message, str): + message = InputMessage( + text=message, + markdown=markdown, + html=html, formatting_entities=formatting_entities, - comment_to=comment_to, background=background + link_preview=link_preview, + file=file, + file_name=file_name, + mime_type=mime_type, + thumb=thumb, + force_file=force_file, + file_size=file_size, + duration=duration, + width=width, + height=height, + title=title, + performer=performer, + supports_streaming=supports_streaming, + video_note=video_note, + voice_note=voice_note, + waveform=waveform, + silent=silent, + buttons=buttons, + ttl=ttl, ) + elif isinstance(message, _custom.Message): + message = message._as_input() + elif not isinstance(message, InputMessage): + raise TypeError(f'message must be either str, Message or InputMessage, but got: {message!r}') entity = await self.get_input_entity(entity) if comment_to is not None: entity, reply_to = await _get_comment_data(self, entity, comment_to) + elif reply_to: + reply_to = utils.get_message_id(reply_to) - if isinstance(message, _tl.Message): - if buttons is None: - markup = message.reply_markup - else: - markup = _custom.button.build_reply_markup(buttons) + if message._file: + # TODO Properly implement allow_cache to reuse the sha256 of the file + # i.e. `None` was used - if silent is None: - silent = message.silent + # TODO album + if message._file._should_upload_thumb(): + message._file._set_uploaded_thumb(await self.upload_file(message._file._thumb)) - if (message.media and not isinstance( - message.media, _tl.MessageMediaWebPage)): - return await self.send_file( - entity, - message.media, - caption=message.message, - silent=silent, - background=background, - reply_to=reply_to, - buttons=markup, - formatting_entities=message.entities, - schedule=schedule - ) + if message._file._should_upload_file(): + message._file._set_uploaded_file(await self.upload_file(message._file._file)) - request = _tl.fn.messages.SendMessage( - peer=entity, - message=message.message or '', - silent=silent, - background=background, - reply_to_msg_id=utils.get_message_id(reply_to), - reply_markup=markup, - entities=message.entities, - clear_draft=clear_draft, - no_webpage=not isinstance( - message.media, _tl.MessageMediaWebPage), - schedule_date=schedule + request = _tl.fn.messages.SendMedia( + entity, message._file._media, reply_to_msg_id=reply_to, message=message._text, + entities=message._fmt_entities, reply_markup=message._reply_markup, silent=message._silent, + schedule_date=schedule, clear_draft=clear_draft, + background=background ) - message = message.message else: - if formatting_entities is None: - message, formatting_entities = await self._parse_message_text(message, parse_mode) - if not message: - raise ValueError( - 'The message cannot be empty unless a file is provided' - ) - request = _tl.fn.messages.SendMessage( peer=entity, message=message, @@ -489,7 +504,7 @@ async def send_message( return _custom.Message._new(self, _tl.Message( id=result.id, peer_id=await _get_peer(self, entity), - message=message, + message=message._text, date=result.date, out=result.out, media=result.media, diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index bc4371f3..f2dc4ede 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -2075,31 +2075,46 @@ class TelegramClient: entity: 'hints.EntityLike', message: 'hints.MessageLike' = '', *, - reply_to: 'typing.Union[int, _tl.Message]' = None, - attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None, - parse_mode: typing.Optional[str] = (), - formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None, - link_preview: bool = True, - file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]' = None, - thumb: 'hints.FileLike' = None, - force_document: bool = False, - clear_draft: bool = False, - buttons: 'hints.MarkupLike' = None, - silent: bool = None, - background: bool = None, + # - Message contents + # Formatting + markdown: str = None, + html: str = None, + formatting_entities: list = None, + link_preview: bool = (), + # Media + file: 'typing.Optional[hints.FileLike]' = None, + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, + file_size: int = None, + # Media attributes + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + # Additional parametrization + silent: bool = False, + buttons: list = None, + ttl: int = None, + # - Send options + reply_to: 'typing.Union[int, _tl.Message]' = None, + clear_draft: bool = False, + background: bool = None, schedule: 'hints.DateLike' = None, - comment_to: 'typing.Union[int, _tl.Message]' = None + comment_to: 'typing.Union[int, _tl.Message]' = None, ) -> '_tl.Message': """ - Sends a message to the specified user, chat or channel. + Sends a Message to the specified user, chat or 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. + The message can be either a string or a previous Message instance. + If it's a previous Message instance, the rest of parameters will be ignored. + If it's not, a Message instance will be constructed, and send_to used. Sending a ``/start`` command with a parameter (like ``?start=data``) is also done through this method. Simply send ``'/start data'`` to @@ -3517,15 +3532,6 @@ class TelegramClient: async def _parse_message_text(self: 'TelegramClient', message, parse_mode): pass - @forward_call(uploads._file_to_media) - async def _file_to_media( - self, file, force_document=False, file_size=None, - progress_callback=None, attributes=None, thumb=None, - allow_cache=True, voice_note=False, video_note=False, - supports_streaming=False, mime_type=None, as_image=None, - ttl=None): - pass - @forward_call(messageparse._get_response_message) def _get_response_message(self: 'TelegramClient', request, result, input_chat): pass diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py index 9c203008..5cfd43e4 100644 --- a/telethon/_client/uploads.py +++ b/telethon/_client/uploads.py @@ -92,105 +92,74 @@ def _resize_photo_if_needed( async def send_file( self: 'TelegramClient', entity: 'hints.EntityLike', - file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]', + file: typing.Optional[hints.FileLike] = None, *, - caption: typing.Union[str, typing.Sequence[str]] = None, - force_document: bool = False, + # - Message contents + # Formatting + caption: 'hints.MessageLike' = '', + markdown: str = None, + html: str = None, + formatting_entities: list = None, + link_preview: bool = (), + # Media + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, file_size: int = None, - clear_draft: bool = False, - progress_callback: 'hints.ProgressCallback' = None, - reply_to: 'hints.MessageIDLike' = None, - attributes: 'typing.Sequence[_tl.TypeDocumentAttribute]' = None, - thumb: 'hints.FileLike' = None, - allow_cache: bool = True, - parse_mode: str = (), - formatting_entities: typing.Optional[typing.List[_tl.TypeMessageEntity]] = None, - voice_note: bool = False, - video_note: bool = False, - buttons: 'hints.MarkupLike' = None, - silent: bool = None, - background: bool = None, + # Media attributes + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + # Additional parametrization + silent: bool = False, + buttons: list = None, + ttl: int = None, + # - Send options + reply_to: 'typing.Union[int, _tl.Message]' = None, + clear_draft: bool = False, + background: bool = None, schedule: 'hints.DateLike' = None, comment_to: 'typing.Union[int, _tl.Message]' = None, - ttl: int = None, - **kwargs) -> '_tl.Message': - # TODO Properly implement allow_cache to reuse the sha256 of the file - # i.e. `None` was used - if not file: - raise TypeError('Cannot use {!r} as file'.format(file)) - - if not caption: - caption = '' - - entity = await self.get_input_entity(entity) - if comment_to is not None: - entity, reply_to = await _get_comment_data(self, entity, comment_to) - else: - reply_to = utils.get_message_id(reply_to) - - # First check if the user passed an iterable, in which case - # we may want to send grouped. - if utils.is_list_like(file): - if utils.is_list_like(caption): - captions = caption - else: - captions = [caption] - - result = [] - while file: - result += await _send_album( - self, entity, file[:10], caption=captions[:10], - progress_callback=progress_callback, reply_to=reply_to, - parse_mode=parse_mode, silent=silent, schedule=schedule, - supports_streaming=supports_streaming, clear_draft=clear_draft, - force_document=force_document, background=background, - ) - file = file[10:] - captions = captions[10:] - - for doc, cap in zip(file, captions): - result.append(await self.send_file( - entity, doc, allow_cache=allow_cache, - caption=cap, force_document=force_document, - progress_callback=progress_callback, reply_to=reply_to, - attributes=attributes, thumb=thumb, voice_note=voice_note, - video_note=video_note, buttons=buttons, silent=silent, - supports_streaming=supports_streaming, schedule=schedule, - clear_draft=clear_draft, background=background, - **kwargs - )) - - return result - - if formatting_entities is not None: - msg_entities = formatting_entities - else: - caption, msg_entities =\ - await self._parse_message_text(caption, parse_mode) - - file_handle, media, image = await _file_to_media( - self, file, force_document=force_document, +) -> '_tl.Message': + self.send_message( + entity=entity, + message=caption, + markdown=markdown, + html=html, + formatting_entities=formatting_entities, + link_preview=link_preview, + file=file, + file_name=file_name, + mime_type=mime_type, + thumb=thumb, + force_file=force_file, file_size=file_size, - progress_callback=progress_callback, - attributes=attributes, allow_cache=allow_cache, thumb=thumb, - voice_note=voice_note, video_note=video_note, - supports_streaming=supports_streaming, ttl=ttl + duration=duration, + width=width, + height=height, + title=title, + performer=performer, + supports_streaming=supports_streaming, + video_note=video_note, + voice_note=voice_note, + waveform=waveform, + silent=silent, + buttons=buttons, + ttl=ttl, + reply_to=reply_to, + clear_draft=clear_draft, + background=background, + schedule=schedule, + comment_to=comment_to, ) - # e.g. invalid cast from :tl:`MessageMediaWebPage` - if not media: - raise TypeError('Cannot use {!r} as file'.format(file)) - - markup = _custom.button.build_reply_markup(buttons) - request = _tl.fn.messages.SendMedia( - entity, media, reply_to_msg_id=reply_to, message=caption, - entities=msg_entities, reply_markup=markup, silent=silent, - schedule_date=schedule, clear_draft=clear_draft, - background=background - ) - return self._get_response_message(request, await self(request), entity) - async def _send_album(self: 'TelegramClient', entity, files, caption='', progress_callback=None, reply_to=None, parse_mode=(), silent=None, schedule=None, @@ -368,98 +337,3 @@ async def upload_file( ) -async def _file_to_media( - self, file, force_document=False, file_size=None, - progress_callback=None, attributes=None, thumb=None, - allow_cache=True, voice_note=False, video_note=False, - supports_streaming=False, mime_type=None, as_image=None, - ttl=None): - if not file: - return None, None, None - - if isinstance(file, pathlib.Path): - file = str(file.absolute()) - - is_image = utils.is_image(file) - if as_image is None: - as_image = is_image and not force_document - - # `aiofiles` do not base `io.IOBase` but do have `read`, so we - # just check for the read attribute to see if it's file-like. - if not isinstance(file, (str, bytes, _tl.InputFile, _tl.InputFileBig))\ - and not hasattr(file, 'read'): - # The user may pass a Message containing media (or the media, - # or anything similar) that should be treated as a file. Try - # getting the input media for whatever they passed and send it. - # - # We pass all attributes since these will be used if the user - # passed :tl:`InputFile`, and all information may be relevant. - try: - return (None, utils.get_input_media( - file, - is_photo=as_image, - attributes=attributes, - force_document=force_document, - voice_note=voice_note, - video_note=video_note, - supports_streaming=supports_streaming, - ttl=ttl - ), as_image) - except TypeError: - # Can't turn whatever was given into media - return None, None, as_image - - media = None - file_handle = None - - if isinstance(file, (_tl.InputFile, _tl.InputFileBig)): - file_handle = file - elif not isinstance(file, str) or os.path.isfile(file): - file_handle = await self.upload_file( - _resize_photo_if_needed(file, as_image), - file_size=file_size, - progress_callback=progress_callback - ) - elif re.match('https?://', file): - if as_image: - media = _tl.InputMediaPhotoExternal(file, ttl_seconds=ttl) - else: - media = _tl.InputMediaDocumentExternal(file, ttl_seconds=ttl) - - if media: - pass # Already have media, don't check the rest - elif not file_handle: - raise ValueError( - 'Failed to convert {} to media. Not an existing file or ' - 'HTTP URL'.format(file) - ) - elif as_image: - media = _tl.InputMediaUploadedPhoto(file_handle, ttl_seconds=ttl) - else: - attributes, mime_type = utils.get_attributes( - file, - mime_type=mime_type, - attributes=attributes, - force_document=force_document and not is_image, - voice_note=voice_note, - video_note=video_note, - supports_streaming=supports_streaming, - thumb=thumb - ) - - if not thumb: - thumb = None - else: - if isinstance(thumb, pathlib.Path): - thumb = str(thumb.absolute()) - thumb = await self.upload_file(thumb, file_size=file_size) - - media = _tl.InputMediaUploadedDocument( - file=file_handle, - mime_type=mime_type, - attributes=attributes, - thumb=thumb, - force_file=force_document and not is_image, - ttl_seconds=ttl - ) - return file_handle, media, as_image diff --git a/telethon/types/_custom/inputfile.py b/telethon/types/_custom/inputfile.py index 115e18a1..d505fd11 100644 --- a/telethon/types/_custom/inputfile.py +++ b/telethon/types/_custom/inputfile.py @@ -1,11 +1,36 @@ import mimetypes import os -import pathlib +import re +import time + +from pathlib import Path from ... import _tl +from ..._misc import utils class InputFile: + # Expected Time-To-Live for _uploaded_*. + # After this period they should be reuploaded. + # Telegram's limit are unknown, so this value is conservative. + UPLOAD_TTL = 8 * 60 * 60 + + __slots__ = ( + # main media + '_file', # can reupload + '_media', # can only use as-is + '_uploaded_file', # (input file, timestamp) + # thumbnail + '_thumb', # can reupload + '_uploaded_thumb', # (input file, timestamp) + # document parameters + '_mime_type', + '_attributes', + '_video_note', + '_force_file', + '_ttl', + ) + def __init__( self, file = None, @@ -26,145 +51,125 @@ class InputFile: waveform: bytes = None, ttl: int = None, ): - if isinstance(file, pathlib.Path): - if not file_name: - file_name = file.name - file = str(file.absolute()) - elif not file_name: - if isinstance(file, str): - file_name = os.path.basename(file) + # main media + self._file = None + self._media = None + self._uploaded_file = None + + if isinstance(file, str) and re.match('https?://', file, flags=re.IGNORECASE): + if not force_file and mime_type.startswith('image'): + self._media = _tl.InputMediaPhotoExternal(file, ttl_seconds=ttl) else: - file_name = getattr(file, 'name', 'unnamed') + self._media = _tl.InputMediaDocumentExternal(file, ttl_seconds=ttl) - if not mime_type: - mime_type = mimetypes.guess_type(file_name)[0] or 'application/octet-stream' + elif isinstance(file, (str, bytes, Path)) or callable(getattr(file, 'read', None)): + self._file = file - mime_type = mime_type.lower() + elif isinstance(file, (_tl.InputFile, _tl.InputFileBig)): + self._uploaded_file = (file, time.time()) - attributes = [_tl.DocumentAttributeFilename(file_name)] - - # TODO hachoir or tinytag or ffmpeg - if mime_type.startswith('image'): - if width is not None and height is not None: - attributes.append(_tl.DocumentAttributeImageSize( - w=width, - h=height, - )) - elif mime_type.startswith('audio'): - attributes.append(_tl.DocumentAttributeAudio( - duration=duration, - voice=voice_note, - title=title, - performer=performer, - waveform=waveform, - )) - elif mime_type.startswith('video'): - attributes.append(_tl.DocumentAttributeVideo( - duration=duration, - w=width, - h=height, - round_message=video_note, - supports_streaming=supports_streaming, - )) - - # mime_type: str = None, - # thumb: str = False, - # force_file: bool = False, - # file_size: int = None, - # ttl: int = None, - - self._file = file - self._attributes = attributes - - - # TODO rest - - is_image = utils.is_image(file) - if as_image is None: - as_image = is_image and not force_document - - # `aiofiles` do not base `io.IOBase` but do have `read`, so we - # just check for the read attribute to see if it's file-like. - if not isinstance(file, (str, bytes, _tl.InputFile, _tl.InputFileBig))\ - and not hasattr(file, 'read'): - # The user may pass a Message containing media (or the media, - # or anything similar) that should be treated as a file. Try - # getting the input media for whatever they passed and send it. - # - # We pass all attributes since these will be used if the user - # passed :tl:`InputFile`, and all information may be relevant. - try: - return (None, utils.get_input_media( - file, - is_photo=as_image, - attributes=attributes, - force_document=force_document, - voice_note=voice_note, - video_note=video_note, - supports_streaming=supports_streaming, - ttl=ttl - ), as_image) - except TypeError: - # Can't turn whatever was given into media - return None, None, as_image - - media = None - file_handle = None - - if isinstance(file, (_tl.InputFile, _tl.InputFileBig)): - file_handle = file - elif not isinstance(file, str) or os.path.isfile(file): - file_handle = await self.upload_file( - _resize_photo_if_needed(file, as_image), - file_size=file_size, - progress_callback=progress_callback - ) - elif re.match('https?://', file): - if as_image: - media = _tl.InputMediaPhotoExternal(file, ttl_seconds=ttl) - else: - media = _tl.InputMediaDocumentExternal(file, ttl_seconds=ttl) - - if media: - pass # Already have media, don't check the rest - elif not file_handle: - raise ValueError( - 'Failed to convert {} to media. Not an existing file or ' - 'HTTP URL'.format(file) - ) - elif as_image: - media = _tl.InputMediaUploadedPhoto(file_handle, ttl_seconds=ttl) else: - attributes, mime_type = utils.get_attributes( + self._media = utils.get_input_media( file, - mime_type=mime_type, - attributes=attributes, - force_document=force_document and not is_image, + is_photo=not force_file and mime_type.startswith('image'), + attributes=[], + force_document=force_file, voice_note=voice_note, video_note=video_note, supports_streaming=supports_streaming, - thumb=thumb + ttl=ttl ) - if not thumb: - thumb = None - else: - if isinstance(thumb, pathlib.Path): - thumb = str(thumb.absolute()) - thumb = await self.upload_file(thumb, file_size=file_size) + # thumbnail + self._thumb = None + self._uploaded_thumb = None - media = _tl.InputMediaUploadedDocument( - file=file_handle, - mime_type=mime_type, - attributes=attributes, - thumb=thumb, - force_file=force_document and not is_image, - ttl_seconds=ttl + if isinstance(thumb, (str, bytes, Path)) or callable(getattr(thumb, 'read', None)): + self._thumb = thumb + + elif isinstance(thumb, (_tl.InputFile, _tl.InputFileBig)): + self._uploaded_thumb = (thumb, time.time()) + + else: + raise TypeError(f'thumb must be a file to upload, but got: {thumb!r}') + + # document parameters (only if it's our file, i.e. there's no media ready yet) + if self._media: + self._mime_type = None + self._attributes = None + self._video_note = None + self._force_file = None + self._ttl = None + else: + if isinstance(file, Path): + if not file_name: + file_name = file.name + file = str(file.absolute()) + elif not file_name: + if isinstance(file, str): + file_name = os.path.basename(file) + else: + file_name = getattr(file, 'name', 'unnamed') + + if not mime_type: + mime_type = mimetypes.guess_type(file_name)[0] or 'application/octet-stream' + + mime_type = mime_type.lower() + + attributes = [_tl.DocumentAttributeFilename(file_name)] + + # TODO hachoir or tinytag or ffmpeg + if mime_type.startswith('image'): + if width is not None and height is not None: + attributes.append(_tl.DocumentAttributeImageSize( + w=width, + h=height, + )) + elif mime_type.startswith('audio'): + attributes.append(_tl.DocumentAttributeAudio( + duration=duration, + voice=voice_note, + title=title, + performer=performer, + waveform=waveform, + )) + elif mime_type.startswith('video'): + attributes.append(_tl.DocumentAttributeVideo( + duration=duration, + w=width, + h=height, + round_message=video_note, + supports_streaming=supports_streaming, + )) + + self._mime_type = mime_type + self._attributes = attributes + self._video_note = video_note + self._force_file = force_file + self._ttl = ttl + + def _should_upload_thumb(self): + return self._thumb and ( + not self._uploaded_thumb + or time.time() > self._uploaded_thumb[1] + InputFile.UPLOAD_TTL) + + def _should_upload_file(self): + return self._file and ( + not self._uploaded_file + or time.time() > self._uploaded_file[1] + InputFile.UPLOAD_TTL) + + def _set_uploaded_thumb(self, input_file): + self._uploaded_thumb = (input_file, time.time()) + + def _set_uploaded_file(self, input_file): + if not self._force_file and self._mime_type.startswith('image'): + self._media = _tl.InputMediaUploadedPhoto(input_file, ttl_seconds=self._ttl) + else: + self._media = _tl.InputMediaUploadedDocument( + file=input_file, + mime_type=self._mime_type, + attributes=self._attributes, + thumb=self._uploaded_thumb[0] if self._uploaded_thumb else None, + force_file=self._force_file, + ttl_seconds=self._ttl, ) - return file_handle, media, as_image - - - - - - diff --git a/telethon/types/_custom/inputmessage.py b/telethon/types/_custom/inputmessage.py index 30b0b079..90e1f124 100644 --- a/telethon/types/_custom/inputmessage.py +++ b/telethon/types/_custom/inputmessage.py @@ -1,3 +1,8 @@ +from typing import Optional +from .inputfile import InputFile +from ... import _misc +from .button import build_reply_markup + class InputMessage: __slots__ = ( @@ -9,21 +14,83 @@ class InputMessage: '_file', ) + _default_parse_mode = (lambda t: (t, []), lambda t, e: t) + _default_link_preview = True + def __init__( self, - text, + text: str = None, *, - link_preview, - silent, - reply_markup, - fmt_entities, - file, + markdown: str = None, + html: str = None, + formatting_entities: list = None, + link_preview: bool = (), + file=None, + file_name: str = None, + mime_type: str = None, + thumb: str = False, + force_file: bool = False, + file_size: int = None, + duration: int = None, + width: int = None, + height: int = None, + title: str = None, + performer: str = None, + supports_streaming: bool = False, + video_note: bool = False, + voice_note: bool = False, + waveform: bytes = None, + silent: bool = False, + buttons: list = None, + ttl: int = None, + parse_fn = None, ): + if (text and markdown) or (text and html) or (markdown and html): + raise ValueError('can only set one of: text, markdown, html') + + if formatting_entities: + text = text or markdown or html + elif text: + text, formatting_entities = self._default_parse_mode[0](text) + elif markdown: + text, formatting_entities = _misc.markdown.parse(markdown) + elif html: + text, formatting_entities = _misc.html.parse(html) + + reply_markup = build_reply_markup(buttons) if buttons else None + + if not text: + text = '' + if not formatting_entities: + formatting_entities = None + + if link_preview == (): + link_preview = self._default_link_preview + + if file and not isinstance(file, InputFile): + file = InputFile( + file=file, + file_name=file_name, + mime_type=mime_type, + thumb=thumb, + force_file=force_file, + file_size=file_size, + duration=duration, + width=width, + height=height, + title=title, + performer=performer, + supports_streaming=supports_streaming, + video_note=video_note, + voice_note=voice_note, + waveform=waveform, + ) + self._text = text self._link_preview = link_preview self._silent = silent self._reply_markup = reply_markup - self._fmt_entities = fmt_entities + self._fmt_entities = formatting_entities self._file = file # oh! when this message is used, the file can be cached in here! if not inputfile upload and set inputfile diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index 7e9f0ba0..7222fb00 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -228,9 +228,6 @@ class Message(ChatGetter, SenderGetter): # region Initialization - _default_parse_mode = None - _default_link_preview = True - def __init__( self, text: str = None, @@ -241,7 +238,7 @@ class Message(ChatGetter, SenderGetter): formatting_entities: list = None, link_preview: bool = (), # Media - file: Optional[hints.FileLike] = None, + file: 'Optional[hints.FileLike]' = None, file_name: str = None, mime_type: str = None, thumb: str = False, @@ -379,54 +376,30 @@ class Message(ChatGetter, SenderGetter): Not all types of media can be used with this parameter, such as text documents, which will fail with ``TtlMediaInvalidError``. """ - if (text and markdown) or (text and html) or (markdown and html): - raise ValueError('can only set one of: text, markdown, html') - - if formatting_entities: - text = text or markdown or html - elif text: - text, formatting_entities = self._default_parse_mode[0](text) - elif markdown: - text, formatting_entities = _misc.markdown.parse(markdown) - elif html: - text, formatting_entities = _misc.html.parse(html) - - reply_markup = build_reply_markup(buttons) if buttons else None - - if not text: - text = '' - if not formatting_entities: - formatting_entities = None - - if link_preview == (): - link_preview = self._default_link_preview - - if file: - file = InputFile( - file=file, - file_name=file_name, - mime_type=mime_type, - thumb=thumb, - force_file=force_file, - file_size=file_size, - duration=duration, - width=width, - height=height, - title=title, - performer=performer, - supports_streaming=supports_streaming, - video_note=video_note, - voice_note=voice_note, - waveform=waveform, - ) - self._message = InputMessage( text=text, + markdown=markdown, + html=html, + formatting_entities=formatting_entities, link_preview=link_preview, + file =file, + file_name=file_name, + mime_type=mime_type, + thumb=thumb, + force_file=force_file, + file_size=file_size, + duration=duration, + width=width, + height=height, + title=title, + performer=performer, + supports_streaming=supports_streaming, + video_note=video_note, + voice_note=voice_note, + waveform=waveform, silent=silent, - reply_markup=reply_markup, - fmt_entities=formatting_entities, - file=file, + buttons=buttons, + ttl=ttl, ) @classmethod @@ -446,7 +419,7 @@ class Message(ChatGetter, SenderGetter): sender_id = utils.get_peer_id(message.peer_id) # Note that these calls would reset the client - ChatGetter.__init__(self, self.peer_id, broadcast=self.post) + ChatGetter.__init__(self, message.peer_id, broadcast=message.post) SenderGetter.__init__(self, sender_id) self._client = client self._message = message @@ -511,8 +484,8 @@ class Message(ChatGetter, SenderGetter): return self - @classmethod - def set_default_parse_mode(cls, mode): + @staticmethod + def set_default_parse_mode(mode): """ Change the default parse mode when creating messages. The ``mode`` can be: @@ -531,27 +504,29 @@ class Message(ChatGetter, SenderGetter): if isinstance(mode, str): mode = mode.lower() if mode in ('md', 'markdown'): - cls._default_parse_mode = (_misc.markdown.parse, _misc.markdown.unparse) + mode = (_misc.markdown.parse, _misc.markdown.unparse) elif mode in ('htm', 'html'): - cls._default_parse_mode = (_misc.html.parse, _misc.html.unparse) + mode = (_misc.html.parse, _misc.html.unparse) else: raise ValueError(f'mode must be one of md, markdown, htm or html, but was {mode!r}') elif callable(mode): - cls._default_parse_mode = (mode, lambda t, e: t) + mode = (mode, lambda t, e: t) elif isinstance(mode, tuple): if len(mode) == 2 and callable(mode[0]) and callable(mode[1]): - cls._default_parse_mode = mode + mode = mode else: raise ValueError(f'mode must be a tuple of exactly two callables') else: raise TypeError(f'mode must be either a str, callable or tuple, but was {mode!r}') + InputMessage._default_parse_mode = mode + @classmethod def set_default_link_preview(cls, enabled): """ Change the default value for link preview (either ``True`` or ``False``). """ - cls._default_link_preview = enabled + InputMessage._default_link_preview = enabled # endregion Initialization @@ -1297,6 +1272,14 @@ class Message(ChatGetter, SenderGetter): # region Private Methods + def _as_input(self): + if isinstance(self._message, InputMessage): + return self._message + + return InputMessage( + + ) + async def _reload_message(self): """ Re-fetches this message to reload the sender and chat entities, From a5dce81d0fdc6f527021d958cb3e47436abeb66b Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 12 Oct 2021 18:01:34 +0200 Subject: [PATCH 077/131] Actually fill parameters in Message._as_input --- telethon/types/_custom/message.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index 7222fb00..103da65b 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -1277,7 +1277,11 @@ class Message(ChatGetter, SenderGetter): return self._message return InputMessage( - + text=self.message, + formatting_entities=self.entities, + file=self.media, + silent=self.silent, + buttons=self.reply_markup, ) async def _reload_message(self): From dea424fdec421c97a7704db99087cc2aaa00ab84 Mon Sep 17 00:00:00 2001 From: penn5 Date: Sat, 11 Dec 2021 10:47:40 +0000 Subject: [PATCH 078/131] Fix typo in messages.py --- telethon/_client/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index 4d2ad927..4a5ae371 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -488,7 +488,7 @@ async def send_message( else: request = _tl.fn.messages.SendMessage( peer=entity, - message=message, + message=message._text, entities=formatting_entities, no_webpage=not link_preview, reply_to_msg_id=utils.get_message_id(reply_to), From d3ef3c69c8ca460afdcc64ffde9a8890b3517dab Mon Sep 17 00:00:00 2001 From: penn5 Date: Sat, 11 Dec 2021 10:55:09 +0000 Subject: [PATCH 079/131] Remove _finish_init from newmessage.py This method was removed in 334a847de78f22dbbcf67f2e68c573d0d4b7c35d --- telethon/_events/newmessage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/telethon/_events/newmessage.py b/telethon/_events/newmessage.py index 58e1a425..b7b9efc0 100644 --- a/telethon/_events/newmessage.py +++ b/telethon/_events/newmessage.py @@ -208,7 +208,6 @@ class NewMessage(EventBuilder): def _set_client(self, client): super()._set_client(client) m = self.message - m._finish_init(client, self._entities, None) self.__dict__['_init'] = True # No new attributes can be set def __getattr__(self, item): From 8d1379f3d4ada8de6b6fd84ac90ecf28bd088fb8 Mon Sep 17 00:00:00 2001 From: penn5 Date: Sat, 11 Dec 2021 10:56:49 +0000 Subject: [PATCH 080/131] Remove _finish_init from chataction.py This method was removed in 334a847de78f22dbbcf67f2e68c573d0d4b7c35d --- telethon/_events/chataction.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/telethon/_events/chataction.py b/telethon/_events/chataction.py index 75f19075..35a5d549 100644 --- a/telethon/_events/chataction.py +++ b/telethon/_events/chataction.py @@ -208,8 +208,6 @@ class ChatAction(EventBuilder): def _set_client(self, client): super()._set_client(client) - if self.action_message: - self.action_message._finish_init(client, self._entities, None) async def respond(self, *args, **kwargs): """ From 8de375323e871792732ecb26dd1db9c26976a110 Mon Sep 17 00:00:00 2001 From: Hackintosh 5 Date: Sat, 11 Dec 2021 12:31:38 +0000 Subject: [PATCH 081/131] Cleanup events code --- telethon/_client/updates.py | 3 ++- telethon/_events/album.py | 2 +- telethon/_events/callbackquery.py | 2 +- telethon/_events/chataction.py | 5 +---- telethon/_events/common.py | 3 ++- telethon/_events/inlinequery.py | 2 +- telethon/_events/messagedeleted.py | 2 +- telethon/_events/messageedited.py | 2 +- telethon/_events/messageread.py | 2 +- telethon/_events/newmessage.py | 14 +++++++------- telethon/_events/raw.py | 2 +- telethon/_events/userupdate.py | 2 +- 12 files changed, 20 insertions(+), 21 deletions(-) diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index 71a3e7e8..5f30fbd8 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -444,9 +444,10 @@ class EventBuilderDict: return self.__dict__[builder] except KeyError: event = self.__dict__[builder] = builder.build( - self.update, self.others, self.client._session_state.user_id) + self.update, self.others, self.client._session_state.user_id, self.entities or {}, self.client) if isinstance(event, EventCommon): + # TODO eww event.original_update = self.update event._entities = self.entities or {} event._set_client(self.client) diff --git a/telethon/_events/album.py b/telethon/_events/album.py index d8dacb98..cb9c39a8 100644 --- a/telethon/_events/album.py +++ b/telethon/_events/album.py @@ -96,7 +96,7 @@ class Album(EventBuilder): super().__init__(chats, blacklist_chats=blacklist_chats, func=func) @classmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others=None, self_id=None, *todo, **todo2): if not others: return # We only care about albums which come inside the same Updates diff --git a/telethon/_events/callbackquery.py b/telethon/_events/callbackquery.py index 0c944400..15f8d12f 100644 --- a/telethon/_events/callbackquery.py +++ b/telethon/_events/callbackquery.py @@ -87,7 +87,7 @@ class CallbackQuery(EventBuilder): )) @classmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others=None, self_id=None, *todo, **todo2): if isinstance(update, _tl.UpdateBotCallbackQuery): return cls.Event(update, update.peer, update.msg_id) elif isinstance(update, _tl.UpdateInlineBotCallbackQuery): diff --git a/telethon/_events/chataction.py b/telethon/_events/chataction.py index 35a5d549..b34ecd65 100644 --- a/telethon/_events/chataction.py +++ b/telethon/_events/chataction.py @@ -33,7 +33,7 @@ class ChatAction(EventBuilder): """ @classmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others=None, self_id=None, *todo, **todo2): # Rely on specific pin updates for unpins, but otherwise ignore them # for new pins (we'd rather handle the new service message with pin, # so that we can act on that message'). @@ -206,9 +206,6 @@ class ChatAction(EventBuilder): self.new_score = new_score self.unpin = not pin - def _set_client(self, client): - super()._set_client(client) - async def respond(self, *args, **kwargs): """ Responds to the chat action message (not as a reply). Shorthand for diff --git a/telethon/_events/common.py b/telethon/_events/common.py index f7b2e066..bfaf3227 100644 --- a/telethon/_events/common.py +++ b/telethon/_events/common.py @@ -67,7 +67,7 @@ class EventBuilder(abc.ABC): @classmethod @abc.abstractmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others, self_id, entities, client): """ Builds an event for the given update if possible, or returns None. @@ -144,6 +144,7 @@ class EventCommon(ChatGetter, abc.ABC): """ Setter so subclasses can act accordingly when the client is set. """ + # TODO Nuke self._client = client if self._chat_peer: self._chat, self._input_chat = utils._get_entity_pair( diff --git a/telethon/_events/inlinequery.py b/telethon/_events/inlinequery.py index 0af44dd0..d3cd6822 100644 --- a/telethon/_events/inlinequery.py +++ b/telethon/_events/inlinequery.py @@ -61,7 +61,7 @@ class InlineQuery(EventBuilder): raise TypeError('Invalid pattern type given') @classmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others=None, self_id=None, *todo, **todo2): if isinstance(update, _tl.UpdateBotInlineQuery): return cls.Event(update) diff --git a/telethon/_events/messagedeleted.py b/telethon/_events/messagedeleted.py index f2a2e9f9..58f9ff5f 100644 --- a/telethon/_events/messagedeleted.py +++ b/telethon/_events/messagedeleted.py @@ -36,7 +36,7 @@ class MessageDeleted(EventBuilder): print('Message', msg_id, 'was deleted in', event.chat_id) """ @classmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others=None, self_id=None, *todo, **todo2): if isinstance(update, _tl.UpdateDeleteMessages): return cls.Event( deleted_ids=update.messages, diff --git a/telethon/_events/messageedited.py b/telethon/_events/messageedited.py index 8eefb9f5..3f430a68 100644 --- a/telethon/_events/messageedited.py +++ b/telethon/_events/messageedited.py @@ -43,7 +43,7 @@ class MessageEdited(NewMessage): print('Message', event.id, 'changed at', event.date) """ @classmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others=None, self_id=None, *todo, **todo2): if isinstance(update, (_tl.UpdateEditMessage, _tl.UpdateEditChannelMessage)): return cls.Event(update.message) diff --git a/telethon/_events/messageread.py b/telethon/_events/messageread.py index 5c37eb2c..0cd50de0 100644 --- a/telethon/_events/messageread.py +++ b/telethon/_events/messageread.py @@ -35,7 +35,7 @@ class MessageRead(EventBuilder): self.inbox = inbox @classmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others=None, self_id=None, *todo, **todo2): if isinstance(update, _tl.UpdateReadHistoryInbox): return cls.Event(update.peer, update.max_id, False) elif isinstance(update, _tl.UpdateReadHistoryOutbox): diff --git a/telethon/_events/newmessage.py b/telethon/_events/newmessage.py index b7b9efc0..e4887002 100644 --- a/telethon/_events/newmessage.py +++ b/telethon/_events/newmessage.py @@ -95,14 +95,14 @@ class NewMessage(EventBuilder): self.from_users = await _into_id_set(client, self.from_users) @classmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others, self_id, entities, client): if isinstance(update, (_tl.UpdateNewMessage, _tl.UpdateNewChannelMessage)): if not isinstance(update.message, _tl.Message): return # We don't care about MessageService's here - event = cls.Event(update.message) + msg = update.message elif isinstance(update, _tl.UpdateShortMessage): - event = cls.Event(_tl.Message( + msg = _tl.Message( out=update.out, mentioned=update.mentioned, media_unread=update.media_unread, @@ -117,9 +117,9 @@ class NewMessage(EventBuilder): reply_to=update.reply_to, entities=update.entities, ttl_period=update.ttl_period - )) + ) elif isinstance(update, _tl.UpdateShortChatMessage): - event = cls.Event(_tl.Message( + msg = _tl.Message( out=update.out, mentioned=update.mentioned, media_unread=update.media_unread, @@ -134,11 +134,11 @@ class NewMessage(EventBuilder): reply_to=update.reply_to, entities=update.entities, ttl_period=update.ttl_period - )) + ) else: return - return event + return cls.Event(_custom.Message._new(client, msg, entities, None)) def filter(self, event): if self._no_check: diff --git a/telethon/_events/raw.py b/telethon/_events/raw.py index 68fdfc0c..496f39e5 100644 --- a/telethon/_events/raw.py +++ b/telethon/_events/raw.py @@ -42,7 +42,7 @@ class Raw(EventBuilder): self.resolved = True @classmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others=None, self_id=None, *todo, **todo2): return update def filter(self, event): diff --git a/telethon/_events/userupdate.py b/telethon/_events/userupdate.py index 61db39ea..b5354ae0 100644 --- a/telethon/_events/userupdate.py +++ b/telethon/_events/userupdate.py @@ -49,7 +49,7 @@ class UserUpdate(EventBuilder): await client.send_message(event.user_id, 'What are you sending?') """ @classmethod - def build(cls, update, others=None, self_id=None): + def build(cls, update, others=None, self_id=None, *todo, **todo2): if isinstance(update, _tl.UpdateUserStatus): return cls.Event(_tl.PeerUser(update.user_id), status=update.status) From b566e59036f91739961a0f2e5bb200d32ac03d16 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 16 Oct 2021 11:53:29 +0200 Subject: [PATCH 082/131] Add stringify back to custom Message --- readthedocs/misc/v2-migration-guide.rst | 33 +++++++++++ telethon/_misc/helpers.py | 67 ++++++++++++++++++++++ telethon/_misc/tlobject.py | 75 ++----------------------- telethon/types/_custom/message.py | 47 +++++++++++++++- 4 files changed, 152 insertions(+), 70 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index c16ed02f..0b8bbc22 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -526,6 +526,39 @@ If you still want the old behaviour, wrap the list inside another list: #+ +Changes to the string and to_dict representation +------------------------------------------------ + +The string representation of raw API objects will now have its "printing depth" limited, meaning +very large and nested objects will be easier to read. + +If you want to see the full object's representation, you should instead use Python's builtin +``repr`` method. + +The ``.stringify`` method remains unchanged. + +Here's a comparison table for a convenient overview: + ++-------------------+---------------------------------------------+---------------------------------------------+ +| | Telethon v1.x | Telethon v2.x | ++-------------------+-------------+--------------+----------------+-------------+--------------+----------------+ +| | ``__str__`` | ``__repr__`` | ``.stringify`` | ``__str__`` | ``__repr__`` | ``.stringify`` | ++-------------------+-------------+--------------+----------------+-------------+--------------+----------------+ +| Useful? | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ++-------------------+-------------+--------------+----------------+-------------+--------------+----------------+ +| Multiline? | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ++-------------------+-------------+--------------+----------------+-------------+--------------+----------------+ +| Shows everything? | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | ++-------------------+-------------+--------------+----------------+-------------+--------------+----------------+ + +Both of the string representations may still change in the future without warning, as Telegram +adds, changes or removes fields. It should only be used for debugging. If you need a persistent +string representation, it is your job to decide which fields you care about and their format. + +The ``Message`` representation now contains different properties, which should be more useful and +less confusing. + + Changes on how to configure a different connection mode ------------------------------------------------------- diff --git a/telethon/_misc/helpers.py b/telethon/_misc/helpers.py index f9297816..a3480007 100644 --- a/telethon/_misc/helpers.py +++ b/telethon/_misc/helpers.py @@ -200,6 +200,73 @@ def _entity_type(entity): # 'Empty' in name or not found, we don't care, not a valid entity. raise TypeError('{} does not have any entity type'.format(entity)) + +def pretty_print(obj, indent=None, max_depth=float('inf')): + max_depth -= 1 + if max_depth < 0: + return '...' + + to_d = getattr(obj, '_to_dict', None) or getattr(obj, 'to_dict', None) + if callable(to_d): + obj = to_d() + + if indent is None: + if isinstance(obj, dict): + return '{}({})'.format(obj.get('_', 'dict'), ', '.join( + '{}={}'.format(k, pretty_print(v, indent, max_depth)) + for k, v in obj.items() if k != '_' + )) + elif isinstance(obj, str) or isinstance(obj, bytes): + return repr(obj) + elif hasattr(obj, '__iter__'): + return '[{}]'.format( + ', '.join(pretty_print(x, indent, max_depth) for x in obj) + ) + else: + return repr(obj) + else: + result = [] + + if isinstance(obj, dict): + result.append(obj.get('_', 'dict')) + result.append('(') + if obj: + result.append('\n') + indent += 1 + for k, v in obj.items(): + if k == '_': + continue + result.append('\t' * indent) + result.append(k) + result.append('=') + result.append(pretty_print(v, indent, max_depth)) + result.append(',\n') + result.pop() # last ',\n' + indent -= 1 + result.append('\n') + result.append('\t' * indent) + result.append(')') + + elif isinstance(obj, str) or isinstance(obj, bytes): + result.append(repr(obj)) + + elif hasattr(obj, '__iter__'): + result.append('[\n') + indent += 1 + for x in obj: + result.append('\t' * indent) + result.append(pretty_print(x, indent, max_depth)) + result.append(',\n') + indent -= 1 + result.append('\t' * indent) + result.append(']') + + else: + result.append(repr(obj)) + + return ''.join(result) + + # endregion # region Cryptographic related utils diff --git a/telethon/_misc/tlobject.py b/telethon/_misc/tlobject.py index da2ed6d6..4045e3ed 100644 --- a/telethon/_misc/tlobject.py +++ b/telethon/_misc/tlobject.py @@ -3,6 +3,7 @@ import json import struct from datetime import datetime, date, timedelta, timezone import time +from .helpers import pretty_print _EPOCH_NAIVE = datetime(*time.gmtime(0)[:6]) _EPOCH_NAIVE_LOCAL = datetime(*time.localtime(0)[:6]) @@ -36,73 +37,6 @@ class TLObject: CONSTRUCTOR_ID = None SUBCLASS_OF_ID = None - @staticmethod - def pretty_format(obj, indent=None): - """ - Pretty formats the given object as a string which is returned. - If indent is None, a single line will be returned. - """ - if indent is None: - if isinstance(obj, TLObject): - obj = obj.to_dict() - - if isinstance(obj, dict): - return '{}({})'.format(obj.get('_', 'dict'), ', '.join( - '{}={}'.format(k, TLObject.pretty_format(v)) - for k, v in obj.items() if k != '_' - )) - elif isinstance(obj, str) or isinstance(obj, bytes): - return repr(obj) - elif hasattr(obj, '__iter__'): - return '[{}]'.format( - ', '.join(TLObject.pretty_format(x) for x in obj) - ) - else: - return repr(obj) - else: - result = [] - if isinstance(obj, TLObject): - obj = obj.to_dict() - - if isinstance(obj, dict): - result.append(obj.get('_', 'dict')) - result.append('(') - if obj: - result.append('\n') - indent += 1 - for k, v in obj.items(): - if k == '_': - continue - result.append('\t' * indent) - result.append(k) - result.append('=') - result.append(TLObject.pretty_format(v, indent)) - result.append(',\n') - result.pop() # last ',\n' - indent -= 1 - result.append('\n') - result.append('\t' * indent) - result.append(')') - - elif isinstance(obj, str) or isinstance(obj, bytes): - result.append(repr(obj)) - - elif hasattr(obj, '__iter__'): - result.append('[\n') - indent += 1 - for x in obj: - result.append('\t' * indent) - result.append(TLObject.pretty_format(x, indent)) - result.append(',\n') - indent -= 1 - result.append('\t' * indent) - result.append(']') - - else: - result.append(repr(obj)) - - return ''.join(result) - @staticmethod def serialize_bytes(data): """Write bytes by using Telegram guidelines""" @@ -164,11 +98,14 @@ class TLObject: def __ne__(self, o): return not isinstance(o, type(self)) or self.to_dict() != o.to_dict() + def __repr__(self): + return pretty_print(self) + def __str__(self): - return TLObject.pretty_format(self) + return pretty_print(self, max_depth=2) def stringify(self): - return TLObject.pretty_format(self, indent=0) + return pretty_print(self, indent=0) def to_dict(self): res = {} diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index 103da65b..bd0b61ee 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -9,7 +9,7 @@ from .file import File from .inputfile import InputFile from .inputmessage import InputMessage from .button import build_reply_markup -from ..._misc import utils, tlobject +from ..._misc import utils, helpers, tlobject from ... import _tl, _misc @@ -1366,5 +1366,50 @@ class Message(ChatGetter, SenderGetter): # endregion Private Methods + def to_dict(self): + return self._message.to_dict() + + def _to_dict(self): + return { + '_': 'Message', + 'id': self.id, + 'out': self.out, + 'date': self.date, + 'text': self.text, + 'sender': self.sender, + 'chat': self.chat, + 'mentioned': self.mentioned, + 'media_unread': self.media_unread, + 'silent': self.silent, + 'post': self.post, + 'from_scheduled': self.from_scheduled, + 'legacy': self.legacy, + 'edit_hide': self.edit_hide, + 'pinned': self.pinned, + 'forward': self.forward, + 'via_bot': self.via_bot, + 'reply_to': self.reply_to, + 'reply_markup': self.reply_markup, + 'views': self.views, + 'forwards': self.forwards, + 'replies': self.replies, + 'edit_date': self.edit_date, + 'post_author': self.post_author, + 'grouped_id': self.grouped_id, + 'ttl_period': self.ttl_period, + 'action': self.action, + 'media': self.media, + 'action_entities': self.action_entities, + } + + def __repr__(self): + return helpers.pretty_print(self) + + def __str__(self): + return helpers.pretty_print(self, max_depth=2) + + def stringify(self): + return helpers.pretty_print(self, indent=0) + # TODO set md by default if commonmark is installed else nothing From dbe66bf805de4c0baec73979a8c80ea654fb323d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 16 Oct 2021 11:55:18 +0200 Subject: [PATCH 083/131] Remove TLObject.to_json --- readthedocs/misc/v2-migration-guide.rst | 8 ++++++++ telethon/_misc/tlobject.py | 17 ----------------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 0b8bbc22..c328549c 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -589,6 +589,14 @@ broken for some time now (see `issue #1319 Date: Sat, 16 Oct 2021 12:40:25 +0200 Subject: [PATCH 084/131] Add enum for typing action --- readthedocs/misc/changelog.rst | 12 +++++++ telethon/_client/chats.py | 60 +++++++++++++--------------------- telethon/_misc/enums.py | 22 +++++++++++++ telethon/enums.py | 1 + 4 files changed, 58 insertions(+), 37 deletions(-) diff --git a/readthedocs/misc/changelog.rst b/readthedocs/misc/changelog.rst index 951cf2e0..74005356 100644 --- a/readthedocs/misc/changelog.rst +++ b/readthedocs/misc/changelog.rst @@ -13,6 +13,18 @@ it can take advantage of new goodies! .. contents:: List of All Versions + +Complete overhaul of the library (v2.0) +======================================= + ++------------------------+ +| Scheme layer used: 133 | ++------------------------+ + +(inc and link all of migration guide) +properly-typed enums for filters and actions + + New schema and bug fixes (v1.23) ================================ diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 693feeab..3034b5d9 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -17,31 +17,6 @@ _MAX_PROFILE_PHOTO_CHUNK_SIZE = 100 class _ChatAction: - _str_mapping = { - 'typing': _tl.SendMessageTypingAction(), - 'contact': _tl.SendMessageChooseContactAction(), - 'game': _tl.SendMessageGamePlayAction(), - 'location': _tl.SendMessageGeoLocationAction(), - 'sticker': _tl.SendMessageChooseStickerAction(), - - 'record-audio': _tl.SendMessageRecordAudioAction(), - 'record-voice': _tl.SendMessageRecordAudioAction(), # alias - 'record-round': _tl.SendMessageRecordRoundAction(), - 'record-video': _tl.SendMessageRecordVideoAction(), - - 'audio': _tl.SendMessageUploadAudioAction(1), - 'voice': _tl.SendMessageUploadAudioAction(1), # alias - 'song': _tl.SendMessageUploadAudioAction(1), # alias - 'round': _tl.SendMessageUploadRoundAction(1), - 'video': _tl.SendMessageUploadVideoAction(1), - - 'photo': _tl.SendMessageUploadPhotoAction(1), - 'document': _tl.SendMessageUploadDocumentAction(1), - 'file': _tl.SendMessageUploadDocumentAction(1), # alias - - 'cancel': _tl.SendMessageCancelAction() - } - def __init__(self, client, chat, action, *, delay, auto_cancel): self._client = client self._chat = chat @@ -88,6 +63,28 @@ class _ChatAction: await self._client(_tl.fn.messages.SetTyping( self._chat, _tl.SendMessageCancelAction())) + @staticmethod + def _parse(action): + if isinstance(action, tlobject.TLObject) and action.SUBCLASS_OF_ID != 0x20b2cc21: + return action + + return { + enums.TYPING: _tl.SendMessageTypingAction(), + enums.CONTACT: _tl.SendMessageChooseContactAction(), + enums.GAME: _tl.SendMessageGamePlayAction(), + enums.LOCATION: _tl.SendMessageGeoLocationAction(), + enums.STICKER: _tl.SendMessageChooseStickerAction(), + enums.RECORD_AUDIO: _tl.SendMessageRecordAudioAction(), + enums.RECORD_ROUND: _tl.SendMessageRecordRoundAction(), + enums.RECORD_VIDEO: _tl.SendMessageRecordVideoAction(), + enums.AUDIO: _tl.SendMessageUploadAudioAction(1), + enums.ROUND: _tl.SendMessageUploadRoundAction(1), + enums.VIDEO: _tl.SendMessageUploadVideoAction(1), + enums.PHOTO: _tl.SendMessageUploadPhotoAction(1), + enums.DOCUMENT: _tl.SendMessageUploadDocumentAction(1), + enums.CANCEL: _tl.SendMessageCancelAction(), + }[enums.parse_typing_action(action)] + def progress(self, current, total): if hasattr(self._action, 'progress'): self._action.progress = 100 * round(current / total) @@ -457,18 +454,7 @@ def action( *, delay: float = 4, auto_cancel: bool = True) -> 'typing.Union[_ChatAction, typing.Coroutine]': - if isinstance(action, str): - try: - action = _ChatAction._str_mapping[action.lower()] - except KeyError: - raise ValueError( - 'No such action "{}"'.format(action)) from None - elif not isinstance(action, tlobject.TLObject) or action.SUBCLASS_OF_ID != 0x20b2cc21: - # 0x20b2cc21 = crc32(b'SendMessageAction') - if isinstance(action, type): - raise ValueError('You must pass an instance, not the class') - else: - raise ValueError('Cannot use {} as action'.format(action)) + action = _ChatAction._parse(action) if isinstance(action, _tl.SendMessageCancelAction): # ``SetTyping.resolve`` will get input peer of ``entity``. diff --git a/telethon/_misc/enums.py b/telethon/_misc/enums.py index edce6776..c8fa656b 100644 --- a/telethon/_misc/enums.py +++ b/telethon/_misc/enums.py @@ -17,6 +17,27 @@ class Participant(Enum): CONTACT = 'contact' +class Action(Enum): + TYPING = 'typing' + CONTACT = 'contact' + GAME = 'game' + LOCATION = 'location' + STICKER = 'sticker' + RECORD_AUDIO = 'record-audio' + RECORD_VOICE = RECORD_AUDIO + RECORD_ROUND = 'record-round' + RECORD_VIDEO = 'record-video' + AUDIO = 'audio' + VOICE = AUDIO + SONG = AUDIO + ROUND = 'round' + VIDEO = 'video' + PHOTO = 'photo' + DOCUMENT = 'document' + FILE = DOCUMENT + CANCEL = 'cancel' + + def _mk_parser(cls): def parser(value): if isinstance(value, cls): @@ -35,3 +56,4 @@ def _mk_parser(cls): parse_conn_mode = _mk_parser(ConnectionMode) parse_participant = _mk_parser(Participant) +parse_typing_action = _mk_parser(Action) diff --git a/telethon/enums.py b/telethon/enums.py index 8de39a15..bad39ea0 100644 --- a/telethon/enums.py +++ b/telethon/enums.py @@ -1,4 +1,5 @@ from ._misc.enums import ( ConnectionMode, Participant, + Action, ) From e2132d5f7c328388424ab3626e32137f5b7b0375 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 16 Oct 2021 13:56:38 +0200 Subject: [PATCH 085/131] Change the way thumb size selection works --- readthedocs/misc/v2-migration-guide.rst | 6 ++ telethon/_client/downloads.py | 63 +++++----------- telethon/_client/telegramclient.py | 45 +++++------- telethon/_misc/enums.py | 96 +++++++++++++++++++++++++ telethon/enums.py | 1 + 5 files changed, 139 insertions(+), 72 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index c328549c..e4e111a4 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -706,3 +706,9 @@ formatting_entities stays because otherwise it's the only feasible way to manual todo update send_message and send_file docs (well review all functions) album overhaul. use a list of Message instead. + +size selector for download_profile_photo and download_media is now different + +still thumb because otherwise documents are weird. + +keep support for explicit size instance? diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py index b29206d4..6339b8c9 100644 --- a/telethon/_client/downloads.py +++ b/telethon/_client/downloads.py @@ -7,7 +7,7 @@ import inspect import asyncio from .._crypto import AES -from .._misc import utils, helpers, requestiter, tlobject, hints +from .._misc import utils, helpers, requestiter, tlobject, hints, enums from .. import errors, _tl try: @@ -180,7 +180,7 @@ async def download_profile_photo( entity: 'hints.EntityLike', file: 'hints.FileLike' = None, *, - download_big: bool = True) -> typing.Optional[str]: + thumb) -> typing.Optional[str]: # hex(crc32(x.encode('ascii'))) for x in # ('User', 'Chat', 'UserFull', 'ChatFull') ENTITIES = (0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697) @@ -189,8 +189,6 @@ async def download_profile_photo( if not isinstance(entity, tlobject.TLObject) or entity.SUBCLASS_OF_ID in INPUTS: entity = await self.get_entity(entity) - thumb = -1 if download_big else 0 - possible_names = [] if entity.SUBCLASS_OF_ID not in ENTITIES: photo = entity @@ -212,11 +210,13 @@ async def download_profile_photo( photo = entity.photo if isinstance(photo, (_tl.UserProfilePhoto, _tl.ChatPhoto)): + thumb = enums.Size.ORIGINAL if thumb == () else enums.parse_photo_size(thumb) + dc_id = photo.dc_id loc = _tl.InputPeerPhotoFileLocation( peer=await self.get_input_entity(entity), photo_id=photo.photo_id, - big=download_big + big=thumb >= enums.Size.LARGE ) else: # It doesn't make any sense to check if `photo` can be used @@ -259,7 +259,7 @@ async def download_media( message: 'hints.MessageLike', file: 'hints.FileLike' = None, *, - thumb: 'typing.Union[int, _tl.TypePhotoSize]' = None, + size = (), progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]: # Downloading large documents may be slow enough to require a new file reference # to be obtained mid-download. Store (input chat, message id) so that the message @@ -292,11 +292,11 @@ async def download_media( return await _download_document( self, media, file, date, thumb, progress_callback, msg_data ) - elif isinstance(media, _tl.MessageMediaContact) and thumb is None: + elif isinstance(media, _tl.MessageMediaContact): return _download_contact( self, media, file ) - elif isinstance(media, (_tl.WebDocument, _tl.WebDocumentNoProxy)) and thumb is None: + elif isinstance(media, (_tl.WebDocument, _tl.WebDocumentNoProxy)): return await _download_web_document( self, media, file, progress_callback ) @@ -491,44 +491,15 @@ def _iter_download( def _get_thumb(thumbs, thumb): - # Seems Telegram has changed the order and put `PhotoStrippedSize` - # last while this is the smallest (layer 116). Ensure we have the - # sizes sorted correctly with a custom function. - def sort_thumbs(thumb): - if isinstance(thumb, _tl.PhotoStrippedSize): - return 1, len(thumb.bytes) - if isinstance(thumb, _tl.PhotoCachedSize): - return 1, len(thumb.bytes) - if isinstance(thumb, _tl.PhotoSize): - return 1, thumb.size - if isinstance(thumb, _tl.PhotoSizeProgressive): - return 1, max(thumb.sizes) - if isinstance(thumb, _tl.VideoSize): - return 2, thumb.size - - # Empty size or invalid should go last - return 0, 0 - - thumbs = list(sorted(thumbs, key=sort_thumbs)) - - for i in reversed(range(len(thumbs))): - # :tl:`PhotoPathSize` is used for animated stickers preview, and the thumb is actually - # a SVG path of the outline. Users expect thumbnails to be JPEG files, so pretend this - # thumb size doesn't actually exist (#1655). - if isinstance(thumbs[i], _tl.PhotoPathSize): - thumbs.pop(i) - - if thumb is None: - return thumbs[-1] - elif isinstance(thumb, int): - return thumbs[thumb] - elif isinstance(thumb, str): - return next((t for t in thumbs if t.type == thumb), None) - elif isinstance(thumb, (_tl.PhotoSize, _tl.PhotoCachedSize, - _tl.PhotoStrippedSize, _tl.VideoSize)): + if isinstance(thumb, tlobject.TLObject): return thumb - else: - return None + + thumb = enums.parse_photo_size(thumb) + return min( + thumbs, + default=None, + key=lambda t: abs(thumb - enums.parse_photo_size(t.type)) + ) def _download_cached_photo_size(self: 'TelegramClient', size, file): # No need to download anything, simply write the bytes @@ -623,7 +594,7 @@ async def _download_document( if not isinstance(document, _tl.Document): return - if thumb is None: + if thumb == (): kind, possible_names = _get_kind_and_names(document.attributes) file = _get_proper_filename( file, kind, utils.get_extension(document), diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index f2dc4ede..034b0a30 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -1610,7 +1610,7 @@ class TelegramClient: entity: 'hints.EntityLike', file: 'hints.FileLike' = None, *, - download_big: bool = True) -> typing.Optional[str]: + thumb: typing.Union[str, enums.Size] = ()) -> typing.Optional[str]: """ Downloads the profile photo from the given user, chat or channel. @@ -1634,8 +1634,15 @@ class TelegramClient: If file is the type `bytes`, it will be downloaded in-memory as a bytestring (e.g. ``file=bytes``). - download_big (`bool`, optional): - Whether to use the big version of the available photos. + thumb (optional): + The thumbnail size to download. A different size may be chosen + if the specified size doesn't exist. The category of the size + you choose will be respected when possible (e.g. if you + specify a cropped size, a cropped variant of similar size will + be preferred over a boxed variant of similar size). Cropped + images are considered to be smaller than boxed images. + + By default, the largest size (original) is downloaded. Returns `None` if no photo was provided, or if it was Empty. On success @@ -1655,7 +1662,7 @@ class TelegramClient: message: 'hints.MessageLike', file: 'hints.FileLike' = None, *, - thumb: 'typing.Union[int, _tl.TypePhotoSize]' = None, + thumb: typing.Union[str, enums.Size] = (), progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]: """ Downloads the given media from a message object. @@ -1680,29 +1687,15 @@ class TelegramClient: A callback function accepting two parameters: ``(received bytes, total)``. - thumb (`int` | :tl:`PhotoSize`, optional): - Which thumbnail size from the document or photo to download, - instead of downloading the document or photo itself. + thumb (optional): + The thumbnail size to download. A different size may be chosen + if the specified size doesn't exist. The category of the size + you choose will be respected when possible (e.g. if you + specify a cropped size, a cropped variant of similar size will + be preferred over a boxed variant of similar size). Cropped + images are considered to be smaller than boxed images. - If it's specified but the file does not have a thumbnail, - this method will return `None`. - - The parameter should be an integer index between ``0`` and - ``len(sizes)``. ``0`` will download the smallest thumbnail, - and ``len(sizes) - 1`` will download the largest thumbnail. - You can also use negative indices, which work the same as - they do in Python's `list`. - - You can also pass the :tl:`PhotoSize` instance to use. - Alternatively, the thumb size type `str` may be used. - - In short, use ``thumb=0`` if you want the smallest thumbnail - and ``thumb=-1`` if you want the largest thumbnail. - - .. note:: - The largest thumbnail may be a video instead of a photo, - as they are available since layer 116 and are bigger than - any of the photos. + By default, the original media is downloaded. Returns `None` if no media was provided, or if it was Empty. On success diff --git a/telethon/_misc/enums.py b/telethon/_misc/enums.py index c8fa656b..2d5742aa 100644 --- a/telethon/_misc/enums.py +++ b/telethon/_misc/enums.py @@ -1,6 +1,16 @@ from enum import Enum +def _impl_op(which): + def op(self, other): + if not isinstance(other, type(self)): + return NotImplemented + + return getattr(self._val(), which)(other._val()) + + return op + + class ConnectionMode(Enum): FULL = 'full' INTERMEDIATE = 'intermediate' @@ -38,6 +48,91 @@ class Action(Enum): CANCEL = 'cancel' +class Size(Enum): + """ + See https://core.telegram.org/api/files#image-thumbnail-types. + + * ``'s'``. The image fits within a box of 100x100. + * ``'m'``. The image fits within a box of 320x320. + * ``'x'``. The image fits within a box of 800x800. + * ``'y'``. The image fits within a box of 1280x1280. + * ``'w'``. The image fits within a box of 2560x2560. + * ``'a'``. The image was cropped to be at most 160x160. + * ``'b'``. The image was cropped to be at most 320x320. + * ``'c'``. The image was cropped to be at most 640x640. + * ``'d'``. The image was cropped to be at most 1280x1280. + * ``'i'``. The image comes inline (no need to download anything). + * ``'j'``. Only the image outline is present (for stickers). + * ``'u'``. The image is actually a short MPEG4 animated video. + * ``'v'``. The image is actually a short MPEG4 video preview. + + The sorting order is first dimensions, then ``cropped < boxed < video < other``. + """ + SMALL = 's' + MEDIUM = 'm' + LARGE = 'x' + EXTRA_LARGE = 'y' + ORIGINAL = 'w' + CROPPED_SMALL = 'a' + CROPPED_MEDIUM = 'b' + CROPPED_LARGE = 'c' + CROPPED_EXTRA_LARGE = 'd' + INLINE = 'i' + OUTLINE = 'j' + ANIMATED = 'u' + VIDEO = 'v' + + def __hash__(self): + return object.__hash__(self) + + __sub__ = _impl_op('__sub__') + __lt__ = _impl_op('__lt__') + __le__ = _impl_op('__le__') + __eq__ = _impl_op('__eq__') + __ne__ = _impl_op('__ne__') + __gt__ = _impl_op('__gt__') + __ge__ = _impl_op('__ge__') + + def _val(self): + return self._category() * 100 + self._size() + + def _category(self): + return { + Size.SMALL: 2, + Size.MEDIUM: 2, + Size.LARGE: 2, + Size.EXTRA_LARGE: 2, + Size.ORIGINAL: 2, + Size.CROPPED_SMALL: 1, + Size.CROPPED_MEDIUM: 1, + Size.CROPPED_LARGE: 1, + Size.CROPPED_EXTRA_LARGE: 1, + Size.INLINE: 4, + Size.OUTLINE: 5, + Size.ANIMATED: 3, + Size.VIDEO: 3, + }[self] + + def _size(self): + return { + Size.SMALL: 1, + Size.MEDIUM: 3, + Size.LARGE: 5, + Size.EXTRA_LARGE: 6, + Size.ORIGINAL: 7, + Size.CROPPED_SMALL: 2, + Size.CROPPED_MEDIUM: 3, + Size.CROPPED_LARGE: 4, + Size.CROPPED_EXTRA_LARGE: 6, + # 0, since they're not the original photo at all + Size.INLINE: 0, + Size.OUTLINE: 0, + # same size as original or extra large (videos are large) + Size.ANIMATED: 7, + Size.VIDEO: 6, + }[self] + + def _mk_parser(cls): def parser(value): if isinstance(value, cls): @@ -57,3 +152,4 @@ def _mk_parser(cls): parse_conn_mode = _mk_parser(ConnectionMode) parse_participant = _mk_parser(Participant) parse_typing_action = _mk_parser(Action) +parse_photo_size = _mk_parser(Size) diff --git a/telethon/enums.py b/telethon/enums.py index bad39ea0..ef7715cc 100644 --- a/telethon/enums.py +++ b/telethon/enums.py @@ -2,4 +2,5 @@ from ._misc.enums import ( ConnectionMode, Participant, Action, + Size, ) From 1b15a34f6928dfb711d4a2056507026f8ec333d6 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 16 Oct 2021 13:59:33 +0200 Subject: [PATCH 086/131] Remove parse_mode from the client --- readthedocs/misc/v2-migration-guide.rst | 8 +++-- telethon/_client/messageparse.py | 10 ------ telethon/_client/telegramclient.py | 44 ------------------------- 3 files changed, 6 insertions(+), 56 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index e4e111a4..0b6a7c01 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -207,8 +207,10 @@ The ``telethon.errors`` module continues to provide custom errors used by the li // TODO provide a way to see which errors are known in the docs or at tl.telethon.dev -The default markdown parse mode now conforms to the commonmark specification ----------------------------------------------------------------------------- +Changes to the default parse mode +--------------------------------- + +The default markdown parse mode now conforms to the commonmark specification. The old markdown parser (which was used as the default ``client.parse_mode``) used to emulate Telegram Desktop's behaviour. Now ``__ @@ -224,6 +226,8 @@ Because now there's proper parsing, you also gain: * Inline links should no longer behave in a strange manner. * Pre-blocks can now have a language. Official clients don't syntax highlight code yet, though. +Furthermore, the parse mode is no longer client-dependant. It is now configured through ``Message``. + // TODO provide a way to get back the old behaviour? diff --git a/telethon/_client/messageparse.py b/telethon/_client/messageparse.py index b7e76e8d..f545a2f3 100644 --- a/telethon/_client/messageparse.py +++ b/telethon/_client/messageparse.py @@ -10,16 +10,6 @@ if typing.TYPE_CHECKING: from .telegramclient import TelegramClient -def get_parse_mode(self: 'TelegramClient'): - return self._parse_mode - -def set_parse_mode(self: 'TelegramClient', mode: str): - self._parse_mode = utils.sanitize_parse_mode(mode) - -# endregion - -# region Private methods - async def _replace_with_mention(self: 'TelegramClient', entities, i, user): """ Helper method to replace ``entities[i]`` to mention ``user``, diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 034b0a30..e9ea9ca9 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -1817,50 +1817,6 @@ class TelegramClient: # endregion Downloads - # region Message parse - - @property - def parse_mode(self: 'TelegramClient'): - """ - 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. - - Example - .. code-block:: python - - # Disabling default formatting - client.parse_mode = None - - # Enabling HTML as the default format - client.parse_mode = 'html' - """ - return messageparse.get_parse_mode(**locals()) - - @parse_mode.setter - def parse_mode(self: 'TelegramClient', mode: str): - return messageparse.set_parse_mode(**locals()) - - # endregion Message parse - # region Messages @forward_call(messages.get_messages) From 010ee0813a73b8c0e7748b9e91b0b3d9f3d76748 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 16 Oct 2021 14:01:56 +0200 Subject: [PATCH 087/131] Rename send_read_acknowledge --- readthedocs/misc/v2-migration-guide.rst | 2 ++ telethon/_client/messages.py | 2 +- telethon/_client/telegramclient.py | 10 +++++----- telethon/_events/album.py | 6 +++--- telethon/types/_custom/message.py | 6 +++--- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 0b6a7c01..774e8634 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -716,3 +716,5 @@ size selector for download_profile_photo and download_media is now different still thumb because otherwise documents are weird. keep support for explicit size instance? + +renamed send_read_acknowledge. add send_read_acknowledge as alias for mark_read? diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index 4a5ae371..f55fed8d 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -667,7 +667,7 @@ async def delete_messages( return sum(r.pts_count for r in res) -async def send_read_acknowledge( +async def mark_read( self: 'TelegramClient', entity: 'hints.EntityLike', message: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]' = None, diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index e9ea9ca9..f6ad961b 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -2490,8 +2490,8 @@ class TelegramClient: await client.delete_messages(chat, messages) """ - @forward_call(messages.send_read_acknowledge) - async def send_read_acknowledge( + @forward_call(messages.mark_read) + async def mark_read( self: 'TelegramClient', entity: 'hints.EntityLike', message: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]' = None, @@ -2535,11 +2535,11 @@ class TelegramClient: .. code-block:: python # using a Message object - await client.send_read_acknowledge(chat, message) + await client.mark_read(chat, message) # ...or using the int ID of a Message - await client.send_read_acknowledge(chat, message_id) + await client.mark_read(chat, message_id) # ...or passing a list of messages to mark as read - await client.send_read_acknowledge(chat, messages) + await client.mark_read(chat, messages) """ @forward_call(messages.pin_message) diff --git a/telethon/_events/album.py b/telethon/_events/album.py index cb9c39a8..19ea4234 100644 --- a/telethon/_events/album.py +++ b/telethon/_events/album.py @@ -310,12 +310,12 @@ class Album(EventBuilder): async def mark_read(self): """ Marks the entire album as read. Shorthand for - `client.send_read_acknowledge() - ` + `client.mark_read() + ` with both ``entity`` and ``message`` already set. """ if self._client: - await self._client.send_read_acknowledge( + await self._client.mark_read( await self.get_input_chat(), max_id=self.messages[-1].id) async def pin(self, *, notify=False): diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index bd0b61ee..278bc0e7 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -1237,12 +1237,12 @@ class Message(ChatGetter, SenderGetter): async def mark_read(self): """ Marks the message as read. Shorthand for - `client.send_read_acknowledge() - ` + `client.mark_read() + ` with both ``entity`` and ``message`` already set. """ if self._client: - await self._client.send_read_acknowledge( + await self._client.mark_read( await self.get_input_chat(), max_id=self.id) async def pin(self, *, notify=False, pm_oneside=False): From 232e76e73a34db706f08e98c0c1ce3abdf2080d2 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 9 Jan 2022 12:20:58 +0100 Subject: [PATCH 088/131] Stop setting the sender to be the channel when missing --- readthedocs/misc/v2-migration-guide.rst | 4 ++++ telethon/types/_custom/message.py | 7 ------- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 774e8634..96ef3b6e 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -494,6 +494,10 @@ empty media. The ``telethon.tl.patched`` hack has been removed. +The message sender no longer is the channel when no sender is provided by Telegram. Telethon used +to patch this value for channels to be the same as the chat, but now it will be faithful to +Telegram's value. + In order to avoid breaking more code than strictly necessary, ``.raw_text`` will remain a synonym of ``.message``, and ``.text`` will still be the text formatted through the ``client.parse_mode``. However, you're encouraged to change uses of ``.raw_text`` with ``.message``, and ``.text`` with diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index 278bc0e7..54dde6b0 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -410,13 +410,6 @@ class Message(ChatGetter, SenderGetter): if isinstance(message, _tl.Message): if message.from_id is not None: sender_id = utils.get_peer_id(message.from_id) - if sender_id is None and message.peer_id and not isinstance(message, _tl.MessageEmpty): - # If the message comes from a Channel, let the sender be it - # ...or... - # incoming messages in private conversations no longer have from_id - # (layer 119+), but the sender can only be the chat we're in. - if message.post or (not message.out and isinstance(message.peer_id, _tl.PeerUser)): - sender_id = utils.get_peer_id(message.peer_id) # Note that these calls would reset the client ChatGetter.__init__(self, message.peer_id, broadcast=message.post) From 721c803af99a17140d77bba37ce6720b81374828 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 9 Jan 2022 12:23:06 +0100 Subject: [PATCH 089/131] Stop opening webbrowser on clicking URL buttons --- readthedocs/misc/v2-migration-guide.rst | 6 ++++++ telethon/types/_custom/messagebutton.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 96ef3b6e..42285c1d 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -701,6 +701,12 @@ CdnDecrypter has been removed It was not really working and was more intended to be an implementation detail than anything else. +URL buttons no longer open the web-browser +------------------------------------------ + +Now the URL is returned. You can still use ``webbrowser.open`` to get the old behaviour. + + --- you can no longer pass an attributes list because the constructor is now nice. diff --git a/telethon/types/_custom/messagebutton.py b/telethon/types/_custom/messagebutton.py index c821c410..2d588727 100644 --- a/telethon/types/_custom/messagebutton.py +++ b/telethon/types/_custom/messagebutton.py @@ -112,7 +112,7 @@ class MessageButton: bot=self._bot, peer=self._chat, start_param=self.button.query )) elif isinstance(self.button, _tl.KeyboardButtonUrl): - return webbrowser.open(self.button.url) + return self.button.url elif isinstance(self.button, _tl.KeyboardButtonGame): req = _tl.fn.messages.GetBotCallbackAnswer( peer=self._chat, msg_id=self._msg_id, game=True From 7ea30961aeca35559b7d7244814f9e2d8a99dcb1 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 9 Jan 2022 13:00:45 +0100 Subject: [PATCH 090/131] Bump minimum required Python version to 3.7 --- .github/workflows/python.yml | 2 +- pyproject.toml | 2 +- readthedocs/basic/signing-in.rst | 19 +++++-------------- readthedocs/developing/testing.rst | 2 +- readthedocs/misc/v2-migration-guide.rst | 4 +++- setup.py | 6 +++--- telethon/_misc/hints.py | 18 +++++------------- 7 files changed, 19 insertions(+), 34 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index d3c34a20..f3fd3106 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.5", "3.6", "3.7", "3.8"] + python-version: ["3.7", "3.8", "3.9", "3.10"] steps: - uses: actions/checkout@v1 - name: Set up Python ${{ matrix.python-version }} diff --git a/pyproject.toml b/pyproject.toml index daae10fa..ca877203 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [tool.tox] legacy_tox_ini = """ [tox] -envlist = py35,py36,py37,py38 +envlist = py37,py38 # run with tox -e py [testenv] diff --git a/readthedocs/basic/signing-in.rst b/readthedocs/basic/signing-in.rst index 7f584a95..c7b60507 100644 --- a/readthedocs/basic/signing-in.rst +++ b/readthedocs/basic/signing-in.rst @@ -122,11 +122,9 @@ with `@BotFather `_. Signing In behind a Proxy ========================= -If you need to use a proxy to access Telegram, -you will need to either: +If you need to use a proxy to access Telegram, you will need to: -* For Python >= 3.6 : `install python-socks[asyncio]`__ -* For Python <= 3.5 : `install PySocks`__ +`install python-socks[asyncio]`__ and then change @@ -147,16 +145,9 @@ consisting of parameters described `in PySocks usage`__. The allowed values for the argument ``proxy_type`` are: -* For Python <= 3.5: - * ``socks.SOCKS5`` or ``'socks5'`` - * ``socks.SOCKS4`` or ``'socks4'`` - * ``socks.HTTP`` or ``'http'`` - -* For Python >= 3.6: - * All of the above - * ``python_socks.ProxyType.SOCKS5`` - * ``python_socks.ProxyType.SOCKS4`` - * ``python_socks.ProxyType.HTTP`` +* ``python_socks.ProxyType.SOCKS5`` +* ``python_socks.ProxyType.SOCKS4`` +* ``python_socks.ProxyType.HTTP`` Example: diff --git a/readthedocs/developing/testing.rst b/readthedocs/developing/testing.rst index badb7dc6..dfabe73f 100644 --- a/readthedocs/developing/testing.rst +++ b/readthedocs/developing/testing.rst @@ -71,7 +71,7 @@ version incompatabilities. Tox environments are declared in the ``tox.ini`` file. The default environments, declared at the top, can be simply run with ``tox``. The option -``tox -e py36,flake`` can be used to request specific environments to be run. +``tox -e py37,flake`` can be used to request specific environments to be run. Brief Introduction to Pytest-cov ================================ diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 42285c1d..d9e3c270 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -16,7 +16,9 @@ good chance you were not relying on this to begin with". Python 3.5 is no longer supported --------------------------------- -The library will no longer attempt to support Python 3.5. The minimum version is now Python 3.6. +The library will no longer attempt to support Python 3.5. The minimum version is now Python 3.7. + +This also means workarounds for 3.6 and below have been dropped. User, chat and channel identifiers are now 64-bit numbers diff --git a/setup.py b/setup.py index 05f0f7b6..caf4a54a 100755 --- a/setup.py +++ b/setup.py @@ -208,7 +208,7 @@ def main(argv): # See https://stackoverflow.com/a/40300957/4759433 # -> https://www.python.org/dev/peps/pep-0345/#requires-python # -> http://setuptools.readthedocs.io/en/latest/setuptools.html - python_requires='>=3.5', + python_requires='>=3.7', # See https://pypi.python.org/pypi?%3Aaction=list_classifiers classifiers=[ @@ -223,10 +223,10 @@ def main(argv): 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', ], keywords='telegram api chat client library messaging mtproto', packages=find_packages(exclude=[ diff --git a/telethon/_misc/hints.py b/telethon/_misc/hints.py index 47f26a8f..52951cec 100644 --- a/telethon/_misc/hints.py +++ b/telethon/_misc/hints.py @@ -48,19 +48,11 @@ FileLike = typing.Union[ _tl.TypeInputFileLocation ] -# Can't use `typing.Type` in Python 3.5.2 -# See https://github.com/python/typing/issues/266 -try: - OutFileLike = typing.Union[ - str, - typing.Type[bytes], - typing.BinaryIO - ] -except TypeError: - OutFileLike = typing.Union[ - str, - typing.BinaryIO - ] +OutFileLike = typing.Union[ + str, + typing.Type[bytes], + typing.BinaryIO +] MessageLike = typing.Union[str, _tl.Message] MessageIDLike = typing.Union[int, _tl.Message, _tl.TypeInputMessage] From be6508dc5d0d28d75a550cc910805af3e92d74a0 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 9 Jan 2022 13:01:16 +0100 Subject: [PATCH 091/131] Use frozen dataclasses for session types Now that 3.7 is the minimum version, we can use dataclasses. --- telethon/_sessions/abstract.py | 12 +-- telethon/_sessions/types.py | 135 ++++++++++++--------------------- 2 files changed, 53 insertions(+), 94 deletions(-) diff --git a/telethon/_sessions/abstract.py b/telethon/_sessions/abstract.py index 2b28ae76..cdb747a4 100644 --- a/telethon/_sessions/abstract.py +++ b/telethon/_sessions/abstract.py @@ -1,4 +1,4 @@ -from .types import DataCenter, ChannelState, SessionState, Entity +from .types import DataCenter, ChannelState, SessionState, EntityType, Entity from abc import ABC, abstractmethod from typing import List, Optional @@ -59,7 +59,7 @@ class Session(ABC): raise NotImplementedError @abstractmethod - async def get_entity(self, ty: Optional[int], id: int) -> Optional[Entity]: + async def get_entity(self, ty: Optional[EntityType], id: int) -> Optional[Entity]: """ Get the `Entity` with matching ``ty`` and ``id``. @@ -67,14 +67,14 @@ class Session(ABC): ``ty`` and ``id``, if the ``ty`` is in a given group, a matching ``access_hash`` with that ``id`` from within any ``ty`` in that group should be returned. - * ``'U'`` and ``'B'`` (user and bot). - * ``'G'`` (small group chat). - * ``'C'``, ``'M'`` and ``'E'`` (broadcast channel, megagroup channel, and gigagroup channel). + * `EntityType.USER` and `EntityType.BOT`. + * `EntityType.GROUP`. + * `EntityType.CHANNEL`, `EntityType.MEGAGROUP` and `EntityType.GIGAGROUP`. For example, if a ``ty`` representing a bot is stored but the asking ``ty`` is a user, the corresponding ``access_hash`` should still be returned. - You may use `types.canonical_entity_type` to find out the canonical type. + You may use ``EntityType.canonical`` to find out the canonical type. A ``ty`` with the value of ``None`` should be treated as "any entity with matching ID". """ diff --git a/telethon/_sessions/types.py b/telethon/_sessions/types.py index 51d4ecb5..a9738709 100644 --- a/telethon/_sessions/types.py +++ b/telethon/_sessions/types.py @@ -1,6 +1,9 @@ from typing import Optional, Tuple +from dataclasses import dataclass +from enum import IntEnum +@dataclass(frozen=True) class DataCenter: """ Stores the information needed to connect to a datacenter. @@ -12,21 +15,14 @@ class DataCenter: """ __slots__ = ('id', 'ipv4', 'ipv6', 'port', 'auth') - def __init__( - self, - id: int, - ipv4: int, - ipv6: Optional[int], - port: int, - auth: bytes - ): - self.id = id - self.ipv4 = ipv4 - self.ipv6 = ipv6 - self.port = port - self.auth = auth + id: int + ipv4: int + ipv6: Optional[int] + port: int + auth: bytes +@dataclass(frozen=True) class SessionState: """ Stores the information needed to fetch updates and about the current user. @@ -45,27 +41,17 @@ class SessionState: """ __slots__ = ('user_id', 'dc_id', 'bot', 'pts', 'qts', 'date', 'seq', 'takeout_id') - def __init__( - self, - user_id: int, - dc_id: int, - bot: bool, - pts: int, - qts: int, - date: int, - seq: int, - takeout_id: Optional[int], - ): - self.user_id = user_id - self.dc_id = dc_id - self.bot = bot - self.pts = pts - self.qts = qts - self.date = date - self.seq = seq - self.takeout_id = takeout_id + user_id: int + dc_id: int + bot: bool + pts: int + qts: int + date: int + seq: int + takeout_id: Optional[int] +@dataclass(frozen=True) class ChannelState: """ Stores the information needed to fetch updates from a channel. @@ -75,24 +61,13 @@ class ChannelState: """ __slots__ = ('channel_id', 'pts') - def __init__( - self, - channel_id: int, - pts: int - ): - self.channel_id = channel_id - self.pts = pts + channel_id: int + pts: int -class Entity: +class EntityType(IntEnum): """ - Stores the information needed to use a certain user, chat or channel with the API. - - * ty: 8-bit number indicating the type of the entity. - * id: 64-bit number uniquely identifying the entity among those of the same type. - * access_hash: 64-bit number needed to use this entity with the API. - - You can rely on the ``ty`` value to be equal to the ASCII character one of: + You can rely on the type value to be equal to the ASCII character one of: * 'U' (85): this entity belongs to a :tl:`User` who is not a ``bot``. * 'B' (66): this entity belongs to a :tl:`User` who is a ``bot``. @@ -101,8 +76,6 @@ class Entity: * 'M' (77): this entity belongs to a megagroup :tl:`Channel`. * 'E' (69): this entity belongs to an "enormous" "gigagroup" :tl:`Channel`. """ - __slots__ = ('ty', 'id', 'access_hash') - USER = ord('U') BOT = ord('B') GROUP = ord('G') @@ -110,48 +83,34 @@ class Entity: MEGAGROUP = ord('M') GIGAGROUP = ord('E') - def __init__( - self, - ty: int, - id: int, - access_hash: int - ): - self.ty = ty - self.id = id - self.access_hash = access_hash + def canonical(self): + """ + Return the canonical version of this type. + """ + return _canon_entity_types[self] -def canonical_entity_type(ty: int, *, _mapping={ - Entity.USER: Entity.USER, - Entity.BOT: Entity.USER, - Entity.GROUP: Entity.GROUP, - Entity.CHANNEL: Entity.CHANNEL, - Entity.MEGAGROUP: Entity.CHANNEL, - Entity.GIGAGROUP: Entity.CHANNEL, -}) -> int: - """ - Return the canonical version of an entity type. - """ - try: - return _mapping[ty] - except KeyError: - ty = chr(ty) if isinstance(ty, int) else ty - raise ValueError(f'entity type {ty!r} is not valid') +_canon_entity_types = { + EntityType.USER: EntityType.USER, + EntityType.BOT: EntityType.USER, + EntityType.GROUP: EntityType.GROUP, + EntityType.CHANNEL: EntityType.CHANNEL, + EntityType.MEGAGROUP: EntityType.CHANNEL, + EntityType.GIGAGROUP: EntityType.CHANNEL, +} -def get_entity_type_group(ty: int, *, _mapping={ - Entity.USER: (Entity.USER, Entity.BOT), - Entity.BOT: (Entity.USER, Entity.BOT), - Entity.GROUP: (Entity.GROUP,), - Entity.CHANNEL: (Entity.CHANNEL, Entity.MEGAGROUP, Entity.GIGAGROUP), - Entity.MEGAGROUP: (Entity.CHANNEL, Entity.MEGAGROUP, Entity.GIGAGROUP), - Entity.GIGAGROUP: (Entity.CHANNEL, Entity.MEGAGROUP, Entity.GIGAGROUP), -}) -> Tuple[int]: +@dataclass(frozen=True) +class Entity: """ - Return the group where an entity type belongs to. + Stores the information needed to use a certain user, chat or channel with the API. + + * ty: 8-bit number indicating the type of the entity. + * id: 64-bit number uniquely identifying the entity among those of the same type. + * access_hash: 64-bit number needed to use this entity with the API. """ - try: - return _mapping[ty] - except KeyError: - ty = chr(ty) if isinstance(ty, int) else ty - raise ValueError(f'entity type {ty!r} is not valid') + __slots__ = ('ty', 'id', 'access_hash') + + ty: EntityType + id: int + access_hash: int From 691160bd92ed0aa39ea551f6a9bb310ab9881cfa Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 9 Jan 2022 13:03:02 +0100 Subject: [PATCH 092/131] Remove 3.7 workarounds --- telethon/_network/connection/connection.py | 17 +++++++------- telethon/errors/__init__.py | 26 +++------------------- 2 files changed, 11 insertions(+), 32 deletions(-) diff --git a/telethon/_network/connection/connection.py b/telethon/_network/connection/connection.py index 4570180d..abf398ee 100644 --- a/telethon/_network/connection/connection.py +++ b/telethon/_network/connection/connection.py @@ -263,15 +263,14 @@ class Connection(abc.ABC): if self._writer: self._writer.close() - if sys.version_info >= (3, 7): - try: - await self._writer.wait_closed() - except Exception as e: - # Disconnecting should never raise. Seen: - # * OSError: No route to host and - # * OSError: [Errno 32] Broken pipe - # * ConnectionResetError - self._log.info('%s during disconnect: %s', type(e), e) + try: + await self._writer.wait_closed() + except Exception as e: + # Disconnecting should never raise. Seen: + # * OSError: No route to host and + # * OSError: [Errno 32] Broken pipe + # * ConnectionResetError + self._log.info('%s during disconnect: %s', type(e), e) def send(self, data): """ diff --git a/telethon/errors/__init__.py b/telethon/errors/__init__.py index 152e7823..0d4ea0cc 100644 --- a/telethon/errors/__init__.py +++ b/telethon/errors/__init__.py @@ -1,5 +1,3 @@ -import sys - from ._custom import ( ReadCancelledError, TypeNotFoundError, @@ -25,24 +23,6 @@ from ._rpcbase import ( _mk_error_type ) -if sys.version_info < (3, 7): - # https://stackoverflow.com/a/7668273/ - class _TelethonErrors: - def __init__(self, _mk_error_type, everything): - self._mk_error_type = _mk_error_type - self.__dict__.update({ - k: v - for k, v in everything.items() - if isinstance(v, type) and issubclass(v, Exception) - }) - - def __getattr__(self, name): - return self._mk_error_type(name=name) - - sys.modules[__name__] = _TelethonErrors(_mk_error_type, globals()) -else: - # https://www.python.org/dev/peps/pep-0562/ - def __getattr__(name): - return _mk_error_type(name=name) - -del sys +# https://www.python.org/dev/peps/pep-0562/ +def __getattr__(name): + return _mk_error_type(name=name) From 2db0725b983a7a6b17de47cb0a89d8cb84904e2a Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 9 Jan 2022 14:41:04 +0100 Subject: [PATCH 093/131] Fix generating error names in TL ref --- telethon_generator/generators/docs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon_generator/generators/docs.py b/telethon_generator/generators/docs.py index 8b46e4d1..50a856b0 100755 --- a/telethon_generator/generators/docs.py +++ b/telethon_generator/generators/docs.py @@ -370,7 +370,7 @@ def _write_html_pages(tlobjects, methods, layer, input_res): )) docs.begin_table(column_count=2) for error in errors: - docs.add_row('{}'.format(error.name)) + docs.add_row('{}'.format(error.canonical_name)) docs.add_row('{}.'.format(error.description)) docs.end_table() docs.write_text('You can import these from ' From be0da9b1835793127f7983538ce1be178faabe17 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 9 Jan 2022 14:41:10 +0100 Subject: [PATCH 094/131] Update takeout to use less hacks --- readthedocs/misc/v2-migration-guide.rst | 18 +++ telethon/__init__.py | 1 + telethon/_client/account.py | 154 +++++++----------------- telethon/_client/telegramclient.py | 77 ++++++++---- telethon/_client/users.py | 4 + 5 files changed, 118 insertions(+), 136 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index d9e3c270..97b0bcb2 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -697,6 +697,24 @@ If you were relying on any of the individual mixins that made up the client, suc There is a single ``TelegramClient`` class now, containing everything you need. +The takeout context-manager has changed +--------------------------------------- + +It no longer has a finalize. All the requests made by the client in the same task will be wrapped, +not only those made through the proxy client returned by the context-manager. + +This cleans up the (rather hacky) implementation, making use of Python's ``contextvar``. If you +still need the takeout session to persist, you should manually use the ``begin_takeout`` and +``end_takeout`` method. + +If you want to ignore the currently-active takeout session in a task, toggle the following context +variable: + +.. code-block:: python + + telethon.ignore_takeout.set(True) + + CdnDecrypter has been removed ----------------------------- diff --git a/telethon/__init__.py b/telethon/__init__.py index 86e0580f..5d337667 100644 --- a/telethon/__init__.py +++ b/telethon/__init__.py @@ -5,6 +5,7 @@ from ._misc import utils as _ # depends on helpers and _tl from ._misc import hints as _ # depends on types/custom from ._client.telegramclient import TelegramClient +from ._client.account import ignore_takeout from . import version, events, errors, enums __version__ = version.__version__ diff --git a/telethon/_client/account.py b/telethon/_client/account.py index eedad595..8c25b232 100644 --- a/telethon/_client/account.py +++ b/telethon/_client/account.py @@ -1,6 +1,7 @@ import functools import inspect import typing +from contextvars import ContextVar from .users import _NOT_A_REQUEST from .._misc import helpers, utils @@ -10,112 +11,43 @@ if typing.TYPE_CHECKING: from .telegramclient import TelegramClient +ignore_takeout = ContextVar('ignore_takeout', default=False) + + # TODO Make use of :tl:`InvokeWithMessagesRange` somehow # For that, we need to use :tl:`GetSplitRanges` first. -class _TakeoutClient: - """ - Proxy object over the client. - """ - __PROXY_INTERFACE = ('__enter__', '__exit__', '__aenter__', '__aexit__') - - def __init__(self, finalize, client, request): - # We use the name mangling for attributes to make them inaccessible - # from within the shadowed client object and to distinguish them from - # its own attributes where needed. - self.__finalize = finalize - self.__client = client - self.__request = request - self.__success = None - - @property - def success(self): - return self.__success - - @success.setter - def success(self, value): - self.__success = value +class _Takeout: + def __init__(self, client, kwargs): + self._client = client + self._kwargs = kwargs async def __aenter__(self): - # Enter/Exit behaviour is "overrode", we don't want to call start. - client = self.__client - if client.session.takeout_id is None: - client.session.takeout_id = (await client(self.__request)).id - elif self.__request is not None: - raise ValueError("Can't send a takeout request while another " - "takeout for the current session still not been finished yet.") - return self + await self._client.begin_takeout(**kwargs) + return self._client async def __aexit__(self, exc_type, exc_value, traceback): - if self.__success is None and self.__finalize: - self.__success = exc_type is None - - if self.__success is not None: - result = await self(_tl.fn.account.FinishTakeoutSession( - self.__success)) - if not result: - raise ValueError("Failed to finish the takeout.") - self.session.takeout_id = None - - async def __call__(self, request, ordered=False): - takeout_id = self.__client.session.takeout_id - if takeout_id is None: - raise ValueError('Takeout mode has not been initialized ' - '(are you calling outside of "with"?)') - - single = not utils.is_list_like(request) - requests = ((request,) if single else request) - wrapped = [] - for r in requests: - if not isinstance(r, _tl.TLRequest): - raise _NOT_A_REQUEST() - await r.resolve(self, utils) - wrapped.append(_tl.fn.InvokeWithTakeout(takeout_id, r)) - - return await self.__client( - wrapped[0] if single else wrapped, ordered=ordered) - - def __getattribute__(self, name): - # We access class via type() because __class__ will recurse infinitely. - # Also note that since we've name-mangled our own class attributes, - # they'll be passed to __getattribute__() as already decorated. For - # example, 'self.__client' will be passed as '_TakeoutClient__client'. - # https://docs.python.org/3/tutorial/classes.html#private-variables - if name.startswith('__') and name not in type(self).__PROXY_INTERFACE: - raise AttributeError # force call of __getattr__ - - # Try to access attribute in the proxy object and check for the same - # attribute in the shadowed object (through our __getattr__) if failed. - return super().__getattribute__(name) - - def __getattr__(self, name): - value = getattr(self.__client, name) - if inspect.ismethod(value): - # Emulate bound methods behavior by partially applying our proxy - # class as the self parameter instead of the client. - return functools.partial( - getattr(self.__client.__class__, name), self) - - return value - - def __setattr__(self, name, value): - if name.startswith('_{}__'.format(type(self).__name__.lstrip('_'))): - # This is our own name-mangled attribute, keep calm. - return super().__setattr__(name, value) - return setattr(self.__client, name, value) + await self._client.end_takeout(success=exc_type is None) -def takeout( - self: 'TelegramClient', - finalize: bool = True, - *, - contacts: bool = None, - users: bool = None, - chats: bool = None, - megagroups: bool = None, - channels: bool = None, - files: bool = None, - max_file_size: bool = None) -> 'TelegramClient': - request_kwargs = dict( +def takeout(self: 'TelegramClient', **kwargs): + return _Takeout(self, kwargs) + + +async def begin_takeout( + self: 'TelegramClient', + *, + contacts: bool = None, + users: bool = None, + chats: bool = None, + megagroups: bool = None, + channels: bool = None, + files: bool = None, + max_file_size: bool = None, +) -> 'TelegramClient': + if takeout_active(): + raise ValueError('a previous takeout session was already active') + + self._session_state.takeout_id = (await client( contacts=contacts, message_users=users, message_chats=chats, @@ -123,21 +55,19 @@ def takeout( message_channels=channels, files=files, file_max_size=max_file_size - ) - arg_specified = (arg is not None for arg in request_kwargs.values()) + )).id - if self.session.takeout_id is None or any(arg_specified): - request = _tl.fn.account.InitTakeoutSession( - **request_kwargs) - else: - request = None - return _TakeoutClient(finalize, self, request) +def takeout_active(self: 'TelegramClient') -> bool: + return self._session_state.takeout_id is not None + async def end_takeout(self: 'TelegramClient', success: bool) -> bool: - try: - async with _TakeoutClient(True, self, None) as takeout: - takeout.success = success - except ValueError: - return False - return True + if not takeout_active(): + raise ValueError('no previous takeout session was active') + + result = await self(_tl.fn.account.FinishTakeoutSession(success)) + if not result: + raise ValueError("could not end the active takeout session") + + self._session_state.takeout_id = None diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index f6ad961b..5dfc0c92 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -174,7 +174,6 @@ class TelegramClient: @forward_call(account.takeout) def takeout( self: 'TelegramClient', - finalize: bool = True, *, contacts: bool = None, users: bool = None, @@ -184,14 +183,39 @@ class TelegramClient: files: bool = None, max_file_size: bool = None) -> 'TelegramClient': """ - Returns a :ref:`telethon-client` which calls methods behind a takeout session. + Returns a context-manager which calls `TelegramClient.begin_takeout` + on enter and `TelegramClient.end_takeout` on exit. The same errors + and conditions apply. - It does so by creating a proxy object over the current client through - which making requests will use :tl:`InvokeWithTakeout` to wrap - them. In other words, returns the current client modified so that - requests are done as a takeout: + This is useful for the common case of not wanting the takeout to + persist (although it still might if a disconnection occurs before it + can be ended). - Some of the calls made through the takeout session will have lower + Example + .. code-block:: python + + async with client.takeout(): + async for message in client.iter_messages(chat, wait_time=0): + ... # Do something with the message + """ + + @forward_call(account.begin_takeout) + def begin_takeout( + self: 'TelegramClient', + *, + contacts: bool = None, + users: bool = None, + chats: bool = None, + megagroups: bool = None, + channels: bool = None, + files: bool = None, + max_file_size: bool = None) -> 'TelegramClient': + """ + Begin a takeout session. All subsequent requests made by the client + will be behind a takeout session. The takeout session will persist + in the session file, until `TelegramClient.end_takeout` is used. + + When the takeout session is enabled, some requests will have lower flood limits. This is useful if you want to export the data from conversations or mass-download media, since the rate limits will be lower. Only some requests will be affected, and you will need @@ -206,20 +230,16 @@ class TelegramClient: can then access ``e.seconds`` to know how long you should wait for before calling the method again. - There's also a `success` property available in the takeout proxy - object, so from the `with` body you can set the boolean result that - will be sent back to Telegram. But if it's left `None` as by - default, then the action is based on the `finalize` parameter. If - it's `True` then the takeout will be finished, and if no exception - occurred during it, then `True` will be considered as a result. - Otherwise, the takeout will not be finished and its ID will be - preserved for future usage in the session. + If you want to ignore the currently-active takeout session in a task, + toggle the following context variable: + + .. code-block:: python + + telethon.ignore_takeout.set(True) + + An error occurs if ``TelegramClient.takeout_active`` was already ``True``. Arguments - finalize (`bool`): - Whether the takeout session should be finalized upon - exit or not. - contacts (`bool`): Set to `True` if you plan on downloading contacts. @@ -253,17 +273,26 @@ class TelegramClient: from telethon import errors try: - async with client.takeout() as takeout: - await client.get_messages('me') # normal call - await takeout.get_messages('me') # wrapped through takeout (less limits) + await client.begin_takeout() - async for message in takeout.iter_messages(chat, wait_time=0): - ... # Do something with the message + await client.get_messages('me') # wrapped through takeout (less limits) + + async for message in client.iter_messages(chat, wait_time=0): + ... # Do something with the message + + await client.end_takeout(success=True) except errors.TakeoutInitDelayError as e: print('Must wait', e.seconds, 'before takeout') + + except Exception: + await client.end_takeout(success=False) """ + @property + def takeout_active(self: 'TelegramClient') -> bool: + return account.takeout_active(self) + @forward_call(account.end_takeout) async def end_takeout(self: 'TelegramClient', success: bool) -> bool: """ diff --git a/telethon/_client/users.py b/telethon/_client/users.py index 02dccf95..69f2c564 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -9,6 +9,7 @@ from ..errors._rpcbase import RpcError, ServerError, FloodError, InvalidDcError, from .._misc import helpers, utils, hints from .._sessions.types import Entity from .. import errors, _tl +from .account import ignore_takeout _NOT_A_REQUEST = lambda: TypeError('You can only invoke requests, not types!') @@ -52,6 +53,9 @@ async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sl else: raise errors.FLOOD_WAIT(420, f'FLOOD_WAIT_{diff}', request=r) + if self._session_state.takeout_id and not ignore_takeout.get(): + r = _tl.fn.InvokeWithTakeout(self._session_state.takeout_id, r) + if self._no_updates: r = _tl.fn.InvokeWithoutUpdates(r) From 7524b652c835bb66c4d81e117ed0ce932ea4f4cf Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 15 Jan 2022 11:22:33 +0100 Subject: [PATCH 095/131] Unify setting session state --- telethon/_client/account.py | 7 ++++--- telethon/_client/auth.py | 29 ++++++++++++++++++-------- telethon/_client/telegrambaseclient.py | 10 ++------- telethon/_client/telegramclient.py | 6 +++++- 4 files changed, 31 insertions(+), 21 deletions(-) diff --git a/telethon/_client/account.py b/telethon/_client/account.py index 8c25b232..73673b8e 100644 --- a/telethon/_client/account.py +++ b/telethon/_client/account.py @@ -1,6 +1,7 @@ import functools import inspect import typing +import dataclasses from contextvars import ContextVar from .users import _NOT_A_REQUEST @@ -47,7 +48,7 @@ async def begin_takeout( if takeout_active(): raise ValueError('a previous takeout session was already active') - self._session_state.takeout_id = (await client( + await self._replace_session_state(takeout_id=(await client( contacts=contacts, message_users=users, message_chats=chats, @@ -55,7 +56,7 @@ async def begin_takeout( message_channels=channels, files=files, file_max_size=max_file_size - )).id + )).id) def takeout_active(self: 'TelegramClient') -> bool: @@ -70,4 +71,4 @@ async def end_takeout(self: 'TelegramClient', success: bool) -> bool: if not result: raise ValueError("could not end the active takeout session") - self._session_state.takeout_id = None + await self._replace_session_state(takeout_id=None) diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index 031b3a11..50df992b 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -5,6 +5,7 @@ import sys import typing import warnings import functools +import dataclasses from .._misc import utils, helpers, password as pwd_mod from .. import errors, _tl @@ -308,6 +309,7 @@ async def sign_up( return await _update_session_state(self, result.user) + async def _update_session_state(self, user, save=True): """ Callback called whenever the login or sign up process completes. @@ -315,20 +317,29 @@ async def _update_session_state(self, user, save=True): """ self._authorized = True - self._session_state.user_id = user.id - self._session_state.bot = user.bot - state = await self(_tl.fn.updates.GetState()) - self._session_state.pts = state.pts - self._session_state.qts = state.qts - self._session_state.date = int(state.date.timestamp()) - self._session_state.seq = state.seq + await _replace_session_state( + self, + save=save, + user_id=user.id, + bot=user.bot, + pts=state.pts, + qts=state.qts, + date=int(state.date.timestamp()), + seq=state.seq, + ) + + return user + + +async def _replace_session_state(self, *, save=True, **changes): + new = dataclasses.replace(self._session_state, **changes) + await self.session.set_state(new) + self._session_state = new - await self.session.set_state(self._session_state) if save: await self.session.save() - return user async def send_code_request( self: 'TelegramClient', diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 9316668b..85512cfb 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -445,10 +445,7 @@ async def _disconnect_coro(self: 'TelegramClient'): pts, date = self._state_cache[None] if pts and date: if self._session_state: - self._session_state.pts = pts - self._session_state.date = date - await self.session.set_state(self._session_state) - await self.session.save() + await self._replace_session_state(pts=pts, date=date) async def _disconnect(self: 'TelegramClient'): """ @@ -467,10 +464,7 @@ async def _switch_dc(self: 'TelegramClient', new_dc): """ self._log[__name__].info('Reconnecting to new data center %s', new_dc) - self._session_state.dc_id = new_dc - await self.session.set_state(self._session_state) - await self.session.save() - + await self._replace_session_state(dc_id=new_dc) await _disconnect(self) return await self.connect() diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 5dfc0c92..9755d437 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -3547,7 +3547,11 @@ class TelegramClient: pass @forward_call(auth._update_session_state) - async def _update_session_state(self, user, save=True): + async def _update_session_state(self, user, *, save=True): + pass + + @forward_call(auth._replace_session_state) + async def _replace_session_state(self, *, save=True, **changes): pass # endregion Private From 02703e37533cbd4d3dfecd26ab04f0aaf6422209 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 15 Jan 2022 13:18:53 +0100 Subject: [PATCH 096/131] Fix circular import regarding ignore_takeout --- telethon/__init__.py | 2 +- telethon/_client/account.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/telethon/__init__.py b/telethon/__init__.py index 5d337667..653356d1 100644 --- a/telethon/__init__.py +++ b/telethon/__init__.py @@ -3,9 +3,9 @@ from ._misc import helpers as _ # no dependencies from . import _tl # no dependencies from ._misc import utils as _ # depends on helpers and _tl from ._misc import hints as _ # depends on types/custom +from ._client.account import ignore_takeout from ._client.telegramclient import TelegramClient -from ._client.account import ignore_takeout from . import version, events, errors, enums __version__ = version.__version__ diff --git a/telethon/_client/account.py b/telethon/_client/account.py index 73673b8e..cdf79850 100644 --- a/telethon/_client/account.py +++ b/telethon/_client/account.py @@ -4,7 +4,6 @@ import typing import dataclasses from contextvars import ContextVar -from .users import _NOT_A_REQUEST from .._misc import helpers, utils from .. import _tl From f5f0c8455357a833db73fd7746cf122f3c1eb6dc Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 15 Jan 2022 13:33:50 +0100 Subject: [PATCH 097/131] Completely overhaul connections and transports Reduce abstraction leaks. Now the transport can hold any state, rather than just the tag. It's also responsible to initialize on the first connection, and they can be cleanly reset. asyncio connections are no longer used, in favour of raw sockets, which should avoid some annoyances. For the time being, more obscure transport modes have been removed, as well as proxy support, until further cleaning is done. --- telethon/_client/telegrambaseclient.py | 54 +-- telethon/_client/telegramclient.py | 1 - telethon/_misc/enums.py | 2 - telethon/_network/__init__.py | 9 +- telethon/_network/connection.py | 61 +++ telethon/_network/connection/__init__.py | 12 - telethon/_network/connection/connection.py | 426 ------------------ telethon/_network/connection/http.py | 39 -- telethon/_network/connection/tcpabridged.py | 33 -- telethon/_network/connection/tcpfull.py | 43 -- .../_network/connection/tcpintermediate.py | 46 -- telethon/_network/connection/tcpmtproxy.py | 152 ------- telethon/_network/connection/tcpobfuscated.py | 62 --- telethon/_network/transports/__init__.py | 4 + telethon/_network/transports/abridged.py | 43 ++ telethon/_network/transports/full.py | 41 ++ telethon/_network/transports/intermediate.py | 29 ++ telethon/_network/transports/transport.py | 17 + 18 files changed, 221 insertions(+), 853 deletions(-) create mode 100644 telethon/_network/connection.py delete mode 100644 telethon/_network/connection/__init__.py delete mode 100644 telethon/_network/connection/connection.py delete mode 100644 telethon/_network/connection/http.py delete mode 100644 telethon/_network/connection/tcpabridged.py delete mode 100644 telethon/_network/connection/tcpfull.py delete mode 100644 telethon/_network/connection/tcpintermediate.py delete mode 100644 telethon/_network/connection/tcpmtproxy.py delete mode 100644 telethon/_network/connection/tcpobfuscated.py create mode 100644 telethon/_network/transports/__init__.py create mode 100644 telethon/_network/transports/abridged.py create mode 100644 telethon/_network/transports/full.py create mode 100644 telethon/_network/transports/intermediate.py create mode 100644 telethon/_network/transports/transport.py diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 85512cfb..e4906fbf 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -11,7 +11,7 @@ import ipaddress from .. import version, __name__ as __base_name__, _tl from .._crypto import rsa from .._misc import markdown, entitycache, statecache, enums, helpers -from .._network import MTProtoSender, Connection, ConnectionTcpFull, connection as conns +from .._network import MTProtoSender, Connection, transports from .._sessions import Session, SQLiteSession, MemorySession from .._sessions.types import DataCenter, SessionState @@ -70,7 +70,7 @@ def init( api_id: int, api_hash: str, *, - connection: 'typing.Type[Connection]' = ConnectionTcpFull, + connection: 'typing.Type[Connection]' = (), use_ipv6: bool = False, proxy: typing.Union[tuple, dict] = None, local_addr: typing.Union[str, tuple] = None, @@ -194,15 +194,12 @@ def init( # For now the current default remains TCP Full; may change to be "smart" if proxies are specified connection = enums.ConnectionMode.FULL - self._connection = { - enums.ConnectionMode.FULL: conns.ConnectionTcpFull, - enums.ConnectionMode.INTERMEDIATE: conns.ConnectionTcpIntermediate, - enums.ConnectionMode.ABRIDGED: conns.ConnectionTcpAbridged, - enums.ConnectionMode.OBFUSCATED: conns.ConnectionTcpObfuscated, - enums.ConnectionMode.HTTP: conns.ConnectionHttp, + self._transport = { + enums.ConnectionMode.FULL: transports.Full(), + enums.ConnectionMode.INTERMEDIATE: transports.Intermediate(), + enums.ConnectionMode.ABRIDGED: transports.Abridged(), }[enums.parse_conn_mode(connection)] - init_proxy = None if not issubclass(self._connection, conns.TcpMTProxy) else \ - _tl.InputClientProxy(*self._connection.address_info(proxy)) + init_proxy = None # Used on connection. Capture the variables in a lambda since # exporting clients need to create this InvokeWithLayer. @@ -334,13 +331,12 @@ async def connect(self: 'TelegramClient') -> None: # Use known key, if any self._sender.auth_key.key = dc.auth - if not await self._sender.connect(self._connection( - str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)), - dc.port, - dc.id, + if not await self._sender.connect(Connection( + ip=str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)), + port=dc.port, + transport=self._transport.recreate_fresh(), loggers=self._log, - proxy=self._proxy, - local_addr=self._local_addr + local_addr=self._local_addr, )): # We don't want to init or modify anything if we were already connected return @@ -396,8 +392,7 @@ async def disconnect(self: 'TelegramClient'): return await _disconnect_coro(self) def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): - init_proxy = None if not issubclass(self._connection, conns.TcpMTProxy) else \ - _tl.InputClientProxy(*self._connection.address_info(proxy)) + init_proxy = None self._init_request.proxy = init_proxy self._proxy = proxy @@ -481,13 +476,12 @@ async def _create_exported_sender(self: 'TelegramClient', dc_id): # If one were to do that, Telegram would reset the connection # with no further clues. sender = MTProtoSender(loggers=self._log) - await sender.connect(self._connection( - str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)), - dc.port, - dc.id, + await self._sender.connect(Connection( + ip=str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)), + port=dc.port, + transport=self._transport.recreate_fresh(), loggers=self._log, - proxy=self._proxy, - local_addr=self._local_addr + local_addr=self._local_addr, )) self._log[__name__].info('Exporting auth for new borrowed sender in %s', dc) auth = await self(_tl.fn.auth.ExportAuthorization(dc_id)) @@ -516,13 +510,13 @@ async def _borrow_exported_sender(self: 'TelegramClient', dc_id): elif state.need_connect(): dc = self._all_dcs[dc_id] - await sender.connect(self._connection( - str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)), - dc.port, - dc.id, + + await self._sender.connect(Connection( + ip=str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)), + port=dc.port, + transport=self._transport.recreate_fresh(), loggers=self._log, - proxy=self._proxy, - local_addr=self._local_addr + local_addr=self._local_addr, )) state.add_borrow() diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 9755d437..bb21a416 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -10,7 +10,6 @@ from . import ( ) from .. import version, _tl from ..types import _custom -from .._network import ConnectionTcpFull from .._events.common import EventBuilder, EventCommon from .._misc import enums diff --git a/telethon/_misc/enums.py b/telethon/_misc/enums.py index 2d5742aa..283030f3 100644 --- a/telethon/_misc/enums.py +++ b/telethon/_misc/enums.py @@ -15,8 +15,6 @@ class ConnectionMode(Enum): FULL = 'full' INTERMEDIATE = 'intermediate' ABRIDGED = 'abridged' - OBFUSCATED = 'obfuscated' - HTTP = 'http' class Participant(Enum): diff --git a/telethon/_network/__init__.py b/telethon/_network/__init__.py index 0b985d58..164acc4e 100644 --- a/telethon/_network/__init__.py +++ b/telethon/_network/__init__.py @@ -5,10 +5,5 @@ with Telegram's servers and the protocol used (TCP full, abridged, etc.). from .mtprotoplainsender import MTProtoPlainSender from .authenticator import do_authentication from .mtprotosender import MTProtoSender -from .connection import ( - Connection, - ConnectionTcpFull, ConnectionTcpIntermediate, ConnectionTcpAbridged, - ConnectionTcpObfuscated, ConnectionTcpMTProxyAbridged, - ConnectionTcpMTProxyIntermediate, - ConnectionTcpMTProxyRandomizedIntermediate, ConnectionHttp, TcpMTProxy -) +from .connection import Connection +from . import transports diff --git a/telethon/_network/connection.py b/telethon/_network/connection.py new file mode 100644 index 00000000..26674aa2 --- /dev/null +++ b/telethon/_network/connection.py @@ -0,0 +1,61 @@ +import asyncio +import socket + +from .transports.transport import Transport + + +CHUNK_SIZE = 32 * 1024 + + +# TODO ideally the mtproto impl would also be sans-io, but that's less pressing +class Connection: + def __init__(self, ip, port, *, transport: Transport, loggers, local_addr=None): + self._ip = ip + self._port = port + self._log = loggers[__name__] + self._local_addr = local_addr + + self._sock = None + self._in_buffer = bytearray() + self._transport = transport + + async def connect(self, timeout=None, ssl=None): + """ + Establishes a connection with the server. + """ + loop = asyncio.get_event_loop() + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setblocking(False) + if self._local_addr: + sock.bind(self._local_addr) + + await asyncio.wait_for(loop.sock_connect(sock, (self._ip, self._port)), timeout) + self._sock = sock + + async def disconnect(self): + self._sock.close() + self._sock = None + + async def send(self, data): + if not self._sock: + raise ConnectionError('not connected') + + loop = asyncio.get_event_loop() + await loop.sock_sendall(self._sock, self._transport.pack(data)) + + async def recv(self): + if not self._sock: + raise ConnectionError('not connected') + + loop = asyncio.get_event_loop() + while True: + try: + length, body = self._transport.unpack(self._in_buffer) + del self._in_buffer[:length] + return body + except EOFError: + self._in_buffer += await loop.sock_recv(self._sock, CHUNK_SIZE) + + def __str__(self): + return f'{self._ip}:{self._port}/{self._transport.__class__.__name__}' diff --git a/telethon/_network/connection/__init__.py b/telethon/_network/connection/__init__.py deleted file mode 100644 index 88771866..00000000 --- a/telethon/_network/connection/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from .connection import Connection -from .tcpfull import ConnectionTcpFull -from .tcpintermediate import ConnectionTcpIntermediate -from .tcpabridged import ConnectionTcpAbridged -from .tcpobfuscated import ConnectionTcpObfuscated -from .tcpmtproxy import ( - TcpMTProxy, - ConnectionTcpMTProxyAbridged, - ConnectionTcpMTProxyIntermediate, - ConnectionTcpMTProxyRandomizedIntermediate -) -from .http import ConnectionHttp diff --git a/telethon/_network/connection/connection.py b/telethon/_network/connection/connection.py deleted file mode 100644 index abf398ee..00000000 --- a/telethon/_network/connection/connection.py +++ /dev/null @@ -1,426 +0,0 @@ -import abc -import asyncio -import socket -import sys - -try: - import ssl as ssl_mod -except ImportError: - ssl_mod = None - -try: - import python_socks -except ImportError: - python_socks = None - -from ...errors._custom import InvalidChecksumError -from ..._misc import helpers - - -class Connection(abc.ABC): - """ - The `Connection` class is a wrapper around ``asyncio.open_connection``. - - Subclasses will implement different transport modes as atomic operations, - which this class eases doing since the exposed interface simply puts and - gets complete data payloads to and from queues. - - The only error that will raise from send and receive methods is - ``ConnectionError``, which will raise when attempting to send if - the client is disconnected (includes remote disconnections). - """ - # this static attribute should be redefined by `Connection` subclasses and - # should be one of `PacketCodec` implementations - packet_codec = None - - def __init__(self, ip, port, dc_id, *, loggers, proxy=None, local_addr=None): - self._ip = ip - self._port = port - self._dc_id = dc_id # only for MTProxy, it's an abstraction leak - self._log = loggers[__name__] - self._proxy = proxy - self._local_addr = local_addr - self._reader = None - self._writer = None - self._connected = False - self._send_task = None - self._recv_task = None - self._codec = None - self._obfuscation = None # TcpObfuscated and MTProxy - self._send_queue = asyncio.Queue(1) - self._recv_queue = asyncio.Queue(1) - - @staticmethod - def _wrap_socket_ssl(sock): - if ssl_mod is None: - raise RuntimeError( - 'Cannot use proxy that requires SSL ' - 'without the SSL module being available' - ) - - return ssl_mod.wrap_socket( - sock, - do_handshake_on_connect=True, - ssl_version=ssl_mod.PROTOCOL_SSLv23, - ciphers='ADH-AES256-SHA') - - @staticmethod - def _parse_proxy(proxy_type, addr, port, rdns=True, username=None, password=None): - if isinstance(proxy_type, str): - proxy_type = proxy_type.lower() - - # Always prefer `python_socks` when available - if python_socks: - from python_socks import ProxyType - - # We do the check for numerical values here - # to be backwards compatible with PySocks proxy format, - # (since socks.SOCKS5 == 2, socks.SOCKS4 == 1, socks.HTTP == 3) - if proxy_type == ProxyType.SOCKS5 or proxy_type == 2 or proxy_type == "socks5": - protocol = ProxyType.SOCKS5 - elif proxy_type == ProxyType.SOCKS4 or proxy_type == 1 or proxy_type == "socks4": - protocol = ProxyType.SOCKS4 - elif proxy_type == ProxyType.HTTP or proxy_type == 3 or proxy_type == "http": - protocol = ProxyType.HTTP - else: - raise ValueError("Unknown proxy protocol type: {}".format(proxy_type)) - - # This tuple must be compatible with `python_socks`' `Proxy.create()` signature - return protocol, addr, port, username, password, rdns - - else: - from socks import SOCKS5, SOCKS4, HTTP - - if proxy_type == 2 or proxy_type == "socks5": - protocol = SOCKS5 - elif proxy_type == 1 or proxy_type == "socks4": - protocol = SOCKS4 - elif proxy_type == 3 or proxy_type == "http": - protocol = HTTP - else: - raise ValueError("Unknown proxy protocol type: {}".format(proxy_type)) - - # This tuple must be compatible with `PySocks`' `socksocket.set_proxy()` signature - return protocol, addr, port, rdns, username, password - - async def _proxy_connect(self, timeout=None, local_addr=None): - if isinstance(self._proxy, (tuple, list)): - parsed = self._parse_proxy(*self._proxy) - elif isinstance(self._proxy, dict): - parsed = self._parse_proxy(**self._proxy) - else: - raise TypeError("Proxy of unknown format: {}".format(type(self._proxy))) - - # Always prefer `python_socks` when available - if python_socks: - # python_socks internal errors are not inherited from - # builtin IOError (just from Exception). Instead of adding those - # in exceptions clauses everywhere through the code, we - # rather monkey-patch them in place. - - python_socks._errors.ProxyError = ConnectionError - python_socks._errors.ProxyConnectionError = ConnectionError - python_socks._errors.ProxyTimeoutError = ConnectionError - - from python_socks.async_.asyncio import Proxy - - proxy = Proxy.create(*parsed) - - # WARNING: If `local_addr` is set we use manual socket creation, because, - # unfortunately, `Proxy.connect()` does not expose `local_addr` - # argument, so if we want to bind socket locally, we need to manually - # create, bind and connect socket, and then pass to `Proxy.connect()` method. - - if local_addr is None: - sock = await proxy.connect( - dest_host=self._ip, - dest_port=self._port, - timeout=timeout - ) - else: - # Here we start manual setup of the socket. - # The `address` represents the proxy ip and proxy port, - # not the destination one (!), because the socket - # connects to the proxy server, not destination server. - # IPv family is also checked on proxy address. - if ':' in proxy.proxy_host: - mode, address = socket.AF_INET6, (proxy.proxy_host, proxy.proxy_port, 0, 0) - else: - mode, address = socket.AF_INET, (proxy.proxy_host, proxy.proxy_port) - - # Create a non-blocking socket and bind it (if local address is specified). - sock = socket.socket(mode, socket.SOCK_STREAM) - sock.setblocking(False) - sock.bind(local_addr) - - # Actual TCP connection is performed here. - await asyncio.wait_for( - asyncio.get_event_loop().sock_connect(sock=sock, address=address), - timeout=timeout - ) - - # As our socket is already created and connected, - # this call sets the destination host/port and - # starts protocol negotiations with the proxy server. - sock = await proxy.connect( - dest_host=self._ip, - dest_port=self._port, - timeout=timeout, - _socket=sock - ) - - else: - import socks - - # Here `address` represents destination address (not proxy), because of - # the `PySocks` implementation of the connection routine. - # IPv family is checked on proxy address, not destination address. - if ':' in parsed[1]: - mode, address = socket.AF_INET6, (self._ip, self._port, 0, 0) - else: - mode, address = socket.AF_INET, (self._ip, self._port) - - # Setup socket, proxy, timeout and bind it (if necessary). - sock = socks.socksocket(mode, socket.SOCK_STREAM) - sock.set_proxy(*parsed) - sock.settimeout(timeout) - - if local_addr is not None: - sock.bind(local_addr) - - # Actual TCP connection and negotiation performed here. - await asyncio.wait_for( - asyncio.get_event_loop().sock_connect(sock=sock, address=address), - timeout=timeout - ) - - sock.setblocking(False) - - return sock - - async def _connect(self, timeout=None, ssl=None): - if self._local_addr is not None: - # NOTE: If port is not specified, we use 0 port - # to notify the OS that port should be chosen randomly - # from the available ones. - if isinstance(self._local_addr, tuple) and len(self._local_addr) == 2: - local_addr = self._local_addr - elif isinstance(self._local_addr, str): - local_addr = (self._local_addr, 0) - else: - raise ValueError("Unknown local address format: {}".format(self._local_addr)) - else: - local_addr = None - - if not self._proxy: - self._reader, self._writer = await asyncio.wait_for( - asyncio.open_connection( - host=self._ip, - port=self._port, - ssl=ssl, - local_addr=local_addr - ), timeout=timeout) - else: - # Proxy setup, connection and negotiation is performed here. - sock = await self._proxy_connect( - timeout=timeout, - local_addr=local_addr - ) - - # Wrap socket in SSL context (if provided) - if ssl: - sock = self._wrap_socket_ssl(sock) - - self._reader, self._writer = await asyncio.open_connection(sock=sock) - - self._codec = self.packet_codec(self) - self._init_conn() - await self._writer.drain() - - async def connect(self, timeout=None, ssl=None): - """ - Establishes a connection with the server. - """ - await self._connect(timeout=timeout, ssl=ssl) - self._connected = True - - loop = asyncio.get_event_loop() - self._send_task = loop.create_task(self._send_loop()) - self._recv_task = loop.create_task(self._recv_loop()) - - async def disconnect(self): - """ - Disconnects from the server, and clears - pending outgoing and incoming messages. - """ - self._connected = False - - await helpers._cancel( - self._log, - send_task=self._send_task, - recv_task=self._recv_task - ) - - if self._writer: - self._writer.close() - try: - await self._writer.wait_closed() - except Exception as e: - # Disconnecting should never raise. Seen: - # * OSError: No route to host and - # * OSError: [Errno 32] Broken pipe - # * ConnectionResetError - self._log.info('%s during disconnect: %s', type(e), e) - - def send(self, data): - """ - Sends a packet of data through this connection mode. - - This method returns a coroutine. - """ - if not self._connected: - raise ConnectionError('Not connected') - - return self._send_queue.put(data) - - async def recv(self): - """ - Receives a packet of data through this connection mode. - - This method returns a coroutine. - """ - while self._connected: - result = await self._recv_queue.get() - if result: # None = sentinel value = keep trying - return result - - raise ConnectionError('Not connected') - - async def _send_loop(self): - """ - This loop is constantly popping items off the queue to send them. - """ - try: - while self._connected: - self._send(await self._send_queue.get()) - await self._writer.drain() - except asyncio.CancelledError: - pass - except Exception as e: - if isinstance(e, IOError): - self._log.info('The server closed the connection while sending') - else: - self._log.exception('Unexpected exception in the send loop') - - await self.disconnect() - - async def _recv_loop(self): - """ - This loop is constantly putting items on the queue as they're read. - """ - while self._connected: - try: - data = await self._recv() - except asyncio.CancelledError: - break - except Exception as e: - if isinstance(e, (IOError, asyncio.IncompleteReadError)): - msg = 'The server closed the connection' - self._log.info(msg) - elif isinstance(e, InvalidChecksumError): - msg = 'The server response had an invalid checksum' - self._log.info(msg) - else: - msg = 'Unexpected exception in the receive loop' - self._log.exception(msg) - - await self.disconnect() - - # Add a sentinel value to unstuck recv - if self._recv_queue.empty(): - self._recv_queue.put_nowait(None) - - break - - try: - await self._recv_queue.put(data) - except asyncio.CancelledError: - break - - def _init_conn(self): - """ - This method will be called after `connect` is called. - After this method finishes, the writer will be drained. - - Subclasses should make use of this if they need to send - data to Telegram to indicate which connection mode will - be used. - """ - if self._codec.tag: - self._writer.write(self._codec.tag) - - def _send(self, data): - self._writer.write(self._codec.encode_packet(data)) - - async def _recv(self): - return await self._codec.read_packet(self._reader) - - def __str__(self): - return '{}:{}/{}'.format( - self._ip, self._port, - self.__class__.__name__.replace('Connection', '') - ) - - -class ObfuscatedConnection(Connection): - """ - Base class for "obfuscated" connections ("obfuscated2", "mtproto proxy") - """ - """ - This attribute should be redefined by subclasses - """ - obfuscated_io = None - - def _init_conn(self): - self._obfuscation = self.obfuscated_io(self) - self._writer.write(self._obfuscation.header) - - def _send(self, data): - self._obfuscation.write(self._codec.encode_packet(data)) - - async def _recv(self): - return await self._codec.read_packet(self._obfuscation) - - -class PacketCodec(abc.ABC): - """ - Base class for packet codecs - """ - - """ - This attribute should be re-defined by subclass to define if some - "magic bytes" should be sent to server right after connection is made to - signal which protocol will be used - """ - tag = None - - def __init__(self, connection): - """ - Codec is created when connection is just made. - """ - self._conn = connection - - @abc.abstractmethod - def encode_packet(self, data): - """ - Encodes single packet and returns encoded bytes. - """ - raise NotImplementedError - - @abc.abstractmethod - async def read_packet(self, reader): - """ - Reads single packet from `reader` object that should have - `readexactly(n)` method. - """ - raise NotImplementedError diff --git a/telethon/_network/connection/http.py b/telethon/_network/connection/http.py deleted file mode 100644 index e2d976f7..00000000 --- a/telethon/_network/connection/http.py +++ /dev/null @@ -1,39 +0,0 @@ -import asyncio - -from .connection import Connection, PacketCodec - - -SSL_PORT = 443 - - -class HttpPacketCodec(PacketCodec): - tag = None - obfuscate_tag = None - - def encode_packet(self, data): - return ('POST /api HTTP/1.1\r\n' - 'Host: {}:{}\r\n' - 'Content-Type: application/x-www-form-urlencoded\r\n' - 'Connection: keep-alive\r\n' - 'Keep-Alive: timeout=100000, max=10000000\r\n' - 'Content-Length: {}\r\n\r\n' - .format(self._conn._ip, self._conn._port, len(data)) - .encode('ascii') + data) - - async def read_packet(self, reader): - while True: - line = await reader.readline() - if not line or line[-1] != b'\n': - raise asyncio.IncompleteReadError(line, None) - - if line.lower().startswith(b'content-length: '): - await reader.readexactly(2) - length = int(line[16:-2]) - return await reader.readexactly(length) - - -class ConnectionHttp(Connection): - packet_codec = HttpPacketCodec - - async def connect(self, timeout=None, ssl=None): - await super().connect(timeout=timeout, ssl=self._port == SSL_PORT) diff --git a/telethon/_network/connection/tcpabridged.py b/telethon/_network/connection/tcpabridged.py deleted file mode 100644 index 171b1d8c..00000000 --- a/telethon/_network/connection/tcpabridged.py +++ /dev/null @@ -1,33 +0,0 @@ -import struct - -from .connection import Connection, PacketCodec - - -class AbridgedPacketCodec(PacketCodec): - tag = b'\xef' - obfuscate_tag = b'\xef\xef\xef\xef' - - def encode_packet(self, data): - length = len(data) >> 2 - if length < 127: - length = struct.pack('B', length) - else: - length = b'\x7f' + int.to_bytes(length, 3, 'little') - return length + data - - async def read_packet(self, reader): - length = struct.unpack('= 127: - length = struct.unpack( - ' 0: - return packet_with_padding[:-pad_size] - return packet_with_padding - - -class ConnectionTcpIntermediate(Connection): - """ - Intermediate mode between `ConnectionTcpFull` and `ConnectionTcpAbridged`. - Always sends 4 extra bytes for the packet length. - """ - packet_codec = IntermediatePacketCodec diff --git a/telethon/_network/connection/tcpmtproxy.py b/telethon/_network/connection/tcpmtproxy.py deleted file mode 100644 index db18a61c..00000000 --- a/telethon/_network/connection/tcpmtproxy.py +++ /dev/null @@ -1,152 +0,0 @@ -import asyncio -import hashlib -import os - -from .connection import ObfuscatedConnection -from .tcpabridged import AbridgedPacketCodec -from .tcpintermediate import ( - IntermediatePacketCodec, - RandomizedIntermediatePacketCodec -) - -from ..._crypto import AESModeCTR - - -class MTProxyIO: - """ - It's very similar to tcpobfuscated.ObfuscatedIO, but the way - encryption keys, protocol tag and dc_id are encoded is different. - """ - header = None - - def __init__(self, connection): - self._reader = connection._reader - self._writer = connection._writer - - (self.header, - self._encrypt, - self._decrypt) = self.init_header( - connection._secret, connection._dc_id, connection.packet_codec) - - @staticmethod - def init_header(secret, dc_id, packet_codec): - # Validate - is_dd = (len(secret) == 17) and (secret[0] == 0xDD) - is_rand_codec = issubclass( - packet_codec, RandomizedIntermediatePacketCodec) - if is_dd and not is_rand_codec: - raise ValueError( - "Only RandomizedIntermediate can be used with dd-secrets") - secret = secret[1:] if is_dd else secret - if len(secret) != 16: - raise ValueError( - "MTProxy secret must be a hex-string representing 16 bytes") - - # Obfuscated messages secrets cannot start with any of these - keywords = (b'PVrG', b'GET ', b'POST', b'\xee\xee\xee\xee') - while True: - random = os.urandom(64) - if (random[0] != 0xef and - random[:4] not in keywords and - random[4:4] != b'\0\0\0\0'): - break - - random = bytearray(random) - random_reversed = random[55:7:-1] # Reversed (8, len=48) - - # Encryption has "continuous buffer" enabled - encrypt_key = hashlib.sha256( - bytes(random[8:40]) + secret).digest() - encrypt_iv = bytes(random[40:56]) - decrypt_key = hashlib.sha256( - bytes(random_reversed[:32]) + secret).digest() - decrypt_iv = bytes(random_reversed[32:48]) - - encryptor = AESModeCTR(encrypt_key, encrypt_iv) - decryptor = AESModeCTR(decrypt_key, decrypt_iv) - - random[56:60] = packet_codec.obfuscate_tag - - dc_id_bytes = dc_id.to_bytes(2, "little", signed=True) - random = random[:60] + dc_id_bytes + random[62:] - random[56:64] = encryptor.encrypt(bytes(random))[56:64] - return (random, encryptor, decryptor) - - async def readexactly(self, n): - return self._decrypt.encrypt(await self._reader.readexactly(n)) - - def write(self, data): - self._writer.write(self._encrypt.encrypt(data)) - - -class TcpMTProxy(ObfuscatedConnection): - """ - Connector which allows user to connect to the Telegram via proxy servers - commonly known as MTProxy. - Implemented very ugly due to the leaky abstractions in Telethon networking - classes that should be refactored later (TODO). - - .. warning:: - - The support for TcpMTProxy classes is **EXPERIMENTAL** and prone to - be changed. You shouldn't be using this class yet. - """ - packet_codec = None - obfuscated_io = MTProxyIO - - # noinspection PyUnusedLocal - def __init__(self, ip, port, dc_id, *, loggers, proxy=None, local_addr=None): - # connect to proxy's host and port instead of telegram's ones - proxy_host, proxy_port = self.address_info(proxy) - self._secret = bytes.fromhex(proxy[2]) - super().__init__( - proxy_host, proxy_port, dc_id, loggers=loggers) - - async def _connect(self, timeout=None, ssl=None): - await super()._connect(timeout=timeout, ssl=ssl) - - # Wait for EOF for 2 seconds (or if _wait_for_data's definition - # is missing or different, just sleep for 2 seconds). This way - # we give the proxy a chance to close the connection if the current - # codec (which the proxy detects with the data we sent) cannot - # be used for this proxy. This is a work around for #1134. - # TODO Sleeping for N seconds may not be the best solution - # TODO This fix could be welcome for HTTP proxies as well - try: - await asyncio.wait_for(self._reader._wait_for_data('proxy'), 2) - except asyncio.TimeoutError: - pass - except Exception: - await asyncio.sleep(2) - - if self._reader.at_eof(): - await self.disconnect() - raise ConnectionError( - 'Proxy closed the connection after sending initial payload') - - @staticmethod - def address_info(proxy_info): - if proxy_info is None: - raise ValueError("No proxy info specified for MTProxy connection") - return proxy_info[:2] - - -class ConnectionTcpMTProxyAbridged(TcpMTProxy): - """ - Connect to proxy using abridged protocol - """ - packet_codec = AbridgedPacketCodec - - -class ConnectionTcpMTProxyIntermediate(TcpMTProxy): - """ - Connect to proxy using intermediate protocol - """ - packet_codec = IntermediatePacketCodec - - -class ConnectionTcpMTProxyRandomizedIntermediate(TcpMTProxy): - """ - Connect to proxy using randomized intermediate protocol (dd-secrets) - """ - packet_codec = RandomizedIntermediatePacketCodec diff --git a/telethon/_network/connection/tcpobfuscated.py b/telethon/_network/connection/tcpobfuscated.py deleted file mode 100644 index 2aeeeac1..00000000 --- a/telethon/_network/connection/tcpobfuscated.py +++ /dev/null @@ -1,62 +0,0 @@ -import os - -from .tcpabridged import AbridgedPacketCodec -from .connection import ObfuscatedConnection - -from ..._crypto import AESModeCTR - - -class ObfuscatedIO: - header = None - - def __init__(self, connection): - self._reader = connection._reader - self._writer = connection._writer - - (self.header, - self._encrypt, - self._decrypt) = self.init_header(connection.packet_codec) - - @staticmethod - def init_header(packet_codec): - # Obfuscated messages secrets cannot start with any of these - keywords = (b'PVrG', b'GET ', b'POST', b'\xee\xee\xee\xee') - while True: - random = os.urandom(64) - if (random[0] != 0xef and - random[:4] not in keywords and - random[4:8] != b'\0\0\0\0'): - break - - random = bytearray(random) - random_reversed = random[55:7:-1] # Reversed (8, len=48) - - # Encryption has "continuous buffer" enabled - encrypt_key = bytes(random[8:40]) - encrypt_iv = bytes(random[40:56]) - decrypt_key = bytes(random_reversed[:32]) - decrypt_iv = bytes(random_reversed[32:48]) - - encryptor = AESModeCTR(encrypt_key, encrypt_iv) - decryptor = AESModeCTR(decrypt_key, decrypt_iv) - - random[56:60] = packet_codec.obfuscate_tag - random[56:64] = encryptor.encrypt(bytes(random))[56:64] - return (random, encryptor, decryptor) - - async def readexactly(self, n): - return self._decrypt.encrypt(await self._reader.readexactly(n)) - - def write(self, data): - self._writer.write(self._encrypt.encrypt(data)) - - -class ConnectionTcpObfuscated(ObfuscatedConnection): - """ - Mode that Telegram defines as "obfuscated2". Encodes the packet - just like `ConnectionTcpAbridged`, but encrypts every message with - a randomly generated key using the AES-CTR mode so the packets are - harder to discern. - """ - obfuscated_io = ObfuscatedIO - packet_codec = AbridgedPacketCodec diff --git a/telethon/_network/transports/__init__.py b/telethon/_network/transports/__init__.py new file mode 100644 index 00000000..36dfc149 --- /dev/null +++ b/telethon/_network/transports/__init__.py @@ -0,0 +1,4 @@ +from .transport import Transport +from .abridged import Abridged +from .full import Full +from .intermediate import Intermediate diff --git a/telethon/_network/transports/abridged.py b/telethon/_network/transports/abridged.py new file mode 100644 index 00000000..c847c249 --- /dev/null +++ b/telethon/_network/transports/abridged.py @@ -0,0 +1,43 @@ +from .transport import Transport +import struct + + +class Abridged(Transport): + def __init__(self): + self._init = False + + def recreate_fresh(self): + return type(self)() + + def pack(self, input: bytes) -> bytes: + if self._init: + header = b'' + else: + header = b'\xef' + self._init = True + + length = len(data) >> 2 + if length < 127: + length = struct.pack('B', length) + else: + length = b'\x7f' + int.to_bytes(length, 3, 'little') + + return header + length + data + + def unpack(self, input: bytes) -> (int, bytes): + if len(input) < 4: + raise EOFError() + + length = input[0] + if length < 127: + offset = 1 + else: + offset = 4 + length = struct.unpack(' bytes: + # https://core.telegram.org/mtproto#tcp-transport + length = len(input) + 12 + data = struct.pack(' (int, bytes): + if len(input) < 12: + raise EOFError() + + length, seq = struct.unpack(' bytes: + if self._init: + header = b'' + else: + header = b'\xee\xee\xee\xee' + self._init = True + + return header + struct.pack(' (int, bytes): + if len(input) < 4: + raise EOFError() + + length = struct.unpack(' bytes: + pass + + # Should raise EOFError if it does not have enough bytes + @abc.abstractmethod + def unpack(self, input: bytes) -> (int, bytes): + pass From fe941cb940d44de0444cb51febbe20a8db9badbc Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 15 Jan 2022 13:41:17 +0100 Subject: [PATCH 098/131] Address immutability issues on connect --- telethon/_client/telegrambaseclient.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index e4906fbf..8ed1a599 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -7,6 +7,7 @@ import platform import time import typing import ipaddress +import dataclasses from .. import version, __name__ as __base_name__, _tl from .._crypto import rsa @@ -342,7 +343,7 @@ async def connect(self: 'TelegramClient') -> None: return if self._sender.auth_key.key != dc.auth: - dc.auth = self._sender.auth_key.key + dc = dataclasses.replace(dc, auth=self._sender.auth_key.key) # Need to send invokeWithLayer for things to work out. # Make the most out of this opportunity by also refreshing our state. @@ -359,11 +360,10 @@ async def connect(self: 'TelegramClient') -> None: ip = int(ipaddress.ip_address(dc.ip_address)) if dc.id in self._all_dcs: - self._all_dcs[dc.id].port = dc.port if dc.ipv6: - self._all_dcs[dc.id].ipv6 = ip + self._all_dcs[dc.id] = dataclasses.replace(self._all_dcs[dc.id], port=dc.port, ipv6=ip) else: - self._all_dcs[dc.id].ipv4 = ip + self._all_dcs[dc.id] = dataclasses.replace(self._all_dcs[dc.id], port=dc.port, ipv4=ip) elif dc.ipv6: self._all_dcs[dc.id] = DataCenter(dc.id, None, ip, dc.port, b'') else: From 3ad6d86cf52a7e5ece9cea8a6a8fd352cd6f357a Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 16 Jan 2022 11:48:30 +0100 Subject: [PATCH 099/131] Update to layer 137 --- telethon_generator/data/api.tl | 144 ++++++++++++++++++++++----------- 1 file changed, 98 insertions(+), 46 deletions(-) diff --git a/telethon_generator/data/api.tl b/telethon_generator/data/api.tl index d9b3d1d7..9aaa0e57 100644 --- a/telethon_generator/data/api.tl +++ b/telethon_generator/data/api.tl @@ -123,13 +123,13 @@ userStatusLastWeek#7bf09fc = UserStatus; userStatusLastMonth#77ebc742 = UserStatus; chatEmpty#29562865 id:long = Chat; -chat#41cbf256 flags:# creator:flags.0?true kicked:flags.1?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat; +chat#41cbf256 flags:# creator:flags.0?true kicked:flags.1?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true noforwards:flags.25?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat; chatForbidden#6592a1a7 id:long title:string = Chat; -channel#8261ac61 flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int = Chat; +channel#8261ac61 flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int = Chat; channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true id:long access_hash:long title:string until_date:flags.16?int = Chat; -chatFull#4dbdc099 flags:# can_set_username:flags.7?true has_scheduled:flags.8?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string = ChatFull; -channelFull#e9b27a17 flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string = ChatFull; +chatFull#d18ee226 flags:# can_set_username:flags.7?true has_scheduled:flags.8?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?Vector = ChatFull; +channelFull#e13c3d20 flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?Vector = ChatFull; chatParticipant#c02d4007 user_id:long inviter_id:long date:int = ChatParticipant; chatParticipantCreator#e46bcee4 user_id:long = ChatParticipant; @@ -142,7 +142,7 @@ chatPhotoEmpty#37c1011c = ChatPhoto; chatPhoto#1c6e1c11 flags:# has_video:flags.0?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = ChatPhoto; messageEmpty#90a6ca84 flags:# id:int peer_id:flags.0?Peer = Message; -message#85d6cbe2 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true legacy:flags.19?true edit_hide:flags.21?true pinned:flags.24?true id:int from_id:flags.8?Peer peer_id:Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long restriction_reason:flags.22?Vector ttl_period:flags.25?int = Message; +message#38116ee0 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true legacy:flags.19?true edit_hide:flags.21?true pinned:flags.24?true noforwards:flags.26?true id:int from_id:flags.8?Peer peer_id:Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long reactions:flags.20?MessageReactions restriction_reason:flags.22?Vector ttl_period:flags.25?int = Message; messageService#2b085862 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true legacy:flags.19?true id:int from_id:flags.8?Peer peer_id:Peer reply_to:flags.3?MessageReplyHeader date:int action:MessageAction ttl_period:flags.25?int = Message; messageMediaEmpty#3ded6320 = MessageMedia; @@ -188,6 +188,7 @@ messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = messageActionSetMessagesTTL#aa1afbfd period:int = MessageAction; messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction; messageActionSetChatTheme#aa786345 emoticon:string = MessageAction; +messageActionChatJoinedByRequest#ebbca3cb = MessageAction; dialog#2c171f72 flags:# pinned:flags.2?true unread_mark:flags.3?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int = Dialog; dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog; @@ -207,7 +208,7 @@ geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radiu auth.sentCode#5e002502 flags:# type:auth.SentCodeType phone_code_hash:string next_type:flags.1?auth.CodeType timeout:flags.2?int = auth.SentCode; -auth.authorization#cd050916 flags:# tmp_sessions:flags.0?int user:User = auth.Authorization; +auth.authorization#33fb7bb8 flags:# setup_password_required:flags.1?true otherwise_relogin_days:flags.1?int tmp_sessions:flags.0?int user:User = auth.Authorization; auth.authorizationSignUpRequired#44747e9a flags:# terms_of_service:flags.0?help.TermsOfService = auth.Authorization; auth.exportedAuthorization#b434e2b8 id:long bytes:bytes = auth.ExportedAuthorization; @@ -221,7 +222,7 @@ inputPeerNotifySettings#9c3d198e flags:# show_previews:flags.0?Bool silent:flags peerNotifySettings#af509d20 flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int sound:flags.3?string = PeerNotifySettings; -peerSettings#733f2961 flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true autoarchived:flags.7?true invite_members:flags.8?true geo_distance:flags.6?int = PeerSettings; +peerSettings#a518110d flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true autoarchived:flags.7?true invite_members:flags.8?true request_chat_broadcast:flags.10?true geo_distance:flags.6?int request_chat_title:flags.9?string request_chat_date:flags.9?int = PeerSettings; wallPaper#a437c3ed id:long flags:# creator:flags.0?true default:flags.1?true pattern:flags.3?true dark:flags.4?true access_hash:long slug:string document:Document settings:flags.2?WallPaperSettings = WallPaper; wallPaperNoFile#e0804116 id:long flags:# default:flags.1?true dark:flags.4?true settings:flags.2?WallPaperSettings = WallPaper; @@ -235,7 +236,7 @@ inputReportReasonCopyright#9b89f93a = ReportReason; inputReportReasonGeoIrrelevant#dbd4feed = ReportReason; inputReportReasonFake#f5ddd6e7 = ReportReason; -userFull#d697ff05 flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true video_calls_available:flags.13?true user:User about:flags.1?string settings:PeerSettings profile_photo:flags.2?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int ttl_period:flags.14?int theme_emoticon:flags.15?string = UserFull; +userFull#cf366521 flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true video_calls_available:flags.13?true id:long about:flags.1?string settings:PeerSettings profile_photo:flags.2?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int ttl_period:flags.14?int theme_emoticon:flags.15?string private_forward_name:flags.16?string = UserFull; contact#145ade0b user_id:long mutual:Bool = Contact; @@ -378,6 +379,9 @@ updateChannelParticipant#985d3abb flags:# channel_id:long date:int actor_id:long updateBotStopped#c4870a49 user_id:long date:int stopped:Bool qts:int = Update; updateGroupCallConnection#b783982 flags:# presentation:flags.0?true params:DataJSON = Update; updateBotCommands#4d712f2e peer:Peer bot_id:long commands:Vector = Update; +updatePendingJoinRequests#7063c3db peer:Peer requests_pending:int recent_requesters:Vector = Update; +updateBotChatInviteRequester#11dfa986 peer:Peer date:int user_id:long about:string invite:ExportedChatInvite qts:int = Update; +updateMessageReactions#154798c3 peer:Peer msg_id:int reactions:MessageReactions = Update; updates.state#a56c2a3e pts:int qts:int date:int seq:int unread_count:int = updates.State; @@ -467,7 +471,7 @@ sendMessageUploadRoundAction#243e1c66 progress:int = SendMessageAction; speakingInGroupCallAction#d92c2285 = SendMessageAction; sendMessageHistoryImportAction#dbda9246 progress:int = SendMessageAction; sendMessageChooseStickerAction#b05ac6b1 = SendMessageAction; -sendMessageEmojiInteraction#6a3233b6 emoticon:string interaction:DataJSON = SendMessageAction; +sendMessageEmojiInteraction#25972bcb emoticon:string msg_id:int interaction:DataJSON = SendMessageAction; sendMessageEmojiInteractionSeen#b665902e emoticon:string = SendMessageAction; contacts.found#b3134d9d my_results:Vector results:Vector chats:Vector users:Vector = contacts.Found; @@ -535,9 +539,9 @@ webPagePending#c586da1c id:long date:int = WebPage; webPage#e89c45b2 flags:# id:long url:string display_url:string hash:int type:flags.0?string site_name:flags.1?string title:flags.2?string description:flags.3?string photo:flags.4?Photo embed_url:flags.5?string embed_type:flags.5?string embed_width:flags.6?int embed_height:flags.6?int duration:flags.7?int author:flags.8?string document:flags.9?Document cached_page:flags.10?Page attributes:flags.12?Vector = WebPage; webPageNotModified#7311ca11 flags:# cached_page_views:flags.0?int = WebPage; -authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true hash:long device_model:string platform:string system_version:string api_id:int app_name:string app_version:string date_created:int date_active:int ip:string country:string region:string = Authorization; +authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true encrypted_requests_disabled:flags.3?true call_requests_disabled:flags.4?true hash:long device_model:string platform:string system_version:string api_id:int app_name:string app_version:string date_created:int date_active:int ip:string country:string region:string = Authorization; -account.authorizations#1250abde authorizations:Vector = account.Authorizations; +account.authorizations#4bff8ea0 authorization_ttl_days:int authorizations:Vector = account.Authorizations; account.password#185b184f flags:# has_recovery:flags.0?true has_secure_values:flags.1?true has_password:flags.2?true current_algo:flags.2?PasswordKdfAlgo srp_B:flags.2?bytes srp_id:flags.2?long hint:flags.3?string email_unconfirmed_pattern:flags.4?string new_algo:PasswordKdfAlgo new_secure_algo:SecurePasswordKdfAlgo secure_random:bytes pending_reset_date:flags.5?int = account.Password; @@ -549,10 +553,10 @@ auth.passwordRecovery#137948a5 email_pattern:string = auth.PasswordRecovery; receivedNotifyMessage#a384b779 id:int flags:int = ReceivedNotifyMessage; -chatInviteExported#b18105e8 flags:# revoked:flags.0?true permanent:flags.5?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int = ExportedChatInvite; +chatInviteExported#ab4a819 flags:# revoked:flags.0?true permanent:flags.5?true request_needed:flags.6?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int requested:flags.7?int title:flags.8?string = ExportedChatInvite; chatInviteAlready#5a686d7c chat:Chat = ChatInvite; -chatInvite#dfc2f58e flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true title:string photo:Photo participants_count:int participants:flags.4?Vector = ChatInvite; +chatInvite#300c44c1 flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true request_needed:flags.6?true title:string about:flags.5?string photo:Photo participants_count:int participants:flags.4?Vector = ChatInvite; chatInvitePeek#61695cb0 chat:Chat expires:int = ChatInvite; inputStickerSetEmpty#ffb62b95 = InputStickerSet; @@ -560,10 +564,12 @@ inputStickerSetID#9de7a269 id:long access_hash:long = InputStickerSet; inputStickerSetShortName#861cc8a0 short_name:string = InputStickerSet; inputStickerSetAnimatedEmoji#28703c8 = InputStickerSet; inputStickerSetDice#e67f520e emoticon:string = InputStickerSet; +inputStickerSetAnimatedEmojiAnimations#cde3739 = InputStickerSet; stickerSet#d7df217a flags:# archived:flags.1?true official:flags.2?true masks:flags.3?true animated:flags.5?true installed_date:flags.0?int id:long access_hash:long title:string short_name:string thumbs:flags.4?Vector thumb_dc_id:flags.4?int thumb_version:flags.4?int count:int hash:int = StickerSet; messages.stickerSet#b60a24a6 set:StickerSet packs:Vector documents:Vector = messages.StickerSet; +messages.stickerSetNotModified#d3f924eb = messages.StickerSet; botCommand#c27ac8c7 command:string description:string = BotCommand; @@ -580,6 +586,8 @@ keyboardButtonBuy#afd93fbb text:string = KeyboardButton; keyboardButtonUrlAuth#10b78d29 flags:# text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; inputKeyboardButtonUrlAuth#d02e7fd4 flags:# request_write_access:flags.0?true text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; keyboardButtonRequestPoll#bbc7515d flags:# quiz:flags.0?Bool text:string = KeyboardButton; +inputKeyboardButtonUserProfile#e988037b text:string user_id:InputUser = KeyboardButton; +keyboardButtonUserProfile#308660c1 text:string user_id:long = KeyboardButton; keyboardButtonRow#77608b83 buttons:Vector = KeyboardButtonRow; @@ -607,6 +615,7 @@ messageEntityUnderline#9c4e7e8b offset:int length:int = MessageEntity; messageEntityStrike#bf0693d4 offset:int length:int = MessageEntity; messageEntityBlockquote#20df5d0 offset:int length:int = MessageEntity; messageEntityBankCard#761e6af4 offset:int length:int = MessageEntity; +messageEntitySpoiler#32ca960f offset:int length:int = MessageEntity; inputChannelEmpty#ee8c1e86 = InputChannel; inputChannel#f35aec28 channel_id:long access_hash:long = InputChannel; @@ -624,7 +633,7 @@ channelMessagesFilterEmpty#94d42ee7 = ChannelMessagesFilter; channelMessagesFilter#cd77d957 flags:# exclude_new_messages:flags.1?true ranges:Vector = ChannelMessagesFilter; channelParticipant#c00c07c0 user_id:long date:int = ChannelParticipant; -channelParticipantSelf#28a8bc67 user_id:long inviter_id:long date:int = ChannelParticipant; +channelParticipantSelf#35a8bfa7 flags:# via_request:flags.0?true user_id:long inviter_id:long date:int = ChannelParticipant; channelParticipantCreator#2fe601d3 flags:# user_id:long admin_rights:ChatAdminRights rank:flags.0?string = ChannelParticipant; channelParticipantAdmin#34c3bb53 flags:# can_edit:flags.0?true self:flags.1?true user_id:long inviter_id:flags.1?long promoted_by:long date:int admin_rights:ChatAdminRights rank:flags.2?string = ChannelParticipant; channelParticipantBanned#6df8014e flags:# left:flags.0?true peer:Peer kicked_by:long date:int banned_rights:ChatBannedRights = ChannelParticipant; @@ -681,11 +690,13 @@ messageFwdHeader#5f777dce flags:# imported:flags.7?true from_id:flags.0?Peer fro auth.codeTypeSms#72a3158c = auth.CodeType; auth.codeTypeCall#741cd3e3 = auth.CodeType; auth.codeTypeFlashCall#226ccefb = auth.CodeType; +auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; +auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; @@ -907,13 +918,16 @@ channelAdminLogEventActionExportedInviteRevoke#410a134e invite:ExportedChatInvit channelAdminLogEventActionExportedInviteEdit#e90ebb59 prev_invite:ExportedChatInvite new_invite:ExportedChatInvite = ChannelAdminLogEventAction; channelAdminLogEventActionParticipantVolume#3e7f6847 participant:GroupCallParticipant = ChannelAdminLogEventAction; channelAdminLogEventActionChangeHistoryTTL#6e941a38 prev_value:int new_value:int = ChannelAdminLogEventAction; -channelAdminLogEventActionChangeTheme#fe69018d prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByRequest#afb6144a invite:ExportedChatInvite approved_by:long = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleNoForwards#cb2ac766 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionSendMessage#278f2868 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAvailableReactions#9cf7f76a prev_value:Vector new_value:Vector = ChannelAdminLogEventAction; channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; channels.adminLogResults#ed8af74d events:Vector chats:Vector users:Vector = channels.AdminLogResults; -channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true = ChannelAdminLogEventsFilter; +channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true send:flags.16?true = ChannelAdminLogEventsFilter; popularContact#5ce14175 client_id:long importers:int = PopularContact; @@ -1082,7 +1096,7 @@ inputWallPaperNoFile#967a462e id:long = InputWallPaper; account.wallPapersNotModified#1c199183 = account.WallPapers; account.wallPapers#cdc3858c hash:long wallpapers:Vector = account.WallPapers; -codeSettings#debebe83 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true = CodeSettings; +codeSettings#8a6469c2 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true allow_missed_call:flags.5?true logout_tokens:flags.6?Vector = CodeSettings; wallPaperSettings#1dc1bca4 flags:# blur:flags.1?true motion:flags.2?true background_color:flags.0?int second_background_color:flags.4?int third_background_color:flags.5?int fourth_background_color:flags.6?int intensity:flags.3?int rotation:flags.4?int = WallPaperSettings; @@ -1122,7 +1136,7 @@ restrictionReason#d072acb4 platform:string reason:string text:string = Restricti inputTheme#3c5693e9 id:long access_hash:long = InputTheme; inputThemeSlug#f5890df1 slug:string = InputTheme; -theme#e802b8dc flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?ThemeSettings installs_count:flags.4?int = Theme; +theme#a00e67d6 flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?Vector emoticon:flags.6?string installs_count:flags.4?int = Theme; account.themesNotModified#f41eb622 = account.Themes; account.themes#9a3d8c6d hash:long themes:Vector = account.Themes; @@ -1234,7 +1248,7 @@ messages.historyImportParsed#5e0fb7b9 flags:# pm:flags.0?true group:flags.1?true messages.affectedFoundMessages#ef8d3e6c pts:int pts_count:int offset:int messages:Vector = messages.AffectedFoundMessages; -chatInviteImporter#b5cd5f4 user_id:long date:int = ChatInviteImporter; +chatInviteImporter#8c5adfd9 flags:# requested:flags.0?true user_id:long date:int about:flags.2?string approved_by:flags.1?long = ChatInviteImporter; messages.exportedChatInvites#bdc62dcc count:int invites:Vector users:Vector = messages.ExportedChatInvites; @@ -1271,15 +1285,39 @@ account.resetPasswordFailedWait#e3779861 retry_date:int = account.ResetPasswordR account.resetPasswordRequestedWait#e9effc7d until_date:int = account.ResetPasswordResult; account.resetPasswordOk#e926d63e = account.ResetPasswordResult; -chatTheme#ed0b5c33 emoticon:string theme:Theme dark_theme:Theme = ChatTheme; - -account.chatThemesNotModified#e011e1c4 = account.ChatThemes; -account.chatThemes#fe4cbebd hash:int themes:Vector = account.ChatThemes; - -sponsoredMessage#2a3c381f flags:# random_id:bytes from_id:Peer start_param:flags.0?string message:string entities:flags.1?Vector = SponsoredMessage; +sponsoredMessage#3a836df8 flags:# random_id:bytes from_id:flags.3?Peer chat_invite:flags.4?ChatInvite chat_invite_hash:flags.4?string channel_post:flags.2?int start_param:flags.0?string message:string entities:flags.1?Vector = SponsoredMessage; messages.sponsoredMessages#65a4c7d5 messages:Vector chats:Vector users:Vector = messages.SponsoredMessages; +searchResultsCalendarPeriod#c9b0539f date:int min_msg_id:int max_msg_id:int count:int = SearchResultsCalendarPeriod; + +messages.searchResultsCalendar#147ee23c flags:# inexact:flags.0?true count:int min_date:int min_msg_id:int offset_id_offset:flags.1?int periods:Vector messages:Vector chats:Vector users:Vector = messages.SearchResultsCalendar; + +searchResultPosition#7f648b67 msg_id:int date:int offset:int = SearchResultsPosition; + +messages.searchResultsPositions#53b22baf count:int positions:Vector = messages.SearchResultsPositions; + +channels.sendAsPeers#8356cda9 peers:Vector chats:Vector users:Vector = channels.SendAsPeers; + +users.userFull#3b6d152e full_user:UserFull chats:Vector users:Vector = users.UserFull; + +messages.peerSettings#6880b94d settings:PeerSettings chats:Vector users:Vector = messages.PeerSettings; + +auth.loggedOut#c3a2835f flags:# future_auth_token:flags.0?bytes = auth.LoggedOut; + +reactionCount#6fb250d1 flags:# chosen:flags.0?true reaction:string count:int = ReactionCount; + +messageReactions#87b6e36 flags:# min:flags.0?true can_see_list:flags.2?true results:Vector recent_reactons:flags.1?Vector = MessageReactions; + +messageUserReaction#932844fa user_id:long reaction:string = MessageUserReaction; + +messages.messageReactionsList#a366923c flags:# count:int reactions:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; + +availableReaction#c077ec01 flags:# inactive:flags.0?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; + +messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; +messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; + ---functions--- invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; @@ -1293,7 +1331,7 @@ invokeWithTakeout#aca9fd2e {X:Type} takeout_id:long query:!X = X; auth.sendCode#a677244f phone_number:string api_id:int api_hash:string settings:CodeSettings = auth.SentCode; auth.signUp#80eee427 phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; auth.signIn#bcd51581 phone_number:string phone_code_hash:string phone_code:string = auth.Authorization; -auth.logOut#5717da40 = Bool; +auth.logOut#3e72ba19 = auth.LoggedOut; auth.resetAuthorizations#9fab0d1a = Bool; auth.exportAuthorization#e5bfffcd dc_id:int = auth.ExportedAuthorization; auth.importAuthorization#a57a7dad id:long bytes:bytes = auth.Authorization; @@ -1366,10 +1404,10 @@ account.resetWallPapers#bb3b9804 = Bool; account.getAutoDownloadSettings#56da0b3f = account.AutoDownloadSettings; account.saveAutoDownloadSettings#76f36233 flags:# low:flags.0?true high:flags.1?true settings:AutoDownloadSettings = Bool; account.uploadTheme#1c3db333 flags:# file:InputFile thumb:flags.0?InputFile file_name:string mime_type:string = Document; -account.createTheme#8432c21f flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?InputThemeSettings = Theme; -account.updateTheme#5cb367d5 flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?InputThemeSettings = Theme; +account.createTheme#652e4400 flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.updateTheme#2bf40ccc flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?Vector = Theme; account.saveTheme#f257106c theme:InputTheme unsave:Bool = Bool; -account.installTheme#7ae43737 flags:# dark:flags.0?true format:flags.1?string theme:flags.1?InputTheme = Bool; +account.installTheme#c727bb3b flags:# dark:flags.0?true theme:flags.1?InputTheme format:flags.2?string base_theme:flags.3?BaseTheme = Bool; account.getTheme#8d9d742b format:string theme:InputTheme document_id:long = Theme; account.getThemes#7206e458 format:string hash:long = account.Themes; account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; @@ -1380,10 +1418,12 @@ account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = Globa account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool; account.resetPassword#9308ce1b = account.ResetPasswordResult; account.declinePasswordReset#4c9409f6 = Bool; -account.getChatThemes#d6d71d7b hash:int = account.ChatThemes; +account.getChatThemes#d638de89 hash:long = account.Themes; +account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool; +account.changeAuthorizationSettings#40f48462 flags:# hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool; users.getUsers#d91a548 id:Vector = Vector; -users.getFullUser#ca30a5b1 id:InputUser = UserFull; +users.getFullUser#b60f5918 id:InputUser = users.UserFull; users.setSecureValueErrors#90c894b5 id:InputUser errors:Vector = Bool; contacts.getContactIDs#7adc669d hash:long = Vector; @@ -1412,15 +1452,15 @@ messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags messages.getHistory#4423e6c5 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; messages.search#a0fda762 flags:# peer:InputPeer q:string from_id:flags.0?InputPeer top_msg_id:flags.1?int filter:MessagesFilter min_date:int max_date:int offset_id:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; messages.readHistory#e306d3a peer:InputPeer max_id:int = messages.AffectedMessages; -messages.deleteHistory#1c015b09 flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int = messages.AffectedHistory; +messages.deleteHistory#b08f922a flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; messages.deleteMessages#e58e95d2 flags:# revoke:flags.0?true id:Vector = messages.AffectedMessages; messages.receivedMessages#5a954c0 max_id:int = Vector; messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action:SendMessageAction = Bool; -messages.sendMessage#520c3870 flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int = Updates; -messages.sendMedia#3491eba9 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int = Updates; -messages.forwardMessages#d9fee60e flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer schedule_date:flags.10?int = Updates; +messages.sendMessage#d9d75a4 flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true peer:InputPeer reply_to_msg_id:flags.0?int message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int send_as:flags.13?InputPeer = Updates; +messages.sendMedia#e25ff8e0 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true peer:InputPeer reply_to_msg_id:flags.0?int media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int send_as:flags.13?InputPeer = Updates; +messages.forwardMessages#cc30290b flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true noforwards:flags.14?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer schedule_date:flags.10?int send_as:flags.13?InputPeer = Updates; messages.reportSpam#cf1592db peer:InputPeer = Bool; -messages.getPeerSettings#3672e09c peer:InputPeer = PeerSettings; +messages.getPeerSettings#efd9a6a2 peer:InputPeer = messages.PeerSettings; messages.report#8953ab4e peer:InputPeer id:Vector reason:ReportReason message:string = Bool; messages.getChats#49e9528f id:Vector = messages.Chats; messages.getFullChat#aeb00b34 chat_id:long = messages.ChatFull; @@ -1444,10 +1484,10 @@ messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages messages.getStickers#d5a5d3a1 emoticon:string hash:long = messages.Stickers; messages.getAllStickers#b8a0a1a8 hash:long = messages.AllStickers; messages.getWebPagePreview#8b68b0cc flags:# message:string entities:flags.3?Vector = MessageMedia; -messages.exportChatInvite#14b9bcd7 flags:# legacy_revoke_permanent:flags.2?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int = ExportedChatInvite; +messages.exportChatInvite#a02ce5d5 flags:# legacy_revoke_permanent:flags.2?true request_needed:flags.3?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int title:flags.4?string = ExportedChatInvite; messages.checkChatInvite#3eadb1bb hash:string = ChatInvite; messages.importChatInvite#6c50051c hash:string = Updates; -messages.getStickerSet#2619a90e stickerset:InputStickerSet = messages.StickerSet; +messages.getStickerSet#c8a0ec74 stickerset:InputStickerSet hash:int = messages.StickerSet; messages.installStickerSet#c78fe460 stickerset:InputStickerSet archived:Bool = messages.StickerSetInstallResult; messages.uninstallStickerSet#f96e55de stickerset:InputStickerSet = Bool; messages.startBot#e6df7378 bot:InputUser peer:InputPeer random_id:long start_param:string = Updates; @@ -1461,7 +1501,7 @@ messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs; messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool; messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults; messages.setInlineBotResults#eb5ea206 flags:# gallery:flags.0?true private:flags.1?true query_id:long results:Vector cache_time:int next_offset:flags.2?string switch_pm:flags.3?InlineBotSwitchPM = Bool; -messages.sendInlineBotResult#220815b0 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to_msg_id:flags.0?int random_id:long query_id:long id:string schedule_date:flags.10?int = Updates; +messages.sendInlineBotResult#7aa11297 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to_msg_id:flags.0?int random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer = Updates; messages.getMessageEditData#fda68d36 peer:InputPeer id:int = messages.MessageEditData; messages.editMessage#48f71778 flags:# no_webpage:flags.1?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.15?int = Updates; messages.editInlineBotMessage#83557dba flags:# no_webpage:flags.1?true id:InputBotInlineMessageID message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector = Bool; @@ -1497,7 +1537,7 @@ messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; messages.getUnreadMentions#46578472 peer:InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; messages.readMentions#f0189d3 peer:InputPeer = messages.AffectedHistory; messages.getRecentLocations#702a40e0 peer:InputPeer limit:int hash:long = messages.Messages; -messages.sendMultiMedia#cc0110cb flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int multi_media:Vector schedule_date:flags.10?int = Updates; +messages.sendMultiMedia#f803138f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true peer:InputPeer reply_to_msg_id:flags.0?int multi_media:Vector schedule_date:flags.10?int send_as:flags.13?InputPeer = Updates; messages.uploadEncryptedFile#5057c497 peer:InputEncryptedChat file:InputEncryptedFile = EncryptedFile; messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; messages.getSplitRanges#1cff7e08 = Vector; @@ -1508,7 +1548,6 @@ messages.updatePinnedMessage#d2aaf7ec flags:# silent:flags.0?true unpin:flags.1? messages.sendVote#10ea6184 peer:InputPeer msg_id:int options:Vector = Updates; messages.getPollResults#73bb643b peer:InputPeer msg_id:int = Updates; messages.getOnlines#6e2be050 peer:InputPeer = ChatOnlines; -messages.getStatsURL#812c2ae6 flags:# dark:flags.0?true peer:InputPeer params:string = StatsURL; messages.editChatAbout#def60797 peer:InputPeer about:string = Bool; messages.editChatDefaultBannedRights#a5866b41 peer:InputPeer banned_rights:ChatBannedRights = Updates; messages.getEmojiKeywords#35a0e062 lang_code:string = EmojiKeywordsDifference; @@ -1542,15 +1581,27 @@ messages.uploadImportedMedia#2a862092 peer:InputPeer import_id:long file_name:st messages.startHistoryImport#b43df344 peer:InputPeer import_id:long = Bool; messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; messages.getExportedChatInvite#73746f5c peer:InputPeer link:string = messages.ExportedChatInvite; -messages.editExportedChatInvite#2e4ffbe flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int = messages.ExportedChatInvite; +messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; messages.deleteRevokedExportedChatInvites#56987bd5 peer:InputPeer admin_id:InputUser = Bool; messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; messages.getAdminsWithInvites#3920e6ef peer:InputPeer = messages.ChatAdminsWithInvites; -messages.getChatInviteImporters#26fb7289 peer:InputPeer link:string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; +messages.getChatInviteImporters#df04dd4e flags:# requested:flags.0?true peer:InputPeer link:flags.1?string q:flags.2?string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; messages.setHistoryTTL#b80e5fe4 peer:InputPeer period:int = Updates; messages.checkHistoryImportPeer#5dc60f03 peer:InputPeer = messages.CheckedHistoryImportPeer; messages.setChatTheme#e63be13f peer:InputPeer emoticon:string = Updates; messages.getMessageReadParticipants#2c6f97b7 peer:InputPeer msg_id:int = Vector; +messages.getSearchResultsCalendar#49f0bde9 peer:InputPeer filter:MessagesFilter offset_id:int offset_date:int = messages.SearchResultsCalendar; +messages.getSearchResultsPositions#6e9583a3 peer:InputPeer filter:MessagesFilter offset_id:int limit:int = messages.SearchResultsPositions; +messages.hideChatJoinRequest#7fe7e815 flags:# approved:flags.0?true peer:InputPeer user_id:InputUser = Updates; +messages.hideAllChatJoinRequests#e085f4ea flags:# approved:flags.0?true peer:InputPeer link:flags.1?string = Updates; +messages.toggleNoForwards#b11eafa2 peer:InputPeer enabled:Bool = Updates; +messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; +messages.sendReaction#25690ce4 flags:# peer:InputPeer msg_id:int reaction:flags.0?string = Updates; +messages.getMessagesReactions#8bba90e6 peer:InputPeer id:Vector = Updates; +messages.getMessageReactionsList#e0ee6b77 flags:# peer:InputPeer id:int reaction:flags.0?string offset:flags.1?string limit:int = messages.MessageReactionsList; +messages.setChatAvailableReactions#14050ea6 peer:InputPeer available_reactions:Vector = Updates; +messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; +messages.setDefaultReaction#d960c4d4 reaction:string = Bool; updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; @@ -1595,8 +1646,7 @@ help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; -channels.deleteUserHistory#d10dd71b channel:InputChannel user_id:InputUser = messages.AffectedHistory; -channels.reportSpam#fe087810 channel:InputChannel user_id:InputUser id:Vector = Bool; +channels.reportSpam#f44a8315 channel:InputChannel participant:InputPeer id:Vector = Bool; channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:long = channels.ChannelParticipants; channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; @@ -1631,6 +1681,8 @@ channels.getInactiveChannels#11e831ee = messages.InactiveChats; channels.convertToGigagroup#b290c69 channel:InputChannel = Updates; channels.viewSponsoredMessage#beaedb94 channel:InputChannel random_id:bytes = Bool; channels.getSponsoredMessages#ec210fbf channel:InputChannel = messages.SponsoredMessages; +channels.getSendAs#dc770ee peer:InputPeer = channels.SendAsPeers; +channels.deleteParticipantHistory#367544db channel:InputChannel participant:InputPeer = messages.AffectedHistory; bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; @@ -1698,4 +1750,4 @@ stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel stats.getMessagePublicForwards#5630281b channel:InputChannel msg_id:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; -// LAYER 133 +// LAYER 137 From 8c9ee3f73152919d24e50a79d10c6af1af1b9f27 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 16 Jan 2022 12:01:18 +0100 Subject: [PATCH 100/131] Document new known errors --- telethon_generator/data/errors.csv | 6 ++++++ telethon_generator/data/methods.csv | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/telethon_generator/data/errors.csv b/telethon_generator/data/errors.csv index 71a417d8..5ef81e85 100644 --- a/telethon_generator/data/errors.csv +++ b/telethon_generator/data/errors.csv @@ -66,6 +66,7 @@ CHAT_ABOUT_TOO_LONG,400,Chat about too long CHAT_ADMIN_INVITE_REQUIRED,403,You do not have the rights to do this CHAT_ADMIN_REQUIRED,400,"Chat admin privileges are required to do that in the specified chat (for example, to send a message in a channel which is not yours), or invalid permissions used for the channel or group" CHAT_FORBIDDEN,403,You cannot write in this chat +CHAT_FORWARDS_RESTRICTED,, CHAT_ID_EMPTY,400,The provided chat ID is empty CHAT_ID_INVALID,400,"Invalid object ID for a chat. Make sure to pass the right types, for instance making sure that the request is designed for chats (not channels/megagroups) or otherwise look for a different one more suited\nAn example working with a megagroup and AddChatUserRequest, it will fail because megagroups are channels. Use InviteToChannelRequest instead" CHAT_INVALID,400,The chat is invalid for this request @@ -116,6 +117,7 @@ ENCRYPTION_OCCUPY_FAILED,500,TDLib developer claimed it is not an error while ac ENTITIES_TOO_LONG,400,It is no longer possible to send such long data inside entity tags (for example inline text URLs) ENTITY_MENTION_USER_INVALID,400,You can't use this entity ERROR_TEXT_EMPTY,400,The provided error message is empty +EXPIRE_DATE_INVALID,400, EXPIRE_FORBIDDEN,400, EXPORT_CARD_INVALID,400,Provided card is invalid EXTERNAL_URL_INVALID,400,External URL invalid @@ -230,6 +232,7 @@ PACK_SHORT_NAME_OCCUPIED,400,A stickerpack with this name already exists PARTICIPANTS_TOO_FEW,400,Not enough participants PARTICIPANT_CALL_FAILED,500,Failure while making call PARTICIPANT_JOIN_MISSING,403, +PARTICIPANT_ID_INVALID,, PARTICIPANT_VERSION_OUTDATED,400,The other participant does not use an up to date telegram client with support for calls PASSWORD_EMPTY,400,The provided password is empty PASSWORD_HASH_INVALID,400,The password (and thus its hash value) you entered is invalid @@ -315,6 +318,8 @@ SCHEDULE_STATUS_PRIVATE,400,You cannot schedule a message until the person comes SCHEDULE_TOO_MUCH,400,You cannot schedule more messages in this chat (last known limit of 100 per chat) SEARCH_QUERY_EMPTY,400,The search query is empty SECONDS_INVALID,400,"Slow mode only supports certain values (e.g. 0, 10s, 30s, 1m, 5m, 15m and 1h)" +SEND_AS_PEER_INVALID,, +SEND_CODE_UNAVAILABLE,406, SEND_MESSAGE_MEDIA_INVALID,400,The message media was invalid or not specified SEND_MESSAGE_TYPE_INVALID,400,The message type is invalid SENSITIVE_CHANGE_FORBIDDEN,403,Your sensitive content settings cannot be changed at this time @@ -366,6 +371,7 @@ TYPE_CONSTRUCTOR_INVALID,400,The type constructor is invalid UNKNOWN_ERROR,400, UNKNOWN_METHOD,500,The method you tried to call cannot be called on non-CDN DCs UNTIL_DATE_INVALID,400,That date cannot be specified in this request (try using None) +UPDATE_APP_TO_LOGIN,406, URL_INVALID,400,The URL used was invalid (e.g. when answering a callback with a URL that's not t.me/yourbot or your game's URL) USER_VOLUME_INVALID,400, USERNAME_INVALID,400,"Nobody is using this username, or the username is unacceptable. If the latter, it must match r""[a-zA-Z][\w\d]{3,30}[a-zA-Z\d]""" diff --git a/telethon_generator/data/methods.csv b/telethon_generator/data/methods.csv index 135d5618..c8c92ab5 100644 --- a/telethon_generator/data/methods.csv +++ b/telethon_generator/data/methods.csv @@ -80,7 +80,7 @@ auth.importLoginToken,user,AUTH_TOKEN_ALREADY_ACCEPTED AUTH_TOKEN_EXPIRED AUTH_T auth.logOut,both, auth.recoverPassword,user,CODE_EMPTY NEW_SETTINGS_INVALID auth.requestPasswordRecovery,user,PASSWORD_EMPTY PASSWORD_RECOVERY_NA -auth.resendCode,user,PHONE_NUMBER_INVALID +auth.resendCode,user,PHONE_NUMBER_INVALID SEND_CODE_UNAVAILABLE auth.resetAuthorizations,user,TIMEOUT auth.sendCode,user,API_ID_INVALID API_ID_PUBLISHED_FLOOD AUTH_RESTART INPUT_REQUEST_TOO_LONG PHONE_NUMBER_APP_SIGNUP_FORBIDDEN PHONE_NUMBER_BANNED PHONE_NUMBER_FLOOD PHONE_NUMBER_INVALID PHONE_PASSWORD_FLOOD PHONE_PASSWORD_PROTECTED auth.signIn,user,PHONE_CODE_EMPTY PHONE_CODE_EXPIRED PHONE_CODE_INVALID PHONE_NUMBER_INVALID PHONE_NUMBER_UNOCCUPIED SESSION_PASSWORD_NEEDED @@ -197,7 +197,7 @@ messages.editChatPhoto,both,CHAT_ID_INVALID INPUT_CONSTRUCTOR_INVALID INPUT_FETC messages.editChatTitle,both,CHAT_ID_INVALID NEED_CHAT_INVALID messages.editInlineBotMessage,both,MESSAGE_ID_INVALID MESSAGE_NOT_MODIFIED messages.editMessage,both,CHANNEL_INVALID CHANNEL_PRIVATE CHAT_WRITE_FORBIDDEN INLINE_BOT_REQUIRED INPUT_USER_DEACTIVATED MEDIA_GROUPED_INVALID MEDIA_NEW_INVALID MEDIA_PREV_INVALID MESSAGE_AUTHOR_REQUIRED MESSAGE_EDIT_TIME_EXPIRED MESSAGE_EMPTY MESSAGE_ID_INVALID MESSAGE_NOT_MODIFIED PEER_ID_INVALID -messages.exportChatInvite,both,CHAT_ID_INVALID +messages.exportChatInvite,both,CHAT_ID_INVALID EXPIRE_DATE_INVALID messages.faveSticker,user,STICKER_ID_INVALID messages.forwardMessages,both,BROADCAST_PUBLIC_VOTERS_FORBIDDEN CHANNEL_INVALID CHANNEL_PRIVATE CHAT_ADMIN_REQUIRED CHAT_ID_INVALID CHAT_SEND_GIFS_FORBIDDEN CHAT_SEND_MEDIA_FORBIDDEN CHAT_SEND_STICKERS_FORBIDDEN CHAT_WRITE_FORBIDDEN GROUPED_MEDIA_INVALID INPUT_USER_DEACTIVATED MEDIA_EMPTY MESSAGE_IDS_EMPTY MESSAGE_ID_INVALID PEER_ID_INVALID PTS_CHANGE_EMPTY RANDOM_ID_DUPLICATE RANDOM_ID_INVALID SCHEDULE_DATE_TOO_LATE SCHEDULE_TOO_MUCH TIMEOUT USER_BANNED_IN_CHANNEL USER_IS_BLOCKED USER_IS_BOT YOU_BLOCKED_USER messages.getAllChats,user, From a95393648f0878b32919ba7a399eaf1a44927aea Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 16 Jan 2022 12:06:42 +0100 Subject: [PATCH 101/131] Remove custom enum parsing Python enums can already be parsed out-of-the-box. --- telethon/_client/chats.py | 32 +++++++++++++------------- telethon/_client/downloads.py | 6 ++--- telethon/_client/telegrambaseclient.py | 2 +- telethon/_misc/enums.py | 22 ------------------ 4 files changed, 20 insertions(+), 42 deletions(-) diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 3034b5d9..acba307e 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -69,21 +69,21 @@ class _ChatAction: return action return { - enums.TYPING: _tl.SendMessageTypingAction(), - enums.CONTACT: _tl.SendMessageChooseContactAction(), - enums.GAME: _tl.SendMessageGamePlayAction(), - enums.LOCATION: _tl.SendMessageGeoLocationAction(), - enums.STICKER: _tl.SendMessageChooseStickerAction(), - enums.RECORD_AUDIO: _tl.SendMessageRecordAudioAction(), - enums.RECORD_ROUND: _tl.SendMessageRecordRoundAction(), - enums.RECORD_VIDEO: _tl.SendMessageRecordVideoAction(), - enums.AUDIO: _tl.SendMessageUploadAudioAction(1), - enums.ROUND: _tl.SendMessageUploadRoundAction(1), - enums.VIDEO: _tl.SendMessageUploadVideoAction(1), - enums.PHOTO: _tl.SendMessageUploadPhotoAction(1), - enums.DOCUMENT: _tl.SendMessageUploadDocumentAction(1), - enums.CANCEL: _tl.SendMessageCancelAction(), - }[enums.parse_typing_action(action)] + enums.Action.TYPING: _tl.SendMessageTypingAction(), + enums.Action.CONTACT: _tl.SendMessageChooseContactAction(), + enums.Action.GAME: _tl.SendMessageGamePlayAction(), + enums.Action.LOCATION: _tl.SendMessageGeoLocationAction(), + enums.Action.STICKER: _tl.SendMessageChooseStickerAction(), + enums.Action.RECORD_AUDIO: _tl.SendMessageRecordAudioAction(), + enums.Action.RECORD_ROUND: _tl.SendMessageRecordRoundAction(), + enums.Action.RECORD_VIDEO: _tl.SendMessageRecordVideoAction(), + enums.Action.AUDIO: _tl.SendMessageUploadAudioAction(1), + enums.Action.ROUND: _tl.SendMessageUploadRoundAction(1), + enums.Action.VIDEO: _tl.SendMessageUploadVideoAction(1), + enums.Action.PHOTO: _tl.SendMessageUploadPhotoAction(1), + enums.Action.DOCUMENT: _tl.SendMessageUploadDocumentAction(1), + enums.Action.CANCEL: _tl.SendMessageCancelAction(), + }[enums.Action(action)] def progress(self, current, total): if hasattr(self._action, 'progress'): @@ -98,7 +98,7 @@ class _ParticipantsIter(requestiter.RequestIter): else: filter = _tl.ChannelParticipantsRecent() else: - filter = enums.parse_participant(filter) + filter = enums.Participant(filter) if filter == enums.Participant.ADMIN: filter = _tl.ChannelParticipantsAdmins() elif filter == enums.Participant.BOT: diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py index 6339b8c9..d472dbcf 100644 --- a/telethon/_client/downloads.py +++ b/telethon/_client/downloads.py @@ -210,7 +210,7 @@ async def download_profile_photo( photo = entity.photo if isinstance(photo, (_tl.UserProfilePhoto, _tl.ChatPhoto)): - thumb = enums.Size.ORIGINAL if thumb == () else enums.parse_photo_size(thumb) + thumb = enums.Size.ORIGINAL if thumb == () else enums.Size(thumb) dc_id = photo.dc_id loc = _tl.InputPeerPhotoFileLocation( @@ -494,11 +494,11 @@ def _get_thumb(thumbs, thumb): if isinstance(thumb, tlobject.TLObject): return thumb - thumb = enums.parse_photo_size(thumb) + thumb = enums.Size(thumb) return min( thumbs, default=None, - key=lambda t: abs(thumb - enums.parse_photo_size(t.type)) + key=lambda t: abs(thumb - enums.Size(t.type)) ) def _download_cached_photo_size(self: 'TelegramClient', size, file): diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 8ed1a599..10cd2d7f 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -199,7 +199,7 @@ def init( enums.ConnectionMode.FULL: transports.Full(), enums.ConnectionMode.INTERMEDIATE: transports.Intermediate(), enums.ConnectionMode.ABRIDGED: transports.Abridged(), - }[enums.parse_conn_mode(connection)] + }[enums.ConnectionMode(connection)] init_proxy = None # Used on connection. Capture the variables in a lambda since diff --git a/telethon/_misc/enums.py b/telethon/_misc/enums.py index 283030f3..79b20242 100644 --- a/telethon/_misc/enums.py +++ b/telethon/_misc/enums.py @@ -129,25 +129,3 @@ class Size(Enum): Size.ANIMATED: 7, Size.VIDEO: 6, }[self] - - -def _mk_parser(cls): - def parser(value): - if isinstance(value, cls): - return value - elif isinstance(value, str): - for variant in cls: - if value == variant.value: - return variant - - raise ValueError(f'unknown {cls.__name__}: {value!r}') - else: - raise TypeError(f'not a valid {cls.__name__}: {type(value).__name__!r}') - - return parser - - -parse_conn_mode = _mk_parser(ConnectionMode) -parse_participant = _mk_parser(Participant) -parse_typing_action = _mk_parser(Action) -parse_photo_size = _mk_parser(Size) From a3513d52321a8aba23a5b094b0a0874aa915dda8 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 16 Jan 2022 12:19:07 +0100 Subject: [PATCH 102/131] Remove broken force_sms --- readthedocs/misc/v2-migration-guide.rst | 2 ++ telethon/_client/auth.py | 43 +++++++++---------------- telethon/_client/telegramclient.py | 12 +------ 3 files changed, 19 insertions(+), 38 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 97b0bcb2..7d047e2e 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -748,3 +748,5 @@ still thumb because otherwise documents are weird. keep support for explicit size instance? renamed send_read_acknowledge. add send_read_acknowledge as alias for mark_read? + +force sms removed as it was broken anyway and not very reliable diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index 50df992b..90bb5ae5 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -37,7 +37,6 @@ def start( password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '), *, bot_token: str = None, - force_sms: bool = False, code_callback: typing.Callable[[], typing.Union[str, int]] = None, first_name: str = 'New User', last_name: str = '', @@ -63,7 +62,6 @@ def start( phone=phone, password=password, bot_token=bot_token, - force_sms=force_sms, code_callback=code_callback, first_name=first_name, last_name=last_name, @@ -71,7 +69,7 @@ def start( )) async def _start( - self: 'TelegramClient', phone, password, bot_token, force_sms, + self: 'TelegramClient', phone, password, bot_token, code_callback, first_name, last_name, max_attempts): if not self.is_connected(): await self.connect() @@ -123,7 +121,7 @@ async def _start( attempts = 0 two_step_detected = False - await self.send_code_request(phone, force_sms=force_sms) + await self.send_code_request(phone) sign_up = False # assume login while attempts < max_attempts: try: @@ -343,37 +341,28 @@ async def _replace_session_state(self, *, save=True, **changes): async def send_code_request( self: 'TelegramClient', - phone: str, - *, - force_sms: bool = False) -> '_tl.auth.SentCode': + phone: str) -> '_tl.auth.SentCode': result = None phone = utils.parse_phone(phone) or self._phone phone_hash = self._phone_code_hash.get(phone) - if not phone_hash: - try: - result = await self(_tl.fn.auth.SendCode( - phone, self.api_id, self.api_hash, _tl.CodeSettings())) - except errors.AuthRestartError: - return await self.send_code_request(phone, force_sms=force_sms) - - # If we already sent a SMS, do not resend the code (hash may be empty) - if isinstance(result.type, _tl.auth.SentCodeTypeSms): - force_sms = False - - # phone_code_hash may be empty, if it is, do not save it (#1283) - if result.phone_code_hash: - self._phone_code_hash[phone] = phone_hash = result.phone_code_hash - else: - force_sms = True - - self._phone = phone - - if force_sms: + if phone_hash: result = await self( _tl.fn.auth.ResendCode(phone, phone_hash)) self._phone_code_hash[phone] = result.phone_code_hash + else: + try: + result = await self(_tl.fn.auth.SendCode( + phone, self.api_id, self.api_hash, _tl.CodeSettings())) + except errors.AuthRestartError: + return await self.send_code_request(phone) + + # phone_code_hash may be empty, if it is, do not save it (#1283) + if result.phone_code_hash: + self._phone_code_hash[phone] = phone_hash = result.phone_code_hash + + self._phone = phone return result diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index bb21a416..79e51404 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -321,7 +321,6 @@ class TelegramClient: password: typing.Callable[[], str] = lambda: getpass.getpass('Please enter your password: '), *, bot_token: str = None, - force_sms: bool = False, code_callback: typing.Callable[[], typing.Union[str, int]] = None, first_name: str = 'New User', last_name: str = '', @@ -358,10 +357,6 @@ class TelegramClient: 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()`. @@ -513,9 +508,7 @@ class TelegramClient: @forward_call(auth.send_code_request) async def send_code_request( self: 'TelegramClient', - phone: str, - *, - force_sms: bool = False) -> '_tl.auth.SentCode': + phone: str) -> '_tl.auth.SentCode': """ Sends the Telegram code needed to login to the given phone number. @@ -523,9 +516,6 @@ class TelegramClient: 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`. From dc0f978b59836f91edd59d344489454f8f2fdbf9 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 16 Jan 2022 12:40:09 +0100 Subject: [PATCH 103/131] Support await on any client.action --- readthedocs/misc/v2-migration-guide.rst | 2 ++ telethon/_client/chats.py | 12 +++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 7d047e2e..91ec043d 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -750,3 +750,5 @@ keep support for explicit size instance? renamed send_read_acknowledge. add send_read_acknowledge as alias for mark_read? force sms removed as it was broken anyway and not very reliable + +you can now await client.action for a one-off any action not just cancel diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index acba307e..f900ce6d 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -27,6 +27,9 @@ class _ChatAction: self._task = None self._running = False + def __await__(self): + return self._once().__await__() + async def __aenter__(self): self._chat = await self._client.get_input_entity(self._chat) @@ -51,6 +54,10 @@ class _ChatAction: self._task = None + async def _once(self): + self._chat = await self._client.get_input_entity(self._chat) + await self._client(_tl.fn.messages.SetTyping(self._chat, self._action)) + async def _update(self): try: while self._running: @@ -456,11 +463,6 @@ def action( auto_cancel: bool = True) -> 'typing.Union[_ChatAction, typing.Coroutine]': action = _ChatAction._parse(action) - if isinstance(action, _tl.SendMessageCancelAction): - # ``SetTyping.resolve`` will get input peer of ``entity``. - return self(_tl.fn.messages.SetTyping( - entity, _tl.SendMessageCancelAction())) - return _ChatAction( self, entity, action, delay=delay, auto_cancel=auto_cancel) From 1e779a91b72cce36075c9b32d0786067b7f17eec Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 16 Jan 2022 12:42:00 +0100 Subject: [PATCH 104/131] Add progress_callback to download_profile_photo --- telethon/_client/downloads.py | 7 ++++--- telethon/_client/telegramclient.py | 7 ++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/telethon/_client/downloads.py b/telethon/_client/downloads.py index d472dbcf..0b9f9aaf 100644 --- a/telethon/_client/downloads.py +++ b/telethon/_client/downloads.py @@ -180,7 +180,8 @@ async def download_profile_photo( entity: 'hints.EntityLike', file: 'hints.FileLike' = None, *, - thumb) -> typing.Optional[str]: + thumb, + progress_callback) -> typing.Optional[str]: # hex(crc32(x.encode('ascii'))) for x in # ('User', 'Chat', 'UserFull', 'ChatFull') ENTITIES = (0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697) @@ -201,7 +202,7 @@ async def download_profile_photo( return await _download_photo( self, entity.chat_photo, file, date=None, - thumb=thumb, progress_callback=None + thumb=thumb, progress_callback=progress_callback ) for attr in ('username', 'first_name', 'title'): @@ -247,7 +248,7 @@ async def download_profile_photo( full = await self(_tl.fn.channels.GetFullChannel(ie)) return await _download_photo( self, full.full_chat.chat_photo, file, - date=None, progress_callback=None, + date=None, progress_callback=progress_callback, thumb=thumb ) else: diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 79e51404..c4b9467b 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -1628,7 +1628,8 @@ class TelegramClient: entity: 'hints.EntityLike', file: 'hints.FileLike' = None, *, - thumb: typing.Union[str, enums.Size] = ()) -> typing.Optional[str]: + thumb: typing.Union[str, enums.Size] = (), + progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[str]: """ Downloads the profile photo from the given user, chat or channel. @@ -1662,6 +1663,10 @@ class TelegramClient: By default, the largest size (original) is downloaded. + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(received bytes, total)``. + 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. From 6eadc8aed815184563734d3fb14c39fc42a77311 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 16 Jan 2022 13:03:00 +0100 Subject: [PATCH 105/131] Simplify accepted values in forward, delete and mark read Forward and delete are meant to delete lists. Now only lists are supported, which should not be an issue as message.forward_to and message.delete both exist. mark_read really only works with one message at a time, so list support was removed for it, as well as the now redundant max_id. --- readthedocs/misc/v2-migration-guide.rst | 7 ++++ telethon/_client/messages.py | 51 +++++++++---------------- telethon/_client/telegramclient.py | 24 ++++++------ 3 files changed, 38 insertions(+), 44 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 91ec043d..10556cc0 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -752,3 +752,10 @@ renamed send_read_acknowledge. add send_read_acknowledge as alias for mark_read? force sms removed as it was broken anyway and not very reliable you can now await client.action for a one-off any action not just cancel + +fwd msg and delete msg now mandate a list rather than a single int or msg +(since there's msg.delete and msg.forward_to this should be no issue). +they are meant to work on lists. + +also mark read only supports single now. a list would just be max anyway. +removed max id since it's not really of much use. diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index f55fed8d..e85921f3 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -518,7 +518,7 @@ async def send_message( async def forward_messages( self: 'TelegramClient', entity: 'hints.EntityLike', - messages: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', + messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]', from_peer: 'hints.EntityLike' = None, *, background: bool = None, @@ -530,10 +530,6 @@ async def forward_messages( if as_album is not None: warnings.warn('the as_album argument is deprecated and no longer has any effect') - single = not utils.is_list_like(messages) - if single: - messages = (messages,) - entity = await self.get_input_entity(entity) if from_peer: @@ -639,16 +635,13 @@ async def edit_message( async def delete_messages( self: 'TelegramClient', entity: 'hints.EntityLike', - message_ids: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', + messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]', *, revoke: bool = True) -> 'typing.Sequence[_tl.messages.AffectedMessages]': - if not utils.is_list_like(message_ids): - message_ids = (message_ids,) - - message_ids = ( + messages = ( m.id if isinstance(m, ( _tl.Message, _tl.MessageService, _tl.MessageEmpty)) - else int(m) for m in message_ids + else int(m) for m in messages ) if entity: @@ -660,42 +653,36 @@ async def delete_messages( if ty == helpers._EntityType.CHANNEL: res = await self([_tl.fn.channels.DeleteMessages( - entity, list(c)) for c in utils.chunks(message_ids)]) + entity, list(c)) for c in utils.chunks(messages)]) else: res = await self([_tl.fn.messages.DeleteMessages( - list(c), revoke) for c in utils.chunks(message_ids)]) + list(c), revoke) for c in utils.chunks(messages)]) return sum(r.pts_count for r in res) async def mark_read( self: 'TelegramClient', entity: 'hints.EntityLike', - message: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]' = None, + message: 'hints.MessageIDLike' = None, *, - max_id: int = None, clear_mentions: bool = False) -> bool: - if max_id is None: - if not message: - max_id = 0 - else: - if utils.is_list_like(message): - max_id = max(msg.id for msg in message) - else: - max_id = message.id + if not message: + max_id = 0 + elif isinstance(message, int): + max_id = message + else: + max_id = message.id entity = await self.get_input_entity(entity) if clear_mentions: await self(_tl.fn.messages.ReadMentions(entity)) - if max_id is None: - return True - if max_id is not None: - if helpers._entity_type(entity) == helpers._EntityType.CHANNEL: - return await self(_tl.fn.channels.ReadHistory( - utils.get_input_channel(entity), max_id=max_id)) - else: - return await self(_tl.fn.messages.ReadHistory( - entity, max_id=max_id)) + if helpers._entity_type(entity) == helpers._EntityType.CHANNEL: + return await self(_tl.fn.channels.ReadHistory( + utils.get_input_channel(entity), max_id=max_id)) + else: + return await self(_tl.fn.messages.ReadHistory( + entity, max_id=max_id)) return False diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index c4b9467b..d5a2fab7 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -2254,7 +2254,7 @@ class TelegramClient: async def forward_messages( self: 'TelegramClient', entity: 'hints.EntityLike', - messages: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', + messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]', from_peer: 'hints.EntityLike' = None, *, background: bool = None, @@ -2276,8 +2276,8 @@ class TelegramClient: entity (`entity`): To which entity the message(s) will be forwarded. - messages (`list` | `int` | `Message `): - The message(s) to forward, or their integer IDs. + messages (`list`): + The messages to forward, or their integer IDs. from_peer (`entity`): If the given messages are integer IDs and not instances @@ -2465,7 +2465,7 @@ class TelegramClient: async def delete_messages( self: 'TelegramClient', entity: 'hints.EntityLike', - message_ids: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', + messages: 'typing.Union[typing.Sequence[hints.MessageIDLike]]', *, revoke: bool = True) -> 'typing.Sequence[_tl.messages.AffectedMessages]': """ @@ -2486,8 +2486,8 @@ class TelegramClient: be `None` for normal chats, but **must** be present for channels and megagroups. - message_ids (`list` | `int` | `Message `): - The IDs (or ID) or messages to be deleted. + messages (`list`): + The messages to delete, or their integer IDs. revoke (`bool`, optional): Whether the message should be deleted for everyone or not. @@ -2517,9 +2517,8 @@ class TelegramClient: async def mark_read( self: 'TelegramClient', entity: 'hints.EntityLike', - message: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]' = None, + message: 'hints.MessageIDLike' = None, *, - max_id: int = None, clear_mentions: bool = False) -> bool: """ Marks messages as read and optionally clears mentions. @@ -2527,8 +2526,8 @@ class TelegramClient: This effectively marks a message as read (or more than one) in the given conversation. - If neither message nor maximum ID are provided, all messages will be - marked as read by assuming that ``max_id = 0``. + If no message or maximum ID is provided, all messages will be + marked as read. If a message or maximum ID is provided, all the messages up to and including such ID will be marked as read (for all messages whose ID @@ -2540,8 +2539,9 @@ class TelegramClient: entity (`entity`): The chat where these messages are located. - message (`list` | `Message `): - Either a list of messages or a single message. + message (`Message `): + The last (most-recent) message which was read, or its ID. + This is only useful if you want to mark a chat as partially read. max_id (`int`): Until which message should the read acknowledge be sent for. From a62627534ec2a3581ae46ca604f3f3be0d24b797 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 16 Jan 2022 13:51:23 +0100 Subject: [PATCH 106/131] Get rid of client.loop Instead, use the asyncio-intended way of implicit loop. --- readthedocs/basic/quick-start.rst | 96 +++++++++---------- readthedocs/basic/signing-in.rst | 15 ++- readthedocs/concepts/full-api.rst | 2 +- readthedocs/concepts/sessions.rst | 4 +- readthedocs/misc/v2-migration-guide.rst | 2 + readthedocs/modules/client.rst | 6 +- telethon/_client/chats.py | 2 +- telethon/_client/telegrambaseclient.py | 25 +---- telethon/_client/telegramclient.py | 24 ----- telethon/_client/updates.py | 4 +- telethon/_events/album.py | 8 +- telethon/_events/callbackquery.py | 49 ++++++---- telethon/_events/inlinequery.py | 2 +- telethon/_network/connection.py | 8 +- telethon/_network/mtprotosender.py | 13 ++- telethon_examples/gui.py | 10 +- .../interactive_telegram_client.py | 30 +++--- telethon_examples/payment.py | 4 +- telethon_examples/quart_login.py | 13 +-- 19 files changed, 140 insertions(+), 177 deletions(-) diff --git a/readthedocs/basic/quick-start.rst b/readthedocs/basic/quick-start.rst index 8dbf928d..bd36b048 100644 --- a/readthedocs/basic/quick-start.rst +++ b/readthedocs/basic/quick-start.rst @@ -8,70 +8,70 @@ use these if possible. .. code-block:: python + import asyncio from telethon import TelegramClient # Remember to use your own values from my.telegram.org! api_id = 12345 api_hash = '0123456789abcdef0123456789abcdef' - client = TelegramClient('anon', api_id, api_hash) async def main(): - # Getting information about yourself - me = await client.get_me() + async with TelegramClient('anon', api_id, api_hash).start() as client: + # Getting information about yourself + me = await client.get_me() - # "me" is a user object. You can pretty-print - # any Telegram object with the "stringify" method: - print(me.stringify()) + # "me" is a user object. You can pretty-print + # any Telegram object with the "stringify" method: + print(me.stringify()) - # When you print something, you see a representation of it. - # You can access all attributes of Telegram objects with - # the dot operator. For example, to get the username: - username = me.username - print(username) - print(me.phone) + # When you print something, you see a representation of it. + # You can access all attributes of Telegram objects with + # the dot operator. For example, to get the username: + username = me.username + print(username) + print(me.phone) - # You can print all the dialogs/conversations that you are part of: - async for dialog in client.iter_dialogs(): - print(dialog.name, 'has ID', dialog.id) + # You can print all the dialogs/conversations that you are part of: + async for dialog in client.iter_dialogs(): + print(dialog.name, 'has ID', dialog.id) - # You can send messages to yourself... - await client.send_message('me', 'Hello, myself!') - # ...to some chat ID - await client.send_message(-100123456, 'Hello, group!') - # ...to your contacts - await client.send_message('+34600123123', 'Hello, friend!') - # ...or even to any username - await client.send_message('username', 'Testing Telethon!') + # You can send messages to yourself... + await client.send_message('me', 'Hello, myself!') + # ...to some chat ID + await client.send_message(-100123456, 'Hello, group!') + # ...to your contacts + await client.send_message('+34600123123', 'Hello, friend!') + # ...or even to any username + await client.send_message('username', 'Testing Telethon!') - # You can, of course, use markdown in your messages: - message = await client.send_message( - 'me', - 'This message has **bold**, `code`, __italics__ and ' - 'a [nice website](https://example.com)!', - link_preview=False - ) + # You can, of course, use markdown in your messages: + message = await client.send_message( + 'me', + 'This message has **bold**, `code`, __italics__ and ' + 'a [nice website](https://example.com)!', + link_preview=False + ) - # Sending a message returns the sent message object, which you can use - print(message.raw_text) + # Sending a message returns the sent message object, which you can use + print(message.raw_text) - # You can reply to messages directly if you have a message object - await message.reply('Cool!') + # You can reply to messages directly if you have a message object + await message.reply('Cool!') - # Or send files, songs, documents, albums... - await client.send_file('me', '/home/me/Pictures/holidays.jpg') + # Or send files, songs, documents, albums... + await client.send_file('me', '/home/me/Pictures/holidays.jpg') - # You can print the message history of any chat: - async for message in client.iter_messages('me'): - print(message.id, message.text) + # You can print the message history of any chat: + async for message in client.iter_messages('me'): + print(message.id, message.text) - # You can download media from messages, too! - # The method will return the path where the file was saved. - if message.photo: - path = await message.download_media() - print('File saved to', path) # printed after download is done + # You can download media from messages, too! + # The method will return the path where the file was saved. + if message.photo: + path = await message.download_media() + print('File saved to', path) # printed after download is done - with client: - client.loop.run_until_complete(main()) + asyncio.run(main()) Here, we show how to sign in, get information about yourself, send @@ -100,8 +100,8 @@ proceeding. We will see all the available methods later on. # Most of your code should go here. # You can of course make and use your own async def (do_something). # They only need to be async if they need to await things. - async with client: + async with client.start(): me = await client.get_me() await do_something(me) - client.loop.run_until_complete(main()) + asyncio.run(main()) diff --git a/readthedocs/basic/signing-in.rst b/readthedocs/basic/signing-in.rst index c7b60507..05183f5c 100644 --- a/readthedocs/basic/signing-in.rst +++ b/readthedocs/basic/signing-in.rst @@ -49,6 +49,7 @@ We can finally write some code to log into our account! .. code-block:: python + import asyncio from telethon import TelegramClient # Use your own values from my.telegram.org @@ -57,10 +58,10 @@ We can finally write some code to log into our account! async def main(): # The first parameter is the .session file name (absolute paths allowed) - async with TelegramClient('anon', api_id, api_hash) as client: + async with TelegramClient('anon', api_id, api_hash).start() as client: await client.send_message('me', 'Hello, myself!') - client.loop.run_until_complete(main()) + asyncio.run(main()) In the first line, we import the class name so we can create an instance @@ -98,21 +99,19 @@ You will still need an API ID and hash, but the process is very similar: .. code-block:: python + import asyncio from telethon import TelegramClient api_id = 12345 api_hash = '0123456789abcdef0123456789abcdef' bot_token = '12345:0123456789abcdef0123456789abcdef' - # We have to manually call "start" if we want an explicit bot token - bot = TelegramClient('bot', api_id, api_hash).start(bot_token=bot_token) - async def main(): # But then we can use the client instance as usual - async with bot: - ... + async with TelegramClient('bot', api_id, api_hash).start(bot_token=bot_token) as bot: + ... # bot is your client - client.loop.run_until_complete(main()) + asyncio.run(main()) To get a bot account, you need to talk diff --git a/readthedocs/concepts/full-api.rst b/readthedocs/concepts/full-api.rst index cce026f1..ac38e3d4 100644 --- a/readthedocs/concepts/full-api.rst +++ b/readthedocs/concepts/full-api.rst @@ -74,7 +74,7 @@ Or we call `client.get_input_entity() async def main(): peer = await client.get_input_entity('someone') - client.loop.run_until_complete(main()) + asyncio.run(main()) .. note:: diff --git a/readthedocs/concepts/sessions.rst b/readthedocs/concepts/sessions.rst index 8ba75938..028366b1 100644 --- a/readthedocs/concepts/sessions.rst +++ b/readthedocs/concepts/sessions.rst @@ -156,8 +156,8 @@ you can save it in a variable directly: .. code-block:: python string = '1aaNk8EX-YRfwoRsebUkugFvht6DUPi_Q25UOCzOAqzc...' - with TelegramClient(StringSession(string), api_id, api_hash) as client: - client.loop.run_until_complete(client.send_message('me', 'Hi')) + async with TelegramClient(StringSession(string), api_id, api_hash).start() as client: + await client.send_message('me', 'Hi') These strings are really convenient for using in places like Heroku since diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 10556cc0..33a515d8 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -759,3 +759,5 @@ they are meant to work on lists. also mark read only supports single now. a list would just be max anyway. removed max id since it's not really of much use. + +client loop has been removed. embrace implicit loop as asyncio does now diff --git a/readthedocs/modules/client.rst b/readthedocs/modules/client.rst index de5502c9..4fc076d8 100644 --- a/readthedocs/modules/client.rst +++ b/readthedocs/modules/client.rst @@ -20,10 +20,10 @@ Each mixin has its own methods, which you all can use. async def main(): # Now you can use all client methods listed below, like for example... - await client.send_message('me', 'Hello to myself!') + async with client.start(): + await client.send_message('me', 'Hello to myself!') - with client: - client.loop.run_until_complete(main()) + asyncio.run(main()) You **don't** need to import these `AuthMethods`, `MessageMethods`, etc. diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index f900ce6d..0759acc2 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -40,7 +40,7 @@ class _ChatAction: self._chat, self._action) self._running = True - self._task = self._client.loop.create_task(self._update()) + self._task = asyncio.create_task(self._update()) return self async def __aexit__(self, *args): diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 10cd2d7f..aeecedd6 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -88,7 +88,6 @@ def init( app_version: str = None, lang_code: str = 'en', system_lang_code: str = 'en', - loop: asyncio.AbstractEventLoop = None, base_logger: typing.Union[str, logging.Logger] = None, receive_updates: bool = True ): @@ -153,24 +152,6 @@ def init( self.api_id = int(api_id) self.api_hash = api_hash - # Current proxy implementation requires `sock_connect`, and some - # event loops lack this method. If the current loop is missing it, - # bail out early and suggest an alternative. - # - # TODO A better fix is obviously avoiding the use of `sock_connect` - # - # See https://github.com/LonamiWebs/Telethon/issues/1337 for details. - if not callable(getattr(self.loop, 'sock_connect', None)): - raise TypeError( - 'Event loop of type {} lacks `sock_connect`, which is needed to use proxies.\n\n' - 'Change the event loop in use to use proxies:\n' - '# https://github.com/LonamiWebs/Telethon/issues/1337\n' - 'import asyncio\n' - 'asyncio.set_event_loop(asyncio.SelectorEventLoop())'.format( - self.loop.__class__.__name__ - ) - ) - if local_addr is not None: if use_ipv6 is False and ':' in local_addr: raise TypeError( @@ -283,10 +264,6 @@ def init( # A place to store if channels are a megagroup or not (see `edit_admin`) self._megagroup_cache = {} - -def get_loop(self: 'TelegramClient') -> asyncio.AbstractEventLoop: - return asyncio.get_event_loop() - def get_flood_sleep_threshold(self): return self._flood_sleep_threshold @@ -382,7 +359,7 @@ async def connect(self: 'TelegramClient') -> None: await self.session.save() - self._updates_handle = self.loop.create_task(self._update_loop()) + self._updates_handle = asyncio.create_task(self._update_loop()) def is_connected(self: 'TelegramClient') -> bool: sender = getattr(self, '_sender', None) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index d5a2fab7..c87f6e84 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -148,10 +148,6 @@ class TelegramClient: "System lang code" to be sent when creating the initial connection. Defaults to `lang_code`. - loop (`asyncio.AbstractEventLoop`, optional): - Asyncio event loop to use. Defaults to `asyncio.get_event_loop()`. - This argument is ignored. - base_logger (`str` | `logging.Logger`, optional): Base logger name or instance to use. If a `str` is given, it'll be passed to `logging.getLogger()`. If a @@ -2666,31 +2662,11 @@ class TelegramClient: app_version: str = None, lang_code: str = 'en', system_lang_code: str = 'en', - loop: asyncio.AbstractEventLoop = None, base_logger: typing.Union[str, logging.Logger] = None, receive_updates: bool = True ): telegrambaseclient.init(**locals()) - @property - def loop(self: 'TelegramClient') -> asyncio.AbstractEventLoop: - """ - Property with the ``asyncio`` event loop used by this client. - - Example - .. code-block:: python - - # Download media in the background - task = client.loop.create_task(message.download_media()) - - # Do some work - ... - - # Join the task (wait for it to complete) - await task - """ - return telegrambaseclient.get_loop(**locals()) - @property def flood_sleep_threshold(self): return telegrambaseclient.get_flood_sleep_threshold(**locals()) diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index 5f30fbd8..d1178304 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -159,14 +159,14 @@ def _process_update(self: 'TelegramClient', update, entities, others): channel_id = self._state_cache.get_channel_id(update) args = (update, entities, others, channel_id, self._state_cache[channel_id]) if self._dispatching_updates_queue is None: - task = self.loop.create_task(_dispatch_update(self, *args)) + task = asyncio.create_task(_dispatch_update(self, *args)) self._updates_queue.add(task) task.add_done_callback(lambda _: self._updates_queue.discard(task)) else: self._updates_queue.put_nowait(args) if not self._dispatching_updates_queue.is_set(): self._dispatching_updates_queue.set() - self.loop.create_task(_dispatch_queue_updates(self)) + asyncio.create_task(_dispatch_queue_updates(self)) self._state_cache.update(update) diff --git a/telethon/_events/album.py b/telethon/_events/album.py index 19ea4234..f290519d 100644 --- a/telethon/_events/album.py +++ b/telethon/_events/album.py @@ -37,15 +37,15 @@ class AlbumHack: # very short-lived but might as well try to do "the right thing". self._client = weakref.ref(client) self._event = event # parent event - self._due = client.loop.time() + _HACK_DELAY + self._due = asyncio.get_running_loop().time() + _HACK_DELAY - client.loop.create_task(self.deliver_event()) + asyncio.create_task(self.deliver_event()) def extend(self, messages): client = self._client() if client: # weakref may be dead self._event.messages.extend(messages) - self._due = client.loop.time() + _HACK_DELAY + self._due = asyncio.get_running_loop().time() + _HACK_DELAY async def deliver_event(self): while True: @@ -53,7 +53,7 @@ class AlbumHack: if client is None: return # weakref is dead, nothing to deliver - diff = self._due - client.loop.time() + diff = self._due - asyncio.get_running_loop().time() if diff <= 0: # We've hit our due time, deliver event. It won't respect # sequential updates but fixing that would just worsen this. diff --git a/telethon/_events/callbackquery.py b/telethon/_events/callbackquery.py index 15f8d12f..dd4f54aa 100644 --- a/telethon/_events/callbackquery.py +++ b/telethon/_events/callbackquery.py @@ -1,5 +1,7 @@ import re import struct +import asyncio +import functools from .common import EventBuilder, EventCommon, name_inner_event from .._misc import utils @@ -7,6 +9,20 @@ from .. import _tl from ..types import _custom +def auto_answer(func): + @functools.wraps(func) + async def wrapped(self, *args, **kwargs): + if self._answered: + return await func(*args, **kwargs) + else: + return (await asyncio.gather( + self._answer(), + func(*args, **kwargs), + ))[1] + + return wrapped + + @name_inner_event class CallbackQuery(EventBuilder): """ @@ -240,16 +256,15 @@ class CallbackQuery(EventBuilder): if self._answered: return + res = await self._client(_tl.fn.messages.SetBotCallbackAnswer( + query_id=self.query.query_id, + cache_time=cache_time, + alert=alert, + message=message, + url=url, + )) self._answered = True - return await self._client( - _tl.fn.messages.SetBotCallbackAnswer( - query_id=self.query.query_id, - cache_time=cache_time, - alert=alert, - message=message, - url=url - ) - ) + return res @property def via_inline(self): @@ -266,35 +281,36 @@ class CallbackQuery(EventBuilder): """ return isinstance(self.query, _tl.UpdateInlineBotCallbackQuery) + @auto_answer async def respond(self, *args, **kwargs): """ Responds to the message (not as a reply). Shorthand for `telethon.client.messages.MessageMethods.send_message` with ``entity`` already set. - This method also creates a task to `answer` the callback. + This method will also `answer` the callback if necessary. This method will likely fail if `via_inline` is `True`. """ - self._client.loop.create_task(self.answer()) return await self._client.send_message( await self.get_input_chat(), *args, **kwargs) + @auto_answer async def reply(self, *args, **kwargs): """ Replies to the message (as a reply). Shorthand for `telethon.client.messages.MessageMethods.send_message` with both ``entity`` and ``reply_to`` already set. - This method also creates a task to `answer` the callback. + This method will also `answer` the callback if necessary. This method will likely fail if `via_inline` is `True`. """ - self._client.loop.create_task(self.answer()) kwargs['reply_to'] = self.query.msg_id return await self._client.send_message( await self.get_input_chat(), *args, **kwargs) + @auto_answer async def edit(self, *args, **kwargs): """ Edits the message. Shorthand for @@ -303,7 +319,7 @@ class CallbackQuery(EventBuilder): Returns `True` if the edit was successful. - This method also creates a task to `answer` the callback. + This method will also `answer` the callback if necessary. .. note:: @@ -311,7 +327,6 @@ class CallbackQuery(EventBuilder): `Message.edit `, since the message object is normally not present. """ - self._client.loop.create_task(self.answer()) if isinstance(self.query.msg_id, _tl.InputBotInlineMessageID): return await self._client.edit_message( None, self.query.msg_id, *args, **kwargs @@ -322,6 +337,7 @@ class CallbackQuery(EventBuilder): *args, **kwargs ) + @auto_answer async def delete(self, *args, **kwargs): """ Deletes the message. Shorthand for @@ -332,11 +348,10 @@ class CallbackQuery(EventBuilder): this `delete` method. Use a `telethon.client.telegramclient.TelegramClient` instance directly. - This method also creates a task to `answer` the callback. + This method will also `answer` the callback if necessary. This method will likely fail if `via_inline` is `True`. """ - self._client.loop.create_task(self.answer()) return await self._client.delete_messages( await self.get_input_chat(), [self.query.msg_id], *args, **kwargs diff --git a/telethon/_events/inlinequery.py b/telethon/_events/inlinequery.py index d3cd6822..2c718e11 100644 --- a/telethon/_events/inlinequery.py +++ b/telethon/_events/inlinequery.py @@ -242,6 +242,6 @@ class InlineQuery(EventBuilder): if inspect.isawaitable(obj): return asyncio.ensure_future(obj) - f = asyncio.get_event_loop().create_future() + f = asyncio.get_running_loop().create_future() f.set_result(obj) return f diff --git a/telethon/_network/connection.py b/telethon/_network/connection.py index 26674aa2..e256b89a 100644 --- a/telethon/_network/connection.py +++ b/telethon/_network/connection.py @@ -23,13 +23,15 @@ class Connection: """ Establishes a connection with the server. """ - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setblocking(False) if self._local_addr: sock.bind(self._local_addr) + # TODO https://github.com/LonamiWebs/Telethon/issues/1337 may be an issue again + # perhaps we just need to ignore async connect on windows and block? await asyncio.wait_for(loop.sock_connect(sock, (self._ip, self._port)), timeout) self._sock = sock @@ -41,14 +43,14 @@ class Connection: if not self._sock: raise ConnectionError('not connected') - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() await loop.sock_sendall(self._sock, self._transport.pack(data)) async def recv(self): if not self._sock: raise ConnectionError('not connected') - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() while True: try: length, body = self._transport.unpack(self._in_buffer) diff --git a/telethon/_network/mtprotosender.py b/telethon/_network/mtprotosender.py index 29371bf6..42dd69a0 100644 --- a/telethon/_network/mtprotosender.py +++ b/telethon/_network/mtprotosender.py @@ -58,7 +58,7 @@ class MTProtoSender: # pending futures should be cancelled. self._user_connected = False self._reconnecting = False - self._disconnected = asyncio.get_event_loop().create_future() + self._disconnected = asyncio.get_running_loop().create_future() self._disconnected.set_result(None) # We need to join the loops upon disconnection @@ -248,18 +248,17 @@ class MTProtoSender: await self._disconnect(error=e) raise e - loop = asyncio.get_event_loop() self._log.debug('Starting send loop') - self._send_loop_handle = loop.create_task(self._send_loop()) + self._send_loop_handle = asyncio.create_task(self._send_loop()) self._log.debug('Starting receive loop') - self._recv_loop_handle = loop.create_task(self._recv_loop()) + self._recv_loop_handle = asyncio.create_task(self._recv_loop()) # _disconnected only completes after manual disconnection # or errors after which the sender cannot continue such # as failing to reconnect or any unexpected error. if self._disconnected.done(): - self._disconnected = loop.create_future() + self._disconnected = asyncio.get_running_loop().create_future() self._log.info('Connection to %s complete!', self._connection) @@ -381,7 +380,7 @@ class MTProtoSender: self._pending_state.clear() if self._auto_reconnect_callback: - asyncio.get_event_loop().create_task(self._auto_reconnect_callback()) + asyncio.create_task(self._auto_reconnect_callback()) break else: @@ -406,7 +405,7 @@ class MTProtoSender: # gets stuck. # TODO It still gets stuck? Investigate where and why. self._reconnecting = True - asyncio.get_event_loop().create_task(self._reconnect(error)) + asyncio.create_task(self._reconnect(error)) def _keepalive_ping(self, rnd_id): """ diff --git a/telethon_examples/gui.py b/telethon_examples/gui.py index bd241f60..61027d52 100644 --- a/telethon_examples/gui.py +++ b/telethon_examples/gui.py @@ -132,7 +132,7 @@ class App(tkinter.Tk): command=self.send_message).grid(row=3, column=2) # Post-init (async, connect client) - self.cl.loop.create_task(self.post_init()) + asyncio.create_task(self.post_init()) async def post_init(self): """ @@ -369,10 +369,4 @@ async def main(interval=0.05): if __name__ == "__main__": - # Some boilerplate code to set up the main method - aio_loop = asyncio.get_event_loop() - try: - aio_loop.run_until_complete(main()) - finally: - if not aio_loop.is_closed(): - aio_loop.close() + asyncio.run(main()) diff --git a/telethon_examples/interactive_telegram_client.py b/telethon_examples/interactive_telegram_client.py index 88f491de..10ca71a1 100644 --- a/telethon_examples/interactive_telegram_client.py +++ b/telethon_examples/interactive_telegram_client.py @@ -9,9 +9,6 @@ from telethon.errors import SessionPasswordNeededError from telethon.network import ConnectionTcpAbridged from telethon.utils import get_display_name -# Create a global variable to hold the loop we will be using -loop = asyncio.get_event_loop() - def sprint(string, *args, **kwargs): """Safe Print (handle UnicodeEncodeErrors on some terminals)""" @@ -50,7 +47,7 @@ async def async_input(prompt): let the loop run while we wait for input. """ print(prompt, end='', flush=True) - return (await loop.run_in_executor(None, sys.stdin.readline)).rstrip() + return (await asyncio.get_running_loop().run_in_executor(None, sys.stdin.readline)).rstrip() def get_env(name, message, cast=str): @@ -109,34 +106,34 @@ class InteractiveTelegramClient(TelegramClient): # media known the message ID, for every message having media. self.found_media = {} + async def init(self): # Calling .connect() may raise a connection error False, so you need # to except those before continuing. Otherwise you may want to retry # as done here. print('Connecting to Telegram servers...') try: - loop.run_until_complete(self.connect()) + await self.connect() except IOError: # We handle IOError and not ConnectionError because # PySocks' errors do not subclass ConnectionError # (so this will work with and without proxies). print('Initial connection failed. Retrying...') - loop.run_until_complete(self.connect()) + await self.connect() # If the user hasn't called .sign_in() or .sign_up() yet, they won't # be authorized. The first thing you must do is authorize. Calling # .sign_in() should only be done once as the information is saved on # the *.session file so you don't need to enter the code every time. - if not loop.run_until_complete(self.is_user_authorized()): + if not await self.is_user_authorized(): print('First run. Sending code request...') user_phone = input('Enter your phone: ') - loop.run_until_complete(self.sign_in(user_phone)) + await self.sign_in(user_phone) self_user = None while self_user is None: code = input('Enter the code you just received: ') try: - self_user =\ - loop.run_until_complete(self.sign_in(code=code)) + self_user = await self.sign_in(code=code) # Two-step verification may be enabled, and .sign_in will # raise this error. If that's the case ask for the password. @@ -146,8 +143,7 @@ class InteractiveTelegramClient(TelegramClient): pw = getpass('Two step verification is enabled. ' 'Please enter your password: ') - self_user =\ - loop.run_until_complete(self.sign_in(password=pw)) + self_user = await self.sign_in(password=pw) async def run(self): """Main loop of the TelegramClient, will wait for user action""" @@ -397,9 +393,13 @@ class InteractiveTelegramClient(TelegramClient): )) -if __name__ == '__main__': +async def main(): SESSION = os.environ.get('TG_SESSION', 'interactive') API_ID = get_env('TG_API_ID', 'Enter your API ID: ', int) API_HASH = get_env('TG_API_HASH', 'Enter your API hash: ') - client = InteractiveTelegramClient(SESSION, API_ID, API_HASH) - loop.run_until_complete(client.run()) + client = await InteractiveTelegramClient(SESSION, API_ID, API_HASH).init() + await client.run() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/telethon_examples/payment.py b/telethon_examples/payment.py index 0e9f9733..3eab88f3 100644 --- a/telethon_examples/payment.py +++ b/telethon_examples/payment.py @@ -7,8 +7,6 @@ import os import time import sys -loop = asyncio.get_event_loop() - """ Provider token can be obtained via @BotFather. more info at https://core.telegram.org/bots/payments#getting-a-token @@ -180,4 +178,4 @@ if __name__ == '__main__': if not provider_token: logger.error("No provider token supplied.") exit(1) - loop.run_until_complete(main()) + asyncio.run(main()) diff --git a/telethon_examples/quart_login.py b/telethon_examples/quart_login.py index 98fb35de..20eae383 100644 --- a/telethon_examples/quart_login.py +++ b/telethon_examples/quart_login.py @@ -134,12 +134,13 @@ async def main(): # By default, `Quart.run` uses `asyncio.run()`, which creates a new asyncio -# event loop. If we create the `TelegramClient` before, `telethon` will -# use `asyncio.get_event_loop()`, which is the implicit loop in the main -# thread. These two loops are different, and it won't work. +# event loop. Instead, we use `asyncio.run()` manually in order to make this +# explicit, as the client cannot be "transferred" between loops while +# connected due to the need to schedule work within an event loop. # -# So, we have to manually pass the same `loop` to both applications to -# make 100% sure it works and to avoid headaches. +# In essence one needs to be careful to avoid mixing event loops, but this is +# simple, as `asyncio.run` is generally only used in the entry-point of the +# program. # # To run Quart inside `async def`, we must use `hypercorn.asyncio.serve()` # directly. @@ -149,4 +150,4 @@ async def main(): # won't have to worry about any of this, but it's still good to be # explicit about the event loop. if __name__ == '__main__': - client.loop.run_until_complete(main()) + asyncio.run(main()) From 4f4c7040d17e51c9b925e460376e8ad0088cbb99 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 16 Jan 2022 13:59:43 +0100 Subject: [PATCH 107/131] Stop using futures as one-shot channels Instead, use a single-item queue. This is asyncio.run-friendly, even when the client is initialized outside of async def. --- telethon/_client/updates.py | 2 +- telethon/_network/mtprotosender.py | 29 ++++++++++++----------------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index d1178304..a0041310 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -30,7 +30,7 @@ async def set_receive_updates(self: 'TelegramClient', receive_updates): async def run_until_disconnected(self: 'TelegramClient'): # Make a high-level request to notify that we want updates await self(_tl.fn.updates.GetState()) - return await self._sender.disconnected + await self._sender.wait_disconnected() def on(self: 'TelegramClient', event: EventBuilder): def decorator(f): diff --git a/telethon/_network/mtprotosender.py b/telethon/_network/mtprotosender.py index 42dd69a0..92438502 100644 --- a/telethon/_network/mtprotosender.py +++ b/telethon/_network/mtprotosender.py @@ -58,8 +58,8 @@ class MTProtoSender: # pending futures should be cancelled. self._user_connected = False self._reconnecting = False - self._disconnected = asyncio.get_running_loop().create_future() - self._disconnected.set_result(None) + self._disconnected = asyncio.Queue(1) + self._disconnected.put_nowait(None) # We need to join the loops upon disconnection self._send_loop_handle = None @@ -191,16 +191,14 @@ class MTProtoSender: self._send_queue.extend(states) return futures - @property - def disconnected(self): + async def wait_disconnected(self): """ - Future that resolves when the connection to Telegram - ends, either by user action or in the background. - - Note that it may resolve in either a ``ConnectionError`` - or any other unexpected error that could not be handled. + Wait until the client is disconnected. + Raise if the disconnection finished with error. """ - return asyncio.shield(self._disconnected) + res = await self._disconnected.get() + if isinstance(res, BaseException): + raise res # Private methods @@ -257,8 +255,8 @@ class MTProtoSender: # _disconnected only completes after manual disconnection # or errors after which the sender cannot continue such # as failing to reconnect or any unexpected error. - if self._disconnected.done(): - self._disconnected = asyncio.get_running_loop().create_future() + while not self._disconnected.empty(): + self._disconnected.get_nowait() self._log.info('Connection to %s complete!', self._connection) @@ -316,11 +314,8 @@ class MTProtoSender: self._log.info('Disconnection from %s complete!', self._connection) self._connection = None - if self._disconnected and not self._disconnected.done(): - if error: - self._disconnected.set_exception(error) - else: - self._disconnected.set_result(None) + if not self._disconnected.full(): + self._disconnected.put_nowait(error) async def _reconnect(self, last_error): """ From 1f1f67b0a666b4fbb1e49f9e1acd7e5da7cd8d6e Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 16 Jan 2022 14:04:26 +0100 Subject: [PATCH 108/131] Remove unused _CacheType --- telethon/_client/uploads.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py index 5cfd43e4..92a2d36e 100644 --- a/telethon/_client/uploads.py +++ b/telethon/_client/uploads.py @@ -24,18 +24,6 @@ if typing.TYPE_CHECKING: from .telegramclient import TelegramClient -class _CacheType: - """Like functools.partial but pretends to be the wrapped class.""" - def __init__(self, cls): - self._cls = cls - - def __call__(self, *args, **kwargs): - return self._cls(*args, file_reference=b'', **kwargs) - - def __eq__(self, other): - return self._cls == other - - def _resize_photo_if_needed( file, is_image, width=1280, height=1280, background=(255, 255, 255)): From 3f68510393112699aea7c7c46b608cbbcfe33701 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 17 Jan 2022 11:41:08 +0100 Subject: [PATCH 109/131] Fix EntityCache not reading the new EntityType --- telethon/_misc/entitycache.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/telethon/_misc/entitycache.py b/telethon/_misc/entitycache.py index 87bde9fa..2b7b8af1 100644 --- a/telethon/_misc/entitycache.py +++ b/telethon/_misc/entitycache.py @@ -3,7 +3,7 @@ import itertools from .._misc import utils from .. import _tl -from .._sessions.types import Entity +from .._sessions.types import EntityType, Entity # Which updates have the following fields? _has_field = { @@ -53,18 +53,18 @@ class EntityCache: In-memory input entity cache, defaultdict-like behaviour. """ def add(self, entities, _mappings={ - _tl.User.CONSTRUCTOR_ID: lambda e: (Entity.BOT if e.bot else Entity.USER, e.id, e.access_hash), - _tl.UserFull.CONSTRUCTOR_ID: lambda e: (Entity.BOT if e.user.bot else Entity.USER, e.user.id, e.user.access_hash), - _tl.Chat.CONSTRUCTOR_ID: lambda e: (Entity.GROUP, e.id, 0), - _tl.ChatFull.CONSTRUCTOR_ID: lambda e: (Entity.GROUP, e.id, 0), - _tl.ChatEmpty.CONSTRUCTOR_ID: lambda e: (Entity.GROUP, e.id, 0), - _tl.ChatForbidden.CONSTRUCTOR_ID: lambda e: (Entity.GROUP, e.id, 0), + _tl.User.CONSTRUCTOR_ID: lambda e: (EntityType.BOT if e.bot else EntityType.USER, e.id, e.access_hash), + _tl.UserFull.CONSTRUCTOR_ID: lambda e: (EntityType.BOT if e.user.bot else EntityType.USER, e.user.id, e.user.access_hash), + _tl.Chat.CONSTRUCTOR_ID: lambda e: (EntityType.GROUP, e.id, 0), + _tl.ChatFull.CONSTRUCTOR_ID: lambda e: (EntityType.GROUP, e.id, 0), + _tl.ChatEmpty.CONSTRUCTOR_ID: lambda e: (EntityType.GROUP, e.id, 0), + _tl.ChatForbidden.CONSTRUCTOR_ID: lambda e: (EntityType.GROUP, e.id, 0), _tl.Channel.CONSTRUCTOR_ID: lambda e: ( - Entity.MEGAGROUP if e.megagroup else (Entity.GIGAGROUP if e.gigagroup else Entity.CHANNEL), + EntityType.MEGAGROUP if e.megagroup else (EntityType.GIGAGROUP if e.gigagroup else EntityType.CHANNEL), e.id, e.access_hash, ), - _tl.ChannelForbidden.CONSTRUCTOR_ID: lambda e: (Entity.MEGAGROUP if e.megagroup else Entity.CHANNEL, e.id, e.access_hash), + _tl.ChannelForbidden.CONSTRUCTOR_ID: lambda e: (EntityType.MEGAGROUP if e.megagroup else EntityType.CHANNEL, e.id, e.access_hash), }): """ Adds the given entities to the cache, if they weren't saved before. @@ -98,11 +98,11 @@ class EntityCache: if not getattr(e, 'min', False) and (access_hash or ty == Entity.GROUP): rows.append(Entity(ty, id, access_hash)) if id not in self.__dict__: - if ty in (Entity.USER, Entity.BOT): + if ty in (EntityType.USER, EntityType.BOT): self.__dict__[id] = _tl.InputPeerUser(id, access_hash) - elif ty in (Entity.GROUP,): + elif ty in (EntityType.GROUP,): self.__dict__[id] = _tl.InputPeerChat(id) - elif ty in (Entity.CHANNEL, Entity.MEGAGROUP, Entity.GIGAGROUP): + elif ty in (EntityType.CHANNEL, EntityType.MEGAGROUP, EntityType.GIGAGROUP): self.__dict__[id] = _tl.InputPeerChannel(id, access_hash) return rows From 85a9c13129e45edd72a6de70b542694233b2423f Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 17 Jan 2022 11:50:28 +0100 Subject: [PATCH 110/131] Fix login info did not persist --- telethon/_client/telegrambaseclient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index aeecedd6..bdf8d539 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -320,7 +320,7 @@ async def connect(self: 'TelegramClient') -> None: return if self._sender.auth_key.key != dc.auth: - dc = dataclasses.replace(dc, auth=self._sender.auth_key.key) + self._all_dcs[dc.id] = dc = dataclasses.replace(dc, auth=self._sender.auth_key.key) # Need to send invokeWithLayer for things to work out. # Make the most out of this opportunity by also refreshing our state. From f8264abb5a14926b52c21dfd6803c9f5e2a882b6 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 18 Jan 2022 12:52:22 +0100 Subject: [PATCH 111/131] Clean-up client's __init__ and remove entity cache Entity cache uses are removed. It was a source of ever-growing memory usage that has to be reworked. This affects everything that tried to obtain an input entity, input sender or input chat (such as the SenderGetter or calls to _get_entity_pair). Input entities need to be reworked in any case. Its removal also affects the automatic cache of any raw API request. Raise last error parameter is removed, and its behaviour made default. The connection type parameter has been removed, since users really have no need to change it. A few more attributes have been made private, since users should not mess with those. --- readthedocs/misc/v2-migration-guide.rst | 10 ++ telethon/_client/auth.py | 11 +- telethon/_client/telegrambaseclient.py | 161 ++++++++---------------- telethon/_client/telegramclient.py | 23 ++-- telethon/_client/updates.py | 29 +---- telethon/_client/users.py | 36 ++---- telethon/_events/album.py | 3 +- telethon/_events/callbackquery.py | 14 +-- telethon/_events/chataction.py | 9 +- telethon/_events/common.py | 3 +- telethon/_events/inlinequery.py | 3 +- telethon/_events/userupdate.py | 3 +- telethon/_misc/utils.py | 12 +- telethon/types/_custom/chatgetter.py | 6 - telethon/types/_custom/draft.py | 6 - telethon/types/_custom/forward.py | 6 +- telethon/types/_custom/message.py | 16 +-- telethon/types/_custom/sendergetter.py | 6 - 18 files changed, 104 insertions(+), 253 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 33a515d8..669ed8a8 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -761,3 +761,13 @@ also mark read only supports single now. a list would just be max anyway. removed max id since it's not really of much use. client loop has been removed. embrace implicit loop as asyncio does now + +renamed some client params, and made other privates + timeout -> connect_timeout + connection_retries -> connect_retries + retry_delay -> connect_retry_delay + +sequential_updates is gone +connection type is gone + +raise_last_call_error is now the default rather than ValueError diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index 90bb5ae5..69b5df7e 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -241,7 +241,7 @@ async def sign_in( elif bot_token: request = _tl.fn.auth.ImportBotAuthorization( flags=0, bot_auth_token=bot_token, - api_id=self.api_id, api_hash=self.api_hash + api_id=self._api_id, api_hash=self._api_hash ) else: raise ValueError('You must provide either phone and code, password, or bot_token.') @@ -313,8 +313,6 @@ async def _update_session_state(self, user, save=True): Callback called whenever the login or sign up process completes. Returns the input user parameter. """ - self._authorized = True - state = await self(_tl.fn.updates.GetState()) await _replace_session_state( self, @@ -332,11 +330,11 @@ async def _update_session_state(self, user, save=True): async def _replace_session_state(self, *, save=True, **changes): new = dataclasses.replace(self._session_state, **changes) - await self.session.set_state(new) + await self._session.set_state(new) self._session_state = new if save: - await self.session.save() + await self._session.save() async def send_code_request( @@ -354,7 +352,7 @@ async def send_code_request( else: try: result = await self(_tl.fn.auth.SendCode( - phone, self.api_id, self.api_hash, _tl.CodeSettings())) + phone, self._api_id, self._api_hash, _tl.CodeSettings())) except errors.AuthRestartError: return await self.send_code_request(phone) @@ -377,7 +375,6 @@ async def log_out(self: 'TelegramClient') -> bool: except errors.RPCError: return False - self._authorized = False self._state_cache.reset() await self.disconnect() diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index bdf8d539..9ed5cd71 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -11,7 +11,7 @@ import dataclasses from .. import version, __name__ as __base_name__, _tl from .._crypto import rsa -from .._misc import markdown, entitycache, statecache, enums, helpers +from .._misc import markdown, statecache, enums, helpers from .._network import MTProtoSender, Connection, transports from .._sessions import Session, SQLiteSession, MemorySession from .._sessions.types import DataCenter, SessionState @@ -71,33 +71,28 @@ def init( api_id: int, api_hash: str, *, - connection: 'typing.Type[Connection]' = (), + # Logging. + base_logger: typing.Union[str, logging.Logger] = None, + # Connection parameters. use_ipv6: bool = False, proxy: typing.Union[tuple, dict] = None, local_addr: typing.Union[str, tuple] = None, - timeout: int = 10, - request_retries: int = 5, - connection_retries: int = 5, - retry_delay: int = 1, - auto_reconnect: bool = True, - sequential_updates: bool = False, - flood_sleep_threshold: int = 60, - raise_last_call_error: bool = False, device_model: str = None, system_version: str = None, app_version: str = None, lang_code: str = 'en', system_lang_code: str = 'en', - base_logger: typing.Union[str, logging.Logger] = None, - receive_updates: bool = True + # Nice-to-have. + auto_reconnect: bool = True, + connect_timeout: int = 10, + connect_retries: int = 4, + connect_retry_delay: int = 1, + request_retries: int = 4, + flood_sleep_threshold: int = 60, + # Update handling. + receive_updates: bool = True, ): - 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 - + # Logging. if isinstance(base_logger, str): base_logger = logging.getLogger(base_logger) elif not isinstance(base_logger, logging.Logger): @@ -112,7 +107,7 @@ def init( self._log = _Loggers() - # Determine what session object we have + # Sessions. if isinstance(session, str) or session is None: try: session = SQLiteSession(session) @@ -131,57 +126,38 @@ def init( 'The given session must be a str or a Session instance.' ) - self.flood_sleep_threshold = flood_sleep_threshold - - # TODO Use AsyncClassWrapper(session) - # ChatGetter and SenderGetter can use the in-memory _entity_cache - # to avoid network access and the need for await in session files. - # - # The session files only wants the entities to persist - # them to disk, and to save additional useful information. - # TODO Session should probably return all cached - # info of entities, not just the input versions - self.session = session - - # Cache session data for convenient access + self._session = session + # In-memory copy of the session's state to avoid a roundtrip as it contains commonly-accessed values. self._session_state = None - self._all_dcs = None - self._state_cache = statecache.StateCache(None, self._log) - self._entity_cache = entitycache.EntityCache() - self.api_id = int(api_id) - self.api_hash = api_hash + # Nice-to-have. + self._request_retries = request_retries + self._connect_retries = connect_retries + self._connect_retry_delay = connect_retry_delay or 0 + self._connect_timeout = connect_timeout + self.flood_sleep_threshold = flood_sleep_threshold + self._flood_waited_requests = {} # prevent calls that would floodwait entirely + self._parse_mode = markdown + + # Connection parameters. + 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.") if local_addr is not None: if use_ipv6 is False and ':' in local_addr: - raise TypeError( - 'A local IPv6 address must only be used with `use_ipv6=True`.' - ) + raise TypeError('A local IPv6 address must only be used with `use_ipv6=True`.') elif use_ipv6 is True and ':' not in local_addr: - raise TypeError( - '`use_ipv6=True` must only be used with a local IPv6 address.' - ) + raise TypeError('`use_ipv6=True` must only be used with a local IPv6 address.') - self._raise_last_call_error = raise_last_call_error - - self._request_retries = request_retries - self._connection_retries = connection_retries - self._retry_delay = retry_delay or 0 - self._proxy = proxy + self._transport = transports.Full() + self._use_ipv6 = use_ipv6 self._local_addr = local_addr - self._timeout = timeout + self._proxy = proxy self._auto_reconnect = auto_reconnect - - if connection == (): - # For now the current default remains TCP Full; may change to be "smart" if proxies are specified - connection = enums.ConnectionMode.FULL - - self._transport = { - enums.ConnectionMode.FULL: transports.Full(), - enums.ConnectionMode.INTERMEDIATE: transports.Intermediate(), - enums.ConnectionMode.ABRIDGED: transports.Abridged(), - }[enums.ConnectionMode(connection)] - init_proxy = None + self._api_id = int(api_id) + self._api_hash = api_hash # Used on connection. Capture the variables in a lambda since # exporting clients need to create this InvokeWithLayer. @@ -196,7 +172,7 @@ def init( default_system_version = re.sub(r'-.+','',system.release) self._init_request = _tl.fn.InitConnection( - api_id=self.api_id, + api_id=self._api_id, device_model=device_model or default_device_model or 'Unknown', system_version=system_version or default_system_version or '1.0', app_version=app_version or self.__version__, @@ -204,65 +180,26 @@ def init( system_lang_code=system_lang_code, lang_pack='', # "langPacks are for official apps only" query=None, - proxy=init_proxy + proxy=None ) self._sender = MTProtoSender( loggers=self._log, - retries=self._connection_retries, - delay=self._retry_delay, + retries=self._connect_retries, + delay=self._connect_retry_delay, auto_reconnect=self._auto_reconnect, - connect_timeout=self._timeout, + connect_timeout=self._connect_timeout, update_callback=self._handle_update, auto_reconnect_callback=self._handle_auto_reconnect ) - # Remember flood-waited requests to avoid making them again - self._flood_waited_requests = {} - - # Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders + # Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders. self._borrowed_senders = {} self._borrow_sender_lock = asyncio.Lock() - self._updates_handle = None - self._last_request = time.time() - self._channel_pts = {} + # Update handling. self._no_updates = not receive_updates - if sequential_updates: - self._updates_queue = asyncio.Queue() - self._dispatching_updates_queue = asyncio.Event() - else: - # Use a set of pending instead of a queue so we can properly - # terminate all pending updates on disconnect. - self._updates_queue = set() - self._dispatching_updates_queue = None - - self._authorized = None # None = unknown, False = no, True = yes - - # Some further state for subclasses - self._event_builders = [] - - # Hack to workaround the fact Telegram may send album updates as - # different Updates when being sent from a different data center. - # {grouped_id: AlbumHack} - # - # FIXME: We don't bother cleaning this up because it's not really - # worth it, albums are pretty rare and this only holds them - # for a second at most. - self._albums = {} - - # 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 - - # A place to store if channels are a megagroup or not (see `edit_admin`) - self._megagroup_cache = {} def get_flood_sleep_threshold(self): return self._flood_sleep_threshold @@ -273,8 +210,8 @@ def set_flood_sleep_threshold(self, value): async def connect(self: 'TelegramClient') -> None: - self._all_dcs = {dc.id: dc for dc in await self.session.get_all_dc()} - self._session_state = await self.session.get_state() + self._all_dcs = {dc.id: dc for dc in await self._session.get_all_dc()} + self._session_state = await self._session.get_state() if self._session_state is None: try_fetch_user = False @@ -347,7 +284,7 @@ async def connect(self: 'TelegramClient') -> None: self._all_dcs[dc.id] = DataCenter(dc.id, ip, None, dc.port, b'') for dc in self._all_dcs.values(): - await self.session.insert_dc(dc) + await self._session.insert_dc(dc) if try_fetch_user: # If there was a previous session state, but the current user ID is 0, it means we've @@ -357,7 +294,7 @@ async def connect(self: 'TelegramClient') -> None: if me: await self._update_session_state(me, save=False) - await self.session.save() + await self._session.save() self._updates_handle = asyncio.create_task(self._update_loop()) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index c87f6e84..5f555b06 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -2645,25 +2645,26 @@ class TelegramClient: api_id: int, api_hash: str, *, - connection: typing.Union[str, enums.ConnectionMode] = (), + # Logging. + base_logger: typing.Union[str, logging.Logger] = None, + # Connection parameters. use_ipv6: bool = False, proxy: typing.Union[tuple, dict] = None, local_addr: typing.Union[str, tuple] = None, - timeout: int = 10, - request_retries: int = 5, - connection_retries: int = 5, - retry_delay: int = 1, - auto_reconnect: bool = True, - sequential_updates: bool = False, - flood_sleep_threshold: int = 60, - raise_last_call_error: bool = False, device_model: str = None, system_version: str = None, app_version: str = None, lang_code: str = 'en', system_lang_code: str = 'en', - base_logger: typing.Union[str, logging.Logger] = None, - receive_updates: bool = True + # Nice-to-have. + auto_reconnect: bool = True, + connect_timeout: int = 10, + connect_retries: int = 4, + connect_retry_delay: int = 1, + request_retries: int = 4, + flood_sleep_threshold: int = 60, + # Update handling. + receive_updates: bool = True, ): telegrambaseclient.init(**locals()) diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index a0041310..141f6b95 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -206,7 +206,7 @@ async def _update_loop(self: 'TelegramClient'): # Entities are not saved when they are inserted because this is a rather expensive # operation (default's sqlite3 takes ~0.1s to commit changes). Do it every minute # instead. No-op if there's nothing new. - await self.session.save() + await self._session.save() # We need to send some content-related request at least hourly # for Telegram to keep delivering updates, otherwise they will @@ -231,33 +231,6 @@ async def _dispatch_queue_updates(self: 'TelegramClient'): self._dispatching_updates_queue.clear() async def _dispatch_update(self: 'TelegramClient', update, entities, others, channel_id, pts_date): - if entities: - rows = self._entity_cache.add(list(entities.values())) - if rows: - await self.session.insert_entities(rows) - - if not self._entity_cache.ensure_cached(update): - # We could add a lock to not fetch the same pts twice if we are - # already fetching it. However this does not happen in practice, - # which makes sense, because different updates have different pts. - if self._state_cache.update(update, check_only=True): - # If the update doesn't have pts, fetching won't do anything. - # For example, UpdateUserStatus or UpdateChatUserTyping. - try: - await _get_difference(self, update, entities, channel_id, pts_date) - except OSError: - pass # We were disconnected, that's okay - except RpcError: - # There's a high chance the request fails because we lack - # the channel. Because these "happen sporadically" (#1428) - # we should be okay (no flood waits) even if more occur. - pass - except ValueError: - # There is a chance that GetFullChannel and GetDifference - # inside the _get_difference() function will end up with - # ValueError("Request was unsuccessful N time(s)") for whatever reasons. - pass - built = EventBuilderDict(self, update, entities, others) for builder, callback in self._event_builders: diff --git a/telethon/_client/users.py b/telethon/_client/users.py index 69f2c564..9f381af6 100644 --- a/telethon/_client/users.py +++ b/telethon/_client/users.py @@ -76,9 +76,6 @@ async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sl exceptions.append(e) results.append(None) continue - entities = self._entity_cache.add(result) - if entities: - await self.session.insert_entities(entities) exceptions.append(None) results.append(result) request_index += 1 @@ -88,9 +85,6 @@ async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sl return results else: result = await future - entities = self._entity_cache.add(result) - if entities: - await self.session.insert_entities(entities) return result except ServerError as e: last_error = e @@ -129,10 +123,7 @@ async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sl raise await self._switch_dc(e.new_dc) - if self._raise_last_call_error and last_error is not None: - raise last_error - raise ValueError('Request was unsuccessful {} time(s)' - .format(attempt)) + raise last_error async def get_me(self: 'TelegramClient', input_peer: bool = False) \ @@ -147,15 +138,12 @@ async def is_bot(self: 'TelegramClient') -> bool: return self._session_state.bot if self._session_state else False async def is_user_authorized(self: 'TelegramClient') -> bool: - if self._authorized is None: - try: - # Any request that requires authorization will work - await self(_tl.fn.updates.GetState()) - self._authorized = True - except RpcError: - self._authorized = False - - return self._authorized + try: + # Any request that requires authorization will work + await self(_tl.fn.updates.GetState()) + return True + except RpcError: + return False async def get_entity( self: 'TelegramClient', @@ -236,14 +224,6 @@ async def get_input_entity( except TypeError: pass - # Next in priority is having a peer (or its ID) cached in-memory - try: - # 0x2d45687 == crc32(b'Peer') - if isinstance(peer, int) or peer.SUBCLASS_OF_ID == 0x2d45687: - return self._entity_cache[peer] - except (AttributeError, KeyError): - pass - # Then come known strings that take precedence if peer in ('me', 'self'): return _tl.InputPeerSelf() @@ -254,7 +234,7 @@ async def get_input_entity( except TypeError: pass else: - entity = await self.session.get_entity(None, peer_id) + entity = await self._session.get_entity(None, peer_id) if entity: if entity.ty in (Entity.USER, Entity.BOT): return _tl.InputPeerUser(entity.id, entity.access_hash) diff --git a/telethon/_events/album.py b/telethon/_events/album.py index f290519d..580e5a31 100644 --- a/telethon/_events/album.py +++ b/telethon/_events/album.py @@ -165,8 +165,7 @@ class Album(EventBuilder): def _set_client(self, client): super()._set_client(client) - self._sender, self._input_sender = utils._get_entity_pair( - self.sender_id, self._entities, client._entity_cache) + self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, self._entities) self.messages = [ _custom.Message._new(client, m, self._entities, None) diff --git a/telethon/_events/callbackquery.py b/telethon/_events/callbackquery.py index dd4f54aa..0e3e2d67 100644 --- a/telethon/_events/callbackquery.py +++ b/telethon/_events/callbackquery.py @@ -166,8 +166,7 @@ class CallbackQuery(EventBuilder): def _set_client(self, client): super()._set_client(client) - self._sender, self._input_sender = utils._get_entity_pair( - self.sender_id, self._entities, client._entity_cache) + self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, self._entities) @property def id(self): @@ -223,13 +222,10 @@ class CallbackQuery(EventBuilder): self._input_sender = utils.get_input_peer(self._chat) if not getattr(self._input_sender, 'access_hash', True): # getattr with True to handle the InputPeerSelf() case - try: - self._input_sender = self._client._entity_cache[self._sender_id] - except KeyError: - m = await self.get_message() - if m: - self._sender = m._sender - self._input_sender = m._input_sender + m = await self.get_message() + if m: + self._sender = m._sender + self._input_sender = m._input_sender async def answer( self, message=None, cache_time=0, *, url=None, alert=False): diff --git a/telethon/_events/chataction.py b/telethon/_events/chataction.py index b34ecd65..671347c0 100644 --- a/telethon/_events/chataction.py +++ b/telethon/_events/chataction.py @@ -401,20 +401,13 @@ class ChatAction(EventBuilder): if self._input_users is None and self._user_ids: self._input_users = [] for user_id in self._user_ids: - # First try to get it from our entities + # Try to get it from our entities try: self._input_users.append(utils.get_input_peer(self._entities[user_id])) continue except (KeyError, TypeError): pass - # If missing, try from the entity cache - try: - self._input_users.append(self._client._entity_cache[user_id]) - continue - except KeyError: - pass - return self._input_users or [] async def get_input_users(self): diff --git a/telethon/_events/common.py b/telethon/_events/common.py index bfaf3227..fb941980 100644 --- a/telethon/_events/common.py +++ b/telethon/_events/common.py @@ -147,8 +147,7 @@ class EventCommon(ChatGetter, abc.ABC): # TODO Nuke self._client = client if self._chat_peer: - self._chat, self._input_chat = utils._get_entity_pair( - self.chat_id, self._entities, client._entity_cache) + self._chat, self._input_chat = utils._get_entity_pair(self.chat_id, self._entities) else: self._chat = self._input_chat = None diff --git a/telethon/_events/inlinequery.py b/telethon/_events/inlinequery.py index 2c718e11..76401962 100644 --- a/telethon/_events/inlinequery.py +++ b/telethon/_events/inlinequery.py @@ -98,8 +98,7 @@ class InlineQuery(EventBuilder): def _set_client(self, client): super()._set_client(client) - self._sender, self._input_sender = utils._get_entity_pair( - self.sender_id, self._entities, client._entity_cache) + self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, self._entities) @property def id(self): diff --git a/telethon/_events/userupdate.py b/telethon/_events/userupdate.py index b5354ae0..35e8044c 100644 --- a/telethon/_events/userupdate.py +++ b/telethon/_events/userupdate.py @@ -94,8 +94,7 @@ class UserUpdate(EventBuilder): def _set_client(self, client): super()._set_client(client) - self._sender, self._input_sender = utils._get_entity_pair( - self.sender_id, self._entities, client._entity_cache) + self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, self._entities) @property def user(self): diff --git a/telethon/_misc/utils.py b/telethon/_misc/utils.py index e412d563..ca2b1388 100644 --- a/telethon/_misc/utils.py +++ b/telethon/_misc/utils.py @@ -578,20 +578,16 @@ def get_input_group_call(call): _raise_cast_fail(call, 'InputGroupCall') -def _get_entity_pair(entity_id, entities, cache, +def _get_entity_pair(entity_id, entities, get_input_peer=get_input_peer): """ Returns ``(entity, input_entity)`` for the given entity ID. """ entity = entities.get(entity_id) try: - input_entity = cache[entity_id] - except KeyError: - # KeyError is unlikely, so another TypeError won't hurt - try: - input_entity = get_input_peer(entity) - except TypeError: - input_entity = None + input_entity = get_input_peer(entity) + except TypeError: + input_entity = None return entity, input_entity diff --git a/telethon/types/_custom/chatgetter.py b/telethon/types/_custom/chatgetter.py index 13e234ee..6bd4c1c3 100644 --- a/telethon/types/_custom/chatgetter.py +++ b/telethon/types/_custom/chatgetter.py @@ -64,12 +64,6 @@ class ChatGetter(abc.ABC): Note that this might not be available if the library doesn't have enough information available. """ - if self._input_chat is None and self._chat_peer and self._client: - try: - self._input_chat = self._client._entity_cache[self._chat_peer] - except KeyError: - pass - return self._input_chat async def get_input_chat(self): diff --git a/telethon/types/_custom/draft.py b/telethon/types/_custom/draft.py index 82e0cb26..46dc9c87 100644 --- a/telethon/types/_custom/draft.py +++ b/telethon/types/_custom/draft.py @@ -49,12 +49,6 @@ class Draft: """ Input version of the entity. """ - if not self._input_entity: - try: - self._input_entity = self._client._entity_cache[self._peer] - except KeyError: - pass - return self._input_entity async def get_entity(self): diff --git a/telethon/types/_custom/forward.py b/telethon/types/_custom/forward.py index c5839c4b..1a4c7672 100644 --- a/telethon/types/_custom/forward.py +++ b/telethon/types/_custom/forward.py @@ -35,13 +35,11 @@ class Forward(ChatGetter, SenderGetter): ty = helpers._entity_type(original.from_id) if ty == helpers._EntityType.USER: sender_id = utils.get_peer_id(original.from_id) - sender, input_sender = utils._get_entity_pair( - sender_id, entities, client._entity_cache) + sender, input_sender = utils._get_entity_pair(sender_id, entities) elif ty in (helpers._EntityType.CHAT, helpers._EntityType.CHANNEL): peer = original.from_id - chat, input_chat = utils._get_entity_pair( - utils.get_peer_id(peer), entities, client._entity_cache) + chat, input_chat = utils._get_entity_pair(utils.get_peer_id(peer), entities) # This call resets the client ChatGetter.__init__(self, peer, chat=chat, input_chat=input_chat) diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index 54dde6b0..c1e213aa 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -435,20 +435,15 @@ class Message(ChatGetter, SenderGetter): if self.peer_id == _tl.PeerUser(client._session_state.user_id) and not self.fwd_from: self.out = True - cache = client._entity_cache + self._sender, self._input_sender = utils._get_entity_pair(self.sender_id, entities) - self._sender, self._input_sender = utils._get_entity_pair( - self.sender_id, entities, cache) - - self._chat, self._input_chat = utils._get_entity_pair( - self.chat_id, entities, cache) + self._chat, self._input_chat = utils._get_entity_pair(self.chat_id, entities) if input_chat: # This has priority self._input_chat = input_chat if self.via_bot_id: - self._via_bot, self._via_input_bot = utils._get_entity_pair( - self.via_bot_id, entities, cache) + self._via_bot, self._via_input_bot = utils._get_entity_pair(self.via_bot_id, entities) if self.fwd_from: self._forward = Forward(self._client, self.fwd_from, entities) @@ -1339,10 +1334,7 @@ class Message(ChatGetter, SenderGetter): raise ValueError('No input sender') return bot else: - try: - return self._client._entity_cache[self.via_bot_id] - except KeyError: - raise ValueError('No input sender') from None + raise ValueError('No input sender') from None def _document_by_attribute(self, kind, condition=None): """ diff --git a/telethon/types/_custom/sendergetter.py b/telethon/types/_custom/sendergetter.py index 673cab25..58d84657 100644 --- a/telethon/types/_custom/sendergetter.py +++ b/telethon/types/_custom/sendergetter.py @@ -64,12 +64,6 @@ class SenderGetter(abc.ABC): Note that this might not be available if the library can't find the input chat, or if the message a broadcast on a channel. """ - if self._input_sender is None and self._sender_id and self._client: - try: - self._input_sender = \ - self._client._entity_cache[self._sender_id] - except KeyError: - pass return self._input_sender async def get_input_sender(self): From 8fc08a0c96804c34b2877db8724843ccc1ff67ea Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 18 Jan 2022 18:16:21 +0100 Subject: [PATCH 112/131] Remove remaining self._all_dcs uses --- telethon/_client/telegrambaseclient.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 9ed5cd71..c8c60df1 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -210,7 +210,7 @@ def set_flood_sleep_threshold(self, value): async def connect(self: 'TelegramClient') -> None: - self._all_dcs = {dc.id: dc for dc in await self._session.get_all_dc()} + all_dcs = {dc.id: dc for dc in await self._session.get_all_dc()} self._session_state = await self._session.get_state() if self._session_state is None: @@ -228,7 +228,7 @@ async def connect(self: 'TelegramClient') -> None: else: try_fetch_user = self._session_state.user_id == 0 - dc = self._all_dcs.get(self._session_state.dc_id) + dc = all_dcs.get(self._session_state.dc_id) if dc is None: dc = DataCenter( id=DEFAULT_DC_ID, @@ -237,7 +237,7 @@ async def connect(self: 'TelegramClient') -> None: port=DEFAULT_PORT, auth=b'', ) - self._all_dcs[dc.id] = dc + all_dcs[dc.id] = dc # Update state (for catching up after a disconnection) # TODO Get state from channels too @@ -257,7 +257,7 @@ async def connect(self: 'TelegramClient') -> None: return if self._sender.auth_key.key != dc.auth: - self._all_dcs[dc.id] = dc = dataclasses.replace(dc, auth=self._sender.auth_key.key) + all_dcs[dc.id] = dc = dataclasses.replace(dc, auth=self._sender.auth_key.key) # Need to send invokeWithLayer for things to work out. # Make the most out of this opportunity by also refreshing our state. @@ -273,17 +273,17 @@ async def connect(self: 'TelegramClient') -> None: continue ip = int(ipaddress.ip_address(dc.ip_address)) - if dc.id in self._all_dcs: + if dc.id in all_dcs: if dc.ipv6: - self._all_dcs[dc.id] = dataclasses.replace(self._all_dcs[dc.id], port=dc.port, ipv6=ip) + all_dcs[dc.id] = dataclasses.replace(all_dcs[dc.id], port=dc.port, ipv6=ip) else: - self._all_dcs[dc.id] = dataclasses.replace(self._all_dcs[dc.id], port=dc.port, ipv4=ip) + all_dcs[dc.id] = dataclasses.replace(all_dcs[dc.id], port=dc.port, ipv4=ip) elif dc.ipv6: - self._all_dcs[dc.id] = DataCenter(dc.id, None, ip, dc.port, b'') + all_dcs[dc.id] = DataCenter(dc.id, None, ip, dc.port, b'') else: - self._all_dcs[dc.id] = DataCenter(dc.id, ip, None, dc.port, b'') + all_dcs[dc.id] = DataCenter(dc.id, ip, None, dc.port, b'') - for dc in self._all_dcs.values(): + for dc in all_dcs.values(): await self._session.insert_dc(dc) if try_fetch_user: @@ -384,7 +384,7 @@ async def _create_exported_sender(self: 'TelegramClient', dc_id): """ # Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt # for clearly showing how to export the authorization - dc = self._all_dcs[dc_id] + dc = next(dc for dc in await self._session.get_all_dc() if dc.id == dc_id) # Can't reuse self._sender._connection as it has its own seqno. # # If one were to do that, Telegram would reset the connection @@ -423,7 +423,7 @@ async def _borrow_exported_sender(self: 'TelegramClient', dc_id): self._borrowed_senders[dc_id] = (state, sender) elif state.need_connect(): - dc = self._all_dcs[dc_id] + dc = next(dc for dc in await self._session.get_all_dc() if dc.id == dc_id) await self._sender.connect(Connection( ip=str(ipaddress.ip_address((self._use_ipv6 and dc.ipv6) or dc.ipv4)), From 7142734fb433c7867cd4f790aa26b50afa0dee87 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 18 Jan 2022 18:19:14 +0100 Subject: [PATCH 113/131] Remove StateCache and EntityCache --- telethon/_client/auth.py | 2 - telethon/_client/telegrambaseclient.py | 8 -- telethon/_client/updates.py | 15 +-- telethon/_misc/entitycache.py | 179 ------------------------- telethon/_misc/statecache.py | 164 ---------------------- 5 files changed, 2 insertions(+), 366 deletions(-) delete mode 100644 telethon/_misc/entitycache.py delete mode 100644 telethon/_misc/statecache.py diff --git a/telethon/_client/auth.py b/telethon/_client/auth.py index 69b5df7e..7d122d8f 100644 --- a/telethon/_client/auth.py +++ b/telethon/_client/auth.py @@ -375,8 +375,6 @@ async def log_out(self: 'TelegramClient') -> bool: except errors.RPCError: return False - self._state_cache.reset() - await self.disconnect() return True diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index c8c60df1..9fbf3f14 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -239,10 +239,6 @@ async def connect(self: 'TelegramClient') -> None: ) all_dcs[dc.id] = dc - # Update state (for catching up after a disconnection) - # TODO Get state from channels too - self._state_cache = statecache.StateCache(self._session_state, self._log) - # Use known key, if any self._sender.auth_key.key = dc.auth @@ -351,10 +347,6 @@ async def _disconnect_coro(self: 'TelegramClient'): await asyncio.wait(self._updates_queue) self._updates_queue.clear() - pts, date = self._state_cache[None] - if pts and date: - if self._session_state: - await self._replace_session_state(pts=pts, date=date) async def _disconnect(self: 'TelegramClient'): """ diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index 141f6b95..92515dae 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -79,10 +79,7 @@ def list_event_handlers(self: 'TelegramClient')\ return [(callback, event) for event, callback in self._event_builders] async def catch_up(self: 'TelegramClient'): - pts, date = self._state_cache[None] - if not pts: - return - + return self._catching_up = True try: while True: @@ -131,8 +128,6 @@ async def catch_up(self: 'TelegramClient'): except (ConnectionError, asyncio.CancelledError): pass finally: - # TODO Save new pts to session - self._state_cache._pts_date = (pts, date) self._catching_up = False @@ -150,14 +145,12 @@ def _handle_update(self: 'TelegramClient', update): else: _process_update(self, update, {}, None) - self._state_cache.update(update) def _process_update(self: 'TelegramClient', update, entities, others): # This part is somewhat hot so we don't bother patching # update with channel ID/its state. Instead we just pass # arguments which is faster. - channel_id = self._state_cache.get_channel_id(update) - args = (update, entities, others, channel_id, self._state_cache[channel_id]) + args = (update, entities, others, channel_id, None) if self._dispatching_updates_queue is None: task = asyncio.create_task(_dispatch_update(self, *args)) self._updates_queue.add(task) @@ -168,8 +161,6 @@ def _process_update(self: 'TelegramClient', update, entities, others): self._dispatching_updates_queue.set() asyncio.create_task(_dispatch_queue_updates(self)) - self._state_cache.update(update) - async def _update_loop(self: 'TelegramClient'): # Pings' ID don't really need to be secure, just "random" rnd = lambda: random.randrange(-2**63, 2**63) @@ -326,7 +317,6 @@ async def _get_difference(self: 'TelegramClient', update, entities, channel_id, result = await self(_tl.fn.channels.GetFullChannel( utils.get_input_channel(where) )) - self._state_cache[channel_id] = result.full_chat.pts return result = await self(_tl.fn.updates.GetChannelDifference( @@ -340,7 +330,6 @@ async def _get_difference(self: 'TelegramClient', update, entities, channel_id, if not pts_date[0]: # First-time, can't get difference. Get pts instead. result = await self(_tl.fn.updates.GetState()) - self._state_cache[None] = result.pts, result.date return result = await self(_tl.fn.updates.GetDifference( diff --git a/telethon/_misc/entitycache.py b/telethon/_misc/entitycache.py deleted file mode 100644 index 2b7b8af1..00000000 --- a/telethon/_misc/entitycache.py +++ /dev/null @@ -1,179 +0,0 @@ -import inspect -import itertools - -from .._misc import utils -from .. import _tl -from .._sessions.types import EntityType, Entity - -# Which updates have the following fields? -_has_field = { - ('user_id', int): [], - ('chat_id', int): [], - ('channel_id', int): [], - ('peer', 'TypePeer'): [], - ('peer', 'TypeDialogPeer'): [], - ('message', 'TypeMessage'): [], -} - -# Note: We don't bother checking for some rare: -# * `UpdateChatParticipantAdd.inviter_id` integer. -# * `UpdateNotifySettings.peer` dialog peer. -# * `UpdatePinnedDialogs.order` list of dialog peers. -# * `UpdateReadMessagesContents.messages` list of messages. -# * `UpdateChatParticipants.participants` list of participants. -# -# There are also some uninteresting `update.message` of type string. - - -def _fill(): - for name in dir(_tl): - update = getattr(_tl, name) - if getattr(update, 'SUBCLASS_OF_ID', None) == 0x9f89304e: - cid = update.CONSTRUCTOR_ID - sig = inspect.signature(update.__init__) - for param in sig.parameters.values(): - vec = _has_field.get((param.name, param.annotation)) - if vec is not None: - vec.append(cid) - - # Future-proof check: if the documentation format ever changes - # then we won't be able to pick the update types we are interested - # in, so we must make sure we have at least an update for each field - # which likely means we are doing it right. - if not all(_has_field.values()): - raise RuntimeError('FIXME: Did the init signature or updates change?') - - -# We use a function to avoid cluttering the globals (with name/update/cid/doc) -_fill() - - -class EntityCache: - """ - In-memory input entity cache, defaultdict-like behaviour. - """ - def add(self, entities, _mappings={ - _tl.User.CONSTRUCTOR_ID: lambda e: (EntityType.BOT if e.bot else EntityType.USER, e.id, e.access_hash), - _tl.UserFull.CONSTRUCTOR_ID: lambda e: (EntityType.BOT if e.user.bot else EntityType.USER, e.user.id, e.user.access_hash), - _tl.Chat.CONSTRUCTOR_ID: lambda e: (EntityType.GROUP, e.id, 0), - _tl.ChatFull.CONSTRUCTOR_ID: lambda e: (EntityType.GROUP, e.id, 0), - _tl.ChatEmpty.CONSTRUCTOR_ID: lambda e: (EntityType.GROUP, e.id, 0), - _tl.ChatForbidden.CONSTRUCTOR_ID: lambda e: (EntityType.GROUP, e.id, 0), - _tl.Channel.CONSTRUCTOR_ID: lambda e: ( - EntityType.MEGAGROUP if e.megagroup else (EntityType.GIGAGROUP if e.gigagroup else EntityType.CHANNEL), - e.id, - e.access_hash, - ), - _tl.ChannelForbidden.CONSTRUCTOR_ID: lambda e: (EntityType.MEGAGROUP if e.megagroup else EntityType.CHANNEL, e.id, e.access_hash), - }): - """ - Adds the given entities to the cache, if they weren't saved before. - - Returns a list of Entity that can be saved in the session. - """ - if not utils.is_list_like(entities): - # Invariant: all "chats" and "users" are always iterables, - # and "user" and "chat" never are (so we wrap them inside a list). - # - # Itself may be already the entity we want to cache. - entities = itertools.chain( - [entities], - getattr(entities, 'chats', []), - getattr(entities, 'users', []), - (hasattr(entities, 'user') and [entities.user]) or [], - (hasattr(entities, 'chat') and [entities.user]) or [], - ) - - rows = [] - for e in entities: - try: - mapper = _mappings[e.CONSTRUCTOR_ID] - except (AttributeError, KeyError): - continue - - ty, id, access_hash = mapper(e) - - # Need to check for non-zero access hash unless it's a group (#354 and #392). - # Also check it's not `min` (`access_hash` usage is limited since layer 102). - if not getattr(e, 'min', False) and (access_hash or ty == Entity.GROUP): - rows.append(Entity(ty, id, access_hash)) - if id not in self.__dict__: - if ty in (EntityType.USER, EntityType.BOT): - self.__dict__[id] = _tl.InputPeerUser(id, access_hash) - elif ty in (EntityType.GROUP,): - self.__dict__[id] = _tl.InputPeerChat(id) - elif ty in (EntityType.CHANNEL, EntityType.MEGAGROUP, EntityType.GIGAGROUP): - self.__dict__[id] = _tl.InputPeerChannel(id, access_hash) - - return rows - - def __getitem__(self, item): - """ - Gets the corresponding :tl:`InputPeer` for the given ID or peer, - or raises ``KeyError`` on any error (i.e. cannot be found). - """ - if not isinstance(item, int) or item < 0: - try: - return self.__dict__[utils.get_peer_id(item)] - except TypeError: - raise KeyError('Invalid key will not have entity') from None - - for cls in (_tl.PeerUser, _tl.PeerChat, _tl.PeerChannel): - result = self.__dict__.get(utils.get_peer_id(cls(item))) - if result: - return result - - raise KeyError('No cached entity for the given key') - - def clear(self): - """ - Clear the entity cache. - """ - self.__dict__.clear() - - def ensure_cached( - self, - update, - has_user_id=frozenset(_has_field[('user_id', int)]), - has_chat_id=frozenset(_has_field[('chat_id', int)]), - has_channel_id=frozenset(_has_field[('channel_id', int)]), - has_peer=frozenset(_has_field[('peer', 'TypePeer')] + _has_field[('peer', 'TypeDialogPeer')]), - has_message=frozenset(_has_field[('message', 'TypeMessage')]) - ): - """ - Ensures that all the relevant entities in the given update are cached. - """ - # This method is called pretty often and we want it to have the lowest - # overhead possible. For that, we avoid `isinstance` and constantly - # getting attributes out of `_tl.` by "caching" the constructor IDs - # in sets inside the arguments, and using local variables. - dct = self.__dict__ - cid = update.CONSTRUCTOR_ID - if cid in has_user_id and \ - update.user_id not in dct: - return False - - if cid in has_chat_id and update.chat_id not in dct: - return False - - if cid in has_channel_id and update.channel_id not in dct: - return False - - if cid in has_peer and \ - utils.get_peer_id(update.peer) not in dct: - return False - - if cid in has_message: - x = update.message - y = getattr(x, 'peer_id', None) # handle MessageEmpty - if y and utils.get_peer_id(y) not in dct: - return False - - y = getattr(x, 'from_id', None) - if y and utils.get_peer_id(y) not in dct: - return False - - # We don't quite worry about entities anywhere else. - # This is enough. - - return True diff --git a/telethon/_misc/statecache.py b/telethon/_misc/statecache.py deleted file mode 100644 index c1a6d7c9..00000000 --- a/telethon/_misc/statecache.py +++ /dev/null @@ -1,164 +0,0 @@ -import inspect - -from .. import _tl - - -# Which updates have the following fields? -_has_channel_id = [] - - -# TODO EntityCache does the same. Reuse? -def _fill(): - for name in dir(_tl): - update = getattr(_tl, name) - if getattr(update, 'SUBCLASS_OF_ID', None) == 0x9f89304e: - cid = update.CONSTRUCTOR_ID - sig = inspect.signature(update.__init__) - for param in sig.parameters.values(): - if param.name == 'channel_id' and param.annotation == int: - _has_channel_id.append(cid) - - if not _has_channel_id: - raise RuntimeError('FIXME: Did the init signature or updates change?') - - -# We use a function to avoid cluttering the globals (with name/update/cid/doc) -_fill() - - -class StateCache: - """ - In-memory update state cache, defaultdict-like behaviour. - """ - def __init__(self, initial, loggers): - # We only care about the pts and the date. By using a tuple which - # is lightweight and immutable we can easily copy them around to - # each update in case they need to fetch missing entities. - self._logger = loggers[__name__] - if initial: - self._pts_date = initial.pts or None, initial.date or None - else: - self._pts_date = None, None - - def reset(self): - self.__dict__.clear() - self._pts_date = None, None - - # TODO Call this when receiving responses too...? - def update( - self, - update, - *, - channel_id=None, - has_pts=frozenset(x.CONSTRUCTOR_ID for x in ( - _tl.UpdateNewMessage, - _tl.UpdateDeleteMessages, - _tl.UpdateReadHistoryInbox, - _tl.UpdateReadHistoryOutbox, - _tl.UpdateWebPage, - _tl.UpdateReadMessagesContents, - _tl.UpdateEditMessage, - _tl.updates.State, - _tl.updates.DifferenceTooLong, - _tl.UpdateShortMessage, - _tl.UpdateShortChatMessage, - _tl.UpdateShortSentMessage - )), - has_date=frozenset(x.CONSTRUCTOR_ID for x in ( - _tl.UpdateUserPhoto, - _tl.UpdateEncryption, - _tl.UpdateEncryptedMessagesRead, - _tl.UpdateChatParticipantAdd, - _tl.updates.DifferenceEmpty, - _tl.UpdateShortMessage, - _tl.UpdateShortChatMessage, - _tl.UpdateShort, - _tl.UpdatesCombined, - _tl.Updates, - _tl.UpdateShortSentMessage, - )), - has_channel_pts=frozenset(x.CONSTRUCTOR_ID for x in ( - _tl.UpdateChannelTooLong, - _tl.UpdateNewChannelMessage, - _tl.UpdateDeleteChannelMessages, - _tl.UpdateEditChannelMessage, - _tl.UpdateChannelWebPage, - _tl.updates.ChannelDifferenceEmpty, - _tl.updates.ChannelDifferenceTooLong, - _tl.updates.ChannelDifference - )), - check_only=False - ): - """ - Update the state with the given update. - """ - cid = update.CONSTRUCTOR_ID - if check_only: - return cid in has_pts or cid in has_date or cid in has_channel_pts - - if cid in has_pts: - if cid in has_date: - self._pts_date = update.pts, update.date - else: - self._pts_date = update.pts, self._pts_date[1] - elif cid in has_date: - self._pts_date = self._pts_date[0], update.date - - if cid in has_channel_pts: - if channel_id is None: - channel_id = self.get_channel_id(update) - - if channel_id is None: - self._logger.info( - 'Failed to retrieve channel_id from %s', update) - else: - self.__dict__[channel_id] = update.pts - - def get_channel_id( - self, - update, - has_channel_id=frozenset(_has_channel_id), - # Hardcoded because only some with message are for channels - has_message=frozenset(x.CONSTRUCTOR_ID for x in ( - _tl.UpdateNewChannelMessage, - _tl.UpdateEditChannelMessage - )) - ): - """ - Gets the **unmarked** channel ID from this update, if it has any. - - Fails for ``*difference`` updates, where ``channel_id`` - is supposedly already known from the outside. - """ - cid = update.CONSTRUCTOR_ID - if cid in has_channel_id: - return update.channel_id - elif cid in has_message: - if update.message.peer_id is None: - # Telegram sometimes sends empty messages to give a newer pts: - # UpdateNewChannelMessage(message=MessageEmpty(id), pts=pts, pts_count=1) - # Not sure why, but it's safe to ignore them. - self._logger.debug('Update has None peer_id %s', update) - else: - return update.message.peer_id.channel_id - - return None - - def __getitem__(self, item): - """ - If `item` is `None`, returns the default ``(pts, date)``. - - If it's an **unmarked** channel ID, returns its ``pts``. - - If no information is known, ``pts`` will be `None`. - """ - if item is None: - return self._pts_date - else: - return self.__dict__.get(item) - - def __setitem__(self, where, value): - if where is None: - self._pts_date = value - else: - self.__dict__[where] = value From 3afabdd7c05eba244a047f10ed5a00aac02541e4 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 18 Jan 2022 18:21:56 +0100 Subject: [PATCH 114/131] Remove auto-reconnect callback It's an abstraction leak. The client should know to refetch updates if a long period passed without them on its own. --- telethon/_client/telegrambaseclient.py | 1 - telethon/_client/telegramclient.py | 4 --- telethon/_client/updates.py | 44 -------------------------- telethon/_network/mtprotosender.py | 7 +--- 4 files changed, 1 insertion(+), 55 deletions(-) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 9fbf3f14..62dc09b0 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -190,7 +190,6 @@ def init( auto_reconnect=self._auto_reconnect, connect_timeout=self._connect_timeout, update_callback=self._handle_update, - auto_reconnect_callback=self._handle_auto_reconnect ) # Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders. diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 5f555b06..412e7b61 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -3513,10 +3513,6 @@ class TelegramClient: def _handle_update(self: 'TelegramClient', update): pass - @forward_call(updates._handle_auto_reconnect) - async def _handle_auto_reconnect(self: 'TelegramClient'): - pass - @forward_call(auth._update_session_state) async def _update_session_state(self, user, *, save=True): pass diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index 92515dae..4b8e29cb 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -347,50 +347,6 @@ async def _get_difference(self: 'TelegramClient', update, entities, channel_id, itertools.chain(result.users, result.chats) }) -async def _handle_auto_reconnect(self: 'TelegramClient'): - # TODO Catch-up - # For now we make a high-level request to let Telegram - # know we are still interested in receiving more updates. - try: - await self.get_me() - except Exception as e: - self._log[__name__].warning('Error executing high-level request ' - 'after reconnect: %s: %s', type(e), e) - - return - try: - self._log[__name__].info( - 'Asking for the current state after reconnect...') - - # TODO consider: - # If there aren't many updates while the client is disconnected - # (I tried with up to 20), Telegram seems to send them without - # asking for them (via updates.getDifference). - # - # On disconnection, the library should probably set a "need - # difference" or "catching up" flag so that any new updates are - # ignored, and then the library should call updates.getDifference - # itself to fetch them. - # - # In any case (either there are too many updates and Telegram - # didn't send them, or there isn't a lot and Telegram sent them - # but we dropped them), we fetch the new difference to get all - # missed updates. I feel like this would be the best solution. - - # If a disconnection occurs, the old known state will be - # the latest one we were aware of, so we can catch up since - # the most recent state we were aware of. - await self.catch_up() - - self._log[__name__].info('Successfully fetched missed updates') - except RpcError as e: - self._log[__name__].warning('Failed to get missed updates after ' - 'reconnect: %r', e) - except Exception: - self._log[__name__].exception( - 'Unhandled exception while getting update difference after reconnect') - - class EventBuilderDict: """ Helper "dictionary" to return events from types and cache them. diff --git a/telethon/_network/mtprotosender.py b/telethon/_network/mtprotosender.py index 92438502..b05137e3 100644 --- a/telethon/_network/mtprotosender.py +++ b/telethon/_network/mtprotosender.py @@ -37,7 +37,7 @@ class MTProtoSender: """ def __init__(self, *, loggers, retries=5, delay=1, auto_reconnect=True, connect_timeout=None, - update_callback=None, auto_reconnect_callback=None): + update_callback=None): self._connection = None self._loggers = loggers self._log = loggers[__name__] @@ -46,7 +46,6 @@ class MTProtoSender: self._auto_reconnect = auto_reconnect self._connect_timeout = connect_timeout self._update_callback = update_callback - self._auto_reconnect_callback = auto_reconnect_callback self._connect_lock = asyncio.Lock() self._ping = None @@ -373,10 +372,6 @@ class MTProtoSender: else: self._send_queue.extend(self._pending_state.values()) self._pending_state.clear() - - if self._auto_reconnect_callback: - asyncio.create_task(self._auto_reconnect_callback()) - break else: ok = False From f6df5d377c78f0dbc2559b14df159b9b28b0303d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 18 Jan 2022 19:46:19 +0100 Subject: [PATCH 115/131] Begin reworking update handling Use a fixed-size queue instead of a callback to deal with updates. Port the message box and entity cache from grammers to start off with a clean design. Temporarily get rid of other cruft such as automatic pings or old catch up implementation. --- telethon/_client/telegrambaseclient.py | 32 +- telethon/_client/telegramclient.py | 5 +- telethon/_client/updates.py | 292 +------------ telethon/_network/mtprotosender.py | 26 +- telethon/_updates/__init__.py | 2 + telethon/_updates/entitycache.py | 97 +++++ telethon/_updates/messagebox.py | 565 +++++++++++++++++++++++++ 7 files changed, 704 insertions(+), 315 deletions(-) create mode 100644 telethon/_updates/__init__.py create mode 100644 telethon/_updates/entitycache.py create mode 100644 telethon/_updates/messagebox.py diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 62dc09b0..b6872dc8 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -11,10 +11,11 @@ import dataclasses from .. import version, __name__ as __base_name__, _tl from .._crypto import rsa -from .._misc import markdown, statecache, enums, helpers +from .._misc import markdown, enums, helpers from .._network import MTProtoSender, Connection, transports from .._sessions import Session, SQLiteSession, MemorySession from .._sessions.types import DataCenter, SessionState +from .._updates import EntityCache, MessageBox DEFAULT_DC_ID = 2 DEFAULT_IPV4_IP = '149.154.167.51' @@ -91,6 +92,7 @@ def init( flood_sleep_threshold: int = 60, # Update handling. receive_updates: bool = True, + max_queued_updates: int = 100, ): # Logging. if isinstance(base_logger, str): @@ -139,6 +141,13 @@ def init( self._flood_waited_requests = {} # prevent calls that would floodwait entirely self._parse_mode = markdown + # Update handling. + self._no_updates = not receive_updates + self._updates_queue = asyncio.Queue(maxsize=max_queued_updates) + self._updates_handle = None + self._message_box = MessageBox() + self._entity_cache = EntityCache() # required for proper update handling (to know when to getDifference) + # Connection parameters. if not api_id or not api_hash: raise ValueError( @@ -189,16 +198,13 @@ def init( delay=self._connect_retry_delay, auto_reconnect=self._auto_reconnect, connect_timeout=self._connect_timeout, - update_callback=self._handle_update, + updates_queue=self._updates_queue, ) # Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders. self._borrowed_senders = {} self._borrow_sender_lock = asyncio.Lock() - # Update handling. - self._no_updates = not receive_updates - def get_flood_sleep_threshold(self): return self._flood_sleep_threshold @@ -337,15 +343,6 @@ async def _disconnect_coro(self: 'TelegramClient'): # If any was borrowed self._borrowed_senders.clear() - # trio's nurseries would handle this for us, but this is asyncio. - # All tasks spawned in the background should properly be terminated. - if self._dispatching_updates_queue is None and self._updates_queue: - for task in self._updates_queue: - task.cancel() - - await asyncio.wait(self._updates_queue) - self._updates_queue.clear() - async def _disconnect(self: 'TelegramClient'): """ @@ -355,8 +352,11 @@ async def _disconnect(self: 'TelegramClient'): their job with the client is complete and we should clean it up all. """ await self._sender.disconnect() - await helpers._cancel(self._log[__name__], - updates_handle=self._updates_handle) + await helpers._cancel(self._log[__name__], updates_handle=self._updates_handle) + try: + await self._updates_handle + except asyncio.CancelledError: + pass async def _switch_dc(self: 'TelegramClient', new_dc): """ diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 412e7b61..4504d589 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -2665,6 +2665,7 @@ class TelegramClient: flood_sleep_threshold: int = 60, # Update handling. receive_updates: bool = True, + max_queued_updates: int = 100, ): telegrambaseclient.init(**locals()) @@ -3509,10 +3510,6 @@ class TelegramClient: async def _clean_exported_senders(self: 'TelegramClient'): pass - @forward_call(updates._handle_update) - def _handle_update(self: 'TelegramClient', update): - pass - @forward_call(auth._update_session_state) async def _update_session_state(self, user, *, save=True): pass diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index 4b8e29cb..2460c561 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -79,295 +79,9 @@ def list_event_handlers(self: 'TelegramClient')\ return [(callback, event) for event, callback in self._event_builders] async def catch_up(self: 'TelegramClient'): - return - self._catching_up = True - try: - while True: - d = await self(_tl.fn.updates.GetDifference( - pts, date, 0 - )) - if isinstance(d, (_tl.updates.DifferenceSlice, - _tl.updates.Difference)): - if isinstance(d, _tl.updates.Difference): - state = d.state - else: - state = d.intermediate_state - - pts, date = state.pts, state.date - _handle_update(self, _tl.Updates( - users=d.users, - chats=d.chats, - date=state.date, - seq=state.seq, - updates=d.other_updates + [ - _tl.UpdateNewMessage(m, 0, 0) - for m in d.new_messages - ] - )) - - # TODO Implement upper limit (max_pts) - # We don't want to fetch updates we already know about. - # - # We may still get duplicates because the Difference - # contains a lot of updates and presumably only has - # the state for the last one, but at least we don't - # unnecessarily fetch too many. - # - # updates.getDifference's pts_total_limit seems to mean - # "how many pts is the request allowed to return", and - # if there is more than that, it returns "too long" (so - # there would be duplicate updates since we know about - # some). This can be used to detect collisions (i.e. - # it would return an update we have already seen). - else: - if isinstance(d, _tl.updates.DifferenceEmpty): - date = d.date - elif isinstance(d, _tl.updates.DifferenceTooLong): - pts = d.pts - break - except (ConnectionError, asyncio.CancelledError): - pass - finally: - self._catching_up = False - - -# It is important to not make _handle_update async because we rely on -# the order that the updates arrive in to update the pts and date to -# be always-increasing. There is also no need to make this async. -def _handle_update(self: 'TelegramClient', update): - if 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: - _process_update(self, u, entities, update.updates) - elif isinstance(update, _tl.UpdateShort): - _process_update(self, update.update, {}, None) - else: - _process_update(self, update, {}, None) - - -def _process_update(self: 'TelegramClient', update, entities, others): - # This part is somewhat hot so we don't bother patching - # update with channel ID/its state. Instead we just pass - # arguments which is faster. - args = (update, entities, others, channel_id, None) - if self._dispatching_updates_queue is None: - task = asyncio.create_task(_dispatch_update(self, *args)) - self._updates_queue.add(task) - task.add_done_callback(lambda _: self._updates_queue.discard(task)) - else: - self._updates_queue.put_nowait(args) - if not self._dispatching_updates_queue.is_set(): - self._dispatching_updates_queue.set() - asyncio.create_task(_dispatch_queue_updates(self)) + pass async def _update_loop(self: 'TelegramClient'): - # Pings' ID don't really need to be secure, just "random" - rnd = lambda: random.randrange(-2**63, 2**63) while self.is_connected(): - try: - await asyncio.wait_for(self.run_until_disconnected(), timeout=60) - continue # We actually just want to act upon timeout - except asyncio.TimeoutError: - pass - except asyncio.CancelledError: - return - except Exception as e: - # Any disconnected exception should be ignored (or it may hint at - # another problem, leading to an infinite loop, hence the logging call) - self._log[__name__].info('Exception waiting on a disconnect: %s', e) - continue - - # Check if we have any exported senders to clean-up periodically - await self._clean_exported_senders() - - # Don't bother sending pings until the low-level connection is - # ready, otherwise a lot of pings will be batched to be sent upon - # reconnect, when we really don't care about that. - if not self._sender._transport_connected(): - continue - - # We also don't really care about their result. - # Just send them periodically. - try: - self._sender._keepalive_ping(rnd()) - except (ConnectionError, asyncio.CancelledError): - return - - # Entities are not saved when they are inserted because this is a rather expensive - # operation (default's sqlite3 takes ~0.1s to commit changes). Do it every minute - # instead. No-op if there's nothing new. - await self._session.save() - - # We need to send some content-related request at least hourly - # for Telegram to keep delivering updates, otherwise they will - # just stop even if we're connected. Do so every 30 minutes. - # - # TODO Call getDifference instead since it's more relevant - if time.time() - self._last_request > 30 * 60: - if not await self.is_user_authorized(): - # What can be the user doing for so - # long without being logged in...? - continue - - try: - await self(_tl.fn.updates.GetState()) - except (ConnectionError, asyncio.CancelledError): - return - -async def _dispatch_queue_updates(self: 'TelegramClient'): - while not self._updates_queue.empty(): - await _dispatch_update(self, *self._updates_queue.get_nowait()) - - self._dispatching_updates_queue.clear() - -async def _dispatch_update(self: 'TelegramClient', update, entities, others, channel_id, pts_date): - built = EventBuilderDict(self, update, entities, others) - - for builder, callback in self._event_builders: - event = built[type(builder)] - if not event: - continue - - if not builder.resolved: - await builder.resolve(self) - - filter = builder.filter(event) - if inspect.isawaitable(filter): - filter = await filter - if not filter: - continue - - try: - await callback(event) - except StopPropagation: - name = getattr(callback, '__name__', repr(callback)) - self._log[__name__].debug( - 'Event handler "%s" stopped chain of propagation ' - 'for event %s.', name, type(event).__name__ - ) - break - except Exception as e: - if not isinstance(e, asyncio.CancelledError) or self.is_connected(): - name = getattr(callback, '__name__', repr(callback)) - self._log[__name__].exception('Unhandled exception on %s', name) - -async def _dispatch_event(self: 'TelegramClient', event): - """ - Dispatches a single, out-of-order event. Used by `AlbumHack`. - """ - # We're duplicating a most logic from `_dispatch_update`, but all in - # the name of speed; we don't want to make it worse for all updates - # just because albums may need it. - for builder, callback in self._event_builders: - if isinstance(builder, Raw): - continue - if not isinstance(event, builder.Event): - continue - - if not builder.resolved: - await builder.resolve(self) - - filter = builder.filter(event) - if inspect.isawaitable(filter): - filter = await filter - if not filter: - continue - - try: - await callback(event) - except StopPropagation: - name = getattr(callback, '__name__', repr(callback)) - self._log[__name__].debug( - 'Event handler "%s" stopped chain of propagation ' - 'for event %s.', name, type(event).__name__ - ) - break - except Exception as e: - if not isinstance(e, asyncio.CancelledError) or self.is_connected(): - name = getattr(callback, '__name__', repr(callback)) - self._log[__name__].exception('Unhandled exception on %s', name) - -async def _get_difference(self: 'TelegramClient', update, entities, channel_id, pts_date): - """ - Get the difference for this `channel_id` if any, then load entities. - - Calls :tl:`updates.getDifference`, which fills the entities cache - (always done by `__call__`) and lets us know about the full entities. - """ - # Fetch since the last known pts/date before this update arrived, - # in order to fetch this update at full, including its entities. - self._log[__name__].debug('Getting difference for entities ' - 'for %r', update.__class__) - if channel_id: - # There are reports where we somehow call get channel difference - # with `InputPeerEmpty`. Check our assumptions to better debug - # this when it happens. - assert isinstance(channel_id, int), 'channel_id was {}, not int in {}'.format(type(channel_id), update) - try: - # Wrap the ID inside a peer to ensure we get a channel back. - where = await self.get_input_entity(_tl.PeerChannel(channel_id)) - except ValueError: - # There's a high chance that this fails, since - # we are getting the difference to fetch entities. - return - - if not pts_date: - # First-time, can't get difference. Get pts instead. - result = await self(_tl.fn.channels.GetFullChannel( - utils.get_input_channel(where) - )) - return - - result = await self(_tl.fn.updates.GetChannelDifference( - channel=where, - filter=_tl.ChannelMessagesFilterEmpty(), - pts=pts_date, # just pts - limit=100, - force=True - )) - else: - if not pts_date[0]: - # First-time, can't get difference. Get pts instead. - result = await self(_tl.fn.updates.GetState()) - return - - result = await self(_tl.fn.updates.GetDifference( - pts=pts_date[0], - date=pts_date[1], - qts=0 - )) - - if isinstance(result, (_tl.updates.Difference, - _tl.updates.DifferenceSlice, - _tl.updates.ChannelDifference, - _tl.updates.ChannelDifferenceTooLong)): - entities.update({ - utils.get_peer_id(x): x for x in - itertools.chain(result.users, result.chats) - }) - -class EventBuilderDict: - """ - Helper "dictionary" to return events from types and cache them. - """ - def __init__(self, client: 'TelegramClient', update, entities, others): - self.client = client - self.update = update - self.entities = entities - self.others = others - - def __getitem__(self, builder): - try: - return self.__dict__[builder] - except KeyError: - event = self.__dict__[builder] = builder.build( - self.update, self.others, self.client._session_state.user_id, self.entities or {}, self.client) - - if isinstance(event, EventCommon): - # TODO eww - event.original_update = self.update - event._entities = self.entities or {} - event._set_client(self.client) - - return event + updates = await self._updates_queue.get() + updates, users, chats = self._message_box.process_updates(updates, self._entity_cache) diff --git a/telethon/_network/mtprotosender.py b/telethon/_network/mtprotosender.py index b05137e3..fa58240f 100644 --- a/telethon/_network/mtprotosender.py +++ b/telethon/_network/mtprotosender.py @@ -1,6 +1,7 @@ import asyncio import collections import struct +import logging from . import authenticator from .._misc.messagepacker import MessagePacker @@ -20,6 +21,9 @@ from .._misc import helpers, utils from .. import _tl +UPDATE_BUFFER_FULL_WARN_DELAY = 15 * 60 + + class MTProtoSender: """ MTProto Mobile Protocol sender @@ -35,9 +39,8 @@ class MTProtoSender: A new authorization key will be generated on connection if no other key exists yet. """ - def __init__(self, *, loggers, - retries=5, delay=1, auto_reconnect=True, connect_timeout=None, - update_callback=None): + def __init__(self, *, loggers, updates_queue, + retries=5, delay=1, auto_reconnect=True, connect_timeout=None,): self._connection = None self._loggers = loggers self._log = loggers[__name__] @@ -45,7 +48,7 @@ class MTProtoSender: self._delay = delay self._auto_reconnect = auto_reconnect self._connect_timeout = connect_timeout - self._update_callback = update_callback + self._updates_queue = updates_queue self._connect_lock = asyncio.Lock() self._ping = None @@ -83,6 +86,9 @@ class MTProtoSender: # is received, but we may still need to resend their state on bad salts. self._last_acks = collections.deque(maxlen=10) + # Last time we warned about the update buffer being full + self._last_update_warn = -UPDATE_BUFFER_FULL_WARN_DELAY + # Jump table from response ID to method that handles it self._handlers = { RpcResult.CONSTRUCTOR_ID: self._handle_rpc_result, @@ -629,8 +635,16 @@ class MTProtoSender: return self._log.debug('Handling update %s', message.obj.__class__.__name__) - if self._update_callback: - self._update_callback(message.obj) + try: + self._updates_queue.put_nowait(message.obj) + except asyncio.QueueFull: + now = asyncio.get_running_loop().time() + if now - self._last_update_warn >= UPDATE_BUFFER_FULL_WARN_DELAY: + self._log.warning( + 'Cannot dispatch update because the buffer capacity of %d was reached', + self._updates_queue.maxsize + ) + self._last_update_warn = now async def _handle_pong(self, message): """ diff --git a/telethon/_updates/__init__.py b/telethon/_updates/__init__.py new file mode 100644 index 00000000..7951c9aa --- /dev/null +++ b/telethon/_updates/__init__.py @@ -0,0 +1,2 @@ +from .entitycache import EntityCache, PackedChat +from .messagebox import MessageBox diff --git a/telethon/_updates/entitycache.py b/telethon/_updates/entitycache.py new file mode 100644 index 00000000..176d2013 --- /dev/null +++ b/telethon/_updates/entitycache.py @@ -0,0 +1,97 @@ +import inspect +import itertools +from dataclasses import dataclass, field +from collections import namedtuple + +from .._misc import utils +from .. import _tl +from .._sessions.types import EntityType, Entity + + +class PackedChat(namedtuple('PackedChat', 'ty id hash')): + __slots__ = () + + @property + def is_user(self): + return self.ty in (EntityType.USER, EntityType.BOT) + + @property + def is_chat(self): + return self.ty in (EntityType.GROUP,) + + @property + def is_channel(self): + return self.ty in (EntityType.CHANNEL, EntityType.MEGAGROUP, EntityType.GIGAGROUP) + + def to_peer(self): + if self.is_user: + return _tl.PeerUser(user_id=self.id) + elif self.is_chat: + return _tl.PeerChat(chat_id=self.id) + elif self.is_channel: + return _tl.PeerChannel(channel_id=self.id) + + def to_input_peer(self): + if self.is_user: + return _tl.InputPeerUser(user_id=self.id, access_hash=self.hash) + elif self.is_chat: + return _tl.InputPeerChat(chat_id=self.id) + elif self.is_channel: + return _tl.InputPeerChannel(channel_id=self.id, access_hash=self.hash) + + def try_to_input_user(self): + if self.is_user: + return _tl.InputUser(user_id=self.id, access_hash=self.hash) + else: + return None + + def try_to_chat_id(self): + if self.is_chat: + return self.id + else: + return None + + def try_to_input_channel(self): + if self.is_channel: + return _tl.InputChannel(channel_id=self.id, access_hash=self.hash) + else: + return None + + def __str__(self): + return f'{chr(self.ty.value)}.{self.id}.{self.hash}' + + +@dataclass +class EntityCache: + hash_map: dict = field(default_factory=dict) # id -> (hash, ty) + self_id: int = None + self_bot: bool = False + + def set_self_user(self, id, bot): + self.self_id = id + self.self_bot = bot + + def get(self, id): + value = self.hash_map.get(id) + return PackedChat(ty=value[1], id=id, hash=value[0]) if value else None + + def extend(self, users, chats): + # See https://core.telegram.org/api/min for "issues" with "min constructors". + self.hash_map.update( + (u.id, ( + u.access_hash, + EntityType.BOT if u.bot else EntityType.USER, + )) + for u in users + if getattr(u, 'access_hash', None) and not u.min + ) + self.hash_map.update( + (c.id, ( + c.access_hash, + EntityType.MEGAGROUP if c.megagroup else ( + EntityType.GIGAGROUP if getattr(c, 'gigagroup', None) else EntityType.CHANNEL + ), + )) + for c in chats + if getattr(c, 'access_hash', None) and not getattr(c, 'min', None) + ) diff --git a/telethon/_updates/messagebox.py b/telethon/_updates/messagebox.py new file mode 100644 index 00000000..555451ad --- /dev/null +++ b/telethon/_updates/messagebox.py @@ -0,0 +1,565 @@ +""" +This module deals with correct handling of updates, including gaps, and knowing when the code +should "get difference" (the set of updates that the client should know by now minus the set +of updates that it actually knows). + +Each chat has its own [`Entry`] in the [`MessageBox`] (this `struct` is the "entry point"). +At any given time, the message box may be either getting difference for them (entry is in +[`MessageBox::getting_diff_for`]) or not. If not getting difference, a possible gap may be +found for the updates (entry is in [`MessageBox::possible_gaps`]). Otherwise, the entry is +on its happy path. + +Gaps are cleared when they are either resolved on their own (by waiting for a short time) +or because we got the difference for the corresponding entry. + +While there are entries for which their difference must be fetched, +[`MessageBox::check_deadlines`] will always return [`Instant::now`], since "now" is the time +to get the difference. +""" +import asyncio +from dataclasses import dataclass, field +from .._sessions.types import SessionState, ChannelState + + +# Telegram sends `seq` equal to `0` when "it doesn't matter", so we use that value too. +NO_SEQ = 0 + +# See https://core.telegram.org/method/updates.getChannelDifference. +BOT_CHANNEL_DIFF_LIMIT = 100000 +USER_CHANNEL_DIFF_LIMIT = 100 + +# > It may be useful to wait up to 0.5 seconds +POSSIBLE_GAP_TIMEOUT = 0.5 + +# After how long without updates the client will "timeout". +# +# When this timeout occurs, the client will attempt to fetch updates by itself, ignoring all the +# updates that arrive in the meantime. After all updates are fetched when this happens, the +# client will resume normal operation, and the timeout will reset. +# +# Documentation recommends 15 minutes without updates (https://core.telegram.org/api/updates). +NO_UPDATES_TIMEOUT = 15 * 60 + +# Entry "enum". +# Account-wide `pts` includes private conversations (one-to-one) and small group chats. +ENTRY_ACCOUNT = object() +# Account-wide `qts` includes only "secret" one-to-one chats. +ENTRY_SECRET = object() +# Integers will be Channel-specific `pts`, and includes "megagroup", "broadcast" and "supergroup" channels. + + +def next_updates_deadline(): + return asyncio.get_running_loop().time() + NO_UPDATES_TIMEOUT + + +class GapError(ValueError): + pass + + +# Represents the information needed to correctly handle a specific `tl::enums::Update`. +@dataclass +class PtsInfo: + pts: int + pts_count: int + entry: object + + @classmethod + def from_update(cls, update): + pts = getattr(update, 'pts', None) + if pts: + pts_count = getattr(update, 'pts_count', None) or 0 + entry = getattr(update, 'channel_id', None) or ENTRY_ACCOUNT + return cls(pts=pts, pts_count=pts_count, entry=entry) + + qts = getattr(update, 'qts', None) + if qts: + pts_count = 1 if isinstance(update, _tl.UpdateNewEncryptedMessage) else 0 + return cls(pts=qts, pts_count=pts_count, entry=ENTRY_SECRET) + + return None + + +# The state of a particular entry in the message box. +@dataclass +class State: + # Current local persistent timestamp. + pts: int + + # Next instant when we would get the update difference if no updates arrived before then. + deadline: float + + +# > ### Recovering gaps +# > […] Manually obtaining updates is also required in the following situations: +# > • Loss of sync: a gap was found in `seq` / `pts` / `qts` (as described above). +# > It may be useful to wait up to 0.5 seconds in this situation and abort the sync in case a new update +# > arrives, that fills the gap. +# +# This is really easy to trigger by spamming messages in a channel (with as little as 3 members works), because +# the updates produced by the RPC request take a while to arrive (whereas the read update comes faster alone). +@dataclass +class PossibleGap: + deadline: float + # Pending updates (those with a larger PTS, producing the gap which may later be filled). + updates: list # of updates + + +# Represents a "message box" (event `pts` for a specific entry). +# +# See https://core.telegram.org/api/updates#message-related-event-sequences. +@dataclass +class MessageBox: + # Map each entry to their current state. + map: dict = field(default_factory=dict) # entry -> state + + # Additional fields beyond PTS needed by `ENTRY_ACCOUNT`. + date: int = 1 + seq: int = 0 + + # Holds the entry with the closest deadline (optimization to avoid recalculating the minimum deadline). + next_deadline: object = None # entry + + # Which entries have a gap and may soon trigger a need to get difference. + # + # If a gap is found, stores the required information to resolve it (when should it timeout and what updates + # should be held in case the gap is resolved on its own). + # + # Not stored directly in `map` as an optimization (else we would need another way of knowing which entries have + # a gap in them). + possible_gaps: dict = field(default_factory=dict) # entry -> possiblegap + + # For which entries are we currently getting difference. + getting_diff_for: set = field(default_factory=set) # entry + + # Temporarily stores which entries should have their update deadline reset. + # Stored in the message box in order to reuse the allocation. + reset_deadlines_for: set = field(default_factory=set) # entry + + # region Creation, querying, and setting base state. + + @classmethod + def load(cls, session_state, channel_states): + """ + Create a [`MessageBox`] from a previously known update state. + """ + deadline = next_updates_deadline() + return cls( + map={ + ENTRY_ACCOUNT: State(pts=session_state.pts, deadline=deadline), + ENTRY_SECRET: State(pts=session_state.qts, deadline=deadline), + **{s.channel_id: s.pts for s in channel_states} + }, + date=session_state.date, + seq=session_state.seq, + next_deadline=ENTRY_ACCOUNT, + ) + + @classmethod + def session_state(self): + """ + Return the current state in a format that sessions understand. + + This should be used for persisting the state. + """ + return SessionState( + user_id=0, + dc_id=0, + bot=False, + pts=self.map.get(ENTRY_ACCOUNT, 0), + qts=self.map.get(ENTRY_SECRET, 0), + date=self.date, + seq=self.seq, + takeout_id=None, + ), [ChannelState(channel_id=id, pts=pts) for id, pts in self.map.items() if isinstance(id, int)] + + def is_empty(self) -> bool: + """ + Return true if the message box is empty and has no state yet. + """ + return self.map.get(ENTRY_ACCOUNT, NO_SEQ) == NO_SEQ + + def check_deadlines(self): + """ + Return the next deadline when receiving updates should timeout. + + If a deadline expired, the corresponding entries will be marked as needing to get its difference. + While there are entries pending of getting their difference, this method returns the current instant. + """ + now = asyncio.get_running_loop().time() + + if self.getting_diff_for: + return now + + deadline = next_updates_deadline() + + # Most of the time there will be zero or one gap in flight so finding the minimum is cheap. + if self.possible_gaps: + deadline = min(deadline, *self.possible_gaps.values()) + elif self.next_deadline in self.map: + deadline = min(deadline, self.map[self.next_deadline]) + + if now > deadline: + # Check all expired entries and add them to the list that needs getting difference. + self.getting_diff_for.update(entry for entry, gap in self.possible_gaps.items() if now > gap.deadline) + self.getting_diff_for.update(entry for entry, state in self.map.items() if now > state.deadline) + + # When extending `getting_diff_for`, it's important to have the moral equivalent of + # `begin_get_diff` (that is, clear possible gaps if we're now getting difference). + for entry in self.getting_diff_for: + self.possible_gaps.pop(entry, None) + + return deadline + + # Reset the deadline for the periods without updates for a given entry. + # + # It also updates the next deadline time to reflect the new closest deadline. + def reset_deadline(self, entry, deadline): + if entry in self.map: + self.map[entry].deadline = deadline + # TODO figure out why not in map may happen + + if self.next_deadline == entry: + # If the updated deadline was the closest one, recalculate the new minimum. + self.next_deadline = min(self.map.items(), key=lambda entry_state: entry_state[1].deadline)[0] + elif deadline < self.map.get(self.next_deadline, 0): + # If the updated deadline is smaller than the next deadline, change the next deadline to be the new one. + self.next_deadline = entry + # else an unrelated deadline was updated, so the closest one remains unchanged. + + # Convenience to reset a channel's deadline, with optional timeout. + def reset_channel_deadline(self, channel_id, timeout): + self.reset_deadlines(channel_id, asyncio.get_running_loop().time() + (timeout or NO_UPDATES_TIMEOUT)) + + # Reset all the deadlines in `reset_deadlines_for` and then empty the set. + def apply_deadlines_reset(self): + next_deadline = next_updates_deadline() + + reset_deadlines_for = self.reset_deadlines_for + self.reset_deadlines_for = set() # "move" the set to avoid self.reset_deadline() from touching it during iter + + for entry in reset_deadlines_for: + self.reset_deadline(entry, next_deadline) + + reset_deadlines_for.clear() # reuse allocation, the other empty set was a temporary dummy value + self.reset_deadlines_for = reset_deadlines_for + + # Sets the update state. + # + # Should be called right after login if [`MessageBox::new`] was used, otherwise undesirable + # updates will be fetched. + def set_state(self, state): + deadline = next_updates_deadline() + self.map[ENTRY_ACCOUNT] = State(pts=state.pts, deadline=deadline) + self.map[ENTRY_SECRET] = State(pts=state.qts, deadline=deadline) + self.date = state.date + self.seq = state.seq + + # Like [`MessageBox::set_state`], but for channels. Useful when getting dialogs. + # + # The update state will only be updated if no entry was known previously. + def try_set_channel_state(self, id, pts): + if id not in self.map: + self.map[id] = State(pts=pts, deadline=next_updates_deadline()) + + # Begin getting difference for the given entry. + # + # Clears any previous gaps. + def begin_get_diff(self, entry): + self.getting_diff_for.add(entry) + self.possible_gaps.pop(entry, None) + + # Finish getting difference for the given entry. + # + # It also resets the deadline. + def end_get_diff(self, entry): + self.getting_diff_for.pop(entry, None) + self.reset_deadline(entry, next_updates_deadline()) + assert entry not in self.possible_gaps, "gaps shouldn't be created while getting difference" + + # endregion Creation, querying, and setting base state. + + # region "Normal" updates flow (processing and detection of gaps). + + # Process an update and return what should be done with it. + # + # Updates corresponding to entries for which their difference is currently being fetched + # will be ignored. While according to the [updates' documentation]: + # + # > Implementations [have] to postpone updates received via the socket while + # > filling gaps in the event and `Update` sequences, as well as avoid filling + # > gaps in the same sequence. + # + # In practice, these updates should have also been retrieved through getting difference. + # + # [updates documentation] https://core.telegram.org/api/updates + def process_updates( + self, + updates, + chat_hashes, + result, # out list of updates; returns list of user, chat, or raise if gap + ): + # XXX adapt updates and chat hashes into updatescombined, raise gap on too long + date = updates.date + seq_start = updates.seq_start + seq = updates.seq + updates = updates.updates + users = updates.users + chats = updates.chats + + # > For all the other [not `updates` or `updatesCombined`] `Updates` type constructors + # > there is no need to check `seq` or change a local state. + if updates.seq_start != NO_SEQ: + if self.seq + 1 > updates.seq_start: + # Skipping updates that were already handled + return (updates.users, updates.chats) + elif self.seq + 1 < updates.seq_start: + # Gap detected + self.begin_get_diff(ENTRY_ACCOUNT) + raise GapError + # else apply + + self.date = updates.date + if updates.seq != NO_SEQ: + self.seq = updates.seq + + result.extend(filter(None, (self.apply_pts_info(u, reset_deadline=True) for u in updates.updates))) + + self.apply_deadlines_reset() + + def _sort_gaps(update): + pts = PtsInfo.from_update(u) + return pts.pts - pts.pts_count if pts else 0 + + if self.possible_gaps: + # For each update in possible gaps, see if the gap has been resolved already. + for key in list(self.possible_gaps.keys()): + self.possible_gaps[key].updates.sort(key=_sort_gaps) + + for _ in range(len(self.possible_gaps[key].updates)): + update = self.possible_gaps[key].updates.pop(0) + + # If this fails to apply, it will get re-inserted at the end. + # All should fail, so the order will be preserved (it would've cycled once). + update = self.apply_pts_info(update, reset_deadline=False) + if update: + result.append(update) + + # Clear now-empty gaps. + self.possible_gaps = {entry: gap for entry, gap in self.possible_gaps if gap.updates} + + return (updates.users, updates.chats) + + # Tries to apply the input update if its `PtsInfo` follows the correct order. + # + # If the update can be applied, it is returned; otherwise, the update is stored in a + # possible gap (unless it was already handled or would be handled through getting + # difference) and `None` is returned. + def apply_pts_info( + self, + update, + *, + reset_deadline, + ): + pts = PtsInfo.from_update(update) + if not pts: + # No pts means that the update can be applied in any order. + return update + + # As soon as we receive an update of any form related to messages (has `PtsInfo`), + # the "no updates" period for that entry is reset. + # + # Build the `HashSet` to avoid calling `reset_deadline` more than once for the same entry. + if reset_deadline: + self.reset_deadlines_for.insert(pts.entry) + + if pts.entry in self.getting_diff_for: + # Note: early returning here also prevents gap from being inserted (which they should + # not be while getting difference). + return None + + if pts.entry in self.map: + local_pts = self.map[pts.entry].pts + if local_pts + pts.pts_count > pts.pts: + # Ignore + return None + elif local_pts + pts.pts_count < pts.pts: + # Possible gap + # TODO store chats too? + if pts.entry not in self.possible_gaps: + self.possible_gaps[pts.entry] = PossibleGap( + deadline=asyncio.get_running_loop().time() + POSSIBLE_GAP_TIMEOUT, + updates=[] + ) + + self.possible_gaps[pts.entry].updates.append(update) + return None + else: + # Apply + pass + else: + # No previous `pts` known, and because this update has to be "right" (it's the first one) our + # `local_pts` must be one less. + local_pts = pts.pts - 1 + + # For example, when we're in a channel, we immediately receive: + # * ReadChannelInbox (pts = X) + # * NewChannelMessage (pts = X, pts_count = 1) + # + # Notice how both `pts` are the same. If we stored the one from the first, then the second one would + # be considered "already handled" and ignored, which is not desirable. Instead, advance local `pts` + # by `pts_count` (which is 0 for updates not directly related to messages, like reading inbox). + if pts.entry in self.map: + self.map[pts.entry].pts = local_pts + pts.pts_count + else: + self.map[pts.entry] = State(pts=local_pts + pts.pts_count, deadline=next_updates_deadline()) + + return update + + # endregion "Normal" updates flow (processing and detection of gaps). + + # region Getting and applying account difference. + + # Return the request that needs to be made to get the difference, if any. + def get_difference(self): + entry = ENTRY_ACCOUNT + if entry in self.getting_diff_for: + if entry in self.map: + return _tl.fn.updates.GetDifference( + pts=state.pts, + pts_total_limit=None, + date=self.date, + qts=self.map[ENTRY_SECRET].pts, + ) + else: + # TODO investigate when/why/if this can happen + self.end_get_diff(entry) + + return None + + # Similar to [`MessageBox::process_updates`], but using the result from getting difference. + def apply_difference( + self, + diff, + chat_hashes, + ): + if isinstance(diff, _tl.updates.DifferenceEmpty): + self.date = diff.date + self.seq = diff.seq + self.end_get_diff(ENTRY_ACCOUNT) + return [], [], [] + elif isinstance(diff, _tl.updates.Difference): + self.end_get_diff(ENTRY_ACCOUNT) + chat_hashes.extend(diff.users, diff.chats) + return self.apply_difference_type(diff) + elif isinstance(diff, _tl.updates.DifferenceSlice): + chat_hashes.extend(diff.users, diff.chats) + return self.apply_difference_type(diff) + elif isinstance(diff, _tl.updates.DifferenceTooLong): + # TODO when are deadlines reset if we update the map?? + self.map[ENTRY_ACCOUNT].pts = diff.pts + self.end_get_diff(ENTRY_ACCOUNT) + return [], [], [] + + def apply_difference_type( + self, + diff, + ): + state = getattr(diff, 'intermediate_state', None) or diff.state + self.map[ENTRY_ACCOUNT].pts = state.pts + self.map[ENTRY_SECRET].pts = state.qts + self.date = state.date + self.seq = state.seq + + for u in diff.updates: + if isinstance(u, _tl.UpdateChannelTooLong): + self.begin_get_diff(u.channel_id) + + updates.extend(_tl.UpdateNewMessage( + message=m, + pts=NO_SEQ, + pts_count=NO_SEQ, + ) for m in diff.new_messages) + updates.extend(_tl.UpdateNewEncryptedMessage( + message=m, + qts=NO_SEQ, + ) for m in diff.new_encrypted_messages) + + return diff.updates, diff.users, diff.chats + + # endregion Getting and applying account difference. + + # region Getting and applying channel difference. + + # Return the request that needs to be made to get a channel's difference, if any. + def get_channel_difference( + self, + chat_hashes, + ): + entry = next((id for id in self.getting_diff_for if isinstance(id, int)), None) + if not entry: + return None + + packed = chat_hashes.get(entry) + if not packed: + # Cannot get channel difference as we're missing its hash + self.end_get_diff(entry) + # Remove the outdated `pts` entry from the map so that the next update can correct + # it. Otherwise, it will spam that the access hash is missing. + self.map.pop(entry, None) + return None + + state = self.map.get(entry) + if not state: + # TODO investigate when/why/if this can happen + # Cannot get channel difference as we're missing its pts + self.end_get_diff(entry) + return None + + return _tl.fn.updates.GetChannelDifference( + force=False, + channel=channel, + filter=_tl.ChannelMessagesFilterEmpty(), + pts=state.pts, + limit=BOT_CHANNEL_DIFF_LIMIT if chat_hashes.is_self_bot() else USER_CHANNEL_DIFF_LIMIT + ) + + # Similar to [`MessageBox::process_updates`], but using the result from getting difference. + def apply_channel_difference( + self, + request, + diff, + chat_hashes, + ): + entry = request.channel.channel_id + self.possible_gaps.remove(entry) + + if isinstance(diff, _tl.updates.ChannelDifferenceEmpty): + assert diff.final + self.end_get_diff(entry) + self.map[entry].pts = diff.pts + return [], [], [] + elif isinstance(diff, _tl.updates.ChannelDifferenceTooLong): + assert diff.final + self.map[entry].pts = diff.dialog.pts + chat_hashes.extend(diff.users, diff.chats) + self.reset_channel_deadline(channel_id, diff.timeout) + # This `diff` has the "latest messages and corresponding chats", but it would + # be strange to give the user only partial changes of these when they would + # expect all updates to be fetched. Instead, nothing is returned. + return [], [], [] + elif isinstance(diff, _tl.updates.ChannelDifference): + if diff.final: + self.end_get_diff(entry) + + self.map[entry].pts = pts + updates.extend(_tl.UpdateNewMessage( + message=m, + pts=NO_SEQ, + pts_count=NO_SEQ, + ) for m in diff.new_messages) + chat_hashes.extend(diff.users, diff.chats); + self.reset_channel_deadline(channel_id, timeout) + + (diff.updates, diff.users, diff.chats) + + # endregion Getting and applying channel difference. From 01291922c97ae0d78a7be0ab3b8fcc2f573e03c1 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 22 Jan 2022 13:27:00 +0100 Subject: [PATCH 116/131] Proper usage of messagebox in update handling loop --- telethon/_client/updates.py | 43 ++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index 2460c561..b0d632b5 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -7,6 +7,7 @@ import time import traceback import typing import logging +from collections import deque from ..errors._rpcbase import RpcError from .._events.common import EventBuilder, EventCommon @@ -82,6 +83,42 @@ async def catch_up(self: 'TelegramClient'): pass async def _update_loop(self: 'TelegramClient'): - while self.is_connected(): - updates = await self._updates_queue.get() - updates, users, chats = self._message_box.process_updates(updates, self._entity_cache) + try: + updates_to_dispatch = deque() + while self.is_connected(): + if updates_to_dispatch: + # TODO dispatch + updates_to_dispatch.popleft() + continue + + get_diff = self._message_box.get_difference() + if get_diff: + self._log[__name__].info('Getting difference for account updates') + diff = await self(get_diff) + updates, users, chats = self._message_box.apply_difference(diff, self._entity_cache) + updates_to_dispatch.extend(updates) + continue + + get_diff = self._message_box.get_channel_difference(self._entity_cache) + if get_diff: + self._log[__name__].info('Getting difference for channel updates') + diff = await self(get_diff) + updates, users, chats = self._message_box.apply_channel_difference(diff, self._entity_cache) + updates_to_dispatch.extend(updates) + continue + + deadline = self._message_box.check_deadlines() + try: + updates = await asyncio.wait_for( + self._updates_queue.get(), + deadline - asyncio.get_running_loop().time() + ) + except asyncio.TimeoutError: + self._log[__name__].info('Timeout waiting for updates expired') + continue + + processed = [] + users, chats = self._message_box.process_updates(updates, self._entity_cache, processed) + updates_to_dispatch.extend(processed) + except Exception: + self._log[__name__].exception('Fatal error handling updates (this is a bug in Telethon, please report it)') From 259fccaaa90e73b64d90d86993f4bf54e719be1b Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sat, 22 Jan 2022 13:27:14 +0100 Subject: [PATCH 117/131] Fix messagebox porting errors --- telethon/_updates/messagebox.py | 66 +++++++++++++++++---------------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/telethon/_updates/messagebox.py b/telethon/_updates/messagebox.py index 555451ad..3035ae0f 100644 --- a/telethon/_updates/messagebox.py +++ b/telethon/_updates/messagebox.py @@ -19,6 +19,7 @@ to get the difference. import asyncio from dataclasses import dataclass, field from .._sessions.types import SessionState, ChannelState +from .. import _tl # Telegram sends `seq` equal to `0` when "it doesn't matter", so we use that value too. @@ -194,7 +195,7 @@ class MessageBox: # Most of the time there will be zero or one gap in flight so finding the minimum is cheap. if self.possible_gaps: - deadline = min(deadline, *self.possible_gaps.values()) + deadline = min(deadline, *(gap.deadline for gap in self.possible_gaps.values())) elif self.next_deadline in self.map: deadline = min(deadline, self.map[self.next_deadline]) @@ -272,7 +273,10 @@ class MessageBox: # # It also resets the deadline. def end_get_diff(self, entry): - self.getting_diff_for.pop(entry, None) + try: + self.getting_diff_for.remove(entry) + except KeyError: + pass self.reset_deadline(entry, next_updates_deadline()) assert entry not in self.possible_gaps, "gaps shouldn't be created while getting difference" @@ -298,36 +302,39 @@ class MessageBox: chat_hashes, result, # out list of updates; returns list of user, chat, or raise if gap ): - # XXX adapt updates and chat hashes into updatescombined, raise gap on too long - date = updates.date - seq_start = updates.seq_start - seq = updates.seq - updates = updates.updates - users = updates.users - chats = updates.chats + date = getattr(updates, 'date', None) + if date is None: + # updatesTooLong is the only one with no date (we treat it as a gap) + raise GapError + + seq = getattr(updates, 'seq', None) or NO_SEQ + seq_start = getattr(updates, 'seq_start', None) or seq + users = getattr(updates, 'users') or [] + chats = getattr(updates, 'chats') or [] + updates = getattr(updates, 'updates', None) or [updates] # > For all the other [not `updates` or `updatesCombined`] `Updates` type constructors # > there is no need to check `seq` or change a local state. - if updates.seq_start != NO_SEQ: - if self.seq + 1 > updates.seq_start: + if seq_start != NO_SEQ: + if self.seq + 1 > seq_start: # Skipping updates that were already handled - return (updates.users, updates.chats) - elif self.seq + 1 < updates.seq_start: + return (users, chats) + elif self.seq + 1 < seq_start: # Gap detected self.begin_get_diff(ENTRY_ACCOUNT) raise GapError # else apply - self.date = updates.date - if updates.seq != NO_SEQ: - self.seq = updates.seq + self.date = date + if seq != NO_SEQ: + self.seq = seq - result.extend(filter(None, (self.apply_pts_info(u, reset_deadline=True) for u in updates.updates))) + result.extend(filter(None, (self.apply_pts_info(u, reset_deadline=True) for u in updates))) self.apply_deadlines_reset() def _sort_gaps(update): - pts = PtsInfo.from_update(u) + pts = PtsInfo.from_update(update) return pts.pts - pts.pts_count if pts else 0 if self.possible_gaps: @@ -345,9 +352,9 @@ class MessageBox: result.append(update) # Clear now-empty gaps. - self.possible_gaps = {entry: gap for entry, gap in self.possible_gaps if gap.updates} + self.possible_gaps = {entry: gap for entry, gap in self.possible_gaps.items() if gap.updates} - return (updates.users, updates.chats) + return (users, chats) # Tries to apply the input update if its `PtsInfo` follows the correct order. # @@ -370,7 +377,7 @@ class MessageBox: # # Build the `HashSet` to avoid calling `reset_deadline` more than once for the same entry. if reset_deadline: - self.reset_deadlines_for.insert(pts.entry) + self.reset_deadlines_for.add(pts.entry) if pts.entry in self.getting_diff_for: # Note: early returning here also prevents gap from being inserted (which they should @@ -425,10 +432,10 @@ class MessageBox: if entry in self.getting_diff_for: if entry in self.map: return _tl.fn.updates.GetDifference( - pts=state.pts, + pts=self.map[ENTRY_ACCOUNT].pts, pts_total_limit=None, date=self.date, - qts=self.map[ENTRY_SECRET].pts, + qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else NO_SEQ, ) else: # TODO investigate when/why/if this can happen @@ -465,26 +472,23 @@ class MessageBox: diff, ): state = getattr(diff, 'intermediate_state', None) or diff.state - self.map[ENTRY_ACCOUNT].pts = state.pts - self.map[ENTRY_SECRET].pts = state.qts - self.date = state.date - self.seq = state.seq + self.set_state(state) - for u in diff.updates: + for u in diff.other_updates: if isinstance(u, _tl.UpdateChannelTooLong): self.begin_get_diff(u.channel_id) - updates.extend(_tl.UpdateNewMessage( + diff.other_updates.extend(_tl.UpdateNewMessage( message=m, pts=NO_SEQ, pts_count=NO_SEQ, ) for m in diff.new_messages) - updates.extend(_tl.UpdateNewEncryptedMessage( + diff.other_updates.extend(_tl.UpdateNewEncryptedMessage( message=m, qts=NO_SEQ, ) for m in diff.new_encrypted_messages) - return diff.updates, diff.users, diff.chats + return diff.other_updates, diff.users, diff.chats # endregion Getting and applying account difference. From 0d597d1003a32b8df0882a6659cb8aacd354eeb0 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 23 Jan 2022 12:23:56 +0100 Subject: [PATCH 118/131] Remove GitHub workflow It's currently broken. --- .github/workflows/python.yml | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 .github/workflows/python.yml diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml deleted file mode 100644 index f3fd3106..00000000 --- a/.github/workflows/python.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Python Library - -on: [push, pull_request] - -jobs: - build: - - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] - steps: - - uses: actions/checkout@v1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - name: Set up env - run: | - python -m pip install --upgrade pip - pip install tox - - name: Lint with flake8 - run: | - tox -e flake - - name: Test with pytest - run: | - # use "py", which is the default python version - tox -e py From de2cd1f2cfd568e3829bacffb9191d12f3121397 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 23 Jan 2022 12:34:16 +0100 Subject: [PATCH 119/131] Fix constructing PtsInfo for channels --- telethon/_updates/messagebox.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/telethon/_updates/messagebox.py b/telethon/_updates/messagebox.py index 3035ae0f..542fc629 100644 --- a/telethon/_updates/messagebox.py +++ b/telethon/_updates/messagebox.py @@ -69,7 +69,10 @@ class PtsInfo: pts = getattr(update, 'pts', None) if pts: pts_count = getattr(update, 'pts_count', None) or 0 - entry = getattr(update, 'channel_id', None) or ENTRY_ACCOUNT + try: + entry = update.message.peer_id.channel_id + except AttributeError: + entry = getattr(update, 'channel_id', None) or ENTRY_ACCOUNT return cls(pts=pts, pts_count=pts_count, entry=entry) qts = getattr(update, 'qts', None) From 1f40372235e0ffbddff7832e48e46252b2efb1d6 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 23 Jan 2022 12:43:41 +0100 Subject: [PATCH 120/131] Fix update handling for channels --- telethon/_client/updates.py | 5 ++++- telethon/_updates/messagebox.py | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index b0d632b5..e3e8fd78 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -96,6 +96,7 @@ async def _update_loop(self: 'TelegramClient'): self._log[__name__].info('Getting difference for account updates') diff = await self(get_diff) updates, users, chats = self._message_box.apply_difference(diff, self._entity_cache) + self._entity_cache.extend(users, chats) updates_to_dispatch.extend(updates) continue @@ -103,7 +104,8 @@ async def _update_loop(self: 'TelegramClient'): if get_diff: self._log[__name__].info('Getting difference for channel updates') diff = await self(get_diff) - updates, users, chats = self._message_box.apply_channel_difference(diff, self._entity_cache) + updates, users, chats = self._message_box.apply_channel_difference(get_diff, diff, self._entity_cache) + self._entity_cache.extend(users, chats) updates_to_dispatch.extend(updates) continue @@ -119,6 +121,7 @@ async def _update_loop(self: 'TelegramClient'): processed = [] users, chats = self._message_box.process_updates(updates, self._entity_cache, processed) + self._entity_cache.extend(users, chats) updates_to_dispatch.extend(processed) except Exception: self._log[__name__].exception('Fatal error handling updates (this is a bug in Telethon, please report it)') diff --git a/telethon/_updates/messagebox.py b/telethon/_updates/messagebox.py index 542fc629..235247f3 100644 --- a/telethon/_updates/messagebox.py +++ b/telethon/_updates/messagebox.py @@ -232,7 +232,7 @@ class MessageBox: # Convenience to reset a channel's deadline, with optional timeout. def reset_channel_deadline(self, channel_id, timeout): - self.reset_deadlines(channel_id, asyncio.get_running_loop().time() + (timeout or NO_UPDATES_TIMEOUT)) + self.reset_deadline(channel_id, asyncio.get_running_loop().time() + (timeout or NO_UPDATES_TIMEOUT)) # Reset all the deadlines in `reset_deadlines_for` and then empty the set. def apply_deadlines_reset(self): @@ -524,10 +524,10 @@ class MessageBox: return _tl.fn.updates.GetChannelDifference( force=False, - channel=channel, + channel=packed.try_to_input_channel(), filter=_tl.ChannelMessagesFilterEmpty(), pts=state.pts, - limit=BOT_CHANNEL_DIFF_LIMIT if chat_hashes.is_self_bot() else USER_CHANNEL_DIFF_LIMIT + limit=BOT_CHANNEL_DIFF_LIMIT if chat_hashes.self_bot else USER_CHANNEL_DIFF_LIMIT ) # Similar to [`MessageBox::process_updates`], but using the result from getting difference. @@ -538,7 +538,7 @@ class MessageBox: chat_hashes, ): entry = request.channel.channel_id - self.possible_gaps.remove(entry) + self.possible_gaps.pop(entry, None) if isinstance(diff, _tl.updates.ChannelDifferenceEmpty): assert diff.final @@ -549,7 +549,7 @@ class MessageBox: assert diff.final self.map[entry].pts = diff.dialog.pts chat_hashes.extend(diff.users, diff.chats) - self.reset_channel_deadline(channel_id, diff.timeout) + self.reset_channel_deadline(entry, diff.timeout) # This `diff` has the "latest messages and corresponding chats", but it would # be strange to give the user only partial changes of these when they would # expect all updates to be fetched. Instead, nothing is returned. @@ -558,15 +558,15 @@ class MessageBox: if diff.final: self.end_get_diff(entry) - self.map[entry].pts = pts - updates.extend(_tl.UpdateNewMessage( + self.map[entry].pts = diff.pts + diff.other_updates.extend(_tl.UpdateNewMessage( message=m, pts=NO_SEQ, pts_count=NO_SEQ, ) for m in diff.new_messages) - chat_hashes.extend(diff.users, diff.chats); - self.reset_channel_deadline(channel_id, timeout) + chat_hashes.extend(diff.users, diff.chats) + self.reset_channel_deadline(entry, None) - (diff.updates, diff.users, diff.chats) + return diff.other_updates, diff.users, diff.chats # endregion Getting and applying channel difference. From f1a517dee6e1b4592fa13f4de7f5d731f9dfed07 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 23 Jan 2022 13:20:35 +0100 Subject: [PATCH 121/131] Process self-produced updates like any other --- readthedocs/misc/v2-migration-guide.rst | 2 ++ telethon/_network/mtprotosender.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 669ed8a8..dc751b1a 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -771,3 +771,5 @@ sequential_updates is gone connection type is gone raise_last_call_error is now the default rather than ValueError + +self-produced updates like getmessage now also trigger a handler diff --git a/telethon/_network/mtprotosender.py b/telethon/_network/mtprotosender.py index fa58240f..177813cd 100644 --- a/telethon/_network/mtprotosender.py +++ b/telethon/_network/mtprotosender.py @@ -603,6 +603,7 @@ class MTProtoSender: if not state.future.cancelled(): state.future.set_exception(e) else: + self._store_own_updates(result) if not state.future.cancelled(): state.future.set_result(result) @@ -646,6 +647,20 @@ class MTProtoSender: ) self._last_update_warn = now + def _store_own_updates(self, obj, *, _update_ids=frozenset(( + _tl.UpdateShortMessage.CONSTRUCTOR_ID, + _tl.UpdateShortChatMessage.CONSTRUCTOR_ID, + _tl.UpdateShort.CONSTRUCTOR_ID, + _tl.UpdatesCombined.CONSTRUCTOR_ID, + _tl.Updates.CONSTRUCTOR_ID, + _tl.UpdateShortSentMessage.CONSTRUCTOR_ID, + ))): + try: + if obj.CONSTRUCTOR_ID in _update_ids: + self._updates_queue.put_nowait(obj) + except AttributeError: + pass + async def _handle_pong(self, message): """ Handles pong results, which don't come inside a ``rpc_result`` From 015acf20c6b8e7ce06b17e5f0b017b410b1f0751 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 23 Jan 2022 13:26:53 +0100 Subject: [PATCH 122/131] Handle TypeNotFoundError during gzip packed msgs --- telethon/_network/mtprotosender.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/telethon/_network/mtprotosender.py b/telethon/_network/mtprotosender.py index 177813cd..2c9c1c59 100644 --- a/telethon/_network/mtprotosender.py +++ b/telethon/_network/mtprotosender.py @@ -625,8 +625,16 @@ class MTProtoSender: """ self._log.debug('Handling gzipped data') with BinaryReader(message.obj.data) as reader: - message.obj = reader.tgread_object() - await self._process_message(message) + try: + message.obj = reader.tgread_object() + except TypeNotFoundError as e: + # Received object which we don't know how to deserialize. + # This is somewhat expected while receiving updates, which + # will eventually trigger a gap error to recover from. + self._log.info('Type %08x not found, remaining data %r', + e.invalid_constructor_id, e.remaining) + else: + await self._process_message(message) async def _handle_update(self, message): try: From f547a00da3e4e04f56ca48fec9591d200c9522cf Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 23 Jan 2022 19:46:37 +0100 Subject: [PATCH 123/131] Persist session state and usage fixes Catching up is now an option when creating the client. --- telethon/_client/telegrambaseclient.py | 13 +++++++++ telethon/_sessions/sqlite.py | 2 +- telethon/_updates/entitycache.py | 3 ++ telethon/_updates/messagebox.py | 40 +++++++++++--------------- 4 files changed, 33 insertions(+), 25 deletions(-) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index b6872dc8..5c32d086 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -91,6 +91,7 @@ def init( request_retries: int = 4, flood_sleep_threshold: int = 60, # Update handling. + catch_up: bool = False, receive_updates: bool = True, max_queued_updates: int = 100, ): @@ -142,6 +143,7 @@ def init( self._parse_mode = markdown # Update handling. + self._catch_up = catch_up self._no_updates = not receive_updates self._updates_queue = asyncio.Queue(maxsize=max_queued_updates) self._updates_handle = None @@ -232,6 +234,8 @@ async def connect(self: 'TelegramClient') -> None: ) else: try_fetch_user = self._session_state.user_id == 0 + if self._catch_up: + self._message_box.load(self._session_state, await self._session.get_all_channel_states()) dc = all_dcs.get(self._session_state.dc_id) if dc is None: @@ -358,6 +362,15 @@ async def _disconnect(self: 'TelegramClient'): except asyncio.CancelledError: pass + await self._session.insert_entities(self._entity_cache.get_all_entities()) + + session_state, channel_states = self._message_box.session_state() + for channel_id, pts in channel_states.items(): + await self._session.insert_channel_state(channel_id, pts) + + await self._replace_session_state(**session_state) + + async def _switch_dc(self: 'TelegramClient', new_dc): """ Permanently switches the current connection to the new data center. diff --git a/telethon/_sessions/sqlite.py b/telethon/_sessions/sqlite.py index 2ea419be..b41975fb 100644 --- a/telethon/_sessions/sqlite.py +++ b/telethon/_sessions/sqlite.py @@ -245,7 +245,7 @@ class SQLiteSession(Session): try: c.executemany( 'insert or replace into entity values (?,?,?)', - [(e.id, e.access_hash, e.ty) for e in entities] + [(e.id, e.access_hash, e.ty.value) for e in entities] ) finally: c.close() diff --git a/telethon/_updates/entitycache.py b/telethon/_updates/entitycache.py index 176d2013..ce89eb4f 100644 --- a/telethon/_updates/entitycache.py +++ b/telethon/_updates/entitycache.py @@ -95,3 +95,6 @@ class EntityCache: for c in chats if getattr(c, 'access_hash', None) and not getattr(c, 'min', None) ) + + def get_all_entities(self): + return [Entity(ty, id, hash) for id, (hash, ty) in self.hash_map.items()] diff --git a/telethon/_updates/messagebox.py b/telethon/_updates/messagebox.py index 235247f3..232ef446 100644 --- a/telethon/_updates/messagebox.py +++ b/telethon/_updates/messagebox.py @@ -141,46 +141,38 @@ class MessageBox: # region Creation, querying, and setting base state. - @classmethod - def load(cls, session_state, channel_states): + def load(self, session_state, channel_states): """ Create a [`MessageBox`] from a previously known update state. """ deadline = next_updates_deadline() - return cls( - map={ - ENTRY_ACCOUNT: State(pts=session_state.pts, deadline=deadline), - ENTRY_SECRET: State(pts=session_state.qts, deadline=deadline), - **{s.channel_id: s.pts for s in channel_states} - }, - date=session_state.date, - seq=session_state.seq, - next_deadline=ENTRY_ACCOUNT, - ) + self.map = { + ENTRY_ACCOUNT: State(pts=session_state.pts, deadline=deadline), + ENTRY_SECRET: State(pts=session_state.qts, deadline=deadline), + **{s.channel_id: State(pts=s.pts, deadline=deadline) for s in channel_states} + } + self.date = session_state.date + self.seq = session_state.seq + self.next_deadline = ENTRY_ACCOUNT - @classmethod def session_state(self): """ - Return the current state in a format that sessions understand. + Return the current state. This should be used for persisting the state. """ - return SessionState( - user_id=0, - dc_id=0, - bot=False, - pts=self.map.get(ENTRY_ACCOUNT, 0), - qts=self.map.get(ENTRY_SECRET, 0), + return dict( + pts=self.map[ENTRY_ACCOUNT].pts if ENTRY_ACCOUNT in self.map else 0, + qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else 0, date=self.date, seq=self.seq, - takeout_id=None, - ), [ChannelState(channel_id=id, pts=pts) for id, pts in self.map.items() if isinstance(id, int)] + ), {id: state.pts for id, state in self.map.items() if isinstance(id, int)} def is_empty(self) -> bool: """ Return true if the message box is empty and has no state yet. """ - return self.map.get(ENTRY_ACCOUNT, NO_SEQ) == NO_SEQ + return ENTRY_ACCOUNT not in self.map or self.map[ENTRY_ACCOUNT] == NO_SEQ def check_deadlines(self): """ @@ -200,7 +192,7 @@ class MessageBox: if self.possible_gaps: deadline = min(deadline, *(gap.deadline for gap in self.possible_gaps.values())) elif self.next_deadline in self.map: - deadline = min(deadline, self.map[self.next_deadline]) + deadline = min(deadline, self.map[self.next_deadline].deadline) if now > deadline: # Check all expired entries and add them to the list that needs getting difference. From 4b85ced1e10193812743fff522c15e12d0733a3e Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 23 Jan 2022 19:53:48 +0100 Subject: [PATCH 124/131] Reimplement catch_up --- telethon/_client/telegramclient.py | 6 ++---- telethon/_client/updates.py | 5 ++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 4504d589..3a39fb28 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -2901,11 +2901,9 @@ class TelegramClient: @forward_call(updates.catch_up) async def catch_up(self: 'TelegramClient'): """ - "Catches up" on the missed updates while the client was offline. - You should call this method after registering the event handlers - so that the updates it loads can by processed by your script. + Forces the client to "catch-up" on missed updates. - This can also be used to forcibly fetch new updates if there are any. + The method does not wait for all updates to be received. Example .. code-block:: python diff --git a/telethon/_client/updates.py b/telethon/_client/updates.py index e3e8fd78..cf26e809 100644 --- a/telethon/_client/updates.py +++ b/telethon/_client/updates.py @@ -80,7 +80,10 @@ def list_event_handlers(self: 'TelegramClient')\ return [(callback, event) for event, callback in self._event_builders] async def catch_up(self: 'TelegramClient'): - pass + # The update loop is probably blocked on either timeout or an update to arrive. + # Unblock the loop by pushing a dummy update which will always trigger a gap. + # This, in return, causes the update loop to catch up. + await self._updates_queue.put(_tl.UpdatesTooLong()) async def _update_loop(self: 'TelegramClient'): try: From 3aa53dd9811038cc9365840beb0f6bdc871bc15c Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 24 Jan 2022 10:59:32 +0100 Subject: [PATCH 125/131] Add missing catch_up param to client init --- telethon/_client/telegramclient.py | 1 + 1 file changed, 1 insertion(+) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 3a39fb28..d78b0ce6 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -2664,6 +2664,7 @@ class TelegramClient: request_retries: int = 4, flood_sleep_threshold: int = 60, # Update handling. + catch_up: bool = False, receive_updates: bool = True, max_queued_updates: int = 100, ): From 4b61ce18ff70d53562911b34f252b5dc08d50ce4 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 24 Jan 2022 11:00:20 +0100 Subject: [PATCH 126/131] Don't store empty pts in messagebox This lets us rely on "not present" for "not initialized", as opposed to having to check not present OR not empty, and helps prevent more bugs. --- telethon/_updates/messagebox.py | 34 ++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/telethon/_updates/messagebox.py b/telethon/_updates/messagebox.py index 232ef446..54c23a49 100644 --- a/telethon/_updates/messagebox.py +++ b/telethon/_updates/messagebox.py @@ -118,7 +118,7 @@ class MessageBox: # Additional fields beyond PTS needed by `ENTRY_ACCOUNT`. date: int = 1 - seq: int = 0 + seq: int = NO_SEQ # Holds the entry with the closest deadline (optimization to avoid recalculating the minimum deadline). next_deadline: object = None # entry @@ -146,11 +146,14 @@ class MessageBox: Create a [`MessageBox`] from a previously known update state. """ deadline = next_updates_deadline() - self.map = { - ENTRY_ACCOUNT: State(pts=session_state.pts, deadline=deadline), - ENTRY_SECRET: State(pts=session_state.qts, deadline=deadline), - **{s.channel_id: State(pts=s.pts, deadline=deadline) for s in channel_states} - } + + self.map.clear() + if session_state.pts != NO_SEQ: + self.map[ENTRY_ACCOUNT] = State(pts=session_state.pts, deadline=deadline) + if session_state.qts != NO_SEQ: + self.map[ENTRY_SECRET] = State(pts=session_state.qts, deadline=deadline) + self.map.update((s.channel_id, State(pts=s.pts, deadline=deadline)) for s in channel_states) + self.date = session_state.date self.seq = session_state.seq self.next_deadline = ENTRY_ACCOUNT @@ -162,8 +165,8 @@ class MessageBox: This should be used for persisting the state. """ return dict( - pts=self.map[ENTRY_ACCOUNT].pts if ENTRY_ACCOUNT in self.map else 0, - qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else 0, + pts=self.map[ENTRY_ACCOUNT].pts if ENTRY_ACCOUNT in self.map else NO_SEQ, + qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else NO_SEQ, date=self.date, seq=self.seq, ), {id: state.pts for id, state in self.map.items() if isinstance(id, int)} @@ -172,7 +175,7 @@ class MessageBox: """ Return true if the message box is empty and has no state yet. """ - return ENTRY_ACCOUNT not in self.map or self.map[ENTRY_ACCOUNT] == NO_SEQ + return ENTRY_ACCOUNT not in self.map def check_deadlines(self): """ @@ -245,8 +248,17 @@ class MessageBox: # updates will be fetched. def set_state(self, state): deadline = next_updates_deadline() - self.map[ENTRY_ACCOUNT] = State(pts=state.pts, deadline=deadline) - self.map[ENTRY_SECRET] = State(pts=state.qts, deadline=deadline) + + if state.pts != NO_SEQ: + self.map[ENTRY_ACCOUNT] = State(pts=state.pts, deadline=deadline) + else: + self.map.pop(ENTRY_ACCOUNT, None) + + if state.qts != NO_SEQ: + self.map[ENTRY_SECRET] = State(pts=state.qts, deadline=deadline) + else: + self.map.pop(ENTRY_SECRET, None) + self.date = state.date self.seq = state.seq From f7ccf8d8438306e3d7691f856c565b7970e6391e Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 24 Jan 2022 11:05:27 +0100 Subject: [PATCH 127/131] Fix reset_deadline check in messagebox --- telethon/_updates/messagebox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/_updates/messagebox.py b/telethon/_updates/messagebox.py index 54c23a49..1562cd21 100644 --- a/telethon/_updates/messagebox.py +++ b/telethon/_updates/messagebox.py @@ -220,7 +220,7 @@ class MessageBox: if self.next_deadline == entry: # If the updated deadline was the closest one, recalculate the new minimum. self.next_deadline = min(self.map.items(), key=lambda entry_state: entry_state[1].deadline)[0] - elif deadline < self.map.get(self.next_deadline, 0): + elif self.next_deadline in self.map and deadline < self.map[self.next_deadline].deadline: # If the updated deadline is smaller than the next deadline, change the next deadline to be the new one. self.next_deadline = entry # else an unrelated deadline was updated, so the closest one remains unchanged. From f7754841723211a3ed013b772cf49172ba7b2b9c Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 24 Jan 2022 11:05:58 +0100 Subject: [PATCH 128/131] Properly load and save channel state --- telethon/_client/telegrambaseclient.py | 11 ++++++++--- telethon/_updates/entitycache.py | 3 +++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/telethon/_client/telegrambaseclient.py b/telethon/_client/telegrambaseclient.py index 5c32d086..480263e4 100644 --- a/telethon/_client/telegrambaseclient.py +++ b/telethon/_client/telegrambaseclient.py @@ -14,7 +14,7 @@ from .._crypto import rsa from .._misc import markdown, enums, helpers from .._network import MTProtoSender, Connection, transports from .._sessions import Session, SQLiteSession, MemorySession -from .._sessions.types import DataCenter, SessionState +from .._sessions.types import DataCenter, SessionState, EntityType, ChannelState from .._updates import EntityCache, MessageBox DEFAULT_DC_ID = 2 @@ -235,7 +235,12 @@ async def connect(self: 'TelegramClient') -> None: else: try_fetch_user = self._session_state.user_id == 0 if self._catch_up: - self._message_box.load(self._session_state, await self._session.get_all_channel_states()) + channel_states = await self._session.get_all_channel_states() + self._message_box.load(self._session_state, channel_states) + for state in channel_states: + entity = await self._session.get_entity(EntityType.CHANNEL, state.channel_id) + if entity: + self._entity_cache.put(entity) dc = all_dcs.get(self._session_state.dc_id) if dc is None: @@ -366,7 +371,7 @@ async def _disconnect(self: 'TelegramClient'): session_state, channel_states = self._message_box.session_state() for channel_id, pts in channel_states.items(): - await self._session.insert_channel_state(channel_id, pts) + await self._session.insert_channel_state(ChannelState(channel_id=channel_id, pts=pts)) await self._replace_session_state(**session_state) diff --git a/telethon/_updates/entitycache.py b/telethon/_updates/entitycache.py index ce89eb4f..8dc95693 100644 --- a/telethon/_updates/entitycache.py +++ b/telethon/_updates/entitycache.py @@ -98,3 +98,6 @@ class EntityCache: def get_all_entities(self): return [Entity(ty, id, hash) for id, (hash, ty) in self.hash_map.items()] + + def put(self, entity): + self.hash_map[entity.id] = (entity.access_hash, entity.ty) From b0b1f304361e861878efbb87b3e141eb5bbc23cd Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 24 Jan 2022 11:21:01 +0100 Subject: [PATCH 129/131] Reintroduce keepalive pings in the sender --- telethon/_network/mtprotosender.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/telethon/_network/mtprotosender.py b/telethon/_network/mtprotosender.py index 2c9c1c59..fb6650ce 100644 --- a/telethon/_network/mtprotosender.py +++ b/telethon/_network/mtprotosender.py @@ -2,6 +2,7 @@ import asyncio import collections import struct import logging +import random from . import authenticator from .._misc.messagepacker import MessagePacker @@ -22,6 +23,7 @@ from .. import _tl UPDATE_BUFFER_FULL_WARN_DELAY = 15 * 60 +PING_DELAY = 60 class MTProtoSender: @@ -51,6 +53,7 @@ class MTProtoSender: self._updates_queue = updates_queue self._connect_lock = asyncio.Lock() self._ping = None + self._next_ping = None # Whether the user has explicitly connected or disconnected. # @@ -123,6 +126,7 @@ class MTProtoSender: self._connection = connection await self._connect() self._user_connected = True + self._next_ping = asyncio.get_running_loop().time() + PING_DELAY return True def is_connected(self): @@ -403,15 +407,15 @@ class MTProtoSender: self._reconnecting = True asyncio.create_task(self._reconnect(error)) - def _keepalive_ping(self, rnd_id): + def _trigger_keepalive_ping(self): """ Send a keep-alive ping. If a pong for the last ping was not received yet, this means we're probably not connected. """ - # TODO this is ugly, update loop shouldn't worry about this, sender should if self._ping is None: - self._ping = rnd_id - self.send(_tl.fn.Ping(rnd_id)) + self._ping = random.randrange(-2**63, 2**63) + self.send(_tl.fn.Ping(self._ping)) + self._next_ping = asyncio.get_running_loop().time() + PING_DELAY else: self._start_reconnect(None) @@ -435,7 +439,11 @@ class MTProtoSender: # TODO Wait for the connection send queue to be empty? # This means that while it's not empty we can wait for # more messages to be added to the send queue. - batch, data = await self._send_queue.get() + try: + batch, data = await asyncio.wait_for(self._send_queue.get(), self._next_ping - asyncio.get_running_loop().time()) + except asyncio.TimeoutError: + self._trigger_keepalive_ping() + continue if not data: continue From a25f0199649efcba83f054dfb305b2af6a34733a Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 24 Jan 2022 11:29:02 +0100 Subject: [PATCH 130/131] Review, unify and simplify retry_range usage --- telethon/_misc/helpers.py | 18 +++--------------- telethon/_network/mtprotosender.py | 9 ++++----- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/telethon/_misc/helpers.py b/telethon/_misc/helpers.py index a3480007..c6c76b8e 100644 --- a/telethon/_misc/helpers.py +++ b/telethon/_misc/helpers.py @@ -102,23 +102,11 @@ def strip_text(text, entities): return text -def retry_range(retries, force_retry=True): +def retry_range(retries): """ - Generates an integer sequence starting from 1. If `retries` is - not a zero or a positive integer value, the sequence will be - infinite, otherwise it will end at `retries + 1`. + Generates an integer sequence starting from 1, always returning once, and adding the given retries. """ - - # We need at least one iteration even if the retries are 0 - # when force_retry is True. - if force_retry and not (retries is None or retries < 0): - retries += 1 - - attempt = 0 - while attempt != retries: - attempt += 1 - yield attempt - + return range(1, max(retries, 0) + 2) async def _maybe_await(value): diff --git a/telethon/_network/mtprotosender.py b/telethon/_network/mtprotosender.py index fb6650ce..84f2b2c0 100644 --- a/telethon/_network/mtprotosender.py +++ b/telethon/_network/mtprotosender.py @@ -249,9 +249,9 @@ class MTProtoSender: break # all steps done, break retry loop else: if not connected: - raise ConnectionError('Connection to Telegram failed {} time(s)'.format(self._retries)) + raise ConnectionError('Connection to Telegram failed {} time(s)'.format(1 + self._retries)) - e = ConnectionError('auth_key generation failed {} time(s)'.format(self._retries)) + e = ConnectionError('auth_key generation failed {} time(s)'.format(1 + self._retries)) await self._disconnect(error=e) raise e @@ -349,12 +349,11 @@ class MTProtoSender: # Start with a clean state (and thus session ID) to avoid old msgs self._state.reset() - retries = self._retries if self._auto_reconnect else 0 + retry_range = helpers.retry_range(self._retries) if self._auto_reconnect else range(0) attempt = 0 ok = True - # We're already "retrying" to connect, so we don't want to force retries - for attempt in helpers.retry_range(retries, force_retry=False): + for attempt in retry_range: try: await self._connect() except (IOError, asyncio.TimeoutError) as e: From 539e3cb8081acbd9a5cc7a61c0731ca62842597e Mon Sep 17 00:00:00 2001 From: Devesh Pal Date: Mon, 24 Jan 2022 17:45:02 +0530 Subject: [PATCH 131/131] Add new features from new layer (#3676) Updated some documentation regarding raw API. get_permissions has been adjusted. Expose more parameters when sending messages. Update chat action. Support sending spoilers. Update buttons. --- readthedocs/examples/users.rst | 2 +- telethon/_client/chats.py | 9 ++------ telethon/_client/messages.py | 16 +++++++++---- telethon/_client/uploads.py | 9 ++++++-- telethon/_events/chataction.py | 14 ++++++++++-- telethon/_misc/html.py | 2 ++ telethon/_misc/markdown.py | 1 + telethon/types/_custom/button.py | 30 +++++++++++++++++++++++-- telethon/types/_custom/inlineresult.py | 8 +++++-- telethon/types/_custom/message.py | 12 ++++++++-- telethon/types/_custom/messagebutton.py | 8 ++++++- 11 files changed, 88 insertions(+), 23 deletions(-) diff --git a/readthedocs/examples/users.rst b/readthedocs/examples/users.rst index d9c648ae..ea83871d 100644 --- a/readthedocs/examples/users.rst +++ b/readthedocs/examples/users.rst @@ -25,7 +25,7 @@ you should use :tl:`GetFullUser`: # or even full = await client(GetFullUserRequest('username')) - bio = full.about + bio = full.full_user.about See :tl:`UserFull` to know what other fields you can access. diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 0759acc2..cd6d9726 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -634,13 +634,8 @@ async def get_permissions( entity = await self.get_entity(entity) if not user: - if isinstance(entity, _tl.Channel): - FullChat = await self(_tl.fn.channels.GetFullChannel(entity)) - elif isinstance(entity, _tl.Chat): - FullChat = await self(_tl.fn.messages.GetFullChat(entity)) - else: - return - return FullChat.chats[0].default_banned_rights + if helpers._entity_type(entity) != helpers._EntityType.USER: + return entity.default_banned_rights entity = await self.get_input_entity(entity) user = await self.get_input_entity(user) diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index e85921f3..250582d0 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -426,8 +426,10 @@ async def send_message( ttl: int = None, # - Send options reply_to: 'typing.Union[int, _tl.Message]' = None, + send_as: 'hints.EntityLike' = None, clear_draft: bool = False, background: bool = None, + noforwards: bool = None, schedule: 'hints.DateLike' = None, comment_to: 'typing.Union[int, _tl.Message]' = None, ) -> '_tl.Message': @@ -483,7 +485,7 @@ async def send_message( entity, message._file._media, reply_to_msg_id=reply_to, message=message._text, entities=message._fmt_entities, reply_markup=message._reply_markup, silent=message._silent, schedule_date=schedule, clear_draft=clear_draft, - background=background + background=background, noforwards=noforwards, send_as=send_as ) else: request = _tl.fn.messages.SendMessage( @@ -496,7 +498,9 @@ async def send_message( silent=silent, background=background, reply_markup=_custom.button.build_reply_markup(buttons), - schedule_date=schedule + schedule_date=schedule, + noforwards=noforwards, + send_as=send_as ) result = await self(request) @@ -525,7 +529,9 @@ async def forward_messages( with_my_score: bool = None, silent: bool = None, as_album: bool = None, - schedule: 'hints.DateLike' = None + schedule: 'hints.DateLike' = None, + noforwards: bool = None, + send_as: 'hints.EntityLike' = None ) -> 'typing.Sequence[_tl.Message]': if as_album is not None: warnings.warn('the as_album argument is deprecated and no longer has any effect') @@ -565,7 +571,9 @@ async def forward_messages( silent=silent, background=background, with_my_score=with_my_score, - schedule_date=schedule + schedule_date=schedule, + noforwards=noforwards, + send_as=send_as ) result = await self(req) sent.extend(self._get_response_message(req, result, entity)) diff --git a/telethon/_client/uploads.py b/telethon/_client/uploads.py index 92a2d36e..a92df8f4 100644 --- a/telethon/_client/uploads.py +++ b/telethon/_client/uploads.py @@ -113,6 +113,8 @@ async def send_file( reply_to: 'typing.Union[int, _tl.Message]' = None, clear_draft: bool = False, background: bool = None, + noforwards: bool = None, + send_as: 'hints.EntityLike' = None, schedule: 'hints.DateLike' = None, comment_to: 'typing.Union[int, _tl.Message]' = None, ) -> '_tl.Message': @@ -146,13 +148,16 @@ async def send_file( background=background, schedule=schedule, comment_to=comment_to, + noforwards=noforwards, + send_as=send_as ) async def _send_album(self: 'TelegramClient', entity, files, caption='', progress_callback=None, reply_to=None, parse_mode=(), silent=None, schedule=None, supports_streaming=None, clear_draft=None, - force_document=False, background=None, ttl=None): + force_document=False, background=None, ttl=None, + send_as=None, noforwards=None): """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 @@ -212,7 +217,7 @@ async def _send_album(self: 'TelegramClient', entity, files, caption='', request = _tl.fn.messages.SendMultiMedia( entity, reply_to_msg_id=reply_to, multi_media=media, silent=silent, schedule_date=schedule, clear_draft=clear_draft, - background=background + background=background, noforwards=noforwards, send_as=send_as ) result = await self(request) diff --git a/telethon/_events/chataction.py b/telethon/_events/chataction.py index 671347c0..0bf83aa1 100644 --- a/telethon/_events/chataction.py +++ b/telethon/_events/chataction.py @@ -76,6 +76,11 @@ class ChatAction(EventBuilder): return cls.Event(msg, added_by=added_by, users=action.users) + elif isinstance(action, _tl.MessageActionChatJoinedByRequest): + # user joined from join request (after getting admin approval) + return cls.Event(msg, + from_approval=True, + users=msg.from_id) elif isinstance(action, _tl.MessageActionChatDeleteUser): return cls.Event(msg, kicked_by=utils.get_peer_id(msg.from_id) if msg.from_id else True, @@ -138,6 +143,10 @@ class ChatAction(EventBuilder): user_kicked (`bool`): `True` if the user was kicked by some other. + user_approved (`bool`): + `True` if the user's join request was approved. + along with `user_joined` will be also True. + created (`bool`, optional): `True` if this chat was just created. @@ -152,7 +161,7 @@ class ChatAction(EventBuilder): """ def __init__(self, where, new_photo=None, - added_by=None, kicked_by=None, created=None, + added_by=None, kicked_by=None, created=None, from_approval=None, users=None, new_title=None, pin_ids=None, pin=None, new_score=None): if isinstance(where, _tl.MessageService): self.action_message = where @@ -177,11 +186,12 @@ class ChatAction(EventBuilder): self.user_added = self.user_joined = self.user_left = \ self.user_kicked = self.unpin = False - if added_by is True: + if added_by is True or from_approval is True: self.user_joined = True elif added_by: self.user_added = True self._added_by = added_by + self.user_approved = from_approval # If `from_id` was not present (it's `True`) or the affected # user was "kicked by itself", then it left. Else it was kicked. diff --git a/telethon/_misc/html.py b/telethon/_misc/html.py index c17f6f7c..cdf4ced4 100644 --- a/telethon/_misc/html.py +++ b/telethon/_misc/html.py @@ -47,6 +47,8 @@ class HTMLToTelegramParser(HTMLParser): EntityType = _tl.MessageEntityUnderline elif tag == 'del' or tag == 's': EntityType = _tl.MessageEntityStrike + elif tag == 'tg-spoiler': + EntityType = _tl.MessageEntitySpoiler elif tag == 'blockquote': EntityType = _tl.MessageEntityBlockquote elif tag == 'code': diff --git a/telethon/_misc/markdown.py b/telethon/_misc/markdown.py index 8dc82701..3b62c995 100644 --- a/telethon/_misc/markdown.py +++ b/telethon/_misc/markdown.py @@ -19,6 +19,7 @@ DELIMITERS = { _tl.MessageEntityCode: ('`', '`'), _tl.MessageEntityItalic: ('_', '_'), _tl.MessageEntityStrike: ('~~', '~~'), + _tl.MessageEntitySpoiler: ('||', '||'), _tl.MessageEntityUnderline: ('# ', ''), } diff --git a/telethon/types/_custom/button.py b/telethon/types/_custom/button.py index ffac7c99..27f1aae9 100644 --- a/telethon/types/_custom/button.py +++ b/telethon/types/_custom/button.py @@ -1,6 +1,8 @@ +import typing + from .messagebutton import MessageButton from ... import _tl -from ..._misc import utils +from ..._misc import utils, hints class Button: @@ -54,6 +56,7 @@ class Button: _tl.KeyboardButtonCallback, _tl.KeyboardButtonGame, _tl.KeyboardButtonSwitchInline, + _tl.KeyboardButtonUserProfile, _tl.KeyboardButtonUrl, _tl.InputKeyboardButtonUrlAuth )) @@ -166,6 +169,29 @@ class Button: fwd_text=fwd_text ) + @staticmethod + def mention(text, input_entity): + """ + Creates a new inline button linked to the profile of user. + + Args: + input_entity: + Input entity of :tl:User to use for profile button. + By default, this is the logged in user (itself), although + you may pass a different input peer. + + .. note:: + + For now, you cannot use ID or username for this argument. + If you want to use different user, you must manually use + `client.get_input_entity() `. + """ + return _tl.InputKeyboardButtonUserProfile( + text, + utils.get_input_user(input_entity or _tl.InputUserSelf()) + ) + + @classmethod def text(cls, text, *, resize=None, single_use=None, selective=None): """ @@ -387,4 +413,4 @@ def build_reply_markup( return _tl.ReplyInlineMarkup(rows) # elif is_normal: return _tl.ReplyKeyboardMarkup( - rows, resize=resize, single_use=single_use, selective=selective) + rows, resize=resize, single_use=single_use, selective=selective) \ No newline at end of file diff --git a/telethon/types/_custom/inlineresult.py b/telethon/types/_custom/inlineresult.py index 052be4dd..45867edb 100644 --- a/telethon/types/_custom/inlineresult.py +++ b/telethon/types/_custom/inlineresult.py @@ -104,7 +104,7 @@ class InlineResult: async def click(self, entity=None, reply_to=None, comment_to=None, silent=False, clear_draft=False, hide_via=False, - background=None): + background=None, send_as=None): """ Clicks this result and sends the associated `message`. @@ -137,6 +137,9 @@ class InlineResult: background (`bool`, optional): Whether the message should be send in background. + send_as (`entity`, optional): + The channel entity on behalf of which, message should be send. + """ if entity: entity = await self._client.get_input_entity(entity) @@ -158,7 +161,8 @@ class InlineResult: background=background, clear_draft=clear_draft, hide_via=hide_via, - reply_to_msg_id=reply_id + reply_to_msg_id=reply_id, + send_as=send_as ) return self._client._get_response_message( req, await self._client(req), entity) diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index c1e213aa..e1bfff41 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -189,6 +189,10 @@ class Message(ChatGetter, SenderGetter): The number of times this message has been forwarded. """) + noforwards = _fwd('noforwards', """ + does the message was sent with noforwards restriction. + """) + replies = _fwd('replies', """ The number of times another message has replied to this message. """) @@ -205,13 +209,17 @@ class Message(ChatGetter, SenderGetter): grouped_id = _fwd('grouped_id', """ If this message belongs to a group of messages (photo albums or video albums), all of them will - have the same value here. + have the same value here.""") - restriction_reason (List[:tl:`RestrictionReason`]) + restriction_reason = _fwd('restriction_reason', """ An optional list of reasons why this message was restricted. If the list is `None`, this message has not been restricted. """) + reactions = _fwd('reactions', """ + emoji reactions attached to the message. + """) + ttl_period = _fwd('ttl_period', """ The Time To Live period configured for this message. The message should be erased from wherever it's stored (memory, a diff --git a/telethon/types/_custom/messagebutton.py b/telethon/types/_custom/messagebutton.py index 2d588727..eee4486e 100644 --- a/telethon/types/_custom/messagebutton.py +++ b/telethon/types/_custom/messagebutton.py @@ -71,6 +71,10 @@ class MessageButton: 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:`KeyboardButtonUserProfile` button, the + `client.get_entity` will be called and the resulting :tl:User will be + returned. + If it's an inline :tl:`KeyboardButtonSwitchInline` button, the :tl:`StartBot` will be invoked and the resulting updates returned. @@ -107,6 +111,8 @@ class MessageButton: return await self._client(req) except BotResponseTimeoutError: return None + elif isinstance(self.button, _tl.KeyboardButtonUserProfile): + return await self._client.get_entity(self.button.user_id) elif isinstance(self.button, _tl.KeyboardButtonSwitchInline): return await self._client(_tl.fn.messages.StartBot( bot=self._bot, peer=self._chat, start_param=self.button.query @@ -143,4 +149,4 @@ class MessageButton: long, lat = share_geo share_geo = _tl.InputMediaGeoPoint(_tl.InputGeoPoint(lat=lat, long=long)) - return await self._client.send_file(self._chat, share_geo) + return await self._client.send_file(self._chat, share_geo) \ No newline at end of file