From 4cf1208c96d72c76b8f5cb47874418b834cb2cb8 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sun, 13 Jan 2019 06:52:25 +0100 Subject: [PATCH 01/22] Update media caption maximum length --- compiler/error/source/400_BAD_REQUEST.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compiler/error/source/400_BAD_REQUEST.tsv b/compiler/error/source/400_BAD_REQUEST.tsv index 0f174c66..82474096 100644 --- a/compiler/error/source/400_BAD_REQUEST.tsv +++ b/compiler/error/source/400_BAD_REQUEST.tsv @@ -62,7 +62,7 @@ USER_IS_BOT A bot cannot send messages to other bots or to itself WEBPAGE_CURL_FAILED Telegram server could not fetch the provided URL STICKERSET_INVALID The requested sticker set is invalid PEER_FLOOD The method can't be used because your account is limited -MEDIA_CAPTION_TOO_LONG The media caption is longer than 200 characters +MEDIA_CAPTION_TOO_LONG The media caption is longer than 1024 characters USER_NOT_MUTUAL_CONTACT The user is not a mutual contact USER_CHANNELS_TOO_MUCH The user is already in too many channels or supergroups API_ID_PUBLISHED_FLOOD You are using an API key that is limited on the server side @@ -86,4 +86,4 @@ TAKEOUT_REQUIRED The method must be invoked inside a takeout session MESSAGE_POLL_CLOSED You can't interact with a closed poll MEDIA_INVALID The media is invalid BOT_SCORE_NOT_MODIFIED The bot score was not modified -USER_BOT_REQUIRED The method can be used by bots only \ No newline at end of file +USER_BOT_REQUIRED The method can be used by bots only From 6df7788379f6a63aa1248ca27a6a469fce9f03ee Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Wed, 16 Jan 2019 13:10:01 +0100 Subject: [PATCH 02/22] Enhance proxy settings - Allow proxy settings to omit "enabled" key - Allow setting proxy to None in order to disable it --- pyrogram/client/client.py | 1585 +------------------------------------ 1 file changed, 1 insertion(+), 1584 deletions(-) diff --git a/pyrogram/client/client.py b/pyrogram/client/client.py index 2912072d..5d5bf945 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -1,1584 +1 @@ -# Pyrogram - Telegram MTProto API Client Library for Python -# Copyright (C) 2017-2019 Dan Tès -# -# This file is part of Pyrogram. -# -# Pyrogram is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Pyrogram is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Pyrogram. If not, see . - -import base64 -import binascii -import json -import logging -import math -import mimetypes -import os -import re -import shutil -import struct -import tempfile -import threading -import time -from configparser import ConfigParser -from datetime import datetime -from hashlib import sha256, md5 -from importlib import import_module -from pathlib import Path -from signal import signal, SIGINT, SIGTERM, SIGABRT -from threading import Thread -from typing import Union, List - -from pyrogram.api import functions, types -from pyrogram.api.core import Object -from pyrogram.api.errors import ( - PhoneMigrate, NetworkMigrate, PhoneNumberInvalid, - PhoneNumberUnoccupied, PhoneCodeInvalid, PhoneCodeHashEmpty, - PhoneCodeExpired, PhoneCodeEmpty, SessionPasswordNeeded, - PasswordHashInvalid, FloodWait, PeerIdInvalid, FirstnameInvalid, PhoneNumberBanned, - VolumeLocNotFound, UserMigrate, FileIdInvalid, ChannelPrivate, PhoneNumberOccupied, - PasswordRecoveryNa, PasswordEmpty -) -from pyrogram.client.handlers import DisconnectHandler -from pyrogram.client.handlers.handler import Handler -from pyrogram.client.methods.password.utils import compute_check -from pyrogram.crypto import AES -from pyrogram.session import Auth, Session -from .dispatcher import Dispatcher -from .ext import utils, Syncer, BaseClient -from .methods import Methods - -log = logging.getLogger(__name__) - - -class Client(Methods, BaseClient): - """This class represents a Client, the main mean for interacting with Telegram. - It exposes bot-like methods for an easy access to the API as well as a simple way to - invoke every single Telegram API method available. - - Args: - session_name (``str``): - Name to uniquely identify a session of either a User or a Bot. - For Users: pass a string of your choice, e.g.: "my_main_account". - For Bots: pass your Bot API token, e.g.: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" - Note: as long as a valid User session file exists, Pyrogram won't ask you again to input your phone number. - - api_id (``int``, *optional*): - The *api_id* part of your Telegram API Key, as integer. E.g.: 12345 - This is an alternative way to pass it if you don't want to use the *config.ini* file. - - api_hash (``str``, *optional*): - The *api_hash* part of your Telegram API Key, as string. E.g.: "0123456789abcdef0123456789abcdef" - This is an alternative way to pass it if you don't want to use the *config.ini* file. - - app_version (``str``, *optional*): - Application version. Defaults to "Pyrogram \U0001f525 vX.Y.Z" - This is an alternative way to set it if you don't want to use the *config.ini* file. - - device_model (``str``, *optional*): - Device model. Defaults to *platform.python_implementation() + " " + platform.python_version()* - This is an alternative way to set it if you don't want to use the *config.ini* file. - - system_version (``str``, *optional*): - Operating System version. Defaults to *platform.system() + " " + platform.release()* - This is an alternative way to set it if you don't want to use the *config.ini* file. - - lang_code (``str``, *optional*): - Code of the language used on the client, in ISO 639-1 standard. Defaults to "en". - This is an alternative way to set it if you don't want to use the *config.ini* file. - - ipv6 (``bool``, *optional*): - Pass True to connect to Telegram using IPv6. - Defaults to False (IPv4). - - proxy (``dict``, *optional*): - Your SOCKS5 Proxy settings as dict, - e.g.: *dict(hostname="11.22.33.44", port=1080, username="user", password="pass")*. - *username* and *password* can be omitted if your proxy doesn't require authorization. - This is an alternative way to setup a proxy if you don't want to use the *config.ini* file. - - test_mode (``bool``, *optional*): - Enable or disable log-in to testing servers. Defaults to False. - Only applicable for new sessions and will be ignored in case previously - created sessions are loaded. - - phone_number (``str`` | ``callable``, *optional*): - Pass your phone number as string (with your Country Code prefix included) to avoid entering it manually. - Or pass a callback function which accepts no arguments and must return the correct phone number as string - (e.g., "391234567890"). - Only applicable for new sessions. - - phone_code (``str`` | ``callable``, *optional*): - Pass the phone code as string (for test numbers only) to avoid entering it manually. Or pass a callback - function which accepts a single positional argument *(phone_number)* and must return the correct phone code - as string (e.g., "12345"). - Only applicable for new sessions. - - password (``str``, *optional*): - Pass your Two-Step Verification password as string (if you have one) to avoid entering it manually. - Or pass a callback function which accepts a single positional argument *(password_hint)* and must return - the correct password as string (e.g., "password"). - Only applicable for new sessions. - - recovery_code (``callable``, *optional*): - Pass a callback function which accepts a single positional argument *(email_pattern)* and must return the - correct password recovery code as string (e.g., "987654"). - Only applicable for new sessions. - - force_sms (``str``, *optional*): - Pass True to force Telegram sending the authorization code via SMS. - Only applicable for new sessions. - - first_name (``str``, *optional*): - Pass a First Name to avoid entering it manually. It will be used to automatically - create a new Telegram account in case the phone number you passed is not registered yet. - Only applicable for new sessions. - - last_name (``str``, *optional*): - Same purpose as *first_name*; pass a Last Name to avoid entering it manually. It can - be an empty string: "". Only applicable for new sessions. - - workers (``int``, *optional*): - Thread pool size for handling incoming updates. Defaults to 4. - - workdir (``str``, *optional*): - Define a custom working directory. The working directory is the location in your filesystem - where Pyrogram will store your session files. Defaults to "." (current directory). - - config_file (``str``, *optional*): - Path of the configuration file. Defaults to ./config.ini - - plugins_dir (``str``, *optional*): - Define a custom directory for your plugins. The plugins directory is the location in your - filesystem where Pyrogram will automatically load your update handlers. - Defaults to None (plugins disabled). - - no_updates (``bool``, *optional*): - Pass True to completely disable incoming updates for the current session. - When updates are disabled your client can't receive any new message. - Useful for batch programs that don't need to deal with updates. - Defaults to False (updates enabled and always received). - - takeout (``bool``, *optional*): - Pass True to let the client use a takeout session instead of a normal one, implies no_updates. - Useful for exporting your Telegram data. Methods invoked inside a takeout session (such as get_history, - download_media, ...) are less prone to throw FloodWait exceptions. - Only available for users, bots will ignore this parameter. - Defaults to False (normal session). - """ - - def __init__(self, - session_name: str, - api_id: Union[int, str] = None, - api_hash: str = None, - app_version: str = None, - device_model: str = None, - system_version: str = None, - lang_code: str = None, - ipv6: bool = False, - proxy: dict = None, - test_mode: bool = False, - phone_number: str = None, - phone_code: Union[str, callable] = None, - password: str = None, - recovery_code: callable = None, - force_sms: bool = False, - first_name: str = None, - last_name: str = None, - workers: int = BaseClient.WORKERS, - workdir: str = BaseClient.WORKDIR, - config_file: str = BaseClient.CONFIG_FILE, - plugins_dir: str = None, - no_updates: bool = None, - takeout: bool = None): - super().__init__() - - self.session_name = session_name - self.api_id = int(api_id) if api_id else None - self.api_hash = api_hash - self.app_version = app_version - self.device_model = device_model - self.system_version = system_version - self.lang_code = lang_code - self.ipv6 = ipv6 - # TODO: Make code consistent, use underscore for private/protected fields - self._proxy = proxy - self.test_mode = test_mode - self.phone_number = phone_number - self.phone_code = phone_code - self.password = password - self.recovery_code = recovery_code - self.force_sms = force_sms - self.first_name = first_name - self.last_name = last_name - self.workers = workers - self.workdir = workdir - self.config_file = config_file - self.plugins_dir = plugins_dir - self.no_updates = no_updates - self.takeout = takeout - - self.dispatcher = Dispatcher(self, workers) - - def __enter__(self): - return self.start() - - def __exit__(self, *args): - self.stop() - - @property - def proxy(self): - return self._proxy - - @proxy.setter - def proxy(self, value): - self._proxy["enabled"] = True - self._proxy.update(value) - - def start(self): - """Use this method to start the Client after creating it. - Requires no parameters. - - Raises: - :class:`Error ` in case of a Telegram RPC error. - ``ConnectionError`` in case you try to start an already started Client. - """ - if self.is_started: - raise ConnectionError("Client has already been started") - - if self.BOT_TOKEN_RE.match(self.session_name): - self.bot_token = self.session_name - self.session_name = self.session_name.split(":")[0] - - self.load_config() - self.load_session() - self.load_plugins() - - self.session = Session( - self, - self.dc_id, - self.auth_key - ) - - self.session.start() - self.is_started = True - - try: - if self.user_id is None: - if self.bot_token is None: - self.authorize_user() - else: - self.authorize_bot() - - self.save_session() - - if self.bot_token is None: - if self.takeout: - self.takeout_id = self.send(functions.account.InitTakeoutSession()).id - log.warning("Takeout session {} initiated".format(self.takeout_id)) - - now = time.time() - - if abs(now - self.date) > Client.OFFLINE_SLEEP: - self.peers_by_username = {} - self.peers_by_phone = {} - - self.get_initial_dialogs() - self.get_contacts() - else: - self.send(functions.messages.GetPinnedDialogs()) - self.get_initial_dialogs_chunk() - else: - self.send(functions.updates.GetState()) - except Exception as e: - self.is_started = False - self.session.stop() - raise e - - for i in range(self.UPDATES_WORKERS): - self.updates_workers_list.append( - Thread( - target=self.updates_worker, - name="UpdatesWorker#{}".format(i + 1) - ) - ) - - self.updates_workers_list[-1].start() - - for i in range(self.DOWNLOAD_WORKERS): - self.download_workers_list.append( - Thread( - target=self.download_worker, - name="DownloadWorker#{}".format(i + 1) - ) - ) - - self.download_workers_list[-1].start() - - self.dispatcher.start() - - mimetypes.init() - Syncer.add(self) - - return self - - def stop(self): - """Use this method to manually stop the Client. - Requires no parameters. - - Raises: - ``ConnectionError`` in case you try to stop an already stopped Client. - """ - if not self.is_started: - raise ConnectionError("Client is already stopped") - - if self.takeout_id: - self.send(functions.account.FinishTakeoutSession()) - log.warning("Takeout session {} finished".format(self.takeout_id)) - - Syncer.remove(self) - self.dispatcher.stop() - - for _ in range(self.DOWNLOAD_WORKERS): - self.download_queue.put(None) - - for i in self.download_workers_list: - i.join() - - self.download_workers_list.clear() - - for _ in range(self.UPDATES_WORKERS): - self.updates_queue.put(None) - - for i in self.updates_workers_list: - i.join() - - self.updates_workers_list.clear() - - for i in self.media_sessions.values(): - i.stop() - - self.media_sessions.clear() - - self.is_started = False - self.session.stop() - - return self - - def restart(self): - """Use this method to restart the Client. - Requires no parameters. - - Raises: - ``ConnectionError`` in case you try to restart a stopped Client. - """ - self.stop() - self.start() - - def idle(self, stop_signals: tuple = (SIGINT, SIGTERM, SIGABRT)): - """Blocks the program execution until one of the signals are received, - then gently stop the Client by closing the underlying connection. - - Args: - stop_signals (``tuple``, *optional*): - Iterable containing signals the signal handler will listen to. - Defaults to (SIGINT, SIGTERM, SIGABRT). - """ - - def signal_handler(*args): - self.is_idle = False - - for s in stop_signals: - signal(s, signal_handler) - - self.is_idle = True - - while self.is_idle: - time.sleep(1) - - self.stop() - - def run(self): - """Use this method to automatically start and idle a Client. - Requires no parameters. - - Raises: - :class:`Error ` in case of a Telegram RPC error. - """ - self.start() - self.idle() - - def add_handler(self, handler: Handler, group: int = 0): - """Use this method to register an update handler. - - You can register multiple handlers, but at most one handler within a group - will be used for a single update. To handle the same update more than once, register - your handler using a different group id (lower group id == higher priority). - - Args: - handler (``Handler``): - The handler to be registered. - - group (``int``, *optional*): - The group identifier, defaults to 0. - - Returns: - A tuple of (handler, group) - """ - if isinstance(handler, DisconnectHandler): - self.disconnect_handler = handler.callback - else: - self.dispatcher.add_handler(handler, group) - - return handler, group - - def remove_handler(self, handler: Handler, group: int = 0): - """Removes a previously-added update handler. - - Make sure to provide the right group that the handler was added in. You can use - the return value of the :meth:`add_handler` method, a tuple of (handler, group), and - pass it directly. - - Args: - handler (``Handler``): - The handler to be removed. - - group (``int``, *optional*): - The group identifier, defaults to 0. - """ - if isinstance(handler, DisconnectHandler): - self.disconnect_handler = None - else: - self.dispatcher.remove_handler(handler, group) - - def stop_transmission(self): - """Use this method to stop downloading or uploading a file. - Must be called inside a progress callback function. - """ - raise Client.StopTransmission - - def authorize_bot(self): - try: - r = self.send( - functions.auth.ImportBotAuthorization( - flags=0, - api_id=self.api_id, - api_hash=self.api_hash, - bot_auth_token=self.bot_token - ) - ) - except UserMigrate as e: - self.session.stop() - - self.dc_id = e.x - self.auth_key = Auth(self.dc_id, self.test_mode, self.ipv6, self._proxy).create() - - self.session = Session( - self, - self.dc_id, - self.auth_key - ) - - self.session.start() - self.authorize_bot() - else: - self.user_id = r.user.id - - print("Logged in successfully as @{}".format(r.user.username)) - - def authorize_user(self): - phone_number_invalid_raises = self.phone_number is not None - phone_code_invalid_raises = self.phone_code is not None - password_invalid_raises = self.password is not None - first_name_invalid_raises = self.first_name is not None - - def default_phone_number_callback(): - while True: - phone_number = input("Enter phone number: ") - confirm = input("Is \"{}\" correct? (y/n): ".format(phone_number)) - - if confirm in ("y", "1"): - return phone_number - elif confirm in ("n", "2"): - continue - - while True: - self.phone_number = ( - default_phone_number_callback() if self.phone_number is None - else str(self.phone_number()) if callable(self.phone_number) - else str(self.phone_number) - ) - - self.phone_number = self.phone_number.strip("+") - - try: - r = self.send( - functions.auth.SendCode( - self.phone_number, - self.api_id, - self.api_hash - ) - ) - except (PhoneMigrate, NetworkMigrate) as e: - self.session.stop() - - self.dc_id = e.x - - self.auth_key = Auth( - self.dc_id, - self.test_mode, - self.ipv6, - self._proxy - ).create() - - self.session = Session( - self, - self.dc_id, - self.auth_key - ) - - self.session.start() - except (PhoneNumberInvalid, PhoneNumberBanned) as e: - if phone_number_invalid_raises: - raise - else: - print(e.MESSAGE) - self.phone_number = None - except FloodWait as e: - if phone_number_invalid_raises: - raise - else: - print(e.MESSAGE.format(x=e.x)) - time.sleep(e.x) - except Exception as e: - log.error(e, exc_info=True) - raise - else: - break - - phone_registered = r.phone_registered - phone_code_hash = r.phone_code_hash - terms_of_service = r.terms_of_service - - if terms_of_service: - print("\n" + terms_of_service.text + "\n") - - if self.force_sms: - self.send( - functions.auth.ResendCode( - phone_number=self.phone_number, - phone_code_hash=phone_code_hash - ) - ) - - while True: - if not phone_registered: - self.first_name = ( - input("First name: ") if self.first_name is None - else str(self.first_name()) if callable(self.first_name) - else str(self.first_name) - ) - - self.last_name = ( - input("Last name: ") if self.last_name is None - else str(self.last_name()) if callable(self.last_name) - else str(self.last_name) - ) - - self.phone_code = ( - input("Enter phone code: ") if self.phone_code is None - else str(self.phone_code(self.phone_number)) if callable(self.phone_code) - else str(self.phone_code) - ) - - try: - if phone_registered: - try: - r = self.send( - functions.auth.SignIn( - self.phone_number, - phone_code_hash, - self.phone_code - ) - ) - except PhoneNumberUnoccupied: - log.warning("Phone number unregistered") - phone_registered = False - continue - else: - try: - r = self.send( - functions.auth.SignUp( - self.phone_number, - phone_code_hash, - self.phone_code, - self.first_name, - self.last_name - ) - ) - except PhoneNumberOccupied: - log.warning("Phone number already registered") - phone_registered = True - continue - except (PhoneCodeInvalid, PhoneCodeEmpty, PhoneCodeExpired, PhoneCodeHashEmpty) as e: - if phone_code_invalid_raises: - raise - else: - print(e.MESSAGE) - self.phone_code = None - except FirstnameInvalid as e: - if first_name_invalid_raises: - raise - else: - print(e.MESSAGE) - self.first_name = None - except SessionPasswordNeeded as e: - print(e.MESSAGE) - - def default_password_callback(password_hint: str) -> str: - print("Hint: {}".format(password_hint)) - return input("Enter password (empty to recover): ") - - def default_recovery_callback(email_pattern: str) -> str: - print("An e-mail containing the recovery code has been sent to {}".format(email_pattern)) - return input("Enter password recovery code: ") - - while True: - try: - r = self.send(functions.account.GetPassword()) - - self.password = ( - default_password_callback(r.hint) if self.password is None - else str(self.password(r.hint) or "") if callable(self.password) - else str(self.password) - ) - - if self.password == "": - r = self.send(functions.auth.RequestPasswordRecovery()) - - self.recovery_code = ( - default_recovery_callback(r.email_pattern) if self.recovery_code is None - else str(self.recovery_code(r.email_pattern)) if callable(self.recovery_code) - else str(self.recovery_code) - ) - - r = self.send( - functions.auth.RecoverPassword( - code=self.recovery_code - ) - ) - else: - r = self.send( - functions.auth.CheckPassword( - password=compute_check(r, self.password) - ) - ) - except (PasswordEmpty, PasswordRecoveryNa, PasswordHashInvalid) as e: - if password_invalid_raises: - raise - else: - print(e.MESSAGE) - self.password = None - self.recovery_code = None - except FloodWait as e: - if password_invalid_raises: - raise - else: - print(e.MESSAGE.format(x=e.x)) - time.sleep(e.x) - self.password = None - self.recovery_code = None - except Exception as e: - log.error(e, exc_info=True) - raise - else: - break - break - except FloodWait as e: - if phone_code_invalid_raises or first_name_invalid_raises: - raise - else: - print(e.MESSAGE.format(x=e.x)) - time.sleep(e.x) - except Exception as e: - log.error(e, exc_info=True) - raise - else: - break - - if terms_of_service: - assert self.send(functions.help.AcceptTermsOfService(terms_of_service.id)) - - self.password = None - self.user_id = r.user.id - - print("Logged in successfully as {}".format(r.user.first_name)) - - def fetch_peers(self, entities: List[Union[types.User, - types.Chat, types.ChatForbidden, - types.Channel, types.ChannelForbidden]]): - for entity in entities: - if isinstance(entity, types.User): - user_id = entity.id - - access_hash = entity.access_hash - - if access_hash is None: - continue - - username = entity.username - phone = entity.phone - - input_peer = types.InputPeerUser( - user_id=user_id, - access_hash=access_hash - ) - - self.peers_by_id[user_id] = input_peer - - if username is not None: - self.peers_by_username[username.lower()] = input_peer - - if phone is not None: - self.peers_by_phone[phone] = input_peer - - if isinstance(entity, (types.Chat, types.ChatForbidden)): - chat_id = entity.id - peer_id = -chat_id - - input_peer = types.InputPeerChat( - chat_id=chat_id - ) - - self.peers_by_id[peer_id] = input_peer - - if isinstance(entity, (types.Channel, types.ChannelForbidden)): - channel_id = entity.id - peer_id = int("-100" + str(channel_id)) - - access_hash = entity.access_hash - - if access_hash is None: - continue - - username = getattr(entity, "username", None) - - input_peer = types.InputPeerChannel( - channel_id=channel_id, - access_hash=access_hash - ) - - self.peers_by_id[peer_id] = input_peer - - if username is not None: - self.peers_by_username[username.lower()] = input_peer - - def download_worker(self): - name = threading.current_thread().name - log.debug("{} started".format(name)) - - while True: - media = self.download_queue.get() - - if media is None: - break - - temp_file_path = "" - final_file_path = "" - - try: - media, file_name, done, progress, progress_args, path = media - - file_id = media.file_id - size = media.file_size - - directory, file_name = os.path.split(file_name) - directory = directory or "downloads" - - try: - decoded = utils.decode(file_id) - fmt = " 24 else " 24: - volume_id = unpacked[4] - secret = unpacked[5] - local_id = unpacked[6] - - media_type_str = Client.MEDIA_TYPE_ID.get(media_type, None) - - if media_type_str is None: - raise FileIdInvalid("Unknown media type: {}".format(unpacked[0])) - - file_name = file_name or getattr(media, "file_name", None) - - if not file_name: - if media_type == 3: - extension = ".ogg" - elif media_type in (4, 10, 13): - extension = mimetypes.guess_extension(media.mime_type) or ".mp4" - elif media_type == 5: - extension = mimetypes.guess_extension(media.mime_type) or ".unknown" - elif media_type == 8: - extension = ".webp" - elif media_type == 9: - extension = mimetypes.guess_extension(media.mime_type) or ".mp3" - elif media_type in (0, 1, 2): - extension = ".jpg" - else: - continue - - file_name = "{}_{}_{}{}".format( - media_type_str, - datetime.fromtimestamp( - getattr(media, "date", None) or time.time() - ).strftime("%Y-%m-%d_%H-%M-%S"), - self.rnd_id(), - extension - ) - - temp_file_path = self.get_file( - dc_id=dc_id, - id=id, - access_hash=access_hash, - volume_id=volume_id, - local_id=local_id, - secret=secret, - size=size, - progress=progress, - progress_args=progress_args - ) - - if temp_file_path: - final_file_path = os.path.abspath(re.sub("\\\\", "/", os.path.join(directory, file_name))) - os.makedirs(directory, exist_ok=True) - shutil.move(temp_file_path, final_file_path) - except Exception as e: - log.error(e, exc_info=True) - - try: - os.remove(temp_file_path) - except OSError: - pass - else: - # TODO: "" or None for faulty download, which is better? - # os.path methods return "" in case something does not exist, I prefer this. - # For now let's keep None - path[0] = final_file_path or None - finally: - done.set() - - log.debug("{} stopped".format(name)) - - def updates_worker(self): - name = threading.current_thread().name - log.debug("{} started".format(name)) - - while True: - updates = self.updates_queue.get() - - if updates is None: - break - - try: - if isinstance(updates, (types.Update, types.UpdatesCombined)): - self.fetch_peers(updates.users) - self.fetch_peers(updates.chats) - - for update in updates.updates: - channel_id = getattr( - getattr( - getattr( - update, "message", None - ), "to_id", None - ), "channel_id", None - ) or getattr(update, "channel_id", None) - - pts = getattr(update, "pts", None) - pts_count = getattr(update, "pts_count", None) - - if isinstance(update, types.UpdateChannelTooLong): - log.warning(update) - - if isinstance(update, types.UpdateNewChannelMessage): - message = update.message - - if not isinstance(message, types.MessageEmpty): - try: - diff = self.send( - functions.updates.GetChannelDifference( - channel=self.resolve_peer(int("-100" + str(channel_id))), - filter=types.ChannelMessagesFilter( - ranges=[types.MessageRange( - min_id=update.message.id, - max_id=update.message.id - )] - ), - pts=pts - pts_count, - limit=pts - ) - ) - except ChannelPrivate: - pass - else: - if not isinstance(diff, types.updates.ChannelDifferenceEmpty): - updates.users += diff.users - updates.chats += diff.chats - - if channel_id and pts: - if channel_id not in self.channels_pts: - self.channels_pts[channel_id] = [] - - if pts in self.channels_pts[channel_id]: - continue - - self.channels_pts[channel_id].append(pts) - - if len(self.channels_pts[channel_id]) > 50: - self.channels_pts[channel_id] = self.channels_pts[channel_id][25:] - - self.dispatcher.updates_queue.put((update, updates.users, updates.chats)) - elif isinstance(updates, (types.UpdateShortMessage, types.UpdateShortChatMessage)): - diff = self.send( - functions.updates.GetDifference( - pts=updates.pts - updates.pts_count, - date=updates.date, - qts=-1 - ) - ) - - if diff.new_messages: - self.dispatcher.updates_queue.put(( - types.UpdateNewMessage( - message=diff.new_messages[0], - pts=updates.pts, - pts_count=updates.pts_count - ), - diff.users, - diff.chats - )) - else: - self.dispatcher.updates_queue.put((diff.other_updates[0], [], [])) - elif isinstance(updates, types.UpdateShort): - self.dispatcher.updates_queue.put((updates.update, [], [])) - elif isinstance(updates, types.UpdatesTooLong): - log.warning(updates) - except Exception as e: - log.error(e, exc_info=True) - - log.debug("{} stopped".format(name)) - - def send(self, - data: Object, - retries: int = Session.MAX_RETRIES, - timeout: float = Session.WAIT_TIMEOUT): - """Use this method to send Raw Function queries. - - This method makes possible to manually call every single Telegram API method in a low-level manner. - Available functions are listed in the :obj:`functions ` package and may accept compound - data types from :obj:`types ` as well as bare types such as ``int``, ``str``, etc... - - Args: - data (``Object``): - The API Schema function filled with proper arguments. - - retries (``int``): - Number of retries. - - timeout (``float``): - Timeout in seconds. - - Raises: - :class:`Error ` in case of a Telegram RPC error. - """ - if not self.is_started: - raise ConnectionError("Client has not been started") - - if self.no_updates: - data = functions.InvokeWithoutUpdates(data) - - if self.takeout_id: - data = functions.InvokeWithTakeout(self.takeout_id, data) - - r = self.session.send(data, retries, timeout) - - self.fetch_peers(getattr(r, "users", [])) - self.fetch_peers(getattr(r, "chats", [])) - - return r - - def load_config(self): - parser = ConfigParser() - parser.read(self.config_file) - - if self.api_id and self.api_hash: - pass - else: - if parser.has_section("pyrogram"): - self.api_id = parser.getint("pyrogram", "api_id") - self.api_hash = parser.get("pyrogram", "api_hash") - else: - raise AttributeError( - "No API Key found. " - "More info: https://docs.pyrogram.ml/start/ProjectSetup#configuration" - ) - - for option in ["app_version", "device_model", "system_version", "lang_code"]: - if getattr(self, option): - pass - else: - if parser.has_section("pyrogram"): - setattr(self, option, parser.get( - "pyrogram", - option, - fallback=getattr(Client, option.upper()) - )) - else: - setattr(self, option, getattr(Client, option.upper())) - - if self._proxy: - self._proxy["enabled"] = True - else: - self._proxy = {} - - if parser.has_section("proxy"): - self._proxy["enabled"] = parser.getboolean("proxy", "enabled") - self._proxy["hostname"] = parser.get("proxy", "hostname") - self._proxy["port"] = parser.getint("proxy", "port") - self._proxy["username"] = parser.get("proxy", "username", fallback=None) or None - self._proxy["password"] = parser.get("proxy", "password", fallback=None) or None - - def load_session(self): - try: - with open(os.path.join(self.workdir, "{}.session".format(self.session_name)), encoding="utf-8") as f: - s = json.load(f) - except FileNotFoundError: - self.dc_id = 1 - self.date = 0 - self.auth_key = Auth(self.dc_id, self.test_mode, self.ipv6, self._proxy).create() - else: - self.dc_id = s["dc_id"] - self.test_mode = s["test_mode"] - self.auth_key = base64.b64decode("".join(s["auth_key"])) - self.user_id = s["user_id"] - self.date = s.get("date", 0) - - for k, v in s.get("peers_by_id", {}).items(): - self.peers_by_id[int(k)] = utils.get_input_peer(int(k), v) - - for k, v in s.get("peers_by_username", {}).items(): - peer = self.peers_by_id.get(v, None) - - if peer: - self.peers_by_username[k] = peer - - for k, v in s.get("peers_by_phone", {}).items(): - peer = self.peers_by_id.get(v, None) - - if peer: - self.peers_by_phone[k] = peer - - def load_plugins(self): - if self.plugins_dir is not None: - plugins_count = 0 - - for path in Path(self.plugins_dir).rglob("*.py"): - file_path = os.path.splitext(str(path))[0] - import_path = [] - - while file_path: - file_path, tail = os.path.split(file_path) - import_path.insert(0, tail) - - import_path = ".".join(import_path) - module = import_module(import_path) - - for name in dir(module): - # noinspection PyBroadException - try: - handler, group = getattr(module, name) - - if isinstance(handler, Handler) and isinstance(group, int): - self.add_handler(handler, group) - - log.info('{}("{}") from "{}" loaded in group {}'.format( - type(handler).__name__, name, import_path, group)) - - plugins_count += 1 - except Exception: - pass - - if plugins_count > 0: - log.warning('Successfully loaded {} plugin{} from "{}"'.format( - plugins_count, - "s" if plugins_count > 1 else "", - self.plugins_dir - )) - else: - log.warning('No plugin loaded: "{}" doesn\'t contain any valid plugin'.format(self.plugins_dir)) - - def save_session(self): - auth_key = base64.b64encode(self.auth_key).decode() - auth_key = [auth_key[i: i + 43] for i in range(0, len(auth_key), 43)] - - os.makedirs(self.workdir, exist_ok=True) - - with open(os.path.join(self.workdir, "{}.session".format(self.session_name)), "w", encoding="utf-8") as f: - json.dump( - dict( - dc_id=self.dc_id, - test_mode=self.test_mode, - auth_key=auth_key, - user_id=self.user_id, - date=self.date - ), - f, - indent=4 - ) - - def get_initial_dialogs_chunk(self, - offset_date: int = 0): - while True: - try: - r = self.send( - functions.messages.GetDialogs( - offset_date=offset_date, - offset_id=0, - offset_peer=types.InputPeerEmpty(), - limit=self.DIALOGS_AT_ONCE, - hash=0, - exclude_pinned=True - ) - ) - except FloodWait as e: - log.warning("get_dialogs flood: waiting {} seconds".format(e.x)) - time.sleep(e.x) - else: - log.info("Total peers: {}".format(len(self.peers_by_id))) - return r - - def get_initial_dialogs(self): - self.send(functions.messages.GetPinnedDialogs()) - - dialogs = self.get_initial_dialogs_chunk() - offset_date = utils.get_offset_date(dialogs) - - while len(dialogs.dialogs) == self.DIALOGS_AT_ONCE: - dialogs = self.get_initial_dialogs_chunk(offset_date) - offset_date = utils.get_offset_date(dialogs) - - self.get_initial_dialogs_chunk() - - def resolve_peer(self, - peer_id: Union[int, str]): - """Use this method to get the InputPeer of a known peer_id. - - This is a utility method intended to be used **only** when working with Raw Functions (i.e: a Telegram API - method you wish to use which is not available yet in the Client class as an easy-to-use method), whenever an - InputPeer type is required. - - Args: - peer_id (``int`` | ``str``): - The peer id you want to extract the InputPeer from. - Can be a direct id (int), a username (str) or a phone number (str). - - Returns: - On success, the resolved peer id is returned in form of an InputPeer object. - - Raises: - :class:`Error ` in case of a Telegram RPC error. - ``KeyError`` in case the peer doesn't exist in the internal database. - """ - try: - return self.peers_by_id[peer_id] - except KeyError: - if type(peer_id) is str: - if peer_id in ("self", "me"): - return types.InputPeerSelf() - - peer_id = re.sub(r"[@+\s]", "", peer_id.lower()) - - try: - int(peer_id) - except ValueError: - if peer_id not in self.peers_by_username: - self.send( - functions.contacts.ResolveUsername( - username=peer_id - ) - ) - - return self.peers_by_username[peer_id] - else: - try: - return self.peers_by_phone[peer_id] - except KeyError: - raise PeerIdInvalid - - if peer_id > 0: - self.fetch_peers( - self.send( - functions.users.GetUsers( - id=[types.InputUser(peer_id, 0)] - ) - ) - ) - else: - if str(peer_id).startswith("-100"): - self.send( - functions.channels.GetChannels( - id=[types.InputChannel(int(str(peer_id)[4:]), 0)] - ) - ) - else: - self.send( - functions.messages.GetChats( - id=[-peer_id] - ) - ) - - try: - return self.peers_by_id[peer_id] - except KeyError: - raise PeerIdInvalid - - def save_file(self, - path: str, - file_id: int = None, - file_part: int = 0, - progress: callable = None, - progress_args: tuple = ()): - """Use this method to upload a file onto Telegram servers, without actually sending the message to anyone. - - This is a utility method intended to be used **only** when working with Raw Functions (i.e: a Telegram API - method you wish to use which is not available yet in the Client class as an easy-to-use method), whenever an - InputFile type is required. - - Args: - path (``str``): - The path of the file you want to upload that exists on your local machine. - - file_id (``int``, *optional*): - In case a file part expired, pass the file_id and the file_part to retry uploading that specific chunk. - - file_part (``int``, *optional*): - In case a file part expired, pass the file_id and the file_part to retry uploading that specific chunk. - - progress (``callable``, *optional*): - Pass a callback function to view the upload progress. - The function must take *(client, current, total, \*args)* as positional arguments (look at the section - below for a detailed description). - - progress_args (``tuple``, *optional*): - Extra custom arguments for the progress callback function. Useful, for example, if you want to pass - a chat_id and a message_id in order to edit a message with the updated progress. - - Other Parameters: - client (:obj:`Client `): - The Client itself, useful when you want to call other API methods inside the callback function. - - current (``int``): - The amount of bytes uploaded so far. - - total (``int``): - The size of the file. - - *args (``tuple``, *optional*): - Extra custom arguments as defined in the *progress_args* parameter. - You can either keep *\*args* or add every single extra argument in your function signature. - - Returns: - On success, the uploaded file is returned in form of an InputFile object. - - Raises: - :class:`Error ` in case of a Telegram RPC error. - """ - part_size = 512 * 1024 - file_size = os.path.getsize(path) - - if file_size == 0: - raise ValueError("File size equals to 0 B") - - if file_size > 1500 * 1024 * 1024: - raise ValueError("Telegram doesn't support uploading files bigger than 1500 MiB") - - file_total_parts = int(math.ceil(file_size / part_size)) - is_big = True if file_size > 10 * 1024 * 1024 else False - is_missing_part = True if file_id is not None else False - file_id = file_id or self.rnd_id() - md5_sum = md5() if not is_big and not is_missing_part else None - - session = Session(self, self.dc_id, self.auth_key, is_media=True) - session.start() - - try: - with open(path, "rb") as f: - f.seek(part_size * file_part) - - while True: - chunk = f.read(part_size) - - if not chunk: - if not is_big: - md5_sum = "".join([hex(i)[2:].zfill(2) for i in md5_sum.digest()]) - break - - if is_big: - rpc = functions.upload.SaveBigFilePart( - file_id=file_id, - file_part=file_part, - file_total_parts=file_total_parts, - bytes=chunk - ) - else: - rpc = functions.upload.SaveFilePart( - file_id=file_id, - file_part=file_part, - bytes=chunk - ) - - assert session.send(rpc), "Couldn't upload file" - - if is_missing_part: - return - - if not is_big: - md5_sum.update(chunk) - - file_part += 1 - - if progress: - progress(self, min(file_part * part_size, file_size), file_size, *progress_args) - except Client.StopTransmission: - raise - except Exception as e: - log.error(e, exc_info=True) - else: - if is_big: - return types.InputFileBig( - id=file_id, - parts=file_total_parts, - name=os.path.basename(path), - - ) - else: - return types.InputFile( - id=file_id, - parts=file_total_parts, - name=os.path.basename(path), - md5_checksum=md5_sum - ) - finally: - session.stop() - - def get_file(self, - dc_id: int, - id: int = None, - access_hash: int = None, - volume_id: int = None, - local_id: int = None, - secret: int = None, - size: int = None, - progress: callable = None, - progress_args: tuple = ()) -> str: - with self.media_sessions_lock: - session = self.media_sessions.get(dc_id, None) - - if session is None: - if dc_id != self.dc_id: - exported_auth = self.send( - functions.auth.ExportAuthorization( - dc_id=dc_id - ) - ) - - session = Session( - self, - dc_id, - Auth(dc_id, self.test_mode, self.ipv6, self._proxy).create(), - is_media=True - ) - - session.start() - - self.media_sessions[dc_id] = session - - session.send( - functions.auth.ImportAuthorization( - id=exported_auth.id, - bytes=exported_auth.bytes - ) - ) - else: - session = Session( - self, - dc_id, - self.auth_key, - is_media=True - ) - - session.start() - - self.media_sessions[dc_id] = session - - if volume_id: # Photos are accessed by volume_id, local_id, secret - location = types.InputFileLocation( - volume_id=volume_id, - local_id=local_id, - secret=secret, - file_reference=b"" - ) - else: # Any other file can be more easily accessed by id and access_hash - location = types.InputDocumentFileLocation( - id=id, - access_hash=access_hash, - file_reference=b"" - ) - - limit = 1024 * 1024 - offset = 0 - file_name = "" - - try: - r = session.send( - functions.upload.GetFile( - location=location, - offset=offset, - limit=limit - ) - ) - - if isinstance(r, types.upload.File): - with tempfile.NamedTemporaryFile("wb", delete=False) as f: - file_name = f.name - - while True: - chunk = r.bytes - - if not chunk: - break - - f.write(chunk) - - offset += limit - - if progress: - progress(self, min(offset, size) if size != 0 else offset, size, *progress_args) - - r = session.send( - functions.upload.GetFile( - location=location, - offset=offset, - limit=limit - ) - ) - - elif isinstance(r, types.upload.FileCdnRedirect): - with self.media_sessions_lock: - cdn_session = self.media_sessions.get(r.dc_id, None) - - if cdn_session is None: - cdn_session = Session( - self, - r.dc_id, - Auth(r.dc_id, self.test_mode, self.ipv6, self._proxy).create(), - is_media=True, - is_cdn=True - ) - - cdn_session.start() - - self.media_sessions[r.dc_id] = cdn_session - - try: - with tempfile.NamedTemporaryFile("wb", delete=False) as f: - file_name = f.name - - while True: - r2 = cdn_session.send( - functions.upload.GetCdnFile( - file_token=r.file_token, - offset=offset, - limit=limit - ) - ) - - if isinstance(r2, types.upload.CdnFileReuploadNeeded): - try: - session.send( - functions.upload.ReuploadCdnFile( - file_token=r.file_token, - request_token=r2.request_token - ) - ) - except VolumeLocNotFound: - break - else: - continue - - chunk = r2.bytes - - # https://core.telegram.org/cdn#decrypting-files - decrypted_chunk = AES.ctr256_decrypt( - chunk, - r.encryption_key, - bytearray( - r.encryption_iv[:-4] - + (offset // 16).to_bytes(4, "big") - ) - ) - - hashes = session.send( - functions.upload.GetCdnFileHashes( - r.file_token, - offset - ) - ) - - # https://core.telegram.org/cdn#verifying-files - for i, h in enumerate(hashes): - cdn_chunk = decrypted_chunk[h.limit * i: h.limit * (i + 1)] - assert h.hash == sha256(cdn_chunk).digest(), "Invalid CDN hash part {}".format(i) - - f.write(decrypted_chunk) - - offset += limit - - if progress: - progress(self, min(offset, size) if size != 0 else offset, size, *progress_args) - - if len(chunk) < limit: - break - except Exception as e: - raise e - except Exception as e: - if not isinstance(e, Client.StopTransmission): - log.error(e, exc_info=True) - - try: - os.remove(file_name) - except OSError: - pass - - return "" - else: - return file_name +# Pyrogram - Telegram MTProto API Client Library for Python # Copyright (C) 2017-2019 Dan Tès # # This file is part of Pyrogram. # # Pyrogram is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Pyrogram is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with Pyrogram. If not, see . import base64 import binascii import json import logging import math import mimetypes import os import re import shutil import struct import tempfile import threading import time from configparser import ConfigParser from datetime import datetime from hashlib import sha256, md5 from importlib import import_module from pathlib import Path from signal import signal, SIGINT, SIGTERM, SIGABRT from threading import Thread from typing import Union, List from pyrogram.api import functions, types from pyrogram.api.core import Object from pyrogram.api.errors import ( PhoneMigrate, NetworkMigrate, PhoneNumberInvalid, PhoneNumberUnoccupied, PhoneCodeInvalid, PhoneCodeHashEmpty, PhoneCodeExpired, PhoneCodeEmpty, SessionPasswordNeeded, PasswordHashInvalid, FloodWait, PeerIdInvalid, FirstnameInvalid, PhoneNumberBanned, VolumeLocNotFound, UserMigrate, FileIdInvalid, ChannelPrivate, PhoneNumberOccupied, PasswordRecoveryNa, PasswordEmpty ) from pyrogram.client.handlers import DisconnectHandler from pyrogram.client.handlers.handler import Handler from pyrogram.client.methods.password.utils import compute_check from pyrogram.crypto import AES from pyrogram.session import Auth, Session from .dispatcher import Dispatcher from .ext import utils, Syncer, BaseClient from .methods import Methods log = logging.getLogger(__name__) class Client(Methods, BaseClient): """This class represents a Client, the main mean for interacting with Telegram. It exposes bot-like methods for an easy access to the API as well as a simple way to invoke every single Telegram API method available. Args: session_name (``str``): Name to uniquely identify a session of either a User or a Bot. For Users: pass a string of your choice, e.g.: "my_main_account". For Bots: pass your Bot API token, e.g.: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" Note: as long as a valid User session file exists, Pyrogram won't ask you again to input your phone number. api_id (``int``, *optional*): The *api_id* part of your Telegram API Key, as integer. E.g.: 12345 This is an alternative way to pass it if you don't want to use the *config.ini* file. api_hash (``str``, *optional*): The *api_hash* part of your Telegram API Key, as string. E.g.: "0123456789abcdef0123456789abcdef" This is an alternative way to pass it if you don't want to use the *config.ini* file. app_version (``str``, *optional*): Application version. Defaults to "Pyrogram \U0001f525 vX.Y.Z" This is an alternative way to set it if you don't want to use the *config.ini* file. device_model (``str``, *optional*): Device model. Defaults to *platform.python_implementation() + " " + platform.python_version()* This is an alternative way to set it if you don't want to use the *config.ini* file. system_version (``str``, *optional*): Operating System version. Defaults to *platform.system() + " " + platform.release()* This is an alternative way to set it if you don't want to use the *config.ini* file. lang_code (``str``, *optional*): Code of the language used on the client, in ISO 639-1 standard. Defaults to "en". This is an alternative way to set it if you don't want to use the *config.ini* file. ipv6 (``bool``, *optional*): Pass True to connect to Telegram using IPv6. Defaults to False (IPv4). proxy (``dict``, *optional*): Your SOCKS5 Proxy settings as dict, e.g.: *dict(hostname="11.22.33.44", port=1080, username="user", password="pass")*. *username* and *password* can be omitted if your proxy doesn't require authorization. This is an alternative way to setup a proxy if you don't want to use the *config.ini* file. test_mode (``bool``, *optional*): Enable or disable log-in to testing servers. Defaults to False. Only applicable for new sessions and will be ignored in case previously created sessions are loaded. phone_number (``str`` | ``callable``, *optional*): Pass your phone number as string (with your Country Code prefix included) to avoid entering it manually. Or pass a callback function which accepts no arguments and must return the correct phone number as string (e.g., "391234567890"). Only applicable for new sessions. phone_code (``str`` | ``callable``, *optional*): Pass the phone code as string (for test numbers only) to avoid entering it manually. Or pass a callback function which accepts a single positional argument *(phone_number)* and must return the correct phone code as string (e.g., "12345"). Only applicable for new sessions. password (``str``, *optional*): Pass your Two-Step Verification password as string (if you have one) to avoid entering it manually. Or pass a callback function which accepts a single positional argument *(password_hint)* and must return the correct password as string (e.g., "password"). Only applicable for new sessions. recovery_code (``callable``, *optional*): Pass a callback function which accepts a single positional argument *(email_pattern)* and must return the correct password recovery code as string (e.g., "987654"). Only applicable for new sessions. force_sms (``str``, *optional*): Pass True to force Telegram sending the authorization code via SMS. Only applicable for new sessions. first_name (``str``, *optional*): Pass a First Name to avoid entering it manually. It will be used to automatically create a new Telegram account in case the phone number you passed is not registered yet. Only applicable for new sessions. last_name (``str``, *optional*): Same purpose as *first_name*; pass a Last Name to avoid entering it manually. It can be an empty string: "". Only applicable for new sessions. workers (``int``, *optional*): Thread pool size for handling incoming updates. Defaults to 4. workdir (``str``, *optional*): Define a custom working directory. The working directory is the location in your filesystem where Pyrogram will store your session files. Defaults to "." (current directory). config_file (``str``, *optional*): Path of the configuration file. Defaults to ./config.ini plugins_dir (``str``, *optional*): Define a custom directory for your plugins. The plugins directory is the location in your filesystem where Pyrogram will automatically load your update handlers. Defaults to None (plugins disabled). no_updates (``bool``, *optional*): Pass True to completely disable incoming updates for the current session. When updates are disabled your client can't receive any new message. Useful for batch programs that don't need to deal with updates. Defaults to False (updates enabled and always received). takeout (``bool``, *optional*): Pass True to let the client use a takeout session instead of a normal one, implies no_updates. Useful for exporting your Telegram data. Methods invoked inside a takeout session (such as get_history, download_media, ...) are less prone to throw FloodWait exceptions. Only available for users, bots will ignore this parameter. Defaults to False (normal session). """ def __init__(self, session_name: str, api_id: Union[int, str] = None, api_hash: str = None, app_version: str = None, device_model: str = None, system_version: str = None, lang_code: str = None, ipv6: bool = False, proxy: dict = None, test_mode: bool = False, phone_number: str = None, phone_code: Union[str, callable] = None, password: str = None, recovery_code: callable = None, force_sms: bool = False, first_name: str = None, last_name: str = None, workers: int = BaseClient.WORKERS, workdir: str = BaseClient.WORKDIR, config_file: str = BaseClient.CONFIG_FILE, plugins_dir: str = None, no_updates: bool = None, takeout: bool = None): super().__init__() self.session_name = session_name self.api_id = int(api_id) if api_id else None self.api_hash = api_hash self.app_version = app_version self.device_model = device_model self.system_version = system_version self.lang_code = lang_code self.ipv6 = ipv6 # TODO: Make code consistent, use underscore for private/protected fields self._proxy = proxy self.test_mode = test_mode self.phone_number = phone_number self.phone_code = phone_code self.password = password self.recovery_code = recovery_code self.force_sms = force_sms self.first_name = first_name self.last_name = last_name self.workers = workers self.workdir = workdir self.config_file = config_file self.plugins_dir = plugins_dir self.no_updates = no_updates self.takeout = takeout self.dispatcher = Dispatcher(self, workers) def __enter__(self): return self.start() def __exit__(self, *args): self.stop() @property def proxy(self): return self._proxy @proxy.setter def proxy(self, value): if value is None: self._proxy = None return if self._proxy is None: self._proxy = {} self._proxy["enabled"] = bool(value.get("enabled", True)) self._proxy.update(value) def start(self): """Use this method to start the Client after creating it. Requires no parameters. Raises: :class:`Error ` in case of a Telegram RPC error. ``ConnectionError`` in case you try to start an already started Client. """ if self.is_started: raise ConnectionError("Client has already been started") if self.BOT_TOKEN_RE.match(self.session_name): self.bot_token = self.session_name self.session_name = self.session_name.split(":")[0] self.load_config() self.load_session() self.load_plugins() self.session = Session( self, self.dc_id, self.auth_key ) self.session.start() self.is_started = True try: if self.user_id is None: if self.bot_token is None: self.authorize_user() else: self.authorize_bot() self.save_session() if self.bot_token is None: if self.takeout: self.takeout_id = self.send(functions.account.InitTakeoutSession()).id log.warning("Takeout session {} initiated".format(self.takeout_id)) now = time.time() if abs(now - self.date) > Client.OFFLINE_SLEEP: self.peers_by_username = {} self.peers_by_phone = {} self.get_initial_dialogs() self.get_contacts() else: self.send(functions.messages.GetPinnedDialogs()) self.get_initial_dialogs_chunk() else: self.send(functions.updates.GetState()) except Exception as e: self.is_started = False self.session.stop() raise e for i in range(self.UPDATES_WORKERS): self.updates_workers_list.append( Thread( target=self.updates_worker, name="UpdatesWorker#{}".format(i + 1) ) ) self.updates_workers_list[-1].start() for i in range(self.DOWNLOAD_WORKERS): self.download_workers_list.append( Thread( target=self.download_worker, name="DownloadWorker#{}".format(i + 1) ) ) self.download_workers_list[-1].start() self.dispatcher.start() mimetypes.init() Syncer.add(self) return self def stop(self): """Use this method to manually stop the Client. Requires no parameters. Raises: ``ConnectionError`` in case you try to stop an already stopped Client. """ if not self.is_started: raise ConnectionError("Client is already stopped") if self.takeout_id: self.send(functions.account.FinishTakeoutSession()) log.warning("Takeout session {} finished".format(self.takeout_id)) Syncer.remove(self) self.dispatcher.stop() for _ in range(self.DOWNLOAD_WORKERS): self.download_queue.put(None) for i in self.download_workers_list: i.join() self.download_workers_list.clear() for _ in range(self.UPDATES_WORKERS): self.updates_queue.put(None) for i in self.updates_workers_list: i.join() self.updates_workers_list.clear() for i in self.media_sessions.values(): i.stop() self.media_sessions.clear() self.is_started = False self.session.stop() return self def restart(self): """Use this method to restart the Client. Requires no parameters. Raises: ``ConnectionError`` in case you try to restart a stopped Client. """ self.stop() self.start() def idle(self, stop_signals: tuple = (SIGINT, SIGTERM, SIGABRT)): """Blocks the program execution until one of the signals are received, then gently stop the Client by closing the underlying connection. Args: stop_signals (``tuple``, *optional*): Iterable containing signals the signal handler will listen to. Defaults to (SIGINT, SIGTERM, SIGABRT). """ def signal_handler(*args): self.is_idle = False for s in stop_signals: signal(s, signal_handler) self.is_idle = True while self.is_idle: time.sleep(1) self.stop() def run(self): """Use this method to automatically start and idle a Client. Requires no parameters. Raises: :class:`Error ` in case of a Telegram RPC error. """ self.start() self.idle() def add_handler(self, handler: Handler, group: int = 0): """Use this method to register an update handler. You can register multiple handlers, but at most one handler within a group will be used for a single update. To handle the same update more than once, register your handler using a different group id (lower group id == higher priority). Args: handler (``Handler``): The handler to be registered. group (``int``, *optional*): The group identifier, defaults to 0. Returns: A tuple of (handler, group) """ if isinstance(handler, DisconnectHandler): self.disconnect_handler = handler.callback else: self.dispatcher.add_handler(handler, group) return handler, group def remove_handler(self, handler: Handler, group: int = 0): """Removes a previously-added update handler. Make sure to provide the right group that the handler was added in. You can use the return value of the :meth:`add_handler` method, a tuple of (handler, group), and pass it directly. Args: handler (``Handler``): The handler to be removed. group (``int``, *optional*): The group identifier, defaults to 0. """ if isinstance(handler, DisconnectHandler): self.disconnect_handler = None else: self.dispatcher.remove_handler(handler, group) def stop_transmission(self): """Use this method to stop downloading or uploading a file. Must be called inside a progress callback function. """ raise Client.StopTransmission def authorize_bot(self): try: r = self.send( functions.auth.ImportBotAuthorization( flags=0, api_id=self.api_id, api_hash=self.api_hash, bot_auth_token=self.bot_token ) ) except UserMigrate as e: self.session.stop() self.dc_id = e.x self.auth_key = Auth(self.dc_id, self.test_mode, self.ipv6, self._proxy).create() self.session = Session( self, self.dc_id, self.auth_key ) self.session.start() self.authorize_bot() else: self.user_id = r.user.id print("Logged in successfully as @{}".format(r.user.username)) def authorize_user(self): phone_number_invalid_raises = self.phone_number is not None phone_code_invalid_raises = self.phone_code is not None password_invalid_raises = self.password is not None first_name_invalid_raises = self.first_name is not None def default_phone_number_callback(): while True: phone_number = input("Enter phone number: ") confirm = input("Is \"{}\" correct? (y/n): ".format(phone_number)) if confirm in ("y", "1"): return phone_number elif confirm in ("n", "2"): continue while True: self.phone_number = ( default_phone_number_callback() if self.phone_number is None else str(self.phone_number()) if callable(self.phone_number) else str(self.phone_number) ) self.phone_number = self.phone_number.strip("+") try: r = self.send( functions.auth.SendCode( self.phone_number, self.api_id, self.api_hash ) ) except (PhoneMigrate, NetworkMigrate) as e: self.session.stop() self.dc_id = e.x self.auth_key = Auth( self.dc_id, self.test_mode, self.ipv6, self._proxy ).create() self.session = Session( self, self.dc_id, self.auth_key ) self.session.start() except (PhoneNumberInvalid, PhoneNumberBanned) as e: if phone_number_invalid_raises: raise else: print(e.MESSAGE) self.phone_number = None except FloodWait as e: if phone_number_invalid_raises: raise else: print(e.MESSAGE.format(x=e.x)) time.sleep(e.x) except Exception as e: log.error(e, exc_info=True) raise else: break phone_registered = r.phone_registered phone_code_hash = r.phone_code_hash terms_of_service = r.terms_of_service if terms_of_service: print("\n" + terms_of_service.text + "\n") if self.force_sms: self.send( functions.auth.ResendCode( phone_number=self.phone_number, phone_code_hash=phone_code_hash ) ) while True: if not phone_registered: self.first_name = ( input("First name: ") if self.first_name is None else str(self.first_name()) if callable(self.first_name) else str(self.first_name) ) self.last_name = ( input("Last name: ") if self.last_name is None else str(self.last_name()) if callable(self.last_name) else str(self.last_name) ) self.phone_code = ( input("Enter phone code: ") if self.phone_code is None else str(self.phone_code(self.phone_number)) if callable(self.phone_code) else str(self.phone_code) ) try: if phone_registered: try: r = self.send( functions.auth.SignIn( self.phone_number, phone_code_hash, self.phone_code ) ) except PhoneNumberUnoccupied: log.warning("Phone number unregistered") phone_registered = False continue else: try: r = self.send( functions.auth.SignUp( self.phone_number, phone_code_hash, self.phone_code, self.first_name, self.last_name ) ) except PhoneNumberOccupied: log.warning("Phone number already registered") phone_registered = True continue except (PhoneCodeInvalid, PhoneCodeEmpty, PhoneCodeExpired, PhoneCodeHashEmpty) as e: if phone_code_invalid_raises: raise else: print(e.MESSAGE) self.phone_code = None except FirstnameInvalid as e: if first_name_invalid_raises: raise else: print(e.MESSAGE) self.first_name = None except SessionPasswordNeeded as e: print(e.MESSAGE) def default_password_callback(password_hint: str) -> str: print("Hint: {}".format(password_hint)) return input("Enter password (empty to recover): ") def default_recovery_callback(email_pattern: str) -> str: print("An e-mail containing the recovery code has been sent to {}".format(email_pattern)) return input("Enter password recovery code: ") while True: try: r = self.send(functions.account.GetPassword()) self.password = ( default_password_callback(r.hint) if self.password is None else str(self.password(r.hint) or "") if callable(self.password) else str(self.password) ) if self.password == "": r = self.send(functions.auth.RequestPasswordRecovery()) self.recovery_code = ( default_recovery_callback(r.email_pattern) if self.recovery_code is None else str(self.recovery_code(r.email_pattern)) if callable(self.recovery_code) else str(self.recovery_code) ) r = self.send( functions.auth.RecoverPassword( code=self.recovery_code ) ) else: r = self.send( functions.auth.CheckPassword( password=compute_check(r, self.password) ) ) except (PasswordEmpty, PasswordRecoveryNa, PasswordHashInvalid) as e: if password_invalid_raises: raise else: print(e.MESSAGE) self.password = None self.recovery_code = None except FloodWait as e: if password_invalid_raises: raise else: print(e.MESSAGE.format(x=e.x)) time.sleep(e.x) self.password = None self.recovery_code = None except Exception as e: log.error(e, exc_info=True) raise else: break break except FloodWait as e: if phone_code_invalid_raises or first_name_invalid_raises: raise else: print(e.MESSAGE.format(x=e.x)) time.sleep(e.x) except Exception as e: log.error(e, exc_info=True) raise else: break if terms_of_service: assert self.send(functions.help.AcceptTermsOfService(terms_of_service.id)) self.password = None self.user_id = r.user.id print("Logged in successfully as {}".format(r.user.first_name)) def fetch_peers(self, entities: List[Union[types.User, types.Chat, types.ChatForbidden, types.Channel, types.ChannelForbidden]]): for entity in entities: if isinstance(entity, types.User): user_id = entity.id access_hash = entity.access_hash if access_hash is None: continue username = entity.username phone = entity.phone input_peer = types.InputPeerUser( user_id=user_id, access_hash=access_hash ) self.peers_by_id[user_id] = input_peer if username is not None: self.peers_by_username[username.lower()] = input_peer if phone is not None: self.peers_by_phone[phone] = input_peer if isinstance(entity, (types.Chat, types.ChatForbidden)): chat_id = entity.id peer_id = -chat_id input_peer = types.InputPeerChat( chat_id=chat_id ) self.peers_by_id[peer_id] = input_peer if isinstance(entity, (types.Channel, types.ChannelForbidden)): channel_id = entity.id peer_id = int("-100" + str(channel_id)) access_hash = entity.access_hash if access_hash is None: continue username = getattr(entity, "username", None) input_peer = types.InputPeerChannel( channel_id=channel_id, access_hash=access_hash ) self.peers_by_id[peer_id] = input_peer if username is not None: self.peers_by_username[username.lower()] = input_peer def download_worker(self): name = threading.current_thread().name log.debug("{} started".format(name)) while True: media = self.download_queue.get() if media is None: break temp_file_path = "" final_file_path = "" try: media, file_name, done, progress, progress_args, path = media file_id = media.file_id size = media.file_size directory, file_name = os.path.split(file_name) directory = directory or "downloads" try: decoded = utils.decode(file_id) fmt = " 24 else " 24: volume_id = unpacked[4] secret = unpacked[5] local_id = unpacked[6] media_type_str = Client.MEDIA_TYPE_ID.get(media_type, None) if media_type_str is None: raise FileIdInvalid("Unknown media type: {}".format(unpacked[0])) file_name = file_name or getattr(media, "file_name", None) if not file_name: if media_type == 3: extension = ".ogg" elif media_type in (4, 10, 13): extension = mimetypes.guess_extension(media.mime_type) or ".mp4" elif media_type == 5: extension = mimetypes.guess_extension(media.mime_type) or ".unknown" elif media_type == 8: extension = ".webp" elif media_type == 9: extension = mimetypes.guess_extension(media.mime_type) or ".mp3" elif media_type in (0, 1, 2): extension = ".jpg" else: continue file_name = "{}_{}_{}{}".format( media_type_str, datetime.fromtimestamp( getattr(media, "date", None) or time.time() ).strftime("%Y-%m-%d_%H-%M-%S"), self.rnd_id(), extension ) temp_file_path = self.get_file( dc_id=dc_id, id=id, access_hash=access_hash, volume_id=volume_id, local_id=local_id, secret=secret, size=size, progress=progress, progress_args=progress_args ) if temp_file_path: final_file_path = os.path.abspath(re.sub("\\\\", "/", os.path.join(directory, file_name))) os.makedirs(directory, exist_ok=True) shutil.move(temp_file_path, final_file_path) except Exception as e: log.error(e, exc_info=True) try: os.remove(temp_file_path) except OSError: pass else: # TODO: "" or None for faulty download, which is better? # os.path methods return "" in case something does not exist, I prefer this. # For now let's keep None path[0] = final_file_path or None finally: done.set() log.debug("{} stopped".format(name)) def updates_worker(self): name = threading.current_thread().name log.debug("{} started".format(name)) while True: updates = self.updates_queue.get() if updates is None: break try: if isinstance(updates, (types.Update, types.UpdatesCombined)): self.fetch_peers(updates.users) self.fetch_peers(updates.chats) for update in updates.updates: channel_id = getattr( getattr( getattr( update, "message", None ), "to_id", None ), "channel_id", None ) or getattr(update, "channel_id", None) pts = getattr(update, "pts", None) pts_count = getattr(update, "pts_count", None) if isinstance(update, types.UpdateChannelTooLong): log.warning(update) if isinstance(update, types.UpdateNewChannelMessage): message = update.message if not isinstance(message, types.MessageEmpty): try: diff = self.send( functions.updates.GetChannelDifference( channel=self.resolve_peer(int("-100" + str(channel_id))), filter=types.ChannelMessagesFilter( ranges=[types.MessageRange( min_id=update.message.id, max_id=update.message.id )] ), pts=pts - pts_count, limit=pts ) ) except ChannelPrivate: pass else: if not isinstance(diff, types.updates.ChannelDifferenceEmpty): updates.users += diff.users updates.chats += diff.chats if channel_id and pts: if channel_id not in self.channels_pts: self.channels_pts[channel_id] = [] if pts in self.channels_pts[channel_id]: continue self.channels_pts[channel_id].append(pts) if len(self.channels_pts[channel_id]) > 50: self.channels_pts[channel_id] = self.channels_pts[channel_id][25:] self.dispatcher.updates_queue.put((update, updates.users, updates.chats)) elif isinstance(updates, (types.UpdateShortMessage, types.UpdateShortChatMessage)): diff = self.send( functions.updates.GetDifference( pts=updates.pts - updates.pts_count, date=updates.date, qts=-1 ) ) if diff.new_messages: self.dispatcher.updates_queue.put(( types.UpdateNewMessage( message=diff.new_messages[0], pts=updates.pts, pts_count=updates.pts_count ), diff.users, diff.chats )) else: self.dispatcher.updates_queue.put((diff.other_updates[0], [], [])) elif isinstance(updates, types.UpdateShort): self.dispatcher.updates_queue.put((updates.update, [], [])) elif isinstance(updates, types.UpdatesTooLong): log.warning(updates) except Exception as e: log.error(e, exc_info=True) log.debug("{} stopped".format(name)) def send(self, data: Object, retries: int = Session.MAX_RETRIES, timeout: float = Session.WAIT_TIMEOUT): """Use this method to send Raw Function queries. This method makes possible to manually call every single Telegram API method in a low-level manner. Available functions are listed in the :obj:`functions ` package and may accept compound data types from :obj:`types ` as well as bare types such as ``int``, ``str``, etc... Args: data (``Object``): The API Schema function filled with proper arguments. retries (``int``): Number of retries. timeout (``float``): Timeout in seconds. Raises: :class:`Error ` in case of a Telegram RPC error. """ if not self.is_started: raise ConnectionError("Client has not been started") if self.no_updates: data = functions.InvokeWithoutUpdates(data) if self.takeout_id: data = functions.InvokeWithTakeout(self.takeout_id, data) r = self.session.send(data, retries, timeout) self.fetch_peers(getattr(r, "users", [])) self.fetch_peers(getattr(r, "chats", [])) return r def load_config(self): parser = ConfigParser() parser.read(self.config_file) if self.api_id and self.api_hash: pass else: if parser.has_section("pyrogram"): self.api_id = parser.getint("pyrogram", "api_id") self.api_hash = parser.get("pyrogram", "api_hash") else: raise AttributeError( "No API Key found. " "More info: https://docs.pyrogram.ml/start/ProjectSetup#configuration" ) for option in ["app_version", "device_model", "system_version", "lang_code"]: if getattr(self, option): pass else: if parser.has_section("pyrogram"): setattr(self, option, parser.get( "pyrogram", option, fallback=getattr(Client, option.upper()) )) else: setattr(self, option, getattr(Client, option.upper())) if self._proxy: self._proxy["enabled"] = bool(self._proxy.get("enabled", True)) else: self._proxy = {} if parser.has_section("proxy"): self._proxy["enabled"] = parser.getboolean("proxy", "enabled", fallback=True) self._proxy["hostname"] = parser.get("proxy", "hostname") self._proxy["port"] = parser.getint("proxy", "port") self._proxy["username"] = parser.get("proxy", "username", fallback=None) or None self._proxy["password"] = parser.get("proxy", "password", fallback=None) or None def load_session(self): try: with open(os.path.join(self.workdir, "{}.session".format(self.session_name)), encoding="utf-8") as f: s = json.load(f) except FileNotFoundError: self.dc_id = 1 self.date = 0 self.auth_key = Auth(self.dc_id, self.test_mode, self.ipv6, self._proxy).create() else: self.dc_id = s["dc_id"] self.test_mode = s["test_mode"] self.auth_key = base64.b64decode("".join(s["auth_key"])) self.user_id = s["user_id"] self.date = s.get("date", 0) for k, v in s.get("peers_by_id", {}).items(): self.peers_by_id[int(k)] = utils.get_input_peer(int(k), v) for k, v in s.get("peers_by_username", {}).items(): peer = self.peers_by_id.get(v, None) if peer: self.peers_by_username[k] = peer for k, v in s.get("peers_by_phone", {}).items(): peer = self.peers_by_id.get(v, None) if peer: self.peers_by_phone[k] = peer def load_plugins(self): if self.plugins_dir is not None: plugins_count = 0 for path in Path(self.plugins_dir).rglob("*.py"): file_path = os.path.splitext(str(path))[0] import_path = [] while file_path: file_path, tail = os.path.split(file_path) import_path.insert(0, tail) import_path = ".".join(import_path) module = import_module(import_path) for name in dir(module): # noinspection PyBroadException try: handler, group = getattr(module, name) if isinstance(handler, Handler) and isinstance(group, int): self.add_handler(handler, group) log.info('{}("{}") from "{}" loaded in group {}'.format( type(handler).__name__, name, import_path, group)) plugins_count += 1 except Exception: pass if plugins_count > 0: log.warning('Successfully loaded {} plugin{} from "{}"'.format( plugins_count, "s" if plugins_count > 1 else "", self.plugins_dir )) else: log.warning('No plugin loaded: "{}" doesn\'t contain any valid plugin'.format(self.plugins_dir)) def save_session(self): auth_key = base64.b64encode(self.auth_key).decode() auth_key = [auth_key[i: i + 43] for i in range(0, len(auth_key), 43)] os.makedirs(self.workdir, exist_ok=True) with open(os.path.join(self.workdir, "{}.session".format(self.session_name)), "w", encoding="utf-8") as f: json.dump( dict( dc_id=self.dc_id, test_mode=self.test_mode, auth_key=auth_key, user_id=self.user_id, date=self.date ), f, indent=4 ) def get_initial_dialogs_chunk(self, offset_date: int = 0): while True: try: r = self.send( functions.messages.GetDialogs( offset_date=offset_date, offset_id=0, offset_peer=types.InputPeerEmpty(), limit=self.DIALOGS_AT_ONCE, hash=0, exclude_pinned=True ) ) except FloodWait as e: log.warning("get_dialogs flood: waiting {} seconds".format(e.x)) time.sleep(e.x) else: log.info("Total peers: {}".format(len(self.peers_by_id))) return r def get_initial_dialogs(self): self.send(functions.messages.GetPinnedDialogs()) dialogs = self.get_initial_dialogs_chunk() offset_date = utils.get_offset_date(dialogs) while len(dialogs.dialogs) == self.DIALOGS_AT_ONCE: dialogs = self.get_initial_dialogs_chunk(offset_date) offset_date = utils.get_offset_date(dialogs) self.get_initial_dialogs_chunk() def resolve_peer(self, peer_id: Union[int, str]): """Use this method to get the InputPeer of a known peer_id. This is a utility method intended to be used **only** when working with Raw Functions (i.e: a Telegram API method you wish to use which is not available yet in the Client class as an easy-to-use method), whenever an InputPeer type is required. Args: peer_id (``int`` | ``str``): The peer id you want to extract the InputPeer from. Can be a direct id (int), a username (str) or a phone number (str). Returns: On success, the resolved peer id is returned in form of an InputPeer object. Raises: :class:`Error ` in case of a Telegram RPC error. ``KeyError`` in case the peer doesn't exist in the internal database. """ try: return self.peers_by_id[peer_id] except KeyError: if type(peer_id) is str: if peer_id in ("self", "me"): return types.InputPeerSelf() peer_id = re.sub(r"[@+\s]", "", peer_id.lower()) try: int(peer_id) except ValueError: if peer_id not in self.peers_by_username: self.send( functions.contacts.ResolveUsername( username=peer_id ) ) return self.peers_by_username[peer_id] else: try: return self.peers_by_phone[peer_id] except KeyError: raise PeerIdInvalid if peer_id > 0: self.fetch_peers( self.send( functions.users.GetUsers( id=[types.InputUser(peer_id, 0)] ) ) ) else: if str(peer_id).startswith("-100"): self.send( functions.channels.GetChannels( id=[types.InputChannel(int(str(peer_id)[4:]), 0)] ) ) else: self.send( functions.messages.GetChats( id=[-peer_id] ) ) try: return self.peers_by_id[peer_id] except KeyError: raise PeerIdInvalid def save_file(self, path: str, file_id: int = None, file_part: int = 0, progress: callable = None, progress_args: tuple = ()): """Use this method to upload a file onto Telegram servers, without actually sending the message to anyone. This is a utility method intended to be used **only** when working with Raw Functions (i.e: a Telegram API method you wish to use which is not available yet in the Client class as an easy-to-use method), whenever an InputFile type is required. Args: path (``str``): The path of the file you want to upload that exists on your local machine. file_id (``int``, *optional*): In case a file part expired, pass the file_id and the file_part to retry uploading that specific chunk. file_part (``int``, *optional*): In case a file part expired, pass the file_id and the file_part to retry uploading that specific chunk. progress (``callable``, *optional*): Pass a callback function to view the upload progress. The function must take *(client, current, total, \*args)* as positional arguments (look at the section below for a detailed description). progress_args (``tuple``, *optional*): Extra custom arguments for the progress callback function. Useful, for example, if you want to pass a chat_id and a message_id in order to edit a message with the updated progress. Other Parameters: client (:obj:`Client `): The Client itself, useful when you want to call other API methods inside the callback function. current (``int``): The amount of bytes uploaded so far. total (``int``): The size of the file. *args (``tuple``, *optional*): Extra custom arguments as defined in the *progress_args* parameter. You can either keep *\*args* or add every single extra argument in your function signature. Returns: On success, the uploaded file is returned in form of an InputFile object. Raises: :class:`Error ` in case of a Telegram RPC error. """ part_size = 512 * 1024 file_size = os.path.getsize(path) if file_size == 0: raise ValueError("File size equals to 0 B") if file_size > 1500 * 1024 * 1024: raise ValueError("Telegram doesn't support uploading files bigger than 1500 MiB") file_total_parts = int(math.ceil(file_size / part_size)) is_big = True if file_size > 10 * 1024 * 1024 else False is_missing_part = True if file_id is not None else False file_id = file_id or self.rnd_id() md5_sum = md5() if not is_big and not is_missing_part else None session = Session(self, self.dc_id, self.auth_key, is_media=True) session.start() try: with open(path, "rb") as f: f.seek(part_size * file_part) while True: chunk = f.read(part_size) if not chunk: if not is_big: md5_sum = "".join([hex(i)[2:].zfill(2) for i in md5_sum.digest()]) break if is_big: rpc = functions.upload.SaveBigFilePart( file_id=file_id, file_part=file_part, file_total_parts=file_total_parts, bytes=chunk ) else: rpc = functions.upload.SaveFilePart( file_id=file_id, file_part=file_part, bytes=chunk ) assert session.send(rpc), "Couldn't upload file" if is_missing_part: return if not is_big: md5_sum.update(chunk) file_part += 1 if progress: progress(self, min(file_part * part_size, file_size), file_size, *progress_args) except Client.StopTransmission: raise except Exception as e: log.error(e, exc_info=True) else: if is_big: return types.InputFileBig( id=file_id, parts=file_total_parts, name=os.path.basename(path), ) else: return types.InputFile( id=file_id, parts=file_total_parts, name=os.path.basename(path), md5_checksum=md5_sum ) finally: session.stop() def get_file(self, dc_id: int, id: int = None, access_hash: int = None, volume_id: int = None, local_id: int = None, secret: int = None, size: int = None, progress: callable = None, progress_args: tuple = ()) -> str: with self.media_sessions_lock: session = self.media_sessions.get(dc_id, None) if session is None: if dc_id != self.dc_id: exported_auth = self.send( functions.auth.ExportAuthorization( dc_id=dc_id ) ) session = Session( self, dc_id, Auth(dc_id, self.test_mode, self.ipv6, self._proxy).create(), is_media=True ) session.start() self.media_sessions[dc_id] = session session.send( functions.auth.ImportAuthorization( id=exported_auth.id, bytes=exported_auth.bytes ) ) else: session = Session( self, dc_id, self.auth_key, is_media=True ) session.start() self.media_sessions[dc_id] = session if volume_id: # Photos are accessed by volume_id, local_id, secret location = types.InputFileLocation( volume_id=volume_id, local_id=local_id, secret=secret, file_reference=b"" ) else: # Any other file can be more easily accessed by id and access_hash location = types.InputDocumentFileLocation( id=id, access_hash=access_hash, file_reference=b"" ) limit = 1024 * 1024 offset = 0 file_name = "" try: r = session.send( functions.upload.GetFile( location=location, offset=offset, limit=limit ) ) if isinstance(r, types.upload.File): with tempfile.NamedTemporaryFile("wb", delete=False) as f: file_name = f.name while True: chunk = r.bytes if not chunk: break f.write(chunk) offset += limit if progress: progress(self, min(offset, size) if size != 0 else offset, size, *progress_args) r = session.send( functions.upload.GetFile( location=location, offset=offset, limit=limit ) ) elif isinstance(r, types.upload.FileCdnRedirect): with self.media_sessions_lock: cdn_session = self.media_sessions.get(r.dc_id, None) if cdn_session is None: cdn_session = Session( self, r.dc_id, Auth(r.dc_id, self.test_mode, self.ipv6, self._proxy).create(), is_media=True, is_cdn=True ) cdn_session.start() self.media_sessions[r.dc_id] = cdn_session try: with tempfile.NamedTemporaryFile("wb", delete=False) as f: file_name = f.name while True: r2 = cdn_session.send( functions.upload.GetCdnFile( file_token=r.file_token, offset=offset, limit=limit ) ) if isinstance(r2, types.upload.CdnFileReuploadNeeded): try: session.send( functions.upload.ReuploadCdnFile( file_token=r.file_token, request_token=r2.request_token ) ) except VolumeLocNotFound: break else: continue chunk = r2.bytes # https://core.telegram.org/cdn#decrypting-files decrypted_chunk = AES.ctr256_decrypt( chunk, r.encryption_key, bytearray( r.encryption_iv[:-4] + (offset // 16).to_bytes(4, "big") ) ) hashes = session.send( functions.upload.GetCdnFileHashes( r.file_token, offset ) ) # https://core.telegram.org/cdn#verifying-files for i, h in enumerate(hashes): cdn_chunk = decrypted_chunk[h.limit * i: h.limit * (i + 1)] assert h.hash == sha256(cdn_chunk).digest(), "Invalid CDN hash part {}".format(i) f.write(decrypted_chunk) offset += limit if progress: progress(self, min(offset, size) if size != 0 else offset, size, *progress_args) if len(chunk) < limit: break except Exception as e: raise e except Exception as e: if not isinstance(e, Client.StopTransmission): log.error(e, exc_info=True) try: os.remove(file_name) except OSError: pass return "" else: return file_name \ No newline at end of file From 3d16a715ad5fd78734a48295cab9940343436667 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Wed, 16 Jan 2019 15:46:46 +0100 Subject: [PATCH 03/22] Fix file using wrong line separator --- pyrogram/client/client.py | 1592 ++++++++++++++++++++++++++++++++++++- 1 file changed, 1591 insertions(+), 1 deletion(-) diff --git a/pyrogram/client/client.py b/pyrogram/client/client.py index 5d5bf945..576dea9a 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -1 +1,1591 @@ -# Pyrogram - Telegram MTProto API Client Library for Python # Copyright (C) 2017-2019 Dan Tès # # This file is part of Pyrogram. # # Pyrogram is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Pyrogram is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with Pyrogram. If not, see . import base64 import binascii import json import logging import math import mimetypes import os import re import shutil import struct import tempfile import threading import time from configparser import ConfigParser from datetime import datetime from hashlib import sha256, md5 from importlib import import_module from pathlib import Path from signal import signal, SIGINT, SIGTERM, SIGABRT from threading import Thread from typing import Union, List from pyrogram.api import functions, types from pyrogram.api.core import Object from pyrogram.api.errors import ( PhoneMigrate, NetworkMigrate, PhoneNumberInvalid, PhoneNumberUnoccupied, PhoneCodeInvalid, PhoneCodeHashEmpty, PhoneCodeExpired, PhoneCodeEmpty, SessionPasswordNeeded, PasswordHashInvalid, FloodWait, PeerIdInvalid, FirstnameInvalid, PhoneNumberBanned, VolumeLocNotFound, UserMigrate, FileIdInvalid, ChannelPrivate, PhoneNumberOccupied, PasswordRecoveryNa, PasswordEmpty ) from pyrogram.client.handlers import DisconnectHandler from pyrogram.client.handlers.handler import Handler from pyrogram.client.methods.password.utils import compute_check from pyrogram.crypto import AES from pyrogram.session import Auth, Session from .dispatcher import Dispatcher from .ext import utils, Syncer, BaseClient from .methods import Methods log = logging.getLogger(__name__) class Client(Methods, BaseClient): """This class represents a Client, the main mean for interacting with Telegram. It exposes bot-like methods for an easy access to the API as well as a simple way to invoke every single Telegram API method available. Args: session_name (``str``): Name to uniquely identify a session of either a User or a Bot. For Users: pass a string of your choice, e.g.: "my_main_account". For Bots: pass your Bot API token, e.g.: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" Note: as long as a valid User session file exists, Pyrogram won't ask you again to input your phone number. api_id (``int``, *optional*): The *api_id* part of your Telegram API Key, as integer. E.g.: 12345 This is an alternative way to pass it if you don't want to use the *config.ini* file. api_hash (``str``, *optional*): The *api_hash* part of your Telegram API Key, as string. E.g.: "0123456789abcdef0123456789abcdef" This is an alternative way to pass it if you don't want to use the *config.ini* file. app_version (``str``, *optional*): Application version. Defaults to "Pyrogram \U0001f525 vX.Y.Z" This is an alternative way to set it if you don't want to use the *config.ini* file. device_model (``str``, *optional*): Device model. Defaults to *platform.python_implementation() + " " + platform.python_version()* This is an alternative way to set it if you don't want to use the *config.ini* file. system_version (``str``, *optional*): Operating System version. Defaults to *platform.system() + " " + platform.release()* This is an alternative way to set it if you don't want to use the *config.ini* file. lang_code (``str``, *optional*): Code of the language used on the client, in ISO 639-1 standard. Defaults to "en". This is an alternative way to set it if you don't want to use the *config.ini* file. ipv6 (``bool``, *optional*): Pass True to connect to Telegram using IPv6. Defaults to False (IPv4). proxy (``dict``, *optional*): Your SOCKS5 Proxy settings as dict, e.g.: *dict(hostname="11.22.33.44", port=1080, username="user", password="pass")*. *username* and *password* can be omitted if your proxy doesn't require authorization. This is an alternative way to setup a proxy if you don't want to use the *config.ini* file. test_mode (``bool``, *optional*): Enable or disable log-in to testing servers. Defaults to False. Only applicable for new sessions and will be ignored in case previously created sessions are loaded. phone_number (``str`` | ``callable``, *optional*): Pass your phone number as string (with your Country Code prefix included) to avoid entering it manually. Or pass a callback function which accepts no arguments and must return the correct phone number as string (e.g., "391234567890"). Only applicable for new sessions. phone_code (``str`` | ``callable``, *optional*): Pass the phone code as string (for test numbers only) to avoid entering it manually. Or pass a callback function which accepts a single positional argument *(phone_number)* and must return the correct phone code as string (e.g., "12345"). Only applicable for new sessions. password (``str``, *optional*): Pass your Two-Step Verification password as string (if you have one) to avoid entering it manually. Or pass a callback function which accepts a single positional argument *(password_hint)* and must return the correct password as string (e.g., "password"). Only applicable for new sessions. recovery_code (``callable``, *optional*): Pass a callback function which accepts a single positional argument *(email_pattern)* and must return the correct password recovery code as string (e.g., "987654"). Only applicable for new sessions. force_sms (``str``, *optional*): Pass True to force Telegram sending the authorization code via SMS. Only applicable for new sessions. first_name (``str``, *optional*): Pass a First Name to avoid entering it manually. It will be used to automatically create a new Telegram account in case the phone number you passed is not registered yet. Only applicable for new sessions. last_name (``str``, *optional*): Same purpose as *first_name*; pass a Last Name to avoid entering it manually. It can be an empty string: "". Only applicable for new sessions. workers (``int``, *optional*): Thread pool size for handling incoming updates. Defaults to 4. workdir (``str``, *optional*): Define a custom working directory. The working directory is the location in your filesystem where Pyrogram will store your session files. Defaults to "." (current directory). config_file (``str``, *optional*): Path of the configuration file. Defaults to ./config.ini plugins_dir (``str``, *optional*): Define a custom directory for your plugins. The plugins directory is the location in your filesystem where Pyrogram will automatically load your update handlers. Defaults to None (plugins disabled). no_updates (``bool``, *optional*): Pass True to completely disable incoming updates for the current session. When updates are disabled your client can't receive any new message. Useful for batch programs that don't need to deal with updates. Defaults to False (updates enabled and always received). takeout (``bool``, *optional*): Pass True to let the client use a takeout session instead of a normal one, implies no_updates. Useful for exporting your Telegram data. Methods invoked inside a takeout session (such as get_history, download_media, ...) are less prone to throw FloodWait exceptions. Only available for users, bots will ignore this parameter. Defaults to False (normal session). """ def __init__(self, session_name: str, api_id: Union[int, str] = None, api_hash: str = None, app_version: str = None, device_model: str = None, system_version: str = None, lang_code: str = None, ipv6: bool = False, proxy: dict = None, test_mode: bool = False, phone_number: str = None, phone_code: Union[str, callable] = None, password: str = None, recovery_code: callable = None, force_sms: bool = False, first_name: str = None, last_name: str = None, workers: int = BaseClient.WORKERS, workdir: str = BaseClient.WORKDIR, config_file: str = BaseClient.CONFIG_FILE, plugins_dir: str = None, no_updates: bool = None, takeout: bool = None): super().__init__() self.session_name = session_name self.api_id = int(api_id) if api_id else None self.api_hash = api_hash self.app_version = app_version self.device_model = device_model self.system_version = system_version self.lang_code = lang_code self.ipv6 = ipv6 # TODO: Make code consistent, use underscore for private/protected fields self._proxy = proxy self.test_mode = test_mode self.phone_number = phone_number self.phone_code = phone_code self.password = password self.recovery_code = recovery_code self.force_sms = force_sms self.first_name = first_name self.last_name = last_name self.workers = workers self.workdir = workdir self.config_file = config_file self.plugins_dir = plugins_dir self.no_updates = no_updates self.takeout = takeout self.dispatcher = Dispatcher(self, workers) def __enter__(self): return self.start() def __exit__(self, *args): self.stop() @property def proxy(self): return self._proxy @proxy.setter def proxy(self, value): if value is None: self._proxy = None return if self._proxy is None: self._proxy = {} self._proxy["enabled"] = bool(value.get("enabled", True)) self._proxy.update(value) def start(self): """Use this method to start the Client after creating it. Requires no parameters. Raises: :class:`Error ` in case of a Telegram RPC error. ``ConnectionError`` in case you try to start an already started Client. """ if self.is_started: raise ConnectionError("Client has already been started") if self.BOT_TOKEN_RE.match(self.session_name): self.bot_token = self.session_name self.session_name = self.session_name.split(":")[0] self.load_config() self.load_session() self.load_plugins() self.session = Session( self, self.dc_id, self.auth_key ) self.session.start() self.is_started = True try: if self.user_id is None: if self.bot_token is None: self.authorize_user() else: self.authorize_bot() self.save_session() if self.bot_token is None: if self.takeout: self.takeout_id = self.send(functions.account.InitTakeoutSession()).id log.warning("Takeout session {} initiated".format(self.takeout_id)) now = time.time() if abs(now - self.date) > Client.OFFLINE_SLEEP: self.peers_by_username = {} self.peers_by_phone = {} self.get_initial_dialogs() self.get_contacts() else: self.send(functions.messages.GetPinnedDialogs()) self.get_initial_dialogs_chunk() else: self.send(functions.updates.GetState()) except Exception as e: self.is_started = False self.session.stop() raise e for i in range(self.UPDATES_WORKERS): self.updates_workers_list.append( Thread( target=self.updates_worker, name="UpdatesWorker#{}".format(i + 1) ) ) self.updates_workers_list[-1].start() for i in range(self.DOWNLOAD_WORKERS): self.download_workers_list.append( Thread( target=self.download_worker, name="DownloadWorker#{}".format(i + 1) ) ) self.download_workers_list[-1].start() self.dispatcher.start() mimetypes.init() Syncer.add(self) return self def stop(self): """Use this method to manually stop the Client. Requires no parameters. Raises: ``ConnectionError`` in case you try to stop an already stopped Client. """ if not self.is_started: raise ConnectionError("Client is already stopped") if self.takeout_id: self.send(functions.account.FinishTakeoutSession()) log.warning("Takeout session {} finished".format(self.takeout_id)) Syncer.remove(self) self.dispatcher.stop() for _ in range(self.DOWNLOAD_WORKERS): self.download_queue.put(None) for i in self.download_workers_list: i.join() self.download_workers_list.clear() for _ in range(self.UPDATES_WORKERS): self.updates_queue.put(None) for i in self.updates_workers_list: i.join() self.updates_workers_list.clear() for i in self.media_sessions.values(): i.stop() self.media_sessions.clear() self.is_started = False self.session.stop() return self def restart(self): """Use this method to restart the Client. Requires no parameters. Raises: ``ConnectionError`` in case you try to restart a stopped Client. """ self.stop() self.start() def idle(self, stop_signals: tuple = (SIGINT, SIGTERM, SIGABRT)): """Blocks the program execution until one of the signals are received, then gently stop the Client by closing the underlying connection. Args: stop_signals (``tuple``, *optional*): Iterable containing signals the signal handler will listen to. Defaults to (SIGINT, SIGTERM, SIGABRT). """ def signal_handler(*args): self.is_idle = False for s in stop_signals: signal(s, signal_handler) self.is_idle = True while self.is_idle: time.sleep(1) self.stop() def run(self): """Use this method to automatically start and idle a Client. Requires no parameters. Raises: :class:`Error ` in case of a Telegram RPC error. """ self.start() self.idle() def add_handler(self, handler: Handler, group: int = 0): """Use this method to register an update handler. You can register multiple handlers, but at most one handler within a group will be used for a single update. To handle the same update more than once, register your handler using a different group id (lower group id == higher priority). Args: handler (``Handler``): The handler to be registered. group (``int``, *optional*): The group identifier, defaults to 0. Returns: A tuple of (handler, group) """ if isinstance(handler, DisconnectHandler): self.disconnect_handler = handler.callback else: self.dispatcher.add_handler(handler, group) return handler, group def remove_handler(self, handler: Handler, group: int = 0): """Removes a previously-added update handler. Make sure to provide the right group that the handler was added in. You can use the return value of the :meth:`add_handler` method, a tuple of (handler, group), and pass it directly. Args: handler (``Handler``): The handler to be removed. group (``int``, *optional*): The group identifier, defaults to 0. """ if isinstance(handler, DisconnectHandler): self.disconnect_handler = None else: self.dispatcher.remove_handler(handler, group) def stop_transmission(self): """Use this method to stop downloading or uploading a file. Must be called inside a progress callback function. """ raise Client.StopTransmission def authorize_bot(self): try: r = self.send( functions.auth.ImportBotAuthorization( flags=0, api_id=self.api_id, api_hash=self.api_hash, bot_auth_token=self.bot_token ) ) except UserMigrate as e: self.session.stop() self.dc_id = e.x self.auth_key = Auth(self.dc_id, self.test_mode, self.ipv6, self._proxy).create() self.session = Session( self, self.dc_id, self.auth_key ) self.session.start() self.authorize_bot() else: self.user_id = r.user.id print("Logged in successfully as @{}".format(r.user.username)) def authorize_user(self): phone_number_invalid_raises = self.phone_number is not None phone_code_invalid_raises = self.phone_code is not None password_invalid_raises = self.password is not None first_name_invalid_raises = self.first_name is not None def default_phone_number_callback(): while True: phone_number = input("Enter phone number: ") confirm = input("Is \"{}\" correct? (y/n): ".format(phone_number)) if confirm in ("y", "1"): return phone_number elif confirm in ("n", "2"): continue while True: self.phone_number = ( default_phone_number_callback() if self.phone_number is None else str(self.phone_number()) if callable(self.phone_number) else str(self.phone_number) ) self.phone_number = self.phone_number.strip("+") try: r = self.send( functions.auth.SendCode( self.phone_number, self.api_id, self.api_hash ) ) except (PhoneMigrate, NetworkMigrate) as e: self.session.stop() self.dc_id = e.x self.auth_key = Auth( self.dc_id, self.test_mode, self.ipv6, self._proxy ).create() self.session = Session( self, self.dc_id, self.auth_key ) self.session.start() except (PhoneNumberInvalid, PhoneNumberBanned) as e: if phone_number_invalid_raises: raise else: print(e.MESSAGE) self.phone_number = None except FloodWait as e: if phone_number_invalid_raises: raise else: print(e.MESSAGE.format(x=e.x)) time.sleep(e.x) except Exception as e: log.error(e, exc_info=True) raise else: break phone_registered = r.phone_registered phone_code_hash = r.phone_code_hash terms_of_service = r.terms_of_service if terms_of_service: print("\n" + terms_of_service.text + "\n") if self.force_sms: self.send( functions.auth.ResendCode( phone_number=self.phone_number, phone_code_hash=phone_code_hash ) ) while True: if not phone_registered: self.first_name = ( input("First name: ") if self.first_name is None else str(self.first_name()) if callable(self.first_name) else str(self.first_name) ) self.last_name = ( input("Last name: ") if self.last_name is None else str(self.last_name()) if callable(self.last_name) else str(self.last_name) ) self.phone_code = ( input("Enter phone code: ") if self.phone_code is None else str(self.phone_code(self.phone_number)) if callable(self.phone_code) else str(self.phone_code) ) try: if phone_registered: try: r = self.send( functions.auth.SignIn( self.phone_number, phone_code_hash, self.phone_code ) ) except PhoneNumberUnoccupied: log.warning("Phone number unregistered") phone_registered = False continue else: try: r = self.send( functions.auth.SignUp( self.phone_number, phone_code_hash, self.phone_code, self.first_name, self.last_name ) ) except PhoneNumberOccupied: log.warning("Phone number already registered") phone_registered = True continue except (PhoneCodeInvalid, PhoneCodeEmpty, PhoneCodeExpired, PhoneCodeHashEmpty) as e: if phone_code_invalid_raises: raise else: print(e.MESSAGE) self.phone_code = None except FirstnameInvalid as e: if first_name_invalid_raises: raise else: print(e.MESSAGE) self.first_name = None except SessionPasswordNeeded as e: print(e.MESSAGE) def default_password_callback(password_hint: str) -> str: print("Hint: {}".format(password_hint)) return input("Enter password (empty to recover): ") def default_recovery_callback(email_pattern: str) -> str: print("An e-mail containing the recovery code has been sent to {}".format(email_pattern)) return input("Enter password recovery code: ") while True: try: r = self.send(functions.account.GetPassword()) self.password = ( default_password_callback(r.hint) if self.password is None else str(self.password(r.hint) or "") if callable(self.password) else str(self.password) ) if self.password == "": r = self.send(functions.auth.RequestPasswordRecovery()) self.recovery_code = ( default_recovery_callback(r.email_pattern) if self.recovery_code is None else str(self.recovery_code(r.email_pattern)) if callable(self.recovery_code) else str(self.recovery_code) ) r = self.send( functions.auth.RecoverPassword( code=self.recovery_code ) ) else: r = self.send( functions.auth.CheckPassword( password=compute_check(r, self.password) ) ) except (PasswordEmpty, PasswordRecoveryNa, PasswordHashInvalid) as e: if password_invalid_raises: raise else: print(e.MESSAGE) self.password = None self.recovery_code = None except FloodWait as e: if password_invalid_raises: raise else: print(e.MESSAGE.format(x=e.x)) time.sleep(e.x) self.password = None self.recovery_code = None except Exception as e: log.error(e, exc_info=True) raise else: break break except FloodWait as e: if phone_code_invalid_raises or first_name_invalid_raises: raise else: print(e.MESSAGE.format(x=e.x)) time.sleep(e.x) except Exception as e: log.error(e, exc_info=True) raise else: break if terms_of_service: assert self.send(functions.help.AcceptTermsOfService(terms_of_service.id)) self.password = None self.user_id = r.user.id print("Logged in successfully as {}".format(r.user.first_name)) def fetch_peers(self, entities: List[Union[types.User, types.Chat, types.ChatForbidden, types.Channel, types.ChannelForbidden]]): for entity in entities: if isinstance(entity, types.User): user_id = entity.id access_hash = entity.access_hash if access_hash is None: continue username = entity.username phone = entity.phone input_peer = types.InputPeerUser( user_id=user_id, access_hash=access_hash ) self.peers_by_id[user_id] = input_peer if username is not None: self.peers_by_username[username.lower()] = input_peer if phone is not None: self.peers_by_phone[phone] = input_peer if isinstance(entity, (types.Chat, types.ChatForbidden)): chat_id = entity.id peer_id = -chat_id input_peer = types.InputPeerChat( chat_id=chat_id ) self.peers_by_id[peer_id] = input_peer if isinstance(entity, (types.Channel, types.ChannelForbidden)): channel_id = entity.id peer_id = int("-100" + str(channel_id)) access_hash = entity.access_hash if access_hash is None: continue username = getattr(entity, "username", None) input_peer = types.InputPeerChannel( channel_id=channel_id, access_hash=access_hash ) self.peers_by_id[peer_id] = input_peer if username is not None: self.peers_by_username[username.lower()] = input_peer def download_worker(self): name = threading.current_thread().name log.debug("{} started".format(name)) while True: media = self.download_queue.get() if media is None: break temp_file_path = "" final_file_path = "" try: media, file_name, done, progress, progress_args, path = media file_id = media.file_id size = media.file_size directory, file_name = os.path.split(file_name) directory = directory or "downloads" try: decoded = utils.decode(file_id) fmt = " 24 else " 24: volume_id = unpacked[4] secret = unpacked[5] local_id = unpacked[6] media_type_str = Client.MEDIA_TYPE_ID.get(media_type, None) if media_type_str is None: raise FileIdInvalid("Unknown media type: {}".format(unpacked[0])) file_name = file_name or getattr(media, "file_name", None) if not file_name: if media_type == 3: extension = ".ogg" elif media_type in (4, 10, 13): extension = mimetypes.guess_extension(media.mime_type) or ".mp4" elif media_type == 5: extension = mimetypes.guess_extension(media.mime_type) or ".unknown" elif media_type == 8: extension = ".webp" elif media_type == 9: extension = mimetypes.guess_extension(media.mime_type) or ".mp3" elif media_type in (0, 1, 2): extension = ".jpg" else: continue file_name = "{}_{}_{}{}".format( media_type_str, datetime.fromtimestamp( getattr(media, "date", None) or time.time() ).strftime("%Y-%m-%d_%H-%M-%S"), self.rnd_id(), extension ) temp_file_path = self.get_file( dc_id=dc_id, id=id, access_hash=access_hash, volume_id=volume_id, local_id=local_id, secret=secret, size=size, progress=progress, progress_args=progress_args ) if temp_file_path: final_file_path = os.path.abspath(re.sub("\\\\", "/", os.path.join(directory, file_name))) os.makedirs(directory, exist_ok=True) shutil.move(temp_file_path, final_file_path) except Exception as e: log.error(e, exc_info=True) try: os.remove(temp_file_path) except OSError: pass else: # TODO: "" or None for faulty download, which is better? # os.path methods return "" in case something does not exist, I prefer this. # For now let's keep None path[0] = final_file_path or None finally: done.set() log.debug("{} stopped".format(name)) def updates_worker(self): name = threading.current_thread().name log.debug("{} started".format(name)) while True: updates = self.updates_queue.get() if updates is None: break try: if isinstance(updates, (types.Update, types.UpdatesCombined)): self.fetch_peers(updates.users) self.fetch_peers(updates.chats) for update in updates.updates: channel_id = getattr( getattr( getattr( update, "message", None ), "to_id", None ), "channel_id", None ) or getattr(update, "channel_id", None) pts = getattr(update, "pts", None) pts_count = getattr(update, "pts_count", None) if isinstance(update, types.UpdateChannelTooLong): log.warning(update) if isinstance(update, types.UpdateNewChannelMessage): message = update.message if not isinstance(message, types.MessageEmpty): try: diff = self.send( functions.updates.GetChannelDifference( channel=self.resolve_peer(int("-100" + str(channel_id))), filter=types.ChannelMessagesFilter( ranges=[types.MessageRange( min_id=update.message.id, max_id=update.message.id )] ), pts=pts - pts_count, limit=pts ) ) except ChannelPrivate: pass else: if not isinstance(diff, types.updates.ChannelDifferenceEmpty): updates.users += diff.users updates.chats += diff.chats if channel_id and pts: if channel_id not in self.channels_pts: self.channels_pts[channel_id] = [] if pts in self.channels_pts[channel_id]: continue self.channels_pts[channel_id].append(pts) if len(self.channels_pts[channel_id]) > 50: self.channels_pts[channel_id] = self.channels_pts[channel_id][25:] self.dispatcher.updates_queue.put((update, updates.users, updates.chats)) elif isinstance(updates, (types.UpdateShortMessage, types.UpdateShortChatMessage)): diff = self.send( functions.updates.GetDifference( pts=updates.pts - updates.pts_count, date=updates.date, qts=-1 ) ) if diff.new_messages: self.dispatcher.updates_queue.put(( types.UpdateNewMessage( message=diff.new_messages[0], pts=updates.pts, pts_count=updates.pts_count ), diff.users, diff.chats )) else: self.dispatcher.updates_queue.put((diff.other_updates[0], [], [])) elif isinstance(updates, types.UpdateShort): self.dispatcher.updates_queue.put((updates.update, [], [])) elif isinstance(updates, types.UpdatesTooLong): log.warning(updates) except Exception as e: log.error(e, exc_info=True) log.debug("{} stopped".format(name)) def send(self, data: Object, retries: int = Session.MAX_RETRIES, timeout: float = Session.WAIT_TIMEOUT): """Use this method to send Raw Function queries. This method makes possible to manually call every single Telegram API method in a low-level manner. Available functions are listed in the :obj:`functions ` package and may accept compound data types from :obj:`types ` as well as bare types such as ``int``, ``str``, etc... Args: data (``Object``): The API Schema function filled with proper arguments. retries (``int``): Number of retries. timeout (``float``): Timeout in seconds. Raises: :class:`Error ` in case of a Telegram RPC error. """ if not self.is_started: raise ConnectionError("Client has not been started") if self.no_updates: data = functions.InvokeWithoutUpdates(data) if self.takeout_id: data = functions.InvokeWithTakeout(self.takeout_id, data) r = self.session.send(data, retries, timeout) self.fetch_peers(getattr(r, "users", [])) self.fetch_peers(getattr(r, "chats", [])) return r def load_config(self): parser = ConfigParser() parser.read(self.config_file) if self.api_id and self.api_hash: pass else: if parser.has_section("pyrogram"): self.api_id = parser.getint("pyrogram", "api_id") self.api_hash = parser.get("pyrogram", "api_hash") else: raise AttributeError( "No API Key found. " "More info: https://docs.pyrogram.ml/start/ProjectSetup#configuration" ) for option in ["app_version", "device_model", "system_version", "lang_code"]: if getattr(self, option): pass else: if parser.has_section("pyrogram"): setattr(self, option, parser.get( "pyrogram", option, fallback=getattr(Client, option.upper()) )) else: setattr(self, option, getattr(Client, option.upper())) if self._proxy: self._proxy["enabled"] = bool(self._proxy.get("enabled", True)) else: self._proxy = {} if parser.has_section("proxy"): self._proxy["enabled"] = parser.getboolean("proxy", "enabled", fallback=True) self._proxy["hostname"] = parser.get("proxy", "hostname") self._proxy["port"] = parser.getint("proxy", "port") self._proxy["username"] = parser.get("proxy", "username", fallback=None) or None self._proxy["password"] = parser.get("proxy", "password", fallback=None) or None def load_session(self): try: with open(os.path.join(self.workdir, "{}.session".format(self.session_name)), encoding="utf-8") as f: s = json.load(f) except FileNotFoundError: self.dc_id = 1 self.date = 0 self.auth_key = Auth(self.dc_id, self.test_mode, self.ipv6, self._proxy).create() else: self.dc_id = s["dc_id"] self.test_mode = s["test_mode"] self.auth_key = base64.b64decode("".join(s["auth_key"])) self.user_id = s["user_id"] self.date = s.get("date", 0) for k, v in s.get("peers_by_id", {}).items(): self.peers_by_id[int(k)] = utils.get_input_peer(int(k), v) for k, v in s.get("peers_by_username", {}).items(): peer = self.peers_by_id.get(v, None) if peer: self.peers_by_username[k] = peer for k, v in s.get("peers_by_phone", {}).items(): peer = self.peers_by_id.get(v, None) if peer: self.peers_by_phone[k] = peer def load_plugins(self): if self.plugins_dir is not None: plugins_count = 0 for path in Path(self.plugins_dir).rglob("*.py"): file_path = os.path.splitext(str(path))[0] import_path = [] while file_path: file_path, tail = os.path.split(file_path) import_path.insert(0, tail) import_path = ".".join(import_path) module = import_module(import_path) for name in dir(module): # noinspection PyBroadException try: handler, group = getattr(module, name) if isinstance(handler, Handler) and isinstance(group, int): self.add_handler(handler, group) log.info('{}("{}") from "{}" loaded in group {}'.format( type(handler).__name__, name, import_path, group)) plugins_count += 1 except Exception: pass if plugins_count > 0: log.warning('Successfully loaded {} plugin{} from "{}"'.format( plugins_count, "s" if plugins_count > 1 else "", self.plugins_dir )) else: log.warning('No plugin loaded: "{}" doesn\'t contain any valid plugin'.format(self.plugins_dir)) def save_session(self): auth_key = base64.b64encode(self.auth_key).decode() auth_key = [auth_key[i: i + 43] for i in range(0, len(auth_key), 43)] os.makedirs(self.workdir, exist_ok=True) with open(os.path.join(self.workdir, "{}.session".format(self.session_name)), "w", encoding="utf-8") as f: json.dump( dict( dc_id=self.dc_id, test_mode=self.test_mode, auth_key=auth_key, user_id=self.user_id, date=self.date ), f, indent=4 ) def get_initial_dialogs_chunk(self, offset_date: int = 0): while True: try: r = self.send( functions.messages.GetDialogs( offset_date=offset_date, offset_id=0, offset_peer=types.InputPeerEmpty(), limit=self.DIALOGS_AT_ONCE, hash=0, exclude_pinned=True ) ) except FloodWait as e: log.warning("get_dialogs flood: waiting {} seconds".format(e.x)) time.sleep(e.x) else: log.info("Total peers: {}".format(len(self.peers_by_id))) return r def get_initial_dialogs(self): self.send(functions.messages.GetPinnedDialogs()) dialogs = self.get_initial_dialogs_chunk() offset_date = utils.get_offset_date(dialogs) while len(dialogs.dialogs) == self.DIALOGS_AT_ONCE: dialogs = self.get_initial_dialogs_chunk(offset_date) offset_date = utils.get_offset_date(dialogs) self.get_initial_dialogs_chunk() def resolve_peer(self, peer_id: Union[int, str]): """Use this method to get the InputPeer of a known peer_id. This is a utility method intended to be used **only** when working with Raw Functions (i.e: a Telegram API method you wish to use which is not available yet in the Client class as an easy-to-use method), whenever an InputPeer type is required. Args: peer_id (``int`` | ``str``): The peer id you want to extract the InputPeer from. Can be a direct id (int), a username (str) or a phone number (str). Returns: On success, the resolved peer id is returned in form of an InputPeer object. Raises: :class:`Error ` in case of a Telegram RPC error. ``KeyError`` in case the peer doesn't exist in the internal database. """ try: return self.peers_by_id[peer_id] except KeyError: if type(peer_id) is str: if peer_id in ("self", "me"): return types.InputPeerSelf() peer_id = re.sub(r"[@+\s]", "", peer_id.lower()) try: int(peer_id) except ValueError: if peer_id not in self.peers_by_username: self.send( functions.contacts.ResolveUsername( username=peer_id ) ) return self.peers_by_username[peer_id] else: try: return self.peers_by_phone[peer_id] except KeyError: raise PeerIdInvalid if peer_id > 0: self.fetch_peers( self.send( functions.users.GetUsers( id=[types.InputUser(peer_id, 0)] ) ) ) else: if str(peer_id).startswith("-100"): self.send( functions.channels.GetChannels( id=[types.InputChannel(int(str(peer_id)[4:]), 0)] ) ) else: self.send( functions.messages.GetChats( id=[-peer_id] ) ) try: return self.peers_by_id[peer_id] except KeyError: raise PeerIdInvalid def save_file(self, path: str, file_id: int = None, file_part: int = 0, progress: callable = None, progress_args: tuple = ()): """Use this method to upload a file onto Telegram servers, without actually sending the message to anyone. This is a utility method intended to be used **only** when working with Raw Functions (i.e: a Telegram API method you wish to use which is not available yet in the Client class as an easy-to-use method), whenever an InputFile type is required. Args: path (``str``): The path of the file you want to upload that exists on your local machine. file_id (``int``, *optional*): In case a file part expired, pass the file_id and the file_part to retry uploading that specific chunk. file_part (``int``, *optional*): In case a file part expired, pass the file_id and the file_part to retry uploading that specific chunk. progress (``callable``, *optional*): Pass a callback function to view the upload progress. The function must take *(client, current, total, \*args)* as positional arguments (look at the section below for a detailed description). progress_args (``tuple``, *optional*): Extra custom arguments for the progress callback function. Useful, for example, if you want to pass a chat_id and a message_id in order to edit a message with the updated progress. Other Parameters: client (:obj:`Client `): The Client itself, useful when you want to call other API methods inside the callback function. current (``int``): The amount of bytes uploaded so far. total (``int``): The size of the file. *args (``tuple``, *optional*): Extra custom arguments as defined in the *progress_args* parameter. You can either keep *\*args* or add every single extra argument in your function signature. Returns: On success, the uploaded file is returned in form of an InputFile object. Raises: :class:`Error ` in case of a Telegram RPC error. """ part_size = 512 * 1024 file_size = os.path.getsize(path) if file_size == 0: raise ValueError("File size equals to 0 B") if file_size > 1500 * 1024 * 1024: raise ValueError("Telegram doesn't support uploading files bigger than 1500 MiB") file_total_parts = int(math.ceil(file_size / part_size)) is_big = True if file_size > 10 * 1024 * 1024 else False is_missing_part = True if file_id is not None else False file_id = file_id or self.rnd_id() md5_sum = md5() if not is_big and not is_missing_part else None session = Session(self, self.dc_id, self.auth_key, is_media=True) session.start() try: with open(path, "rb") as f: f.seek(part_size * file_part) while True: chunk = f.read(part_size) if not chunk: if not is_big: md5_sum = "".join([hex(i)[2:].zfill(2) for i in md5_sum.digest()]) break if is_big: rpc = functions.upload.SaveBigFilePart( file_id=file_id, file_part=file_part, file_total_parts=file_total_parts, bytes=chunk ) else: rpc = functions.upload.SaveFilePart( file_id=file_id, file_part=file_part, bytes=chunk ) assert session.send(rpc), "Couldn't upload file" if is_missing_part: return if not is_big: md5_sum.update(chunk) file_part += 1 if progress: progress(self, min(file_part * part_size, file_size), file_size, *progress_args) except Client.StopTransmission: raise except Exception as e: log.error(e, exc_info=True) else: if is_big: return types.InputFileBig( id=file_id, parts=file_total_parts, name=os.path.basename(path), ) else: return types.InputFile( id=file_id, parts=file_total_parts, name=os.path.basename(path), md5_checksum=md5_sum ) finally: session.stop() def get_file(self, dc_id: int, id: int = None, access_hash: int = None, volume_id: int = None, local_id: int = None, secret: int = None, size: int = None, progress: callable = None, progress_args: tuple = ()) -> str: with self.media_sessions_lock: session = self.media_sessions.get(dc_id, None) if session is None: if dc_id != self.dc_id: exported_auth = self.send( functions.auth.ExportAuthorization( dc_id=dc_id ) ) session = Session( self, dc_id, Auth(dc_id, self.test_mode, self.ipv6, self._proxy).create(), is_media=True ) session.start() self.media_sessions[dc_id] = session session.send( functions.auth.ImportAuthorization( id=exported_auth.id, bytes=exported_auth.bytes ) ) else: session = Session( self, dc_id, self.auth_key, is_media=True ) session.start() self.media_sessions[dc_id] = session if volume_id: # Photos are accessed by volume_id, local_id, secret location = types.InputFileLocation( volume_id=volume_id, local_id=local_id, secret=secret, file_reference=b"" ) else: # Any other file can be more easily accessed by id and access_hash location = types.InputDocumentFileLocation( id=id, access_hash=access_hash, file_reference=b"" ) limit = 1024 * 1024 offset = 0 file_name = "" try: r = session.send( functions.upload.GetFile( location=location, offset=offset, limit=limit ) ) if isinstance(r, types.upload.File): with tempfile.NamedTemporaryFile("wb", delete=False) as f: file_name = f.name while True: chunk = r.bytes if not chunk: break f.write(chunk) offset += limit if progress: progress(self, min(offset, size) if size != 0 else offset, size, *progress_args) r = session.send( functions.upload.GetFile( location=location, offset=offset, limit=limit ) ) elif isinstance(r, types.upload.FileCdnRedirect): with self.media_sessions_lock: cdn_session = self.media_sessions.get(r.dc_id, None) if cdn_session is None: cdn_session = Session( self, r.dc_id, Auth(r.dc_id, self.test_mode, self.ipv6, self._proxy).create(), is_media=True, is_cdn=True ) cdn_session.start() self.media_sessions[r.dc_id] = cdn_session try: with tempfile.NamedTemporaryFile("wb", delete=False) as f: file_name = f.name while True: r2 = cdn_session.send( functions.upload.GetCdnFile( file_token=r.file_token, offset=offset, limit=limit ) ) if isinstance(r2, types.upload.CdnFileReuploadNeeded): try: session.send( functions.upload.ReuploadCdnFile( file_token=r.file_token, request_token=r2.request_token ) ) except VolumeLocNotFound: break else: continue chunk = r2.bytes # https://core.telegram.org/cdn#decrypting-files decrypted_chunk = AES.ctr256_decrypt( chunk, r.encryption_key, bytearray( r.encryption_iv[:-4] + (offset // 16).to_bytes(4, "big") ) ) hashes = session.send( functions.upload.GetCdnFileHashes( r.file_token, offset ) ) # https://core.telegram.org/cdn#verifying-files for i, h in enumerate(hashes): cdn_chunk = decrypted_chunk[h.limit * i: h.limit * (i + 1)] assert h.hash == sha256(cdn_chunk).digest(), "Invalid CDN hash part {}".format(i) f.write(decrypted_chunk) offset += limit if progress: progress(self, min(offset, size) if size != 0 else offset, size, *progress_args) if len(chunk) < limit: break except Exception as e: raise e except Exception as e: if not isinstance(e, Client.StopTransmission): log.error(e, exc_info=True) try: os.remove(file_name) except OSError: pass return "" else: return file_name \ No newline at end of file +# Pyrogram - Telegram MTProto API Client Library for Python +# Copyright (C) 2017-2019 Dan Tès +# +# This file is part of Pyrogram. +# +# Pyrogram is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pyrogram is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Pyrogram. If not, see . + +import base64 +import binascii +import json +import logging +import math +import mimetypes +import os +import re +import shutil +import struct +import tempfile +import threading +import time +from configparser import ConfigParser +from datetime import datetime +from hashlib import sha256, md5 +from importlib import import_module +from pathlib import Path +from signal import signal, SIGINT, SIGTERM, SIGABRT +from threading import Thread +from typing import Union, List + +from pyrogram.api import functions, types +from pyrogram.api.core import Object +from pyrogram.api.errors import ( + PhoneMigrate, NetworkMigrate, PhoneNumberInvalid, + PhoneNumberUnoccupied, PhoneCodeInvalid, PhoneCodeHashEmpty, + PhoneCodeExpired, PhoneCodeEmpty, SessionPasswordNeeded, + PasswordHashInvalid, FloodWait, PeerIdInvalid, FirstnameInvalid, PhoneNumberBanned, + VolumeLocNotFound, UserMigrate, FileIdInvalid, ChannelPrivate, PhoneNumberOccupied, + PasswordRecoveryNa, PasswordEmpty +) +from pyrogram.client.handlers import DisconnectHandler +from pyrogram.client.handlers.handler import Handler +from pyrogram.client.methods.password.utils import compute_check +from pyrogram.crypto import AES +from pyrogram.session import Auth, Session +from .dispatcher import Dispatcher +from .ext import utils, Syncer, BaseClient +from .methods import Methods + +log = logging.getLogger(__name__) + + +class Client(Methods, BaseClient): + """This class represents a Client, the main mean for interacting with Telegram. + It exposes bot-like methods for an easy access to the API as well as a simple way to + invoke every single Telegram API method available. + + Args: + session_name (``str``): + Name to uniquely identify a session of either a User or a Bot. + For Users: pass a string of your choice, e.g.: "my_main_account". + For Bots: pass your Bot API token, e.g.: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + Note: as long as a valid User session file exists, Pyrogram won't ask you again to input your phone number. + + api_id (``int``, *optional*): + The *api_id* part of your Telegram API Key, as integer. E.g.: 12345 + This is an alternative way to pass it if you don't want to use the *config.ini* file. + + api_hash (``str``, *optional*): + The *api_hash* part of your Telegram API Key, as string. E.g.: "0123456789abcdef0123456789abcdef" + This is an alternative way to pass it if you don't want to use the *config.ini* file. + + app_version (``str``, *optional*): + Application version. Defaults to "Pyrogram \U0001f525 vX.Y.Z" + This is an alternative way to set it if you don't want to use the *config.ini* file. + + device_model (``str``, *optional*): + Device model. Defaults to *platform.python_implementation() + " " + platform.python_version()* + This is an alternative way to set it if you don't want to use the *config.ini* file. + + system_version (``str``, *optional*): + Operating System version. Defaults to *platform.system() + " " + platform.release()* + This is an alternative way to set it if you don't want to use the *config.ini* file. + + lang_code (``str``, *optional*): + Code of the language used on the client, in ISO 639-1 standard. Defaults to "en". + This is an alternative way to set it if you don't want to use the *config.ini* file. + + ipv6 (``bool``, *optional*): + Pass True to connect to Telegram using IPv6. + Defaults to False (IPv4). + + proxy (``dict``, *optional*): + Your SOCKS5 Proxy settings as dict, + e.g.: *dict(hostname="11.22.33.44", port=1080, username="user", password="pass")*. + *username* and *password* can be omitted if your proxy doesn't require authorization. + This is an alternative way to setup a proxy if you don't want to use the *config.ini* file. + + test_mode (``bool``, *optional*): + Enable or disable log-in to testing servers. Defaults to False. + Only applicable for new sessions and will be ignored in case previously + created sessions are loaded. + + phone_number (``str`` | ``callable``, *optional*): + Pass your phone number as string (with your Country Code prefix included) to avoid entering it manually. + Or pass a callback function which accepts no arguments and must return the correct phone number as string + (e.g., "391234567890"). + Only applicable for new sessions. + + phone_code (``str`` | ``callable``, *optional*): + Pass the phone code as string (for test numbers only) to avoid entering it manually. Or pass a callback + function which accepts a single positional argument *(phone_number)* and must return the correct phone code + as string (e.g., "12345"). + Only applicable for new sessions. + + password (``str``, *optional*): + Pass your Two-Step Verification password as string (if you have one) to avoid entering it manually. + Or pass a callback function which accepts a single positional argument *(password_hint)* and must return + the correct password as string (e.g., "password"). + Only applicable for new sessions. + + recovery_code (``callable``, *optional*): + Pass a callback function which accepts a single positional argument *(email_pattern)* and must return the + correct password recovery code as string (e.g., "987654"). + Only applicable for new sessions. + + force_sms (``str``, *optional*): + Pass True to force Telegram sending the authorization code via SMS. + Only applicable for new sessions. + + first_name (``str``, *optional*): + Pass a First Name to avoid entering it manually. It will be used to automatically + create a new Telegram account in case the phone number you passed is not registered yet. + Only applicable for new sessions. + + last_name (``str``, *optional*): + Same purpose as *first_name*; pass a Last Name to avoid entering it manually. It can + be an empty string: "". Only applicable for new sessions. + + workers (``int``, *optional*): + Thread pool size for handling incoming updates. Defaults to 4. + + workdir (``str``, *optional*): + Define a custom working directory. The working directory is the location in your filesystem + where Pyrogram will store your session files. Defaults to "." (current directory). + + config_file (``str``, *optional*): + Path of the configuration file. Defaults to ./config.ini + + plugins_dir (``str``, *optional*): + Define a custom directory for your plugins. The plugins directory is the location in your + filesystem where Pyrogram will automatically load your update handlers. + Defaults to None (plugins disabled). + + no_updates (``bool``, *optional*): + Pass True to completely disable incoming updates for the current session. + When updates are disabled your client can't receive any new message. + Useful for batch programs that don't need to deal with updates. + Defaults to False (updates enabled and always received). + + takeout (``bool``, *optional*): + Pass True to let the client use a takeout session instead of a normal one, implies no_updates. + Useful for exporting your Telegram data. Methods invoked inside a takeout session (such as get_history, + download_media, ...) are less prone to throw FloodWait exceptions. + Only available for users, bots will ignore this parameter. + Defaults to False (normal session). + """ + + def __init__(self, + session_name: str, + api_id: Union[int, str] = None, + api_hash: str = None, + app_version: str = None, + device_model: str = None, + system_version: str = None, + lang_code: str = None, + ipv6: bool = False, + proxy: dict = None, + test_mode: bool = False, + phone_number: str = None, + phone_code: Union[str, callable] = None, + password: str = None, + recovery_code: callable = None, + force_sms: bool = False, + first_name: str = None, + last_name: str = None, + workers: int = BaseClient.WORKERS, + workdir: str = BaseClient.WORKDIR, + config_file: str = BaseClient.CONFIG_FILE, + plugins_dir: str = None, + no_updates: bool = None, + takeout: bool = None): + super().__init__() + + self.session_name = session_name + self.api_id = int(api_id) if api_id else None + self.api_hash = api_hash + self.app_version = app_version + self.device_model = device_model + self.system_version = system_version + self.lang_code = lang_code + self.ipv6 = ipv6 + # TODO: Make code consistent, use underscore for private/protected fields + self._proxy = proxy + self.test_mode = test_mode + self.phone_number = phone_number + self.phone_code = phone_code + self.password = password + self.recovery_code = recovery_code + self.force_sms = force_sms + self.first_name = first_name + self.last_name = last_name + self.workers = workers + self.workdir = workdir + self.config_file = config_file + self.plugins_dir = plugins_dir + self.no_updates = no_updates + self.takeout = takeout + + self.dispatcher = Dispatcher(self, workers) + + def __enter__(self): + return self.start() + + def __exit__(self, *args): + self.stop() + + @property + def proxy(self): + return self._proxy + + @proxy.setter + def proxy(self, value): + if value is None: + self._proxy = None + return + + if self._proxy is None: + self._proxy = {} + + self._proxy["enabled"] = bool(value.get("enabled", True)) + self._proxy.update(value) + + def start(self): + """Use this method to start the Client after creating it. + Requires no parameters. + + Raises: + :class:`Error ` in case of a Telegram RPC error. + ``ConnectionError`` in case you try to start an already started Client. + """ + if self.is_started: + raise ConnectionError("Client has already been started") + + if self.BOT_TOKEN_RE.match(self.session_name): + self.bot_token = self.session_name + self.session_name = self.session_name.split(":")[0] + + self.load_config() + self.load_session() + self.load_plugins() + + self.session = Session( + self, + self.dc_id, + self.auth_key + ) + + self.session.start() + self.is_started = True + + try: + if self.user_id is None: + if self.bot_token is None: + self.authorize_user() + else: + self.authorize_bot() + + self.save_session() + + if self.bot_token is None: + if self.takeout: + self.takeout_id = self.send(functions.account.InitTakeoutSession()).id + log.warning("Takeout session {} initiated".format(self.takeout_id)) + + now = time.time() + + if abs(now - self.date) > Client.OFFLINE_SLEEP: + self.peers_by_username = {} + self.peers_by_phone = {} + + self.get_initial_dialogs() + self.get_contacts() + else: + self.send(functions.messages.GetPinnedDialogs()) + self.get_initial_dialogs_chunk() + else: + self.send(functions.updates.GetState()) + except Exception as e: + self.is_started = False + self.session.stop() + raise e + + for i in range(self.UPDATES_WORKERS): + self.updates_workers_list.append( + Thread( + target=self.updates_worker, + name="UpdatesWorker#{}".format(i + 1) + ) + ) + + self.updates_workers_list[-1].start() + + for i in range(self.DOWNLOAD_WORKERS): + self.download_workers_list.append( + Thread( + target=self.download_worker, + name="DownloadWorker#{}".format(i + 1) + ) + ) + + self.download_workers_list[-1].start() + + self.dispatcher.start() + + mimetypes.init() + Syncer.add(self) + + return self + + def stop(self): + """Use this method to manually stop the Client. + Requires no parameters. + + Raises: + ``ConnectionError`` in case you try to stop an already stopped Client. + """ + if not self.is_started: + raise ConnectionError("Client is already stopped") + + if self.takeout_id: + self.send(functions.account.FinishTakeoutSession()) + log.warning("Takeout session {} finished".format(self.takeout_id)) + + Syncer.remove(self) + self.dispatcher.stop() + + for _ in range(self.DOWNLOAD_WORKERS): + self.download_queue.put(None) + + for i in self.download_workers_list: + i.join() + + self.download_workers_list.clear() + + for _ in range(self.UPDATES_WORKERS): + self.updates_queue.put(None) + + for i in self.updates_workers_list: + i.join() + + self.updates_workers_list.clear() + + for i in self.media_sessions.values(): + i.stop() + + self.media_sessions.clear() + + self.is_started = False + self.session.stop() + + return self + + def restart(self): + """Use this method to restart the Client. + Requires no parameters. + + Raises: + ``ConnectionError`` in case you try to restart a stopped Client. + """ + self.stop() + self.start() + + def idle(self, stop_signals: tuple = (SIGINT, SIGTERM, SIGABRT)): + """Blocks the program execution until one of the signals are received, + then gently stop the Client by closing the underlying connection. + + Args: + stop_signals (``tuple``, *optional*): + Iterable containing signals the signal handler will listen to. + Defaults to (SIGINT, SIGTERM, SIGABRT). + """ + + def signal_handler(*args): + self.is_idle = False + + for s in stop_signals: + signal(s, signal_handler) + + self.is_idle = True + + while self.is_idle: + time.sleep(1) + + self.stop() + + def run(self): + """Use this method to automatically start and idle a Client. + Requires no parameters. + + Raises: + :class:`Error ` in case of a Telegram RPC error. + """ + self.start() + self.idle() + + def add_handler(self, handler: Handler, group: int = 0): + """Use this method to register an update handler. + + You can register multiple handlers, but at most one handler within a group + will be used for a single update. To handle the same update more than once, register + your handler using a different group id (lower group id == higher priority). + + Args: + handler (``Handler``): + The handler to be registered. + + group (``int``, *optional*): + The group identifier, defaults to 0. + + Returns: + A tuple of (handler, group) + """ + if isinstance(handler, DisconnectHandler): + self.disconnect_handler = handler.callback + else: + self.dispatcher.add_handler(handler, group) + + return handler, group + + def remove_handler(self, handler: Handler, group: int = 0): + """Removes a previously-added update handler. + + Make sure to provide the right group that the handler was added in. You can use + the return value of the :meth:`add_handler` method, a tuple of (handler, group), and + pass it directly. + + Args: + handler (``Handler``): + The handler to be removed. + + group (``int``, *optional*): + The group identifier, defaults to 0. + """ + if isinstance(handler, DisconnectHandler): + self.disconnect_handler = None + else: + self.dispatcher.remove_handler(handler, group) + + def stop_transmission(self): + """Use this method to stop downloading or uploading a file. + Must be called inside a progress callback function. + """ + raise Client.StopTransmission + + def authorize_bot(self): + try: + r = self.send( + functions.auth.ImportBotAuthorization( + flags=0, + api_id=self.api_id, + api_hash=self.api_hash, + bot_auth_token=self.bot_token + ) + ) + except UserMigrate as e: + self.session.stop() + + self.dc_id = e.x + self.auth_key = Auth(self.dc_id, self.test_mode, self.ipv6, self._proxy).create() + + self.session = Session( + self, + self.dc_id, + self.auth_key + ) + + self.session.start() + self.authorize_bot() + else: + self.user_id = r.user.id + + print("Logged in successfully as @{}".format(r.user.username)) + + def authorize_user(self): + phone_number_invalid_raises = self.phone_number is not None + phone_code_invalid_raises = self.phone_code is not None + password_invalid_raises = self.password is not None + first_name_invalid_raises = self.first_name is not None + + def default_phone_number_callback(): + while True: + phone_number = input("Enter phone number: ") + confirm = input("Is \"{}\" correct? (y/n): ".format(phone_number)) + + if confirm in ("y", "1"): + return phone_number + elif confirm in ("n", "2"): + continue + + while True: + self.phone_number = ( + default_phone_number_callback() if self.phone_number is None + else str(self.phone_number()) if callable(self.phone_number) + else str(self.phone_number) + ) + + self.phone_number = self.phone_number.strip("+") + + try: + r = self.send( + functions.auth.SendCode( + self.phone_number, + self.api_id, + self.api_hash + ) + ) + except (PhoneMigrate, NetworkMigrate) as e: + self.session.stop() + + self.dc_id = e.x + + self.auth_key = Auth( + self.dc_id, + self.test_mode, + self.ipv6, + self._proxy + ).create() + + self.session = Session( + self, + self.dc_id, + self.auth_key + ) + + self.session.start() + except (PhoneNumberInvalid, PhoneNumberBanned) as e: + if phone_number_invalid_raises: + raise + else: + print(e.MESSAGE) + self.phone_number = None + except FloodWait as e: + if phone_number_invalid_raises: + raise + else: + print(e.MESSAGE.format(x=e.x)) + time.sleep(e.x) + except Exception as e: + log.error(e, exc_info=True) + raise + else: + break + + phone_registered = r.phone_registered + phone_code_hash = r.phone_code_hash + terms_of_service = r.terms_of_service + + if terms_of_service: + print("\n" + terms_of_service.text + "\n") + + if self.force_sms: + self.send( + functions.auth.ResendCode( + phone_number=self.phone_number, + phone_code_hash=phone_code_hash + ) + ) + + while True: + if not phone_registered: + self.first_name = ( + input("First name: ") if self.first_name is None + else str(self.first_name()) if callable(self.first_name) + else str(self.first_name) + ) + + self.last_name = ( + input("Last name: ") if self.last_name is None + else str(self.last_name()) if callable(self.last_name) + else str(self.last_name) + ) + + self.phone_code = ( + input("Enter phone code: ") if self.phone_code is None + else str(self.phone_code(self.phone_number)) if callable(self.phone_code) + else str(self.phone_code) + ) + + try: + if phone_registered: + try: + r = self.send( + functions.auth.SignIn( + self.phone_number, + phone_code_hash, + self.phone_code + ) + ) + except PhoneNumberUnoccupied: + log.warning("Phone number unregistered") + phone_registered = False + continue + else: + try: + r = self.send( + functions.auth.SignUp( + self.phone_number, + phone_code_hash, + self.phone_code, + self.first_name, + self.last_name + ) + ) + except PhoneNumberOccupied: + log.warning("Phone number already registered") + phone_registered = True + continue + except (PhoneCodeInvalid, PhoneCodeEmpty, PhoneCodeExpired, PhoneCodeHashEmpty) as e: + if phone_code_invalid_raises: + raise + else: + print(e.MESSAGE) + self.phone_code = None + except FirstnameInvalid as e: + if first_name_invalid_raises: + raise + else: + print(e.MESSAGE) + self.first_name = None + except SessionPasswordNeeded as e: + print(e.MESSAGE) + + def default_password_callback(password_hint: str) -> str: + print("Hint: {}".format(password_hint)) + return input("Enter password (empty to recover): ") + + def default_recovery_callback(email_pattern: str) -> str: + print("An e-mail containing the recovery code has been sent to {}".format(email_pattern)) + return input("Enter password recovery code: ") + + while True: + try: + r = self.send(functions.account.GetPassword()) + + self.password = ( + default_password_callback(r.hint) if self.password is None + else str(self.password(r.hint) or "") if callable(self.password) + else str(self.password) + ) + + if self.password == "": + r = self.send(functions.auth.RequestPasswordRecovery()) + + self.recovery_code = ( + default_recovery_callback(r.email_pattern) if self.recovery_code is None + else str(self.recovery_code(r.email_pattern)) if callable(self.recovery_code) + else str(self.recovery_code) + ) + + r = self.send( + functions.auth.RecoverPassword( + code=self.recovery_code + ) + ) + else: + r = self.send( + functions.auth.CheckPassword( + password=compute_check(r, self.password) + ) + ) + except (PasswordEmpty, PasswordRecoveryNa, PasswordHashInvalid) as e: + if password_invalid_raises: + raise + else: + print(e.MESSAGE) + self.password = None + self.recovery_code = None + except FloodWait as e: + if password_invalid_raises: + raise + else: + print(e.MESSAGE.format(x=e.x)) + time.sleep(e.x) + self.password = None + self.recovery_code = None + except Exception as e: + log.error(e, exc_info=True) + raise + else: + break + break + except FloodWait as e: + if phone_code_invalid_raises or first_name_invalid_raises: + raise + else: + print(e.MESSAGE.format(x=e.x)) + time.sleep(e.x) + except Exception as e: + log.error(e, exc_info=True) + raise + else: + break + + if terms_of_service: + assert self.send(functions.help.AcceptTermsOfService(terms_of_service.id)) + + self.password = None + self.user_id = r.user.id + + print("Logged in successfully as {}".format(r.user.first_name)) + + def fetch_peers(self, entities: List[Union[types.User, + types.Chat, types.ChatForbidden, + types.Channel, types.ChannelForbidden]]): + for entity in entities: + if isinstance(entity, types.User): + user_id = entity.id + + access_hash = entity.access_hash + + if access_hash is None: + continue + + username = entity.username + phone = entity.phone + + input_peer = types.InputPeerUser( + user_id=user_id, + access_hash=access_hash + ) + + self.peers_by_id[user_id] = input_peer + + if username is not None: + self.peers_by_username[username.lower()] = input_peer + + if phone is not None: + self.peers_by_phone[phone] = input_peer + + if isinstance(entity, (types.Chat, types.ChatForbidden)): + chat_id = entity.id + peer_id = -chat_id + + input_peer = types.InputPeerChat( + chat_id=chat_id + ) + + self.peers_by_id[peer_id] = input_peer + + if isinstance(entity, (types.Channel, types.ChannelForbidden)): + channel_id = entity.id + peer_id = int("-100" + str(channel_id)) + + access_hash = entity.access_hash + + if access_hash is None: + continue + + username = getattr(entity, "username", None) + + input_peer = types.InputPeerChannel( + channel_id=channel_id, + access_hash=access_hash + ) + + self.peers_by_id[peer_id] = input_peer + + if username is not None: + self.peers_by_username[username.lower()] = input_peer + + def download_worker(self): + name = threading.current_thread().name + log.debug("{} started".format(name)) + + while True: + media = self.download_queue.get() + + if media is None: + break + + temp_file_path = "" + final_file_path = "" + + try: + media, file_name, done, progress, progress_args, path = media + + file_id = media.file_id + size = media.file_size + + directory, file_name = os.path.split(file_name) + directory = directory or "downloads" + + try: + decoded = utils.decode(file_id) + fmt = " 24 else " 24: + volume_id = unpacked[4] + secret = unpacked[5] + local_id = unpacked[6] + + media_type_str = Client.MEDIA_TYPE_ID.get(media_type, None) + + if media_type_str is None: + raise FileIdInvalid("Unknown media type: {}".format(unpacked[0])) + + file_name = file_name or getattr(media, "file_name", None) + + if not file_name: + if media_type == 3: + extension = ".ogg" + elif media_type in (4, 10, 13): + extension = mimetypes.guess_extension(media.mime_type) or ".mp4" + elif media_type == 5: + extension = mimetypes.guess_extension(media.mime_type) or ".unknown" + elif media_type == 8: + extension = ".webp" + elif media_type == 9: + extension = mimetypes.guess_extension(media.mime_type) or ".mp3" + elif media_type in (0, 1, 2): + extension = ".jpg" + else: + continue + + file_name = "{}_{}_{}{}".format( + media_type_str, + datetime.fromtimestamp( + getattr(media, "date", None) or time.time() + ).strftime("%Y-%m-%d_%H-%M-%S"), + self.rnd_id(), + extension + ) + + temp_file_path = self.get_file( + dc_id=dc_id, + id=id, + access_hash=access_hash, + volume_id=volume_id, + local_id=local_id, + secret=secret, + size=size, + progress=progress, + progress_args=progress_args + ) + + if temp_file_path: + final_file_path = os.path.abspath(re.sub("\\\\", "/", os.path.join(directory, file_name))) + os.makedirs(directory, exist_ok=True) + shutil.move(temp_file_path, final_file_path) + except Exception as e: + log.error(e, exc_info=True) + + try: + os.remove(temp_file_path) + except OSError: + pass + else: + # TODO: "" or None for faulty download, which is better? + # os.path methods return "" in case something does not exist, I prefer this. + # For now let's keep None + path[0] = final_file_path or None + finally: + done.set() + + log.debug("{} stopped".format(name)) + + def updates_worker(self): + name = threading.current_thread().name + log.debug("{} started".format(name)) + + while True: + updates = self.updates_queue.get() + + if updates is None: + break + + try: + if isinstance(updates, (types.Update, types.UpdatesCombined)): + self.fetch_peers(updates.users) + self.fetch_peers(updates.chats) + + for update in updates.updates: + channel_id = getattr( + getattr( + getattr( + update, "message", None + ), "to_id", None + ), "channel_id", None + ) or getattr(update, "channel_id", None) + + pts = getattr(update, "pts", None) + pts_count = getattr(update, "pts_count", None) + + if isinstance(update, types.UpdateChannelTooLong): + log.warning(update) + + if isinstance(update, types.UpdateNewChannelMessage): + message = update.message + + if not isinstance(message, types.MessageEmpty): + try: + diff = self.send( + functions.updates.GetChannelDifference( + channel=self.resolve_peer(int("-100" + str(channel_id))), + filter=types.ChannelMessagesFilter( + ranges=[types.MessageRange( + min_id=update.message.id, + max_id=update.message.id + )] + ), + pts=pts - pts_count, + limit=pts + ) + ) + except ChannelPrivate: + pass + else: + if not isinstance(diff, types.updates.ChannelDifferenceEmpty): + updates.users += diff.users + updates.chats += diff.chats + + if channel_id and pts: + if channel_id not in self.channels_pts: + self.channels_pts[channel_id] = [] + + if pts in self.channels_pts[channel_id]: + continue + + self.channels_pts[channel_id].append(pts) + + if len(self.channels_pts[channel_id]) > 50: + self.channels_pts[channel_id] = self.channels_pts[channel_id][25:] + + self.dispatcher.updates_queue.put((update, updates.users, updates.chats)) + elif isinstance(updates, (types.UpdateShortMessage, types.UpdateShortChatMessage)): + diff = self.send( + functions.updates.GetDifference( + pts=updates.pts - updates.pts_count, + date=updates.date, + qts=-1 + ) + ) + + if diff.new_messages: + self.dispatcher.updates_queue.put(( + types.UpdateNewMessage( + message=diff.new_messages[0], + pts=updates.pts, + pts_count=updates.pts_count + ), + diff.users, + diff.chats + )) + else: + self.dispatcher.updates_queue.put((diff.other_updates[0], [], [])) + elif isinstance(updates, types.UpdateShort): + self.dispatcher.updates_queue.put((updates.update, [], [])) + elif isinstance(updates, types.UpdatesTooLong): + log.warning(updates) + except Exception as e: + log.error(e, exc_info=True) + + log.debug("{} stopped".format(name)) + + def send(self, + data: Object, + retries: int = Session.MAX_RETRIES, + timeout: float = Session.WAIT_TIMEOUT): + """Use this method to send Raw Function queries. + + This method makes possible to manually call every single Telegram API method in a low-level manner. + Available functions are listed in the :obj:`functions ` package and may accept compound + data types from :obj:`types ` as well as bare types such as ``int``, ``str``, etc... + + Args: + data (``Object``): + The API Schema function filled with proper arguments. + + retries (``int``): + Number of retries. + + timeout (``float``): + Timeout in seconds. + + Raises: + :class:`Error ` in case of a Telegram RPC error. + """ + if not self.is_started: + raise ConnectionError("Client has not been started") + + if self.no_updates: + data = functions.InvokeWithoutUpdates(data) + + if self.takeout_id: + data = functions.InvokeWithTakeout(self.takeout_id, data) + + r = self.session.send(data, retries, timeout) + + self.fetch_peers(getattr(r, "users", [])) + self.fetch_peers(getattr(r, "chats", [])) + + return r + + def load_config(self): + parser = ConfigParser() + parser.read(self.config_file) + + if self.api_id and self.api_hash: + pass + else: + if parser.has_section("pyrogram"): + self.api_id = parser.getint("pyrogram", "api_id") + self.api_hash = parser.get("pyrogram", "api_hash") + else: + raise AttributeError( + "No API Key found. " + "More info: https://docs.pyrogram.ml/start/ProjectSetup#configuration" + ) + + for option in ["app_version", "device_model", "system_version", "lang_code"]: + if getattr(self, option): + pass + else: + if parser.has_section("pyrogram"): + setattr(self, option, parser.get( + "pyrogram", + option, + fallback=getattr(Client, option.upper()) + )) + else: + setattr(self, option, getattr(Client, option.upper())) + + if self._proxy: + self._proxy["enabled"] = bool(self._proxy.get("enabled", True)) + else: + self._proxy = {} + + if parser.has_section("proxy"): + self._proxy["enabled"] = parser.getboolean("proxy", "enabled", fallback=True) + self._proxy["hostname"] = parser.get("proxy", "hostname") + self._proxy["port"] = parser.getint("proxy", "port") + self._proxy["username"] = parser.get("proxy", "username", fallback=None) or None + self._proxy["password"] = parser.get("proxy", "password", fallback=None) or None + + def load_session(self): + try: + with open(os.path.join(self.workdir, "{}.session".format(self.session_name)), encoding="utf-8") as f: + s = json.load(f) + except FileNotFoundError: + self.dc_id = 1 + self.date = 0 + self.auth_key = Auth(self.dc_id, self.test_mode, self.ipv6, self._proxy).create() + else: + self.dc_id = s["dc_id"] + self.test_mode = s["test_mode"] + self.auth_key = base64.b64decode("".join(s["auth_key"])) + self.user_id = s["user_id"] + self.date = s.get("date", 0) + + for k, v in s.get("peers_by_id", {}).items(): + self.peers_by_id[int(k)] = utils.get_input_peer(int(k), v) + + for k, v in s.get("peers_by_username", {}).items(): + peer = self.peers_by_id.get(v, None) + + if peer: + self.peers_by_username[k] = peer + + for k, v in s.get("peers_by_phone", {}).items(): + peer = self.peers_by_id.get(v, None) + + if peer: + self.peers_by_phone[k] = peer + + def load_plugins(self): + if self.plugins_dir is not None: + plugins_count = 0 + + for path in Path(self.plugins_dir).rglob("*.py"): + file_path = os.path.splitext(str(path))[0] + import_path = [] + + while file_path: + file_path, tail = os.path.split(file_path) + import_path.insert(0, tail) + + import_path = ".".join(import_path) + module = import_module(import_path) + + for name in dir(module): + # noinspection PyBroadException + try: + handler, group = getattr(module, name) + + if isinstance(handler, Handler) and isinstance(group, int): + self.add_handler(handler, group) + + log.info('{}("{}") from "{}" loaded in group {}'.format( + type(handler).__name__, name, import_path, group)) + + plugins_count += 1 + except Exception: + pass + + if plugins_count > 0: + log.warning('Successfully loaded {} plugin{} from "{}"'.format( + plugins_count, + "s" if plugins_count > 1 else "", + self.plugins_dir + )) + else: + log.warning('No plugin loaded: "{}" doesn\'t contain any valid plugin'.format(self.plugins_dir)) + + def save_session(self): + auth_key = base64.b64encode(self.auth_key).decode() + auth_key = [auth_key[i: i + 43] for i in range(0, len(auth_key), 43)] + + os.makedirs(self.workdir, exist_ok=True) + + with open(os.path.join(self.workdir, "{}.session".format(self.session_name)), "w", encoding="utf-8") as f: + json.dump( + dict( + dc_id=self.dc_id, + test_mode=self.test_mode, + auth_key=auth_key, + user_id=self.user_id, + date=self.date + ), + f, + indent=4 + ) + + def get_initial_dialogs_chunk(self, + offset_date: int = 0): + while True: + try: + r = self.send( + functions.messages.GetDialogs( + offset_date=offset_date, + offset_id=0, + offset_peer=types.InputPeerEmpty(), + limit=self.DIALOGS_AT_ONCE, + hash=0, + exclude_pinned=True + ) + ) + except FloodWait as e: + log.warning("get_dialogs flood: waiting {} seconds".format(e.x)) + time.sleep(e.x) + else: + log.info("Total peers: {}".format(len(self.peers_by_id))) + return r + + def get_initial_dialogs(self): + self.send(functions.messages.GetPinnedDialogs()) + + dialogs = self.get_initial_dialogs_chunk() + offset_date = utils.get_offset_date(dialogs) + + while len(dialogs.dialogs) == self.DIALOGS_AT_ONCE: + dialogs = self.get_initial_dialogs_chunk(offset_date) + offset_date = utils.get_offset_date(dialogs) + + self.get_initial_dialogs_chunk() + + def resolve_peer(self, + peer_id: Union[int, str]): + """Use this method to get the InputPeer of a known peer_id. + + This is a utility method intended to be used **only** when working with Raw Functions (i.e: a Telegram API + method you wish to use which is not available yet in the Client class as an easy-to-use method), whenever an + InputPeer type is required. + + Args: + peer_id (``int`` | ``str``): + The peer id you want to extract the InputPeer from. + Can be a direct id (int), a username (str) or a phone number (str). + + Returns: + On success, the resolved peer id is returned in form of an InputPeer object. + + Raises: + :class:`Error ` in case of a Telegram RPC error. + ``KeyError`` in case the peer doesn't exist in the internal database. + """ + try: + return self.peers_by_id[peer_id] + except KeyError: + if type(peer_id) is str: + if peer_id in ("self", "me"): + return types.InputPeerSelf() + + peer_id = re.sub(r"[@+\s]", "", peer_id.lower()) + + try: + int(peer_id) + except ValueError: + if peer_id not in self.peers_by_username: + self.send( + functions.contacts.ResolveUsername( + username=peer_id + ) + ) + + return self.peers_by_username[peer_id] + else: + try: + return self.peers_by_phone[peer_id] + except KeyError: + raise PeerIdInvalid + + if peer_id > 0: + self.fetch_peers( + self.send( + functions.users.GetUsers( + id=[types.InputUser(peer_id, 0)] + ) + ) + ) + else: + if str(peer_id).startswith("-100"): + self.send( + functions.channels.GetChannels( + id=[types.InputChannel(int(str(peer_id)[4:]), 0)] + ) + ) + else: + self.send( + functions.messages.GetChats( + id=[-peer_id] + ) + ) + + try: + return self.peers_by_id[peer_id] + except KeyError: + raise PeerIdInvalid + + def save_file(self, + path: str, + file_id: int = None, + file_part: int = 0, + progress: callable = None, + progress_args: tuple = ()): + """Use this method to upload a file onto Telegram servers, without actually sending the message to anyone. + + This is a utility method intended to be used **only** when working with Raw Functions (i.e: a Telegram API + method you wish to use which is not available yet in the Client class as an easy-to-use method), whenever an + InputFile type is required. + + Args: + path (``str``): + The path of the file you want to upload that exists on your local machine. + + file_id (``int``, *optional*): + In case a file part expired, pass the file_id and the file_part to retry uploading that specific chunk. + + file_part (``int``, *optional*): + In case a file part expired, pass the file_id and the file_part to retry uploading that specific chunk. + + progress (``callable``, *optional*): + Pass a callback function to view the upload progress. + The function must take *(client, current, total, \*args)* as positional arguments (look at the section + below for a detailed description). + + progress_args (``tuple``, *optional*): + Extra custom arguments for the progress callback function. Useful, for example, if you want to pass + a chat_id and a message_id in order to edit a message with the updated progress. + + Other Parameters: + client (:obj:`Client `): + The Client itself, useful when you want to call other API methods inside the callback function. + + current (``int``): + The amount of bytes uploaded so far. + + total (``int``): + The size of the file. + + *args (``tuple``, *optional*): + Extra custom arguments as defined in the *progress_args* parameter. + You can either keep *\*args* or add every single extra argument in your function signature. + + Returns: + On success, the uploaded file is returned in form of an InputFile object. + + Raises: + :class:`Error ` in case of a Telegram RPC error. + """ + part_size = 512 * 1024 + file_size = os.path.getsize(path) + + if file_size == 0: + raise ValueError("File size equals to 0 B") + + if file_size > 1500 * 1024 * 1024: + raise ValueError("Telegram doesn't support uploading files bigger than 1500 MiB") + + file_total_parts = int(math.ceil(file_size / part_size)) + is_big = True if file_size > 10 * 1024 * 1024 else False + is_missing_part = True if file_id is not None else False + file_id = file_id or self.rnd_id() + md5_sum = md5() if not is_big and not is_missing_part else None + + session = Session(self, self.dc_id, self.auth_key, is_media=True) + session.start() + + try: + with open(path, "rb") as f: + f.seek(part_size * file_part) + + while True: + chunk = f.read(part_size) + + if not chunk: + if not is_big: + md5_sum = "".join([hex(i)[2:].zfill(2) for i in md5_sum.digest()]) + break + + if is_big: + rpc = functions.upload.SaveBigFilePart( + file_id=file_id, + file_part=file_part, + file_total_parts=file_total_parts, + bytes=chunk + ) + else: + rpc = functions.upload.SaveFilePart( + file_id=file_id, + file_part=file_part, + bytes=chunk + ) + + assert session.send(rpc), "Couldn't upload file" + + if is_missing_part: + return + + if not is_big: + md5_sum.update(chunk) + + file_part += 1 + + if progress: + progress(self, min(file_part * part_size, file_size), file_size, *progress_args) + except Client.StopTransmission: + raise + except Exception as e: + log.error(e, exc_info=True) + else: + if is_big: + return types.InputFileBig( + id=file_id, + parts=file_total_parts, + name=os.path.basename(path), + + ) + else: + return types.InputFile( + id=file_id, + parts=file_total_parts, + name=os.path.basename(path), + md5_checksum=md5_sum + ) + finally: + session.stop() + + def get_file(self, + dc_id: int, + id: int = None, + access_hash: int = None, + volume_id: int = None, + local_id: int = None, + secret: int = None, + size: int = None, + progress: callable = None, + progress_args: tuple = ()) -> str: + with self.media_sessions_lock: + session = self.media_sessions.get(dc_id, None) + + if session is None: + if dc_id != self.dc_id: + exported_auth = self.send( + functions.auth.ExportAuthorization( + dc_id=dc_id + ) + ) + + session = Session( + self, + dc_id, + Auth(dc_id, self.test_mode, self.ipv6, self._proxy).create(), + is_media=True + ) + + session.start() + + self.media_sessions[dc_id] = session + + session.send( + functions.auth.ImportAuthorization( + id=exported_auth.id, + bytes=exported_auth.bytes + ) + ) + else: + session = Session( + self, + dc_id, + self.auth_key, + is_media=True + ) + + session.start() + + self.media_sessions[dc_id] = session + + if volume_id: # Photos are accessed by volume_id, local_id, secret + location = types.InputFileLocation( + volume_id=volume_id, + local_id=local_id, + secret=secret, + file_reference=b"" + ) + else: # Any other file can be more easily accessed by id and access_hash + location = types.InputDocumentFileLocation( + id=id, + access_hash=access_hash, + file_reference=b"" + ) + + limit = 1024 * 1024 + offset = 0 + file_name = "" + + try: + r = session.send( + functions.upload.GetFile( + location=location, + offset=offset, + limit=limit + ) + ) + + if isinstance(r, types.upload.File): + with tempfile.NamedTemporaryFile("wb", delete=False) as f: + file_name = f.name + + while True: + chunk = r.bytes + + if not chunk: + break + + f.write(chunk) + + offset += limit + + if progress: + progress(self, min(offset, size) if size != 0 else offset, size, *progress_args) + + r = session.send( + functions.upload.GetFile( + location=location, + offset=offset, + limit=limit + ) + ) + + elif isinstance(r, types.upload.FileCdnRedirect): + with self.media_sessions_lock: + cdn_session = self.media_sessions.get(r.dc_id, None) + + if cdn_session is None: + cdn_session = Session( + self, + r.dc_id, + Auth(r.dc_id, self.test_mode, self.ipv6, self._proxy).create(), + is_media=True, + is_cdn=True + ) + + cdn_session.start() + + self.media_sessions[r.dc_id] = cdn_session + + try: + with tempfile.NamedTemporaryFile("wb", delete=False) as f: + file_name = f.name + + while True: + r2 = cdn_session.send( + functions.upload.GetCdnFile( + file_token=r.file_token, + offset=offset, + limit=limit + ) + ) + + if isinstance(r2, types.upload.CdnFileReuploadNeeded): + try: + session.send( + functions.upload.ReuploadCdnFile( + file_token=r.file_token, + request_token=r2.request_token + ) + ) + except VolumeLocNotFound: + break + else: + continue + + chunk = r2.bytes + + # https://core.telegram.org/cdn#decrypting-files + decrypted_chunk = AES.ctr256_decrypt( + chunk, + r.encryption_key, + bytearray( + r.encryption_iv[:-4] + + (offset // 16).to_bytes(4, "big") + ) + ) + + hashes = session.send( + functions.upload.GetCdnFileHashes( + r.file_token, + offset + ) + ) + + # https://core.telegram.org/cdn#verifying-files + for i, h in enumerate(hashes): + cdn_chunk = decrypted_chunk[h.limit * i: h.limit * (i + 1)] + assert h.hash == sha256(cdn_chunk).digest(), "Invalid CDN hash part {}".format(i) + + f.write(decrypted_chunk) + + offset += limit + + if progress: + progress(self, min(offset, size) if size != 0 else offset, size, *progress_args) + + if len(chunk) < limit: + break + except Exception as e: + raise e + except Exception as e: + if not isinstance(e, Client.StopTransmission): + log.error(e, exc_info=True) + + try: + os.remove(file_name) + except OSError: + pass + + return "" + else: + return file_name From 6ec3b12aebf3e486f73967bb91f807e3a15f6061 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Wed, 16 Jan 2019 15:40:02 +0100 Subject: [PATCH 04/22] Smart plugins enhancements --- pyrogram/client/client.py | 153 +++++++++++++++++++++++++++++++------- 1 file changed, 126 insertions(+), 27 deletions(-) diff --git a/pyrogram/client/client.py b/pyrogram/client/client.py index 576dea9a..fe4de3ff 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -157,10 +157,8 @@ class Client(Methods, BaseClient): config_file (``str``, *optional*): Path of the configuration file. Defaults to ./config.ini - plugins_dir (``str``, *optional*): - Define a custom directory for your plugins. The plugins directory is the location in your - filesystem where Pyrogram will automatically load your update handlers. - Defaults to None (plugins disabled). + plugins (``dict``, *optional*): + TODO: doctrings no_updates (``bool``, *optional*): Pass True to completely disable incoming updates for the current session. @@ -197,7 +195,7 @@ class Client(Methods, BaseClient): workers: int = BaseClient.WORKERS, workdir: str = BaseClient.WORKDIR, config_file: str = BaseClient.CONFIG_FILE, - plugins_dir: str = None, + plugins: dict = None, no_updates: bool = None, takeout: bool = None): super().__init__() @@ -223,7 +221,7 @@ class Client(Methods, BaseClient): self.workers = workers self.workdir = workdir self.config_file = config_file - self.plugins_dir = plugins_dir + self.plugins = plugins self.no_updates = no_updates self.takeout = takeout @@ -1074,6 +1072,38 @@ class Client(Methods, BaseClient): self._proxy["username"] = parser.get("proxy", "username", fallback=None) or None self._proxy["password"] = parser.get("proxy", "password", fallback=None) or None + if self.plugins: + self.plugins["enabled"] = bool(self.plugins.get("enabled", True)) + else: + self.plugins = {} + + try: + section = parser["plugins"] + + include = section.get("include") or None + exclude = section.get("exclude") or None + + if include is not None: + include = [ + (i.split()[0], i.split()[1:] or None) + for i in include.strip().split("\n") + ] + + if exclude is not None: + exclude = [ + (i.split()[0], i.split()[1:] or None) + for i in exclude.strip().split("\n") + ] + + self.plugins["enabled"] = section.getboolean("enabled", True) + self.plugins["root"] = section.get("root") + self.plugins["include"] = include + self.plugins["exclude"] = exclude + except KeyError: + pass + else: + print(self.plugins) + def load_session(self): try: with open(os.path.join(self.workdir, "{}.session".format(self.session_name)), encoding="utf-8") as f: @@ -1105,43 +1135,112 @@ class Client(Methods, BaseClient): self.peers_by_phone[k] = peer def load_plugins(self): - if self.plugins_dir is not None: + if self.plugins.get("enabled", False): + root = self.plugins["root"] + include = self.plugins["include"] + exclude = self.plugins["exclude"] + plugins_count = 0 - for path in Path(self.plugins_dir).rglob("*.py"): - file_path = os.path.splitext(str(path))[0] - import_path = [] + if include is None: + for path in sorted(Path(root).rglob("*.py")): + module_path = os.path.splitext(str(path))[0].replace("/", ".") + module = import_module(module_path) - while file_path: - file_path, tail = os.path.split(file_path) - import_path.insert(0, tail) + for name in vars(module).keys(): + # noinspection PyBroadException + try: + handler, group = getattr(module, name) - import_path = ".".join(import_path) - module = import_module(import_path) + if isinstance(handler, Handler) and isinstance(group, int): + self.add_handler(handler, group) + + log.info('[LOAD] {}("{}") in group {} from "{}"'.format( + type(handler).__name__, name, group, module_path)) + + plugins_count += 1 + except Exception: + pass + else: + for path, handlers in include: + module_path = root + "." + path + warn_non_existent_functions = True - for name in dir(module): - # noinspection PyBroadException try: - handler, group = getattr(module, name) + module = import_module(module_path) + except ModuleNotFoundError: + log.warning('[LOAD] Ignoring non-existent module "{}"'.format(module_path)) + continue - if isinstance(handler, Handler) and isinstance(group, int): - self.add_handler(handler, group) + if "__path__" in dir(module): + log.warning('[LOAD] Ignoring namespace "{}"'.format(module_path)) + continue - log.info('{}("{}") from "{}" loaded in group {}'.format( - type(handler).__name__, name, import_path, group)) + if handlers is None: + handlers = vars(module).keys() + warn_non_existent_functions = False - plugins_count += 1 - except Exception: - pass + for name in handlers: + # noinspection PyBroadException + try: + handler, group = getattr(module, name) + + if isinstance(handler, Handler) and isinstance(group, int): + self.add_handler(handler, group) + + log.info('[LOAD] {}("{}") in group {} from "{}"'.format( + type(handler).__name__, name, group, module_path)) + + plugins_count += 1 + except Exception: + if warn_non_existent_functions: + log.warning('[LOAD] Ignoring non-existent function "{}" from "{}"'.format( + name, module_path)) + + if exclude is not None: + for path, handlers in exclude: + module_path = root + "." + path + warn_non_existent_functions = True + + try: + module = import_module(module_path) + except ModuleNotFoundError: + log.warning('[UNLOAD] Ignoring non-existent module "{}"'.format(module_path)) + continue + + if "__path__" in dir(module): + log.warning('[UNLOAD] Ignoring namespace "{}"'.format(module_path)) + continue + + if handlers is None: + handlers = vars(module).keys() + warn_non_existent_functions = False + + for name in handlers: + # noinspection PyBroadException + try: + handler, group = getattr(module, name) + + if isinstance(handler, Handler) and isinstance(group, int): + self.remove_handler(handler, group) + + log.info('[UNLOAD] {}("{}") from group {} in "{}"'.format( + type(handler).__name__, name, group, module_path)) + + plugins_count -= 1 + except Exception: + if warn_non_existent_functions: + log.warning('[UNLOAD] Ignoring non-existent function "{}" from "{}"'.format( + name, module_path)) if plugins_count > 0: log.warning('Successfully loaded {} plugin{} from "{}"'.format( plugins_count, "s" if plugins_count > 1 else "", - self.plugins_dir + root )) else: - log.warning('No plugin loaded: "{}" doesn\'t contain any valid plugin'.format(self.plugins_dir)) + log.warning('No plugin loaded: "{}" doesn\'t contain any valid plugin'.format(root)) def save_session(self): auth_key = base64.b64encode(self.auth_key).decode() From be013de4d4b0534d8428a4a39dadc738294a659e Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Wed, 16 Jan 2019 20:25:48 +0100 Subject: [PATCH 05/22] Fix plugins load via Client parameter --- pyrogram/client/client.py | 56 +++++++++++++++------------------------ 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/pyrogram/client/client.py b/pyrogram/client/client.py index fe4de3ff..eb205bc2 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -1074,35 +1074,27 @@ class Client(Methods, BaseClient): if self.plugins: self.plugins["enabled"] = bool(self.plugins.get("enabled", True)) + self.plugins["include"] = "\n".join(self.plugins.get("include", [])) or None + self.plugins["exclude"] = "\n".join(self.plugins.get("exclude", [])) or None else: - self.plugins = {} - try: section = parser["plugins"] - include = section.get("include") or None - exclude = section.get("exclude") or None - - if include is not None: - include = [ - (i.split()[0], i.split()[1:] or None) - for i in include.strip().split("\n") - ] - - if exclude is not None: - exclude = [ - (i.split()[0], i.split()[1:] or None) - for i in exclude.strip().split("\n") - ] - - self.plugins["enabled"] = section.getboolean("enabled", True) - self.plugins["root"] = section.get("root") - self.plugins["include"] = include - self.plugins["exclude"] = exclude + self.plugins = { + "enabled": section.getboolean("enabled", True), + "root": section.get("root"), + "include": section.get("include") or None, + "exclude": section.get("exclude") or None + } except KeyError: pass - else: - print(self.plugins) + + for option in ["include", "exclude"]: + if self.plugins[option] is not None: + self.plugins[option] = [ + (i.split()[0], i.split()[1:] or None) + for i in self.plugins[option].strip().split("\n") + ] def load_session(self): try: @@ -1140,7 +1132,7 @@ class Client(Methods, BaseClient): include = self.plugins["include"] exclude = self.plugins["exclude"] - plugins_count = 0 + count = 0 if include is None: for path in sorted(Path(root).rglob("*.py")): @@ -1158,7 +1150,7 @@ class Client(Methods, BaseClient): log.info('[LOAD] {}("{}") in group {} from "{}"'.format( type(handler).__name__, name, group, module_path)) - plugins_count += 1 + count += 1 except Exception: pass else: @@ -1191,7 +1183,7 @@ class Client(Methods, BaseClient): log.info('[LOAD] {}("{}") in group {} from "{}"'.format( type(handler).__name__, name, group, module_path)) - plugins_count += 1 + count += 1 except Exception: if warn_non_existent_functions: log.warning('[LOAD] Ignoring non-existent function "{}" from "{}"'.format( @@ -1227,20 +1219,16 @@ class Client(Methods, BaseClient): log.info('[UNLOAD] {}("{}") from group {} in "{}"'.format( type(handler).__name__, name, group, module_path)) - plugins_count -= 1 + count -= 1 except Exception: if warn_non_existent_functions: log.warning('[UNLOAD] Ignoring non-existent function "{}" from "{}"'.format( name, module_path)) - if plugins_count > 0: - log.warning('Successfully loaded {} plugin{} from "{}"'.format( - plugins_count, - "s" if plugins_count > 1 else "", - root - )) + if count > 0: + log.warning('Successfully loaded {} plugin{} from "{}"'.format(count, "s" if count > 1 else "", root)) else: - log.warning('No plugin loaded: "{}" doesn\'t contain any valid plugin'.format(root)) + log.warning('No plugin loaded from "{}"'.format(root)) def save_session(self): auth_key = base64.b64encode(self.auth_key).decode() From 919894cd3f9d2f9911328ec8fe9ec72779f19d82 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Thu, 17 Jan 2019 15:21:50 +0100 Subject: [PATCH 06/22] Update Smart Plugins docs --- docs/source/resources/SmartPlugins.rst | 195 +++++++++++++++++++++++-- 1 file changed, 181 insertions(+), 14 deletions(-) diff --git a/docs/source/resources/SmartPlugins.rst b/docs/source/resources/SmartPlugins.rst index 46c4e17a..3bff9632 100644 --- a/docs/source/resources/SmartPlugins.rst +++ b/docs/source/resources/SmartPlugins.rst @@ -1,9 +1,9 @@ Smart Plugins ============= -Pyrogram embeds a **smart** (automatic) and lightweight plugin system that is meant to further simplify the organization -of large projects and to provide a way for creating pluggable components that can be **easily shared** across different -Pyrogram applications with **minimal boilerplate code**. +Pyrogram embeds a **smart**, lightweight yet powerful plugin system that is meant to further simplify the organization +of large projects and to provide a way for creating pluggable (modular) components that can be **easily shared** across +different Pyrogram applications with **minimal boilerplate code**. .. tip:: @@ -13,7 +13,7 @@ Introduction ------------ Prior to the Smart Plugin system, pluggable handlers were already possible. For example, if you wanted to modularize -your applications, you had to do something like this... +your applications, you had to do something like this: .. note:: @@ -63,7 +63,7 @@ your applications, you had to do something like this... app.run() -...which is already nice and doesn't add *too much* boilerplate code, but things can get boring still; you have to +This is already nice and doesn't add *too much* boilerplate code, but things can get boring still; you have to manually ``import``, manually :meth:`add_handler ` and manually instantiate each :obj:`MessageHandler ` object because **you can't use those cool decorators** for your functions. So... What if you could? @@ -74,8 +74,8 @@ Using Smart Plugins Setting up your Pyrogram project to accommodate Smart Plugins is pretty straightforward: #. Create a new folder to store all the plugins (e.g.: "plugins"). -#. Put your files full of plugins inside. -#. Enable plugins in your Client. +#. Put your files full of plugins inside. Organize them as you wish. +#. Enable plugins in your Client or via the *config.ini* file. .. note:: @@ -107,20 +107,187 @@ Setting up your Pyrogram project to accommodate Smart Plugins is pretty straight def echo_reversed(client, message): message.reply(message.text[::-1]) +- ``config.ini`` + + .. code-block:: ini + + [plugins] + root = plugins + - ``main.py`` .. code-block:: python from pyrogram import Client - Client("my_account", plugins_dir="plugins").run() + Client("my_account").run() -The first important thing to note is the new ``plugins`` folder, whose name is passed to the the ``plugins_dir`` -parameter when creating a :obj:`Client ` in the ``main.py`` file — you can put *any python file* in -there and each file can contain *any decorated function* (handlers) with only one limitation: within a single plugin -file you must use different names for each decorated function. Your Pyrogram Client instance will **automatically** -scan the folder upon creation to search for valid handlers and register them for you. + Alternatively, without using the *config.ini* file: + + .. code-block:: python + + from pyrogram import Client + + plugins = dict( + root="plugins" + ) + + Client("my_account", plugins=plugins).run() + +The first important thing to note is the new ``plugins`` folder. You can put *any python file* in *any subfolder* and +each file can contain *any decorated function* (handlers) with one limitation: within a single module (file) you must +use different names for each decorated function. + +The second thing is telling Pyrogram where to look for your plugins: you can either use the *config.ini* file or +the Client parameter "plugins"; the *root* value must match the name of your plugins folder. Your Pyrogram Client +instance will **automatically** scan the folder upon starting to search for valid handlers and register them for you. Then you'll notice you can now use decorators. That's right, you can apply the usual decorators to your callback functions in a static way, i.e. **without having the Client instance around**: simply use ``@Client`` (Client class) -instead of the usual ``@app`` (Client instance) namespace and things will work just the same. +instead of the usual ``@app`` (Client instance) and things will work just the same. + +Specifying the Plugins to include +--------------------------------- + +By default, if you don't explicitly supply a list of plugins, every valid one found inside your plugins root folder will +be included by following the alphabetical order of the directory structure (files and subfolders); the single handlers +found inside each module will be, instead, loaded in the order they are defined, from top to bottom. + +.. note:: + + Remember: there can be at most one handler, within a group, dealing with a specific update. Plugins with overlapping + filters included a second time will not work. Learn more at `More on Updates `_. + +This default loading behaviour is usually enough, but sometimes you want to have more control on what to include (or +exclude) and in which exact order to load plugins. The way to do this is to make use of ``include`` and ``exclude`` +keys, either in the *config.ini* or in the dictionary passed as Client argument. Here's how they work: + +- If both ``include`` and ``exclude`` are omitted, all plugins are loaded as described above. +- If ``include`` is given, only the specified plugins will be loaded, in the order they are passed. +- If ``exclude`` is given, the plugins specified here will be unloaded. + +The ``include`` and ``exclude`` value is a **list of strings**. Each string containing the path of the module relative +to the plugins root folder, in Python notation (dots instead of slashes). + + E.g.: ``subfolder.module`` refers to ``plugins/subfolder/module.py`` (root="plugins"). + +You can also choose the order in which the single handlers inside a module are loaded, thus overriding the default +top-to-bottom loading policy. You can do this by appending the name of the functions to the module path, each one +separated by a blank space. + + E.g.: ``subfolder.module fn2 fn1 fn3`` will load *fn2*, *fn1* and *fn3* from *subfolder.module*, in this order. + +Examples +^^^^^^^^ + +Given this plugins folder structure with three modules, each containing their own handlers (fn1, fn2, etc...), which are +also organized in subfolders: + +.. code-block:: text + + myproject/ + plugins/ + subfolder1/ + plugins1.py + - fn1 + - fn2 + - fn3 + subfolder2/ + plugins2.py + ... + plugins0.py + ... + ... + +- Load every handler from every module, namely *plugins0.py*, *plugins1.py* and *plugins2.py* in alphabetical order + (files) and definition order (handlers inside files): + + Using *config.ini* file: + + .. code-block:: ini + + [plugins] + root = plugins + + Using *Client*'s parameter: + + .. code-block:: python + + plugins = dict( + root="plugins" + ) + + Client("my_account", plugins=plugins).run() + +- Load only handlers defined inside *plugins2.py* and *plugins0.py*, in this order: + + Using *config.ini* file: + + .. code-block:: ini + + [plugins] + root = plugins + include = + subfolder2.plugins2 + plugins0 + + Using *Client*'s parameter: + + .. code-block:: python + + plugins = dict( + root="plugins", + include=[ + "subfolder2.plugins2", + "plugins0" + ] + ) + + Client("my_account", plugins=plugins).run() + +- Load everything except the handlers inside *plugins2.py*: + + Using *config.ini* file: + + .. code-block:: ini + + [plugins] + root = plugins + exclude = subfolder2.plugins2 + + Using *Client*'s parameter: + + .. code-block:: python + + plugins = dict( + root="plugins", + exclude=["subfolder2.plugins2"] + ) + + Client("my_account", plugins=plugins).run() + +- Load only *fn3*, *fn1* and *fn2* (in this order) from *plugins1.py*: + + Using *config.ini* file: + + .. code-block:: ini + + [plugins] + root = plugins + include = subfolder1.plugins1 fn3 fn1 fn2 + + Using *Client*'s parameter: + + .. code-block:: python + + plugins = dict( + root="plugins", + include=["subfolder1.plugins1 fn3 fn1 fn2"] + ) + + Client("my_account", plugins=plugins).run() + +Load/Unload Plugins at Runtime +------------------------------ + +TODO \ No newline at end of file From 8cfb6614d4fd8ee4c58bfc0d4018888388f26abc Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Thu, 17 Jan 2019 15:23:46 +0100 Subject: [PATCH 07/22] Rename TgCrypto title to Fast Crypto More generic name. Keep TgCrypto.rst in order not to break links --- docs/source/resources/TgCrypto.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/resources/TgCrypto.rst b/docs/source/resources/TgCrypto.rst index 734c48e4..2af09a06 100644 --- a/docs/source/resources/TgCrypto.rst +++ b/docs/source/resources/TgCrypto.rst @@ -1,5 +1,5 @@ -TgCrypto -======== +Fast Crypto +=========== Pyrogram's speed can be *dramatically* boosted up by TgCrypto_, a high-performance, easy-to-install Telegram Crypto Library specifically written in C for Pyrogram [#f1]_ as a Python extension. From 76d4e4f60e721b7aab3586745fdf6131bff00b97 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 21 Jan 2019 15:36:54 +0100 Subject: [PATCH 08/22] Fix "left" status not being parsed in ChatMember (#204) --- pyrogram/client/types/user_and_chats/chat_member.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyrogram/client/types/user_and_chats/chat_member.py b/pyrogram/client/types/user_and_chats/chat_member.py index e901e0e1..3a4a93f7 100644 --- a/pyrogram/client/types/user_and_chats/chat_member.py +++ b/pyrogram/client/types/user_and_chats/chat_member.py @@ -155,7 +155,11 @@ class ChatMember(PyrogramType): chat_member = ChatMember( user=user, - status="kicked" if rights.view_messages else "restricted", + status=( + "left" if member.left + else "kicked" if rights.view_messages + else "restricted" + ), until_date=0 if rights.until_date == (1 << 31) - 1 else rights.until_date, client=client ) From d6a1503344d73def675205cc0563aa3cb43ef480 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 21 Jan 2019 15:38:36 +0100 Subject: [PATCH 09/22] Add "date" attribute to ChatMember (#204) --- pyrogram/client/types/user_and_chats/chat_member.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pyrogram/client/types/user_and_chats/chat_member.py b/pyrogram/client/types/user_and_chats/chat_member.py index 3a4a93f7..6b024737 100644 --- a/pyrogram/client/types/user_and_chats/chat_member.py +++ b/pyrogram/client/types/user_and_chats/chat_member.py @@ -33,6 +33,9 @@ class ChatMember(PyrogramType): The member's status in the chat. Can be "creator", "administrator", "member", "restricted", "left" or "kicked". + date (``int``, *optional*): + Date when the user joined, unix time. Not available for creator. + until_date (``int``, *optional*): Restricted and kicked only. Date when restrictions will be lifted for this user, unix time. @@ -86,6 +89,7 @@ class ChatMember(PyrogramType): client: "pyrogram.client.ext.BaseClient", user: "pyrogram.User", status: str, + date: int = None, until_date: int = None, can_be_edited: bool = None, can_change_info: bool = None, @@ -104,6 +108,7 @@ class ChatMember(PyrogramType): self.user = user self.status = status + self.date = date self.until_date = until_date self.can_be_edited = can_be_edited self.can_change_info = can_change_info @@ -124,13 +129,13 @@ class ChatMember(PyrogramType): user = pyrogram.User._parse(client, user) if isinstance(member, (types.ChannelParticipant, types.ChannelParticipantSelf, types.ChatParticipant)): - return ChatMember(user=user, status="member", client=client) + return ChatMember(user=user, status="member", date=member.date, client=client) if isinstance(member, (types.ChannelParticipantCreator, types.ChatParticipantCreator)): return ChatMember(user=user, status="creator", client=client) if isinstance(member, types.ChatParticipantAdmin): - return ChatMember(user=user, status="administrator", client=client) + return ChatMember(user=user, status="administrator", date=member.date, client=client) if isinstance(member, types.ChannelParticipantAdmin): rights = member.admin_rights @@ -138,6 +143,7 @@ class ChatMember(PyrogramType): return ChatMember( user=user, status="administrator", + date=member.date, can_be_edited=member.can_edit, can_change_info=rights.change_info, can_post_messages=rights.post_messages, @@ -160,6 +166,7 @@ class ChatMember(PyrogramType): else "kicked" if rights.view_messages else "restricted" ), + date=member.date, until_date=0 if rights.until_date == (1 << 31) - 1 else rights.until_date, client=client ) From a57ee7b33392655db84b638fc3605508881cdfb8 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 21 Jan 2019 16:33:33 +0100 Subject: [PATCH 10/22] Accommodate parsing of invited_by attribute of ChatMember (#204) --- pyrogram/client/methods/chats/get_chat_member.py | 4 +++- pyrogram/client/types/user_and_chats/chat_members.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyrogram/client/methods/chats/get_chat_member.py b/pyrogram/client/methods/chats/get_chat_member.py index 4b67dd5e..faf0c33b 100644 --- a/pyrogram/client/methods/chats/get_chat_member.py +++ b/pyrogram/client/methods/chats/get_chat_member.py @@ -67,6 +67,8 @@ class GetChatMember(BaseClient): ) ) - return pyrogram.ChatMember._parse(self, r.participant, r.users[0]) + users = {i.id: i for i in r.users} + + return pyrogram.ChatMember._parse(self, r.participant, users) else: raise ValueError("The chat_id \"{}\" belongs to a user".format(chat_id)) diff --git a/pyrogram/client/types/user_and_chats/chat_members.py b/pyrogram/client/types/user_and_chats/chat_members.py index 88219514..39d69089 100644 --- a/pyrogram/client/types/user_and_chats/chat_members.py +++ b/pyrogram/client/types/user_and_chats/chat_members.py @@ -59,7 +59,7 @@ class ChatMembers(PyrogramType): total_count = len(members) for member in members: - chat_members.append(ChatMember._parse(client, member, users[member.user_id])) + chat_members.append(ChatMember._parse(client, member, users)) return ChatMembers( total_count=total_count, From 16b7203ee9593f546c61172a52b9bad3a6cb57ed Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 21 Jan 2019 16:34:46 +0100 Subject: [PATCH 11/22] Add invite_by attribute in ChatMember (#204) --- .../client/types/user_and_chats/chat_member.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/pyrogram/client/types/user_and_chats/chat_member.py b/pyrogram/client/types/user_and_chats/chat_member.py index 6b024737..38e90b8d 100644 --- a/pyrogram/client/types/user_and_chats/chat_member.py +++ b/pyrogram/client/types/user_and_chats/chat_member.py @@ -36,6 +36,10 @@ class ChatMember(PyrogramType): date (``int``, *optional*): Date when the user joined, unix time. Not available for creator. + invited_by (:obj:`User `, *optional*): + Information about the user who invited this member. + In case the user joined by himself this will be the same as "user". + until_date (``int``, *optional*): Restricted and kicked only. Date when restrictions will be lifted for this user, unix time. @@ -90,6 +94,7 @@ class ChatMember(PyrogramType): user: "pyrogram.User", status: str, date: int = None, + invited_by: "pyrogram.User" = None, until_date: int = None, can_be_edited: bool = None, can_change_info: bool = None, @@ -109,6 +114,7 @@ class ChatMember(PyrogramType): self.user = user self.status = status self.date = date + self.invited_by = invited_by self.until_date = until_date self.can_be_edited = can_be_edited self.can_change_info = can_change_info @@ -125,17 +131,18 @@ class ChatMember(PyrogramType): self.can_add_web_page_previews = can_add_web_page_previews @staticmethod - def _parse(client, member, user) -> "ChatMember": - user = pyrogram.User._parse(client, user) + def _parse(client, member, users) -> "ChatMember": + user = pyrogram.User._parse(client, users[member.user_id]) + invited_by = pyrogram.User._parse(client, users[member.inviter_id]) if hasattr(member, "inviter_id") else None if isinstance(member, (types.ChannelParticipant, types.ChannelParticipantSelf, types.ChatParticipant)): - return ChatMember(user=user, status="member", date=member.date, client=client) + return ChatMember(user=user, status="member", date=member.date, invited_by=invited_by, client=client) if isinstance(member, (types.ChannelParticipantCreator, types.ChatParticipantCreator)): return ChatMember(user=user, status="creator", client=client) if isinstance(member, types.ChatParticipantAdmin): - return ChatMember(user=user, status="administrator", date=member.date, client=client) + return ChatMember(user=user, status="administrator", date=member.date, invited_by=invited_by, client=client) if isinstance(member, types.ChannelParticipantAdmin): rights = member.admin_rights @@ -144,6 +151,7 @@ class ChatMember(PyrogramType): user=user, status="administrator", date=member.date, + invited_by=invited_by, can_be_edited=member.can_edit, can_change_info=rights.change_info, can_post_messages=rights.post_messages, From f0c8f65e9dc47447ed37a8216d03e63f0018b6bd Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 21 Jan 2019 16:41:56 +0100 Subject: [PATCH 12/22] Add promoted_by attribute in ChatMember (#204) --- pyrogram/client/types/user_and_chats/chat_member.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyrogram/client/types/user_and_chats/chat_member.py b/pyrogram/client/types/user_and_chats/chat_member.py index 38e90b8d..c2dbccbd 100644 --- a/pyrogram/client/types/user_and_chats/chat_member.py +++ b/pyrogram/client/types/user_and_chats/chat_member.py @@ -37,9 +37,12 @@ class ChatMember(PyrogramType): Date when the user joined, unix time. Not available for creator. invited_by (:obj:`User `, *optional*): - Information about the user who invited this member. + Administrators and self member only. Information about the user who invited this member. In case the user joined by himself this will be the same as "user". + promoted_by (:obj:`User `, *optional*): + Administrators only. Information about the user who promoted this member as administrator. + until_date (``int``, *optional*): Restricted and kicked only. Date when restrictions will be lifted for this user, unix time. @@ -95,6 +98,7 @@ class ChatMember(PyrogramType): status: str, date: int = None, invited_by: "pyrogram.User" = None, + promoted_by: "pyrogram.User" = None, until_date: int = None, can_be_edited: bool = None, can_change_info: bool = None, @@ -115,6 +119,7 @@ class ChatMember(PyrogramType): self.status = status self.date = date self.invited_by = invited_by + self.promoted_by = promoted_by self.until_date = until_date self.can_be_edited = can_be_edited self.can_change_info = can_change_info @@ -152,6 +157,7 @@ class ChatMember(PyrogramType): status="administrator", date=member.date, invited_by=invited_by, + promoted_by=pyrogram.User._parse(client, users[member.promoted_by]), can_be_edited=member.can_edit, can_change_info=rights.change_info, can_post_messages=rights.post_messages, From b919ed82420c9358dcb5c73858d5fc4fd0a55193 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 21 Jan 2019 16:53:54 +0100 Subject: [PATCH 13/22] Add restricted_by attribute in ChatMember (#204) --- pyrogram/client/types/user_and_chats/chat_member.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyrogram/client/types/user_and_chats/chat_member.py b/pyrogram/client/types/user_and_chats/chat_member.py index c2dbccbd..590a67e5 100644 --- a/pyrogram/client/types/user_and_chats/chat_member.py +++ b/pyrogram/client/types/user_and_chats/chat_member.py @@ -43,6 +43,9 @@ class ChatMember(PyrogramType): promoted_by (:obj:`User `, *optional*): Administrators only. Information about the user who promoted this member as administrator. + restricted_by (:obj:`User `, *optional*): + Restricted and kicked only. Information about the user who restricted or kicked this member. + until_date (``int``, *optional*): Restricted and kicked only. Date when restrictions will be lifted for this user, unix time. @@ -99,6 +102,7 @@ class ChatMember(PyrogramType): date: int = None, invited_by: "pyrogram.User" = None, promoted_by: "pyrogram.User" = None, + restricted_by: "pyrogram.User" = None, until_date: int = None, can_be_edited: bool = None, can_change_info: bool = None, @@ -120,6 +124,7 @@ class ChatMember(PyrogramType): self.date = date self.invited_by = invited_by self.promoted_by = promoted_by + self.restricted_by = restricted_by self.until_date = until_date self.can_be_edited = can_be_edited self.can_change_info = can_change_info @@ -181,6 +186,7 @@ class ChatMember(PyrogramType): else "restricted" ), date=member.date, + restricted_by=pyrogram.User._parse(client, users[member.kicked_by]), until_date=0 if rights.until_date == (1 << 31) - 1 else rights.until_date, client=client ) From c0a5b0a2c3263a26376beca3cc78b69df6cce59d Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 21 Jan 2019 16:56:22 +0100 Subject: [PATCH 14/22] Fix kicked members reporting "left" as status --- pyrogram/client/types/user_and_chats/chat_member.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyrogram/client/types/user_and_chats/chat_member.py b/pyrogram/client/types/user_and_chats/chat_member.py index 590a67e5..70f32540 100644 --- a/pyrogram/client/types/user_and_chats/chat_member.py +++ b/pyrogram/client/types/user_and_chats/chat_member.py @@ -181,8 +181,8 @@ class ChatMember(PyrogramType): chat_member = ChatMember( user=user, status=( - "left" if member.left - else "kicked" if rights.view_messages + "kicked" if rights.view_messages + else "left" if member.left else "restricted" ), date=member.date, From 44deabf3999da863d41e631619b3b2abedde89bc Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Thu, 24 Jan 2019 17:21:41 +0100 Subject: [PATCH 15/22] Update iter_chat_members efficiency --- pyrogram/client/ext/base_client.py | 3 +++ pyrogram/client/methods/chats/iter_chat_members.py | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pyrogram/client/ext/base_client.py b/pyrogram/client/ext/base_client.py index 7b94ae6e..d2c348a8 100644 --- a/pyrogram/client/ext/base_client.py +++ b/pyrogram/client/ext/base_client.py @@ -126,3 +126,6 @@ class BaseClient: def get_chat_members(self, *args, **kwargs): pass + + def get_chat_members_count(self, *args, **kwargs): + pass diff --git a/pyrogram/client/methods/chats/iter_chat_members.py b/pyrogram/client/methods/chats/iter_chat_members.py index 963081f8..5d0fa911 100644 --- a/pyrogram/client/methods/chats/iter_chat_members.py +++ b/pyrogram/client/methods/chats/iter_chat_members.py @@ -81,9 +81,14 @@ class IterChatMembers(BaseClient): yielded = set() queries = [query] if query else QUERIES total = limit or (1 << 31) - 1 - filter = Filters.RECENT if total <= 10000 and filter == Filters.ALL else filter limit = min(200, total) + filter = ( + Filters.RECENT + if self.get_chat_members_count(chat_id) <= 10000 and filter == Filters.ALL + else filter + ) + if filter not in QUERYABLE_FILTERS: queries = [""] From 8a41075dc7f30d1f8a9610f2eabfe14e2f606938 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Thu, 24 Jan 2019 20:00:59 +0100 Subject: [PATCH 16/22] Update README.rst --- README.rst | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/README.rst b/README.rst index f7d2b44e..fcc6407a 100644 --- a/README.rst +++ b/README.rst @@ -3,6 +3,8 @@ Pyrogram ======== + `A fully asynchronous variant is also available! `_ + .. code-block:: python from pyrogram import Client, Filters @@ -17,18 +19,20 @@ Pyrogram app.run() -**Pyrogram** is a brand new Telegram_ Client Library written from the ground up in Python and C. It can be used for -building custom Telegram applications that interact with the MTProto API as both User and Bot. +**Pyrogram** is an elegant, easy-to-use Telegram_ client library and framework written from the ground up in Python and C. +It enables you to easily build custom Telegram applications that interact with the MTProto API as both user and bot. Features -------- -- **Easy to use**: You can easily install Pyrogram using pip and start building your app right away. -- **High-level**: The low-level details of MTProto are abstracted and automatically handled. +- **Easy**: You can install Pyrogram with pip and start building your app right away. +- **Elegant**: Low-level details are abstracted and re-presented in a much nicer and easier way. - **Fast**: Crypto parts are boosted up by TgCrypto_, a high-performance library written in pure C. -- **Updated** to the latest Telegram API version, currently Layer 91 on top of MTProto 2.0. -- **Documented**: The Pyrogram API is well documented and resembles the Telegram Bot API. -- **Full API**, allowing to execute any advanced action an official client is able to do, and more. +- **Documented**: Pyrogram API methods, types and public interfaces are well documented. +- **Type-hinted**: Exposed Pyrogram types and method parameters are all type-hinted. +- **Updated**, to the latest Telegram API version, currently Layer 91 on top of MTProto 2.0. +- **Pluggable**: The Smart Plugin system allows to write components with minimal boilerplate code. +- **Comprehensive**: Execute any advanced action an official client is able to do, and even more. Requirements ------------ @@ -47,7 +51,7 @@ Getting Started --------------- - The Docs contain lots of resources to help you getting started with Pyrogram: https://docs.pyrogram.ml. -- Reading Examples_ in this repository is also a good way for learning how things work. +- Reading `Examples in this repository`_ is also a good way for learning how Pyrogram works. - Seeking extra help? Don't be shy, come join and ask our Community_! - For other requests you can send an Email_ or a Message_. @@ -67,7 +71,7 @@ Copyright & License .. _`Telegram`: https://telegram.org/ .. _`Telegram API key`: https://docs.pyrogram.ml/start/ProjectSetup#api-keys .. _`Community`: https://t.me/PyrogramChat -.. _`Examples`: https://github.com/pyrogram/pyrogram/tree/master/examples +.. _`Examples in this repository`: https://github.com/pyrogram/pyrogram/tree/master/examples .. _`GitHub`: https://github.com/pyrogram/pyrogram/issues .. _`Email`: admin@pyrogram.ml .. _`Message`: https://t.me/haskell @@ -83,17 +87,17 @@ Copyright & License

