diff --git a/.gitignore b/.gitignore index f737e858..aef0b91f 100755 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ telethon/tl/all_tlobjects.py usermedia/ api/settings +# Quick tests should live in this file +example.py + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/setup.py b/setup.py index f3ec9173..dd755a32 100644 --- a/setup.py +++ b/setup.py @@ -60,6 +60,7 @@ setup( 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6' ], # What does your project relate to? diff --git a/telethon/errors/__init__.py b/telethon/errors/__init__.py index 7e781968..08ccee20 100644 --- a/telethon/errors/__init__.py +++ b/telethon/errors/__init__.py @@ -32,12 +32,12 @@ def rpc_message_to_error(code, message): return cls(extra=extra) elif code == 403: - return ForbiddenError() + return ForbiddenError(message) elif code == 404: - return NotFoundError() + return NotFoundError(message) elif code == 500: - return ServerError() + return ServerError(message) return RPCError('{} (code {})'.format(message, code)) diff --git a/telethon/errors/rpc_errors.py b/telethon/errors/rpc_errors.py index 55d03ff3..5c938641 100644 --- a/telethon/errors/rpc_errors.py +++ b/telethon/errors/rpc_errors.py @@ -38,6 +38,10 @@ class ForbiddenError(RPCError): code = 403 message = 'FORBIDDEN' + def __init__(self, message): + super().__init__(self, message) + self.message = message + class NotFoundError(RPCError): """ @@ -46,6 +50,10 @@ class NotFoundError(RPCError): code = 404 message = 'NOT_FOUND' + def __init__(self, message): + super().__init__(self, message) + self.message = message + class FloodError(RPCError): """ @@ -67,6 +75,10 @@ class ServerError(RPCError): code = 500 message = 'INTERNAL' + def __init__(self, message): + super().__init__(self, message) + self.message = message + class BadMessageError(Exception): """Occurs when handling a bad_message_notification""" diff --git a/telethon/network/authenticator.py b/telethon/network/authenticator.py index d13dfc9a..52971ffb 100644 --- a/telethon/network/authenticator.py +++ b/telethon/network/authenticator.py @@ -148,7 +148,7 @@ def do_authentication(transport): server_time = dh_inner_data_reader.read_int() time_offset = server_time - int(time.time()) - b = get_int(os.urandom(2048), signed=False) + b = get_int(os.urandom(256), signed=False) gb = pow(g, b, dh_prime) gab = pow(ga, b, dh_prime) diff --git a/telethon/network/mtproto_sender.py b/telethon/network/mtproto_sender.py index d1ccca48..516a381c 100644 --- a/telethon/network/mtproto_sender.py +++ b/telethon/network/mtproto_sender.py @@ -113,11 +113,13 @@ class MtProtoSender: self._logger.info('Request result received') self._logger.debug('receive() released the lock') - def receive_update(self, timeout=timedelta(seconds=5)): - """Receives an update object and returns its result""" + def receive_updates(self, timeout=timedelta(seconds=5)): + """Receives one or more update objects + and returns them as a list + """ updates = [] self.receive(timeout=timeout, updates=updates) - return updates[0] + return updates def cancel_receive(self): """Cancels any pending receive operation @@ -131,13 +133,13 @@ class MtProtoSender: def _send_packet(self, packet, request): """Sends the given packet bytes with the additional information of the original request. This does NOT lock the threads!""" - request.msg_id = self.session.get_new_msg_id() + request.request_msg_id = self.session.get_new_msg_id() # First calculate plain_text to encrypt it with BinaryWriter() as plain_writer: plain_writer.write_long(self.session.salt, signed=False) plain_writer.write_long(self.session.id, signed=False) - plain_writer.write_long(request.msg_id) + plain_writer.write_long(request.request_msg_id) plain_writer.write_int( self.session.generate_sequence(request.confirmed)) @@ -221,7 +223,7 @@ class MtProtoSender: if code == 0x62d6b459: ack = reader.tgread_object() for r in self._pending_receive: - if r.msg_id in ack.msg_ids: + if r.request_msg_id in ack.msg_ids: self._logger.warning('Ack found for the a request') if self.logging_out: @@ -257,7 +259,7 @@ class MtProtoSender: try: request = next(r for r in self._pending_receive - if r.msg_id == received_msg_id) + if r.request_msg_id == received_msg_id) self._logger.warning('Pong confirmed a request') request.confirm_received = True @@ -294,7 +296,7 @@ class MtProtoSender: try: request = next(r for r in self._pending_receive - if r.msg_id == bad_msg_id) + if r.request_msg_id == bad_msg_id) self.send(request) except StopIteration: pass @@ -328,7 +330,7 @@ class MtProtoSender: try: request = next(r for r in self._pending_receive - if r.msg_id == request_id) + if r.request_msg_id == request_id) request.confirm_received = True except StopIteration: diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index 312e4835..9e28075e 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -47,7 +47,7 @@ class TelegramBareClient: """ # Current TelegramClient version - __version__ = '0.10.1' + __version__ = '0.11' # region Initialization diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 50727ce2..09d0092e 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -11,7 +11,7 @@ from .errors import (RPCError, UnauthorizedError, InvalidParameterError, ReadCancelledError, FileMigrateError, PhoneMigrateError, NetworkMigrateError, UserMigrateError, PhoneCodeEmptyError, PhoneCodeExpiredError, PhoneCodeHashEmptyError, - PhoneCodeInvalidError) + PhoneCodeInvalidError, InvalidChecksumError) # For sending and receiving requests from .tl import MTProtoRequest, Session, JsonSession @@ -145,21 +145,13 @@ class TelegramClient(TelegramBareClient): self._cached_clients.clear() - def reconnect(self, new_dc=None): - """Disconnects and connects again (effectively reconnecting). - - If 'new_dc' is not None, the current authorization key is - removed, the DC used is switched, and a new connection is made. - - *args will be ignored. - """ - super(TelegramClient, self).reconnect(new_dc=new_dc) - # endregion - # region Working with different Data Centers + # region Working with different connections - def _get_exported_client(self, dc_id, init_connection=False): + def _get_exported_client(self, dc_id, + init_connection=False, + bypass_cache=False): """Gets a cached exported TelegramBareClient for the desired DC. If it's the first time retrieving the TelegramBareClient, the @@ -168,12 +160,16 @@ class TelegramClient(TelegramBareClient): If after using the sender a ConnectionResetError is raised, this method should be called again with init_connection=True - in order to perform the reconnection.""" + in order to perform the reconnection. + + If bypass_cache is True, a new client will be exported and + it will not be cached. + """ # Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt # for clearly showing how to export the authorization! ^^ client = self._cached_clients.get(dc_id) - if client: + if client and not bypass_cache: if init_connection: client.reconnect() return client @@ -185,16 +181,46 @@ class TelegramClient(TelegramBareClient): # Create a temporary session for this IP address, which needs # to be different because each auth_key is unique per DC. - session = JsonSession(None) + # + # Construct this session with the connection parameters + # (system version, device model...) from the current one. + session = JsonSession(self.session) session.server_address = dc.ip_address session.port = dc.port client = TelegramBareClient(session, self.api_id, self.api_hash) client.connect(exported_auth=export_auth) - # Don't go through this expensive process every time. - self._cached_clients[dc_id] = client + if not bypass_cache: + # Don't go through this expensive process every time. + self._cached_clients[dc_id] = client return client + def create_new_connection(self, on_dc=None): + """Creates a new connection which can be used in parallel + with the original TelegramClient. A TelegramBareClient + will be returned already connected, and the caller is + responsible to disconnect it. + + If 'on_dc' is None, the new client will run on the same + data center as the current client (most common case). + + If the client is meant to be used on a different data + center, the data center ID should be specified instead. + + Note that TelegramBareClients will not handle automatic + reconnection (i.e. switching to another data center to + download media), and InvalidDCError will be raised in + such case. + """ + if on_dc is None: + client = TelegramBareClient(self.session, self.api_id, self.api_hash, + proxy=self.proxy) + client.connect() + else: + client = self._get_exported_client(on_dc, bypass_cache=True) + + return client + # endregion # region Telegram requests functions @@ -447,8 +473,10 @@ class TelegramClient(TelegramBareClient): total_messages = getattr(result, 'count', len(result.messages)) # Iterate over all the messages and find the sender User - entities = [find_user_or_chat(msg.from_id, result.users, result.chats) - for msg in result.messages] + entities = [find_user_or_chat(m.from_id, result.users, result.chats) + if m.from_id is not None else + find_user_or_chat(m.to_id, result.users, result.chats) + for m in result.messages] return total_messages, result.messages, entities @@ -474,6 +502,8 @@ class TelegramClient(TelegramBareClient): # endregion + # region Uploading files + def send_photo_file(self, input_file, entity, caption=''): """Sends a previously uploaded input_file (which should be a photo) to the given entity (or input peer)""" @@ -771,14 +801,20 @@ class TelegramClient(TelegramBareClient): self._logger.debug('Updates thread acquired the lock') try: self._updates_thread_receiving.set() - self._logger.debug('Trying to receive updates from the updates thread') + self._logger.debug( + 'Trying to receive updates from the updates thread' + ) - result = self.sender.receive_update(timeout=timeout) + updates = self.sender.receive_updates(timeout=timeout) self._updates_thread_receiving.clear() - self._logger.info('Received update from the updates thread') - for handler in self._update_handlers: - handler(result) + self._logger.info( + 'Received {} update(s) from the updates thread' + .format(len(updates)) + ) + for update in updates: + for handler in self._update_handlers: + handler(update) except ConnectionResetError: self._logger.info('Server disconnected us. Reconnecting...') @@ -790,6 +826,14 @@ class TelegramClient(TelegramBareClient): except ReadCancelledError: self._logger.info('Receiving updates cancelled') + except BrokenPipeError: + self._logger.info('Tcp session is broken. Reconnecting...') + self.reconnect() + + except InvalidChecksumError: + self._logger.info('MTProto session is broken. Reconnecting...') + self.reconnect() + except OSError: self._logger.warning('OSError on updates thread, %s logging out', 'was' if self.sender.logging_out else 'was not') diff --git a/telethon/tl/mtproto_request.py b/telethon/tl/mtproto_request.py index 3c319d8c..391eeb07 100644 --- a/telethon/tl/mtproto_request.py +++ b/telethon/tl/mtproto_request.py @@ -5,7 +5,7 @@ class MTProtoRequest: def __init__(self): self.sent = False - self.msg_id = 0 # Long + self.request_msg_id = 0 # Long self.sequence = 0 self.dirty = False diff --git a/telethon/tl/session.py b/telethon/tl/session.py index 3f42f51c..ee5f1091 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -99,14 +99,28 @@ class JsonSession: through an official Telegram client to revoke the authorization. """ def __init__(self, session_user_id): + """session_user_id should either be a string or another Session. + Note that if another session is given, only parameters like + those required to init a connection will be copied. + """ # These values will NOT be saved - self.session_user_id = session_user_id + if isinstance(session_user_id, JsonSession): + self.session_user_id = None - # For connection purposes - self.device_model = platform.node() - self.system_version = platform.system() - self.app_version = '0' - self.lang_code = 'en' + # For connection purposes + session = session_user_id + self.device_model = session.device_model + self.system_version = session.system_version + self.app_version = session.app_version + self.lang_code = session.lang_code + + else: # str / None + self.session_user_id = session_user_id + + self.device_model = platform.node() + self.system_version = platform.system() + self.app_version = '1.0' # note: '0' will provoke error + self.lang_code = 'en' # Cross-thread safety self._lock = Lock() diff --git a/telethon/utils.py b/telethon/utils.py index 7a6c0a8b..d94734a2 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -5,9 +5,10 @@ to convert between an entity like an User, Chat, etc. into its Input version) from mimetypes import add_type, guess_extension from .tl.types import ( - Channel, Chat, ChatPhoto, InputPeerChannel, InputPeerChat, InputPeerUser, - MessageMediaDocument, MessageMediaPhoto, PeerChannel, PeerChat, PeerUser, - User, UserProfilePhoto) + Channel, ChannelForbidden, Chat, ChatEmpty, ChatForbidden, ChatFull, + ChatPhoto, InputPeerChannel, InputPeerChat, InputPeerUser, InputPeerEmpty, + InputPeerSelf, MessageMediaDocument, MessageMediaPhoto, PeerChannel, + PeerChat, PeerUser, User, UserFull, UserProfilePhoto) def get_display_name(entity): @@ -51,18 +52,27 @@ def get_extension(media): def get_input_peer(entity): """Gets the input peer for the given "entity" (user, chat or channel). A ValueError is raised if the given entity isn't a supported type.""" - if (isinstance(entity, InputPeerUser) or - isinstance(entity, InputPeerChat) or - isinstance(entity, InputPeerChannel)): + if type(entity).subclass_of_id == 0xc91c90b6: # crc32('InputUser') return entity if isinstance(entity, User): return InputPeerUser(entity.id, entity.access_hash) - if isinstance(entity, Chat): + + if any(isinstance(entity, c) for c in ( + Chat, ChatEmpty, ChatForbidden)): return InputPeerChat(entity.id) - if isinstance(entity, Channel): + + if any(isinstance(entity, c) for c in ( + Channel, ChannelForbidden)): return InputPeerChannel(entity.id, entity.access_hash) + # Less common cases + if isinstance(entity, UserFull): + return InputPeerUser(entity.user.id, entity.user.access_hash) + + if isinstance(entity, ChatFull): + return InputPeerChat(entity.id) + raise ValueError('Cannot cast {} to any kind of InputPeer.' .format(type(entity).__name__)) diff --git a/telethon_examples/interactive_telegram_client.py b/telethon_examples/interactive_telegram_client.py index 6784d411..f4c6ba8a 100644 --- a/telethon_examples/interactive_telegram_client.py +++ b/telethon_examples/interactive_telegram_client.py @@ -1,4 +1,3 @@ -import shutil from getpass import getpass from telethon import TelegramClient @@ -6,9 +5,6 @@ from telethon.errors import SessionPasswordNeededError from telethon.tl.types import UpdateShortChatMessage, UpdateShortMessage from telethon.utils import get_display_name -# Get the (current) number of lines in the terminal -cols, rows = shutil.get_terminal_size() - def sprint(string, *args, **kwargs): """Safe Print (handle UnicodeEncodeErrors on some terminals)""" @@ -47,7 +43,8 @@ class InteractiveTelegramClient(TelegramClient): Telegram through Telethon, such as listing dialogs (open chats), talking to people, downloading media, and receiving updates. """ - def __init__(self, session_user_id, user_phone, api_id, api_hash, proxy=None): + def __init__(self, session_user_id, user_phone, api_id, api_hash, + proxy=None): print_title('Initialization') print('Initializing interactive example...') @@ -76,7 +73,7 @@ class InteractiveTelegramClient(TelegramClient): self_user = self.sign_in(user_phone, code) # Two-step verification may be enabled - except SessionPasswordNeededError as e: + except SessionPasswordNeededError: pw = getpass('Two step verification is enabled. ' 'Please enter your password: ') @@ -100,8 +97,7 @@ class InteractiveTelegramClient(TelegramClient): print_title('Dialogs window') # Display them so the user can choose - for i, entity in enumerate(entities): - i += 1 # 1-based index + for i, entity in enumerate(entities, start=1): sprint('{}. {}'.format(i, get_display_name(entity))) # Let the user decide who they want to talk to @@ -120,7 +116,7 @@ class InteractiveTelegramClient(TelegramClient): try: i = int(i if i else 0) - 1 - # Ensure it is inside the bounds, otherwise set to None and retry + # Ensure it is inside the bounds, otherwise retry if not 0 <= i < dialog_count: i = None except ValueError: @@ -162,7 +158,14 @@ class InteractiveTelegramClient(TelegramClient): for msg, sender in zip( reversed(messages), reversed(senders)): # Get the name of the sender if any - name = sender.first_name if sender else '???' + if sender: + name = getattr(sender, 'first_name', None) + if not name: + name = getattr(sender, 'title') + if not name: + name = '???' + else: + name = '???' # Format the message content if getattr(msg, 'media', None): diff --git a/telethon_generator/tl_generator.py b/telethon_generator/tl_generator.py index 5ccaa27c..8ccc4d65 100755 --- a/telethon_generator/tl_generator.py +++ b/telethon_generator/tl_generator.py @@ -2,6 +2,7 @@ import os import re import shutil +from zlib import crc32 from collections import defaultdict try: @@ -186,6 +187,13 @@ class TLGenerator: builder.writeln('from {}.tl.mtproto_request import MTProtoRequest' .format('.' * depth)) + if tlobject.is_function and \ + any(a for a in tlobject.args if a.type == 'InputPeer'): + # We can automatically convert a normal peer to an InputPeer, + # it will make invoking a lot of requests a lot simpler. + builder.writeln('from {}.utils import get_input_peer' + .format('.' * depth)) + if any(a for a in tlobject.args if a.can_be_inferred): # Currently only 'random_id' needs 'os' to be imported builder.writeln('import os') @@ -207,6 +215,9 @@ class TLGenerator: # Class-level variable to store its constructor ID builder.writeln("# Telegram's constructor (U)ID for this class") builder.writeln('constructor_id = {}'.format(hex(tlobject.id))) + builder.writeln("# Also the ID of its resulting type for fast checks") + builder.writeln('subclass_of_id = {}'.format( + hex(crc32(tlobject.result.encode('ascii'))))) builder.writeln() # Flag arguments must go last @@ -306,6 +317,10 @@ class TLGenerator: ) else: raise ValueError('Cannot infer a value for ', arg) + elif arg.type == 'InputPeer' and tlobject.is_function: + # Well-known case, auto-cast it to the right type + builder.writeln( + 'self.{0} = get_input_peer({0})'.format(arg.name)) else: builder.writeln('self.{0} = {0}'.format(arg.name)) diff --git a/try_telethon.py b/try_telethon.py index 6793cdd2..74eb08c0 100755 --- a/try_telethon.py +++ b/try_telethon.py @@ -24,11 +24,18 @@ def load_settings(path='api/settings'): if __name__ == '__main__': # Load the settings and initialize the client settings = load_settings() + kwargs = {} + if settings.get('socks_proxy'): + import socks # $ pip install pysocks + host, port = settings['socks_proxy'].split(':') + kwargs = dict(proxy=(socks.SOCKS5, host, int(port))) + client = InteractiveTelegramClient( session_user_id=str(settings.get('session_name', 'anonymous')), user_phone=str(settings['user_phone']), api_id=settings['api_id'], - api_hash=str(settings['api_hash'])) + api_hash=str(settings['api_hash']), + **kwargs) print('Initialization done!')