2017-08-23 01:27:33 +03:00
|
|
|
import os
|
2017-09-07 21:17:40 +03:00
|
|
|
import threading
|
2017-08-23 01:48:00 +03:00
|
|
|
from datetime import datetime, timedelta
|
2017-09-11 13:53:39 +03:00
|
|
|
from functools import lru_cache
|
2016-10-09 13:57:38 +03:00
|
|
|
from mimetypes import guess_type
|
2017-09-07 22:32:46 +03:00
|
|
|
from threading import Thread
|
2017-09-20 14:22:56 +03:00
|
|
|
try:
|
|
|
|
import socks
|
|
|
|
except ImportError:
|
|
|
|
socks = None
|
2017-06-08 14:12:57 +03:00
|
|
|
|
|
|
|
from . import TelegramBareClient
|
2017-05-21 14:02:54 +03:00
|
|
|
from . import helpers as utils
|
2017-09-04 18:18:33 +03:00
|
|
|
from .errors import (
|
|
|
|
RPCError, UnauthorizedError, InvalidParameterError, PhoneCodeEmptyError,
|
|
|
|
PhoneMigrateError, NetworkMigrateError, UserMigrateError,
|
|
|
|
PhoneCodeExpiredError, PhoneCodeHashEmptyError, PhoneCodeInvalidError
|
|
|
|
)
|
|
|
|
from .network import ConnectionMode
|
2017-08-29 16:59:08 +03:00
|
|
|
from .tl import Session, TLObject
|
2017-09-07 22:23:37 +03:00
|
|
|
from .tl.functions import PingRequest
|
2017-09-04 18:18:33 +03:00
|
|
|
from .tl.functions.account import (
|
|
|
|
GetPasswordRequest
|
|
|
|
)
|
|
|
|
from .tl.functions.auth import (
|
|
|
|
CheckPasswordRequest, LogOutRequest, SendCodeRequest, SignInRequest,
|
|
|
|
SignUpRequest, ImportBotAuthorizationRequest
|
|
|
|
)
|
|
|
|
from .tl.functions.contacts import (
|
|
|
|
GetContactsRequest, ResolveUsernameRequest
|
|
|
|
)
|
2017-05-21 14:02:54 +03:00
|
|
|
from .tl.functions.messages import (
|
2016-11-30 00:29:42 +03:00
|
|
|
GetDialogsRequest, GetHistoryRequest, ReadHistoryRequest, SendMediaRequest,
|
2017-09-04 18:18:33 +03:00
|
|
|
SendMessageRequest
|
|
|
|
)
|
2017-09-07 21:17:40 +03:00
|
|
|
from .tl.functions.updates import (
|
|
|
|
GetStateRequest
|
|
|
|
)
|
2017-09-04 18:18:33 +03:00
|
|
|
from .tl.functions.users import (
|
|
|
|
GetUsersRequest
|
|
|
|
)
|
2017-05-21 14:02:54 +03:00
|
|
|
from .tl.types import (
|
2017-08-23 02:35:12 +03:00
|
|
|
DocumentAttributeAudio, DocumentAttributeFilename,
|
2017-06-08 14:12:57 +03:00
|
|
|
InputDocumentFileLocation, InputFileLocation,
|
2016-11-30 00:29:42 +03:00
|
|
|
InputMediaUploadedDocument, InputMediaUploadedPhoto, InputPeerEmpty,
|
2017-08-23 01:48:00 +03:00
|
|
|
Message, MessageMediaContact, MessageMediaDocument, MessageMediaPhoto,
|
2017-09-13 13:00:27 +03:00
|
|
|
InputUserSelf, UserProfilePhoto, ChatPhoto, UpdateMessageID,
|
2017-09-18 12:59:55 +03:00
|
|
|
UpdateNewMessage, UpdateShortSentMessage
|
2017-09-04 18:18:33 +03:00
|
|
|
)
|
2017-08-23 01:01:10 +03:00
|
|
|
from .utils import find_user_or_chat, get_extension
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2016-09-04 12:07:18 +03:00
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
class TelegramClient(TelegramBareClient):
|
|
|
|
"""Full featured TelegramClient meant to extend the basic functionality -
|
2016-09-04 12:07:18 +03:00
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
As opposed to the TelegramBareClient, this one features downloading
|
|
|
|
media from different data centers, starting a second thread to
|
|
|
|
handle updates, and some very common functionality.
|
|
|
|
"""
|
2016-09-18 12:59:12 +03:00
|
|
|
|
2017-05-08 17:01:53 +03:00
|
|
|
# region Initialization
|
2016-09-07 12:36:34 +03:00
|
|
|
|
2017-09-04 12:24:10 +03:00
|
|
|
def __init__(self, session, api_id, api_hash,
|
|
|
|
connection_mode=ConnectionMode.TCP_FULL,
|
|
|
|
proxy=None,
|
2017-09-08 13:54:38 +03:00
|
|
|
process_updates=False,
|
2017-08-29 17:06:14 +03:00
|
|
|
timeout=timedelta(seconds=5),
|
|
|
|
**kwargs):
|
2016-11-30 17:36:59 +03:00
|
|
|
"""Initializes the Telegram client with the specified API ID and Hash.
|
|
|
|
|
2017-06-08 17:23:05 +03:00
|
|
|
Session can either be a `str` object (filename for the .session)
|
|
|
|
or it can be a `Session` instance (in which case list_sessions()
|
|
|
|
would probably not work). Pass 'None' for it to be a temporary
|
|
|
|
session - remember to '.log_out()'!
|
2016-11-30 17:36:59 +03:00
|
|
|
|
2017-09-04 12:24:10 +03:00
|
|
|
The 'connection_mode' should be any value under ConnectionMode.
|
|
|
|
This will only affect how messages are sent over the network
|
|
|
|
and how much processing is required before sending them.
|
|
|
|
|
2017-09-08 13:54:38 +03:00
|
|
|
If 'process_updates' is set to True, incoming updates will be
|
|
|
|
processed and you must manually call 'self.updates.poll()' from
|
|
|
|
another thread to retrieve the saved update objects, or your
|
|
|
|
memory will fill with these. You may modify the value of
|
|
|
|
'self.updates.polling' at any later point.
|
2017-09-07 21:29:51 +03:00
|
|
|
|
2017-09-08 13:54:38 +03:00
|
|
|
Despite the value of 'process_updates', if you later call
|
|
|
|
'.add_update_handler(...)', updates will also be processed
|
|
|
|
and the update objects will be passed to the handlers you added.
|
2017-09-07 19:49:08 +03:00
|
|
|
|
2017-08-29 17:06:14 +03:00
|
|
|
If more named arguments are provided as **kwargs, they will be
|
|
|
|
used to update the Session instance. Most common settings are:
|
2017-06-30 12:48:45 +03:00
|
|
|
device_model = platform.node()
|
|
|
|
system_version = platform.system()
|
|
|
|
app_version = TelegramClient.__version__
|
|
|
|
lang_code = 'en'
|
|
|
|
system_lang_code = lang_code
|
2017-08-29 17:06:14 +03:00
|
|
|
report_errors = True
|
2017-06-08 17:23:05 +03:00
|
|
|
"""
|
2017-06-08 14:12:57 +03:00
|
|
|
if not api_id or not api_hash:
|
2016-11-30 00:29:42 +03:00
|
|
|
raise PermissionError(
|
2017-06-08 14:12:57 +03:00
|
|
|
"Your API ID or Hash cannot be empty or None. "
|
|
|
|
"Refer to Telethon's README.rst for more information.")
|
2016-09-04 12:07:18 +03:00
|
|
|
|
2016-11-30 17:36:59 +03:00
|
|
|
# Determine what session object we have
|
2017-05-11 15:08:38 +03:00
|
|
|
if isinstance(session, str) or session is None:
|
2017-08-29 16:59:08 +03:00
|
|
|
session = Session.try_load_or_create_new(session)
|
|
|
|
elif not isinstance(session, Session):
|
2016-11-30 17:56:30 +03:00
|
|
|
raise ValueError(
|
2017-06-08 14:12:57 +03:00
|
|
|
'The given session must be a str or a Session instance.')
|
2016-11-30 17:36:59 +03:00
|
|
|
|
2017-09-04 12:24:10 +03:00
|
|
|
super().__init__(
|
|
|
|
session, api_id, api_hash,
|
2017-09-07 19:49:08 +03:00
|
|
|
connection_mode=connection_mode,
|
|
|
|
proxy=proxy,
|
2017-09-08 13:54:38 +03:00
|
|
|
process_updates=process_updates,
|
2017-09-07 19:49:08 +03:00
|
|
|
timeout=timeout
|
2017-09-04 12:24:10 +03:00
|
|
|
)
|
2017-06-07 21:08:16 +03:00
|
|
|
|
2017-06-08 17:23:05 +03:00
|
|
|
# Used on connection - the user may modify these and reconnect
|
2017-08-29 17:06:14 +03:00
|
|
|
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)
|
2017-06-30 12:48:45 +03:00
|
|
|
|
2017-05-29 22:24:47 +03:00
|
|
|
self._updates_thread = None
|
2017-08-31 11:34:09 +03:00
|
|
|
self._phone_code_hash = None
|
|
|
|
self._phone = None
|
2016-09-07 12:36:34 +03:00
|
|
|
|
2017-09-22 13:20:38 +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
|
|
|
|
|
2017-09-21 16:36:20 +03:00
|
|
|
# Save whether the user is authorized here (a.k.a. logged in)
|
|
|
|
self._authorized = False
|
|
|
|
|
2017-08-23 01:27:33 +03:00
|
|
|
# Uploaded files cache so subsequent calls are instant
|
|
|
|
self._upload_cache = {}
|
|
|
|
|
2017-09-03 10:56:10 +03:00
|
|
|
# Constantly read for results and updates from within the main client
|
|
|
|
self._recv_thread = None
|
|
|
|
|
2017-09-07 22:23:37 +03:00
|
|
|
# Default PingRequest delay
|
|
|
|
self._last_ping = datetime.now()
|
|
|
|
self._ping_delay = timedelta(minutes=1)
|
|
|
|
|
2016-09-07 12:36:34 +03:00
|
|
|
# endregion
|
|
|
|
|
|
|
|
# region Connecting
|
2016-09-04 12:07:18 +03:00
|
|
|
|
2017-09-18 11:21:53 +03:00
|
|
|
def connect(self, exported_auth=None):
|
2017-06-08 17:59:47 +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-09-18 11:21:53 +03:00
|
|
|
exported_auth is meant for internal purposes and can be ignored.
|
2017-06-08 17:59:47 +03:00
|
|
|
"""
|
2017-09-20 14:22:56 +03:00
|
|
|
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)
|
|
|
|
|
2017-09-21 16:36:20 +03:00
|
|
|
if not ok:
|
|
|
|
return False
|
|
|
|
|
2017-09-22 13:20:38 +03:00
|
|
|
self._user_connected = True
|
2017-09-21 16:36:20 +03:00
|
|
|
try:
|
|
|
|
self.sync_updates()
|
|
|
|
self._set_connected_and_authorized()
|
|
|
|
except UnauthorizedError:
|
|
|
|
self._authorized = False
|
2017-09-03 10:56:10 +03:00
|
|
|
|
2017-09-21 16:36:20 +03:00
|
|
|
return True
|
2017-06-08 17:23:05 +03:00
|
|
|
|
2016-09-09 12:47:37 +03:00
|
|
|
def disconnect(self):
|
2017-06-08 14:12:57 +03:00
|
|
|
"""Disconnects from the Telegram server
|
|
|
|
and stops all the spawned threads"""
|
2017-09-22 13:20:38 +03:00
|
|
|
self._user_connected = False
|
2017-09-03 10:56:10 +03:00
|
|
|
self._recv_thread = None
|
|
|
|
|
2017-09-03 14:44:52 +03:00
|
|
|
# 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()
|
|
|
|
|
2017-05-30 14:03:14 +03:00
|
|
|
# Also disconnect all the cached senders
|
2017-06-09 11:35:19 +03:00
|
|
|
for sender in self._cached_clients.values():
|
2017-05-30 14:03:14 +03:00
|
|
|
sender.disconnect()
|
|
|
|
|
2017-06-09 11:35:19 +03:00
|
|
|
self._cached_clients.clear()
|
2017-05-30 14:03:14 +03:00
|
|
|
|
2016-09-07 12:36:34 +03:00
|
|
|
# endregion
|
2016-09-04 12:07:18 +03:00
|
|
|
|
2017-06-15 16:50:44 +03:00
|
|
|
# region Working with different connections
|
2017-05-30 13:14:29 +03:00
|
|
|
|
2017-09-20 14:22:56 +03:00
|
|
|
def _on_read_thread(self):
|
|
|
|
return self._recv_thread is not None and \
|
|
|
|
threading.get_ident() == self._recv_thread.ident
|
|
|
|
|
2017-09-02 20:33:42 +03:00
|
|
|
def create_new_connection(self, on_dc=None, timeout=timedelta(seconds=5)):
|
2017-06-15 16:50:44 +03:00
|
|
|
"""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:
|
2017-07-04 11:21:15 +03:00
|
|
|
client = TelegramBareClient(
|
2017-09-02 20:33:42 +03:00
|
|
|
self.session, self.api_id, self.api_hash,
|
2017-09-22 17:11:07 +03:00
|
|
|
proxy=self._sender.connection.conn.proxy, timeout=timeout
|
2017-09-02 20:33:42 +03:00
|
|
|
)
|
2017-06-15 16:50:44 +03:00
|
|
|
client.connect()
|
|
|
|
else:
|
|
|
|
client = self._get_exported_client(on_dc, bypass_cache=True)
|
|
|
|
|
|
|
|
return client
|
|
|
|
|
2017-05-30 13:14:29 +03:00
|
|
|
# endregion
|
|
|
|
|
2016-09-07 12:36:34 +03:00
|
|
|
# region Telegram requests functions
|
2016-09-04 12:07:18 +03:00
|
|
|
|
2017-09-11 11:52:36 +03:00
|
|
|
def invoke(self, request, *args, **kwargs):
|
2017-06-08 14:12:57 +03:00
|
|
|
"""Invokes (sends) a MTProtoRequest and returns (receives) its result.
|
2017-09-11 11:52:36 +03:00
|
|
|
An optional 'retries' parameter can be set.
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2017-06-08 17:59:47 +03:00
|
|
|
*args will be ignored.
|
2017-06-08 14:12:57 +03:00
|
|
|
"""
|
2017-09-22 13:44:09 +03:00
|
|
|
# This is only valid when the read thread is reconnecting,
|
|
|
|
# that is, the connection lock is locked.
|
2017-09-22 17:02:10 +03:00
|
|
|
if self._on_read_thread() and not self._connect_lock.locked():
|
2017-09-23 12:31:58 +03:00
|
|
|
return # Just ignore, we would be raising and crashing the thread
|
2017-09-07 19:25:17 +03:00
|
|
|
|
2017-09-18 11:59:54 +03:00
|
|
|
self.updates.check_error()
|
|
|
|
|
2017-03-20 14:23:53 +03:00
|
|
|
try:
|
2017-09-22 17:02:10 +03:00
|
|
|
# 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()
|
|
|
|
|
2017-09-07 19:25:17 +03:00
|
|
|
return super().invoke(
|
2017-09-22 13:44:09 +03:00
|
|
|
request, call_receive=call_receive,
|
2017-09-11 11:52:36 +03:00
|
|
|
retries=kwargs.get('retries', 5)
|
2017-09-07 19:25:17 +03:00
|
|
|
)
|
2016-09-11 17:24:03 +03:00
|
|
|
|
2017-06-10 12:47:51 +03:00
|
|
|
except (PhoneMigrateError, NetworkMigrateError, UserMigrateError) as e:
|
2017-07-10 16:21:20 +03:00
|
|
|
self._logger.debug('DC error when invoking request, '
|
2017-09-03 14:44:52 +03:00
|
|
|
'attempting to reconnect at DC {}'
|
|
|
|
.format(e.new_dc))
|
2017-06-08 17:51:20 +03:00
|
|
|
|
2017-09-22 13:20:38 +03:00
|
|
|
# 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?
|
2017-09-22 13:31:41 +03:00
|
|
|
self._reconnect(new_dc=e.new_dc)
|
2017-06-22 12:43:42 +03:00
|
|
|
return self.invoke(request)
|
2017-05-29 22:24:47 +03:00
|
|
|
|
2017-09-22 13:44:09 +03:00
|
|
|
except ConnectionResetError as e:
|
2017-09-22 13:20:38 +03:00
|
|
|
if self._connect_lock.locked():
|
|
|
|
# We are connecting and we don't want to reconnect there...
|
|
|
|
raise
|
2017-09-22 13:31:41 +03:00
|
|
|
while self._user_connected and not self._reconnect():
|
2017-09-22 13:20:38 +03:00
|
|
|
pass # Retry forever until we finally can send the request
|
|
|
|
|
2017-07-02 12:56:40 +03:00
|
|
|
# Let people use client(SomeRequest()) instead client.invoke(...)
|
|
|
|
__call__ = invoke
|
|
|
|
|
2017-06-22 12:43:42 +03:00
|
|
|
def invoke_on_dc(self, request, dc_id, reconnect=False):
|
2017-05-30 14:03:14 +03:00
|
|
|
"""Invokes the given request on a different DC
|
|
|
|
by making use of the exported MtProtoSenders.
|
|
|
|
|
2017-06-03 14:36:41 +03:00
|
|
|
If 'reconnect=True', then the a reconnection will be performed and
|
|
|
|
ConnectionResetError will be raised if it occurs a second time.
|
2017-05-30 14:03:14 +03:00
|
|
|
"""
|
|
|
|
try:
|
2017-06-09 11:35:19 +03:00
|
|
|
client = self._get_exported_client(
|
2017-05-30 14:03:14 +03:00
|
|
|
dc_id, init_connection=reconnect)
|
|
|
|
|
2017-06-09 11:35:19 +03:00
|
|
|
return client.invoke(request)
|
2017-05-30 14:03:14 +03:00
|
|
|
|
|
|
|
except ConnectionResetError:
|
|
|
|
if reconnect:
|
|
|
|
raise
|
|
|
|
else:
|
2017-06-22 12:43:42 +03:00
|
|
|
return self.invoke_on_dc(request, dc_id, reconnect=True)
|
2017-05-30 14:03:14 +03:00
|
|
|
|
2016-09-11 17:24:03 +03:00
|
|
|
# region Authorization requests
|
|
|
|
|
2016-09-07 12:36:34 +03:00
|
|
|
def is_user_authorized(self):
|
2017-06-04 18:24:08 +03:00
|
|
|
"""Has the user been authorized yet
|
|
|
|
(code request sent and confirmed)?"""
|
2017-09-21 16:36:20 +03:00
|
|
|
return self._authorized
|
2016-09-04 12:07:18 +03:00
|
|
|
|
2017-08-31 11:34:09 +03:00
|
|
|
def send_code_request(self, phone):
|
2016-09-07 12:36:34 +03:00
|
|
|
"""Sends a code request to the specified phone number"""
|
2017-09-20 13:47:19 +03:00
|
|
|
if isinstance(phone, int):
|
|
|
|
phone = str(phone)
|
|
|
|
elif phone.startswith('+'):
|
|
|
|
phone = phone.strip('+')
|
|
|
|
|
|
|
|
result = self(SendCodeRequest(phone, self.api_id, self.api_hash))
|
2017-08-31 11:34:09 +03:00
|
|
|
self._phone = phone
|
|
|
|
self._phone_code_hash = result.phone_code_hash
|
|
|
|
return result
|
2017-06-08 14:12:57 +03:00
|
|
|
|
2017-08-31 11:34:09 +03:00
|
|
|
def sign_in(self, phone=None, code=None,
|
2017-06-08 14:12:57 +03:00
|
|
|
password=None, bot_token=None):
|
|
|
|
"""Completes the sign in process with the phone number + code pair.
|
2016-09-05 19:35:12 +03:00
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
If no phone or code is provided, then the sole password will be used.
|
|
|
|
The password should be used after a normal authorization attempt
|
2017-09-18 10:37:20 +03:00
|
|
|
has happened and an SessionPasswordNeededError was raised.
|
2016-09-04 12:07:18 +03:00
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
To login as a bot, only `bot_token` should be provided.
|
|
|
|
This should equal to the bot access hash provided by
|
|
|
|
https://t.me/BotFather during your bot creation.
|
2017-03-20 14:31:13 +03:00
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
If the login succeeds, the logged in user is returned.
|
|
|
|
"""
|
2017-08-31 11:34:09 +03:00
|
|
|
|
2017-08-31 11:38:53 +03:00
|
|
|
if phone and not code:
|
2017-08-31 11:34:09 +03:00
|
|
|
return self.send_code_request(phone)
|
|
|
|
elif code:
|
2017-08-31 11:38:53 +03:00
|
|
|
if self._phone is None:
|
2016-11-30 00:29:42 +03:00
|
|
|
raise ValueError(
|
2017-06-08 14:12:57 +03:00
|
|
|
'Please make sure to call send_code_request first.')
|
2016-09-16 14:35:14 +03:00
|
|
|
|
2016-11-26 14:04:02 +03:00
|
|
|
try:
|
2017-08-24 19:05:32 +03:00
|
|
|
if isinstance(code, int):
|
|
|
|
code = str(code)
|
2017-07-02 12:56:40 +03:00
|
|
|
result = self(SignInRequest(
|
2017-08-31 11:34:09 +03:00
|
|
|
self._phone, self._phone_code_hash, code
|
2017-08-24 19:05:32 +03:00
|
|
|
))
|
2016-11-26 14:04:02 +03:00
|
|
|
|
2017-06-10 12:47:51 +03:00
|
|
|
except (PhoneCodeEmptyError, PhoneCodeExpiredError,
|
|
|
|
PhoneCodeHashEmptyError, PhoneCodeInvalidError):
|
|
|
|
return None
|
2016-11-26 14:04:02 +03:00
|
|
|
elif password:
|
2017-07-02 12:56:40 +03:00
|
|
|
salt = self(GetPasswordRequest()).current_salt
|
2017-09-21 16:36:20 +03:00
|
|
|
result = self(CheckPasswordRequest(
|
|
|
|
utils.get_password_hash(password, salt)
|
|
|
|
))
|
2017-03-20 14:31:13 +03:00
|
|
|
elif bot_token:
|
2017-07-02 12:56:40 +03:00
|
|
|
result = self(ImportBotAuthorizationRequest(
|
2017-06-08 14:12:57 +03:00
|
|
|
flags=0, bot_auth_token=bot_token,
|
2017-09-21 16:36:20 +03:00
|
|
|
api_id=self.api_id, api_hash=self.api_hash
|
|
|
|
))
|
2016-11-26 14:04:02 +03:00
|
|
|
else:
|
2016-11-30 00:29:42 +03:00
|
|
|
raise ValueError(
|
2017-08-31 11:34:09 +03:00
|
|
|
'You must provide a phone and a code the first time, '
|
2017-09-21 16:36:20 +03:00
|
|
|
'and a password only if an RPCError was raised before.'
|
|
|
|
)
|
2016-09-04 12:07:18 +03:00
|
|
|
|
2017-09-21 16:36:20 +03:00
|
|
|
self._set_connected_and_authorized()
|
2017-06-08 14:12:57 +03:00
|
|
|
return result.user
|
2016-09-04 12:07:18 +03:00
|
|
|
|
2017-08-31 11:34:09 +03:00
|
|
|
def sign_up(self, code, first_name, last_name=''):
|
2016-09-16 14:35:14 +03:00
|
|
|
"""Signs up to Telegram. Make sure you sent a code request first!"""
|
2017-09-21 16:36:20 +03:00
|
|
|
result = self(SignUpRequest(
|
2017-09-04 19:07:20 +03:00
|
|
|
phone_number=self._phone,
|
|
|
|
phone_code_hash=self._phone_code_hash,
|
|
|
|
phone_code=code,
|
|
|
|
first_name=first_name,
|
|
|
|
last_name=last_name
|
2017-09-21 16:36:20 +03:00
|
|
|
))
|
|
|
|
|
|
|
|
self._set_connected_and_authorized()
|
|
|
|
return result.user
|
2016-09-16 14:35:14 +03:00
|
|
|
|
|
|
|
def log_out(self):
|
2017-06-08 14:12:57 +03:00
|
|
|
"""Logs out and deletes the current session.
|
|
|
|
Returns True if everything went okay."""
|
2017-04-14 16:28:15 +03:00
|
|
|
# Special flag when logging out (so the ack request confirms it)
|
2017-06-22 12:43:42 +03:00
|
|
|
self._sender.logging_out = True
|
2017-08-21 10:00:23 +03:00
|
|
|
|
2016-09-16 14:35:14 +03:00
|
|
|
try:
|
2017-07-02 12:56:40 +03:00
|
|
|
self(LogOutRequest())
|
2017-08-23 00:12:32 +03:00
|
|
|
# The server may have already disconnected us, we still
|
|
|
|
# try to disconnect to make sure.
|
2017-04-14 16:28:15 +03:00
|
|
|
self.disconnect()
|
2017-05-21 14:59:16 +03:00
|
|
|
except (RPCError, ConnectionError):
|
2017-04-11 10:52:44 +03:00
|
|
|
# Something happened when logging out, restore the state back
|
2017-06-22 12:43:42 +03:00
|
|
|
self._sender.logging_out = False
|
2016-09-16 14:35:14 +03:00
|
|
|
return False
|
|
|
|
|
2017-08-21 10:00:23 +03:00
|
|
|
self.session.delete()
|
|
|
|
self.session = None
|
|
|
|
return True
|
|
|
|
|
2017-06-04 18:24:08 +03:00
|
|
|
def get_me(self):
|
|
|
|
"""Gets "me" (the self user) which is currently authenticated,
|
|
|
|
or None if the request fails (hence, not authenticated)."""
|
|
|
|
try:
|
2017-07-02 12:56:40 +03:00
|
|
|
return self(GetUsersRequest([InputUserSelf()]))[0]
|
2017-06-10 12:47:51 +03:00
|
|
|
except UnauthorizedError:
|
|
|
|
return None
|
2017-06-04 18:24:08 +03:00
|
|
|
|
2016-09-11 17:24:03 +03:00
|
|
|
# endregion
|
|
|
|
|
|
|
|
# region Dialogs ("chats") requests
|
|
|
|
|
2016-11-30 00:29:42 +03:00
|
|
|
def get_dialogs(self,
|
2017-05-05 16:11:48 +03:00
|
|
|
limit=10,
|
2016-11-30 00:29:42 +03:00
|
|
|
offset_date=None,
|
|
|
|
offset_id=0,
|
|
|
|
offset_peer=InputPeerEmpty()):
|
2017-06-08 14:12:57 +03:00
|
|
|
"""Returns a tuple of lists ([dialogs], [entities])
|
|
|
|
with at least 'limit' items each.
|
|
|
|
|
|
|
|
If `limit` is 0, all dialogs will (should) retrieved.
|
|
|
|
The `entities` represent the user, chat or channel
|
|
|
|
corresponding to that dialog.
|
|
|
|
"""
|
2016-09-06 19:54:49 +03:00
|
|
|
|
2017-07-02 12:56:40 +03:00
|
|
|
r = self(
|
2016-11-30 00:29:42 +03:00
|
|
|
GetDialogsRequest(
|
|
|
|
offset_date=offset_date,
|
|
|
|
offset_id=offset_id,
|
|
|
|
offset_peer=offset_peer,
|
2017-05-05 16:11:48 +03:00
|
|
|
limit=limit))
|
2016-11-30 00:29:42 +03:00
|
|
|
return (
|
|
|
|
r.dialogs,
|
|
|
|
[find_user_or_chat(d.peer, r.users, r.chats) for d in r.dialogs])
|
2016-09-06 19:54:49 +03:00
|
|
|
|
2016-09-11 17:24:03 +03:00
|
|
|
# endregion
|
|
|
|
|
|
|
|
# region Message requests
|
|
|
|
|
2016-11-30 00:29:42 +03:00
|
|
|
def send_message(self,
|
2017-01-17 22:22:47 +03:00
|
|
|
entity,
|
2016-11-30 00:29:42 +03:00
|
|
|
message,
|
2017-09-13 12:51:23 +03:00
|
|
|
reply_to=None,
|
2017-07-07 11:37:19 +03:00
|
|
|
link_preview=True):
|
2017-06-08 14:12:57 +03:00
|
|
|
"""Sends a message to the given entity (or input peer)
|
2017-09-13 13:00:27 +03:00
|
|
|
and returns the sent message as a Telegram object.
|
2017-08-23 01:01:10 +03:00
|
|
|
|
2017-09-13 12:51:23 +03:00
|
|
|
If 'reply_to' is set to either a message or a message ID,
|
|
|
|
the sent message will be replying to such message.
|
2017-08-23 01:01:10 +03:00
|
|
|
"""
|
2017-09-18 12:59:55 +03:00
|
|
|
entity = self.get_entity(entity)
|
2017-07-04 17:53:07 +03:00
|
|
|
request = SendMessageRequest(
|
2017-09-18 12:59:55 +03:00
|
|
|
peer=entity,
|
2017-06-11 20:16:59 +03:00
|
|
|
message=message,
|
|
|
|
entities=[],
|
2017-09-13 12:51:23 +03:00
|
|
|
no_webpage=not link_preview,
|
|
|
|
reply_to_msg_id=self._get_reply_to(reply_to)
|
2017-07-04 17:53:07 +03:00
|
|
|
)
|
|
|
|
result = self(request)
|
2017-09-19 17:27:10 +03:00
|
|
|
if isinstance(result, UpdateShortSentMessage):
|
2017-09-18 12:59:55 +03:00
|
|
|
return Message(
|
|
|
|
id=result.id,
|
|
|
|
to_id=entity,
|
|
|
|
message=message,
|
|
|
|
date=result.date,
|
|
|
|
out=result.out,
|
|
|
|
media=result.media,
|
|
|
|
entities=result.entities
|
|
|
|
)
|
|
|
|
|
2017-09-13 13:00:27 +03:00
|
|
|
# Telegram seems to send updateMessageID first, then updateNewMessage,
|
|
|
|
# however let's not rely on that just in case.
|
|
|
|
msg_id = None
|
|
|
|
for update in result.updates:
|
|
|
|
if isinstance(update, UpdateMessageID):
|
|
|
|
if update.random_id == request.random_id:
|
|
|
|
msg_id = update.id
|
|
|
|
break
|
|
|
|
|
|
|
|
for update in result.updates:
|
|
|
|
if isinstance(update, UpdateNewMessage):
|
|
|
|
if update.message.id == msg_id:
|
|
|
|
return update.message
|
|
|
|
|
|
|
|
return None # Should not happen
|
2016-09-06 19:54:49 +03:00
|
|
|
|
2016-11-30 00:29:42 +03:00
|
|
|
def get_message_history(self,
|
2017-01-17 22:22:47 +03:00
|
|
|
entity,
|
2016-11-30 00:29:42 +03:00
|
|
|
limit=20,
|
|
|
|
offset_date=None,
|
|
|
|
offset_id=0,
|
|
|
|
max_id=0,
|
|
|
|
min_id=0,
|
|
|
|
add_offset=0):
|
2016-09-08 13:13:31 +03:00
|
|
|
"""
|
2017-01-17 22:22:47 +03:00
|
|
|
Gets the message history for the specified entity
|
2016-09-08 13:13:31 +03:00
|
|
|
|
2017-08-23 01:01:10 +03:00
|
|
|
:param entity: The entity from whom to retrieve the message history
|
2016-09-08 13:13:31 +03:00
|
|
|
:param limit: Number of messages to be retrieved
|
|
|
|
:param offset_date: Offset date (messages *previous* to this date will be retrieved)
|
|
|
|
:param offset_id: Offset message ID (only messages *previous* to the given ID will be retrieved)
|
|
|
|
:param max_id: All the messages with a higher (newer) ID or equal to this will be excluded
|
|
|
|
:param min_id: All the messages with a lower (older) ID or equal to this will be excluded
|
|
|
|
:param add_offset: Additional message offset (all of the specified offsets + this offset = older messages)
|
|
|
|
|
|
|
|
:return: A tuple containing total message count and two more lists ([messages], [senders]).
|
|
|
|
Note that the sender can be null if it was not found!
|
2017-08-23 01:01:10 +03:00
|
|
|
|
|
|
|
The entity may be a phone or an username at the expense of
|
|
|
|
some performance loss.
|
2016-09-08 13:13:31 +03:00
|
|
|
"""
|
2017-07-02 12:56:40 +03:00
|
|
|
result = self(GetHistoryRequest(
|
2017-09-11 12:54:32 +03:00
|
|
|
peer=self.get_entity(entity),
|
2017-07-02 12:56:40 +03:00
|
|
|
limit=limit,
|
|
|
|
offset_date=offset_date,
|
|
|
|
offset_id=offset_id,
|
|
|
|
max_id=max_id,
|
|
|
|
min_id=min_id,
|
|
|
|
add_offset=add_offset
|
|
|
|
))
|
2016-09-08 13:13:31 +03:00
|
|
|
|
2017-06-14 15:06:35 +03:00
|
|
|
# The result may be a messages slice (not all messages were retrieved)
|
|
|
|
# or simply a messages TLObject. In the later case, no "count"
|
|
|
|
# attribute is specified, so the total messages count is simply
|
|
|
|
# the count of retrieved messages
|
2016-09-08 13:13:31 +03:00
|
|
|
total_messages = getattr(result, 'count', len(result.messages))
|
2016-09-11 11:35:02 +03:00
|
|
|
|
2016-09-12 15:07:45 +03:00
|
|
|
# Iterate over all the messages and find the sender User
|
2017-06-15 10:41:01 +03:00
|
|
|
entities = [find_user_or_chat(m.from_id, result.users, result.chats)
|
|
|
|
if m.from_id is not None else
|
|
|
|
find_user_or_chat(m.to_id, result.users, result.chats)
|
|
|
|
for m in result.messages]
|
2017-06-14 15:06:35 +03:00
|
|
|
|
|
|
|
return total_messages, result.messages, entities
|
2016-09-12 15:07:45 +03:00
|
|
|
|
2017-01-17 22:22:47 +03:00
|
|
|
def send_read_acknowledge(self, entity, messages=None, max_id=None):
|
2016-10-02 14:57:03 +03:00
|
|
|
"""Sends a "read acknowledge" (i.e., notifying the given peer that we've
|
2017-06-10 14:39:37 +03:00
|
|
|
read their messages, also known as the "double check").
|
2016-10-02 14:57:03 +03:00
|
|
|
|
|
|
|
Either a list of messages (or a single message) can be given,
|
|
|
|
or the maximum message ID (until which message we want to send the read acknowledge).
|
|
|
|
|
2017-08-23 01:01:10 +03:00
|
|
|
Returns an AffectedMessages TLObject
|
|
|
|
|
|
|
|
The entity may be a phone or an username at the expense of
|
|
|
|
some performance loss.
|
|
|
|
"""
|
2016-10-02 14:57:03 +03:00
|
|
|
if max_id is None:
|
|
|
|
if not messages:
|
2016-11-30 00:29:42 +03:00
|
|
|
raise InvalidParameterError(
|
|
|
|
'Either a message list or a max_id must be provided.')
|
2016-10-02 14:57:03 +03:00
|
|
|
|
|
|
|
if isinstance(messages, list):
|
|
|
|
max_id = max(msg.id for msg in messages)
|
|
|
|
else:
|
|
|
|
max_id = messages.id
|
|
|
|
|
2017-07-02 12:56:40 +03:00
|
|
|
return self(ReadHistoryRequest(
|
2017-09-11 12:54:32 +03:00
|
|
|
peer=self.get_entity(entity),
|
2017-07-02 12:56:40 +03:00
|
|
|
max_id=max_id
|
|
|
|
))
|
2016-10-02 14:57:03 +03:00
|
|
|
|
2017-09-13 12:51:23 +03:00
|
|
|
@staticmethod
|
|
|
|
def _get_reply_to(reply_to):
|
|
|
|
"""Sanitizes the 'reply_to' parameter a user may send"""
|
|
|
|
if reply_to is None:
|
|
|
|
return None
|
|
|
|
|
|
|
|
if isinstance(reply_to, int):
|
|
|
|
return reply_to
|
|
|
|
|
|
|
|
if isinstance(reply_to, TLObject) and \
|
|
|
|
type(reply_to).subclass_of_id == 0x790009e3:
|
|
|
|
# hex(crc32(b'Message')) = 0x790009e3
|
|
|
|
return reply_to.id
|
|
|
|
|
|
|
|
raise ValueError('Invalid reply_to type: ', type(reply_to))
|
|
|
|
|
2016-09-11 17:24:03 +03:00
|
|
|
# endregion
|
|
|
|
|
2017-06-15 16:50:44 +03:00
|
|
|
# region Uploading files
|
|
|
|
|
2017-08-23 01:27:33 +03:00
|
|
|
def send_file(self, entity, file, caption='',
|
2017-09-13 12:30:20 +03:00
|
|
|
force_document=False, progress_callback=None,
|
2017-09-13 12:51:23 +03:00
|
|
|
reply_to=None,
|
2017-09-13 12:30:20 +03:00
|
|
|
**kwargs):
|
2017-08-23 01:27:33 +03:00
|
|
|
"""Sends a file to the specified entity.
|
|
|
|
The file may either be a path, a byte array, or a stream.
|
|
|
|
|
|
|
|
An optional caption can also be specified for said file.
|
|
|
|
|
|
|
|
If "force_document" is False, the file will be sent as a photo
|
|
|
|
if it's recognised to have a common image format (e.g. .png, .jpg).
|
2016-09-11 14:10:27 +03:00
|
|
|
|
2017-08-23 01:27:33 +03:00
|
|
|
Otherwise, the file will always be sent as an uncompressed document.
|
|
|
|
|
|
|
|
Subsequent calls with the very same file will result in
|
|
|
|
immediate uploads, unless .clear_file_cache() is called.
|
|
|
|
|
|
|
|
If "progress_callback" is not None, it should be a function that
|
|
|
|
takes two parameters, (bytes_uploaded, total_bytes).
|
2017-08-23 01:01:10 +03:00
|
|
|
|
2017-09-13 12:51:23 +03:00
|
|
|
The "reply_to" parameter works exactly as the one on .send_message.
|
|
|
|
|
|
|
|
If "is_voice_note" in kwargs, despite its value, and the file is
|
2017-09-13 12:30:20 +03:00
|
|
|
sent as a document, it will be sent as a voice note.
|
|
|
|
|
2017-08-23 01:01:10 +03:00
|
|
|
The entity may be a phone or an username at the expense of
|
|
|
|
some performance loss.
|
|
|
|
"""
|
2017-08-23 01:27:33 +03:00
|
|
|
as_photo = False
|
|
|
|
if isinstance(file, str):
|
|
|
|
lowercase_file = file.lower()
|
|
|
|
as_photo = any(
|
|
|
|
lowercase_file.endswith(ext)
|
|
|
|
for ext in ('.png', '.jpg', '.gif', '.jpeg')
|
|
|
|
)
|
|
|
|
|
|
|
|
file_hash = hash(file)
|
|
|
|
if file_hash in self._upload_cache:
|
|
|
|
file_handle = self._upload_cache[file_hash]
|
|
|
|
else:
|
2017-08-23 01:55:34 +03:00
|
|
|
self._upload_cache[file_hash] = file_handle = self.upload_file(
|
|
|
|
file, progress_callback=progress_callback
|
|
|
|
)
|
2016-09-12 20:32:16 +03:00
|
|
|
|
2017-08-23 01:27:33 +03:00
|
|
|
if as_photo and not force_document:
|
|
|
|
media = InputMediaUploadedPhoto(file_handle, caption)
|
|
|
|
else:
|
|
|
|
mime_type = None
|
|
|
|
if isinstance(file, str):
|
|
|
|
# Determine mime-type and attributes
|
|
|
|
# Take the first element by using [0] since it returns a tuple
|
|
|
|
mime_type = guess_type(file)[0]
|
|
|
|
attributes = [
|
|
|
|
DocumentAttributeFilename(os.path.abspath(file))
|
|
|
|
# TODO If the input file is an audio, find out:
|
|
|
|
# Performer and song title and add DocumentAttributeAudio
|
|
|
|
]
|
|
|
|
else:
|
|
|
|
attributes = [DocumentAttributeFilename('unnamed')]
|
|
|
|
|
2017-09-13 12:30:20 +03:00
|
|
|
if 'is_voice_note' in kwargs:
|
|
|
|
attributes.append(DocumentAttributeAudio(0, voice=True))
|
|
|
|
|
2017-08-23 01:27:33 +03:00
|
|
|
# Ensure we have a mime type, any; but it cannot be None
|
|
|
|
# 'The "octet-stream" subtype is used to indicate that a body
|
|
|
|
# contains arbitrary binary data.'
|
|
|
|
if not mime_type:
|
|
|
|
mime_type = 'application/octet-stream'
|
|
|
|
|
|
|
|
media = InputMediaUploadedDocument(
|
|
|
|
file=file_handle,
|
2016-11-30 00:29:42 +03:00
|
|
|
mime_type=mime_type,
|
|
|
|
attributes=attributes,
|
2017-08-23 01:27:33 +03:00
|
|
|
caption=caption
|
|
|
|
)
|
2017-08-23 01:01:10 +03:00
|
|
|
|
2017-08-23 01:27:33 +03:00
|
|
|
# Once the media type is properly specified and the file uploaded,
|
|
|
|
# send the media message to the desired entity.
|
2017-07-02 12:56:40 +03:00
|
|
|
self(SendMediaRequest(
|
2017-09-11 12:54:32 +03:00
|
|
|
peer=self.get_entity(entity),
|
2017-09-13 12:51:23 +03:00
|
|
|
media=media,
|
|
|
|
reply_to_msg_id=self._get_reply_to(reply_to)
|
2017-06-11 20:16:59 +03:00
|
|
|
))
|
2016-09-11 14:10:27 +03:00
|
|
|
|
2017-09-13 12:51:23 +03:00
|
|
|
def send_voice_note(self, entity, file, caption='', upload_progress=None,
|
|
|
|
reply_to=None):
|
2017-09-13 12:30:20 +03:00
|
|
|
"""Wrapper method around .send_file() with is_voice_note=()"""
|
|
|
|
return self.send_file(entity, file, caption,
|
|
|
|
upload_progress=upload_progress,
|
2017-09-13 12:51:23 +03:00
|
|
|
reply_to=reply_to,
|
2017-09-13 12:30:20 +03:00
|
|
|
is_voice_note=()) # empty tuple is enough
|
|
|
|
|
2017-08-23 01:27:33 +03:00
|
|
|
def clear_file_cache(self):
|
|
|
|
"""Calls to .send_file() will cache the remote location of the
|
|
|
|
uploaded files so that subsequent files can be immediate, so
|
|
|
|
uploading the same file path will result in using the cached
|
|
|
|
version. To avoid this a call to this method should be made.
|
|
|
|
"""
|
|
|
|
self._upload_cache.clear()
|
|
|
|
|
2016-09-12 20:32:16 +03:00
|
|
|
# endregion
|
|
|
|
|
|
|
|
# region Downloading media requests
|
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
def download_profile_photo(self, entity, file=None, download_big=True):
|
2017-08-23 02:35:12 +03:00
|
|
|
"""Downloads the profile photo for an user or a chat (channels too).
|
|
|
|
Returns None if no photo was provided, or if it was Empty.
|
2016-10-03 20:44:01 +03:00
|
|
|
|
2017-08-23 02:35:12 +03:00
|
|
|
If an entity itself (an user, chat or channel) is given, the photo
|
|
|
|
to be downloaded will be downloaded automatically.
|
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
On success, the file path is returned since it may differ from
|
|
|
|
the one provided.
|
|
|
|
|
|
|
|
The specified output file can either be a file path, a directory,
|
|
|
|
or a stream-like object. If the path exists and is a file, it will
|
|
|
|
be overwritten.
|
2017-08-23 02:35:12 +03:00
|
|
|
|
|
|
|
The entity may be a phone or an username at the expense of
|
|
|
|
some performance loss.
|
|
|
|
"""
|
2017-08-24 18:44:38 +03:00
|
|
|
possible_names = []
|
2017-08-23 02:35:12 +03:00
|
|
|
if not isinstance(entity, TLObject) or type(entity).subclass_of_id in (
|
|
|
|
0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697
|
|
|
|
):
|
|
|
|
# Maybe it is an user or a chat? Or their full versions?
|
|
|
|
#
|
|
|
|
# The hexadecimal numbers above are simply:
|
|
|
|
# hex(crc32(x.encode('ascii'))) for x in
|
|
|
|
# ('User', 'Chat', 'UserFull', 'ChatFull')
|
2017-09-11 12:54:32 +03:00
|
|
|
entity = self.get_entity(entity)
|
2017-08-23 02:35:12 +03:00
|
|
|
if not hasattr(entity, 'photo'):
|
|
|
|
# Special case: may be a ChatFull with photo:Photo
|
|
|
|
# This is different from a normal UserProfilePhoto and Chat
|
|
|
|
if hasattr(entity, 'chat_photo'):
|
|
|
|
return self._download_photo(
|
2017-08-24 18:44:38 +03:00
|
|
|
entity.chat_photo, file,
|
|
|
|
date=None, progress_callback=None
|
2017-08-23 02:35:12 +03:00
|
|
|
)
|
|
|
|
else:
|
|
|
|
# Give up
|
|
|
|
return None
|
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
for attr in ('username', 'first_name', 'title'):
|
|
|
|
possible_names.append(getattr(entity, attr, None))
|
2017-08-23 02:35:12 +03:00
|
|
|
|
|
|
|
entity = entity.photo
|
|
|
|
|
|
|
|
if not isinstance(entity, UserProfilePhoto) and \
|
|
|
|
not isinstance(entity, ChatPhoto):
|
|
|
|
return None
|
|
|
|
|
2016-10-03 20:44:01 +03:00
|
|
|
if download_big:
|
2017-08-23 02:35:12 +03:00
|
|
|
photo_location = entity.photo_big
|
2016-10-03 20:44:01 +03:00
|
|
|
else:
|
2017-08-23 02:35:12 +03:00
|
|
|
photo_location = entity.photo_small
|
2016-10-03 20:44:01 +03:00
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
file = self._get_proper_filename(
|
|
|
|
file, 'profile_photo', '.jpg',
|
|
|
|
possible_names=possible_names
|
|
|
|
)
|
|
|
|
|
2016-10-03 20:44:01 +03:00
|
|
|
# Download the media with the largest size input file location
|
2017-06-08 14:12:57 +03:00
|
|
|
self.download_file(
|
2016-11-30 00:29:42 +03:00
|
|
|
InputFileLocation(
|
|
|
|
volume_id=photo_location.volume_id,
|
|
|
|
local_id=photo_location.local_id,
|
2017-06-08 14:12:57 +03:00
|
|
|
secret=photo_location.secret
|
|
|
|
),
|
2017-08-24 18:44:38 +03:00
|
|
|
file
|
2017-06-08 14:12:57 +03:00
|
|
|
)
|
2017-08-24 18:44:38 +03:00
|
|
|
return file
|
2016-10-03 20:44:01 +03:00
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
def download_media(self, message, file=None, progress_callback=None):
|
2017-08-23 01:48:00 +03:00
|
|
|
"""Downloads the media from a specified Message (it can also be
|
|
|
|
the message.media) into the desired file (a stream or str),
|
|
|
|
optionally finding its extension automatically.
|
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
The specified output file can either be a file path, a directory,
|
|
|
|
or a stream-like object. If the path exists and is a file, it will
|
|
|
|
be overwritten.
|
2017-08-23 01:48:00 +03:00
|
|
|
|
|
|
|
If the operation succeeds, the path will be returned (since
|
|
|
|
the extension may have been added automatically). Otherwise,
|
|
|
|
None is returned.
|
2017-07-23 19:38:27 +03:00
|
|
|
|
|
|
|
The progress_callback should be a callback function which takes
|
|
|
|
two parameters, uploaded size and total file size (both in bytes).
|
|
|
|
This will be called every time a part is downloaded
|
|
|
|
"""
|
2017-08-23 01:48:00 +03:00
|
|
|
# TODO This won't work for messageService
|
|
|
|
if isinstance(message, Message):
|
|
|
|
date = message.date
|
2017-08-24 18:44:38 +03:00
|
|
|
media = message.media
|
2017-08-23 01:48:00 +03:00
|
|
|
else:
|
|
|
|
date = datetime.now()
|
2017-08-24 18:44:38 +03:00
|
|
|
media = message
|
2017-08-23 01:48:00 +03:00
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
if isinstance(media, MessageMediaPhoto):
|
2017-08-23 01:48:00 +03:00
|
|
|
return self._download_photo(
|
2017-08-24 18:44:38 +03:00
|
|
|
media, file, date, progress_callback
|
2017-08-23 01:48:00 +03:00
|
|
|
)
|
2017-08-24 18:44:38 +03:00
|
|
|
elif isinstance(media, MessageMediaDocument):
|
2017-08-23 01:48:00 +03:00
|
|
|
return self._download_document(
|
2017-08-24 18:44:38 +03:00
|
|
|
media, file, date, progress_callback
|
2017-08-23 01:48:00 +03:00
|
|
|
)
|
2017-08-24 18:44:38 +03:00
|
|
|
elif isinstance(media, MessageMediaContact):
|
2017-08-23 01:48:00 +03:00
|
|
|
return self._download_contact(
|
2017-08-24 18:44:38 +03:00
|
|
|
media, file
|
2017-08-23 01:48:00 +03:00
|
|
|
)
|
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
def _download_photo(self, mm_photo, file, date, progress_callback):
|
2017-08-23 01:48:00 +03:00
|
|
|
"""Specialized version of .download_media() for photos"""
|
2016-09-17 18:04:30 +03:00
|
|
|
|
2016-09-11 17:24:03 +03:00
|
|
|
# Determine the photo and its largest size
|
2017-08-24 18:44:38 +03:00
|
|
|
photo = mm_photo.photo
|
2016-09-17 18:04:30 +03:00
|
|
|
largest_size = photo.sizes[-1]
|
|
|
|
file_size = largest_size.size
|
|
|
|
largest_size = largest_size.location
|
2016-09-10 19:05:20 +03:00
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
file = self._get_proper_filename(file, 'photo', '.jpg', date=date)
|
2016-09-10 19:05:20 +03:00
|
|
|
|
2016-09-12 20:32:16 +03:00
|
|
|
# Download the media with the largest size input file location
|
2017-06-08 14:12:57 +03:00
|
|
|
self.download_file(
|
2016-11-30 00:29:42 +03:00
|
|
|
InputFileLocation(
|
|
|
|
volume_id=largest_size.volume_id,
|
|
|
|
local_id=largest_size.local_id,
|
2017-06-08 14:12:57 +03:00
|
|
|
secret=largest_size.secret
|
|
|
|
),
|
2017-07-20 10:37:19 +03:00
|
|
|
file,
|
2016-11-30 00:29:42 +03:00
|
|
|
file_size=file_size,
|
2017-06-08 14:12:57 +03:00
|
|
|
progress_callback=progress_callback
|
|
|
|
)
|
2017-07-20 10:37:19 +03:00
|
|
|
return file
|
2016-09-12 20:32:16 +03:00
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
def _download_document(self, mm_doc, file, date, progress_callback):
|
2017-08-23 01:48:00 +03:00
|
|
|
"""Specialized version of .download_media() for documents"""
|
2017-08-24 18:44:38 +03:00
|
|
|
document = mm_doc.document
|
2016-09-17 18:04:30 +03:00
|
|
|
file_size = document.size
|
2016-09-12 20:32:16 +03:00
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
possible_names = []
|
|
|
|
for attr in document.attributes:
|
|
|
|
if isinstance(attr, DocumentAttributeFilename):
|
|
|
|
possible_names.insert(0, attr.file_name)
|
2016-09-12 20:32:16 +03:00
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
elif isinstance(attr, DocumentAttributeAudio):
|
|
|
|
possible_names.append('{} - {}'.format(
|
|
|
|
attr.performer, attr.title
|
|
|
|
))
|
2016-09-12 20:32:16 +03:00
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
file = self._get_proper_filename(
|
|
|
|
file, 'document', get_extension(mm_doc),
|
|
|
|
date=date, possible_names=possible_names
|
|
|
|
)
|
2016-09-12 20:32:16 +03:00
|
|
|
|
2017-06-08 14:12:57 +03:00
|
|
|
self.download_file(
|
2016-11-30 00:29:42 +03:00
|
|
|
InputDocumentFileLocation(
|
|
|
|
id=document.id,
|
|
|
|
access_hash=document.access_hash,
|
2017-06-08 14:12:57 +03:00
|
|
|
version=document.version
|
|
|
|
),
|
2017-07-20 10:37:19 +03:00
|
|
|
file,
|
2016-11-30 00:29:42 +03:00
|
|
|
file_size=file_size,
|
2017-06-08 14:12:57 +03:00
|
|
|
progress_callback=progress_callback
|
|
|
|
)
|
2017-07-20 10:37:19 +03:00
|
|
|
return file
|
2016-09-12 20:32:16 +03:00
|
|
|
|
|
|
|
@staticmethod
|
2017-08-24 18:44:38 +03:00
|
|
|
def _download_contact(mm_contact, file):
|
2017-08-23 01:48:00 +03:00
|
|
|
"""Specialized version of .download_media() for contacts.
|
|
|
|
Will make use of the vCard 4.0 format
|
|
|
|
"""
|
2017-08-24 18:44:38 +03:00
|
|
|
first_name = mm_contact.first_name
|
|
|
|
last_name = mm_contact.last_name
|
|
|
|
phone_number = mm_contact.phone_number
|
2016-09-12 20:32:16 +03:00
|
|
|
|
2017-07-20 10:37:19 +03:00
|
|
|
if isinstance(file, str):
|
2017-08-24 18:44:38 +03:00
|
|
|
file = TelegramClient._get_proper_filename(
|
|
|
|
file, 'contact', '.vcard',
|
|
|
|
possible_names=[first_name, phone_number, last_name]
|
|
|
|
)
|
2017-07-23 18:08:04 +03:00
|
|
|
f = open(file, 'w', encoding='utf-8')
|
2017-07-20 10:37:19 +03:00
|
|
|
else:
|
2017-07-23 18:08:04 +03:00
|
|
|
f = file
|
|
|
|
|
|
|
|
try:
|
|
|
|
f.write('BEGIN:VCARD\n')
|
|
|
|
f.write('VERSION:4.0\n')
|
|
|
|
f.write('N:{};{};;;\n'.format(
|
|
|
|
first_name, last_name if last_name else '')
|
|
|
|
)
|
|
|
|
f.write('FN:{}\n'.format(' '.join((first_name, last_name))))
|
|
|
|
f.write('TEL;TYPE=cell;VALUE=uri:tel:+{}\n'.format(
|
2016-11-30 00:29:42 +03:00
|
|
|
phone_number))
|
2017-07-23 18:08:04 +03:00
|
|
|
f.write('END:VCARD\n')
|
|
|
|
finally:
|
|
|
|
# Only close the stream if we opened it
|
|
|
|
if isinstance(file, str):
|
|
|
|
f.close()
|
2016-09-12 20:32:16 +03:00
|
|
|
|
2017-07-20 10:37:19 +03:00
|
|
|
return file
|
2016-09-12 20:32:16 +03:00
|
|
|
|
2017-08-24 18:44:38 +03:00
|
|
|
@staticmethod
|
|
|
|
def _get_proper_filename(file, kind, extension,
|
|
|
|
date=None, possible_names=None):
|
|
|
|
"""Gets a proper filename for 'file', if this is a path.
|
|
|
|
|
|
|
|
'kind' should be the kind of the output file (photo, document...)
|
|
|
|
'extension' should be the extension to be added to the file if
|
|
|
|
the filename doesn't have any yet
|
|
|
|
'date' should be when this file was originally sent, if known
|
|
|
|
'possible_names' should be an ordered list of possible names
|
|
|
|
|
|
|
|
If no modification is made to the path, any existing file
|
|
|
|
will be overwritten.
|
|
|
|
If any modification is made to the path, this method will
|
|
|
|
ensure that no existing file will be overwritten.
|
|
|
|
"""
|
|
|
|
if file is not None and not isinstance(file, str):
|
|
|
|
# Probably a stream-like object, we cannot set a filename here
|
|
|
|
return file
|
|
|
|
|
|
|
|
if file is None:
|
|
|
|
file = ''
|
|
|
|
elif os.path.isfile(file):
|
|
|
|
# Make no modifications to valid existing paths
|
|
|
|
return file
|
|
|
|
|
|
|
|
if os.path.isdir(file) or not file:
|
|
|
|
try:
|
|
|
|
name = None if possible_names is None else next(
|
|
|
|
x for x in possible_names if x
|
|
|
|
)
|
|
|
|
except StopIteration:
|
|
|
|
name = None
|
|
|
|
|
|
|
|
if not name:
|
|
|
|
name = '{}_{}-{:02}-{:02}_{:02}-{:02}-{:02}'.format(
|
|
|
|
kind,
|
|
|
|
date.year, date.month, date.day,
|
|
|
|
date.hour, date.minute, date.second,
|
|
|
|
)
|
|
|
|
file = os.path.join(file, name)
|
|
|
|
|
|
|
|
directory, name = os.path.split(file)
|
|
|
|
name, ext = os.path.splitext(name)
|
|
|
|
if not ext:
|
|
|
|
ext = extension
|
|
|
|
|
|
|
|
result = os.path.join(directory, name + ext)
|
|
|
|
if not os.path.isfile(result):
|
|
|
|
return result
|
|
|
|
|
|
|
|
i = 1
|
|
|
|
while True:
|
|
|
|
result = os.path.join(directory, '{} ({}){}'.format(name, i, ext))
|
|
|
|
if not os.path.isfile(result):
|
|
|
|
return result
|
|
|
|
i += 1
|
|
|
|
|
2016-09-11 17:24:03 +03:00
|
|
|
# endregion
|
2016-09-10 19:05:20 +03:00
|
|
|
|
2016-09-07 12:36:34 +03:00
|
|
|
# endregion
|
|
|
|
|
2017-08-23 01:01:10 +03:00
|
|
|
# region Small utilities to make users' life easier
|
|
|
|
|
2017-09-11 13:53:39 +03:00
|
|
|
@lru_cache()
|
2017-09-11 12:54:32 +03:00
|
|
|
def get_entity(self, entity):
|
2017-08-23 01:01:10 +03:00
|
|
|
"""Turns an entity into a valid Telegram user or chat.
|
|
|
|
If "entity" is a string, and starts with '+', or if
|
|
|
|
it is an integer value, it will be resolved as if it
|
|
|
|
were a phone number.
|
|
|
|
|
|
|
|
If "entity" is a string and doesn't start with '+', or
|
|
|
|
it starts with '@', it will be resolved from the username.
|
|
|
|
If no exact match is returned, an error will be raised.
|
|
|
|
|
|
|
|
If the entity is neither, and it's not a TLObject, an
|
|
|
|
error will be raised.
|
|
|
|
"""
|
2017-08-23 01:27:33 +03:00
|
|
|
# TODO Maybe cache both the contacts and the entities.
|
|
|
|
# If an user cannot be found, force a cache update through
|
|
|
|
# a public method (since users may change their username)
|
2017-08-23 01:01:10 +03:00
|
|
|
if isinstance(entity, TLObject):
|
|
|
|
return entity
|
|
|
|
|
|
|
|
if isinstance(entity, int):
|
|
|
|
entity = '+{}'.format(entity) # Turn it into a phone-like str
|
|
|
|
|
|
|
|
if isinstance(entity, str):
|
|
|
|
if entity.startswith('+'):
|
2017-09-07 11:44:54 +03:00
|
|
|
contacts = self(GetContactsRequest(0))
|
2017-08-23 01:01:10 +03:00
|
|
|
try:
|
|
|
|
stripped_phone = entity.strip('+')
|
|
|
|
return next(
|
|
|
|
u for u in contacts.users
|
|
|
|
if u.phone and u.phone.endswith(stripped_phone)
|
|
|
|
)
|
|
|
|
except StopIteration:
|
|
|
|
raise ValueError(
|
|
|
|
'Could not find user with phone {}, '
|
|
|
|
'add them to your contacts first'.format(entity)
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
username = entity.strip('@').lower()
|
|
|
|
resolved = self(ResolveUsernameRequest(username))
|
|
|
|
for c in resolved.chats:
|
|
|
|
if getattr(c, 'username', '').lower() == username:
|
|
|
|
return c
|
|
|
|
for u in resolved.users:
|
|
|
|
if getattr(u, 'username', '').lower() == username:
|
|
|
|
return u
|
|
|
|
|
|
|
|
raise ValueError(
|
|
|
|
'Could not find user with username {}'.format(entity)
|
|
|
|
)
|
|
|
|
|
|
|
|
raise ValueError(
|
|
|
|
'Cannot turn "{}" into any entity (user or chat)'.format(entity)
|
|
|
|
)
|
|
|
|
|
|
|
|
# endregion
|
|
|
|
|
2016-09-07 12:36:34 +03:00
|
|
|
# region Updates handling
|
|
|
|
|
2017-09-07 21:17:40 +03:00
|
|
|
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.
|
|
|
|
"""
|
2017-09-21 16:36:20 +03:00
|
|
|
self.updates.process(self(GetStateRequest()))
|
2017-09-07 21:17:40 +03:00
|
|
|
|
2016-09-11 12:50:38 +03:00
|
|
|
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"""
|
2017-09-08 13:54:38 +03:00
|
|
|
sync = not self.updates.handlers
|
2017-09-07 19:58:54 +03:00
|
|
|
self.updates.handlers.append(handler)
|
2017-09-08 13:54:38 +03:00
|
|
|
if sync:
|
|
|
|
self.sync_updates()
|
2016-09-09 12:47:37 +03:00
|
|
|
|
2016-09-11 12:50:38 +03:00
|
|
|
def remove_update_handler(self, handler):
|
2017-09-07 19:58:54 +03:00
|
|
|
self.updates.handlers.remove(handler)
|
2016-09-07 12:36:34 +03:00
|
|
|
|
2017-03-28 19:46:07 +03:00
|
|
|
def list_update_handlers(self):
|
2017-09-07 19:58:54 +03:00
|
|
|
return self.updates.handlers[:]
|
2017-06-07 21:08:16 +03:00
|
|
|
|
2016-09-07 12:36:34 +03:00
|
|
|
# endregion
|
2017-09-03 10:56:10 +03:00
|
|
|
|
|
|
|
# Constant read
|
|
|
|
|
2017-09-21 16:36:20 +03:00
|
|
|
def _set_connected_and_authorized(self):
|
|
|
|
self._authorized = True
|
2017-09-22 14:13:41 +03:00
|
|
|
if self._recv_thread is None:
|
|
|
|
self._recv_thread = Thread(
|
|
|
|
name='ReadThread', daemon=True,
|
|
|
|
target=self._recv_thread_impl
|
|
|
|
)
|
|
|
|
self._recv_thread.start()
|
2017-09-21 16:36:20 +03:00
|
|
|
|
2017-09-03 10:56:10 +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):
|
2017-09-22 13:20:38 +03:00
|
|
|
while self._user_connected:
|
2017-09-03 10:56:10 +03:00
|
|
|
try:
|
2017-09-07 22:23:37 +03:00
|
|
|
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()
|
|
|
|
|
2017-09-07 19:49:08 +03:00
|
|
|
self._sender.receive(update_state=self.updates)
|
2017-09-03 10:56:10 +03:00
|
|
|
except TimeoutError:
|
|
|
|
# No problem.
|
|
|
|
pass
|
2017-09-03 11:05:18 +03:00
|
|
|
except ConnectionResetError:
|
2017-09-22 13:20:38 +03:00
|
|
|
self._logger.debug('Server disconnected us. Reconnecting...')
|
2017-09-22 13:31:41 +03:00
|
|
|
while self._user_connected and not self._reconnect():
|
2017-09-22 13:20:38 +03:00
|
|
|
pass # Retry forever, this is instant messaging
|
|
|
|
|
2017-09-18 11:59:54 +03:00
|
|
|
except Exception as e:
|
|
|
|
# Unknown exception, pass it to the main thread
|
|
|
|
self.updates.set_error(e)
|
2017-09-22 14:13:41 +03:00
|
|
|
break
|
|
|
|
|
|
|
|
self._recv_thread = None
|
2017-09-03 10:56:10 +03:00
|
|
|
|
|
|
|
# endregion
|