- Telegram MTProto API Client Library for Python + Telegram MTProto API Framework for Python
- - Download - - • Documentation • + + Changelog + + • Community @@ -104,7 +108,7 @@ Copyright & License TgCrypto + alt="TgCrypto Version">

@@ -112,12 +116,12 @@ Copyright & License :target: https://pyrogram.ml :alt: Pyrogram -.. |description| replace:: **Telegram MTProto API Client Library for Python** +.. |description| replace:: **Telegram MTProto API Framework for Python** -.. |scheme| image:: "https://img.shields.io/badge/schema-layer%2091-eda738.svg?longCache=true&colorA=262b30" +.. |schema| image:: "https://img.shields.io/badge/schema-layer%2091-eda738.svg?longCache=true&colorA=262b30" :target: compiler/api/source/main_api.tl - :alt: Scheme Layer + :alt: Schema Layer .. |tgcrypto| image:: "https://img.shields.io/badge/tgcrypto-v1.1.1-eda738.svg?longCache=true&colorA=262b30" :target: https://github.com/pyrogram/tgcrypto - :alt: TgCrypto + :alt: TgCrypto Version From d23e4079e4d29b72cd029d0f81c70e2a69de56dc Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Thu, 24 Jan 2019 20:03:14 +0100 Subject: [PATCH 17/22] Update docs index page --- docs/source/index.rst | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index ca9a38a3..67a3b03f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -10,27 +10,28 @@ Welcome to Pyrogram

