diff --git a/telethon/__init__.py b/telethon/__init__.py index cfeba49e..01c48df7 100644 --- a/telethon/__init__.py +++ b/telethon/__init__.py @@ -1,6 +1,5 @@ import logging -from .telegram_bare_client import TelegramBareClient -from .telegram_client import TelegramClient +from .client.telegramclient import TelegramClient from .network import connection from . import tl, version diff --git a/telethon/client/__init__.py b/telethon/client/__init__.py index e69de29b..3b39c679 100644 --- a/telethon/client/__init__.py +++ b/telethon/client/__init__.py @@ -0,0 +1,11 @@ +""" +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). +""" diff --git a/telethon/client/telegrambaseclient.py b/telethon/client/telegrambaseclient.py index 6e90025a..40ae78eb 100644 --- a/telethon/client/telegrambaseclient.py +++ b/telethon/client/telegrambaseclient.py @@ -1,3 +1,4 @@ +import abc import asyncio import itertools import logging @@ -5,15 +6,15 @@ import platform import warnings from datetime import timedelta, datetime -from . import version, errors, utils -from .crypto import rsa -from .extensions import markdown -from .network import MTProtoSender, ConnectionTcpFull -from .network.mtprotostate import MTProtoState -from .sessions import Session, SQLiteSession -from .tl import TLObject, types, functions -from .tl.all_tlobjects import LAYER -from .update_state import UpdateState +from .. import version, errors, utils +from ..crypto import rsa +from ..extensions import markdown +from ..network import MTProtoSender, ConnectionTcpFull +from ..network.mtprotostate import MTProtoState +from ..sessions import Session, SQLiteSession +from ..tl import TLObject, types, functions +from ..tl.all_tlobjects import LAYER +from ..update_state import UpdateState DEFAULT_DC_ID = 4 DEFAULT_IPV4_IP = '149.154.167.51' @@ -23,10 +24,11 @@ DEFAULT_PORT = 443 __log__ = logging.getLogger(__name__) -class TelegramBareClient: +class TelegramBaseClient(abc.ABC): """ - A bare Telegram client that somewhat eases the usage of the - ``MTProtoSender``. + This is the abstract base class for the client. It defines some + basic stuff like connecting, switching data center, etc, and + leaves the `__call__` unimplemented. Args: session (`str` | `telethon.sessions.abstract.Session`, `None`): @@ -260,8 +262,8 @@ class TelegramBareClient: async def _get_dc(self, dc_id, cdn=False): """Gets the Data Center (DC) associated to 'dc_id'""" - if not TelegramBareClient._config: - TelegramBareClient._config =\ + if not TelegramBaseClient._config: + TelegramBaseClient._config =\ await self(functions.help.GetConfigRequest()) try: @@ -272,7 +274,7 @@ class TelegramBareClient: rsa.add_key(pk.public_key) return next( - dc for dc in TelegramBareClient._config.dc_options + dc for dc in TelegramBaseClient._config.dc_options if dc.id == dc_id and bool(dc.ipv6) == self._use_ipv6 and bool(dc.cdn) == cdn ) @@ -281,7 +283,7 @@ class TelegramBareClient: raise # New configuration, perhaps a new CDN was added? - TelegramBareClient._config =\ + TelegramBaseClient._config =\ await self(functions.help.GetConfigRequest()) return self._get_dc(dc_id, cdn=cdn) @@ -369,7 +371,8 @@ class TelegramBareClient: # region Invoking Telegram requests - async def __call__(self, request, retries=5, ordered=False): + @abc.abstractmethod + def __call__(self, request, retries=5, ordered=False): """ Invokes (sends) one or more MTProtoRequests and returns (receives) their result. @@ -387,36 +390,7 @@ class TelegramBareClient: The result of the request (often a `TLObject`) or a list of results if more than one request was given. """ - requests = (request,) if not utils.is_list_like(request) else request - if not all(isinstance(x, TLObject) and - x.content_related for x in requests): - raise TypeError('You can only invoke requests, not types!') - - for r in requests: - await r.resolve(self, utils) - - for _ in range(retries): - try: - future = self._sender.send(request, ordered=ordered) - if isinstance(future, list): - results = [] - for f in future: - results.append(await f) - return results - else: - return await future - except (errors.ServerError, errors.RpcCallFailError): - pass - except (errors.FloodWaitError, errors.FloodTestPhoneWaitError) as e: - if e.seconds <= self.session.flood_sleep_threshold: - await asyncio.sleep(e.seconds) - else: - raise - except (errors.PhoneMigrateError, errors.NetworkMigrateError, - errors.UserMigrateError) as e: - await self._switch_dc(e.new_dc) - - raise ValueError('Number of retries reached 0') + raise NotImplementedError # Let people use client.invoke(SomeRequest()) instead client(...) async def invoke(self, *args, **kwargs): @@ -425,227 +399,3 @@ class TelegramBareClient: return await self(*args, **kwargs) # endregion - - # region Minimal helpers - - async def get_me(self, input_peer=False): - """ - Gets "me" (the self user) which is currently authenticated, - or None if the request fails (hence, not authenticated). - - Args: - input_peer (`bool`, optional): - Whether to return the :tl:`InputPeerUser` version or the normal - :tl:`User`. This can be useful if you just need to know the ID - of yourself. - - Returns: - Your own :tl:`User`. - """ - if input_peer and self._self_input_peer: - return self._self_input_peer - - try: - me = (await self( - functions.users.GetUsersRequest([types.InputUserSelf()])))[0] - - if not self._self_input_peer: - self._self_input_peer = utils.get_input_peer( - me, allow_self=False - ) - - return self._self_input_peer if input_peer else me - except errors.UnauthorizedError: - return None - - async def get_entity(self, entity): - """ - Turns the given entity into a valid Telegram user or chat. - - entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): - The entity (or iterable of entities) to be transformed. - If it's a string which can be converted to an integer or starts - with '+' it will be resolved as if it were a phone number. - - If it doesn't start with '+' or starts with a '@' it will be - be resolved from the username. If no exact match is returned, - an error will be raised. - - If the entity is an integer or a Peer, its information will be - returned through a call to self.get_input_peer(entity). - - If the entity is neither, and it's not a TLObject, an - error will be raised. - - Returns: - :tl:`User`, :tl:`Chat` or :tl:`Channel` corresponding to the - input entity. A list will be returned if more than one was given. - """ - single = not utils.is_list_like(entity) - if single: - entity = (entity,) - - # Group input entities by string (resolve username), - # input users (get users), input chat (get chats) and - # input channels (get channels) to get the most entities - # in the less amount of calls possible. - inputs = [ - x if isinstance(x, str) else await self.get_input_entity(x) - for x in entity - ] - users = [x for x in inputs - if isinstance(x, (types.InputPeerUser, types.InputPeerSelf))] - chats = [x.chat_id for x in inputs - if isinstance(x, types.InputPeerChat)] - channels = [x for x in inputs - if isinstance(x, types.InputPeerChannel)] - if users: - # GetUsersRequest has a limit of 200 per call - tmp = [] - while users: - curr, users = users[:200], users[200:] - tmp.extend(await self(functions.users.GetUsersRequest(curr))) - users = tmp - if chats: # TODO Handle chats slice? - chats = (await self( - functions.messages.GetChatsRequest(chats))).chats - if channels: - channels = (await self( - functions.channels.GetChannelsRequest(channels))).chats - - # Merge users, chats and channels into a single dictionary - id_entity = { - utils.get_peer_id(x): x - for x in itertools.chain(users, chats, channels) - } - - # We could check saved usernames and put them into the users, - # chats and channels list from before. While this would reduce - # the amount of ResolveUsername calls, it would fail to catch - # username changes. - result = [ - await self._get_entity_from_string(x) if isinstance(x, str) - else ( - id_entity[utils.get_peer_id(x)] - if not isinstance(x, types.InputPeerSelf) - else next(u for u in id_entity.values() - if isinstance(u, types.User) and u.is_self) - ) - for x in inputs - ] - return result[0] if single else result - - async def get_input_entity(self, peer): - """ - Turns the given peer into its input entity version. Most requests - use this kind of InputUser, InputChat and so on, so this is the - most suitable call to make for those cases. - - entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): - The integer ID of an user or otherwise either of a - :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`, for - which to get its ``Input*`` version. - - If this ``Peer`` hasn't been seen before by the library, the top - dialogs will be loaded and their entities saved to the session - file (unless this feature was disabled explicitly). - - If in the end the access hash required for the peer was not found, - a ValueError will be raised. - - Returns: - :tl:`InputPeerUser`, :tl:`InputPeerChat` or :tl:`InputPeerChannel` - or :tl:`InputPeerSelf` if the parameter is ``'me'`` or ``'self'``. - - If you need to get the ID of yourself, you should use - `get_me` with ``input_peer=True``) instead. - """ - if peer in ('me', 'self'): - return types.InputPeerSelf() - - try: - # First try to get the entity from cache, otherwise figure it out - return self.session.get_input_entity(peer) - except ValueError: - pass - - if isinstance(peer, str): - return utils.get_input_peer( - await self._get_entity_from_string(peer)) - - if not isinstance(peer, int) and (not isinstance(peer, TLObject) - or peer.SUBCLASS_OF_ID != 0x2d45687): - # Try casting the object into an input peer. Might TypeError. - # Don't do it if a not-found ID was given (instead ValueError). - # Also ignore Peer (0x2d45687 == crc32(b'Peer'))'s, lacking hash. - return utils.get_input_peer(peer) - - raise ValueError( - 'Could not find the input entity for "{}". Please read https://' - 'telethon.readthedocs.io/en/latest/extra/basic/entities.html to' - ' find out more details.' - .format(peer) - ) - - # endregion - - # region Private methods - - async def _get_entity_from_string(self, string): - """ - Gets a full entity from the given string, which may be a phone or - an username, and processes all the found entities on the session. - The string may also be a user link, or a channel/chat invite link. - - This method has the side effect of adding the found users to the - session database, so it can be queried later without API calls, - if this option is enabled on the session. - - Returns the found entity, or raises TypeError if not found. - """ - phone = utils.parse_phone(string) - if phone: - for user in (await self( - functions.contacts.GetContactsRequest(0))).users: - if user.phone == phone: - return user - else: - username, is_join_chat = utils.parse_username(string) - if is_join_chat: - invite = await self( - functions.messages.CheckChatInviteRequest(username)) - - if isinstance(invite, types.ChatInvite): - raise ValueError( - 'Cannot get entity from a channel (or group) ' - 'that you are not part of. Join the group and retry' - ) - elif isinstance(invite, types.ChatInviteAlready): - return invite.chat - elif username: - if username in ('me', 'self'): - return await self.get_me() - - try: - result = await self( - functions.contacts.ResolveUsernameRequest(username)) - except errors.UsernameNotOccupiedError as e: - raise ValueError('No user has "{}" as username' - .format(username)) from e - - for entity in itertools.chain(result.users, result.chats): - if getattr(entity, 'username', None) or '' \ - .lower() == username: - return entity - try: - # Nobody with this username, maybe it's an exact name/title - return await self.get_entity( - self.session.get_input_entity(string)) - except ValueError: - pass - - raise ValueError( - 'Cannot find any entity corresponding to "{}"'.format(string) - ) - - # endregion diff --git a/telethon/client/telegramclient.py b/telethon/client/telegramclient.py index 6f7b77ff..ee8ca624 100644 --- a/telethon/client/telegramclient.py +++ b/telethon/client/telegramclient.py @@ -11,17 +11,17 @@ from collections import UserList from io import BytesIO from mimetypes import guess_type -from .crypto import CdnDecrypter -from .tl.custom import InputSizedFile -from .tl.functions.help import AcceptTermsOfServiceRequest -from .tl.functions.updates import GetDifferenceRequest -from .tl.functions.upload import ( +from ..crypto import CdnDecrypter +from ..tl.custom import InputSizedFile +from ..tl.functions.help import AcceptTermsOfServiceRequest +from ..tl.functions.updates import GetDifferenceRequest +from ..tl.functions.upload import ( SaveBigFilePartRequest, SaveFilePartRequest, GetFileRequest ) -from .tl.types.updates import ( +from ..tl.types.updates import ( DifferenceSlice, DifferenceEmpty, Difference, DifferenceTooLong ) -from .tl.types.upload import FileCdnRedirect +from ..tl.types.upload import FileCdnRedirect try: import socks @@ -35,23 +35,23 @@ try: except ImportError: hachoir = None -from . import TelegramBareClient -from . import helpers, events -from .errors import ( +from .telegrambaseclient import TelegramBaseClient +from .. import helpers, events +from ..errors import ( PhoneCodeEmptyError, PhoneCodeExpiredError, PhoneCodeHashEmptyError, PhoneCodeInvalidError, LocationInvalidError, SessionPasswordNeededError, FileMigrateError, PhoneNumberUnoccupiedError, PhoneNumberOccupiedError ) -from .tl.custom import Draft, Dialog -from .tl.functions.account import ( +from ..tl.custom import Draft, Dialog +from ..tl.functions.account import ( GetPasswordRequest, UpdatePasswordSettingsRequest ) -from .tl.functions.auth import ( +from ..tl.functions.auth import ( CheckPasswordRequest, LogOutRequest, SendCodeRequest, SignInRequest, SignUpRequest, ResendCodeRequest, ImportBotAuthorizationRequest ) -from .tl.functions.messages import ( +from ..tl.functions.messages import ( GetDialogsRequest, GetHistoryRequest, SendMediaRequest, SendMessageRequest, GetAllDraftsRequest, ReadMentionsRequest, SendMultiMediaRequest, @@ -59,13 +59,13 @@ from .tl.functions.messages import ( ForwardMessagesRequest, SearchRequest ) -from .tl.functions import channels -from .tl.functions import messages +from ..tl.functions import channels +from ..tl.functions import messages -from .tl.functions.channels import ( +from ..tl.functions.channels import ( GetFullChannelRequest, GetParticipantsRequest ) -from .tl.types import ( +from ..tl.types import ( DocumentAttributeAudio, DocumentAttributeFilename, InputMediaUploadedDocument, InputMediaUploadedPhoto, InputPeerEmpty, Message, MessageMediaContact, MessageMediaDocument, MessageMediaPhoto, @@ -81,21 +81,21 @@ from .tl.types import ( ChannelParticipantsBanned, ChannelParticipantsKicked, InputMessagesFilterEmpty, UpdatesCombined ) -from .tl.types.messages import DialogsSlice, MessagesNotModified -from .tl.types.account import PasswordInputSettings, NoPassword -from .tl import custom -from .utils import Default -from .extensions import markdown, html +from ..tl.types.messages import DialogsSlice, MessagesNotModified +from ..tl.types.account import PasswordInputSettings, NoPassword +from ..tl import custom +from ..utils import Default +from ..extensions import markdown, html __log__ = logging.getLogger(__name__) import os from datetime import datetime -from . import utils -from .errors import RPCError -from .tl import TLObject +from .. import utils +from ..errors import RPCError +from ..tl import TLObject -class TelegramClient(TelegramBareClient): +class TelegramClient(TelegramBaseClient): """ Initializes the Telegram client with the specified API ID and Hash. This is identical to the `telethon.telegram_bare_client.TelegramBareClient` diff --git a/telethon/client/users.py b/telethon/client/users.py new file mode 100644 index 00000000..1d56d3e2 --- /dev/null +++ b/telethon/client/users.py @@ -0,0 +1,264 @@ +import asyncio +import itertools + +from .telegrambaseclient import TelegramBaseClient +from .. import errors, utils +from ..tl import TLObject, types, functions + + +class UserMethods(TelegramBaseClient): + async def __call__(self, request, retries=5, ordered=False): + requests = (request,) if not utils.is_list_like(request) else request + if not all(isinstance(x, TLObject) and + x.content_related for x in requests): + raise TypeError('You can only invoke requests, not types!') + + for r in requests: + await r.resolve(self, utils) + + for _ in range(retries): + try: + future = self._sender.send(request, ordered=ordered) + if isinstance(future, list): + results = [] + for f in future: + results.append(await f) + return results + else: + return await future + except (errors.ServerError, errors.RpcCallFailError): + pass + except (errors.FloodWaitError, errors.FloodTestPhoneWaitError) as e: + if e.seconds <= self.session.flood_sleep_threshold: + await asyncio.sleep(e.seconds) + else: + raise + except (errors.PhoneMigrateError, errors.NetworkMigrateError, + errors.UserMigrateError) as e: + await self._switch_dc(e.new_dc) + + raise ValueError('Number of retries reached 0') + + # region Public methods + + async def get_me(self, input_peer=False): + """ + Gets "me" (the self user) which is currently authenticated, + or None if the request fails (hence, not authenticated). + + Args: + input_peer (`bool`, optional): + Whether to return the :tl:`InputPeerUser` version or the normal + :tl:`User`. This can be useful if you just need to know the ID + of yourself. + + Returns: + Your own :tl:`User`. + """ + if input_peer and self._self_input_peer: + return self._self_input_peer + + try: + me = (await self( + functions.users.GetUsersRequest([types.InputUserSelf()])))[0] + + if not self._self_input_peer: + self._self_input_peer = utils.get_input_peer( + me, allow_self=False + ) + + return self._self_input_peer if input_peer else me + except errors.UnauthorizedError: + return None + + async def get_entity(self, entity): + """ + Turns the given entity into a valid Telegram user or chat. + + entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): + The entity (or iterable of entities) to be transformed. + If it's a string which can be converted to an integer or starts + with '+' it will be resolved as if it were a phone number. + + If it doesn't start with '+' or starts with a '@' it will be + be resolved from the username. If no exact match is returned, + an error will be raised. + + If the entity is an integer or a Peer, its information will be + returned through a call to self.get_input_peer(entity). + + If the entity is neither, and it's not a TLObject, an + error will be raised. + + Returns: + :tl:`User`, :tl:`Chat` or :tl:`Channel` corresponding to the + input entity. A list will be returned if more than one was given. + """ + single = not utils.is_list_like(entity) + if single: + entity = (entity,) + + # Group input entities by string (resolve username), + # input users (get users), input chat (get chats) and + # input channels (get channels) to get the most entities + # in the less amount of calls possible. + inputs = [ + x if isinstance(x, str) else await self.get_input_entity(x) + for x in entity + ] + users = [x for x in inputs + if isinstance(x, (types.InputPeerUser, types.InputPeerSelf))] + chats = [x.chat_id for x in inputs + if isinstance(x, types.InputPeerChat)] + channels = [x for x in inputs + if isinstance(x, types.InputPeerChannel)] + if users: + # GetUsersRequest has a limit of 200 per call + tmp = [] + while users: + curr, users = users[:200], users[200:] + tmp.extend(await self(functions.users.GetUsersRequest(curr))) + users = tmp + if chats: # TODO Handle chats slice? + chats = (await self( + functions.messages.GetChatsRequest(chats))).chats + if channels: + channels = (await self( + functions.channels.GetChannelsRequest(channels))).chats + + # Merge users, chats and channels into a single dictionary + id_entity = { + utils.get_peer_id(x): x + for x in itertools.chain(users, chats, channels) + } + + # We could check saved usernames and put them into the users, + # chats and channels list from before. While this would reduce + # the amount of ResolveUsername calls, it would fail to catch + # username changes. + result = [ + await self._get_entity_from_string(x) if isinstance(x, str) + else ( + id_entity[utils.get_peer_id(x)] + if not isinstance(x, types.InputPeerSelf) + else next(u for u in id_entity.values() + if isinstance(u, types.User) and u.is_self) + ) + for x in inputs + ] + return result[0] if single else result + + async def get_input_entity(self, peer): + """ + Turns the given peer into its input entity version. Most requests + use this kind of InputUser, InputChat and so on, so this is the + most suitable call to make for those cases. + + entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): + The integer ID of an user or otherwise either of a + :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`, for + which to get its ``Input*`` version. + + If this ``Peer`` hasn't been seen before by the library, the top + dialogs will be loaded and their entities saved to the session + file (unless this feature was disabled explicitly). + + If in the end the access hash required for the peer was not found, + a ValueError will be raised. + + Returns: + :tl:`InputPeerUser`, :tl:`InputPeerChat` or :tl:`InputPeerChannel` + or :tl:`InputPeerSelf` if the parameter is ``'me'`` or ``'self'``. + + If you need to get the ID of yourself, you should use + `get_me` with ``input_peer=True``) instead. + """ + if peer in ('me', 'self'): + return types.InputPeerSelf() + + try: + # First try to get the entity from cache, otherwise figure it out + return self.session.get_input_entity(peer) + except ValueError: + pass + + if isinstance(peer, str): + return utils.get_input_peer( + await self._get_entity_from_string(peer)) + + if not isinstance(peer, int) and (not isinstance(peer, TLObject) + or peer.SUBCLASS_OF_ID != 0x2d45687): + # Try casting the object into an input peer. Might TypeError. + # Don't do it if a not-found ID was given (instead ValueError). + # Also ignore Peer (0x2d45687 == crc32(b'Peer'))'s, lacking hash. + return utils.get_input_peer(peer) + + raise ValueError( + 'Could not find the input entity for "{}". Please read https://' + 'telethon.readthedocs.io/en/latest/extra/basic/entities.html to' + ' find out more details.' + .format(peer) + ) + + # endregion + + # region Private methods + + async def _get_entity_from_string(self, string): + """ + Gets a full entity from the given string, which may be a phone or + an username, and processes all the found entities on the session. + The string may also be a user link, or a channel/chat invite link. + + This method has the side effect of adding the found users to the + session database, so it can be queried later without API calls, + if this option is enabled on the session. + + Returns the found entity, or raises TypeError if not found. + """ + phone = utils.parse_phone(string) + if phone: + for user in (await self( + functions.contacts.GetContactsRequest(0))).users: + if user.phone == phone: + return user + else: + username, is_join_chat = utils.parse_username(string) + if is_join_chat: + invite = await self( + functions.messages.CheckChatInviteRequest(username)) + + if isinstance(invite, types.ChatInvite): + raise ValueError( + 'Cannot get entity from a channel (or group) ' + 'that you are not part of. Join the group and retry' + ) + elif isinstance(invite, types.ChatInviteAlready): + return invite.chat + elif username: + if username in ('me', 'self'): + return await self.get_me() + + try: + result = await self( + functions.contacts.ResolveUsernameRequest(username)) + except errors.UsernameNotOccupiedError as e: + raise ValueError('No user has "{}" as username' + .format(username)) from e + + for entity in itertools.chain(result.users, result.chats): + if getattr(entity, 'username', None) or '' \ + .lower() == username: + return entity + try: + # Nobody with this username, maybe it's an exact name/title + return await self.get_entity( + self.session.get_input_entity(string)) + except ValueError: + pass + + raise ValueError( + 'Cannot find any entity corresponding to "{}"'.format(string) + ) + + # endregion