2017-06-08 14:12:57 +03:00
|
|
|
import logging
|
2017-09-29 21:50:27 +03:00
|
|
|
import os
|
2018-03-02 22:05:09 +03:00
|
|
|
import platform
|
2017-09-29 21:50:27 +03:00
|
|
|
import threading
|
|
|
|
from datetime import timedelta, datetime
|
2017-10-22 14:15:52 +03:00
|
|
|
from signal import signal, SIGINT, SIGTERM, SIGABRT
|
2017-09-22 13:44:09 +03:00
|
|
|
from threading import Lock
|
2017-09-29 21:50:27 +03:00
|
|
|
from time import sleep
|
2018-01-19 13:47:45 +03:00
|
|
|
from . import version, utils
|
2018-01-18 15:55:03 +03:00
|
|
|
from .crypto import rsa
|
2017-07-26 17:10:45 +03:00
|
|
|
from .errors import (
|
2018-01-18 15:55:03 +03:00
|
|
|
RPCError, BrokenAuthKeyError, ServerError, FloodWaitError,
|
|
|
|
FloodTestPhoneWaitError, TypeNotFoundError, UnauthorizedError,
|
|
|
|
PhoneMigrateError, NetworkMigrateError, UserMigrateError
|
2017-07-26 17:10:45 +03:00
|
|
|
)
|
2017-09-04 12:24:10 +03:00
|
|
|
from .network import authenticator, MtProtoSender, Connection, ConnectionMode
|
2018-03-02 00:34:32 +03:00
|
|
|
from .sessions import Session, SQLiteSession
|
2018-01-20 00:55:28 +03:00
|
|
|
from .tl import TLObject
|
2017-09-17 17:17:55 +03:00
|
|
|
from .tl.all_tlobjects import LAYER
|
2017-08-24 14:02:48 +03:00
|
|
|
from .tl.functions import (
|
2017-09-29 21:50:27 +03:00
|
|
|
InitConnectionRequest, InvokeWithLayerRequest, PingRequest
|
2017-08-24 14:02:48 +03:00
|
|
|
)
|
2017-07-04 11:21:15 +03:00
|
|
|
from .tl.functions.auth import (
|
|
|
|
ImportAuthorizationRequest, ExportAuthorizationRequest
|
|
|
|
)
|
2017-09-04 18:18:33 +03:00
|
|
|
from .tl.functions.help import (
|
|
|
|
GetCdnConfigRequest, GetConfigRequest
|
|
|
|
)
|
2017-09-29 21:50:27 +03:00
|
|
|
from .tl.functions.updates import GetStateRequest
|
2017-10-24 16:40:51 +03:00
|
|
|
from .tl.types.auth import ExportedAuthorization
|
2017-09-07 19:49:08 +03:00
|
|
|
from .update_state import UpdateState
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2017-12-28 03:04:11 +03:00
|
|
|
DEFAULT_DC_ID = 4
|
2017-11-16 15:30:18 +03:00
|
|
|
DEFAULT_IPV4_IP = '149.154.167.51'
|
|
|
|
DEFAULT_IPV6_IP = '[2001:67c:4e8:f002::a]'
|
|
|
|
DEFAULT_PORT = 443
|
|
|
|
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__ = logging.getLogger(__name__)
|
|
|
|
|
2017-11-16 15:30:18 +03:00
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
class TelegramBareClient:
|
|
|
|
"""Bare Telegram Client with just the minimum -
|
|
|
|
|
|
|
|
The reason to distinguish between a MtProtoSender and a
|
|
|
|
TelegramClient itself is because the sender is just that,
|
|
|
|
a sender, which should know nothing about Telegram but
|
|
|
|
rather how to handle this specific connection.
|
|
|
|
|
|
|
|
The TelegramClient itself should know how to initialize
|
|
|
|
a proper connection to the servers, as well as other basic
|
|
|
|
methods such as disconnection and reconnection.
|
|
|
|
|
|
|
|
This distinction between a bare client and a full client
|
|
|
|
makes it possible to create clones of the bare version
|
|
|
|
(by using the same session, IP address and port) to be
|
|
|
|
able to execute queries on either, without the additional
|
|
|
|
cost that would involve having the methods for signing in,
|
|
|
|
logging out, and such.
|
|
|
|
"""
|
|
|
|
|
|
|
|
# Current TelegramClient version
|
2017-10-28 13:21:07 +03:00
|
|
|
__version__ = version.__version__
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2017-09-17 15:30:23 +03:00
|
|
|
# TODO Make this thread-safe, all connections share the same DC
|
2017-10-24 16:40:51 +03:00
|
|
|
_config = None # Server configuration (with .dc_options)
|
2017-09-17 15:30:23 +03:00
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
# region Initialization
|
|
|
|
|
2017-06-22 12:43:42 +03:00
|
|
|
def __init__(self, session, api_id, api_hash,
|
2017-09-04 12:24:10 +03:00
|
|
|
connection_mode=ConnectionMode.TCP_FULL,
|
2017-11-16 15:30:18 +03:00
|
|
|
use_ipv6=False,
|
2017-09-07 19:49:08 +03:00
|
|
|
proxy=None,
|
2017-09-30 12:17:31 +03:00
|
|
|
update_workers=None,
|
2017-09-30 16:53:47 +03:00
|
|
|
spawn_read_thread=False,
|
2017-09-29 21:50:27 +03:00
|
|
|
timeout=timedelta(seconds=5),
|
2018-03-15 12:22:21 +03:00
|
|
|
report_errors=True,
|
2018-03-02 22:05:09 +03:00
|
|
|
device_model=None,
|
|
|
|
system_version=None,
|
|
|
|
app_version=None,
|
|
|
|
lang_code='en',
|
|
|
|
system_lang_code='en'):
|
2017-09-29 21:50:27 +03:00
|
|
|
"""Refer to TelegramClient.__init__ for docs on this method"""
|
|
|
|
if not api_id or not api_hash:
|
2017-12-28 02:22:28 +03:00
|
|
|
raise ValueError(
|
2017-09-29 21:50:27 +03:00
|
|
|
"Your API ID or Hash cannot be empty or None. "
|
2018-01-08 16:04:04 +03:00
|
|
|
"Refer to telethon.rtfd.io for more information.")
|
2017-09-29 21:50:27 +03:00
|
|
|
|
2017-11-16 15:30:18 +03:00
|
|
|
self._use_ipv6 = use_ipv6
|
2018-03-02 00:34:32 +03:00
|
|
|
|
2017-09-29 21:50:27 +03:00
|
|
|
# Determine what session object we have
|
|
|
|
if isinstance(session, str) or session is None:
|
2018-03-02 00:34:32 +03:00
|
|
|
session = SQLiteSession(session)
|
2017-09-29 21:50:27 +03:00
|
|
|
elif not isinstance(session, Session):
|
2017-12-28 02:22:28 +03:00
|
|
|
raise TypeError(
|
2017-09-29 21:50:27 +03:00
|
|
|
'The given session must be a str or a Session instance.'
|
|
|
|
)
|
|
|
|
|
2017-11-16 15:40:25 +03:00
|
|
|
# ':' in session.server_address is True if it's an IPv6 address
|
|
|
|
if (not session.server_address or
|
|
|
|
(':' in session.server_address) != use_ipv6):
|
2017-12-28 03:04:11 +03:00
|
|
|
session.set_dc(
|
|
|
|
DEFAULT_DC_ID,
|
|
|
|
DEFAULT_IPV6_IP if self._use_ipv6 else DEFAULT_IPV4_IP,
|
|
|
|
DEFAULT_PORT
|
|
|
|
)
|
2017-11-16 15:30:18 +03:00
|
|
|
|
2018-03-15 12:22:21 +03:00
|
|
|
session.report_errors = report_errors
|
2017-06-08 14:12:57 +03:00
|
|
|
self.session = session
|
2017-06-11 23:42:04 +03:00
|
|
|
self.api_id = int(api_id)
|
2017-06-08 14:12:57 +03:00
|
|
|
self.api_hash = api_hash
|
2017-09-21 14:43:33 +03:00
|
|
|
|
2017-09-30 12:45:35 +03:00
|
|
|
# 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.
|
2017-09-21 14:43:33 +03:00
|
|
|
self._sender = MtProtoSender(self.session, Connection(
|
|
|
|
mode=connection_mode, proxy=proxy, timeout=timeout
|
|
|
|
))
|
|
|
|
|
2017-09-22 13:20:38 +03:00
|
|
|
# Two threads may be calling reconnect() when the connection is lost,
|
|
|
|
# we only want one to actually perform the reconnection.
|
2017-09-30 19:25:09 +03:00
|
|
|
self._reconnect_lock = Lock()
|
2017-09-22 13:20:38 +03:00
|
|
|
|
2017-09-30 17:32:10 +03:00
|
|
|
# Cache "exported" sessions as 'dc_id: Session' not to recreate
|
|
|
|
# them all the time since generating a new key is a relatively
|
|
|
|
# expensive operation.
|
|
|
|
self._exported_sessions = {}
|
2017-07-04 11:21:15 +03:00
|
|
|
|
2017-09-07 19:49:08 +03:00
|
|
|
# This member will process updates if enabled.
|
|
|
|
# One may change self.updates.enabled at any later point.
|
2017-09-30 12:17:31 +03:00
|
|
|
self.updates = UpdateState(workers=update_workers)
|
2017-09-02 22:45:27 +03:00
|
|
|
|
2017-09-29 21:50:27 +03:00
|
|
|
# Used on connection - the user may modify these and reconnect
|
2018-03-02 22:05:09 +03:00
|
|
|
system = platform.uname()
|
|
|
|
self.device_model = device_model or system.system or 'Unknown'
|
|
|
|
self.system_version = system_version or system.release or '1.0'
|
|
|
|
self.app_version = app_version or self.__version__
|
|
|
|
self.lang_code = lang_code
|
|
|
|
self.system_lang_code = system_lang_code
|
2017-09-29 21:50:27 +03:00
|
|
|
|
|
|
|
# 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)
|
2017-10-18 13:17:13 +03:00
|
|
|
self._authorized = None # None = We don't know yet
|
2017-09-29 21:50:27 +03:00
|
|
|
|
2017-12-28 03:13:24 +03:00
|
|
|
# The first request must be in invokeWithLayer(initConnection(X)).
|
|
|
|
# See https://core.telegram.org/api/invoking#saving-client-info.
|
|
|
|
self._first_request = True
|
|
|
|
|
2017-09-30 12:28:15 +03:00
|
|
|
# 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
|
2017-09-29 21:50:27 +03:00
|
|
|
self._recv_thread = None
|
2018-01-14 23:20:22 +03:00
|
|
|
self._idling = threading.Event()
|
2017-09-29 21:50:27 +03:00
|
|
|
|
|
|
|
# Default PingRequest delay
|
|
|
|
self._last_ping = datetime.now()
|
|
|
|
self._ping_delay = timedelta(minutes=1)
|
|
|
|
|
2018-02-15 13:41:32 +03:00
|
|
|
# Also have another delay for GetStateRequest.
|
|
|
|
#
|
|
|
|
# If the connection is kept alive for long without invoking any
|
|
|
|
# high level request the server simply stops sending updates.
|
|
|
|
# TODO maybe we can have ._last_request instead if any req works?
|
|
|
|
self._last_state = datetime.now()
|
|
|
|
self._state_delay = timedelta(hours=1)
|
|
|
|
|
2017-10-04 15:09:46 +03:00
|
|
|
# Some errors are known but there's nothing we can do from the
|
|
|
|
# background thread. If any of these happens, call .disconnect(),
|
|
|
|
# and raise them next time .invoke() is tried to be called.
|
|
|
|
self._background_error = None
|
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
# endregion
|
|
|
|
|
|
|
|
# region Connecting
|
|
|
|
|
2017-10-24 16:40:51 +03:00
|
|
|
def connect(self, _sync_updates=True):
|
2017-06-08 14:12:57 +03:00
|
|
|
"""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.
|
2017-06-09 11:35:19 +03:00
|
|
|
|
2017-09-30 17:32:10 +03:00
|
|
|
Note that the optional parameters are meant for internal use.
|
|
|
|
|
|
|
|
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.
|
2017-06-08 14:12:57 +03:00
|
|
|
"""
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.info('Connecting to %s:%d...',
|
|
|
|
self.session.server_address, self.session.port)
|
|
|
|
|
2017-10-04 15:09:46 +03:00
|
|
|
self._background_error = None # Clear previous errors
|
2017-09-29 21:50:27 +03:00
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
try:
|
2017-09-21 14:43:33 +03:00
|
|
|
self._sender.connect()
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.info('Connection success!')
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2017-09-29 21:50:27 +03:00
|
|
|
# Connection was successful! Try syncing the update state
|
2017-09-30 17:32:10 +03:00
|
|
|
# UNLESS '_sync_updates' is False (we probably are in
|
|
|
|
# another data center and this would raise UserMigrateError)
|
2017-09-29 21:50:27 +03:00
|
|
|
# to also assert whether the user is logged in or not.
|
|
|
|
self._user_connected = True
|
2017-10-24 16:40:51 +03:00
|
|
|
if self._authorized is None and _sync_updates:
|
2017-09-30 17:11:16 +03:00
|
|
|
try:
|
|
|
|
self.sync_updates()
|
|
|
|
self._set_connected_and_authorized()
|
|
|
|
except UnauthorizedError:
|
|
|
|
self._authorized = False
|
2017-10-18 15:45:08 +03:00
|
|
|
elif self._authorized:
|
|
|
|
self._set_connected_and_authorized()
|
2017-09-29 21:50:27 +03:00
|
|
|
|
2017-09-17 15:25:53 +03:00
|
|
|
return True
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2017-07-26 17:10:45 +03:00
|
|
|
except TypeNotFoundError as e:
|
|
|
|
# This is fine, probably layer migration
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.warning('Connection failed, got unexpected type with ID '
|
|
|
|
'%s. Migrating?', hex(e.invalid_constructor_id))
|
2017-07-26 17:10:45 +03:00
|
|
|
self.disconnect()
|
2017-10-24 16:40:51 +03:00
|
|
|
return self.connect(_sync_updates=_sync_updates)
|
2017-07-26 17:10:45 +03:00
|
|
|
|
2017-12-20 14:47:10 +03:00
|
|
|
except (RPCError, ConnectionError) as e:
|
2017-06-08 14:12:57 +03:00
|
|
|
# Probably errors from the previous session, ignore them
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.error('Connection failed due to %s', e)
|
2017-06-08 14:12:57 +03:00
|
|
|
self.disconnect()
|
2017-09-17 17:20:04 +03:00
|
|
|
return False
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2017-09-17 17:39:29 +03:00
|
|
|
def is_connected(self):
|
2017-09-21 14:43:33 +03:00
|
|
|
return self._sender.is_connected()
|
2017-09-17 17:39:29 +03:00
|
|
|
|
2017-10-24 16:40:51 +03:00
|
|
|
def _wrap_init_connection(self, query):
|
|
|
|
"""Wraps query around InvokeWithLayerRequest(InitConnectionRequest())"""
|
|
|
|
return InvokeWithLayerRequest(LAYER, InitConnectionRequest(
|
2017-09-17 15:25:53 +03:00
|
|
|
api_id=self.api_id,
|
2018-03-02 22:05:09 +03:00
|
|
|
device_model=self.device_model,
|
|
|
|
system_version=self.system_version,
|
|
|
|
app_version=self.app_version,
|
|
|
|
lang_code=self.lang_code,
|
|
|
|
system_lang_code=self.system_lang_code,
|
2017-09-17 15:25:53 +03:00
|
|
|
lang_pack='', # "langPacks are for official apps only"
|
|
|
|
query=query
|
2017-10-24 16:40:51 +03:00
|
|
|
))
|
2017-09-17 15:25:53 +03:00
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
def disconnect(self):
|
2017-09-29 21:50:27 +03:00
|
|
|
"""Disconnects from the Telegram server
|
|
|
|
and stops all the spawned threads"""
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.info('Disconnecting...')
|
2017-10-09 12:47:10 +03:00
|
|
|
self._user_connected = False # This will stop recv_thread's loop
|
2017-12-20 14:47:10 +03:00
|
|
|
|
|
|
|
__log__.debug('Stopping all workers...')
|
2017-10-01 20:56:24 +03:00
|
|
|
self.updates.stop_workers()
|
|
|
|
|
2017-10-09 12:47:10 +03:00
|
|
|
# This will trigger a "ConnectionResetError" on the recv_thread,
|
|
|
|
# which won't attempt reconnecting as ._user_connected is False.
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.debug('Disconnecting the socket...')
|
2017-09-21 14:43:33 +03:00
|
|
|
self._sender.disconnect()
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2017-09-30 17:32:10 +03:00
|
|
|
# TODO Shall we clear the _exported_sessions, or may be reused?
|
2017-12-28 03:13:24 +03:00
|
|
|
self._first_request = True # On reconnect it will be first again
|
2018-01-26 11:59:49 +03:00
|
|
|
self.session.close()
|
2017-09-29 21:50:27 +03:00
|
|
|
|
2017-09-22 13:31:41 +03:00
|
|
|
def _reconnect(self, new_dc=None):
|
2017-09-22 13:20:38 +03:00
|
|
|
"""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
|
|
|
|
library is reconnecting.
|
2017-06-08 17:51:20 +03:00
|
|
|
|
2017-09-22 13:20:38 +03:00
|
|
|
If 'new_dc' is set, the client is first disconnected from the
|
|
|
|
current data center, clears the auth key for the old DC, and
|
|
|
|
connects to the new data center.
|
2017-06-08 17:51:20 +03:00
|
|
|
"""
|
2017-09-22 13:20:38 +03:00
|
|
|
if new_dc is None:
|
2017-11-13 12:31:32 +03:00
|
|
|
if self.is_connected():
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.info('Reconnection aborted: already connected')
|
2017-11-13 12:31:32 +03:00
|
|
|
return True
|
2017-10-09 14:23:39 +03:00
|
|
|
|
2017-11-13 12:31:32 +03:00
|
|
|
try:
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.info('Attempting reconnection...')
|
2017-11-13 12:31:32 +03:00
|
|
|
return self.connect()
|
2017-12-20 14:47:10 +03:00
|
|
|
except ConnectionResetError as e:
|
|
|
|
__log__.warning('Reconnection failed due to %s', e)
|
2017-11-13 12:31:32 +03:00
|
|
|
return False
|
2017-09-22 13:20:38 +03:00
|
|
|
else:
|
2017-10-24 16:40:51 +03:00
|
|
|
# Since we're reconnecting possibly due to a UserMigrateError,
|
|
|
|
# we need to first know the Data Centers we can connect to. Do
|
|
|
|
# that before disconnecting.
|
2017-06-08 17:51:20 +03:00
|
|
|
dc = self._get_dc(new_dc)
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.info('Reconnecting to new data center %s', dc)
|
2017-10-24 16:40:51 +03:00
|
|
|
|
2017-12-28 03:04:11 +03:00
|
|
|
self.session.set_dc(dc.id, dc.ip_address, dc.port)
|
2017-10-24 16:40:51 +03:00
|
|
|
# auth_key's are associated with a server, which has now changed
|
|
|
|
# so it's not valid anymore. Set to None to force recreating it.
|
|
|
|
self.session.auth_key = None
|
2017-06-08 17:51:20 +03:00
|
|
|
self.session.save()
|
2017-10-24 16:40:51 +03:00
|
|
|
self.disconnect()
|
2017-09-22 13:45:14 +03:00
|
|
|
return self.connect()
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2017-12-28 02:09:29 +03:00
|
|
|
def set_proxy(self, proxy):
|
2017-12-28 00:50:49 +03:00
|
|
|
"""Change the proxy used by the connections.
|
|
|
|
"""
|
|
|
|
if self.is_connected():
|
|
|
|
raise RuntimeError("You can't change the proxy while connected.")
|
|
|
|
self._sender.connection.conn.proxy = proxy
|
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
# endregion
|
|
|
|
|
2017-09-29 21:50:27 +03:00
|
|
|
# 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
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2017-11-16 15:30:18 +03:00
|
|
|
def _get_dc(self, dc_id, cdn=False):
|
2017-06-08 14:12:57 +03:00
|
|
|
"""Gets the Data Center (DC) associated to 'dc_id'"""
|
2017-10-24 16:40:51 +03:00
|
|
|
if not TelegramBareClient._config:
|
|
|
|
TelegramBareClient._config = self(GetConfigRequest())
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2017-08-24 14:02:48 +03:00
|
|
|
try:
|
2017-09-05 17:11:02 +03:00
|
|
|
if cdn:
|
|
|
|
# Ensure we have the latest keys for the CDNs
|
|
|
|
for pk in self(GetCdnConfigRequest()).public_keys:
|
|
|
|
rsa.add_key(pk.public_key)
|
|
|
|
|
2017-08-29 14:49:41 +03:00
|
|
|
return next(
|
2017-10-24 16:40:51 +03:00
|
|
|
dc for dc in TelegramBareClient._config.dc_options
|
2017-11-16 15:30:18 +03:00
|
|
|
if dc.id == dc_id and bool(dc.ipv6) == self._use_ipv6 and bool(dc.cdn) == cdn
|
2017-08-29 14:49:41 +03:00
|
|
|
)
|
2017-08-24 14:02:48 +03:00
|
|
|
except StopIteration:
|
|
|
|
if not cdn:
|
|
|
|
raise
|
|
|
|
|
2017-09-05 17:11:02 +03:00
|
|
|
# New configuration, perhaps a new CDN was added?
|
2017-10-24 16:40:51 +03:00
|
|
|
TelegramBareClient._config = self(GetConfigRequest())
|
2017-11-16 15:30:18 +03:00
|
|
|
return self._get_dc(dc_id, cdn=cdn)
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2017-09-30 17:32:10 +03:00
|
|
|
def _get_exported_client(self, dc_id):
|
|
|
|
"""Creates and connects a new TelegramBareClient for the desired DC.
|
2017-07-04 11:21:15 +03:00
|
|
|
|
2017-09-30 17:32:10 +03:00
|
|
|
If it's the first time calling the method with a given dc_id,
|
|
|
|
a new session will be first created, and its auth key generated.
|
|
|
|
Exporting/Importing the authorization will also be done so that
|
|
|
|
the auth is bound with the key.
|
2017-07-04 11:21:15 +03:00
|
|
|
"""
|
|
|
|
# Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt
|
|
|
|
# for clearly showing how to export the authorization! ^^
|
2017-09-30 17:32:10 +03:00
|
|
|
session = self._exported_sessions.get(dc_id)
|
|
|
|
if session:
|
|
|
|
export_auth = None # Already bound with the auth key
|
2017-07-04 11:21:15 +03:00
|
|
|
else:
|
2017-09-30 17:32:10 +03:00
|
|
|
# TODO Add a lock, don't allow two threads to create an auth key
|
2017-09-30 18:51:07 +03:00
|
|
|
# (when calling .connect() if there wasn't a previous session).
|
2017-09-30 17:32:10 +03:00
|
|
|
# for the same data center.
|
2017-07-04 11:21:15 +03:00
|
|
|
dc = self._get_dc(dc_id)
|
|
|
|
|
|
|
|
# Export the current authorization to the new DC.
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.info('Exporting authorization for data center %s', dc)
|
2017-07-04 11:21:15 +03:00
|
|
|
export_auth = self(ExportAuthorizationRequest(dc_id))
|
|
|
|
|
|
|
|
# Create a temporary session for this IP address, which needs
|
|
|
|
# to be different because each auth_key is unique per DC.
|
|
|
|
#
|
|
|
|
# Construct this session with the connection parameters
|
|
|
|
# (system version, device model...) from the current one.
|
2018-03-02 00:34:32 +03:00
|
|
|
session = self.session.clone()
|
2017-12-28 03:04:11 +03:00
|
|
|
session.set_dc(dc.id, dc.ip_address, dc.port)
|
2017-09-30 17:32:10 +03:00
|
|
|
self._exported_sessions[dc_id] = session
|
2017-07-04 11:21:15 +03:00
|
|
|
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.info('Creating exported new client')
|
2017-09-30 17:32:10 +03:00
|
|
|
client = TelegramBareClient(
|
|
|
|
session, self.api_id, self.api_hash,
|
|
|
|
proxy=self._sender.connection.conn.proxy,
|
|
|
|
timeout=self._sender.connection.get_timeout()
|
|
|
|
)
|
2017-10-24 16:40:51 +03:00
|
|
|
client.connect(_sync_updates=False)
|
|
|
|
if isinstance(export_auth, ExportedAuthorization):
|
|
|
|
client(ImportAuthorizationRequest(
|
|
|
|
id=export_auth.id, bytes=export_auth.bytes
|
|
|
|
))
|
|
|
|
elif export_auth is not None:
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.warning('Unknown export auth type %s', export_auth)
|
2017-10-24 16:40:51 +03:00
|
|
|
|
2017-09-30 19:33:34 +03:00
|
|
|
client._authorized = True # We exported the auth, so we got auth
|
2017-09-30 17:32:10 +03:00
|
|
|
return client
|
2017-07-04 11:21:15 +03:00
|
|
|
|
2017-09-30 18:51:07 +03:00
|
|
|
def _get_cdn_client(self, cdn_redirect):
|
|
|
|
"""Similar to ._get_exported_client, but for CDNs"""
|
|
|
|
session = self._exported_sessions.get(cdn_redirect.dc_id)
|
|
|
|
if not session:
|
|
|
|
dc = self._get_dc(cdn_redirect.dc_id, cdn=True)
|
2018-03-02 00:34:32 +03:00
|
|
|
session = self.session.clone()
|
2017-12-28 03:04:11 +03:00
|
|
|
session.set_dc(dc.id, dc.ip_address, dc.port)
|
2017-09-30 18:51:07 +03:00
|
|
|
self._exported_sessions[cdn_redirect.dc_id] = session
|
|
|
|
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.info('Creating new CDN client')
|
2017-09-30 18:51:07 +03:00
|
|
|
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.
|
|
|
|
#
|
2017-10-24 16:40:51 +03:00
|
|
|
# We won't be calling GetConfigRequest because it's only called
|
|
|
|
# when needed by ._get_dc, and also it's static so it's likely
|
|
|
|
# set already. Avoid invoking non-CDN methods by not syncing updates.
|
|
|
|
client.connect(_sync_updates=False)
|
2017-09-30 19:33:34 +03:00
|
|
|
client._authorized = self._authorized
|
2017-09-30 18:51:07 +03:00
|
|
|
return client
|
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
# endregion
|
|
|
|
|
|
|
|
# region Invoking Telegram requests
|
|
|
|
|
2017-10-15 12:05:56 +03:00
|
|
|
def __call__(self, *requests, retries=5):
|
2017-06-08 14:12:57 +03:00
|
|
|
"""Invokes (sends) a MTProtoRequest and returns (receives) its result.
|
|
|
|
|
2017-09-29 21:50:27 +03:00
|
|
|
The invoke will be retried up to 'retries' times before raising
|
2017-12-28 02:22:28 +03:00
|
|
|
RuntimeError().
|
2017-06-08 14:12:57 +03:00
|
|
|
"""
|
2017-09-25 21:52:27 +03:00
|
|
|
if not all(isinstance(x, TLObject) and
|
|
|
|
x.content_related for x in requests):
|
2017-12-28 02:22:28 +03:00
|
|
|
raise TypeError('You can only invoke requests, not types!')
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2018-01-07 01:43:40 +03:00
|
|
|
if self._background_error:
|
|
|
|
raise self._background_error
|
|
|
|
|
2018-01-19 13:47:45 +03:00
|
|
|
for request in requests:
|
|
|
|
request.resolve(self, utils)
|
|
|
|
|
2017-12-20 14:47:10 +03:00
|
|
|
# For logging purposes
|
|
|
|
if len(requests) == 1:
|
|
|
|
which = type(requests[0]).__name__
|
|
|
|
else:
|
|
|
|
which = '{} requests ({})'.format(
|
|
|
|
len(requests), [type(x).__name__ for x in requests])
|
|
|
|
|
2017-09-29 21:50:27 +03:00
|
|
|
# Determine the sender to be used (main or a new connection)
|
2018-01-07 01:43:40 +03:00
|
|
|
__log__.debug('Invoking %s', which)
|
2018-01-14 23:20:22 +03:00
|
|
|
call_receive = \
|
|
|
|
not self._idling.is_set() or self._reconnect_lock.locked()
|
2018-01-07 01:43:40 +03:00
|
|
|
|
|
|
|
for retry in range(retries):
|
|
|
|
result = self._invoke(call_receive, *requests)
|
|
|
|
if result is not None:
|
|
|
|
return result
|
|
|
|
|
2018-03-07 13:13:55 +03:00
|
|
|
log = __log__.info if retry == 0 else __log__.warning
|
|
|
|
log('Invoking %s failed %d times, connecting again and retrying',
|
|
|
|
[str(x) for x in requests], retry + 1)
|
|
|
|
|
2018-01-07 01:43:40 +03:00
|
|
|
sleep(1)
|
|
|
|
# The ReadThread has priority when attempting reconnection,
|
|
|
|
# since this thread is constantly running while __call__ is
|
|
|
|
# only done sometimes. Here try connecting only once/retry.
|
|
|
|
if not self._reconnect_lock.locked():
|
|
|
|
with self._reconnect_lock:
|
|
|
|
self._reconnect()
|
|
|
|
|
2018-03-01 15:21:28 +03:00
|
|
|
raise RuntimeError('Number of retries reached 0 for {}.'.format(
|
|
|
|
[type(x).__name__ for x in requests]
|
|
|
|
))
|
2017-09-30 12:45:35 +03:00
|
|
|
|
2017-10-01 17:04:14 +03:00
|
|
|
# Let people use client.invoke(SomeRequest()) instead client(...)
|
|
|
|
invoke = __call__
|
|
|
|
|
2018-01-07 01:43:40 +03:00
|
|
|
def _invoke(self, call_receive, *requests):
|
2017-06-08 14:12:57 +03:00
|
|
|
try:
|
2017-09-03 14:45:13 +03:00
|
|
|
# Ensure that we start with no previous errors (i.e. resending)
|
2017-09-25 21:52:27 +03:00
|
|
|
for x in requests:
|
|
|
|
x.confirm_received.clear()
|
|
|
|
x.rpc_error = None
|
2017-09-03 14:45:13 +03:00
|
|
|
|
2017-10-24 16:40:51 +03:00
|
|
|
if not self.session.auth_key:
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.info('Need to generate new auth key before invoking')
|
2018-01-04 23:07:29 +03:00
|
|
|
self._first_request = True
|
2017-10-24 16:40:51 +03:00
|
|
|
self.session.auth_key, self.session.time_offset = \
|
|
|
|
authenticator.do_authentication(self._sender.connection)
|
|
|
|
|
2017-12-28 03:13:24 +03:00
|
|
|
if self._first_request:
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.info('Initializing a new connection while invoking')
|
2017-10-24 16:40:51 +03:00
|
|
|
if len(requests) == 1:
|
|
|
|
requests = [self._wrap_init_connection(requests[0])]
|
|
|
|
else:
|
|
|
|
# We need a SINGLE request (like GetConfig) to init conn.
|
|
|
|
# Once that's done, the N original requests will be
|
|
|
|
# invoked.
|
|
|
|
TelegramBareClient._config = self(
|
|
|
|
self._wrap_init_connection(GetConfigRequest())
|
|
|
|
)
|
|
|
|
|
2018-01-07 01:43:40 +03:00
|
|
|
self._sender.send(*requests)
|
2017-09-29 21:50:27 +03:00
|
|
|
|
2017-09-03 10:56:10 +03:00
|
|
|
if not call_receive:
|
2017-09-02 22:27:11 +03:00
|
|
|
# TODO This will be slightly troublesome if we allow
|
|
|
|
# switching between constant read or not on the fly.
|
|
|
|
# Must also watch out for calling .read() from two places,
|
|
|
|
# in which case a Lock would be required for .receive().
|
2017-09-25 21:52:27 +03:00
|
|
|
for x in requests:
|
|
|
|
x.confirm_received.wait(
|
2018-01-07 01:43:40 +03:00
|
|
|
self._sender.connection.get_timeout()
|
2017-09-25 21:52:27 +03:00
|
|
|
)
|
2017-09-02 22:27:11 +03:00
|
|
|
else:
|
2017-09-25 21:52:27 +03:00
|
|
|
while not all(x.confirm_received.is_set() for x in requests):
|
2018-01-07 01:43:40 +03:00
|
|
|
self._sender.receive(update_state=self.updates)
|
2017-09-02 22:27:11 +03:00
|
|
|
|
2017-10-24 16:40:51 +03:00
|
|
|
except BrokenAuthKeyError:
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.error('Authorization key seems broken and was invalid!')
|
2017-10-24 16:40:51 +03:00
|
|
|
self.session.auth_key = None
|
|
|
|
|
2018-01-24 00:25:52 +03:00
|
|
|
except TypeNotFoundError as e:
|
|
|
|
# Only occurs when we call receive. May happen when
|
|
|
|
# we need to reconnect to another DC on login and
|
|
|
|
# Telegram somehow sends old objects (like configOld)
|
|
|
|
self._first_request = True
|
|
|
|
__log__.warning('Read unknown TLObject code ({}). '
|
|
|
|
'Setting again first_request flag.'
|
|
|
|
.format(hex(e.invalid_constructor_id)))
|
|
|
|
|
2017-09-17 17:32:51 +03:00
|
|
|
except TimeoutError:
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.warning('Invoking timed out') # We will just retry
|
2017-09-17 17:32:51 +03:00
|
|
|
|
2018-01-11 17:41:57 +03:00
|
|
|
except ConnectionResetError as e:
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.warning('Connection was reset while invoking')
|
2017-11-13 12:31:32 +03:00
|
|
|
if self._user_connected:
|
|
|
|
# Server disconnected us, __call__ will try reconnecting.
|
|
|
|
return None
|
2017-09-29 20:55:14 +03:00
|
|
|
else:
|
2017-11-13 12:31:32 +03:00
|
|
|
# User never called .connect(), so raise this error.
|
2018-01-11 17:41:57 +03:00
|
|
|
raise RuntimeError('Tried to invoke without .connect()') from e
|
2017-09-29 21:50:27 +03:00
|
|
|
|
2017-12-28 03:13:24 +03:00
|
|
|
# Clear the flag if we got this far
|
|
|
|
self._first_request = False
|
2017-10-24 16:40:51 +03:00
|
|
|
|
2017-09-25 21:52:27 +03:00
|
|
|
try:
|
|
|
|
raise next(x.rpc_error for x in requests if x.rpc_error)
|
|
|
|
except StopIteration:
|
|
|
|
if any(x.result is None for x in requests):
|
|
|
|
# "A container may only be accepted or
|
2017-09-30 12:45:35 +03:00
|
|
|
# rejected by the other party as a whole."
|
|
|
|
return None
|
2017-10-01 14:24:04 +03:00
|
|
|
|
2017-10-01 17:02:29 +03:00
|
|
|
if len(requests) == 1:
|
|
|
|
return requests[0].result
|
|
|
|
else:
|
|
|
|
return [x.result for x in requests]
|
2017-09-11 11:52:36 +03:00
|
|
|
|
2017-10-01 12:25:56 +03:00
|
|
|
except (PhoneMigrateError, NetworkMigrateError,
|
|
|
|
UserMigrateError) as e:
|
|
|
|
|
|
|
|
# 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)
|
2018-01-07 01:43:40 +03:00
|
|
|
return self._invoke(call_receive, *requests)
|
2017-10-01 12:25:56 +03:00
|
|
|
|
|
|
|
except ServerError as e:
|
|
|
|
# Telegram is having some issues, just retry
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.error('Telegram servers are having internal errors %s', e)
|
2017-10-01 12:25:56 +03:00
|
|
|
|
2018-01-10 19:34:34 +03:00
|
|
|
except (FloodWaitError, FloodTestPhoneWaitError) as e:
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.warning('Request invoked too often, wait %ds', e.seconds)
|
2017-10-08 17:15:30 +03:00
|
|
|
if e.seconds > self.session.flood_sleep_threshold | 0:
|
|
|
|
raise
|
|
|
|
|
|
|
|
sleep(e.seconds)
|
2017-10-01 12:25:56 +03:00
|
|
|
|
2017-09-29 21:50:27 +03:00
|
|
|
# Some really basic functionality
|
|
|
|
|
|
|
|
def is_user_authorized(self):
|
|
|
|
"""Has the user been authorized yet
|
|
|
|
(code request sent and confirmed)?"""
|
|
|
|
return self._authorized
|
|
|
|
|
2018-01-19 13:52:44 +03:00
|
|
|
def get_input_entity(self, peer):
|
|
|
|
"""
|
|
|
|
Stub method, no functionality so that calling
|
|
|
|
``.get_input_entity()`` from ``.resolve()`` doesn't fail.
|
|
|
|
"""
|
|
|
|
return peer
|
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
# endregion
|
|
|
|
|
2017-09-29 21:50:27 +03:00
|
|
|
# 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()))
|
2018-02-15 13:41:32 +03:00
|
|
|
self._last_state = datetime.now()
|
2017-09-29 21:50:27 +03:00
|
|
|
|
|
|
|
# endregion
|
|
|
|
|
2017-12-28 00:33:25 +03:00
|
|
|
# region Constant read
|
2017-09-29 21:50:27 +03:00
|
|
|
|
|
|
|
def _set_connected_and_authorized(self):
|
|
|
|
self._authorized = True
|
2017-10-01 20:56:24 +03:00
|
|
|
self.updates.setup_workers()
|
2017-09-30 12:28:15 +03:00
|
|
|
if self._spawn_read_thread and self._recv_thread is None:
|
2017-09-29 21:50:27 +03:00
|
|
|
self._recv_thread = threading.Thread(
|
|
|
|
name='ReadThread', daemon=True,
|
|
|
|
target=self._recv_thread_impl
|
|
|
|
)
|
|
|
|
self._recv_thread.start()
|
|
|
|
|
2017-10-22 14:15:52 +03:00
|
|
|
def _signal_handler(self, signum, frame):
|
|
|
|
if self._user_connected:
|
|
|
|
self.disconnect()
|
|
|
|
else:
|
|
|
|
os._exit(1)
|
|
|
|
|
|
|
|
def idle(self, stop_signals=(SIGINT, SIGTERM, SIGABRT)):
|
|
|
|
"""
|
|
|
|
Idles the program by looping forever and listening for updates
|
|
|
|
until one of the signals are received, which breaks the loop.
|
|
|
|
|
|
|
|
:param stop_signals:
|
|
|
|
Iterable containing signals from the signal module that will
|
|
|
|
be subscribed to TelegramClient.disconnect() (effectively
|
|
|
|
stopping the idle loop), which will be called on receiving one
|
|
|
|
of those signals.
|
|
|
|
:return:
|
|
|
|
"""
|
|
|
|
if self._spawn_read_thread and not self._on_read_thread():
|
2017-12-28 02:22:28 +03:00
|
|
|
raise RuntimeError('Can only idle if spawn_read_thread=False')
|
2017-10-22 14:15:52 +03:00
|
|
|
|
2018-01-14 23:20:22 +03:00
|
|
|
self._idling.set()
|
2017-10-22 14:15:52 +03:00
|
|
|
for sig in stop_signals:
|
|
|
|
signal(sig, self._signal_handler)
|
|
|
|
|
2017-12-20 14:47:10 +03:00
|
|
|
if self._on_read_thread():
|
|
|
|
__log__.info('Starting to wait for items from the network')
|
|
|
|
else:
|
|
|
|
__log__.info('Idling to receive items from the network')
|
|
|
|
|
2017-09-29 21:50:27 +03:00
|
|
|
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()
|
|
|
|
|
2018-02-15 13:41:32 +03:00
|
|
|
if datetime.now() > self._last_state + self._state_delay:
|
|
|
|
self._sender.send(GetStateRequest())
|
|
|
|
self._last_state = datetime.now()
|
|
|
|
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.debug('Receiving items from the network...')
|
2017-09-29 21:50:27 +03:00
|
|
|
self._sender.receive(update_state=self.updates)
|
|
|
|
except TimeoutError:
|
2017-12-20 14:47:10 +03:00
|
|
|
# No problem
|
2018-01-25 20:44:21 +03:00
|
|
|
__log__.debug('Receiving items from the network timed out')
|
2017-09-29 21:50:27 +03:00
|
|
|
except ConnectionResetError:
|
2017-12-20 14:47:10 +03:00
|
|
|
if self._user_connected:
|
|
|
|
__log__.error('Connection was reset while receiving '
|
|
|
|
'items. Reconnecting')
|
2017-11-13 12:31:32 +03:00
|
|
|
with self._reconnect_lock:
|
|
|
|
while self._user_connected and not self._reconnect():
|
|
|
|
sleep(0.1) # Retry forever, this is instant messaging
|
2018-02-03 17:39:37 +03:00
|
|
|
|
|
|
|
if self.is_connected():
|
|
|
|
# Telegram seems to kick us every 1024 items received
|
|
|
|
# from the network not considering things like bad salt.
|
|
|
|
# We must execute some *high level* request (that's not
|
|
|
|
# a ping) if we want to receive updates again.
|
|
|
|
# TODO Test if getDifference works too (better alternative)
|
|
|
|
self._sender.send(GetStateRequest())
|
2018-01-14 23:20:22 +03:00
|
|
|
except:
|
|
|
|
self._idling.clear()
|
|
|
|
raise
|
2017-09-29 21:50:27 +03:00
|
|
|
|
2018-01-14 23:20:22 +03:00
|
|
|
self._idling.clear()
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.info('Connection closed by the user, not reading anymore')
|
|
|
|
|
2017-10-22 14:15:52 +03:00
|
|
|
# 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):
|
|
|
|
# This thread is "idle" (only listening for updates), but also
|
|
|
|
# excepts everything unlike the manual idle because it should
|
|
|
|
# not crash.
|
|
|
|
while self._user_connected:
|
|
|
|
try:
|
|
|
|
self.idle(stop_signals=tuple())
|
2017-09-30 19:39:31 +03:00
|
|
|
except Exception as error:
|
2017-12-20 14:47:10 +03:00
|
|
|
__log__.exception('Unknown exception in the read thread! '
|
|
|
|
'Disconnecting and leaving it to main thread')
|
2017-09-29 21:50:27 +03:00
|
|
|
# Unknown exception, pass it to the main thread
|
2017-10-04 15:09:46 +03:00
|
|
|
|
|
|
|
try:
|
|
|
|
import socks
|
2017-10-13 12:38:12 +03:00
|
|
|
if isinstance(error, (
|
|
|
|
socks.GeneralProxyError, socks.ProxyConnectionError
|
|
|
|
)):
|
2017-10-04 15:09:46 +03:00
|
|
|
# This is a known error, and it's not related to
|
|
|
|
# Telegram but rather to the proxy. Disconnect and
|
|
|
|
# hand it over to the main thread.
|
|
|
|
self._background_error = error
|
|
|
|
self.disconnect()
|
|
|
|
break
|
|
|
|
except ImportError:
|
2017-11-13 12:31:32 +03:00
|
|
|
"Not using PySocks, so it can't be a proxy error"
|
2017-09-29 21:50:27 +03:00
|
|
|
|
|
|
|
self._recv_thread = None
|
|
|
|
|
|
|
|
# endregion
|