- Telegram MTProto API Client Library for Python + Telegram MTProto API Framework for Python +
- - Download + + Documentation • - - Source code + + Changelog Community
- + Scheme Layer + alt="Schema Layer"> TgCrypto + alt="TgCrypto Version">

@@ -55,18 +56,20 @@ using the Next button at the end of each page. But first, here's a brief overvie About ----- -**Pyrogram** is a brand new Telegram_ Client Library written from the ground up in Python and C. It can be used for -building custom Telegram applications that interact with the MTProto API as both User and Bot. +**Pyrogram** is an elegant, easy-to-use Telegram_ client library and framework written from the ground up in Python and C. +It enables you to easily build custom Telegram applications that interact with the MTProto API as both user and bot. Features -------- -- **Easy to use**: You can easily install Pyrogram using pip and start building your app right away. -- **High-level**: The low-level details of MTProto are abstracted and automatically handled. +- **Easy**: You can install Pyrogram with pip and start building your app right away. +- **Elegant**: Low-level details are abstracted and re-presented in a much nicer and easier way. - **Fast**: Crypto parts are boosted up by TgCrypto_, a high-performance library written in pure C. -- **Updated** to the latest Telegram API version, currently Layer 91 on top of MTProto 2.0. -- **Documented**: The Pyrogram API is well documented and resembles the Telegram Bot API. -- **Full API**, allowing to execute any advanced action an official client is able to do, and more. +- **Documented**: Pyrogram API methods, types and public interfaces are well documented. +- **Type-hinted**: Exposed Pyrogram types and method parameters are all type-hinted. +- **Updated**, to the latest Telegram API version, currently Layer 91 on top of MTProto 2.0. +- **Pluggable**: The Smart Plugin system allows to write components with minimal boilerplate code. +- **Comprehensive**: Execute any advanced action an official client is able to do, and even more. To get started, press the Next button. From c8e8fe0cd7c9bf050eefe2ae9abee7ac650238e5 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 25 Jan 2019 00:38:43 +0100 Subject: [PATCH 18/22] Add Load/Unload Plugins at Runtime section in SmartPlugins.rst --- docs/source/resources/SmartPlugins.rst | 82 +++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/docs/source/resources/SmartPlugins.rst b/docs/source/resources/SmartPlugins.rst index 3bff9632..a609c276 100644 --- a/docs/source/resources/SmartPlugins.rst +++ b/docs/source/resources/SmartPlugins.rst @@ -13,7 +13,8 @@ Introduction ------------ Prior to the Smart Plugin system, pluggable handlers were already possible. For example, if you wanted to modularize -your applications, you had to do something like this: +your applications, you had to put your function definitions in separate files and register them inside your main script, +like this: .. note:: @@ -66,15 +67,15 @@ your applications, you had to do something like this: This is already nice and doesn't add *too much* boilerplate code, but things can get boring still; you have to manually ``import``, manually :meth:`add_handler ` and manually instantiate each :obj:`MessageHandler ` object because **you can't use those cool decorators** for your -functions. So... What if you could? +functions. So, what if you could? Smart Plugins solve this issue by taking care of handlers registration automatically. Using Smart Plugins ------------------- -Setting up your Pyrogram project to accommodate Smart Plugins is pretty straightforward: +Setting up your Pyrogram project to accommodate Smart Plugins is straightforward: -#. Create a new folder to store all the plugins (e.g.: "plugins"). -#. Put your files full of plugins inside. Organize them as you wish. +#. Create a new folder to store all the plugins (e.g.: "plugins", "handlers", ...). +#. Put your python files full of plugins inside. Organize them as you wish. #. Enable plugins in your Client or via the *config.ini* file. .. note:: @@ -160,7 +161,7 @@ found inside each module will be, instead, loaded in the order they are defined, This default loading behaviour is usually enough, but sometimes you want to have more control on what to include (or exclude) and in which exact order to load plugins. The way to do this is to make use of ``include`` and ``exclude`` -keys, either in the *config.ini* or in the dictionary passed as Client argument. Here's how they work: +keys, either in the *config.ini* file or in the dictionary passed as Client argument. Here's how they work: - If both ``include`` and ``exclude`` are omitted, all plugins are loaded as described above. - If ``include`` is given, only the specified plugins will be loaded, in the order they are passed. @@ -169,7 +170,7 @@ keys, either in the *config.ini* or in the dictionary passed as Client argument. The ``include`` and ``exclude`` value is a **list of strings**. Each string containing the path of the module relative to the plugins root folder, in Python notation (dots instead of slashes). - E.g.: ``subfolder.module`` refers to ``plugins/subfolder/module.py`` (root="plugins"). + E.g.: ``subfolder.module`` refers to ``plugins/subfolder/module.py``, with ``root="plugins"`. You can also choose the order in which the single handlers inside a module are loaded, thus overriding the default top-to-bottom loading policy. You can do this by appending the name of the functions to the module path, each one @@ -290,4 +291,69 @@ also organized in subfolders: Load/Unload Plugins at Runtime ------------------------------ -TODO \ No newline at end of file +In the `previous section <#specifying-the-plugins-to-include>`_ we've explained how to specify which plugins to load and +which to ignore before your Client starts. Here we'll show, instead, how to unload and load again a previously +registered plugins at runtime. + +Each function decorated with the usual ``on_message`` decorator (or any other decorator that deals with Telegram updates +) will be modified in such a way that, when you reference them later on, they will be actually pointing to a tuple of +*(handler: Handler, group: int)*. The actual callback function is therefore stored inside the handler's *callback* +attribute. Here's an example: + +- ``plugins/handlers.py`` + + .. code-block:: python + :emphasize-lines: 5, 6 + + @Client.on_message(Filters.text & Filters.private) + def echo(client, message): + message.reply(message.text) + + print(echo) + print(echo[0].callback) + +- Printing ``echo`` will show something like ``(, 0)``. + +- Printing ``echo[0].callback``, that is, the *callback* attribute of the first eleent of the tuple, which is an + Handler, will reveal the actual callback ````. + +Unloading +^^^^^^^^^ + +In order to unload a plugin, or any other handler, all you need to do is obtain a reference to it (by importing the +relevant module) and call :meth:`remove_handler ` Client's method with your function +name preceded by the star ``*`` operator as argument. Example: + +- ``main.py`` + + .. code-block:: python + + from plugins.handlers import echo + + ... + + app.remove_handler(*echo) + +The star ``*`` operator is used to unpack the tuple into positional arguments so that *remove_handler* will receive +exactly what is needed. The same could have been achieved with: + +.. code-block:: python + + handler, group = echo + app.remove_handler(handler, group) + +Loading +^^^^^^^ + +Similarly to the unloading process, in order to load again a previously unloaded plugin you do the same, but this time +using :meth:`add_handler ` instead. Example: + +- ``main.py`` + + .. code-block:: python + + from plugins.handlers import echo + + ... + + app.add_handler(*echo) \ No newline at end of file From 6d7d7f6c5eb8948bc36b285817f27d217dcc96ad Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 25 Jan 2019 09:21:21 +0100 Subject: [PATCH 19/22] Add ConfigurationFile.rst --- docs/source/index.rst | 1 + docs/source/resources/ConfigurationFile.rst | 73 +++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 docs/source/resources/ConfigurationFile.rst diff --git a/docs/source/index.rst b/docs/source/index.rst index 67a3b03f..f4abd5f8 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -88,6 +88,7 @@ To get started, press the Next button. resources/UpdateHandling resources/UsingFilters resources/MoreOnUpdates + resources/ConfigurationFile resources/SmartPlugins resources/AutoAuthorization resources/CustomizeSessions diff --git a/docs/source/resources/ConfigurationFile.rst b/docs/source/resources/ConfigurationFile.rst new file mode 100644 index 00000000..1f9966a2 --- /dev/null +++ b/docs/source/resources/ConfigurationFile.rst @@ -0,0 +1,73 @@ +Configuration File +================== + +As already mentioned in previous sections, Pyrogram can also be configured by the use of an INI file. +This page explains how this file is structured in Pyrogram, how to use it and why. + +Introduction +------------ + +The idea behind using a configuration file is to help keeping your code free of settings (private) information such as +the API Key and Proxy without having you to even deal with how to load such settings. The configuration file, usually +referred as ``config.ini` file, is automatically loaded from the root of your working directory; all you need to do is +fill in the necessary parts. + +.. note:: + + The configuration file is optional, but recommended. If, for any reason, you prefer not to use it, there's always an + alternative way to configure Pyrogram via Client's parameters. Doing so, you can have full control on how to store + and load your settings (e.g.: from environment variables). + + Settings specified via Client's parameter have higher priority and will override any setting stored in the + configuration file. + + +The config.ini File +------------------- + +By default, Pyrogram will look for a file named ``config.ini`` placed at the root of your working directory, that is, in +the same folder of your running script. You can change the name or location of your configuration file by specifying +that in your Client's parameter *config_file*. + +- Replace the default *config.ini* file with *my_configuration.ini*: + + .. code-block:: python + + from pyrogram import Client + + app = Client("my_account", config_file="my_configuration.ini") + + +Configuration Sections +---------------------- + +There are all the sections Pyrogram uses in its configuration file: + +Pyrogram +^^^^^^^^ + +The ``pyrogram`` section is used to store Telegram credentials, namely the API Key, which consists of two parts: +*api_id* and *api_hash*. + +.. code-block:: ini + + [pyrogram] + api_id = 12345 + api_hash = 0123456789abcdef0123456789abcdef + +`More info <../start/Setup.html#configuration>`_ + +Proxy +^^^^^ + +The ``proxy`` section contains settings about your SOCKS5 proxy. + +.. code-block:: ini + + [proxy] + enabled = True + hostname = 11.22.33.44 + port = 1080 + username = + password = + From c17bf3c64c5519b406cbd25f4e22c13bcd056bfb Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 25 Jan 2019 09:40:37 +0100 Subject: [PATCH 20/22] Fix sphinx warnings --- docs/source/resources/SmartPlugins.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/resources/SmartPlugins.rst b/docs/source/resources/SmartPlugins.rst index a609c276..972efdd8 100644 --- a/docs/source/resources/SmartPlugins.rst +++ b/docs/source/resources/SmartPlugins.rst @@ -170,7 +170,7 @@ keys, either in the *config.ini* file or in the dictionary passed as Client argu The ``include`` and ``exclude`` value is a **list of strings**. Each string containing the path of the module relative to the plugins root folder, in Python notation (dots instead of slashes). - E.g.: ``subfolder.module`` refers to ``plugins/subfolder/module.py``, with ``root="plugins"`. + E.g.: ``subfolder.module`` refers to ``plugins/subfolder/module.py``, with ``root="plugins"``. You can also choose the order in which the single handlers inside a module are loaded, thus overriding the default top-to-bottom loading policy. You can do this by appending the name of the functions to the module path, each one From e4c8592616436329b24f5225ede57a7003c1dd09 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 25 Jan 2019 09:40:55 +0100 Subject: [PATCH 21/22] Update ConfigurationFile.rst --- docs/source/resources/ConfigurationFile.rst | 35 +++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/source/resources/ConfigurationFile.rst b/docs/source/resources/ConfigurationFile.rst index 1f9966a2..759bfd9f 100644 --- a/docs/source/resources/ConfigurationFile.rst +++ b/docs/source/resources/ConfigurationFile.rst @@ -9,7 +9,7 @@ Introduction The idea behind using a configuration file is to help keeping your code free of settings (private) information such as the API Key and Proxy without having you to even deal with how to load such settings. The configuration file, usually -referred as ``config.ini` file, is automatically loaded from the root of your working directory; all you need to do is +referred as ``config.ini`` file, is automatically loaded from the root of your working directory; all you need to do is fill in the necessary parts. .. note:: @@ -25,9 +25,9 @@ fill in the necessary parts. The config.ini File ------------------- -By default, Pyrogram will look for a file named ``config.ini`` placed at the root of your working directory, that is, in -the same folder of your running script. You can change the name or location of your configuration file by specifying -that in your Client's parameter *config_file*. +By default, Pyrogram will look for a file named ``config.ini`` placed at the root of your working directory, that is, +the same folder of your running script. You can change the name or location of your configuration file by specifying it +in your Client's parameter *config_file*. - Replace the default *config.ini* file with *my_configuration.ini*: @@ -41,13 +41,12 @@ that in your Client's parameter *config_file*. Configuration Sections ---------------------- -There are all the sections Pyrogram uses in its configuration file: +These are all the sections Pyrogram uses in its configuration file: Pyrogram ^^^^^^^^ -The ``pyrogram`` section is used to store Telegram credentials, namely the API Key, which consists of two parts: -*api_id* and *api_hash*. +The ``[pyrogram]`` section contains your Telegram API credentials *api_id* and *api_hash*. .. code-block:: ini @@ -55,12 +54,12 @@ The ``pyrogram`` section is used to store Telegram credentials, namely the API K api_id = 12345 api_hash = 0123456789abcdef0123456789abcdef -`More info <../start/Setup.html#configuration>`_ +`More info about API Key. <../start/Setup.html#configuration>`_ Proxy ^^^^^ -The ``proxy`` section contains settings about your SOCKS5 proxy. +The ``[proxy]`` section contains settings about your SOCKS5 proxy. .. code-block:: ini @@ -71,3 +70,21 @@ The ``proxy`` section contains settings about your SOCKS5 proxy. username = password = +`More info about SOCKS5 Proxy. `_ + +Plugins +^^^^^^^ + +The ``[plugins]`` section contains settings about Smart Plugins. + +.. code-block:: ini + + [plugins] + root = plugins + include = + module + folder.module + exclude = + module fn2 + +`More info about Smart Plugins. `_ From 5e97cb34208dc7bacdf60880b094960cb01fa501 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 25 Jan 2019 09:43:56 +0100 Subject: [PATCH 22/22] Update documentation index page --- docs/source/index.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index f4abd5f8..067e6fbf 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -49,15 +49,15 @@ Welcome to Pyrogram app.run() -Welcome to Pyrogram's Documentation! Here you can find resources for learning how to use the library. +Welcome to Pyrogram's Documentation! Here you can find resources for learning how to use the framework. Contents are organized into self-contained topics and can be accessed from the sidebar, or by following them in order using the Next button at the end of each page. But first, here's a brief overview of what is this all about. About ----- -**Pyrogram** is an elegant, easy-to-use Telegram_ client library and framework written from the ground up in Python and C. -It enables you to easily build custom Telegram applications that interact with the MTProto API as both user and bot. +**Pyrogram** is an elegant, easy-to-use Telegram_ client library and framework written from the ground up in Python and +C. It enables you to easily build custom Telegram applications that interact with the MTProto API as both user and bot. Features --------