From a932fb64701500f6aa50d0cca7a534a42a655a23 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 26 Nov 2017 16:57:40 +0100 Subject: [PATCH 01/78] Document the crypto/ module --- telethon/crypto/__init__.py | 5 ++++ telethon/crypto/aes.py | 17 +++++++++--- telethon/crypto/aes_ctr.py | 21 +++++++++++++++ telethon/crypto/auth_key.py | 20 ++++++++++++-- telethon/crypto/cdn_decrypter.py | 45 ++++++++++++++++++++++++-------- telethon/crypto/factorization.py | 19 ++++++++++++++ telethon/crypto/libssl.py | 16 ++++++++++++ telethon/crypto/rsa.py | 20 +++++++++++--- 8 files changed, 142 insertions(+), 21 deletions(-) diff --git a/telethon/crypto/__init__.py b/telethon/crypto/__init__.py index d151a96c..aa470adf 100644 --- a/telethon/crypto/__init__.py +++ b/telethon/crypto/__init__.py @@ -1,3 +1,8 @@ +""" +This module contains several utilities regarding cryptographic purposes, +such as the AES IGE mode used by Telegram, the authorization key bound with +their data centers, and so on. +""" from .aes import AES from .aes_ctr import AESModeCTR from .auth_key import AuthKey diff --git a/telethon/crypto/aes.py b/telethon/crypto/aes.py index c09add56..191cde15 100644 --- a/telethon/crypto/aes.py +++ b/telethon/crypto/aes.py @@ -1,3 +1,6 @@ +""" +AES IGE implementation in Python. This module may use libssl if available. +""" import os import pyaes from . import libssl @@ -9,10 +12,15 @@ if libssl.AES is not None: else: # Fallback to a pure Python implementation class AES: + """ + Class that servers as an interface to encrypt and decrypt + text through the AES IGE mode. + """ @staticmethod def decrypt_ige(cipher_text, key, iv): - """Decrypts the given text in 16-bytes blocks by using the - given key and 32-bytes initialization vector + """ + Decrypts the given text in 16-bytes blocks by using the + given key and 32-bytes initialization vector. """ iv1 = iv[:len(iv) // 2] iv2 = iv[len(iv) // 2:] @@ -42,8 +50,9 @@ else: @staticmethod def encrypt_ige(plain_text, key, iv): - """Encrypts the given text in 16-bytes blocks by using the - given key and 32-bytes initialization vector + """ + Encrypts the given text in 16-bytes blocks by using the + given key and 32-bytes initialization vector. """ # Add random padding iff it's not evenly divisible by 16 already diff --git a/telethon/crypto/aes_ctr.py b/telethon/crypto/aes_ctr.py index 7bd7b79a..34422904 100644 --- a/telethon/crypto/aes_ctr.py +++ b/telethon/crypto/aes_ctr.py @@ -1,3 +1,6 @@ +""" +This module holds the AESModeCTR wrapper class. +""" import pyaes @@ -6,6 +9,12 @@ class AESModeCTR: # TODO Maybe make a pull request to pyaes to support iv on CTR def __init__(self, key, iv): + """ + Initializes the AES CTR mode with the given key/iv pair. + + :param key: the key to be used as bytes. + :param iv: the bytes initialization vector. Must have a length of 16. + """ # TODO Use libssl if available assert isinstance(key, bytes) self._aes = pyaes.AESModeOfOperationCTR(key) @@ -15,7 +24,19 @@ class AESModeCTR: self._aes._counter._counter = list(iv) def encrypt(self, data): + """ + Encrypts the given plain text through AES CTR. + + :param data: the plain text to be encrypted. + :return: the encrypted cipher text. + """ return self._aes.encrypt(data) def decrypt(self, data): + """ + Decrypts the given cipher text through AES CTR + + :param data: the cipher text to be decrypted. + :return: the decrypted plain text. + """ return self._aes.decrypt(data) diff --git a/telethon/crypto/auth_key.py b/telethon/crypto/auth_key.py index 17a7f8ca..679e62ff 100644 --- a/telethon/crypto/auth_key.py +++ b/telethon/crypto/auth_key.py @@ -1,3 +1,6 @@ +""" +This module holds the AuthKey class. +""" import struct from hashlib import sha1 @@ -6,7 +9,16 @@ from ..extensions import BinaryReader class AuthKey: + """ + Represents an authorization key, used to encrypt and decrypt + messages sent to Telegram's data centers. + """ def __init__(self, data): + """ + Initializes a new authorization key. + + :param data: the data in bytes that represent this auth key. + """ self.key = data with BinaryReader(sha1(self.key).digest()) as reader: @@ -15,8 +27,12 @@ class AuthKey: self.key_id = reader.read_long(signed=False) def calc_new_nonce_hash(self, new_nonce, number): - """Calculates the new nonce hash based on - the current class fields' values + """ + Calculates the new nonce hash based on the current attributes. + + :param new_nonce: the new nonce to be hashed. + :param number: number to prepend before the hash. + :return: the hash for the given new nonce. """ new_nonce = new_nonce.to_bytes(32, 'little', signed=True) data = new_nonce + struct.pack(' Date: Sun, 26 Nov 2017 17:06:09 +0100 Subject: [PATCH 02/78] Document the errors/ module --- telethon/errors/__init__.py | 19 +++++++ telethon/errors/common.py | 29 ++++++++--- telethon/errors/rpc_base_errors.py | 79 ++++++++++++++++-------------- 3 files changed, 84 insertions(+), 43 deletions(-) diff --git a/telethon/errors/__init__.py b/telethon/errors/__init__.py index 6e62bfb9..fbb2f424 100644 --- a/telethon/errors/__init__.py +++ b/telethon/errors/__init__.py @@ -1,3 +1,7 @@ +""" +This module holds all the base and automatically generated errors that the +Telegram API has. See telethon_generator/errors.json for more. +""" import urllib.request import re from threading import Thread @@ -13,6 +17,13 @@ from .rpc_error_list import * def report_error(code, message, report_method): + """ + Reports an RPC error to pwrtelegram. + + :param code: the integer code of the error (like 400). + :param message: the message representing the error. + :param report_method: the constructor ID of the function that caused it. + """ try: # Ensure it's signed report_method = int.from_bytes( @@ -30,6 +41,14 @@ def report_error(code, message, report_method): def rpc_message_to_error(code, message, report_method=None): + """ + Converts a Telegram's RPC Error to a Python error. + + :param code: the integer code of the error (like 400). + :param message: the message representing the error. + :param report_method: if present, the ID of the method that caused it. + :return: the RPCError as a Python exception that represents this error. + """ if report_method is not None: Thread( target=report_error, diff --git a/telethon/errors/common.py b/telethon/errors/common.py index be3b1d93..f2f21840 100644 --- a/telethon/errors/common.py +++ b/telethon/errors/common.py @@ -2,20 +2,23 @@ class ReadCancelledError(Exception): - """Occurs when a read operation was cancelled""" + """Occurs when a read operation was cancelled.""" def __init__(self): super().__init__(self, 'The read operation was cancelled.') class InvalidParameterError(Exception): - """Occurs when an invalid parameter is given, for example, - when either A or B are required but none is given""" + """ + Occurs when an invalid parameter is given, for example, + when either A or B are required but none is given. + """ class TypeNotFoundError(Exception): - """Occurs when a type is not found, for example, - when trying to read a TLObject with an invalid constructor code""" - + """ + Occurs when a type is not found, for example, + when trying to read a TLObject with an invalid constructor code. + """ def __init__(self, invalid_constructor_id): super().__init__( self, 'Could not find a matching Constructor ID for the TLObject ' @@ -27,6 +30,10 @@ class TypeNotFoundError(Exception): class InvalidChecksumError(Exception): + """ + Occurs when using the TCP full mode and the checksum of a received + packet doesn't match the expected checksum. + """ def __init__(self, checksum, valid_checksum): super().__init__( self, @@ -39,6 +46,9 @@ class InvalidChecksumError(Exception): class BrokenAuthKeyError(Exception): + """ + Occurs when the authorization key for a data center is not valid. + """ def __init__(self): super().__init__( self, @@ -47,6 +57,9 @@ class BrokenAuthKeyError(Exception): class SecurityError(Exception): + """ + Generic security error, mostly used when generating a new AuthKey. + """ def __init__(self, *args): if not args: args = ['A security check failed.'] @@ -54,6 +67,10 @@ class SecurityError(Exception): class CdnFileTamperedError(SecurityError): + """ + Occurs when there's a hash mismatch between the decrypted CDN file + and its expected hash. + """ def __init__(self): super().__init__( 'The CDN file has been altered and its download cancelled.' diff --git a/telethon/errors/rpc_base_errors.py b/telethon/errors/rpc_base_errors.py index 5c938641..9e6eed1a 100644 --- a/telethon/errors/rpc_base_errors.py +++ b/telethon/errors/rpc_base_errors.py @@ -1,11 +1,12 @@ class RPCError(Exception): + """Base class for all Remote Procedure Call errors.""" code = None message = None class InvalidDCError(RPCError): """ - The request must be repeated, but directed to a different data center. + The request must be repeated, but directed to a different data center. """ code = 303 message = 'ERROR_SEE_OTHER' @@ -13,9 +14,9 @@ class InvalidDCError(RPCError): class BadRequestError(RPCError): """ - The query contains errors. In the event that a request was created - using a form and contains user generated data, the user should be - notified that the data must be corrected before the query is repeated. + The query contains errors. In the event that a request was created + using a form and contains user generated data, the user should be + notified that the data must be corrected before the query is repeated. """ code = 400 message = 'BAD_REQUEST' @@ -23,8 +24,8 @@ class BadRequestError(RPCError): class UnauthorizedError(RPCError): """ - There was an unauthorized attempt to use functionality available only - to authorized users. + There was an unauthorized attempt to use functionality available only + to authorized users. """ code = 401 message = 'UNAUTHORIZED' @@ -32,8 +33,8 @@ class UnauthorizedError(RPCError): class ForbiddenError(RPCError): """ - Privacy violation. For example, an attempt to write a message to - someone who has blacklisted the current user. + Privacy violation. For example, an attempt to write a message to + someone who has blacklisted the current user. """ code = 403 message = 'FORBIDDEN' @@ -45,7 +46,7 @@ class ForbiddenError(RPCError): class NotFoundError(RPCError): """ - An attempt to invoke a non-existent object, such as a method. + An attempt to invoke a non-existent object, such as a method. """ code = 404 message = 'NOT_FOUND' @@ -57,10 +58,10 @@ class NotFoundError(RPCError): class FloodError(RPCError): """ - The maximum allowed number of attempts to invoke the given method - with the given input parameters has been exceeded. For example, in an - attempt to request a large number of text messages (SMS) for the same - phone number. + The maximum allowed number of attempts to invoke the given method + with the given input parameters has been exceeded. For example, in an + attempt to request a large number of text messages (SMS) for the same + phone number. """ code = 420 message = 'FLOOD' @@ -68,9 +69,9 @@ class FloodError(RPCError): class ServerError(RPCError): """ - An internal server error occurred while a request was being processed - for example, there was a disruption while accessing a database or file - storage. + An internal server error occurred while a request was being processed + for example, there was a disruption while accessing a database or file + storage. """ code = 500 message = 'INTERNAL' @@ -81,38 +82,42 @@ class ServerError(RPCError): class BadMessageError(Exception): - """Occurs when handling a bad_message_notification""" + """Occurs when handling a bad_message_notification.""" ErrorMessages = { 16: - 'msg_id too low (most likely, client time is wrong it would be worthwhile to ' - 'synchronize it using msg_id notifications and re-send the original message ' - 'with the "correct" msg_id or wrap it in a container with a new msg_id if the ' - 'original message had waited too long on the client to be transmitted).', + 'msg_id too low (most likely, client time is wrong it would be ' + 'worthwhile to synchronize it using msg_id notifications and re-send ' + 'the original message with the "correct" msg_id or wrap it in a ' + 'container with a new msg_id if the original message had waited too ' + 'long on the client to be transmitted).', 17: - 'msg_id too high (similar to the previous case, the client time has to be ' - 'synchronized, and the message re-sent with the correct msg_id).', + 'msg_id too high (similar to the previous case, the client time has ' + 'to be synchronized, and the message re-sent with the correct msg_id).', 18: - 'Incorrect two lower order msg_id bits (the server expects client message msg_id ' - 'to be divisible by 4).', + 'Incorrect two lower order msg_id bits (the server expects client ' + 'message msg_id to be divisible by 4).', 19: - 'Container msg_id is the same as msg_id of a previously received message ' - '(this must never happen).', + 'Container msg_id is the same as msg_id of a previously received ' + 'message (this must never happen).', 20: - 'Message too old, and it cannot be verified whether the server has received a ' - 'message with this msg_id or not.', + 'Message too old, and it cannot be verified whether the server has ' + 'received a message with this msg_id or not.', 32: - 'msg_seqno too low (the server has already received a message with a lower ' - 'msg_id but with either a higher or an equal and odd seqno).', + 'msg_seqno too low (the server has already received a message with a ' + 'lower msg_id but with either a higher or an equal and odd seqno).', 33: - 'msg_seqno too high (similarly, there is a message with a higher msg_id but with ' - 'either a lower or an equal and odd seqno).', + 'msg_seqno too high (similarly, there is a message with a higher ' + 'msg_id but with either a lower or an equal and odd seqno).', 34: 'An even msg_seqno expected (irrelevant message), but odd received.', - 35: 'Odd msg_seqno expected (relevant message), but even received.', + 35: + 'Odd msg_seqno expected (relevant message), but even received.', 48: - 'Incorrect server salt (in this case, the bad_server_salt response is received with ' - 'the correct salt, and the message is to be re-sent with it).', - 64: 'Invalid container.' + 'Incorrect server salt (in this case, the bad_server_salt response ' + 'is received with the correct salt, and the message is to be re-sent ' + 'with it).', + 64: + 'Invalid container.' } def __init__(self, code): From 57a70d0d47c294f28c02a0f1664e5b717a6f73c2 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 26 Nov 2017 17:14:28 +0100 Subject: [PATCH 03/78] Document the extensions/ module --- telethon/extensions/binary_reader.py | 44 ++++++++++++++++------------ telethon/extensions/markdown.py | 20 ++++++++----- telethon/extensions/tcp_client.py | 38 +++++++++++++++++------- 3 files changed, 65 insertions(+), 37 deletions(-) diff --git a/telethon/extensions/binary_reader.py b/telethon/extensions/binary_reader.py index c5abcbf9..19fb608b 100644 --- a/telethon/extensions/binary_reader.py +++ b/telethon/extensions/binary_reader.py @@ -1,3 +1,6 @@ +""" +This module contains the BinaryReader utility class. +""" import os from datetime import datetime from io import BufferedReader, BytesIO @@ -30,32 +33,32 @@ class BinaryReader: # "All numbers are written as little endian." # https://core.telegram.org/mtproto def read_byte(self): - """Reads a single byte value""" + """Reads a single byte value.""" return self.read(1)[0] def read_int(self, signed=True): - """Reads an integer (4 bytes) value""" + """Reads an integer (4 bytes) value.""" return int.from_bytes(self.read(4), byteorder='little', signed=signed) def read_long(self, signed=True): - """Reads a long integer (8 bytes) value""" + """Reads a long integer (8 bytes) value.""" return int.from_bytes(self.read(8), byteorder='little', signed=signed) def read_float(self): - """Reads a real floating point (4 bytes) value""" + """Reads a real floating point (4 bytes) value.""" return unpack(' 'y!'. + """ + Gets the inner text that's surrounded by the given entity or entities. + For instance: text = 'hey!', entity = MessageEntityBold(2, 2) -> 'y!'. + + :param text: the original text. + :param entity: the entity or entities that must be matched. + :return: a single result or a list of the text surrounded by the entities. """ if not isinstance(entity, TLObject) and hasattr(entity, '__iter__'): multiple = True diff --git a/telethon/extensions/tcp_client.py b/telethon/extensions/tcp_client.py index af9bfbfe..3941a4d6 100644 --- a/telethon/extensions/tcp_client.py +++ b/telethon/extensions/tcp_client.py @@ -1,4 +1,6 @@ -# Python rough implementation of a C# TCP client +""" +This module holds a rough implementation of the C# TCP client. +""" import errno import socket from datetime import timedelta @@ -7,7 +9,14 @@ from threading import Lock class TcpClient: + """A simple TCP client to ease the work with sockets and proxies.""" def __init__(self, proxy=None, timeout=timedelta(seconds=5)): + """ + Initializes the TCP client. + + :param proxy: the proxy to be used, if any. + :param timeout: the timeout for connect, read and write operations. + """ self.proxy = proxy self._socket = None self._closing_lock = Lock() @@ -33,8 +42,11 @@ class TcpClient: self._socket.settimeout(self.timeout) def connect(self, ip, port): - """Connects to the specified IP and port number. - 'timeout' must be given in seconds + """ + Tries connecting forever to IP:port unless an OSError is raised. + + :param ip: the IP to connect to. + :param port: the port to connect to. """ if ':' in ip: # IPv6 # The address needs to be surrounded by [] as discussed on PR#425 @@ -65,12 +77,13 @@ class TcpClient: raise def _get_connected(self): + """Determines whether the client is connected or not.""" return self._socket is not None and self._socket.fileno() >= 0 connected = property(fget=_get_connected) def close(self): - """Closes the connection""" + """Closes the connection.""" if self._closing_lock.locked(): # Already closing, no need to close again (avoid None.close()) return @@ -86,7 +99,11 @@ class TcpClient: self._socket = None def write(self, data): - """Writes (sends) the specified bytes to the connected peer""" + """ + Writes (sends) the specified bytes to the connected peer. + + :param data: the data to send. + """ if self._socket is None: raise ConnectionResetError() @@ -105,13 +122,11 @@ class TcpClient: raise def read(self, size): - """Reads (receives) a whole block of 'size bytes - from the connected peer. + """ + Reads (receives) a whole block of size bytes from the connected peer. - A timeout can be specified, which will cancel the operation if - no data has been read in the specified time. If data was read - and it's waiting for more, the timeout will NOT cancel the - operation. Set to None for no timeout + :param size: the size of the block to be read. + :return: the read data with len(data) == size. """ if self._socket is None: raise ConnectionResetError() @@ -141,5 +156,6 @@ class TcpClient: return buffer.raw.getvalue() def _raise_connection_reset(self): + """Disconnects the client and raises ConnectionResetError.""" self.close() # Connection reset -> flag as socket closed raise ConnectionResetError('The server has closed the connection.') From 605c103f298636c970af2eea65ea0a07e231aedd Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 26 Nov 2017 17:16:59 +0100 Subject: [PATCH 04/78] Add unparse markdown method --- telethon/extensions/markdown.py | 65 ++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/telethon/extensions/markdown.py b/telethon/extensions/markdown.py index a960bb34..24ae5aa7 100644 --- a/telethon/extensions/markdown.py +++ b/telethon/extensions/markdown.py @@ -24,6 +24,12 @@ DEFAULT_DELIMITERS = { # reason why there's '\0' after every match-literal character. DEFAULT_URL_RE = re.compile(b'\\[\0(.+?)\\]\0\\(\0(.+?)\\)\0') +# Reverse operation for DEFAULT_URL_RE. {0} for text, {1} for URL. +DEFAULT_URL_FORMAT = '[{0}]({1})' + +# Encoding to be used +ENC = 'utf-16le' + def parse(message, delimiters=None, url_re=None): """ @@ -46,7 +52,7 @@ def parse(message, delimiters=None, url_re=None): return message, [] delimiters = DEFAULT_DELIMITERS - delimiters = {k.encode('utf-16le'): v for k, v in delimiters.items()} + delimiters = {k.encode(ENC): v for k, v in delimiters.items()} # Cannot use a for loop because we need to skip some indices i = 0 @@ -56,7 +62,7 @@ def parse(message, delimiters=None, url_re=None): # Work on byte level with the utf-16le encoding to get the offsets right. # The offset will just be half the index we're at. - message = message.encode('utf-16le') + message = message.encode(ENC) while i < len(message): if url_re and current is None: # If we're not inside a previous match since Telegram doesn't allow @@ -72,7 +78,7 @@ def parse(message, delimiters=None, url_re=None): result.append(MessageEntityTextUrl( offset=i // 2, length=len(url_match.group(1)) // 2, - url=url_match.group(2).decode('utf-16le') + url=url_match.group(2).decode(ENC) )) i += len(url_match.group(1)) # Next loop iteration, don't check delimiters, since @@ -127,7 +133,54 @@ def parse(message, delimiters=None, url_re=None): + message[2 * current.offset:] ) - return message.decode('utf-16le'), result + return message.decode(ENC), result + + +def unparse(text, entities, delimiters=None, url_fmt=None): + """ + Performs the reverse operation to .parse(), effectively returning + markdown-like syntax given a normal text and its MessageEntity's. + + :param text: the text to be reconverted into markdown. + :param entities: the MessageEntity's applied to the text. + :return: a markdown-like text representing the combination of both inputs. + """ + if not delimiters: + if delimiters is not None: + return text + delimiters = DEFAULT_DELIMITERS + + if url_fmt is None: + url_fmt = DEFAULT_URL_FORMAT + + if isinstance(entities, TLObject): + entities = (entities,) + else: + entities = tuple(sorted(entities, key=lambda e: e.offset, reverse=True)) + + # Reverse the delimiters, and encode them as utf16 + delimiters = {v: k.encode(ENC) for k, v in delimiters.items()} + text = text.encode(ENC) + for entity in entities: + s = entity.offset * 2 + e = (entity.offset + entity.length) * 2 + delimiter = delimiters.get(type(entity), None) + if delimiter: + text = text[:s] + delimiter + text[s:e] + delimiter + text[e:] + elif isinstance(entity, MessageEntityTextUrl) and url_fmt: + # If byte-strings supported .format(), we, could have converted + # the str url_fmt to a byte-string with the following regex: + # re.sub(b'{\0\s*(?:([01])\0)?\s*}\0',rb'{\1}',url_fmt.encode(ENC)) + # + # This would preserve {}, {0} and {1}. + # Alternatively (as it's done), we can decode/encode it every time. + text = ( + text[:s] + + url_fmt.format(text[s:e].decode(ENC), entity.url).encode(ENC) + + text[e:] + ) + + return text.decode(ENC) def get_inner_text(text, entity): @@ -145,11 +198,11 @@ def get_inner_text(text, entity): entity = [entity] multiple = False - text = text.encode('utf-16le') + text = text.encode(ENC) result = [] for e in entity: start = e.offset * 2 end = (e.offset + e.length) * 2 - result.append(text[start:end].decode('utf-16le')) + result.append(text[start:end].decode(ENC)) return result if multiple else result[0] From 7509ba906796a49995345d72dc2effaa8a178f11 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 29 Nov 2017 12:34:15 +0100 Subject: [PATCH 05/78] Assert that module was generated correctly on setup.py pypi --- setup.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/setup.py b/setup.py index 3f8ee7a6..0c531d70 100755 --- a/setup.py +++ b/setup.py @@ -71,6 +71,16 @@ def main(): print('Done.') elif len(argv) >= 2 and argv[1] == 'pypi': + # (Re)generate the code to make sure we don't push without it + gen_tl() + + # Try importing the telethon module to assert it has no errors + try: + import telethon + except: + print('Packaging for PyPi aborted, importing the module failed.') + return + # Need python3.5 or higher, but Telethon is supposed to support 3.x # Place it here since noone should be running ./setup.py pypi anyway from subprocess import run From 9046b46fcd24b6fa083476c043021e8193e1638a Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 30 Nov 2017 13:20:51 +0100 Subject: [PATCH 06/78] Document the network/ module --- telethon/network/__init__.py | 4 + telethon/network/authenticator.py | 30 +++- telethon/network/connection.py | 102 ++++++++++++- telethon/network/mtproto_plain_sender.py | 31 +++- telethon/network/mtproto_sender.py | 179 +++++++++++++++++++---- 5 files changed, 300 insertions(+), 46 deletions(-) diff --git a/telethon/network/__init__.py b/telethon/network/__init__.py index 77bd4406..d2538924 100644 --- a/telethon/network/__init__.py +++ b/telethon/network/__init__.py @@ -1,3 +1,7 @@ +""" +This module contains several classes regarding network, low level connection +with Telegram's servers and the protocol used (TCP full, abridged, etc.). +""" from .mtproto_plain_sender import MtProtoPlainSender from .authenticator import do_authentication from .mtproto_sender import MtProtoSender diff --git a/telethon/network/authenticator.py b/telethon/network/authenticator.py index 00a28fdf..a73bae38 100644 --- a/telethon/network/authenticator.py +++ b/telethon/network/authenticator.py @@ -1,3 +1,7 @@ +""" +This module contains several functions that authenticate the client machine +with Telegram's servers, effectively creating an authorization key. +""" import os import time from hashlib import sha1 @@ -18,6 +22,14 @@ from ..tl.functions import ( def do_authentication(connection, retries=5): + """ + Performs the authentication steps on the given connection. + Raises an error if all attempts fail. + + :param connection: the connection to be used (must be connected). + :param retries: how many times should we retry on failure. + :return: + """ if not retries or retries < 0: retries = 1 @@ -32,9 +44,11 @@ def do_authentication(connection, retries=5): def _do_authentication(connection): - """Executes the authentication process with the Telegram servers. - If no error is raised, returns both the authorization key and the - time offset. + """ + Executes the authentication process with the Telegram servers. + + :param connection: the connection to be used (must be connected). + :return: returns a (authorization key, time offset) tuple. """ sender = MtProtoPlainSender(connection) @@ -195,8 +209,12 @@ def _do_authentication(connection): def get_int(byte_array, signed=True): - """Gets the specified integer from its byte array. - This should be used by the authenticator, - who requires the data to be in big endian + """ + Gets the specified integer from its byte array. + This should be used by this module alone, as it works with big endian. + + :param byte_array: the byte array representing th integer. + :param signed: whether the number is signed or not. + :return: the integer representing the given byte array. """ return int.from_bytes(byte_array, byteorder='big', signed=signed) diff --git a/telethon/network/connection.py b/telethon/network/connection.py index fe04352f..ff255d00 100644 --- a/telethon/network/connection.py +++ b/telethon/network/connection.py @@ -1,3 +1,7 @@ +""" +This module holds both the Connection class and the ConnectionMode enum, +which specifies the protocol to be used by the Connection. +""" import os import struct from datetime import timedelta @@ -35,16 +39,24 @@ class ConnectionMode(Enum): class Connection: - """Represents an abstract connection (TCP, TCP abridged...). - 'mode' must be any of the ConnectionMode enumeration. + """ + Represents an abstract connection (TCP, TCP abridged...). + 'mode' must be any of the ConnectionMode enumeration. - Note that '.send()' and '.recv()' refer to messages, which - will be packed accordingly, whereas '.write()' and '.read()' - work on plain bytes, with no further additions. + Note that '.send()' and '.recv()' refer to messages, which + will be packed accordingly, whereas '.write()' and '.read()' + work on plain bytes, with no further additions. """ def __init__(self, mode=ConnectionMode.TCP_FULL, proxy=None, timeout=timedelta(seconds=5)): + """ + Initializes a new connection. + + :param mode: the ConnectionMode to be used. + :param proxy: whether to use a proxy or not. + :param timeout: timeout to be used for all operations. + """ self._mode = mode self._send_counter = 0 self._aes_encrypt, self._aes_decrypt = None, None @@ -75,6 +87,12 @@ class Connection: setattr(self, 'read', self._read_plain) def connect(self, ip, port): + """ + Estabilishes a connection to IP:port. + + :param ip: the IP to connect to. + :param port: the port to connect to. + """ try: self.conn.connect(ip, port) except OSError as e: @@ -92,9 +110,13 @@ class Connection: self._setup_obfuscation() def get_timeout(self): + """Returns the timeout used by the connection.""" return self.conn.timeout def _setup_obfuscation(self): + """ + Sets up the obfuscated protocol. + """ # Obfuscated messages secrets cannot start with any of these keywords = (b'PVrG', b'GET ', b'POST', b'\xee' * 4) while True: @@ -122,13 +144,19 @@ class Connection: self.conn.write(bytes(random)) def is_connected(self): + """ + Determines whether the connection is alive or not. + + :return: true if it's connected. + """ return self.conn.connected def close(self): + """Closes the connection.""" self.conn.close() def clone(self): - """Creates a copy of this Connection""" + """Creates a copy of this Connection.""" return Connection( mode=self._mode, proxy=self.conn.proxy, timeout=self.conn.timeout ) @@ -141,6 +169,15 @@ class Connection: raise ValueError('Invalid connection mode specified: ' + str(self._mode)) def _recv_tcp_full(self): + """ + Receives a message from the network, + internally encoded using the TCP full protocol. + + May raise InvalidChecksumError if the received data doesn't + match its valid checksum. + + :return: the read message payload. + """ packet_len_seq = self.read(8) # 4 and 4 packet_len, seq = struct.unpack('= 127: length = struct.unpack('> 2 if length < 127: length = struct.pack('B', length) @@ -201,9 +268,21 @@ class Connection: raise ValueError('Invalid connection mode specified: ' + str(self._mode)) def _read_plain(self, length): + """ + Reads data from the socket connection. + + :param length: how many bytes should be read. + :return: a byte sequence with len(data) == length + """ return self.conn.read(length) def _read_obfuscated(self, length): + """ + Reads data and decrypts from the socket connection. + + :param length: how many bytes should be read. + :return: the decrypted byte sequence with len(data) == length + """ return self._aes_decrypt.encrypt( self.conn.read(length) ) @@ -216,9 +295,20 @@ class Connection: raise ValueError('Invalid connection mode specified: ' + str(self._mode)) def _write_plain(self, data): + """ + Writes the given data through the socket connection. + + :param data: the data in bytes to be written. + """ self.conn.write(data) def _write_obfuscated(self, data): + """ + Writes the given data through the socket connection, + using the obfuscated mode (AES encryption is applied on top). + + :param data: the data in bytes to be written. + """ self.conn.write(self._aes_encrypt.encrypt(data)) # endregion diff --git a/telethon/network/mtproto_plain_sender.py b/telethon/network/mtproto_plain_sender.py index c7c021be..cb6d63af 100644 --- a/telethon/network/mtproto_plain_sender.py +++ b/telethon/network/mtproto_plain_sender.py @@ -1,3 +1,7 @@ +""" +This module contains the class used to communicate with Telegram's servers +in plain text, when no authorization key has been created yet. +""" import struct import time @@ -6,32 +10,47 @@ from ..extensions import BinaryReader class MtProtoPlainSender: - """MTProto Mobile Protocol plain sender - (https://core.telegram.org/mtproto/description#unencrypted-messages) + """ + MTProto Mobile Protocol plain sender + (https://core.telegram.org/mtproto/description#unencrypted-messages) """ def __init__(self, connection): + """ + Initializes the MTProto plain sender. + + :param connection: the Connection to be used. + """ self._sequence = 0 self._time_offset = 0 self._last_msg_id = 0 self._connection = connection def connect(self): + """Connects to Telegram's servers.""" self._connection.connect() def disconnect(self): + """Disconnects from Telegram's servers.""" self._connection.close() def send(self, data): - """Sends a plain packet (auth_key_id = 0) containing the - given message body (data) + """ + Sends a plain packet (auth_key_id = 0) containing the + given message body (data). + + :param data: the data to be sent. """ self._connection.send( struct.pack(' Date: Thu, 30 Nov 2017 13:34:55 +0100 Subject: [PATCH 07/78] Fix TLParser not stripping inline comments --- telethon_generator/parser/tl_parser.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/telethon_generator/parser/tl_parser.py b/telethon_generator/parser/tl_parser.py index a08521db..8c24cbf4 100644 --- a/telethon_generator/parser/tl_parser.py +++ b/telethon_generator/parser/tl_parser.py @@ -17,11 +17,13 @@ class TLParser: # Read all the lines from the .tl file for line in file: + # Strip comments from the line + comment_index = line.find('//') + if comment_index != -1: + line = line[:comment_index] + line = line.strip() - - # Ensure that the line is not a comment - if line and not line.startswith('//'): - + if line: # Check whether the line is a type change # (types <-> functions) or not match = re.match('---(\w+)---', line) From 7d7b2cb1fa769393de2e3b0e2e7b9cc606e0b846 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 30 Nov 2017 20:40:35 +0100 Subject: [PATCH 08/78] Remove redundant checks from UpdateState --- telethon/update_state.py | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/telethon/update_state.py b/telethon/update_state.py index 9410125e..302d4ab8 100644 --- a/telethon/update_state.py +++ b/telethon/update_state.py @@ -163,27 +163,22 @@ class UpdateState: self._latest_updates.append(data) - if type(update).SUBCLASS_OF_ID == 0x8af52aac: # crc32(b'Updates') - # Expand "Updates" into "Update", and pass these to callbacks. - # Since .users and .chats have already been processed, we - # don't need to care about those either. - if isinstance(update, tl.UpdateShort): - self._updates.append(update.update) - self._updates_available.set() + if isinstance(update, tl.UpdateShort): + self._updates.append(update.update) + self._updates_available.set() - elif isinstance(update, (tl.Updates, tl.UpdatesCombined)): - self._updates.extend(update.updates) - self._updates_available.set() + # Expand "Updates" into "Update", and pass these to callbacks. + # Since .users and .chats have already been processed, we + # don't need to care about those either. + elif isinstance(update, (tl.Updates, tl.UpdatesCombined)): + self._updates.extend(update.updates) + self._updates_available.set() - elif not isinstance(update, tl.UpdatesTooLong): - # TODO Handle "Updates too long" - self._updates.append(update) - self._updates_available.set() - - elif type(update).SUBCLASS_OF_ID == 0x9f89304e: # crc32(b'Update') + elif not isinstance(update, tl.UpdatesTooLong): + # TODO Handle "Updates too long" self._updates.append(update) self._updates_available.set() + else: - self._logger.debug('Ignoring "update" of type {}'.format( - type(update).__name__) - ) + self._updates.append(update) + self._updates_available.set() From 21a93d58ec1949a55c31364658f390d685c2ddb9 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 30 Nov 2017 21:09:34 +0100 Subject: [PATCH 09/78] Use a synchronized queue instead event/deque pair --- telethon/update_state.py | 42 ++++++++++++++-------------------------- 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/telethon/update_state.py b/telethon/update_state.py index 302d4ab8..b7c43ba3 100644 --- a/telethon/update_state.py +++ b/telethon/update_state.py @@ -1,8 +1,9 @@ import logging import pickle from collections import deque +from queue import Queue, Empty from datetime import datetime -from threading import RLock, Event, Thread +from threading import RLock, Thread from .tl import types as tl @@ -26,8 +27,7 @@ class UpdateState: self.handlers = [] self._updates_lock = RLock() - self._updates_available = Event() - self._updates = deque() + self._updates = Queue() self._latest_updates = deque(maxlen=10) self._logger = logging.getLogger(__name__) @@ -37,24 +37,18 @@ class UpdateState: def can_poll(self): """Returns True if a call to .poll() won't lock""" - return self._updates_available.is_set() + return not self._updates.empty() def poll(self, timeout=None): """Polls an update or blocks until an update object is available. If 'timeout is not None', it should be a floating point value, and the method will 'return None' if waiting times out. """ - if not self._updates_available.wait(timeout=timeout): + try: + update = self._updates.get(timeout=timeout) + except Empty: return - with self._updates_lock: - if not self._updates_available.is_set(): - return - - update = self._updates.popleft() - if not self._updates: - self._updates_available.clear() - if isinstance(update, Exception): raise update # Some error was set through (surely StopIteration) @@ -70,7 +64,8 @@ class UpdateState: self.stop_workers() self._workers = n if n is None: - self._updates.clear() + while self._updates: + self._updates.get() else: self.setup_workers() @@ -86,8 +81,7 @@ class UpdateState: # on all the worker threads # TODO Should this reset the pts and such? for _ in range(self._workers): - self._updates.appendleft(StopIteration()) - self._updates_available.set() + self._updates.put(StopIteration()) for t in self._worker_threads: t.join() @@ -164,21 +158,15 @@ class UpdateState: self._latest_updates.append(data) if isinstance(update, tl.UpdateShort): - self._updates.append(update.update) - self._updates_available.set() - + self._updates.put(update.update) # Expand "Updates" into "Update", and pass these to callbacks. # Since .users and .chats have already been processed, we # don't need to care about those either. elif isinstance(update, (tl.Updates, tl.UpdatesCombined)): - self._updates.extend(update.updates) - self._updates_available.set() - + for u in update.updates: + self._updates.put(u) elif not isinstance(update, tl.UpdatesTooLong): # TODO Handle "Updates too long" - self._updates.append(update) - self._updates_available.set() - + self._updates.put(update) else: - self._updates.append(update) - self._updates_available.set() + self._updates.put(update) From 6662f49bcbf7b7a1f0db855fe8c6a2fd883ac0b7 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 30 Nov 2017 21:10:02 +0100 Subject: [PATCH 10/78] Remove another redundant if --- telethon/update_state.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/telethon/update_state.py b/telethon/update_state.py index b7c43ba3..c3768fbd 100644 --- a/telethon/update_state.py +++ b/telethon/update_state.py @@ -165,8 +165,6 @@ class UpdateState: elif isinstance(update, (tl.Updates, tl.UpdatesCombined)): for u in update.updates: self._updates.put(u) - elif not isinstance(update, tl.UpdatesTooLong): - # TODO Handle "Updates too long" - self._updates.put(update) + # TODO Handle "tl.UpdatesTooLong" else: self._updates.put(update) From d4d7aa9063a7d53b80a0d3eb8bc0f1e5434d0506 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 3 Dec 2017 21:10:22 +0100 Subject: [PATCH 11/78] Use signed salt --- telethon/network/mtproto_sender.py | 7 ++----- telethon/tl/session.py | 9 +++++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/telethon/network/mtproto_sender.py b/telethon/network/mtproto_sender.py index 117c6f68..33794167 100644 --- a/telethon/network/mtproto_sender.py +++ b/telethon/network/mtproto_sender.py @@ -155,7 +155,7 @@ class MtProtoSender: :param message: the TLMessage to be sent. """ plain_text = \ - struct.pack(' Date: Mon, 4 Dec 2017 20:34:35 +0100 Subject: [PATCH 12/78] Don't ignore NewSessionCreated salt --- telethon/network/mtproto_sender.py | 1 + 1 file changed, 1 insertion(+) diff --git a/telethon/network/mtproto_sender.py b/telethon/network/mtproto_sender.py index 33794167..b775ae92 100644 --- a/telethon/network/mtproto_sender.py +++ b/telethon/network/mtproto_sender.py @@ -490,6 +490,7 @@ class MtProtoSender: """ new_session = reader.tgread_object() assert isinstance(new_session, NewSessionCreated) + self.session.salt = new_session.server_salt # TODO https://goo.gl/LMyN7A return True From 0e0bc6ecbc84e2703aa75bc7e626ff13a2bf4032 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 7 Dec 2017 12:22:40 +0100 Subject: [PATCH 13/78] Fix session ID is also signed since d4d7aa9 --- telethon/network/mtproto_sender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/network/mtproto_sender.py b/telethon/network/mtproto_sender.py index b775ae92..41c791d9 100644 --- a/telethon/network/mtproto_sender.py +++ b/telethon/network/mtproto_sender.py @@ -155,7 +155,7 @@ class MtProtoSender: :param message: the TLMessage to be sent. """ plain_text = \ - struct.pack(' Date: Thu, 14 Dec 2017 14:46:57 +0330 Subject: [PATCH 14/78] Fix typo in sessions.rst (#491) --- readthedocs/extra/basic/sessions.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readthedocs/extra/basic/sessions.rst b/readthedocs/extra/basic/sessions.rst index 0f9d458a..f55d9703 100644 --- a/readthedocs/extra/basic/sessions.rst +++ b/readthedocs/extra/basic/sessions.rst @@ -4,7 +4,7 @@ Session Files ============== -The first parameter you pass the the constructor of the +The first parameter you pass the constructor of the ``TelegramClient`` is the ``session``, and defaults to be the session name (or full path). That is, if you create a ``TelegramClient('anon')`` instance and connect, an ``anon.session`` file will be created on the @@ -45,4 +45,4 @@ methods. For example, you could save it on a database: # load relevant data to the database You should read the ``session.py`` source file to know what “relevant -data” you need to keep track of. \ No newline at end of file +data” you need to keep track of. From 7d189119f40a6a30f3647a23f832ae378e32ae47 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 15 Dec 2017 19:46:17 +0100 Subject: [PATCH 15/78] Fix salt migration failing with valid signed salts --- telethon/tl/session.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/telethon/tl/session.py b/telethon/tl/session.py index dfbfffb3..e530cc83 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -128,8 +128,9 @@ class Session: result.port = data.get('port', result.port) result.salt = data.get('salt', result.salt) # Keep while migrating from unsigned to signed salt - result.salt = struct.unpack( - 'q', struct.pack('Q', result.salt))[0] + if result.salt > 0: + result.salt = struct.unpack( + 'q', struct.pack('Q', result.salt))[0] result.layer = data.get('layer', result.layer) result.server_address = \ From 5842d3741bcda3d54d4fc69b39adf0f1233c768e Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 20 Dec 2017 12:47:10 +0100 Subject: [PATCH 16/78] Make a proper use of the logging module --- telethon/network/mtproto_sender.py | 34 ++++------- telethon/telegram_bare_client.py | 96 +++++++++++++++++++++--------- telethon/update_state.py | 11 ++-- 3 files changed, 87 insertions(+), 54 deletions(-) diff --git a/telethon/network/mtproto_sender.py b/telethon/network/mtproto_sender.py index 41c791d9..d76d44ae 100644 --- a/telethon/network/mtproto_sender.py +++ b/telethon/network/mtproto_sender.py @@ -21,7 +21,7 @@ from ..tl.types import ( ) from ..tl.functions.auth import LogOutRequest -logging.getLogger(__name__).addHandler(logging.NullHandler()) +__log__ = logging.getLogger(__name__) class MtProtoSender: @@ -46,7 +46,6 @@ class MtProtoSender: """ self.session = session self.connection = connection - self._logger = logging.getLogger(__name__) # Message IDs that need confirmation self._need_confirmation = set() @@ -137,6 +136,9 @@ class MtProtoSender: # "This packet should be skipped"; since this may have # been a result for a request, invalidate every request # and just re-invoke them to avoid problems + __log__.exception('Error while receiving server response. ' + '%d pending request(s) will be ignored', + len(self._pending_receive)) self._clear_all_pending() return @@ -218,7 +220,7 @@ class MtProtoSender: code = reader.read_int(signed=False) reader.seek(-4) - # The following codes are "parsed manually" + __log__.debug('Processing server message with ID %s', hex(code)) if code == 0xf35c6d01: # rpc_result, (response of an RPC call) return self._handle_rpc_result(msg_id, sequence, reader) @@ -257,7 +259,6 @@ class MtProtoSender: if r: r.result = True # Telegram won't send this value r.confirm_received.set() - self._logger.debug('Message ack confirmed', r) return True @@ -270,11 +271,9 @@ class MtProtoSender: return True - self._logger.debug( - '[WARN] Unknown message: {}, data left in the buffer: {}' - .format( - hex(code), repr(reader.get_bytes()[reader.tell_position():]) - ) + __log__.warning( + 'Unknown message with ID %d, data left in the buffer %s', + hex(code), repr(reader.get_bytes()[reader.tell_position():]) ) return False @@ -351,13 +350,11 @@ class MtProtoSender: :param reader: the reader containing the Pong. :return: true, as it always succeeds. """ - self._logger.debug('Handling pong') pong = reader.tgread_object() assert isinstance(pong, Pong) request = self._pop_request(pong.msg_id) if request: - self._logger.debug('Pong confirmed a request') request.result = pong request.confirm_received.set() @@ -372,7 +369,6 @@ class MtProtoSender: :param reader: the reader containing the MessageContainer. :return: true, as it always succeeds. """ - self._logger.debug('Handling container') for inner_msg_id, _, inner_len in MessageContainer.iter_read(reader): begin_position = reader.tell_position() @@ -397,7 +393,6 @@ class MtProtoSender: :param reader: the reader containing the BadServerSalt. :return: true, as it always succeeds. """ - self._logger.debug('Handling bad server salt') bad_salt = reader.tgread_object() assert isinstance(bad_salt, BadServerSalt) @@ -418,28 +413,29 @@ class MtProtoSender: :param reader: the reader containing the BadMessageError. :return: true, as it always succeeds. """ - self._logger.debug('Handling bad message notification') bad_msg = reader.tgread_object() assert isinstance(bad_msg, BadMsgNotification) error = BadMessageError(bad_msg.error_code) + __log__.warning('Read bad msg notification %s: %s', bad_msg, error) if bad_msg.error_code in (16, 17): # sent msg_id too low or too high (respectively). # Use the current msg_id to determine the right time offset. self.session.update_time_offset(correct_msg_id=msg_id) - self._logger.debug('Read Bad Message error: ' + str(error)) - self._logger.debug('Attempting to use the correct time offset.') + __log__.info('Attempting to use the correct time offset') self._resend_request(bad_msg.bad_msg_id) return True elif bad_msg.error_code == 32: # msg_seqno too low, so just pump it up by some "large" amount # TODO A better fix would be to start with a new fresh session ID self.session._sequence += 64 + __log__.info('Attempting to set the right higher sequence') self._resend_request(bad_msg.bad_msg_id) return True elif bad_msg.error_code == 33: # msg_seqno too high never seems to happen but just in case self.session._sequence -= 16 + __log__.info('Attempting to set the right lower sequence') self._resend_request(bad_msg.bad_msg_id) return True else: @@ -504,7 +500,6 @@ class MtProtoSender: :return: true if the request ID to which this result belongs is found, false otherwise (meaning nothing was read). """ - self._logger.debug('Handling RPC result') reader.read_int(signed=False) # code request_id = reader.read_long() inner_code = reader.read_int(signed=False) @@ -530,11 +525,9 @@ class MtProtoSender: request.confirm_received.set() # else TODO Where should this error be reported? # Read may be async. Can an error not-belong to a request? - self._logger.debug('Read RPC error: %s', str(error)) return True # All contents were read okay elif request: - self._logger.debug('Reading request response') if inner_code == 0x3072cfa1: # GZip packed unpacked_data = gzip.decompress(reader.tgread_bytes()) with BinaryReader(unpacked_data) as compressed_reader: @@ -549,7 +542,7 @@ class MtProtoSender: # If it's really a result for RPC from previous connection # session, it will be skipped by the handle_container() - self._logger.debug('Lost request will be skipped.') + __log__.warning('Lost request will be skipped') return False def _handle_gzip_packed(self, msg_id, sequence, reader, state): @@ -561,7 +554,6 @@ class MtProtoSender: :param reader: the reader containing the GzipPacked. :return: the result of processing the packed message. """ - self._logger.debug('Handling gzip packed data') with BinaryReader(GzipPacked.read(reader)) as compressed_reader: # We are reentering process_msg, which seemingly the same msg_id # to the self._need_confirmation set. Remove it from there first diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index 97251547..6c7d3ab0 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -43,6 +43,8 @@ DEFAULT_IPV4_IP = '149.154.167.51' DEFAULT_IPV6_IP = '[2001:67c:4e8:f002::a]' DEFAULT_PORT = 443 +__log__ = logging.getLogger(__name__) + class TelegramBareClient: """Bare Telegram Client with just the minimum - @@ -117,8 +119,6 @@ class TelegramBareClient: mode=connection_mode, proxy=proxy, timeout=timeout )) - self._logger = logging.getLogger(__name__) - # Two threads may be calling reconnect() when the connection is lost, # we only want one to actually perform the reconnection. self._reconnect_lock = Lock() @@ -191,11 +191,15 @@ class TelegramBareClient: native data center, raising a "UserMigrateError", and calling .disconnect() in the process. """ + __log__.info('Connecting to %s:%d...', + self.session.server_address, self.session.port) + self._main_thread_ident = threading.get_ident() self._background_error = None # Clear previous errors try: self._sender.connect() + __log__.info('Connection success!') # Connection was successful! Try syncing the update state # UNLESS '_sync_updates' is False (we probably are in @@ -215,14 +219,15 @@ class TelegramBareClient: except TypeNotFoundError as e: # This is fine, probably layer migration - self._logger.debug('Found invalid item, probably migrating', e) + __log__.warning('Connection failed, got unexpected type with ID ' + '%s. Migrating?', hex(e.invalid_constructor_id)) self.disconnect() return self.connect(_sync_updates=_sync_updates) - except (RPCError, ConnectionError): + except (RPCError, ConnectionError) as e: # Probably errors from the previous session, ignore them + __log__.error('Connection failed due to %s', e) self.disconnect() - self._logger.exception('Could not stabilise initial connection.') return False def is_connected(self): @@ -244,14 +249,19 @@ class TelegramBareClient: def disconnect(self): """Disconnects from the Telegram server and stops all the spawned threads""" + __log__.info('Disconnecting...') self._user_connected = False # This will stop recv_thread's loop + + __log__.debug('Stopping all workers...') self.updates.stop_workers() # This will trigger a "ConnectionResetError" on the recv_thread, # which won't attempt reconnecting as ._user_connected is False. + __log__.debug('Disconnecting the socket...') self._sender.disconnect() if self._recv_thread: + __log__.debug('Joining the read thread...') self._recv_thread.join() # TODO Shall we clear the _exported_sessions, or may be reused? @@ -268,17 +278,21 @@ class TelegramBareClient: """ if new_dc is None: if self.is_connected(): + __log__.info('Reconnection aborted: already connected') return True try: + __log__.info('Attempting reconnection...') return self.connect() - except ConnectionResetError: + except ConnectionResetError as e: + __log__.warning('Reconnection failed due to %s', e) return False else: # Since we're reconnecting possibly due to a UserMigrateError, # we need to first know the Data Centers we can connect to. Do # that before disconnecting. dc = self._get_dc(new_dc) + __log__.info('Reconnecting to new data center %s', dc) self.session.server_address = dc.ip_address self.session.port = dc.port @@ -340,6 +354,7 @@ class TelegramBareClient: dc = self._get_dc(dc_id) # Export the current authorization to the new DC. + __log__.info('Exporting authorization for data center %s', dc) export_auth = self(ExportAuthorizationRequest(dc_id)) # Create a temporary session for this IP address, which needs @@ -352,6 +367,7 @@ class TelegramBareClient: session.port = dc.port self._exported_sessions[dc_id] = session + __log__.info('Creating exported new client') client = TelegramBareClient( session, self.api_id, self.api_hash, proxy=self._sender.connection.conn.proxy, @@ -363,7 +379,7 @@ class TelegramBareClient: id=export_auth.id, bytes=export_auth.bytes )) elif export_auth is not None: - self._logger.warning('Unknown return export_auth type', export_auth) + __log__.warning('Unknown export auth type %s', export_auth) client._authorized = True # We exported the auth, so we got auth return client @@ -378,6 +394,7 @@ class TelegramBareClient: session.port = dc.port self._exported_sessions[cdn_redirect.dc_id] = session + __log__.info('Creating new CDN client') client = TelegramBareClient( session, self.api_id, self.api_hash, proxy=self._sender.connection.conn.proxy, @@ -407,12 +424,23 @@ class TelegramBareClient: x.content_related for x in requests): raise ValueError('You can only invoke requests, not types!') + # For logging purposes + if len(requests) == 1: + which = type(requests[0]).__name__ + else: + which = '{} requests ({})'.format( + len(requests), [type(x).__name__ for x in requests]) + # Determine the sender to be used (main or a new connection) on_main_thread = threading.get_ident() == self._main_thread_ident if on_main_thread or self._on_read_thread(): + __log__.debug('Invoking %s from main thread', which) sender = self._sender update_state = self.updates else: + __log__.debug('Invoking %s from background thread. ' + 'Creating temporary connection', which) + sender = self._sender.clone() sender.connect() # We're on another connection, Telegram will resend all the @@ -431,7 +459,7 @@ class TelegramBareClient: call_receive = not on_main_thread or self._recv_thread is None \ or self._reconnect_lock.locked() try: - for _ in range(retries): + for attempt in range(retries): if self._background_error and on_main_thread: raise self._background_error @@ -441,7 +469,9 @@ class TelegramBareClient: if result is not None: return result - self._logger.debug('RPC failed. Attempting reconnection.') + __log__.warning('Invoking %s failed %d times, ' + 'reconnecting and retrying', + [str(x) for x in requests], attempt + 1) sleep(1) # The ReadThread has priority when attempting reconnection, # since this thread is constantly running while __call__ is @@ -475,11 +505,13 @@ class TelegramBareClient: 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. + __log__.info('Need to generate new auth key before invoking') self.session.auth_key, self.session.time_offset = \ authenticator.do_authentication(self._sender.connection) init_connection = True if init_connection: + __log__.info('Initializing a new connection while invoking') if len(requests) == 1: requests = [self._wrap_init_connection(requests[0])] else: @@ -506,13 +538,14 @@ class TelegramBareClient: sender.receive(update_state=update_state) except BrokenAuthKeyError: - self._logger.error('Broken auth key, a new one will be generated') + __log__.error('Authorization key seems broken and was invalid!') self.session.auth_key = None except TimeoutError: - pass # We will just retry + __log__.warning('Invoking timed out') # We will just retry except ConnectionResetError: + __log__.warning('Connection was reset while invoking') if self._user_connected: # Server disconnected us, __call__ will try reconnecting. return None @@ -541,10 +574,6 @@ class TelegramBareClient: except (PhoneMigrateError, NetworkMigrateError, UserMigrateError) as e: - self._logger.debug( - 'DC error when invoking request, ' - 'attempting to reconnect at DC {}'.format(e.new_dc) - ) # TODO What happens with the background thread here? # For normal use cases, this won't happen, because this will only @@ -555,17 +584,13 @@ class TelegramBareClient: except ServerError as e: # Telegram is having some issues, just retry - self._logger.debug( - '[ERROR] Telegram is having some internal issues', e - ) + __log__.error('Telegram servers are having internal errors %s', e) except FloodWaitError as e: + __log__.warning('Request invoked too often, wait %ds', e.seconds) if e.seconds > self.session.flood_sleep_threshold | 0: raise - self._logger.debug( - 'Sleep of %d seconds below threshold, sleeping' % e.seconds - ) sleep(e.seconds) # Some really basic functionality @@ -628,6 +653,8 @@ class TelegramBareClient: file_id = utils.generate_random_long() hash_md5 = md5() + __log__.info('Uploading file of %d bytes in %d chunks of %d', + file_size, part_count, part_size) stream = open(file, 'rb') if isinstance(file, str) else BytesIO(file) try: for part_index in range(part_count): @@ -644,6 +671,7 @@ class TelegramBareClient: result = self(request) if result: + __log__.debug('Uploaded %d/%d', part_index, part_count) if not is_large: # No need to update the hash if it's a large file hash_md5.update(part) @@ -712,6 +740,7 @@ class TelegramBareClient: client = self cdn_decrypter = None + __log__.info('Downloading file in chunks of %d bytes', part_size) try: offset = 0 while True: @@ -724,12 +753,14 @@ class TelegramBareClient: )) if isinstance(result, FileCdnRedirect): + __log__.info('File lives in a CDN') cdn_decrypter, result = \ CdnDecrypter.prepare_decrypter( client, self._get_cdn_client(result), result ) except FileMigrateError as e: + __log__.info('File lives in another DC') client = self._get_exported_client(e.new_dc) continue @@ -742,6 +773,7 @@ class TelegramBareClient: return getattr(result, 'type', '') f.write(result.bytes) + __log__.debug('Saved %d more bytes', len(result.bytes)) if progress_callback: progress_callback(f.tell(), file_size) finally: @@ -803,7 +835,6 @@ class TelegramBareClient: if self._user_connected: self.disconnect() else: - self._logger.debug('Forcing exit...') os._exit(1) def idle(self, stop_signals=(SIGINT, SIGTERM, SIGABRT)): @@ -824,6 +855,11 @@ class TelegramBareClient: for sig in stop_signals: signal(sig, self._signal_handler) + if self._on_read_thread(): + __log__.info('Starting to wait for items from the network') + else: + __log__.info('Idling to receive items from the network') + while self._user_connected: try: if datetime.now() > self._last_ping + self._ping_delay: @@ -832,16 +868,21 @@ class TelegramBareClient: )) self._last_ping = datetime.now() + __log__.debug('Receiving items from the network...') self._sender.receive(update_state=self.updates) except TimeoutError: - # No problem. - pass + # No problem + __log__.info('Receiving items from the network timed out') except ConnectionResetError: - self._logger.debug('Server disconnected us. Reconnecting...') + if self._user_connected: + __log__.error('Connection was reset while receiving ' + 'items. Reconnecting') with self._reconnect_lock: while self._user_connected and not self._reconnect(): sleep(0.1) # Retry forever, this is instant messaging + __log__.info('Connection closed by the user, not reading anymore') + # By using this approach, another thread will be # created and started upon connection to constantly read # from the other end. Otherwise, manual calls to .receive() @@ -857,10 +898,9 @@ class TelegramBareClient: try: self.idle(stop_signals=tuple()) except Exception as error: + __log__.exception('Unknown exception in the read thread! ' + 'Disconnecting and leaving it to main thread') # Unknown exception, pass it to the main thread - self._logger.exception( - 'Unknown error on the read thread, please report' - ) try: import socks diff --git a/telethon/update_state.py b/telethon/update_state.py index c3768fbd..9f308d89 100644 --- a/telethon/update_state.py +++ b/telethon/update_state.py @@ -7,6 +7,8 @@ from threading import RLock, Thread from .tl import types as tl +__log__ = logging.getLogger(__name__) + class UpdateState: """Used to hold the current state of processed updates. @@ -30,8 +32,6 @@ class UpdateState: self._updates = Queue() self._latest_updates = deque(maxlen=10) - self._logger = logging.getLogger(__name__) - # https://core.telegram.org/api/updates self._state = tl.updates.State(0, 0, datetime.now(), 0, 0) @@ -115,9 +115,7 @@ class UpdateState: break except: # We don't want to crash a worker thread due to any reason - self._logger.exception( - '[ERROR] Unhandled exception on worker {}'.format(wid) - ) + __log__.exception('Unhandled exception on worker %d', wid) def process(self, update): """Processes an update object. This method is normally called by @@ -128,11 +126,13 @@ class UpdateState: with self._updates_lock: if isinstance(update, tl.updates.State): + __log__.debug('Saved new updates state') self._state = update return # Nothing else to be done pts = getattr(update, 'pts', self._state.pts) if hasattr(update, 'pts') and pts <= self._state.pts: + __log__.info('Ignoring %s, already have it', update) return # We already handled this update self._state.pts = pts @@ -153,6 +153,7 @@ class UpdateState: """ data = pickle.dumps(update.to_dict()) if data in self._latest_updates: + __log__.info('Ignoring %s, already have it', update) return # Duplicated too self._latest_updates.append(data) From c848ae0ace8a6d496fdb52a7f8a0519d0259d839 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 20 Dec 2017 17:45:40 +0100 Subject: [PATCH 17/78] Move tgread_object() outside specific msg processing calls --- telethon/network/mtproto_sender.py | 94 +++++++++++++----------------- 1 file changed, 40 insertions(+), 54 deletions(-) diff --git a/telethon/network/mtproto_sender.py b/telethon/network/mtproto_sender.py index d76d44ae..7e4d2f18 100644 --- a/telethon/network/mtproto_sender.py +++ b/telethon/network/mtproto_sender.py @@ -220,41 +220,52 @@ class MtProtoSender: code = reader.read_int(signed=False) reader.seek(-4) - __log__.debug('Processing server message with ID %s', hex(code)) + # These are a bit of special case, not yet generated by the code gen if code == 0xf35c6d01: # rpc_result, (response of an RPC call) + __log__.debug('Processing Remote Procedure Call result') return self._handle_rpc_result(msg_id, sequence, reader) - if code == Pong.CONSTRUCTOR_ID: - return self._handle_pong(msg_id, sequence, reader) - if code == MessageContainer.CONSTRUCTOR_ID: + __log__.debug('Processing container result') return self._handle_container(msg_id, sequence, reader, state) if code == GzipPacked.CONSTRUCTOR_ID: + __log__.debug('Processing gzipped result') return self._handle_gzip_packed(msg_id, sequence, reader, state) - if code == BadServerSalt.CONSTRUCTOR_ID: - return self._handle_bad_server_salt(msg_id, sequence, reader) + if code not in tlobjects: + __log__.warning( + 'Unknown message with ID %d, data left in the buffer %s', + hex(code), repr(reader.get_bytes()[reader.tell_position():]) + ) + return False - if code == BadMsgNotification.CONSTRUCTOR_ID: - return self._handle_bad_msg_notification(msg_id, sequence, reader) + obj = reader.tgread_object() + __log__.debug('Processing %s result', type(obj).__name__) - if code == MsgDetailedInfo.CONSTRUCTOR_ID: - return self._handle_msg_detailed_info(msg_id, sequence, reader) + if isinstance(obj, Pong): + return self._handle_pong(msg_id, sequence, obj) - if code == MsgNewDetailedInfo.CONSTRUCTOR_ID: - return self._handle_msg_new_detailed_info(msg_id, sequence, reader) + if isinstance(obj, BadServerSalt): + return self._handle_bad_server_salt(msg_id, sequence, obj) - if code == NewSessionCreated.CONSTRUCTOR_ID: - return self._handle_new_session_created(msg_id, sequence, reader) + if isinstance(obj, BadMsgNotification): + return self._handle_bad_msg_notification(msg_id, sequence, obj) - if code == MsgsAck.CONSTRUCTOR_ID: # may handle the request we wanted - ack = reader.tgread_object() - assert isinstance(ack, MsgsAck) + if isinstance(obj, MsgDetailedInfo): + return self._handle_msg_detailed_info(msg_id, sequence, obj) + + if isinstance(obj, MsgNewDetailedInfo): + return self._handle_msg_new_detailed_info(msg_id, sequence, obj) + + if isinstance(obj, NewSessionCreated): + return self._handle_new_session_created(msg_id, sequence, obj) + + if isinstance(obj, MsgsAck): # may handle the request we wanted # Ignore every ack request *unless* when logging out, when it's # when it seems to only make sense. We also need to set a non-None # result since Telegram doesn't send the response for these. - for msg_id in ack.msg_ids: + for msg_id in obj.msg_ids: r = self._pop_request_of_type(msg_id, LogOutRequest) if r: r.result = True # Telegram won't send this value @@ -262,20 +273,12 @@ class MtProtoSender: return True - # If the code is not parsed manually then it should be a TLObject. - if code in tlobjects: - result = reader.tgread_object() - self.session.process_entities(result) - if state: - state.process(result) + # If the object isn't any of the above, then it should be an Update. + self.session.process_entities(obj) + if state: + state.process(obj) - return True - - __log__.warning( - 'Unknown message with ID %d, data left in the buffer %s', - hex(code), repr(reader.get_bytes()[reader.tell_position():]) - ) - return False + return True # endregion @@ -341,7 +344,7 @@ class MtProtoSender: if requests: return self.send(*requests) - def _handle_pong(self, msg_id, sequence, reader): + def _handle_pong(self, msg_id, sequence, pong): """ Handles a Pong response. @@ -350,9 +353,6 @@ class MtProtoSender: :param reader: the reader containing the Pong. :return: true, as it always succeeds. """ - pong = reader.tgread_object() - assert isinstance(pong, Pong) - request = self._pop_request(pong.msg_id) if request: request.result = pong @@ -384,7 +384,7 @@ class MtProtoSender: return True - def _handle_bad_server_salt(self, msg_id, sequence, reader): + def _handle_bad_server_salt(self, msg_id, sequence, bad_salt): """ Handles a BadServerSalt response. @@ -393,9 +393,6 @@ class MtProtoSender: :param reader: the reader containing the BadServerSalt. :return: true, as it always succeeds. """ - bad_salt = reader.tgread_object() - assert isinstance(bad_salt, BadServerSalt) - self.session.salt = bad_salt.new_server_salt self.session.save() @@ -404,7 +401,7 @@ class MtProtoSender: self._resend_request(bad_salt.bad_msg_id) return True - def _handle_bad_msg_notification(self, msg_id, sequence, reader): + def _handle_bad_msg_notification(self, msg_id, sequence, bad_msg): """ Handles a BadMessageError response. @@ -413,9 +410,6 @@ class MtProtoSender: :param reader: the reader containing the BadMessageError. :return: true, as it always succeeds. """ - bad_msg = reader.tgread_object() - assert isinstance(bad_msg, BadMsgNotification) - error = BadMessageError(bad_msg.error_code) __log__.warning('Read bad msg notification %s: %s', bad_msg, error) if bad_msg.error_code in (16, 17): @@ -441,7 +435,7 @@ class MtProtoSender: else: raise error - def _handle_msg_detailed_info(self, msg_id, sequence, reader): + def _handle_msg_detailed_info(self, msg_id, sequence, msg_new): """ Handles a MsgDetailedInfo response. @@ -450,15 +444,12 @@ class MtProtoSender: :param reader: the reader containing the MsgDetailedInfo. :return: true, as it always succeeds. """ - msg_new = reader.tgread_object() - assert isinstance(msg_new, MsgDetailedInfo) - # TODO For now, simply ack msg_new.answer_msg_id # Relevant tdesktop source code: https://goo.gl/VvpCC6 self._send_acknowledge(msg_new.answer_msg_id) return True - def _handle_msg_new_detailed_info(self, msg_id, sequence, reader): + def _handle_msg_new_detailed_info(self, msg_id, sequence, msg_new): """ Handles a MsgNewDetailedInfo response. @@ -467,15 +458,12 @@ class MtProtoSender: :param reader: the reader containing the MsgNewDetailedInfo. :return: true, as it always succeeds. """ - msg_new = reader.tgread_object() - assert isinstance(msg_new, MsgNewDetailedInfo) - # TODO For now, simply ack msg_new.answer_msg_id # Relevant tdesktop source code: https://goo.gl/G7DPsR self._send_acknowledge(msg_new.answer_msg_id) return True - def _handle_new_session_created(self, msg_id, sequence, reader): + def _handle_new_session_created(self, msg_id, sequence, new_session): """ Handles a NewSessionCreated response. @@ -484,8 +472,6 @@ class MtProtoSender: :param reader: the reader containing the NewSessionCreated. :return: true, as it always succeeds. """ - new_session = reader.tgread_object() - assert isinstance(new_session, NewSessionCreated) self.session.salt = new_session.server_salt # TODO https://goo.gl/LMyN7A return True From 23ab70fc29048d8a0cc135fb731d8ab4124170ec Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 20 Dec 2017 17:48:41 +0100 Subject: [PATCH 18/78] Remove unused request_msg_id from the TLObject class --- telethon/tl/tlobject.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/telethon/tl/tlobject.py b/telethon/tl/tlobject.py index 68c5e741..e2b23018 100644 --- a/telethon/tl/tlobject.py +++ b/telethon/tl/tlobject.py @@ -4,8 +4,6 @@ from threading import Event class TLObject: def __init__(self): - self.request_msg_id = 0 # Long - self.confirm_received = Event() self.rpc_error = None From 992017ddf8eb3d9fcc70ff82761acdea4260ecb6 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 22 Dec 2017 11:27:57 +0100 Subject: [PATCH 19/78] Except ConnectionAbortedError on TcpClient --- telethon/extensions/tcp_client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/telethon/extensions/tcp_client.py b/telethon/extensions/tcp_client.py index 3941a4d6..9a007dcd 100644 --- a/telethon/extensions/tcp_client.py +++ b/telethon/extensions/tcp_client.py @@ -113,7 +113,7 @@ class TcpClient: self._socket.sendall(data) except socket.timeout as e: raise TimeoutError() from e - except BrokenPipeError: + except (BrokenPipeError, ConnectionAbortedError): self._raise_connection_reset() except OSError as e: if e.errno == errno.EBADF: @@ -139,6 +139,11 @@ class TcpClient: partial = self._socket.recv(bytes_left) except socket.timeout as e: raise TimeoutError() from e + except ConnectionAbortedError: + # ConnectionAbortedError: [WinError 10053] + # An established connection was aborted by + # the software in your host machine. + self._raise_connection_reset() except OSError as e: if e.errno == errno.EBADF or e.errno == errno.ENOTSOCK: self._raise_connection_reset() From 4a2a64ce2f6f5b3b89d3dbfcd0af29725bb03732 Mon Sep 17 00:00:00 2001 From: "Dmitry D. Chernov" Date: Sat, 23 Dec 2017 05:45:23 +1000 Subject: [PATCH 20/78] TcpClient: Catch ConnectionError instead of its particular cases That can be more reliable, especially in the case of using PySocks. --- telethon/extensions/tcp_client.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/telethon/extensions/tcp_client.py b/telethon/extensions/tcp_client.py index 9a007dcd..f59bb9f0 100644 --- a/telethon/extensions/tcp_client.py +++ b/telethon/extensions/tcp_client.py @@ -113,7 +113,7 @@ class TcpClient: self._socket.sendall(data) except socket.timeout as e: raise TimeoutError() from e - except (BrokenPipeError, ConnectionAbortedError): + except ConnectionError: self._raise_connection_reset() except OSError as e: if e.errno == errno.EBADF: @@ -139,10 +139,7 @@ class TcpClient: partial = self._socket.recv(bytes_left) except socket.timeout as e: raise TimeoutError() from e - except ConnectionAbortedError: - # ConnectionAbortedError: [WinError 10053] - # An established connection was aborted by - # the software in your host machine. + except ConnectionError: self._raise_connection_reset() except OSError as e: if e.errno == errno.EBADF or e.errno == errno.ENOTSOCK: From fb9813ae61bd6657293c867bf17af102cb8c37c2 Mon Sep 17 00:00:00 2001 From: "Dmitry D. Chernov" Date: Sun, 24 Dec 2017 21:21:14 +1000 Subject: [PATCH 21/78] TelegramClient.send_code_request(): Change logic of methods invocation Before: First call, force_sms=False: SendCodeRequest Next call, force_sms=False: SendCodeRequest First call, force_sms=True: raise ValueError Next call, force_sms=True: ResendCodeRequest That's inconvenient because the user must remember whether the code requested at all and whether the request was successful. In addition, the repeated invocation of SendCodeRequest does nothing. This commit changes logic to this: First call, force_sms=False: SendCodeRequest Next call, force_sms=False: ResendCodeRequest First call, force_sms=True: SendCodeRequest, ResendCodeRequest Next call, force_sms=True: ResendCodeRequest --- telethon/telegram_client.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index fc4b4342..8546c377 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -138,23 +138,24 @@ class TelegramClient(TelegramBareClient): :param str | int phone: The phone to which the code will be sent. :param bool force_sms: - Whether to force sending as SMS. You should call it at least - once before without this set to True first. + Whether to force sending as SMS. :return auth.SentCode: Information about the result of the request. """ phone = EntityDatabase.parse_phone(phone) or self._phone - if force_sms: - if not self._phone_code_hash: - raise ValueError( - 'You must call this method without force_sms at least once.' - ) - result = self(ResendCodeRequest(phone, self._phone_code_hash)) - else: + + if not self._phone_code_hash: result = self(SendCodeRequest(phone, self.api_id, self.api_hash)) self._phone_code_hash = result.phone_code_hash + else: + force_sms = True self._phone = phone + + if force_sms: + result = self(ResendCodeRequest(phone, self._phone_code_hash)) + self._phone_code_hash = result.phone_code_hash + return result def sign_in(self, phone=None, code=None, From 9c66f0b2b48dc8ed526b4a253719aa974f71254d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Sun, 24 Dec 2017 15:14:54 +0100 Subject: [PATCH 22/78] Fix empty strings not working as expected for flag parameters --- telethon_generator/tl_generator.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/telethon_generator/tl_generator.py b/telethon_generator/tl_generator.py index 60f07bd6..f8a9e873 100644 --- a/telethon_generator/tl_generator.py +++ b/telethon_generator/tl_generator.py @@ -464,9 +464,11 @@ class TLGenerator: # Vector flags are special since they consist of 3 values, # so we need an extra join here. Note that empty vector flags # should NOT be sent either! - builder.write("b'' if not {} else b''.join((".format(name)) + builder.write("b'' if {0} is None or {0} is False " + "else b''.join((".format(name)) else: - builder.write("b'' if not {} else (".format(name)) + builder.write("b'' if {0} is None or {0} is False " + "else (".format(name)) if arg.is_vector: if arg.use_vector_id: @@ -495,11 +497,14 @@ class TLGenerator: # There's a flag indicator, but no flag arguments so it's 0 builder.write(r"b'\0\0\0\0'") else: - builder.write("struct.pack(' Date: Sun, 24 Dec 2017 16:18:09 +0100 Subject: [PATCH 23/78] Create a convenient class to wrap Dialog instances --- telethon/telegram_client.py | 42 ++++++++++++---------------------- telethon/tl/custom/__init__.py | 1 + telethon/tl/custom/dialog.py | 37 ++++++++++++++++++++++++++++++ telethon/utils.py | 6 ++--- 4 files changed, 55 insertions(+), 31 deletions(-) create mode 100644 telethon/tl/custom/dialog.py diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 8546c377..32ade1a9 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -1,5 +1,7 @@ +import itertools import os import time +from collections import OrderedDict from datetime import datetime, timedelta from mimetypes import guess_type @@ -16,7 +18,7 @@ from .errors import ( ) from .network import ConnectionMode from .tl import TLObject -from .tl.custom import Draft +from .tl.custom import Draft, Dialog from .tl.entity_database import EntityDatabase from .tl.functions.account import ( GetPasswordRequest @@ -294,15 +296,14 @@ class TelegramClient(TelegramBareClient): 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]). + + :return List[telethon.tl.custom.Dialog]: A list dialogs. """ limit = float('inf') if limit is None else int(limit) if limit == 0: return [], [] - dialogs = {} # Use peer id as identifier to avoid dupes - messages = {} # Used later for sorting TODO also return these? - entities = {} + dialogs = OrderedDict() # Use peer id as identifier to avoid dupes while len(dialogs) < limit: real_limit = min(limit - len(dialogs), 100) r = self(GetDialogsRequest( @@ -312,16 +313,13 @@ class TelegramClient(TelegramBareClient): limit=real_limit )) - for d in r.dialogs: - dialogs[utils.get_peer_id(d.peer, True)] = d - for m in r.messages: - messages[m.id] = m + messages = {m.id: m for m in r.messages} + entities = {utils.get_peer_id(x, add_mark=True): x + for x in itertools.chain(r.users, r.chats)} - # We assume users can't have the same ID as a chat - for u in r.users: - entities[u.id] = u - for c in r.chats: - entities[c.id] = c + for d in r.dialogs: + dialogs[utils.get_peer_id(d.peer, add_mark=True)] = \ + Dialog(self, d, entities, messages) if len(r.dialogs) < real_limit or not isinstance(r, DialogsSlice): # Less than we requested means we reached the end, or @@ -334,20 +332,8 @@ class TelegramClient(TelegramBareClient): ) offset_id = r.messages[-1].id & 4294967296 # Telegram/danog magic - # Sort by message date. Windows will raise if timestamp is 0, - # so we need to set at least one day ahead while still being - # the smallest date possible. - no_date = datetime.fromtimestamp(86400) - ds = list(sorted( - dialogs.values(), - key=lambda d: getattr(messages[d.top_message], 'date', no_date) - )) - if limit < float('inf'): - ds = ds[:limit] - return ( - ds, - [utils.find_user_or_chat(d.peer, entities, entities) for d in ds] - ) + dialogs = list(dialogs.values()) + return dialogs[:limit] if limit < float('inf') else dialogs def get_drafts(self): # TODO: Ability to provide a `filter` """ diff --git a/telethon/tl/custom/__init__.py b/telethon/tl/custom/__init__.py index 40914f16..5b6bf44d 100644 --- a/telethon/tl/custom/__init__.py +++ b/telethon/tl/custom/__init__.py @@ -1 +1,2 @@ from .draft import Draft +from .dialog import Dialog diff --git a/telethon/tl/custom/dialog.py b/telethon/tl/custom/dialog.py new file mode 100644 index 00000000..bac8b0de --- /dev/null +++ b/telethon/tl/custom/dialog.py @@ -0,0 +1,37 @@ +from . import Draft +from ... import utils + + +class Dialog: + """ + Custom class that encapsulates a dialog (an open "conversation" with + someone, a group or a channel) providing an abstraction to easily + access the input version/normal entity/message etc. The library will + return instances of this class when calling `client.get_dialogs()`. + """ + def __init__(self, client, dialog, entities, messages): + # Both entities and messages being dicts {ID: item} + self._client = client + self.dialog = dialog + self.pinned = bool(dialog.pinned) + self.message = messages.get(dialog.top_message, None) + self.date = getattr(self.message, 'date', None) + + self.entity = entities[utils.get_peer_id(dialog.peer, add_mark=True)] + self.input_entity = utils.get_input_peer(self.entity) + self.name = utils.get_display_name(self.entity) + + self.unread_count = dialog.unread_count + self.unread_mentions_count = dialog.unread_mentions_count + + if dialog.draft: + self.draft = Draft(client, dialog.peer, dialog.draft) + else: + self.draft = None + + def send_message(self, *args, **kwargs): + """ + Sends a message to this dialog. This is just a wrapper around + client.send_message(dialog.input_entity, *args, **kwargs). + """ + return self._client.send_message(self.input_entity, *args, **kwargs) diff --git a/telethon/utils.py b/telethon/utils.py index 3259c8e2..5e92b13d 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -35,12 +35,12 @@ def get_display_name(entity): elif entity.last_name: return entity.last_name else: - return '(No name)' + return '' - if isinstance(entity, (Chat, Channel)): + elif isinstance(entity, (Chat, Channel)): return entity.title - return '(unknown)' + return '' # For some reason, .webp (stickers' format) is not registered add_type('image/webp', '.webp') From c218df87d7fa0aa13ead7080fed84b5c9a3ac3ef Mon Sep 17 00:00:00 2001 From: Tanuj Date: Mon, 25 Dec 2017 16:26:29 +0000 Subject: [PATCH 24/78] Remove reference to README.rst (#504) --- telethon/telegram_bare_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index 6c7d3ab0..6c258c9a 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -86,7 +86,7 @@ class TelegramBareClient: if not api_id or not api_hash: raise PermissionError( "Your API ID or Hash cannot be empty or None. " - "Refer to Telethon's README.rst for more information.") + "Refer to Telethon's wiki for more information.") self._use_ipv6 = use_ipv6 From b11c2e885bd8c5babeb83d15adb6ac6b6611cc99 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 25 Dec 2017 17:59:39 +0100 Subject: [PATCH 25/78] Fix assertion for multiple same flag parameters --- telethon_generator/tl_generator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/telethon_generator/tl_generator.py b/telethon_generator/tl_generator.py index f8a9e873..4adb5378 100644 --- a/telethon_generator/tl_generator.py +++ b/telethon_generator/tl_generator.py @@ -311,8 +311,10 @@ class TLGenerator: for ra in repeated_args.values(): if len(ra) > 1: - cnd1 = ('self.{}'.format(a.name) for a in ra) - cnd2 = ('not self.{}'.format(a.name) for a in ra) + cnd1 = ('(self.{0} or self.{0} is not None)' + .format(a.name) for a in ra) + cnd2 = ('(self.{0} is None or self.{0} is False)' + .format(a.name) for a in ra) builder.writeln( "assert ({}) or ({}), '{} parameters must all " "be False-y (like None) or all me True-y'".format( From 664417b40949decd44fc12e09ce4d296f93fab39 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 26 Dec 2017 16:45:47 +0100 Subject: [PATCH 26/78] Use sqlite3 instead JSON for the session files --- telethon/telegram_bare_client.py | 2 +- telethon/tl/session.py | 232 +++++++++++++++++++++---------- 2 files changed, 162 insertions(+), 72 deletions(-) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index 6c258c9a..d4f19b8d 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -92,7 +92,7 @@ class TelegramBareClient: # Determine what session object we have if isinstance(session, str) or session is None: - session = Session.try_load_or_create_new(session) + session = Session(session) elif not isinstance(session, Session): raise ValueError( 'The given session must be a str or a Session instance.' diff --git a/telethon/tl/session.py b/telethon/tl/session.py index e530cc83..e9885a56 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -1,15 +1,19 @@ import json import os import platform +import sqlite3 import struct import time -from base64 import b64encode, b64decode +from base64 import b64decode from os.path import isfile as file_exists from threading import Lock from .entity_database import EntityDatabase from .. import helpers +EXTENSION = '.session' +CURRENT_VERSION = 1 # database version + class Session: """This session contains the required information to login into your @@ -25,6 +29,7 @@ class Session: those required to init a connection will be copied. """ # These values will NOT be saved + self.filename = ':memory:' if isinstance(session_user_id, Session): self.session_user_id = None @@ -41,7 +46,10 @@ class Session: self.flood_sleep_threshold = session.flood_sleep_threshold else: # str / None - self.session_user_id = session_user_id + if session_user_id: + self.filename = session_user_id + if not self.filename.endswith(EXTENSION): + self.filename += EXTENSION system = platform.uname() self.device_model = system.system if system.system else 'Unknown' @@ -54,49 +62,172 @@ class Session: self.save_entities = True self.flood_sleep_threshold = 60 + # These values will be saved + self._server_address = None + self._port = None + self._auth_key = None + self._layer = 0 + self._salt = 0 # Signed long + self.entities = EntityDatabase() # Known and cached entities + # Cross-thread safety self._seq_no_lock = Lock() self._msg_id_lock = Lock() - self._save_lock = Lock() + self._db_lock = Lock() + + # Migrating from .json -> SQL + self._check_migrate_json() + + self._conn = sqlite3.connect(self.filename, check_same_thread=False) + c = self._conn.cursor() + c.execute("select name from sqlite_master " + "where type='table' and name='version'") + if c.fetchone(): + # Tables already exist, check for the version + c.execute("select version from version") + version = c.fetchone()[0] + if version != CURRENT_VERSION: + self._upgrade_database(old=version) + self.save() + + # These values will be saved + c.execute('select * from sessions') + self._server_address, self._port, key, \ + self._layer, self._salt = c.fetchone() + + from ..crypto import AuthKey + self._auth_key = AuthKey(data=key) + c.close() + else: + # Tables don't exist, create new ones + c.execute("create table version (version integer)") + c.execute( + """create table sessions ( + server_address text, + port integer, + auth_key blob, + layer integer, + salt integer + )""" + ) + c.execute( + """create table entities ( + id integer, + hash integer, + username text, + phone integer, + name text + )""" + ) + c.execute("insert into version values (1)") + c.close() + self.save() self.id = helpers.generate_random_long(signed=True) self._sequence = 0 self.time_offset = 0 self._last_msg_id = 0 # Long - # These values will be saved - self.server_address = None - self.port = None - self.auth_key = None - self.layer = 0 - self.salt = 0 # Signed long - self.entities = EntityDatabase() # Known and cached entities + def _check_migrate_json(self): + if file_exists(self.filename): + try: + with open(self.filename, encoding='utf-8') as f: + data = json.load(f) + self._port = data.get('port', self._port) + self._salt = data.get('salt', self._salt) + # Keep while migrating from unsigned to signed salt + if self._salt > 0: + self._salt = struct.unpack( + 'q', struct.pack('Q', self._salt))[0] + + self._layer = data.get('layer', self._layer) + self._server_address = \ + data.get('server_address', self._server_address) + + from ..crypto import AuthKey + if data.get('auth_key_data', None) is not None: + key = b64decode(data['auth_key_data']) + self._auth_key = AuthKey(data=key) + + self.entities = EntityDatabase(data.get('entities', [])) + self.delete() # Delete JSON file to create database + except (UnicodeDecodeError, json.decoder.JSONDecodeError): + pass + + def _upgrade_database(self, old): + pass + + # Data from sessions should be kept as properties + # not to fetch the database every time we need it + @property + def server_address(self): + return self._server_address + + @server_address.setter + def server_address(self, value): + self._server_address = value + self._update_session_table() + + @property + def port(self): + return self._port + + @port.setter + def port(self, value): + self._port = value + self._update_session_table() + + @property + def auth_key(self): + return self._auth_key + + @auth_key.setter + def auth_key(self, value): + self._auth_key = value + self._update_session_table() + + @property + def layer(self): + return self._layer + + @layer.setter + def layer(self, value): + self._layer = value + self._update_session_table() + + @property + def salt(self): + return self._salt + + @salt.setter + def salt(self, value): + self._salt = value + self._update_session_table() + + def _update_session_table(self): + with self._db_lock: + c = self._conn.cursor() + c.execute('delete from sessions') + c.execute('insert into sessions values (?,?,?,?,?)', ( + self._server_address, + self._port, + self._auth_key.key if self._auth_key else b'', + self._layer, + self._salt + )) + c.close() def save(self): """Saves the current session object as session_user_id.session""" - if not self.session_user_id or self._save_lock.locked(): - return - - with self._save_lock: - with open('{}.session'.format(self.session_user_id), 'w') as file: - out_dict = { - 'port': self.port, - 'salt': self.salt, - 'layer': self.layer, - 'server_address': self.server_address, - 'auth_key_data': - b64encode(self.auth_key.key).decode('ascii') - if self.auth_key else None - } - if self.save_entities: - out_dict['entities'] = self.entities.get_input_list() - - json.dump(out_dict, file) + with self._db_lock: + self._conn.commit() def delete(self): """Deletes the current session file""" + if self.filename == ':memory:': + return True try: - os.remove('{}.session'.format(self.session_user_id)) + os.remove(self.filename) return True except OSError: return False @@ -107,48 +238,7 @@ class Session: using this client and never logged out """ return [os.path.splitext(os.path.basename(f))[0] - for f in os.listdir('.') if f.endswith('.session')] - - @staticmethod - def try_load_or_create_new(session_user_id): - """Loads a saved session_user_id.session or creates a new one. - If session_user_id=None, later .save()'s will have no effect. - """ - if session_user_id is None: - return Session(None) - else: - path = '{}.session'.format(session_user_id) - result = Session(session_user_id) - if not file_exists(path): - return result - - try: - with open(path, 'r') as file: - data = json.load(file) - result.port = data.get('port', result.port) - result.salt = data.get('salt', result.salt) - # Keep while migrating from unsigned to signed salt - if result.salt > 0: - result.salt = struct.unpack( - 'q', struct.pack('Q', result.salt))[0] - - result.layer = data.get('layer', result.layer) - result.server_address = \ - data.get('server_address', result.server_address) - - # FIXME We need to import the AuthKey here or otherwise - # we get cyclic dependencies. - from ..crypto import AuthKey - if data.get('auth_key_data', None) is not None: - key = b64decode(data['auth_key_data']) - result.auth_key = AuthKey(data=key) - - result.entities = EntityDatabase(data.get('entities', [])) - - except (json.decoder.JSONDecodeError, UnicodeDecodeError): - pass - - return result + for f in os.listdir('.') if f.endswith(EXTENSION)] def generate_sequence(self, content_related): """Thread safe method to generates the next sequence number, From 0a4849b150b284908cd75af9f43e08d3870f7a26 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 26 Dec 2017 16:59:30 +0100 Subject: [PATCH 27/78] Small cleanup of the Session class --- telethon/tl/session.py | 55 ++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/telethon/tl/session.py b/telethon/tl/session.py index e9885a56..ff4631f8 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -23,37 +23,34 @@ class Session: If you think the session has been compromised, close all the sessions through an official Telegram client to revoke the authorization. """ - def __init__(self, session_user_id): + def __init__(self, session_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.filename = ':memory:' - if isinstance(session_user_id, Session): - self.session_user_id = None - - # 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 - self.system_lang_code = session.system_lang_code - self.lang_pack = session.lang_pack - self.report_errors = session.report_errors - self.save_entities = session.save_entities - self.flood_sleep_threshold = session.flood_sleep_threshold + # For connection purposes + if isinstance(session_id, Session): + self.device_model = session_id.device_model + self.system_version = session_id.system_version + self.app_version = session_id.app_version + self.lang_code = session_id.lang_code + self.system_lang_code = session_id.system_lang_code + self.lang_pack = session_id.lang_pack + self.report_errors = session_id.report_errors + self.save_entities = session_id.save_entities + self.flood_sleep_threshold = session_id.flood_sleep_threshold else: # str / None - if session_user_id: - self.filename = session_user_id + if session_id: + self.filename = session_id if not self.filename.endswith(EXTENSION): self.filename += EXTENSION system = platform.uname() - self.device_model = system.system if system.system else 'Unknown' - self.system_version = system.release if system.release else '1.0' + self.device_model = system.system or 'Unknown' + self.system_version = system.release or '1.0' self.app_version = '1.0' # '0' will provoke error self.lang_code = 'en' self.system_lang_code = self.lang_code @@ -62,6 +59,16 @@ class Session: self.save_entities = True self.flood_sleep_threshold = 60 + self.id = helpers.generate_random_long(signed=True) + self._sequence = 0 + self.time_offset = 0 + self._last_msg_id = 0 # Long + + # Cross-thread safety + self._seq_no_lock = Lock() + self._msg_id_lock = Lock() + self._db_lock = Lock() + # These values will be saved self._server_address = None self._port = None @@ -70,11 +77,6 @@ class Session: self._salt = 0 # Signed long self.entities = EntityDatabase() # Known and cached entities - # Cross-thread safety - self._seq_no_lock = Lock() - self._msg_id_lock = Lock() - self._db_lock = Lock() - # Migrating from .json -> SQL self._check_migrate_json() @@ -123,11 +125,6 @@ class Session: c.close() self.save() - self.id = helpers.generate_random_long(signed=True) - self._sequence = 0 - self.time_offset = 0 - self._last_msg_id = 0 # Long - def _check_migrate_json(self): if file_exists(self.filename): try: From aef96f1b6898a5a4b48b3a6943eb574ab5df1052 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 00:50:09 +0100 Subject: [PATCH 28/78] Remove custom EntityDatabase and use sqlite3 instead There are still a few things to change, like cleaning up the code and actually caching the entities as a whole (currently, although the username/phone/name can be used to fetch their input version which is an improvement, their full version needs to be re-fetched. Maybe it's a good thing though?) --- telethon/telegram_client.py | 65 ++++----- telethon/tl/entity_database.py | 252 --------------------------------- telethon/tl/session.py | 137 ++++++++++++++++-- telethon/utils.py | 30 ++++ 4 files changed, 181 insertions(+), 303 deletions(-) delete mode 100644 telethon/tl/entity_database.py diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 32ade1a9..5d09ee2c 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -19,7 +19,6 @@ from .errors import ( from .network import ConnectionMode from .tl import TLObject from .tl.custom import Draft, Dialog -from .tl.entity_database import EntityDatabase from .tl.functions.account import ( GetPasswordRequest ) @@ -144,7 +143,7 @@ class TelegramClient(TelegramBareClient): :return auth.SentCode: Information about the result of the request. """ - phone = EntityDatabase.parse_phone(phone) or self._phone + phone = utils.parse_phone(phone) or self._phone if not self._phone_code_hash: result = self(SendCodeRequest(phone, self.api_id, self.api_hash)) @@ -188,7 +187,7 @@ class TelegramClient(TelegramBareClient): if phone and not code: return self.send_code_request(phone) elif code: - phone = EntityDatabase.parse_phone(phone) or self._phone + phone = utils.parse_phone(phone) or self._phone phone_code_hash = phone_code_hash or self._phone_code_hash if not phone: raise ValueError( @@ -1009,12 +1008,8 @@ class TelegramClient(TelegramBareClient): may be out of date. :return: """ - if not force_fetch: - # Try to use cache unless we want to force a fetch - try: - return self.session.entities[entity] - except KeyError: - pass + # TODO Actually cache {id: entities} again + # >>> if not force_fetch: reuse cached if isinstance(entity, int) or ( isinstance(entity, TLObject) and @@ -1022,36 +1017,38 @@ class TelegramClient(TelegramBareClient): type(entity).SUBCLASS_OF_ID in (0xc91c90b6, 0x2d45687)): ie = self.get_input_entity(entity) if isinstance(ie, InputPeerUser): - self(GetUsersRequest([ie])) + return self(GetUsersRequest([ie]))[0] elif isinstance(ie, InputPeerChat): - self(GetChatsRequest([ie.chat_id])) + return self(GetChatsRequest([ie.chat_id])).chats[0] elif isinstance(ie, InputPeerChannel): - self(GetChannelsRequest([ie])) - try: - # session.process_entities has been called in the MtProtoSender - # with the result of these calls, so they should now be on the - # entities database. - return self.session.entities[ie] - except KeyError: - pass + return self(GetChannelsRequest([ie])).chats[0] if isinstance(entity, str): - return self._get_entity_from_string(entity) + # TODO This probably can be done better... + invite = self._load_entity_from_string(entity) + if invite: + return invite + return self.get_entity(self.session.get_input_entity(entity)) raise ValueError( 'Cannot turn "{}" into any entity (user or chat)'.format(entity) ) - def _get_entity_from_string(self, string): - """Gets an entity from the given string, which may be a phone or - an username, and processes all the found entities on the session. + def _load_entity_from_string(self, string): """ - phone = EntityDatabase.parse_phone(string) + Loads an entity from the given string, which may be a phone or + an username, and processes all the found entities on the session. + + This method will effectively add the found users to the session + database, so it can be queried later. + + May return a channel or chat if the string was an invite. + """ + phone = utils.parse_phone(string) if phone: - entity = phone self(GetContactsRequest(0)) else: - entity, is_join_chat = EntityDatabase.parse_username(string) + entity, is_join_chat = utils.parse_username(string) if is_join_chat: invite = self(CheckChatInviteRequest(entity)) if isinstance(invite, ChatInvite): @@ -1063,13 +1060,6 @@ class TelegramClient(TelegramBareClient): return invite.chat else: self(ResolveUsernameRequest(entity)) - # MtProtoSender will call .process_entities on the requests made - try: - return self.session.entities[entity] - except KeyError: - raise ValueError( - 'Could not find user with username {}'.format(entity) - ) def get_input_entity(self, peer): """ @@ -1092,12 +1082,15 @@ class TelegramClient(TelegramBareClient): """ try: # First try to get the entity from cache, otherwise figure it out - return self.session.entities.get_input_entity(peer) + return self.session.get_input_entity(peer) except KeyError: pass if isinstance(peer, str): - return utils.get_input_peer(self._get_entity_from_string(peer)) + invite = self._load_entity_from_string(peer) + if invite: + return utils.get_input_peer(invite) + return self.session.get_input_entity(peer) is_peer = False if isinstance(peer, int): @@ -1130,7 +1123,7 @@ class TelegramClient(TelegramBareClient): exclude_pinned=True )) try: - return self.session.entities.get_input_entity(peer) + return self.session.get_input_entity(peer) except KeyError: pass diff --git a/telethon/tl/entity_database.py b/telethon/tl/entity_database.py deleted file mode 100644 index 9002ebd8..00000000 --- a/telethon/tl/entity_database.py +++ /dev/null @@ -1,252 +0,0 @@ -import re -from threading import Lock - -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 - - -USERNAME_RE = re.compile( - r'@|(?:https?://)?(?:telegram\.(?:me|dog)|t\.me)/(joinchat/)?' -) - - -class EntityDatabase: - def __init__(self, input_list=None, enabled=True, enabled_full=True): - """Creates a new entity database with an initial load of "Input" - entities, if any. - - If 'enabled', input entities will be saved. The whole entity - will be saved if both 'enabled' and 'enabled_full' are True. - """ - self.enabled = enabled - self.enabled_full = enabled_full - - self._lock = Lock() - self._entities = {} # marked_id: user|chat|channel - - if input_list: - # TODO For compatibility reasons some sessions were saved with - # 'access_hash': null in the JSON session file. Drop these, as - # it means we don't have access to such InputPeers. Issue #354. - self._input_entities = { - k: v for k, v in input_list if v is not None - } - else: - self._input_entities = {} # marked_id: hash - - # TODO Allow disabling some extra mappings - self._username_id = {} # username: marked_id - self._phone_id = {} # phone: marked_id - - def process(self, tlobject): - """Processes all the found entities on the given TLObject, - unless .enabled is False. - - Returns True if new input entities were added. - """ - if not self.enabled: - return False - - # Save all input entities we know of - if not isinstance(tlobject, TLObject) and hasattr(tlobject, '__iter__'): - # This may be a list of users already for instance - return self.expand(tlobject) - - entities = [] - if hasattr(tlobject, 'chats') and hasattr(tlobject.chats, '__iter__'): - entities.extend(tlobject.chats) - if hasattr(tlobject, 'users') and hasattr(tlobject.users, '__iter__'): - entities.extend(tlobject.users) - - return self.expand(entities) - - def expand(self, entities): - """Adds new input entities to the local database unconditionally. - Unknown types will be ignored. - """ - if not entities or not self.enabled: - return False - - new = [] # Array of entities (User, Chat, or Channel) - new_input = {} # Dictionary of {entity_marked_id: access_hash} - for e in entities: - if not isinstance(e, TLObject): - continue - - try: - p = utils.get_input_peer(e, allow_self=False) - marked_id = utils.get_peer_id(p, add_mark=True) - - has_hash = False - if isinstance(p, InputPeerChat): - # Chats don't have a hash - new_input[marked_id] = 0 - has_hash = True - elif p.access_hash: - # Some users and channels seem to be returned without - # an 'access_hash', meaning Telegram doesn't want you - # to access them. This is the reason behind ensuring - # that the 'access_hash' is non-zero. See issue #354. - new_input[marked_id] = p.access_hash - has_hash = True - - if self.enabled_full and has_hash: - if isinstance(e, (User, Chat, Channel)): - new.append(e) - except ValueError: - pass - - with self._lock: - before = len(self._input_entities) - self._input_entities.update(new_input) - for e in new: - self._add_full_entity(e) - return len(self._input_entities) != before - - def _add_full_entity(self, entity): - """Adds a "full" entity (User, Chat or Channel, not "Input*"), - despite the value of self.enabled and self.enabled_full. - - Not to be confused with UserFull, ChatFull, or ChannelFull, - "full" means simply not "Input*". - """ - marked_id = utils.get_peer_id( - utils.get_input_peer(entity, allow_self=False), add_mark=True - ) - try: - old_entity = self._entities[marked_id] - old_entity.__dict__.update(entity.__dict__) # Keep old references - - # Update must delete old username and phone - username = getattr(old_entity, 'username', None) - if username: - del self._username_id[username.lower()] - - phone = getattr(old_entity, 'phone', None) - if phone: - del self._phone_id[phone] - except KeyError: - # Add new entity - self._entities[marked_id] = entity - - # Always update username or phone if any - username = getattr(entity, 'username', None) - if username: - self._username_id[username.lower()] = marked_id - - phone = getattr(entity, 'phone', None) - if phone: - self._phone_id[phone] = marked_id - - def _parse_key(self, key): - """Parses the given string, integer or TLObject key into a - marked user ID ready for use on self._entities. - - If a callable key is given, the entity will be passed to the - function, and if it returns a true-like value, the marked ID - for such entity will be returned. - - Raises ValueError if it cannot be parsed. - """ - if isinstance(key, str): - phone = EntityDatabase.parse_phone(key) - try: - if phone: - return self._phone_id[phone] - else: - username, _ = EntityDatabase.parse_username(key) - return self._username_id[username.lower()] - except KeyError as e: - raise ValueError() from e - - if isinstance(key, int): - return key # normal IDs are assumed users - - if isinstance(key, TLObject): - return utils.get_peer_id(key, add_mark=True) - - if callable(key): - for k, v in self._entities.items(): - if key(v): - return k - - raise ValueError() - - def __getitem__(self, key): - """See the ._parse_key() docstring for possible values of the key""" - try: - return self._entities[self._parse_key(key)] - except (ValueError, KeyError) as e: - raise KeyError(key) from e - - def __delitem__(self, key): - try: - old = self._entities.pop(self._parse_key(key)) - # Try removing the username and phone (if pop didn't fail), - # since the entity may have no username or phone, just ignore - # errors. It should be there if we popped the entity correctly. - try: - del self._username_id[getattr(old, 'username', None)] - except KeyError: - pass - - try: - del self._phone_id[getattr(old, 'phone', None)] - except KeyError: - pass - - except (ValueError, KeyError) as e: - raise KeyError(key) from e - - @staticmethod - def parse_phone(phone): - """Parses the given phone, or returns None if it's invalid""" - if isinstance(phone, int): - return str(phone) - else: - phone = re.sub(r'[+()\s-]', '', str(phone)) - if phone.isdigit(): - return phone - - @staticmethod - def parse_username(username): - """Parses the given username or channel access hash, given - a string, username or URL. Returns a tuple consisting of - both the stripped username and whether it is a joinchat/ hash. - """ - username = username.strip() - m = USERNAME_RE.match(username) - if m: - return username[m.end():], bool(m.group(1)) - else: - return username, False - - def get_input_entity(self, peer): - try: - i = utils.get_peer_id(peer, add_mark=True) - h = self._input_entities[i] # we store the IDs marked - i, k = utils.resolve_id(i) # removes the mark and returns kind - - if k == PeerUser: - return InputPeerUser(i, h) - elif k == PeerChat: - return InputPeerChat(i) - elif k == PeerChannel: - return InputPeerChannel(i, h) - - except ValueError as e: - raise KeyError(peer) from e - raise KeyError(peer) - - def get_input_list(self): - return list(self._input_entities.items()) - - def clear(self, target=None): - if target is None: - self._entities.clear() - else: - del self[target] diff --git a/telethon/tl/session.py b/telethon/tl/session.py index ff4631f8..12bc3937 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -8,8 +8,12 @@ from base64 import b64decode from os.path import isfile as file_exists from threading import Lock -from .entity_database import EntityDatabase -from .. import helpers +from .. import utils, helpers +from ..tl import TLObject +from ..tl.types import ( + PeerUser, PeerChat, PeerChannel, + InputPeerUser, InputPeerChat, InputPeerChannel +) EXTENSION = '.session' CURRENT_VERSION = 1 # database version @@ -75,10 +79,9 @@ class Session: self._auth_key = None self._layer = 0 self._salt = 0 # Signed long - self.entities = EntityDatabase() # Known and cached entities # Migrating from .json -> SQL - self._check_migrate_json() + entities = self._check_migrate_json() self._conn = sqlite3.connect(self.filename, check_same_thread=False) c = self._conn.cursor() @@ -114,14 +117,20 @@ class Session: ) c.execute( """create table entities ( - id integer, - hash integer, + id integer primary key, + hash integer not null, username text, phone integer, name text )""" ) c.execute("insert into version values (1)") + # Migrating from JSON -> new table and may have entities + if entities: + c.executemany( + 'insert or replace into entities values (?,?,?,?,?)', + entities + ) c.close() self.save() @@ -130,6 +139,8 @@ class Session: try: with open(self.filename, encoding='utf-8') as f: data = json.load(f) + self.delete() # Delete JSON file to create database + self._port = data.get('port', self._port) self._salt = data.get('salt', self._salt) # Keep while migrating from unsigned to signed salt @@ -146,10 +157,12 @@ class Session: key = b64decode(data['auth_key_data']) self._auth_key = AuthKey(data=key) - self.entities = EntityDatabase(data.get('entities', [])) - self.delete() # Delete JSON file to create database + rows = [] + for p_id, p_hash in data.get('entities', []): + rows.append((p_id, p_hash, None, None, None)) + return rows except (UnicodeDecodeError, json.decoder.JSONDecodeError): - pass + return [] # No entities def _upgrade_database(self, old): pass @@ -275,9 +288,103 @@ class Session: correct = correct_msg_id >> 32 self.time_offset = correct - now - def process_entities(self, tlobject): - try: - if self.entities.process(tlobject): - self.save() # Save if any new entities got added - except: - pass + # Entity processing + + def process_entities(self, tlo): + """Processes all the found entities on the given TLObject, + unless .enabled is False. + + Returns True if new input entities were added. + """ + if not self.save_entities: + return + + if not isinstance(tlo, TLObject) and hasattr(tlo, '__iter__'): + # This may be a list of users already for instance + entities = tlo + else: + entities = [] + if hasattr(tlo, 'chats') and hasattr(tlo.chats, '__iter__'): + entities.extend(tlo.chats) + if hasattr(tlo, 'users') and hasattr(tlo.users, '__iter__'): + entities.extend(tlo.users) + if not entities: + return + + rows = [] # Rows to add (id, hash, username, phone, name) + for e in entities: + if not isinstance(e, TLObject): + continue + try: + p = utils.get_input_peer(e, allow_self=False) + marked_id = utils.get_peer_id(p, add_mark=True) + + p_hash = None + if isinstance(p, InputPeerChat): + p_hash = 0 + elif p.access_hash: + # Some users and channels seem to be returned without + # an 'access_hash', meaning Telegram doesn't want you + # to access them. This is the reason behind ensuring + # that the 'access_hash' is non-zero. See issue #354. + p_hash = p.access_hash + + if p_hash is not None: + username = getattr(e, 'username', None) + phone = getattr(e, 'phone', None) + name = utils.get_display_name(e) or None + rows.append((marked_id, p_hash, username, phone, name)) + except ValueError: + pass + if not rows: + return + + with self._db_lock: + self._conn.executemany( + 'insert or replace into entities values (?,?,?,?,?)', rows + ) + self.save() + + def get_input_entity(self, key): + """Parses the given string, integer or TLObject key into a + marked entity ID, which is then used to fetch the hash + from the database. + + If a callable key is given, every row will be fetched, + and passed as a tuple to a function, that should return + a true-like value when the desired row is found. + + Raises ValueError if it cannot be found. + """ + c = self._conn.cursor() + if isinstance(key, str): + phone = utils.parse_phone(key) + if phone: + c.execute('select id, hash from entities where phone=?', + (phone,)) + else: + username, _ = utils.parse_username(key) + c.execute('select id, hash from entities where username=?', + (username,)) + + if isinstance(key, TLObject): + # crc32(b'InputPeer') and crc32(b'Peer') + if type(key).SUBCLASS_OF_ID == 0xc91c90b6: + return key + key = utils.get_peer_id(key, add_mark=True) + + if isinstance(key, int): + c.execute('select id, hash from entities where id=?', (key,)) + + result = c.fetchone() + if result: + i, h = result # unpack resulting tuple + i, k = utils.resolve_id(i) # removes the mark and returns kind + if k == PeerUser: + return InputPeerUser(i, h) + elif k == PeerChat: + return InputPeerChat(i) + elif k == PeerChannel: + return InputPeerChannel(i, h) + else: + raise ValueError('Could not find input entity with key ', key) diff --git a/telethon/utils.py b/telethon/utils.py index 5e92b13d..04970632 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -5,6 +5,8 @@ to convert between an entity like an User, Chat, etc. into its Input version) import math from mimetypes import add_type, guess_extension +import re + from .tl import TLObject from .tl.types import ( Channel, ChannelForbidden, Chat, ChatEmpty, ChatForbidden, ChatFull, @@ -24,6 +26,11 @@ from .tl.types import ( ) +USERNAME_RE = re.compile( + r'@|(?:https?://)?(?:telegram\.(?:me|dog)|t\.me)/(joinchat/)?' +) + + def get_display_name(entity): """Gets the input peer for the given "entity" (user, chat or channel) Returns None if it was not found""" @@ -305,6 +312,29 @@ def get_input_media(media, user_caption=None, is_photo=False): _raise_cast_fail(media, 'InputMedia') +def parse_phone(phone): + """Parses the given phone, or returns None if it's invalid""" + if isinstance(phone, int): + return str(phone) + else: + phone = re.sub(r'[+()\s-]', '', str(phone)) + if phone.isdigit(): + return phone + + +def parse_username(username): + """Parses the given username or channel access hash, given + a string, username or URL. Returns a tuple consisting of + both the stripped username and whether it is a joinchat/ hash. + """ + username = username.strip() + m = USERNAME_RE.match(username) + if m: + return username[m.end():], bool(m.group(1)) + else: + return username, False + + def get_peer_id(peer, add_mark=False): """Finds the ID of the given peer, and optionally converts it to the "bot api" format if 'add_mark' is set to True. From 86429e7291a79cea8bdda27fa1f5b64860b1ba69 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 11:54:08 +0100 Subject: [PATCH 29/78] Lowercase usernames before adding them to the database --- telethon/tl/session.py | 13 ++++++------- telethon/utils.py | 9 ++++++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/telethon/tl/session.py b/telethon/tl/session.py index 12bc3937..e3dea190 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -330,7 +330,7 @@ class Session: p_hash = p.access_hash if p_hash is not None: - username = getattr(e, 'username', None) + username = getattr(e, 'username', '').lower() or None phone = getattr(e, 'phone', None) name = utils.get_display_name(e) or None rows.append((marked_id, p_hash, username, phone, name)) @@ -357,6 +357,11 @@ class Session: Raises ValueError if it cannot be found. """ c = self._conn.cursor() + if isinstance(key, TLObject): + if type(key).SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer') + return key + key = utils.get_peer_id(key, add_mark=True) + if isinstance(key, str): phone = utils.parse_phone(key) if phone: @@ -367,12 +372,6 @@ class Session: c.execute('select id, hash from entities where username=?', (username,)) - if isinstance(key, TLObject): - # crc32(b'InputPeer') and crc32(b'Peer') - if type(key).SUBCLASS_OF_ID == 0xc91c90b6: - return key - key = utils.get_peer_id(key, add_mark=True) - if isinstance(key, int): c.execute('select id, hash from entities where id=?', (key,)) diff --git a/telethon/utils.py b/telethon/utils.py index 04970632..0662a99d 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -325,14 +325,17 @@ def parse_phone(phone): def parse_username(username): """Parses the given username or channel access hash, given a string, username or URL. Returns a tuple consisting of - both the stripped username and whether it is a joinchat/ hash. + both the stripped, lowercase username and whether it is + a joinchat/ hash (in which case is not lowercase'd). """ username = username.strip() m = USERNAME_RE.match(username) if m: - return username[m.end():], bool(m.group(1)) + result = username[m.end():] + is_invite = bool(m.group(1)) + return result if is_invite else result.lower(), is_invite else: - return username, False + return username.lower(), False def get_peer_id(peer, add_mark=False): From 5c17097d8d011b0af11bebe0d1cedfad4c25ab7c Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 11:56:05 +0100 Subject: [PATCH 30/78] Clean up .get_entity and remove force_fetch --- telethon/telegram_client.py | 83 +++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 45 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 5d09ee2c..c1eab9fa 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -982,7 +982,7 @@ class TelegramClient(TelegramBareClient): # region Small utilities to make users' life easier - def get_entity(self, entity, force_fetch=False): + def get_entity(self, entity): """ Turns the given entity into a valid Telegram user or chat. @@ -1001,16 +1001,8 @@ class TelegramClient(TelegramBareClient): If the entity is neither, and it's not a TLObject, an error will be raised. - :param force_fetch: - If True, the entity cache is bypassed and the entity is fetched - again with an API call. Defaults to False to avoid unnecessary - calls, but since a cached version would be returned, the entity - may be out of date. - :return: + :return: User, Chat or Channel corresponding to the input entity. """ - # TODO Actually cache {id: entities} again - # >>> if not force_fetch: reuse cached - if isinstance(entity, int) or ( isinstance(entity, TLObject) and # crc32(b'InputPeer') and crc32(b'Peer') @@ -1024,33 +1016,33 @@ class TelegramClient(TelegramBareClient): return self(GetChannelsRequest([ie])).chats[0] if isinstance(entity, str): - # TODO This probably can be done better... - invite = self._load_entity_from_string(entity) - if invite: - return invite - return self.get_entity(self.session.get_input_entity(entity)) + return self._get_entity_from_string(entity) raise ValueError( 'Cannot turn "{}" into any entity (user or chat)'.format(entity) ) - def _load_entity_from_string(self, string): + def _get_entity_from_string(self, string): """ - Loads an entity from the given string, which may be a phone or + 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 will effectively add the found users to the session - database, so it can be queried later. + 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. - May return a channel or chat if the string was an invite. + Returns the found entity. """ phone = utils.parse_phone(string) if phone: - self(GetContactsRequest(0)) + for user in self(GetContactsRequest(0)).users: + if user.phone == phone: + return user else: - entity, is_join_chat = utils.parse_username(string) + string, is_join_chat = utils.parse_username(string) if is_join_chat: - invite = self(CheckChatInviteRequest(entity)) + invite = self(CheckChatInviteRequest(string)) if isinstance(invite, ChatInvite): # If it's an invite to a chat, the user must join before # for the link to be resolved and work, otherwise raise. @@ -1059,7 +1051,10 @@ class TelegramClient(TelegramBareClient): elif isinstance(invite, ChatInviteAlready): return invite.chat else: - self(ResolveUsernameRequest(entity)) + result = self(ResolveUsernameRequest(string)) + for entity in itertools.chain(result.users, result.chats): + if entity.username.lower() == string: + return entity def get_input_entity(self, peer): """ @@ -1078,7 +1073,8 @@ class TelegramClient(TelegramBareClient): If in the end the access hash required for the peer was not found, a ValueError will be raised. - :return: + + :return: InputPeerUser, InputPeerChat or InputPeerChannel. """ try: # First try to get the entity from cache, otherwise figure it out @@ -1087,10 +1083,7 @@ class TelegramClient(TelegramBareClient): pass if isinstance(peer, str): - invite = self._load_entity_from_string(peer) - if invite: - return utils.get_input_peer(invite) - return self.session.get_input_entity(peer) + return utils.get_input_peer(self._get_entity_from_string(peer)) is_peer = False if isinstance(peer, int): @@ -1110,22 +1103,22 @@ class TelegramClient(TelegramBareClient): 'Cannot turn "{}" into an input entity.'.format(peer) ) - if self.session.save_entities: - # Not found, look in the latest dialogs. - # This is useful if for instance someone just sent a message but - # the updates didn't specify who, as this person or chat should - # be in the latest dialogs. - self(GetDialogsRequest( - offset_date=None, - offset_id=0, - offset_peer=InputPeerEmpty(), - limit=0, - exclude_pinned=True - )) - try: - return self.session.get_input_entity(peer) - except KeyError: - pass + # Not found, look in the latest dialogs. + # This is useful if for instance someone just sent a message but + # the updates didn't specify who, as this person or chat should + # be in the latest dialogs. + dialogs = self(GetDialogsRequest( + offset_date=None, + offset_id=0, + offset_peer=InputPeerEmpty(), + limit=0, + exclude_pinned=True + )) + + target = utils.get_peer_id(peer, add_mark=True) + for entity in itertools.chain(dialogs.users, dialogs.chats): + if utils.get_peer_id(entity, add_mark=True) == target: + return utils.get_input_peer(entity) raise ValueError( 'Could not find the input entity corresponding to "{}".' From b6b47d175c73d9461a7952414f98f2a4e99566d7 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 12:16:49 +0100 Subject: [PATCH 31/78] Fix username.lower() on instances with username field but None --- telethon/tl/session.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/telethon/tl/session.py b/telethon/tl/session.py index e3dea190..8fcbf31d 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -330,7 +330,9 @@ class Session: p_hash = p.access_hash if p_hash is not None: - username = getattr(e, 'username', '').lower() or None + username = getattr(e, 'username', None) or None + if username is not None: + username = username.lower() phone = getattr(e, 'phone', None) name = utils.get_display_name(e) or None rows.append((marked_id, p_hash, username, phone, name)) From 3512028d0ffaaaf2cbb4850a73f42e2b69a3f7ee Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 12:36:14 +0100 Subject: [PATCH 32/78] Fix .get_input_entity excepting wrong type --- telethon/telegram_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index c1eab9fa..3a264b42 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -1079,7 +1079,7 @@ class TelegramClient(TelegramBareClient): try: # First try to get the entity from cache, otherwise figure it out return self.session.get_input_entity(peer) - except KeyError: + except ValueError: pass if isinstance(peer, str): From f96d88d3b5e6527efa0e9b3dd7e4b98abdcd40a1 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 12:36:38 +0100 Subject: [PATCH 33/78] Modify .get_entity to support fetching many entities at once --- telethon/telegram_client.py | 56 ++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 3a264b42..5aa08c42 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -987,7 +987,7 @@ class TelegramClient(TelegramBareClient): Turns the given entity into a valid Telegram user or chat. :param entity: - The entity to be transformed. + 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. @@ -1003,24 +1003,46 @@ class TelegramClient(TelegramBareClient): :return: User, Chat or Channel corresponding to the input entity. """ - if isinstance(entity, int) or ( - isinstance(entity, TLObject) and - # crc32(b'InputPeer') and crc32(b'Peer') - type(entity).SUBCLASS_OF_ID in (0xc91c90b6, 0x2d45687)): - ie = self.get_input_entity(entity) - if isinstance(ie, InputPeerUser): - return self(GetUsersRequest([ie]))[0] - elif isinstance(ie, InputPeerChat): - return self(GetChatsRequest([ie.chat_id])).chats[0] - elif isinstance(ie, InputPeerChannel): - return self(GetChannelsRequest([ie])).chats[0] + if not isinstance(entity, str) and hasattr(entity, '__iter__'): + single = False + else: + single = True + entity = (entity,) - if isinstance(entity, str): - return self._get_entity_from_string(entity) + # Group input entities by string (resolve username), + # input users (get users), input chat (get chats) and + # input channels (get channels) to get the most entities + # in the less amount of calls possible. + inputs = [ + x if isinstance(x, str) else self.get_input_entity(x) + for x in entity + ] + users = [x for x in inputs if isinstance(x, InputPeerUser)] + chats = [x.chat_id for x in inputs if isinstance(x, InputPeerChat)] + channels = [x for x in inputs if isinstance(x, InputPeerChannel)] + if users: + users = self(GetUsersRequest(users)) + if chats: # TODO Handle chats slice? + chats = self(GetChatsRequest(chats)).chats + if channels: + channels = self(GetChannelsRequest(channels)).chats - raise ValueError( - 'Cannot turn "{}" into any entity (user or chat)'.format(entity) - ) + # Merge users, chats and channels into a single dictionary + id_entity = { + utils.get_peer_id(x, add_mark=True): x + for x in itertools.chain(users, chats, channels) + } + + # We could check saved usernames and put them into the users, + # chats and channels list from before. While this would reduce + # the amount of ResolveUsername calls, it would fail to catch + # username changes. + result = [ + self._get_entity_from_string(x) if isinstance(x, str) + else id_entity[utils.get_peer_id(x, add_mark=True)] + for x in inputs + ] + return result[0] if single else result def _get_entity_from_string(self, string): """ From f8745155ac5440c2e1b1dfb0a206ba9e2ac20d13 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 12:37:07 +0100 Subject: [PATCH 34/78] Stop joining read thread on disconnect, as it may be None --- telethon/telegram_bare_client.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index 6c258c9a..233dfdb7 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -260,10 +260,6 @@ class TelegramBareClient: __log__.debug('Disconnecting the socket...') self._sender.disconnect() - if self._recv_thread: - __log__.debug('Joining the read thread...') - self._recv_thread.join() - # TODO Shall we clear the _exported_sessions, or may be reused? pass From 843e777eba04946cc649c091bc762908597edfe8 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 12:58:50 +0100 Subject: [PATCH 35/78] Simplify .process_entities() flow --- telethon/tl/session.py | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/telethon/tl/session.py b/telethon/tl/session.py index 8fcbf31d..c19b37db 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -318,26 +318,23 @@ class Session: try: p = utils.get_input_peer(e, allow_self=False) marked_id = utils.get_peer_id(p, add_mark=True) - - p_hash = None - if isinstance(p, InputPeerChat): - p_hash = 0 - elif p.access_hash: - # Some users and channels seem to be returned without - # an 'access_hash', meaning Telegram doesn't want you - # to access them. This is the reason behind ensuring - # that the 'access_hash' is non-zero. See issue #354. - p_hash = p.access_hash - - if p_hash is not None: - username = getattr(e, 'username', None) or None - if username is not None: - username = username.lower() - phone = getattr(e, 'phone', None) - name = utils.get_display_name(e) or None - rows.append((marked_id, p_hash, username, phone, name)) except ValueError: - pass + continue + + p_hash = getattr(p, 'access_hash', 0) + if p_hash is None: + # Some users and channels seem to be returned without + # an 'access_hash', meaning Telegram doesn't want you + # to access them. This is the reason behind ensuring + # that the 'access_hash' is non-zero. See issue #354. + continue + + username = getattr(e, 'username', None) or None + if username is not None: + username = username.lower() + phone = getattr(e, 'phone', None) + name = utils.get_display_name(e) or None + rows.append((marked_id, p_hash, username, phone, name)) if not rows: return From 932ed9ea9d7ccd0ec833984295405a5f028ad6cd Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 13:06:03 +0100 Subject: [PATCH 36/78] Cast to input peer early on get input entity and close cursor --- telethon/tl/session.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/telethon/tl/session.py b/telethon/tl/session.py index c19b37db..1e374a54 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -355,12 +355,13 @@ class Session: Raises ValueError if it cannot be found. """ - c = self._conn.cursor() if isinstance(key, TLObject): + key = utils.get_input_peer(key) if type(key).SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer') return key key = utils.get_peer_id(key, add_mark=True) + c = self._conn.cursor() if isinstance(key, str): phone = utils.parse_phone(key) if phone: @@ -375,6 +376,7 @@ class Session: c.execute('select id, hash from entities where id=?', (key,)) result = c.fetchone() + c.close() if result: i, h = result # unpack resulting tuple i, k = utils.resolve_id(i) # removes the mark and returns kind From f29ee41f6c3c914929f2e46289fecee01446d5ee Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 13:27:54 +0100 Subject: [PATCH 37/78] Don't use rowid for the entities table --- telethon/tl/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/tl/session.py b/telethon/tl/session.py index 1e374a54..3dfba1d9 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -122,7 +122,7 @@ class Session: username text, phone integer, name text - )""" + ) without rowid""" ) c.execute("insert into version values (1)") # Migrating from JSON -> new table and may have entities From 73edb0f4ff53a9e91f64481ae0d4e529064abebe Mon Sep 17 00:00:00 2001 From: Birger Jarl Date: Wed, 27 Dec 2017 16:52:33 +0300 Subject: [PATCH 38/78] Avoid using None dates on file download (#462) --- telethon/telegram_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 32ade1a9..3a0b9e4f 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -954,6 +954,8 @@ class TelegramClient(TelegramBareClient): name = None if not name: + if not date: + date = datetime.now() name = '{}_{}-{:02}-{:02}_{:02}-{:02}-{:02}'.format( kind, date.year, date.month, date.day, From 21e5f0b547703867cae9bac41ac42f94ba857911 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 15:08:29 +0100 Subject: [PATCH 39/78] Fix GetUsersRequest has a limit of 200 --- telethon/telegram_client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 5aa08c42..67180cb3 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -1021,7 +1021,12 @@ class TelegramClient(TelegramBareClient): chats = [x.chat_id for x in inputs if isinstance(x, InputPeerChat)] channels = [x for x in inputs if isinstance(x, InputPeerChannel)] if users: - users = self(GetUsersRequest(users)) + # GetUsersRequest has a limit of 200 per call + tmp = [] + while users: + curr, users = users[:200], users[200:] + tmp.extend(self(GetUsersRequest(curr))) + users = tmp if chats: # TODO Handle chats slice? chats = self(GetChatsRequest(chats)).chats if channels: From f3d47769df830e91203e66cc76f6004e2e75ed62 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 15:26:23 +0100 Subject: [PATCH 40/78] Fix .send_read_acknowledge() for channels (#501) --- telethon/telegram_client.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 3a0b9e4f..792ccd06 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -558,13 +558,13 @@ class TelegramClient(TelegramBareClient): return total_messages, messages, senders - def send_read_acknowledge(self, entity, messages=None, max_id=None): + def send_read_acknowledge(self, entity, message=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"). :param entity: The chat where these messages are located. - :param messages: Either a list of messages or a single message. + :param message: Either a list of messages or a single message. :param max_id: Overrides messages, until which message should the acknowledge should be sent. :return: @@ -574,15 +574,16 @@ class TelegramClient(TelegramBareClient): raise InvalidParameterError( 'Either a message list or a max_id must be provided.') - if isinstance(messages, list): - max_id = max(msg.id for msg in messages) + if hasattr(message, '__iter__'): + max_id = max(msg.id for msg in message) else: - max_id = messages.id + max_id = message.id - return self(ReadHistoryRequest( - peer=self.get_input_entity(entity), - max_id=max_id - )) + entity = self.get_input_entity(entity) + if entity == InputPeerChannel: + return self(channels.ReadHistoryRequest(entity, max_id=max_id)) + else: + return self(messages.ReadHistoryRequest(entity, max_id=max_id)) @staticmethod def _get_reply_to(reply_to): From a5b1457eee8ee33eff9e868ce4672892b5fd86a0 Mon Sep 17 00:00:00 2001 From: "Dmitry D. Chernov" Date: Thu, 28 Dec 2017 07:33:25 +1000 Subject: [PATCH 41/78] TelegramBareClient: Fix lost #region --- telethon/telegram_bare_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index 233dfdb7..27acfe9a 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -815,7 +815,7 @@ class TelegramBareClient: # endregion - # Constant read + # region Constant read def _set_connected_and_authorized(self): self._authorized = True From fa64a5f7b8a59745b6363a8fc7c4c9beb5edc6f7 Mon Sep 17 00:00:00 2001 From: "Dmitry D. Chernov" Date: Thu, 28 Dec 2017 07:50:49 +1000 Subject: [PATCH 42/78] TelegramBareClient: Add set_proxy() method This allows to change proxy without recreation of the client instance. --- telethon/telegram_bare_client.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index 27acfe9a..8dad6d29 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -299,6 +299,13 @@ class TelegramBareClient: self.disconnect() return self.connect() + def set_proxy(proxy): + """Change the proxy used by the connections. + """ + if self.is_connected(): + raise RuntimeError("You can't change the proxy while connected.") + self._sender.connection.conn.proxy = proxy + # endregion # region Working with different connections/Data Centers From 292e4fc29f188b356ca266de5c36823b91d24d4c Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 23:45:48 +0100 Subject: [PATCH 43/78] Fix .get_dialogs() being inconsistent with the return type --- telethon/telegram_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 792ccd06..d8000b3b 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -301,7 +301,7 @@ class TelegramClient(TelegramBareClient): """ limit = float('inf') if limit is None else int(limit) if limit == 0: - return [], [] + return [] dialogs = OrderedDict() # Use peer id as identifier to avoid dupes while len(dialogs) < limit: From bdd63b91a21eb5a832b455f8bf30624beb22a721 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 23:54:31 +0100 Subject: [PATCH 44/78] Fix .download_profile_photo() for some channels (closes #500) --- telethon/telegram_client.py | 50 +++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index d8000b3b..e0708bc9 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -14,7 +14,8 @@ from . import TelegramBareClient from . import helpers, utils from .errors import ( RPCError, UnauthorizedError, InvalidParameterError, PhoneCodeEmptyError, - PhoneCodeExpiredError, PhoneCodeHashEmptyError, PhoneCodeInvalidError + PhoneCodeExpiredError, PhoneCodeHashEmptyError, PhoneCodeInvalidError, + LocationInvalidError ) from .network import ConnectionMode from .tl import TLObject @@ -43,7 +44,7 @@ from .tl.functions.users import ( GetUsersRequest ) from .tl.functions.channels import ( - GetChannelsRequest + GetChannelsRequest, GetFullChannelRequest ) from .tl.types import ( DocumentAttributeAudio, DocumentAttributeFilename, @@ -744,6 +745,7 @@ class TelegramClient(TelegramBareClient): 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. """ + photo = entity possible_names = [] if not isinstance(entity, TLObject) or type(entity).SUBCLASS_OF_ID in ( 0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697 @@ -769,31 +771,41 @@ class TelegramClient(TelegramBareClient): for attr in ('username', 'first_name', 'title'): possible_names.append(getattr(entity, attr, None)) - entity = entity.photo + photo = entity.photo - if not isinstance(entity, UserProfilePhoto) and \ - not isinstance(entity, ChatPhoto): + if not isinstance(photo, UserProfilePhoto) and \ + not isinstance(photo, ChatPhoto): return None - if download_big: - photo_location = entity.photo_big - else: - photo_location = entity.photo_small - + photo_location = photo.photo_big if download_big else photo.photo_small file = self._get_proper_filename( file, 'profile_photo', '.jpg', possible_names=possible_names ) # Download the media with the largest size input file location - self.download_file( - InputFileLocation( - volume_id=photo_location.volume_id, - local_id=photo_location.local_id, - secret=photo_location.secret - ), - file - ) + try: + self.download_file( + InputFileLocation( + volume_id=photo_location.volume_id, + local_id=photo_location.local_id, + secret=photo_location.secret + ), + file + ) + except LocationInvalidError: + # See issue #500, Android app fails as of v4.6.0 (1155). + # The fix seems to be using the full channel chat photo. + ie = self.get_input_entity(entity) + if isinstance(ie, InputPeerChannel): + full = self(GetFullChannelRequest(ie)) + return self._download_photo( + full.full_chat.chat_photo, file, + date=None, progress_callback=None + ) + else: + # Until there's a report for chats, no need to. + return None return file def download_media(self, message, file=None, progress_callback=None): @@ -833,7 +845,7 @@ class TelegramClient(TelegramBareClient): """Specialized version of .download_media() for photos""" # Determine the photo and its largest size - photo = mm_photo.photo + photo = getattr(mm_photo, 'photo', mm_photo) largest_size = photo.sizes[-1] file_size = largest_size.size largest_size = largest_size.location From b1b3610c1ff3ff6f852d37337befcb58afa8dda6 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 00:09:29 +0100 Subject: [PATCH 45/78] Add missing self to .set_proxy (fa64a5f) --- telethon/telegram_bare_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index 8dad6d29..f22d13e6 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -299,7 +299,7 @@ class TelegramBareClient: self.disconnect() return self.connect() - def set_proxy(proxy): + def set_proxy(self, proxy): """Change the proxy used by the connections. """ if self.is_connected(): From b252468ca293b1a72fc7af4184533b556e84df18 Mon Sep 17 00:00:00 2001 From: "Dmitry D. Chernov" Date: Thu, 28 Dec 2017 07:50:49 +1000 Subject: [PATCH 46/78] TelegramBareClient: Add set_proxy() method This allows to change proxy without recreation of the client instance. --- telethon/telegram_bare_client.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index 27acfe9a..f22d13e6 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -299,6 +299,13 @@ class TelegramBareClient: self.disconnect() return self.connect() + def set_proxy(self, proxy): + """Change the proxy used by the connections. + """ + if self.is_connected(): + raise RuntimeError("You can't change the proxy while connected.") + self._sender.connection.conn.proxy = proxy + # endregion # region Working with different connections/Data Centers From 166d5a401237ee56eb6e14a26dea8cb66648bb3f Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 23:45:48 +0100 Subject: [PATCH 47/78] Fix .get_dialogs() being inconsistent with the return type --- telethon/telegram_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 792ccd06..d8000b3b 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -301,7 +301,7 @@ class TelegramClient(TelegramBareClient): """ limit = float('inf') if limit is None else int(limit) if limit == 0: - return [], [] + return [] dialogs = OrderedDict() # Use peer id as identifier to avoid dupes while len(dialogs) < limit: From 1a746e14643a91ae33d186a383d1cfdc36433081 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 27 Dec 2017 23:54:31 +0100 Subject: [PATCH 48/78] Fix .download_profile_photo() for some channels (closes #500) --- telethon/telegram_client.py | 50 +++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index d8000b3b..e0708bc9 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -14,7 +14,8 @@ from . import TelegramBareClient from . import helpers, utils from .errors import ( RPCError, UnauthorizedError, InvalidParameterError, PhoneCodeEmptyError, - PhoneCodeExpiredError, PhoneCodeHashEmptyError, PhoneCodeInvalidError + PhoneCodeExpiredError, PhoneCodeHashEmptyError, PhoneCodeInvalidError, + LocationInvalidError ) from .network import ConnectionMode from .tl import TLObject @@ -43,7 +44,7 @@ from .tl.functions.users import ( GetUsersRequest ) from .tl.functions.channels import ( - GetChannelsRequest + GetChannelsRequest, GetFullChannelRequest ) from .tl.types import ( DocumentAttributeAudio, DocumentAttributeFilename, @@ -744,6 +745,7 @@ class TelegramClient(TelegramBareClient): 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. """ + photo = entity possible_names = [] if not isinstance(entity, TLObject) or type(entity).SUBCLASS_OF_ID in ( 0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697 @@ -769,31 +771,41 @@ class TelegramClient(TelegramBareClient): for attr in ('username', 'first_name', 'title'): possible_names.append(getattr(entity, attr, None)) - entity = entity.photo + photo = entity.photo - if not isinstance(entity, UserProfilePhoto) and \ - not isinstance(entity, ChatPhoto): + if not isinstance(photo, UserProfilePhoto) and \ + not isinstance(photo, ChatPhoto): return None - if download_big: - photo_location = entity.photo_big - else: - photo_location = entity.photo_small - + photo_location = photo.photo_big if download_big else photo.photo_small file = self._get_proper_filename( file, 'profile_photo', '.jpg', possible_names=possible_names ) # Download the media with the largest size input file location - self.download_file( - InputFileLocation( - volume_id=photo_location.volume_id, - local_id=photo_location.local_id, - secret=photo_location.secret - ), - file - ) + try: + self.download_file( + InputFileLocation( + volume_id=photo_location.volume_id, + local_id=photo_location.local_id, + secret=photo_location.secret + ), + file + ) + except LocationInvalidError: + # See issue #500, Android app fails as of v4.6.0 (1155). + # The fix seems to be using the full channel chat photo. + ie = self.get_input_entity(entity) + if isinstance(ie, InputPeerChannel): + full = self(GetFullChannelRequest(ie)) + return self._download_photo( + full.full_chat.chat_photo, file, + date=None, progress_callback=None + ) + else: + # Until there's a report for chats, no need to. + return None return file def download_media(self, message, file=None, progress_callback=None): @@ -833,7 +845,7 @@ class TelegramClient(TelegramBareClient): """Specialized version of .download_media() for photos""" # Determine the photo and its largest size - photo = mm_photo.photo + photo = getattr(mm_photo, 'photo', mm_photo) largest_size = photo.sizes[-1] file_size = largest_size.size largest_size = largest_size.location From 6ec6967ff9a2e09aae70b500273075bdfbae975c Mon Sep 17 00:00:00 2001 From: "Dmitry D. Chernov" Date: Thu, 28 Dec 2017 09:22:28 +1000 Subject: [PATCH 49/78] Make exception types correspond to Python docs --- docs/docs_writer.py | 2 +- telethon/errors/__init__.py | 5 ++--- telethon/errors/common.py | 7 ------- telethon/extensions/binary_reader.py | 9 ++++----- telethon/extensions/tcp_client.py | 2 +- telethon/telegram_bare_client.py | 16 ++++++++-------- telethon/telegram_client.py | 15 +++++++-------- telethon/tl/custom/draft.py | 2 +- telethon/tl/tlobject.py | 3 ++- telethon/utils.py | 6 +++--- 10 files changed, 29 insertions(+), 38 deletions(-) diff --git a/docs/docs_writer.py b/docs/docs_writer.py index f9042f00..9eec6cd7 100644 --- a/docs/docs_writer.py +++ b/docs/docs_writer.py @@ -90,7 +90,7 @@ class DocsWriter: def end_menu(self): """Ends an opened menu""" if not self.menu_began: - raise ValueError('No menu had been started in the first place.') + raise RuntimeError('No menu had been started in the first place.') self.write('') def write_title(self, title, level=1): diff --git a/telethon/errors/__init__.py b/telethon/errors/__init__.py index fbb2f424..9126aca3 100644 --- a/telethon/errors/__init__.py +++ b/telethon/errors/__init__.py @@ -7,9 +7,8 @@ import re from threading import Thread from .common import ( - ReadCancelledError, InvalidParameterError, TypeNotFoundError, - InvalidChecksumError, BrokenAuthKeyError, SecurityError, - CdnFileTamperedError + ReadCancelledError, TypeNotFoundError, InvalidChecksumError, + BrokenAuthKeyError, SecurityError, CdnFileTamperedError ) # This imports the base errors too, as they're imported there diff --git a/telethon/errors/common.py b/telethon/errors/common.py index f2f21840..46b0b52e 100644 --- a/telethon/errors/common.py +++ b/telethon/errors/common.py @@ -7,13 +7,6 @@ class ReadCancelledError(Exception): super().__init__(self, 'The read operation was cancelled.') -class InvalidParameterError(Exception): - """ - Occurs when an invalid parameter is given, for example, - when either A or B are required but none is given. - """ - - class TypeNotFoundError(Exception): """ Occurs when a type is not found, for example, diff --git a/telethon/extensions/binary_reader.py b/telethon/extensions/binary_reader.py index 19fb608b..460bed96 100644 --- a/telethon/extensions/binary_reader.py +++ b/telethon/extensions/binary_reader.py @@ -6,7 +6,7 @@ from datetime import datetime from io import BufferedReader, BytesIO from struct import unpack -from ..errors import InvalidParameterError, TypeNotFoundError +from ..errors import TypeNotFoundError from ..tl.all_tlobjects import tlobjects @@ -22,8 +22,7 @@ class BinaryReader: elif stream: self.stream = stream else: - raise InvalidParameterError( - 'Either bytes or a stream must be provided') + raise ValueError('Either bytes or a stream must be provided') self.reader = BufferedReader(self.stream) self._last = None # Should come in handy to spot -404 errors @@ -110,7 +109,7 @@ class BinaryReader: elif value == 0xbc799737: # boolFalse return False else: - raise ValueError('Invalid boolean code {}'.format(hex(value))) + raise RuntimeError('Invalid boolean code {}'.format(hex(value))) def tgread_date(self): """Reads and converts Unix time (used by Telegram) @@ -141,7 +140,7 @@ class BinaryReader: def tgread_vector(self): """Reads a vector (a list) of Telegram objects.""" if 0x1cb5c415 != self.read_int(signed=False): - raise ValueError('Invalid constructor code, vector was expected') + raise RuntimeError('Invalid constructor code, vector was expected') count = self.read_int() return [self.tgread_object() for _ in range(count)] diff --git a/telethon/extensions/tcp_client.py b/telethon/extensions/tcp_client.py index f59bb9f0..61be30f5 100644 --- a/telethon/extensions/tcp_client.py +++ b/telethon/extensions/tcp_client.py @@ -26,7 +26,7 @@ class TcpClient: elif isinstance(timeout, (int, float)): self.timeout = float(timeout) else: - raise ValueError('Invalid timeout type', type(timeout)) + raise TypeError('Invalid timeout type: {}'.format(type(timeout))) def _recreate_socket(self, mode): if self.proxy is None: diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index f22d13e6..36820629 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -84,7 +84,7 @@ class TelegramBareClient: **kwargs): """Refer to TelegramClient.__init__ for docs on this method""" if not api_id or not api_hash: - raise PermissionError( + raise ValueError( "Your API ID or Hash cannot be empty or None. " "Refer to Telethon's wiki for more information.") @@ -94,7 +94,7 @@ class TelegramBareClient: if isinstance(session, str) or session is None: session = Session.try_load_or_create_new(session) elif not isinstance(session, Session): - raise ValueError( + raise TypeError( 'The given session must be a str or a Session instance.' ) @@ -421,11 +421,11 @@ class TelegramBareClient: """Invokes (sends) a MTProtoRequest and returns (receives) its result. The invoke will be retried up to 'retries' times before raising - ValueError(). + RuntimeError(). """ if not all(isinstance(x, TLObject) and x.content_related for x in requests): - raise ValueError('You can only invoke requests, not types!') + raise TypeError('You can only invoke requests, not types!') # For logging purposes if len(requests) == 1: @@ -486,7 +486,7 @@ class TelegramBareClient: else: sender.connect() - raise ValueError('Number of retries reached 0.') + raise RuntimeError('Number of retries reached 0.') finally: if sender != self._sender: sender.disconnect() # Close temporary connections @@ -682,8 +682,8 @@ class TelegramBareClient: if progress_callback: progress_callback(stream.tell(), file_size) else: - raise ValueError('Failed to upload file part {}.' - .format(part_index)) + raise RuntimeError( + 'Failed to upload file part {}.'.format(part_index)) finally: stream.close() @@ -853,7 +853,7 @@ class TelegramBareClient: :return: """ if self._spawn_read_thread and not self._on_read_thread(): - raise ValueError('Can only idle if spawn_read_thread=False') + raise RuntimeError('Can only idle if spawn_read_thread=False') for sig in stop_signals: signal(sig, self._signal_handler) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index e0708bc9..11d677ae 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -13,9 +13,8 @@ except ImportError: from . import TelegramBareClient from . import helpers, utils from .errors import ( - RPCError, UnauthorizedError, InvalidParameterError, PhoneCodeEmptyError, - PhoneCodeExpiredError, PhoneCodeHashEmptyError, PhoneCodeInvalidError, - LocationInvalidError + RPCError, UnauthorizedError, PhoneCodeEmptyError, PhoneCodeExpiredError, + PhoneCodeHashEmptyError, PhoneCodeInvalidError, LocationInvalidError ) from .network import ConnectionMode from .tl import TLObject @@ -381,7 +380,7 @@ class TelegramClient(TelegramBareClient): if parse_mode in {'md', 'markdown'}: message, msg_entities = markdown.parse(message) else: - raise ValueError('Unknown parsing mode', parse_mode) + raise ValueError('Unknown parsing mode: {}'.format(parse_mode)) else: msg_entities = [] @@ -572,7 +571,7 @@ class TelegramClient(TelegramBareClient): """ if max_id is None: if not messages: - raise InvalidParameterError( + raise ValueError( 'Either a message list or a max_id must be provided.') if hasattr(message, '__iter__'): @@ -600,7 +599,7 @@ class TelegramClient(TelegramBareClient): # hex(crc32(b'Message')) = 0x790009e3 return reply_to.id - raise ValueError('Invalid reply_to type: ', type(reply_to)) + raise TypeError('Invalid reply_to type: {}'.format(type(reply_to))) # endregion @@ -1053,7 +1052,7 @@ class TelegramClient(TelegramBareClient): if isinstance(entity, str): return self._get_entity_from_string(entity) - raise ValueError( + raise TypeError( 'Cannot turn "{}" into any entity (user or chat)'.format(entity) ) @@ -1128,7 +1127,7 @@ class TelegramClient(TelegramBareClient): pass if not is_peer: - raise ValueError( + raise TypeError( 'Cannot turn "{}" into an input entity.'.format(peer) ) diff --git a/telethon/tl/custom/draft.py b/telethon/tl/custom/draft.py index c50baa78..abf84548 100644 --- a/telethon/tl/custom/draft.py +++ b/telethon/tl/custom/draft.py @@ -21,7 +21,7 @@ class Draft: @classmethod def _from_update(cls, client, update): if not isinstance(update, UpdateDraftMessage): - raise ValueError( + raise TypeError( 'You can only create a new `Draft` from a corresponding ' '`UpdateDraftMessage` object.' ) diff --git a/telethon/tl/tlobject.py b/telethon/tl/tlobject.py index e2b23018..489765e2 100644 --- a/telethon/tl/tlobject.py +++ b/telethon/tl/tlobject.py @@ -97,7 +97,8 @@ class TLObject: if isinstance(data, str): data = data.encode('utf-8') else: - raise ValueError('bytes or str expected, not', type(data)) + raise TypeError( + 'bytes or str expected, not {}'.format(type(data))) r = [] if len(data) < 254: diff --git a/telethon/utils.py b/telethon/utils.py index 5e92b13d..388af83e 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -67,13 +67,13 @@ def get_extension(media): def _raise_cast_fail(entity, target): - raise ValueError('Cannot cast {} to any kind of {}.' - .format(type(entity).__name__, target)) + raise TypeError('Cannot cast {} to any kind of {}.'.format( + type(entity).__name__, target)) def get_input_peer(entity, allow_self=True): """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.""" + A TypeError is raised if the given entity isn't a supported type.""" if not isinstance(entity, TLObject): _raise_cast_fail(entity, 'InputPeer') From ab07f0220a6646379fa27d840d8129d41b0248cb Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 01:04:11 +0100 Subject: [PATCH 50/78] Save dc_id instead layer and salt in the session file Server salts change every 30 minutes after all, so keeping them in the long-term storage session file doesn't make much sense. Saving the layer doesn't make sense either, as it was only used to know whether to init connection or not, but it should be done always. --- telethon/telegram_bare_client.py | 26 +++++------- telethon/tl/session.py | 62 +++++++---------------------- telethon_tests/higher_level_test.py | 2 +- 3 files changed, 26 insertions(+), 64 deletions(-) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index d4f19b8d..d8cc498e 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -39,6 +39,7 @@ from .update_state import UpdateState from .utils import get_appropriated_part_size +DEFAULT_DC_ID = 4 DEFAULT_IPV4_IP = '149.154.167.51' DEFAULT_IPV6_IP = '[2001:67c:4e8:f002::a]' DEFAULT_PORT = 443 @@ -101,9 +102,11 @@ class TelegramBareClient: # ':' in session.server_address is True if it's an IPv6 address if (not session.server_address or (':' in session.server_address) != use_ipv6): - session.port = DEFAULT_PORT - session.server_address = \ - DEFAULT_IPV6_IP if self._use_ipv6 else DEFAULT_IPV4_IP + session.set_dc( + DEFAULT_DC_ID, + DEFAULT_IPV6_IP if self._use_ipv6 else DEFAULT_IPV4_IP, + DEFAULT_PORT + ) self.session = session self.api_id = int(api_id) @@ -294,8 +297,7 @@ class TelegramBareClient: dc = self._get_dc(new_dc) __log__.info('Reconnecting to new data center %s', dc) - self.session.server_address = dc.ip_address - self.session.port = dc.port + self.session.set_dc(dc.id, dc.ip_address, dc.port) # auth_key's are associated with a server, which has now changed # so it's not valid anymore. Set to None to force recreating it. self.session.auth_key = None @@ -363,8 +365,7 @@ class TelegramBareClient: # Construct this session with the connection parameters # (system version, device model...) from the current one. session = Session(self.session) - session.server_address = dc.ip_address - session.port = dc.port + session.set_dc(dc.id, dc.ip_address, dc.port) self._exported_sessions[dc_id] = session __log__.info('Creating exported new client') @@ -390,8 +391,7 @@ class TelegramBareClient: if not session: dc = self._get_dc(cdn_redirect.dc_id, cdn=True) session = Session(self.session) - session.server_address = dc.ip_address - session.port = dc.port + session.set_dc(dc.id, dc.ip_address, dc.port) self._exported_sessions[cdn_redirect.dc_id] = session __log__.info('Creating new CDN client') @@ -494,7 +494,7 @@ class TelegramBareClient: def _invoke(self, sender, call_receive, update_state, *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 + init_connection = False # TODO Only first call try: # Ensure that we start with no previous errors (i.e. resending) @@ -553,12 +553,6 @@ class TelegramBareClient: # User never called .connect(), so raise this error. raise - 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: diff --git a/telethon/tl/session.py b/telethon/tl/session.py index 3dfba1d9..030b4e13 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -67,6 +67,7 @@ class Session: self._sequence = 0 self.time_offset = 0 self._last_msg_id = 0 # Long + self.salt = 0 # Long # Cross-thread safety self._seq_no_lock = Lock() @@ -74,11 +75,10 @@ class Session: self._db_lock = Lock() # These values will be saved + self._dc_id = 0 self._server_address = None self._port = None self._auth_key = None - self._layer = 0 - self._salt = 0 # Signed long # Migrating from .json -> SQL entities = self._check_migrate_json() @@ -97,8 +97,7 @@ class Session: # These values will be saved c.execute('select * from sessions') - self._server_address, self._port, key, \ - self._layer, self._salt = c.fetchone() + self._dc_id, self._server_address, self._port, key, = c.fetchone() from ..crypto import AuthKey self._auth_key = AuthKey(data=key) @@ -108,12 +107,11 @@ class Session: c.execute("create table version (version integer)") c.execute( """create table sessions ( + dc_id integer primary key, server_address text, port integer, - auth_key blob, - layer integer, - salt integer - )""" + auth_key blob + ) without rowid""" ) c.execute( """create table entities ( @@ -142,13 +140,6 @@ class Session: self.delete() # Delete JSON file to create database self._port = data.get('port', self._port) - self._salt = data.get('salt', self._salt) - # Keep while migrating from unsigned to signed salt - if self._salt > 0: - self._salt = struct.unpack( - 'q', struct.pack('Q', self._salt))[0] - - self._layer = data.get('layer', self._layer) self._server_address = \ data.get('server_address', self._server_address) @@ -169,24 +160,20 @@ class Session: # Data from sessions should be kept as properties # not to fetch the database every time we need it + def set_dc(self, dc_id, server_address, port): + self._dc_id = dc_id + self._server_address = server_address + self._port = port + self._update_session_table() + @property def server_address(self): return self._server_address - @server_address.setter - def server_address(self, value): - self._server_address = value - self._update_session_table() - @property def port(self): return self._port - @port.setter - def port(self, value): - self._port = value - self._update_session_table() - @property def auth_key(self): return self._auth_key @@ -196,34 +183,15 @@ class Session: self._auth_key = value self._update_session_table() - @property - def layer(self): - return self._layer - - @layer.setter - def layer(self, value): - self._layer = value - self._update_session_table() - - @property - def salt(self): - return self._salt - - @salt.setter - def salt(self, value): - self._salt = value - self._update_session_table() - def _update_session_table(self): with self._db_lock: c = self._conn.cursor() c.execute('delete from sessions') - c.execute('insert into sessions values (?,?,?,?,?)', ( + c.execute('insert into sessions values (?,?,?,?)', ( + self._dc_id, self._server_address, self._port, - self._auth_key.key if self._auth_key else b'', - self._layer, - self._salt + self._auth_key.key if self._auth_key else b'' )) c.close() diff --git a/telethon_tests/higher_level_test.py b/telethon_tests/higher_level_test.py index 7bd4b181..7433fac9 100644 --- a/telethon_tests/higher_level_test.py +++ b/telethon_tests/higher_level_test.py @@ -18,7 +18,7 @@ class HigherLevelTests(unittest.TestCase): @staticmethod def test_cdn_download(): client = TelegramClient(None, api_id, api_hash) - client.session.server_address = '149.154.167.40' + client.session.set_dc(0, '149.154.167.40', 80) assert client.connect() try: From 2a10f315119283b2976261f2585551610b680320 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 01:13:24 +0100 Subject: [PATCH 51/78] Always wrap init connection for first call Ping @delivrance. See https://core.telegram.org/api/invoking#saving-client-info. --- telethon/telegram_bare_client.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index d8cc498e..55ac6c41 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -154,6 +154,10 @@ class TelegramBareClient: # Save whether the user is authorized here (a.k.a. logged in) self._authorized = None # None = We don't know yet + # The first request must be in invokeWithLayer(initConnection(X)). + # See https://core.telegram.org/api/invoking#saving-client-info. + self._first_request = True + # Uploaded files cache so subsequent calls are instant self._upload_cache = {} @@ -268,7 +272,7 @@ class TelegramBareClient: self._recv_thread.join() # TODO Shall we clear the _exported_sessions, or may be reused? - pass + self._first_request = True # On reconnect it will be first again def _reconnect(self, new_dc=None): """If 'new_dc' is not set, only a call to .connect() will be made @@ -492,10 +496,6 @@ class TelegramBareClient: invoke = __call__ def _invoke(self, sender, call_receive, update_state, *requests): - # We need to specify the new layer (by initializing a new - # connection) if it has changed from the latest known one. - init_connection = False # TODO Only first call - try: # Ensure that we start with no previous errors (i.e. resending) for x in requests: @@ -503,14 +503,11 @@ class TelegramBareClient: 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. __log__.info('Need to generate new auth key before invoking') self.session.auth_key, self.session.time_offset = \ authenticator.do_authentication(self._sender.connection) - init_connection = True - if init_connection: + if self._first_request: __log__.info('Initializing a new connection while invoking') if len(requests) == 1: requests = [self._wrap_init_connection(requests[0])] @@ -553,6 +550,9 @@ class TelegramBareClient: # User never called .connect(), so raise this error. raise + # Clear the flag if we got this far + self._first_request = False + try: raise next(x.rpc_error for x in requests if x.rpc_error) except StopIteration: From 0755bda2208360c496c264b7840e6d16299f4a0e Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 02:01:22 +0100 Subject: [PATCH 52/78] Stop returning tuples off .get_message_history() Now the information is saved in the modified Message instances, which makes it easier to use (message.sender, message.to...) --- telethon/telegram_client.py | 47 ++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 2f48f7a7..a5ea1025 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -484,8 +484,12 @@ class TelegramClient(TelegramBareClient): Additional message offset (all of the specified offsets + this offset = older messages). - :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! + :return: A list of messages with extra attributes: + .total = total amount of messages in this history + .sender = entity of the sender + .fwd_from.sender = if fwd_from, who sent it originally + .fwd_from.channel = if fwd_from, original channel + .to = entity to which the message was sent """ entity = self.get_input_entity(entity) limit = float('inf') if limit is None else int(limit) @@ -537,25 +541,30 @@ class TelegramClient(TelegramBareClient): if limit > 3000: time.sleep(1) - # In a new list with the same length as the messages append - # their senders, so people can zip(messages, senders). - senders = [] + # Add a few extra attributes to the Message to make it friendlier. for m in messages: - if m.from_id: - who = entities[utils.get_peer_id(m.from_id, add_mark=True)] - elif getattr(m, 'fwd_from', None): - # .from_id is optional, so this is the sanest fallback. - who = entities[utils.get_peer_id( - m.fwd_from.from_id or PeerChannel(m.fwd_from.channel_id), - add_mark=True - )] - else: - # If there's not even a FwdHeader, fallback to the sender - # being where the message was sent. - who = entities[utils.get_peer_id(m.to_id, add_mark=True)] - senders.append(who) + # TODO Better way to return a total without tuples? + m.total = total_messages + m.sender = (None if not m.from_id else + entities[utils.get_peer_id(m.from_id, add_mark=True)]) - return total_messages, messages, senders + if getattr(m, 'fwd_from', None): + m.fwd_from.sender = ( + None if not m.fwd_from.from_id else + entities[utils.get_peer_id( + m.fwd_from.from_id, add_mark=True + )] + ) + m.fwd_from.channel = ( + None if not m.fwd_from.channel_id else + entities[utils.get_peer_id( + PeerChannel(m.fwd_from.channel_id), add_mark=True + )] + ) + + m.to = entities[utils.get_peer_id(m.to_id, add_mark=True)] + + return messages def send_read_acknowledge(self, entity, message=None, max_id=None): """ From 459022bdabba3cefe5db09fb8da81eca2cb65156 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 11:49:35 +0100 Subject: [PATCH 53/78] Return a UserList with a .total attribute for get dialogs/history --- telethon/telegram_client.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index a5ea1025..b59f8705 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -1,7 +1,7 @@ import itertools import os import time -from collections import OrderedDict +from collections import OrderedDict, UserList from datetime import datetime, timedelta from mimetypes import guess_type @@ -296,12 +296,23 @@ class TelegramClient(TelegramBareClient): :param offset_peer: The peer to be used as an offset. - :return List[telethon.tl.custom.Dialog]: A list dialogs. + :return UserList[telethon.tl.custom.Dialog]: + A list dialogs, with an additional .total attribute on the list. """ limit = float('inf') if limit is None else int(limit) if limit == 0: - return [] + # Special case, get a single dialog and determine count + dialogs = self(GetDialogsRequest( + offset_date=offset_date, + offset_id=offset_id, + offset_peer=offset_peer, + limit=1 + )) + result = UserList() + result.total = getattr(dialogs, 'count', len(dialogs.dialogs)) + return result + total_count = 0 dialogs = OrderedDict() # Use peer id as identifier to avoid dupes while len(dialogs) < limit: real_limit = min(limit - len(dialogs), 100) @@ -312,6 +323,7 @@ class TelegramClient(TelegramBareClient): limit=real_limit )) + total_count = getattr(r, 'count', len(r.dialogs)) messages = {m.id: m for m in r.messages} entities = {utils.get_peer_id(x, add_mark=True): x for x in itertools.chain(r.users, r.chats)} @@ -331,7 +343,8 @@ class TelegramClient(TelegramBareClient): ) offset_id = r.messages[-1].id & 4294967296 # Telegram/danog magic - dialogs = list(dialogs.values()) + dialogs = UserList(dialogs.values()) + dialogs.total = total_count return dialogs[:limit] if limit < float('inf') else dialogs def get_drafts(self): # TODO: Ability to provide a `filter` @@ -485,7 +498,7 @@ class TelegramClient(TelegramBareClient): (all of the specified offsets + this offset = older messages). :return: A list of messages with extra attributes: - .total = total amount of messages in this history + .total = (on the list) total amount of messages sent .sender = entity of the sender .fwd_from.sender = if fwd_from, who sent it originally .fwd_from.channel = if fwd_from, original channel @@ -502,7 +515,7 @@ class TelegramClient(TelegramBareClient): return getattr(result, 'count', len(result.messages)), [], [] total_messages = 0 - messages = [] + messages = UserList() entities = {} while len(messages) < limit: # Telegram has a hard limit of 100 @@ -542,9 +555,9 @@ class TelegramClient(TelegramBareClient): time.sleep(1) # Add a few extra attributes to the Message to make it friendlier. + messages.total = total_messages for m in messages: # TODO Better way to return a total without tuples? - m.total = total_messages m.sender = (None if not m.from_id else entities[utils.get_peer_id(m.from_id, add_mark=True)]) From bfff1567aff5889e1069c9be2d116ce7c2dc39eb Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 11:55:05 +0100 Subject: [PATCH 54/78] Fix up some mismatching raise/except types since 6ec6967 --- telethon/telegram_client.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index b59f8705..b5f85fd3 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -30,7 +30,7 @@ from .tl.functions.contacts import ( GetContactsRequest, ResolveUsernameRequest ) from .tl.functions.messages import ( - GetDialogsRequest, GetHistoryRequest, ReadHistoryRequest, SendMediaRequest, + GetDialogsRequest, GetHistoryRequest, SendMediaRequest, SendMessageRequest, GetChatsRequest, GetAllDraftsRequest, CheckChatInviteRequest ) @@ -1091,17 +1091,11 @@ class TelegramClient(TelegramBareClient): 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. -<<<<<<< HEAD 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. -======= - raise TypeError( - 'Cannot turn "{}" into any entity (user or chat)'.format(entity) - ) ->>>>>>> 6ec6967ff9a2e09aae70b500273075bdfbae975c - Returns the found entity. + Returns the found entity, or raises TypeError if not found. """ phone = utils.parse_phone(string) if phone: @@ -1125,6 +1119,10 @@ class TelegramClient(TelegramBareClient): if entity.username.lower() == string: return entity + raise TypeError( + 'Cannot turn "{}" into any entity (user or chat)'.format(string) + ) + def get_input_entity(self, peer): """ Turns the given peer into its input entity version. Most requests @@ -1164,7 +1162,7 @@ class TelegramClient(TelegramBareClient): if not is_peer: try: return utils.get_input_peer(peer) - except ValueError: + except TypeError: pass if not is_peer: @@ -1189,7 +1187,7 @@ class TelegramClient(TelegramBareClient): if utils.get_peer_id(entity, add_mark=True) == target: return utils.get_input_peer(entity) - raise ValueError( + raise TypeError( 'Could not find the input entity corresponding to "{}".' 'Make sure you have encountered this peer before.'.format(peer) ) From 75a342e24ba431fcbacfd47073edff4ec97f3114 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 12:11:31 +0100 Subject: [PATCH 55/78] Fix .download_media() not handling Photo (closes #473) --- telethon/telegram_client.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index b5f85fd3..77a71537 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -52,7 +52,7 @@ from .tl.types import ( InputUserSelf, UserProfilePhoto, ChatPhoto, UpdateMessageID, UpdateNewChannelMessage, UpdateNewMessage, UpdateShortSentMessage, PeerUser, InputPeerUser, InputPeerChat, InputPeerChannel, MessageEmpty, - ChatInvite, ChatInviteAlready, PeerChannel + ChatInvite, ChatInviteAlready, PeerChannel, Photo ) from .tl.types.messages import DialogsSlice from .extensions import markdown @@ -848,7 +848,7 @@ class TelegramClient(TelegramBareClient): date = datetime.now() media = message - if isinstance(media, MessageMediaPhoto): + if isinstance(media, (MessageMediaPhoto, Photo)): return self._download_photo( media, file, date, progress_callback ) @@ -861,11 +861,15 @@ class TelegramClient(TelegramBareClient): media, file ) - def _download_photo(self, mm_photo, file, date, progress_callback): + def _download_photo(self, photo, file, date, progress_callback): """Specialized version of .download_media() for photos""" # Determine the photo and its largest size - photo = getattr(mm_photo, 'photo', mm_photo) + if isinstance(photo, MessageMediaPhoto): + photo = photo.photo + if not isinstance(photo, Photo): + return + largest_size = photo.sizes[-1] file_size = largest_size.size largest_size = largest_size.location From 3537e9bcc9060e45f8d4d6714875c23608c7afc5 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 12:32:16 +0100 Subject: [PATCH 56/78] Support more types to represent a date --- telethon/tl/tlobject.py | 20 +++++++++++++++++++- telethon_generator/tl_generator.py | 6 +----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/telethon/tl/tlobject.py b/telethon/tl/tlobject.py index 489765e2..0ed7b015 100644 --- a/telethon/tl/tlobject.py +++ b/telethon/tl/tlobject.py @@ -1,4 +1,5 @@ -from datetime import datetime +import struct +from datetime import datetime, date from threading import Event @@ -125,6 +126,23 @@ class TLObject: r.append(bytes(padding)) return b''.join(r) + @staticmethod + def serialize_datetime(dt): + if not dt: + return b'\0\0\0\0' + + if isinstance(dt, datetime): + dt = int(dt.timestamp()) + elif isinstance(dt, date): + dt = int(datetime(dt.year, dt.month, dt.day, dt).timestamp()) + elif isinstance(dt, float): + dt = int(dt) + + if isinstance(dt, int): + return struct.pack(' Date: Thu, 28 Dec 2017 12:43:50 +0100 Subject: [PATCH 57/78] Update to v0.16 --- telethon/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/version.py b/telethon/version.py index 096fbd6c..e7fcc442 100644 --- a/telethon/version.py +++ b/telethon/version.py @@ -1,3 +1,3 @@ # Versions should comply with PEP440. # This line is parsed in setup.py: -__version__ = '0.15.5' +__version__ = '0.16' From 7ed3be8e6f0ec053338e1dc4f936430f4b07aedf Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 13:21:35 +0100 Subject: [PATCH 58/78] Fix .get_dialogs() failing due to IDs being marked Also removed utils.find_user_or_chat to prevent this from happening again. Using a dict {marked_id: entity} is better. --- telethon/telegram_client.py | 5 ++--- telethon/utils.py | 22 ---------------------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 77a71537..c508ad52 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -338,9 +338,8 @@ class TelegramClient(TelegramBareClient): break offset_date = r.messages[-1].date - offset_peer = utils.find_user_or_chat( - r.dialogs[-1].peer, entities, entities - ) + offset_peer = entities[ + utils.get_peer_id(r.dialogs[-1].peer, add_mark=True)] offset_id = r.messages[-1].id & 4294967296 # Telegram/danog magic dialogs = UserList(dialogs.values()) diff --git a/telethon/utils.py b/telethon/utils.py index 720345db..531b0dc7 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -386,28 +386,6 @@ def resolve_id(marked_id): return -marked_id, PeerChat -def find_user_or_chat(peer, users, chats): - """Finds the corresponding user or chat given a peer. - Returns None if it was not found""" - if isinstance(peer, PeerUser): - peer, where = peer.user_id, users - else: - where = chats - if isinstance(peer, PeerChat): - peer = peer.chat_id - elif isinstance(peer, PeerChannel): - peer = peer.channel_id - - if isinstance(peer, int): - if isinstance(where, dict): - return where.get(peer) - else: - try: - return next(x for x in where if x.id == peer) - except StopIteration: - pass - - def get_appropriated_part_size(file_size): """Gets the appropriated part size when uploading or downloading files, given an initial file size""" From 55b67b65a1026bb874598f75186a10bc204f0c90 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 13:31:43 +0100 Subject: [PATCH 59/78] Remove optional add_mark parameter from .get_peer_id It was always True after all, and it made no sense for it to be False. --- telethon/telegram_client.py | 29 +++++++++++++---------------- telethon/tl/custom/dialog.py | 2 +- telethon/tl/session.py | 4 ++-- telethon/utils.py | 22 ++++++++++++---------- 4 files changed, 28 insertions(+), 29 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index c508ad52..3b17e4c2 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -325,11 +325,11 @@ class TelegramClient(TelegramBareClient): total_count = getattr(r, 'count', len(r.dialogs)) messages = {m.id: m for m in r.messages} - entities = {utils.get_peer_id(x, add_mark=True): x + entities = {utils.get_peer_id(x): x for x in itertools.chain(r.users, r.chats)} for d in r.dialogs: - dialogs[utils.get_peer_id(d.peer, add_mark=True)] = \ + dialogs[utils.get_peer_id(d.peer)] = \ Dialog(self, d, entities, messages) if len(r.dialogs) < real_limit or not isinstance(r, DialogsSlice): @@ -338,8 +338,7 @@ class TelegramClient(TelegramBareClient): break offset_date = r.messages[-1].date - offset_peer = entities[ - utils.get_peer_id(r.dialogs[-1].peer, add_mark=True)] + offset_peer = entities[utils.get_peer_id(r.dialogs[-1].peer)] offset_id = r.messages[-1].id & 4294967296 # Telegram/danog magic dialogs = UserList(dialogs.values()) @@ -536,9 +535,9 @@ class TelegramClient(TelegramBareClient): # TODO We can potentially use self.session.database, but since # it might be disabled, use a local dictionary. for u in result.users: - entities[utils.get_peer_id(u, add_mark=True)] = u + entities[utils.get_peer_id(u)] = u for c in result.chats: - entities[utils.get_peer_id(c, add_mark=True)] = c + entities[utils.get_peer_id(c)] = c if len(result.messages) < real_limit: break @@ -558,23 +557,21 @@ class TelegramClient(TelegramBareClient): for m in messages: # TODO Better way to return a total without tuples? m.sender = (None if not m.from_id else - entities[utils.get_peer_id(m.from_id, add_mark=True)]) + entities[utils.get_peer_id(m.from_id)]) if getattr(m, 'fwd_from', None): m.fwd_from.sender = ( None if not m.fwd_from.from_id else - entities[utils.get_peer_id( - m.fwd_from.from_id, add_mark=True - )] + entities[utils.get_peer_id(m.fwd_from.from_id)] ) m.fwd_from.channel = ( None if not m.fwd_from.channel_id else entities[utils.get_peer_id( - PeerChannel(m.fwd_from.channel_id), add_mark=True + PeerChannel(m.fwd_from.channel_id) )] ) - m.to = entities[utils.get_peer_id(m.to_id, add_mark=True)] + m.to = entities[utils.get_peer_id(m.to_id)] return messages @@ -1073,7 +1070,7 @@ class TelegramClient(TelegramBareClient): # Merge users, chats and channels into a single dictionary id_entity = { - utils.get_peer_id(x, add_mark=True): x + utils.get_peer_id(x): x for x in itertools.chain(users, chats, channels) } @@ -1083,7 +1080,7 @@ class TelegramClient(TelegramBareClient): # username changes. result = [ self._get_entity_from_string(x) if isinstance(x, str) - else id_entity[utils.get_peer_id(x, add_mark=True)] + else id_entity[utils.get_peer_id(x)] for x in inputs ] return result[0] if single else result @@ -1185,9 +1182,9 @@ class TelegramClient(TelegramBareClient): exclude_pinned=True )) - target = utils.get_peer_id(peer, add_mark=True) + target = utils.get_peer_id(peer) for entity in itertools.chain(dialogs.users, dialogs.chats): - if utils.get_peer_id(entity, add_mark=True) == target: + if utils.get_peer_id(entity) == target: return utils.get_input_peer(entity) raise TypeError( diff --git a/telethon/tl/custom/dialog.py b/telethon/tl/custom/dialog.py index bac8b0de..fd36ba8f 100644 --- a/telethon/tl/custom/dialog.py +++ b/telethon/tl/custom/dialog.py @@ -17,7 +17,7 @@ class Dialog: self.message = messages.get(dialog.top_message, None) self.date = getattr(self.message, 'date', None) - self.entity = entities[utils.get_peer_id(dialog.peer, add_mark=True)] + self.entity = entities[utils.get_peer_id(dialog.peer)] self.input_entity = utils.get_input_peer(self.entity) self.name = utils.get_display_name(self.entity) diff --git a/telethon/tl/session.py b/telethon/tl/session.py index 030b4e13..bb38f489 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -285,7 +285,7 @@ class Session: continue try: p = utils.get_input_peer(e, allow_self=False) - marked_id = utils.get_peer_id(p, add_mark=True) + marked_id = utils.get_peer_id(p) except ValueError: continue @@ -327,7 +327,7 @@ class Session: key = utils.get_input_peer(key) if type(key).SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer') return key - key = utils.get_peer_id(key, add_mark=True) + key = utils.get_peer_id(key) c = self._conn.cursor() if isinstance(key, str): diff --git a/telethon/utils.py b/telethon/utils.py index 531b0dc7..48c867d1 100644 --- a/telethon/utils.py +++ b/telethon/utils.py @@ -338,9 +338,14 @@ def parse_username(username): return username.lower(), False -def get_peer_id(peer, add_mark=False): - """Finds the ID of the given peer, and optionally converts it to - the "bot api" format if 'add_mark' is set to True. +def get_peer_id(peer): + """ + Finds the ID of the given peer, and converts it to the "bot api" format + so it the peer can be identified back. User ID is left unmodified, + chat ID is negated, and channel ID is prefixed with -100. + + The original ID and the peer type class can be returned with + a call to utils.resolve_id(marked_id). """ # First we assert it's a Peer TLObject, or early return for integers if not isinstance(peer, TLObject): @@ -357,7 +362,7 @@ def get_peer_id(peer, add_mark=False): if isinstance(peer, (PeerUser, InputPeerUser)): return peer.user_id elif isinstance(peer, (PeerChat, InputPeerChat)): - return -peer.chat_id if add_mark else peer.chat_id + return -peer.chat_id elif isinstance(peer, (PeerChannel, InputPeerChannel, ChannelFull)): if isinstance(peer, ChannelFull): # Special case: .get_input_peer can't return InputChannel from @@ -365,12 +370,9 @@ def get_peer_id(peer, add_mark=False): i = peer.id else: i = peer.channel_id - if add_mark: - # Concat -100 through math tricks, .to_supergroup() on Madeline - # IDs will be strictly positive -> log works - return -(i + pow(10, math.floor(math.log10(i) + 3))) - else: - return i + # Concat -100 through math tricks, .to_supergroup() on Madeline + # IDs will be strictly positive -> log works + return -(i + pow(10, math.floor(math.log10(i) + 3))) _raise_cast_fail(peer, 'int') From 50d413b1c93119635b8e27efc8b77a8f9683438a Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 14:55:02 +0100 Subject: [PATCH 60/78] Fix slicing dialogs was turning UserList into list --- telethon/telegram_client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 3b17e4c2..72f9f98b 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -341,9 +341,11 @@ class TelegramClient(TelegramBareClient): offset_peer = entities[utils.get_peer_id(r.dialogs[-1].peer)] offset_id = r.messages[-1].id & 4294967296 # Telegram/danog magic - dialogs = UserList(dialogs.values()) + dialogs = UserList( + itertools.islice(dialogs.values(), min(limit, len(dialogs))) + ) dialogs.total = total_count - return dialogs[:limit] if limit < float('inf') else dialogs + return dialogs def get_drafts(self): # TODO: Ability to provide a `filter` """ From 4a139b0ae499f3d3026010a9392ee8a88bff0d54 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 14:58:42 +0100 Subject: [PATCH 61/78] Fix session table may be empty if no DC switch --- telethon/tl/session.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/telethon/tl/session.py b/telethon/tl/session.py index bb38f489..26c9576e 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -97,10 +97,12 @@ class Session: # These values will be saved c.execute('select * from sessions') - self._dc_id, self._server_address, self._port, key, = c.fetchone() + tuple_ = c.fetchone() + if tuple_: + self._dc_id, self._server_address, self._port, key, = tuple_ + from ..crypto import AuthKey + self._auth_key = AuthKey(data=key) - from ..crypto import AuthKey - self._auth_key = AuthKey(data=key) c.close() else: # Tables don't exist, create new ones From ea436a4fac307336251429470cd623e829ef9681 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 16:25:41 +0100 Subject: [PATCH 62/78] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 21e76aca..c4b9b7e8 100755 --- a/README.rst +++ b/README.rst @@ -47,7 +47,7 @@ Doing stuff client.send_file('username', '/home/myself/Pictures/holidays.jpg') client.download_profile_photo(me) - total, messages, senders = client.get_message_history('username') + messages = client.get_message_history('username') client.download_media(messages[0]) From 47b53ce89f529128ddc74ad45dec84b67a0c0b7a Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 28 Dec 2017 17:06:14 +0100 Subject: [PATCH 63/78] Except only UnicodeDecodeError to check migration (fix #511) --- telethon/tl/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/tl/session.py b/telethon/tl/session.py index 26c9576e..236c1096 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -154,7 +154,7 @@ class Session: for p_id, p_hash in data.get('entities', []): rows.append((p_id, p_hash, None, None, None)) return rows - except (UnicodeDecodeError, json.decoder.JSONDecodeError): + except UnicodeDecodeError: return [] # No entities def _upgrade_database(self, old): From 0570c55120a3a764c4a3649bdc41acc5736f970d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 29 Dec 2017 00:43:52 +0100 Subject: [PATCH 64/78] Remove hardcoded database version from session sql statement --- telethon/tl/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telethon/tl/session.py b/telethon/tl/session.py index 236c1096..193c6d44 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -107,6 +107,7 @@ class Session: else: # Tables don't exist, create new ones c.execute("create table version (version integer)") + c.execute("insert into version values (?)", (CURRENT_VERSION,)) c.execute( """create table sessions ( dc_id integer primary key, @@ -124,7 +125,6 @@ class Session: name text ) without rowid""" ) - c.execute("insert into version values (1)") # Migrating from JSON -> new table and may have entities if entities: c.executemany( From d2121c76cbb6db4574f9ef3e88ecb96c891f3a02 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 29 Dec 2017 19:41:12 +0100 Subject: [PATCH 65/78] Fetch and persist each auth_key per DC --- telethon/tl/session.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/telethon/tl/session.py b/telethon/tl/session.py index 193c6d44..8c2850bf 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -168,6 +168,17 @@ class Session: self._port = port self._update_session_table() + # Fetch the auth_key corresponding to this data center + c = self._conn.cursor() + c.execute('select auth_key from sessions') + tuple_ = c.fetchone() + if tuple_: + from ..crypto import AuthKey + self._auth_key = AuthKey(data=tuple_[0]) + else: + self._auth_key = None + c.close() + @property def server_address(self): return self._server_address @@ -188,8 +199,7 @@ class Session: def _update_session_table(self): with self._db_lock: c = self._conn.cursor() - c.execute('delete from sessions') - c.execute('insert into sessions values (?,?,?,?)', ( + c.execute('insert or replace into sessions values (?,?,?,?)', ( self._dc_id, self._server_address, self._port, From cbf6306599115ad7a130eee07a8bf7106caef683 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 29 Dec 2017 22:07:16 +0100 Subject: [PATCH 66/78] Fix early cast to input from 932ed9e causing error on Peer --- telethon/tl/session.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/telethon/tl/session.py b/telethon/tl/session.py index 8c2850bf..3fa13d23 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -336,10 +336,12 @@ class Session: Raises ValueError if it cannot be found. """ if isinstance(key, TLObject): - key = utils.get_input_peer(key) - if type(key).SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer') - return key - key = utils.get_peer_id(key) + try: + # Try to early return if this key can be casted as input peer + return utils.get_input_peer(key) + except TypeError: + # Otherwise, get the ID of the peer + key = utils.get_peer_id(key) c = self._conn.cursor() if isinstance(key, str): From 6eef6f5d239e554697a69f44eb278b330a18cbe2 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Tue, 2 Jan 2018 00:02:31 +0100 Subject: [PATCH 67/78] Update to layer 74 --- telethon_generator/scheme.tl | 45 +++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/telethon_generator/scheme.tl b/telethon_generator/scheme.tl index 2ecb31b4..1d03c281 100644 --- a/telethon_generator/scheme.tl +++ b/telethon_generator/scheme.tl @@ -166,11 +166,9 @@ inputMediaGifExternal#4843b0fd url:string q:string = InputMedia; inputMediaPhotoExternal#922aec1 flags:# url:string caption:string ttl_seconds:flags.0?int = InputMedia; inputMediaDocumentExternal#b6f74335 flags:# url:string caption:string ttl_seconds:flags.0?int = InputMedia; inputMediaGame#d33f43f3 id:InputGame = InputMedia; -inputMediaInvoice#92153685 flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:string start_param:string = InputMedia; +inputMediaInvoice#f4e096c3 flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:string provider_data:DataJSON start_param:string = InputMedia; inputMediaGeoLive#7b1a118f geo_point:InputGeoPoint period:int = InputMedia; -inputSingleMedia#5eaa7809 media:InputMedia random_id:long = InputSingleMedia; - inputChatPhotoEmpty#1ca48f57 = InputChatPhoto; inputChatUploadedPhoto#927c55b4 file:InputFile = InputChatPhoto; inputChatPhoto#8953ad37 id:InputPhoto = InputChatPhoto; @@ -345,6 +343,7 @@ messages.dialogsSlice#71e094f3 count:int dialogs:Vector messages:Vector< messages.messages#8c718e87 messages:Vector chats:Vector users:Vector = messages.Messages; messages.messagesSlice#b446ae3 count:int messages:Vector chats:Vector users:Vector = messages.Messages; messages.channelMessages#99262e37 flags:# pts:int count:int messages:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesNotModified#74535f21 count:int = messages.Messages; messages.chats#64ff9fd5 chats:Vector = messages.Chats; messages.chatsSlice#9cd81144 count:int chats:Vector = messages.Chats; @@ -357,7 +356,6 @@ inputMessagesFilterEmpty#57e2f66c = MessagesFilter; inputMessagesFilterPhotos#9609a51c = MessagesFilter; inputMessagesFilterVideo#9fc00e65 = MessagesFilter; inputMessagesFilterPhotoVideo#56e9f0e4 = MessagesFilter; -inputMessagesFilterPhotoVideoDocuments#d95e73bb = MessagesFilter; inputMessagesFilterDocument#9eddf188 = MessagesFilter; inputMessagesFilterUrl#7ef0dd87 = MessagesFilter; inputMessagesFilterGif#ffc86587 = MessagesFilter; @@ -368,8 +366,8 @@ inputMessagesFilterPhoneCalls#80c99768 flags:# missed:flags.0?true = MessagesFil inputMessagesFilterRoundVoice#7a7c17a4 = MessagesFilter; inputMessagesFilterRoundVideo#b549da53 = MessagesFilter; inputMessagesFilterMyMentions#c1f8e69a = MessagesFilter; -inputMessagesFilterContacts#e062db83 = MessagesFilter; inputMessagesFilterGeo#e7026d0d = MessagesFilter; +inputMessagesFilterContacts#e062db83 = MessagesFilter; updateNewMessage#1f2b0afd message:Message pts:int pts_count:int = Update; updateMessageID#4e90bfd6 id:int random_id:long = Update; @@ -463,7 +461,7 @@ upload.fileCdnRedirect#ea52fe5a dc_id:int file_token:bytes encryption_key:bytes dcOption#5d8c6cc flags:# ipv6:flags.0?true media_only:flags.1?true tcpo_only:flags.2?true cdn:flags.3?true static:flags.4?true id:int ip_address:string port:int = DcOption; -config#9c840964 flags:# phonecalls_enabled:flags.1?true date:int expires:int test_mode:Bool this_dc:int dc_options:Vector chat_size_max:int megagroup_size_max:int forwarded_count_max:int online_update_period_ms:int offline_blur_timeout_ms:int offline_idle_timeout_ms:int online_cloud_timeout_ms:int notify_cloud_delay_ms:int notify_default_delay_ms:int chat_big_size:int push_chat_period_ms:int push_chat_limit:int saved_gifs_limit:int edit_time_limit:int rating_e_decay:int stickers_recent_limit:int stickers_faved_limit:int channels_read_media_period:int tmp_sessions:flags.0?int pinned_dialogs_count_max:int call_receive_timeout_ms:int call_ring_timeout_ms:int call_connect_timeout_ms:int call_packet_timeout_ms:int me_url_prefix:string suggested_lang_code:flags.2?string lang_pack_version:flags.2?int disabled_features:Vector = Config; +config#9c840964 flags:# phonecalls_enabled:flags.1?true default_p2p_contacts:flags.3?true date:int expires:int test_mode:Bool this_dc:int dc_options:Vector chat_size_max:int megagroup_size_max:int forwarded_count_max:int online_update_period_ms:int offline_blur_timeout_ms:int offline_idle_timeout_ms:int online_cloud_timeout_ms:int notify_cloud_delay_ms:int notify_default_delay_ms:int chat_big_size:int push_chat_period_ms:int push_chat_limit:int saved_gifs_limit:int edit_time_limit:int rating_e_decay:int stickers_recent_limit:int stickers_faved_limit:int channels_read_media_period:int tmp_sessions:flags.0?int pinned_dialogs_count_max:int call_receive_timeout_ms:int call_ring_timeout_ms:int call_connect_timeout_ms:int call_packet_timeout_ms:int me_url_prefix:string suggested_lang_code:flags.2?string lang_pack_version:flags.2?int disabled_features:Vector = Config; nearestDc#8e1a1775 country:string this_dc:int nearest_dc:int = NearestDc; @@ -524,7 +522,7 @@ sendMessageGamePlayAction#dd6a8f48 = SendMessageAction; sendMessageRecordRoundAction#88f27fbc = SendMessageAction; sendMessageUploadRoundAction#243e1c66 progress:int = SendMessageAction; -contacts.found#1aa1f784 results:Vector chats:Vector users:Vector = contacts.Found; +contacts.found#b3134d9d my_results:Vector results:Vector chats:Vector users:Vector = contacts.Found; inputPrivacyKeyStatusTimestamp#4f96cb18 = InputPrivacyKey; inputPrivacyKeyChatInvite#bdfb0426 = InputPrivacyKey; @@ -723,7 +721,7 @@ auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; -messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; +messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; messages.messageEditData#26b5dde6 flags:# caption:flags.0?true = messages.MessageEditData; @@ -825,7 +823,7 @@ dataJSON#7d748d04 data:string = DataJSON; labeledPrice#cb296bf8 label:string amount:long = LabeledPrice; -invoice#c30aa358 flags:# test:flags.0?true name_requested:flags.1?true phone_requested:flags.2?true email_requested:flags.3?true shipping_address_requested:flags.4?true flexible:flags.5?true currency:string prices:Vector = Invoice; +invoice#c30aa358 flags:# test:flags.0?true name_requested:flags.1?true phone_requested:flags.2?true email_requested:flags.3?true shipping_address_requested:flags.4?true flexible:flags.5?true phone_to_provider:flags.6?true email_to_provider:flags.7?true currency:string prices:Vector = Invoice; paymentCharge#ea02c27e id:string provider_charge_id:string = PaymentCharge; @@ -856,6 +854,8 @@ payments.savedInfo#fb8fe43c flags:# has_saved_credentials:flags.1?true saved_inf inputPaymentCredentialsSaved#c10eb2cf id:string tmp_password:bytes = InputPaymentCredentials; inputPaymentCredentials#3417d728 flags:# save:flags.0?true data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsApplePay#aa1c39f payment_data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsAndroidPay#ca05d50e payment_token:DataJSON google_transaction_id:string = InputPaymentCredentials; account.tmpPassword#db64fd34 tmp_password:bytes valid_until:int = account.TmpPassword; @@ -893,7 +893,7 @@ langPackDifference#f385c1f6 lang_code:string from_version:int version:int string langPackLanguage#117698f1 name:string native_name:string lang_code:string = LangPackLanguage; -channelAdminRights#5d7ceba5 flags:# change_info:flags.0?true post_messages:flags.1?true edit_messages:flags.2?true delete_messages:flags.3?true ban_users:flags.4?true invite_users:flags.5?true invite_link:flags.6?true pin_messages:flags.7?true add_admins:flags.9?true = ChannelAdminRights; +channelAdminRights#5d7ceba5 flags:# change_info:flags.0?true post_messages:flags.1?true edit_messages:flags.2?true delete_messages:flags.3?true ban_users:flags.4?true invite_users:flags.5?true invite_link:flags.6?true pin_messages:flags.7?true add_admins:flags.9?true manage_call:flags.10?true = ChannelAdminRights; channelBannedRights#58cf4249 flags:# view_messages:flags.0?true send_messages:flags.1?true send_media:flags.2?true send_stickers:flags.3?true send_gifs:flags.4?true send_games:flags.5?true send_inline:flags.6?true embed_links:flags.7?true until_date:int = ChannelBannedRights; @@ -927,13 +927,15 @@ cdnFileHash#77eec38f offset:int limit:int hash:bytes = CdnFileHash; messages.favedStickersNotModified#9e8fa6d3 = messages.FavedStickers; messages.favedStickers#f37f2f16 hash:int packs:Vector stickers:Vector = messages.FavedStickers; -help.recentMeUrls#e0310d7 urls:Vector chats:Vector users:Vector = help.RecentMeUrls; - +recentMeUrlUnknown#46e1d13d url:string = RecentMeUrl; recentMeUrlUser#8dbc3336 url:string user_id:int = RecentMeUrl; recentMeUrlChat#a01b22f9 url:string chat_id:int = RecentMeUrl; -recentMeUrlStickerSet#bc0a57dc url:string set:StickerSetCovered = RecentMeUrl; recentMeUrlChatInvite#eb49081d url:string chat_invite:ChatInvite = RecentMeUrl; -recentMeUrlUnknown#46e1d13d url:string = RecentMeUrl; +recentMeUrlStickerSet#bc0a57dc url:string set:StickerSetCovered = RecentMeUrl; + +help.recentMeUrls#e0310d7 urls:Vector chats:Vector users:Vector = help.RecentMeUrls; + +inputSingleMedia#5eaa7809 media:InputMedia random_id:long = InputSingleMedia; ---functions--- @@ -961,8 +963,8 @@ auth.resendCode#3ef1a9bf phone_number:string phone_code_hash:string = auth.SentC auth.cancelCode#1f040578 phone_number:string phone_code_hash:string = Bool; auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector = Bool; -account.registerDevice#637ea878 token_type:int token:string = Bool; -account.unregisterDevice#65c55b40 token_type:int token:string = Bool; +account.registerDevice#f75874d1 token_type:int token:string other_uids:Vector = Bool; +account.unregisterDevice#3076c4bf token_type:int token:string other_uids:Vector = Bool; account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool; account.getNotifySettings#12b3ad31 peer:InputNotifyPeer = PeerNotifySettings; account.resetNotifySettings#db7e1747 = Bool; @@ -1010,7 +1012,7 @@ contacts.resetSaved#879537f1 = Bool; messages.getMessages#4222fa74 id:Vector = messages.Messages; messages.getDialogs#191ba9c5 flags:# exclude_pinned:flags.0?true offset_date:int offset_id:int offset_peer:InputPeer limit:int = messages.Dialogs; -messages.getHistory#afa92846 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.getHistory#dcbb8260 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:int = messages.Messages; messages.search#39e9ea0 flags:# peer:InputPeer q:string from_id:flags.0?InputUser filter:MessagesFilter min_date:int max_date:int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; messages.readHistory#e306d3a peer:InputPeer max_id:int = messages.AffectedMessages; messages.deleteHistory#1c015b09 flags:# just_clear:flags.0?true peer:InputPeer max_id:int = messages.AffectedHistory; @@ -1067,7 +1069,7 @@ messages.setInlineBotResults#eb5ea206 flags:# gallery:flags.0?true private:flags messages.sendInlineBotResult#b16e06fe flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int random_id:long query_id:long id:string = Updates; messages.getMessageEditData#fda68d36 peer:InputPeer id:int = messages.MessageEditData; messages.editMessage#5d1b8dd flags:# no_webpage:flags.1?true stop_geo_live:flags.12?true peer:InputPeer id:int message:flags.11?string reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector geo_point:flags.13?InputGeoPoint = Updates; -messages.editInlineBotMessage#130c2c85 flags:# no_webpage:flags.1?true id:InputBotInlineMessageID message:flags.11?string reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector = Bool; +messages.editInlineBotMessage#b0e08243 flags:# no_webpage:flags.1?true stop_geo_live:flags.12?true id:InputBotInlineMessageID message:flags.11?string reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector geo_point:flags.13?InputGeoPoint = Bool; messages.getBotCallbackAnswer#810a9fec flags:# game:flags.1?true peer:InputPeer msg_id:int data:flags.0?bytes = messages.BotCallbackAnswer; messages.setBotCallbackAnswer#d58f130a flags:# alert:flags.1?true query_id:long message:flags.0?string url:flags.2?string cache_time:int = Bool; messages.getPeerDialogs#2d9776b9 peers:Vector = messages.PeerDialogs; @@ -1098,9 +1100,10 @@ messages.sendScreenshotNotification#c97df020 peer:InputPeer reply_to_msg_id:int messages.getFavedStickers#21ce0b0e hash:int = messages.FavedStickers; messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; messages.getUnreadMentions#46578472 peer:InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; -messages.getRecentLocations#249431e2 peer:InputPeer limit:int = messages.Messages; messages.readMentions#f0189d3 peer:InputPeer = messages.AffectedHistory; +messages.getRecentLocations#249431e2 peer:InputPeer limit:int = messages.Messages; messages.sendMultiMedia#2095512f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int multi_media:Vector = Updates; +messages.uploadEncryptedFile#5057c497 peer:InputEncryptedChat file:InputEncryptedFile = EncryptedFile; updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; @@ -1153,7 +1156,7 @@ channels.inviteToChannel#199f3a6c channel:InputChannel users:Vector = channels.exportInvite#c7560885 channel:InputChannel = ExportedChatInvite; channels.deleteChannel#c0111fe3 channel:InputChannel = Updates; channels.toggleInvites#49609307 channel:InputChannel enabled:Bool = Updates; -channels.exportMessageLink#c846d22d channel:InputChannel id:int = ExportedMessageLink; +channels.exportMessageLink#ceb77163 channel:InputChannel id:int grouped:Bool = ExportedMessageLink; channels.toggleSignatures#1f69b606 channel:InputChannel enabled:Bool = Updates; channels.updatePinnedMessage#a72ded52 flags:# silent:flags.0?true channel:InputChannel id:int = Updates; channels.getAdminedPublicChannels#8d8d82d7 = messages.Chats; @@ -1193,4 +1196,4 @@ langpack.getStrings#2e1ee318 lang_code:string keys:Vector = Vector; -// LAYER 73 +// LAYER 74 From 33d6afa0bdef17931672806d519792a0a4578eda Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Wed, 3 Jan 2018 19:18:24 +0100 Subject: [PATCH 68/78] Add missing L74 hash parameter to .get_message_history() --- telethon/telegram_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 72f9f98b..70c2784c 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -527,7 +527,8 @@ class TelegramClient(TelegramBareClient): offset_id=offset_id, max_id=max_id, min_id=min_id, - add_offset=add_offset + add_offset=add_offset, + hash=0 )) messages.extend( m for m in result.messages if not isinstance(m, MessageEmpty) From b9cd9a66396f272216ef52feabe7a43366bd3d89 Mon Sep 17 00:00:00 2001 From: Csaba Henk Date: Tue, 2 Jan 2018 09:56:37 +0100 Subject: [PATCH 69/78] fix get_dialogs() return type in example Catching up with 238198db where get_dialogs return type was changed. --- telethon_examples/interactive_telegram_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/telethon_examples/interactive_telegram_client.py b/telethon_examples/interactive_telegram_client.py index 52c2c356..501d557b 100644 --- a/telethon_examples/interactive_telegram_client.py +++ b/telethon_examples/interactive_telegram_client.py @@ -138,15 +138,15 @@ class InteractiveTelegramClient(TelegramClient): # Entities represent the user, chat or channel # corresponding to the dialog on the same index. - dialogs, entities = self.get_dialogs(limit=dialog_count) + dialogs = self.get_dialogs(limit=dialog_count) i = None while i is None: print_title('Dialogs window') # Display them so the user can choose - for i, entity in enumerate(entities, start=1): - sprint('{}. {}'.format(i, get_display_name(entity))) + for i, dialog in enumerate(dialogs, start=1): + sprint('{}. {}'.format(i, get_display_name(dialog.entity))) # Let the user decide who they want to talk to print() @@ -177,7 +177,7 @@ class InteractiveTelegramClient(TelegramClient): i = None # Retrieve the selected user (or chat, or channel) - entity = entities[i] + entity = dialogs[i].entity # Show some information print_title('Chat with "{}"'.format(get_display_name(entity))) From 78871b697e9f6a330b5984a0d69231ca0f5aaa13 Mon Sep 17 00:00:00 2001 From: Csaba Henk Date: Tue, 2 Jan 2018 13:30:29 +0100 Subject: [PATCH 70/78] client: return the message in send_file, too --- telethon/telegram_client.py | 39 +++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 70c2784c..730f7445 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -363,6 +363,22 @@ class TelegramClient(TelegramBareClient): drafts = [Draft._from_update(self, u) for u in response.updates] return drafts + @staticmethod + def _get_response_message(request, result): + # Telegram seems to send updateMessageID first, then updateNewMessage, + # however let's not rely on that just in case. + msg_id = None + for update in result.updates: + if isinstance(update, UpdateMessageID): + if update.random_id == request.random_id: + msg_id = update.id + break + + for update in result.updates: + if isinstance(update, (UpdateNewChannelMessage, UpdateNewMessage)): + if update.message.id == msg_id: + return update.message + def send_message(self, entity, message, @@ -415,21 +431,7 @@ class TelegramClient(TelegramBareClient): entities=result.entities ) - # Telegram seems to send updateMessageID first, then updateNewMessage, - # however let's not rely on that just in case. - msg_id = None - for update in result.updates: - if isinstance(update, UpdateMessageID): - if update.random_id == request.random_id: - msg_id = update.id - break - - for update in result.updates: - if isinstance(update, (UpdateNewChannelMessage, UpdateNewMessage)): - if update.message.id == msg_id: - return update.message - - return None # Should not happen + return self._get_response_message(request, result) def delete_messages(self, entity, message_ids, revoke=True): """ @@ -723,11 +725,14 @@ class TelegramClient(TelegramBareClient): # Once the media type is properly specified and the file uploaded, # send the media message to the desired entity. - self(SendMediaRequest( + request = SendMediaRequest( peer=self.get_input_entity(entity), media=media, reply_to_msg_id=self._get_reply_to(reply_to) - )) + ) + result = self(request) + + return self._get_response_message(request, result) def send_voice_note(self, entity, file, caption='', upload_progress=None, reply_to=None): From 2c437c51bb672cd1eaa4155b205dcfa20f535eb8 Mon Sep 17 00:00:00 2001 From: Csaba Henk Date: Wed, 3 Jan 2018 12:47:38 +0100 Subject: [PATCH 71/78] client: add thumbnail support for send_file() --- telethon/telegram_client.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 730f7445..3be2ac62 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -631,6 +631,7 @@ class TelegramClient(TelegramBareClient): force_document=False, progress_callback=None, reply_to=None, attributes=None, + thumb=None, **kwargs): """ Sends a file to the specified entity. @@ -658,6 +659,8 @@ class TelegramClient(TelegramBareClient): :param attributes: Optional attributes that override the inferred ones, like DocumentAttributeFilename and so on. + :param thumb: + Optional thumbnail (for videos). :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. @@ -716,11 +719,16 @@ class TelegramClient(TelegramBareClient): if not mime_type: mime_type = 'application/octet-stream' + input_kw = {} + if thumb: + input_kw['thumb'] = self.upload_file(thumb) + media = InputMediaUploadedDocument( file=file_handle, mime_type=mime_type, attributes=list(attr_dict.values()), - caption=caption + caption=caption, + **input_kw ) # Once the media type is properly specified and the file uploaded, From 4fba27dee9a6fdb31b85567f97582bbcd629bb00 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 4 Jan 2018 15:33:48 +0100 Subject: [PATCH 72/78] Accept InputFile/InputFileBig on .upload_file for 2c437c51 Now an input file thumbnail can also be specified, instead needing to reupload the file every time. --- telethon/telegram_bare_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index 036aafd2..28bd3334 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -624,6 +624,9 @@ class TelegramBareClient: part_size_kb = get_appropriated_part_size(file_size) file_name = os.path.basename(file_path) """ + if isinstance(file, (InputFile, InputFileBig)): + return file # Already uploaded + if isinstance(file, str): file_size = os.path.getsize(file) elif isinstance(file, bytes): From b45b63d71d06b2b011f9534d523c3c2bd949b8ec Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 4 Jan 2018 21:07:29 +0100 Subject: [PATCH 73/78] Assert ._first_request is True with None auth_key (#517) --- telethon/telegram_bare_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index 28bd3334..8a0c43d9 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -507,6 +507,7 @@ class TelegramBareClient: if not self.session.auth_key: __log__.info('Need to generate new auth key before invoking') + self._first_request = True self.session.auth_key, self.session.time_offset = \ authenticator.do_authentication(self._sender.connection) From 6cb5931e7a71467cc6e48c9f6f947bb0b6279312 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Thu, 4 Jan 2018 23:37:47 +0100 Subject: [PATCH 74/78] Call .disconnect() on client.__del__ for convenience --- telethon/telegram_bare_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index 8a0c43d9..93453322 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -270,6 +270,9 @@ class TelegramBareClient: # TODO Shall we clear the _exported_sessions, or may be reused? self._first_request = True # On reconnect it will be first again + def __del__(self): + self.disconnect() + def _reconnect(self, new_dc=None): """If 'new_dc' is not set, only a call to .connect() will be made since it's assumed that the connection has been lost and the From cb45e8fca991b13e7e81cbef524ef0b34e5a4ed8 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 5 Jan 2018 00:59:53 +0100 Subject: [PATCH 75/78] Clean up and complete RTD documentation --- .../accessing-the-full-api.rst | 71 ++-- readthedocs/extra/advanced-usage/sessions.rst | 46 +++ .../extra/advanced-usage/signing-in.rst | 58 ---- .../extra/advanced-usage/users-and-chats.rst | 324 ------------------ readthedocs/extra/advanced.rst | 48 --- readthedocs/extra/basic/creating-a-client.rst | 111 +++++- readthedocs/extra/basic/entities.rst | 87 +++++ readthedocs/extra/basic/getting-started.rst | 17 +- readthedocs/extra/basic/installation.rst | 39 +-- readthedocs/extra/basic/sending-requests.rst | 55 --- readthedocs/extra/basic/sessions.rst | 48 --- readthedocs/extra/basic/telegram-client.rst | 99 ++++++ .../extra/basic/working-with-updates.rst | 65 ++-- readthedocs/extra/developing/api-status.rst | 54 +++ readthedocs/extra/developing/coding-style.rst | 22 ++ readthedocs/extra/developing/philosophy.rst | 25 ++ .../extra/developing/project-structure.rst | 43 +++ .../telegram-api-in-other-languages.rst | 64 ++++ readthedocs/extra/developing/test-servers.rst | 32 ++ .../tips-for-porting-the-project.rst | 17 + .../understanding-the-type-language.rst | 35 ++ .../{advanced-usage => examples}/bots.rst | 22 +- .../extra/examples/chats-and-channels.rst | 205 +++++++++++ .../working-with-messages.rst | 57 +-- ...eleted-limited-or-deactivated-accounts.rst | 6 +- .../extra/troubleshooting/enable-logging.rst | 28 +- .../extra/troubleshooting/rpc-errors.rst | 10 +- readthedocs/extra/wall-of-shame.rst | 57 +++ readthedocs/index.rst | 53 ++- 29 files changed, 1096 insertions(+), 702 deletions(-) rename readthedocs/extra/{basic => advanced-usage}/accessing-the-full-api.rst (58%) create mode 100644 readthedocs/extra/advanced-usage/sessions.rst delete mode 100644 readthedocs/extra/advanced-usage/signing-in.rst delete mode 100644 readthedocs/extra/advanced-usage/users-and-chats.rst delete mode 100644 readthedocs/extra/advanced.rst create mode 100644 readthedocs/extra/basic/entities.rst delete mode 100644 readthedocs/extra/basic/sending-requests.rst delete mode 100644 readthedocs/extra/basic/sessions.rst create mode 100644 readthedocs/extra/basic/telegram-client.rst create mode 100644 readthedocs/extra/developing/api-status.rst create mode 100644 readthedocs/extra/developing/coding-style.rst create mode 100644 readthedocs/extra/developing/philosophy.rst create mode 100644 readthedocs/extra/developing/project-structure.rst create mode 100644 readthedocs/extra/developing/telegram-api-in-other-languages.rst create mode 100644 readthedocs/extra/developing/test-servers.rst create mode 100644 readthedocs/extra/developing/tips-for-porting-the-project.rst create mode 100644 readthedocs/extra/developing/understanding-the-type-language.rst rename readthedocs/extra/{advanced-usage => examples}/bots.rst (77%) create mode 100644 readthedocs/extra/examples/chats-and-channels.rst rename readthedocs/extra/{advanced-usage => examples}/working-with-messages.rst (65%) create mode 100644 readthedocs/extra/wall-of-shame.rst diff --git a/readthedocs/extra/basic/accessing-the-full-api.rst b/readthedocs/extra/advanced-usage/accessing-the-full-api.rst similarity index 58% rename from readthedocs/extra/basic/accessing-the-full-api.rst rename to readthedocs/extra/advanced-usage/accessing-the-full-api.rst index ab6682db..04659bdb 100644 --- a/readthedocs/extra/basic/accessing-the-full-api.rst +++ b/readthedocs/extra/advanced-usage/accessing-the-full-api.rst @@ -1,33 +1,41 @@ .. _accessing-the-full-api: -========================== +====================== Accessing the Full API -========================== +====================== -The ``TelegramClient`` doesn’t offer a method for every single request -the Telegram API supports. However, it’s very simple to ``.invoke()`` -any request. Whenever you need something, don’t forget to `check the + +The ``TelegramClient`` doesn't offer a method for every single request +the Telegram API supports. However, it's very simple to *call* or *invoke* +any request. Whenever you need something, don't forget to `check the documentation`__ and look for the `method you need`__. There you can go through a sorted list of everything you can do. + +.. note:: + + Removing the hand crafted documentation for methods is still + a work in progress! + + You should also refer to the documentation to see what the objects (constructors) Telegram returns look like. Every constructor inherits -from a common type, and that’s the reason for this distinction. +from a common type, and that's the reason for this distinction. -Say ``client.send_message()`` didn’t exist, we could use the `search`__ -to look for “message”. There we would find `SendMessageRequest`__, +Say ``client.send_message()`` didn't exist, we could use the `search`__ +to look for "message". There we would find `SendMessageRequest`__, which we can work with. Every request is a Python class, and has the parameters needed for you to invoke it. You can also call ``help(request)`` for information on -what input parameters it takes. Remember to “Copy import to the -clipboard”, or your script won’t be aware of this class! Now we have: +what input parameters it takes. Remember to "Copy import to the +clipboard", or your script won't be aware of this class! Now we have: .. code-block:: python from telethon.tl.functions.messages import SendMessageRequest -If you’re going to use a lot of these, you may do: +If you're going to use a lot of these, you may do: .. code-block:: python @@ -53,20 +61,20 @@ Or we call ``.get_input_entity()``: peer = client.get_input_entity('someone') -When you’re going to invoke an API method, most require you to pass an +When you're going to invoke an API method, most require you to pass an ``InputUser``, ``InputChat``, or so on, this is why using -``.get_input_entity()`` is more straightforward (and sometimes -immediate, if you know the ID of the user for instance). If you also -need to have information about the whole user, use ``.get_entity()`` -instead: +``.get_input_entity()`` is more straightforward (and often +immediate, if you've seen the user before, know their ID, etc.). +If you also need to have information about the whole user, use +``.get_entity()`` instead: .. code-block:: python entity = client.get_entity('someone') In the later case, when you use the entity, the library will cast it to -its “input” version for you. If you already have the complete user and -want to cache its input version so the library doesn’t have to do this +its "input" version for you. If you already have the complete user and +want to cache its input version so the library doesn't have to do this every time its used, simply call ``.get_input_peer``: .. code-block:: python @@ -83,10 +91,9 @@ request we do: result = client(SendMessageRequest(peer, 'Hello there!')) # __call__ is an alias for client.invoke(request). Both will work -Message sent! Of course, this is only an example. -There are nearly 250 methods available as of layer 73, -and you can use every single of them as you wish. -Remember to use the right types! To sum up: +Message sent! Of course, this is only an example. There are nearly 250 +methods available as of layer 73, and you can use every single of them +as you wish. Remember to use the right types! To sum up: .. code-block:: python @@ -97,16 +104,16 @@ Remember to use the right types! To sum up: .. note:: - Note that some requests have a "hash" parameter. This is **not** your ``api_hash``! - It likely isn't your self-user ``.access_hash`` either. - It's a special hash used by Telegram to only send a difference of new data - that you don't already have with that request, - so you can leave it to 0, and it should work (which means no hash is known yet). + Note that some requests have a "hash" parameter. This is **not** + your ``api_hash``! It likely isn't your self-user ``.access_hash`` either. - For those requests having a "limit" parameter, - you can often set it to zero to signify "return as many items as possible". - This won't work for all of them though, - for instance, in "messages.search" it will actually return 0 items. + It's a special hash used by Telegram to only send a difference of new data + that you don't already have with that request, so you can leave it to 0, + and it should work (which means no hash is known yet). + + For those requests having a "limit" parameter, you can often set it to + zero to signify "return default amount". This won't work for all of them + though, for instance, in "messages.search" it will actually return 0 items. __ https://lonamiwebs.github.io/Telethon @@ -114,4 +121,4 @@ __ https://lonamiwebs.github.io/Telethon/methods/index.html __ https://lonamiwebs.github.io/Telethon/?q=message __ https://lonamiwebs.github.io/Telethon/methods/messages/send_message.html __ https://lonamiwebs.github.io/Telethon/types/input_peer.html -__ https://lonamiwebs.github.io/Telethon/constructors/input_peer_user.html \ No newline at end of file +__ https://lonamiwebs.github.io/Telethon/constructors/input_peer_user.html diff --git a/readthedocs/extra/advanced-usage/sessions.rst b/readthedocs/extra/advanced-usage/sessions.rst new file mode 100644 index 00000000..7f1ded9b --- /dev/null +++ b/readthedocs/extra/advanced-usage/sessions.rst @@ -0,0 +1,46 @@ +.. _sessions: + +============== +Session Files +============== + +The first parameter you pass the the constructor of the ``TelegramClient`` is +the ``session``, and defaults to be the session name (or full path). That is, +if you create a ``TelegramClient('anon')`` instance and connect, an +``anon.session`` file will be created on the working directory. + +These database files using ``sqlite3`` contain the required information to +talk to the Telegram servers, such as to which IP the client should connect, +port, authorization key so that messages can be encrypted, and so on. + +These files will by default also save all the input entities that you've seen, +so that you can get information about an user or channel by just their ID. +Telegram will **not** send their ``access_hash`` required to retrieve more +information about them, if it thinks you have already seem them. For this +reason, the library needs to store this information offline. + +The library will by default too save all the entities (chats and channels +with their name and username, and users with the phone too) in the session +file, so that you can quickly access them by username or phone number. + +If you're not going to work with updates, or don't need to cache the +``access_hash`` associated with the entities' ID, you can disable this +by setting ``client.session.save_entities = False``, or pass it as a +parameter to the ``TelegramClient``. + +If you don't want to save the files as a database, you can also create +your custom ``Session`` subclass and override the ``.save()`` and ``.load()`` +methods. For example, you could save it on a database: + + .. code-block:: python + + class DatabaseSession(Session): + def save(): + # serialize relevant data to the database + + def load(): + # load relevant data to the database + + +You should read the ````session.py```` source file to know what "relevant +data" you need to keep track of. diff --git a/readthedocs/extra/advanced-usage/signing-in.rst b/readthedocs/extra/advanced-usage/signing-in.rst deleted file mode 100644 index 08f4fe3d..00000000 --- a/readthedocs/extra/advanced-usage/signing-in.rst +++ /dev/null @@ -1,58 +0,0 @@ -========================= -Signing In -========================= - -.. note:: - Make sure you have gone through :ref:`prelude` already! - - -Two Factor Authorization (2FA) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -If you have Two Factor Authorization (from now on, 2FA) enabled on your account, calling -:meth:`telethon.TelegramClient.sign_in` will raise a `SessionPasswordNeededError`. -When this happens, just :meth:`telethon.TelegramClient.sign_in` again with a ``password=``: - - .. code-block:: python - - import getpass - from telethon.errors import SessionPasswordNeededError - - client.sign_in(phone) - try: - client.sign_in(code=input('Enter code: ')) - except SessionPasswordNeededError: - client.sign_in(password=getpass.getpass()) - -Enabling 2FA -************* - -If you don't have 2FA enabled, but you would like to do so through Telethon, take as example the following code snippet: - - .. code-block:: python - - import os - from hashlib import sha256 - from telethon.tl.functions import account - from telethon.tl.types.account import PasswordInputSettings - - new_salt = client(account.GetPasswordRequest()).new_salt - salt = new_salt + os.urandom(8) # new random salt - - pw = 'secret'.encode('utf-8') # type your new password here - hint = 'hint' - - pw_salted = salt + pw + salt - pw_hash = sha256(pw_salted).digest() - - result = client(account.UpdatePasswordSettingsRequest( - current_password_hash=salt, - new_settings=PasswordInputSettings( - new_salt=salt, - new_password_hash=pw_hash, - hint=hint - ) - )) - -Thanks to `Issue 259 `_ for the tip! - diff --git a/readthedocs/extra/advanced-usage/users-and-chats.rst b/readthedocs/extra/advanced-usage/users-and-chats.rst deleted file mode 100644 index a48a2857..00000000 --- a/readthedocs/extra/advanced-usage/users-and-chats.rst +++ /dev/null @@ -1,324 +0,0 @@ -========================= -Users and Chats -========================= - -.. note:: - Make sure you have gone through :ref:`prelude` already! - -.. contents:: - :depth: 2 - -.. _retrieving-an-entity: - -Retrieving an entity (user or group) -************************************** -An “entity” is used to refer to either an `User`__ or a `Chat`__ -(which includes a `Channel`__). The most straightforward way to get -an entity is to use ``TelegramClient.get_entity()``. This method accepts -either a string, which can be a username, phone number or `t.me`__-like -link, or an integer that will be the ID of an **user**. You can use it -like so: - - .. code-block:: python - - # all of these work - lonami = client.get_entity('lonami') - lonami = client.get_entity('t.me/lonami') - lonami = client.get_entity('https://telegram.dog/lonami') - - # other kind of entities - channel = client.get_entity('telegram.me/joinchat/AAAAAEkk2WdoDrB4-Q8-gg') - contact = client.get_entity('+34xxxxxxxxx') - friend = client.get_entity(friend_id) - -For the last one to work, the library must have “seen” the user at least -once. The library will “see” the user as long as any request contains -them, so if you’ve called ``.get_dialogs()`` for instance, and your -friend was there, the library will know about them. For more, read about -the :ref:`sessions`. - -If you want to get a channel or chat by ID, you need to specify that -they are a channel or a chat. The library can’t infer what they are by -just their ID (unless the ID is marked, but this is only done -internally), so you need to wrap the ID around a `Peer`__ object: - - .. code-block:: python - - from telethon.tl.types import PeerUser, PeerChat, PeerChannel - my_user = client.get_entity(PeerUser(some_id)) - my_chat = client.get_entity(PeerChat(some_id)) - my_channel = client.get_entity(PeerChannel(some_id)) - -**Note** that most requests don’t ask for an ``User``, or a ``Chat``, -but rather for ``InputUser``, ``InputChat``, and so on. If this is the -case, you should prefer ``.get_input_entity()`` over ``.get_entity()``, -as it will be immediate if you provide an ID (whereas ``.get_entity()`` -may need to find who the entity is first). - -Via your open “chats” (dialogs) -------------------------------- - -.. note:: - Please read here: :ref:`retrieving-all-dialogs`. - -Via ResolveUsernameRequest --------------------------- - -This is the request used by ``.get_entity`` internally, but you can also -use it by hand: - -.. code-block:: python - - from telethon.tl.functions.contacts import ResolveUsernameRequest - - result = client(ResolveUsernameRequest('username')) - found_chats = result.chats - found_users = result.users - # result.peer may be a PeerUser, PeerChat or PeerChannel - -See `Peer`__ for more information about this result. - -Via MessageFwdHeader --------------------- - -If all you have is a `MessageFwdHeader`__ after you retrieved a bunch -of messages, this gives you access to the ``from_id`` (if forwarded from -an user) and ``channel_id`` (if forwarded from a channel). Invoking -`GetMessagesRequest`__ also returns a list of ``chats`` and -``users``, and you can find the desired entity there: - - .. code-block:: python - - # Logic to retrieve messages with `GetMessagesRequest´ - messages = foo() - fwd_header = bar() - - user = next(u for u in messages.users if u.id == fwd_header.from_id) - channel = next(c for c in messages.chats if c.id == fwd_header.channel_id) - -Or you can just call ``.get_entity()`` with the ID, as you should have -seen that user or channel before. A call to ``GetMessagesRequest`` may -still be neeed. - -Via GetContactsRequest ----------------------- - -The library will call this for you if you pass a phone number to -``.get_entity``, but again, it can be done manually. If the user you -want to talk to is a contact, you can use `GetContactsRequest`__: - - .. code-block:: python - - from telethon.tl.functions.contacts import GetContactsRequest - from telethon.tl.types.contacts import Contacts - - contacts = client(GetContactsRequest(0)) - if isinstance(contacts, Contacts): - users = contacts.users - contacts = contacts.contacts - -__ https://lonamiwebs.github.io/Telethon/types/user.html -__ https://lonamiwebs.github.io/Telethon/types/chat.html -__ https://lonamiwebs.github.io/Telethon/constructors/channel.html -__ https://t.me -__ https://lonamiwebs.github.io/Telethon/types/peer.html -__ https://lonamiwebs.github.io/Telethon/types/peer.html -__ https://lonamiwebs.github.io/Telethon/constructors/message_fwd_header.html -__ https://lonamiwebs.github.io/Telethon/methods/messages/get_messages.html -__ https://lonamiwebs.github.io/Telethon/methods/contacts/get_contacts.html - - -.. _retrieving-all-dialogs: - -Retrieving all dialogs -*********************** - -There are several ``offset_xyz=`` parameters that have no effect at all, -but there's not much one can do since this is something the server should handle. -Currently, the only way to get all dialogs -(open chats, conversations, etc.) is by using the ``offset_date``: - - .. code-block:: python - - from telethon.tl.functions.messages import GetDialogsRequest - from telethon.tl.types import InputPeerEmpty - from time import sleep - - dialogs = [] - users = [] - chats = [] - - last_date = None - chunk_size = 20 - while True: - result = client(GetDialogsRequest( - offset_date=last_date, - offset_id=0, - offset_peer=InputPeerEmpty(), - limit=chunk_size - )) - dialogs.extend(result.dialogs) - users.extend(result.users) - chats.extend(result.chats) - if not result.messages: - break - last_date = min(msg.date for msg in result.messages) - sleep(2) - - -Joining a chat or channel -******************************* - -Note that `Chat`__\ s are normal groups, and `Channel`__\ s are a -special form of `Chat`__\ s, -which can also be super-groups if their ``megagroup`` member is -``True``. - -Joining a public channel ------------------------- - -Once you have the :ref:`entity ` -of the channel you want to join to, you can -make use of the `JoinChannelRequest`__ to join such channel: - - .. code-block:: python - - from telethon.tl.functions.channels import JoinChannelRequest - client(JoinChannelRequest(channel)) - - # In the same way, you can also leave such channel - from telethon.tl.functions.channels import LeaveChannelRequest - client(LeaveChannelRequest(input_channel)) - -For more on channels, check the `channels namespace`__. - -Joining a private chat or channel ---------------------------------- - -If all you have is a link like this one: -``https://t.me/joinchat/AAAAAFFszQPyPEZ7wgxLtd``, you already have -enough information to join! The part after the -``https://t.me/joinchat/``, this is, ``AAAAAFFszQPyPEZ7wgxLtd`` on this -example, is the ``hash`` of the chat or channel. Now you can use -`ImportChatInviteRequest`__ as follows: - - .. -block:: python - - from telethon.tl.functions.messages import ImportChatInviteRequest - updates = client(ImportChatInviteRequest('AAAAAEHbEkejzxUjAUCfYg')) - -Adding someone else to such chat or channel -------------------------------------------- - -If you don’t want to add yourself, maybe because you’re already in, you -can always add someone else with the `AddChatUserRequest`__, which -use is very straightforward: - - .. code-block:: python - - from telethon.tl.functions.messages import AddChatUserRequest - - client(AddChatUserRequest( - chat_id, - user_to_add, - fwd_limit=10 # allow the user to see the 10 last messages - )) - -Checking a link without joining -------------------------------- - -If you don’t need to join but rather check whether it’s a group or a -channel, you can use the `CheckChatInviteRequest`__, which takes in -the `hash`__ of said channel or group. - -__ https://lonamiwebs.github.io/Telethon/constructors/chat.html -__ https://lonamiwebs.github.io/Telethon/constructors/channel.html -__ https://lonamiwebs.github.io/Telethon/types/chat.html -__ https://lonamiwebs.github.io/Telethon/methods/channels/join_channel.html -__ https://lonamiwebs.github.io/Telethon/methods/channels/index.html -__ https://lonamiwebs.github.io/Telethon/methods/messages/import_chat_invite.html -__ https://lonamiwebs.github.io/Telethon/methods/messages/add_chat_user.html -__ https://lonamiwebs.github.io/Telethon/methods/messages/check_chat_invite.html -__ https://github.com/LonamiWebs/Telethon/wiki/Joining-a-chat-or-channel#joining-a-private-chat-or-channel - - -Retrieving all chat members (channels too) -****************************************** - -In order to get all the members from a mega-group or channel, you need -to use `GetParticipantsRequest`__. As we can see it needs an -`InputChannel`__, (passing the mega-group or channel you’re going to -use will work), and a mandatory `ChannelParticipantsFilter`__. The -closest thing to “no filter” is to simply use -`ChannelParticipantsSearch`__ with an empty ``'q'`` string. - -If we want to get *all* the members, we need to use a moving offset and -a fixed limit: - - .. code-block:: python - - from telethon.tl.functions.channels import GetParticipantsRequest - from telethon.tl.types import ChannelParticipantsSearch - from time import sleep - - offset = 0 - limit = 100 - all_participants = [] - - while True: - participants = client.invoke(GetParticipantsRequest( - channel, ChannelParticipantsSearch(''), offset, limit - )) - if not participants.users: - break - all_participants.extend(participants.users) - offset += len(participants.users) - # sleep(1) # This line seems to be optional, no guarantees! - -Note that ``GetParticipantsRequest`` returns `ChannelParticipants`__, -which may have more information you need (like the role of the -participants, total count of members, etc.) - -__ https://lonamiwebs.github.io/Telethon/methods/channels/get_participants.html -__ https://lonamiwebs.github.io/Telethon/methods/channels/get_participants.html -__ https://lonamiwebs.github.io/Telethon/types/channel_participants_filter.html -__ https://lonamiwebs.github.io/Telethon/constructors/channel_participants_search.html -__ https://lonamiwebs.github.io/Telethon/constructors/channels/channel_participants.html - - -Recent Actions -******************** - -“Recent actions” is simply the name official applications have given to -the “admin log”. Simply use `GetAdminLogRequest`__ for that, and -you’ll get AdminLogResults.events in return which in turn has the final -`.action`__. - -__ https://lonamiwebs.github.io/Telethon/methods/channels/get_admin_log.html -__ https://lonamiwebs.github.io/Telethon/types/channel_admin_log_event_action.html - - -Increasing View Count in a Channel -**************************************** - -It has been asked `quite`__ `a few`__ `times`__ (really, `many`__), and -while I don’t understand why so many people ask this, the solution is to -use `GetMessagesViewsRequest`__, setting ``increment=True``: - - .. code-block:: python - - - # Obtain `channel' through dialogs or through client.get_entity() or anyhow. - # Obtain `msg_ids' through `.get_message_history()` or anyhow. Must be a list. - - client(GetMessagesViewsRequest( - peer=channel, - id=msg_ids, - increment=True - )) - -__ https://github.com/LonamiWebs/Telethon/issues/233 -__ https://github.com/LonamiWebs/Telethon/issues/305 -__ https://github.com/LonamiWebs/Telethon/issues/409 -__ https://github.com/LonamiWebs/Telethon/issues/447 -__ https://lonamiwebs.github.io/Telethon/methods/messages/get_messages_views.html \ No newline at end of file diff --git a/readthedocs/extra/advanced.rst b/readthedocs/extra/advanced.rst deleted file mode 100644 index 4433116d..00000000 --- a/readthedocs/extra/advanced.rst +++ /dev/null @@ -1,48 +0,0 @@ -.. _prelude: - -Prelude ---------- - -Before reading any specific example, make sure to read the following common steps: - -All the examples assume that you have successfully created a client and you're authorized as follows: - - .. code-block:: python - - from telethon import TelegramClient - - # Use your own values here - api_id = 12345 - api_hash = '0123456789abcdef0123456789abcdef' - phone_number = '+34600000000' - - client = TelegramClient('some_name', api_id, api_hash) - client.connect() # Must return True, otherwise, try again - - if not client.is_user_authorized(): - client.send_code_request(phone_number) - # .sign_in() may raise PhoneNumberUnoccupiedError - # In that case, you need to call .sign_up() to get a new account - client.sign_in(phone_number, input('Enter code: ')) - - # The `client´ is now ready - -Although Python will probably clean up the resources used by the ``TelegramClient``, -you should always ``.disconnect()`` it once you're done: - - .. code-block:: python - - try: - # Code using the client goes here - except: - # No matter what happens, always disconnect in the end - client.disconnect() - -If the examples aren't enough, you're strongly advised to read the source code -for the InteractiveTelegramClient_ for an overview on how you could build your next script. -This example shows a basic usage more than enough in most cases. Even reading the source -for the TelegramClient_ may help a lot! - - -.. _InteractiveTelegramClient: https://github.com/LonamiWebs/Telethon/blob/master/telethon_examples/interactive_telegram_client.py -.. _TelegramClient: https://github.com/LonamiWebs/Telethon/blob/master/telethon/telegram_client.py diff --git a/readthedocs/extra/basic/creating-a-client.rst b/readthedocs/extra/basic/creating-a-client.rst index 997386db..58f36125 100644 --- a/readthedocs/extra/basic/creating-a-client.rst +++ b/readthedocs/extra/basic/creating-a-client.rst @@ -1,24 +1,28 @@ .. _creating-a-client: -=================== +================= Creating a Client -=================== +================= + Before working with Telegram's API, you need to get your own API ID and hash: -1. Follow `this link `_ and login with your phone number. +1. Follow `this link `_ and login with your + phone number. 2. Click under API Development tools. -3. A *Create new application* window will appear. Fill in your application details. -There is no need to enter any *URL*, and only the first two fields (*App title* and *Short name*) -can be changed later as far as I'm aware. +3. A *Create new application* window will appear. Fill in your application + details. There is no need to enter any *URL*, and only the first two + fields (*App title* and *Short name*) can currently be changed later. -4. Click on *Create application* at the end. Remember that your **API hash is secret** -and Telegram won't let you revoke it. Don't post it anywhere! +4. Click on *Create application* at the end. Remember that your + **API hash is secret** and Telegram won't let you revoke it. + Don't post it anywhere! Once that's ready, the next step is to create a ``TelegramClient``. -This class will be your main interface with Telegram's API, and creating one is very simple: +This class will be your main interface with Telegram's API, and creating +one is very simple: .. code-block:: python @@ -31,14 +35,18 @@ This class will be your main interface with Telegram's API, and creating one is client = TelegramClient('some_name', api_id, api_hash) -Note that ``'some_name'`` will be used to save your session (persistent information such as access key and others) -as ``'some_name.session'`` in your disk. This is simply a JSON file which you can (but shouldn't) modify. -Before using the client, you must be connected to Telegram. Doing so is very easy: +Note that ``'some_name'`` will be used to save your session (persistent +information such as access key and others) as ``'some_name.session'`` in +your disk. This is by default a database file using Python's ``sqlite3``. + +Before using the client, you must be connected to Telegram. +Doing so is very easy: ``client.connect() # Must return True, otherwise, try again`` -You may or may not be authorized yet. You must be authorized before you're able to send any request: +You may or may not be authorized yet. You must be authorized +before you're able to send any request: ``client.is_user_authorized() # Returns True if you can send requests`` @@ -52,13 +60,25 @@ If you're not authorized, you need to ``.sign_in()``: # If .sign_in raises SessionPasswordNeeded error, call .sign_in(password=...) # You can import both exceptions from telethon.errors. -``myself`` is your Telegram user. -You can view all the information about yourself by doing ``print(myself.stringify())``. -You're now ready to use the client as you wish! +``myself`` is your Telegram user. You can view all the information about +yourself by doing ``print(myself.stringify())``. You're now ready to use +the client as you wish! Remember that any object returned by the API has +mentioned ``.stringify()`` method, and printing these might prove useful. + +As a full example: + + .. code-block:: python + + client = TelegramClient('anon', api_id, api_hash) + assert client.connect() + if not client.is_user_authorized(): + client.send_code_request(phone_number) + me = client.sign_in(phone_number, input('Enter code: ')) + .. note:: - If you want to use a **proxy**, you have to `install PySocks`__ (via pip or manual) - and then set the appropriated parameters: + If you want to use a **proxy**, you have to `install PySocks`__ + (via pip or manual) and then set the appropriated parameters: .. code-block:: python @@ -72,5 +92,58 @@ You're now ready to use the client as you wish! consisting of parameters described `here`__. + +Two Factor Authorization (2FA) +****************************** + +If you have Two Factor Authorization (from now on, 2FA) enabled on your +account, calling :meth:`telethon.TelegramClient.sign_in` will raise a +`SessionPasswordNeededError`. When this happens, just +:meth:`telethon.TelegramClient.sign_in` again with a ``password=``: + + .. code-block:: python + + import getpass + from telethon.errors import SessionPasswordNeededError + + client.sign_in(phone) + try: + client.sign_in(code=input('Enter code: ')) + except SessionPasswordNeededError: + client.sign_in(password=getpass.getpass()) + + +If you don't have 2FA enabled, but you would like to do so through Telethon, +take as example the following code snippet: + + .. code-block:: python + + import os + from hashlib import sha256 + from telethon.tl.functions import account + from telethon.tl.types.account import PasswordInputSettings + + new_salt = client(account.GetPasswordRequest()).new_salt + salt = new_salt + os.urandom(8) # new random salt + + pw = 'secret'.encode('utf-8') # type your new password here + hint = 'hint' + + pw_salted = salt + pw + salt + pw_hash = sha256(pw_salted).digest() + + result = client(account.UpdatePasswordSettingsRequest( + current_password_hash=salt, + new_settings=PasswordInputSettings( + new_salt=salt, + new_password_hash=pw_hash, + hint=hint + ) + )) + +Thanks to `Issue 259 `_ +for the tip! + + __ https://github.com/Anorov/PySocks#installation -__ https://github.com/Anorov/PySocks#usage-1%3E \ No newline at end of file +__ https://github.com/Anorov/PySocks#usage-1%3E diff --git a/readthedocs/extra/basic/entities.rst b/readthedocs/extra/basic/entities.rst new file mode 100644 index 00000000..c03ec6ce --- /dev/null +++ b/readthedocs/extra/basic/entities.rst @@ -0,0 +1,87 @@ +========================= +Users, Chats and Channels +========================= + + +Introduction +************ + +The library widely uses the concept of "entities". An entity will refer +to any ``User``, ``Chat`` or ``Channel`` object that the API may return +in response to certain methods, such as ``GetUsersRequest``. + +To save bandwidth, the API also makes use of their "input" versions. +The input version of an entity (e.g. ``InputPeerUser``, ``InputChat``, +etc.) only contains the minimum required information that's required +for Telegram to be able to identify who you're referring to: their ID +and hash. This ID/hash pair is unique per user, so if you use the pair +given by another user **or bot** it will **not** work. + +To save *even more* bandwidth, the API also makes use of the ``Peer`` +versions, which just have an ID. This serves to identify them, but +peers alone are not enough to use them. You need to know their hash +before you can "use them". + +Luckily, the library tries to simplify this mess the best it can. + + +Getting entities +**************** + +Through the use of the :ref:`sessions`, the library will automatically +remember the ID and hash pair, along with some extra information, so +you're able to just do this: + + .. code-block:: python + + # dialogs are the "conversations you have open" + # this method returns a list of Dialog, which + # have the .entity attribute and other information. + dialogs = client.get_dialogs(limit=200) + + # all of these work and do the same + lonami = client.get_entity('lonami') + lonami = client.get_entity('t.me/lonami') + lonami = client.get_entity('https://telegram.dog/lonami') + + # other kind of entities + channel = client.get_entity('telegram.me/joinchat/AAAAAEkk2WdoDrB4-Q8-gg') + contact = client.get_entity('+34xxxxxxxxx') + friend = client.get_entity(friend_id) + + # using peers/input peers (note that the API may return these) + # users, chats and channels may all have the same ID, so it's + # necessary to wrap (at least) chat and channels inside Peer. + from telethon.tl.types import PeerUser, PeerChat, PeerChannel + my_user = client.get_entity(PeerUser(some_id)) + my_chat = client.get_entity(PeerChat(some_id)) + my_channel = client.get_entity(PeerChannel(some_id)) + + +All methods in the :ref:`telegram-client` call ``.get_entity()`` to further +save you from the hassle of doing so manually, so doing things like +``client.send_message('lonami', 'hi!')`` is possible. + +Every entity the library "sees" (in any response to any call) will by +default be cached in the ``.session`` file, to avoid performing +unnecessary API calls. If the entity cannot be found, some calls +like ``ResolveUsernameRequest`` or ``GetContactsRequest`` may be +made to obtain the required information. + + +Entities vs. Input Entities +*************************** + +As we mentioned before, API calls don't need to know the whole information +about the entities, only their ID and hash. For this reason, another method, +``.get_input_entity()`` is available. This will always use the cache while +possible, making zero API calls most of the time. When a request is made, +if you provided the full entity, e.g. an ``User``, the library will convert +it to the required ``InputPeer`` automatically for you. + +**You should always favour ``.get_input_entity()``** over ``.get_entity()`` +for this reason! Calling the latter will always make an API call to get +the most recent information about said entity, but invoking requests don't +need this information, just the ``InputPeer``. Only use ``.get_entity()`` +if you need to get actual information, like the username, name, title, etc. +of the entity. diff --git a/readthedocs/extra/basic/getting-started.rst b/readthedocs/extra/basic/getting-started.rst index bad3ea30..de0b3baf 100644 --- a/readthedocs/extra/basic/getting-started.rst +++ b/readthedocs/extra/basic/getting-started.rst @@ -3,13 +3,13 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. +=============== +Getting Started +=============== -================= -Getting Started! -================= Simple Installation -********************* +******************* ``pip install telethon`` @@ -17,7 +17,7 @@ Simple Installation Creating a client -************** +***************** .. code-block:: python @@ -39,8 +39,9 @@ Creating a client **More details**: :ref:`creating-a-client` -Simple Stuff -************** +Basic Usage +*********** + .. code-block:: python print(me.stringify()) @@ -52,3 +53,5 @@ Simple Stuff total, messages, senders = client.get_message_history('username') client.download_media(messages[0]) + **More details**: :ref:`telegram-client` + diff --git a/readthedocs/extra/basic/installation.rst b/readthedocs/extra/basic/installation.rst index ecad699b..03aed393 100644 --- a/readthedocs/extra/basic/installation.rst +++ b/readthedocs/extra/basic/installation.rst @@ -1,18 +1,20 @@ .. _installation: -================= +============ Installation -================= +============ Automatic Installation -^^^^^^^^^^^^^^^^^^^^^^^ +********************** + To install Telethon, simply do: ``pip install telethon`` -If you get something like ``"SyntaxError: invalid syntax"`` or any other error while installing, -it's probably because ``pip`` defaults to Python 2, which is not supported. Use ``pip3`` instead. +If you get something like ``"SyntaxError: invalid syntax"`` or any other +error while installing/importing the library, it's probably because ``pip`` +defaults to Python 2, which is not supported. Use ``pip3`` instead. If you already have the library installed, upgrade with: @@ -20,7 +22,7 @@ If you already have the library installed, upgrade with: You can also install the library directly from GitHub or a fork: - .. code-block:: python + .. code-block:: sh # pip install git+https://github.com/LonamiWebs/Telethon.git or @@ -32,13 +34,15 @@ If you don't have root access, simply pass the ``--user`` flag to the pip comman Manual Installation -^^^^^^^^^^^^^^^^^^^^ +******************* -1. Install the required ``pyaes`` (`GitHub`__ | `PyPi`__) and ``rsa`` (`GitHub`__ | `PyPi`__) modules: +1. Install the required ``pyaes`` (`GitHub`__ | `PyPi`__) and + ``rsa`` (`GitHub`__ | `PyPi`__) modules: ``sudo -H pip install pyaes rsa`` -2. Clone Telethon's GitHub repository: ``git clone https://github.com/LonamiWebs/Telethon.git`` +2. Clone Telethon's GitHub repository: + ``git clone https://github.com/LonamiWebs/Telethon.git`` 3. Enter the cloned repository: ``cd Telethon`` @@ -50,22 +54,15 @@ To generate the documentation, ``cd docs`` and then ``python3 generate.py``. Optional dependencies -^^^^^^^^^^^^^^^^^^^^^^^^ - -If you're using the library under ARM (or even if you aren't), -you may want to install ``sympy`` through ``pip`` for a substantial speed-up -when generating the keys required to connect to Telegram -(you can of course do this on desktop too). See `issue #199`__ for more. - -If ``libssl`` is available on your system, it will also be used wherever encryption is needed. - -If neither of these are available, a pure Python callback will be used instead, -so you can still run the library wherever Python is available! +********************* +If ``libssl`` is available on your system, it will be used wherever encryption +is needed, but otherwise it will fall back to pure Python implementation so it +will also work without it. __ https://github.com/ricmoo/pyaes __ https://pypi.python.org/pypi/pyaes __ https://github.com/sybrenstuvel/python-rsa/ __ https://pypi.python.org/pypi/rsa/3.4.2 -__ https://github.com/LonamiWebs/Telethon/issues/199 \ No newline at end of file +__ https://github.com/LonamiWebs/Telethon/issues/199 diff --git a/readthedocs/extra/basic/sending-requests.rst b/readthedocs/extra/basic/sending-requests.rst deleted file mode 100644 index 160e2259..00000000 --- a/readthedocs/extra/basic/sending-requests.rst +++ /dev/null @@ -1,55 +0,0 @@ -.. _sending-requests: - -================== -Sending Requests -================== - -Since we're working with Python, one must not forget that they can do ``help(client)`` or ``help(TelegramClient)`` -at any time for a more detailed description and a list of all the available methods. -Calling ``help()`` from an interactive Python session will always list all the methods for any object, even yours! - -Interacting with the Telegram API is done through sending **requests**, -this is, any "method" listed on the API. There are a few methods on the ``TelegramClient`` class -that abstract you from the need of manually importing the requests you need. - -For instance, retrieving your own user can be done in a single line: - - ``myself = client.get_me()`` - -Internally, this method has sent a request to Telegram, who replied with the information about your own user. - -If you want to retrieve any other user, chat or channel (channels are a special subset of chats), -you want to retrieve their "entity". This is how the library refers to either of these: - - .. code-block:: python - - # The method will infer that you've passed an username - # It also accepts phone numbers, and will get the user - # from your contact list. - lonami = client.get_entity('lonami') - -Note that saving and using these entities will be more important when Accessing the Full API. -For now, this is a good way to get information about an user or chat. - -Other common methods for quick scripts are also available: - - .. code-block:: python - - # Sending a message (use an entity/username/etc) - client.send_message('TheAyyBot', 'ayy') - - # Sending a photo, or a file - client.send_file(myself, '/path/to/the/file.jpg', force_document=True) - - # Downloading someone's profile photo. File is saved to 'where' - where = client.download_profile_photo(someone) - - # Retrieving the message history - total, messages, senders = client.get_message_history(someone) - - # Downloading the media from a specific message - # You can specify either a directory, a filename, or nothing at all - where = client.download_media(message, '/path/to/output') - -Remember that you can call ``.stringify()`` to any object Telegram returns to pretty print it. -Calling ``str(result)`` does the same operation, but on a single line. diff --git a/readthedocs/extra/basic/sessions.rst b/readthedocs/extra/basic/sessions.rst deleted file mode 100644 index f55d9703..00000000 --- a/readthedocs/extra/basic/sessions.rst +++ /dev/null @@ -1,48 +0,0 @@ -.. _sessions: - -============== -Session Files -============== - -The first parameter you pass the constructor of the -``TelegramClient`` is the ``session``, and defaults to be the session -name (or full path). That is, if you create a ``TelegramClient('anon')`` -instance and connect, an ``anon.session`` file will be created on the -working directory. - -These JSON session files contain the required information to talk to the -Telegram servers, such as to which IP the client should connect, port, -authorization key so that messages can be encrypted, and so on. - -These files will by default also save all the input entities that you’ve -seen, so that you can get information about an user or channel by just -their ID. Telegram will **not** send their ``access_hash`` required to -retrieve more information about them, if it thinks you have already seem -them. For this reason, the library needs to store this information -offline. - -The library will by default too save all the entities (users with their -name, username, chats and so on) **in memory**, not to disk, so that you -can quickly access them by username or phone number. This can be -disabled too. Run ``help(client.session.entities)`` to see the available -methods (or ``help(EntityDatabase)``). - -If you’re not going to work without updates, or don’t need to cache the -``access_hash`` associated with the entities’ ID, you can disable this -by setting ``client.session.save_entities = False``. - -If you don’t want to save the files as JSON, you can also create your -custom ``Session`` subclass and override the ``.save()`` and ``.load()`` -methods. For example, you could save it on a database: - - .. code-block:: python - - class DatabaseSession(Session): - def save(): - # serialize relevant data to the database - - def load(): - # load relevant data to the database - -You should read the ``session.py`` source file to know what “relevant -data” you need to keep track of. diff --git a/readthedocs/extra/basic/telegram-client.rst b/readthedocs/extra/basic/telegram-client.rst new file mode 100644 index 00000000..5663f533 --- /dev/null +++ b/readthedocs/extra/basic/telegram-client.rst @@ -0,0 +1,99 @@ +.. _telegram-client: + +============== +TelegramClient +============== + + +Introduction +************ + +The ``TelegramClient`` is the central class of the library, the one +you will be using most of the time. For this reason, it's important +to know what it offers. + +Since we're working with Python, one must not forget that we can do +``help(client)`` or ``help(TelegramClient)`` at any time for a more +detailed description and a list of all the available methods. Calling +``help()`` from an interactive Python session will always list all the +methods for any object, even yours! + +Interacting with the Telegram API is done through sending **requests**, +this is, any "method" listed on the API. There are a few methods (and +growing!) on the ``TelegramClient`` class that abstract you from the +need of manually importing the requests you need. + +For instance, retrieving your own user can be done in a single line: + + ``myself = client.get_me()`` + +Internally, this method has sent a request to Telegram, who replied with +the information about your own user, and then the desired information +was extracted from their response. + +If you want to retrieve any other user, chat or channel (channels are a +special subset of chats), you want to retrieve their "entity". This is +how the library refers to either of these: + + .. code-block:: python + + # The method will infer that you've passed an username + # It also accepts phone numbers, and will get the user + # from your contact list. + lonami = client.get_entity('lonami') + +The so called "entities" are another important whole concept on its own, +and you should +Note that saving and using these entities will be more important when +Accessing the Full API. For now, this is a good way to get information +about an user or chat. + +Other common methods for quick scripts are also available: + + .. code-block:: python + + # Sending a message (use an entity/username/etc) + client.send_message('TheAyyBot', 'ayy') + + # Sending a photo, or a file + client.send_file(myself, '/path/to/the/file.jpg', force_document=True) + + # Downloading someone's profile photo. File is saved to 'where' + where = client.download_profile_photo(someone) + + # Retrieving the message history + messages = client.get_message_history(someone) + + # Downloading the media from a specific message + # You can specify either a directory, a filename, or nothing at all + where = client.download_media(message, '/path/to/output') + + # Call .disconnect() when you're done + client.disconnect() + +Remember that you can call ``.stringify()`` to any object Telegram returns +to pretty print it. Calling ``str(result)`` does the same operation, but on +a single line. + + +Available methods +***************** + +This page lists all the "handy" methods available for you to use in the +``TelegramClient`` class. These are simply wrappers around the "raw" +Telegram API, making it much more manageable and easier to work with. + +Please refer to :ref:`accessing-the-full-api` if these aren't enough, +and don't be afraid to read the source code of the InteractiveTelegramClient_ +or even the TelegramClient_ itself to learn how it works. + + +.. _InteractiveTelegramClient: https://github.com/LonamiWebs/Telethon/blob/master/telethon_examples/interactive_telegram_client.py +.. _TelegramClient: https://github.com/LonamiWebs/Telethon/blob/master/telethon/telegram_client.py + + + +.. automodule:: telethon.telegram_client + :members: + :undoc-members: + :show-inheritance: diff --git a/readthedocs/extra/basic/working-with-updates.rst b/readthedocs/extra/basic/working-with-updates.rst index c5d9e919..bb78eb97 100644 --- a/readthedocs/extra/basic/working-with-updates.rst +++ b/readthedocs/extra/basic/working-with-updates.rst @@ -14,23 +14,24 @@ The library can run in four distinguishable modes: - With several worker threads that run your update handlers. - A mix of the above. -Since this section is about updates, we'll describe the simplest way to work with them. - -.. warning:: - Remember that you should always call ``client.disconnect()`` once you're done. +Since this section is about updates, we'll describe the simplest way to +work with them. Using multiple workers -^^^^^^^^^^^^^^^^^^^^^^^ +********************** -When you create your client, simply pass a number to the ``update_workers`` parameter: +When you create your client, simply pass a number to the +``update_workers`` parameter: ``client = TelegramClient('session', api_id, api_hash, update_workers=4)`` -4 workers should suffice for most cases (this is also the default on `Python Telegram Bot`__). -You can set this value to more, or even less if you need. +4 workers should suffice for most cases (this is also the default on +`Python Telegram Bot`__). You can set this value to more, or even less +if you need. -The next thing you want to do is to add a method that will be called when an `Update`__ arrives: +The next thing you want to do is to add a method that will be called when +an `Update`__ arrives: .. code-block:: python @@ -41,7 +42,8 @@ The next thing you want to do is to add a method that will be called when an `Up # do more work here, or simply sleep! That's it! Now let's do something more interesting. -Every time an user talks to use, let's reply to them with the same text reversed: +Every time an user talks to use, let's reply to them with the same +text reversed: .. code-block:: python @@ -56,16 +58,18 @@ Every time an user talks to use, let's reply to them with the same text reversed input('Press enter to stop this!') client.disconnect() -We only ask you one thing: don't keep this running for too long, or your contacts will go mad. +We only ask you one thing: don't keep this running for too long, or your +contacts will go mad. Spawning no worker at all -^^^^^^^^^^^^^^^^^^^^^^^^^^ +************************* -All the workers do is loop forever and poll updates from a queue that is filled from the ``ReadThread``, -responsible for reading every item off the network. -If you only need a worker and the ``MainThread`` would be doing no other job, -this is the preferred way. You can easily do the same as the workers like so: +All the workers do is loop forever and poll updates from a queue that is +filled from the ``ReadThread``, responsible for reading every item off +the network. If you only need a worker and the ``MainThread`` would be +doing no other job, this is the preferred way. You can easily do the same +as the workers like so: .. code-block:: python @@ -81,24 +85,27 @@ this is the preferred way. You can easily do the same as the workers like so: client.disconnect() -Note that ``poll`` accepts a ``timeout=`` parameter, -and it will return ``None`` if other thread got the update before you could or if the timeout expired, -so it's important to check ``if not update``. +Note that ``poll`` accepts a ``timeout=`` parameter, and it will return +``None`` if other thread got the update before you could or if the timeout +expired, so it's important to check ``if not update``. -This can coexist with the rest of ``N`` workers, or you can set it to ``0`` additional workers: +This can coexist with the rest of ``N`` workers, or you can set it to ``0`` +additional workers: ``client = TelegramClient('session', api_id, api_hash, update_workers=0)`` -You **must** set it to ``0`` (or other number), as it defaults to ``None`` and there is a different. -``None`` workers means updates won't be processed *at all*, -so you must set it to some value (0 or greater) if you want ``client.updates.poll()`` to work. +You **must** set it to ``0`` (or other number), as it defaults to ``None`` +and there is a different. ``None`` workers means updates won't be processed +*at all*, so you must set it to some value (``0`` or greater) if you want +``client.updates.poll()`` to work. Using the main thread instead the ``ReadThread`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +************************************************ -If you have no work to do on the ``MainThread`` and you were planning to have a ``while True: sleep(1)``, -don't do that. Instead, don't spawn the secondary ``ReadThread`` at all like so: +If you have no work to do on the ``MainThread`` and you were planning to have +a ``while True: sleep(1)``, don't do that. Instead, don't spawn the secondary +``ReadThread`` at all like so: .. code-block:: python @@ -111,8 +118,8 @@ And then ``.idle()`` from the ``MainThread``: ``client.idle()`` -You can stop it with :kbd:`Control+C`, -and you can configure the signals to be used in a similar fashion to `Python Telegram Bot`__. +You can stop it with :kbd:`Control+C`, and you can configure the signals +to be used in a similar fashion to `Python Telegram Bot`__. As a complete example: @@ -132,4 +139,4 @@ As a complete example: __ https://python-telegram-bot.org/ __ https://lonamiwebs.github.io/Telethon/types/update.html -__ https://github.com/python-telegram-bot/python-telegram-bot/blob/4b3315db6feebafb94edcaa803df52bb49999ced/telegram/ext/updater.py#L460 \ No newline at end of file +__ https://github.com/python-telegram-bot/python-telegram-bot/blob/4b3315db6feebafb94edcaa803df52bb49999ced/telegram/ext/updater.py#L460 diff --git a/readthedocs/extra/developing/api-status.rst b/readthedocs/extra/developing/api-status.rst new file mode 100644 index 00000000..b5092dad --- /dev/null +++ b/readthedocs/extra/developing/api-status.rst @@ -0,0 +1,54 @@ +========== +API Status +========== + + +In an attempt to help everyone who works with the Telegram API, the +library will by default report all *Remote Procedure Call* errors to +`RPC PWRTelegram `__, a public database +anyone can query, made by `Daniil `__. All the +information sent is a ``GET`` request with the error code, error message +and method used. + +If you still would like to opt out, simply set +``client.session.report_errors = False`` to disable this feature, or +pass ``report_errors=False`` as a named parameter when creating a +``TelegramClient`` instance. However Daniil would really thank you if +you helped him (and everyone) by keeping it on! + +Querying the API status +*********************** + +The API is accessed through ``GET`` requests, which can be made for +instance through ``curl``. A JSON response will be returned. + +**All known errors and their description**: + +.. code:: bash + + curl https://rpc.pwrtelegram.xyz/?all + +**Error codes for a specific request**: + +.. code:: bash + + curl https://rpc.pwrtelegram.xyz/?for=messages.sendMessage + +**Number of ``RPC_CALL_FAIL``\ 's**: + +.. code:: bash + + curl https://rpc.pwrtelegram.xyz/?rip # last hour + curl https://rpc.pwrtelegram.xyz/?rip=$(time()-60) # last minute + +**Description of errors**: + +.. code:: bash + + curl https://rpc.pwrtelegram.xyz/?description_for=SESSION_REVOKED + +**Code of a specific error**: + +.. code:: bash + + curl https://rpc.pwrtelegram.xyz/?code_for=STICKERSET_INVALID diff --git a/readthedocs/extra/developing/coding-style.rst b/readthedocs/extra/developing/coding-style.rst new file mode 100644 index 00000000..c629034c --- /dev/null +++ b/readthedocs/extra/developing/coding-style.rst @@ -0,0 +1,22 @@ +============ +Coding Style +============ + + +Basically, make it **readable**, while keeping the style similar to the +code of whatever file you're working on. + +Also note that not everyone has 4K screens for their primary monitors, +so please try to stick to the 80-columns limit. This makes it easy to +``git diff`` changes from a terminal before committing changes. If the +line has to be long, please don't exceed 120 characters. + +For the commit messages, please make them *explanatory*. Not only +they're helpful to troubleshoot when certain issues could have been +introduced, but they're also used to construct the change log once a new +version is ready. + +If you don't know enough Python, I strongly recommend reading `Dive Into +Python 3 `__, available online for +free. For instance, remember to do ``if x is None`` or +``if x is not None`` instead ``if x == None``! diff --git a/readthedocs/extra/developing/philosophy.rst b/readthedocs/extra/developing/philosophy.rst new file mode 100644 index 00000000..f779be2b --- /dev/null +++ b/readthedocs/extra/developing/philosophy.rst @@ -0,0 +1,25 @@ +========== +Philosophy +========== + + +The intention of the library is to have an existing MTProto library +existing with hardly any dependencies (indeed, wherever Python is +available, you can run this library). + +Being written in Python means that performance will be nowhere close to +other implementations written in, for instance, Java, C++, Rust, or +pretty much any other compiled language. However, the library turns out +to actually be pretty decent for common operations such as sending +messages, receiving updates, or other scripting. Uploading files may be +notably slower, but if you would like to contribute, pull requests are +appreciated! + +If ``libssl`` is available on your system, the library will make use of +it to speed up some critical parts such as encrypting and decrypting the +messages. Files will notably be sent and downloaded faster. + +The main focus is to keep everything clean and simple, for everyone to +understand how working with MTProto and Telegram works. Don't be afraid +to read the source, the code won't bite you! It may prove useful when +using the library on your own use cases. diff --git a/readthedocs/extra/developing/project-structure.rst b/readthedocs/extra/developing/project-structure.rst new file mode 100644 index 00000000..d40c6031 --- /dev/null +++ b/readthedocs/extra/developing/project-structure.rst @@ -0,0 +1,43 @@ +================= +Project Structure +================= + + +Main interface +************** + +The library itself is under the ``telethon/`` directory. The +``__init__.py`` file there exposes the main ``TelegramClient``, a class +that servers as a nice interface with the most commonly used methods on +Telegram such as sending messages, retrieving the message history, +handling updates, etc. + +The ``TelegramClient`` inherits the ``TelegramBareClient``. The later is +basically a pruned version of the ``TelegramClient``, which knows basic +stuff like ``.invoke()``\ 'ing requests, downloading files, or switching +between data centers. This is primary to keep the method count per class +and file low and manageable. + +Both clients make use of the ``network/mtproto_sender.py``. The +``MtProtoSender`` class handles packing requests with the ``salt``, +``id``, ``sequence``, etc., and also handles how to process responses +(i.e. pong, RPC errors). This class communicates through Telegram via +its ``.connection`` member. + +The ``Connection`` class uses a ``extensions/tcp_client``, a C#-like +``TcpClient`` to ease working with sockets in Python. All the +``TcpClient`` know is how to connect through TCP and writing/reading +from the socket with optional cancel. + +The ``Connection`` class bundles up all the connections modes and sends +and receives the messages accordingly (TCP full, obfuscated, +intermediate…). + +Auto-generated code +******************* + +The files under ``telethon_generator/`` are used to generate the code +that gets placed under ``telethon/tl/``. The ``TLGenerator`` takes in a +``.tl`` file, and spits out the generated classes which represent, as +Python classes, the request and types defined in the ``.tl`` file. It +also constructs an index so that they can be imported easily. diff --git a/readthedocs/extra/developing/telegram-api-in-other-languages.rst b/readthedocs/extra/developing/telegram-api-in-other-languages.rst new file mode 100644 index 00000000..0adeb988 --- /dev/null +++ b/readthedocs/extra/developing/telegram-api-in-other-languages.rst @@ -0,0 +1,64 @@ +=============================== +Telegram API in Other Languages +=============================== + + +Telethon was made for **Python**, and as far as I know, there is no +*exact* port to other languages. However, there *are* other +implementations made by awesome people (one needs to be awesome to +understand the official Telegram documentation) on several languages +(even more Python too), listed below: + +C +* + +Possibly the most well-known unofficial open source implementation out +there by `**@vysheng** `__, +```tgl`` `__, and its console client +```telegram-cli`` `__. Latest development +has been moved to `BitBucket `__. + +JavaScript +********** + +`**@zerobias** `__ is working on +```telegram-mtproto`` `__, +a work-in-progress JavaScript library installable via +```npm`` `__. + +Kotlin +****** + +`Kotlogram `__ is a Telegram +implementation written in Kotlin (the now +`official `__ +language for +`Android `__) by +`**@badoualy** `__, currently as a beta– +yet working. + +PHP +*** + +A PHP implementation is also available thanks to +`**@danog** `__ and his +`MadelineProto `__ project, with +a very nice `online +documentation `__ too. + +Python +****** + +A fairly new (as of the end of 2017) Telegram library written from the +ground up in Python by +`**@delivrance** `__ and his +`Pyrogram `__ library! No hard +feelings Dan and good luck dealing with some of your users ;) + +Rust +**** + +Yet another work-in-progress implementation, this time for Rust thanks +to `**@JuanPotato** `__ under the fancy +name of `Vail `__. This one is very +early still, but progress is being made at a steady rate. diff --git a/readthedocs/extra/developing/test-servers.rst b/readthedocs/extra/developing/test-servers.rst new file mode 100644 index 00000000..2ba66897 --- /dev/null +++ b/readthedocs/extra/developing/test-servers.rst @@ -0,0 +1,32 @@ +============ +Test Servers +============ + + +To run Telethon on a test server, use the following code: + + .. code-block:: python + + client = TelegramClient(None, api_id, api_hash) + client.session.server_address = '149.154.167.40' + client.connect() + +You can check your ``'test ip'`` on https://my.telegram.org. + +You should set ``None`` session so to ensure you're generating a new +authorization key for it (it would fail if you used a session where you +had previously connected to another data center). + +Once you're connected, you'll likely need to ``.sign_up()``. Remember +`anyone can access the phone you +choose `__, +so don't store sensitive data here: + + .. code-block:: python + + from random import randint + + dc_id = '2' # Change this to the DC id of the test server you chose + phone = '99966' + dc_id + str(randint(9999)).zfill(4) + client.send_code_request(phone) + client.sign_up(dc_id * 5, 'Some', 'Name') diff --git a/readthedocs/extra/developing/tips-for-porting-the-project.rst b/readthedocs/extra/developing/tips-for-porting-the-project.rst new file mode 100644 index 00000000..c7135096 --- /dev/null +++ b/readthedocs/extra/developing/tips-for-porting-the-project.rst @@ -0,0 +1,17 @@ +============================ +Tips for Porting the Project +============================ + + +If you're going to use the code on this repository to guide you, please +be kind and don't forget to mention it helped you! + +You should start by reading the source code on the `first +release `__ of +the project, and start creating a ``MtProtoSender``. Once this is made, +you should write by hand the code to authenticate on the Telegram's +server, which are some steps required to get the key required to talk to +them. Save it somewhere! Then, simply mimic, or reinvent other parts of +the code, and it will be ready to go within a few days. + +Good luck! diff --git a/readthedocs/extra/developing/understanding-the-type-language.rst b/readthedocs/extra/developing/understanding-the-type-language.rst new file mode 100644 index 00000000..c82063ef --- /dev/null +++ b/readthedocs/extra/developing/understanding-the-type-language.rst @@ -0,0 +1,35 @@ +=============================== +Understanding the Type Language +=============================== + + +`Telegram's Type Language `__ +(also known as TL, found on ``.tl`` files) is a concise way to define +what other programming languages commonly call classes or structs. + +Every definition is written as follows for a Telegram object is defined +as follows: + +.. code:: tl + + name#id argument_name:argument_type = CommonType + +This means that in a single line you know what the ``TLObject`` name is. +You know it's unique ID, and you know what arguments it has. It really +isn't that hard to write a generator for generating code to any +platform! + +The generated code should also be able to *encode* the ``TLObject`` (let +this be a request or a type) into bytes, so they can be sent over the +network. This isn't a big deal either, because you know how the +``TLObject``\ 's are made, and how the types should be serialized. + +You can either write your own code generator, or use the one this +library provides, but please be kind and keep some special mention to +this project for helping you out. + +This is only a introduction. The ``TL`` language is not *that* easy. But +it's not that hard either. You're free to sniff the +``telethon_generator/`` files and learn how to parse other more complex +lines, such as ``flags`` (to indicate things that may or may not be +written at all) and ``vector``\ 's. diff --git a/readthedocs/extra/advanced-usage/bots.rst b/readthedocs/extra/examples/bots.rst similarity index 77% rename from readthedocs/extra/advanced-usage/bots.rst rename to readthedocs/extra/examples/bots.rst index 091eada1..b231e200 100644 --- a/readthedocs/extra/advanced-usage/bots.rst +++ b/readthedocs/extra/examples/bots.rst @@ -1,13 +1,14 @@ -====== +==== Bots -====== +==== + Talking to Inline Bots -^^^^^^^^^^^^^^^^^^^^^^ +********************** -You can query an inline bot, such as `@VoteBot`__ -(note, *query*, not *interact* with a voting message), by making use of -the `GetInlineBotResultsRequest`__ request: +You can query an inline bot, such as `@VoteBot`__ (note, *query*, +not *interact* with a voting message), by making use of the +`GetInlineBotResultsRequest`__ request: .. code-block:: python @@ -32,11 +33,10 @@ And you can select any of their results by using Talking to Bots with special reply markup -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +***************************************** To interact with a message that has a special reply markup, such as -`@VoteBot`__ polls, you would use -`GetBotCallbackAnswerRequest`__: +`@VoteBot`__ polls, you would use `GetBotCallbackAnswerRequest`__: .. code-block:: python @@ -48,7 +48,7 @@ To interact with a message that has a special reply markup, such as data=msg.reply_markup.rows[wanted_row].buttons[wanted_button].data )) -It’s a bit verbose, but it has all the information you would need to +It's a bit verbose, but it has all the information you would need to show it visually (button rows, and buttons within each row, each with its own data). @@ -56,4 +56,4 @@ __ https://t.me/vote __ https://lonamiwebs.github.io/Telethon/methods/messages/get_inline_bot_results.html __ https://lonamiwebs.github.io/Telethon/methods/messages/send_inline_bot_result.html __ https://lonamiwebs.github.io/Telethon/methods/messages/get_bot_callback_answer.html -__ https://t.me/vote \ No newline at end of file +__ https://t.me/vote diff --git a/readthedocs/extra/examples/chats-and-channels.rst b/readthedocs/extra/examples/chats-and-channels.rst new file mode 100644 index 00000000..1bafec80 --- /dev/null +++ b/readthedocs/extra/examples/chats-and-channels.rst @@ -0,0 +1,205 @@ +=============================== +Working with Chats and Channels +=============================== + + +Joining a chat or channel +************************* + +Note that `Chat`__\ s are normal groups, and `Channel`__\ s are a +special form of `Chat`__\ s, +which can also be super-groups if their ``megagroup`` member is +``True``. + + +Joining a public channel +************************ + +Once you have the :ref:`entity ` of the channel you want to join +to, you can make use of the `JoinChannelRequest`__ to join such channel: + + .. code-block:: python + + from telethon.tl.functions.channels import JoinChannelRequest + client(JoinChannelRequest(channel)) + + # In the same way, you can also leave such channel + from telethon.tl.functions.channels import LeaveChannelRequest + client(LeaveChannelRequest(input_channel)) + + +For more on channels, check the `channels namespace`__. + + +Joining a private chat or channel +********************************* + +If all you have is a link like this one: +``https://t.me/joinchat/AAAAAFFszQPyPEZ7wgxLtd``, you already have +enough information to join! The part after the +``https://t.me/joinchat/``, this is, ``AAAAAFFszQPyPEZ7wgxLtd`` on this +example, is the ``hash`` of the chat or channel. Now you can use +`ImportChatInviteRequest`__ as follows: + + .. -block:: python + + from telethon.tl.functions.messages import ImportChatInviteRequest + updates = client(ImportChatInviteRequest('AAAAAEHbEkejzxUjAUCfYg')) + + +Adding someone else to such chat or channel +******************************************* + +If you don't want to add yourself, maybe because you're already in, +you can always add someone else with the `AddChatUserRequest`__, +which use is very straightforward: + + .. code-block:: python + + from telethon.tl.functions.messages import AddChatUserRequest + + client(AddChatUserRequest( + chat_id, + user_to_add, + fwd_limit=10 # allow the user to see the 10 last messages + )) + + +Checking a link without joining +******************************* + +If you don't need to join but rather check whether it's a group or a +channel, you can use the `CheckChatInviteRequest`__, which takes in +the `hash`__ of said channel or group. + +__ https://lonamiwebs.github.io/Telethon/constructors/chat.html +__ https://lonamiwebs.github.io/Telethon/constructors/channel.html +__ https://lonamiwebs.github.io/Telethon/types/chat.html +__ https://lonamiwebs.github.io/Telethon/methods/channels/join_channel.html +__ https://lonamiwebs.github.io/Telethon/methods/channels/index.html +__ https://lonamiwebs.github.io/Telethon/methods/messages/import_chat_invite.html +__ https://lonamiwebs.github.io/Telethon/methods/messages/add_chat_user.html +__ https://lonamiwebs.github.io/Telethon/methods/messages/check_chat_invite.html +__ https://github.com/LonamiWebs/Telethon/wiki/Joining-a-chat-or-channel#joining-a-private-chat-or-channel + + +Retrieving all chat members (channels too) +****************************************** + +In order to get all the members from a mega-group or channel, you need +to use `GetParticipantsRequest`__. As we can see it needs an +`InputChannel`__, (passing the mega-group or channel you're going to +use will work), and a mandatory `ChannelParticipantsFilter`__. The +closest thing to "no filter" is to simply use +`ChannelParticipantsSearch`__ with an empty ``'q'`` string. + +If we want to get *all* the members, we need to use a moving offset and +a fixed limit: + + .. code-block:: python + + from telethon.tl.functions.channels import GetParticipantsRequest + from telethon.tl.types import ChannelParticipantsSearch + from time import sleep + + offset = 0 + limit = 100 + all_participants = [] + + while True: + participants = client.invoke(GetParticipantsRequest( + channel, ChannelParticipantsSearch(''), offset, limit + )) + if not participants.users: + break + all_participants.extend(participants.users) + offset += len(participants.users) + + +Note that ``GetParticipantsRequest`` returns `ChannelParticipants`__, +which may have more information you need (like the role of the +participants, total count of members, etc.) + +__ https://lonamiwebs.github.io/Telethon/methods/channels/get_participants.html +__ https://lonamiwebs.github.io/Telethon/methods/channels/get_participants.html +__ https://lonamiwebs.github.io/Telethon/types/channel_participants_filter.html +__ https://lonamiwebs.github.io/Telethon/constructors/channel_participants_search.html +__ https://lonamiwebs.github.io/Telethon/constructors/channels/channel_participants.html + + +Recent Actions +************** + +"Recent actions" is simply the name official applications have given to +the "admin log". Simply use `GetAdminLogRequest`__ for that, and +you'll get AdminLogResults.events in return which in turn has the final +`.action`__. + +__ https://lonamiwebs.github.io/Telethon/methods/channels/get_admin_log.html +__ https://lonamiwebs.github.io/Telethon/types/channel_admin_log_event_action.html + + +Admin Permissions +***************** + +Giving or revoking admin permissions can be done with the `EditAdminRequest`__: + + .. code-block:: python + + from telethon.tl.functions.channels import EditAdminRequest + from telethon.tl.types import ChannelAdminRights + + # You need both the channel and who to grant permissions + # They can either be channel/user or input channel/input user. + # + # ChannelAdminRights is a list of granted permissions. + # Set to True those you want to give. + rights = ChannelAdminRights( + post_messages=None, + add_admins=None, + invite_users=None, + change_info=True, + ban_users=None, + delete_messages=True, + pin_messages=True, + invite_link=None, + edit_messages=None + ) + + client(EditAdminRequest(channel, who, rights)) + + +Thanks to `@Kyle2142`__ for `pointing out`__ that you **cannot** set +to ``True`` the ``post_messages`` and ``edit_messages`` fields. Those that +are ``None`` can be omitted (left here so you know `which are available`__. + +__ https://lonamiwebs.github.io/Telethon/methods/channels/edit_admin.html +__ https://github.com/Kyle2142 +__ https://github.com/LonamiWebs/Telethon/issues/490 +__ https://lonamiwebs.github.io/Telethon/constructors/channel_admin_rights.html + + +Increasing View Count in a Channel +********************************** + +It has been asked `quite`__ `a few`__ `times`__ (really, `many`__), and +while I don't understand why so many people ask this, the solution is to +use `GetMessagesViewsRequest`__, setting ``increment=True``: + + .. code-block:: python + + + # Obtain `channel' through dialogs or through client.get_entity() or anyhow. + # Obtain `msg_ids' through `.get_message_history()` or anyhow. Must be a list. + + client(GetMessagesViewsRequest( + peer=channel, + id=msg_ids, + increment=True + )) + +__ https://github.com/LonamiWebs/Telethon/issues/233 +__ https://github.com/LonamiWebs/Telethon/issues/305 +__ https://github.com/LonamiWebs/Telethon/issues/409 +__ https://github.com/LonamiWebs/Telethon/issues/447 +__ https://lonamiwebs.github.io/Telethon/methods/messages/get_messages_views.html diff --git a/readthedocs/extra/advanced-usage/working-with-messages.rst b/readthedocs/extra/examples/working-with-messages.rst similarity index 65% rename from readthedocs/extra/advanced-usage/working-with-messages.rst rename to readthedocs/extra/examples/working-with-messages.rst index 2c141406..880bac6f 100644 --- a/readthedocs/extra/advanced-usage/working-with-messages.rst +++ b/readthedocs/extra/examples/working-with-messages.rst @@ -1,20 +1,18 @@ -========================= +===================== Working with messages -========================= - -.. note:: - Make sure you have gone through :ref:`prelude` already! +===================== Forwarding messages ******************* -Note that ForwardMessageRequest_ (note it's Message, singular) will *not* work if channels are involved. -This is because channel (and megagroups) IDs are not unique, so you also need to know who the sender is -(a parameter this request doesn't have). +Note that ForwardMessageRequest_ (note it's Message, singular) will *not* +work if channels are involved. This is because channel (and megagroups) IDs +are not unique, so you also need to know who the sender is (a parameter this +request doesn't have). -Either way, you are encouraged to use ForwardMessagesRequest_ (note it's Message*s*, plural) *always*, -since it is more powerful, as follows: +Either way, you are encouraged to use ForwardMessagesRequest_ (note it's +Message*s*, plural) *always*, since it is more powerful, as follows: .. code-block:: python @@ -31,14 +29,16 @@ since it is more powerful, as follows: to_peer=to_entity # who are we forwarding them to? )) -The named arguments are there for clarity, although they're not needed because they appear in order. -You can obviously just wrap a single message on the list too, if that's all you have. +The named arguments are there for clarity, although they're not needed because +they appear in order. You can obviously just wrap a single message on the list +too, if that's all you have. Searching Messages ******************* -Messages are searched through the obvious SearchRequest_, but you may run into issues_. A valid example would be: +Messages are searched through the obvious SearchRequest_, but you may run +into issues_. A valid example would be: .. code-block:: python @@ -46,27 +46,32 @@ Messages are searched through the obvious SearchRequest_, but you may run into i entity, 'query', InputMessagesFilterEmpty(), None, None, 0, 0, 100 )) -It's important to note that the optional parameter ``from_id`` has been left omitted and thus defaults to ``None``. -Changing it to InputUserEmpty_, as one could think to specify "no user", won't work because this parameter is a flag, +It's important to note that the optional parameter ``from_id`` has been left +omitted and thus defaults to ``None``. Changing it to InputUserEmpty_, as one +could think to specify "no user", won't work because this parameter is a flag, and it being unspecified has a different meaning. -If one were to set ``from_id=InputUserEmpty()``, it would filter messages from "empty" senders, -which would likely match no users. +If one were to set ``from_id=InputUserEmpty()``, it would filter messages +from "empty" senders, which would likely match no users. -If you get a ``ChatAdminRequiredError`` on a channel, it's probably because you tried setting the ``from_id`` filter, -and as the error says, you can't do that. Leave it set to ``None`` and it should work. +If you get a ``ChatAdminRequiredError`` on a channel, it's probably because +you tried setting the ``from_id`` filter, and as the error says, you can't +do that. Leave it set to ``None`` and it should work. -As with every method, make sure you use the right ID/hash combination for your ``InputUser`` or ``InputChat``, -or you'll likely run into errors like ``UserIdInvalidError``. +As with every method, make sure you use the right ID/hash combination for +your ``InputUser`` or ``InputChat``, or you'll likely run into errors like +``UserIdInvalidError``. Sending stickers -***************** +**************** -Stickers are nothing else than ``files``, and when you successfully retrieve the stickers for a certain sticker set, -all you will have are ``handles`` to these files. Remember, the files Telegram holds on their servers can be referenced -through this pair of ID/hash (unique per user), and you need to use this handle when sending a "document" message. -This working example will send yourself the very first sticker you have: +Stickers are nothing else than ``files``, and when you successfully retrieve +the stickers for a certain sticker set, all you will have are ``handles`` to +these files. Remember, the files Telegram holds on their servers can be +referenced through this pair of ID/hash (unique per user), and you need to +use this handle when sending a "document" message. This working example will +send yourself the very first sticker you have: .. code-block:: python diff --git a/readthedocs/extra/troubleshooting/deleted-limited-or-deactivated-accounts.rst b/readthedocs/extra/troubleshooting/deleted-limited-or-deactivated-accounts.rst index 1ad3da19..6426ada9 100644 --- a/readthedocs/extra/troubleshooting/deleted-limited-or-deactivated-accounts.rst +++ b/readthedocs/extra/troubleshooting/deleted-limited-or-deactivated-accounts.rst @@ -1,6 +1,6 @@ -========================================= +======================================== Deleted, Limited or Deactivated Accounts -========================================= +======================================== If you're from Iran or Russian, we have bad news for you. Telegram is much more likely to ban these numbers, @@ -23,4 +23,4 @@ For more discussion, please see `issue 297`__. __ https://t.me/SpamBot -__ https://github.com/LonamiWebs/Telethon/issues/297 \ No newline at end of file +__ https://github.com/LonamiWebs/Telethon/issues/297 diff --git a/readthedocs/extra/troubleshooting/enable-logging.rst b/readthedocs/extra/troubleshooting/enable-logging.rst index a6d45d00..897052e2 100644 --- a/readthedocs/extra/troubleshooting/enable-logging.rst +++ b/readthedocs/extra/troubleshooting/enable-logging.rst @@ -1,15 +1,18 @@ ================ -Enable Logging +Enabling Logging ================ Telethon makes use of the `logging`__ module, and you can enable it as follows: - .. code-block:: python +.. code:: python - import logging - logging.basicConfig(level=logging.DEBUG) + import logging + logging.basicConfig(level=logging.DEBUG) -You can also use it in your own project very easily: +The library has the `NullHandler`__ added by default so that no log calls +will be printed unless you explicitly enable it. + +You can also `use the module`__ on your own project very easily: .. code-block:: python @@ -21,4 +24,17 @@ You can also use it in your own project very easily: logger.warning('This is a warning!') -__ https://docs.python.org/3/library/logging.html \ No newline at end of file +If you want to enable ``logging`` for your project *but* use a different +log level for the library: + + .. code-block:: python + + import logging + logging.basicConfig(level=logging.DEBUG) + # For instance, show only warnings and above + logging.getLogger('telethon').setLevel(level=logging.WARNING) + + +__ https://docs.python.org/3/library/logging.html +__ https://docs.python.org/3/howto/logging.html#configuring-logging-for-a-library +__ https://docs.python.org/3/howto/logging.html diff --git a/readthedocs/extra/troubleshooting/rpc-errors.rst b/readthedocs/extra/troubleshooting/rpc-errors.rst index 6e8a59f0..3618fb9a 100644 --- a/readthedocs/extra/troubleshooting/rpc-errors.rst +++ b/readthedocs/extra/troubleshooting/rpc-errors.rst @@ -3,9 +3,9 @@ RPC Errors ========== RPC stands for Remote Procedure Call, and when Telethon raises an -``RPCError``, it’s most likely because you have invoked some of the API +``RPCError``, it's most likely because you have invoked some of the API methods incorrectly (wrong parameters, wrong permissions, or even -something went wrong on Telegram’s server). The most common are: +something went wrong on Telegram's server). The most common are: - ``FloodError`` (420), the same request was repeated many times. Must wait ``.seconds``. @@ -13,7 +13,7 @@ something went wrong on Telegram’s server). The most common are: verification on Telegram. - ``CdnFileTamperedError``, if the media you were trying to download from a CDN has been altered. -- ``ChatAdminRequiredError``, you don’t have permissions to perform +- ``ChatAdminRequiredError``, you don't have permissions to perform said operation on a chat or channel. Try avoiding filters, i.e. when searching messages. @@ -22,6 +22,6 @@ The generic classes for different error codes are: \* ``InvalidDCError`` ``BadRequestError`` (400), the request contained errors. \* ``UnauthorizedError`` (401), the user is not authorized yet. \* ``ForbiddenError`` (403), privacy violation error. \* ``NotFoundError`` -(404), make sure you’re invoking ``Request``\ ’s! +(404), make sure you're invoking ``Request``\ 's! -If the error is not recognised, it will only be an ``RPCError``. \ No newline at end of file +If the error is not recognised, it will only be an ``RPCError``. diff --git a/readthedocs/extra/wall-of-shame.rst b/readthedocs/extra/wall-of-shame.rst new file mode 100644 index 00000000..b3c9a028 --- /dev/null +++ b/readthedocs/extra/wall-of-shame.rst @@ -0,0 +1,57 @@ +This project has an +`issues `__ section for +you to file **issues** whenever you encounter any when working with the +library. Said section is **not** for issues on *your* program but rather +issues with Telethon itself. + +If you have not made the effort to 1. `read through the +wiki `__ and 2. `look for +the method you need `__, you +will end up on the `Wall of +Shame `__, +i.e. all issues labeled +`"RTFM" `__: + +> > **rtfm** +> > Literally "Read The F\ **king Manual"; a term showing the +frustration of being bothered with questions so trivial that the asker +could have quickly figured out the answer on their own with minimal +effort, usually by reading readily-available documents. People who +say"RTFM!" might be considered rude, but the true rude ones are the +annoying people who take absolutely no self-responibility and expect to +have all the answers handed to them personally. +> > *"Damn, that's the twelveth time that somebody posted this question +to the messageboard today! RTFM, already!"* +> > **\ by Bill M. July 27, 2004*\* + +If you have indeed read the wiki, and have tried looking for the method, +and yet you didn't find what you need, **that's fine**. Telegram's API +can have some obscure names at times, and for this reason, there is a +`"question" +label `__ +with questions that are okay to ask. Just state what you've tried so +that we know you've made an effort, or you'll go to the Wall of Shame. + +Of course, if the issue you're going to open is not even a question but +a real issue with the library (thankfully, most of the issues have been +that!), you won't end up here. Don't worry. + +Current winner +-------------- + +The current winner is `issue +213 `__: + +**Issue:** + + .. figure:: https://user-images.githubusercontent.com/6297805/29822978-9a9a6ef0-8ccd-11e7-9ec5-934ea0f57681.jpg + :alt: Winner issue + + Winner issue + +**Answer:** + + .. figure:: https://user-images.githubusercontent.com/6297805/29822983-9d523402-8ccd-11e7-9fb1-5783740ee366.jpg + :alt: Winner issue answer + + Winner issue answer diff --git a/readthedocs/index.rst b/readthedocs/index.rst index b5c77e6b..8e5c6053 100644 --- a/readthedocs/index.rst +++ b/readthedocs/index.rst @@ -3,11 +3,14 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. +==================================== Welcome to Telethon's documentation! ==================================== -Pure Python 3 Telegram client library. Official Site `here `_. +Pure Python 3 Telegram client library. +Official Site `here `_. +Please follow the links below to get you started. .. _installation-and-usage: @@ -19,10 +22,9 @@ Pure Python 3 Telegram client library. Official Site `here Date: Fri, 5 Jan 2018 01:03:57 +0100 Subject: [PATCH 76/78] Update README to point to RTD instead GitHub's wiki --- README.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index c4b9b7e8..f524384e 100755 --- a/README.rst +++ b/README.rst @@ -54,7 +54,7 @@ Doing stuff Next steps ---------- -Do you like how Telethon looks? Check the -`wiki over GitHub `_ for a -more in-depth explanation, with examples, troubleshooting issues, and more -useful information. +Do you like how Telethon looks? Check out +`Read The Docs `_ +for a more in-depth explanation, with examples, +troubleshooting issues, and more useful information. From a489b4b18b76c0dccf22b85880505fd5ea54838e Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 5 Jan 2018 13:30:21 +0100 Subject: [PATCH 77/78] Clean up some more twirks on RTD and update docstrings --- readthedocs/extra/basic/creating-a-client.rst | 6 +- readthedocs/extra/basic/entities.rst | 14 +- readthedocs/extra/basic/getting-started.rst | 3 +- readthedocs/extra/basic/installation.rst | 1 - readthedocs/extra/developing/api-status.rst | 2 +- .../extra/troubleshooting/rpc-errors.rst | 4 +- readthedocs/extra/wall-of-shame.rst | 5 + telethon/telegram_client.py | 477 +++++++++++------- 8 files changed, 307 insertions(+), 205 deletions(-) diff --git a/readthedocs/extra/basic/creating-a-client.rst b/readthedocs/extra/basic/creating-a-client.rst index 58f36125..81e19c83 100644 --- a/readthedocs/extra/basic/creating-a-client.rst +++ b/readthedocs/extra/basic/creating-a-client.rst @@ -98,7 +98,7 @@ Two Factor Authorization (2FA) If you have Two Factor Authorization (from now on, 2FA) enabled on your account, calling :meth:`telethon.TelegramClient.sign_in` will raise a -`SessionPasswordNeededError`. When this happens, just +``SessionPasswordNeededError``. When this happens, just :meth:`telethon.TelegramClient.sign_in` again with a ``password=``: .. code-block:: python @@ -113,7 +113,7 @@ account, calling :meth:`telethon.TelegramClient.sign_in` will raise a client.sign_in(password=getpass.getpass()) -If you don't have 2FA enabled, but you would like to do so through Telethon, +If you don't have 2FA enabled, but you would like to do so through the library, take as example the following code snippet: .. code-block:: python @@ -146,4 +146,4 @@ for the tip! __ https://github.com/Anorov/PySocks#installation -__ https://github.com/Anorov/PySocks#usage-1%3E +__ https://github.com/Anorov/PySocks#usage-1 diff --git a/readthedocs/extra/basic/entities.rst b/readthedocs/extra/basic/entities.rst index c03ec6ce..bc87539a 100644 --- a/readthedocs/extra/basic/entities.rst +++ b/readthedocs/extra/basic/entities.rst @@ -34,22 +34,22 @@ you're able to just do this: .. code-block:: python - # dialogs are the "conversations you have open" - # this method returns a list of Dialog, which - # have the .entity attribute and other information. + # Dialogs are the "conversations you have open". + # This method returns a list of Dialog, which + # has the .entity attribute and other information. dialogs = client.get_dialogs(limit=200) - # all of these work and do the same + # All of these work and do the same. lonami = client.get_entity('lonami') lonami = client.get_entity('t.me/lonami') lonami = client.get_entity('https://telegram.dog/lonami') - # other kind of entities + # Other kind of entities. channel = client.get_entity('telegram.me/joinchat/AAAAAEkk2WdoDrB4-Q8-gg') contact = client.get_entity('+34xxxxxxxxx') friend = client.get_entity(friend_id) - # using peers/input peers (note that the API may return these) + # Using Peer/InputPeer (note that the API may return these) # users, chats and channels may all have the same ID, so it's # necessary to wrap (at least) chat and channels inside Peer. from telethon.tl.types import PeerUser, PeerChat, PeerChannel @@ -79,7 +79,7 @@ possible, making zero API calls most of the time. When a request is made, if you provided the full entity, e.g. an ``User``, the library will convert it to the required ``InputPeer`` automatically for you. -**You should always favour ``.get_input_entity()``** over ``.get_entity()`` +**You should always favour** ``.get_input_entity()`` **over** ``.get_entity()`` for this reason! Calling the latter will always make an API call to get the most recent information about said entity, but invoking requests don't need this information, just the ``InputPeer``. Only use ``.get_entity()`` diff --git a/readthedocs/extra/basic/getting-started.rst b/readthedocs/extra/basic/getting-started.rst index de0b3baf..88a6247c 100644 --- a/readthedocs/extra/basic/getting-started.rst +++ b/readthedocs/extra/basic/getting-started.rst @@ -50,8 +50,7 @@ Basic Usage client.send_file('username', '/home/myself/Pictures/holidays.jpg') client.download_profile_photo(me) - total, messages, senders = client.get_message_history('username') + messages = client.get_message_history('username') client.download_media(messages[0]) **More details**: :ref:`telegram-client` - diff --git a/readthedocs/extra/basic/installation.rst b/readthedocs/extra/basic/installation.rst index 03aed393..b4fb1ac2 100644 --- a/readthedocs/extra/basic/installation.rst +++ b/readthedocs/extra/basic/installation.rst @@ -65,4 +65,3 @@ __ https://github.com/ricmoo/pyaes __ https://pypi.python.org/pypi/pyaes __ https://github.com/sybrenstuvel/python-rsa/ __ https://pypi.python.org/pypi/rsa/3.4.2 -__ https://github.com/LonamiWebs/Telethon/issues/199 diff --git a/readthedocs/extra/developing/api-status.rst b/readthedocs/extra/developing/api-status.rst index b5092dad..492340a4 100644 --- a/readthedocs/extra/developing/api-status.rst +++ b/readthedocs/extra/developing/api-status.rst @@ -34,7 +34,7 @@ instance through ``curl``. A JSON response will be returned. curl https://rpc.pwrtelegram.xyz/?for=messages.sendMessage -**Number of ``RPC_CALL_FAIL``\ 's**: +**Number of** ``RPC_CALL_FAIL``: .. code:: bash diff --git a/readthedocs/extra/troubleshooting/rpc-errors.rst b/readthedocs/extra/troubleshooting/rpc-errors.rst index 3618fb9a..55a21d7b 100644 --- a/readthedocs/extra/troubleshooting/rpc-errors.rst +++ b/readthedocs/extra/troubleshooting/rpc-errors.rst @@ -7,8 +7,8 @@ RPC stands for Remote Procedure Call, and when Telethon raises an methods incorrectly (wrong parameters, wrong permissions, or even something went wrong on Telegram's server). The most common are: -- ``FloodError`` (420), the same request was repeated many times. Must - wait ``.seconds``. +- ``FloodWaitError`` (420), the same request was repeated many times. + Must wait ``.seconds`` (you can access this parameter). - ``SessionPasswordNeededError``, if you have setup two-steps verification on Telegram. - ``CdnFileTamperedError``, if the media you were trying to download diff --git a/readthedocs/extra/wall-of-shame.rst b/readthedocs/extra/wall-of-shame.rst index b3c9a028..95ad3e04 100644 --- a/readthedocs/extra/wall-of-shame.rst +++ b/readthedocs/extra/wall-of-shame.rst @@ -1,3 +1,8 @@ +============= +Wall of Shame +============= + + This project has an `issues `__ section for you to file **issues** whenever you encounter any when working with the diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 3be2ac62..7d17cad1 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -59,11 +59,66 @@ from .extensions import markdown class TelegramClient(TelegramBareClient): - """Full featured TelegramClient meant to extend the basic functionality - + """ + Initializes the Telegram client with the specified API ID and Hash. - As opposed to the TelegramBareClient, this one features downloading - media from different data centers, starting a second thread to - handle updates, and some very common functionality. + Args: + session (:obj:`str` | :obj:`Session` | :obj:`None`): + The file name of the session file to be used if a string is + given (it may be a full path), or the Session instance to be + used otherwise. If it's ``None``, the session will not be saved, + and you should call :meth:`.log_out()` when you're done. + + api_id (:obj:`int` | :obj:`str`): + The API ID you obtained from https://my.telegram.org. + + api_hash (:obj:`str`): + The API ID you obtained from https://my.telegram.org. + + connection_mode (:obj:`ConnectionMode`, optional): + The connection mode to be used when creating a new connection + to the servers. Defaults to the ``TCP_FULL`` mode. + This will only affect how messages are sent over the network + and how much processing is required before sending them. + + use_ipv6 (:obj:`bool`, optional): + Whether to connect to the servers through IPv6 or not. + By default this is ``False`` as IPv6 support is not + too widespread yet. + + proxy (:obj:`tuple` | :obj:`dict`, optional): + A tuple consisting of ``(socks.SOCKS5, 'host', port)``. + See https://github.com/Anorov/PySocks#usage-1 for more. + + update_workers (:obj:`int`, optional): + If specified, represents how many extra threads should + be spawned to handle incoming updates, and updates will + be kept in memory until they are processed. Note that + you must set this to at least ``0`` if you want to be + able to process updates through :meth:`updates.poll()`. + + timeout (:obj:`int` | :obj:`float` | :obj:`timedelta`, optional): + The timeout to be used when receiving responses from + the network. Defaults to 5 seconds. + + spawn_read_thread (:obj:`bool`, optional): + Whether to use an extra background thread or not. Defaults + to ``True`` so receiving items from the network happens + instantly, as soon as they arrive. Can still be disabled + if you want to run the library without any additional thread. + + Kwargs: + Extra parameters will be forwarded to the ``Session`` file. + Most relevant parameters are: + + .. code-block:: python + + device_model = platform.node() + system_version = platform.system() + app_version = TelegramClient.__version__ + lang_code = 'en' + system_lang_code = lang_code + report_errors = True """ # region Initialization @@ -76,42 +131,6 @@ class TelegramClient(TelegramBareClient): timeout=timedelta(seconds=5), spawn_read_thread=True, **kwargs): - """Initializes the Telegram client with the specified API ID and Hash. - - Session can either be a `str` object (filename for the .session) - or it can be a `Session` instance (in which case list_sessions() - would probably not work). Pass 'None' for it to be a temporary - session - remember to '.log_out()'! - - The 'connection_mode' should be any value under ConnectionMode. - This will only affect how messages are sent over the network - and how much processing is required before sending them. - - The integer 'update_workers' represents depending on its value: - is None: Updates will *not* be stored in memory. - = 0: Another thread is responsible for calling self.updates.poll() - > 0: 'update_workers' background threads will be spawned, any - any of them will invoke all the self.updates.handlers. - - If 'spawn_read_thread', a background thread will be started once - an authorized user has been logged in to Telegram to read items - (such as updates and responses) from the network as soon as they - occur, which will speed things up. - - If you don't want to spawn any additional threads, pending updates - will be read and processed accordingly after invoking a request - and not immediately. This is useful if you don't care about updates - at all and have set 'update_workers=None'. - - If more named arguments are provided as **kwargs, they will be - used to update the Session instance. Most common settings are: - device_model = platform.node() - system_version = platform.system() - app_version = TelegramClient.__version__ - lang_code = 'en' - system_lang_code = lang_code - report_errors = True - """ super().__init__( session, api_id, api_hash, connection_mode=connection_mode, @@ -134,13 +153,17 @@ class TelegramClient(TelegramBareClient): # region Authorization requests def send_code_request(self, phone, force_sms=False): - """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. - :param bool force_sms: - Whether to force sending as SMS. - :return auth.SentCode: + Args: + phone (:obj:`str` | :obj:`int`): + The phone to which the code will be sent. + + force_sms (:obj:`bool`, optional): + Whether to force sending as SMS. + + Returns: Information about the result of the request. """ phone = utils.parse_phone(phone) or self._phone @@ -165,23 +188,30 @@ class TelegramClient(TelegramBareClient): Starts or completes the sign in process with the given phone number or code that Telegram sent. - :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. + Args: + phone (:obj:`str` | :obj:`int`): + The phone to send the code to if no code was provided, + or to override the phone that was previously used with + these requests. - :return auth.SentCode | User: - The signed in user, or the information about .send_code_request(). + code (:obj:`str` | :obj:`int`): + The code that Telegram sent. + + password (:obj:`str`): + 2FA password, should be used if a previous call raised + SessionPasswordNeededError. + + bot_token (:obj:`str`): + Used to sign in as a bot. Not all requests will be available. + This should be the hash the @BotFather gave you. + + phone_code_hash (:obj:`str`): + The hash returned by .send_code_request. This can be set to None + to use the last hash known. + + Returns: + The signed in user, or the information about + :meth:`.send_code_request()`. """ if phone and not code: @@ -229,10 +259,18 @@ class TelegramClient(TelegramBareClient): 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. + Args: + code (:obj:`str` | :obj:`int`): + The code sent by Telegram + + first_name (:obj:`str`): + The first name to be used by the new account. + + last_name (:obj:`str`, optional) + Optional last name. + + Returns: + The new created user. """ result = self(SignUpRequest( phone_number=self._phone, @@ -246,9 +284,11 @@ class TelegramClient(TelegramBareClient): return result.user def log_out(self): - """Logs out Telegram and deletes the current *.session file. + """ + Logs out Telegram and deletes the current *.session file. - :return bool: True if the operation was successful. + Returns: + True if the operation was successful. """ try: self(LogOutRequest()) @@ -265,7 +305,8 @@ class TelegramClient(TelegramBareClient): Gets "me" (the self user) which is currently authenticated, or None if the request fails (hence, not authenticated). - :return User: Your own user. + Returns: + Your own user. """ try: return self(GetUsersRequest([InputUserSelf()]))[0] @@ -284,19 +325,24 @@ class TelegramClient(TelegramBareClient): """ Gets N "dialogs" (open "chats" or conversations with other people). - :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. + Args: + limit (:obj:`int` | :obj:`None`): + How many dialogs to be retrieved as maximum. Can be set to + ``None`` to retrieve all dialogs. Note that this may take + whole minutes if you have hundreds of dialogs, as Telegram + will tell the library to slow down through a + ``FloodWaitError``. - :return UserList[telethon.tl.custom.Dialog]: + offset_date (:obj:`datetime`, optional): + The offset date to be used. + + offset_id (:obj:`int`, optional): + The message ID to be used as an offset. + + offset_peer (:obj:`InputPeer`, optional): + The peer to be used as an offset. + + Returns: A list dialogs, with an additional .total attribute on the list. """ limit = float('inf') if limit is None else int(limit) @@ -351,11 +397,10 @@ 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()`. - - :return List[telethon.tl.custom.Draft]: A list of open drafts + Returns: + A list of custom ``Draft`` objects that are easy to work with: + You can call :meth:`draft.set_message('text')` to change the message, + or delete it through :meth:`draft.delete()`. """ response = self(GetAllDraftsRequest()) self.session.process_entities(response) @@ -365,6 +410,7 @@ class TelegramClient(TelegramBareClient): @staticmethod def _get_response_message(request, result): + """Extracts the response message known a request and Update result""" # Telegram seems to send updateMessageID first, then updateNewMessage, # however let's not rely on that just in case. msg_id = None @@ -388,19 +434,26 @@ class TelegramClient(TelegramBareClient): """ Sends the given message to the specified entity (user/chat/channel). - :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 str parse_mode: - Can be 'md' or 'markdown' for markdown-like parsing, in a similar - fashion how official clients work. - :param link_preview: - Should the link preview be shown? + Args: + entity (:obj:`entity`): + To who will it be sent. - :return Message: the sent message + message (:obj:`str`): + The message to be sent. + + reply_to (:obj:`int` | :obj:`Message`, optional): + Whether to reply to a message or not. If an integer is provided, + it should be the ID of the message that it should reply to. + + parse_mode (:obj:`str`, optional): + Can be 'md' or 'markdown' for markdown-like parsing, in a similar + fashion how official clients work. + + link_preview (:obj:`bool`, optional): + Should the link preview be shown? + + Returns: + the sent message """ entity = self.get_input_entity(entity) if parse_mode: @@ -435,21 +488,25 @@ class TelegramClient(TelegramBareClient): def delete_messages(self, entity, message_ids, revoke=True): """ - Deletes a message from a chat, optionally "for everyone" with argument - `revoke` set to `True`. + Deletes a message from a chat, optionally "for everyone". - The `revoke` argument has no effect for Channels and Megagroups, - where it inherently behaves as being `True`. + Args: + entity (:obj:`entity`): + From who the message will be deleted. This can actually + be ``None`` for normal chats, but **must** be present + for channels and megagroups. - Note: The `entity` argument can be `None` for normal chats, but it's - 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. + message_ids (:obj:`list` | :obj:`int` | :obj:`Message`): + The IDs (or ID) or messages to be deleted. - :param entity: ID or Entity of the chat - :param list message_ids: ID(s) or `Message` object(s) of the message(s) to delete - :param revoke: Delete the message for everyone or just this client - :returns .messages.AffectedMessages: Messages affected by deletion. + revoke (:obj:`bool`, optional): + Whether the message should be deleted for everyone or not. + By default it has the opposite behaviour of official clients, + and it will delete the message for everyone. + This has no effect on channels or megagroups. + + Returns: + The affected messages. """ if not isinstance(message_ids, list): @@ -477,34 +534,45 @@ class TelegramClient(TelegramBareClient): """ Gets the message history for the specified entity - :param entity: - The entity from whom to retrieve the message history. - :param limit: - Number of messages to be retrieved. Due to limitations with the API - retrieving more than 3000 messages will take longer than half a - minute (or even more based on previous calls). The limit may also - be None, which would eventually return the whole history. - :param offset_date: - Offset date (messages *previous* to this date will be retrieved). - :param offset_id: - Offset message ID (only messages *previous* to the given ID will - be retrieved). - :param max_id: - All the messages with a higher (newer) ID or equal to this will - be excluded - :param min_id: - All the messages with a lower (older) ID or equal to this will - be excluded. - :param add_offset: - Additional message offset - (all of the specified offsets + this offset = older messages). + Args: + entity (:obj:`entity`): + The entity from whom to retrieve the message history. - :return: A list of messages with extra attributes: - .total = (on the list) total amount of messages sent - .sender = entity of the sender - .fwd_from.sender = if fwd_from, who sent it originally - .fwd_from.channel = if fwd_from, original channel - .to = entity to which the message was sent + limit (:obj:`int` | :obj:`None`, optional): + Number of messages to be retrieved. Due to limitations with + the API retrieving more than 3000 messages will take longer + than half a minute (or even more based on previous calls). + The limit may also be ``None``, which would eventually return + the whole history. + + offset_date (:obj:`datetime`): + Offset date (messages *previous* to this date will be + retrieved). Exclusive. + + offset_id (:obj:`int`): + Offset message ID (only messages *previous* to the given + ID will be retrieved). Exclusive. + + max_id (:obj:`int`): + All the messages with a higher (newer) ID or equal to this will + be excluded + + min_id (:obj:`int`): + All the messages with a lower (older) ID or equal to this will + be excluded. + + add_offset (:obj:`int`): + Additional message offset (all of the specified offsets + + this offset = older messages). + + Returns: + A list of messages with extra attributes: + + * ``.total`` = (on the list) total amount of messages sent. + * ``.sender`` = entity of the sender. + * ``.fwd_from.sender`` = if fwd_from, who sent it originally. + * ``.fwd_from.channel`` = if fwd_from, original channel. + * ``.to`` = entity to which the message was sent. """ entity = self.get_input_entity(entity) limit = float('inf') if limit is None else int(limit) @@ -585,11 +653,16 @@ class TelegramClient(TelegramBareClient): Sends a "read acknowledge" (i.e., notifying the given peer that we've read their messages, also known as the "double check"). - :param entity: The chat where these messages are located. - :param message: Either a list of messages or a single message. - :param max_id: Overrides messages, until which message should the - acknowledge should be sent. - :return: + Args: + entity (:obj:`entity`): + The chat where these messages are located. + + message (:obj:`list` | :obj:`Message`): + Either a list of messages or a single message. + + max_id (:obj:`int`): + Overrides messages, until which message should the + acknowledge should be sent. """ if max_id is None: if not messages: @@ -636,35 +709,47 @@ class TelegramClient(TelegramBareClient): """ Sends a file to the specified entity. - :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". + Args: + entity (:obj:`entity`): + Who will receive the file. - 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 thumb: - Optional thumbnail (for videos). - :param kwargs: + file (:obj:`str` | :obj:`bytes` | :obj:`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. + + caption (:obj:`str`, optional): + Optional caption for the sent media message. + + force_document (:obj:`bool`, optional): + 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. + + progress_callback (:obj:`callable`, optional): + A callback function accepting two parameters: + ``(sent bytes, total)``. + + reply_to (:obj:`int` | :obj:`Message`): + Same as reply_to from .send_message(). + + attributes (:obj:`list`, optional): + Optional attributes that override the inferred ones, like + ``DocumentAttributeFilename`` and so on. + + thumb (:obj:`str` | :obj:`bytes` | :obj:`file`): + Optional thumbnail (for videos). + + 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. - :return: + + Returns: + The message containing the sent file. """ as_photo = False if isinstance(file, str): @@ -766,15 +851,19 @@ class TelegramClient(TelegramBareClient): """ Downloads the profile photo of the given entity (user/chat/channel). - :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 + Args: + entity (:obj:`entity`): + From who the photo will be downloaded. + + file (:obj:`str` | :obj:`file`, optional): + The output file path, directory, or stream-like object. + If the path exists and is a file, it will be overwritten. + + download_big (:obj:`bool`, optional): + Whether to use the big version of the available photos. + + Returns: + ``None`` if no photo was provided, or if it was Empty. On success the file path is returned since it may differ from the one given. """ photo = entity @@ -843,14 +932,21 @@ class TelegramClient(TelegramBareClient): def download_media(self, message, file=None, progress_callback=None): """ Downloads the given media, or the media from a specified Message. - :param message: + + message (:obj:`Message` | :obj:`Media`): The media or message containing the media that will be downloaded. - :param file: + + file (:obj:`str` | :obj:`file`, optional): 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: + + progress_callback (:obj:`callable`, optional): + A callback function accepting two parameters: + ``(recv bytes, total)``. + + Returns: + ``None`` if no media was provided, or if it was Empty. On success + the file path is returned since it may differ from the one given. """ # TODO This won't work for messageService if isinstance(message, Message): @@ -1038,7 +1134,7 @@ class TelegramClient(TelegramBareClient): """ Turns the given entity into a valid Telegram user or chat. - :param entity: + entity (:obj:`str` | :obj:`int` | :obj:`Peer` | :obj:`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. @@ -1053,7 +1149,9 @@ class TelegramClient(TelegramBareClient): If the entity is neither, and it's not a TLObject, an error will be raised. - :return: User, Chat or Channel corresponding to the input entity. + Returns: + ``User``, ``Chat`` or ``Channel`` corresponding to the input + entity. """ if not isinstance(entity, str) and hasattr(entity, '__iter__'): single = False @@ -1145,19 +1243,20 @@ class TelegramClient(TelegramBareClient): use this kind of InputUser, InputChat and so on, so this is the most suitable call to make for those cases. - :param peer: + entity (:obj:`str` | :obj:`int` | :obj:`Peer` | :obj:`InputPeer`): The integer ID of an user or otherwise either of a - PeerUser, PeerChat or PeerChannel, for which to get its - Input* version. + ``PeerUser``, ``PeerChat`` or ``PeerChannel``, for + which to get its ``Input*`` version. - If this Peer hasn't been seen before by the library, the top + 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: InputPeerUser, InputPeerChat or InputPeerChannel. + Returns: + ``InputPeerUser``, ``InputPeerChat`` or ``InputPeerChannel``. """ try: # First try to get the entity from cache, otherwise figure it out From c4e26c95f58673fb5316b649801ac16356719090 Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Fri, 5 Jan 2018 15:33:25 +0100 Subject: [PATCH 78/78] Always cache files smaller than 10MB, now in the database This removes the need for a .clear_cache() method as now files are identified by their MD5 (which needs to be calculated always) and their file size (to make collisions even more unlikely) instead using the file path (which can now change). --- telethon/telegram_bare_client.py | 56 +++++++++++++++++++------------- telethon/telegram_client.py | 17 ++-------- telethon/tl/session.py | 44 +++++++++++++++++++++++-- 3 files changed, 76 insertions(+), 41 deletions(-) diff --git a/telethon/telegram_bare_client.py b/telethon/telegram_bare_client.py index 93453322..ab6d3bbb 100644 --- a/telethon/telegram_bare_client.py +++ b/telethon/telegram_bare_client.py @@ -158,9 +158,6 @@ class TelegramBareClient: # See https://core.telegram.org/api/invoking#saving-client-info. self._first_request = True - # Uploaded files cache so subsequent calls are instant - self._upload_cache = {} - # Constantly read for results and updates from within the main client, # if the user has left enabled such option. self._spawn_read_thread = spawn_read_thread @@ -639,6 +636,7 @@ class TelegramBareClient: file = file.read() file_size = len(file) + # File will now either be a string or bytes if not part_size_kb: part_size_kb = get_appropriated_part_size(file_size) @@ -649,18 +647,40 @@ class TelegramBareClient: if part_size % 1024 != 0: raise ValueError('The part size must be evenly divisible by 1024') + # Set a default file name if None was specified + file_id = utils.generate_random_long() + if not file_name: + if isinstance(file, str): + file_name = os.path.basename(file) + else: + file_name = str(file_id) + # Determine whether the file is too big (over 10MB) or not # Telegram does make a distinction between smaller or larger files is_large = file_size > 10 * 1024 * 1024 + if not is_large: + # Calculate the MD5 hash before anything else. + # As this needs to be done always for small files, + # might as well do it before anything else and + # check the cache. + if isinstance(file, str): + with open(file, 'rb') as stream: + file = stream.read() + hash_md5 = md5(file) + tuple_ = self.session.get_file(hash_md5.digest(), file_size) + if tuple_: + __log__.info('File was already cached, not uploading again') + return InputFile(name=file_name, + md5_checksum=tuple_[0], id=tuple_[2], parts=tuple_[3]) + else: + hash_md5 = None + part_count = (file_size + part_size - 1) // part_size - - file_id = utils.generate_random_long() - hash_md5 = md5() - __log__.info('Uploading file of %d bytes in %d chunks of %d', file_size, part_count, part_size) - stream = open(file, 'rb') if isinstance(file, str) else BytesIO(file) - try: + + with open(file, 'rb') if isinstance(file, str) else BytesIO(file) \ + as stream: for part_index in range(part_count): # Read the file by in chunks of size part_size part = stream.read(part_size) @@ -675,29 +695,19 @@ class TelegramBareClient: result = self(request) if result: - __log__.debug('Uploaded %d/%d', part_index, part_count) - if not is_large: - # No need to update the hash if it's a large file - hash_md5.update(part) - + __log__.debug('Uploaded %d/%d', part_index + 1, part_count) if progress_callback: progress_callback(stream.tell(), file_size) else: raise RuntimeError( 'Failed to upload file part {}.'.format(part_index)) - finally: - stream.close() - - # Set a default file name if None was specified - if not file_name: - if isinstance(file, str): - file_name = os.path.basename(file) - else: - file_name = str(file_id) if is_large: return InputFileBig(file_id, part_count, file_name) else: + self.session.cache_file( + hash_md5.digest(), file_size, file_id, part_count) + return InputFile(file_id, part_count, file_name, md5_checksum=hash_md5.hexdigest()) diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 7d17cad1..7b8a84fa 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -759,13 +759,8 @@ class TelegramClient(TelegramBareClient): for ext in ('.png', '.jpg', '.gif', '.jpeg') ) - file_hash = hash(file) - if file_hash in self._upload_cache: - file_handle = self._upload_cache[file_hash] - else: - self._upload_cache[file_hash] = file_handle = self.upload_file( - file, progress_callback=progress_callback - ) + file_handle = self.upload_file( + file, progress_callback=progress_callback) if as_photo and not force_document: media = InputMediaUploadedPhoto(file_handle, caption) @@ -835,14 +830,6 @@ class TelegramClient(TelegramBareClient): reply_to=reply_to, is_voice_note=()) # empty tuple is enough - def clear_file_cache(self): - """Calls to .send_file() will cache the remote location of the - uploaded files so that subsequent files can be immediate, so - uploading the same file path will result in using the cached - version. To avoid this a call to this method should be made. - """ - self._upload_cache.clear() - # endregion # region Downloading media requests diff --git a/telethon/tl/session.py b/telethon/tl/session.py index 3fa13d23..59794f16 100644 --- a/telethon/tl/session.py +++ b/telethon/tl/session.py @@ -2,7 +2,6 @@ import json import os import platform import sqlite3 -import struct import time from base64 import b64decode from os.path import isfile as file_exists @@ -16,7 +15,7 @@ from ..tl.types import ( ) EXTENSION = '.session' -CURRENT_VERSION = 1 # database version +CURRENT_VERSION = 2 # database version class Session: @@ -93,6 +92,8 @@ class Session: version = c.fetchone()[0] if version != CURRENT_VERSION: self._upgrade_database(old=version) + c.execute("delete from version") + c.execute("insert into version values (?)", (CURRENT_VERSION,)) self.save() # These values will be saved @@ -125,6 +126,17 @@ class Session: name text ) without rowid""" ) + # Save file_size along with md5_digest + # to make collisions even more unlikely. + c.execute( + """create table sent_files ( + md5_digest blob, + file_size integer, + file_id integer, + part_count integer, + primary key(md5_digest, file_size) + ) without rowid""" + ) # Migrating from JSON -> new table and may have entities if entities: c.executemany( @@ -158,7 +170,17 @@ class Session: return [] # No entities def _upgrade_database(self, old): - pass + if old == 1: + self._conn.execute( + """create table sent_files ( + md5_digest blob, + file_size integer, + file_id integer, + part_count integer, + primary key(md5_digest, file_size) + ) without rowid""" + ) + old = 2 # Data from sessions should be kept as properties # not to fetch the database every time we need it @@ -370,3 +392,19 @@ class Session: return InputPeerChannel(i, h) else: raise ValueError('Could not find input entity with key ', key) + + # File processing + + def get_file(self, md5_digest, file_size): + return self._conn.execute( + 'select * from sent_files ' + 'where md5_digest = ? and file_size = ?', (md5_digest, file_size) + ).fetchone() + + def cache_file(self, md5_digest, file_size, file_id, part_count): + with self._db_lock: + self._conn.execute( + 'insert into sent_files values (?,?,?,?)', + (md5_digest, file_size, file_id, part_count) + ) + self.save()