mirror of
https://github.com/LonamiWebs/Telethon.git
synced 2024-11-23 09:53:45 +03:00
Merge pull request #277 from LonamiWebs/many-connections
This commit is contained in:
commit
4685cda751
|
@ -10,7 +10,7 @@ from ..errors import CdnFileTamperedError
|
||||||
class CdnDecrypter:
|
class CdnDecrypter:
|
||||||
"""Used when downloading a file results in a 'FileCdnRedirect' to
|
"""Used when downloading a file results in a 'FileCdnRedirect' to
|
||||||
both prepare the redirect, decrypt the file as it downloads, and
|
both prepare the redirect, decrypt the file as it downloads, and
|
||||||
ensure the file hasn't been tampered.
|
ensure the file hasn't been tampered. https://core.telegram.org/cdn
|
||||||
"""
|
"""
|
||||||
def __init__(self, cdn_client, file_token, cdn_aes, cdn_file_hashes):
|
def __init__(self, cdn_client, file_token, cdn_aes, cdn_file_hashes):
|
||||||
self.client = cdn_client
|
self.client = cdn_client
|
||||||
|
@ -19,46 +19,26 @@ class CdnDecrypter:
|
||||||
self.cdn_file_hashes = cdn_file_hashes
|
self.cdn_file_hashes = cdn_file_hashes
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def prepare_decrypter(client, client_cls, cdn_redirect):
|
def prepare_decrypter(client, cdn_client, cdn_redirect):
|
||||||
"""Prepares a CDN decrypter, returning (decrypter, file data).
|
"""Prepares a CDN decrypter, returning (decrypter, file data).
|
||||||
'client' should be the original TelegramBareClient that
|
'client' should be an existing client not connected to a CDN.
|
||||||
tried to download the file.
|
'cdn_client' should be an already-connected TelegramBareClient
|
||||||
|
with the auth key already created.
|
||||||
'client_cls' should be the class of the TelegramBareClient.
|
|
||||||
"""
|
"""
|
||||||
# TODO Avoid the need for 'client_cls=TelegramBareClient'
|
|
||||||
# https://core.telegram.org/cdn
|
|
||||||
cdn_aes = AESModeCTR(
|
cdn_aes = AESModeCTR(
|
||||||
key=cdn_redirect.encryption_key,
|
key=cdn_redirect.encryption_key,
|
||||||
# 12 first bytes of the IV..4 bytes of the offset (0, big endian)
|
# 12 first bytes of the IV..4 bytes of the offset (0, big endian)
|
||||||
iv=cdn_redirect.encryption_iv[:12] + bytes(4)
|
iv=cdn_redirect.encryption_iv[:12] + bytes(4)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a new client on said CDN
|
|
||||||
dc = client._get_dc(cdn_redirect.dc_id, cdn=True)
|
|
||||||
session = Session(client.session)
|
|
||||||
session.server_address = dc.ip_address
|
|
||||||
session.port = dc.port
|
|
||||||
cdn_client = client_cls( # Avoid importing TelegramBareClient
|
|
||||||
session, client.api_id, client.api_hash,
|
|
||||||
timeout=client._sender.connection.get_timeout()
|
|
||||||
)
|
|
||||||
# This will make use of the new RSA keys for this specific CDN.
|
|
||||||
#
|
|
||||||
# We assume that cdn_redirect.cdn_file_hashes are ordered by offset,
|
# We assume that cdn_redirect.cdn_file_hashes are ordered by offset,
|
||||||
# and that there will be enough of these to retrieve the whole file.
|
# and that there will be enough of these to retrieve the whole file.
|
||||||
#
|
|
||||||
# This relies on the fact that TelegramBareClient._dc_options is
|
|
||||||
# static and it won't be called from this DC (it would fail).
|
|
||||||
cdn_client.connect()
|
|
||||||
|
|
||||||
# CDN client is ready, create the resulting CdnDecrypter
|
|
||||||
decrypter = CdnDecrypter(
|
decrypter = CdnDecrypter(
|
||||||
cdn_client, cdn_redirect.file_token,
|
cdn_client, cdn_redirect.file_token,
|
||||||
cdn_aes, cdn_redirect.cdn_file_hashes
|
cdn_aes, cdn_redirect.cdn_file_hashes
|
||||||
)
|
)
|
||||||
|
|
||||||
cdn_file = client(GetCdnFileRequest(
|
cdn_file = cdn_client(GetCdnFileRequest(
|
||||||
file_token=cdn_redirect.file_token,
|
file_token=cdn_redirect.file_token,
|
||||||
offset=cdn_redirect.cdn_file_hashes[0].offset,
|
offset=cdn_redirect.cdn_file_hashes[0].offset,
|
||||||
limit=cdn_redirect.cdn_file_hashes[0].limit
|
limit=cdn_redirect.cdn_file_hashes[0].limit
|
||||||
|
|
|
@ -130,6 +130,13 @@ class Connection:
|
||||||
def close(self):
|
def close(self):
|
||||||
self.conn.close()
|
self.conn.close()
|
||||||
|
|
||||||
|
def clone(self):
|
||||||
|
"""Creates a copy of this Connection"""
|
||||||
|
return Connection(self.ip, self.port,
|
||||||
|
mode=self._mode,
|
||||||
|
proxy=self.conn.proxy,
|
||||||
|
timeout=self.conn.timeout)
|
||||||
|
|
||||||
# region Receive message implementations
|
# region Receive message implementations
|
||||||
|
|
||||||
def recv(self):
|
def recv(self):
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import gzip
|
import gzip
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from threading import RLock
|
|
||||||
|
|
||||||
from .. import helpers as utils
|
from .. import helpers as utils
|
||||||
from ..crypto import AES
|
from ..crypto import AES
|
||||||
|
@ -20,7 +19,12 @@ logging.getLogger(__name__).addHandler(logging.NullHandler())
|
||||||
|
|
||||||
class MtProtoSender:
|
class MtProtoSender:
|
||||||
"""MTProto Mobile Protocol sender
|
"""MTProto Mobile Protocol sender
|
||||||
(https://core.telegram.org/mtproto/description)
|
(https://core.telegram.org/mtproto/description).
|
||||||
|
|
||||||
|
Note that this class is not thread-safe, and calling send/receive
|
||||||
|
from two or more threads at the same time is undefined behaviour.
|
||||||
|
Rationale: a new connection should be spawned to send/receive requests
|
||||||
|
in parallel, so thread-safety (hence locking) isn't needed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session, connection):
|
def __init__(self, session, connection):
|
||||||
|
@ -37,11 +41,6 @@ class MtProtoSender:
|
||||||
# Requests (as msg_id: Message) sent waiting to be received
|
# Requests (as msg_id: Message) sent waiting to be received
|
||||||
self._pending_receive = {}
|
self._pending_receive = {}
|
||||||
|
|
||||||
# Sending and receiving are independent, but two threads cannot
|
|
||||||
# send or receive at the same time no matter what.
|
|
||||||
self._send_lock = RLock()
|
|
||||||
self._recv_lock = RLock()
|
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
"""Connects to the server"""
|
"""Connects to the server"""
|
||||||
self.connection.connect()
|
self.connection.connect()
|
||||||
|
@ -55,6 +54,10 @@ class MtProtoSender:
|
||||||
self._need_confirmation.clear()
|
self._need_confirmation.clear()
|
||||||
self._clear_all_pending()
|
self._clear_all_pending()
|
||||||
|
|
||||||
|
def clone(self):
|
||||||
|
"""Creates a copy of this MtProtoSender as a new connection"""
|
||||||
|
return MtProtoSender(self.session, self.connection.clone())
|
||||||
|
|
||||||
# region Send and receive
|
# region Send and receive
|
||||||
|
|
||||||
def send(self, *requests):
|
def send(self, *requests):
|
||||||
|
@ -93,7 +96,6 @@ class MtProtoSender:
|
||||||
Any unhandled object (likely updates) will be passed to
|
Any unhandled object (likely updates) will be passed to
|
||||||
update_state.process(TLObject).
|
update_state.process(TLObject).
|
||||||
"""
|
"""
|
||||||
with self._recv_lock:
|
|
||||||
try:
|
try:
|
||||||
body = self.connection.recv()
|
body = self.connection.recv()
|
||||||
except (BufferError, InvalidChecksumError):
|
except (BufferError, InvalidChecksumError):
|
||||||
|
@ -128,7 +130,6 @@ class MtProtoSender:
|
||||||
cipher_text = AES.encrypt_ige(plain_text, key, iv)
|
cipher_text = AES.encrypt_ige(plain_text, key, iv)
|
||||||
|
|
||||||
result = key_id + msg_key + cipher_text
|
result = key_id + msg_key + cipher_text
|
||||||
with self._send_lock:
|
|
||||||
self.connection.send(result)
|
self.connection.send(result)
|
||||||
|
|
||||||
def _decode_msg(self, body):
|
def _decode_msg(self, body):
|
||||||
|
|
|
@ -1,21 +1,24 @@
|
||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
import os
|
||||||
|
import threading
|
||||||
|
from datetime import timedelta, datetime
|
||||||
from hashlib import md5
|
from hashlib import md5
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from os import path
|
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
from . import helpers as utils
|
from . import helpers as utils
|
||||||
from .crypto import rsa, CdnDecrypter
|
from .crypto import rsa, CdnDecrypter
|
||||||
from .errors import (
|
from .errors import (
|
||||||
RPCError, BrokenAuthKeyError,
|
RPCError, BrokenAuthKeyError,
|
||||||
FloodWaitError, FileMigrateError, TypeNotFoundError
|
FloodWaitError, FileMigrateError, TypeNotFoundError,
|
||||||
|
UnauthorizedError, PhoneMigrateError, NetworkMigrateError, UserMigrateError
|
||||||
)
|
)
|
||||||
from .network import authenticator, MtProtoSender, Connection, ConnectionMode
|
from .network import authenticator, MtProtoSender, Connection, ConnectionMode
|
||||||
from .tl import TLObject, Session
|
from .tl import TLObject, Session
|
||||||
from .tl.all_tlobjects import LAYER
|
from .tl.all_tlobjects import LAYER
|
||||||
from .tl.functions import (
|
from .tl.functions import (
|
||||||
InitConnectionRequest, InvokeWithLayerRequest
|
InitConnectionRequest, InvokeWithLayerRequest, PingRequest
|
||||||
)
|
)
|
||||||
from .tl.functions.auth import (
|
from .tl.functions.auth import (
|
||||||
ImportAuthorizationRequest, ExportAuthorizationRequest
|
ImportAuthorizationRequest, ExportAuthorizationRequest
|
||||||
|
@ -23,6 +26,7 @@ from .tl.functions.auth import (
|
||||||
from .tl.functions.help import (
|
from .tl.functions.help import (
|
||||||
GetCdnConfigRequest, GetConfigRequest
|
GetCdnConfigRequest, GetConfigRequest
|
||||||
)
|
)
|
||||||
|
from .tl.functions.updates import GetStateRequest
|
||||||
from .tl.functions.upload import (
|
from .tl.functions.upload import (
|
||||||
GetFileRequest, SaveBigFilePartRequest, SaveFilePartRequest
|
GetFileRequest, SaveBigFilePartRequest, SaveFilePartRequest
|
||||||
)
|
)
|
||||||
|
@ -63,18 +67,34 @@ class TelegramBareClient:
|
||||||
def __init__(self, session, api_id, api_hash,
|
def __init__(self, session, api_id, api_hash,
|
||||||
connection_mode=ConnectionMode.TCP_FULL,
|
connection_mode=ConnectionMode.TCP_FULL,
|
||||||
proxy=None,
|
proxy=None,
|
||||||
process_updates=False,
|
update_workers=None,
|
||||||
timeout=timedelta(seconds=5)):
|
spawn_read_thread=False,
|
||||||
"""Initializes the Telegram client with the specified API ID and Hash.
|
timeout=timedelta(seconds=5),
|
||||||
Session must always be a Session instance, and an optional proxy
|
**kwargs):
|
||||||
can also be specified to be used on the connection.
|
"""Refer to TelegramClient.__init__ for docs on this method"""
|
||||||
"""
|
if not api_id or not api_hash:
|
||||||
|
raise PermissionError(
|
||||||
|
"Your API ID or Hash cannot be empty or None. "
|
||||||
|
"Refer to Telethon's README.rst for more information.")
|
||||||
|
|
||||||
|
# Determine what session object we have
|
||||||
|
if isinstance(session, str) or session is None:
|
||||||
|
session = Session.try_load_or_create_new(session)
|
||||||
|
elif not isinstance(session, Session):
|
||||||
|
raise ValueError(
|
||||||
|
'The given session must be a str or a Session instance.'
|
||||||
|
)
|
||||||
|
|
||||||
self.session = session
|
self.session = session
|
||||||
self.api_id = int(api_id)
|
self.api_id = int(api_id)
|
||||||
self.api_hash = api_hash
|
self.api_hash = api_hash
|
||||||
if self.api_id < 20: # official apps must use obfuscated
|
if self.api_id < 20: # official apps must use obfuscated
|
||||||
connection_mode = ConnectionMode.TCP_OBFUSCATED
|
connection_mode = ConnectionMode.TCP_OBFUSCATED
|
||||||
|
|
||||||
|
# This is the main sender, which will be used from the thread
|
||||||
|
# that calls .connect(). Every other thread will spawn a new
|
||||||
|
# temporary connection. The connection on this one is always
|
||||||
|
# kept open so Telegram can send us updates.
|
||||||
self._sender = MtProtoSender(self.session, Connection(
|
self._sender = MtProtoSender(self.session, Connection(
|
||||||
self.session.server_address, self.session.port,
|
self.session.server_address, self.session.port,
|
||||||
mode=connection_mode, proxy=proxy, timeout=timeout
|
mode=connection_mode, proxy=proxy, timeout=timeout
|
||||||
|
@ -86,28 +106,76 @@ class TelegramBareClient:
|
||||||
# we only want one to actually perform the reconnection.
|
# we only want one to actually perform the reconnection.
|
||||||
self._connect_lock = Lock()
|
self._connect_lock = Lock()
|
||||||
|
|
||||||
# Cache "exported" senders 'dc_id: TelegramBareClient' and
|
# Cache "exported" sessions as 'dc_id: Session' not to recreate
|
||||||
# their corresponding sessions not to recreate them all
|
# them all the time since generating a new key is a relatively
|
||||||
# the time since it's a (somewhat expensive) process.
|
# expensive operation.
|
||||||
self._cached_clients = {}
|
self._exported_sessions = {}
|
||||||
|
|
||||||
# This member will process updates if enabled.
|
# This member will process updates if enabled.
|
||||||
# One may change self.updates.enabled at any later point.
|
# One may change self.updates.enabled at any later point.
|
||||||
self.updates = UpdateState(process_updates)
|
self.updates = UpdateState(workers=update_workers)
|
||||||
|
|
||||||
|
# Used on connection - the user may modify these and reconnect
|
||||||
|
kwargs['app_version'] = kwargs.get('app_version', self.__version__)
|
||||||
|
for name, value in kwargs.items():
|
||||||
|
if hasattr(self.session, name):
|
||||||
|
setattr(self.session, name, value)
|
||||||
|
|
||||||
|
# Despite the state of the real connection, keep track of whether
|
||||||
|
# the user has explicitly called .connect() or .disconnect() here.
|
||||||
|
# This information is required by the read thread, who will be the
|
||||||
|
# one attempting to reconnect on the background *while* the user
|
||||||
|
# doesn't explicitly call .disconnect(), thus telling it to stop
|
||||||
|
# retrying. The main thread, knowing there is a background thread
|
||||||
|
# attempting reconnection as soon as it happens, will just sleep.
|
||||||
|
self._user_connected = False
|
||||||
|
|
||||||
|
# Save whether the user is authorized here (a.k.a. logged in)
|
||||||
|
self._authorized = False
|
||||||
|
|
||||||
|
# Uploaded files cache so subsequent calls are instant
|
||||||
|
self._upload_cache = {}
|
||||||
|
|
||||||
|
# Constantly read for results and updates from within the main client,
|
||||||
|
# if the user has left enabled such option.
|
||||||
|
self._spawn_read_thread = spawn_read_thread
|
||||||
|
self._recv_thread = None
|
||||||
|
|
||||||
|
# Identifier of the main thread (the one that called .connect()).
|
||||||
|
# This will be used to create new connections from any other thread,
|
||||||
|
# so that requests can be sent in parallel.
|
||||||
|
self._main_thread_ident = None
|
||||||
|
|
||||||
|
# Default PingRequest delay
|
||||||
|
self._last_ping = datetime.now()
|
||||||
|
self._ping_delay = timedelta(minutes=1)
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
# region Connecting
|
# region Connecting
|
||||||
|
|
||||||
def connect(self, exported_auth=None):
|
def connect(self, _exported_auth=None, _sync_updates=True, _cdn=False):
|
||||||
"""Connects to the Telegram servers, executing authentication if
|
"""Connects to the Telegram servers, executing authentication if
|
||||||
required. Note that authenticating to the Telegram servers is
|
required. Note that authenticating to the Telegram servers is
|
||||||
not the same as authenticating the desired user itself, which
|
not the same as authenticating the desired user itself, which
|
||||||
may require a call (or several) to 'sign_in' for the first time.
|
may require a call (or several) to 'sign_in' for the first time.
|
||||||
|
|
||||||
If 'exported_auth' is not None, it will be used instead to
|
Note that the optional parameters are meant for internal use.
|
||||||
|
|
||||||
|
If '_exported_auth' is not None, it will be used instead to
|
||||||
determine the authorization key for the current session.
|
determine the authorization key for the current session.
|
||||||
|
|
||||||
|
If '_sync_updates', sync_updates() will be called and a
|
||||||
|
second thread will be started if necessary. Note that this
|
||||||
|
will FAIL if the client is not connected to the user's
|
||||||
|
native data center, raising a "UserMigrateError", and
|
||||||
|
calling .disconnect() in the process.
|
||||||
|
|
||||||
|
If '_cdn' is False, methods that are not allowed on such data
|
||||||
|
centers won't be invoked.
|
||||||
"""
|
"""
|
||||||
|
self._main_thread_ident = threading.get_ident()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._sender.connect()
|
self._sender.connect()
|
||||||
if not self.session.auth_key:
|
if not self.session.auth_key:
|
||||||
|
@ -126,30 +194,46 @@ class TelegramBareClient:
|
||||||
init_connection = self.session.layer != LAYER
|
init_connection = self.session.layer != LAYER
|
||||||
|
|
||||||
if init_connection:
|
if init_connection:
|
||||||
if exported_auth is not None:
|
if _exported_auth is not None:
|
||||||
self._init_connection(ImportAuthorizationRequest(
|
self._init_connection(ImportAuthorizationRequest(
|
||||||
exported_auth.id, exported_auth.bytes
|
_exported_auth.id, _exported_auth.bytes
|
||||||
))
|
))
|
||||||
else:
|
elif not _cdn:
|
||||||
TelegramBareClient._dc_options = \
|
TelegramBareClient._dc_options = \
|
||||||
self._init_connection(GetConfigRequest()).dc_options
|
self._init_connection(GetConfigRequest()).dc_options
|
||||||
|
|
||||||
elif exported_auth is not None:
|
elif _exported_auth is not None:
|
||||||
self(ImportAuthorizationRequest(
|
self(ImportAuthorizationRequest(
|
||||||
exported_auth.id, exported_auth.bytes
|
_exported_auth.id, _exported_auth.bytes
|
||||||
))
|
))
|
||||||
|
|
||||||
if TelegramBareClient._dc_options is None:
|
if TelegramBareClient._dc_options is None and not _cdn:
|
||||||
TelegramBareClient._dc_options = \
|
TelegramBareClient._dc_options = \
|
||||||
self(GetConfigRequest()).dc_options
|
self(GetConfigRequest()).dc_options
|
||||||
|
|
||||||
|
# Connection was successful! Try syncing the update state
|
||||||
|
# UNLESS '_sync_updates' is False (we probably are in
|
||||||
|
# another data center and this would raise UserMigrateError)
|
||||||
|
# to also assert whether the user is logged in or not.
|
||||||
|
self._user_connected = True
|
||||||
|
if _sync_updates and not _cdn:
|
||||||
|
try:
|
||||||
|
self.sync_updates()
|
||||||
|
self._set_connected_and_authorized()
|
||||||
|
except UnauthorizedError:
|
||||||
|
self._authorized = False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except TypeNotFoundError as e:
|
except TypeNotFoundError as e:
|
||||||
# This is fine, probably layer migration
|
# This is fine, probably layer migration
|
||||||
self._logger.debug('Found invalid item, probably migrating', e)
|
self._logger.debug('Found invalid item, probably migrating', e)
|
||||||
self.disconnect()
|
self.disconnect()
|
||||||
return self.connect(exported_auth=exported_auth)
|
return self.connect(
|
||||||
|
_exported_auth=_exported_auth,
|
||||||
|
_sync_updates=_sync_updates,
|
||||||
|
_cdn=_cdn
|
||||||
|
)
|
||||||
|
|
||||||
except (RPCError, ConnectionError) as error:
|
except (RPCError, ConnectionError) as error:
|
||||||
# Probably errors from the previous session, ignore them
|
# Probably errors from the previous session, ignore them
|
||||||
|
@ -178,9 +262,20 @@ class TelegramBareClient:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
"""Disconnects from the Telegram server"""
|
"""Disconnects from the Telegram server
|
||||||
|
and stops all the spawned threads"""
|
||||||
|
self._user_connected = False
|
||||||
|
self._recv_thread = None
|
||||||
|
|
||||||
|
# This will trigger a "ConnectionResetError", for subsequent calls
|
||||||
|
# to read or send (from another thread) and usually, the background
|
||||||
|
# thread would try restarting the connection but since the
|
||||||
|
# ._recv_thread = None, it knows it doesn't have to.
|
||||||
self._sender.disconnect()
|
self._sender.disconnect()
|
||||||
|
|
||||||
|
# TODO Shall we clear the _exported_sessions, or may be reused?
|
||||||
|
pass
|
||||||
|
|
||||||
def _reconnect(self, new_dc=None):
|
def _reconnect(self, new_dc=None):
|
||||||
"""If 'new_dc' is not set, only a call to .connect() will be made
|
"""If 'new_dc' is not set, only a call to .connect() will be made
|
||||||
since it's assumed that the connection has been lost and the
|
since it's assumed that the connection has been lost and the
|
||||||
|
@ -210,7 +305,11 @@ class TelegramBareClient:
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
# region Working with different Data Centers
|
# region Working with different connections/Data Centers
|
||||||
|
|
||||||
|
def _on_read_thread(self):
|
||||||
|
return self._recv_thread is not None and \
|
||||||
|
threading.get_ident() == self._recv_thread.ident
|
||||||
|
|
||||||
def _get_dc(self, dc_id, ipv6=False, cdn=False):
|
def _get_dc(self, dc_id, ipv6=False, cdn=False):
|
||||||
"""Gets the Data Center (DC) associated to 'dc_id'"""
|
"""Gets the Data Center (DC) associated to 'dc_id'"""
|
||||||
|
@ -237,30 +336,23 @@ class TelegramBareClient:
|
||||||
TelegramBareClient._dc_options = self(GetConfigRequest()).dc_options
|
TelegramBareClient._dc_options = self(GetConfigRequest()).dc_options
|
||||||
return self._get_dc(dc_id, ipv6=ipv6, cdn=cdn)
|
return self._get_dc(dc_id, ipv6=ipv6, cdn=cdn)
|
||||||
|
|
||||||
def _get_exported_client(self, dc_id,
|
def _get_exported_client(self, dc_id):
|
||||||
init_connection=False,
|
"""Creates and connects a new TelegramBareClient for the desired DC.
|
||||||
bypass_cache=False):
|
|
||||||
"""Gets a cached exported TelegramBareClient for the desired DC.
|
|
||||||
|
|
||||||
If it's the first time retrieving the TelegramBareClient, the
|
If it's the first time calling the method with a given dc_id,
|
||||||
current authorization is exported to the new DC so that
|
a new session will be first created, and its auth key generated.
|
||||||
it can be used there, and the connection is initialized.
|
Exporting/Importing the authorization will also be done so that
|
||||||
|
the auth is bound with the key.
|
||||||
If after using the sender a ConnectionResetError is raised,
|
|
||||||
this method should be called again with init_connection=True
|
|
||||||
in order to perform the reconnection.
|
|
||||||
|
|
||||||
If bypass_cache is True, a new client will be exported and
|
|
||||||
it will not be cached.
|
|
||||||
"""
|
"""
|
||||||
# Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt
|
# Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt
|
||||||
# for clearly showing how to export the authorization! ^^
|
# for clearly showing how to export the authorization! ^^
|
||||||
client = self._cached_clients.get(dc_id)
|
session = self._exported_sessions.get(dc_id)
|
||||||
if client and not bypass_cache:
|
if session:
|
||||||
if init_connection:
|
export_auth = None # Already bound with the auth key
|
||||||
client.reconnect()
|
|
||||||
return client
|
|
||||||
else:
|
else:
|
||||||
|
# TODO Add a lock, don't allow two threads to create an auth key
|
||||||
|
# (when calling .connect() if there wasn't a previous session).
|
||||||
|
# for the same data center.
|
||||||
dc = self._get_dc(dc_id)
|
dc = self._get_dc(dc_id)
|
||||||
|
|
||||||
# Export the current authorization to the new DC.
|
# Export the current authorization to the new DC.
|
||||||
|
@ -274,46 +366,90 @@ class TelegramBareClient:
|
||||||
session = Session(self.session)
|
session = Session(self.session)
|
||||||
session.server_address = dc.ip_address
|
session.server_address = dc.ip_address
|
||||||
session.port = dc.port
|
session.port = dc.port
|
||||||
|
self._exported_sessions[dc_id] = session
|
||||||
|
|
||||||
client = TelegramBareClient(
|
client = TelegramBareClient(
|
||||||
session, self.api_id, self.api_hash,
|
session, self.api_id, self.api_hash,
|
||||||
proxy=self._sender.connection.conn.proxy,
|
proxy=self._sender.connection.conn.proxy,
|
||||||
timeout=self._sender.connection.get_timeout()
|
timeout=self._sender.connection.get_timeout()
|
||||||
)
|
)
|
||||||
client.connect(exported_auth=export_auth)
|
client.connect(_exported_auth=export_auth, _sync_updates=False)
|
||||||
|
return client
|
||||||
|
|
||||||
if not bypass_cache:
|
def _get_cdn_client(self, cdn_redirect):
|
||||||
# Don't go through this expensive process every time.
|
"""Similar to ._get_exported_client, but for CDNs"""
|
||||||
self._cached_clients[dc_id] = client
|
session = self._exported_sessions.get(cdn_redirect.dc_id)
|
||||||
|
if not session:
|
||||||
|
dc = self._get_dc(cdn_redirect.dc_id, cdn=True)
|
||||||
|
session = Session(self.session)
|
||||||
|
session.server_address = dc.ip_address
|
||||||
|
session.port = dc.port
|
||||||
|
self._exported_sessions[cdn_redirect.dc_id] = session
|
||||||
|
|
||||||
|
client = TelegramBareClient(
|
||||||
|
session, self.api_id, self.api_hash,
|
||||||
|
proxy=self._sender.connection.conn.proxy,
|
||||||
|
timeout=self._sender.connection.get_timeout()
|
||||||
|
)
|
||||||
|
|
||||||
|
# This will make use of the new RSA keys for this specific CDN.
|
||||||
|
#
|
||||||
|
# This relies on the fact that TelegramBareClient._dc_options is
|
||||||
|
# static and it won't be called from this DC (it would fail).
|
||||||
|
client.connect(cdn=True) # Avoid invoking non-CDN specific methods
|
||||||
return client
|
return client
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
# region Invoking Telegram requests
|
# region Invoking Telegram requests
|
||||||
|
|
||||||
def invoke(self, *requests, call_receive=True, retries=5):
|
def invoke(self, *requests, retries=5):
|
||||||
"""Invokes (sends) a MTProtoRequest and returns (receives) its result.
|
"""Invokes (sends) a MTProtoRequest and returns (receives) its result.
|
||||||
|
|
||||||
If 'updates' is not None, all read update object will be put
|
The invoke will be retried up to 'retries' times before raising
|
||||||
in such list. Otherwise, update objects will be ignored.
|
ValueError().
|
||||||
|
|
||||||
If 'call_receive' is set to False, then there should be another
|
|
||||||
thread calling to 'self._sender.receive()' running or this method
|
|
||||||
will lock forever.
|
|
||||||
"""
|
"""
|
||||||
|
# Any error from a background thread will be "posted" and checked here
|
||||||
|
self.updates.check_error()
|
||||||
|
|
||||||
if not all(isinstance(x, TLObject) and
|
if not all(isinstance(x, TLObject) and
|
||||||
x.content_related for x in requests):
|
x.content_related for x in requests):
|
||||||
raise ValueError('You can only invoke requests, not types!')
|
raise ValueError('You can only invoke requests, not types!')
|
||||||
|
|
||||||
if retries <= 0:
|
# Determine the sender to be used (main or a new connection)
|
||||||
raise ValueError('Number of retries reached 0.')
|
on_main_thread = threading.get_ident() == self._main_thread_ident
|
||||||
|
if on_main_thread or self._on_read_thread():
|
||||||
|
sender = self._sender
|
||||||
|
else:
|
||||||
|
sender = self._sender.clone()
|
||||||
|
sender.connect()
|
||||||
|
|
||||||
|
# We should call receive from this thread if there's no background
|
||||||
|
# thread reading or if the server disconnected us and we're trying
|
||||||
|
# to reconnect. This is because the read thread may either be
|
||||||
|
# locked also trying to reconnect or we may be said thread already.
|
||||||
|
call_receive = not on_main_thread or \
|
||||||
|
self._recv_thread is None or self._connect_lock.locked()
|
||||||
|
try:
|
||||||
|
for _ in range(retries):
|
||||||
|
result = self._invoke(sender, call_receive, *requests)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
raise ValueError('Number of retries reached 0.')
|
||||||
|
finally:
|
||||||
|
if sender != self._sender:
|
||||||
|
sender.disconnect() # Close temporary connections
|
||||||
|
|
||||||
|
def _invoke(self, sender, call_receive, *requests):
|
||||||
try:
|
try:
|
||||||
# Ensure that we start with no previous errors (i.e. resending)
|
# Ensure that we start with no previous errors (i.e. resending)
|
||||||
for x in requests:
|
for x in requests:
|
||||||
x.confirm_received.clear()
|
x.confirm_received.clear()
|
||||||
x.rpc_error = None
|
x.rpc_error = None
|
||||||
|
|
||||||
self._sender.send(*requests)
|
sender.send(*requests)
|
||||||
|
|
||||||
if not call_receive:
|
if not call_receive:
|
||||||
# TODO This will be slightly troublesome if we allow
|
# TODO This will be slightly troublesome if we allow
|
||||||
# switching between constant read or not on the fly.
|
# switching between constant read or not on the fly.
|
||||||
|
@ -321,33 +457,60 @@ class TelegramBareClient:
|
||||||
# in which case a Lock would be required for .receive().
|
# in which case a Lock would be required for .receive().
|
||||||
for x in requests:
|
for x in requests:
|
||||||
x.confirm_received.wait(
|
x.confirm_received.wait(
|
||||||
self._sender.connection.get_timeout()
|
sender.connection.get_timeout()
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
while not all(x.confirm_received.is_set() for x in requests):
|
while not all(x.confirm_received.is_set() for x in requests):
|
||||||
self._sender.receive(update_state=self.updates)
|
sender.receive(update_state=self.updates)
|
||||||
|
|
||||||
|
except (PhoneMigrateError, NetworkMigrateError,
|
||||||
|
UserMigrateError) as e:
|
||||||
|
self._logger.debug(
|
||||||
|
'DC error when invoking request, '
|
||||||
|
'attempting to reconnect at DC {}'.format(e.new_dc)
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO What happens with the background thread here?
|
||||||
|
# For normal use cases, this won't happen, because this will only
|
||||||
|
# be on the very first connection (not authorized, not running),
|
||||||
|
# but may be an issue for people who actually travel?
|
||||||
|
self._reconnect(new_dc=e.new_dc)
|
||||||
|
return self._invoke(sender, call_receive, *requests)
|
||||||
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
pass # We will just retry
|
pass # We will just retry
|
||||||
|
|
||||||
except ConnectionResetError:
|
except ConnectionResetError:
|
||||||
|
if self._connect_lock.locked():
|
||||||
|
# We are connecting and we don't want to reconnect there...
|
||||||
|
raise
|
||||||
|
|
||||||
self._logger.debug('Server disconnected us. Reconnecting and '
|
self._logger.debug('Server disconnected us. Reconnecting and '
|
||||||
'resending request...')
|
'resending request...')
|
||||||
self._reconnect()
|
|
||||||
|
if sender != self._sender:
|
||||||
|
# TODO Try reconnecting forever too?
|
||||||
|
sender.connect()
|
||||||
|
else:
|
||||||
|
while self._user_connected and not self._reconnect():
|
||||||
|
sleep(0.1) # Retry forever until we can send the request
|
||||||
|
|
||||||
except FloodWaitError:
|
except FloodWaitError:
|
||||||
|
sender.disconnect()
|
||||||
self.disconnect()
|
self.disconnect()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if sender != self._sender:
|
||||||
|
sender.disconnect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raise next(x.rpc_error for x in requests if x.rpc_error)
|
raise next(x.rpc_error for x in requests if x.rpc_error)
|
||||||
except StopIteration:
|
except StopIteration:
|
||||||
if any(x.result is None for x in requests):
|
if any(x.result is None for x in requests):
|
||||||
# "A container may only be accepted or
|
# "A container may only be accepted or
|
||||||
# rejected by the other party as a whole."
|
# rejected by the other party as a whole."
|
||||||
return self.invoke(
|
return None
|
||||||
*requests, call_receive=call_receive, retries=(retries - 1)
|
|
||||||
)
|
|
||||||
elif len(requests) == 1:
|
elif len(requests) == 1:
|
||||||
return requests[0].result
|
return requests[0].result
|
||||||
else:
|
else:
|
||||||
|
@ -356,6 +519,13 @@ class TelegramBareClient:
|
||||||
# Let people use client(SomeRequest()) instead client.invoke(...)
|
# Let people use client(SomeRequest()) instead client.invoke(...)
|
||||||
__call__ = invoke
|
__call__ = invoke
|
||||||
|
|
||||||
|
# Some really basic functionality
|
||||||
|
|
||||||
|
def is_user_authorized(self):
|
||||||
|
"""Has the user been authorized yet
|
||||||
|
(code request sent and confirmed)?"""
|
||||||
|
return self._authorized
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
# region Uploading media
|
# region Uploading media
|
||||||
|
@ -381,10 +551,10 @@ class TelegramBareClient:
|
||||||
|
|
||||||
Default values for the optional parameters if left as None are:
|
Default values for the optional parameters if left as None are:
|
||||||
part_size_kb = get_appropriated_part_size(file_size)
|
part_size_kb = get_appropriated_part_size(file_size)
|
||||||
file_name = path.basename(file_path)
|
file_name = os.path.basename(file_path)
|
||||||
"""
|
"""
|
||||||
if isinstance(file, str):
|
if isinstance(file, str):
|
||||||
file_size = path.getsize(file)
|
file_size = os.path.getsize(file)
|
||||||
elif isinstance(file, bytes):
|
elif isinstance(file, bytes):
|
||||||
file_size = len(file)
|
file_size = len(file)
|
||||||
else:
|
else:
|
||||||
|
@ -440,7 +610,7 @@ class TelegramBareClient:
|
||||||
# Set a default file name if None was specified
|
# Set a default file name if None was specified
|
||||||
if not file_name:
|
if not file_name:
|
||||||
if isinstance(file, str):
|
if isinstance(file, str):
|
||||||
file_name = path.basename(file)
|
file_name = os.path.basename(file)
|
||||||
else:
|
else:
|
||||||
file_name = str(file_id)
|
file_name = str(file_id)
|
||||||
|
|
||||||
|
@ -509,7 +679,7 @@ class TelegramBareClient:
|
||||||
if isinstance(result, FileCdnRedirect):
|
if isinstance(result, FileCdnRedirect):
|
||||||
cdn_decrypter, result = \
|
cdn_decrypter, result = \
|
||||||
CdnDecrypter.prepare_decrypter(
|
CdnDecrypter.prepare_decrypter(
|
||||||
client, TelegramBareClient, result
|
client, self._get_cdn_client(result), result
|
||||||
)
|
)
|
||||||
|
|
||||||
except FileMigrateError as e:
|
except FileMigrateError as e:
|
||||||
|
@ -528,6 +698,9 @@ class TelegramBareClient:
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback(f.tell(), file_size)
|
progress_callback(f.tell(), file_size)
|
||||||
finally:
|
finally:
|
||||||
|
if client != self:
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
if cdn_decrypter:
|
if cdn_decrypter:
|
||||||
try:
|
try:
|
||||||
cdn_decrypter.client.disconnect()
|
cdn_decrypter.client.disconnect()
|
||||||
|
@ -537,3 +710,73 @@ class TelegramBareClient:
|
||||||
f.close()
|
f.close()
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
|
# region Updates handling
|
||||||
|
|
||||||
|
def sync_updates(self):
|
||||||
|
"""Synchronizes self.updates to their initial state. Will be
|
||||||
|
called automatically on connection if self.updates.enabled = True,
|
||||||
|
otherwise it should be called manually after enabling updates.
|
||||||
|
"""
|
||||||
|
self.updates.process(self(GetStateRequest()))
|
||||||
|
|
||||||
|
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"""
|
||||||
|
sync = not self.updates.handlers
|
||||||
|
self.updates.handlers.append(handler)
|
||||||
|
if sync:
|
||||||
|
self.sync_updates()
|
||||||
|
|
||||||
|
def remove_update_handler(self, handler):
|
||||||
|
self.updates.handlers.remove(handler)
|
||||||
|
|
||||||
|
def list_update_handlers(self):
|
||||||
|
return self.updates.handlers[:]
|
||||||
|
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
# Constant read
|
||||||
|
|
||||||
|
def _set_connected_and_authorized(self):
|
||||||
|
self._authorized = True
|
||||||
|
if self._spawn_read_thread and self._recv_thread is None:
|
||||||
|
self._recv_thread = threading.Thread(
|
||||||
|
name='ReadThread', daemon=True,
|
||||||
|
target=self._recv_thread_impl
|
||||||
|
)
|
||||||
|
self._recv_thread.start()
|
||||||
|
|
||||||
|
# By using this approach, another thread will be
|
||||||
|
# created and started upon connection to constantly read
|
||||||
|
# from the other end. Otherwise, manual calls to .receive()
|
||||||
|
# must be performed. The MtProtoSender cannot be connected,
|
||||||
|
# or an error will be thrown.
|
||||||
|
#
|
||||||
|
# This way, sending and receiving will be completely independent.
|
||||||
|
def _recv_thread_impl(self):
|
||||||
|
while self._user_connected:
|
||||||
|
try:
|
||||||
|
if datetime.now() > self._last_ping + self._ping_delay:
|
||||||
|
self._sender.send(PingRequest(
|
||||||
|
int.from_bytes(os.urandom(8), 'big', signed=True)
|
||||||
|
))
|
||||||
|
self._last_ping = datetime.now()
|
||||||
|
|
||||||
|
self._sender.receive(update_state=self.updates)
|
||||||
|
except TimeoutError:
|
||||||
|
# No problem.
|
||||||
|
pass
|
||||||
|
except ConnectionResetError:
|
||||||
|
self._logger.debug('Server disconnected us. Reconnecting...')
|
||||||
|
while self._user_connected and not self._reconnect():
|
||||||
|
sleep(0.1) # Retry forever, this is instant messaging
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Unknown exception, pass it to the main thread
|
||||||
|
self.updates.set_error(e)
|
||||||
|
break
|
||||||
|
|
||||||
|
self._recv_thread = None
|
||||||
|
|
||||||
|
# endregion
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import os
|
import os
|
||||||
import threading
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from mimetypes import guess_type
|
from mimetypes import guess_type
|
||||||
from threading import Thread
|
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import socks
|
import socks
|
||||||
|
@ -15,12 +12,10 @@ from . import TelegramBareClient
|
||||||
from . import helpers as utils
|
from . import helpers as utils
|
||||||
from .errors import (
|
from .errors import (
|
||||||
RPCError, UnauthorizedError, InvalidParameterError, PhoneCodeEmptyError,
|
RPCError, UnauthorizedError, InvalidParameterError, PhoneCodeEmptyError,
|
||||||
PhoneMigrateError, NetworkMigrateError, UserMigrateError,
|
|
||||||
PhoneCodeExpiredError, PhoneCodeHashEmptyError, PhoneCodeInvalidError
|
PhoneCodeExpiredError, PhoneCodeHashEmptyError, PhoneCodeInvalidError
|
||||||
)
|
)
|
||||||
from .network import ConnectionMode
|
from .network import ConnectionMode
|
||||||
from .tl import Session, TLObject
|
from .tl import TLObject
|
||||||
from .tl.functions import PingRequest
|
|
||||||
from .tl.functions.account import (
|
from .tl.functions.account import (
|
||||||
GetPasswordRequest
|
GetPasswordRequest
|
||||||
)
|
)
|
||||||
|
@ -35,9 +30,6 @@ from .tl.functions.messages import (
|
||||||
GetDialogsRequest, GetHistoryRequest, ReadHistoryRequest, SendMediaRequest,
|
GetDialogsRequest, GetHistoryRequest, ReadHistoryRequest, SendMediaRequest,
|
||||||
SendMessageRequest
|
SendMessageRequest
|
||||||
)
|
)
|
||||||
from .tl.functions.updates import (
|
|
||||||
GetStateRequest
|
|
||||||
)
|
|
||||||
from .tl.functions.users import (
|
from .tl.functions.users import (
|
||||||
GetUsersRequest
|
GetUsersRequest
|
||||||
)
|
)
|
||||||
|
@ -65,8 +57,9 @@ class TelegramClient(TelegramBareClient):
|
||||||
def __init__(self, session, api_id, api_hash,
|
def __init__(self, session, api_id, api_hash,
|
||||||
connection_mode=ConnectionMode.TCP_FULL,
|
connection_mode=ConnectionMode.TCP_FULL,
|
||||||
proxy=None,
|
proxy=None,
|
||||||
process_updates=False,
|
update_workers=None,
|
||||||
timeout=timedelta(seconds=5),
|
timeout=timedelta(seconds=5),
|
||||||
|
spawn_read_thread=True,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
"""Initializes the Telegram client with the specified API ID and Hash.
|
"""Initializes the Telegram client with the specified API ID and Hash.
|
||||||
|
|
||||||
|
@ -79,15 +72,21 @@ class TelegramClient(TelegramBareClient):
|
||||||
This will only affect how messages are sent over the network
|
This will only affect how messages are sent over the network
|
||||||
and how much processing is required before sending them.
|
and how much processing is required before sending them.
|
||||||
|
|
||||||
If 'process_updates' is set to True, incoming updates will be
|
The integer 'update_workers' represents depending on its value:
|
||||||
processed and you must manually call 'self.updates.poll()' from
|
is None: Updates will *not* be stored in memory.
|
||||||
another thread to retrieve the saved update objects, or your
|
= 0: Another thread is responsible for calling self.updates.poll()
|
||||||
memory will fill with these. You may modify the value of
|
> 0: 'update_workers' background threads will be spawned, any
|
||||||
'self.updates.polling' at any later point.
|
any of them will invoke all the self.updates.handlers.
|
||||||
|
|
||||||
Despite the value of 'process_updates', if you later call
|
If 'spawn_read_thread', a background thread will be started once
|
||||||
'.add_update_handler(...)', updates will also be processed
|
an authorized user has been logged in to Telegram to read items
|
||||||
and the update objects will be passed to the handlers you added.
|
(such as updates and responses) from the network as soon as they
|
||||||
|
occur, which will speed things up.
|
||||||
|
|
||||||
|
If you don't want to spawn any additional threads, pending updates
|
||||||
|
will be read and processed accordingly after invoking a request
|
||||||
|
and not immediately. This is useful if you don't care about updates
|
||||||
|
at all and have set 'update_workers=None'.
|
||||||
|
|
||||||
If more named arguments are provided as **kwargs, they will be
|
If more named arguments are provided as **kwargs, they will be
|
||||||
used to update the Session instance. Most common settings are:
|
used to update the Session instance. Most common settings are:
|
||||||
|
@ -98,221 +97,25 @@ class TelegramClient(TelegramBareClient):
|
||||||
system_lang_code = lang_code
|
system_lang_code = lang_code
|
||||||
report_errors = True
|
report_errors = True
|
||||||
"""
|
"""
|
||||||
if not api_id or not api_hash:
|
|
||||||
raise PermissionError(
|
|
||||||
"Your API ID or Hash cannot be empty or None. "
|
|
||||||
"Refer to Telethon's README.rst for more information.")
|
|
||||||
|
|
||||||
# Determine what session object we have
|
|
||||||
if isinstance(session, str) or session is None:
|
|
||||||
session = Session.try_load_or_create_new(session)
|
|
||||||
elif not isinstance(session, Session):
|
|
||||||
raise ValueError(
|
|
||||||
'The given session must be a str or a Session instance.')
|
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
session, api_id, api_hash,
|
session, api_id, api_hash,
|
||||||
connection_mode=connection_mode,
|
connection_mode=connection_mode,
|
||||||
proxy=proxy,
|
proxy=proxy,
|
||||||
process_updates=process_updates,
|
update_workers=update_workers,
|
||||||
|
spawn_read_thread=spawn_read_thread,
|
||||||
timeout=timeout
|
timeout=timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
# Used on connection - the user may modify these and reconnect
|
# Some fields to easy signing in
|
||||||
kwargs['app_version'] = kwargs.get('app_version', self.__version__)
|
|
||||||
for name, value in kwargs.items():
|
|
||||||
if hasattr(self.session, name):
|
|
||||||
setattr(self.session, name, value)
|
|
||||||
|
|
||||||
self._updates_thread = None
|
|
||||||
self._phone_code_hash = None
|
self._phone_code_hash = None
|
||||||
self._phone = None
|
self._phone = None
|
||||||
|
|
||||||
# Despite the state of the real connection, keep track of whether
|
|
||||||
# the user has explicitly called .connect() or .disconnect() here.
|
|
||||||
# This information is required by the read thread, who will be the
|
|
||||||
# one attempting to reconnect on the background *while* the user
|
|
||||||
# doesn't explicitly call .disconnect(), thus telling it to stop
|
|
||||||
# retrying. The main thread, knowing there is a background thread
|
|
||||||
# attempting reconnection as soon as it happens, will just sleep.
|
|
||||||
self._user_connected = False
|
|
||||||
|
|
||||||
# Save whether the user is authorized here (a.k.a. logged in)
|
|
||||||
self._authorized = False
|
|
||||||
|
|
||||||
# Uploaded files cache so subsequent calls are instant
|
|
||||||
self._upload_cache = {}
|
|
||||||
|
|
||||||
# Constantly read for results and updates from within the main client
|
|
||||||
self._recv_thread = None
|
|
||||||
|
|
||||||
# Default PingRequest delay
|
|
||||||
self._last_ping = datetime.now()
|
|
||||||
self._ping_delay = timedelta(minutes=1)
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
|
|
||||||
# region Connecting
|
|
||||||
|
|
||||||
def connect(self, exported_auth=None):
|
|
||||||
"""Connects to the Telegram servers, executing authentication if
|
|
||||||
required. Note that authenticating to the Telegram servers is
|
|
||||||
not the same as authenticating the desired user itself, which
|
|
||||||
may require a call (or several) to 'sign_in' for the first time.
|
|
||||||
|
|
||||||
exported_auth is meant for internal purposes and can be ignored.
|
|
||||||
"""
|
|
||||||
if socks and self._recv_thread:
|
|
||||||
# Treat proxy errors specially since they're not related to
|
|
||||||
# Telegram itself, but rather to the proxy. If any happens on
|
|
||||||
# the read thread forward it to the main thread.
|
|
||||||
try:
|
|
||||||
ok = super().connect(exported_auth=exported_auth)
|
|
||||||
except socks.ProxyConnectionError as e:
|
|
||||||
ok = False
|
|
||||||
# Report the exception to the main thread
|
|
||||||
self.updates.set_error(e)
|
|
||||||
else:
|
|
||||||
ok = super().connect(exported_auth=exported_auth)
|
|
||||||
|
|
||||||
if not ok:
|
|
||||||
return False
|
|
||||||
|
|
||||||
self._user_connected = True
|
|
||||||
try:
|
|
||||||
self.sync_updates()
|
|
||||||
self._set_connected_and_authorized()
|
|
||||||
except UnauthorizedError:
|
|
||||||
self._authorized = False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def disconnect(self):
|
|
||||||
"""Disconnects from the Telegram server
|
|
||||||
and stops all the spawned threads"""
|
|
||||||
self._user_connected = False
|
|
||||||
self._recv_thread = None
|
|
||||||
|
|
||||||
# This will trigger a "ConnectionResetError", usually, the background
|
|
||||||
# thread would try restarting the connection but since the
|
|
||||||
# ._recv_thread = None, it knows it doesn't have to.
|
|
||||||
super().disconnect()
|
|
||||||
|
|
||||||
# Also disconnect all the cached senders
|
|
||||||
for sender in self._cached_clients.values():
|
|
||||||
sender.disconnect()
|
|
||||||
|
|
||||||
self._cached_clients.clear()
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
|
|
||||||
# region Working with different connections
|
|
||||||
|
|
||||||
def _on_read_thread(self):
|
|
||||||
return self._recv_thread is not None and \
|
|
||||||
threading.get_ident() == self._recv_thread.ident
|
|
||||||
|
|
||||||
def create_new_connection(self, on_dc=None, timeout=timedelta(seconds=5)):
|
|
||||||
"""Creates a new connection which can be used in parallel
|
|
||||||
with the original TelegramClient. A TelegramBareClient
|
|
||||||
will be returned already connected, and the caller is
|
|
||||||
responsible to disconnect it.
|
|
||||||
|
|
||||||
If 'on_dc' is None, the new client will run on the same
|
|
||||||
data center as the current client (most common case).
|
|
||||||
|
|
||||||
If the client is meant to be used on a different data
|
|
||||||
center, the data center ID should be specified instead.
|
|
||||||
"""
|
|
||||||
if on_dc is None:
|
|
||||||
client = TelegramBareClient(
|
|
||||||
self.session, self.api_id, self.api_hash,
|
|
||||||
proxy=self._sender.connection.conn.proxy, timeout=timeout
|
|
||||||
)
|
|
||||||
client.connect()
|
|
||||||
else:
|
|
||||||
client = self._get_exported_client(on_dc, bypass_cache=True)
|
|
||||||
|
|
||||||
return client
|
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
# region Telegram requests functions
|
# region Telegram requests functions
|
||||||
|
|
||||||
def invoke(self, *requests, **kwargs):
|
|
||||||
"""Invokes (sends) one or several MTProtoRequest and returns
|
|
||||||
(receives) their result. An optional named 'retries' parameter
|
|
||||||
can be used, indicating how many times it should retry.
|
|
||||||
"""
|
|
||||||
# This is only valid when the read thread is reconnecting,
|
|
||||||
# that is, the connection lock is locked.
|
|
||||||
if self._on_read_thread() and not self._connect_lock.locked():
|
|
||||||
return # Just ignore, we would be raising and crashing the thread
|
|
||||||
|
|
||||||
self.updates.check_error()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# We should call receive from this thread if there's no background
|
|
||||||
# thread reading or if the server disconnected us and we're trying
|
|
||||||
# to reconnect. This is because the read thread may either be
|
|
||||||
# locked also trying to reconnect or we may be said thread already.
|
|
||||||
call_receive = \
|
|
||||||
self._recv_thread is None or self._connect_lock.locked()
|
|
||||||
|
|
||||||
return super().invoke(
|
|
||||||
*requests,
|
|
||||||
call_receive=call_receive,
|
|
||||||
retries=kwargs.get('retries', 5)
|
|
||||||
)
|
|
||||||
|
|
||||||
except (PhoneMigrateError, NetworkMigrateError, UserMigrateError) as e:
|
|
||||||
self._logger.debug('DC error when invoking request, '
|
|
||||||
'attempting to reconnect at DC {}'
|
|
||||||
.format(e.new_dc))
|
|
||||||
|
|
||||||
# TODO What happens with the background thread here?
|
|
||||||
# For normal use cases, this won't happen, because this will only
|
|
||||||
# be on the very first connection (not authorized, not running),
|
|
||||||
# but may be an issue for people who actually travel?
|
|
||||||
self._reconnect(new_dc=e.new_dc)
|
|
||||||
return self.invoke(*requests)
|
|
||||||
|
|
||||||
except ConnectionResetError as e:
|
|
||||||
if self._connect_lock.locked():
|
|
||||||
# We are connecting and we don't want to reconnect there...
|
|
||||||
raise
|
|
||||||
while self._user_connected and not self._reconnect():
|
|
||||||
sleep(0.1) # Retry forever until we can send the request
|
|
||||||
|
|
||||||
# Let people use client(SomeRequest()) instead client.invoke(...)
|
|
||||||
__call__ = invoke
|
|
||||||
|
|
||||||
def invoke_on_dc(self, request, dc_id, reconnect=False):
|
|
||||||
"""Invokes the given request on a different DC
|
|
||||||
by making use of the exported MtProtoSenders.
|
|
||||||
|
|
||||||
If 'reconnect=True', then the a reconnection will be performed and
|
|
||||||
ConnectionResetError will be raised if it occurs a second time.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
client = self._get_exported_client(
|
|
||||||
dc_id, init_connection=reconnect)
|
|
||||||
|
|
||||||
return client.invoke(request)
|
|
||||||
|
|
||||||
except ConnectionResetError:
|
|
||||||
if reconnect:
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
return self.invoke_on_dc(request, dc_id, reconnect=True)
|
|
||||||
|
|
||||||
# region Authorization requests
|
# region Authorization requests
|
||||||
|
|
||||||
def is_user_authorized(self):
|
|
||||||
"""Has the user been authorized yet
|
|
||||||
(code request sent and confirmed)?"""
|
|
||||||
return self._authorized
|
|
||||||
|
|
||||||
def send_code_request(self, phone):
|
def send_code_request(self, phone):
|
||||||
"""Sends a code request to the specified phone number"""
|
"""Sends a code request to the specified phone number"""
|
||||||
if isinstance(phone, int):
|
if isinstance(phone, int):
|
||||||
|
@ -1006,73 +809,3 @@ class TelegramClient(TelegramBareClient):
|
||||||
)
|
)
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
# region Updates handling
|
|
||||||
|
|
||||||
def sync_updates(self):
|
|
||||||
"""Synchronizes self.updates to their initial state. Will be
|
|
||||||
called automatically on connection if self.updates.enabled = True,
|
|
||||||
otherwise it should be called manually after enabling updates.
|
|
||||||
"""
|
|
||||||
self.updates.process(self(GetStateRequest()))
|
|
||||||
|
|
||||||
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"""
|
|
||||||
sync = not self.updates.handlers
|
|
||||||
self.updates.handlers.append(handler)
|
|
||||||
if sync:
|
|
||||||
self.sync_updates()
|
|
||||||
|
|
||||||
def remove_update_handler(self, handler):
|
|
||||||
self.updates.handlers.remove(handler)
|
|
||||||
|
|
||||||
def list_update_handlers(self):
|
|
||||||
return self.updates.handlers[:]
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
|
|
||||||
# Constant read
|
|
||||||
|
|
||||||
def _set_connected_and_authorized(self):
|
|
||||||
self._authorized = True
|
|
||||||
if self._recv_thread is None:
|
|
||||||
self._recv_thread = Thread(
|
|
||||||
name='ReadThread', daemon=True,
|
|
||||||
target=self._recv_thread_impl
|
|
||||||
)
|
|
||||||
self._recv_thread.start()
|
|
||||||
|
|
||||||
# By using this approach, another thread will be
|
|
||||||
# created and started upon connection to constantly read
|
|
||||||
# from the other end. Otherwise, manual calls to .receive()
|
|
||||||
# must be performed. The MtProtoSender cannot be connected,
|
|
||||||
# or an error will be thrown.
|
|
||||||
#
|
|
||||||
# This way, sending and receiving will be completely independent.
|
|
||||||
def _recv_thread_impl(self):
|
|
||||||
while self._user_connected:
|
|
||||||
try:
|
|
||||||
if datetime.now() > self._last_ping + self._ping_delay:
|
|
||||||
self._sender.send(PingRequest(
|
|
||||||
int.from_bytes(os.urandom(8), 'big', signed=True)
|
|
||||||
))
|
|
||||||
self._last_ping = datetime.now()
|
|
||||||
|
|
||||||
self._sender.receive(update_state=self.updates)
|
|
||||||
except TimeoutError:
|
|
||||||
# No problem.
|
|
||||||
pass
|
|
||||||
except ConnectionResetError:
|
|
||||||
self._logger.debug('Server disconnected us. Reconnecting...')
|
|
||||||
while self._user_connected and not self._reconnect():
|
|
||||||
sleep(0.1) # Retry forever, this is instant messaging
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Unknown exception, pass it to the main thread
|
|
||||||
self.updates.set_error(e)
|
|
||||||
break
|
|
||||||
|
|
||||||
self._recv_thread = None
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
|
import logging
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import RLock, Event
|
from threading import RLock, Event, Thread
|
||||||
|
|
||||||
from .tl import types as tl
|
from .tl import types as tl
|
||||||
|
|
||||||
|
@ -9,27 +10,46 @@ class UpdateState:
|
||||||
"""Used to hold the current state of processed updates.
|
"""Used to hold the current state of processed updates.
|
||||||
To retrieve an update, .poll() should be called.
|
To retrieve an update, .poll() should be called.
|
||||||
"""
|
"""
|
||||||
def __init__(self, polling):
|
WORKER_POLL_TIMEOUT = 5.0 # Avoid waiting forever on the workers
|
||||||
self._polling = polling
|
|
||||||
|
def __init__(self, workers=None):
|
||||||
|
"""
|
||||||
|
:param workers: This integer parameter has three possible cases:
|
||||||
|
workers is None: Updates will *not* be stored on self.
|
||||||
|
workers = 0: Another thread is responsible for calling self.poll()
|
||||||
|
workers > 0: 'workers' background threads will be spawned, any
|
||||||
|
any of them will invoke all the self.handlers.
|
||||||
|
"""
|
||||||
|
self._workers = workers
|
||||||
|
self._worker_threads = []
|
||||||
|
|
||||||
self.handlers = []
|
self.handlers = []
|
||||||
self._updates_lock = RLock()
|
self._updates_lock = RLock()
|
||||||
self._updates_available = Event()
|
self._updates_available = Event()
|
||||||
self._updates = deque()
|
self._updates = deque()
|
||||||
|
|
||||||
|
self._logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# https://core.telegram.org/api/updates
|
# https://core.telegram.org/api/updates
|
||||||
self._state = tl.updates.State(0, 0, datetime.now(), 0, 0)
|
self._state = tl.updates.State(0, 0, datetime.now(), 0, 0)
|
||||||
|
self._setup_workers()
|
||||||
|
|
||||||
def can_poll(self):
|
def can_poll(self):
|
||||||
"""Returns True if a call to .poll() won't lock"""
|
"""Returns True if a call to .poll() won't lock"""
|
||||||
return self._updates_available.is_set()
|
return self._updates_available.is_set()
|
||||||
|
|
||||||
def poll(self):
|
def poll(self, timeout=None):
|
||||||
"""Polls an update or blocks until an update object is available"""
|
"""Polls an update or blocks until an update object is available.
|
||||||
if not self._polling:
|
If 'timeout is not None', it should be a floating point value,
|
||||||
raise ValueError('Updates are not being polled hence not saved.')
|
and the method will 'return None' if waiting times out.
|
||||||
|
"""
|
||||||
|
if not self._updates_available.wait(timeout=timeout):
|
||||||
|
return
|
||||||
|
|
||||||
self._updates_available.wait()
|
|
||||||
with self._updates_lock:
|
with self._updates_lock:
|
||||||
|
if not self._updates_available.is_set():
|
||||||
|
return
|
||||||
|
|
||||||
update = self._updates.popleft()
|
update = self._updates.popleft()
|
||||||
if not self._updates:
|
if not self._updates:
|
||||||
self._updates_available.clear()
|
self._updates_available.clear()
|
||||||
|
@ -39,16 +59,62 @@ class UpdateState:
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|
||||||
def get_polling(self):
|
def get_workers(self):
|
||||||
return self._polling
|
return self._workers
|
||||||
|
|
||||||
def set_polling(self, polling):
|
def set_workers(self, n):
|
||||||
self._polling = polling
|
"""Changes the number of workers running.
|
||||||
if not polling:
|
If 'n is None', clears all pending updates from memory.
|
||||||
with self._updates_lock:
|
"""
|
||||||
|
self._stop_workers()
|
||||||
|
self._workers = n
|
||||||
|
if n is None:
|
||||||
self._updates.clear()
|
self._updates.clear()
|
||||||
|
else:
|
||||||
|
self._setup_workers()
|
||||||
|
|
||||||
polling = property(fget=get_polling, fset=set_polling)
|
workers = property(fget=get_workers, fset=set_workers)
|
||||||
|
|
||||||
|
def _stop_workers(self):
|
||||||
|
"""Raises "StopIterationException" on the worker threads to stop them,
|
||||||
|
and also clears all of them off the list
|
||||||
|
"""
|
||||||
|
self.set_error(StopIteration())
|
||||||
|
for t in self._worker_threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
self._worker_threads.clear()
|
||||||
|
|
||||||
|
def _setup_workers(self):
|
||||||
|
if self._worker_threads or not self._workers:
|
||||||
|
# There already are workers, or workers is None or 0. Do nothing.
|
||||||
|
return
|
||||||
|
|
||||||
|
for i in range(self._workers):
|
||||||
|
thread = Thread(
|
||||||
|
target=UpdateState._worker_loop,
|
||||||
|
name='UpdateWorker{}'.format(i),
|
||||||
|
daemon=True,
|
||||||
|
args=(self, i)
|
||||||
|
)
|
||||||
|
self._worker_threads.append(thread)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _worker_loop(self, wid):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
update = self.poll(timeout=UpdateState.WORKER_POLL_TIMEOUT)
|
||||||
|
# TODO Maybe people can add different handlers per update type
|
||||||
|
if update:
|
||||||
|
for handler in self.handlers:
|
||||||
|
handler(update)
|
||||||
|
except StopIteration:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
# We don't want to crash a worker thread due to any reason
|
||||||
|
self._logger.debug(
|
||||||
|
'[ERROR] Unhandled exception on worker {}'.format(wid), e
|
||||||
|
)
|
||||||
|
|
||||||
def set_error(self, error):
|
def set_error(self, error):
|
||||||
"""Sets an error, so that the next call to .poll() will raise it.
|
"""Sets an error, so that the next call to .poll() will raise it.
|
||||||
|
@ -69,8 +135,8 @@ class UpdateState:
|
||||||
"""Processes an update object. This method is normally called by
|
"""Processes an update object. This method is normally called by
|
||||||
the library itself.
|
the library itself.
|
||||||
"""
|
"""
|
||||||
if not self._polling and not self.handlers:
|
if self._workers is None:
|
||||||
return
|
return # No processing needs to be done if nobody's working
|
||||||
|
|
||||||
with self._updates_lock:
|
with self._updates_lock:
|
||||||
if isinstance(update, tl.updates.State):
|
if isinstance(update, tl.updates.State):
|
||||||
|
@ -82,9 +148,5 @@ class UpdateState:
|
||||||
return # We already handled this update
|
return # We already handled this update
|
||||||
|
|
||||||
self._state.pts = pts
|
self._state.pts = pts
|
||||||
if self._polling:
|
|
||||||
self._updates.append(update)
|
self._updates.append(update)
|
||||||
self._updates_available.set()
|
self._updates_available.set()
|
||||||
|
|
||||||
for handler in self.handlers:
|
|
||||||
handler(update)
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user