mirror of
https://github.com/LonamiWebs/Telethon.git
synced 2025-08-08 12:59:46 +00:00
Totally refactored source files location
Now it *should* be easier to turn Telethon into a pip package
This commit is contained in:
3
telethon/__init__.py
Normal file
3
telethon/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .errors import *
|
||||
from .telegram_client import TelegramClient
|
||||
from .interactive_telegram_client import InteractiveTelegramClient
|
4
telethon/crypto/__init__.py
Normal file
4
telethon/crypto/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .aes import AES
|
||||
from .rsa import RSA, RSAServerKey
|
||||
from .auth_key import AuthKey
|
||||
from .factorizator import Factorizator
|
66
telethon/crypto/aes.py
Normal file
66
telethon/crypto/aes.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import os
|
||||
import pyaes
|
||||
|
||||
|
||||
class AES:
|
||||
@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"""
|
||||
iv1 = iv[:len(iv)//2]
|
||||
iv2 = iv[len(iv)//2:]
|
||||
|
||||
aes = pyaes.AES(key)
|
||||
|
||||
plain_text = []
|
||||
blocks_count = len(cipher_text) // 16
|
||||
|
||||
cipher_text_block = [0] * 16
|
||||
for block_index in range(blocks_count):
|
||||
for i in range(16):
|
||||
cipher_text_block[i] = cipher_text[block_index * 16 + i] ^ iv2[i]
|
||||
|
||||
plain_text_block = aes.decrypt(cipher_text_block)
|
||||
|
||||
for i in range(16):
|
||||
plain_text_block[i] ^= iv1[i]
|
||||
|
||||
iv1 = cipher_text[block_index * 16:block_index * 16 + 16]
|
||||
iv2 = plain_text_block[:]
|
||||
|
||||
plain_text.extend(plain_text_block[:])
|
||||
|
||||
return bytes(plain_text)
|
||||
|
||||
@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"""
|
||||
|
||||
# Add random padding if and only if it's not evenly divisible by 16 already
|
||||
if len(plain_text) % 16 != 0:
|
||||
padding_count = 16 - len(plain_text) % 16
|
||||
plain_text += os.urandom(padding_count)
|
||||
|
||||
iv1 = iv[:len(iv)//2]
|
||||
iv2 = iv[len(iv)//2:]
|
||||
|
||||
aes = pyaes.AES(key)
|
||||
|
||||
cipher_text = []
|
||||
blocks_count = len(plain_text) // 16
|
||||
|
||||
for block_index in range(blocks_count):
|
||||
plain_text_block = list(plain_text[block_index * 16:block_index * 16 + 16])
|
||||
for i in range(16):
|
||||
plain_text_block[i] ^= iv1[i]
|
||||
|
||||
cipher_text_block = aes.encrypt(plain_text_block)
|
||||
|
||||
for i in range(16):
|
||||
cipher_text_block[i] ^= iv2[i]
|
||||
|
||||
iv1 = cipher_text_block[:]
|
||||
iv2 = plain_text[block_index * 16:block_index * 16 + 16]
|
||||
|
||||
cipher_text.extend(cipher_text_block[:])
|
||||
|
||||
return bytes(cipher_text)
|
22
telethon/crypto/auth_key.py
Executable file
22
telethon/crypto/auth_key.py
Executable file
@@ -0,0 +1,22 @@
|
||||
from telethon.utils import BinaryWriter, BinaryReader
|
||||
import telethon.helpers as utils
|
||||
|
||||
|
||||
class AuthKey:
|
||||
def __init__(self, data):
|
||||
self.key = data
|
||||
|
||||
with BinaryReader(utils.sha1(self.key)) as reader:
|
||||
self.aux_hash = reader.read_long(signed=False)
|
||||
reader.read(4)
|
||||
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"""
|
||||
with BinaryWriter() as writer:
|
||||
writer.write(new_nonce)
|
||||
writer.write_byte(number)
|
||||
writer.write_long(self.aux_hash, signed=False)
|
||||
|
||||
new_nonce_hash = utils.calc_msg_key(writer.get_bytes())
|
||||
return new_nonce_hash
|
62
telethon/crypto/factorizator.py
Executable file
62
telethon/crypto/factorizator.py
Executable file
@@ -0,0 +1,62 @@
|
||||
from random import randint
|
||||
|
||||
|
||||
class Factorizator:
|
||||
@staticmethod
|
||||
def find_small_multiplier_lopatin(what):
|
||||
"""Finds the small multiplier by using Lopatin's method"""
|
||||
g = 0
|
||||
for i in range(3):
|
||||
q = (randint(0, 127) & 15) + 17
|
||||
x = randint(0, 1000000000) + 1
|
||||
y = x
|
||||
lim = 1 << (i + 18)
|
||||
for j in range(1, lim):
|
||||
a, b, c = x, x, q
|
||||
while b != 0:
|
||||
if (b & 1) != 0:
|
||||
c += a
|
||||
if c >= what:
|
||||
c -= what
|
||||
a += a
|
||||
if a >= what:
|
||||
a -= what
|
||||
b >>= 1
|
||||
|
||||
x = c
|
||||
z = y - x if x < y else x - y
|
||||
g = Factorizator.gcd(z, what)
|
||||
if g != 1:
|
||||
break
|
||||
|
||||
if (j & (j - 1)) == 0:
|
||||
y = x
|
||||
|
||||
if g > 1:
|
||||
break
|
||||
|
||||
p = what // g
|
||||
return min(p, g)
|
||||
|
||||
@staticmethod
|
||||
def gcd(a, b):
|
||||
"""Calculates the greatest common divisor"""
|
||||
while a != 0 and b != 0:
|
||||
while b & 1 == 0:
|
||||
b >>= 1
|
||||
|
||||
while a & 1 == 0:
|
||||
a >>= 1
|
||||
|
||||
if a > b:
|
||||
a -= b
|
||||
else:
|
||||
b -= a
|
||||
|
||||
return a if b == 0 else b
|
||||
|
||||
@staticmethod
|
||||
def factorize(pq):
|
||||
"""Factorizes the given number and returns both the divisor and the number divided by the divisor"""
|
||||
divisor = Factorizator.find_small_multiplier_lopatin(pq)
|
||||
return divisor, pq // divisor
|
58
telethon/crypto/rsa.py
Executable file
58
telethon/crypto/rsa.py
Executable file
@@ -0,0 +1,58 @@
|
||||
from telethon.utils import BinaryWriter
|
||||
import telethon.helpers as utils
|
||||
import os
|
||||
|
||||
|
||||
class RSAServerKey:
|
||||
def __init__(self, fingerprint, m, e):
|
||||
self.fingerprint = fingerprint
|
||||
self.m = m
|
||||
self.e = e
|
||||
|
||||
def encrypt(self, data, offset=None, length=None):
|
||||
"""Encrypts the given data with the current key"""
|
||||
if offset is None:
|
||||
offset = 0
|
||||
if length is None:
|
||||
length = len(data)
|
||||
|
||||
with BinaryWriter() as writer:
|
||||
# Write SHA
|
||||
writer.write(utils.sha1(data[offset:offset+length]))
|
||||
# Write data
|
||||
writer.write(data[offset:offset+length])
|
||||
# Add padding if required
|
||||
if length < 235:
|
||||
writer.write(os.urandom(235 - length))
|
||||
|
||||
result = int.from_bytes(writer.get_bytes(), byteorder='big')
|
||||
result = pow(result, self.e, self.m)
|
||||
|
||||
# If the result byte count is less than 256, since the byte order is big,
|
||||
# the non-used bytes on the left will be 0 and act as padding,
|
||||
# without need of any additional checks
|
||||
return int.to_bytes(result, length=256, byteorder='big', signed=False)
|
||||
|
||||
|
||||
class RSA:
|
||||
_server_keys = {
|
||||
'216be86c022bb4c3':
|
||||
RSAServerKey('216be86c022bb4c3', int('C150023E2F70DB7985DED064759CFECF0AF328E69A41DAF4D6F01B538135A6F9'
|
||||
'1F8F8B2A0EC9BA9720CE352EFCF6C5680FFC424BD634864902DE0B4BD6D49F4E'
|
||||
'580230E3AE97D95C8B19442B3C0A10D8F5633FECEDD6926A7F6DAB0DDB7D457F'
|
||||
'9EA81B8465FCD6FFFEED114011DF91C059CAEDAF97625F6C96ECC74725556934'
|
||||
'EF781D866B34F011FCE4D835A090196E9A5F0E4449AF7EB697DDB9076494CA5F'
|
||||
'81104A305B6DD27665722C46B60E5DF680FB16B210607EF217652E60236C255F'
|
||||
'6A28315F4083A96791D7214BF64C1DF4FD0DB1944FB26A2A57031B32EEE64AD1'
|
||||
'5A8BA68885CDE74A5BFC920F6ABF59BA5C75506373E7130F9042DA922179251F',
|
||||
16), int('010001', 16))
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def encrypt(fingerprint, data, offset=None, length=None):
|
||||
"""Encrypts the given data given a fingerprint"""
|
||||
if fingerprint.lower() not in RSA._server_keys:
|
||||
return None
|
||||
|
||||
key = RSA._server_keys[fingerprint.lower()]
|
||||
return key.encrypt(data, offset, length)
|
214
telethon/errors.py
Normal file
214
telethon/errors.py
Normal file
@@ -0,0 +1,214 @@
|
||||
import re
|
||||
|
||||
|
||||
class ReadCancelledError(Exception):
|
||||
"""Occurs when a read operation was cancelled"""
|
||||
def __init__(self):
|
||||
super().__init__(self, 'You must run `python3 tl_generator.py` first. #ReadTheDocs!')
|
||||
|
||||
|
||||
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,
|
||||
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 '
|
||||
'that was supposed to be read with ID {}. Most likely, a TLObject '
|
||||
'was trying to be read when it should not be read.'
|
||||
.format(hex(invalid_constructor_id)))
|
||||
|
||||
self.invalid_constructor_id = invalid_constructor_id
|
||||
|
||||
|
||||
class InvalidDCError(Exception):
|
||||
def __init__(self, new_dc):
|
||||
super().__init__(self, 'Your phone number is registered to #{} DC. '
|
||||
'This should have been handled automatically; '
|
||||
'if it has not, please restart the app.'.format(new_dc))
|
||||
|
||||
self.new_dc = new_dc
|
||||
|
||||
|
||||
class InvalidChecksumError(Exception):
|
||||
def __init__(self, checksum, valid_checksum):
|
||||
super().__init__(self, 'Invalid checksum ({} when {} was expected). This packet should be skipped.'
|
||||
.format(checksum, valid_checksum))
|
||||
|
||||
self.checksum = checksum
|
||||
self.valid_checksum = valid_checksum
|
||||
|
||||
|
||||
class RPCError(Exception):
|
||||
|
||||
CodeMessages = {
|
||||
303: ('ERROR_SEE_OTHER', 'The request must be repeated, but directed to a different data center.'),
|
||||
|
||||
400: ('BAD_REQUEST', '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.'),
|
||||
|
||||
401: ('UNAUTHORIZED', 'There was an unauthorized attempt to use functionality available only to '
|
||||
'authorized users.'),
|
||||
|
||||
403: ('FORBIDDEN', 'Privacy violation. For example, an attempt to write a message to someone who '
|
||||
'has blacklisted the current user.'),
|
||||
|
||||
404: ('NOT_FOUND', 'An attempt to invoke a non-existent object, such as a method.'),
|
||||
|
||||
420: ('FLOOD', '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.'),
|
||||
|
||||
500: ('INTERNAL', 'An internal server error occurred while a request was being processed; '
|
||||
'for example, there was a disruption while accessing a database or file storage.')
|
||||
}
|
||||
|
||||
ErrorMessages = {
|
||||
# 303 ERROR_SEE_OTHER
|
||||
'FILE_MIGRATE_(\d+)': 'The file to be accessed is currently stored in a different data center (#{}).',
|
||||
|
||||
'PHONE_MIGRATE_(\d+)': 'The phone number a user is trying to use for authorization is associated '
|
||||
'with a different data center (#{}).',
|
||||
|
||||
'NETWORK_MIGRATE_(\d+)': 'The source IP address is associated with a different data center (#{}, '
|
||||
'for registration).',
|
||||
|
||||
'USER_MIGRATE_(\d+)': 'The user whose identity is being used to execute queries is associated with '
|
||||
'a different data center (#{} for registration).',
|
||||
|
||||
# 400 BAD_REQUEST
|
||||
'FIRSTNAME_INVALID': 'The first name is invalid.',
|
||||
|
||||
'LASTNAME_INVALID': 'The last name is invalid.',
|
||||
|
||||
'PHONE_NUMBER_INVALID': 'The phone number is invalid.',
|
||||
|
||||
'PHONE_CODE_HASH_EMPTY': 'The phone code hash is missing.',
|
||||
|
||||
'PHONE_CODE_EMPTY': 'The phone code is missing.',
|
||||
|
||||
'PHONE_CODE_INVALID': 'The phone code entered was invalid.',
|
||||
|
||||
'PHONE_CODE_EXPIRED': 'The confirmation code has expired.',
|
||||
|
||||
'API_ID_INVALID': 'The api_id/api_hash combination is invalid.',
|
||||
|
||||
'PHONE_NUMBER_OCCUPIED': 'The phone number is already in use.',
|
||||
|
||||
'PHONE_NUMBER_UNOCCUPIED': 'The phone number is not yet being used.',
|
||||
|
||||
'USERS_TOO_FEW': 'Not enough users (to create a chat, for example).',
|
||||
|
||||
'USERS_TOO_MUCH': 'The maximum number of users has been exceeded (to create a chat, for example).',
|
||||
|
||||
'TYPE_CONSTRUCTOR_INVALID': 'The type constructor is invalid.',
|
||||
|
||||
'FILE_PART_INVALID': 'The file part number is invalid.',
|
||||
|
||||
'FILE_PARTS_INVALID': 'The number of file parts is invalid.',
|
||||
|
||||
'FILE_PART_(\d+)_MISSING': 'Part {} of the file is missing from storage.',
|
||||
|
||||
'MD5_CHECKSUM_INVALID': 'The MD5 checksums do not match.',
|
||||
|
||||
'PHOTO_INVALID_DIMENSIONS': 'The photo dimensions are invalid.',
|
||||
|
||||
'FIELD_NAME_INVALID': 'The field with the name FIELD_NAME is invalid.',
|
||||
|
||||
'FIELD_NAME_EMPTY': 'The field with the name FIELD_NAME is missing.',
|
||||
|
||||
'MSG_WAIT_FAILED': 'A waiting call returned an error.',
|
||||
|
||||
'CHAT_ADMIN_REQUIRED': 'Chat admin privileges are required to do that in the specified chat '
|
||||
'(for example, to send a message in a channel which is not yours).',
|
||||
|
||||
# 401 UNAUTHORIZED
|
||||
'AUTH_KEY_UNREGISTERED': 'The key is not registered in the system.',
|
||||
|
||||
'AUTH_KEY_INVALID': 'The key is invalid.',
|
||||
|
||||
'USER_DEACTIVATED': 'The user has been deleted/deactivated.',
|
||||
|
||||
'SESSION_REVOKED': 'The authorization has been invalidated, because of the user terminating all sessions.',
|
||||
|
||||
'SESSION_EXPIRED': 'The authorization has expired.',
|
||||
|
||||
'ACTIVE_USER_REQUIRED': 'The method is only available to already activated users.',
|
||||
|
||||
'AUTH_KEY_PERM_EMPTY': 'The method is unavailable for temporary authorization key, not bound to permanent.',
|
||||
|
||||
# 420 FLOOD
|
||||
'FLOOD_WAIT_(\d+)': 'A wait of {} seconds is required.'
|
||||
}
|
||||
|
||||
def __init__(self, code, message):
|
||||
self.code = code
|
||||
self.code_meaning = RPCError.CodeMessages[code]
|
||||
|
||||
self.message = message
|
||||
self.must_resend = code == 303 # ERROR_SEE_OTHER, "The request must be repeated"
|
||||
|
||||
called_super = False
|
||||
for key, error_msg in RPCError.ErrorMessages.items():
|
||||
match = re.match(key, message)
|
||||
if match:
|
||||
# Get additional_data, if any
|
||||
if match.groups():
|
||||
self.additional_data = int(match.group(1))
|
||||
super().__init__(self, error_msg.format(self.additional_data))
|
||||
else:
|
||||
self.additional_data = None
|
||||
super().__init__(self, error_msg)
|
||||
|
||||
called_super = True
|
||||
break
|
||||
|
||||
if not called_super:
|
||||
super().__init__(self, 'Unknown error message with code {}: {}'.format(code, message))
|
||||
|
||||
|
||||
class BadMessageError(Exception):
|
||||
"""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).',
|
||||
|
||||
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).',
|
||||
|
||||
18: '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).',
|
||||
|
||||
20: '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).',
|
||||
|
||||
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).',
|
||||
|
||||
34: 'An even msg_seqno expected (irrelevant message), but odd 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.'
|
||||
}
|
||||
|
||||
def __init__(self, code):
|
||||
super().__init__(self, BadMessageError
|
||||
.ErrorMessages.get(code,'Unknown error code (this should not happen): {}.'.format(code)))
|
||||
|
||||
self.code = code
|
61
telethon/helpers.py
Executable file
61
telethon/helpers.py
Executable file
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
import hashlib
|
||||
|
||||
# region Multiple utilities
|
||||
|
||||
|
||||
def generate_random_long(signed=True):
|
||||
"""Generates a random long integer (8 bytes), which is optionally signed"""
|
||||
return int.from_bytes(os.urandom(8), signed=signed, byteorder='little')
|
||||
|
||||
|
||||
def ensure_parent_dir_exists(file_path):
|
||||
"""Ensures that the parent directory exists"""
|
||||
parent = os.path.dirname(file_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Cryptographic related utils
|
||||
|
||||
|
||||
def calc_key(shared_key, msg_key, client):
|
||||
"""Calculate the key based on Telegram guidelines, specifying whether it's the client or not"""
|
||||
x = 0 if client else 8
|
||||
|
||||
sha1a = sha1(msg_key + shared_key[x:x + 32])
|
||||
sha1b = sha1(shared_key[x + 32:x + 48] + msg_key + shared_key[x + 48:x + 64])
|
||||
sha1c = sha1(shared_key[x + 64:x + 96] + msg_key)
|
||||
sha1d = sha1(msg_key + shared_key[x + 96:x + 128])
|
||||
|
||||
key = sha1a[0:8] + sha1b[8:20] + sha1c[4:16]
|
||||
iv = sha1a[8:20] + sha1b[0:8] + sha1c[16:20] + sha1d[0:8]
|
||||
|
||||
return key, iv
|
||||
|
||||
|
||||
def calc_msg_key(data):
|
||||
"""Calculates the message key from the given data"""
|
||||
return sha1(data)[4:20]
|
||||
|
||||
|
||||
def generate_key_data_from_nonces(server_nonce, new_nonce):
|
||||
"""Generates the key data corresponding to the given nonces"""
|
||||
hash1 = sha1(bytes(new_nonce + server_nonce))
|
||||
hash2 = sha1(bytes(server_nonce + new_nonce))
|
||||
hash3 = sha1(bytes(new_nonce + new_nonce))
|
||||
|
||||
key = hash1 + hash2[:12]
|
||||
iv = hash2[12:20] + hash3 + new_nonce[:4]
|
||||
return key, iv
|
||||
|
||||
|
||||
def sha1(data):
|
||||
"""Calculates the SHA1 digest for the given data"""
|
||||
sha = hashlib.sha1()
|
||||
sha.update(data)
|
||||
return sha.digest()
|
||||
|
||||
# endregion
|
217
telethon/interactive_telegram_client.py
Normal file
217
telethon/interactive_telegram_client.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from telethon.tl.types import UpdateShortChatMessage
|
||||
from telethon.tl.types import UpdateShortMessage
|
||||
from telethon import TelegramClient
|
||||
import shutil
|
||||
|
||||
# Get the (current) number of lines in the terminal
|
||||
cols, rows = shutil.get_terminal_size()
|
||||
|
||||
|
||||
def print_title(title):
|
||||
# Clear previous window
|
||||
print('\n')
|
||||
available_cols = cols - 2 # -2 sincewe omit '┌' and '┐'
|
||||
print('┌{}┐'.format('─' * available_cols))
|
||||
print('│{}│'.format(title.center(available_cols)))
|
||||
print('└{}┘'.format('─' * available_cols))
|
||||
|
||||
|
||||
def bytes_to_string(byte_count):
|
||||
"""Converts a byte count to a string (in KB, MB...)"""
|
||||
suffix_index = 0
|
||||
while byte_count >= 1024:
|
||||
byte_count /= 1024
|
||||
suffix_index += 1
|
||||
|
||||
return '{:.2f}{}'.format(byte_count, [' bytes', 'KB', 'MB', 'GB', 'TB'][suffix_index])
|
||||
|
||||
|
||||
class InteractiveTelegramClient(TelegramClient):
|
||||
def __init__(self, session_user_id, user_phone, layer, api_id, api_hash):
|
||||
print_title('Initialization')
|
||||
|
||||
print('Initializing interactive example...')
|
||||
super().__init__(session_user_id, layer, api_id, api_hash)
|
||||
|
||||
# Store all the found media in memory here,
|
||||
# so it can be downloaded if the user wants
|
||||
self.found_media = set()
|
||||
|
||||
print('Connecting to Telegram servers...')
|
||||
self.connect()
|
||||
|
||||
# Then, ensure we're authorized and have access
|
||||
if not self.is_user_authorized():
|
||||
print('First run. Sending code request...')
|
||||
self.send_code_request(user_phone)
|
||||
|
||||
code_ok = False
|
||||
while not code_ok:
|
||||
code = input('Enter the code you just received: ')
|
||||
code_ok = self.sign_in(user_phone, code)
|
||||
|
||||
def run(self):
|
||||
# Listen for updates
|
||||
self.add_update_handler(self.update_handler)
|
||||
|
||||
# Enter a while loop to chat as long as the user wants
|
||||
while True:
|
||||
# Retrieve the top dialogs
|
||||
dialog_count = 10
|
||||
dialogs, displays, inputs = self.get_dialogs(dialog_count)
|
||||
|
||||
i = None
|
||||
while i is None:
|
||||
try:
|
||||
print_title('Dialogs window')
|
||||
|
||||
# Display them so the user can choose
|
||||
for i, display in enumerate(displays):
|
||||
i += 1 # 1-based index for normies
|
||||
print('{}. {}'.format(i, display))
|
||||
|
||||
# Let the user decide who they want to talk to
|
||||
print()
|
||||
print('> Who do you want to send messages to?')
|
||||
print('> Available commands:')
|
||||
print(' !q: Quits the dialogs window and exits.')
|
||||
print(' !l: Logs out, terminating this session.')
|
||||
print()
|
||||
i = input('Enter dialog ID or a command: ')
|
||||
if i == '!q':
|
||||
return
|
||||
if i == '!l':
|
||||
self.log_out()
|
||||
return
|
||||
|
||||
i = int(i if i else 0) - 1
|
||||
# Ensure it is inside the bounds, otherwise set to None and retry
|
||||
if not 0 <= i < dialog_count:
|
||||
i = None
|
||||
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Retrieve the selected user
|
||||
display = displays[i]
|
||||
input_peer = inputs[i]
|
||||
|
||||
# Show some information
|
||||
print_title('Chat with "{}"'.format(display))
|
||||
print('Available commands:')
|
||||
print(' !q: Quits the current chat.')
|
||||
print(' !Q: Quits the current chat and exits.')
|
||||
print(' !h: prints the latest messages (message History) of the chat.')
|
||||
print(' !p <path>: sends a Photo located at the given path.')
|
||||
print(' !f <path>: sends a File document located at the given path.')
|
||||
print(' !d <msg-id>: Downloads the given message media (if any).')
|
||||
print()
|
||||
|
||||
# And start a while loop to chat
|
||||
while True:
|
||||
msg = input('Enter a message: ')
|
||||
# Quit
|
||||
if msg == '!q':
|
||||
break
|
||||
elif msg == '!Q':
|
||||
return
|
||||
|
||||
# History
|
||||
elif msg == '!h':
|
||||
# First retrieve the messages and some information
|
||||
total_count, messages, senders = self.get_message_history(input_peer, limit=10)
|
||||
# Iterate over all (in reverse order so the latest appears the last in the console)
|
||||
# and print them in "[hh:mm] Sender: Message" text format
|
||||
for msg, sender in zip(reversed(messages), reversed(senders)):
|
||||
# Get the name of the sender if any
|
||||
name = sender.first_name if sender else '???'
|
||||
|
||||
# Format the message content
|
||||
if msg.media:
|
||||
self.found_media.add(msg)
|
||||
content = '<{}> {}'.format( # The media may or may not have a caption
|
||||
msg.media.__class__.__name__, getattr(msg.media, 'caption', ''))
|
||||
else:
|
||||
content = msg.message
|
||||
|
||||
# And print it to the user
|
||||
print('[{}:{}] (ID={}) {}: {}'.format(
|
||||
msg.date.hour, msg.date.minute, msg.id, name, content))
|
||||
|
||||
# Send photo
|
||||
elif msg.startswith('!p '):
|
||||
# Slice the message to get the path
|
||||
self.send_photo(path=msg[len('!p '):], peer=input_peer)
|
||||
|
||||
# Send file (document)
|
||||
elif msg.startswith('!f '):
|
||||
# Slice the message to get the path
|
||||
self.send_document(path=msg[len('!f '):], peer=input_peer)
|
||||
|
||||
# Download media
|
||||
elif msg.startswith('!d '):
|
||||
# Slice the message to get message ID
|
||||
self.download_media(msg[len('!d '):])
|
||||
|
||||
# Send chat message (if any)
|
||||
elif msg:
|
||||
self.send_message(input_peer, msg, markdown=True, no_web_page=True)
|
||||
|
||||
def send_photo(self, path, peer):
|
||||
print('Uploading {}...'.format(path))
|
||||
input_file = self.upload_file(path, progress_callback=self.upload_progress_callback)
|
||||
|
||||
# After we have the handle to the uploaded file, send it to our peer
|
||||
self.send_photo_file(input_file, peer)
|
||||
print('Photo sent!')
|
||||
|
||||
def send_document(self, path, peer):
|
||||
print('Uploading {}...'.format(path))
|
||||
input_file = self.upload_file(path, progress_callback=self.upload_progress_callback)
|
||||
|
||||
# After we have the handle to the uploaded file, send it to our peer
|
||||
self.send_document_file(input_file, peer)
|
||||
print('Document sent!')
|
||||
|
||||
def download_media(self, media_id):
|
||||
try:
|
||||
# The user may have entered a non-integer string!
|
||||
msg_media_id = int(media_id)
|
||||
|
||||
# Search the message ID
|
||||
for msg in self.found_media:
|
||||
if msg.id == msg_media_id:
|
||||
# Let the output be the message ID
|
||||
output = str('usermedia/{}'.format(msg_media_id))
|
||||
print('Downloading media with name {}...'.format(output))
|
||||
output = self.download_msg_media(msg.media,
|
||||
file_path=output,
|
||||
progress_callback=self.download_progress_callback)
|
||||
print('Media downloaded to {}!'.format(output))
|
||||
|
||||
except ValueError:
|
||||
print('Invalid media ID given!')
|
||||
|
||||
@staticmethod
|
||||
def download_progress_callback(downloaded_bytes, total_bytes):
|
||||
InteractiveTelegramClient.print_progress('Downloaded', downloaded_bytes, total_bytes)
|
||||
|
||||
@staticmethod
|
||||
def upload_progress_callback(uploaded_bytes, total_bytes):
|
||||
InteractiveTelegramClient.print_progress('Uploaded', uploaded_bytes, total_bytes)
|
||||
|
||||
@staticmethod
|
||||
def print_progress(progress_type, downloaded_bytes, total_bytes):
|
||||
print('{} {} out of {} ({:.2%})'.format(
|
||||
progress_type,
|
||||
bytes_to_string(downloaded_bytes),
|
||||
bytes_to_string(total_bytes),
|
||||
downloaded_bytes / total_bytes))
|
||||
|
||||
@staticmethod
|
||||
def update_handler(update_object):
|
||||
if type(update_object) is UpdateShortMessage:
|
||||
print('[User #{} sent {}]'.format(update_object.user_id, update_object.message))
|
||||
|
||||
elif type(update_object) is UpdateShortChatMessage:
|
||||
print('[Chat #{} sent {}]'.format(update_object.chat_id, update_object.message))
|
5
telethon/network/__init__.py
Executable file
5
telethon/network/__init__.py
Executable file
@@ -0,0 +1,5 @@
|
||||
from .mtproto_plain_sender import MtProtoPlainSender
|
||||
from .tcp_client import TcpClient
|
||||
from .authenticator import do_authentication
|
||||
from .mtproto_sender import MtProtoSender
|
||||
from .tcp_transport import TcpTransport
|
209
telethon/network/authenticator.py
Executable file
209
telethon/network/authenticator.py
Executable file
@@ -0,0 +1,209 @@
|
||||
import os
|
||||
import time
|
||||
import telethon.helpers as utils
|
||||
from telethon.utils import BinaryWriter, BinaryReader
|
||||
from telethon.crypto import AES, AuthKey, Factorizator, RSA
|
||||
from telethon.network import MtProtoPlainSender
|
||||
|
||||
|
||||
def do_authentication(transport):
|
||||
"""Executes the authentication process with the Telegram servers.
|
||||
If no error is rose, returns both the authorization key and the time offset"""
|
||||
sender = MtProtoPlainSender(transport)
|
||||
|
||||
# Step 1 sending: PQ Request
|
||||
nonce = os.urandom(16)
|
||||
with BinaryWriter() as writer:
|
||||
writer.write_int(0x60469778, signed=False) # Constructor number
|
||||
writer.write(nonce)
|
||||
sender.send(writer.get_bytes())
|
||||
|
||||
# Step 1 response: PQ Request
|
||||
pq, pq_bytes, server_nonce, fingerprints = None, None, None, []
|
||||
with BinaryReader(sender.receive()) as reader:
|
||||
response_code = reader.read_int(signed=False)
|
||||
if response_code != 0x05162463:
|
||||
raise AssertionError('Invalid response code: {}'.format(hex(response_code)))
|
||||
|
||||
nonce_from_server = reader.read(16)
|
||||
if nonce_from_server != nonce:
|
||||
raise AssertionError('Invalid nonce from server')
|
||||
|
||||
server_nonce = reader.read(16)
|
||||
|
||||
pq_bytes = reader.tgread_bytes()
|
||||
pq = get_int(pq_bytes)
|
||||
|
||||
vector_id = reader.read_int()
|
||||
if vector_id != 0x1cb5c415:
|
||||
raise AssertionError('Invalid vector constructor ID: {}'.format(hex(response_code)))
|
||||
|
||||
fingerprints = []
|
||||
fingerprint_count = reader.read_int()
|
||||
for _ in range(fingerprint_count):
|
||||
fingerprints.append(reader.read(8))
|
||||
|
||||
# Step 2 sending: DH Exchange
|
||||
new_nonce = os.urandom(32)
|
||||
p, q = Factorizator.factorize(pq)
|
||||
with BinaryWriter() as pq_inner_data_writer:
|
||||
pq_inner_data_writer.write_int(0x83c95aec, signed=False) # PQ Inner Data
|
||||
pq_inner_data_writer.tgwrite_bytes(get_byte_array(pq, signed=False))
|
||||
pq_inner_data_writer.tgwrite_bytes(get_byte_array(min(p, q), signed=False))
|
||||
pq_inner_data_writer.tgwrite_bytes(get_byte_array(max(p, q), signed=False))
|
||||
pq_inner_data_writer.write(nonce)
|
||||
pq_inner_data_writer.write(server_nonce)
|
||||
pq_inner_data_writer.write(new_nonce)
|
||||
|
||||
cipher_text, target_fingerprint = None, None
|
||||
for fingerprint in fingerprints:
|
||||
cipher_text = RSA.encrypt(get_fingerprint_text(fingerprint), pq_inner_data_writer.get_bytes())
|
||||
|
||||
if cipher_text is not None:
|
||||
target_fingerprint = fingerprint
|
||||
break
|
||||
|
||||
if cipher_text is None:
|
||||
raise AssertionError('Could not find a valid key for fingerprints: {}'
|
||||
.format(', '.join([get_fingerprint_text(f) for f in fingerprints])))
|
||||
|
||||
with BinaryWriter() as req_dh_params_writer:
|
||||
req_dh_params_writer.write_int(0xd712e4be, signed=False) # Req DH Params
|
||||
req_dh_params_writer.write(nonce)
|
||||
req_dh_params_writer.write(server_nonce)
|
||||
req_dh_params_writer.tgwrite_bytes(get_byte_array(min(p, q), signed=False))
|
||||
req_dh_params_writer.tgwrite_bytes(get_byte_array(max(p, q), signed=False))
|
||||
req_dh_params_writer.write(target_fingerprint)
|
||||
req_dh_params_writer.tgwrite_bytes(cipher_text)
|
||||
|
||||
req_dh_params_bytes = req_dh_params_writer.get_bytes()
|
||||
sender.send(req_dh_params_bytes)
|
||||
|
||||
# Step 2 response: DH Exchange
|
||||
encrypted_answer = None
|
||||
with BinaryReader(sender.receive()) as reader:
|
||||
response_code = reader.read_int(signed=False)
|
||||
|
||||
if response_code == 0x79cb045d:
|
||||
raise AssertionError('Server DH params fail: TODO')
|
||||
|
||||
if response_code != 0xd0e8075c:
|
||||
raise AssertionError('Invalid response code: {}'.format(hex(response_code)))
|
||||
|
||||
nonce_from_server = reader.read(16)
|
||||
if nonce_from_server != nonce:
|
||||
raise NotImplementedError('Invalid nonce from server')
|
||||
|
||||
server_nonce_from_server = reader.read(16)
|
||||
if server_nonce_from_server != server_nonce:
|
||||
raise NotImplementedError('Invalid server nonce from server')
|
||||
|
||||
encrypted_answer = reader.tgread_bytes()
|
||||
|
||||
# Step 3 sending: Complete DH Exchange
|
||||
key, iv = utils.generate_key_data_from_nonces(server_nonce, new_nonce)
|
||||
plain_text_answer = AES.decrypt_ige(encrypted_answer, key, iv)
|
||||
|
||||
g, dh_prime, ga, time_offset = None, None, None, None
|
||||
with BinaryReader(plain_text_answer) as dh_inner_data_reader:
|
||||
hashsum = dh_inner_data_reader.read(20)
|
||||
code = dh_inner_data_reader.read_int(signed=False)
|
||||
if code != 0xb5890dba:
|
||||
raise AssertionError('Invalid DH Inner Data code: {}'.format(code))
|
||||
|
||||
nonce_from_server1 = dh_inner_data_reader.read(16)
|
||||
if nonce_from_server1 != nonce:
|
||||
raise AssertionError('Invalid nonce in encrypted answer')
|
||||
|
||||
server_nonce_from_server1 = dh_inner_data_reader.read(16)
|
||||
if server_nonce_from_server1 != server_nonce:
|
||||
raise AssertionError('Invalid server nonce in encrypted answer')
|
||||
|
||||
g = dh_inner_data_reader.read_int()
|
||||
dh_prime = get_int(dh_inner_data_reader.tgread_bytes(), signed=False)
|
||||
ga = get_int(dh_inner_data_reader.tgread_bytes(), signed=False)
|
||||
|
||||
server_time = dh_inner_data_reader.read_int()
|
||||
time_offset = server_time - int(time.time())
|
||||
|
||||
b = get_int(os.urandom(2048), signed=False)
|
||||
gb = pow(g, b, dh_prime)
|
||||
gab = pow(ga, b, dh_prime)
|
||||
|
||||
# Prepare client DH Inner Data
|
||||
with BinaryWriter() as client_dh_inner_data_writer:
|
||||
client_dh_inner_data_writer.write_int(0x6643b654, signed=False) # Client DH Inner Data
|
||||
client_dh_inner_data_writer.write(nonce)
|
||||
client_dh_inner_data_writer.write(server_nonce)
|
||||
client_dh_inner_data_writer.write_long(0) # TODO retry_id
|
||||
client_dh_inner_data_writer.tgwrite_bytes(get_byte_array(gb, signed=False))
|
||||
|
||||
with BinaryWriter() as client_dh_inner_data_with_hash_writer:
|
||||
client_dh_inner_data_with_hash_writer.write(utils.sha1(client_dh_inner_data_writer.get_bytes()))
|
||||
client_dh_inner_data_with_hash_writer.write(client_dh_inner_data_writer.get_bytes())
|
||||
client_dh_inner_data_bytes = client_dh_inner_data_with_hash_writer.get_bytes()
|
||||
|
||||
# Encryption
|
||||
client_dh_inner_data_encrypted_bytes = AES.encrypt_ige(client_dh_inner_data_bytes, key, iv)
|
||||
|
||||
# Prepare Set client DH params
|
||||
with BinaryWriter() as set_client_dh_params_writer:
|
||||
set_client_dh_params_writer.write_int(0xf5045f1f, signed=False)
|
||||
set_client_dh_params_writer.write(nonce)
|
||||
set_client_dh_params_writer.write(server_nonce)
|
||||
set_client_dh_params_writer.tgwrite_bytes(client_dh_inner_data_encrypted_bytes)
|
||||
|
||||
set_client_dh_params_bytes = set_client_dh_params_writer.get_bytes()
|
||||
sender.send(set_client_dh_params_bytes)
|
||||
|
||||
# Step 3 response: Complete DH Exchange
|
||||
with BinaryReader(sender.receive()) as reader:
|
||||
code = reader.read_int(signed=False)
|
||||
if code == 0x3bcbf734: # DH Gen OK
|
||||
nonce_from_server = reader.read(16)
|
||||
if nonce_from_server != nonce:
|
||||
raise NotImplementedError('Invalid nonce from server')
|
||||
|
||||
server_nonce_from_server = reader.read(16)
|
||||
if server_nonce_from_server != server_nonce:
|
||||
raise NotImplementedError('Invalid server nonce from server')
|
||||
|
||||
new_nonce_hash1 = reader.read(16)
|
||||
auth_key = AuthKey(get_byte_array(gab, signed=False))
|
||||
|
||||
new_nonce_hash_calculated = auth_key.calc_new_nonce_hash(new_nonce, 1)
|
||||
if new_nonce_hash1 != new_nonce_hash_calculated:
|
||||
raise AssertionError('Invalid new nonce hash')
|
||||
|
||||
return auth_key, time_offset
|
||||
|
||||
elif code == 0x46dc1fb9: # DH Gen Retry
|
||||
raise NotImplementedError('dh_gen_retry')
|
||||
|
||||
elif code == 0xa69dae02: # DH Gen Fail
|
||||
raise NotImplementedError('dh_gen_fail')
|
||||
|
||||
else:
|
||||
raise AssertionError('DH Gen unknown: {}'.format(hex(code)))
|
||||
|
||||
|
||||
def get_fingerprint_text(fingerprint):
|
||||
"""Gets a fingerprint text in 01-23-45-67-89-AB-CD-EF format (no hyphens)"""
|
||||
return ''.join(hex(b)[2:].rjust(2, '0').upper() for b in fingerprint)
|
||||
|
||||
|
||||
# The following methods operate in big endian (unlike most of Telegram API) because:
|
||||
# > "...pq is a representation of a natural number (in binary *big endian* format)..."
|
||||
# > "...current value of dh_prime equals (in *big-endian* byte order)..."
|
||||
# Reference: https://core.telegram.org/mtproto/auth_key
|
||||
def get_byte_array(integer, signed):
|
||||
"""Gets the arbitrary-length byte array corresponding to the given integer"""
|
||||
bits = integer.bit_length()
|
||||
byte_length = (bits + 8 - 1) // 8 # 8 bits per byte
|
||||
return int.to_bytes(integer, length=byte_length, byteorder='big', signed=signed)
|
||||
|
||||
|
||||
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"""
|
||||
return int.from_bytes(byte_array, byteorder='big', signed=signed)
|
49
telethon/network/mtproto_plain_sender.py
Executable file
49
telethon/network/mtproto_plain_sender.py
Executable file
@@ -0,0 +1,49 @@
|
||||
import time
|
||||
import random
|
||||
from telethon.utils import BinaryWriter, BinaryReader
|
||||
|
||||
|
||||
class MtProtoPlainSender:
|
||||
"""MTProto Mobile Protocol plain sender (https://core.telegram.org/mtproto/description#unencrypted-messages)"""
|
||||
def __init__(self, transport):
|
||||
self._sequence = 0
|
||||
self._time_offset = 0
|
||||
self._last_msg_id = 0
|
||||
self._transport = transport
|
||||
|
||||
def send(self, data):
|
||||
"""Sends a plain packet (auth_key_id = 0) containing the given message body (data)"""
|
||||
with BinaryWriter() as writer:
|
||||
writer.write_long(0)
|
||||
writer.write_long(self.get_new_msg_id())
|
||||
writer.write_int(len(data))
|
||||
writer.write(data)
|
||||
|
||||
packet = writer.get_bytes()
|
||||
self._transport.send(packet)
|
||||
|
||||
def receive(self):
|
||||
"""Receives a plain packet, returning the body of the response"""
|
||||
seq, body = self._transport.receive()
|
||||
with BinaryReader(body) as reader:
|
||||
auth_key_id = reader.read_long()
|
||||
msg_id = reader.read_long()
|
||||
message_length = reader.read_int()
|
||||
|
||||
response = reader.read(message_length)
|
||||
return response
|
||||
|
||||
def get_new_msg_id(self):
|
||||
"""Generates a new message ID based on the current time (in ms) since epoch"""
|
||||
# See https://core.telegram.org/mtproto/description#message-identifier-msg-id
|
||||
ms_time = int(time.time() * 1000)
|
||||
new_msg_id = (((ms_time // 1000) << 32) | # "must approximately equal unixtime*2^32"
|
||||
((ms_time % 1000) << 22) | # "approximate moment in time the message was created"
|
||||
random.randint(0, 524288) << 2) # "message identifiers are divisible by 4"
|
||||
|
||||
# Ensure that we always return a message ID which is higher than the previous one
|
||||
if self._last_msg_id >= new_msg_id:
|
||||
new_msg_id = self._last_msg_id + 4
|
||||
|
||||
self._last_msg_id = new_msg_id
|
||||
return new_msg_id
|
331
telethon/network/mtproto_sender.py
Executable file
331
telethon/network/mtproto_sender.py
Executable file
@@ -0,0 +1,331 @@
|
||||
import gzip
|
||||
from telethon.errors import *
|
||||
from time import sleep
|
||||
from threading import Thread, Lock
|
||||
|
||||
import telethon.helpers as utils
|
||||
from telethon.crypto import AES
|
||||
from telethon.utils import BinaryWriter, BinaryReader
|
||||
from telethon.tl.types import MsgsAck
|
||||
from telethon.tl.all_tlobjects import tlobjects
|
||||
|
||||
|
||||
class MtProtoSender:
|
||||
"""MTProto Mobile Protocol sender (https://core.telegram.org/mtproto/description)"""
|
||||
def __init__(self, transport, session):
|
||||
self.transport = transport
|
||||
self.session = session
|
||||
|
||||
self.need_confirmation = [] # Message IDs that need confirmation
|
||||
self.on_update_handlers = []
|
||||
|
||||
# Store a Lock instance to make this class safely multi-threaded
|
||||
self.lock = Lock()
|
||||
|
||||
self.updates_thread = Thread(target=self.updates_thread_method, name='Updates thread')
|
||||
self.updates_thread_running = False
|
||||
self.updates_thread_receiving = False
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnects and **stops all the running threads** if any"""
|
||||
self.set_listen_for_updates(enabled=False)
|
||||
self.transport.close()
|
||||
|
||||
def add_update_handler(self, handler):
|
||||
"""Adds an update handler (a method with one argument, the received
|
||||
TLObject) that is fired when there are updates available"""
|
||||
|
||||
first_handler = not self.on_update_handlers
|
||||
self.on_update_handlers.append(handler)
|
||||
|
||||
# If this is the first added handler,
|
||||
# we must start the thread to receive updates
|
||||
if first_handler:
|
||||
self.set_listen_for_updates(enabled=True)
|
||||
|
||||
def remove_update_handler(self, handler):
|
||||
self.on_update_handlers.remove(handler)
|
||||
|
||||
# If there are no more update handlers, stop the thread
|
||||
if not self.on_update_handlers:
|
||||
self.set_listen_for_updates(False)
|
||||
|
||||
def generate_sequence(self, confirmed):
|
||||
"""Generates the next sequence number, based on whether it
|
||||
was confirmed yet or not"""
|
||||
if confirmed:
|
||||
result = self.session.sequence * 2 + 1
|
||||
self.session.sequence += 1
|
||||
return result
|
||||
else:
|
||||
return self.session.sequence * 2
|
||||
|
||||
# region Send and receive
|
||||
|
||||
def send(self, request, resend=False):
|
||||
"""Sends the specified MTProtoRequest, previously sending any message
|
||||
which needed confirmation. This also pauses the updates thread"""
|
||||
|
||||
# Only cancel the receive *if* it was the
|
||||
# updates thread who was receiving. We do
|
||||
# not want to cancel other pending requests!
|
||||
if self.updates_thread_receiving:
|
||||
self.transport.cancel_receive()
|
||||
|
||||
# Now only us can be using this method if we're not resending
|
||||
if not resend:
|
||||
self.lock.acquire()
|
||||
|
||||
# If any message needs confirmation send an AckRequest first
|
||||
if self.need_confirmation:
|
||||
msgs_ack = MsgsAck(self.need_confirmation)
|
||||
with BinaryWriter() as writer:
|
||||
msgs_ack.on_send(writer)
|
||||
self.send_packet(writer.get_bytes(), msgs_ack)
|
||||
|
||||
del self.need_confirmation[:]
|
||||
|
||||
# Finally send our packed request
|
||||
with BinaryWriter() as writer:
|
||||
request.on_send(writer)
|
||||
self.send_packet(writer.get_bytes(), request)
|
||||
|
||||
# And update the saved session
|
||||
self.session.save()
|
||||
# Don't resume the updates thread yet,
|
||||
# since every send() is preceded by a receive()
|
||||
|
||||
def receive(self, request):
|
||||
"""Receives the specified MTProtoRequest ("fills in it"
|
||||
the received data). This also restores the updates thread"""
|
||||
|
||||
try:
|
||||
# Don't stop trying to receive until we get the request we wanted
|
||||
while not request.confirm_received:
|
||||
seq, body = self.transport.receive()
|
||||
message, remote_msg_id, remote_sequence = self.decode_msg(body)
|
||||
|
||||
with BinaryReader(message) as reader:
|
||||
self.process_msg(remote_msg_id, remote_sequence, reader, request)
|
||||
|
||||
finally:
|
||||
# Once we are done trying to get our request,
|
||||
# restore the updates thread and release the lock
|
||||
self.lock.release()
|
||||
|
||||
# endregion
|
||||
|
||||
# region Low level processing
|
||||
|
||||
def send_packet(self, packet, request):
|
||||
"""Sends the given packet bytes with the additional
|
||||
information of the original request. This does NOT lock the threads!"""
|
||||
request.msg_id = self.session.get_new_msg_id()
|
||||
|
||||
# First calculate plain_text to encrypt it
|
||||
with BinaryWriter() as plain_writer:
|
||||
plain_writer.write_long(self.session.salt, signed=False)
|
||||
plain_writer.write_long(self.session.id, signed=False)
|
||||
plain_writer.write_long(request.msg_id)
|
||||
plain_writer.write_int(self.generate_sequence(request.confirmed))
|
||||
plain_writer.write_int(len(packet))
|
||||
plain_writer.write(packet)
|
||||
|
||||
msg_key = utils.calc_msg_key(plain_writer.get_bytes())
|
||||
|
||||
key, iv = utils.calc_key(self.session.auth_key.key, msg_key, True)
|
||||
cipher_text = AES.encrypt_ige(plain_writer.get_bytes(), key, iv)
|
||||
|
||||
# And then finally send the encrypted packet
|
||||
with BinaryWriter() as cipher_writer:
|
||||
cipher_writer.write_long(self.session.auth_key.key_id, signed=False)
|
||||
cipher_writer.write(msg_key)
|
||||
cipher_writer.write(cipher_text)
|
||||
self.transport.send(cipher_writer.get_bytes())
|
||||
|
||||
def decode_msg(self, body):
|
||||
"""Decodes an received encrypted message body bytes"""
|
||||
message = None
|
||||
remote_msg_id = None
|
||||
remote_sequence = None
|
||||
|
||||
with BinaryReader(body) as reader:
|
||||
if len(body) < 8:
|
||||
raise BufferError("Can't decode packet ({})".format(body))
|
||||
|
||||
# TODO Check for both auth key ID and msg_key correctness
|
||||
remote_auth_key_id = reader.read_long()
|
||||
msg_key = reader.read(16)
|
||||
|
||||
key, iv = utils.calc_key(self.session.auth_key.key, msg_key, False)
|
||||
plain_text = AES.decrypt_ige(reader.read(len(body) - reader.tell_position()), key, iv)
|
||||
|
||||
with BinaryReader(plain_text) as plain_text_reader:
|
||||
remote_salt = plain_text_reader.read_long()
|
||||
remote_session_id = plain_text_reader.read_long()
|
||||
remote_msg_id = plain_text_reader.read_long()
|
||||
remote_sequence = plain_text_reader.read_int()
|
||||
msg_len = plain_text_reader.read_int()
|
||||
message = plain_text_reader.read(msg_len)
|
||||
|
||||
return message, remote_msg_id, remote_sequence
|
||||
|
||||
def process_msg(self, msg_id, sequence, reader, request=None):
|
||||
"""Processes and handles a Telegram message"""
|
||||
|
||||
# TODO Check salt, session_id and sequence_number
|
||||
self.need_confirmation.append(msg_id)
|
||||
|
||||
code = reader.read_int(signed=False)
|
||||
reader.seek(-4)
|
||||
|
||||
# The following codes are "parsed manually"
|
||||
if code == 0xf35c6d01: # rpc_result, (response of an RPC call, i.e., we sent a request)
|
||||
return self.handle_rpc_result(msg_id, sequence, reader, request)
|
||||
|
||||
if code == 0x73f1f8dc: # msg_container
|
||||
return self.handle_container(msg_id, sequence, reader, request)
|
||||
if code == 0x3072cfa1: # gzip_packed
|
||||
return self.handle_gzip_packed(msg_id, sequence, reader, request)
|
||||
if code == 0xedab447b: # bad_server_salt
|
||||
return self.handle_bad_server_salt(msg_id, sequence, reader, request)
|
||||
if code == 0xa7eff811: # bad_msg_notification
|
||||
return self.handle_bad_msg_notification(msg_id, sequence, reader)
|
||||
|
||||
# If the code is not parsed manually, then it was parsed by the code generator!
|
||||
# In this case, we will simply treat the incoming TLObject as an Update,
|
||||
# if we can first find a matching TLObject
|
||||
if code in tlobjects.keys():
|
||||
return self.handle_update(msg_id, sequence, reader)
|
||||
|
||||
print('Unknown message: {}'.format(hex(code)))
|
||||
return False
|
||||
|
||||
# endregion
|
||||
|
||||
# region Message handling
|
||||
|
||||
def handle_update(self, msg_id, sequence, reader):
|
||||
tlobject = reader.tgread_object()
|
||||
for handler in self.on_update_handlers:
|
||||
handler(tlobject)
|
||||
|
||||
return False
|
||||
|
||||
def handle_container(self, msg_id, sequence, reader, request):
|
||||
code = reader.read_int(signed=False)
|
||||
size = reader.read_int()
|
||||
for _ in range(size):
|
||||
inner_msg_id = reader.read_long(signed=False)
|
||||
inner_sequence = reader.read_int()
|
||||
inner_length = reader.read_int()
|
||||
begin_position = reader.tell_position()
|
||||
|
||||
if not self.process_msg(inner_msg_id, sequence, reader, request):
|
||||
reader.set_position(begin_position + inner_length)
|
||||
|
||||
return False
|
||||
|
||||
def handle_bad_server_salt(self, msg_id, sequence, reader, request):
|
||||
code = reader.read_int(signed=False)
|
||||
bad_msg_id = reader.read_long(signed=False)
|
||||
bad_msg_seq_no = reader.read_int()
|
||||
error_code = reader.read_int()
|
||||
new_salt = reader.read_long(signed=False)
|
||||
|
||||
self.session.salt = new_salt
|
||||
|
||||
if request is None:
|
||||
raise ValueError('Tried to handle a bad server salt with no request specified')
|
||||
|
||||
# Resend
|
||||
self.send(request, resend=True)
|
||||
|
||||
return True
|
||||
|
||||
def handle_bad_msg_notification(self, msg_id, sequence, reader):
|
||||
code = reader.read_int(signed=False)
|
||||
request_id = reader.read_long(signed=False)
|
||||
request_sequence = reader.read_int()
|
||||
|
||||
error_code = reader.read_int()
|
||||
raise BadMessageError(error_code)
|
||||
|
||||
def handle_rpc_result(self, msg_id, sequence, reader, request):
|
||||
if not request:
|
||||
raise ValueError('RPC results should only happen after a request was sent')
|
||||
|
||||
code = reader.read_int(signed=False)
|
||||
request_id = reader.read_long(signed=False)
|
||||
inner_code = reader.read_int(signed=False)
|
||||
|
||||
if request_id == request.msg_id:
|
||||
request.confirm_received = True
|
||||
|
||||
if inner_code == 0x2144ca19: # RPC Error
|
||||
error = RPCError(code=reader.read_int(), message=reader.tgread_string())
|
||||
if error.must_resend:
|
||||
request.confirm_received = False
|
||||
|
||||
if error.message.startswith('FLOOD_WAIT_'):
|
||||
print('Should wait {}s. Sleeping until then.'.format(error.additional_data))
|
||||
sleep(error.additional_data)
|
||||
|
||||
elif error.message.startswith('PHONE_MIGRATE_'):
|
||||
raise InvalidDCError(error.additional_data)
|
||||
|
||||
else:
|
||||
raise error
|
||||
else:
|
||||
if inner_code == 0x3072cfa1: # GZip packed
|
||||
unpacked_data = gzip.decompress(reader.tgread_bytes())
|
||||
with BinaryReader(unpacked_data) as compressed_reader:
|
||||
request.on_response(compressed_reader)
|
||||
else:
|
||||
reader.seek(-4)
|
||||
request.on_response(reader)
|
||||
|
||||
def handle_gzip_packed(self, msg_id, sequence, reader, request):
|
||||
code = reader.read_int(signed=False)
|
||||
packed_data = reader.tgread_bytes()
|
||||
unpacked_data = gzip.decompress(packed_data)
|
||||
|
||||
with BinaryReader(unpacked_data) as compressed_reader:
|
||||
return self.process_msg(msg_id, sequence, compressed_reader, request)
|
||||
|
||||
# endregion
|
||||
|
||||
def set_listen_for_updates(self, enabled):
|
||||
if enabled:
|
||||
if not self.updates_thread_running:
|
||||
self.updates_thread_running = True
|
||||
self.updates_thread_receiving = False
|
||||
|
||||
self.updates_thread.start()
|
||||
else:
|
||||
self.updates_thread_running = False
|
||||
if self.updates_thread_receiving:
|
||||
self.transport.cancel_receive()
|
||||
|
||||
def updates_thread_method(self):
|
||||
"""This method will run until specified and listen for incoming updates"""
|
||||
while self.updates_thread_running:
|
||||
with self.lock:
|
||||
try:
|
||||
self.updates_thread_receiving = True
|
||||
seq, body = self.transport.receive()
|
||||
message, remote_msg_id, remote_sequence = self.decode_msg(body)
|
||||
|
||||
with BinaryReader(message) as reader:
|
||||
self.process_msg(remote_msg_id, remote_sequence, reader)
|
||||
|
||||
except ReadCancelledError:
|
||||
pass
|
||||
|
||||
self.updates_thread_receiving = False
|
||||
|
||||
# If we are here, it is because the read was cancelled
|
||||
# Sleep a bit just to give enough time for the other thread
|
||||
# to acquire the lock. No need to sleep if we're not running anymore
|
||||
if self.updates_thread_running:
|
||||
sleep(0.1)
|
75
telethon/network/tcp_client.py
Executable file
75
telethon/network/tcp_client.py
Executable file
@@ -0,0 +1,75 @@
|
||||
# Python rough implementation of a C# TCP client
|
||||
import socket
|
||||
import time
|
||||
from threading import Lock
|
||||
|
||||
from telethon.errors import ReadCancelledError
|
||||
from telethon.utils import BinaryWriter
|
||||
|
||||
|
||||
class TcpClient:
|
||||
def __init__(self):
|
||||
self.connected = False
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
|
||||
# Support for multi-threading advantages and safety
|
||||
self.cancelled = False # Has the read operation been cancelled?
|
||||
self.delay = 0.1 # Read delay when there was no data available
|
||||
self.lock = Lock()
|
||||
|
||||
def connect(self, ip, port):
|
||||
"""Connects to the specified IP and port number"""
|
||||
self.socket.connect((ip, port))
|
||||
self.connected = True
|
||||
|
||||
def close(self):
|
||||
"""Closes the connection"""
|
||||
self.socket.close()
|
||||
self.connected = False
|
||||
self.socket.setblocking(True)
|
||||
|
||||
def write(self, data):
|
||||
"""Writes (sends) the specified bytes to the connected peer"""
|
||||
|
||||
# Ensure that only one thread can send data at once
|
||||
with self.lock:
|
||||
# Set blocking so it doesn't error
|
||||
self.socket.setblocking(True)
|
||||
self.socket.sendall(data)
|
||||
|
||||
def read(self, buffer_size):
|
||||
"""Reads (receives) the specified bytes from the connected peer"""
|
||||
|
||||
# Ensure that only one thread can receive data at once
|
||||
with self.lock:
|
||||
# Ensure it is not cancelled at first, so we can enter the loop
|
||||
self.cancelled = False
|
||||
|
||||
# Set non-blocking so it can be cancelled
|
||||
self.socket.setblocking(False)
|
||||
|
||||
with BinaryWriter() as writer:
|
||||
while writer.written_count < buffer_size:
|
||||
# Only do cancel if no data was read yet
|
||||
# Otherwise, carry on reading and finish
|
||||
if self.cancelled and writer.written_count == 0:
|
||||
raise ReadCancelledError()
|
||||
|
||||
try:
|
||||
# When receiving from the socket, we may not receive all the data at once
|
||||
# This is why we need to keep checking to make sure that we receive it all
|
||||
left_count = buffer_size - writer.written_count
|
||||
partial = self.socket.recv(left_count)
|
||||
writer.write(partial)
|
||||
|
||||
except BlockingIOError:
|
||||
# There was no data available for us to read. Sleep a bit
|
||||
time.sleep(self.delay)
|
||||
|
||||
# If everything went fine, return the read bytes
|
||||
return writer.get_bytes()
|
||||
|
||||
def cancel_read(self):
|
||||
"""Cancels the read operation IF it hasn't yet
|
||||
started, raising a ReadCancelledError"""
|
||||
self.cancelled = True
|
67
telethon/network/tcp_transport.py
Executable file
67
telethon/network/tcp_transport.py
Executable file
@@ -0,0 +1,67 @@
|
||||
from binascii import crc32
|
||||
from telethon.network import TcpClient
|
||||
from telethon.errors import *
|
||||
from telethon.utils import BinaryWriter
|
||||
|
||||
|
||||
class TcpTransport:
|
||||
def __init__(self, ip_address, port):
|
||||
self.tcp_client = TcpClient()
|
||||
self.send_counter = 0
|
||||
|
||||
self.tcp_client.connect(ip_address, port)
|
||||
|
||||
# Original reference: https://core.telegram.org/mtproto#tcp-transport
|
||||
# The packets are encoded as: total length, sequence number, packet and checksum (CRC32)
|
||||
def send(self, packet):
|
||||
"""Sends the given packet (bytes array) to the connected peer"""
|
||||
if not self.tcp_client.connected:
|
||||
raise ConnectionError('Client not connected to server.')
|
||||
|
||||
with BinaryWriter() as writer:
|
||||
writer.write_int(len(packet) + 12) # 12 = size_of (integer) * 3
|
||||
writer.write_int(self.send_counter)
|
||||
writer.write(packet)
|
||||
|
||||
crc = crc32(writer.get_bytes())
|
||||
writer.write_int(crc, signed=False)
|
||||
|
||||
self.tcp_client.write(writer.get_bytes())
|
||||
self.send_counter += 1
|
||||
|
||||
def receive(self):
|
||||
"""Receives a TCP message (tuple(sequence number, body)) from the connected peer"""
|
||||
|
||||
# First read everything we need
|
||||
packet_length_bytes = self.tcp_client.read(4)
|
||||
packet_length = int.from_bytes(packet_length_bytes, byteorder='little')
|
||||
|
||||
seq_bytes = self.tcp_client.read(4)
|
||||
seq = int.from_bytes(seq_bytes, byteorder='little')
|
||||
|
||||
body = self.tcp_client.read(packet_length - 12)
|
||||
|
||||
checksum = int.from_bytes(self.tcp_client.read(4), byteorder='little', signed=False)
|
||||
|
||||
# Then perform the checks
|
||||
rv = packet_length_bytes + seq_bytes + body
|
||||
valid_checksum = crc32(rv)
|
||||
|
||||
if checksum != valid_checksum:
|
||||
raise InvalidChecksumError(checksum, valid_checksum)
|
||||
|
||||
# If we passed the tests, we can then return a valid TCP message
|
||||
return seq, body
|
||||
|
||||
def close(self):
|
||||
if self.tcp_client.connected:
|
||||
self.tcp_client.close()
|
||||
|
||||
def cancel_receive(self):
|
||||
"""Cancels (stops) trying to receive from the
|
||||
remote peer and raises a ReadCancelledError"""
|
||||
self.tcp_client.cancel_read()
|
||||
|
||||
def get_client_delay(self):
|
||||
"""Gets the client read delay"""
|
||||
return self.tcp_client.delay
|
1
telethon/parser/__init__.py
Normal file
1
telethon/parser/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .markdown_parser import parse_message_entities
|
140
telethon/parser/markdown_parser.py
Normal file
140
telethon/parser/markdown_parser.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from telethon.tl.types import MessageEntityBold, MessageEntityItalic, MessageEntityCode, MessageEntityTextUrl
|
||||
|
||||
|
||||
def parse_message_entities(msg):
|
||||
"""Parses a message and returns the parsed message and the entities (bold, italic...).
|
||||
Note that although markdown-like syntax is used, this does not reflect the complete specification!"""
|
||||
|
||||
# Store the entities here
|
||||
entities = []
|
||||
|
||||
# Convert the message to a mutable list
|
||||
msg = list(msg)
|
||||
|
||||
# First, let's handle all the text links in the message, so afterwards it's clean
|
||||
# for us to get our hands dirty with the other indicators (bold, italic and fixed)
|
||||
url_indices = [None] * 4 # start/end text index, start/end url index
|
||||
valid_url_indices = [] # all the valid url_indices found
|
||||
for i, c in enumerate(msg):
|
||||
if c is '[':
|
||||
url_indices[0] = i
|
||||
|
||||
# From now on, also ensure that the last item was set
|
||||
elif c == ']' and url_indices[0]:
|
||||
url_indices[1] = i
|
||||
|
||||
elif c == '(' and url_indices[1]:
|
||||
# If the previous index (']') is not exactly before the current index ('('),
|
||||
# then it's not a valid text link, so clear the previous state
|
||||
if url_indices[1] != i - 1:
|
||||
url_indices[:2] = [None] * 2
|
||||
else:
|
||||
url_indices[2] = i
|
||||
|
||||
elif c == ')' and url_indices[2]:
|
||||
# We have succeeded to find a markdown-like text link!
|
||||
url_indices[3] = i
|
||||
valid_url_indices.append(url_indices[:]) # Append a copy
|
||||
url_indices = [None] * 4
|
||||
|
||||
# Iterate in reverse order to clean the text from the urls
|
||||
# (not to affect previous indices) and append MessageEntityTextUrl's
|
||||
for i in range(len(valid_url_indices) - 1, -1, -1):
|
||||
vui = valid_url_indices[i]
|
||||
|
||||
# Add 1 when slicing the message not to include the [] nor ()
|
||||
# There is no need to subtract 1 on the later part because that index is already excluded
|
||||
link_text = ''.join(msg[vui[0]+1:vui[1]])
|
||||
link_url = ''.join(msg[vui[2]+1:vui[3]])
|
||||
|
||||
# After we have retrieved both the link text and url, replace them in the message
|
||||
# Now we do have to add 1 to include the [] and () when deleting and replacing!
|
||||
del msg[vui[2]:vui[3]+1]
|
||||
msg[vui[0]:vui[1]+1] = link_text
|
||||
|
||||
# Finally, update the current valid index url to reflect that all the previous VUI's will be removed
|
||||
# This is because, after the previous VUI's get done, their part of the message is removed too,
|
||||
# hence we need to update the current VUI subtracting that removed part length
|
||||
for prev_vui in valid_url_indices[:i]:
|
||||
prev_vui_length = prev_vui[3] - prev_vui[2] - 1
|
||||
displacement = prev_vui_length + len('[]()')
|
||||
vui[0] -= displacement
|
||||
vui[1] -= displacement
|
||||
# No need to subtract the displacement from the URL part (indices 2 and 3)
|
||||
|
||||
# When calculating the length, subtract 1 again not to include the previously called ']'
|
||||
entities.append(MessageEntityTextUrl(offset=vui[0], length=vui[1] - vui[0] - 1, url=link_url))
|
||||
|
||||
# After the message is clean from links, handle all the indicator flags
|
||||
indicator_flags = {
|
||||
'*': None,
|
||||
'_': None,
|
||||
'`': None
|
||||
}
|
||||
|
||||
# Iterate over the list to find the indicators of entities
|
||||
for i, c in enumerate(msg):
|
||||
# Only perform further check if the current character is an indicator
|
||||
if c in indicator_flags:
|
||||
# If it is the first time we find this indicator, update its index
|
||||
if indicator_flags[c] is None:
|
||||
indicator_flags[c] = i
|
||||
|
||||
# Otherwise, it means that we found it before. Hence, the message entity *is* complete
|
||||
else:
|
||||
# Then we have found a new whole valid entity
|
||||
offset = indicator_flags[c]
|
||||
length = i - offset - 1 # Subtract -1 not to include the indicator itself
|
||||
|
||||
# Add the corresponding entity
|
||||
if c == '*':
|
||||
entities.append(MessageEntityBold(offset=offset, length=length))
|
||||
|
||||
elif c == '_':
|
||||
entities.append(MessageEntityItalic(offset=offset, length=length))
|
||||
|
||||
elif c == '`':
|
||||
entities.append(MessageEntityCode(offset=offset, length=length))
|
||||
|
||||
# Clear the flag to start over with this indicator
|
||||
indicator_flags[c] = None
|
||||
|
||||
# Sort the entities by their offset first
|
||||
entities = sorted(entities, key=lambda e: e.offset)
|
||||
|
||||
# Now that all the entities have been found and sorted, remove
|
||||
# their indicators from the message and update the offsets
|
||||
for entity in entities:
|
||||
if type(entity) is not MessageEntityTextUrl:
|
||||
# Clean the message from the current entity's indicators
|
||||
del msg[entity.offset + entity.length + 1]
|
||||
del msg[entity.offset]
|
||||
|
||||
# Iterate over all the entities but the current
|
||||
for subentity in [e for e in entities if e is not entity]:
|
||||
# First case, one in one out: so*me_th_in*g.
|
||||
# In this case, the current entity length is decreased by two,
|
||||
# and all the subentities offset decreases 1
|
||||
if (subentity.offset > entity.offset and
|
||||
subentity.offset + subentity.length < entity.offset + entity.length):
|
||||
entity.length -= 2
|
||||
subentity.offset -= 1
|
||||
|
||||
# Second case, both inside: so*me_th*in_g.
|
||||
# In this case, the current entity length is decreased by one,
|
||||
# and all the subentities offset and length decrease 1
|
||||
elif (entity.offset < subentity.offset < entity.offset + entity.length and
|
||||
subentity.offset + subentity.length > entity.offset + entity.length):
|
||||
entity.length -= 1
|
||||
subentity.offset -= 1
|
||||
subentity.length -= 1
|
||||
|
||||
# Third case, both outside: so*me*th_in_g.
|
||||
# In this case, the current entity is left untouched,
|
||||
# and all the subentities offset decreases 2
|
||||
elif subentity.offset > entity.offset + entity.length:
|
||||
subentity.offset -= 2
|
||||
|
||||
# Finally, we can join our poor mutilated message back and return
|
||||
msg = ''.join(msg)
|
||||
return msg, entities
|
585
telethon/telegram_client.py
Normal file
585
telethon/telegram_client.py
Normal file
@@ -0,0 +1,585 @@
|
||||
import platform
|
||||
from datetime import datetime
|
||||
from hashlib import md5
|
||||
from os import path
|
||||
from mimetypes import guess_extension, guess_type
|
||||
|
||||
# For sending and receiving requests
|
||||
from telethon.tl import MTProtoRequest
|
||||
from telethon.tl import Session
|
||||
|
||||
# The Requests and types that we'll be using
|
||||
from telethon.tl.functions.upload import SaveBigFilePartRequest
|
||||
from telethon.tl.types import \
|
||||
PeerUser, PeerChat, PeerChannel, \
|
||||
InputPeerUser, InputPeerChat, InputPeerChannel, InputPeerEmpty, \
|
||||
InputFile, InputFileLocation, InputMediaUploadedPhoto, InputMediaUploadedDocument, \
|
||||
MessageMediaContact, MessageMediaDocument, MessageMediaPhoto, \
|
||||
DocumentAttributeAudio, DocumentAttributeFilename, InputDocumentFileLocation
|
||||
|
||||
from telethon.tl.functions import InvokeWithLayerRequest, InitConnectionRequest
|
||||
from telethon.tl.functions.help import GetConfigRequest
|
||||
from telethon.tl.functions.auth import SendCodeRequest, SignInRequest, SignUpRequest, LogOutRequest
|
||||
from telethon.tl.functions.upload import SaveFilePartRequest, GetFileRequest
|
||||
from telethon.tl.functions.messages import GetDialogsRequest, GetHistoryRequest, SendMessageRequest, SendMediaRequest
|
||||
|
||||
import telethon.helpers as utils
|
||||
import telethon.network.authenticator as authenticator
|
||||
|
||||
from telethon.errors import *
|
||||
from telethon.network import MtProtoSender, TcpTransport
|
||||
from telethon.parser.markdown_parser import parse_message_entities
|
||||
|
||||
|
||||
class TelegramClient:
|
||||
|
||||
# region Initialization
|
||||
|
||||
def __init__(self, session_user_id, layer, api_id, api_hash):
|
||||
if api_id is None or api_hash is None:
|
||||
raise PermissionError('Your API ID or Hash are invalid. Please read "Requirements" on README.md')
|
||||
|
||||
self.api_id = api_id
|
||||
self.api_hash = api_hash
|
||||
|
||||
self.layer = layer
|
||||
|
||||
self.session = Session.try_load_or_create_new(session_user_id)
|
||||
self.transport = TcpTransport(self.session.server_address, self.session.port)
|
||||
|
||||
# These will be set later
|
||||
self.dc_options = None
|
||||
self.sender = None
|
||||
self.phone_code_hashes = {}
|
||||
|
||||
# endregion
|
||||
|
||||
# region Connecting
|
||||
|
||||
def connect(self, reconnect=False):
|
||||
"""Connects to the Telegram servers, executing authentication if required.
|
||||
Note that authenticating to the Telegram servers is not the same as authenticating
|
||||
the app, which requires to send a code first."""
|
||||
try:
|
||||
if not self.session.auth_key or reconnect:
|
||||
self.session.auth_key, self.session.time_offset = \
|
||||
authenticator.do_authentication(self.transport)
|
||||
|
||||
self.session.save()
|
||||
|
||||
self.sender = MtProtoSender(self.transport, self.session)
|
||||
|
||||
# Now it's time to send an InitConnectionRequest
|
||||
# This must always be invoked with the layer we'll be using
|
||||
query = InitConnectionRequest(api_id=self.api_id,
|
||||
device_model=platform.node(),
|
||||
system_version=platform.system(),
|
||||
app_version='0.4',
|
||||
lang_code='en',
|
||||
query=GetConfigRequest())
|
||||
|
||||
result = self.invoke(InvokeWithLayerRequest(layer=self.layer, query=query))
|
||||
|
||||
# We're only interested in the DC options,
|
||||
# although many other options are available!
|
||||
self.dc_options = result.dc_options
|
||||
return True
|
||||
except RPCError as error:
|
||||
print('Could not stabilise initial connection: {}'.format(error))
|
||||
return False
|
||||
|
||||
def reconnect_to_dc(self, dc_id):
|
||||
"""Reconnects to the specified DC ID. This is automatically called after an InvalidDCError is raised"""
|
||||
if self.dc_options is None or not self.dc_options:
|
||||
raise ConnectionError("Can't reconnect. Stabilise an initial connection first.")
|
||||
|
||||
dc = next(dc for dc in self.dc_options if dc.id == dc_id)
|
||||
|
||||
self.transport.close()
|
||||
self.transport = TcpTransport(dc.ip_address, dc.port)
|
||||
self.session.server_address = dc.ip_address
|
||||
self.session.port = dc.port
|
||||
self.session.save()
|
||||
|
||||
self.connect(reconnect=True)
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnects from the Telegram server **and pauses all the spawned threads**"""
|
||||
if self.sender:
|
||||
self.sender.disconnect()
|
||||
|
||||
# endregion
|
||||
|
||||
# region Telegram requests functions
|
||||
|
||||
def invoke(self, request):
|
||||
"""Invokes a MTProtoRequest (sends and receives it) and returns its result"""
|
||||
if not issubclass(type(request), MTProtoRequest):
|
||||
raise ValueError('You can only invoke MtProtoRequests')
|
||||
|
||||
self.sender.send(request)
|
||||
self.sender.receive(request)
|
||||
|
||||
return request.result
|
||||
|
||||
# region Authorization requests
|
||||
|
||||
def is_user_authorized(self):
|
||||
"""Has the user been authorized yet (code request sent and confirmed)?
|
||||
Note that this will NOT yield the correct result if the session was revoked by another client!"""
|
||||
return self.session.user is not None
|
||||
|
||||
def send_code_request(self, phone_number):
|
||||
"""Sends a code request to the specified phone number"""
|
||||
request = SendCodeRequest(phone_number, self.api_id, self.api_hash)
|
||||
completed = False
|
||||
while not completed:
|
||||
try:
|
||||
result = self.invoke(request)
|
||||
self.phone_code_hashes[phone_number] = result.phone_code_hash
|
||||
completed = True
|
||||
|
||||
except InvalidDCError as error:
|
||||
self.reconnect_to_dc(error.new_dc)
|
||||
|
||||
def sign_in(self, phone_number, code):
|
||||
"""Completes the authorization of a phone number by providing the received code"""
|
||||
if phone_number not in self.phone_code_hashes:
|
||||
raise ValueError('Please make sure you have called send_code_request first.')
|
||||
|
||||
try:
|
||||
result = self.invoke(SignInRequest(
|
||||
phone_number, self.phone_code_hashes[phone_number], code))
|
||||
|
||||
except RPCError as error:
|
||||
if error.message.startswith('PHONE_CODE_'):
|
||||
print(error)
|
||||
return False
|
||||
else:
|
||||
raise error
|
||||
|
||||
# Result is an Auth.Authorization TLObject
|
||||
self.session.user = result.user
|
||||
self.session.save()
|
||||
|
||||
# Now that we're authorized, we can listen for incoming updates
|
||||
self.sender.set_listen_for_updates(True)
|
||||
return True
|
||||
|
||||
def sign_up(self, phone_number, code, first_name, last_name=''):
|
||||
"""Signs up to Telegram. Make sure you sent a code request first!"""
|
||||
result = self.invoke(SignUpRequest(phone_number=phone_number,
|
||||
phone_code_hash=self.phone_code_hashes[phone_number],
|
||||
phone_code=code,
|
||||
first_name=first_name,
|
||||
last_name=last_name))
|
||||
|
||||
self.session.user = result.user
|
||||
self.session.save()
|
||||
|
||||
def log_out(self):
|
||||
"""Logs out and deletes the current session. Returns True if everything went OK"""
|
||||
try:
|
||||
# This request is a bit special. Nothing is received after
|
||||
self.sender.send(LogOutRequest())
|
||||
if not self.session.delete():
|
||||
return False
|
||||
|
||||
self.session = None
|
||||
except:
|
||||
return False
|
||||
|
||||
# endregion
|
||||
|
||||
# region Dialogs ("chats") requests
|
||||
|
||||
def get_dialogs(self, count=10, offset_date=None, offset_id=0, offset_peer=InputPeerEmpty()):
|
||||
"""Returns a tuple of lists ([dialogs], [displays], [input_peers]) with 'count' items each"""
|
||||
|
||||
r = self.invoke(GetDialogsRequest(offset_date=offset_date,
|
||||
offset_id=offset_id,
|
||||
offset_peer=offset_peer,
|
||||
limit=count))
|
||||
|
||||
return (r.dialogs,
|
||||
[self.find_display_name(d.peer, r.users, r.chats) for d in r.dialogs],
|
||||
[self.find_input_peer(d.peer, r.users, r.chats) for d in r.dialogs])
|
||||
|
||||
# endregion
|
||||
|
||||
# region Message requests
|
||||
|
||||
def send_message(self, input_peer, message, markdown=False, no_web_page=False):
|
||||
"""Sends a message to the given input peer"""
|
||||
if markdown:
|
||||
msg, entities = parse_message_entities(message)
|
||||
else:
|
||||
msg, entities = message, []
|
||||
|
||||
self.invoke(SendMessageRequest(peer=input_peer,
|
||||
message=msg,
|
||||
random_id=utils.generate_random_long(),
|
||||
entities=entities,
|
||||
no_webpage=no_web_page))
|
||||
|
||||
def get_message_history(self, input_peer, limit=20,
|
||||
offset_date=None, offset_id=0, max_id=0, min_id=0, add_offset=0):
|
||||
"""
|
||||
Gets the message history for the specified InputPeer
|
||||
|
||||
:param input_peer: The InputPeer from whom to retrieve the message history
|
||||
:param limit: Number of messages to be retrieved
|
||||
: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)
|
||||
|
||||
: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!
|
||||
"""
|
||||
result = self.invoke(GetHistoryRequest(input_peer,
|
||||
limit=limit,
|
||||
offset_date=offset_date,
|
||||
offset_id=offset_id,
|
||||
max_id=max_id,
|
||||
min_id=min_id,
|
||||
add_offset=add_offset))
|
||||
|
||||
# The result may be a messages slice (not all messages were retrieved) or
|
||||
# simply a messages TLObject. In the later case, no "count" attribute is specified:
|
||||
# the total messages count is retrieved by counting all the retrieved messages
|
||||
total_messages = getattr(result, 'count', len(result.messages))
|
||||
|
||||
# Iterate over all the messages and find the sender User
|
||||
users = []
|
||||
for msg in result.messages:
|
||||
for usr in result.users:
|
||||
if msg.from_id == usr.id:
|
||||
users.append(usr)
|
||||
break
|
||||
|
||||
return total_messages, result.messages, users
|
||||
|
||||
# endregion
|
||||
|
||||
# TODO Handle media downloading/uploading in a different session?
|
||||
# "It is recommended that large queries (upload.getFile, upload.saveFilePart)
|
||||
# be handled through a separate session and a separate connection"
|
||||
# region Uploading media requests
|
||||
|
||||
def upload_file(self, file_path, part_size_kb=None, file_name=None, progress_callback=None):
|
||||
"""Uploads the specified file_path and returns a handle which can be later used
|
||||
|
||||
:param file_path: The file path of the file that will be uploaded
|
||||
:param part_size_kb: The part size when uploading the file. None = Automatic
|
||||
:param file_name: The name of the uploaded file. None = Automatic
|
||||
:param progress_callback: A callback function which takes two parameters,
|
||||
uploaded size (in bytes) and total file size (in bytes)
|
||||
This is called every time a part is uploaded
|
||||
"""
|
||||
file_size = path.getsize(file_path)
|
||||
if not part_size_kb:
|
||||
part_size_kb = self.find_appropiate_part_size(file_size)
|
||||
|
||||
if part_size_kb > 512:
|
||||
raise ValueError('The part size must be less or equal to 512KB')
|
||||
|
||||
part_size = int(part_size_kb * 1024)
|
||||
if part_size % 1024 != 0:
|
||||
raise ValueError('The part size must be evenly divisible by 1024')
|
||||
|
||||
# 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
|
||||
part_count = (file_size + part_size - 1) // part_size
|
||||
|
||||
# Multiply the datetime timestamp by 10^6 to get the ticks
|
||||
# This is high likely going to be unique
|
||||
file_id = int(datetime.now().timestamp() * (10 ** 6))
|
||||
hash_md5 = md5()
|
||||
|
||||
with open(file_path, 'rb') as file:
|
||||
for part_index in range(part_count):
|
||||
# Read the file by in chunks of size part_size
|
||||
part = file.read(part_size)
|
||||
|
||||
# The SavePartRequest is different depending on whether
|
||||
# the file is too large or not (over or less than 10MB)
|
||||
if is_large:
|
||||
request = SaveBigFilePartRequest(file_id, part_index, part_count, part)
|
||||
else:
|
||||
request = SaveFilePartRequest(file_id, part_index, part)
|
||||
|
||||
# Invoke the file upload and increment both the part index and MD5 checksum
|
||||
result = self.invoke(request)
|
||||
if result:
|
||||
hash_md5.update(part)
|
||||
if progress_callback:
|
||||
progress_callback(file.tell(), file_size)
|
||||
else:
|
||||
raise ValueError('Could not upload file part #{}'.format(part_index))
|
||||
|
||||
# Set a default file name if None was specified
|
||||
if not file_name:
|
||||
file_name = path.basename(file_path)
|
||||
|
||||
# After the file has been uploaded, we can return a handle pointing to it
|
||||
return InputFile(id=file_id,
|
||||
parts=part_count,
|
||||
name=file_name,
|
||||
md5_checksum=hash_md5.hexdigest())
|
||||
|
||||
def send_photo_file(self, input_file, input_peer, caption=''):
|
||||
"""Sends a previously uploaded input_file
|
||||
(which should be a photo) to an input_peer"""
|
||||
self.send_media_file(
|
||||
InputMediaUploadedPhoto(input_file, caption), input_peer)
|
||||
|
||||
def send_document_file(self, input_file, input_peer, caption=''):
|
||||
"""Sends a previously uploaded input_file
|
||||
(which should be a document) to an input_peer"""
|
||||
|
||||
# Determine mime-type and attributes
|
||||
# Take the first element by using [0] since it returns a tuple
|
||||
mime_type = guess_type(input_file.name)[0]
|
||||
attributes = [
|
||||
DocumentAttributeFilename(input_file.name)
|
||||
# TODO If the input file is an audio, find out:
|
||||
# Performer and song title and add DocumentAttributeAudio
|
||||
]
|
||||
# Ensure we have a mime type, any; but it cannot be None
|
||||
# «The "octet-stream" subtype is used to indicate that a body contains arbitrary binary data.»
|
||||
if not mime_type:
|
||||
mime_type = 'application/octet-stream'
|
||||
self.send_media_file(InputMediaUploadedDocument(file=input_file,
|
||||
mime_type=mime_type,
|
||||
attributes=attributes,
|
||||
caption=caption), input_peer)
|
||||
|
||||
def send_media_file(self, input_media, input_peer):
|
||||
"""Sends any input_media (contact, document, photo...) to an input_peer"""
|
||||
self.invoke(SendMediaRequest(peer=input_peer,
|
||||
media=input_media,
|
||||
random_id=utils.generate_random_long()))
|
||||
|
||||
# endregion
|
||||
|
||||
# region Downloading media requests
|
||||
|
||||
def download_msg_media(self, message_media, file_path, add_extension=True, progress_callback=None):
|
||||
"""Downloads the given MessageMedia (Photo, Document or Contact)
|
||||
into the desired file_path, optionally finding its extension automatically
|
||||
The progress_callback should be a callback function which takes two parameters,
|
||||
uploaded size (in bytes) and total file size (in bytes).
|
||||
This will be called every time a part is downloaded"""
|
||||
if type(message_media) == MessageMediaPhoto:
|
||||
return self.download_photo(message_media, file_path, add_extension, progress_callback)
|
||||
|
||||
elif type(message_media) == MessageMediaDocument:
|
||||
return self.download_document(message_media, file_path, add_extension, progress_callback)
|
||||
|
||||
elif type(message_media) == MessageMediaContact:
|
||||
return self.download_contact(message_media, file_path, add_extension)
|
||||
|
||||
def download_photo(self, message_media_photo, file_path, add_extension=False,
|
||||
progress_callback=None):
|
||||
"""Downloads MessageMediaPhoto's largest size into the desired
|
||||
file_path, optionally finding its extension automatically
|
||||
The progress_callback should be a callback function which takes two parameters,
|
||||
uploaded size (in bytes) and total file size (in bytes).
|
||||
This will be called every time a part is downloaded"""
|
||||
|
||||
# Determine the photo and its largest size
|
||||
photo = message_media_photo.photo
|
||||
largest_size = photo.sizes[-1]
|
||||
file_size = largest_size.size
|
||||
largest_size = largest_size.location
|
||||
|
||||
# Photos are always compressed into a .jpg by Telegram
|
||||
if add_extension:
|
||||
file_path += '.jpg'
|
||||
|
||||
# Download the media with the largest size input file location
|
||||
self.download_file_loc(InputFileLocation(volume_id=largest_size.volume_id,
|
||||
local_id=largest_size.local_id,
|
||||
secret=largest_size.secret),
|
||||
file_path, file_size, progress_callback)
|
||||
return file_path
|
||||
|
||||
def download_document(self, message_media_document, file_path=None, add_extension=True,
|
||||
progress_callback=None):
|
||||
"""Downloads the given MessageMediaDocument into the desired
|
||||
file_path, optionally finding its extension automatically.
|
||||
If no file_path is given, it will try to be guessed from the document
|
||||
The progress_callback should be a callback function which takes two parameters,
|
||||
uploaded size (in bytes) and total file size (in bytes).
|
||||
This will be called every time a part is downloaded"""
|
||||
document = message_media_document.document
|
||||
file_size = document.size
|
||||
|
||||
# If no file path was given, try to guess it from the attributes
|
||||
if file_path is None:
|
||||
for attr in document.attributes:
|
||||
if type(attr) == DocumentAttributeFilename:
|
||||
file_path = attr.file_name
|
||||
break # This attribute has higher preference
|
||||
|
||||
elif type(attr) == DocumentAttributeAudio:
|
||||
file_path = '{} - {}'.format(attr.performer, attr.title)
|
||||
|
||||
if file_path is None:
|
||||
print('Could not determine a filename for the document')
|
||||
|
||||
# Guess the extension based on the mime_type
|
||||
if add_extension:
|
||||
ext = guess_extension(document.mime_type)
|
||||
if ext is not None:
|
||||
file_path += ext
|
||||
|
||||
self.download_file_loc(InputDocumentFileLocation(id=document.id,
|
||||
access_hash=document.access_hash,
|
||||
version=document.version),
|
||||
file_path, file_size, progress_callback)
|
||||
|
||||
return file_path
|
||||
|
||||
@staticmethod
|
||||
def download_contact(message_media_contact, file_path, add_extension=True):
|
||||
"""Downloads a media contact using the vCard 4.0 format"""
|
||||
|
||||
first_name = message_media_contact.first_name
|
||||
last_name = message_media_contact.last_name
|
||||
phone_number = message_media_contact.phone_number
|
||||
|
||||
# The only way we can save a contact in an understandable
|
||||
# way by phones is by using the .vCard format
|
||||
if add_extension:
|
||||
file_path += '.vcard'
|
||||
|
||||
# Ensure that we'll be able to download the contact
|
||||
utils.ensure_parent_dir_exists(file_path)
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as file:
|
||||
file.write('BEGIN:VCARD\n')
|
||||
file.write('VERSION:4.0\n')
|
||||
file.write('N:{};{};;;\n'.format(first_name, last_name if last_name else ''))
|
||||
file.write('FN:{}\n'.format(' '.join((first_name, last_name))))
|
||||
file.write('TEL;TYPE=cell;VALUE=uri:tel:+{}\n'.format(phone_number))
|
||||
file.write('END:VCARD\n')
|
||||
|
||||
return file_path
|
||||
|
||||
def download_file_loc(self, input_location, file_path, part_size_kb=64,
|
||||
file_size=None, progress_callback=None):
|
||||
"""Downloads media from the given input_file_location to the specified file_path.
|
||||
If a progress_callback function is given, it will be called taking two
|
||||
arguments (downloaded bytes count and total file size)"""
|
||||
|
||||
if not part_size_kb:
|
||||
if not file_size:
|
||||
raise ValueError('A part size value must be provided')
|
||||
else:
|
||||
part_size_kb = self.find_appropiate_part_size(file_size)
|
||||
|
||||
part_size = int(part_size_kb * 1024)
|
||||
if part_size % 1024 != 0:
|
||||
raise ValueError('The part size must be evenly divisible by 1024')
|
||||
|
||||
# Ensure that we'll be able to download the media
|
||||
utils.ensure_parent_dir_exists(file_path)
|
||||
|
||||
# Start with an offset index of 0
|
||||
offset_index = 0
|
||||
with open(file_path, 'wb') as file:
|
||||
while True:
|
||||
# The current offset equals the offset_index multiplied by the part size
|
||||
offset = offset_index * part_size
|
||||
result = self.invoke(GetFileRequest(input_location, offset, part_size))
|
||||
offset_index += 1
|
||||
|
||||
# If we have received no data (0 bytes), the file is over
|
||||
# So there is nothing left to download and write
|
||||
if not result.bytes:
|
||||
return result.type # Return some extra information
|
||||
|
||||
file.write(result.bytes)
|
||||
if progress_callback:
|
||||
progress_callback(file.tell(), file_size)
|
||||
|
||||
# endregion
|
||||
|
||||
# endregion
|
||||
|
||||
# region Utilities
|
||||
|
||||
@staticmethod
|
||||
def find_display_name(peer, users, chats):
|
||||
"""Searches the display name for peer in both users and chats.
|
||||
Returns None if it was not found"""
|
||||
try:
|
||||
if type(peer) is PeerUser:
|
||||
user = next(u for u in users if u.id == peer.user_id)
|
||||
if user.last_name is not None:
|
||||
return '{} {}'.format(user.first_name, user.last_name)
|
||||
return user.first_name
|
||||
|
||||
elif type(peer) is PeerChat:
|
||||
return next(c for c in chats if c.id == peer.chat_id).title
|
||||
|
||||
elif type(peer) is PeerChannel:
|
||||
return next(c for c in chats if c.id == peer.channel_id).title
|
||||
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def find_input_peer(peer, users, chats):
|
||||
"""Searches the given peer in both users and chats and returns an InputPeer for it.
|
||||
Returns None if it was not found"""
|
||||
try:
|
||||
if type(peer) is PeerUser:
|
||||
user = next(u for u in users if u.id == peer.user_id)
|
||||
return InputPeerUser(user.id, user.access_hash)
|
||||
|
||||
elif type(peer) is PeerChat:
|
||||
chat = next(c for c in chats if c.id == peer.chat_id)
|
||||
return InputPeerChat(chat.id)
|
||||
|
||||
elif type(peer) is PeerChannel:
|
||||
channel = next(c for c in chats if c.id == peer.channel_id)
|
||||
return InputPeerChannel(channel.id, channel.access_hash)
|
||||
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def find_appropiate_part_size(file_size):
|
||||
if file_size <= 1048576: # 1MB
|
||||
return 32
|
||||
if file_size <= 10485760: # 10MB
|
||||
return 64
|
||||
if file_size <= 393216000: # 375MB
|
||||
return 128
|
||||
if file_size <= 786432000: # 750MB
|
||||
return 256
|
||||
if file_size <= 1572864000: # 1500MB
|
||||
return 512
|
||||
|
||||
raise ValueError('File size too large')
|
||||
|
||||
# endregion
|
||||
|
||||
# region Updates handling
|
||||
|
||||
def add_update_handler(self, handler):
|
||||
"""Adds an update handler (a function which takes a TLObject,
|
||||
an update, as its parameter) and listens for updates"""
|
||||
self.sender.add_update_handler(handler)
|
||||
|
||||
def remove_update_handler(self, handler):
|
||||
self.sender.remove_update_handler(handler)
|
||||
|
||||
# endregion
|
2
telethon/tl/__init__.py
Executable file
2
telethon/tl/__init__.py
Executable file
@@ -0,0 +1,2 @@
|
||||
from telethon.tl.mtproto_request import MTProtoRequest
|
||||
from telethon.tl.session import Session
|
40
telethon/tl/mtproto_request.py
Executable file
40
telethon/tl/mtproto_request.py
Executable file
@@ -0,0 +1,40 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class MTProtoRequest:
|
||||
def __init__(self):
|
||||
self.sent = False
|
||||
|
||||
self.msg_id = 0 # Long
|
||||
self.sequence = 0
|
||||
|
||||
self.dirty = False
|
||||
self.send_time = None
|
||||
self.confirm_received = False
|
||||
|
||||
# These should be overrode
|
||||
self.constructor_id = 0
|
||||
self.confirmed = False
|
||||
self.responded = False
|
||||
|
||||
# These should not be overrode
|
||||
def on_send_success(self):
|
||||
self.send_time = datetime.now()
|
||||
self.sent = True
|
||||
|
||||
def on_confirm(self):
|
||||
self.confirm_received = True
|
||||
|
||||
def need_resend(self):
|
||||
return self.dirty or (self.confirmed and not self.confirm_received and
|
||||
datetime.now() - self.send_time > timedelta(seconds=3))
|
||||
|
||||
# These should be overrode
|
||||
def on_send(self, writer):
|
||||
pass
|
||||
|
||||
def on_response(self, reader):
|
||||
pass
|
||||
|
||||
def on_exception(self, exception):
|
||||
pass
|
64
telethon/tl/session.py
Executable file
64
telethon/tl/session.py
Executable file
@@ -0,0 +1,64 @@
|
||||
from os.path import isfile as file_exists
|
||||
import os
|
||||
import time
|
||||
import pickle
|
||||
import random
|
||||
import telethon.helpers as utils
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, session_user_id):
|
||||
self.session_user_id = session_user_id
|
||||
self.server_address = '91.108.56.165'
|
||||
self.port = 443
|
||||
self.auth_key = None
|
||||
self.id = utils.generate_random_long(signed=False)
|
||||
self.sequence = 0
|
||||
self.salt = 0 # Unsigned long
|
||||
self.time_offset = 0
|
||||
self.last_message_id = 0 # Long
|
||||
self.user = None
|
||||
|
||||
def save(self):
|
||||
"""Saves the current session object as session_user_id.session"""
|
||||
if self.session_user_id:
|
||||
with open('{}.session'.format(self.session_user_id), 'wb') as file:
|
||||
pickle.dump(self, file)
|
||||
|
||||
def delete(self):
|
||||
"""Deletes the current session file"""
|
||||
try:
|
||||
os.remove('{}.session'.format(self.session_user_id))
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def try_load_or_create_new(session_user_id):
|
||||
"""Loads a saved session_user_id session, or creates a new one if none existed before.
|
||||
If the given session_user_id is None, we assume that it is for testing purposes"""
|
||||
if session_user_id is None:
|
||||
return Session(None)
|
||||
|
||||
else:
|
||||
filepath = '{}.session'.format(session_user_id)
|
||||
|
||||
if file_exists(filepath):
|
||||
with open(filepath, 'rb') as file:
|
||||
return pickle.load(file)
|
||||
else:
|
||||
return Session(session_user_id)
|
||||
|
||||
def get_new_msg_id(self):
|
||||
"""Generates a new message ID based on the current time (in ms) since epoch"""
|
||||
# Refer to mtproto_plain_sender.py for the original method, this is a simple copy
|
||||
ms_time = int(time.time() * 1000)
|
||||
new_msg_id = (((ms_time // 1000 + self.time_offset) << 32) | # "must approximately equal unixtime*2^32"
|
||||
((ms_time % 1000) << 22) | # "approximate moment in time the message was created"
|
||||
random.randint(0, 524288) << 2) # "message identifiers are divisible by 4"
|
||||
|
||||
if self.last_message_id >= new_msg_id:
|
||||
new_msg_id = self.last_message_id + 4
|
||||
|
||||
self.last_message_id = new_msg_id
|
||||
return new_msg_id
|
2
telethon/utils/__init__.py
Executable file
2
telethon/utils/__init__.py
Executable file
@@ -0,0 +1,2 @@
|
||||
from .binary_writer import BinaryWriter
|
||||
from .binary_reader import BinaryReader
|
160
telethon/utils/binary_reader.py
Executable file
160
telethon/utils/binary_reader.py
Executable file
@@ -0,0 +1,160 @@
|
||||
from datetime import datetime
|
||||
from io import BytesIO, BufferedReader
|
||||
from telethon.tl.all_tlobjects import tlobjects
|
||||
from struct import unpack
|
||||
from telethon.errors import *
|
||||
import inspect
|
||||
import os
|
||||
|
||||
|
||||
class BinaryReader:
|
||||
"""
|
||||
Small utility class to read binary data.
|
||||
Also creates a "Memory Stream" if necessary
|
||||
"""
|
||||
def __init__(self, data=None, stream=None):
|
||||
if data:
|
||||
self.stream = BytesIO(data)
|
||||
elif stream:
|
||||
self.stream = stream
|
||||
else:
|
||||
raise InvalidParameterError("Either bytes or a stream must be provided")
|
||||
|
||||
self.reader = BufferedReader(self.stream)
|
||||
|
||||
# region Reading
|
||||
|
||||
# "All numbers are written as little endian." |> Source: https://core.telegram.org/mtproto
|
||||
def read_byte(self):
|
||||
"""Reads a single byte value"""
|
||||
return self.read(1)[0]
|
||||
|
||||
def read_int(self, signed=True):
|
||||
"""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"""
|
||||
return int.from_bytes(self.read(8), byteorder='little', signed=signed)
|
||||
|
||||
def read_float(self):
|
||||
"""Reads a real floating point (4 bytes) value"""
|
||||
return unpack('<f', self.read(4))[0]
|
||||
|
||||
def read_double(self):
|
||||
"""Reads a real floating point (8 bytes) value"""
|
||||
return unpack('<d', self.read(8))[0]
|
||||
|
||||
def read_large_int(self, bits, signed=True):
|
||||
"""Reads a n-bits long integer value"""
|
||||
return int.from_bytes(self.read(bits // 8), byteorder='little', signed=signed)
|
||||
|
||||
def read(self, length):
|
||||
"""Read the given amount of bytes"""
|
||||
result = self.reader.read(length)
|
||||
if len(result) != length:
|
||||
raise BufferError('Trying to read outside the data bounds (no more data left to read)')
|
||||
|
||||
return result
|
||||
|
||||
def get_bytes(self):
|
||||
"""Gets the byte array representing the current buffer as a whole"""
|
||||
return self.stream.getvalue()
|
||||
|
||||
# endregion
|
||||
|
||||
# region Telegram custom reading
|
||||
|
||||
def tgread_bytes(self):
|
||||
"""Reads a Telegram-encoded byte array, without the need of specifying its length"""
|
||||
first_byte = self.read_byte()
|
||||
if first_byte == 254:
|
||||
length = self.read_byte() | (self.read_byte() << 8) | (self.read_byte() << 16)
|
||||
padding = length % 4
|
||||
else:
|
||||
length = first_byte
|
||||
padding = (length + 1) % 4
|
||||
|
||||
data = self.read(length)
|
||||
if padding > 0:
|
||||
padding = 4 - padding
|
||||
self.read(padding)
|
||||
|
||||
return data
|
||||
|
||||
def tgread_string(self):
|
||||
"""Reads a Telegram-encoded string"""
|
||||
return str(self.tgread_bytes(), encoding='utf-8')
|
||||
|
||||
def tgread_bool(self):
|
||||
"""Reads a Telegram boolean value"""
|
||||
value = self.read_int(signed=False)
|
||||
if value == 0x997275b5: # boolTrue
|
||||
return True
|
||||
elif value == 0xbc799737: # boolFalse
|
||||
return False
|
||||
else:
|
||||
raise ValueError('Invalid boolean code {}'.format(hex(value)))
|
||||
|
||||
def tgread_date(self):
|
||||
"""Reads and converts Unix time (used by Telegram) into a Python datetime object"""
|
||||
value = self.read_int()
|
||||
return None if value == 0 else datetime.fromtimestamp(value)
|
||||
|
||||
def tgread_object(self):
|
||||
"""Reads a Telegram object"""
|
||||
constructor_id = self.read_int(signed=False)
|
||||
clazz = tlobjects.get(constructor_id, None)
|
||||
if clazz is None:
|
||||
# The class was None, but there's still a
|
||||
# chance of it being a manually parsed value like bool!
|
||||
value = constructor_id
|
||||
if value == 0x997275b5: # boolTrue
|
||||
return True
|
||||
elif value == 0xbc799737: # boolFalse
|
||||
return False
|
||||
|
||||
# If there was still no luck, give up
|
||||
raise TypeNotFoundError(constructor_id)
|
||||
|
||||
# Now we need to determine the number of parameters of the class, so we can
|
||||
# instantiate it with all of them set to None, and still, no need to write
|
||||
# the default =None in all the classes, thus forcing the user to provide a real value
|
||||
sig = inspect.signature(clazz.__init__)
|
||||
params = [None] * (len(sig.parameters) - 1) # Subtract 1 (self)
|
||||
result = clazz(*params) # https://docs.python.org/3/tutorial/controlflow.html#unpacking-argument-lists
|
||||
|
||||
# Finally, read the object and return the result
|
||||
result.on_response(self)
|
||||
return result
|
||||
|
||||
# endregion
|
||||
|
||||
def close(self):
|
||||
self.reader.close()
|
||||
|
||||
# region Position related
|
||||
|
||||
def tell_position(self):
|
||||
"""Tells the current position on the stream"""
|
||||
return self.reader.tell()
|
||||
|
||||
def set_position(self, position):
|
||||
"""Sets the current position on the stream"""
|
||||
self.reader.seek(position)
|
||||
|
||||
def seek(self, offset):
|
||||
"""Seeks the stream position given an offset from the current position. May be negative"""
|
||||
self.reader.seek(offset, os.SEEK_CUR)
|
||||
|
||||
# endregion
|
||||
|
||||
# region with block
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
# endregion
|
124
telethon/utils/binary_writer.py
Executable file
124
telethon/utils/binary_writer.py
Executable file
@@ -0,0 +1,124 @@
|
||||
from io import BytesIO, BufferedWriter
|
||||
from struct import pack
|
||||
|
||||
|
||||
class BinaryWriter:
|
||||
"""
|
||||
Small utility class to write binary data.
|
||||
Also creates a "Memory Stream" if necessary
|
||||
"""
|
||||
|
||||
def __init__(self, stream=None):
|
||||
if not stream:
|
||||
stream = BytesIO()
|
||||
|
||||
self.stream = stream
|
||||
self.writer = BufferedWriter(self.stream)
|
||||
self.written_count = 0
|
||||
|
||||
# region Writing
|
||||
|
||||
# "All numbers are written as little endian." |> Source: https://core.telegram.org/mtproto
|
||||
def write_byte(self, value):
|
||||
"""Writes a single byte value"""
|
||||
self.writer.write(pack('B', value))
|
||||
self.written_count += 1
|
||||
|
||||
def write_int(self, value, signed=True):
|
||||
"""Writes an integer value (4 bytes), which can or cannot be signed"""
|
||||
self.writer.write(int.to_bytes(value, length=4, byteorder='little', signed=signed))
|
||||
self.written_count += 4
|
||||
|
||||
def write_long(self, value, signed=True):
|
||||
"""Writes a long integer value (8 bytes), which can or cannot be signed"""
|
||||
self.writer.write(int.to_bytes(value, length=8, byteorder='little', signed=signed))
|
||||
self.written_count += 8
|
||||
|
||||
def write_float(self, value):
|
||||
"""Writes a floating point value (4 bytes)"""
|
||||
self.writer.write(pack('<f', value))
|
||||
self.written_count += 4
|
||||
|
||||
def write_double(self, value):
|
||||
"""Writes a floating point value (8 bytes)"""
|
||||
self.writer.write(pack('<d', value))
|
||||
self.written_count += 8
|
||||
|
||||
def write_large_int(self, value, bits, signed=True):
|
||||
"""Writes a n-bits long integer value"""
|
||||
self.writer.write(int.to_bytes(value, length=bits // 8, byteorder='little', signed=signed))
|
||||
self.written_count += bits // 8
|
||||
|
||||
def write(self, data):
|
||||
"""Writes the given bytes array"""
|
||||
self.writer.write(data)
|
||||
self.written_count += len(data)
|
||||
|
||||
# endregion
|
||||
|
||||
# region Telegram custom writing
|
||||
|
||||
def tgwrite_bytes(self, data):
|
||||
"""Write bytes by using Telegram guidelines"""
|
||||
if len(data) < 254:
|
||||
padding = (len(data) + 1) % 4
|
||||
if padding != 0:
|
||||
padding = 4 - padding
|
||||
|
||||
self.write(bytes([len(data)]))
|
||||
self.write(data)
|
||||
|
||||
else:
|
||||
padding = len(data) % 4
|
||||
if padding != 0:
|
||||
padding = 4 - padding
|
||||
|
||||
self.write(bytes([254]))
|
||||
self.write(bytes([len(data) % 256]))
|
||||
self.write(bytes([(len(data) >> 8) % 256]))
|
||||
self.write(bytes([(len(data) >> 16) % 256]))
|
||||
self.write(data)
|
||||
|
||||
self.write(bytes(padding))
|
||||
|
||||
def tgwrite_string(self, string):
|
||||
"""Write a string by using Telegram guidelines"""
|
||||
self.tgwrite_bytes(string.encode('utf-8'))
|
||||
|
||||
def tgwrite_bool(self, boolean):
|
||||
"""Write a boolean value by using Telegram guidelines"""
|
||||
# boolTrue boolFalse
|
||||
self.write_int(0x997275b5 if boolean else 0xbc799737, signed=False)
|
||||
|
||||
def tgwrite_date(self, datetime):
|
||||
"""Converts a Python datetime object into Unix time (used by Telegram) and writes it"""
|
||||
value = 0 if datetime is None else int(datetime.timestamp())
|
||||
self.write_int(value)
|
||||
|
||||
# endregion
|
||||
|
||||
def flush(self):
|
||||
"""Flush the current stream to "update" changes"""
|
||||
self.writer.flush()
|
||||
|
||||
def close(self):
|
||||
"""Close the current stream"""
|
||||
self.writer.close()
|
||||
|
||||
def get_bytes(self, flush=True):
|
||||
"""Get the current bytes array content from the buffer, optionally flushing first"""
|
||||
if flush:
|
||||
self.writer.flush()
|
||||
return self.stream.getvalue()
|
||||
|
||||
def get_written_bytes_count(self):
|
||||
"""Gets the count of bytes written in the buffer.
|
||||
This may NOT be equal to the stream length if one was provided when initializing the writer"""
|
||||
return self.written_count
|
||||
|
||||
# with block
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
Reference in New Issue
Block a user