diff --git a/setup.py b/setup.py index 13be0144..2058924f 100755 --- a/setup.py +++ b/setup.py @@ -89,7 +89,7 @@ def main(): for x in ('build', 'dist', 'Telethon.egg-info'): rmtree(x, ignore_errors=True) - if len(argv) >= 2 and argv[1] == 'fetch_errors': + elif len(argv) >= 2 and argv[1] == 'fetch_errors': from telethon_generator.error_generator import fetch_errors fetch_errors(ERRORS_JSON) diff --git a/telethon/network/mtproto_sender.py b/telethon/network/mtproto_sender.py index 26e5302a..332180da 100644 --- a/telethon/network/mtproto_sender.py +++ b/telethon/network/mtproto_sender.py @@ -81,6 +81,9 @@ class MtProtoSender: message = messages[0] else: message = TLMessage(self.session, MessageContainer(messages)) + # On bad_msg_salt errors, Telegram will reply with the ID of + # the container and not the requests it contains, so in case + # this happens we need to know to which container they belong. for m in messages: m.container_msg_id = message.msg_id @@ -261,7 +264,12 @@ class MtProtoSender: return self._pending_receive.pop(msg_id).request def _pop_requests_of_container(self, container_msg_id): - msgs = [msg for msg in self._pending_receive.values() if msg.container_msg_id == container_msg_id] + """Pops the pending requests (plural) from self._pending_receive if + they were sent on a container that matches container_msg_id. + """ + msgs = [msg for msg in self._pending_receive.values() + if msg.container_msg_id == container_msg_id] + requests = [msg.request for msg in msgs] for msg in msgs: self._pending_receive.pop(msg.msg_id, None) @@ -273,12 +281,17 @@ class MtProtoSender: self._pending_receive.clear() async def _resend_request(self, msg_id): + """Re-sends the request that belongs to a certain msg_id. This may + also be the msg_id of a container if they were sent in one. + """ request = self._pop_request(msg_id) if request: + self._logger.debug('Resending request') await self.send(request) return requests = self._pop_requests_of_container(msg_id) if requests: + self._logger.debug('Resending container of requests') await self.send(*requests) async def _handle_pong(self, msg_id, sequence, reader): @@ -322,6 +335,8 @@ class MtProtoSender: )[0] self.session.save() + # "the bad_server_salt response is received with the + # correct salt, and the message is to be re-sent with it" await self._resend_request(bad_salt.bad_msg_id) return True diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index acf37ba5..fd42fbc9 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -30,6 +30,7 @@ from .tl.functions.upload import ( GetFileRequest, SaveBigFilePartRequest, SaveFilePartRequest ) from .tl.types import InputFile, InputFileBig +from .tl.types.auth import ExportedAuthorization from .tl.types.upload import FileCdnRedirect from .update_state import UpdateState from .utils import get_appropriated_part_size @@ -59,7 +60,7 @@ class TelegramBareClient: __version__ = '0.15.3' # TODO Make this thread-safe, all connections share the same DC - _dc_options = None + _config = None # Server configuration (with .dc_options) # region Initialization @@ -148,7 +149,7 @@ class TelegramBareClient: # region Connecting - async def connect(self, _exported_auth=None, _sync_updates=True, _cdn=False): + async def connect(self, _sync_updates=True): """Connects to the Telegram servers, executing authentication if required. Note that authenticating to the Telegram servers is not the same as authenticating the desired user itself, which @@ -156,60 +157,21 @@ class TelegramBareClient: Note that the optional parameters are meant for internal use. - If '_exported_auth' is not None, it will be used instead to - determine the authorization key for the current session. - If '_sync_updates', sync_updates() will be called and a second thread will be started if necessary. Note that this will FAIL if the client is not connected to the user's native data center, raising a "UserMigrateError", and calling .disconnect() in the process. - - If '_cdn' is False, methods that are not allowed on such data - centers won't be invoked. """ try: await self._sender.connect() - if not self.session.auth_key: - # New key, we need to tell the server we're going to use - # the latest layer - try: - self.session.auth_key, self.session.time_offset = \ - await authenticator.do_authentication(self._sender.connection) - except BrokenAuthKeyError: - self._user_connected = False - return False - - self.session.layer = LAYER - self.session.save() - init_connection = True - else: - init_connection = self.session.layer != LAYER - - if init_connection: - if _exported_auth is not None: - await self._init_connection(ImportAuthorizationRequest( - _exported_auth.id, _exported_auth.bytes - )) - elif not _cdn: - TelegramBareClient._dc_options = \ - (await self._init_connection(GetConfigRequest())).dc_options - - elif _exported_auth is not None: - await self(ImportAuthorizationRequest( - _exported_auth.id, _exported_auth.bytes - )) - - if TelegramBareClient._dc_options is None and not _cdn: - TelegramBareClient._dc_options = \ - (await self(GetConfigRequest())).dc_options # Connection was successful! Try syncing the update state # UNLESS '_sync_updates' is False (we probably are in # another data center and this would raise UserMigrateError) # to also assert whether the user is logged in or not. self._user_connected = True - if self._authorized is None and _sync_updates and not _cdn: + if self._authorized is None and _sync_updates: try: await self.sync_updates() self._set_connected_and_authorized() @@ -224,11 +186,7 @@ class TelegramBareClient: # This is fine, probably layer migration self._logger.debug('Found invalid item, probably migrating', e) self.disconnect() - return await self.connect( - _exported_auth=_exported_auth, - _sync_updates=_sync_updates, - _cdn=_cdn - ) + return await self.connect(_sync_updates=_sync_updates) except (RPCError, ConnectionError) as error: # Probably errors from the previous session, ignore them @@ -241,8 +199,9 @@ class TelegramBareClient: def is_connected(self): return self._sender.is_connected() - async def _init_connection(self, query=None): - result = await self(InvokeWithLayerRequest(LAYER, InitConnectionRequest( + def _wrap_init_connection(self, query): + """Wraps query around InvokeWithLayerRequest(InitConnectionRequest())""" + return InvokeWithLayerRequest(LAYER, InitConnectionRequest( api_id=self.api_id, device_model=self.session.device_model, system_version=self.session.system_version, @@ -251,10 +210,7 @@ class TelegramBareClient: system_lang_code=self.session.system_lang_code, lang_pack='', # "langPacks are for official apps only" query=query - ))) - self.session.layer = LAYER - self.session.save() - return result + )) def disconnect(self): """Disconnects from the Telegram server""" @@ -286,13 +242,18 @@ class TelegramBareClient: finally: self._reconnect_lock.release() else: - self.disconnect() - self.session.auth_key = None # Force creating new auth_key + # Since we're reconnecting possibly due to a UserMigrateError, + # we need to first know the Data Centers we can connect to. Do + # that before disconnecting. dc = await self._get_dc(new_dc) - ip = dc.ip_address - self.session.server_address = ip + + self.session.server_address = dc.ip_address self.session.port = dc.port + # auth_key's are associated with a server, which has now changed + # so it's not valid anymore. Set to None to force recreating it. + self.session.auth_key = None self.session.save() + self.disconnect() return await self.connect() # endregion @@ -301,11 +262,8 @@ class TelegramBareClient: async def _get_dc(self, dc_id, ipv6=False, cdn=False): """Gets the Data Center (DC) associated to 'dc_id'""" - if TelegramBareClient._dc_options is None: - raise ConnectionError( - 'Cannot determine the required data center IP address. ' - 'Stabilise a successful initial connection first.' - ) + if not TelegramBareClient._config: + TelegramBareClient._config = await self(GetConfigRequest()) try: if cdn: @@ -314,15 +272,15 @@ class TelegramBareClient: rsa.add_key(pk.public_key) return next( - dc for dc in TelegramBareClient._dc_options if dc.id == dc_id - and bool(dc.ipv6) == ipv6 and bool(dc.cdn) == cdn + dc for dc in TelegramBareClient._config.dc_options + if dc.id == dc_id and bool(dc.ipv6) == ipv6 and bool(dc.cdn) == cdn ) except StopIteration: if not cdn: raise # New configuration, perhaps a new CDN was added? - TelegramBareClient._dc_options = await (self(GetConfigRequest())).dc_options + TelegramBareClient._config = await self(GetConfigRequest()) return await self._get_dc(dc_id, ipv6=ipv6, cdn=cdn) async def _get_exported_client(self, dc_id): @@ -363,7 +321,14 @@ class TelegramBareClient: timeout=self._sender.connection.get_timeout(), loop=self._loop ) - await client.connect(_exported_auth=export_auth, _sync_updates=False) + await client.connect(_sync_updates=False) + if isinstance(export_auth, ExportedAuthorization): + await client(ImportAuthorizationRequest( + id=export_auth.id, bytes=export_auth.bytes + )) + elif export_auth is not None: + self._logger.warning('Unknown return export_auth type', export_auth) + client._authorized = True # We exported the auth, so we got auth return client @@ -386,9 +351,10 @@ class TelegramBareClient: # This will make use of the new RSA keys for this specific CDN. # - # This relies on the fact that TelegramBareClient._dc_options is - # static and it won't be called from this DC (it would fail). - await client.connect(_cdn=True) # Avoid invoking non-CDN methods + # 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. + await client.connect(_sync_updates=False) client._authorized = self._authorized return client @@ -423,11 +389,33 @@ class TelegramBareClient: invoke = __call__ async def _invoke(self, call_receive, retry, *requests): + # We need to specify the new layer (by initializing a new + # connection) if it has changed from the latest known one. + init_connection = self.session.layer != LAYER + try: # Ensure that we start with no previous errors (i.e. resending) for x in requests: x.rpc_error = None + if not self.session.auth_key: + # New key, we need to tell the server we're going to use + # the latest layer and initialize the connection doing so. + self.session.auth_key, self.session.time_offset = \ + await authenticator.do_authentication(self._sender.connection) + init_connection = True + + if init_connection: + if len(requests) == 1: + requests = [self._wrap_init_connection(requests[0])] + else: + # We need a SINGLE request (like GetConfig) to init conn. + # Once that's done, the N original requests will be + # invoked. + TelegramBareClient._config = await self( + self._wrap_init_connection(GetConfigRequest()) + ) + await self._sender.send(*requests) if not call_receive: @@ -440,6 +428,13 @@ class TelegramBareClient: while not all(x.confirm_received.is_set() for x in requests): await self._sender.receive(update_state=self.updates) + except BrokenAuthKeyError: + self._logger.error('Broken auth key, a new one will be generated') + self.session.auth_key = None + + except TimeoutError: + pass # We will just retry + except ConnectionResetError: if not self._user_connected or self._reconnect_lock.locked(): # Only attempt reconnecting if the user called connect and not @@ -453,6 +448,12 @@ class TelegramBareClient: await asyncio.sleep(retry + 1, loop=self._loop) return None + if init_connection: + # We initialized the connection successfully, even if + # a request had an RPC error we have invoked it fine. + self.session.layer = LAYER + self.session.save() + try: raise next(x.rpc_error for x in requests if x.rpc_error) except StopIteration: @@ -728,7 +729,8 @@ class TelegramBareClient: if need_reconnect: need_reconnect = False while self._user_connected and not await self._reconnect(): - await asyncio.sleep(0.1, loop=self._loop) # Retry forever, this is instant messaging + # Retry forever, this is instant messaging + await asyncio.sleep(0.1, loop=self._loop) await self._sender.receive(update_state=self.updates) except TimeoutError: @@ -748,7 +750,8 @@ class TelegramBareClient: try: import socks if isinstance(error, ( - socks.GeneralProxyError, socks.ProxyConnectionError + socks.GeneralProxyError, + socks.ProxyConnectionError )): # This is a known error, and it's not related to # Telegram but rather to the proxy. Disconnect and @@ -764,6 +767,7 @@ class TelegramBareClient: # add a little sleep to avoid the CPU usage going mad. await asyncio.sleep(0.1, loop=self._loop) break + self._recv_loop = None # endregion diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index b6f2597e..a51032f2 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -51,7 +51,6 @@ from .tl.types import ( PeerUser, InputPeerUser, InputPeerChat, InputPeerChannel) from .tl.types.messages import DialogsSlice - class TelegramClient(TelegramBareClient): """Full featured TelegramClient meant to extend the basic functionality""" @@ -103,7 +102,11 @@ class TelegramClient(TelegramBareClient): # region Authorization requests async def send_code_request(self, phone): - """Sends a code request to the specified phone number""" + """Sends a code request to the specified phone number. + + :param str | int phone: The phone to which the code will be sent. + :return auth.SentCode: Information about the result of the request. + """ phone = EntityDatabase.parse_phone(phone) or self._phone result = await self(SendCodeRequest(phone, self.api_id, self.api_hash)) self._phone = phone @@ -112,26 +115,27 @@ class TelegramClient(TelegramBareClient): async def sign_in(self, phone=None, code=None, password=None, bot_token=None, phone_code_hash=None): - """Completes the sign in process with the phone number + code pair. + """ + Starts or completes the sign in process with the given phone number + or code that Telegram sent. - If no phone or code is provided, then the sole password will be used. - The password should be used after a normal authorization attempt - has happened and an SessionPasswordNeededError was raised. + :param str | int phone: + The phone to send the code to if no code was provided, or to + override the phone that was previously used with these requests. + :param str | int code: + The code that Telegram sent. + :param str password: + 2FA password, should be used if a previous call raised + SessionPasswordNeededError. + :param str bot_token: + Used to sign in as a bot. Not all requests will be available. + This should be the hash the @BotFather gave you. + :param str phone_code_hash: + The hash returned by .send_code_request. This can be set to None + to use the last hash known. - If you're calling .sign_in() on two completely different clients - (for example, through an API that creates a new client per phone), - you must first call .sign_in(phone) to receive the code, and then - with the result such method results, call - .sign_in(phone, code, phone_code_hash=result.phone_code_hash). - - If this is done on the same client, the client will fill said values - for you. - - To login as a bot, only `bot_token` should be provided. - This should equal to the bot access hash provided by - https://t.me/BotFather during your bot creation. - - If the login succeeds, the logged in user is returned. + :return auth.SentCode | User: + The signed in user, or the information about .send_code_request(). """ if phone and not code: @@ -175,7 +179,15 @@ class TelegramClient(TelegramBareClient): return result.user async def sign_up(self, code, first_name, last_name=''): - """Signs up to Telegram. Make sure you sent a code request first!""" + """ + Signs up to Telegram if you don't have an account yet. + You must call .send_code_request(phone) first. + + :param str | int code: The code sent by Telegram + :param str first_name: The first name to be used by the new account. + :param str last_name: Optional last name. + :return User: The new created user. + """ result = await self(SignUpRequest( phone_number=self._phone, phone_code_hash=self._phone_code_hash, @@ -188,8 +200,10 @@ class TelegramClient(TelegramBareClient): return result.user async def log_out(self): - """Logs out and deletes the current session. - Returns True if everything went okay.""" + """Logs out Telegram and deletes the current *.session file. + + :return bool: True if the operation was successful. + """ try: await self(LogOutRequest()) except RPCError: @@ -201,8 +215,12 @@ class TelegramClient(TelegramBareClient): return True async def get_me(self): - """Gets "me" (the self user) which is currently authenticated, - or None if the request fails (hence, not authenticated).""" + """ + Gets "me" (the self user) which is currently authenticated, + or None if the request fails (hence, not authenticated). + + :return User: Your own user. + """ try: return (await self(GetUsersRequest([InputUserSelf()])))[0] except UnauthorizedError: @@ -217,15 +235,21 @@ class TelegramClient(TelegramBareClient): offset_date=None, offset_id=0, offset_peer=InputPeerEmpty()): - """Returns a tuple of lists ([dialogs], [entities]) - with at least 'limit' items each unless all dialogs were consumed. + """ + Gets N "dialogs" (open "chats" or conversations with other people). - If `limit` is None, all dialogs will be retrieved (from the given - offset) will be retrieved. - - The `entities` represent the user, chat or channel - corresponding to that dialog. If it's an integer, not - all dialogs may be retrieved at once. + :param limit: + 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. + :param offset_date: + The offset date to be used. + :param offset_id: + The message ID to be used as an offset. + :param offset_peer: + The peer to be used as an offset. + :return: A tuple of lists ([dialogs], [entities]). """ if limit is None: limit = float('inf') @@ -284,8 +308,9 @@ class TelegramClient(TelegramBareClient): """ Gets all open draft messages. - Returns a list of custom `Draft` objects that are easy to work with: You can call - `draft.set_message('text')` to change the message, or delete it through `draft.delete()`. + Returns a list of custom `Draft` objects that are easy to work with: + You can call `draft.set_message('text')` to change the message, + or delete it through `draft.delete()`. :return List[telethon.tl.custom.Draft]: A list of open drafts """ @@ -300,11 +325,14 @@ class TelegramClient(TelegramBareClient): message, reply_to=None, link_preview=True): - """Sends a message to the given entity (or input peer) - and returns the sent message as a Telegram object. + """ + Sends the given message to the specified entity (user/chat/channel). - If 'reply_to' is set to either a message or a message ID, - the sent message will be replying to such message. + :param str | int | User | Chat | Channel entity: To who will it be sent. + :param str message: The message to be sent. + :param int | Message reply_to: Whether to reply to a message or not. + :param link_preview: Should the link preview be shown? + :return Message: the sent message """ entity = await self.get_input_entity(entity) request = SendMessageRequest( @@ -348,11 +376,11 @@ class TelegramClient(TelegramBareClient): Deletes a message from a chat, optionally "for everyone" with argument `revoke` set to `True`. - The `revoke` argument has no effect for Channels and Supergroups, + The `revoke` argument has no effect for Channels and Megagroups, where it inherently behaves as being `True`. Note: The `entity` argument can be `None` for normal chats, but it's - mandatory to delete messages from Channels and Supergroups. It is also + mandatory to delete messages from Channels and Megagroups. It is also possible to supply a chat_id which will be automatically resolved to the right type of InputPeer. @@ -397,9 +425,6 @@ class TelegramClient(TelegramBareClient): :return: A tuple containing total message count and two more lists ([messages], [senders]). Note that the sender can be null if it was not found! - - The entity may be a phone or an username at the expense of - some performance loss. """ result = await self(GetHistoryRequest( peer=await self.get_input_entity(entity), @@ -429,16 +454,15 @@ class TelegramClient(TelegramBareClient): return total_messages, result.messages, entities async def send_read_acknowledge(self, entity, messages=None, max_id=None): - """Sends a "read acknowledge" (i.e., notifying the given peer that we've - read their messages, also known as the "double check"). + """ + Sends a "read acknowledge" (i.e., notifying the given peer that we've + read their messages, also known as the "double check"). - Either a list of messages (or a single message) can be given, - or the maximum message ID (until which message we want to send the read acknowledge). - - Returns an AffectedMessages TLObject - - The entity may be a phone or an username at the expense of - some performance loss. + :param entity: The chat where these messages are located. + :param messages: Either a list of messages or a single message. + :param max_id: Overrides messages, until which message should the + acknowledge should be sent. + :return: """ if max_id is None: if not messages: @@ -480,36 +504,36 @@ class TelegramClient(TelegramBareClient): reply_to=None, attributes=None, **kwargs): - """Sends a file to the specified entity. - The file may either be a path, a byte array, or a stream. - 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". + """ + Sends a file to the specified entity. - An optional caption can also be specified for said file. - - If "force_document" is False, the file will be sent as a photo - if it's recognised to have a common image format (e.g. .png, .jpg). - - Otherwise, the file will always be sent as an uncompressed document. - - Subsequent calls with the very same file will result in - immediate uploads, unless .clear_file_cache() is called. - - If "progress_callback" is not None, it should be a function that - takes two parameters, (bytes_uploaded, total_bytes). - - The "reply_to" parameter works exactly as the one on .send_message. - - If "attributes" is set to be a list of DocumentAttribute's, these - will override the automatically inferred ones (so that you can - modify the file name of the file sent for instance). + :param entity: + Who will receive the file. + :param 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". + Subsequent calls with the very same file will result in + immediate uploads, unless .clear_file_cache() is called. + :param caption: + Optional caption for the sent media message. + :param force_document: + If left to False and the file is a path that ends with .png, .jpg + and such, the file will be sent as a photo. Otherwise always as + a document. + :param progress_callback: + A callback function accepting two parameters: (sent bytes, total) + :param reply_to: + Same as reply_to from .send_message(). + :param attributes: + Optional attributes that override the inferred ones, like + DocumentAttributeFilename and so on. + :param kwargs: If "is_voice_note" in kwargs, despite its value, and the file is sent as a document, it will be sent as a voice note. - - The entity may be a phone or an username at the expense of - some performance loss. + :return: """ as_photo = False if isinstance(file, str): @@ -600,21 +624,19 @@ class TelegramClient(TelegramBareClient): # region Downloading media requests async def download_profile_photo(self, entity, file=None, download_big=True): - """Downloads the profile photo for an user or a chat (channels too). - Returns None if no photo was provided, or if it was Empty. + """ + Downloads the profile photo of the given entity (user/chat/channel). - If an entity itself (an user, chat or channel) is given, the photo - to be downloaded will be downloaded automatically. - - On success, the file path is returned since it may differ from - the one provided. - - The specified output file can either be a file path, a directory, - or a stream-like object. If the path exists and is a file, it will - be overwritten. - - The entity may be a phone or an username at the expense of - some performance loss. + :param entity: + From who the photo will be downloaded. + :param file: + The output file path, directory, or stream-like object. + If the path exists and is a file, it will be overwritten. + :param download_big: + Whether to use the big version of the available photos. + :return: + 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. """ possible_names = [] if not isinstance(entity, TLObject) or type(entity).SUBCLASS_OF_ID in ( @@ -669,21 +691,16 @@ class TelegramClient(TelegramBareClient): return file async def download_media(self, message, file=None, progress_callback=None): - """Downloads the media from a specified Message (it can also be - the message.media) into the desired file (a stream or str), - optionally finding its extension automatically. - - The specified output file can either be a file path, a directory, - or a stream-like object. If the path exists and is a file, it will - be overwritten. - - If the operation succeeds, the path will be returned (since - the extension may have been added automatically). Otherwise, - None is returned. - - The progress_callback should be a callback function which takes - two parameters, uploaded size and total file size (both in bytes). - This will be called every time a part is downloaded + """ + Downloads the given media, or the media from a specified Message. + :param message: + The media or message containing the media that will be downloaded. + :param file: + The output file path, directory, or stream-like object. + If the path exists and is a file, it will be overwritten. + :param progress_callback: + A callback function accepting two parameters: (recv bytes, total) + :return: """ # TODO This won't work for messageService if isinstance(message, Message): @@ -781,14 +798,14 @@ class TelegramClient(TelegramBareClient): f = file try: + # Remove these pesky characters + first_name = first_name.replace(';', '') + last_name = (last_name or '').replace(';', '') f.write('BEGIN:VCARD\n') f.write('VERSION:4.0\n') - f.write('N:{};{};;;\n'.format( - first_name, last_name if last_name else '') - ) - f.write('FN:{}\n'.format(' '.join((first_name, last_name)))) - f.write('TEL;TYPE=cell;VALUE=uri:tel:+{}\n'.format( - phone_number)) + f.write('N:{};{};;;\n'.format(first_name, last_name)) + f.write('FN:{} {}\n'.format(first_name, last_name)) + f.write('TEL;TYPE=cell;VALUE=uri:tel:+{}\n'.format(phone_number)) f.write('END:VCARD\n') finally: # Only close the stream if we opened it @@ -862,20 +879,24 @@ class TelegramClient(TelegramBareClient): # region Small utilities to make users' life easier async def get_entity(self, entity): - """Turns an entity into a valid Telegram user or chat. - If "entity" is a string which can be converted to an integer, - or if it starts with '+' it will be resolved as if it - were a phone number. + """ + Turns the given entity into a valid Telegram user or chat. - If "entity" is a string and doesn't start with '+', or - it starts with '@', it will be resolved from the username. - If no exact match is returned, an error will be raised. + :param entity: + The entity 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 "entity" is an integer or a "Peer", its information will - be returned through a call to self.get_input_peer(entity). + 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 neither, and it's not a TLObject, 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. + :return: """ try: return self.session.entities[entity] @@ -929,14 +950,23 @@ class TelegramClient(TelegramBareClient): ) async def get_input_entity(self, peer): - """Gets the input entity given its PeerUser, PeerChat, PeerChannel. - If no Peer class is used, peer is assumed to be the integer ID - of an User. + """ + 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. - If this Peer hasn't been seen before by the library, all dialogs - will loaded, and their entities saved to the session file. + :param peer: + The integer ID of an user or otherwise either of a + PeerUser, PeerChat or PeerChannel, for which to get its + Input* version. - If even after it's not found, a ValueError is raised. + 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. + :return: """ try: # First try to get the entity from cache, otherwise figure it out @@ -987,4 +1017,4 @@ class TelegramClient(TelegramBareClient): 'Make sure you have encountered this peer before.'.format(peer) ) - # endregion + # endregion diff --git a/telethon/tl/entity_database.py b/telethon/tl/entity_database.py index a932d0da..c333649b 100644 --- a/telethon/tl/entity_database.py +++ b/telethon/tl/entity_database.py @@ -1,11 +1,11 @@ import re -from .. import utils from ..tl import TLObject from ..tl.types import ( User, Chat, Channel, PeerUser, PeerChat, PeerChannel, InputPeerUser, InputPeerChat, InputPeerChannel ) +from .. import utils # Keep this line the last to maybe fix #357 class EntityDatabase: diff --git a/telethon/tl/tlobject.py b/telethon/tl/tlobject.py index 5827e5f8..a15519ae 100644 --- a/telethon/tl/tlobject.py +++ b/telethon/tl/tlobject.py @@ -91,8 +91,11 @@ class TLObject: @staticmethod def serialize_bytes(data): """Write bytes by using Telegram guidelines""" - if isinstance(data, str): - data = data.encode('utf-8') + if not isinstance(data, bytes): + if isinstance(data, str): + data = data.encode('utf-8') + else: + raise ValueError('bytes or str expected, not', type(data)) r = [] if len(data) < 254: diff --git a/telethon/utils.py b/telethon/utils.py index d8bfb89f..afb24b16 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -142,7 +142,10 @@ def get_input_user(entity): else: return InputUser(entity.id, entity.access_hash) - if isinstance(entity, UserEmpty): + if isinstance(entity, InputPeerSelf): + return InputUserSelf() + + if isinstance(entity, (UserEmpty, InputPeerEmpty)): return InputUserEmpty() if isinstance(entity, UserFull): diff --git a/telethon_examples/anytime.png b/telethon_examples/anytime.png new file mode 100644 index 00000000..c8663cfa Binary files /dev/null and b/telethon_examples/anytime.png differ diff --git a/telethon_examples/auto_reply.py b/telethon_examples/auto_reply.py deleted file mode 100755 index 2af7d8ca..00000000 --- a/telethon_examples/auto_reply.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -# disclaimer: you should not actually use this. it can be quite spammy. -from telethon import TelegramClient -from telethon.errors import SessionPasswordNeededError -from getpass import getpass -from telethon.tl.types import InputPeerUser,InputPeerChannel -from telethon.tl.types import Updates -from telethon.tl.types import UpdateNewChannelMessage,UpdateNewMessage -from telethon.tl.functions.messages import SendMessageRequest,EditMessageRequest -from telethon.tl.types import MessageService -from nltk.tokenize import word_tokenize -from os import environ -from time import sleep - -CHANNELS = {} -CHANNELNAMES = {} -USERS = {} -EMACS_BLACKLIST = [1058260578, # si @linux_group - 123456789] -REACTS = {'emacs':'Needs more vim.', - 'chrome':'Needs more firefox.', -} - -class NeedsMore(TelegramClient): - def __init__(self): - settings = {'api_id':int(environ['TG_API_ID']), - 'api_hash':environ['TG_API_HASH'], - 'user_phone':environ['TG_PHONE'], - 'session_name':'needsmore'} - super().__init__( - settings.get('session_name','session1'), - settings['api_id'], - settings['api_hash'], - proxy=None, - process_updates=True) - - user_phone = settings['user_phone'] - - print('INFO: Connecting to Telegram Servers...', end='', flush=True) - self.connect() - print('Done!') - - if not self.is_user_authorized(): - print('INFO: Unauthorized user') - self.send_code_request(user_phone) - code_ok = False - while not code_ok: - code = input('Enter the auth code: ') - try: - code_ok = self.sign_in(user_phone, code) - except SessionPasswordNeededError: - pw = getpass('Two step verification enabled. Please enter your password: ') - self.sign_in(password=pw) - print('INFO: Client initialized succesfully!') - - def run(self): - # Listen for updates - while True: - update = self.updates.poll() # This will block until an update is available - triggers = [] - if isinstance(update, Updates): - for x in update.updates: - if not isinstance(x,UpdateNewChannelMessage): continue - if isinstance(x.message,MessageService): continue - # We're only interested in messages to supergroups - words = word_tokenize(x.message.message.lower()) - # Avoid matching 'emacs' in 'spacemacs' and similar - if 'emacs' in words and x.message.to_id.channel_id not in EMACS_BLACKLIST: - triggers.append(('emacs',x.message)) - if 'chrome' in words: - triggers.append(('chrome',x.message)) - if 'x files theme' == x.message.message.lower() and x.message.out: - # Automatically reply to yourself saying 'x files theme' with the audio - msg = x.message - chan = InputPeerChannel(msg.to_id.channel_id,CHANNELS[msg.to_id.channel_id]) - self.send_voice_note(chan,'xfiles.m4a',reply_to=msg.id) - sleep(1) - if '.shrug' in x.message.message.lower() and x.message.out: - # Automatically replace '.shrug' in any message you - # send to a supergroup with the shrug emoticon - msg = x.message - chan = InputPeerChannel(msg.to_id.channel_id,CHANNELS[msg.to_id.channel_id]) - self(EditMessageRequest(chan,msg.id, - message=msg.message.replace('.shrug','¯\_(ツ)_/¯'))) - sleep(1) - - for trigger in triggers: - msg = trigger[1] - chan = InputPeerChannel(msg.to_id.channel_id,CHANNELS[msg.to_id.channel_id]) - log_chat = InputPeerUser(user_id=123456789,access_hash=987654321234567890) - self.send_message(log_chat,"{} said {} in {}. Sending react {}".format( - msg.from_id,msg.message,CHANNELNAMES[msg.to_id.channel_id],REACTS[trigger[0]][:20])) - react = '>{}\n{}'.format(trigger[0],REACTS[trigger[0]]) - self.invoke(SendMessageRequest(chan,react,reply_to_msg_id=msg.id)) - sleep(1) - -if __name__ == "__main__": - #TODO: this block could be moved to __init__ - # You can create these text files using https://github.com/LonamiWebs/Telethon/wiki/Retrieving-all-dialogs - with open('channels.txt','r') as f: - # Format: channel_id access_hash #Channel Name - lines = f.readlines() - chans = [l.split(' #',1)[0].split(' ') for l in lines] - CHANNELS = {int(c[0]):int(c[1]) for c in chans} # id:hash - CHANNELNAMES = {int(l.split()[0]):l.split('#',1)[1].strip() for l in lines} #id:name - with open('users','r') as f: - # Format: [user_id, access_hash, 'username', 'Firstname Lastname'] - lines = f.readlines() - uss = [l.strip()[1:-1].split(',') for l in lines] - USERS = {int(user[0]):int(user[1]) for user in uss} # id:hash - - needsmore = NeedsMore() - needsmore.run() diff --git a/telethon_examples/interactive_telegram_client.py b/telethon_examples/interactive_telegram_client.py index 031a78cd..ee179a42 100644 --- a/telethon_examples/interactive_telegram_client.py +++ b/telethon_examples/interactive_telegram_client.py @@ -20,11 +20,11 @@ def sprint(string, *args, **kwargs): def print_title(title): - # Clear previous window - print('\n') - print('=={}=='.format('=' * len(title))) + """Helper function to print titles to the console more nicely""" + sprint('\n') + sprint('=={}=='.format('=' * len(title))) sprint('= {} ='.format(title)) - print('=={}=='.format('=' * len(title))) + sprint('=={}=='.format('=' * len(title))) def bytes_to_string(byte_count): @@ -34,8 +34,9 @@ def bytes_to_string(byte_count): byte_count /= 1024 suffix_index += 1 - return '{:.2f}{}'.format(byte_count, - [' bytes', 'KB', 'MB', 'GB', 'TB'][suffix_index]) + return '{:.2f}{}'.format( + byte_count, [' bytes', 'KB', 'MB', 'GB', 'TB'][suffix_index] + ) class InteractiveTelegramClient(TelegramClient): @@ -48,13 +49,38 @@ class InteractiveTelegramClient(TelegramClient): """ def __init__(self, session_user_id, user_phone, api_id, api_hash, proxy=None): + """ + Initializes the InteractiveTelegramClient. + :param session_user_id: Name of the *.session file. + :param user_phone: The phone of the user that will login. + :param api_id: Telegram's api_id acquired through my.telegram.org. + :param api_hash: Telegram's api_hash. + :param proxy: Optional proxy tuple/dictionary. + """ print_title('Initialization') print('Initializing interactive example...') + + # The first step is to initialize the TelegramClient, as we are + # subclassing it, we need to call super().__init__(). On a more + # normal case you would want 'client = TelegramClient(...)' super().__init__( + # These parameters should be passed always, session name and API session_user_id, api_id, api_hash, + + # You can optionally change the connection mode by using this enum. + # This changes how much data will be sent over the network with + # every request, and how it will be formatted. Default is + # ConnectionMode.TCP_FULL, and smallest is TCP_TCP_ABRIDGED. connection_mode=ConnectionMode.TCP_ABRIDGED, + + # If you're using a proxy, set it here. proxy=proxy, + + # If you want to receive updates, you need to start one or more + # "update workers" which are background threads that will allow + # you to run things when your update handlers (callbacks) are + # called with an Update object. update_workers=1 ) @@ -62,6 +88,8 @@ class InteractiveTelegramClient(TelegramClient): # so it can be downloaded if the user wants self.found_media = set() + # Calling .connect() may return False, so you need to assert it's + # True before continuing. Otherwise you may want to retry as done here. print('Connecting to Telegram servers...') if not self.connect(): print('Initial connection failed. Retrying...') @@ -69,18 +97,24 @@ class InteractiveTelegramClient(TelegramClient): print('Could not connect to Telegram servers.') return - # Then, ensure we're authorized and have access + # 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 self.is_user_authorized(): print('First run. Sending code request...') - self.send_code_request(user_phone) + self.sign_in(user_phone) self_user = None while self_user is None: code = input('Enter the code you just received: ') try: - self_user = self.sign_in(user_phone, code) + self_user = self.sign_in(code=code) - # Two-step verification may be enabled + # Two-step verification may be enabled, and .sign_in will + # raise this error. If that's the case ask for the password. + # Note that getpass() may not work on PyCharm due to a bug, + # if that's the case simply change it for input(). except SessionPasswordNeededError: pw = getpass('Two step verification is enabled. ' 'Please enter your password: ') @@ -88,16 +122,22 @@ class InteractiveTelegramClient(TelegramClient): self_user = self.sign_in(password=pw) def run(self): - # Listen for updates + """Main loop of the TelegramClient, will wait for user action""" + + # Once everything is ready, we can add an update handler. Every + # update object will be passed to the self.update_handler method, + # where we can process it as we need. self.add_update_handler(self.update_handler) # Enter a while loop to chat as long as the user wants while True: - # Retrieve the top dialogs + # Retrieve the top dialogs. You can set the limit to None to + # retrieve all of them if you wish, but beware that may take + # a long time if you have hundreds of them. dialog_count = 15 # Entities represent the user, chat or channel - # corresponding to the dialog on the same index + # corresponding to the dialog on the same index. dialogs, entities = self.get_dialogs(limit=dialog_count) i = None @@ -119,6 +159,12 @@ class InteractiveTelegramClient(TelegramClient): if i == '!q': return if i == '!l': + # Logging out will cause the user to need to reenter the + # code next time they want to use the library, and will + # also delete the *.session file off the filesystem. + # + # This is not the same as simply calling .disconnect(), + # which simply shuts down everything gracefully. self.log_out() return @@ -158,8 +204,8 @@ class InteractiveTelegramClient(TelegramClient): # History elif msg == '!h': # First retrieve the messages and some information - total_count, messages, senders = self.get_message_history( - entity, limit=10) + total_count, messages, senders = \ + self.get_message_history(entity, limit=10) # Iterate over all (in reverse order so the latest appear # the last in the console) and print them with format: @@ -237,6 +283,7 @@ class InteractiveTelegramClient(TelegramClient): entity, msg, link_preview=False) def send_photo(self, path, entity): + """Sends the file located at path to the desired entity as a photo""" self.send_file( entity, path, progress_callback=self.upload_progress_callback @@ -244,6 +291,7 @@ class InteractiveTelegramClient(TelegramClient): print('Photo sent!') def send_document(self, path, entity): + """Sends the file located at path to the desired entity as a document""" self.send_file( entity, path, force_document=True, @@ -252,6 +300,9 @@ class InteractiveTelegramClient(TelegramClient): print('Document sent!') def download_media_by_id(self, media_id): + """Given a message ID, finds the media this message contained and + downloads it. + """ try: # The user may have entered a non-integer string! msg_media_id = int(media_id) @@ -291,6 +342,11 @@ class InteractiveTelegramClient(TelegramClient): ) def update_handler(self, update): + """Callback method for received Updates""" + + # We have full control over what we want to do with the updates. + # In our case we only want to react to chat messages, so we use + # isinstance() to behave accordingly on these cases. if isinstance(update, UpdateShortMessage): who = self.get_entity(update.user_id) if update.out: diff --git a/telethon_examples/print_updates.py b/telethon_examples/print_updates.py new file mode 100755 index 00000000..ab7ba1d4 --- /dev/null +++ b/telethon_examples/print_updates.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# A simple script to print all updates received + +from getpass import getpass +from os import environ +# environ is used to get API information from environment variables +# You could also use a config file, pass them as arguments, +# or even hardcode them (not recommended) +from telethon import TelegramClient +from telethon.errors import SessionPasswordNeededError + +def main(): + session_name = environ.get('TG_SESSION', 'session') + user_phone = environ['TG_PHONE'] + client = TelegramClient(session_name, + int(environ['TG_API_ID']), + environ['TG_API_HASH'], + proxy=None, + update_workers=4) + + print('INFO: Connecting to Telegram Servers...', end='', flush=True) + client.connect() + print('Done!') + + if not client.is_user_authorized(): + print('INFO: Unauthorized user') + client.send_code_request(user_phone) + code_ok = False + while not code_ok: + code = input('Enter the auth code: ') + try: + code_ok = client.sign_in(user_phone, code) + except SessionPasswordNeededError: + password = getpass('Two step verification enabled. Please enter your password: ') + code_ok = client.sign_in(password=password) + print('INFO: Client initialized succesfully!') + + client.add_update_handler(update_handler) + input('Press Enter to stop this!\n') + +def update_handler(update): + print(update) + print('Press Enter to stop this!') + +if __name__ == '__main__': + main() diff --git a/telethon_examples/replier.py b/telethon_examples/replier.py new file mode 100755 index 00000000..66026363 --- /dev/null +++ b/telethon_examples/replier.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +A example script to automatically send messages based on certain triggers. + +The script makes uses of environment variables to determine the API ID, +hash, phone and such to be used. You may want to add these to your .bashrc +file, including TG_API_ID, TG_API_HASH, TG_PHONE and optionally TG_SESSION. + +This script assumes that you have certain files on the working directory, +such as "xfiles.m4a" or "anytime.png" for some of the automated replies. +""" +from getpass import getpass +from collections import defaultdict +from datetime import datetime, timedelta +from os import environ + +import re + +from telethon import TelegramClient +from telethon.errors import SessionPasswordNeededError +from telethon.tl.types import UpdateNewChannelMessage, UpdateShortMessage, MessageService +from telethon.tl.functions.messages import EditMessageRequest + +"""Uncomment this for debugging +import logging +logging.basicConfig(level=logging.DEBUG) +logging.debug('dbg') +logging.info('info') +""" + +REACTS = {'emacs': 'Needs more vim', + 'chrome': 'Needs more Firefox'} + +# A list of dates of reactions we've sent, so we can keep track of floods +recent_reacts = defaultdict(list) + + +def update_handler(update): + global recent_reacts + try: + msg = update.message + except AttributeError: + # print(update, 'did not have update.message') + return + if isinstance(msg, MessageService): + print(msg, 'was service msg') + return + + # React to messages in supergroups and PMs + if isinstance(update, UpdateNewChannelMessage): + words = re.split('\W+', msg.message) + for trigger, response in REACTS.items(): + if len(recent_reacts[msg.to_id.channel_id]) > 3: + # Silently ignore triggers if we've recently sent 3 reactions + break + + if trigger in words: + # Remove recent replies older than 10 minutes + recent_reacts[msg.to_id.channel_id] = [ + a for a in recent_reacts[msg.to_id.channel_id] if + datetime.now() - a < timedelta(minutes=10) + ] + # Send a reaction + client.send_message(msg.to_id, response, reply_to=msg.id) + # Add this reaction to the list of recent actions + recent_reacts[msg.to_id.channel_id].append(datetime.now()) + + if isinstance(update, UpdateShortMessage): + words = re.split('\W+', msg) + for trigger, response in REACTS.items(): + if len(recent_reacts[update.user_id]) > 3: + # Silently ignore triggers if we've recently sent 3 reactions + break + + if trigger in words: + # Send a reaction + client.send_message(update.user_id, response, reply_to=update.id) + # Add this reaction to the list of recent reactions + recent_reacts[update.user_id].append(datetime.now()) + + # Automatically send relevant media when we say certain things + # When invoking requests, get_input_entity needs to be called manually + if isinstance(update, UpdateNewChannelMessage) and msg.out: + if msg.message.lower() == 'x files theme': + client.send_voice_note(msg.to_id, 'xfiles.m4a', reply_to=msg.id) + if msg.message.lower() == 'anytime': + client.send_file(msg.to_id, 'anytime.png', reply_to=msg.id) + if '.shrug' in msg.message: + client(EditMessageRequest( + client.get_input_entity(msg.to_id), msg.id, + message=msg.message.replace('.shrug', r'¯\_(ツ)_/¯') + )) + + if isinstance(update, UpdateShortMessage) and update.out: + if msg.lower() == 'x files theme': + client.send_voice_note(update.user_id, 'xfiles.m4a', reply_to=update.id) + if msg.lower() == 'anytime': + client.send_file(update.user_id, 'anytime.png', reply_to=update.id) + if '.shrug' in msg: + client(EditMessageRequest( + client.get_input_entity(update.user_id), update.id, + message=msg.replace('.shrug', r'¯\_(ツ)_/¯') + )) + + +if __name__ == '__main__': + session_name = environ.get('TG_SESSION', 'session') + user_phone = environ['TG_PHONE'] + client = TelegramClient( + session_name, int(environ['TG_API_ID']), environ['TG_API_HASH'], + proxy=None, update_workers=4 + ) + try: + print('INFO: Connecting to Telegram Servers...', end='', flush=True) + client.connect() + print('Done!') + + if not client.is_user_authorized(): + print('INFO: Unauthorized user') + client.send_code_request(user_phone) + code_ok = False + while not code_ok: + code = input('Enter the auth code: ') + try: + code_ok = client.sign_in(user_phone, code) + except SessionPasswordNeededError: + password = getpass('Two step verification enabled. ' + 'Please enter your password: ') + code_ok = client.sign_in(password=password) + print('INFO: Client initialized successfully!') + + client.add_update_handler(update_handler) + input('Press Enter to stop this!\n') + except KeyboardInterrupt: + pass + finally: + client.disconnect() diff --git a/telethon_generator/__init__.py b/telethon_generator/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/telethon_generator/__init__.py @@ -0,0 +1 @@ + diff --git a/telethon_generator/error_descriptions b/telethon_generator/error_descriptions index f0a14e68..500504d7 100644 --- a/telethon_generator/error_descriptions +++ b/telethon_generator/error_descriptions @@ -45,7 +45,7 @@ PHONE_NUMBER_OCCUPIED=The phone number is already in use PHONE_NUMBER_UNOCCUPIED=The phone number is not yet being used PHOTO_INVALID_DIMENSIONS=The photo dimensions are invalid TYPE_CONSTRUCTOR_INVALID=The type constructor is invalid -USERNAME_INVALID=Unacceptable username. Must match r"[a-zA-Z][\w\d]{4,31}" +USERNAME_INVALID=Unacceptable username. Must match r"[a-zA-Z][\w\d]{3,30}[a-zA-Z\d]" USERNAME_NOT_MODIFIED=The username is not different from the current username USERNAME_NOT_OCCUPIED=The username is not in use by anyone else yet USERNAME_OCCUPIED=The username is already taken diff --git a/telethon_generator/error_generator.py b/telethon_generator/error_generator.py index feb32b5a..30163dfc 100644 --- a/telethon_generator/error_generator.py +++ b/telethon_generator/error_generator.py @@ -154,11 +154,10 @@ def generate_code(output, json_file, errors_desc): patterns.append((pattern, name)) capture = capture_names.get(name, 'x') if has_captures else None # TODO Some errors have the same name but different code, - # split this accross different files? + # split this across different files? write_error(f, error_code, name, description, capture) f.write('\n\nrpc_errors_all = {\n') for pattern, name in patterns: f.write(' {}: {},\n'.format(repr(pattern), name)) f.write('}\n') - diff --git a/telethon_generator/parser/tl_object.py b/telethon_generator/parser/tl_object.py index 79b4385d..278a66eb 100644 --- a/telethon_generator/parser/tl_object.py +++ b/telethon_generator/parser/tl_object.py @@ -104,8 +104,7 @@ class TLObject: def class_name_for(typename, is_function=False): """Gets the class name following the Python style guidelines""" # Courtesy of http://stackoverflow.com/a/31531797/4759433 - result = re.sub(r'_([a-z])', lambda m: m.group(1).upper(), - typename) + result = re.sub(r'_([a-z])', lambda m: m.group(1).upper(), typename) result = result[:1].upper() + result[1:].replace('_', '') # If it's a function, let it end with "Request" to identify them if is_function: diff --git a/telethon_generator/tl_generator.py b/telethon_generator/tl_generator.py index 8fc6bb2d..60f07bd6 100644 --- a/telethon_generator/tl_generator.py +++ b/telethon_generator/tl_generator.py @@ -129,9 +129,6 @@ class TLGenerator: builder.writeln( 'from {}.tl.tlobject import TLObject'.format('.' * depth) ) - builder.writeln( - 'from {}.tl import types'.format('.' * depth) - ) # Add the relative imports to the namespaces, # unless we already are in a namespace. @@ -494,11 +491,15 @@ class TLGenerator: elif arg.flag_indicator: # Calculate the flags with those items which are not None - builder.write("struct.pack('